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

@@ -0,0 +1,5 @@
---
"@gsxdsm/fusion": minor
---
Add agent memory file management support across core, API routes, and the dashboard Agent Memory tab, including listing, reading, and editing `.fusion/agent-memory/{agentId}` files.

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

View File

@@ -2659,6 +2659,25 @@ export function updateAgentMemory(agentId: string, memory: string, projectId?: s
});
}
/** List file-based memory entries for a specific agent */
export function fetchAgentMemoryFiles(agentId: string, projectId?: string): Promise<{ files: MemoryFileInfo[] }> {
return api<{ files: MemoryFileInfo[] }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory/files`, projectId));
}
/** Read one file-based memory entry for a specific agent */
export function fetchAgentMemoryFile(agentId: string, path: string, projectId?: string): Promise<{ path: string; content: string }> {
const query = `path=${encodeURIComponent(path)}`;
return api<{ path: string; content: string }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory/file?${query}`, projectId));
}
/** Save one file-based memory entry for a specific agent */
export function saveAgentMemoryFile(agentId: string, path: string, content: string, projectId?: string): Promise<{ success: boolean }> {
return api<{ success: boolean }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory/file`, projectId), {
method: "PUT",
body: JSON.stringify({ path, content }),
});
}
/** Update an agent's state */
export function updateAgentState(agentId: string, state: AgentState, projectId?: string): Promise<Agent> {
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/state`, projectId), {

View File

@@ -7,8 +7,8 @@ import {
} from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels } from "../api";
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels } from "../api";
import type { Agent } from "../api";
import type { AgentLogEntry, Task } from "@fusion/core";
import { AgentLogViewer } from "./AgentLogViewer";
@@ -89,6 +89,28 @@ const RUN_STATUS_ICONS: Record<string, { icon: typeof CheckCircle; color: string
terminated: { icon: Square, color: "var(--text-muted, #8b949e)" },
};
const MEMORY_LAYER_NAMES: Record<MemoryFileInfo["layer"], string> = {
"long-term": "Long-term",
daily: "Daily",
dreams: "Dreams",
};
const MEMORY_LAYER_DESCRIPTIONS: Record<MemoryFileInfo["layer"], string> = {
"long-term": "Curated durable decisions, conventions, constraints, and pitfalls for this specific agent.",
daily: "Raw daily observations and open loops recorded by this agent.",
dreams: "Synthesized patterns and emerging themes distilled from this agent's daily memory.",
};
function pickDefaultAgentMemoryPath(files: MemoryFileInfo[], currentPath: string): string {
if (files.some((file) => file.path === currentPath)) {
return currentPath;
}
return files.find((file) => file.layer === "long-term")?.path
?? files[0]?.path
?? "";
}
export function AgentDetailView({ agentId, projectId, onClose, addToast, onChildClick }: AgentDetailViewProps) {
const [agent, setAgent] = useState<AgentDetail | null>(null);
const [logs, setLogs] = useState<AgentLogEntry[]>([]);
@@ -1704,16 +1726,79 @@ function MemoryTab({
const [justSaved, setJustSaved] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [memoryFiles, setMemoryFiles] = useState<MemoryFileInfo[]>([]);
const [memoryFilesLoading, setMemoryFilesLoading] = useState(false);
const [selectedFilePath, setSelectedFilePath] = useState("");
const [selectedFileContent, setSelectedFileContent] = useState("");
const [selectedFileDirty, setSelectedFileDirty] = useState(false);
const [selectedFileLoading, setSelectedFileLoading] = useState(false);
const [savingSelectedFile, setSavingSelectedFile] = useState(false);
const [selectedFileJustSaved, setSelectedFileJustSaved] = useState(false);
const [fileSwitchHint, setFileSwitchHint] = useState("");
const isReadOnly = agent.state === "running";
const hasInlineChanges = memory !== (agent.memory ?? "");
const selectedMemoryFile = useMemo(
() => memoryFiles.find((file) => file.path === selectedFilePath),
[memoryFiles, selectedFilePath],
);
const selectedLayerDescription = selectedMemoryFile
? MEMORY_LAYER_DESCRIPTIONS[selectedMemoryFile.layer]
: "Select a memory file to view or edit.";
const loadSelectedMemoryFile = useCallback(async (path: string) => {
setSelectedFileLoading(true);
try {
const result = await fetchAgentMemoryFile(agent.id, path, projectId);
setSelectedFilePath(result.path);
setSelectedFileContent(result.content);
setSelectedFileDirty(false);
setSelectedFileJustSaved(false);
} catch (err: any) {
addToast(`Failed to load agent memory file: ${err.message}`, "error");
} finally {
setSelectedFileLoading(false);
}
}, [agent.id, projectId, addToast]);
const loadMemoryFiles = useCallback(async (preferredPath = "") => {
setMemoryFilesLoading(true);
try {
const { files } = await fetchAgentMemoryFiles(agent.id, projectId);
setMemoryFiles(files);
if (files.length === 0) {
setSelectedFilePath("");
setSelectedFileContent("");
setSelectedFileDirty(false);
return;
}
const nextPath = pickDefaultAgentMemoryPath(files, preferredPath);
await loadSelectedMemoryFile(nextPath);
} catch (err: any) {
addToast(`Failed to load memory files: ${err.message}`, "error");
setMemoryFiles([]);
setSelectedFilePath("");
setSelectedFileContent("");
setSelectedFileDirty(false);
} finally {
setMemoryFilesLoading(false);
}
}, [agent.id, projectId, addToast, loadSelectedMemoryFile]);
useEffect(() => {
setMemory(agent.memory ?? "");
setJustSaved(false);
setShowPreview(false);
}, [agent.id, agent.memory]);
setFileSwitchHint("");
setSelectedFileJustSaved(false);
void loadMemoryFiles();
}, [agent.id, agent.memory, loadMemoryFiles]);
const isReadOnly = agent.state === "running";
const hasChanges = memory !== (agent.memory ?? "");
const handleSave = async () => {
const handleSaveInlineMemory = async () => {
if (memory.length > 50000) {
addToast("Memory must be at most 50,000 characters", "error");
return;
@@ -1733,6 +1818,40 @@ function MemoryTab({
}
};
const handleSelectMemoryFile = async (path: string) => {
if (!path || path === selectedFilePath) {
return;
}
if (selectedFileDirty) {
setFileSwitchHint("Save the current file before switching to another file.");
return;
}
setFileSwitchHint("");
await loadSelectedMemoryFile(path);
};
const handleSaveSelectedMemoryFile = async () => {
if (!selectedFilePath) {
return;
}
setSavingSelectedFile(true);
try {
await saveAgentMemoryFile(agent.id, selectedFilePath, selectedFileContent, projectId);
setSelectedFileDirty(false);
setSelectedFileJustSaved(true);
setTimeout(() => setSelectedFileJustSaved(false), 3000);
setFileSwitchHint("");
await loadMemoryFiles(selectedFilePath);
addToast("Agent memory file saved", "success");
} catch (err: any) {
addToast(`Failed to save agent memory file: ${err.message}`, "error");
} finally {
setSavingSelectedFile(false);
}
};
return (
<div className="config-tab">
<div className="config-section">
@@ -1748,7 +1867,10 @@ function MemoryTab({
<div className="config-fields">
<div className="config-field">
<label htmlFor="agent-memory">Agent Memory</label>
<label htmlFor="agent-memory">Inline Memory</label>
<span className="config-hint" style={{ display: "block", marginBottom: 8 }}>
Short-form memory stored directly on the agent record and injected into prompts.
</span>
<div className="agent-content-toolbar">
<div className="agent-content-mode-toggle">
{!isReadOnly && (
@@ -1788,8 +1910,9 @@ function MemoryTab({
) : (
<textarea
id="agent-memory"
aria-label="Agent Memory"
className="input"
rows={15}
rows={10}
placeholder="Durable preferences, operating habits, and context this agent should carry across tasks..."
value={memory}
readOnly={isReadOnly}
@@ -1801,17 +1924,87 @@ function MemoryTab({
/>
)}
{!showPreview && (
<span className="config-hint">This is injected as Agent Memory in the prompt and kept separate from workspace Project Memory. Max 50,000 characters.</span>
<span className="config-hint">This is the inline memory field on the agent JSON record. Max 50,000 characters.</span>
)}
</div>
<div className="config-field">
<label htmlFor="agent-memory-file-select">Memory Files</label>
<span className="config-hint" style={{ display: "block", marginBottom: 8 }}>
Full OpenClaw memory files at <code>.fusion/agent-memory/{agent.id}/</code> (MEMORY.md, DREAMS.md, and daily notes).
</span>
<select
id="agent-memory-file-select"
className="select"
value={selectedFilePath}
disabled={memoryFilesLoading || selectedFileLoading || savingSelectedFile || memoryFiles.length === 0}
onChange={(e) => {
void handleSelectMemoryFile(e.target.value);
}}
>
{memoryFiles.length === 0 ? (
<option value="">No memory files found</option>
) : (
memoryFiles.map((file) => (
<option key={file.path} value={file.path}>
{MEMORY_LAYER_NAMES[file.layer]} {file.label}
</option>
))
)}
</select>
{memoryFilesLoading && (
<span className="config-hint" style={{ display: "inline-flex", gap: 6, marginTop: 8 }}>
<Loader2 size={14} className="animate-spin" />
Loading memory files
</span>
)}
{selectedMemoryFile && (
<div className="config-hint" style={{ marginTop: 8 }}>
<strong>{MEMORY_LAYER_NAMES[selectedMemoryFile.layer]}</strong> · {selectedLayerDescription}
<br />
{selectedMemoryFile.size.toLocaleString()} bytes · Updated {relativeTime(selectedMemoryFile.updatedAt)}
</div>
)}
<textarea
className="input"
rows={14}
placeholder="Select a memory file to view and edit its content..."
value={selectedFileContent}
readOnly={isReadOnly || !selectedFilePath || selectedFileLoading}
onChange={(e) => {
setSelectedFileContent(e.target.value);
setSelectedFileDirty(true);
setSelectedFileJustSaved(false);
setFileSwitchHint("");
}}
style={{ fontFamily: "monospace", fontSize: "0.875rem", resize: "vertical", marginTop: 8 }}
/>
{selectedFileLoading && (
<span className="config-hint" style={{ display: "inline-flex", gap: 6, marginTop: 8 }}>
<Loader2 size={14} className="animate-spin" />
Loading file content
</span>
)}
{fileSwitchHint && (
<span className="config-hint" style={{ display: "block", marginTop: 8 }}>
{fileSwitchHint}
</span>
)}
</div>
</div>
{!showPreview && (
<div className="config-actions">
<div className="config-actions">
{!showPreview && (
<button
className="btn btn--primary"
disabled={!hasChanges || isSaving || isReadOnly}
onClick={() => void handleSave()}
disabled={!hasInlineChanges || isSaving || isReadOnly}
onClick={() => void handleSaveInlineMemory()}
>
{isSaving ? (
<>
@@ -1825,14 +2018,37 @@ function MemoryTab({
</>
)}
</button>
{!hasChanges && justSaved && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
Memory saved
</span>
)}
<button
className="btn"
disabled={!selectedFileDirty || savingSelectedFile || !selectedFilePath || isReadOnly}
onClick={() => void handleSaveSelectedMemoryFile()}
>
{savingSelectedFile ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving file
</>
) : (
<>
<CheckCircle size={16} />
Save Memory File
</>
)}
</div>
)}
</button>
{!hasInlineChanges && justSaved && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
Memory saved
</span>
)}
{!selectedFileDirty && selectedFileJustSaved && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
Memory file saved
</span>
)}
</div>
</div>
</div>
);

View File

@@ -1,5 +1,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { mkdtempSync, readFileSync } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { request } from "../test-request.js";
type AgentRecord = {
@@ -22,8 +26,11 @@ const mockGetAgentsByReportsTo = vi.fn();
const mockListAgents = vi.fn().mockResolvedValue([]);
const mockChatStoreInit = vi.fn().mockResolvedValue(undefined);
vi.mock("@fusion/core", () => {
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
AgentStore: class MockAgentStore {
init = mockInit;
getAgent = mockGetAgent;
@@ -38,12 +45,16 @@ vi.mock("@fusion/core", () => {
});
class MockStore extends EventEmitter {
constructor(private readonly rootDir: string) {
super();
}
getRootDir(): string {
return "/tmp/fn-1171-test";
return this.rootDir;
}
getFusionDir(): string {
return "/tmp/fn-1171-test/.fusion";
return join(this.rootDir, ".fusion");
}
getDatabase() {
@@ -75,6 +86,7 @@ describe("Agent soul/memory routes", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
let agents: Map<string, AgentRecord>;
let tempDir: string;
beforeEach(async () => {
vi.clearAllMocks();
@@ -107,15 +119,24 @@ describe("Agent soul/memory routes", () => {
return Array.from(agents.values()).filter((agent) => agent.reportsTo === agentId);
});
store = new MockStore();
tempDir = mkdtempSync(join(tmpdir(), "fn-2150-agent-memory-routes-"));
await mkdir(join(tempDir, ".fusion"), { recursive: true });
store = new MockStore(tempDir);
const { createServer } = await import("../server.js");
app = createServer(store as any);
});
afterEach(() => {
afterEach(async () => {
vi.restoreAllMocks();
if (tempDir) {
await rm(tempDir, { recursive: true, force: true });
}
});
const agentMemoryPath = (agentId: string, fileName: string) => join(tempDir, ".fusion", "agent-memory", agentId, fileName);
const agentMemoryDisplayPath = (agentId: string, fileName: string) => `.fusion/agent-memory/${agentId}/${fileName}`;
it("GET /api/agents/:id/soul returns null when not set", async () => {
agents.set("agent-001", createAgent());
@@ -235,6 +256,131 @@ describe("Agent soul/memory routes", () => {
expect(missingPatchMemory.status).toBe(404);
});
it("GET /api/agents/:id/memory/files returns file list for existing agent", async () => {
agents.set("agent-001", createAgent());
const response = await request(app, "GET", "/api/agents/agent-001/memory/files");
expect(response.status).toBe(200);
expect((response.body as any).files).toEqual(expect.arrayContaining([
expect.objectContaining({
path: agentMemoryDisplayPath("agent-001", "MEMORY.md"),
layer: "long-term",
label: "Long-term memory",
}),
expect.objectContaining({
path: agentMemoryDisplayPath("agent-001", "DREAMS.md"),
layer: "dreams",
label: "Dreams",
}),
expect.objectContaining({
path: expect.stringMatching(/^\.fusion\/agent-memory\/agent-001\/\d{4}-\d{2}-\d{2}\.md$/),
layer: "daily",
}),
]));
});
it("GET /api/agents/:id/memory/files returns 404 for nonexistent agent", async () => {
const response = await request(app, "GET", "/api/agents/agent-missing/memory/files");
expect(response.status).toBe(404);
});
it("GET /api/agents/:id/memory/file?path=... returns file content", async () => {
agents.set("agent-001", createAgent());
const filePath = agentMemoryDisplayPath("agent-001", "MEMORY.md");
await mkdir(join(tempDir, ".fusion", "agent-memory", "agent-001"), { recursive: true });
await writeFile(agentMemoryPath("agent-001", "MEMORY.md"), "# Agent Memory\n\nRoute read test", "utf-8");
const response = await request(
app,
"GET",
`/api/agents/agent-001/memory/file?path=${encodeURIComponent(filePath)}`,
);
expect(response.status).toBe(200);
expect(response.body).toEqual({
path: filePath,
content: "# Agent Memory\n\nRoute read test",
});
});
it("GET /api/agents/:id/memory/file returns 400 without path param", async () => {
agents.set("agent-001", createAgent());
const response = await request(app, "GET", "/api/agents/agent-001/memory/file");
expect(response.status).toBe(400);
expect((response.body as any).error).toBe("path is required");
});
it("PUT /api/agents/:id/memory/file writes content successfully", async () => {
agents.set("agent-001", createAgent());
const path = agentMemoryDisplayPath("agent-001", "2026-04-19.md");
const content = "# Agent Daily Memory 2026-04-19\n\nSaved via route";
const response = await request(
app,
"PUT",
"/api/agents/agent-001/memory/file",
JSON.stringify({ path, content }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect(response.body).toEqual({ success: true });
expect(readFileSync(agentMemoryPath("agent-001", "2026-04-19.md"), "utf-8")).toBe(content);
});
it("PUT /api/agents/:id/memory/file returns 400 without path or content", async () => {
agents.set("agent-001", createAgent());
const missingPath = await request(
app,
"PUT",
"/api/agents/agent-001/memory/file",
JSON.stringify({ content: "x" }),
{ "content-type": "application/json" },
);
const missingContent = await request(
app,
"PUT",
"/api/agents/agent-001/memory/file",
JSON.stringify({ path: agentMemoryDisplayPath("agent-001", "MEMORY.md") }),
{ "content-type": "application/json" },
);
expect(missingPath.status).toBe(400);
expect((missingPath.body as any).error).toBe("path must be a string");
expect(missingContent.status).toBe(400);
expect((missingContent.body as any).error).toBe("content must be a string");
});
it("all agent memory file endpoints return 404 for nonexistent agent", async () => {
const listResponse = await request(app, "GET", "/api/agents/agent-missing/memory/files");
const getResponse = await request(
app,
"GET",
`/api/agents/agent-missing/memory/file?path=${encodeURIComponent(agentMemoryDisplayPath("agent-missing", "MEMORY.md"))}`,
);
const putResponse = await request(
app,
"PUT",
"/api/agents/agent-missing/memory/file",
JSON.stringify({
path: agentMemoryDisplayPath("agent-missing", "MEMORY.md"),
content: "missing",
}),
{ "content-type": "application/json" },
);
expect(listResponse.status).toBe(404);
expect(getResponse.status).toBe(404);
expect(putResponse.status).toBe(404);
});
it("GET /api/agents/:id/employees returns same payload as /children", async () => {
agents.set("agent-parent", createAgent({ id: "agent-parent", name: "Parent" }));
agents.set("agent-child-1", createAgent({ id: "agent-child-1", name: "Child One", reportsTo: "agent-parent" }));

View File

@@ -18,7 +18,7 @@ import * as nodeFs from "node:fs";
import { promisify } from "node:util";
import type { TaskStore, Column, ScheduleType, ActivityEventType, ModelPreset, MessageType, ParticipantType, RoutineTriggerType, ProjectSettings, EnrichedChatSession } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, type PiExtensionEntry, type PiExtensionSettings, getCurrentRepo, isGhAuthenticated, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupRoutine, exportSettings, importSettings, validateImportData, MessageStore, RoutineStore, isWebhookTrigger, resolveMemoryBackend, getMemoryBackendCapabilities, listMemoryBackendTypes, listProjectMemoryFiles, readProjectMemoryFile, readProjectMemoryFileContent, writeProjectMemoryFile, readMemory, writeMemory, searchProjectMemory, isQmdAvailable, installQmd, refreshQmdProjectMemoryIndex, QMD_INSTALL_COMMAND, MemoryBackendError, scheduleQmdProjectMemoryRefresh, discoverPiExtensions, updatePiExtensionDisabledIds, getFusionAgentDir, getLegacyPiAgentDir, ensureMemoryFileWithBackend, readInsightsMemory, writeInsightsMemory, generateMemoryAudit, buildInsightExtractionPrompt, parseInsightExtractionResponse, processAndAuditInsightExtraction } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, type PiExtensionEntry, type PiExtensionSettings, getCurrentRepo, isGhAuthenticated, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupRoutine, exportSettings, importSettings, validateImportData, MessageStore, RoutineStore, isWebhookTrigger, resolveMemoryBackend, getMemoryBackendCapabilities, listMemoryBackendTypes, listProjectMemoryFiles, readProjectMemoryFile, readProjectMemoryFileContent, writeProjectMemoryFile, listAgentMemoryFiles, readAgentMemoryFile, writeAgentMemoryFile, readMemory, writeMemory, searchProjectMemory, isQmdAvailable, installQmd, refreshQmdProjectMemoryIndex, QMD_INSTALL_COMMAND, MemoryBackendError, scheduleQmdProjectMemoryRefresh, discoverPiExtensions, updatePiExtensionDisabledIds, getFusionAgentDir, getLegacyPiAgentDir, ensureMemoryFileWithBackend, readInsightsMemory, writeInsightsMemory, generateMemoryAudit, buildInsightExtractionPrompt, parseInsightExtractionResponse, processAndAuditInsightExtraction } from "@fusion/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, parseBadgeUrl } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
@@ -12446,6 +12446,115 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* GET /api/agents/:id/memory/files
* Lists OpenClaw memory files for one agent.
*/
router.get("/agents/:id/memory/files", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agentId = req.params.id;
const agent = await agentStore.getAgent(agentId);
if (!agent) {
throw notFound("Agent not found");
}
const rootDir = scopedStore.getRootDir();
const files = await listAgentMemoryFiles(rootDir, agentId);
res.json({ files });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof MemoryBackendError) {
const status = err.code === "NOT_FOUND" ? 404 : err.code === "UNSUPPORTED" ? 400 : 500;
throw new ApiError(status, `Memory operation failed: ${err.message}`, { code: err.code, backend: err.backend });
}
rethrowAsApiError(err, "Failed to list agent memory files");
}
});
/**
* GET /api/agents/:id/memory/file?path=.fusion/agent-memory/:id/MEMORY.md
* Reads a validated agent memory file.
*/
router.get("/agents/:id/memory/file", async (req, res) => {
try {
const path = typeof req.query.path === "string" ? req.query.path : "";
if (!path) {
throw badRequest("path is required");
}
const { store: scopedStore } = await getProjectContext(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agentId = req.params.id;
const agent = await agentStore.getAgent(agentId);
if (!agent) {
throw notFound("Agent not found");
}
const rootDir = scopedStore.getRootDir();
const result = await readAgentMemoryFile(rootDir, agentId, path);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof MemoryBackendError) {
const status = err.code === "NOT_FOUND" ? 404 : err.code === "UNSUPPORTED" ? 400 : 500;
throw new ApiError(status, `Memory operation failed: ${err.message}`, { code: err.code, backend: err.backend });
}
rethrowAsApiError(err, "Failed to read agent memory file");
}
});
/**
* PUT /api/agents/:id/memory/file
* Writes one validated agent memory file.
*/
router.put("/agents/:id/memory/file", async (req, res) => {
try {
const { path, content } = req.body ?? {};
if (typeof path !== "string" || !path.trim()) {
throw badRequest("path must be a string");
}
if (typeof content !== "string") {
throw badRequest("content must be a string");
}
const { store: scopedStore } = await getProjectContext(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agentId = req.params.id;
const agent = await agentStore.getAgent(agentId);
if (!agent) {
throw notFound("Agent not found");
}
const rootDir = scopedStore.getRootDir();
const result = await writeAgentMemoryFile(rootDir, agentId, path, content);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof MemoryBackendError) {
const status = err.code === "NOT_FOUND" ? 404 : err.code === "UNSUPPORTED" ? 400 : 500;
throw new ApiError(status, `Memory operation failed: ${err.message}`, { code: err.code, backend: err.backend });
}
rethrowAsApiError(err, "Failed to save agent memory file");
}
});
/**
* POST /api/agents/:id/state
* Update agent state.