feat(FN-4393): complete Step 2 — add memory index builder

Fusion-Task-Id: FN-4393
Fusion-Task-Lineage: 3615215d-9caa-4402-9258-a5a5de137dd6
This commit is contained in:
Fusion
2026-05-13 23:02:22 -07:00
committed by gsxdsm
parent cb17dcf1a7
commit 86381e4ad5
2 changed files with 155 additions and 0 deletions

View File

@@ -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<string> {
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"');
});
});

View File

@@ -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<string> {
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<string> {
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)}`;
}