From ea1903ac107ee3a5571300e90ad46bda9f4ad428 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sun, 5 Jul 2026 19:20:33 +0300 Subject: [PATCH] feat(framework): repo files as persistent AI memory (#260) --- .changeset/open-loop-repo-memory.md | 14 +++++ packages/framework/src/cli.ts | 8 +++ packages/framework/src/index.ts | 7 +++ packages/framework/src/memory.test.ts | 47 ++++++++++++++ packages/framework/src/memory.ts | 90 +++++++++++++++++++++++++++ packages/framework/src/run.test.ts | 25 ++++++++ packages/framework/src/run.ts | 17 ++++- 7 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 .changeset/open-loop-repo-memory.md create mode 100644 packages/framework/src/memory.test.ts create mode 100644 packages/framework/src/memory.ts diff --git a/.changeset/open-loop-repo-memory.md b/.changeset/open-loop-repo-memory.md new file mode 100644 index 0000000..3fe3a11 --- /dev/null +++ b/.changeset/open-loop-repo-memory.md @@ -0,0 +1,14 @@ +--- +'@gemstack/framework': minor +--- + +Repo files as persistent AI memory (#260). + +The agent now reads the project's special files (CODE-OVERVIEW.md, +KNOWLEDGE-BASE.md, BRAINSTORMING.md, DECISIONS.md) at the start of a run and is +told to keep the ones it owns current, so a project's memory lives in the repo as +plain markdown and the next run picks up where the last left off. `DECISIONS.md` +stays framework-owned (we write it from the decisions ledger), so the agent reads +it but does not edit it. New: `loadRepoMemory(cwd)`, `memoryFraming`, +`MEMORY_FILES`, and a `memory` option on `runFramework`; the CLI reads the files +from the workspace and frames them alongside personas and skills. diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts index cf8cc0c..0c9ecee 100644 --- a/packages/framework/src/cli.ts +++ b/packages/framework/src/cli.ts @@ -24,6 +24,7 @@ import { import { FAKE_DEPLOY, FAKE_INTENT, FAKE_SIGNALS, fakeDriver } from './fake-script.js' import { discoverExtensions, readProjectSignals } from './extensions.js' import { loadFrameworkConfig, type FrameworkFileConfig } from './config.js' +import { loadRepoMemory } from './memory.js' import { preflight } from './preflight.js' import { RunStore } from './store/index.js' @@ -521,6 +522,12 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise m.content)) io.out(`◆ project memory: ${memory.filter(m => m.content).map(m => m.name).join(', ')}`) + const runOpts: RunFrameworkOptions = { intent, scope: opts.scope, @@ -537,6 +544,7 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise { const link = chooseSessionLink(opts, fake) return link ? { sessionLink: link } : {} diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index e9294c5..af31fd7 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -92,6 +92,13 @@ export { FRAMEWORK_CONFIG_FILES, type FrameworkFileConfig, } from './config.js' +export { + loadRepoMemory, + memoryFraming, + MEMORY_FILES, + type MemoryFile, + type LoadedMemory, +} from './memory.js' export { preflight, type PreflightResult, diff --git a/packages/framework/src/memory.test.ts b/packages/framework/src/memory.test.ts new file mode 100644 index 0000000..bca5598 --- /dev/null +++ b/packages/framework/src/memory.test.ts @@ -0,0 +1,47 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { loadRepoMemory, memoryFraming, MEMORY_FILES } from './memory.js' + +test('loadRepoMemory reads present files and leaves missing ones without content', async () => { + const dir = await mkdtemp(join(tmpdir(), 'repo-memory-')) + try { + await writeFile(join(dir, 'CODE-OVERVIEW.md'), ' A tiny app.\n') + await writeFile(join(dir, 'KNOWLEDGE-BASE.md'), '') // empty file -> no content + const loaded = await loadRepoMemory(dir) + // every canonical file comes back, so the agent is told to create missing ones + assert.equal(loaded.length, MEMORY_FILES.length) + const overview = loaded.find(m => m.name === 'CODE-OVERVIEW.md') + assert.equal(overview?.content, 'A tiny app.') // trimmed + assert.equal(loaded.find(m => m.name === 'KNOWLEDGE-BASE.md')?.content, undefined) + assert.equal(loaded.find(m => m.name === 'BRAINSTORMING.md')?.content, undefined) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('memoryFraming is empty for an empty list', () => { + assert.equal(memoryFraming([]), '') +}) + +test('memoryFraming separates agent-owned files from framework-owned, and shows current contents', () => { + const framing = memoryFraming([ + { name: 'CODE-OVERVIEW.md', purpose: 'a map of the codebase', content: 'It is a blog.' }, + { name: 'KNOWLEDGE-BASE.md', purpose: 'durable facts' }, // absent + { name: 'DECISIONS.md', purpose: 'the decision log', agentMaintained: false }, + ]) + // agent-owned files land under the "keep up to date" instruction + assert.match(framing, /Keep these up to date[\s\S]*CODE-OVERVIEW\.md/) + // DECISIONS.md is flagged read-only so the agent will not clobber our ledger write + assert.match(framing, /Read-only[\s\S]*DECISIONS\.md/) + assert.doesNotMatch(framing.split('Read-only')[0]!, /DECISIONS\.md/) // not in the owned list + // present contents are inlined + assert.match(framing, /### CODE-OVERVIEW\.md\nIt is a blog\./) +}) + +test('memoryFraming tells the agent to start the files when none exist yet', () => { + const framing = memoryFraming([{ name: 'CODE-OVERVIEW.md', purpose: 'a map of the codebase' }]) + assert.match(framing, /None exist yet/) +}) diff --git a/packages/framework/src/memory.ts b/packages/framework/src/memory.ts new file mode 100644 index 0000000..b1eb425 --- /dev/null +++ b/packages/framework/src/memory.ts @@ -0,0 +1,90 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' + +/** + * A canonical repo memory file (#204, #260): a special file at the workspace root + * the agent reads at the start of a run for context and keeps current as it works, + * so the project's memory lives in the repo as plain markdown (Rom's idea). This + * doubles as persistence: the next run picks up where the last left off. + */ +export interface MemoryFile { + /** File name at the workspace root. */ + name: string + /** What it holds, shown to the agent so it knows what belongs where. */ + purpose: string + /** + * False when The Framework owns the file and the agent must not edit it (it may + * still read it for context). `DECISIONS.md` is written from our decisions + * ledger at run end, so an agent edit would be clobbered. Default `true`. + */ + agentMaintained?: boolean +} + +/** The canonical repo memory files, in the order they are framed. */ +export const MEMORY_FILES: readonly MemoryFile[] = [ + { name: 'CODE-OVERVIEW.md', purpose: 'a map of the codebase: structure, key modules, and how they fit together' }, + { name: 'KNOWLEDGE-BASE.md', purpose: 'durable facts and conventions learned about this project' }, + { name: 'BRAINSTORMING.md', purpose: 'open ideas and things to explore later' }, + // Framework-owned: written from the decisions ledger at run end. Read-only to the agent. + { name: 'DECISIONS.md', purpose: 'the running log of decisions and why', agentMaintained: false }, +] + +/** A memory file paired with its current contents. */ +export interface LoadedMemory extends MemoryFile { + /** Current file contents (trimmed), or undefined when the file does not exist yet. */ + content?: string +} + +/** + * Read the canonical memory files present in a workspace. Every entry comes back + * (so the agent is told to create the missing ones); a file that does not exist + * yet simply has no `content`. + */ +export async function loadRepoMemory( + dir: string, + files: readonly MemoryFile[] = MEMORY_FILES, +): Promise { + return Promise.all( + files.map(async file => { + try { + const content = (await readFile(join(dir, file.name), 'utf8')).trim() + return content ? { ...file, content } : { ...file } + } catch { + return { ...file } + } + }), + ) +} + +/** + * Build the system-prompt block that turns the repo's special files into the + * agent's persistent memory: the current contents of any files present (so the + * agent starts with that context), plus an instruction to keep the ones it owns + * current. Returns `''` when there is nothing to frame. + */ +export function memoryFraming(memories: readonly LoadedMemory[]): string { + if (memories.length === 0) return '' + const owned = memories.filter(m => m.agentMaintained !== false) + const framework = memories.filter(m => m.agentMaintained === false) + const present = memories.filter(m => m.content) + + const lines: string[] = ['## Project memory'] + lines.push( + "This project keeps its long-term memory in these files at the repo root. Read them for context before you start.", + ) + if (owned.length) { + lines.push('\nKeep these up to date as you work, creating one when it does not exist yet:') + for (const m of owned) lines.push(`- ${m.name}: ${m.purpose}`) + } + if (framework.length) { + lines.push('\nRead-only (The Framework writes these, do not edit them):') + for (const m of framework) lines.push(`- ${m.name}: ${m.purpose}`) + } + if (present.length) { + lines.push('\nCurrent contents:') + for (const m of present) lines.push(`\n### ${m.name}\n${m.content}`) + } else { + lines.push('\nNone exist yet. Start them as you learn about the project.') + } + return lines.join('\n') +} diff --git a/packages/framework/src/run.test.ts b/packages/framework/src/run.test.ts index 942b3e7..f7670e7 100644 --- a/packages/framework/src/run.test.ts +++ b/packages/framework/src/run.test.ts @@ -190,6 +190,31 @@ test('an empty from-scratch project is still framed with the flagship page build assert.match(system(), /https:\/\/vike\.dev\/llms\.txt/) }) +test('repo memory files frame the agent: contents + a maintain instruction (#260)', async () => { + const { driver, system } = recordingDriver() + await runFramework({ + intent: FAKE_INTENT, + driver, + cwd: '/tmp/ws', + signals: FAKE_SIGNALS, + memory: [ + { name: 'CODE-OVERVIEW.md', purpose: 'a map of the codebase', content: 'A blog with comments.' }, + { name: 'DECISIONS.md', purpose: 'the decision log', agentMaintained: false }, + ], + onEvent: () => {}, + }) + assert.match(system(), /Project memory/) + assert.match(system(), /A blog with comments\./) // contents inlined as context + assert.match(system(), /Keep these up to date[\s\S]*CODE-OVERVIEW\.md/) + assert.match(system(), /Read-only[\s\S]*DECISIONS\.md/) // agent must not clobber our ledger write +}) + +test('no memory option leaves the framing unchanged (#260)', async () => { + const { driver, system } = recordingDriver() + await runFramework({ intent: FAKE_INTENT, driver, cwd: '/tmp/ws', signals: FAKE_SIGNALS, onEvent: () => {} }) + assert.doesNotMatch(system(), /Project memory/) +}) + test('a registered extension auto-activates by its signal, no opt-in needed (#190)', async () => { const { driver, system } = recordingDriver() const audit = defineFrameworkExtension({ diff --git a/packages/framework/src/run.ts b/packages/framework/src/run.ts index 5fc4a7c..f6f8a3c 100644 --- a/packages/framework/src/run.ts +++ b/packages/framework/src/run.ts @@ -26,6 +26,7 @@ import { type LocalRunnerSession, } from '@gemstack/ai-autopilot' import type { Driver, DriverEvent, DriverSession } from './driver/index.js' +import { memoryFraming, type LoadedMemory } from './memory.js' import { decideDeploy, deployWith, driverArchitect, driverBuild, driverChecklist, driverImprove, driverLoopPrompts } from './steps.js' import { hasSessionIdPlaceholder, resolveSessionLink, type FrameworkEvent } from './events.js' @@ -85,6 +86,13 @@ export interface RunFrameworkOptions { model?: string /** Signals for preset detection (deps/files). Default: none, so the flagship preset wins. */ signals?: FrameworkSignals + /** + * The repo's memory files ({@link LoadedMemory}) to frame the agent with (#260): + * their current contents become context and the agent is told to keep the ones + * it owns current (persistence lives in the repo as markdown). Load with + * `loadRepoMemory(cwd)`. Omit or pass `[]` to frame no memory. + */ + memory?: readonly LoadedMemory[] /** * A user-picked Open Loop domain preset ({loops, prompts, skills}) to run the * build under (#251). Its skills (and their personas) frame every phase, and @@ -238,7 +246,14 @@ export async function runFramework(opts: RunFrameworkOptions): Promise