feat(FN-2150): add agent memory file management

- Add core helpers to list, read, and write .fusion/agent-memory/{agentId} files with strict path validation and exports
- Add dashboard API routes and client methods for agent memory file listing and single-file read/write operations
- Expand AgentDetailView memory tab with file selection, editing, save states, and inline validation feedback
- Add route/backend coverage for agent memory file endpoints and include a @gsxdsm/fusion minor changeset
This commit is contained in:
Fusion
2026-04-19 10:58:20 -07:00
committed by gsxdsm
parent 302367e5c4
commit 2b3f971fd5
8 changed files with 804 additions and 27 deletions

View File

@@ -463,6 +463,9 @@ export {
readProjectMemoryFile,
readProjectMemoryFileContent,
writeProjectMemoryFile,
listAgentMemoryFiles,
readAgentMemoryFile,
writeAgentMemoryFile,
} from "./memory-backend.js";
export {

View File

@@ -31,6 +31,9 @@ import {
listProjectMemoryFiles,
readProjectMemoryFile,
writeProjectMemoryFile,
listAgentMemoryFiles,
readAgentMemoryFile,
writeAgentMemoryFile,
} from "./memory-backend.js";
import type { MemoryBackend } from "./memory-backend.js";
@@ -41,6 +44,7 @@ describe("memory-backend", () => {
const legacyMemoryFile = "memory.md";
const legacyRequestPath = [".fusion", legacyMemoryFile].join("/");
const legacyMemoryPath = (rootDir: string) => join(rootDir, ".fusion", legacyMemoryFile);
const agentWorkspacePath = (rootDir: string, agentId: string) => join(rootDir, ".fusion", "agent-memory", agentId);
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), "kb-memory-backend-test-"));
@@ -285,6 +289,117 @@ describe("memory-backend", () => {
});
});
// ── Agent Memory File Functions ───────────────────────────────────
describe("agent memory file functions", () => {
const agentId = "agent-001";
const fixedDate = new Date("2026-04-19T12:00:00.000Z");
const workspaceDisplay = `.fusion/agent-memory/${agentId}`;
it("listAgentMemoryFiles seeds missing workspaces and returns default files", async () => {
const files = await listAgentMemoryFiles(tempDir, agentId, fixedDate);
expect(files).toHaveLength(3);
expect(files.map((file) => file.path)).toEqual([
`${workspaceDisplay}/MEMORY.md`,
`${workspaceDisplay}/2026-04-19.md`,
`${workspaceDisplay}/DREAMS.md`,
]);
});
it("listAgentMemoryFiles returns correct layers and labels", async () => {
const files = await listAgentMemoryFiles(tempDir, agentId, fixedDate);
const byPath = new Map(files.map((file) => [file.path, file]));
expect(byPath.get(`${workspaceDisplay}/MEMORY.md`)).toMatchObject({
layer: "long-term",
label: "Long-term memory",
});
expect(byPath.get(`${workspaceDisplay}/DREAMS.md`)).toMatchObject({
layer: "dreams",
label: "Dreams",
});
expect(byPath.get(`${workspaceDisplay}/2026-04-19.md`)).toMatchObject({
layer: "daily",
label: "Daily notes 2026-04-19",
});
});
it("readAgentMemoryFile reads existing file content", async () => {
const path = `${workspaceDisplay}/MEMORY.md`;
await writeAgentMemoryFile(tempDir, agentId, path, "# Agent Memory\n\nReadable content");
await expect(readAgentMemoryFile(tempDir, agentId, path)).resolves.toEqual({
path,
content: "# Agent Memory\n\nReadable content",
});
});
it("readAgentMemoryFile throws NOT_FOUND for valid missing files", async () => {
const path = `${workspaceDisplay}/2025-01-01.md`;
await expect(readAgentMemoryFile(tempDir, agentId, path)).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("readAgentMemoryFile rejects path traversal attempts", async () => {
await expect(readAgentMemoryFile(tempDir, agentId, "../../etc/passwd")).rejects.toMatchObject({
code: "UNSUPPORTED",
});
});
it("readAgentMemoryFile rejects absolute paths", async () => {
await expect(readAgentMemoryFile(tempDir, agentId, "/etc/passwd")).rejects.toMatchObject({
code: "UNSUPPORTED",
});
});
it("readAgentMemoryFile rejects unsupported filenames", async () => {
await expect(readAgentMemoryFile(tempDir, agentId, `${workspaceDisplay}/notes.txt`)).rejects.toMatchObject({
code: "UNSUPPORTED",
});
});
it("writeAgentMemoryFile writes content to a new daily file", async () => {
const path = `${workspaceDisplay}/2026-04-20.md`;
const content = "# Agent Daily Memory 2026-04-20\n\nFresh notes";
await expect(writeAgentMemoryFile(tempDir, agentId, path, content)).resolves.toEqual({ success: true });
expect(readFileSync(join(agentWorkspacePath(tempDir, agentId), "2026-04-20.md"), "utf-8")).toBe(content);
});
it("writeAgentMemoryFile overwrites existing content", async () => {
const path = `${workspaceDisplay}/DREAMS.md`;
await writeAgentMemoryFile(tempDir, agentId, path, "Original dreams");
await writeAgentMemoryFile(tempDir, agentId, path, "Updated dreams");
await expect(readAgentMemoryFile(tempDir, agentId, path)).resolves.toEqual({
path,
content: "Updated dreams",
});
});
it("writeAgentMemoryFile rejects path traversal attempts", async () => {
await expect(writeAgentMemoryFile(tempDir, agentId, "../../etc/passwd", "oops")).rejects.toMatchObject({
code: "UNSUPPORTED",
});
});
it("writeAgentMemoryFile rejects absolute paths", async () => {
await expect(writeAgentMemoryFile(tempDir, agentId, "/etc/passwd", "oops")).rejects.toMatchObject({
code: "UNSUPPORTED",
});
});
it("writeAgentMemoryFile rejects unsupported filenames", async () => {
await expect(writeAgentMemoryFile(tempDir, agentId, `${workspaceDisplay}/notes.txt`, "oops")).rejects.toMatchObject({
code: "UNSUPPORTED",
});
});
});
// ── ReadOnlyMemoryBackend ─────────────────────────────────────────
describe("ReadOnlyMemoryBackend", () => {

View File

@@ -21,6 +21,7 @@ export const QMD_INSTALL_COMMAND = "bun install -g @tobilu/qmd";
export const QMD_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
const DAILY_MEMORY_RE = /^\d{4}-\d{2}-\d{2}\.md$/;
const AGENT_MEMORY_WORKSPACE_PATH = ".fusion/agent-memory";
const MAX_MEMORY_SNIPPET_CHARS = 700;
const DEFAULT_MEMORY_GET_LINES = 120;
const MAX_MEMORY_GET_LINES = 400;
@@ -565,6 +566,19 @@ function getMemoryFileLabel(displayPath: string): string {
return `Daily notes ${basename(displayPath, ".md")}`;
}
function getAgentMemoryFileLayer(fileName: string): MemoryFileInfo["layer"] {
if (fileName === MEMORY_LONG_TERM_FILENAME) return "long-term";
if (fileName === MEMORY_DREAMS_FILENAME) return "dreams";
return "daily";
}
function getAgentMemoryFileLabel(fileName: string): string {
const layer = getAgentMemoryFileLayer(fileName);
if (layer === "long-term") return "Long-term memory";
if (layer === "dreams") return "Dreams";
return `Daily notes ${basename(fileName, ".md")}`;
}
export async function listProjectMemoryFiles(rootDir: string, date = new Date()): Promise<MemoryFileInfo[]> {
await ensureOpenClawMemoryFiles(rootDir, date);
const files = await listMemoryFiles(rootDir);
@@ -616,6 +630,156 @@ export async function writeProjectMemoryFile(rootDir: string, path: string, cont
return { success: true, backend: "file" };
}
export async function listAgentMemoryFiles(rootDir: string, agentId: string, date = new Date()): Promise<MemoryFileInfo[]> {
const { agentMemoryWorkspacePath, ensureAgentMemoryFiles } = await import("./memory-dreams.js");
await ensureAgentMemoryFiles(rootDir, { id: agentId, name: "", memory: "" }, date);
const workspacePath = agentMemoryWorkspacePath(rootDir, agentId);
const workspaceDisplayPath = relative(rootDir, workspacePath).replace(/\\/g, "/");
const files: Array<{ absPath: string; displayPath: string; fileName: string }> = [];
const longTermPath = join(workspacePath, MEMORY_LONG_TERM_FILENAME);
if (existsSync(longTermPath)) {
files.push({
absPath: longTermPath,
displayPath: `${workspaceDisplayPath}/${MEMORY_LONG_TERM_FILENAME}`,
fileName: MEMORY_LONG_TERM_FILENAME,
});
}
const dreamsPath = join(workspacePath, MEMORY_DREAMS_FILENAME);
if (existsSync(dreamsPath)) {
files.push({
absPath: dreamsPath,
displayPath: `${workspaceDisplayPath}/${MEMORY_DREAMS_FILENAME}`,
fileName: MEMORY_DREAMS_FILENAME,
});
}
if (existsSync(workspacePath)) {
for (const entry of await readdir(workspacePath)) {
if (!DAILY_MEMORY_RE.test(entry)) continue;
const absPath = join(workspacePath, entry);
const fileStat = await stat(absPath);
if (fileStat.isFile()) {
files.push({
absPath,
displayPath: `${workspaceDisplayPath}/${entry}`,
fileName: entry,
});
}
}
}
const uniqueFiles = Array.from(new Map(files.map((file) => [file.displayPath, file])).values());
const infos = await Promise.all(uniqueFiles.map(async (file) => {
const fileStat = await stat(file.absPath);
return {
path: file.displayPath,
label: getAgentMemoryFileLabel(file.fileName),
layer: getAgentMemoryFileLayer(file.fileName),
size: fileStat.size,
updatedAt: fileStat.mtime.toISOString(),
} satisfies MemoryFileInfo;
}));
const order: Record<MemoryFileInfo["layer"], number> = {
"long-term": 0,
daily: 1,
dreams: 2,
};
return infos.sort((a, b) => order[a.layer] - order[b.layer] || b.path.localeCompare(a.path));
}
async function resolveAgentMemoryFilePath(
rootDir: string,
agentId: string,
requestedPath: string,
): Promise<{ absPath: string; displayPath: string }> {
const { agentMemoryWorkspacePath } = await import("./memory-dreams.js");
const workspacePath = agentMemoryWorkspacePath(rootDir, agentId);
const workspaceDisplayPath = relative(rootDir, workspacePath).replace(/\\/g, "/");
const workspacePrefix = `${workspaceDisplayPath}/`;
const trimmed = requestedPath.trim();
if (!trimmed) {
throw new MemoryBackendError("NOT_FOUND", "Memory path is required", "file");
}
if (isAbsolute(trimmed) || isPathTraversal(trimmed)) {
throw new MemoryBackendError("UNSUPPORTED", "Memory paths must be workspace-relative", "file");
}
const normalized = normalize(trimmed).replace(/\\/g, "/");
const fileName = basename(normalized);
if (
fileName !== MEMORY_LONG_TERM_FILENAME
&& fileName !== MEMORY_DREAMS_FILENAME
&& !DAILY_MEMORY_RE.test(fileName)
) {
throw new MemoryBackendError(
"UNSUPPORTED",
`Memory path '${requestedPath}' is outside allowed files: ${AGENT_MEMORY_WORKSPACE_PATH}/{agentId}/MEMORY.md, ${AGENT_MEMORY_WORKSPACE_PATH}/{agentId}/DREAMS.md, ${AGENT_MEMORY_WORKSPACE_PATH}/{agentId}/YYYY-MM-DD.md`,
"file",
);
}
const displayPath = normalized === fileName
? `${workspaceDisplayPath}/${fileName}`
: normalized;
if (!displayPath.startsWith(workspacePrefix)) {
throw new MemoryBackendError(
"UNSUPPORTED",
`Memory path '${requestedPath}' must be within ${workspaceDisplayPath}/`,
"file",
);
}
const absPath = resolve(rootDir, displayPath);
const rel = relative(rootDir, absPath);
if (!rel || rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
throw new MemoryBackendError("UNSUPPORTED", "Memory path escapes project root", "file");
}
const relToWorkspace = relative(workspacePath, absPath);
if (!relToWorkspace || relToWorkspace.startsWith(`..${sep}`) || relToWorkspace === ".." || isAbsolute(relToWorkspace)) {
throw new MemoryBackendError("UNSUPPORTED", "Memory path escapes agent memory workspace", "file");
}
const relToWorkspaceNormalized = relToWorkspace.replace(/\\/g, "/");
if (relToWorkspaceNormalized.includes("/")) {
throw new MemoryBackendError("UNSUPPORTED", "Agent memory paths must not include subdirectories", "file");
}
return { absPath, displayPath };
}
export async function readAgentMemoryFile(rootDir: string, agentId: string, path: string): Promise<{ path: string; content: string }> {
const { absPath, displayPath } = await resolveAgentMemoryFilePath(rootDir, agentId, path);
try {
const content = await readFile(absPath, "utf-8");
return { path: displayPath, content };
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw new MemoryBackendError("NOT_FOUND", `Memory path '${path}' not found`, "file");
}
throw new MemoryBackendError("READ_FAILED", `Failed to read memory path '${path}': ${(err as Error).message}`, "file");
}
}
export async function writeAgentMemoryFile(rootDir: string, agentId: string, path: string, content: string): Promise<{ success: boolean }> {
const { absPath } = await resolveAgentMemoryFilePath(rootDir, agentId, path);
await mkdir(dirname(absPath), { recursive: true });
const tmpPath = `${absPath}.tmp`;
await writeFile(tmpPath, content, "utf-8");
const { rename } = await import("node:fs/promises");
await rename(tmpPath, absPath);
return { success: true };
}
function isPathTraversal(path: string): boolean {
return path.split(/[\\/]+/).includes("..");
}