Skip to content

Commit f3ceeef

Browse files
committed
fix(memory): add config-memory prompt block to prevent SDK auto-include truncation (#90)
Mirror the working-memory.ts truncation pattern for phantom-config/memory/ files (heartbeat-log.md, presence-log.md, corrections.md, principles.md). Each file is capped at 100 lines with header + recent tail + compaction nudge, preventing the SDK from silently replacing large memory files with stubs at session start. Excludes agent-notes.md to preserve the existing architecture decision (agent reads its own writes via Read tool to avoid feedback loop). 10 new tests, all 1839 project tests pass.
1 parent 34c252a commit f3ceeef

3 files changed

Lines changed: 204 additions & 0 deletions

File tree

src/agent/prompt-assembler.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { existsSync } from "node:fs";
22
import { join } from "node:path";
33
import type { PhantomConfig } from "../config/types.ts";
44
import type { EvolvedConfig } from "../evolution/types.ts";
5+
import { getPhantomConfigMemoryRoot } from "../memory-files/paths.ts";
56
import type { RoleTemplate } from "../roles/types.ts";
67
import { buildAgentMemoryInstructions } from "./prompt-blocks/agent-memory-instructions.ts";
8+
import { buildConfigMemory } from "./prompt-blocks/config-memory.ts";
79
import { buildDashboardAwarenessLines } from "./prompt-blocks/dashboard-awareness.ts";
810
import { buildEvolvedSections } from "./prompt-blocks/evolved.ts";
911
import { buildInstructions } from "./prompt-blocks/instructions.ts";
@@ -69,6 +71,12 @@ export function assemblePrompt(
6971
sections.push(workingMemory);
7072
}
7173

74+
// 8.5. Config memory - phantom-config/memory/ files (agent-notes.md, corrections.md, etc.)
75+
const configMemory = buildConfigMemory(getPhantomConfigMemoryRoot());
76+
if (configMemory) {
77+
sections.push(configMemory);
78+
}
79+
7280
// 9. Memory context - what you remember (dynamic, changes per query)
7381
if (memoryContext) {
7482
sections.push(buildMemorySection(memoryContext));
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3+
import { join } from "node:path";
4+
import { buildConfigMemory } from "../config-memory.ts";
5+
6+
describe("buildConfigMemory", () => {
7+
const testDir = join(process.cwd(), "test-config-memory");
8+
9+
beforeEach(() => {
10+
if (existsSync(testDir)) {
11+
rmSync(testDir, { recursive: true, force: true });
12+
}
13+
mkdirSync(testDir, { recursive: true });
14+
});
15+
16+
afterEach(() => {
17+
if (existsSync(testDir)) {
18+
rmSync(testDir, { recursive: true, force: true });
19+
}
20+
});
21+
22+
test("returns empty string when directory does not exist", () => {
23+
const nonExistentDir = join(testDir, "does-not-exist");
24+
const result = buildConfigMemory(nonExistentDir);
25+
expect(result).toBe("");
26+
});
27+
28+
test("returns empty string when directory exists but has no known files", () => {
29+
const result = buildConfigMemory(testDir);
30+
expect(result).toBe("");
31+
});
32+
33+
test("returns empty string when files exist but are empty", () => {
34+
writeFileSync(join(testDir, "agent-notes.md"), "");
35+
writeFileSync(join(testDir, "corrections.md"), "");
36+
const result = buildConfigMemory(testDir);
37+
expect(result).toBe("");
38+
});
39+
40+
test("returns file content with heading when file is under MAX_LINES", () => {
41+
const content = "# Test Notes\n\nThis is a short note.\nAnother line.";
42+
writeFileSync(join(testDir, "corrections.md"), content);
43+
44+
const result = buildConfigMemory(testDir);
45+
46+
expect(result).toContain("# Config Memory Files");
47+
expect(result).toContain("## corrections.md");
48+
expect(result).toContain("This is a short note.");
49+
expect(result).toContain("Another line.");
50+
expect(result).not.toContain("was truncated");
51+
});
52+
53+
test("truncates file when over MAX_LINES with header + tail + compaction nudge", () => {
54+
const lines = ["# Header Line 1", "## Header Line 2", "### Header Line 3"];
55+
for (let i = 4; i <= 150; i++) {
56+
lines.push(`Line ${i}`);
57+
}
58+
const content = lines.join("\n");
59+
writeFileSync(join(testDir, "corrections.md"), content);
60+
61+
const result = buildConfigMemory(testDir);
62+
63+
expect(result).toContain("# Config Memory Files");
64+
expect(result).toContain("## corrections.md");
65+
expect(result).toContain("# Header Line 1");
66+
expect(result).toContain("## Header Line 2");
67+
expect(result).toContain("### Header Line 3");
68+
expect(result).toContain("<!-- corrections.md was truncated. Please compact this file. -->");
69+
expect(result).toContain("Line 150");
70+
// Middle lines should be omitted
71+
expect(result).not.toContain("Line 50");
72+
});
73+
74+
test("processes multiple files independently", () => {
75+
writeFileSync(join(testDir, "corrections.md"), "# Corrections\n\nCorrection 1");
76+
writeFileSync(join(testDir, "principles.md"), "# Principles\n\nPrinciple 1");
77+
78+
const result = buildConfigMemory(testDir);
79+
80+
expect(result).toContain("## corrections.md");
81+
expect(result).toContain("Correction 1");
82+
expect(result).toContain("## principles.md");
83+
expect(result).toContain("Principle 1");
84+
});
85+
86+
test("only reads known memory files, not arbitrary files", () => {
87+
writeFileSync(join(testDir, "corrections.md"), "# Known File\n\nThis should appear.");
88+
writeFileSync(join(testDir, "unknown-file.md"), "# Unknown File\n\nThis should NOT appear.");
89+
90+
const result = buildConfigMemory(testDir);
91+
92+
expect(result).toContain("corrections.md");
93+
expect(result).toContain("This should appear.");
94+
expect(result).not.toContain("unknown-file.md");
95+
expect(result).not.toContain("This should NOT appear.");
96+
});
97+
98+
test("skips files that cannot be read", () => {
99+
writeFileSync(join(testDir, "corrections.md"), "# Good File\n\nContent here.");
100+
// Create a file but then remove it to simulate read failure scenario
101+
const badPath = join(testDir, "principles.md");
102+
writeFileSync(badPath, "temp");
103+
rmSync(badPath);
104+
105+
const result = buildConfigMemory(testDir);
106+
107+
// Should still process the good file
108+
expect(result).toContain("corrections.md");
109+
expect(result).toContain("Content here.");
110+
// Should not fail, just skip the missing file
111+
expect(result).not.toContain("principles.md");
112+
});
113+
114+
test("handles all known memory files", () => {
115+
const knownFiles = ["corrections.md", "principles.md", "heartbeat-log.md", "presence-log.md"];
116+
117+
for (const fileName of knownFiles) {
118+
writeFileSync(join(testDir, fileName), `# ${fileName}\n\nTest content for ${fileName}`);
119+
}
120+
121+
const result = buildConfigMemory(testDir);
122+
123+
for (const fileName of knownFiles) {
124+
expect(result).toContain(`## ${fileName}`);
125+
expect(result).toContain(`Test content for ${fileName}`);
126+
}
127+
});
128+
129+
test("excludes agent-notes.md to avoid feedback loop", () => {
130+
writeFileSync(join(testDir, "agent-notes.md"), "# Agent Notes\n\nThis should NOT appear.");
131+
writeFileSync(join(testDir, "corrections.md"), "# Corrections\n\nThis should appear.");
132+
133+
const result = buildConfigMemory(testDir);
134+
135+
expect(result).not.toContain("agent-notes.md");
136+
expect(result).not.toContain("This should NOT appear.");
137+
expect(result).toContain("corrections.md");
138+
expect(result).toContain("This should appear.");
139+
});
140+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { existsSync, readFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
4+
// Known append-only memory files in phantom-config/memory/ that the agent
5+
// writes during evolution and that we need to truncate to avoid SDK auto-
6+
// include size budget truncation. Each file is processed independently and
7+
// returned as a subsection.
8+
//
9+
// NOTE: agent-notes.md is explicitly excluded. The agent reads its own
10+
// writes with the Read tool when needed, avoiding a feedback loop that
11+
// would re-present the agent's past entries as canonical context on every
12+
// query (see prompt-assembler.ts section 6b comment).
13+
const KNOWN_MEMORY_FILES = ["corrections.md", "principles.md", "heartbeat-log.md", "presence-log.md"];
14+
15+
// Reads memory files from phantom-config/memory/ and truncates each to
16+
// MAX_LINES with a compaction warning so unbounded append-only logs cannot
17+
// blow up the context window. Returns an empty string when no files exist.
18+
export function buildConfigMemory(configMemoryDir: string): string {
19+
const sections: string[] = [];
20+
const MAX_LINES = 100;
21+
22+
for (const fileName of KNOWN_MEMORY_FILES) {
23+
const filePath = join(configMemoryDir, fileName);
24+
try {
25+
if (!existsSync(filePath)) continue;
26+
const content = readFileSync(filePath, "utf-8").trim();
27+
if (!content) continue;
28+
29+
const lines = content.split("\n");
30+
let processedContent: string;
31+
32+
if (lines.length > MAX_LINES) {
33+
const header = lines.slice(0, 3);
34+
const recent = lines.slice(-(MAX_LINES - 5));
35+
const truncated = [
36+
...header,
37+
"",
38+
`<!-- ${fileName} was truncated. Please compact this file. -->`,
39+
"",
40+
...recent,
41+
].join("\n");
42+
processedContent = truncated;
43+
} else {
44+
processedContent = content;
45+
}
46+
47+
sections.push(`## ${fileName}\n\n${processedContent}`);
48+
} catch {
49+
// Skip files that cannot be read
50+
}
51+
}
52+
53+
if (sections.length === 0) return "";
54+
55+
return `# Config Memory Files\n\nThese files contain your learnings and observations from past sessions. They live in phantom-config/memory/ and grow over time.\n\n${sections.join("\n\n")}`;
56+
}

0 commit comments

Comments
 (0)