ajhahn.de
← eeco
Go 415 lines
// 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 <workspace>/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
}