package ai import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "time" ) // AICallsFilename is the central AI-call ledger filename inside // /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 /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[:]) }