fix(FN-000): rebuild layered memory system
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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." })),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user