diff --git a/packages/engine/src/__tests__/agent-memory-index.test.ts b/packages/engine/src/__tests__/agent-memory-index.test.ts new file mode 100644 index 000000000..9b7f9cd9a --- /dev/null +++ b/packages/engine/src/__tests__/agent-memory-index.test.ts @@ -0,0 +1,72 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildMemoryIndex } from "../agent-memory-index.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))); + tempDirs.length = 0; +}); + +async function setupRoot(): Promise { + const dir = await mkdtemp(join(tmpdir(), "fn-memory-index-")); + tempDirs.push(dir); + return dir; +} + +describe("buildMemoryIndex", () => { + it("includes both agent and project sections when both files exist", async () => { + const root = await setupRoot(); + await mkdir(join(root, ".fusion", "agent-memory", "agent-1"), { recursive: true }); + await mkdir(join(root, ".fusion", "memory"), { recursive: true }); + await writeFile(join(root, ".fusion", "agent-memory", "agent-1", "MEMORY.md"), "## Habits\n\nAlways test first\n"); + await writeFile(join(root, ".fusion", "memory", "MEMORY.md"), "## Conventions\n\nUse pnpm\n"); + + const result = await buildMemoryIndex({ rootDir: root, agentId: "agent-1" }); + expect(result).toContain("## Agent Memory Index"); + expect(result).toContain(".fusion/agent-memory/agent-1/MEMORY.md"); + expect(result).toContain("\"Habits\" — Always test first"); + expect(result).toContain("## Project Memory Index"); + expect(result).toContain("\"Conventions\" — Use pnpm"); + }); + + it("includes only agent section when project memory is missing", async () => { + const root = await setupRoot(); + await mkdir(join(root, ".fusion", "agent-memory", "agent-1"), { recursive: true }); + await writeFile(join(root, ".fusion", "agent-memory", "agent-1", "MEMORY.md"), "## Preferences\n\nCompact output\n"); + + const result = await buildMemoryIndex({ rootDir: root, agentId: "agent-1" }); + expect(result).toContain("## Agent Memory Index"); + expect(result).not.toContain("## Project Memory Index"); + }); + + it("returns empty string when files are missing", async () => { + const root = await setupRoot(); + const result = await buildMemoryIndex({ rootDir: root, agentId: "agent-1" }); + expect(result).toBe(""); + }); + + it("truncates oversized output with ellipsis", async () => { + const root = await setupRoot(); + await mkdir(join(root, ".fusion", "agent-memory", "agent-1"), { recursive: true }); + await writeFile( + join(root, ".fusion", "agent-memory", "agent-1", "MEMORY.md"), + Array.from({ length: 200 }, (_, i) => `## Heading ${i}\n\nSummary ${i}\n`).join("\n"), + ); + + const result = await buildMemoryIndex({ rootDir: root, agentId: "agent-1" }); + expect(result.endsWith("…")).toBe(true); + }); + + it("keeps heading without descriptor when body is missing", async () => { + const root = await setupRoot(); + await mkdir(join(root, ".fusion", "agent-memory", "agent-1"), { recursive: true }); + await writeFile(join(root, ".fusion", "agent-memory", "agent-1", "MEMORY.md"), "## Empty Heading\n\n## Next\n\nline\n"); + + const result = await buildMemoryIndex({ rootDir: root, agentId: "agent-1" }); + expect(result).toContain(' - "Empty Heading"'); + }); +}); diff --git a/packages/engine/src/agent-memory-index.ts b/packages/engine/src/agent-memory-index.ts new file mode 100644 index 000000000..173c9dcce --- /dev/null +++ b/packages/engine/src/agent-memory-index.ts @@ -0,0 +1,83 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +type BuildMemoryIndexInput = { + rootDir: string; + agentId: string; +}; + +type HeadingEntry = { + heading: string; + summary?: string; +}; + +const MAX_INDEX_BYTES = 800; + +function clampUtf8(input: string, maxBytes: number): string { + if (Buffer.byteLength(input, "utf8") <= maxBytes) return input; + let out = ""; + for (const char of input) { + if (Buffer.byteLength(out + char, "utf8") > maxBytes) break; + out += char; + } + return out; +} + +function parseHeadings(content: string): HeadingEntry[] { + const lines = content.split(/\r?\n/); + const entries: HeadingEntry[] = []; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]?.trim() ?? ""; + if (!line.startsWith("## ")) continue; + + const heading = line.slice(3).trim(); + if (!heading) continue; + + let summary: string | undefined; + for (let j = i + 1; j < lines.length; j += 1) { + const candidate = (lines[j] ?? "").trim(); + if (!candidate) continue; + if (candidate.startsWith("## ")) break; + summary = candidate; + break; + } + entries.push({ heading, summary }); + } + + return entries; +} + +async function buildFileSection(sectionHeader: string, displayPath: string, fsPath: string): Promise { + try { + const content = await readFile(fsPath, "utf-8"); + const headings = parseHeadings(content); + const lines = [sectionHeader, `- ${displayPath}`]; + for (const entry of headings) { + const descriptor = entry.summary ? ` — ${entry.summary}` : ""; + lines.push(` - "${entry.heading}"${descriptor}`); + } + return `${lines.join("\n")}\n`; + } catch { + return ""; + } +} + +export async function buildMemoryIndex({ rootDir, agentId }: BuildMemoryIndexInput): Promise { + const safeAgentId = agentId.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "agent"; + const agentDisplayPath = `.fusion/agent-memory/${safeAgentId}/MEMORY.md`; + const agentFsPath = join(rootDir, ".fusion", "agent-memory", safeAgentId, "MEMORY.md"); + + const projectDisplayPath = ".fusion/memory/MEMORY.md"; + const projectFsPath = join(rootDir, ".fusion", "memory", "MEMORY.md"); + + const [agentSection, projectSection] = await Promise.all([ + buildFileSection("## Agent Memory Index (use fn_memory_search / fn_memory_get to read)", agentDisplayPath, agentFsPath), + buildFileSection("## Project Memory Index", projectDisplayPath, projectFsPath), + ]); + + const assembled = [agentSection, projectSection].filter(Boolean).join("\n").trim(); + if (!assembled) return ""; + if (Buffer.byteLength(assembled, "utf8") <= MAX_INDEX_BYTES) return assembled; + return `${clampUtf8(assembled, MAX_INDEX_BYTES - 1)}…`; +}