diff --git a/app/src/components/intelligence/MemoryGraphMap.tsx b/app/src/components/intelligence/MemoryGraphMap.tsx new file mode 100644 index 000000000..6cef5b5dd --- /dev/null +++ b/app/src/components/intelligence/MemoryGraphMap.tsx @@ -0,0 +1,372 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import type { GraphRelation } from '../../utils/tauriCommands'; + +interface MemoryGraphMapProps { + relations: GraphRelation[]; + loading?: boolean; +} + +interface GraphNode { + id: string; + label: string; + namespace: string | null; + connectionCount: number; + x: number; + y: number; + vx: number; + vy: number; +} + +interface GraphEdge { + source: string; + target: string; + predicate: string; +} + +const NAMESPACE_COLORS = [ + '#4A83DD', // ocean blue + '#4DC46F', // sage green + '#E8A838', // amber + '#9B8AFB', // lavender + '#F56565', // coral + '#7DD3FC', // sky + '#FDA4AF', // rose + '#6EE7B7', // mint +]; + +const WIDTH = 800; +const HEIGHT = 500; +const MAX_NODES = 100; +const MAX_EDGES = 200; + +function truncate(s: string, max = 15): string { + return s.length > max ? s.slice(0, max - 1) + '…' : s; +} + +function buildGraph(relations: GraphRelation[]): { nodes: GraphNode[]; edges: GraphEdge[] } { + // Cap edges by evidence count descending + const sorted = [...relations].sort((a, b) => b.evidenceCount - a.evidenceCount); + const cappedRelations = sorted.slice(0, MAX_EDGES); + + // Collect unique entity ids + const entitySet = new Map(); + + for (const r of cappedRelations) { + const subKey = r.subject.toLowerCase(); + const objKey = r.object.toLowerCase(); + + const existing = entitySet.get(subKey); + entitySet.set(subKey, { namespace: r.namespace, count: (existing?.count ?? 0) + 1 }); + + const existingObj = entitySet.get(objKey); + entitySet.set(objKey, { + namespace: existingObj?.namespace ?? r.namespace, + count: (existingObj?.count ?? 0) + 1, + }); + } + + // Sort by connection count, cap at MAX_NODES + const sortedEntities = [...entitySet.entries()].sort((a, b) => b[1].count - a[1].count); + const cappedEntities = sortedEntities.slice(0, MAX_NODES); + const allowedIds = new Set(cappedEntities.map(([id]) => id)); + + const nodes: GraphNode[] = cappedEntities.map(([id, info]) => ({ + id, + label: id, + namespace: info.namespace, + connectionCount: info.count, + x: 80 + Math.random() * (WIDTH - 160), + y: 80 + Math.random() * (HEIGHT - 160), + vx: 0, + vy: 0, + })); + + const edges: GraphEdge[] = cappedRelations + .filter(r => allowedIds.has(r.subject.toLowerCase()) && allowedIds.has(r.object.toLowerCase())) + .map(r => ({ + source: r.subject.toLowerCase(), + target: r.object.toLowerCase(), + predicate: r.predicate, + })); + + return { nodes, edges }; +} + +function runSimulation(nodes: GraphNode[], edges: GraphEdge[], iterations = 150): GraphNode[] { + const nodeMap = new Map(nodes.map(n => [n.id, { ...n }])); + const nodeList = [...nodeMap.values()]; + + const REPULSION = 3500; + const ATTRACTION = 0.04; + const CENTER_FORCE = 0.012; + const DAMPING = 0.75; + const cx = WIDTH / 2; + const cy = HEIGHT / 2; + + for (let iter = 0; iter < iterations; iter++) { + for (let i = 0; i < nodeList.length; i++) { + for (let j = i + 1; j < nodeList.length; j++) { + const a = nodeList[i]; + const b = nodeList[j]; + const dx = b.x - a.x || 0.01; + const dy = b.y - a.y || 0.01; + const dist2 = dx * dx + dy * dy; + const force = REPULSION / (dist2 + 1); + const fx = (dx / Math.sqrt(dist2 + 1)) * force; + const fy = (dy / Math.sqrt(dist2 + 1)) * force; + a.vx -= fx; + a.vy -= fy; + b.vx += fx; + b.vy += fy; + } + } + for (const edge of edges) { + const a = nodeMap.get(edge.source); + const b = nodeMap.get(edge.target); + if (!a || !b) continue; + const dx = b.x - a.x; + const dy = b.y - a.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + const delta = dist - 120; + const fx = (dx / dist) * delta * ATTRACTION; + const fy = (dy / dist) * delta * ATTRACTION; + a.vx += fx; + a.vy += fy; + b.vx -= fx; + b.vy -= fy; + } + for (const n of nodeList) { + n.vx += (cx - n.x) * CENTER_FORCE; + n.vy += (cy - n.y) * CENTER_FORCE; + n.vx *= DAMPING; + n.vy *= DAMPING; + n.x = Math.max(40, Math.min(WIDTH - 40, n.x + n.vx)); + n.y = Math.max(40, Math.min(HEIGHT - 40, n.y + n.vy)); + } + } + + return nodeList; +} + +export function MemoryGraphMap({ relations, loading }: MemoryGraphMapProps) { + const [nodes, setNodes] = useState([]); + const [edges, setEdges] = useState([]); + const [hoveredEdge, setHoveredEdge] = useState(null); + const [selectedNode, setSelectedNode] = useState(null); + const [namespacePalette, setNamespacePalette] = useState>(new Map()); + // Build graph data from relations (synchronous, deterministic) + const { initialNodes, initialEdges, palette } = useMemo(() => { + if (relations.length === 0) { + return { + initialNodes: [] as GraphNode[], + initialEdges: [] as GraphEdge[], + palette: new Map(), + }; + } + const { nodes: rawNodes, edges: rawEdges } = buildGraph(relations); + const namespaces = [...new Set(rawNodes.map(n => n.namespace ?? '__none__'))]; + const p = new Map(); + namespaces.forEach((ns, i) => { + p.set(ns, NAMESPACE_COLORS[i % NAMESPACE_COLORS.length]); + }); + const simulated = runSimulation(rawNodes, rawEdges); + return { initialNodes: simulated, initialEdges: rawEdges, palette: p }; + }, [relations]); + + // Sync memo results into state (needed for interactive selection/hover) + useEffect(() => { + setNodes(initialNodes); + setEdges(initialEdges); + setNamespacePalette(palette); + }, [initialNodes, initialEdges, palette]); + + const getNodeColor = useCallback( + (node: GraphNode): string => { + const ns = node.namespace ?? '__none__'; + return namespacePalette.get(ns) ?? NAMESPACE_COLORS[0]; + }, + [namespacePalette] + ); + + const nodeMap = new Map(nodes.map(n => [n.id, n])); + + const centerNodeId = + nodes.find(n => n.id === 'user' || n.id === 'self' || n.id === 'you')?.id ?? + (nodes.length > 0 ? nodes[0].id : null); + + // Connected node ids for selected highlight + const connectedIds = selectedNode + ? new Set( + edges + .filter(e => e.source === selectedNode || e.target === selectedNode) + .flatMap(e => [e.source, e.target]) + ) + : null; + + const namespaceEntries = [...namespacePalette.entries()].filter(([ns]) => ns !== '__none__'); + + if (loading) { + return ( +
+

Memory Graph

+
+
+
+ Loading graph… +
+
+
+ ); + } + + if (nodes.length === 0) { + return ( +
+

Memory Graph

+
+

No memory graph data yet

+
+
+ ); + } + + const maxConn = Math.max(...nodes.map(n => n.connectionCount), 1); + + return ( +
+

Memory Graph

+ +
+ setSelectedNode(null)}> + + + + + + + {/* Edges */} + {edges.map((edge, i) => { + const src = nodeMap.get(edge.source); + const tgt = nodeMap.get(edge.target); + if (!src || !tgt) return null; + + const isHighlighted = + selectedNode === null || edge.source === selectedNode || edge.target === selectedNode; + + const midX = (src.x + tgt.x) / 2; + const midY = (src.y + tgt.y) / 2; + + return ( + + setHoveredEdge(i)} + onMouseLeave={() => setHoveredEdge(null)} + /> + {/* Edge label */} + + {truncate(edge.predicate, 18)} + + + ); + })} + + {/* Nodes */} + {nodes.map(node => { + const r = 8 + (node.connectionCount / maxConn) * 18; + const color = getNodeColor(node); + const isCenter = node.id === centerNodeId; + const isSelected = selectedNode === node.id; + const isDimmed = selectedNode !== null && !connectedIds?.has(node.id); + + return ( + { + e.stopPropagation(); + setSelectedNode(selectedNode === node.id ? null : node.id); + }}> + {(isCenter || isSelected) && ( + + )} + + + {isCenter && node.id !== 'you' ? 'You' : truncate(node.label)} + + + ); + })} + +
+ + {/* Legend */} + {namespaceEntries.length > 0 && ( +
+ {namespaceEntries.map(([ns, color]) => ( +
+
+ {ns} +
+ ))} + {namespacePalette.has('__none__') && ( +
+
+ uncategorized +
+ )} +
+ )} + +

+ {nodes.length} entities · {edges.length} relations · click a node to highlight connections +

+
+ ); +} diff --git a/app/src/components/intelligence/MemoryHeatmap.tsx b/app/src/components/intelligence/MemoryHeatmap.tsx new file mode 100644 index 000000000..a7f77b8c7 --- /dev/null +++ b/app/src/components/intelligence/MemoryHeatmap.tsx @@ -0,0 +1,239 @@ +import { useMemo, useState } from 'react'; + +interface MemoryHeatmapProps { + /** Array of document/relation timestamps (unix epoch seconds). */ + timestamps: number[]; + loading?: boolean; +} + +const MONTHS = 8; +const DAYS_PER_WEEK = 7; +const CELL_GAP = 2; +const DAY_LABELS = ['', 'Mon', '', 'Wed', '', 'Fri', '']; + +const INTENSITY_COLORS = [ + 'rgba(255,255,255,0.04)', // 0 events + 'rgba(74,131,221,0.25)', // 1 + 'rgba(74,131,221,0.45)', // 2-3 + 'rgba(74,131,221,0.65)', // 4-6 + 'rgba(74,131,221,0.85)', // 7+ +]; + +function getIntensity(count: number): number { + if (count === 0) return 0; + if (count === 1) return 1; + if (count <= 3) return 2; + if (count <= 6) return 3; + return 4; +} + +function dateToKey(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +function formatDate(date: Date): string { + return date.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +export function MemoryHeatmap({ timestamps, loading }: MemoryHeatmapProps) { + const [hoveredCell, setHoveredCell] = useState<{ + date: Date; + count: number; + x: number; + y: number; + } | null>(null); + + const { grid, monthLabels, totalEvents, maxDailyCount, totalWeeks } = useMemo(() => { + // The window: 6 months ago through today + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const rangeStart = new Date(today); + rangeStart.setMonth(rangeStart.getMonth() - MONTHS); + rangeStart.setDate(1); // start of that month + + // Align to the Sunday of rangeStart's week + const startDate = new Date(rangeStart); + startDate.setDate(startDate.getDate() - startDate.getDay()); + + // Count timestamps that fall anywhere (not limited to the 6-month window) + // — this means ingesting old data still lights up that old date. + const countMap = new Map(); + let total = 0; + let maxCount = 0; + + for (const ts of timestamps) { + const date = new Date(ts > 9999999999 ? ts : ts * 1000); + const key = dateToKey(date); + const prev = countMap.get(key) ?? 0; + const next = prev + 1; + countMap.set(key, next); + // Only count towards total/max if inside our display range + if (date >= startDate && date <= today) { + total++; + if (next > maxCount) maxCount = next; + } + } + + // Build grid + const cells: { date: Date; count: number; weekIdx: number; dayIdx: number }[] = []; + const months: { label: string; weekIdx: number }[] = []; + let lastMonth = -1; + let weekIdx = 0; + + const cursor = new Date(startDate); + while (cursor <= today) { + const d = cursor.getDay(); // 0=Sun ... 6=Sat + + if (d === 0 && cells.length > 0) weekIdx++; + + const cellDate = new Date(cursor); + const key = dateToKey(cellDate); + cells.push({ date: cellDate, count: countMap.get(key) ?? 0, weekIdx, dayIdx: d }); + + // Track month labels (on the first Sunday-row cell of each new month) + if (cellDate.getMonth() !== lastMonth && d === 0) { + lastMonth = cellDate.getMonth(); + months.push({ label: cellDate.toLocaleDateString('en-US', { month: 'short' }), weekIdx }); + } + + cursor.setDate(cursor.getDate() + 1); + } + + return { + grid: cells, + monthLabels: months, + totalEvents: total, + maxDailyCount: maxCount, + totalWeeks: weekIdx + 1, + }; + }, [timestamps]); + + // Dynamic cell size: fill available width (parent is ~100%). + // We use a viewBox + 100% width so SVG scales to fit container. + const DAY_LABEL_WIDTH = 28; + const cellSize = 11; + const svgWidth = DAY_LABEL_WIDTH + totalWeeks * (cellSize + CELL_GAP) + CELL_GAP; + const svgHeight = DAYS_PER_WEEK * (cellSize + CELL_GAP) + 22; + + if (loading) { + return ( +
+

Ingestion Activity

+
+
+ ); + } + + return ( +
+
+
+

Ingestion Activity

+

+ {totalEvents} event{totalEvents !== 1 ? 's' : ''} over the last {MONTHS} months + {maxDailyCount > 0 && <> · peak: {maxDailyCount}/day} +

+
+
+ Less + {INTENSITY_COLORS.map((color, i) => ( +
+ ))} + More +
+
+ + + {/* Day labels */} + {DAY_LABELS.map((label, i) => + label ? ( + + {label} + + ) : null + )} + + {/* Month labels */} + {monthLabels.map((m, i) => ( + + {m.label} + + ))} + + {/* Cells */} + {grid.map((cell, i) => { + const x = DAY_LABEL_WIDTH + cell.weekIdx * (cellSize + CELL_GAP); + const y = 18 + cell.dayIdx * (cellSize + CELL_GAP); + const intensity = getIntensity(cell.count); + + return ( + { + const rect = (e.target as SVGRectElement).getBoundingClientRect(); + setHoveredCell({ + date: cell.date, + count: cell.count, + x: rect.left + rect.width / 2, + y: rect.top, + }); + }} + onMouseLeave={() => setHoveredCell(null)} + /> + ); + })} + + + {/* Tooltip */} + {hoveredCell && ( +
+ + {hoveredCell.count} event{hoveredCell.count !== 1 ? 's' : ''} + {' '} + on {formatDate(hoveredCell.date)} +
+ )} +
+ ); +} diff --git a/app/src/components/intelligence/MemoryInsights.tsx b/app/src/components/intelligence/MemoryInsights.tsx new file mode 100644 index 000000000..68eebb780 --- /dev/null +++ b/app/src/components/intelligence/MemoryInsights.tsx @@ -0,0 +1,291 @@ +import { useMemo, useState } from 'react'; + +import type { GraphRelation } from '../../utils/tauriCommands'; + +interface MemoryInsightsProps { + relations: GraphRelation[]; + loading?: boolean; +} + +/** + * Categorizes graph relations into insight types based on their predicates. + * This gives the user a structured view of what the system has learned. + */ +type InsightCategory = 'facts' | 'preferences' | 'relationships' | 'skills' | 'opinions' | 'other'; + +interface InsightGroup { + category: InsightCategory; + label: string; + icon: string; + color: string; + bgColor: string; + borderColor: string; + items: InsightItem[]; +} + +interface InsightItem { + subject: string; + predicate: string; + object: string; + evidenceCount: number; + namespace: string | null; + updatedAt: number; +} + +const PREDICATE_CATEGORIES: Record = { + // Facts + is: 'facts', + 'is a': 'facts', + was: 'facts', + has: 'facts', + contains: 'facts', + located_in: 'facts', + created_by: 'facts', + founded: 'facts', + built: 'facts', + // Preferences + prefers: 'preferences', + likes: 'preferences', + dislikes: 'preferences', + wants: 'preferences', + uses: 'preferences', + favors: 'preferences', + avoids: 'preferences', + // Relationships + knows: 'relationships', + works_with: 'relationships', + reports_to: 'relationships', + manages: 'relationships', + collaborates_with: 'relationships', + member_of: 'relationships', + part_of: 'relationships', + belongs_to: 'relationships', + // Skills + skilled_in: 'skills', + experienced_with: 'skills', + certified_in: 'skills', + specializes_in: 'skills', + proficient_in: 'skills', + // Opinions + thinks: 'opinions', + believes: 'opinions', + considers: 'opinions', + feels: 'opinions', + views: 'opinions', +}; + +function categorize(predicate: string): InsightCategory { + const normalized = predicate.toLowerCase().replace(/[_-]/g, ' ').trim(); + if (PREDICATE_CATEGORIES[normalized]) return PREDICATE_CATEGORIES[normalized]; + + // Fuzzy match: check if predicate contains known keywords + for (const [key, category] of Object.entries(PREDICATE_CATEGORIES)) { + if (normalized.includes(key) || key.includes(normalized)) return category; + } + + return 'other'; +} + +const CATEGORY_CONFIG: Record< + InsightCategory, + { label: string; icon: string; color: string; bgColor: string; borderColor: string } +> = { + facts: { + label: 'Known Facts', + icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z', + color: 'text-emerald-300', + bgColor: 'bg-emerald-500/10', + borderColor: 'border-emerald-500/20', + }, + preferences: { + label: 'Preferences', + icon: 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z', + color: 'text-rose-300', + bgColor: 'bg-rose-500/10', + borderColor: 'border-rose-500/20', + }, + relationships: { + label: 'Relationships', + icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z', + color: 'text-primary-300', + bgColor: 'bg-primary-500/10', + borderColor: 'border-primary-500/20', + }, + skills: { + label: 'Skills & Expertise', + icon: 'M13 10V3L4 14h7v7l9-11h-7z', + color: 'text-amber-300', + bgColor: 'bg-amber-500/10', + borderColor: 'border-amber-500/20', + }, + opinions: { + label: 'Opinions & Beliefs', + icon: 'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z', + color: 'text-lavender-300', + bgColor: 'bg-lavender-500/10', + borderColor: 'border-lavender-500/20', + }, + other: { + label: 'Other Insights', + icon: 'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z', + color: 'text-stone-300', + bgColor: 'bg-stone-500/10', + borderColor: 'border-stone-500/20', + }, +}; + +export function MemoryInsights({ relations, loading }: MemoryInsightsProps) { + const [expandedCategory, setExpandedCategory] = useState(null); + + const groups = useMemo(() => { + const buckets = new Map(); + + for (const rel of relations) { + const category = categorize(rel.predicate); + const items = buckets.get(category) ?? []; + items.push({ + subject: rel.subject, + predicate: rel.predicate, + object: rel.object, + evidenceCount: rel.evidenceCount, + namespace: rel.namespace, + updatedAt: rel.updatedAt, + }); + buckets.set(category, items); + } + + // Sort items within each bucket by evidence count descending + for (const items of buckets.values()) { + items.sort((a, b) => b.evidenceCount - a.evidenceCount); + } + + const categoryOrder: InsightCategory[] = [ + 'facts', + 'preferences', + 'relationships', + 'skills', + 'opinions', + 'other', + ]; + + return categoryOrder + .filter(cat => (buckets.get(cat)?.length ?? 0) > 0) + .map(cat => ({ category: cat, ...CATEGORY_CONFIG[cat], items: buckets.get(cat)! })); + }, [relations]); + + if (loading) { + return ( +
+

Intelligent Insights

+
+ {[1, 2, 3].map(i => ( +
+ ))} +
+
+ ); + } + + if (groups.length === 0) { + return ( +
+

Intelligent Insights

+

+ No insights yet. Ingest documents to extract facts, preferences, and relationships. +

+
+ ); + } + + return ( +
+
+
+

Intelligent Insights

+

+ Extracted knowledge organized by type — {relations.length} total relations +

+
+
+ +
+ {groups.map(group => { + const isExpanded = expandedCategory === group.category; + const displayItems = isExpanded ? group.items.slice(0, 20) : group.items.slice(0, 3); + + return ( +
+ + +
+ {displayItems.map((item, idx) => ( +
+ + {item.subject} + + {item.predicate} + + {item.object} + + {item.evidenceCount > 1 && ( + + x{item.evidenceCount} + + )} +
+ ))} + {!isExpanded && group.items.length > 3 && ( +
+ +{group.items.length - 3} more +
+ )} +
+
+ ); + })} +
+
+ ); +} diff --git a/app/src/components/intelligence/MemoryStatsBar.tsx b/app/src/components/intelligence/MemoryStatsBar.tsx new file mode 100644 index 000000000..db6dc478c --- /dev/null +++ b/app/src/components/intelligence/MemoryStatsBar.tsx @@ -0,0 +1,112 @@ +interface MemoryStatsBarProps { + totalDocs: number; + totalFiles: number; + totalNamespaces: number; + totalRelations: number; + totalSessions: number | null; + totalTokens: number | null; + /** Estimated storage in bytes (sum of document content lengths). */ + estimatedStorageBytes: number; + /** Unix-epoch seconds of the oldest document. */ + oldestDocTimestamp: number | null; + /** Unix-epoch seconds of the newest document. */ + newestDocTimestamp: number | null; + docsToday: number; + loading?: boolean; +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const value = bytes / Math.pow(1024, i); + return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`; +} + +function formatTimeAgo(epochSeconds: number): string { + const now = Date.now() / 1000; + const diff = now - epochSeconds; + if (diff < 60) return 'just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`; + if (diff < 31536000) return `${Math.floor(diff / 2592000)}mo ago`; + return `${(diff / 31536000).toFixed(1)}y ago`; +} + +function formatNumber(value: number): string { + return new Intl.NumberFormat().format(value); +} + +export function MemoryStatsBar(props: MemoryStatsBarProps) { + const { + totalDocs, + totalFiles, + totalNamespaces, + totalRelations, + totalSessions, + totalTokens, + estimatedStorageBytes, + oldestDocTimestamp, + newestDocTimestamp, + docsToday, + loading, + } = props; + + const stats = [ + { + label: 'Storage', + value: estimatedStorageBytes > 0 ? formatBytes(estimatedStorageBytes) : '--', + sub: totalFiles > 0 ? `${formatNumber(totalFiles)} files` : undefined, + color: 'text-primary-300', + }, + { + label: 'Documents', + value: formatNumber(totalDocs), + sub: docsToday > 0 ? `+${docsToday} today` : undefined, + color: 'text-emerald-300', + }, + { + label: 'Namespaces', + value: formatNumber(totalNamespaces), + sub: undefined, + color: 'text-amber-300', + }, + { + label: 'Relations', + value: formatNumber(totalRelations), + sub: undefined, + color: 'text-lavender-300', + }, + { + label: 'First Memory', + value: oldestDocTimestamp ? formatTimeAgo(oldestDocTimestamp) : '--', + sub: newestDocTimestamp ? `Latest: ${formatTimeAgo(newestDocTimestamp)}` : undefined, + color: 'text-sky-300', + }, + { + label: 'Sessions', + value: totalSessions !== null ? formatNumber(totalSessions) : '--', + sub: totalTokens !== null ? `${formatNumber(totalTokens)} tokens` : undefined, + color: 'text-rose-300', + }, + ]; + + return ( +
+ {stats.map(stat => ( +
+
+ {stat.label} +
+
+ {loading ?
: stat.value} +
+ {stat.sub &&
{stat.sub}
} +
+ ))} +
+ ); +} diff --git a/app/src/components/intelligence/MemoryWorkspace.tsx b/app/src/components/intelligence/MemoryWorkspace.tsx index 3be1bdc09..57a527fdb 100644 --- a/app/src/components/intelligence/MemoryWorkspace.tsx +++ b/app/src/components/intelligence/MemoryWorkspace.tsx @@ -15,81 +15,69 @@ import { memoryQueryNamespace, memoryRecallNamespace, } from '../../utils/tauriCommands'; +import { MemoryGraphMap } from './MemoryGraphMap'; +import { MemoryHeatmap } from './MemoryHeatmap'; +import { MemoryInsights } from './MemoryInsights'; +import { MemoryStatsBar } from './MemoryStatsBar'; type MemoryDoc = { documentId: string; namespace: string; title?: string; raw: unknown }; -type MemoryNode = { label: string; value?: string }; - interface MemoryWorkspaceProps { onToast: (toast: Omit) => void; } +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + function asRecord(value: unknown): Record | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null; - } + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; return value as Record; } function pickString(record: Record, keys: string[]): string | undefined { for (const key of keys) { const value = record[key]; - if (typeof value === 'string' && value.trim()) { - return value; - } + if (typeof value === 'string' && value.trim()) return value; } return undefined; } function findDocumentRows(payload: unknown): unknown[] { - if (Array.isArray(payload)) { - return payload; - } - + if (Array.isArray(payload)) return payload; const root = asRecord(payload); if (!root) return []; - - const candidates = ['documents', 'items', 'results']; - for (const key of candidates) { + for (const key of ['documents', 'items', 'results']) { const value = root[key]; if (Array.isArray(value)) return value; } - const data = asRecord(root.data); if (!data) return []; - - for (const key of candidates) { + for (const key of ['documents', 'items', 'results']) { const value = data[key]; if (Array.isArray(value)) return value; } - return []; } function normalizeMemoryDocuments(payload: unknown): MemoryDoc[] { - const rows = findDocumentRows(payload); - - return rows + return findDocumentRows(payload) .map(row => { const record = asRecord(row); if (!record) return null; - const documentId = pickString(record, ['documentId', 'document_id', 'id']); const namespace = pickString(record, ['namespace']); const title = pickString(record, ['title', 'name']); - if (!documentId || !namespace) return null; - return { documentId, namespace, title, raw: row } as MemoryDoc; }) .filter((doc): doc is MemoryDoc => Boolean(doc)); } -function parseTimestamp(raw: unknown): Date | null { +function extractTimestamp(raw: unknown): number | null { const record = asRecord(raw); if (!record) return null; - - const maybeDateKeys = [ + for (const key of [ 'createdAt', 'created_at', 'updatedAt', @@ -97,26 +85,27 @@ function parseTimestamp(raw: unknown): Date | null { 'timestamp', 'insertedAt', 'inserted_at', - ]; - - for (const key of maybeDateKeys) { + ]) { const value = record[key]; - if (typeof value === 'number' && Number.isFinite(value)) { - const asMs = value > 9999999999 ? value : value * 1000; - const date = new Date(asMs); - if (!Number.isNaN(date.getTime())) return date; + return value > 9999999999 ? value / 1000 : value; } - if (typeof value === 'string') { const date = new Date(value); - if (!Number.isNaN(date.getTime())) return date; + if (!Number.isNaN(date.getTime())) return date.getTime() / 1000; } } - return null; } +function estimateContentSize(raw: unknown): number { + const record = asRecord(raw); + if (!record) return 0; + const content = record.content; + if (typeof content === 'string') return new TextEncoder().encode(content).length; + return 0; +} + function isSameLocalDay(left: Date, right: Date): boolean { return ( left.getFullYear() === right.getFullYear() && @@ -125,18 +114,12 @@ function isSameLocalDay(left: Date, right: Date): boolean { ); } -function formatNumber(value: number): string { - return new Intl.NumberFormat().format(value); -} +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { - const { - sessions, - memoryFiles, - entities, - isLoading: statsLoading, - refetch: refetchStats, - } = useIntelligenceStats(); + const { sessions, isLoading: statsLoading, refetch: refetchStats } = useIntelligenceStats(); const [memoryDocs, setMemoryDocs] = useState([]); const [memoryNamespaces, setMemoryNamespaces] = useState([]); @@ -144,6 +127,11 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { const [memoryWorkspaceLoading, setMemoryWorkspaceLoading] = useState(false); const [memoryWorkspaceError, setMemoryWorkspaceError] = useState(null); + const [graphRelations, setGraphRelations] = useState([]); + const [graphRelationsLoading, setGraphRelationsLoading] = useState(false); + + // Manage memory section (collapsible) + const [manageOpen, setManageOpen] = useState(false); const [selectedNamespace, setSelectedNamespace] = useState(''); const [selectedFile, setSelectedFile] = useState('memory.md'); const [selectedFileContent, setSelectedFileContent] = useState(''); @@ -160,12 +148,12 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { const [memoryNote, setMemoryNote] = useState(''); const [memoryNoteSaving, setMemoryNoteSaving] = useState(false); - const [graphRelations, setGraphRelations] = useState([]); - const [graphRelationsLoading, setGraphRelationsLoading] = useState(false); + // --------------------------------------------------------------------------- + // Data loading + // --------------------------------------------------------------------------- const loadWorkspace = useCallback(async () => { if (!isTauri()) return; - setMemoryWorkspaceLoading(true); setMemoryWorkspaceError(null); @@ -176,7 +164,6 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { aiListMemoryFiles('memory'), ]); - // Load graph relations from local store setGraphRelationsLoading(true); try { const relations = await memoryGraphQuery(); @@ -197,7 +184,6 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { if (!selectedNamespace && namespacesPayload.length > 0) { setSelectedNamespace(namespacesPayload[0]); } - if (!combinedFiles.includes(selectedFile)) { setSelectedFile(combinedFiles[0] || 'memory.md'); } @@ -213,10 +199,8 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { const loadSelectedFile = useCallback(async () => { if (!isTauri() || !selectedFile) return; - setSelectedFileLoading(true); setSelectedFileError(null); - try { const content = await aiReadMemoryFile(selectedFile); setSelectedFileContent(content); @@ -231,18 +215,20 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { useEffect(() => { loadWorkspace(); }, [loadWorkspace]); - useEffect(() => { loadSelectedFile(); }, [loadSelectedFile]); + // --------------------------------------------------------------------------- + // Management handlers + // --------------------------------------------------------------------------- + const handleDeleteMemoryDoc = useCallback( async (doc: MemoryDoc) => { const confirmed = window.confirm( `Delete document "${doc.documentId}" from namespace "${doc.namespace}"?` ); if (!confirmed) return; - try { await memoryDeleteDocument(doc.documentId, doc.namespace); await loadWorkspace(); @@ -261,10 +247,8 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { const handleQueryNamespace = useCallback(async () => { if (!selectedNamespace || !queryInput.trim()) return; - setQueryLoading(true); setMemoryActionError(null); - try { const response = await memoryQueryNamespace(selectedNamespace, queryInput.trim(), 10); setQueryResult(response); @@ -278,10 +262,8 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { const handleRecallNamespace = useCallback(async () => { if (!selectedNamespace) return; - setRecallLoading(true); setMemoryActionError(null); - try { const response = await memoryRecallNamespace(selectedNamespace, 10); setRecallResult(response ?? ''); @@ -295,10 +277,8 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { const handleSaveMemoryNote = useCallback(async () => { if (!memoryNote.trim()) return; - setMemoryNoteSaving(true); setMemoryActionError(null); - try { let existing = ''; try { @@ -306,17 +286,14 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { } catch { existing = ''; } - const timestamp = new Date().toLocaleString(); const noteBlock = `\n\n## Manual note (${timestamp})\n${memoryNote.trim()}\n`; const nextContent = existing ? `${existing}${noteBlock}` : `# Memory\n${noteBlock}`; - await aiWriteMemoryFile('memory.md', nextContent); setMemoryNote(''); await loadWorkspace(); await loadSelectedFile(); await refetchStats(); - onToast({ type: 'success', title: 'Memory Updated', @@ -329,397 +306,265 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { } }, [loadSelectedFile, loadWorkspace, memoryNote, onToast, refetchStats]); + // --------------------------------------------------------------------------- + // Derived data + // --------------------------------------------------------------------------- + const today = new Date(); - const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String( - today.getDate() - ).padStart(2, '0')}`; - const memoryFilesToday = memoryFilesList.filter(filePath => filePath.includes(todayKey)).length; - const memoryDocsToday = memoryDocs.filter(doc => { - const timestamp = parseTimestamp(doc.raw); - return timestamp ? isSameLocalDay(timestamp, today) : false; + const docsToday = memoryDocs.filter(doc => { + const ts = extractTimestamp(doc.raw); + return ts ? isSameLocalDay(new Date(ts * 1000), today) : false; }).length; - const topNamespaces = useMemo(() => { - const counter = new Map(); + const estimatedStorageBytes = useMemo( + () => memoryDocs.reduce((sum, doc) => sum + estimateContentSize(doc.raw), 0), + [memoryDocs] + ); - for (const doc of memoryDocs) { - counter.set(doc.namespace, (counter.get(doc.namespace) || 0) + 1); - } + const docTimestamps = useMemo( + () => memoryDocs.map(doc => extractTimestamp(doc.raw)).filter((t): t is number => t !== null), + [memoryDocs] + ); - return Array.from(counter.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 3) - .map(([namespace, count]) => ({ namespace, count })); - }, [memoryDocs]); + const oldestTimestamp = docTimestamps.length > 0 ? Math.min(...docTimestamps) : null; + const newestTimestamp = docTimestamps.length > 0 ? Math.max(...docTimestamps) : null; - const graphNodes = useMemo(() => { - const namespaceNodes = topNamespaces.map(item => ({ - label: item.namespace, - value: `${item.count} docs`, - })); - - // Build entity nodes from local graph store relations - const entityCounts = new Map(); + // Combine doc timestamps + graph relation updated_at for heatmap + const heatmapTimestamps = useMemo(() => { + const timestamps = [...docTimestamps]; for (const rel of graphRelations) { - const subjectType = - (rel.attrs?.entity_types as Record | undefined)?.subject ?? 'entity'; - const objectType = - (rel.attrs?.entity_types as Record | undefined)?.object ?? 'entity'; - entityCounts.set(subjectType, (entityCounts.get(subjectType) ?? 0) + 1); - entityCounts.set(objectType, (entityCounts.get(objectType) ?? 0) + 1); + if (rel.updatedAt) timestamps.push(rel.updatedAt); } + return timestamps; + }, [docTimestamps, graphRelations]); - let entityNodes: MemoryNode[]; - if (entityCounts.size > 0) { - entityNodes = Array.from(entityCounts.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 3) - .map(([type, count]) => ({ label: type, value: `${count} entities` })); - } else { - // Fallback to backend API entity counts - entityNodes = Object.entries(entities || {}) - .sort((a, b) => b[1] - a[1]) - .slice(0, 3) - .map(([type, count]) => ({ label: type, value: `${count} entities` })); - } - - return [...namespaceNodes, ...entityNodes].slice(0, 6); - }, [entities, graphRelations, topNamespaces]); + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- return ( -
-
-
-

Memory Workspace

-

- Browse memory files, inspect graph relationships, and manage stored context. -

+
+ {/* Header */} +
+
+
+

Memory

+

+ Your AI's knowledge graph, extracted insights, and ingestion activity. +

+
+
+ + {/* Stats bar */} + +
+ + {/* Knowledge Graph */} + + + {/* Intelligent Insights */} + + + {/* Activity Heatmap */} + + + {/* Collapsible: Files & Management */} +
-
- -
-
-
Files (All-Time)
-
- {memoryFiles !== null ? formatNumber(memoryFiles) : '--'} -
-
-
-
Files (Today)
-
- {formatNumber(memoryFilesToday)} -
-
-
-
Docs (All-Time)
-
- {formatNumber(memoryDocs.length)} -
-
-
-
Docs (Today)
-
- {formatNumber(memoryDocsToday)} -
-
-
- -
-
-

Sample Memory Graph

-
+ onClick={() => setManageOpen(!manageOpen)} + className="w-full flex items-center justify-between p-4 text-left hover:bg-white/5 transition-colors rounded-xl"> +
- {graphNodes.map((_, index) => { - const points = [ - [50, 12], - [80, 24], - [88, 52], - [76, 80], - [24, 80], - [12, 50], - ] as const; - const [x, y] = points[index] || [50, 90]; - - return ( - - ); - })} + className={`w-4 h-4 text-stone-400 transition-transform ${manageOpen ? 'rotate-90' : ''}`} + fill="none" + stroke="currentColor" + viewBox="0 0 24 24"> + +

Files & Management

+ + {memoryFilesList.length} files · {memoryNamespaces.length} namespaces ·{' '} + {memoryDocs.length} docs + +
+ -
- You (Core Memory) -
- - {graphNodes.map((node, index) => { - const positions = [ - 'left-1/2 top-3 -translate-x-1/2', - 'right-3 top-9', - 'right-2 top-1/2 -translate-y-1/2', - 'right-8 bottom-3', - 'left-8 bottom-3', - 'left-2 top-1/2 -translate-y-1/2', - ]; - - return ( -
-
{node.label}
- {node.value && ( -
{node.value}
+ {manageOpen && ( +
+ {/* File browser */} +
+

Memory Files

+
+
+ {memoryFilesList.length === 0 ? ( +
No files found.
+ ) : ( + memoryFilesList.map(filePath => ( + + )) + )} +
+
+ {selectedFileLoading ? ( +
Loading file...
+ ) : selectedFileError ? ( +
{selectedFileError}
+ ) : ( +
+                      {selectedFileContent || 'Empty file'}
+                    
)}
- ); - })} - - {graphNodes.length === 0 && ( -
- No graph nodes yet. Refresh memory to load.
- )} -
-
- Nodes show top namespaces and entity buckets connected to your core profile. -
+
-
-
-
- Graph relations{' '} - {graphRelations.length > 0 && ( - ({graphRelations.length}) + {/* Namespace query & management */} +
+
+

Manage Namespace

+ + + +