Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ai-autopilot-code-overview.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions packages/ai-autopilot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
65 changes: 65 additions & 0 deletions packages/ai-autopilot/src/overview/agent.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
84 changes: 84 additions & 0 deletions packages/ai-autopilot/src/overview/agent.ts
Original file line number Diff line number Diff line change
@@ -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)'
},
})
}
26 changes: 26 additions & 0 deletions packages/ai-autopilot/src/overview/index.ts
Original file line number Diff line number Diff line change
@@ -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'
110 changes: 110 additions & 0 deletions packages/ai-autopilot/src/overview/maintainer.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}): OverviewFs & { files: Record<string, string> } {
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`/)
})
})
Loading
Loading