fix(FN-000): simplify qmd memory settings
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user