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[:])
}