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
14 changes: 14 additions & 0 deletions .changeset/open-loop-repo-memory.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions packages/framework/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -521,6 +522,12 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
if (extensions.length) discovered = extensions
}

// The repo's own memory files (#260) frame the agent: it reads them for context
// and keeps the ones it owns current. Read from the run's workspace; --fake's
// empty tmp cwd simply has none, so the demo stays deterministic.
const memory = await loadRepoMemory(cwd)
if (memory.some(m => m.content)) io.out(`◆ project memory: ${memory.filter(m => m.content).map(m => m.name).join(', ')}`)

const runOpts: RunFrameworkOptions = {
intent,
scope: opts.scope,
Expand All @@ -537,6 +544,7 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
...(discovered ? { extensions: discovered } : {}),
...(opts.composeExtensions ? { composeExtensions: true } : {}),
...(domainPreset ? { preset: domainPreset, ...(modes.length ? { modes } : {}) } : {}),
...(memory.length ? { memory } : {}),
...((): { sessionLink?: string } => {
const link = chooseSessionLink(opts, fake)
return link ? { sessionLink: link } : {}
Expand Down
7 changes: 7 additions & 0 deletions packages/framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
47 changes: 47 additions & 0 deletions packages/framework/src/memory.test.ts
Original file line number Diff line number Diff line change
@@ -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/)
})
90 changes: 90 additions & 0 deletions packages/framework/src/memory.ts
Original file line number Diff line number Diff line change
@@ -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<LoadedMemory[]> {
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')
}
25 changes: 25 additions & 0 deletions packages/framework/src/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
17 changes: 16 additions & 1 deletion packages/framework/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -238,7 +246,14 @@ export async function runFramework(opts: RunFrameworkOptions): Promise<RunFramew
extensions: activeExtensions,
neutral: neutralPersonas,
})
const system = [...personas.map(personaInstructions), ...skills.map(skillInstructions)].join('\n\n')
// The repo's own memory files (#260) frame the agent alongside personas + skills:
// their contents give context, and the agent is told to keep the ones it owns current.
const memoryBlock = opts.memory && opts.memory.length ? memoryFraming(opts.memory) : ''
const system = [
...personas.map(personaInstructions),
...skills.map(skillInstructions),
...(memoryBlock ? [memoryBlock] : []),
].join('\n\n')

// The session id is not known until the first driver turn returns, so a
// templated link (`.../{sessionId}`) can only resolve later. A literal link is
Expand Down
Loading