import { useState, useMemo, useCallback, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Brain, Loader2 } from "lucide-react"; import "./MemoryView.css"; import "./SettingsModal.css"; import type { MemoryFileInfo, MemoryRetrievalTestResult } from "../api"; import { FileEditor } from "./FileEditor"; import { ViewHeader } from "./ViewHeader"; import { useMemoryData } from "../hooks/useMemoryData"; interface MemoryViewProps { projectId?: string; addToast: (message: string, type: "success" | "error" | "info") => void; onSendSelectionToTask?: (description: string) => void; } type Tab = "working" | "insights" | "engines"; /** Known category headers in the insights file */ const CATEGORY_HEADERS: Record = { "Patterns": "pattern", "Principles": "principle", "Conventions": "convention", "Pitfalls": "pitfall", "Context": "context", }; const MEMORY_FILE_OPTION_LABEL_MAX_CHARS = 72; function truncateMiddle(value: string, maxChars: number): string { if (value.length <= maxChars) { return value; } const visibleChars = Math.max(1, maxChars - 1); const startChars = Math.ceil(visibleChars / 2); const endChars = Math.floor(visibleChars / 2); return `${value.slice(0, startChars)}…${value.slice(value.length - endChars)}`; } function formatMemoryFileOptionLabel(file: MemoryFileInfo): string { const fullLabel = `${file.label} — ${file.path}`; return truncateMiddle(fullLabel, MEMORY_FILE_OPTION_LABEL_MAX_CHARS); } interface ParsedInsightCategory { name: string; key: string; items: string[]; expanded: boolean; } /* FNXC:MemoryView 2026-07-10-23:00: Insights parsing bug fix: the old implementation stripped the "- " bullet prefix BEFORE filtering for lines that start with "- ", so no markdown bullet ever survived the filter. Every category then collapsed into a single giant blob item and the "Total Insights" stat undercounted (~1 per category), contradicting the server-computed insight count on the Engines health card. Bullets must be FILTERED first, then stripped. HTML comments in the section body (extraction markers like "recurring themes that work well") are metadata, not insights, and are removed before parsing. */ /** Parse insights markdown content into categorized sections */ function parseInsightsContent(content: string | null): ParsedInsightCategory[] { if (!content) return []; const categories: ParsedInsightCategory[] = []; const sections = content.split(/(?=^## )/m); for (const section of sections) { const trimmed = section.trim(); if (!trimmed) continue; // Check if this is a category header const match = trimmed.match(/^##\s+(.+?)(\n|$)/); if (match) { const header = match[1].trim(); const key = CATEGORY_HEADERS[header] ?? header.toLowerCase(); const body = trimmed .slice(match[0].length) .replace(//g, "") .trim(); /* FNXC:MemoryView 2026-07-11-01:00: PR #2003 review: an insight can span multiple lines (one bullet followed by continuation text). Bullet lines start a new item; non-empty non-bullet lines append to the previous item instead of being dropped, so multiline insights render in full rather than silently truncating after the first line. FNXC:MemoryView 2026-07-11-01:40: PR #2003 review round 2: only a TOP-LEVEL bullet (column 0 on the raw line) starts a new insight. Indented sub-bullets (" - detail A") belong to the insight above them, so the new-item test runs against the untrimmed line and continuations keep their leading indentation (trimEnd only) for rendering. */ const items = body .split("\n") .reduce((acc, rawLine) => { const line = rawLine.trim(); if (/^[-*]\s+/.test(rawLine)) { acc.push(line.replace(/^[-*]\s+/, "")); } else if (line.length > 0 && acc.length > 0) { acc[acc.length - 1] = `${acc[acc.length - 1]}\n${rawLine.trimEnd()}`; } return acc; }, []); if (items.length > 0 || body.length > 0) { categories.push({ name: header, key, items: items.length > 0 ? items : (body.length > 0 ? [body] : []), expanded: true, }); } } } return categories; } /** Parse the "Last Updated" timestamp from insights content */ function parseLastUpdated(content: string | null): string | null { if (!content) return null; const match = content.match(/##\s+Last\s+Updated:\s*(\d{4}-\d{2}-\d{2})/i); return match ? match[1] : null; } /** Count total insights from parsed categories */ function countTotalInsights(categories: ParsedInsightCategory[]): number { return categories.reduce((sum, cat) => sum + cat.items.length, 0); } export function MemoryView({ projectId, addToast, onSendSelectionToTask }: MemoryViewProps) { const { t } = useTranslation("app"); const [activeTab, setActiveTab] = useState("working"); const [expandedCategories, setExpandedCategories] = useState>(new Set()); const [editingInsights, setEditingInsights] = useState(false); const [insightsEditorContent, setInsightsEditorContent] = useState(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(null); const { insightsContent, insightsLoading, insightsExists, saveInsights, memorySettings, settingsLoading, saveMemorySettings, savingMemorySettings, backendStatus, backendLoading, extractInsights, extracting, auditReport, auditLoading, refreshAudit, compactMemory, compacting, installQmdAction, installingQmd, testRetrieval, memoryFiles, memoryFilesLoading, selectedFilePath, selectedFileContent, selectedFileLoading, selectedFileDirty, setSelectedFileContent, selectFile, saveSelectedFile, savingSelectedFile, reloadMemoryFiles, triggerDreamNow, dreamRunning, } = 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 ? (selectedMemoryFile.layer === "long-term" ? t("memory.layerDescLongTerm", "Curated durable decisions, conventions, constraints, and pitfalls promoted from dreams.") : selectedMemoryFile.layer === "daily" ? t("memory.layerDescDaily", "Raw daily observations, open loops, and running context for dream processing.") : t("memory.layerDescDreams", "Synthesized patterns and open loops promoted from daily memory.")) : t("memory.editorDefaultDescription", "Edits the selected memory file."); // Parse insights content const parsedCategories = useMemo( () => parseInsightsContent(insightsContent), [insightsContent], ); const totalInsights = useMemo( () => countTotalInsights(parsedCategories), [parsedCategories], ); const lastUpdated = useMemo( () => parseLastUpdated(insightsContent), [insightsContent], ); // Toggle category expansion const toggleCategory = useCallback((key: string) => { setExpandedCategories((prev) => { const next = new Set(prev); if (next.has(key)) { next.delete(key); } else { next.add(key); } return next; }); }, []); const handleSelectMemoryFile = useCallback(async (path: string) => { try { await selectFile(path); } catch { addToast(t("memory.loadFileFailed", "Failed to load memory file"), "error"); } }, [selectFile, addToast]); const handleSaveSelectedFile = useCallback(async () => { try { await saveSelectedFile(); addToast(t("memory.memorySaved", "Memory saved"), "success"); } catch { addToast(t("memory.saveMemoryFailed", "Failed to save memory"), "error"); } }, [saveSelectedFile, addToast]); const handleSaveMemorySettings = useCallback(async () => { if (!memorySettingsDirty) { return; } const patch: Partial = {}; 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(t("memory.settingsSaved", "Memory settings saved"), "success"); } catch { addToast(t("memory.saveSettingsFailed", "Failed to save memory settings"), "error"); } }, [memorySettingsDirty, memorySettingsDraft, memorySettings, saveMemorySettings, addToast]); const handleInstallQmd = useCallback(async () => { try { const result = await installQmdAction(); addToast( result.qmdAvailable ? t("memory.qmdInstallSuccess", "qmd installed successfully") : t("memory.qmdInstallUnavailable", "qmd install finished, but qmd is still unavailable"), result.qmdAvailable ? "success" : "info", ); } catch { addToast(t("memory.installQmdFailed", "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 ? t("memory.retrievalTestComplete", "Memory retrieval test complete") : t("memory.retrievalTestFallback", "qmd is not installed; local fallback was used"), result.qmdAvailable ? "success" : "info", ); } catch { addToast(t("memory.retrievalTestFailed", "Failed to test memory retrieval"), "error"); } finally { setMemoryTestLoading(false); } }, [memoryTestQuery, testRetrieval, addToast]); const handleDreamNow = useCallback(async () => { try { await triggerDreamNow(); addToast(t("memory.dreamProcessingComplete", "Dream processing completed"), "success"); await reloadMemoryFiles(); } catch (error) { addToast(error instanceof Error ? error.message : t("memory.dreamProcessingFailed", "Failed to run dream processing"), "error"); } }, [triggerDreamNow, reloadMemoryFiles, addToast]); // Handle compact memory const handleCompactMemory = useCallback(async () => { try { await compactMemory(selectedFilePath); addToast(t("memory.fileCompacted", "Memory file compacted"), "success"); } catch { addToast(t("memory.compactFailed", "Failed to compact memory"), "error"); } }, [compactMemory, selectedFilePath, addToast]); // Handle extract insights const handleExtractInsights = useCallback(async () => { try { const result = await extractInsights(); addToast(result.summary, "success"); } catch (err) { addToast(err instanceof Error ? err.message : t("memory.extractInsightsFailed", "Failed to extract insights"), "error"); } }, [extractInsights, addToast]); // Handle save insights (from raw editor) const handleSaveInsights = useCallback(async () => { if (insightsEditorContent === null) return; try { await saveInsights(insightsEditorContent); setEditingInsights(false); setInsightsEditorContent(null); addToast(t("memory.insightsSaved", "Insights saved"), "success"); } catch { addToast(t("memory.saveInsightsFailed", "Failed to save insights"), "error"); } }, [insightsEditorContent, saveInsights, addToast]); // Start editing insights const handleStartEditingInsights = useCallback(() => { setInsightsEditorContent(insightsContent ?? ""); setEditingInsights(true); }, [insightsContent]); // Cancel editing insights const handleCancelEditingInsights = useCallback(() => { setEditingInsights(false); setInsightsEditorContent(null); }, []); const backendStatusResolved = !backendLoading && backendStatus !== null; const isWritable = backendStatus?.capabilities?.writable ?? false; return (
{/* FNXC:Navigation 2026-06-22-01:10: Memory adopts the shared ViewHeader (CC-modeled) for a consistent main-content title row. FNXC:Memory 2026-06-22-12:00: The Memory view header should be title-only; remove the "Working memory, long-term insights, and engine status" subtitle so the tab bar becomes the first content under the header. */} {/* Tab bar */}
{/* Content area */}
{/* Working Memory Tab */} {activeTab === "working" && (
{backendStatusResolved && !isWritable && (
{t("memory.readOnlyBanner", "This memory backend is read-only. Changes cannot be saved.")}
)} {memoryFilesLoading || selectedFileLoading ? (
{t("memory.loadingFile", "Loading memory file…")}
) : ( <>
{selectedFileDirty ? t("memory.fileSwitchDirtyHint", "Save or discard the current edits before switching files.") : t("memory.fileSwitchHint", "Choose any project memory file to view or edit.")}
{selectedMemoryFile && (
{selectedMemoryFile.layer === "long-term" ? t("memory.layerLongTerm", "Long-term") : selectedMemoryFile.layer === "daily" ? t("memory.layerDaily", "Daily") : t("memory.layerDreams", "Dreams")} {selectedMemoryFile.path} {t("memory.fileSummary", "{{size}} bytes · updated {{updatedAt}}", { size: selectedMemoryFile.size.toLocaleString(), updatedAt: new Date(selectedMemoryFile.updatedAt).toLocaleString() })}
)}
{selectedLayerDescription}
{/* FNXC:MemoryView 2026-07-10-14:30: First-run review: the FileEditor's collapsed toolbar rendered as an unlabeled empty bar with only a chevron above the editor, and a user asked "what is this button?". Force the toolbar actions (Edit/Preview/Wrap) to stay visible so the toolbar always shows labeled controls instead of a mystery chevron-only bar. */}
{t("memory.charCount", "{{count}} characters", { count: selectedFileContent.length })}
{isWritable && selectedFileContent.length > 0 && ( )} {selectedFileDirty && isWritable && ( )}
{/* FNXC:MemoryView 2026-07-10-14:30: First-run review: the two automation checkbox rows ("Process dreams from daily memory" / "Auto-Summarize Memory") had inconsistent checkbox/label/hint alignment and mixed casing (sentence case vs Title Case). Both rows now share the .memory-toggle-row layout (hint indented under the label text, aligned past the checkbox) and use sentence-case labels. The dreams row also carries a hover tooltip (title attr) explaining the pipeline: daily notes are distilled into DREAMS.md and reusable lessons are promoted into MEMORY.md. */}
{t("memory.dreamsEnabledHint", "Turns daily notes into DREAMS.md and promotes reusable lessons into MEMORY.md.")}
{memorySettingsDraft.memoryEnabled && memorySettingsDraft.memoryDreamsEnabled && ( <>
{ setMemorySettingsDraft((prev) => ({ ...prev, memoryDreamsSchedule: event.target.value, })); }} placeholder="0 4 * * *" disabled={settingsLoading} /> {t("memory.dreamsScheduleHint", "Cron expression for dream processing.")}
{t("memory.dreamNowHint", "Manually trigger dream processing now.")}
)}
{t("memory.autoSummarizeHint", "Automatically compacts memory when it exceeds the threshold on a schedule.")}
{memorySettingsDraft.memoryEnabled && memorySettingsDraft.memoryAutoSummarizeEnabled && ( <>
{ setMemorySettingsDraft((prev) => ({ ...prev, memoryAutoSummarizeThresholdChars: parseInt(event.target.value, 10) || 50000, })); }} min={1000} disabled={settingsLoading} /> {t("memory.compactionThresholdHint", "Memory will be compacted when it exceeds this character count")}
{ setMemorySettingsDraft((prev) => ({ ...prev, memoryAutoSummarizeSchedule: event.target.value, })); }} placeholder="0 3 * * *" disabled={settingsLoading} /> {t("memory.autoSummarizeScheduleHint", "Cron expression for auto-summarize schedule (default: daily at 3 AM)")}
)}
{!memorySettingsDraft.memoryEnabled && (
{t("memory.disabledMessage", "Memory is currently disabled. Enable memory tools in Settings to edit these automations.")}
)} {memorySettingsDirty && (
)}
)}
)} {/* Insights Tab */} {activeTab === "insights" && (
{insightsLoading ? (
{t("memory.loadingInsights", "Loading insights…")}
) : editingInsights ? ( // Raw editor mode
{/* FNXC:MemoryView 2026-07-10-14:30: Same as the working-memory editor: keep toolbar actions visible so no unlabeled chevron-only bar renders above the raw insights editor. */}
) : !insightsExists || parsedCategories.length === 0 ? ( // Empty state

{t("memory.noInsights", "No insights extracted yet.")}

{t("memory.noInsightsHint", "Insights are automatically extracted from working memory. Click \"Extract Now\" to trigger extraction manually.")}

) : ( // Parsed insights view <>
{totalInsights}
{t("memory.totalInsights", "Total Insights")}
{parsedCategories.length}
{t("memory.categories", "Categories")}
{lastUpdated && (
{lastUpdated}
{t("memory.lastUpdated", "Last Updated")}
)}
{parsedCategories.map((category) => { const isExpanded = !expandedCategories.has(category.key); return (
toggleCategory(category.key)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleCategory(category.key); } }} >

{category.name}

{category.items.length}
{isExpanded && (
{category.items.map((item, index) => (
{item.replace(/^-\s+/, "").replace(/^\*\s+/, "")}
))}
)}
); })}
)}
)} {/* Engines Tab */} {activeTab === "engines" && (
{backendLoading || auditLoading ? (
{t("memory.loadingEngineStatus", "Loading engine status…")}
) : ( <> {/* QMD Integration Card */}

{t("memory.qmdIntegrationTitle", "QMD Integration")}

{backendStatus?.qmdAvailable === true ? (
{t("memory.qmdInstalled", "Installed")} {t("memory.qmdAvailableOnPath", "qmd is available on PATH.")}
) : backendStatus?.qmdAvailable === false ? (
{t("memory.qmdNotInstalled", "qmd is not installed. Search will use local files. Install indexed retrieval:")} {backendStatus.qmdInstallCommand || "bun install -g @tobilu/qmd"}
) : (
{t("memory.qmdChecking", "Checking")} {t("memory.qmdCheckingAvailability", "Checking qmd availability…")}
)} {/* FNXC:MemoryView 2026-07-10-23:30: The capability badge row (Readable/Writable/Atomic Writes/Persistent) belongs to the Current Backend card only; it was duplicated verbatim on this QMD card, showing the same four badges twice on the Engines tab. QMD availability is this card's whole story. */}
{/* Memory Retrieval Test Card */}

{t("memory.testMemorySearchTitle", "Test Memory Search")}

setMemoryTestQuery(event.target.value)} placeholder={t("memory.searchPlaceholder", "Search memory with qmd")} />
{t("memory.testSearchHint", "Runs the same qmd-backed memory_search path agents use.")} {memoryTestResult && (
{t("memory.testResultCount", "{{count}} result for \"{{query}}\"", { count: memoryTestResult.results.length, query: memoryTestResult.query, defaultValue_one: "{{count}} result for \"{{query}}\"", defaultValue_other: "{{count}} results for \"{{query}}\"" })} {t("memory.testResultStatus", "qmd {{qmdStatus}} · {{fallbackStatus}}", { qmdStatus: memoryTestResult.qmdAvailable ? t("memory.qmdStatusAvailable", "available") : t("memory.qmdStatusMissing", "missing"), fallbackStatus: memoryTestResult.usedFallback ? t("memory.localFallbackUsed", "local fallback used") : t("memory.qmdPathUsed", "qmd path used") })} {memoryTestResult.results.length > 0 ? (
    {memoryTestResult.results.map((result, index) => (
  • {result.path}:{result.lineStart}

    {result.snippet}

  • ))}
) : ( {t("memory.noMatchingMemory", "No matching memory found.")} )}
)}
{/* Backend Card */}

{t("memory.currentBackendTitle", "Current Backend")}

{ backendStatus?.currentBackend === "file" ? t("memory.backendFile", "File (.fusion/memory/, agent//memory/)") : backendStatus?.currentBackend === "readonly" ? t("memory.backendReadonly", "Read-Only") : backendStatus?.currentBackend === "qmd" ? t("memory.backendQmd", "QMD (Quantized Memory Distillation)") : (backendStatus?.currentBackend ?? "unknown") }
{backendStatus?.capabilities?.readable && ( {t("memory.capReadable", "Readable")} )} {backendStatus?.capabilities?.writable && ( {t("memory.capWritable", "Writable")} )} {backendStatus?.capabilities?.supportsAtomicWrite && ( {t("memory.capAtomicWrites", "Atomic Writes")} )} {backendStatus?.capabilities?.persistent && ( {t("memory.capPersistent", "Persistent")} )}
{/* Health Status Card */} {auditReport && (

{t("memory.healthStatusTitle", "Health Status")}

{auditReport.health === "healthy" ? t("memory.healthHealthy", "Healthy") : auditReport.health === "warning" ? t("memory.healthWarning", "Warning") : t("memory.healthIssues", "Issues Found")}
{t("memory.workingMemoryLabel", "Working Memory")}
{t("memory.sizeChars", "{{size}} chars", { size: auditReport.workingMemory.size })}
{t("memory.sectionCount", "{{count}} sections", { count: auditReport.workingMemory.sectionCount })}
{t("memory.insightsMemoryLabel", "Insights Memory")}
{t("memory.sizeChars", "{{size}} chars", { size: auditReport.insightsMemory.size })}
{t("memory.insightCount", "{{count}} insights", { count: auditReport.insightsMemory.insightCount })}
{t("memory.lastExtractionLabel", "Last Extraction")}
{auditReport.extraction.success ? ( {t("memory.extractionSuccess", "Success")} ) : ( {t("memory.extractionFailed", "Failed")} )}
{auditReport.extraction.summary || t("memory.insightsExtracted", "{{count}} insights extracted", { count: auditReport.extraction.insightCount })}
{t("memory.pruningLabel", "Pruning")}
{auditReport.pruning.applied ? ( {t("memory.pruningApplied", "Applied")} ) : ( {t("memory.pruningNotNeeded", "Not needed")} )}
{auditReport.pruning.applied && (
{auditReport.pruning.reason}
)}
)} {/* Audit Checks */} {auditReport && auditReport.checks.length > 0 && (

{t("memory.auditChecksTitle", "Audit Checks")}

{auditReport.checks.map((check) => (
{check.passed ? "✓" : "✗"}
{check.name}
{check.details}
))}
)} {/* Actions */}
{/* Note about Settings */}
{t("memory.settingsNote", "Note: Change backend type in")}
)}
)}
); }