diff --git a/.changeset/ai-autopilot-code-overview.md b/.changeset/ai-autopilot-code-overview.md new file mode 100644 index 0000000..f00b5e9 --- /dev/null +++ b/.changeset/ai-autopilot-code-overview.md @@ -0,0 +1,5 @@ +--- +"@gemstack/ai-autopilot": minor +--- + +Add scale mode: an always-current `CODE-OVERVIEW.md` the agent reads first in a large repo so it stays oriented without re-scanning the tree. The hard part is keeping it fresh — a stale overview is worse than none — so refreshes are gated by a deterministic **material-change detector** (`detectMaterialChange`): a build/config change, a test-framework migration, a directory restructure, or a large change across many areas, not every routine edit. `CodeOverviewMaintainer` owns the policy (refresh on material change, skip otherwise, persist over an `OverviewFs`); `agentOverview` regenerates the map with an `ai-sdk` agent (seeding the previous overview so it revises rather than rewrites); and `overviewLoopPrompt` drops the maintainer into the loop (#113) so it self-maintains on `major-change`. The `CODE-OVERVIEW.md` markdown round-trips via `parseOverview` / `serializeOverview`. Regeneration is injected, so the whole policy is tested offline against a stub. Closes #114. diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index 74eb3b5..f05c7c7 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -73,6 +73,15 @@ * - {@link agentDeploy} + the {@link DeployTarget} seam ({@link planOnlyTarget}, * {@link FakeDeployTarget}) — the final phase: decide SSR/SSG/SPA + target and * narrate; real adapters are infra-gated + * + * Scale mode keeps a compact `CODE-OVERVIEW.md` the agent reads first in a large + * repo, refreshed only on *material* change (build tooling, test framework, a + * directory restructure) so the map never rots or churns. + * + * - {@link CodeOverviewMaintainer} — holds the map, refreshes on material change + * - {@link detectMaterialChange} — the deterministic refresh trigger + * - {@link agentOverview} / {@link overviewLoopPrompt} — regenerate with an agent, + * and wire the maintainer into the loop */ export { Supervisor } from './supervisor.js' export { agentPlanner, type AgentPlannerOptions } from './planner.js' @@ -226,6 +235,31 @@ export { type DeployContext, type LoopPassContext, } from './bootstrap/index.js' +export { + CodeOverviewMaintainer, + createOverviewMaintainer, + detectMaterialChange, + agentOverview, + overviewLoopPrompt, + parseOverview, + serializeOverview, + loadOverview, + saveOverview, + nodeOverviewFs, + OVERVIEW_FILE, + type MaintainerOptions, + type DetectOptions, + type AgentOverviewOptions, + type OverviewLoopPromptOptions, + type CodeOverview, + type OverviewSection, + type MaterialChange, + type OverviewFs, + type RegenerateContext, + type Regenerate, + type OverviewRefresh, + type OverviewEvent, +} from './overview/index.js' export type { Subtask, PlannedSubtask, diff --git a/packages/ai-autopilot/src/overview/agent.test.ts b/packages/ai-autopilot/src/overview/agent.test.ts new file mode 100644 index 0000000..1d79902 --- /dev/null +++ b/packages/ai-autopilot/src/overview/agent.test.ts @@ -0,0 +1,65 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { AiFake, agent } from '@gemstack/ai-sdk' +import { agentOverview, overviewLoopPrompt } from './agent.js' +import { CodeOverviewMaintainer } from './maintainer.js' +import { Loop } from '../loop/loop.js' +import { defineRule } from '../loop/define.js' +import type { CodeOverview } from './types.js' + +describe('agentOverview (default regenerate over an ai-sdk agent)', () => { + it('parses a structured overview and seeds the previous one', async () => { + const fake = AiFake.fake() + try { + fake.respondWithSequence([ + { text: JSON.stringify({ summary: 'A Vike shop', sections: [{ title: 'Structure', body: 'pages/, database/' }] }) }, + ]) + const previous: CodeOverview = { summary: 'old', sections: [] } + const overview = await agentOverview(agent({ instructions: 'mapper' }))({ reason: 'restructure', previous }) + + assert.equal(overview.summary, 'A Vike shop') + assert.equal(overview.sections[0]?.title, 'Structure') + // the previous overview reached the model so it revises rather than rewrites + const sent = JSON.stringify(fake.getCalls()[0]) + assert.match(sent, /old/) + assert.match(sent, /restructure/) + } finally { + fake.restore() + } + }) +}) + +describe('overviewLoopPrompt (wire the maintainer into the loop)', () => { + it('refreshes on a material loop event and reports it', async () => { + let regenerated = 0 + const maintainer = new CodeOverviewMaintainer({ + regenerate: () => { regenerated++; return { summary: 'fresh', sections: [] } }, + }) + const loop = new Loop({ + rules: [defineRule({ on: 'major-change', run: ['code-overview'] })], + prompts: [overviewLoopPrompt(maintainer)], + }) + const result = await loop.handle({ kind: 'major-change', summary: 'switched build tool', paths: ['vite.config.ts'] }) + + assert.equal(regenerated, 1) + assert.match(result.outcomes[0]?.passes[0]?.text ?? '', /Refreshed CODE-OVERVIEW\.md/) + assert.equal(maintainer.get()?.summary, 'fresh') + }) + + it('leaves the overview alone on an immaterial event', async () => { + let regenerated = 0 + const maintainer = new CodeOverviewMaintainer({ + overview: { summary: 'kept', sections: [] }, + regenerate: () => { regenerated++; return { summary: 'nope', sections: [] } }, + }) + const loop = new Loop({ + rules: [defineRule({ on: 'major-change', run: ['code-overview'] })], + prompts: [overviewLoopPrompt(maintainer)], + }) + const result = await loop.handle({ kind: 'major-change', summary: 'tweak copy', paths: ['pages/about.tsx'] }) + + assert.equal(regenerated, 0) + assert.match(result.outcomes[0]?.passes[0]?.text ?? '', /unchanged/) + assert.equal(maintainer.get()?.summary, 'kept') + }) +}) diff --git a/packages/ai-autopilot/src/overview/agent.ts b/packages/ai-autopilot/src/overview/agent.ts new file mode 100644 index 0000000..6fc4279 --- /dev/null +++ b/packages/ai-autopilot/src/overview/agent.ts @@ -0,0 +1,84 @@ +import { Output } from '@gemstack/ai-sdk' +import type { Agent } from '@gemstack/ai-sdk' +import { z } from 'zod' +import { definePrompt } from '../loop/define.js' +import type { LoopPrompt } from '../loop/types.js' +import { serializeOverview } from './markdown.js' +import type { CodeOverviewMaintainer } from './maintainer.js' +import type { CodeOverview, Regenerate } from './types.js' + +/** + * The default wirings of scale mode onto the real primitives: an `ai-sdk` agent + * that regenerates the overview by reading the tree, and a {@link LoopPrompt} that + * drops the maintainer into the loop (#113) so it self-refreshes on material + * changes. Both keep the model + runner injected, so the maintainer's policy is + * tested offline with a stub `regenerate`. + */ + +/** Options for {@link agentOverview}. */ +export interface AgentOverviewOptions { + /** Override the instruction the regeneration agent is prompted with. */ + instructions?: string +} + +const DEFAULT_OVERVIEW_INSTRUCTIONS = `You maintain CODE-OVERVIEW.md — a compact map of this codebase that another +agent reads first, before working, so it stays oriented without scanning the +whole tree. Read the repository structure with your tools and produce a fresh +overview. Keep it short and current; a stale or bloated overview is worse than +none. Cover: what the repo is (one paragraph), its top-level structure, the key +modules and what each owns, the entry points, and the conventions worth knowing. +When a previous overview is given, update it — keep what still holds, fix what +drifted, and do not pad.` + +/** + * A {@link Regenerate} backed by an `ai-sdk` agent. The agent should carry the + * tools to read the workspace tree (e.g. `runnerTools(session)`); it is prompted + * for a structured `{ summary, sections }` overview, with the previous one seeded + * so it revises rather than rewrites blind. + */ +export function agentOverview(overviewer: Agent, opts: AgentOverviewOptions = {}): Regenerate { + const schema = z.object({ + summary: z.string().describe('What this repo is, in a sentence or two'), + sections: z + .array(z.object({ title: z.string(), body: z.string() })) + .describe('Titled sections: structure, key modules, entry points, conventions'), + }) + const output = Output.object({ schema }) + const instructions = opts.instructions ?? DEFAULT_OVERVIEW_INSTRUCTIONS + + return async ctx => { + const parts = [instructions, `# Why now\n${ctx.reason}`] + if (ctx.previous) parts.push(`# Current CODE-OVERVIEW.md (update this)\n${serializeOverview(ctx.previous)}`) + parts.push(output.toSystemPrompt()) + const response = await overviewer.prompt(parts.join('\n\n')) + return output.parse(response.text ?? '') as CodeOverview + } +} + +/** Options for {@link overviewLoopPrompt}. */ +export interface OverviewLoopPromptOptions { + /** The prompt id the loop references. Default `code-overview`. */ + id?: string +} + +/** + * Bridge a {@link CodeOverviewMaintainer} into a {@link LoopPrompt}, so adding its + * id to a loop rule (e.g. on `major-change`) makes the overview self-maintain: the + * loop hands it the event, the maintainer refreshes only if the change is + * material, and the prompt reports what it did. This is the "regen via the loop" + * wiring (#113) the issue asks for. + */ +export function overviewLoopPrompt( + maintainer: CodeOverviewMaintainer, + opts: OverviewLoopPromptOptions = {}, +): LoopPrompt { + return definePrompt({ + id: opts.id ?? 'code-overview', + run: async ctx => { + const refresh = await maintainer.handle(ctx.event) + return refresh.refreshed + ? `Refreshed CODE-OVERVIEW.md: ${refresh.reasons.join('; ')}` + : 'CODE-OVERVIEW.md unchanged (change was not material)' + }, + }) +} diff --git a/packages/ai-autopilot/src/overview/index.ts b/packages/ai-autopilot/src/overview/index.ts new file mode 100644 index 0000000..6389f4a --- /dev/null +++ b/packages/ai-autopilot/src/overview/index.ts @@ -0,0 +1,26 @@ +/** + * Scale mode — an always-current `CODE-OVERVIEW.md` the agent reads first in a + * large repo, kept fresh by refreshing only on *material* changes (#114). + * + * - {@link CodeOverviewMaintainer} — holds the map, refreshes on material change + * - {@link detectMaterialChange} — the deterministic trigger (build/test/layout) + * - {@link agentOverview} — regenerate the map with an `ai-sdk` agent + * - {@link overviewLoopPrompt} — drop the maintainer into the loop (#113) + * - {@link parseOverview} / {@link serializeOverview} — the `CODE-OVERVIEW.md` form + * - {@link loadOverview} / {@link saveOverview} — persist over an {@link OverviewFs} + */ +export { CodeOverviewMaintainer, createOverviewMaintainer, type MaintainerOptions } from './maintainer.js' +export { detectMaterialChange, type DetectOptions } from './material.js' +export { agentOverview, overviewLoopPrompt, type AgentOverviewOptions, type OverviewLoopPromptOptions } from './agent.js' +export { parseOverview, serializeOverview } from './markdown.js' +export { loadOverview, saveOverview, nodeOverviewFs, OVERVIEW_FILE } from './store.js' +export type { + CodeOverview, + OverviewSection, + MaterialChange, + OverviewFs, + RegenerateContext, + Regenerate, + OverviewRefresh, + OverviewEvent, +} from './types.js' diff --git a/packages/ai-autopilot/src/overview/maintainer.test.ts b/packages/ai-autopilot/src/overview/maintainer.test.ts new file mode 100644 index 0000000..1ed1277 --- /dev/null +++ b/packages/ai-autopilot/src/overview/maintainer.test.ts @@ -0,0 +1,110 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { CodeOverviewMaintainer, createOverviewMaintainer } from './maintainer.js' +import { serializeOverview } from './markdown.js' +import { OVERVIEW_FILE } from './store.js' +import type { CodeOverview, OverviewEvent, OverviewFs, RegenerateContext } from './types.js' + +/** An in-memory {@link OverviewFs} for tests. */ +function memFs(seed: Record = {}): OverviewFs & { files: Record } { + const files = { ...seed } + return { + files, + async read(path) { + const v = files[path] + if (v === undefined) throw new Error(`no such file: ${path}`) + return v + }, + async write(path, contents) { + files[path] = contents + }, + async exists(path) { + return path in files + }, + } +} + +const overviewOf = (summary: string): CodeOverview => ({ summary, sections: [] }) + +describe('CodeOverviewMaintainer — material gating', () => { + it('regenerates + persists on a material change', async () => { + const calls: RegenerateContext[] = [] + const fs = memFs() + const maintainer = new CodeOverviewMaintainer({ + fs, + regenerate: ctx => { + calls.push(ctx) + return overviewOf('fresh map') + }, + }) + const refresh = await maintainer.handle({ kind: 'major-change', summary: 'migrated to vitest', paths: ['vitest.config.ts'] }) + + assert.equal(refresh.refreshed, true) + assert.match(refresh.reasons.join(), /test-tooling/) + assert.equal(maintainer.get()?.summary, 'fresh map') + assert.equal(fs.files[OVERVIEW_FILE], serializeOverview(overviewOf('fresh map'))) + assert.match(calls[0]!.reason, /test-tooling/) // reason threaded to regenerate + }) + + it('skips an immaterial change — no regenerate, overview untouched', async () => { + let regenerated = 0 + const maintainer = new CodeOverviewMaintainer({ + overview: overviewOf('existing'), + regenerate: () => { regenerated++; return overviewOf('should not happen') }, + }) + const refresh = await maintainer.handle({ kind: 'major-change', summary: 'fix a typo', paths: ['src/x.ts'] }) + + assert.equal(refresh.refreshed, false) + assert.deepEqual(refresh.reasons, []) + assert.equal(refresh.overview?.summary, 'existing') + assert.equal(regenerated, 0) + }) + + it('passes the previous overview into regenerate so it revises rather than rewrites', async () => { + let seenPrevious: CodeOverview | undefined + const maintainer = new CodeOverviewMaintainer({ + overview: overviewOf('v1'), + regenerate: ctx => { seenPrevious = ctx.previous; return overviewOf('v2') }, + }) + await maintainer.handle({ kind: 'major-change', paths: ['package.json'] }) + assert.equal(seenPrevious?.summary, 'v1') + }) +}) + +describe('CodeOverviewMaintainer — generate + load + persistence', () => { + it('generate() regenerates unconditionally (on-demand) and persists', async () => { + const fs = memFs() + const events: OverviewEvent[] = [] + const maintainer = createOverviewMaintainer({ + fs, + regenerate: () => overviewOf('generated'), + onEvent: e => events.push(e), + }) + const overview = await maintainer.generate() + assert.equal(overview.summary, 'generated') + assert.ok(OVERVIEW_FILE in fs.files) + assert.ok(events.some(e => e.type === 'generated')) + }) + + it('load() reads an existing CODE-OVERVIEW.md from the fs', async () => { + const fs = memFs({ [OVERVIEW_FILE]: serializeOverview(overviewOf('from disk')) }) + const maintainer = new CodeOverviewMaintainer({ fs, regenerate: () => overviewOf('x') }) + assert.equal(maintainer.get(), undefined) + await maintainer.load() + assert.equal(maintainer.get()?.summary, 'from disk') + }) + + it('isolates a throwing onEvent callback', async () => { + const maintainer = new CodeOverviewMaintainer({ + regenerate: () => overviewOf('ok'), + onEvent: () => { throw new Error('observer bug') }, + }) + await maintainer.handle({ kind: 'major-change', paths: ['package.json'] }) + assert.equal(maintainer.get()?.summary, 'ok') + }) + + it('requires a regenerate function', () => { + // @ts-expect-error missing regenerate + assert.throws(() => new CodeOverviewMaintainer({}), /requires a `regenerate`/) + }) +}) diff --git a/packages/ai-autopilot/src/overview/maintainer.ts b/packages/ai-autopilot/src/overview/maintainer.ts new file mode 100644 index 0000000..6766257 --- /dev/null +++ b/packages/ai-autopilot/src/overview/maintainer.ts @@ -0,0 +1,129 @@ +import type { LoopEvent } from '../loop/types.js' +import { detectMaterialChange } from './material.js' +import { loadOverview, saveOverview, OVERVIEW_FILE } from './store.js' +import type { + CodeOverview, + MaterialChange, + OverviewEvent, + OverviewFs, + OverviewRefresh, + Regenerate, +} from './types.js' + +/** Options for {@link CodeOverviewMaintainer}. */ +export interface MaintainerOptions { + /** Produce a fresh overview. Injected — usually `agentOverview(agent)`. */ + regenerate: Regenerate + /** Seed the current overview (e.g. a just-loaded one). */ + overview?: CodeOverview + /** Decide whether a change is material. Default {@link detectMaterialChange}. */ + detect?: (event: LoopEvent) => MaterialChange + /** Persist the overview here when set (host or a runner session's `fs`). */ + fs?: OverviewFs + /** Overview file path when persisting. Default `CODE-OVERVIEW.md`. */ + path?: string + /** Observe progress. Isolated: a throwing callback is logged and swallowed. */ + onEvent?: (event: OverviewEvent) => void +} + +/** + * Owns the overview's maintenance policy: it holds the current {@link CodeOverview}, + * regenerates it on demand, and — the point of scale mode — refreshes it only + * when a change is *material*. Feed it loop events via {@link handle} (wire it into + * the loop with `overviewLoopPrompt`) and it self-maintains; immaterial edits are + * skipped so the map does not churn on every commit. + * + * ```ts + * const maintainer = new CodeOverviewMaintainer({ regenerate: agentOverview(agent), fs: nodeOverviewFs() }) + * await maintainer.load() + * await maintainer.handle({ kind: 'major-change', summary: 'migrated to vitest', paths: ['vitest.config.ts'] }) + * ``` + */ +export class CodeOverviewMaintainer { + private overview?: CodeOverview + private readonly regenerate: Regenerate + private readonly detect: (event: LoopEvent) => MaterialChange + private readonly fs?: OverviewFs + private readonly path: string + private readonly emit: (event: OverviewEvent) => void + + constructor(opts: MaintainerOptions) { + if (typeof opts?.regenerate !== 'function') { + throw new TypeError('[ai-autopilot] CodeOverviewMaintainer requires a `regenerate` function') + } + this.regenerate = opts.regenerate + if (opts.overview) this.overview = opts.overview + this.detect = opts.detect ?? (event => detectMaterialChange(event)) + if (opts.fs) this.fs = opts.fs + this.path = opts.path ?? OVERVIEW_FILE + this.emit = makeEmitter(opts.onEvent) + } + + /** The current overview, or `undefined` if none has been generated/loaded. */ + get(): CodeOverview | undefined { + return this.overview + } + + /** Load the overview from the configured `fs` (no-op when no `fs` or no file). */ + async load(): Promise { + if (!this.fs) return this.overview + const loaded = await loadOverview(this.fs, this.path) + if (loaded) this.overview = loaded + return this.overview + } + + /** Regenerate the overview unconditionally (the on-demand path) and persist it. */ + async generate(reason = 'on-demand', event?: LoopEvent): Promise { + const overview = await this.regenerate({ + reason, + ...(this.overview ? { previous: this.overview } : {}), + ...(event ? { event } : {}), + }) + this.overview = overview + await this.persist() + this.emit({ type: 'generated', reason }) + return overview + } + + /** + * Feed a change to the maintainer. Regenerates + persists only when the change + * is material (see {@link detectMaterialChange}); otherwise it is skipped and + * the overview is left untouched. + */ + async handle(event: LoopEvent): Promise { + const verdict = this.detect(event) + if (!verdict.material) { + this.emit({ type: 'skip', event }) + return { refreshed: false, reasons: [], ...(this.overview ? { overview: this.overview } : {}) } + } + this.emit({ type: 'refresh', reasons: verdict.reasons }) + const overview = await this.regenerate({ + reason: verdict.reasons.join('; '), + event, + ...(this.overview ? { previous: this.overview } : {}), + }) + this.overview = overview + await this.persist() + return { refreshed: true, reasons: verdict.reasons, overview } + } + + private async persist(): Promise { + if (this.fs && this.overview) await saveOverview(this.fs, this.overview, this.path) + } +} + +/** Factory mirror of `new CodeOverviewMaintainer(...)`. */ +export function createOverviewMaintainer(opts: MaintainerOptions): CodeOverviewMaintainer { + return new CodeOverviewMaintainer(opts) +} + +function makeEmitter(onEvent: MaintainerOptions['onEvent']): (event: OverviewEvent) => void { + if (!onEvent) return () => {} + return event => { + try { + onEvent(event) + } catch (err) { + console.error('[ai-autopilot] overview onEvent callback threw; ignoring:', err) + } + } +} diff --git a/packages/ai-autopilot/src/overview/markdown.test.ts b/packages/ai-autopilot/src/overview/markdown.test.ts new file mode 100644 index 0000000..d3df597 --- /dev/null +++ b/packages/ai-autopilot/src/overview/markdown.test.ts @@ -0,0 +1,35 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { parseOverview, serializeOverview } from './markdown.js' +import type { CodeOverview } from './types.js' + +describe('CODE-OVERVIEW.md round-trip', () => { + const overview: CodeOverview = { + summary: 'A server-rendered bookstore on Vike + universal-orm.', + sections: [ + { title: 'Structure', body: '- `pages/` — Vike routes\n- `database/` — schema + migrations' }, + { title: 'Conventions', body: 'Data access goes through the model builder.' }, + ], + } + + it('serializes to a titled markdown doc', () => { + const md = serializeOverview(overview) + assert.match(md, /^# Code Overview\n/) + assert.match(md, /## Structure/) + assert.match(md, /## Conventions/) + }) + + it('parses back to the same data (lossless)', () => { + assert.deepEqual(parseOverview(serializeOverview(overview)), overview) + }) + + it('treats text before the first ## as the summary and tolerates a missing title', () => { + const parsed = parseOverview('Just a repo.\n\n## Structure\nflat.') + assert.equal(parsed.summary, 'Just a repo.') + assert.deepEqual(parsed.sections, [{ title: 'Structure', body: 'flat.' }]) + }) + + it('yields an empty overview for an empty string', () => { + assert.deepEqual(parseOverview(''), { summary: '', sections: [] }) + }) +}) diff --git a/packages/ai-autopilot/src/overview/markdown.ts b/packages/ai-autopilot/src/overview/markdown.ts new file mode 100644 index 0000000..581e67b --- /dev/null +++ b/packages/ai-autopilot/src/overview/markdown.ts @@ -0,0 +1,64 @@ +import type { CodeOverview, OverviewSection } from './types.js' + +/** + * `CODE-OVERVIEW.md` is the canonical form — human-editable and diff-friendly, so + * a maintainer (or a person) can hand-correct it. These round-trip it to/from the + * {@link CodeOverview} data. Shape: + * + * ```md + * # Code Overview + * + * + * + * ## Structure + * + * + * ## Key modules + * + * ``` + */ + +const TITLE = '# Code Overview' + +/** Serialize a {@link CodeOverview} to `CODE-OVERVIEW.md`. */ +export function serializeOverview(overview: CodeOverview): string { + const parts = [TITLE] + const summary = overview.summary.trim() + if (summary) parts.push(summary) + for (const section of overview.sections) { + const title = section.title.trim() + if (!title) continue + parts.push(`## ${title}`) + const body = section.body.trim() + if (body) parts.push(body) + } + return parts.join('\n\n') + '\n' +} + +/** + * Parse `CODE-OVERVIEW.md` back into a {@link CodeOverview}. Tolerant: a missing + * `# Code Overview` title is fine, the text before the first `##` is the summary, + * and each `## Heading` starts a section. An empty string yields an empty overview. + */ +export function parseOverview(markdown: string): CodeOverview { + const lines = markdown.replace(/\r\n/g, '\n').split('\n') + + let summaryLines: string[] = [] + const sections: OverviewSection[] = [] + let current: { title: string; body: string[] } | undefined + + for (const line of lines) { + const heading = /^##\s+(.*)$/.exec(line) + if (heading) { + if (current) sections.push({ title: current.title, body: current.body.join('\n').trim() }) + current = { title: heading[1]!.trim(), body: [] } + continue + } + if (/^#\s+/.test(line)) continue // the top-level `# Code Overview` title + if (current) current.body.push(line) + else summaryLines.push(line) + } + if (current) sections.push({ title: current.title, body: current.body.join('\n').trim() }) + + return { summary: summaryLines.join('\n').trim(), sections } +} diff --git a/packages/ai-autopilot/src/overview/material.test.ts b/packages/ai-autopilot/src/overview/material.test.ts new file mode 100644 index 0000000..dfe7b32 --- /dev/null +++ b/packages/ai-autopilot/src/overview/material.test.ts @@ -0,0 +1,48 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { detectMaterialChange } from './material.js' + +describe('detectMaterialChange', () => { + it('flags a build/config change', () => { + const v = detectMaterialChange({ kind: 'major-change', paths: ['src/foo.ts', 'package.json'] }) + assert.equal(v.material, true) + assert.match(v.reasons.join(), /build\/config change \(package\.json\)/) + }) + + it('flags a test-framework migration', () => { + const v = detectMaterialChange({ kind: 'major-change', paths: ['vitest.config.ts'] }) + assert.equal(v.material, true) + assert.match(v.reasons.join(), /test-tooling change/) + }) + + it('flags a large change spread across several areas', () => { + const paths = ['a/1.ts', 'a/2.ts', 'b/3.ts', 'b/4.ts', 'c/5.ts', 'c/6.ts', 'd/7.ts', 'd/8.ts'] + const v = detectMaterialChange({ kind: 'major-change', paths }) + assert.equal(v.material, true) + assert.match(v.reasons.join(), /large change across 8 files in 4 areas/) + }) + + it('does not flag a big change confined to one area', () => { + const paths = Array.from({ length: 10 }, (_, i) => `src/feature/${i}.ts`) + const v = detectMaterialChange({ kind: 'major-change', paths }) + assert.equal(v.material, false) + }) + + it('flags a restructure described in the summary even without telltale paths', () => { + const v = detectMaterialChange({ kind: 'major-change', summary: 'Renamed and moved the auth module', paths: ['src/auth/index.ts'] }) + assert.equal(v.material, true) + assert.match(v.reasons.join(), /restructure described/) + }) + + it('skips a routine edit', () => { + const v = detectMaterialChange({ kind: 'major-change', summary: 'fix a typo', paths: ['src/util.ts'] }) + assert.deepEqual(v, { material: false, reasons: [] }) + }) + + it('honors a custom threshold and extra patterns', () => { + assert.equal(detectMaterialChange({ kind: 'x', paths: ['a/1.ts', 'b/2.ts'] }, { manyFilesThreshold: 2 }).material, true) + const extra = detectMaterialChange({ kind: 'x', paths: ['infra/deploy.tf'] }, { extraPatterns: [/\.tf$/] }) + assert.equal(extra.material, true) + assert.match(extra.reasons.join(), /watched path changed/) + }) +}) diff --git a/packages/ai-autopilot/src/overview/material.ts b/packages/ai-autopilot/src/overview/material.ts new file mode 100644 index 0000000..bafc379 --- /dev/null +++ b/packages/ai-autopilot/src/overview/material.ts @@ -0,0 +1,84 @@ +import type { LoopEvent } from '../loop/types.js' +import type { MaterialChange } from './types.js' + +/** + * The material-change detector — the whole point of scale mode. A `CODE-OVERVIEW` + * that lags the code is worse than none, so it must refresh on the changes that + * actually move the map (build tooling, test framework, directory layout, a new + * area) and ignore the routine edits that don't. This is deterministic and + * path-driven so it is cheap to run on every loop event. + * + * Signals (validated against Cloudflare's published reviewer, which hit the same + * "instructions rot fast" problem): build/config change, test-tooling change, + * directory restructure, and a large change touching many files. + */ + +/** Build / config files whose change reshapes how the app is built or run. */ +const BUILD_CONFIG: RegExp[] = [ + /(^|\/)package\.json$/, + /(^|\/)pnpm-workspace\.yaml$/, + /(^|\/)turbo\.json$/, + /(^|\/)tsconfig(\.[\w-]+)?\.json$/, + /(^|\/)(vite|rollup|webpack|esbuild|astro|svelte|nuxt|next)\.config\.[cm]?[jt]s$/, + /\.config\.[cm]?[jt]s$/, +] + +/** Test-tooling files whose change signals a framework migration. */ +const TEST_TOOLING: RegExp[] = [ + /(^|\/)(vitest|jest|playwright|cypress|karma|ava)\.config\.[cm]?[jt]s$/, + /(^|\/)jest\.setup\.[cm]?[jt]s$/, +] + +/** Summary keywords that describe a structural change even without telltale paths. */ +const RESTRUCTURE_WORDS = /\b(restructur|reorganiz|reorganis|migrat|rename|move[ds]?|scaffold|new (module|package|area|service))\b/i + +/** Options for {@link detectMaterialChange}. */ +export interface DetectOptions { + /** A change touching at least this many files counts as material. Default 8. */ + manyFilesThreshold?: number + /** Extra path patterns to treat as material (project-specific). */ + extraPatterns?: RegExp[] +} + +const first = (paths: readonly string[], patterns: RegExp[]): string | undefined => + paths.find(p => patterns.some(re => re.test(p))) + +/** The top-level directory segment of a path (or undefined for a root-level file). */ +function topDir(path: string): string | undefined { + const clean = path.replace(/^\.?\//, '') + const slash = clean.indexOf('/') + return slash === -1 ? undefined : clean.slice(0, slash) +} + +/** + * Decide whether a {@link LoopEvent} is material enough to refresh the overview. + * Pure and deterministic — same event in, same verdict out. + */ +export function detectMaterialChange(event: LoopEvent, opts: DetectOptions = {}): MaterialChange { + const threshold = opts.manyFilesThreshold ?? 8 + const paths = event.paths ?? [] + const reasons: string[] = [] + + const build = first(paths, BUILD_CONFIG) + if (build) reasons.push(`build/config change (${build})`) + + const test = first(paths, TEST_TOOLING) + if (test) reasons.push(`test-tooling change (${test})`) + + if (opts.extraPatterns?.length) { + const extra = first(paths, opts.extraPatterns) + if (extra) reasons.push(`watched path changed (${extra})`) + } + + // A change spread across many top-level areas reshapes the structure section. + const dirs = new Set(paths.map(topDir).filter((d): d is string => d !== undefined)) + if (paths.length >= threshold && dirs.size >= 2) { + reasons.push(`large change across ${paths.length} files in ${dirs.size} areas`) + } + + if (event.summary && RESTRUCTURE_WORDS.test(event.summary)) { + reasons.push('restructure described in the change summary') + } + + return { material: reasons.length > 0, reasons } +} diff --git a/packages/ai-autopilot/src/overview/store.ts b/packages/ai-autopilot/src/overview/store.ts new file mode 100644 index 0000000..751138c --- /dev/null +++ b/packages/ai-autopilot/src/overview/store.ts @@ -0,0 +1,50 @@ +import { parseOverview, serializeOverview } from './markdown.js' +import type { CodeOverview, OverviewFs } from './types.js' + +/** Default location of the overview file, at the project/workspace root. */ +export const OVERVIEW_FILE = 'CODE-OVERVIEW.md' + +/** + * Load a {@link CodeOverview} from `path`. A missing file yields `undefined` (the + * expected first-run state) so callers can tell "never generated" from "empty". + */ +export async function loadOverview(fs: OverviewFs, path: string = OVERVIEW_FILE): Promise { + if (!(await fs.exists(path))) return undefined + return parseOverview(await fs.read(path)) +} + +/** Persist a {@link CodeOverview} to `path` as `CODE-OVERVIEW.md`. */ +export async function saveOverview( + fs: OverviewFs, + overview: CodeOverview, + path: string = OVERVIEW_FILE, +): Promise { + await fs.write(path, serializeOverview(overview)) +} + +/** + * An {@link OverviewFs} backed by the host filesystem (`node:fs/promises`). The + * import is dynamic so the overview core stays free of a hard `node:fs` + * dependency — the map and its markdown work in any runtime; only this adapter + * touches disk. + */ +export function nodeOverviewFs(): OverviewFs { + return { + async read(path) { + const { readFile } = await import('node:fs/promises') + return readFile(path, 'utf8') + }, + async write(path, contents) { + const { writeFile } = await import('node:fs/promises') + await writeFile(path, contents, 'utf8') + }, + async exists(path) { + const { stat } = await import('node:fs/promises') + try { + return (await stat(path)).isFile() + } catch { + return false + } + }, + } +} diff --git a/packages/ai-autopilot/src/overview/types.ts b/packages/ai-autopilot/src/overview/types.ts new file mode 100644 index 0000000..1b99428 --- /dev/null +++ b/packages/ai-autopilot/src/overview/types.ts @@ -0,0 +1,78 @@ +import type { LoopEvent } from '../loop/types.js' + +/** + * Scale mode — a compact, always-current map of the codebase the agent reads + * first before working in a large repo, so it stays oriented without re-scanning + * the whole tree and blowing the context budget (#114). + * + * The artifact is `CODE-OVERVIEW.md`. The hard part is not generating it once but + * keeping it current: a stale overview is worse than none. So the trigger is a + * **material-change detector** ({@link detectMaterialChange}) wired into the loop + * (#113) — the overview refreshes on a build-tool change, a test-framework + * migration, or a directory restructure, not on every edit and not only + * on-demand. {@link CodeOverviewMaintainer} owns that policy; the regeneration + * itself is an injected, agent-backed step so it runs offline against a stub. + */ + +/** One titled section of the overview (e.g. Structure, Key modules, Conventions). */ +export interface OverviewSection { + title: string + body: string +} + +/** The parsed `CODE-OVERVIEW.md`: a one-paragraph summary plus titled sections. */ +export interface CodeOverview { + /** What this repo is, in a sentence or two. */ + summary: string + /** The map: structure, key modules, entry points, conventions, ... */ + sections: OverviewSection[] +} + +/** The verdict of the material-change detector. */ +export interface MaterialChange { + /** True when the change is structural enough to warrant refreshing the overview. */ + material: boolean + /** Why — the concrete signals that fired (empty when not material). */ + reasons: string[] +} + +/** + * The slice of a filesystem the store needs — the same read/write/exists subset + * as the decisions store, so a booted runner session's `fs` satisfies it and the + * overview persists inside a sandbox the same way it does on the host. + */ +export interface OverviewFs { + read(path: string): Promise + write(path: string, contents: string): Promise + exists(path: string): Promise +} + +/** What the injected regeneration step receives. */ +export interface RegenerateContext { + /** The current overview, when one exists — to update rather than rewrite blind. */ + previous?: CodeOverview + /** Why the refresh was triggered (the material-change reasons, or "on-demand"). */ + reason: string + /** The loop event that triggered it, when driven by the loop. */ + event?: LoopEvent + signal?: AbortSignal +} + +/** Produces a fresh {@link CodeOverview}. Injected so the maintainer runs offline. */ +export type Regenerate = (ctx: RegenerateContext) => CodeOverview | Promise + +/** The outcome of feeding one change to the maintainer. */ +export interface OverviewRefresh { + /** True when the change was material and the overview was regenerated. */ + refreshed: boolean + /** The material-change reasons (empty when the change was skipped as immaterial). */ + reasons: string[] + /** The current overview after handling — the fresh one when refreshed, else unchanged. */ + overview?: CodeOverview +} + +/** Progress events emitted while the maintainer works (for logging / a surface). */ +export type OverviewEvent = + | { type: 'skip'; event: LoopEvent } + | { type: 'refresh'; reasons: string[] } + | { type: 'generated'; reason: string }