Skip to content

Commit 2cfa86e

Browse files
omri-mayaclaude
andcommitted
feat(memory): opt-in persistent memory scaffold for providers
Adds a provider capability (usesMemoryScaffold) and a container-side boot scaffold that materializes a persistent memory/ tree for providers that opt in. Dormant for the default provider — the scaffold is only built when a provider declares the capability, so existing installs are byte-identical (asserted by a boot-gate wiring test). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 36cbf17 commit 2cfa86e

7 files changed

Lines changed: 141 additions & 0 deletions

File tree

container/agent-runner/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { fileURLToPath } from 'url';
2727

2828
import { loadConfig } from './config.js';
2929
import { buildSystemPromptAddendum } from './destinations.js';
30+
import { ensureMemoryScaffold } from './memory-scaffold.js';
3031
// Providers barrel — each enabled provider self-registers on import.
3132
// Provider skills append imports to providers/index.ts.
3233
import './providers/index.js';
@@ -95,6 +96,12 @@ async function main(): Promise<void> {
9596
effort: config.effort,
9697
});
9798

99+
// Providers that lack native memory opt in via `usesMemoryScaffold`; for them
100+
// the runner creates a persistent memory/ tree in its host-backed workspace at
101+
// boot (idempotent). Default off — the trunk default (Claude) omits the flag
102+
// and keeps its native memory untouched.
103+
if (provider.usesMemoryScaffold) ensureMemoryScaffold();
104+
98105
await runPollLoop({
99106
provider,
100107
providerName,
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it } from 'bun:test';
2+
import fs from 'fs';
3+
import os from 'os';
4+
import path from 'path';
5+
6+
import { ensureMemoryScaffold } from './memory-scaffold.js';
7+
8+
describe('ensureMemoryScaffold', () => {
9+
it('deterministically creates the memory tree', () => {
10+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-mem-'));
11+
try {
12+
ensureMemoryScaffold(base);
13+
14+
expect(fs.existsSync(path.join(base, 'memory', 'index.md'))).toBe(true);
15+
expect(fs.existsSync(path.join(base, 'memory', 'system', 'definition.md'))).toBe(true);
16+
expect(fs.existsSync(path.join(base, 'memory', 'memories'))).toBe(true);
17+
expect(fs.existsSync(path.join(base, 'memory', 'data'))).toBe(true);
18+
} finally {
19+
fs.rmSync(base, { recursive: true, force: true });
20+
}
21+
});
22+
23+
it('is idempotent and never clobbers the agent edits', () => {
24+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-mem-'));
25+
try {
26+
ensureMemoryScaffold(base);
27+
const indexFile = path.join(base, 'memory', 'index.md');
28+
fs.writeFileSync(indexFile, '# my own index\n');
29+
30+
ensureMemoryScaffold(base);
31+
32+
expect(fs.readFileSync(indexFile, 'utf-8')).toBe('# my own index\n');
33+
} finally {
34+
fs.rmSync(base, { recursive: true, force: true });
35+
}
36+
});
37+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import { fileURLToPath } from 'url';
4+
5+
/**
6+
* Create the agent's persistent memory scaffold, container-side, at boot.
7+
*
8+
* The runner owns its own workspace: it writes the memory tree straight into
9+
* `/workspace/agent` (the host-backed, RW group dir, so it persists across the
10+
* ephemeral container). No host-side step, nothing mounted in.
11+
*
12+
* The default `definition.md` / `index.md` live as real markdown templates next
13+
* to this module (under `memory-templates/`) — not as strings in code — so the
14+
* doctrine is editable as markdown and the agent receives an unescaped copy.
15+
* They ship in the mounted `/app/src` tree, so no image change is needed.
16+
*
17+
* Idempotent — only writes what's missing, so the agent's own edits and
18+
* accumulated memory are never clobbered on a later wake. Provider-agnostic:
19+
* the runner makes no assumption about which harness is running — a provider
20+
* opts in via `usesMemoryScaffold`.
21+
*/
22+
const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'memory-templates');
23+
24+
export function ensureMemoryScaffold(baseDir = '/workspace/agent'): void {
25+
const memoryDir = path.join(baseDir, 'memory');
26+
const systemDir = path.join(memoryDir, 'system');
27+
28+
for (const dir of [systemDir, path.join(memoryDir, 'memories'), path.join(memoryDir, 'data')]) {
29+
fs.mkdirSync(dir, { recursive: true });
30+
}
31+
32+
copyTemplateIfMissing('definition.md', path.join(systemDir, 'definition.md'));
33+
copyTemplateIfMissing('index.md', path.join(memoryDir, 'index.md'));
34+
}
35+
36+
function copyTemplateIfMissing(template: string, dest: string): void {
37+
if (fs.existsSync(dest)) return;
38+
fs.copyFileSync(path.join(TEMPLATES_DIR, template), dest);
39+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, it } from 'bun:test';
2+
import fs from 'fs';
3+
import path from 'path';
4+
5+
// Wiring guard for the memory-scaffold seam: the boot gate in index.ts
6+
// (`if (provider.usesMemoryScaffold) ensureMemoryScaffold()`) is the seam's
7+
// single functional reach-in. The unit tests in memory-scaffold.test.ts drive
8+
// ensureMemoryScaffold directly and stay green if the gate is deleted — this
9+
// test goes red. main() can't be driven in-process (it reads
10+
// /workspace/agent/container.json and enters the poll loop), so the guard is
11+
// structural: gate + import must both be present in the real entry point.
12+
describe('memory scaffold boot wiring', () => {
13+
const indexSrc = fs.readFileSync(path.join(import.meta.dir, 'index.ts'), 'utf-8');
14+
15+
it('gates the scaffold on the provider capability in main()', () => {
16+
expect(indexSrc).toContain('if (provider.usesMemoryScaffold) ensureMemoryScaffold()');
17+
});
18+
19+
it('imports ensureMemoryScaffold from the seam module', () => {
20+
expect(indexSrc).toContain("import { ensureMemoryScaffold } from './memory-scaffold.js'");
21+
});
22+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Agent Memory System
2+
3+
This editable file defines how your persistent memory works. It is a starting
4+
point, not a contract — reorganize it as the work demands. If the user or another
5+
memory system replaces this definition, follow the replacement.
6+
7+
Start every memory task at `memory/index.md`, then follow the narrowest relevant index.
8+
Treat indexes as core data: keep them accurate and concise.
9+
Every folder of durable memory has its own `index.md` describing its contents.
10+
When an index grows past roughly 20 entries, group related items into subfolders,
11+
and give each new subfolder its own `index.md` linked from the parent.
12+
13+
Use `memory/memories/` for durable facts, project context, people, decisions, and entity notes.
14+
Use `memory/data/` for structured reference data, datasets, tables, and reusable records.
15+
Use entity folders for things that matter: projects, people, places, organizations, decisions.
16+
17+
When the user shares something that should survive future turns, store it in the
18+
smallest useful file; prefer updating an existing file over creating duplicates.
19+
Write concise, source-aware notes; include dates when timing matters.
20+
If a fact is corrected, update the memory and keep only useful history.
21+
When you add, move, or remove memory, update the nearest index.
22+
Before answering from memory, read the relevant index or file instead of guessing;
23+
if memory is missing or uncertain, say so and verify when it matters.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Memory Index
2+
3+
- [Memory system definition](system/definition.md)
4+
- [Memories](memories/) - durable facts, people, projects, decisions
5+
- [Data](data/) - structured reference data

container/agent-runner/src/providers/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ export interface AgentProvider {
66
*/
77
readonly supportsNativeSlashCommands: boolean;
88

9+
/**
10+
* Optional. When true, the runner scaffolds a persistent `memory/` tree in the
11+
* agent's workspace at boot. Providers with their own native memory (e.g.
12+
* Claude's `CLAUDE.local.md`) omit this and get nothing — memory is opt-in per
13+
* provider, never gated on a provider name.
14+
*/
15+
readonly usesMemoryScaffold?: boolean;
16+
917
/** Start a new query. Returns a handle for streaming input and output. */
1018
query(input: QueryInput): AgentQuery;
1119

0 commit comments

Comments
 (0)