Skip to content
Open
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
8 changes: 8 additions & 0 deletions src/agent/prompt-assembler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { existsSync } from "node:fs";
import { join } from "node:path";
import type { PhantomConfig } from "../config/types.ts";
import type { EvolvedConfig } from "../evolution/types.ts";
import { getPhantomConfigMemoryRoot } from "../memory-files/paths.ts";
import type { RoleTemplate } from "../roles/types.ts";
import { buildAgentMemoryInstructions } from "./prompt-blocks/agent-memory-instructions.ts";
import { buildConfigMemory } from "./prompt-blocks/config-memory.ts";
import { buildDashboardAwarenessLines } from "./prompt-blocks/dashboard-awareness.ts";
import { buildEvolvedSections } from "./prompt-blocks/evolved.ts";
import { buildInstructions } from "./prompt-blocks/instructions.ts";
Expand Down Expand Up @@ -69,6 +71,12 @@ export function assemblePrompt(
sections.push(workingMemory);
}

// 8.5. Config memory - phantom-config/memory/ files (agent-notes.md, corrections.md, etc.)
const configMemory = buildConfigMemory(getPhantomConfigMemoryRoot());
if (configMemory) {
sections.push(configMemory);
}

// 9. Memory context - what you remember (dynamic, changes per query)
if (memoryContext) {
sections.push(buildMemorySection(memoryContext));
Expand Down
140 changes: 140 additions & 0 deletions src/agent/prompt-blocks/__tests__/config-memory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { buildConfigMemory } from "../config-memory.ts";

describe("buildConfigMemory", () => {
const testDir = join(process.cwd(), "test-config-memory");

beforeEach(() => {
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
mkdirSync(testDir, { recursive: true });
});

afterEach(() => {
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
});

test("returns empty string when directory does not exist", () => {
const nonExistentDir = join(testDir, "does-not-exist");
const result = buildConfigMemory(nonExistentDir);
expect(result).toBe("");
});

test("returns empty string when directory exists but has no known files", () => {
const result = buildConfigMemory(testDir);
expect(result).toBe("");
});

test("returns empty string when files exist but are empty", () => {
writeFileSync(join(testDir, "agent-notes.md"), "");
writeFileSync(join(testDir, "corrections.md"), "");
const result = buildConfigMemory(testDir);
expect(result).toBe("");
});

test("returns file content with heading when file is under MAX_LINES", () => {
const content = "# Test Notes\n\nThis is a short note.\nAnother line.";
writeFileSync(join(testDir, "corrections.md"), content);

const result = buildConfigMemory(testDir);

expect(result).toContain("# Config Memory Files");
expect(result).toContain("## corrections.md");
expect(result).toContain("This is a short note.");
expect(result).toContain("Another line.");
expect(result).not.toContain("was truncated");
});

test("truncates file when over MAX_LINES with header + tail + compaction nudge", () => {
const lines = ["# Header Line 1", "## Header Line 2", "### Header Line 3"];
for (let i = 4; i <= 150; i++) {
lines.push(`Line ${i}`);
}
const content = lines.join("\n");
writeFileSync(join(testDir, "corrections.md"), content);

const result = buildConfigMemory(testDir);

expect(result).toContain("# Config Memory Files");
expect(result).toContain("## corrections.md");
expect(result).toContain("# Header Line 1");
expect(result).toContain("## Header Line 2");
expect(result).toContain("### Header Line 3");
expect(result).toContain("<!-- corrections.md was truncated. Please compact this file. -->");
expect(result).toContain("Line 150");
// Middle lines should be omitted
expect(result).not.toContain("Line 50");
});

test("processes multiple files independently", () => {
writeFileSync(join(testDir, "corrections.md"), "# Corrections\n\nCorrection 1");
writeFileSync(join(testDir, "principles.md"), "# Principles\n\nPrinciple 1");

const result = buildConfigMemory(testDir);

expect(result).toContain("## corrections.md");
expect(result).toContain("Correction 1");
expect(result).toContain("## principles.md");
expect(result).toContain("Principle 1");
});

test("only reads known memory files, not arbitrary files", () => {
writeFileSync(join(testDir, "corrections.md"), "# Known File\n\nThis should appear.");
writeFileSync(join(testDir, "unknown-file.md"), "# Unknown File\n\nThis should NOT appear.");

const result = buildConfigMemory(testDir);

expect(result).toContain("corrections.md");
expect(result).toContain("This should appear.");
expect(result).not.toContain("unknown-file.md");
expect(result).not.toContain("This should NOT appear.");
});

test("skips files that cannot be read", () => {
writeFileSync(join(testDir, "corrections.md"), "# Good File\n\nContent here.");
// Create a file but then remove it to simulate read failure scenario
const badPath = join(testDir, "principles.md");
writeFileSync(badPath, "temp");
rmSync(badPath);

const result = buildConfigMemory(testDir);

// Should still process the good file
expect(result).toContain("corrections.md");
expect(result).toContain("Content here.");
// Should not fail, just skip the missing file
expect(result).not.toContain("principles.md");
});

test("handles all known memory files", () => {
const knownFiles = ["corrections.md", "principles.md", "heartbeat-log.md", "presence-log.md"];

for (const fileName of knownFiles) {
writeFileSync(join(testDir, fileName), `# ${fileName}\n\nTest content for ${fileName}`);
}

const result = buildConfigMemory(testDir);

for (const fileName of knownFiles) {
expect(result).toContain(`## ${fileName}`);
expect(result).toContain(`Test content for ${fileName}`);
}
});

test("excludes agent-notes.md to avoid feedback loop", () => {
writeFileSync(join(testDir, "agent-notes.md"), "# Agent Notes\n\nThis should NOT appear.");
writeFileSync(join(testDir, "corrections.md"), "# Corrections\n\nThis should appear.");

const result = buildConfigMemory(testDir);

expect(result).not.toContain("agent-notes.md");
expect(result).not.toContain("This should NOT appear.");
expect(result).toContain("corrections.md");
expect(result).toContain("This should appear.");
});
});
56 changes: 56 additions & 0 deletions src/agent/prompt-blocks/config-memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";

// Known append-only memory files in phantom-config/memory/ that the agent
// writes during evolution and that we need to truncate to avoid SDK auto-
// include size budget truncation. Each file is processed independently and
// returned as a subsection.
//
// NOTE: agent-notes.md is explicitly excluded. The agent reads its own
// writes with the Read tool when needed, avoiding a feedback loop that
// would re-present the agent's past entries as canonical context on every
// query (see prompt-assembler.ts section 6b comment).
const KNOWN_MEMORY_FILES = ["corrections.md", "principles.md", "heartbeat-log.md", "presence-log.md"];

// Reads memory files from phantom-config/memory/ and truncates each to
// MAX_LINES with a compaction warning so unbounded append-only logs cannot
// blow up the context window. Returns an empty string when no files exist.
export function buildConfigMemory(configMemoryDir: string): string {
const sections: string[] = [];
const MAX_LINES = 100;

for (const fileName of KNOWN_MEMORY_FILES) {
const filePath = join(configMemoryDir, fileName);
try {
if (!existsSync(filePath)) continue;
const content = readFileSync(filePath, "utf-8").trim();
if (!content) continue;

const lines = content.split("\n");
let processedContent: string;

if (lines.length > MAX_LINES) {
const header = lines.slice(0, 3);
const recent = lines.slice(-(MAX_LINES - 5));
const truncated = [
...header,
"",
`<!-- ${fileName} was truncated. Please compact this file. -->`,
"",
...recent,
].join("\n");
processedContent = truncated;
} else {
processedContent = content;
}

sections.push(`## ${fileName}\n\n${processedContent}`);
} catch {
// Skip files that cannot be read
}
}

if (sections.length === 0) return "";

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")}`;
}
Loading