fix(FN-000): simplify qmd memory settings

This commit is contained in:
gsxdsm
2026-04-17 08:16:36 -07:00
parent 8a8f6bf938
commit 39cbfaba02
20 changed files with 576 additions and 511 deletions

View File

@@ -383,6 +383,27 @@ export interface MemoryBackendStatus {
capabilities: MemoryBackendCapabilities;
/** List of registered backend types available */
availableBackends: string[];
/** Whether the qmd CLI is available on PATH */
qmdAvailable?: boolean;
/** Suggested install command when qmd is unavailable */
qmdInstallCommand?: string;
}
export interface MemorySearchResult {
path: string;
lineStart: number;
lineEnd: number;
snippet: string;
score: number;
backend: string;
}
export interface MemoryRetrievalTestResult {
query: string;
qmdAvailable: boolean;
usedFallback: boolean;
qmdInstallCommand: string;
results: MemorySearchResult[];
}
/**
@@ -393,6 +414,13 @@ export function fetchMemoryBackendStatus(projectId?: string): Promise<MemoryBack
return api<MemoryBackendStatus>(withProjectId("/memory/backend", projectId));
}
export function testMemoryRetrieval(query: string, projectId?: string): Promise<MemoryRetrievalTestResult> {
return api<MemoryRetrievalTestResult>(withProjectId("/memory/test", projectId), {
method: "POST",
body: JSON.stringify({ query }),
});
}
/** Fetch global (user-level) settings from ~/.fusion/settings.json */
export function fetchGlobalSettings(): Promise<GlobalSettings> {
return api<GlobalSettings>("/settings/global");

View File

@@ -25,6 +25,7 @@ const mockCompactMemory = vi.fn();
const mockFetchGlobalConcurrency = vi.fn();
const mockUpdateGlobalConcurrency = vi.fn();
const mockFetchMemoryBackendStatus = vi.fn();
const mockTestMemoryRetrieval = vi.fn();
vi.mock("../api", () => ({
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
@@ -47,6 +48,7 @@ vi.mock("../api", () => ({
fetchGlobalConcurrency: (...args: unknown[]) => mockFetchGlobalConcurrency(...args),
updateGlobalConcurrency: (...args: unknown[]) => mockUpdateGlobalConcurrency(...args),
fetchMemoryBackendStatus: (...args: unknown[]) => mockFetchMemoryBackendStatus(...args),
testMemoryRetrieval: (...args: unknown[]) => mockTestMemoryRetrieval(...args),
}));
// Mock the hook
@@ -108,9 +110,23 @@ describe("SettingsModal", () => {
},
],
});
mockFetchMemoryFile.mockResolvedValue({ path: ".fusion/memory/MEMORY.md", content: "## Existing memory\n- Learned pattern" });
mockFetchMemoryFile.mockImplementation((path: string) =>
Promise.resolve({
path,
content: path.endsWith("DREAMS.md")
? "## Existing dreams\n- Pattern from daily notes"
: "## Existing memory\n- Learned pattern",
}),
);
mockSaveMemoryFile.mockResolvedValue({ success: true });
mockCompactMemory.mockResolvedValue({ content: "# Compacted Memory\n\nImportant content." });
mockTestMemoryRetrieval.mockResolvedValue({
query: "pattern",
qmdAvailable: true,
usedFallback: false,
qmdInstallCommand: "bun add -g qmd",
results: [],
});
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: {} });
@@ -124,8 +140,23 @@ describe("SettingsModal", () => {
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
qmdAvailable: true,
qmdInstallCommand: "bun add -g qmd",
});
mockUseMemoryBackendStatus.mockReturnValue({
status: {
currentBackend: "qmd",
capabilities: {
readable: true,
writable: true,
supportsAtomicWrite: false,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
qmdAvailable: true,
qmdInstallCommand: "bun add -g qmd",
},
currentBackend: "file",
capabilities: {
readable: true,
@@ -355,7 +386,7 @@ describe("SettingsModal", () => {
// Click the Memory section in the sidebar
await userEvent.click(screen.getByText("Memory"));
const checkbox = screen.getByRole("checkbox", { name: /enable project memory/i });
const checkbox = screen.getByRole("checkbox", { name: /enable memory tools/i });
expect(checkbox).toBeDefined();
// Default is enabled, so checkbox should be checked
expect(checkbox).toBeChecked();
@@ -376,7 +407,7 @@ describe("SettingsModal", () => {
// Click the Memory section in the sidebar
await userEvent.click(screen.getByText("Memory"));
const checkbox = screen.getByRole("checkbox", { name: /enable project memory/i });
const checkbox = screen.getByRole("checkbox", { name: /enable memory tools/i });
expect(checkbox).toBeDefined();
expect(checkbox).not.toBeChecked();
});
@@ -391,7 +422,7 @@ describe("SettingsModal", () => {
// Click the Memory section in the sidebar
await userEvent.click(screen.getByText("Memory"));
const checkbox = screen.getByRole("checkbox", { name: /enable project memory/i });
const checkbox = screen.getByRole("checkbox", { name: /enable memory tools/i });
expect(checkbox).toBeChecked();
// Uncheck it
@@ -416,11 +447,11 @@ describe("SettingsModal", () => {
await waitFor(() => {
expect(mockFetchMemoryFiles).toHaveBeenCalledWith(undefined);
expect(mockFetchMemoryFile).toHaveBeenCalledWith(".fusion/memory/MEMORY.md", undefined);
expect(mockFetchMemoryFile).toHaveBeenCalledWith(".fusion/memory/DREAMS.md", undefined);
});
const editor = await screen.findByLabelText("Editor for .fusion/memory/MEMORY.md") as HTMLTextAreaElement;
expect(editor.value).toContain("Existing memory");
const editor = await screen.findByLabelText("Editor for .fusion/memory/DREAMS.md") as HTMLTextAreaElement;
expect(editor.value).toContain("Existing dreams");
});
it("shows loading state while memory is being fetched", async () => {
@@ -444,7 +475,7 @@ describe("SettingsModal", () => {
resolveMemory?.({ content: "# Loaded" });
await waitFor(() => {
expect(screen.getByLabelText("Editor for .fusion/memory/MEMORY.md")).toBeDefined();
expect(screen.getByLabelText("Editor for .fusion/memory/DREAMS.md")).toBeDefined();
});
});
@@ -458,6 +489,13 @@ describe("SettingsModal", () => {
await userEvent.click(screen.getByText("Memory"));
await waitFor(() => {
expect(mockFetchMemoryFile).toHaveBeenCalledWith(".fusion/memory/DREAMS.md", undefined);
});
const select = await screen.findByLabelText("Memory File");
await userEvent.selectOptions(select, ".fusion/memory/MEMORY.md");
const editor = await screen.findByLabelText("Editor for .fusion/memory/MEMORY.md");
fireEvent.change(editor, { target: { value: "# Updated memory\n- Reusable learning" } });
@@ -475,7 +513,7 @@ describe("SettingsModal", () => {
});
it("handles empty memory content from API", async () => {
mockFetchMemoryFile.mockResolvedValueOnce({ path: ".fusion/memory/MEMORY.md", content: "" });
mockFetchMemoryFile.mockResolvedValueOnce({ path: ".fusion/memory/DREAMS.md", content: "" });
renderModal();
await waitFor(() => {
@@ -484,7 +522,7 @@ describe("SettingsModal", () => {
await userEvent.click(screen.getByText("Memory"));
const editor = await screen.findByLabelText("Editor for .fusion/memory/MEMORY.md") as HTMLTextAreaElement;
const editor = await screen.findByLabelText("Editor for .fusion/memory/DREAMS.md") as HTMLTextAreaElement;
expect(editor.value).toBe("");
});

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, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, fetchGlobalConcurrency, updateGlobalConcurrency, compactMemory, fetchPiExtensions, updatePiExtensions } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryBackendCapabilities, MemoryFileInfo, PiExtensionSettings } from "../api";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, fetchGlobalConcurrency, updateGlobalConcurrency, fetchPiExtensions, updatePiExtensions, testMemoryRetrieval } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryBackendCapabilities, MemoryFileInfo, MemoryRetrievalTestResult, PiExtensionSettings } from "../api";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import type { ToastType } from "../hooks/useToast";
import { ThemeSelector } from "./ThemeSelector";
@@ -56,6 +56,9 @@ type SettingsSection = {
isGroupHeader?: boolean;
};
const MOBILE_SETTINGS_MEDIA_QUERY = "(max-width: 768px)";
const DEFAULT_MEMORY_EDITOR_PATH = ".fusion/memory/DREAMS.md";
const SETTINGS_SECTIONS: SettingsSection[] = [
// Global group
{ id: "authentication", label: "Authentication", scope: undefined, icon: Globe },
@@ -140,6 +143,11 @@ export function SettingsModal({
// Find the first non-group-header section for default active section
const firstNonHeaderSection = SETTINGS_SECTIONS.find((s) => !s.isGroupHeader);
const [activeSection, setActiveSection] = useState<SectionId>(initialSection ?? firstNonHeaderSection?.id ?? "authentication");
const [showMobileSectionPicker, setShowMobileSectionPicker] = useState(() =>
typeof window !== "undefined" && typeof window.matchMedia === "function"
? window.matchMedia(MOBILE_SETTINGS_MEDIA_QUERY)?.matches === true
: false,
);
const [prefixError, setPrefixError] = useState<string | null>(null);
/** Get the scope of the currently active section */
@@ -177,9 +185,11 @@ export function SettingsModal({
const [memoryContent, setMemoryContent] = useState("");
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");
const [selectedMemoryPath, setSelectedMemoryPath] = useState(DEFAULT_MEMORY_EDITOR_PATH);
const [memoryTestQuery, setMemoryTestQuery] = useState("");
const [memoryTestLoading, setMemoryTestLoading] = useState(false);
const [memoryTestResult, setMemoryTestResult] = useState<MemoryRetrievalTestResult | null>(null);
// Global concurrency state
const [globalMaxConcurrent, setGlobalMaxConcurrent] = useState<number | undefined>(4);
@@ -195,14 +205,35 @@ export function SettingsModal({
// Memory backend status - called at component top level to comply with React Rules of Hooks
const {
currentBackend: memoryCurrentBackend,
status: memoryBackendStatus,
capabilities: memoryCapabilities,
availableBackends: memoryAvailableBackends,
loading: memoryBackendLoading,
error: memoryBackendError,
refresh: refreshMemoryBackend,
} = useMemoryBackendStatus({ projectId });
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return;
}
const mediaQuery = window.matchMedia(MOBILE_SETTINGS_MEDIA_QUERY);
if (!mediaQuery) {
return;
}
const updateMobilePicker = (event?: MediaQueryListEvent) => {
setShowMobileSectionPicker(event ? event.matches : mediaQuery.matches);
};
updateMobilePicker();
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", updateMobilePicker);
return () => mediaQuery.removeEventListener("change", updateMobilePicker);
}
mediaQuery.addListener(updateMobilePicker);
return () => mediaQuery.removeListener(updateMobilePicker);
}, []);
useEffect(() => {
// Load both merged and scoped settings to enable inheritance detection
Promise.all([fetchSettings(projectId), fetchSettingsByScope(projectId)])
@@ -274,7 +305,10 @@ export function SettingsModal({
setMemoryFiles(files);
const nextPath = files.some((file) => file.path === selectedMemoryPath)
? selectedMemoryPath
: files[0]?.path || ".fusion/memory/MEMORY.md";
: files.find((file) => file.path === DEFAULT_MEMORY_EDITOR_PATH)?.path
?? files.find((file) => file.layer === "dreams")?.path
?? files[0]?.path
?? DEFAULT_MEMORY_EDITOR_PATH;
setSelectedMemoryPath(nextPath);
const { content } = await fetchMemoryFile(nextPath, projectId);
if (cancelled) return;
@@ -845,20 +879,22 @@ export function SettingsModal({
}
}, [selectedMemoryPath, memoryContent, projectId, addToast]);
const handleCompactMemory = useCallback(async () => {
setCompactLoading(true);
const handleTestMemoryRetrieval = useCallback(async () => {
setMemoryTestLoading(true);
setMemoryTestResult(null);
try {
const { content } = await compactMemory(projectId);
setSelectedMemoryPath(".fusion/memory/MEMORY.md");
setMemoryContent(content);
setMemoryDirty(true);
addToast("Memory compacted successfully", "success");
const result = await testMemoryRetrieval(memoryTestQuery, projectId);
setMemoryTestResult(result);
addToast(
result.qmdAvailable ? "Memory retrieval test complete" : "qmd is not installed; local fallback was used",
result.qmdAvailable ? "success" : "warning",
);
} catch (err: any) {
addToast(err?.message || "Failed to compact memory", "error");
addToast(err?.message || "Failed to test memory retrieval", "error");
} finally {
setCompactLoading(false);
setMemoryTestLoading(false);
}
}, [projectId, addToast]);
}, [memoryTestQuery, projectId, addToast]);
const savePresetDraft = () => {
if (!presetDraft) return;
@@ -2125,15 +2161,13 @@ export function SettingsModal({
case "memory": {
// Use memory backend status from top-level hook call
const {
currentBackend,
capabilities,
availableBackends,
status: backendStatus,
loading: backendLoading,
error: backendError,
} = {
currentBackend: memoryCurrentBackend,
capabilities: memoryCapabilities,
availableBackends: memoryAvailableBackends,
status: memoryBackendStatus,
loading: memoryBackendLoading,
error: memoryBackendError,
};
@@ -2143,23 +2177,6 @@ export function SettingsModal({
const isBackendWritable = capabilities?.writable ?? true;
const isEditingAllowed = isMemoryEnabled && isBackendWritable;
// Backend display names
const backendNames: Record<string, string> = {
file: "Markdown files",
readonly: "Read-Only",
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",
@@ -2172,34 +2189,12 @@ export function SettingsModal({
<>
{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 className="form-group">
<small className="settings-muted">
Memory lives in <code>.fusion/memory/</code>. Agents search with qmd first, fall back to local files when qmd is missing, and open exact line windows only when needed.
</small>
</div>
<div className="form-group">
<label htmlFor="memoryEnabled" className="checkbox-label">
<input
@@ -2210,115 +2205,27 @@ export function SettingsModal({
setForm((f) => ({ ...f, memoryEnabled: e.target.checked }))
}
/>
Enable project memory
Enable memory tools
</label>
<small>Agents get memory_search, memory_get, and memory_append tools, plus a pre-compaction memory flush when context recovery needs it.</small>
</div>
<div className="form-group">
<label htmlFor="memoryBackendType">Memory Backend</label>
<select
id="memoryBackendType"
value={form.memoryBackendType || "file"}
onChange={(e) => {
const value = e.target.value;
setForm((f) => ({
...f,
memoryBackendType: value === "file" ? undefined : value,
}));
}}
disabled={!isMemoryEnabled}
>
{availableBackends.map((backend) => (
<option key={backend} value={backend}>
{backendNames[backend] || backend}
</option>
))}
</select>
<small>{backendDescriptions[form.memoryBackendType || "file"] || "Custom backend registered by the engine."}</small>
<small>Agents get memory_search, memory_get, and memory_append tools. Search defaults to qmd with a local file fallback.</small>
</div>
{backendLoading ? (
<div className="form-group">
<small className="settings-muted">Loading backend status...</small>
<small className="settings-muted">Checking memory write access...</small>
</div>
) : backendError ? (
<div className="form-group">
<small className="field-error">Failed to load backend status: {backendError}</small>
</div>
) : currentBackend ? (
<div className="form-group">
<small className="settings-muted">
Current backend: <strong>{backendNames[currentBackend] || currentBackend}</strong>
{!isBackendWritable && " (read-only)"}
</small>
{isBackendWritable && capabilities && (
<small className="settings-muted settings-subline">
Supports: {enabledCapabilities.join(", ")}
</small>
)}
</div>
) : null}
<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>
{backendStatus?.qmdAvailable === false && (
<div className="settings-empty-state memory-status-message">
qmd is not installed. Search will use local files.
Install indexed retrieval: <code>{backendStatus.qmdInstallCommand || "bun add -g qmd"}</code>
</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">
@@ -2333,21 +2240,69 @@ export function SettingsModal({
/>
Process dreams from daily memory
</label>
<small>Turns daily notes into DREAMS.md synthesis and promotes reusable lessons into MEMORY.md.</small>
<small>Turns daily notes into DREAMS.md 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>
{isMemoryEnabled && form.memoryDreamsEnabled === true && (
<div className="form-group">
<label htmlFor="memoryDreamsSchedule">Dream Schedule</label>
<input
id="memoryDreamsSchedule"
type="text"
value={form.memoryDreamsSchedule ?? "0 4 * * *"}
onChange={(e) =>
setForm((f) => ({ ...f, memoryDreamsSchedule: e.target.value }))
}
/>
<small>Cron expression for dream processing.</small>
</div>
)}
<div className="memory-retrieval-test">
<div className="form-group">
<label htmlFor="memoryRetrievalQuery">Test Retrieval</label>
<input
id="memoryRetrievalQuery"
type="text"
value={memoryTestQuery}
onChange={(e) => setMemoryTestQuery(e.target.value)}
placeholder="Search memory with qmd"
/>
<small>Runs the same qmd-backed memory_search path agents use.</small>
</div>
<div className="form-group">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={handleTestMemoryRetrieval}
disabled={memoryTestLoading}
>
{memoryTestLoading ? "Testing…" : "Test Retrieval"}
</button>
</div>
{memoryTestResult && (
<div className="memory-test-result">
<strong>
{memoryTestResult.results.length} result{memoryTestResult.results.length === 1 ? "" : "s"}
{" "}for "{memoryTestResult.query}"
</strong>
<small>
qmd {memoryTestResult.qmdAvailable ? "available" : "missing"} · {memoryTestResult.usedFallback ? "local fallback used" : "qmd path used"}
</small>
{memoryTestResult.results.length > 0 ? (
<ul>
{memoryTestResult.results.map((result, index) => (
<li key={`${result.path}-${result.lineStart}-${index}`}>
<span>{result.path}:{result.lineStart}</span>
<p>{result.snippet}</p>
</li>
))}
</ul>
) : (
<small>No matching memory found.</small>
)}
</div>
)}
</div>
{!isMemoryEnabled && (
@@ -2357,8 +2312,7 @@ export function SettingsModal({
)}
{isMemoryEnabled && !isBackendWritable && (
<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 to enable editing.
Memory is configured with a read-only backend. You can view the file, but saving is disabled.
</div>
)}
@@ -2386,7 +2340,7 @@ export function SettingsModal({
<small>
{memoryDirty
? "Save or discard the current edits before switching files."
: "Choose any project memory layer to inspect or edit."}
: "Choose any project memory file to view or edit. Dreams is selected by default."}
</small>
</div>
{selectedMemoryFile && (
@@ -2401,7 +2355,7 @@ export function SettingsModal({
<div className="form-group">
<label>{selectedMemoryFile?.label || "Memory Editor"}</label>
<small>
{selectedMemoryFile?.layer === "long-term" && "Curated durable decisions, conventions, constraints, and pitfalls."}
{selectedMemoryFile?.layer === "long-term" && "Curated durable decisions, conventions, constraints, and pitfalls promoted from dreams."}
{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."}
@@ -2438,19 +2392,6 @@ export function SettingsModal({
<small className="field-error">Cannot save: {isMemoryEnabled ? "Backend is read-only" : "Memory is disabled"}</small>
</div>
)}
{isEditingAllowed && (
<div className="form-group">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={handleCompactMemory}
disabled={compactLoading}
>
{compactLoading ? "Compacting…" : "Compact Memory"}
</button>
</div>
)}
</>
);
}
@@ -3145,6 +3086,23 @@ export function SettingsModal({
<div className="settings-empty-state settings-loading">Loading…</div>
) : (
<div className="settings-layout">
{showMobileSectionPicker && (
<div className="settings-mobile-section-picker">
<label htmlFor="settings-mobile-section">Settings Section</label>
<select
id="settings-mobile-section"
className="select touch-target"
value={activeSection}
onChange={(event) => setActiveSection(event.target.value as SectionId)}
>
{SETTINGS_SECTIONS.filter((section) => !section.isGroupHeader).map((section) => (
<option key={section.id} value={section.id}>
{section.label}
</option>
))}
</select>
</div>
)}
<nav className="settings-sidebar">
{SETTINGS_SECTIONS.map((section) => {
// Render group headers as non-clickable styled divs

View File

@@ -81,6 +81,13 @@ vi.mock("../../api", () => ({
importSettings: vi.fn(() => Promise.resolve({ success: true, globalCount: 0, projectCount: 0 })),
fetchMemoryFiles: vi.fn(() => Promise.resolve({
files: [
{
path: ".fusion/memory/DREAMS.md",
label: "Dreams",
layer: "dreams",
size: 0,
updatedAt: "2026-04-17T12:00:00.000Z",
},
{
path: ".fusion/memory/MEMORY.md",
label: "Long-term memory",
@@ -90,14 +97,34 @@ vi.mock("../../api", () => ({
},
],
})),
fetchMemoryFile: vi.fn(() => Promise.resolve({ path: ".fusion/memory/MEMORY.md", content: "" })),
fetchMemoryFile: vi.fn((path = ".fusion/memory/DREAMS.md") => Promise.resolve({ path, content: "" })),
saveMemoryFile: vi.fn(() => Promise.resolve({ success: true })),
compactMemory: vi.fn(() => Promise.resolve({ content: "# Compacted Memory\n\nImportant content." })),
testMemoryRetrieval: vi.fn(() => Promise.resolve({
query: "project memory",
qmdAvailable: true,
usedFallback: false,
qmdInstallCommand: "bun add -g qmd",
results: [],
})),
}));
// Mock useMemoryBackendStatus hook
vi.mock("../../hooks/useMemoryBackendStatus", () => ({
useMemoryBackendStatus: vi.fn(() => ({
status: {
currentBackend: "qmd",
capabilities: {
readable: true,
writable: true,
supportsAtomicWrite: false,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
qmdAvailable: true,
qmdInstallCommand: "bun add -g qmd",
},
currentBackend: "file",
capabilities: {
readable: true,
@@ -116,6 +143,19 @@ vi.mock("../../hooks/useMemoryBackendStatus", () => ({
// Mock useMemoryBackendStatus hook
vi.mock("../../hooks/useMemoryBackendStatus", () => ({
useMemoryBackendStatus: vi.fn(() => ({
status: {
currentBackend: "qmd",
capabilities: {
readable: true,
writable: true,
supportsAtomicWrite: false,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
qmdAvailable: true,
qmdInstallCommand: "bun add -g qmd",
},
currentBackend: "file",
capabilities: {
readable: true,
@@ -4030,128 +4070,17 @@ describe("Prompts section", () => {
});
});
describe("Memory section - Compact Memory", () => {
it("renders Compact Memory button in memory section", async () => {
describe("Memory section - file editor", () => {
it("shows the memory file selector and defaults to dreams", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Navigate to Memory section
const memorySection = screen.getByText("Memory");
fireEvent.click(memorySection);
fireEvent.click(screen.getByText("Memory"));
await waitFor(() => {
// Should show Compact Memory button
expect(screen.getByText("Compact Memory")).toBeInTheDocument();
});
});
it("calls compactMemory when Compact Memory button is clicked", async () => {
const { compactMemory } = await import("../../api");
(compactMemory as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
content: "# Compacted Memory\n\nImportant content.",
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Navigate to Memory section
const memorySection = screen.getByText("Memory");
fireEvent.click(memorySection);
await waitFor(() => {
// Click Compact Memory button
const compactBtn = screen.getByText("Compact Memory");
fireEvent.click(compactBtn);
});
await waitFor(() => {
expect(compactMemory).toHaveBeenCalled();
});
});
it("updates editor content after successful compaction", async () => {
const { compactMemory } = await import("../../api");
const newContent = "# Compacted Memory\n\nImportant content.";
(compactMemory as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
content: newContent,
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Navigate to Memory section
const memorySection = screen.getByText("Memory");
fireEvent.click(memorySection);
await waitFor(() => {
// Click Compact Memory button
const compactBtn = screen.getByText("Compact Memory");
fireEvent.click(compactBtn);
});
await waitFor(() => {
// Should show success toast
expect(addToast).toHaveBeenCalledWith("Memory compacted successfully", "success");
});
});
it("disables button while compacting", async () => {
let resolveCompact: (value: { content: string }) => void;
const { compactMemory } = await import("../../api");
(compactMemory as ReturnType<typeof vi.fn>).mockImplementation(
() => new Promise((resolve) => { resolveCompact = resolve; })
);
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Navigate to Memory section
const memorySection = screen.getByText("Memory");
fireEvent.click(memorySection);
await waitFor(() => {
// Click Compact Memory button
const compactBtn = screen.getByText("Compact Memory");
fireEvent.click(compactBtn);
});
await waitFor(() => {
// Button should show loading text
expect(screen.getByText("Compacting…")).toBeInTheDocument();
});
// Complete the promise
resolveCompact!({ content: "# Compacted Memory\n\nImportant content." });
await waitFor(() => {
// Button should be back to normal
expect(screen.getByText("Compact Memory")).toBeInTheDocument();
});
});
it("shows error toast when compaction fails", async () => {
const { compactMemory } = await import("../../api");
(compactMemory as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("AI service unavailable")
);
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Navigate to Memory section
const memorySection = screen.getByText("Memory");
fireEvent.click(memorySection);
await waitFor(() => {
// Click Compact Memory button
const compactBtn = screen.getByText("Compact Memory");
fireEvent.click(compactBtn);
});
await waitFor(() => {
// Should show error toast
expect(addToast).toHaveBeenCalledWith("AI service unavailable", "error");
});
const selector = await screen.findByLabelText("Memory File") as HTMLSelectElement;
expect(selector.value).toBe(".fusion/memory/DREAMS.md");
expect(screen.getByText(/Choose any project memory file to view or edit/i)).toBeInTheDocument();
expect(screen.queryByText("Compact Memory")).not.toBeInTheDocument();
});
});
});

View File

@@ -51,6 +51,13 @@ vi.mock("../../api", () => ({
importSettings: vi.fn(() => Promise.resolve({ success: true, globalCount: 0, projectCount: 0 })),
fetchMemoryFiles: vi.fn(() => Promise.resolve({
files: [
{
path: ".fusion/memory/DREAMS.md",
label: "Dreams",
layer: "dreams",
size: 0,
updatedAt: "2026-04-17T12:00:00.000Z",
},
{
path: ".fusion/memory/MEMORY.md",
label: "Long-term memory",
@@ -60,8 +67,15 @@ vi.mock("../../api", () => ({
},
],
})),
fetchMemoryFile: vi.fn(() => Promise.resolve({ path: ".fusion/memory/MEMORY.md", content: "" })),
fetchMemoryFile: vi.fn((path = ".fusion/memory/DREAMS.md") => Promise.resolve({ path, content: "" })),
saveMemoryFile: vi.fn(() => Promise.resolve({ success: true })),
testMemoryRetrieval: vi.fn(() => Promise.resolve({
query: "project memory",
qmdAvailable: true,
usedFallback: false,
qmdInstallCommand: "bun add -g qmd",
results: [],
})),
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({
@@ -74,11 +88,26 @@ vi.mock("../../api", () => ({
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
qmdAvailable: true,
qmdInstallCommand: "bun add -g qmd",
})),
}));
vi.mock("../../hooks/useMemoryBackendStatus", () => ({
useMemoryBackendStatus: vi.fn(() => ({
status: {
currentBackend: "qmd",
capabilities: {
readable: true,
writable: true,
supportsAtomicWrite: false,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
qmdAvailable: true,
qmdInstallCommand: "bun add -g qmd",
},
currentBackend: "file",
capabilities: {
readable: true,
@@ -96,6 +125,22 @@ vi.mock("../../hooks/useMemoryBackendStatus", () => ({
import { fetchSettings } from "../../api";
function mockSettingsViewport(matches: boolean): void {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -110,6 +155,7 @@ function expectMobileRule(css: string, selector: string, declaration: string): v
describe("SettingsModal mobile adaptations", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSettingsViewport(false);
});
it("renders mobile-targeted settings layout classes", async () => {
@@ -121,6 +167,18 @@ describe("SettingsModal mobile adaptations", () => {
expect(container.querySelector(".settings-content")).toBeTruthy();
});
it("can open memory settings from the mobile section picker", async () => {
mockSettingsViewport(true);
const user = userEvent.setup();
const { getByLabelText, findByText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
await user.selectOptions(getByLabelText("Settings Section"), "memory");
expect(await findByText(/Memory lives in/)).toBeTruthy();
expect(getByLabelText("Memory File")).toBeTruthy();
});
it("renders settings nav items with active class for touch styling", async () => {
const { container } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
@@ -172,11 +230,8 @@ describe("SettingsModal mobile adaptations", () => {
const css = fs.readFileSync(stylesPath, "utf-8");
expectMobileRule(css, ".settings-layout", "flex-direction: column;");
expectMobileRule(css, ".settings-sidebar", "flex-direction: row;");
expectMobileRule(css, ".settings-sidebar", "align-items: center;");
expectMobileRule(css, ".settings-sidebar", "overflow-x: auto;");
expectMobileRule(css, ".settings-sidebar", "scrollbar-width: none;");
expectMobileRule(css, ".settings-sidebar::-webkit-scrollbar", "display: none;");
expectMobileRule(css, ".settings-mobile-section-picker", "display: flex;");
expectMobileRule(css, ".settings-sidebar", "display: none;");
expectMobileRule(css, ".settings-nav-item", "display: flex;");
expectMobileRule(css, ".settings-nav-item", "align-items: center;");
expectMobileRule(css, ".settings-nav-item", "justify-content: center;");

View File

@@ -3282,6 +3282,10 @@ body {
overflow: hidden;
}
.settings-mobile-section-picker {
display: none;
}
.settings-sidebar {
width: 170px;
min-width: 170px;
@@ -3419,100 +3423,56 @@ 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-status-message {
margin-bottom: var(--space-md);
}
.memory-model-panel__header {
display: flex;
justify-content: space-between;
gap: var(--space-lg);
align-items: flex-start;
margin-bottom: var(--space-lg);
.memory-retrieval-test {
margin-top: 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;
.memory-test-result {
margin: 0 var(--space-xl) var(--space-lg);
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;
.memory-test-result strong,
.memory-test-result span {
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 {
.memory-test-result small {
display: block;
margin-top: var(--space-xs);
color: var(--text-muted);
font-size: 12px;
}
.memory-status-message {
margin-bottom: var(--space-md);
.memory-test-result ul {
margin: var(--space-md) 0 0;
padding: 0;
list-style: none;
}
.memory-test-result li {
padding-top: var(--space-sm);
border-top: var(--btn-border-width) solid var(--border);
}
.memory-test-result li + li {
margin-top: var(--space-sm);
}
.memory-test-result p {
margin: var(--space-xs) 0 0;
color: var(--text-muted);
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.memory-editor-section {
@@ -7005,27 +6965,38 @@ body {
flex-shrink: 0;
}
/* Settings modal: stack sidebar above content for mobile */
/* Settings modal: use the section picker as the only mobile navigation */
.settings-layout {
flex-direction: column;
min-height: 0;
}
.settings-sidebar {
width: auto;
min-width: 0;
flex-direction: row;
align-items: center;
border-right: none;
border-bottom: 1px solid var(--border);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding: 6px 8px;
gap: 2px;
.settings-mobile-section-picker {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-md) var(--space-lg);
border-bottom: var(--btn-border-width) solid var(--border);
background: var(--surface);
}
.settings-sidebar::-webkit-scrollbar {
.settings-mobile-section-picker label {
color: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
}
.settings-mobile-section-picker select {
width: 100%;
}
.settings-mobile-section-picker select:focus-visible {
outline: none;
border-color: var(--todo);
box-shadow: var(--focus-ring-strong);
}
.settings-sidebar {
display: none;
}
@@ -7089,21 +7060,6 @@ 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;
}

View File

@@ -12691,7 +12691,7 @@ describe("GET /api/memory/backend", () => {
});
});
it("defaults to file backend when no backend type is set", async () => {
it("defaults to qmd backend when no backend type is set", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
memoryEnabled: true,
});
@@ -12699,11 +12699,11 @@ describe("GET /api/memory/backend", () => {
const res = await GET(buildApp(), "/api/memory/backend");
expect(res.status).toBe(200);
expect(res.body.currentBackend).toBe("file");
expect(res.body.currentBackend).toBe("qmd");
});
it("returns file backend for unknown custom backend type (fallback)", async () => {
// Unknown backend types are persisted but fallback to file at runtime
it("returns qmd backend for unknown custom backend type (fallback)", async () => {
// Unknown backend types are persisted but fallback to qmd at runtime
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
memoryBackendType: "unknown-custom-backend",
memoryEnabled: true,
@@ -12712,8 +12712,8 @@ describe("GET /api/memory/backend", () => {
const res = await GET(buildApp(), "/api/memory/backend");
expect(res.status).toBe(200);
// currentBackend reflects the effective backend (file fallback)
expect(res.body.currentBackend).toBe("file");
// currentBackend reflects the effective backend (qmd fallback)
expect(res.body.currentBackend).toBe("qmd");
// But availableBackends is still the list of registered backends
expect(res.body.availableBackends).toContain("file");
expect(res.body.availableBackends).toContain("readonly");

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, listProjectMemoryFiles, readProjectMemoryFile, writeProjectMemoryFile, 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, searchProjectMemory, isQmdAvailable, QMD_INSTALL_COMMAND, 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";
@@ -2645,6 +2645,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
currentBackend: resolveMemoryBackend(settings).type,
capabilities,
availableBackends,
qmdAvailable: await isQmdAvailable(),
qmdInstallCommand: QMD_INSTALL_COMMAND,
});
} catch (err: unknown) {
if (err instanceof ApiError) {
@@ -2654,6 +2656,36 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
router.post("/memory/test", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
const rootDir = scopedStore.getRootDir();
const query = typeof req.body?.query === "string" && req.body.query.trim()
? req.body.query.trim()
: "project memory";
const qmdAvailable = await isQmdAvailable();
const results = await searchProjectMemory(
rootDir,
{ query, limit: 5 },
{ ...settings, memoryBackendType: "qmd" },
);
res.json({
query,
qmdAvailable,
usedFallback: !qmdAvailable,
qmdInstallCommand: QMD_INSTALL_COMMAND,
results,
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to test memory retrieval");
}
});
/**
* POST /api/memory/compact
* AI-powered memory compaction using the memory compaction service.