// Package brief renders a deterministic, no-AI-spend project brief for // an AI assistant: what eeco is, the shape of the project, where to look // for detail, what eeco already knows, and the open decisions. // // It is the engine behind `eeco go`. The brief lets any assistant — // not only the strongest — pick up a project quickly and cheaply: one // command returns a compact map instead of a scan across many files. // // The package only reads — the resolved config, the memory store, and // the queue file — and writes nothing. The output carries no timestamp // and lists facts in the store's stable sort order, so it is // reproducible and safe to snapshot in a golden test. // // Collect gathers the brief once into a Data value; Render turns that // value into the Markdown brief and RenderJSON into a JSON object, so // the two representations always describe the same project state. package brief import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "strings" "time" "github.com/ajhahnde/eeco/internal/config" "github.com/ajhahnde/eeco/internal/gitx" "github.com/ajhahnde/eeco/internal/memory" "github.com/ajhahnde/eeco/internal/notes" "github.com/ajhahnde/eeco/internal/queue" "github.com/ajhahnde/eeco/internal/workflow" ) // nowFunc is the clock the assembly timer reads. It is a package var so a // test can pin elapsed time and assert exact output, mirroring the // injectable memory.Store.Now seam and the os.Stdin seams in cmd/eeco. var nowFunc = time.Now // EstimateTokens approximates a token count from a byte length via the // ~4-bytes-per-token heuristic. It is a deliberate estimate, never a real // tokenizer count — eeco ships zero runtime dependencies, so no tokenizer // can be embedded. Callers MUST present the result with a "≈" prefix so // it never reads as a precise figure. EstimateTokens(0) == 0. func EstimateTokens(n int) int { return n / 4 } // AssemblyMetrics reports one `eeco go` brief assembly. The byte fields // are real measurements; token figures derived from them (via // EstimateTokens) are estimates. The value carries no project state and // is never part of the brief or the --json surface. type AssemblyMetrics struct { Elapsed time.Duration // wall-clock for Collect + the Markdown render BriefBytes int // real bytes of the rendered Markdown brief KnowledgeBytes int // real on-disk size of the distilled knowledge layer } // Measure renders the Markdown brief for cfg — the full brief, or the // smaller --brief variant when brief is true — exactly as Render and // RenderBrief do, and reports how the assembly went. The returned text is // byte-identical to Render(cfg) / RenderBrief(cfg), so a --metrics readout // never perturbs stdout or the brief goldens. // // Elapsed times only Collect plus the Markdown render — the work // "assembling the brief" names. The knowledge-byte baseline is measured // outside that window: reading the layer off disk is not part of how long // the brief took to build. A nil config is an error, mirroring Render. func Measure(cfg *config.Config, brief bool) (string, AssemblyMetrics, error) { if cfg == nil { return "", AssemblyMetrics{}, errors.New("brief.Measure: nil config") } start := nowFunc() d, err := Collect(cfg) if err != nil { return "", AssemblyMetrics{}, err } if brief { d.TrimToBrief() } text := renderMarkdown(d) elapsed := nowFunc().Sub(start) kb, err := knowledgeBytes(cfg) if err != nil { return "", AssemblyMetrics{}, err } return text, AssemblyMetrics{ Elapsed: elapsed, BriefBytes: len(text), KnowledgeBytes: kb, }, nil } // knowledgeBytes sums the real on-disk size of the knowledge layer the // brief distills: every memory fact file, the queue, and every note. It // mirrors memory.Store.LoadAll's selection (skips MEMORY.md, dot-prefixed // entries, the attic and other directories, and non-.md files) but counts // disabled facts too — they are real bytes on disk that the brief omits, // which is exactly the compression a "distilled M into N" readout should // report. A missing file or directory contributes 0, not an error (an // uninitialised or empty workspace honestly distills 0 bytes); any other // I/O fault is wrapped and returned. func knowledgeBytes(cfg *config.Config) (int, error) { total := 0 // Memory facts: /memory/*.md, same selection as LoadAll. n, err := dirMarkdownBytes(filepath.Join(cfg.Workspace, "memory"), memory.IndexFilename) if err != nil { return 0, err } total += n // Queue: /state/. n, err = fileBytes(filepath.Join(cfg.Workspace, "state", queue.Filename)) if err != nil { return 0, err } total += n // Notes: /notes/*.md (counted unconditionally — the // baseline is what knowledge exists, not what the brief chose to show). n, err = dirMarkdownBytes(filepath.Join(cfg.Workspace, "notes"), "") if err != nil { return 0, err } total += n return total, nil } // dirMarkdownBytes sums the size of every regular ".md" file directly // under dir, skipping subdirectories, dot-prefixed entries, and the file // named skip (the MEMORY.md index for the memory dir; "" skips nothing). // DirEntry.Info avoids a second stat per file. A missing directory is 0 // bytes, not an error. func dirMarkdownBytes(dir, skip string) (int, error) { entries, err := os.ReadDir(dir) if err != nil { if errors.Is(err, os.ErrNotExist) { return 0, nil } return 0, err } total := 0 for _, e := range entries { if e.IsDir() { continue } name := e.Name() if name == skip || strings.HasPrefix(name, ".") || !strings.HasSuffix(name, ".md") { continue } info, err := e.Info() if err != nil { return 0, err } total += int(info.Size()) } return total, nil } // fileBytes returns the on-disk size of path. A missing file is 0 bytes, // not an error; any other stat fault is returned. func fileBytes(path string) (int, error) { info, err := os.Stat(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return 0, nil } return 0, err } return int(info.Size()), nil } // Data is the deterministic project brief in structured form: the data // behind `eeco go`, independent of how it is rendered. Render turns it // into Markdown, RenderJSON into a JSON object. The static onboarding // prose ("Working with eeco", "Recording back") is not part of Data — // it carries no project state and lives only in the Markdown brief. type Data struct { Project string `json:"project"` Profile string `json:"profile"` Gate []string `json:"gate"` TopLevel []string `json:"top_level"` Initialized bool `json:"initialized"` Workflows []string `json:"workflows"` WhereToLook []Pointer `json:"where_to_look"` Knowledge []KnowledgeFact `json:"knowledge"` OpenDecisions []string `json:"open_decisions"` // BriefMode is set by TrimToBrief and controls Markdown rendering: // when true the "Working with eeco" preamble and "Recording back" // outro are omitted. It carries no project state and is excluded // from JSON output so the nine-frozen-top-level-key contract holds. BriefMode bool `json:"-"` // IncludeNotes mirrors cfg.BriefIncludeNotes and gates the "Recent // notes" section in the Markdown render. The Notes payload itself is // hidden from JSON for the same nine-frozen-top-level-key reason: // notes belong to the assistant-prose channel, not the // machine-parsed brief. IncludeNotes bool `json:"-"` Notes []notes.Note `json:"-"` } // briefCap is the per-section list cap TrimToBrief enforces — the same // N=5 ceiling Render already applies to the open-decisions section, now // extended to the where-to-look and knowledge lists so an assistant on // a tight context budget always reads a bounded brief. const briefCap = 5 // TrimToBrief reshapes d into the smaller brief form: BriefMode is set // so Render skips the preamble and outro sections, and each per-section // list is capped at briefCap. JSON output is unchanged in shape — the // nine top-level keys remain, with arrays possibly shorter. func (d *Data) TrimToBrief() { d.trimToCap(briefCap) } // trimToCap sets BriefMode and caps each per-section list at n entries. // n is the per-section ceiling TrimToBrief and the RenderWithinBudget // ladder share; n == 0 empties the lists, leaving only the fixed // section scaffolding. Each list is only ever shortened (resliced), // never written into, so a caller may trim a shallow copy of one // Collect result repeatedly without disturbing the original. func (d *Data) trimToCap(n int) { d.BriefMode = true if len(d.WhereToLook) > n { d.WhereToLook = d.WhereToLook[:n] } if len(d.Knowledge) > n { d.Knowledge = d.Knowledge[:n] } if len(d.OpenDecisions) > n { d.OpenDecisions = d.OpenDecisions[:n] } if len(d.Notes) > n { d.Notes = d.Notes[:n] } } // BudgetReport describes the outcome of RenderWithinBudget: which trim // tier produced the returned brief, its byte size, and whether it fit // the requested budget. type BudgetReport struct { // Tier is "full", "brief", or "brief (cap N)" — the trim tier the // returned text was rendered from. Tier string // Bytes is the byte length of the returned brief. Bytes int // Met is true when Bytes is within the requested budget. It is // false only when even the smallest tier (cap 0) overruns — the // caller still receives that smallest brief. Met bool } // Pointer is one topic → file entry: a memory fact that carries a ref, // the fastest path for an assistant to the right file. type Pointer struct { Description string `json:"description"` Ref string `json:"ref"` } // KnowledgeFact is one load-bearing memory fact — project, feedback, or // user — in the terse name/description/type shape of the MEMORY.md index. type KnowledgeFact struct { Name string `json:"name"` Description string `json:"description"` Type string `json:"type"` } // Collect assembles the structured project brief for cfg. It reads the // memory store and the queue file when the workspace is initialised and // degrades gracefully when it is not: Project, Profile, Gate, TopLevel, // and Workflows are populated either way. Every slice field is non-nil // so the JSON form renders an empty list rather than null. A non-nil // error means a real I/O fault while reading the store or the queue. func Collect(cfg *config.Config) (Data, error) { if cfg == nil { return Data{}, errors.New("brief.Collect: nil config") } d := Data{ Project: filepath.Base(cfg.RepoRoot), Profile: string(cfg.Profile), Gate: config.GateSteps(cfg.Gate), TopLevel: []string{}, Workflows: workflow.DefaultRegistry().Names(), WhereToLook: []Pointer{}, Knowledge: []KnowledgeFact{}, OpenDecisions: []string{}, } d.TopLevel = append(d.TopLevel, topLevel(cfg)...) d.Initialized = config.IsInitialized(cfg) if !d.Initialized { return d, nil } store, err := memory.Open(cfg) if err != nil { return Data{}, fmt.Errorf("brief: open memory: %w", err) } facts, err := store.LoadAll() if err != nil { return Data{}, fmt.Errorf("brief: load memory: %w", err) } for _, f := range facts { if f.Disabled { continue } if ref := strings.TrimSpace(f.Ref); ref != "" { d.WhereToLook = append(d.WhereToLook, Pointer{Description: f.Description, Ref: ref}) } switch f.Type { case memory.TypeProject, memory.TypeFeedback, memory.TypeUser: d.Knowledge = append(d.Knowledge, KnowledgeFact{ Name: f.Name, Description: f.Description, Type: string(f.Type), }) } } items, err := queueLines(cfg) if err != nil { return Data{}, fmt.Errorf("brief: read queue: %w", err) } d.OpenDecisions = append(d.OpenDecisions, items...) if cfg.BriefIncludeNotes { d.IncludeNotes = true list, err := notes.List(filepath.Join(cfg.Workspace, "notes")) if err != nil { return Data{}, fmt.Errorf("brief: list notes: %w", err) } // The full brief still caps notes at briefCap so a workspace with // dozens of scribbles cannot balloon the brief; the trim ladder // shortens it further when budget is tight. if len(list) > briefCap { list = list[:briefCap] } d.Notes = list } return d, nil } // Render assembles the Markdown project brief for cfg. It reads the // memory store and the queue file when the workspace is initialised and // degrades gracefully when it is not: the "Working with eeco" and // "Project" sections render either way. A non-nil error means a real // I/O fault while reading the store or the queue. func Render(cfg *config.Config) (string, error) { d, err := Collect(cfg) if err != nil { return "", err } return renderMarkdown(d), nil } // RenderBrief is Render's smaller sibling: it collects the same data // then trims via TrimToBrief, so the assistant-facing preamble and // outro drop out and each per-section list is capped at briefCap. func RenderBrief(cfg *config.Config) (string, error) { d, err := Collect(cfg) if err != nil { return "", err } d.TrimToBrief() return renderMarkdown(d), nil } // RenderJSON assembles the project brief for cfg as an indented JSON // object — the machine-readable counterpart to Render, for a downstream // agent or script rather than an assistant reading prose. It carries the // same project state as the Markdown brief and is equally deterministic. func RenderJSON(cfg *config.Config) (string, error) { d, err := Collect(cfg) if err != nil { return "", err } return marshalData(d) } // RenderJSONBrief is RenderJSON's smaller sibling: TrimToBrief caps the // per-section arrays before marshalling, keeping the nine top-level // keys (arrays may be shorter, never absent or null). func RenderJSONBrief(cfg *config.Config) (string, error) { d, err := Collect(cfg) if err != nil { return "", err } d.TrimToBrief() return marshalData(d) } // RenderWithinBudget renders the Markdown brief for cfg trimmed to fit // maxBytes. It is the engine behind `eeco go --write` when the // `context_budget` config key is set: the persisted brief an assistant // re-reads each session stays under a known size. // // It walks a deterministic ladder — the full brief, then the brief form // (preamble/outro dropped) with per-section lists capped at 5, 4, 3, 2, // 1, and finally 0 — and returns the largest tier whose rendered byte // length is within maxBytes. When skipFull is set (the caller passed // --brief) the full tier is left out and the ladder starts at the brief // form. maxBytes <= 0 means no cap: the full brief (or, with skipFull, // the brief form) is returned unchanged. // // When even the cap-0 tier overruns maxBytes the smallest brief is // returned anyway with BudgetReport.Met false — a brief slightly over // budget beats no brief at all. A non-nil error means a real I/O fault // while reading the store or the queue. func RenderWithinBudget(cfg *config.Config, maxBytes int, skipFull bool) (string, BudgetReport, error) { base, err := Collect(cfg) if err != nil { return "", BudgetReport{}, err } // renderTier renders a shallow copy of base trimmed to cap n; a // negative n leaves the full brief untrimmed. trimToCap only // reshortens slices, so each tier is independent of the others. renderTier := func(n int) string { d := base if n >= 0 { d.trimToCap(n) } return renderMarkdown(d) } tierName := func(n int) string { switch { case n < 0: return "full" case n == briefCap: return "brief" default: return fmt.Sprintf("brief (cap %d)", n) } } // The ladder, widest tier first: full (cap -1), then briefCap down // to 0. skipFull drops the full tier. ladder := []int{-1} for n := briefCap; n >= 0; n-- { ladder = append(ladder, n) } if skipFull { ladder = ladder[1:] } if maxBytes <= 0 { n := ladder[0] text := renderTier(n) return text, BudgetReport{Tier: tierName(n), Bytes: len(text), Met: true}, nil } var text string var n int for _, n = range ladder { text = renderTier(n) if len(text) <= maxBytes { return text, BudgetReport{Tier: tierName(n), Bytes: len(text), Met: true}, nil } } // Nothing fit: text/n hold the last (smallest) tier rendered. return text, BudgetReport{Tier: tierName(n), Bytes: len(text), Met: false}, nil } // renderMarkdown serialises a Data value to the Markdown brief. When // d.BriefMode is set the preamble and outro sections are omitted; every // other section renders as in the full brief so the smaller form stays // a strict subset. func renderMarkdown(d Data) string { var b strings.Builder fmt.Fprintf(&b, "# %s — eeco project brief\n\n", d.Project) b.WriteString("Written by `eeco go`: a deterministic, no-AI-spend project brief.\n") b.WriteString("Read this once instead of scanning the tree, then open the files\n") b.WriteString("named under \"Where to look\" for detail.\n\n") if !d.BriefMode { writeWorkingWithEeco(&b, d.Workflows) } writeProject(&b, d) writeWhereToLook(&b, d) writeKnowledge(&b, d) if d.IncludeNotes { writeNotes(&b, d) } writeDecisions(&b, d) if !d.BriefMode { writeRecordingBack(&b) } return b.String() } // marshalData turns a Data value into the indented JSON brief. func marshalData(d Data) (string, error) { out, err := json.MarshalIndent(d, "", " ") if err != nil { return "", fmt.Errorf("brief: marshal json: %w", err) } return string(out) + "\n", nil } // writeWorkingWithEeco explains eeco to the assistant: the durable // context it keeps and the safe, read-only commands worth running. The // builtin list is taken from the registry so it never drifts. func writeWorkingWithEeco(b *strings.Builder, workflows []string) { b.WriteString("## Working with eeco\n\n") b.WriteString("This repo uses eeco — a local tool that keeps project memory and a\n") b.WriteString("decision queue so an assistant carries durable context across\n") b.WriteString("sessions. Commands you can run (read-only, safe by default):\n\n") b.WriteString("- `eeco go` — print this brief\n") b.WriteString("- `eeco doctor` — workspace and configuration diagnostics\n") fmt.Fprintf(b, "- `eeco run ` — run a workflow (builtins: %s)\n", strings.Join(workflows, ", ")) b.WriteString("- `eeco gc` — memory garbage collection\n\n") b.WriteString("Findings and decisions go to eeco's queue, not silent edits to the\n") b.WriteString("tracked tree.\n\n") } // writeProject states the detected profile, the parse/build gate, and // the repository's top-level layout. func writeProject(b *strings.Builder, d Data) { b.WriteString("## Project\n\n") fmt.Fprintf(b, "- profile: %s\n", d.Profile) gate := "(none)" if len(d.Gate) > 0 { gate = strings.Join(d.Gate, " && ") } fmt.Fprintf(b, "- gate: %s\n", gate) if len(d.TopLevel) == 0 { b.WriteString("- top-level: (empty)\n\n") return } fmt.Fprintf(b, "- top-level: %s\n\n", strings.Join(d.TopLevel, ", ")) } // topLevel lists the repository's top-level entry names. It derives // them from git's tracked set when git is available, so build // artifacts, the eeco workspace, and other untracked clutter stay out // of the brief; it falls back to a directory listing otherwise. Either // path skips the .git directory and eeco's own per-user workspace dir // (cfg.Username, which holds the .eeco engine workspace), and the // result is sorted, so the brief is deterministic. func topLevel(cfg *config.Config) []string { skip := func(seg string) bool { return seg == ".git" || seg == cfg.WorkspaceName || (cfg.Username != "" && seg == cfg.Username) } if tracked, err := gitx.TrackedFiles(cfg.RepoRoot); err == nil && len(tracked) > 0 { seen := map[string]struct{}{} var out []string for _, p := range tracked { seg, _, _ := strings.Cut(p, "/") if skip(seg) { continue } if _, ok := seen[seg]; ok { continue } seen[seg] = struct{}{} out = append(out, seg) } sort.Strings(out) return out } // No git, or an unborn repo: fall back to a directory listing. ents, err := os.ReadDir(cfg.RepoRoot) if err != nil { return nil } var out []string for _, e := range ents { if skip(e.Name()) { continue } out = append(out, e.Name()) } return out } // writeWhereToLook turns memory facts that carry a ref into a topic → // file map: the fastest path for an assistant to the right file. func writeWhereToLook(b *strings.Builder, d Data) { b.WriteString("## Where to look\n\n") if !d.Initialized { b.WriteString("Workspace not initialised — run `eeco init` to start project memory.\n\n") return } if len(d.WhereToLook) == 0 { b.WriteString("No file pointers recorded yet.\n") } else { for _, p := range d.WhereToLook { fmt.Fprintf(b, "- %s → `%s`\n", p.Description, p.Ref) } } b.WriteString("\n") } // writeKnowledge lists the load-bearing facts — project, feedback, and // user — as terse name/description lines, the same shape as the // MEMORY.md index. func writeKnowledge(b *strings.Builder, d Data) { b.WriteString("## What eeco knows\n\n") if !d.Initialized { b.WriteString("Workspace not initialised — no project memory yet.\n\n") return } if len(d.Knowledge) == 0 { b.WriteString("No durable facts recorded yet.\n") } else { for _, f := range d.Knowledge { fmt.Fprintf(b, "- %s — %s (%s)\n", f.Name, f.Description, f.Type) } } b.WriteString("\n") } // writeNotes lists the most recent free-form workspace notes. The // section appears only when cfg.BriefIncludeNotes is set (mirrored on // d.IncludeNotes); the timestamp is formatted in UTC so the brief stays // reproducible across machines, and the list is already capped at // briefCap by Collect (with the trim ladder shortening further when // budget is tight). func writeNotes(b *strings.Builder, d Data) { b.WriteString("## Recent notes\n\n") if !d.Initialized { b.WriteString("Workspace not initialised — no notes yet.\n\n") return } if len(d.Notes) == 0 { b.WriteString("No notes recorded yet — add one with `eeco add note \"...\"`.\n\n") return } for _, n := range d.Notes { fmt.Fprintf(b, "- %s — %s\n", n.When.UTC().Format("2006-01-02 15:04"), n.Summary) } b.WriteString("\n") } // writeDecisions reports the open queue items — the only things eeco // flags as needing a human decision. func writeDecisions(b *strings.Builder, d Data) { b.WriteString("## Open decisions\n\n") if !d.Initialized { b.WriteString("Workspace not initialised — no queue yet.\n\n") return } if len(d.OpenDecisions) == 0 { b.WriteString("None — nothing is waiting on a decision.\n\n") return } fmt.Fprintf(b, "%d open:\n", len(d.OpenDecisions)) for _, it := range d.OpenDecisions { fmt.Fprintf(b, "- %s\n", it) } b.WriteString("\n") } // queueLines returns the text of each unchecked queue item, the same // read-only extraction `eeco uninstall` uses. A missing queue file is // not an error: the workspace simply has no open items yet. func queueLines(cfg *config.Config) ([]string, error) { data, err := os.ReadFile(filepath.Join(cfg.Workspace, "state", queue.Filename)) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil } return nil, err } var out []string const prefix = "- [ ]" for raw := range strings.SplitSeq(string(data), "\n") { line := strings.TrimSpace(raw) if !strings.HasPrefix(line, prefix) { continue } out = append(out, strings.TrimSpace(line[len(prefix):])) } return out, nil } // writeRecordingBack tells the assistant how to keep the brief useful: // record durable facts and route decisions through the queue. func writeRecordingBack(b *strings.Builder) { b.WriteString("## Recording back\n\n") b.WriteString("Keep this brief useful for the next session: record durable facts\n") b.WriteString("in eeco's memory store and route findings and decisions through its\n") b.WriteString("queue rather than acting silently. Run `eeco doctor` if anything\n") b.WriteString("here looks stale.\n") }