feat(FN-2049): expand memory view with multi-file editing and retrieval controls
- Extend useMemoryData with multi-file memory documents, settings state, and retrieval test helpers - Upgrade MemoryView working memory tab to manage multiple files with file switching and editor actions - Add configuration controls for dreams mode and auto-summarize behavior in memory settings - Integrate qmd retrieval testing in the engines tab and surface retrieval results in dedicated cards - Add styles for memory settings panels and retrieval result cards to match dashboard UI patterns
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { MemoryFileInfo, MemoryRetrievalTestResult } from "../api";
|
||||
import { FileEditor } from "./FileEditor";
|
||||
import { useMemoryData } from "../hooks/useMemoryData";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface MemoryViewProps {
|
||||
projectId?: string;
|
||||
@@ -19,6 +20,20 @@ const CATEGORY_HEADERS: Record<string, string> = {
|
||||
"Context": "context",
|
||||
};
|
||||
|
||||
const MEMORY_LAYER_NAMES: Record<MemoryFileInfo["layer"], string> = {
|
||||
"long-term": "Long-term",
|
||||
daily: "Daily",
|
||||
dreams: "Dreams",
|
||||
legacy: "Legacy",
|
||||
};
|
||||
|
||||
const MEMORY_LAYER_DESCRIPTIONS: Record<MemoryFileInfo["layer"], string> = {
|
||||
"long-term": "Curated durable decisions, conventions, constraints, and pitfalls promoted from dreams.",
|
||||
daily: "Raw daily observations, open loops, and running context for dream processing.",
|
||||
dreams: "Synthesized patterns and open loops promoted from daily memory.",
|
||||
legacy: "Compatibility mirror for older agents and tools.",
|
||||
};
|
||||
|
||||
interface ParsedInsightCategory {
|
||||
name: string;
|
||||
key: string;
|
||||
@@ -107,19 +122,27 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||
const [editingInsights, setEditingInsights] = useState(false);
|
||||
const [insightsEditorContent, setInsightsEditorContent] = useState<string | null>(null);
|
||||
const [memorySettingsDraft, setMemorySettingsDraft] = useState({
|
||||
memoryEnabled: true,
|
||||
memoryAutoSummarizeEnabled: false,
|
||||
memoryAutoSummarizeThresholdChars: 50_000,
|
||||
memoryAutoSummarizeSchedule: "0 3 * * *",
|
||||
memoryDreamsEnabled: false,
|
||||
memoryDreamsSchedule: "0 4 * * *",
|
||||
});
|
||||
const [memoryTestQuery, setMemoryTestQuery] = useState("");
|
||||
const [memoryTestLoading, setMemoryTestLoading] = useState(false);
|
||||
const [memoryTestResult, setMemoryTestResult] = useState<MemoryRetrievalTestResult | null>(null);
|
||||
|
||||
const {
|
||||
workingMemory,
|
||||
workingMemoryLoading,
|
||||
workingMemoryDirty,
|
||||
setWorkingMemory,
|
||||
saveWorkingMemory,
|
||||
savingWorkingMemory,
|
||||
insightsContent,
|
||||
insightsLoading,
|
||||
insightsExists,
|
||||
refreshInsights,
|
||||
saveInsights,
|
||||
memorySettings,
|
||||
settingsLoading,
|
||||
saveMemorySettings,
|
||||
savingMemorySettings,
|
||||
backendStatus,
|
||||
backendLoading,
|
||||
extractInsights,
|
||||
@@ -129,22 +152,57 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
refreshAudit,
|
||||
compactMemory,
|
||||
compacting,
|
||||
installQmdAction,
|
||||
installingQmd,
|
||||
testRetrieval,
|
||||
memoryFiles,
|
||||
memoryFilesLoading,
|
||||
selectedFilePath,
|
||||
selectedFileContent,
|
||||
selectedFileLoading,
|
||||
selectedFileDirty,
|
||||
setSelectedFileContent,
|
||||
selectFile,
|
||||
saveSelectedFile,
|
||||
savingSelectedFile,
|
||||
} = useMemoryData({ projectId });
|
||||
|
||||
useEffect(() => {
|
||||
setMemorySettingsDraft(memorySettings);
|
||||
}, [memorySettings]);
|
||||
|
||||
const memorySettingsDirty = useMemo(() => (
|
||||
memorySettingsDraft.memoryEnabled !== memorySettings.memoryEnabled
|
||||
|| memorySettingsDraft.memoryAutoSummarizeEnabled !== memorySettings.memoryAutoSummarizeEnabled
|
||||
|| memorySettingsDraft.memoryAutoSummarizeThresholdChars !== memorySettings.memoryAutoSummarizeThresholdChars
|
||||
|| memorySettingsDraft.memoryAutoSummarizeSchedule !== memorySettings.memoryAutoSummarizeSchedule
|
||||
|| memorySettingsDraft.memoryDreamsEnabled !== memorySettings.memoryDreamsEnabled
|
||||
|| memorySettingsDraft.memoryDreamsSchedule !== memorySettings.memoryDreamsSchedule
|
||||
), [memorySettingsDraft, memorySettings]);
|
||||
|
||||
const selectedMemoryFile = useMemo(
|
||||
() => memoryFiles.find((file) => file.path === selectedFilePath),
|
||||
[memoryFiles, selectedFilePath],
|
||||
);
|
||||
|
||||
const selectedLayerDescription = selectedMemoryFile
|
||||
? MEMORY_LAYER_DESCRIPTIONS[selectedMemoryFile.layer]
|
||||
: "Edits the selected memory file.";
|
||||
|
||||
// Parse insights content
|
||||
const parsedCategories = useMemo(
|
||||
() => parseInsightsContent(insightsContent),
|
||||
[insightsContent]
|
||||
[insightsContent],
|
||||
);
|
||||
|
||||
const totalInsights = useMemo(
|
||||
() => countTotalInsights(parsedCategories),
|
||||
[parsedCategories]
|
||||
[parsedCategories],
|
||||
);
|
||||
|
||||
const lastUpdated = useMemo(
|
||||
() => parseLastUpdated(insightsContent),
|
||||
[insightsContent]
|
||||
[insightsContent],
|
||||
);
|
||||
|
||||
// Toggle category expansion
|
||||
@@ -160,25 +218,96 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle save working memory
|
||||
const handleSaveWorkingMemory = useCallback(async () => {
|
||||
const handleSelectMemoryFile = useCallback(async (path: string) => {
|
||||
try {
|
||||
await saveWorkingMemory();
|
||||
addToast("Working memory saved", "success");
|
||||
await selectFile(path);
|
||||
} catch {
|
||||
addToast("Failed to save working memory", "error");
|
||||
addToast("Failed to load memory file", "error");
|
||||
}
|
||||
}, [saveWorkingMemory, addToast]);
|
||||
}, [selectFile, addToast]);
|
||||
|
||||
const handleSaveSelectedFile = useCallback(async () => {
|
||||
try {
|
||||
await saveSelectedFile();
|
||||
addToast("Memory saved", "success");
|
||||
} catch {
|
||||
addToast("Failed to save memory", "error");
|
||||
}
|
||||
}, [saveSelectedFile, addToast]);
|
||||
|
||||
const handleSaveMemorySettings = useCallback(async () => {
|
||||
if (!memorySettingsDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const patch: Partial<typeof memorySettingsDraft> = {};
|
||||
|
||||
if (memorySettingsDraft.memoryEnabled !== memorySettings.memoryEnabled) {
|
||||
patch.memoryEnabled = memorySettingsDraft.memoryEnabled;
|
||||
}
|
||||
if (memorySettingsDraft.memoryAutoSummarizeEnabled !== memorySettings.memoryAutoSummarizeEnabled) {
|
||||
patch.memoryAutoSummarizeEnabled = memorySettingsDraft.memoryAutoSummarizeEnabled;
|
||||
}
|
||||
if (memorySettingsDraft.memoryAutoSummarizeThresholdChars !== memorySettings.memoryAutoSummarizeThresholdChars) {
|
||||
patch.memoryAutoSummarizeThresholdChars = memorySettingsDraft.memoryAutoSummarizeThresholdChars;
|
||||
}
|
||||
if (memorySettingsDraft.memoryAutoSummarizeSchedule !== memorySettings.memoryAutoSummarizeSchedule) {
|
||||
patch.memoryAutoSummarizeSchedule = memorySettingsDraft.memoryAutoSummarizeSchedule;
|
||||
}
|
||||
if (memorySettingsDraft.memoryDreamsEnabled !== memorySettings.memoryDreamsEnabled) {
|
||||
patch.memoryDreamsEnabled = memorySettingsDraft.memoryDreamsEnabled;
|
||||
}
|
||||
if (memorySettingsDraft.memoryDreamsSchedule !== memorySettings.memoryDreamsSchedule) {
|
||||
patch.memoryDreamsSchedule = memorySettingsDraft.memoryDreamsSchedule;
|
||||
}
|
||||
|
||||
try {
|
||||
await saveMemorySettings(patch);
|
||||
addToast("Memory settings saved", "success");
|
||||
} catch {
|
||||
addToast("Failed to save memory settings", "error");
|
||||
}
|
||||
}, [memorySettingsDirty, memorySettingsDraft, memorySettings, saveMemorySettings, addToast]);
|
||||
|
||||
const handleInstallQmd = useCallback(async () => {
|
||||
try {
|
||||
const result = await installQmdAction();
|
||||
addToast(
|
||||
result.qmdAvailable ? "qmd installed successfully" : "qmd install finished, but qmd is still unavailable",
|
||||
result.qmdAvailable ? "success" : "info",
|
||||
);
|
||||
} catch {
|
||||
addToast("Failed to install qmd", "error");
|
||||
}
|
||||
}, [installQmdAction, addToast]);
|
||||
|
||||
const handleTestRetrieval = useCallback(async () => {
|
||||
setMemoryTestLoading(true);
|
||||
setMemoryTestResult(null);
|
||||
|
||||
try {
|
||||
const result = await testRetrieval(memoryTestQuery);
|
||||
setMemoryTestResult(result);
|
||||
addToast(
|
||||
result.qmdAvailable ? "Memory retrieval test complete" : "qmd is not installed; local fallback was used",
|
||||
result.qmdAvailable ? "success" : "info",
|
||||
);
|
||||
} catch {
|
||||
addToast("Failed to test memory retrieval", "error");
|
||||
} finally {
|
||||
setMemoryTestLoading(false);
|
||||
}
|
||||
}, [memoryTestQuery, testRetrieval, addToast]);
|
||||
|
||||
// Handle compact memory
|
||||
const handleCompactMemory = useCallback(async () => {
|
||||
try {
|
||||
await compactMemory();
|
||||
addToast("Memory compacted successfully", "success");
|
||||
await compactMemory(selectedFilePath);
|
||||
addToast("Memory file compacted", "success");
|
||||
} catch {
|
||||
addToast("Failed to compact memory", "error");
|
||||
}
|
||||
}, [compactMemory, addToast]);
|
||||
}, [compactMemory, selectedFilePath, addToast]);
|
||||
|
||||
// Handle extract insights
|
||||
const handleExtractInsights = useCallback(async () => {
|
||||
@@ -274,31 +403,71 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workingMemoryLoading ? (
|
||||
{memoryFilesLoading || selectedFileLoading ? (
|
||||
<div className="memory-empty-state">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
<span>Loading working memory…</span>
|
||||
<span>Loading memory file…</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="memory-editor-container">
|
||||
<FileEditor
|
||||
content={workingMemory}
|
||||
onChange={setWorkingMemory}
|
||||
readOnly={!isWritable}
|
||||
filePath=".fusion/memory/MEMORY.md"
|
||||
/>
|
||||
<div className="memory-editor-section">
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryViewFilePath">Memory File</label>
|
||||
<select
|
||||
id="memoryViewFilePath"
|
||||
className="select"
|
||||
value={selectedFilePath}
|
||||
onChange={(event) => {
|
||||
void handleSelectMemoryFile(event.target.value);
|
||||
}}
|
||||
disabled={selectedFileDirty}
|
||||
>
|
||||
{memoryFiles.map((file) => (
|
||||
<option key={file.path} value={file.path}>
|
||||
{file.label} - {file.path}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>
|
||||
{selectedFileDirty
|
||||
? "Save or discard the current edits before switching files."
|
||||
: "Choose any project memory file to view or edit."}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{selectedMemoryFile && (
|
||||
<div className="memory-file-summary">
|
||||
<span>{MEMORY_LAYER_NAMES[selectedMemoryFile.layer]}</span>
|
||||
<strong>{selectedMemoryFile.path}</strong>
|
||||
<small>
|
||||
{selectedMemoryFile.size.toLocaleString()} bytes · updated {new Date(selectedMemoryFile.updatedAt).toLocaleString()}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>{selectedMemoryFile?.label || "Memory Editor"}</label>
|
||||
<small>{selectedLayerDescription}</small>
|
||||
<div className="memory-editor-container">
|
||||
<FileEditor
|
||||
content={selectedFileContent}
|
||||
onChange={setSelectedFileContent}
|
||||
readOnly={!isWritable}
|
||||
filePath={selectedFilePath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="memory-action-bar">
|
||||
<span className="memory-char-count">{workingMemory.length} characters</span>
|
||||
<span className="memory-char-count">{selectedFileContent.length} characters</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
{isWritable && workingMemory.length > 0 && (
|
||||
{isWritable && selectedFileContent.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleCompactMemory}
|
||||
disabled={compacting}
|
||||
disabled={compacting || selectedFileDirty}
|
||||
>
|
||||
{compacting ? (
|
||||
<>
|
||||
@@ -306,18 +475,18 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
Compacting…
|
||||
</>
|
||||
) : (
|
||||
"Compact Memory"
|
||||
"Compact Selected File"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{workingMemoryDirty && isWritable && (
|
||||
{selectedFileDirty && isWritable && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleSaveWorkingMemory}
|
||||
disabled={savingWorkingMemory}
|
||||
onClick={handleSaveSelectedFile}
|
||||
disabled={savingSelectedFile}
|
||||
>
|
||||
{savingWorkingMemory ? (
|
||||
{savingSelectedFile ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Saving…
|
||||
@@ -328,6 +497,138 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="memory-config-section">
|
||||
<div className="memory-settings-group">
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryDreamsEnabled" className="checkbox-label">
|
||||
<input
|
||||
id="memoryDreamsEnabled"
|
||||
type="checkbox"
|
||||
checked={memorySettingsDraft.memoryDreamsEnabled}
|
||||
onChange={(event) => {
|
||||
setMemorySettingsDraft((prev) => ({
|
||||
...prev,
|
||||
memoryDreamsEnabled: event.target.checked,
|
||||
}));
|
||||
}}
|
||||
disabled={!memorySettingsDraft.memoryEnabled || settingsLoading}
|
||||
/>
|
||||
Process dreams from daily memory
|
||||
</label>
|
||||
<small>Turns daily notes into DREAMS.md and promotes reusable lessons into MEMORY.md.</small>
|
||||
</div>
|
||||
|
||||
{memorySettingsDraft.memoryEnabled && memorySettingsDraft.memoryDreamsEnabled && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryDreamsSchedule">Dream Schedule</label>
|
||||
<input
|
||||
id="memoryDreamsSchedule"
|
||||
type="text"
|
||||
className="input"
|
||||
value={memorySettingsDraft.memoryDreamsSchedule}
|
||||
onChange={(event) => {
|
||||
setMemorySettingsDraft((prev) => ({
|
||||
...prev,
|
||||
memoryDreamsSchedule: event.target.value,
|
||||
}));
|
||||
}}
|
||||
placeholder="0 4 * * *"
|
||||
disabled={settingsLoading}
|
||||
/>
|
||||
<small>Cron expression for dream processing.</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="memory-settings-group">
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryAutoSummarizeEnabled" className="checkbox-label">
|
||||
<input
|
||||
id="memoryAutoSummarizeEnabled"
|
||||
type="checkbox"
|
||||
checked={memorySettingsDraft.memoryAutoSummarizeEnabled}
|
||||
onChange={(event) => {
|
||||
setMemorySettingsDraft((prev) => ({
|
||||
...prev,
|
||||
memoryAutoSummarizeEnabled: event.target.checked,
|
||||
}));
|
||||
}}
|
||||
disabled={!memorySettingsDraft.memoryEnabled || settingsLoading}
|
||||
/>
|
||||
Auto-Summarize Memory
|
||||
</label>
|
||||
<small>Automatically compact memory when it exceeds the threshold on a schedule</small>
|
||||
</div>
|
||||
|
||||
{memorySettingsDraft.memoryEnabled && memorySettingsDraft.memoryAutoSummarizeEnabled && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryAutoSummarizeThresholdChars">Compaction Threshold (chars)</label>
|
||||
<input
|
||||
id="memoryAutoSummarizeThresholdChars"
|
||||
type="number"
|
||||
className="input"
|
||||
value={memorySettingsDraft.memoryAutoSummarizeThresholdChars}
|
||||
onChange={(event) => {
|
||||
setMemorySettingsDraft((prev) => ({
|
||||
...prev,
|
||||
memoryAutoSummarizeThresholdChars: parseInt(event.target.value, 10) || 50000,
|
||||
}));
|
||||
}}
|
||||
min={1000}
|
||||
disabled={settingsLoading}
|
||||
/>
|
||||
<small>Memory will be compacted when it exceeds this character count</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryAutoSummarizeSchedule">Schedule (cron)</label>
|
||||
<input
|
||||
id="memoryAutoSummarizeSchedule"
|
||||
type="text"
|
||||
className="input"
|
||||
value={memorySettingsDraft.memoryAutoSummarizeSchedule}
|
||||
onChange={(event) => {
|
||||
setMemorySettingsDraft((prev) => ({
|
||||
...prev,
|
||||
memoryAutoSummarizeSchedule: event.target.value,
|
||||
}));
|
||||
}}
|
||||
placeholder="0 3 * * *"
|
||||
disabled={settingsLoading}
|
||||
/>
|
||||
<small>Cron expression for auto-summarize schedule (default: daily at 3 AM)</small>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!memorySettingsDraft.memoryEnabled && (
|
||||
<div className="settings-empty-state memory-status-message">
|
||||
Memory is currently disabled. Enable memory tools in Settings to edit these automations.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{memorySettingsDirty && (
|
||||
<div className="memory-action-bar">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleSaveMemorySettings}
|
||||
disabled={savingMemorySettings || settingsLoading}
|
||||
>
|
||||
{savingMemorySettings ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Saving…
|
||||
</>
|
||||
) : (
|
||||
"Save Settings"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -489,6 +790,94 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* QMD Integration Card */}
|
||||
<div className="memory-engine-card memory-qmd-card">
|
||||
<h3>QMD Integration</h3>
|
||||
{backendStatus?.qmdAvailable ? (
|
||||
<div className="memory-engine-status">
|
||||
<span className="memory-health-badge memory-health-badge--healthy">Installed</span>
|
||||
<span className="memory-char-count">qmd is available on PATH.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="settings-empty-state memory-status-message">
|
||||
<span>
|
||||
qmd is not installed. Search will use local files. Install indexed retrieval: <code>{backendStatus?.qmdInstallCommand || "bun install -g @tobilu/qmd"}</code>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleInstallQmd}
|
||||
disabled={installingQmd}
|
||||
>
|
||||
{installingQmd ? "Installing…" : "Install qmd"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: "var(--space-xs)", marginTop: "var(--space-sm)", flexWrap: "wrap" }}>
|
||||
{backendStatus?.capabilities?.readable && (
|
||||
<span className="memory-capability-badge">Readable</span>
|
||||
)}
|
||||
{backendStatus?.capabilities?.writable && (
|
||||
<span className="memory-capability-badge">Writable</span>
|
||||
)}
|
||||
{backendStatus?.capabilities?.supportsAtomicWrite && (
|
||||
<span className="memory-capability-badge">Atomic Writes</span>
|
||||
)}
|
||||
{backendStatus?.capabilities?.persistent && (
|
||||
<span className="memory-capability-badge">Persistent</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Memory Retrieval Test Card */}
|
||||
<div className="memory-engine-card memory-retrieval-card">
|
||||
<h3>Test Memory Search</h3>
|
||||
<div className="memory-retrieval-input-row">
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={memoryTestQuery}
|
||||
onChange={(event) => setMemoryTestQuery(event.target.value)}
|
||||
placeholder="Search memory with qmd"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleTestRetrieval}
|
||||
disabled={memoryTestLoading}
|
||||
>
|
||||
{memoryTestLoading ? "Testing…" : "Test Retrieval"}
|
||||
</button>
|
||||
</div>
|
||||
<small className="settings-muted">
|
||||
Runs the same qmd-backed memory_search path agents use.
|
||||
</small>
|
||||
|
||||
{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>
|
||||
|
||||
{/* Backend Card */}
|
||||
<div className="memory-engine-card">
|
||||
<h3>Current Backend</h3>
|
||||
|
||||
@@ -7,18 +7,41 @@ import {
|
||||
triggerInsightExtraction,
|
||||
fetchMemoryAudit,
|
||||
fetchMemoryStats,
|
||||
compactMemory,
|
||||
fetchMemoryBackendStatus,
|
||||
compactMemory as compactMemoryApi,
|
||||
fetchSettings,
|
||||
updateSettings,
|
||||
fetchMemoryFiles,
|
||||
fetchMemoryFile,
|
||||
saveMemoryFile,
|
||||
installQmd,
|
||||
testMemoryRetrieval,
|
||||
type MemoryAuditReport,
|
||||
type MemoryBackendStatus,
|
||||
type MemoryFileInfo,
|
||||
type MemoryRetrievalTestResult,
|
||||
type QmdInstallResult,
|
||||
} from "../api";
|
||||
import { useMemoryBackendStatus } from "./useMemoryBackendStatus";
|
||||
|
||||
const DEFAULT_MEMORY_FILE_PATH = ".fusion/memory/MEMORY.md";
|
||||
const DEFAULT_AUTO_SUMMARIZE_THRESHOLD = 50_000;
|
||||
const DEFAULT_AUTO_SUMMARIZE_SCHEDULE = "0 3 * * *";
|
||||
const DEFAULT_DREAMS_SCHEDULE = "0 4 * * *";
|
||||
|
||||
interface UseMemoryDataOptions {
|
||||
/** Project ID for multi-project contexts */
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
interface MemorySettingsState {
|
||||
memoryEnabled: boolean;
|
||||
memoryAutoSummarizeEnabled: boolean;
|
||||
memoryAutoSummarizeThresholdChars: number;
|
||||
memoryAutoSummarizeSchedule: string;
|
||||
memoryDreamsEnabled: boolean;
|
||||
memoryDreamsSchedule: string;
|
||||
}
|
||||
|
||||
interface UseMemoryDataResult {
|
||||
// Working memory
|
||||
workingMemory: string;
|
||||
@@ -35,6 +58,25 @@ interface UseMemoryDataResult {
|
||||
refreshInsights: () => Promise<void>;
|
||||
saveInsights: (content: string) => Promise<void>;
|
||||
|
||||
// Settings
|
||||
memorySettings: MemorySettingsState;
|
||||
settingsLoading: boolean;
|
||||
savingMemorySettings: boolean;
|
||||
saveMemorySettings: (patch: Partial<MemorySettingsState>) => Promise<void>;
|
||||
|
||||
// Multi-file memory editor
|
||||
memoryFiles: MemoryFileInfo[];
|
||||
memoryFilesLoading: boolean;
|
||||
selectedFilePath: string;
|
||||
selectedFileContent: string;
|
||||
selectedFileLoading: boolean;
|
||||
selectedFileDirty: boolean;
|
||||
setSelectedFileContent: (content: string) => void;
|
||||
selectFile: (path: string) => Promise<void>;
|
||||
saveSelectedFile: () => Promise<void>;
|
||||
savingSelectedFile: boolean;
|
||||
reloadMemoryFiles: () => Promise<void>;
|
||||
|
||||
// Backend status
|
||||
backendStatus: MemoryBackendStatus | null;
|
||||
backendLoading: boolean;
|
||||
@@ -49,13 +91,46 @@ interface UseMemoryDataResult {
|
||||
refreshAudit: () => Promise<void>;
|
||||
|
||||
// Compact
|
||||
compactMemory: () => Promise<void>;
|
||||
compactMemory: (path?: string) => Promise<void>;
|
||||
compacting: boolean;
|
||||
|
||||
// QMD integration
|
||||
installQmdAction: () => Promise<QmdInstallResult>;
|
||||
installingQmd: boolean;
|
||||
testRetrieval: (query: string) => Promise<MemoryRetrievalTestResult>;
|
||||
|
||||
// Stats
|
||||
stats: { workingMemorySize: number; insightsSize: number; insightsExists: boolean } | null;
|
||||
}
|
||||
|
||||
function extractMemorySettings(source: {
|
||||
memoryEnabled?: boolean;
|
||||
memoryAutoSummarizeEnabled?: boolean;
|
||||
memoryAutoSummarizeThresholdChars?: number;
|
||||
memoryAutoSummarizeSchedule?: string;
|
||||
memoryDreamsEnabled?: boolean;
|
||||
memoryDreamsSchedule?: string;
|
||||
}): MemorySettingsState {
|
||||
return {
|
||||
memoryEnabled: source.memoryEnabled !== false,
|
||||
memoryAutoSummarizeEnabled: source.memoryAutoSummarizeEnabled ?? false,
|
||||
memoryAutoSummarizeThresholdChars: source.memoryAutoSummarizeThresholdChars ?? DEFAULT_AUTO_SUMMARIZE_THRESHOLD,
|
||||
memoryAutoSummarizeSchedule: source.memoryAutoSummarizeSchedule ?? DEFAULT_AUTO_SUMMARIZE_SCHEDULE,
|
||||
memoryDreamsEnabled: source.memoryDreamsEnabled ?? false,
|
||||
memoryDreamsSchedule: source.memoryDreamsSchedule ?? DEFAULT_DREAMS_SCHEDULE,
|
||||
};
|
||||
}
|
||||
|
||||
function pickDefaultMemoryPath(files: MemoryFileInfo[], currentPath: string): string {
|
||||
if (files.some((file) => file.path === currentPath)) {
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
return files.find((file) => file.path === DEFAULT_MEMORY_FILE_PATH)?.path
|
||||
?? files[0]?.path
|
||||
?? DEFAULT_MEMORY_FILE_PATH;
|
||||
}
|
||||
|
||||
export function useMemoryData(options: UseMemoryDataOptions = {}): UseMemoryDataResult {
|
||||
const { projectId } = options;
|
||||
|
||||
@@ -70,6 +145,20 @@ export function useMemoryData(options: UseMemoryDataOptions = {}): UseMemoryData
|
||||
const [insightsLoading, setInsightsLoading] = useState(true);
|
||||
const [insightsExists, setInsightsExists] = useState(false);
|
||||
|
||||
// Settings state
|
||||
const [memorySettings, setMemorySettings] = useState<MemorySettingsState>(() => extractMemorySettings({}));
|
||||
const [settingsLoading, setSettingsLoading] = useState(true);
|
||||
const [savingMemorySettings, setSavingMemorySettings] = useState(false);
|
||||
|
||||
// Multi-file state
|
||||
const [memoryFiles, setMemoryFiles] = useState<MemoryFileInfo[]>([]);
|
||||
const [memoryFilesLoading, setMemoryFilesLoading] = useState(true);
|
||||
const [selectedFilePath, setSelectedFilePath] = useState(DEFAULT_MEMORY_FILE_PATH);
|
||||
const [selectedFileContent, setSelectedFileContentRaw] = useState("");
|
||||
const [selectedFileLoading, setSelectedFileLoading] = useState(false);
|
||||
const [selectedFileDirty, setSelectedFileDirty] = useState(false);
|
||||
const [savingSelectedFile, setSavingSelectedFile] = useState(false);
|
||||
|
||||
// Extraction state
|
||||
const [extracting, setExtracting] = useState(false);
|
||||
|
||||
@@ -80,11 +169,57 @@ export function useMemoryData(options: UseMemoryDataOptions = {}): UseMemoryData
|
||||
// Compact state
|
||||
const [compacting, setCompacting] = useState(false);
|
||||
|
||||
// QMD state
|
||||
const [installingQmd, setInstallingQmd] = useState(false);
|
||||
|
||||
// Stats state
|
||||
const [stats, setStats] = useState<{ workingMemorySize: number; insightsSize: number; insightsExists: boolean } | null>(null);
|
||||
|
||||
// Backend status from existing hook
|
||||
const { status: backendStatus, loading: backendLoading } = useMemoryBackendStatus({ projectId });
|
||||
const {
|
||||
status: backendStatus,
|
||||
loading: backendLoading,
|
||||
refresh: refreshBackendStatus,
|
||||
} = useMemoryBackendStatus({ projectId });
|
||||
|
||||
const setSelectedFileContent = useCallback((content: string) => {
|
||||
setSelectedFileContentRaw(content);
|
||||
setSelectedFileDirty(true);
|
||||
}, []);
|
||||
|
||||
const loadMemoryFileContent = useCallback(async (path: string) => {
|
||||
setSelectedFileLoading(true);
|
||||
try {
|
||||
const { content } = await fetchMemoryFile(path, projectId);
|
||||
setSelectedFilePath(path);
|
||||
setSelectedFileContentRaw(content);
|
||||
setSelectedFileDirty(false);
|
||||
} finally {
|
||||
setSelectedFileLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const reloadMemoryFiles = useCallback(async () => {
|
||||
setMemoryFilesLoading(true);
|
||||
try {
|
||||
const { files } = await fetchMemoryFiles(projectId);
|
||||
setMemoryFiles(files);
|
||||
|
||||
if (files.length === 0) {
|
||||
setSelectedFilePath(DEFAULT_MEMORY_FILE_PATH);
|
||||
setSelectedFileContentRaw("");
|
||||
setSelectedFileDirty(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPath = pickDefaultMemoryPath(files, selectedFilePath);
|
||||
if (nextPath !== selectedFilePath) {
|
||||
await loadMemoryFileContent(nextPath);
|
||||
}
|
||||
} finally {
|
||||
setMemoryFilesLoading(false);
|
||||
}
|
||||
}, [projectId, selectedFilePath, loadMemoryFileContent]);
|
||||
|
||||
// Fetch working memory on mount
|
||||
useEffect(() => {
|
||||
@@ -140,6 +275,86 @@ export function useMemoryData(options: UseMemoryDataOptions = {}): UseMemoryData
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
// Fetch memory settings on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadSettings() {
|
||||
setSettingsLoading(true);
|
||||
try {
|
||||
const settings = await fetchSettings(projectId);
|
||||
if (!cancelled) {
|
||||
setMemorySettings(extractMemorySettings(settings));
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setMemorySettings(extractMemorySettings({}));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setSettingsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadSettings();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
// Fetch memory files and initial selected file content
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadFiles() {
|
||||
setMemoryFilesLoading(true);
|
||||
try {
|
||||
const { files } = await fetchMemoryFiles(projectId);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMemoryFiles(files);
|
||||
|
||||
if (files.length === 0) {
|
||||
setSelectedFilePath(DEFAULT_MEMORY_FILE_PATH);
|
||||
setSelectedFileContentRaw("");
|
||||
setSelectedFileDirty(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPath = pickDefaultMemoryPath(files, selectedFilePath);
|
||||
const { content } = await fetchMemoryFile(nextPath, projectId);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFilePath(nextPath);
|
||||
setSelectedFileContentRaw(content);
|
||||
setSelectedFileDirty(false);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setMemoryFiles([]);
|
||||
setSelectedFilePath(DEFAULT_MEMORY_FILE_PATH);
|
||||
setSelectedFileContentRaw("");
|
||||
setSelectedFileDirty(false);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setMemoryFilesLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadFiles();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, selectedFilePath]);
|
||||
|
||||
// Fetch audit on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -166,6 +381,30 @@ export function useMemoryData(options: UseMemoryDataOptions = {}): UseMemoryData
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
// Fetch lightweight stats on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const data = await fetchMemoryStats(projectId);
|
||||
if (!cancelled) {
|
||||
setStats(data);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setStats(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadStats();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
// Set working memory with dirty tracking
|
||||
const setWorkingMemory = useCallback((content: string) => {
|
||||
setWorkingMemoryRaw(content);
|
||||
@@ -185,6 +424,55 @@ export function useMemoryData(options: UseMemoryDataOptions = {}): UseMemoryData
|
||||
}
|
||||
}, [workingMemory, workingMemoryDirty, projectId]);
|
||||
|
||||
// Save memory settings
|
||||
const saveMemorySettings = useCallback(async (patch: Partial<MemorySettingsState>) => {
|
||||
setSavingMemorySettings(true);
|
||||
try {
|
||||
const updated = await updateSettings(patch, projectId);
|
||||
setMemorySettings(extractMemorySettings(updated));
|
||||
} finally {
|
||||
setSavingMemorySettings(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// Select a memory file and load its content
|
||||
const selectFile = useCallback(async (path: string) => {
|
||||
await loadMemoryFileContent(path);
|
||||
}, [loadMemoryFileContent]);
|
||||
|
||||
// Save selected file content
|
||||
const saveSelectedFile = useCallback(async () => {
|
||||
if (!selectedFileDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingSelectedFile(true);
|
||||
try {
|
||||
await saveMemoryFile(selectedFilePath, selectedFileContent, projectId);
|
||||
setSelectedFileDirty(false);
|
||||
await reloadMemoryFiles();
|
||||
} finally {
|
||||
setSavingSelectedFile(false);
|
||||
}
|
||||
}, [selectedFileContent, selectedFileDirty, selectedFilePath, projectId, reloadMemoryFiles]);
|
||||
|
||||
// Install qmd and refresh backend status
|
||||
const installQmdAction = useCallback(async () => {
|
||||
setInstallingQmd(true);
|
||||
try {
|
||||
const result = await installQmd(projectId);
|
||||
await refreshBackendStatus();
|
||||
return result;
|
||||
} finally {
|
||||
setInstallingQmd(false);
|
||||
}
|
||||
}, [projectId, refreshBackendStatus]);
|
||||
|
||||
// Test retrieval
|
||||
const testRetrievalAction = useCallback(async (query: string) => {
|
||||
return testMemoryRetrieval(query, projectId);
|
||||
}, [projectId]);
|
||||
|
||||
// Refresh audit
|
||||
const refreshAudit = useCallback(async () => {
|
||||
try {
|
||||
@@ -227,17 +515,29 @@ export function useMemoryData(options: UseMemoryDataOptions = {}): UseMemoryData
|
||||
}, [projectId, refreshInsights, refreshAudit]);
|
||||
|
||||
// Compact memory
|
||||
const compactMemoryAction = useCallback(async () => {
|
||||
const compactMemoryAction = useCallback(async (path?: string) => {
|
||||
setCompacting(true);
|
||||
try {
|
||||
const result = await compactMemory(projectId);
|
||||
// Update working memory with compacted content
|
||||
const result = path
|
||||
? await compactMemoryApi(path, projectId)
|
||||
: await compactMemoryApi(projectId);
|
||||
|
||||
if (path) {
|
||||
const nextPath = result.path ?? path;
|
||||
setSelectedFilePath(nextPath);
|
||||
setSelectedFileContentRaw(result.content);
|
||||
setSelectedFileDirty(false);
|
||||
await reloadMemoryFiles();
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy behavior for single-file working memory editor
|
||||
setWorkingMemoryRaw(result.content);
|
||||
setWorkingMemoryDirty(true);
|
||||
} finally {
|
||||
setCompacting(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
}, [projectId, reloadMemoryFiles]);
|
||||
|
||||
return {
|
||||
// Working memory
|
||||
@@ -255,6 +555,25 @@ export function useMemoryData(options: UseMemoryDataOptions = {}): UseMemoryData
|
||||
refreshInsights,
|
||||
saveInsights,
|
||||
|
||||
// Settings
|
||||
memorySettings,
|
||||
settingsLoading,
|
||||
savingMemorySettings,
|
||||
saveMemorySettings,
|
||||
|
||||
// Multi-file memory editor
|
||||
memoryFiles,
|
||||
memoryFilesLoading,
|
||||
selectedFilePath,
|
||||
selectedFileContent,
|
||||
selectedFileLoading,
|
||||
selectedFileDirty,
|
||||
setSelectedFileContent,
|
||||
selectFile,
|
||||
saveSelectedFile,
|
||||
savingSelectedFile,
|
||||
reloadMemoryFiles,
|
||||
|
||||
// Backend status
|
||||
backendStatus,
|
||||
backendLoading,
|
||||
@@ -272,6 +591,11 @@ export function useMemoryData(options: UseMemoryDataOptions = {}): UseMemoryData
|
||||
compactMemory: compactMemoryAction,
|
||||
compacting,
|
||||
|
||||
// QMD integration
|
||||
installQmdAction,
|
||||
installingQmd,
|
||||
testRetrieval: testRetrievalAction,
|
||||
|
||||
// Stats
|
||||
stats,
|
||||
};
|
||||
|
||||
@@ -36982,6 +36982,46 @@ html .column.drag-over * {
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.memory-config-section {
|
||||
margin-top: var(--space-lg);
|
||||
padding-top: var(--space-lg);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.memory-settings-group {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
padding: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.memory-qmd-card {
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.memory-retrieval-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.memory-retrieval-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.memory-retrieval-input-row .input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.memory-retrieval-card .memory-test-result {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Mobile responsive for file mention popup */
|
||||
@media (max-width: 768px) {
|
||||
.file-mention-popup {
|
||||
@@ -37032,4 +37072,18 @@ html .column.drag-over * {
|
||||
.memory-engine-card {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.memory-retrieval-input-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.memory-settings-group {
|
||||
padding: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.memory-config-section {
|
||||
margin-top: var(--space-md);
|
||||
padding-top: var(--space-md);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user