diff --git a/src/agent/prompt-assembler.ts b/src/agent/prompt-assembler.ts index 1c13430..22a7c67 100644 --- a/src/agent/prompt-assembler.ts +++ b/src/agent/prompt-assembler.ts @@ -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"; @@ -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)); diff --git a/src/agent/prompt-blocks/__tests__/config-memory.test.ts b/src/agent/prompt-blocks/__tests__/config-memory.test.ts new file mode 100644 index 0000000..c52a176 --- /dev/null +++ b/src/agent/prompt-blocks/__tests__/config-memory.test.ts @@ -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(""); + 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."); + }); +}); diff --git a/src/agent/prompt-blocks/config-memory.ts b/src/agent/prompt-blocks/config-memory.ts new file mode 100644 index 0000000..007e9d3 --- /dev/null +++ b/src/agent/prompt-blocks/config-memory.ts @@ -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, + "", + ``, + "", + ...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")}`; +}