ajhahn.de
← eeco
Go 130 lines
package ai

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"
)

// AICallsFilename is the central AI-call ledger filename inside
// <workspace>/state/. It records one entry per gated provider attempt —
// ran, parked, or gated-out — so the operator has an audit trail of what
// the AI was asked and what it produced. Frozen surface; renaming or
// removing it is a breaking change.
const AICallsFilename = "ai-calls.json"

// aiCallTokens is the token accounting for one recorded call. Zero on
// parked passes and for providers that do not surface usage.
type aiCallTokens struct {
	Input       int `json:"input"`
	CachedInput int `json:"cached_input"`
	Output      int `json:"output"`
}

// aiCallRecord is one entry in the AI-call ledger. It stores hashes, not
// raw text: the prompt and response bodies stay under state/parked/ when
// applicable and are never duplicated here. ResponseSHA256 and ParkReason
// are additive (omitted from the wire when empty) so older ledgers
// round-trip.
type aiCallRecord struct {
	Label          string       `json:"label"`
	Provider       string       `json:"provider"`
	Model          string       `json:"model,omitempty"`
	PromptSHA256   string       `json:"prompt_sha256"`
	ResponseSHA256 string       `json:"response_sha256,omitempty"`
	Ran            bool         `json:"ran"`
	Parked         bool         `json:"parked"`
	ParkReason     string       `json:"park_reason,omitempty"`
	Tokens         aiCallTokens `json:"tokens"`
	Tools          []string     `json:"tools,omitempty"`
	TS             string       `json:"ts"`
}

// aiCallLedger is the on-disk shape of the AI-call ledger.
type aiCallLedger struct {
	Records []aiCallRecord `json:"records"`
}

// recordCall appends one record for a gated attempt. It is best-effort:
// a missing StateDir or any I/O fault is swallowed so the ledger can
// never turn a gated pass into a hard failure (floor invariant). The
// prompt hash is over the same folded prompt the CLI provider feeds and
// the parked file stores, so a ledger entry pins exactly to its parked
// prompt. tools is the tool names the model invoked in this round; nil
// (every pre-tool-use caller) marshals away under omitempty, so existing
// records are byte-identical.
func (g *Gate) recordCall(req Request, provider, model, respText string, ran, parked bool, reason string, usage Usage, tools []string) {
	if g.StateDir == "" {
		return
	}
	rec := aiCallRecord{
		Label:        req.Label,
		Provider:     provider,
		Model:        model,
		PromptSHA256: sha256Hex(foldPrompt(req)),
		Ran:          ran,
		Parked:       parked,
		ParkReason:   reason,
		Tokens: aiCallTokens{
			Input:       usage.InputTokens,
			CachedInput: usage.CachedInputTokens,
			Output:      usage.OutputTokens,
		},
		Tools: tools,
		TS:    time.Now().UTC().Format(time.RFC3339),
	}
	if respText != "" {
		rec.ResponseSHA256 = sha256Hex(respText)
	}
	_ = appendAICall(g.StateDir, rec)
}

// appendAICall loads the ledger, appends rec, and writes it back. A
// missing file is the empty ledger; a corrupt file degrades to the empty
// ledger so a broken file is never fatal — the next write rewrites it
// from scratch (the evolve-history discipline). Marshalled with
// indentation and a trailing newline so the file is human-inspectable.
func appendAICall(stateDir string, rec aiCallRecord) error {
	if err := os.MkdirAll(stateDir, 0o755); err != nil {
		return fmt.Errorf("ai ledger: state dir: %w", err)
	}
	ledger := loadAICalls(stateDir)
	ledger.Records = append(ledger.Records, rec)
	b, err := json.MarshalIndent(ledger, "", "  ")
	if err != nil {
		return fmt.Errorf("ai ledger: encode: %w", err)
	}
	return os.WriteFile(filepath.Join(stateDir, AICallsFilename), append(b, '\n'), 0o644)
}

// loadAICalls reads <stateDir>/ai-calls.json. A missing or corrupt file
// is the empty ledger.
func loadAICalls(stateDir string) aiCallLedger {
	var l aiCallLedger
	b, err := os.ReadFile(filepath.Join(stateDir, AICallsFilename))
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return l
		}
		return l
	}
	if len(b) == 0 {
		return l
	}
	if jerr := json.Unmarshal(b, &l); jerr != nil {
		return aiCallLedger{}
	}
	return l
}

// sha256Hex returns the lowercase hex SHA-256 of s.
func sha256Hex(s string) string {
	sum := sha256.Sum256([]byte(s))
	return hex.EncodeToString(sum[:])
}