import { useState, useEffect, useMemo, useCallback } from "react"; import type { ReactNode } from "react"; import { useConfigStore } from "@/store/config-store"; import { useTranslation } from "@/lib/i18n"; import { Modal } from "@/components/ui/Modal"; import { Brain, User, AlertTriangle, FileText, Calendar, RefreshCw, Search, Copy, Check, Trash2, ChevronDown, ChevronRight, Sparkles, Settings2, } from "lucide-react"; interface MemoryFile { name: string; filename: string; content: string; updatedAt: string; } interface MemoryEntry { text: string; created: string; last: string; } const FILE_ICONS: Record = { "MEMORY.md": Brain, "USER.md": User, "failures.md": AlertTriangle, }; const FILE_COLORS: Record = { "MEMORY.md": "#3b82f6", "USER.md": "#10b981", "failures.md": "#ef4444", }; const FILE_LABEL_KEYS: Record = { "MEMORY.md": "memory.project_memories", "USER.md": "memory.user_profile", "failures.md": "memory.failure_records", }; /** Date groups expanded by default per file */ const DEFAULT_OPEN_GROUPS = 3; /** Parse §-separated entries with `` markers */ function parseMemoryEntries(content: string): MemoryEntry[] { const entries: MemoryEntry[] = []; const sections = content.split("§").filter((s) => s.trim().length > 0); for (const section of sections) { const trimmed = section.trim(); // Match `` at the end // Tolerate extra fields after `last=` (e.g. `, project64=...`) before `-->` const markerMatch = trimmed.match( /\s*$/ ); if (markerMatch) { entries.push({ text: trimmed.slice(0, markerMatch.index).trim(), created: markerMatch[1]?.trim() ?? "", last: markerMatch[2]?.trim() ?? "", }); } else { // No date marker — treat as a standalone entry entries.push({ text: trimmed, created: "", last: "" }); } } return entries; } /** Group entries by date (descending) */ function groupByDate(entries: MemoryEntry[]): Map { const groups = new Map(); for (const entry of entries) { const dateKey = entry.created || entry.last || "other"; if (!groups.has(dateKey)) groups.set(dateKey, []); groups.get(dateKey)!.push(entry); } return new Map( [...groups.entries()].sort((a, b) => { if (a[0] === "other") return 1; if (b[0] === "other") return -1; return b[0].localeCompare(a[0]); }) ); } function formatDate(iso: string): string { if (!iso) return "—"; return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric", }); } /** Highlight search matches inside a plain-text fragment */ function highlight(text: string, q: string): ReactNode { if (!q || !text) return text; const lower = text.toLowerCase(); const parts: ReactNode[] = []; let pos = 0; let idx = lower.indexOf(q); while (idx !== -1) { if (idx > pos) parts.push(text.slice(pos, idx)); parts.push( {text.slice(idx, idx + q.length)} ); pos = idx + q.length; idx = lower.indexOf(q, pos); } if (pos < text.length) parts.push(text.slice(pos)); return parts; } /** Lightweight inline markdown: **bold**, `code`, [text](url). No external deps. */ function renderInline(text: string, q: string): ReactNode { const re = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\((?:https?:\/\/)[^)\s]+\))/g; const parts = text.split(re); return parts.map((part, i) => { if (/^\*\*[^*]+\*\*$/.test(part)) { return ( {highlight(part.slice(2, -2), q)} ); } if (/^`[^`]+`$/.test(part)) { return ( {highlight(part.slice(1, -1), q)} ); } const link = part.match(/^\[([^\]]+)\]\(((?:https?:\/\/)[^)\s]+)\)$/); if (link) { return ( {highlight(link[1] ?? "", q)} ); } return {highlight(part, q)}; }); } function EntryCard({ entry, q, onDelete, }: { entry: MemoryEntry; q: string; onDelete: () => void; }) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); const handleCopy = () => { const markCopied = () => { setCopied(true); setTimeout(() => setCopied(false), 1500); }; navigator.clipboard.writeText(entry.text).then(markCopied).catch(() => { // Fallback for non-focused/insecure contexts const ta = document.createElement("textarea"); ta.value = entry.text; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); try { document.execCommand("copy"); markCopied(); } finally { document.body.removeChild(ta); } }); }; return (

{renderInline(entry.text, q)}

{/* Hover actions: copy / delete */}
); } function MemoryFileSection({ file, entries, q, onDeleteEntry, }: { file: MemoryFile; entries: MemoryEntry[]; q: string; onDeleteEntry: (entry: MemoryEntry) => void; }) { const { t } = useTranslation(); const Icon = FILE_ICONS[file.filename] || FileText; const color = FILE_COLORS[file.filename] || "#6b7280"; // Collapse overrides: user toggles win over the default (first N groups open) const [openOverrides, setOpenOverrides] = useState>({}); const grouped = useMemo(() => groupByDate(entries), [entries]); const labelKey = FILE_LABEL_KEYS[file.filename]; const toggleGroup = (key: string, currentOpen: boolean) => setOpenOverrides((prev) => ({ ...prev, [key]: !currentOpen })); return (
{/* File header */}

{labelKey ? t(labelKey) : file.name}

{file.filename} {t("memory.entries_count", String(entries.length))} {file.updatedAt && ( {formatDate(file.updatedAt)} )}
{/* Entries grouped by date */} {entries.length === 0 ? (

{t("memory.empty_file")}

) : (
{[...grouped.entries()].map(([dateKey, dateEntries], groupIdx) => { // Searching forces all matched groups open; otherwise first N open by default const open = q ? true : openOverrides[dateKey] ?? groupIdx < DEFAULT_OPEN_GROUPS; return (
{/* Date header (click to collapse/expand) */} {/* Timeline entries */} {open && (
{dateEntries.map((entry, idx) => ( onDeleteEntry(entry)} /> ))}
)}
); })}
)}
); } export function MemoryPage() { const { t } = useTranslation(); const { initialized, allModels, allProviders } = useConfigStore(); const [files, setFiles] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const [filter, setFilter] = useState(""); const [deleteTarget, setDeleteTarget] = useState<{ filename: string; entry: MemoryEntry } | null>(null); const [deleting, setDeleting] = useState(false); // One-click optimize (memory consolidation) const [optimizing, setOptimizing] = useState(false); const [optimizeMsg, setOptimizeMsg] = useState<{ ok: boolean; text: string } | null>(null); const [optimizeElapsed, setOptimizeElapsed] = useState(0); // Auto-write memory model config const [showConfig, setShowConfig] = useState(false); const [cfg, setCfg] = useState<{ llmModelOverride: string; llmThinkingOverride: string; consolidationTimeoutMs: number; memoryCharLimit: number; userCharLimit: number; memoryOverflowStrategy: string }>( { llmModelOverride: "", llmThinkingOverride: "off", consolidationTimeoutMs: 600000, memoryCharLimit: 5000, userCharLimit: 5000, memoryOverflowStrategy: "auto-consolidate" } ); const [cfgSaving, setCfgSaving] = useState(false); const [cfgMsg, setCfgMsg] = useState<{ ok: boolean; text: string } | null>(null); // Capacity status (per-target chars vs limit) for progress bars const [status, setStatus] = useState<{ filename: string; target: string; chars: number; limit: number }[]>([]); // Models eligible as the memory-write model: custom providers, plus built-in // providers that have an API key actually saved (hasAuth). Built-ins without // a key can't run, so they're excluded from the dropdown. const eligibleModels = useMemo(() => { const usableProviderIds = new Set( allProviders.filter((p) => p.type === "custom" || p.hasAuth).map((p) => p.id) ); return allModels.filter((m) => usableProviderIds.has(m.providerId)); }, [allModels, allProviders]); const loadStatus = useCallback(() => { fetch("/api/pi/memory/status") .then((r) => r.json()) .then((s: { targets?: { filename: string; target: string; chars: number; limit: number }[] }) => setStatus(s.targets ?? [])) .catch(() => {}); }, []); const loadAll = useCallback(() => { if (!initialized) return; setRefreshing(true); fetch("/api/pi/memory") .then((r) => r.json()) .then((data) => { setFiles(data); setError(null); }) .catch((e) => setError(e.message)) .finally(() => { setLoading(false); setRefreshing(false); }); }, [initialized]); useEffect(() => { loadAll(); }, [loadAll]); useEffect(() => { loadStatus(); }, [loadStatus]); // Load the hermes auto-write model config once. useEffect(() => { fetch("/api/pi/memory/config") .then((r) => r.json()) .then((c: { llmModelOverride?: string; llmThinkingOverride?: string; consolidationTimeoutMs?: number; memoryCharLimit?: number; userCharLimit?: number; memoryOverflowStrategy?: string }) => setCfg({ llmModelOverride: c.llmModelOverride ?? "", llmThinkingOverride: c.llmThinkingOverride ?? "off", consolidationTimeoutMs: c.consolidationTimeoutMs ?? 600000, memoryCharLimit: c.memoryCharLimit ?? 5000, userCharLimit: c.userCharLimit ?? 5000, memoryOverflowStrategy: c.memoryOverflowStrategy ?? "auto-consolidate", }) ) .catch(() => {}); }, []); const formatBytes = (n: number) => { const abs = Math.abs(n); if (abs >= 1024) return `${(n / 1024).toFixed(1)} KB`; return `${n} B`; }; const handleOptimize = async () => { setOptimizing(true); setOptimizeMsg(null); setOptimizeElapsed(0); // While the consolidation child runs, poll capacity + elapsed so the user // sees the bars move and a running timer instead of a frozen spinner. const startedAt = Date.now(); const timer = window.setInterval(() => { setOptimizeElapsed(Math.round((Date.now() - startedAt) / 1000)); loadStatus(); }, 2000); try { const res = await fetch("/api/pi/memory/optimize", { method: "POST" }); const result = (await res.json()) as { success: boolean; freedBytes?: number; message?: string }; if (result.success) { const freed = result.freedBytes ?? 0; setOptimizeMsg({ ok: true, text: freed > 0 ? t("memory.optimize_done", formatBytes(freed)) : t("memory.optimize_none") }); loadAll(); loadStatus(); } else { setOptimizeMsg({ ok: false, text: t("memory.optimize_failed", result.message ?? "") }); } } catch (e) { setOptimizeMsg({ ok: false, text: t("memory.optimize_failed", e instanceof Error ? e.message : "") }); } finally { window.clearInterval(timer); setOptimizing(false); } }; const handleSaveConfig = async () => { setCfgSaving(true); setCfgMsg(null); try { const res = await fetch("/api/pi/memory/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ llmModelOverride: cfg.llmModelOverride.trim(), llmThinkingOverride: cfg.llmThinkingOverride, consolidationTimeoutMs: cfg.consolidationTimeoutMs, memoryCharLimit: cfg.memoryCharLimit, userCharLimit: cfg.userCharLimit, memoryOverflowStrategy: cfg.memoryOverflowStrategy, }), }); const { success } = (await res.json()) as { success: boolean }; setCfgMsg({ ok: !!success, text: success ? t("memory.config_saved") : t("memory.config_save_failed") }); if (success) { loadStatus(); // Close the modal shortly after showing the success message. setTimeout(() => setShowConfig(false), 600); } } catch { setCfgMsg({ ok: false, text: t("memory.config_save_failed") }); } finally { setCfgSaving(false); } }; const handleDelete = async () => { if (!deleteTarget) return; setDeleting(true); try { const res = await fetch("/api/pi/memory/delete-entry", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filename: deleteTarget.filename, text: deleteTarget.entry.text }), }); const result = await res.json(); if (result.success) { setDeleteTarget(null); loadAll(); } else { alert(t("memory.delete_failed")); } } catch { alert(t("memory.delete_failed")); } finally { setDeleting(false); } }; // Parse all files once; stats + search operate on parsed entries const parsed = useMemo( () => files .filter((f) => f.content) .map((f) => ({ file: f, entries: parseMemoryEntries(f.content) })), [files] ); const q = filter.trim().toLowerCase(); const visible = useMemo(() => { if (!q) return parsed; return parsed .map(({ file, entries }) => ({ file, entries: entries.filter((e) => e.text.toLowerCase().includes(q)), })) .filter(({ entries }) => entries.length > 0); }, [parsed, q]); if (loading) { return (
); } if (error) { return (

{t("memory.load_failed")}: {error}

); } const totalEntries = parsed.reduce((s, p) => s + p.entries.length, 0); const totalChars = parsed.reduce((sum, item) => sum + item.file.content.length, 0); const latestFile = [...parsed].sort((a, b) => (b.file.updatedAt || "").localeCompare(a.file.updatedAt || ""))[0]?.file; return (
{t("memory.knowledge_synced")}

{t("memory.title")}

{t("memory.summary", String(totalEntries), String(parsed.length))}

{(optimizing || optimizeMsg || status.length > 0) && (
{/* Per-target capacity bars */}
{status.map((s) => { const pct = s.limit > 0 ? Math.min(100, Math.round((s.chars / s.limit) * 100)) : 0; const over = s.chars > s.limit; const barColor = over ? "#ef4444" : pct > 80 ? "#f59e0b" : "#10b981"; const label = FILE_LABEL_KEYS[s.filename] ? t(FILE_LABEL_KEYS[s.filename]!) : s.filename; return (
{label} · {s.filename} {s.chars} / {s.limit} ({pct}%)
); })}
{/* Optimize progress / result line */} {optimizing && (
{t("memory.optimizing")} · {optimizeElapsed}s
)} {!optimizing && optimizeMsg && (
{optimizeMsg.text}
)}
)} {parsed.length === 0 ? (

{t("memory.no_memory")}

{t("memory.no_memory_desc")}

) : ( <>
{t("memory.files_indexed")}{parsed.length}{t("memory.summary", String(totalEntries), String(parsed.length))}
{t("memory.entry_count")}{totalEntries}{formatDate(latestFile?.updatedAt || "")}
{t("memory.text_volume")}{totalChars > 1000 ? `${(totalChars / 1000).toFixed(1)}K` : totalChars}{t("memory.characters_indexed")}
{t("memory.channels")} {parsed.map(({ file, entries }) => { const Icon = FILE_ICONS[file.filename] || FileText; return {file.filename}{entries.length}; })}
{/* Search */}
setFilter(e.target.value)} placeholder={t("memory.search_placeholder")} className="w-full rounded-lg border py-2 pl-9 pr-3 text-sm outline-none focus:ring-1 focus:ring-blue-500" style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--card-border)", color: "var(--page-text)", }} />
{visible.length === 0 ? (

{t("memory.no_results")}

) : (
{visible.map(({ file, entries }) => (
setDeleteTarget({ filename: file.filename, entry })} />
))}
)} )} {/* Auto-write memory model config */} !cfgSaving && setShowConfig(false)} title={t("memory.config_title")} size="md" >

{t("memory.config_desc")}

setCfg({ ...cfg, consolidationTimeoutMs: Math.max(60, Number(e.target.value) || 0) * 1000 })} className="w-full rounded-lg border px-3 py-1.5 text-sm outline-none focus:ring-1 focus:ring-blue-500" style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--card-border)", color: "var(--page-text)" }} />
{/* Capacity limits */}
setCfg({ ...cfg, memoryCharLimit: Math.max(1000, Number(e.target.value) || 0) })} className="w-full rounded-lg border px-3 py-1.5 text-sm outline-none focus:ring-1 focus:ring-blue-500" style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--card-border)", color: "var(--page-text)" }} />

{t("memory.config_mem_limit_hint", String(cfg.memoryCharLimit * 2))}

setCfg({ ...cfg, userCharLimit: Math.max(1000, Number(e.target.value) || 0) })} className="w-full rounded-lg border px-3 py-1.5 text-sm outline-none focus:ring-1 focus:ring-blue-500" style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--card-border)", color: "var(--page-text)" }} />
{cfgMsg && ( {cfgMsg.text} )}
{/* Delete entry confirm */} !deleting && setDeleteTarget(null)} title={t("memory.delete_title")} size="md" > {deleteTarget && (

{t("memory.delete_confirm")}

{deleteTarget.entry.text.length > 300 ? deleteTarget.entry.text.slice(0, 300) + "…" : deleteTarget.entry.text}

{t("memory.delete_note")}

)}
); }