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:
@@ -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), {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user