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

@@ -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("..");
}