// Package ai is the pluggable AI-provider bridge with eeco's shared, // opt-in gating. // // A Provider runs a single Request and returns a Response. Every call // goes through a Gate that enforces the floor invariants from PLAN.md: // // - consent: a pass runs only with --ai or an automation level that // implies consent; // - budget cap: a fixed number of gated passes per invocation (a // tool-using pass may make several model calls but counts as one); // - never a silent spend, never a hard failure: on no-consent, over // budget, or provider error the prompt is parked to state/ and a // queue item is appended, and the caller falls back to its non-AI // path. // // One provider is wired: a generic CLI-based provider that shells an // operator-chosen command (eeco no longer runs an in-binary model client; // the AI lives in the harness eeco configures). An unconfigured setup // yields a stub whose Run cleanly reports "not configured" (handled as a // parked pass, not an error). Every attempt is recorded to the AI-call // ledger (state/ai-calls.json). No provider brand appears in product copy. package ai import ( "bytes" "context" "errors" "fmt" "os" "os/exec" "path/filepath" "sort" "strings" "time" "github.com/ajhahnde/eeco/internal/config" "github.com/ajhahnde/eeco/internal/queue" ) // ErrNotConfigured is returned by a provider that has nothing wired. The // Gate treats it like any other non-result: the prompt is parked, never // surfaced as a hard failure. var ErrNotConfigured = errors.New("ai provider not configured") // Message is one turn in a multi-turn conversation. Role is "user" or // "assistant"; the System block stays on Request.System. The transcript // is folded to a single prompt string for the CLI provider (foldPrompt). type Message struct { Role string // "user" | "assistant" Text string // plain text turn } // Request is one gated provider call. System is the deterministic, // stable-across-calls block (cheap to recompute, the natural cache // prefix); User is the volatile per-call instruction or input. A // provider may ignore Model and Cache. // // Messages carries multi-turn history; when it is non-empty the provider // uses it in place of folding System+User into one user turn (and User is // ignored). Single-turn callers leave Messages nil and keep today's // behaviour byte-identical. type Request struct { Label string // parking + ledger key System string // cacheable deterministic block (stable across calls) User string // volatile per-call instruction / input Messages []Message // multi-turn history; non-empty overrides User Model string // optional model override; provider may ignore Cache bool // hint: ephemeral-cache the System block } // Usage reports token accounting for one provider call. Zero for // providers that do not surface it (the CLI provider) and on every // parked pass. type Usage struct { InputTokens int CachedInputTokens int OutputTokens int } // Response is the result of one provider call. Model is the model the // provider actually resolved (may differ from Request.Model); empty when // the provider does not resolve a model (the CLI provider leaves it // empty). type Response struct { Text string Model string Usage Usage } // Provider runs a single Request and returns a Response. The // implementation must respect ctx cancellation and must not write to the // tracked tree. type Provider interface { // Name is an internal identifier for selection and logging only; it // is never written into product copy or the tracked tree. Name() string Run(ctx context.Context, req Request) (Response, error) } // foldPrompt collapses a Request to the single prompt string the // stdin-fed CLI provider, the parked-prompt file, and the ledger hash all // share. With Messages set it renders a transcript (optional System block, // then "User: …" / "Assistant: …" per turn); otherwise an empty System // yields exactly User, so today's User-only callers feed byte-identical // stdin to the CLI provider. func foldPrompt(req Request) string { if len(req.Messages) > 0 { var b strings.Builder if req.System != "" { b.WriteString(req.System) b.WriteString("\n\n") } for i, m := range req.Messages { if i > 0 { b.WriteString("\n\n") } role := "User" if m.Role == "assistant" { role = "Assistant" } b.WriteString(role) b.WriteString(": ") b.WriteString(m.Text) } return strings.TrimSpace(b.String()) } if req.System == "" { return req.User } return req.System + "\n\n" + req.User } // cliProvider shells a configured command, feeding the folded prompt on // stdin and taking stdout as the response. The command is operator-chosen // via `ai_command`; no specific tool is assumed or named. It ignores // Model and Cache and reports no token usage. type cliProvider struct{ argv []string } func (cliProvider) Name() string { return "cli" } func (c cliProvider) Run(ctx context.Context, req Request) (Response, error) { if len(c.argv) == 0 { return Response{}, ErrNotConfigured } cmd := exec.CommandContext(ctx, c.argv[0], c.argv[1:]...) cmd.Stdin = strings.NewReader(foldPrompt(req)) var out, errb bytes.Buffer cmd.Stdout = &out cmd.Stderr = &errb if err := cmd.Run(); err != nil { msg := strings.TrimSpace(errb.String()) if msg == "" { msg = err.Error() } return Response{}, fmt.Errorf("provider call failed: %s", msg) } return Response{Text: strings.TrimSpace(out.String())}, nil } // notConfigured is the clean stub used when no provider is wired. type notConfigured struct{} func (notConfigured) Name() string { return "none" } func (notConfigured) Run(context.Context, Request) (Response, error) { return Response{}, ErrNotConfigured } // Select returns the provider implied by config. An explicit // `ai_provider == "cli"` selects the CLI provider when `ai_command` is // set, else the not-configured stub. Every other value — empty/auto, the // legacy `anthropic` (the in-binary API provider was retired), or any // unknown string — falls back to auto: a configured `ai_command` picks the // CLI provider, else the not-configured stub. An unrecognised value is // always tolerated (floor invariant — never fail on this key). func Select(cfg *config.Config) Provider { if cfg == nil { return notConfigured{} } if cfg.AIProvider == "cli" { if p, ok := selectCLI(cfg); ok { return p } return notConfigured{} } // Auto (and any other value, incl. legacy "anthropic"): a configured // command wins, else unconfigured. if p, ok := selectCLI(cfg); ok { return p } return notConfigured{} } // selectCLI returns the CLI provider when `ai_command` is set. func selectCLI(cfg *config.Config) (Provider, bool) { if len(cfg.AICommand) == 0 { return nil, false } return cliProvider{argv: append([]string(nil), cfg.AICommand...)}, true } // Outcome reports what the Gate did with a request. Exactly one of Ran // or Skipped is true. When Skipped is true the prompt was parked and a // queue item was appended; Reason explains why the pass did not run. // Usage carries the provider's token accounting on Ran (zero on Skipped). type Outcome struct { Text string Ran bool Skipped bool Parked string // path of the parked prompt, when Skipped Reason string Usage Usage // the provider's token accounting on Ran (zero on Skipped) } // ResponseScanner inspects a provider response before the Gate hands it to the // caller. Returns nil/empty for a clean response, else one human-readable // description per violation. Text-only + caller-agnostic so a future tool-use // slice can reuse it on serialized tool-call arguments. The detector lives in // internal/workflow and is injected to keep internal/ai workflow-import-free. type ResponseScanner func(text string) []string // Gate wraps a Provider with consent, a budget cap, and prompt-parking. // A Gate is single-invocation: Budget is spent across all Run calls on // the instance. type Gate struct { Provider Provider // Consent is true when --ai was passed or the automation level // implies standing consent. Consent bool // Budget is the maximum number of gated passes per invocation (a // tool-using pass may make several model calls but counts as one); // <= 0 disables AI. Budget int // StateDir is /state: parked prompts, queue.md, and the // AI-call ledger live here. StateDir string // Project is a short handle (repo basename) for queue items. Project string // Scanner, when non-nil, runs on every successful provider response before // it is recorded or returned. A non-empty result blocks the pass. Scanner ResponseScanner spent int } // NewGate builds the Gate for one invocation from config, the --ai flag, // and a pre-write response scanner. Consent is the flag OR an automation // level that implies it. The scanner is a required parameter so every call // site names the attribution filter explicitly; pass nil only where no // filtering is wanted (test Gates keep the nil-safe zero value). func NewGate(cfg *config.Config, aiFlag bool, scanner ResponseScanner) *Gate { return &Gate{ Provider: Select(cfg), Consent: aiFlag || cfg.Automation.ImpliesAIConsent(), Budget: cfg.AIBudget, StateDir: filepath.Join(cfg.Workspace, "state"), Project: filepath.Base(cfg.RepoRoot), Scanner: scanner, } } // Run executes one gated pass. It never returns a hard failure for a // missing consent, an exhausted budget, or a provider error: in every // such case the prompt is parked, a queue item is appended, the attempt // is recorded to the ledger, and the returned Outcome has Skipped set so // the caller takes its non-AI path. A non-nil error means parking itself // failed (a real I/O fault). func (g *Gate) Run(ctx context.Context, req Request) (Outcome, error) { if !g.Consent { reason := "AI pass not consented (use --ai or set automation=auto)" g.recordCall(req, g.providerName(), "", "", false, true, reason, Usage{}, nil) return g.park(req, reason) } if g.Budget <= 0 || g.spent >= g.Budget { reason := fmt.Sprintf("AI budget exhausted (cap %d)", g.Budget) g.recordCall(req, g.providerName(), "", "", false, true, reason, Usage{}, nil) return g.park(req, reason) } g.spent++ prov := g.Provider if prov == nil { prov = notConfigured{} } resp, err := prov.Run(ctx, req) if err != nil { reason := "provider unavailable: " + err.Error() g.recordCall(req, prov.Name(), resp.Model, "", false, true, reason, resp.Usage, nil) return g.park(req, reason) } if strings.TrimSpace(resp.Text) == "" { reason := "provider returned no text" g.recordCall(req, prov.Name(), resp.Model, "", false, true, reason, resp.Usage, nil) return g.park(req, reason) } // Pre-write attribution filter (Slice 3): enforce eeco's no-AI-attribution // rule on text eeco itself initiates, before it can reach the workspace and // be copied into the tracked tree. A flagged response is blocked like any // other non-result — recorded (the blocked response's hash IS captured, and // its real token cost stands; the call did happen) then parked — and the // caller falls back to its non-AI path. if g.Scanner != nil { if v := g.Scanner(resp.Text); len(v) > 0 { reason := "AI response blocked: attribution violation (" + strings.Join(v, "; ") + ")" g.recordCall(req, prov.Name(), resp.Model, resp.Text, false, true, reason, resp.Usage, nil) return g.park(req, reason) } } g.recordCall(req, prov.Name(), resp.Model, resp.Text, true, false, "", resp.Usage, nil) return Outcome{Text: resp.Text, Ran: true, Usage: resp.Usage}, nil } // providerName returns the selected provider's name for ledger records on // paths where no provider call is attempted. func (g *Gate) providerName() string { if g.Provider == nil { return notConfigured{}.Name() } return g.Provider.Name() } // park writes the folded prompt under StateDir/parked/ and appends a // queue item so the spend is visible and recoverable. The parked file // lives inside the gitignored workspace (write-scope floor invariant). func (g *Gate) park(req Request, reason string) (Outcome, error) { out := Outcome{Skipped: true, Reason: reason} if g.StateDir == "" { // No place to park: still never a hard failure for the caller. return out, nil } dir := filepath.Join(g.StateDir, "parked") if err := os.MkdirAll(dir, 0o755); err != nil { return out, fmt.Errorf("park prompt: %w", err) } ts := time.Now().UTC() name := sanitize(req.Label) + "-" + ts.Format("20060102T150405.000000000Z") + ".md" path := filepath.Join(dir, name) body := fmt.Sprintf( "parked AI prompt\n\nlabel: %s\nreason: %s\ntime: %s\n\n----- prompt -----\n%s\n", req.Label, reason, ts.Format(time.RFC3339), foldPrompt(req)) if err := os.WriteFile(path, []byte(body), 0o644); err != nil { return out, fmt.Errorf("park prompt: %w", err) } out.Parked = path rel := path if r, err := filepath.Rel(filepath.Dir(g.StateDir), path); err == nil { rel = r } qerr := queue.Append(g.StateDir, queue.Item{ Kind: "ai-parked", Title: "AI pass parked for " + req.Label, Project: g.Project, Detail: reason + "\nprompt saved: " + rel, Date: ts, }) if qerr != nil { return out, fmt.Errorf("park prompt: queue: %w", qerr) } return out, nil } // ProjectDigest is the deterministic, no-spend System block for the // background project-understanding pass: the profile plus the sorted // top-level entry names. Reading file names is not an AI spend; only a // gated provider call is. func ProjectDigest(cfg *config.Config) string { var names []string if cfg != nil { if ents, err := os.ReadDir(cfg.RepoRoot); err == nil { for _, e := range ents { if e.Name() == ".git" || e.Name() == cfg.WorkspaceName { continue } names = append(names, e.Name()) } } } sort.Strings(names) prof := "generic" if cfg != nil { prof = string(cfg.Profile) } return fmt.Sprintf( "Profile: %s\nTop-level entries: %s\n", prof, strings.Join(names, ", ")) } // Understand runs the background project-understanding pass through the // Gate: it is a provider call subject to the same consent, budget, and // parking as any other (PLAN.md §AI providers). The deterministic digest // is the cacheable System block; the instruction is the User turn. func Understand(ctx context.Context, g *Gate, cfg *config.Config) (Outcome, error) { req := Request{ Label: "project-understanding", System: ProjectDigest(cfg), User: "Summarise this project and its likely maintenance risks. Be concrete and terse.", Cache: true, } return g.Run(ctx, req) } // sanitize keeps a label safe as a filename component. func sanitize(s string) string { s = strings.Map(func(r rune) rune { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-': return r default: return '-' } }, s) if s == "" { return "pass" } return s }