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:
72
packages/engine/src/__tests__/agent-memory-index.test.ts
Normal file
72
packages/engine/src/__tests__/agent-memory-index.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
83
packages/engine/src/agent-memory-index.ts
Normal file
83
packages/engine/src/agent-memory-index.ts
Normal 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)}…`;
|
||||
}
|
||||
Reference in New Issue
Block a user