fix(FN-000): rebuild layered memory system

This commit is contained in:
gsxdsm
2026-04-17 07:36:22 -07:00
parent 4579e38bd6
commit 985b8a4eb5
24 changed files with 1835 additions and 116 deletions

View File

@@ -326,6 +326,30 @@ export function saveMemory(content: string, projectId?: string): Promise<{ succe
});
}
export interface MemoryFileInfo {
path: string;
label: string;
layer: "long-term" | "daily" | "dreams" | "legacy";
size: number;
updatedAt: string;
}
export function fetchMemoryFiles(projectId?: string): Promise<{ files: MemoryFileInfo[] }> {
return api<{ files: MemoryFileInfo[] }>(withProjectId("/memory/files", projectId));
}
export function fetchMemoryFile(path: string, projectId?: string): Promise<{ path: string; content: string }> {
const query = `path=${encodeURIComponent(path)}`;
return api<{ path: string; content: string }>(withProjectId(`/memory/file?${query}`, projectId));
}
export function saveMemoryFile(path: string, content: string, projectId?: string): Promise<{ success: boolean }> {
return api<{ success: boolean }>(withProjectId("/memory/file", projectId), {
method: "PUT",
body: JSON.stringify({ path, content }),
});
}
/**
* Compact memory content using AI to distill it down to the most important insights.
* Reads current memory, compacts it via AI, and writes the result back.

View File

@@ -18,8 +18,10 @@ const mockTestNtfyNotification = vi.fn();
const mockFetchBackups = vi.fn();
const mockCreateBackup = vi.fn();
const mockImportSettings = vi.fn();
const mockFetchMemory = vi.fn();
const mockSaveMemory = vi.fn();
const mockFetchMemoryFiles = vi.fn();
const mockFetchMemoryFile = vi.fn();
const mockSaveMemoryFile = vi.fn();
const mockCompactMemory = vi.fn();
const mockFetchGlobalConcurrency = vi.fn();
const mockUpdateGlobalConcurrency = vi.fn();
const mockFetchMemoryBackendStatus = vi.fn();
@@ -38,8 +40,10 @@ vi.mock("../api", () => ({
testNtfyNotification: (...args: unknown[]) => mockTestNtfyNotification(...args),
fetchBackups: (...args: unknown[]) => mockFetchBackups(...args),
createBackup: (...args: unknown[]) => mockCreateBackup(...args),
fetchMemory: (...args: unknown[]) => mockFetchMemory(...args),
saveMemory: (...args: unknown[]) => mockSaveMemory(...args),
fetchMemoryFiles: (...args: unknown[]) => mockFetchMemoryFiles(...args),
fetchMemoryFile: (...args: unknown[]) => mockFetchMemoryFile(...args),
saveMemoryFile: (...args: unknown[]) => mockSaveMemoryFile(...args),
compactMemory: (...args: unknown[]) => mockCompactMemory(...args),
fetchGlobalConcurrency: (...args: unknown[]) => mockFetchGlobalConcurrency(...args),
updateGlobalConcurrency: (...args: unknown[]) => mockUpdateGlobalConcurrency(...args),
fetchMemoryBackendStatus: (...args: unknown[]) => mockFetchMemoryBackendStatus(...args),
@@ -86,8 +90,27 @@ describe("SettingsModal", () => {
mockFetchAuthStatus.mockResolvedValue({ providers: [] });
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
mockFetchBackups.mockResolvedValue({ backups: [], totalSize: 0 });
mockFetchMemory.mockResolvedValue({ content: "## Existing memory\n- Learned pattern" });
mockSaveMemory.mockResolvedValue({ success: true });
mockFetchMemoryFiles.mockResolvedValue({
files: [
{
path: ".fusion/memory/MEMORY.md",
label: "Long-term memory",
layer: "long-term",
size: 42,
updatedAt: "2026-04-17T12:00:00.000Z",
},
{
path: ".fusion/memory/DREAMS.md",
label: "Dreams",
layer: "dreams",
size: 21,
updatedAt: "2026-04-17T12:00:00.000Z",
},
],
});
mockFetchMemoryFile.mockResolvedValue({ path: ".fusion/memory/MEMORY.md", content: "## Existing memory\n- Learned pattern" });
mockSaveMemoryFile.mockResolvedValue({ success: true });
mockCompactMemory.mockResolvedValue({ content: "# Compacted Memory\n\nImportant content." });
mockImportSettings.mockResolvedValue({ success: true, globalCount: 0, projectCount: 0 });
mockFetchGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} });
mockUpdateGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} });
@@ -387,21 +410,22 @@ describe("SettingsModal", () => {
expect(mockFetchSettings).toHaveBeenCalled();
});
expect(mockFetchMemory).not.toHaveBeenCalled();
expect(mockFetchMemoryFiles).not.toHaveBeenCalled();
await userEvent.click(screen.getByText("Memory"));
await waitFor(() => {
expect(mockFetchMemory).toHaveBeenCalledWith(undefined);
expect(mockFetchMemoryFiles).toHaveBeenCalledWith(undefined);
expect(mockFetchMemoryFile).toHaveBeenCalledWith(".fusion/memory/MEMORY.md", undefined);
});
const editor = await screen.findByLabelText("Editor for .fusion/memory.md") as HTMLTextAreaElement;
const editor = await screen.findByLabelText("Editor for .fusion/memory/MEMORY.md") as HTMLTextAreaElement;
expect(editor.value).toContain("Existing memory");
});
it("shows loading state while memory is being fetched", async () => {
let resolveMemory: ((value: { content: string }) => void) | undefined;
mockFetchMemory.mockReturnValueOnce(
mockFetchMemoryFile.mockReturnValueOnce(
new Promise<{ content: string }>((resolve) => {
resolveMemory = resolve;
})
@@ -420,7 +444,7 @@ describe("SettingsModal", () => {
resolveMemory?.({ content: "# Loaded" });
await waitFor(() => {
expect(screen.getByLabelText("Editor for .fusion/memory.md")).toBeDefined();
expect(screen.getByLabelText("Editor for .fusion/memory/MEMORY.md")).toBeDefined();
});
});
@@ -434,20 +458,24 @@ describe("SettingsModal", () => {
await userEvent.click(screen.getByText("Memory"));
const editor = await screen.findByLabelText("Editor for .fusion/memory.md");
const editor = await screen.findByLabelText("Editor for .fusion/memory/MEMORY.md");
fireEvent.change(editor, { target: { value: "# Updated memory\n- Reusable learning" } });
const saveButton = await screen.findByRole("button", { name: "Save Memory" });
await userEvent.click(saveButton);
await waitFor(() => {
expect(mockSaveMemory).toHaveBeenCalledWith("# Updated memory\n- Reusable learning", undefined);
expect(mockSaveMemoryFile).toHaveBeenCalledWith(
".fusion/memory/MEMORY.md",
"# Updated memory\n- Reusable learning",
undefined,
);
});
expect(addToast).toHaveBeenCalledWith("Memory saved", "success");
});
it("handles empty memory content from API", async () => {
mockFetchMemory.mockResolvedValueOnce({ content: "" });
mockFetchMemoryFile.mockResolvedValueOnce({ path: ".fusion/memory/MEMORY.md", content: "" });
renderModal();
await waitFor(() => {
@@ -456,9 +484,36 @@ describe("SettingsModal", () => {
await userEvent.click(screen.getByText("Memory"));
const editor = await screen.findByLabelText("Editor for .fusion/memory.md") as HTMLTextAreaElement;
const editor = await screen.findByLabelText("Editor for .fusion/memory/MEMORY.md") as HTMLTextAreaElement;
expect(editor.value).toBe("");
});
it("switches between memory files in the editor", async () => {
mockFetchMemoryFile.mockImplementation((path: string) =>
Promise.resolve({
path,
content: path.endsWith("DREAMS.md") ? "# Dreams\n\n- Pattern" : "# Memory\n\n- Durable",
}),
);
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Memory"));
const select = await screen.findByLabelText("Memory File");
await userEvent.selectOptions(select, ".fusion/memory/DREAMS.md");
await waitFor(() => {
expect(mockFetchMemoryFile).toHaveBeenCalledWith(".fusion/memory/DREAMS.md", undefined);
});
const editor = await screen.findByLabelText("Editor for .fusion/memory/DREAMS.md") as HTMLTextAreaElement;
expect(editor.value).toContain("Dreams");
});
});
describe("Experimental Features section", () => {

View File

@@ -2,8 +2,8 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { Globe, Folder } from "lucide-react";
import { THINKING_LEVELS, PROMPT_KEY_CATALOG, isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, PromptKey, AgentPromptsConfig } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemory, saveMemory, fetchGlobalConcurrency, updateGlobalConcurrency, compactMemory, fetchPiExtensions, updatePiExtensions } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryBackendCapabilities, PiExtensionSettings } from "../api";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, fetchGlobalConcurrency, updateGlobalConcurrency, compactMemory, fetchPiExtensions, updatePiExtensions } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryBackendCapabilities, MemoryFileInfo, PiExtensionSettings } from "../api";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import type { ToastType } from "../hooks/useToast";
import { ThemeSelector } from "./ThemeSelector";
@@ -178,6 +178,8 @@ export function SettingsModal({
const [memoryLoading, setMemoryLoading] = useState(false);
const [memoryDirty, setMemoryDirty] = useState(false);
const [compactLoading, setCompactLoading] = useState(false);
const [memoryFiles, setMemoryFiles] = useState<MemoryFileInfo[]>([]);
const [selectedMemoryPath, setSelectedMemoryPath] = useState(".fusion/memory/MEMORY.md");
// Global concurrency state
const [globalMaxConcurrent, setGlobalMaxConcurrent] = useState<number | undefined>(4);
@@ -266,8 +268,15 @@ export function SettingsModal({
let cancelled = false;
setMemoryLoading(true);
fetchMemory(projectId)
.then(({ content }) => {
fetchMemoryFiles(projectId)
.then(async ({ files }) => {
if (cancelled) return;
setMemoryFiles(files);
const nextPath = files.some((file) => file.path === selectedMemoryPath)
? selectedMemoryPath
: files[0]?.path || ".fusion/memory/MEMORY.md";
setSelectedMemoryPath(nextPath);
const { content } = await fetchMemoryFile(nextPath, projectId);
if (cancelled) return;
setMemoryContent(content);
setMemoryDirty(false);
@@ -286,7 +295,7 @@ export function SettingsModal({
return () => {
cancelled = true;
};
}, [activeSection, memoryDirty, projectId, addToast]);
}, [activeSection, memoryDirty, selectedMemoryPath, projectId, addToast]);
useEffect(() => {
if (activeSection === "authentication") {
@@ -828,18 +837,19 @@ export function SettingsModal({
const handleSaveMemory = useCallback(async () => {
try {
await saveMemory(memoryContent, projectId);
await saveMemoryFile(selectedMemoryPath, memoryContent, projectId);
setMemoryDirty(false);
addToast("Memory saved", "success");
} catch (err: any) {
addToast(err?.message || "Failed to save memory", "error");
}
}, [memoryContent, projectId, addToast]);
}, [selectedMemoryPath, memoryContent, projectId, addToast]);
const handleCompactMemory = useCallback(async () => {
setCompactLoading(true);
try {
const { content } = await compactMemory(projectId);
setSelectedMemoryPath(".fusion/memory/MEMORY.md");
setMemoryContent(content);
setMemoryDirty(true);
addToast("Memory compacted successfully", "success");
@@ -2135,15 +2145,61 @@ export function SettingsModal({
// Backend display names
const backendNames: Record<string, string> = {
file: "File (.fusion/memory.md)",
file: "Markdown files",
readonly: "Read-Only",
qmd: "QMD (Quantized Memory Distillation)",
qmd: "QMD search",
};
const backendDescriptions: Record<string, string> = {
file: "Stores MEMORY.md, daily notes, and DREAMS.md under .fusion/memory with built-in keyword search.",
qmd: "Uses qmd for memory_search when available, then falls back to the Markdown file search.",
readonly: "Allows read/search access without agent writes. Use this when memory is managed outside Fusion.",
};
const enabledCapabilities = [
capabilities?.readable ? "read" : null,
capabilities?.writable ? "write" : null,
capabilities?.supportsAtomicWrite ? "atomic writes" : null,
capabilities?.persistent ? "persistent" : null,
].filter(Boolean);
const selectedMemoryFile = memoryFiles.find((file) => file.path === selectedMemoryPath);
const memoryLayerNames: Record<MemoryFileInfo["layer"], string> = {
"long-term": "Long-term",
daily: "Daily",
dreams: "Dreams",
legacy: "Legacy",
};
return (
<>
{renderScopeBanner()}
<h4 className="settings-section-heading">Memory</h4>
<div className="memory-model-panel">
<div className="memory-model-panel__header">
<div>
<strong>Layered project memory</strong>
<p>
Agents search memory first, open bounded line windows only when needed, and append new durable notes instead of pasting the whole memory file into prompts.
</p>
</div>
<span className="memory-model-panel__badge">OpenClaw-style</span>
</div>
<div className="memory-layer-grid">
<div className="memory-layer-card">
<span className="memory-layer-card__label">Long-term</span>
<strong>.fusion/memory/MEMORY.md</strong>
<p>Curated decisions, conventions, constraints, and pitfalls.</p>
</div>
<div className="memory-layer-card">
<span className="memory-layer-card__label">Daily</span>
<strong>.fusion/memory/YYYY-MM-DD.md</strong>
<p>Fresh observations and open loops from active work.</p>
</div>
<div className="memory-layer-card">
<span className="memory-layer-card__label">Dreams</span>
<strong>.fusion/memory/DREAMS.md</strong>
<p>Periodic synthesis that promotes reusable lessons back into long-term memory.</p>
</div>
</div>
</div>
<div className="form-group">
<label htmlFor="memoryEnabled" className="checkbox-label">
<input
@@ -2156,10 +2212,9 @@ export function SettingsModal({
/>
Enable project memory
</label>
<small>When enabled, agents will consult and update .fusion/memory.md with durable project learnings</small>
<small>Agents get memory_search, memory_get, and memory_append tools, plus a pre-compaction memory flush when context recovery needs it.</small>
</div>
{/* Backend type selector */}
<div className="form-group">
<label htmlFor="memoryBackendType">Memory Backend</label>
<select
@@ -2180,10 +2235,9 @@ export function SettingsModal({
</option>
))}
</select>
<small>Choose how project memory is stored. File backend uses .fusion/memory.md. QMD enables advanced memory features.</small>
<small>{backendDescriptions[form.memoryBackendType || "file"] || "Custom backend registered by the engine."}</small>
</div>
{/* Backend capabilities info */}
{backendLoading ? (
<div className="form-group">
<small className="settings-muted">Loading backend status...</small>
@@ -2199,46 +2253,161 @@ export function SettingsModal({
{!isBackendWritable && " (read-only)"}
</small>
{isBackendWritable && capabilities && (
<small className="settings-muted" style={{ display: "block", marginTop: "2px" }}>
Supports: {capabilities.readable ? "read" : ""}{capabilities.readable && capabilities.writable ? ", " : ""}{capabilities.writable ? "write" : ""}{capabilities.supportsAtomicWrite ? ", atomic writes" : ""}{capabilities.persistent ? ", persistent" : ""}
<small className="settings-muted settings-subline">
Supports: {enabledCapabilities.join(", ")}
</small>
)}
</div>
) : null}
<div style={{ borderTop: "1px solid var(--border)", margin: "var(--space-lg) 0" }} />
<div className="form-group">
<small>This file stores durable project learnings that agents consult during triage and execution.</small>
<div className="memory-prompt-contract">
<strong>Agent prompt contract</strong>
<ul>
<li>Search first with memory_search; do not read all memory by default.</li>
<li>Open exact context with memory_get line windows.</li>
<li>Append durable findings to daily notes or long-term memory with memory_append.</li>
<li>On context overflow, compact prompt memory before full session compaction.</li>
</ul>
</div>
<div className="form-group">
<label htmlFor="memoryAutoSummarizeEnabled" className="checkbox-label">
<input
id="memoryAutoSummarizeEnabled"
type="checkbox"
checked={form.memoryAutoSummarizeEnabled === true}
onChange={(e) =>
setForm((f) => ({ ...f, memoryAutoSummarizeEnabled: e.target.checked }))
}
disabled={!isMemoryEnabled}
/>
Compact long-term memory automatically
</label>
<small>Checks MEMORY.md on a schedule and distills it when it crosses the configured size threshold.</small>
</div>
<div className="memory-settings-row">
<div className="form-group">
<label htmlFor="memoryAutoSummarizeThresholdChars">Compaction Threshold</label>
<input
id="memoryAutoSummarizeThresholdChars"
type="number"
min={1000}
step={1000}
value={form.memoryAutoSummarizeThresholdChars ?? 50000}
onChange={(e) =>
setForm((f) => ({
...f,
memoryAutoSummarizeThresholdChars: Number(e.target.value) || undefined,
}))
}
disabled={!isMemoryEnabled || form.memoryAutoSummarizeEnabled !== true}
/>
<small>Characters before scheduled compaction runs.</small>
</div>
<div className="form-group">
<label htmlFor="memoryAutoSummarizeSchedule">Compaction Schedule</label>
<input
id="memoryAutoSummarizeSchedule"
type="text"
value={form.memoryAutoSummarizeSchedule ?? "0 3 * * *"}
onChange={(e) =>
setForm((f) => ({ ...f, memoryAutoSummarizeSchedule: e.target.value }))
}
disabled={!isMemoryEnabled || form.memoryAutoSummarizeEnabled !== true}
/>
<small>Cron expression for long-term memory compaction.</small>
</div>
</div>
<div className="form-group">
<label htmlFor="memoryDreamsEnabled" className="checkbox-label">
<input
id="memoryDreamsEnabled"
type="checkbox"
checked={form.memoryDreamsEnabled === true}
onChange={(e) =>
setForm((f) => ({ ...f, memoryDreamsEnabled: e.target.checked }))
}
disabled={!isMemoryEnabled}
/>
Process dreams from daily memory
</label>
<small>Turns daily notes into DREAMS.md synthesis and promotes reusable lessons into MEMORY.md.</small>
</div>
<div className="form-group">
<label htmlFor="memoryDreamsSchedule">Dream Processing Schedule</label>
<input
id="memoryDreamsSchedule"
type="text"
value={form.memoryDreamsSchedule ?? "0 4 * * *"}
onChange={(e) =>
setForm((f) => ({ ...f, memoryDreamsSchedule: e.target.value }))
}
disabled={!isMemoryEnabled || form.memoryDreamsEnabled !== true}
/>
<small>Cron expression for daily memory synthesis.</small>
</div>
{/* Read-only state warnings */}
{!isMemoryEnabled && (
<div className="settings-empty-state" style={{ marginBottom: "var(--space-md)" }}>
<div className="settings-empty-state memory-status-message">
Memory is currently disabled. You can view the file, but editing is read-only until memory is re-enabled.
</div>
)}
{isMemoryEnabled && !isBackendWritable && (
<div className="settings-empty-state" style={{ marginBottom: "var(--space-md)" }}>
<div className="settings-empty-state memory-status-message">
The selected backend ({backendNames[currentBackend || "file"] || currentBackend || "file"}) is read-only.
You can view the file, but saving is disabled. Select a writable backend (File or QMD) to enable editing.
You can view the file, but saving is disabled. Select a writable backend to enable editing.
</div>
)}
{memoryLoading ? (
<div className="settings-empty-state">Loading memory…</div>
) : (
<div className="form-group">
<div
style={{
height: "400px",
border: "1px solid var(--border)",
borderRadius: "var(--radius)",
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
<div className="memory-editor-section">
<div className="form-group">
<label htmlFor="memoryFilePath">Memory File</label>
<select
id="memoryFilePath"
value={selectedMemoryPath}
onChange={(e) => {
setSelectedMemoryPath(e.target.value);
setMemoryDirty(false);
}}
disabled={memoryDirty}
>
{memoryFiles.map((file) => (
<option key={file.path} value={file.path}>
{file.label} - {file.path}
</option>
))}
</select>
<small>
{memoryDirty
? "Save or discard the current edits before switching files."
: "Choose any project memory layer to inspect or edit."}
</small>
</div>
{selectedMemoryFile && (
<div className="memory-file-summary">
<span>{memoryLayerNames[selectedMemoryFile.layer]}</span>
<strong>{selectedMemoryFile.path}</strong>
<small>
{selectedMemoryFile.size.toLocaleString()} bytes · updated {new Date(selectedMemoryFile.updatedAt).toLocaleString()}
</small>
</div>
)}
<div className="form-group">
<label>{selectedMemoryFile?.label || "Memory Editor"}</label>
<small>
{selectedMemoryFile?.layer === "long-term" && "Curated durable decisions, conventions, constraints, and pitfalls."}
{selectedMemoryFile?.layer === "daily" && "Raw daily observations, open loops, and running context for dream processing."}
{selectedMemoryFile?.layer === "dreams" && "Synthesized patterns and open loops promoted from daily memory."}
{selectedMemoryFile?.layer === "legacy" && "Compatibility mirror for older agents and tools."}
{!selectedMemoryFile && "Edits the selected memory file."}
</small>
<div className="memory-editor-frame">
<FileEditor
content={memoryContent}
onChange={(content) => {
@@ -2246,10 +2415,11 @@ export function SettingsModal({
setMemoryDirty(true);
}}
readOnly={!isEditingAllowed}
filePath=".fusion/memory.md"
filePath={selectedMemoryPath}
/>
</div>
</div>
</div>
)}
{memoryDirty && isEditingAllowed && (

View File

@@ -79,8 +79,19 @@ vi.mock("../../api", () => ({
createBackup: vi.fn(() => Promise.resolve({ success: true })),
exportSettings: vi.fn(() => Promise.resolve({ version: 1, exportedAt: new Date().toISOString(), global: undefined, project: {} })),
importSettings: vi.fn(() => Promise.resolve({ success: true, globalCount: 0, projectCount: 0 })),
fetchMemory: vi.fn(() => Promise.resolve({ content: "" })),
saveMemory: vi.fn(() => Promise.resolve({ success: true })),
fetchMemoryFiles: vi.fn(() => Promise.resolve({
files: [
{
path: ".fusion/memory/MEMORY.md",
label: "Long-term memory",
layer: "long-term",
size: 0,
updatedAt: "2026-04-17T12:00:00.000Z",
},
],
})),
fetchMemoryFile: vi.fn(() => Promise.resolve({ path: ".fusion/memory/MEMORY.md", content: "" })),
saveMemoryFile: vi.fn(() => Promise.resolve({ success: true })),
compactMemory: vi.fn(() => Promise.resolve({ content: "# Compacted Memory\n\nImportant content." })),
}));

View File

@@ -49,8 +49,19 @@ vi.mock("../../api", () => ({
createBackup: vi.fn(() => Promise.resolve({ success: true })),
exportSettings: vi.fn(() => Promise.resolve({ version: 1, exportedAt: new Date().toISOString(), global: undefined, project: {} })),
importSettings: vi.fn(() => Promise.resolve({ success: true, globalCount: 0, projectCount: 0 })),
fetchMemory: vi.fn(() => Promise.resolve({ content: "" })),
saveMemory: vi.fn(() => Promise.resolve({ success: true })),
fetchMemoryFiles: vi.fn(() => Promise.resolve({
files: [
{
path: ".fusion/memory/MEMORY.md",
label: "Long-term memory",
layer: "long-term",
size: 0,
updatedAt: "2026-04-17T12:00:00.000Z",
},
],
})),
fetchMemoryFile: vi.fn(() => Promise.resolve({ path: ".fusion/memory/MEMORY.md", content: "" })),
saveMemoryFile: vi.fn(() => Promise.resolve({ success: true })),
fetchGlobalConcurrency: vi.fn(() => Promise.resolve({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} })),
updateGlobalConcurrency: vi.fn(() => Promise.resolve({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} })),
fetchMemoryBackendStatus: vi.fn(() => Promise.resolve({

View File

@@ -3418,6 +3418,154 @@ body {
}
}
/* === Memory Settings === */
.memory-model-panel,
.memory-prompt-contract {
margin: var(--space-md) var(--space-xl) var(--space-lg);
padding: var(--space-lg);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
}
.memory-model-panel__header {
display: flex;
justify-content: space-between;
gap: var(--space-lg);
align-items: flex-start;
margin-bottom: var(--space-lg);
}
.memory-model-panel__header strong,
.memory-prompt-contract strong {
color: var(--text);
font-size: 14px;
}
.memory-model-panel__header p,
.memory-layer-card p {
margin: var(--space-xs) 0 0;
color: var(--text-muted);
font-size: 12px;
line-height: 1.5;
}
.memory-model-panel__badge,
.memory-layer-card__label {
display: inline-flex;
align-items: center;
border-radius: var(--radius-pill);
font-size: 11px;
font-weight: 600;
color: var(--color-info);
background: color-mix(in srgb, var(--color-info) 12%, transparent);
}
.memory-model-panel__badge {
padding: var(--space-xs) var(--space-sm);
white-space: nowrap;
}
.memory-layer-grid,
.memory-settings-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-md);
}
.memory-settings-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.memory-layer-card {
min-width: 0;
padding: var(--space-md);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
}
.memory-layer-card__label {
padding: var(--space-xs) var(--space-sm);
margin-bottom: var(--space-sm);
}
.memory-layer-card strong {
display: block;
overflow-wrap: anywhere;
color: var(--text);
font-family: var(--font-mono);
font-size: 12px;
}
.memory-prompt-contract ul {
margin: var(--space-sm) 0 0;
padding-left: var(--space-lg);
color: var(--text-muted);
font-size: 12px;
line-height: 1.6;
}
.settings-subline {
display: block;
margin-top: var(--space-xs);
}
.memory-status-message {
margin-bottom: var(--space-md);
}
.memory-editor-section {
margin-top: var(--space-lg);
}
.memory-file-summary {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-xs) var(--space-md);
align-items: center;
margin: 0 var(--space-xl) var(--space-md);
padding: var(--space-md);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
}
.memory-file-summary span {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-pill);
color: var(--color-info);
background: color-mix(in srgb, var(--color-info) 12%, transparent);
font-size: 11px;
font-weight: 600;
}
.memory-file-summary strong {
min-width: 0;
overflow-wrap: anywhere;
color: var(--text);
font-family: var(--font-mono);
font-size: 12px;
}
.memory-file-summary small {
grid-column: 2;
color: var(--text-muted);
font-size: 12px;
}
.memory-editor-frame {
min-height: 50vh;
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
display: flex;
flex-direction: column;
}
/* === Auth Provider Cards === */
.auth-section-hint {
padding: 12px 16px;
@@ -6941,6 +7089,38 @@ body {
padding: 12px 14px;
}
.memory-model-panel,
.memory-prompt-contract {
margin: var(--space-md) var(--space-lg) var(--space-lg);
}
.memory-model-panel__header {
flex-direction: column;
gap: var(--space-sm);
}
.memory-layer-grid,
.memory-settings-row {
grid-template-columns: 1fr;
}
.memory-editor-frame {
min-height: 45vh;
}
.memory-file-summary {
grid-template-columns: 1fr;
margin: 0 var(--space-lg) var(--space-md);
}
.memory-file-summary span {
justify-content: flex-start;
}
.memory-file-summary small {
grid-column: auto;
}
.settings-description {
padding: 0 14px;
}

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 } 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, readMemory, writeMemory, MemoryBackendError, discoverPiExtensions, updatePiExtensionDisabledIds, getFusionAgentDir, getLegacyPiAgentDir } 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, writeProjectMemoryFile, readMemory, writeMemory, MemoryBackendError, discoverPiExtensions, updatePiExtensionDisabledIds, getFusionAgentDir, getLegacyPiAgentDir } from "@fusion/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, parseBadgeUrl } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
@@ -2529,6 +2529,95 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* GET /api/memory/files
* Lists editable project memory files across the layered memory workspace.
*/
router.get("/memory/files", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const files = await listProjectMemoryFiles(rootDir);
res.json({ files });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to list memory files");
}
});
/**
* GET /api/memory/file?path=.fusion/memory/MEMORY.md
* Reads one validated memory file. Paths outside memory are rejected by core.
*/
router.get("/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 rootDir = scopedStore.getRootDir();
const result = await readProjectMemoryFile(rootDir, { path, lineCount: 400 });
res.json({ path: result.path, content: result.content });
} 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 memory file");
}
});
/**
* PUT /api/memory/file
* Writes one validated memory file. Read-only backends reject writes.
*/
router.put("/memory/file", async (req, res) => {
try {
const { path, content } = req.body ?? {};
if (typeof path !== "string" || !path) {
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 settings = await scopedStore.getSettings();
const backend = resolveMemoryBackend(settings);
if (!backend.capabilities.writable) {
throw new MemoryBackendError("READ_ONLY", "This backend is read-only and cannot write memory", backend.type);
}
const rootDir = scopedStore.getRootDir();
await writeProjectMemoryFile(rootDir, path, content);
res.json({ success: true });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof MemoryBackendError) {
const details = { code: err.code, backend: err.backend };
switch (err.code) {
case "READ_ONLY":
case "UNSUPPORTED":
case "CONFLICT":
throw new ApiError(409, `Memory operation failed: ${err.message}`, details);
case "NOT_FOUND":
throw new ApiError(404, `Memory operation failed: ${err.message}`, details);
default:
throw new ApiError(500, `Memory operation failed: ${err.message}`, details);
}
}
rethrowAsApiError(err, "Failed to save memory file");
}
});
// ── Memory Backend Routes ─────────────────────────────────────
/**