Go 696 lines
// 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: <workspace>/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: <workspace>/state/<queue.Filename>.
n, err = fileBytes(filepath.Join(cfg.Workspace, "state", queue.Filename))
if err != nil {
return 0, err
}
total += n
// Notes: <workspace>/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 <workflow>` — 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")
}