feat(memory): redesign memory workspace with graph, insights, heatmap (#185)

* updated memory files

* updated memory

* Refactor MemoryHeatmap component to enhance functionality and improve performance

- Updated the MemoryHeatmap component to track document/relation timestamps over the last 8 months instead of 52 weeks.
- Improved date handling by aligning the start date to the nearest Sunday and counting timestamps within the display range.
- Enhanced grid generation logic to dynamically calculate the number of weeks displayed based on the available data.
- Adjusted SVG dimensions to ensure proper scaling and responsiveness.
- Updated UI text to reflect the new time frame for event tracking.

These changes improve the accuracy and usability of the MemoryHeatmap, providing a clearer view of user activity over time.

* feat(memory): redesign memory workspace with graph, insights, heatmap

Break MemoryWorkspace into focused sub-components (MemoryStatsBar,
MemoryGraphMap, MemoryInsights, MemoryHeatmap). Fix lint errors in
MemoryGraphMap (window.rAF, unused ref). Change heatmap to fixed
8-month range with responsive SVG width.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: format MemoryGraphMap with prettier

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(tests): update MemoryWorkspace tests for refactored components

Align test assertions with the new sub-component structure
(MemoryStatsBar, MemoryGraphMap, MemoryInsights, MemoryHeatmap).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Steven Enamakel
2026-04-01 21:42:39 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 0dd4d1f60b
commit cb3f2d351c
6 changed files with 1290 additions and 443 deletions
@@ -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<string, { namespace: string | null; count: number }>();
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<GraphNode[]>([]);
const [edges, setEdges] = useState<GraphEdge[]>([]);
const [hoveredEdge, setHoveredEdge] = useState<number | null>(null);
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const [namespacePalette, setNamespacePalette] = useState<Map<string, string>>(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<string, string>(),
};
}
const { nodes: rawNodes, edges: rawEdges } = buildGraph(relations);
const namespaces = [...new Set(rawNodes.map(n => n.namespace ?? '__none__'))];
const p = new Map<string, string>();
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 (
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
<p className="text-sm font-semibold text-white mb-3">Memory Graph</p>
<div className="flex items-center justify-center" style={{ minHeight: 320 }}>
<div className="flex gap-2 items-center text-stone-400 text-sm">
<div className="w-4 h-4 rounded-full border-2 border-primary-500 border-t-transparent animate-spin" />
Loading graph
</div>
</div>
</div>
);
}
if (nodes.length === 0) {
return (
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
<p className="text-sm font-semibold text-white mb-3">Memory Graph</p>
<div className="flex items-center justify-center" style={{ minHeight: 320 }}>
<p className="text-stone-400 text-sm">No memory graph data yet</p>
</div>
</div>
);
}
const maxConn = Math.max(...nodes.map(n => n.connectionCount), 1);
return (
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
<p className="text-sm font-semibold text-white mb-3">Memory Graph</p>
<div
className="w-full overflow-hidden rounded-lg border border-white/5"
style={{ minHeight: 320 }}>
<svg
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
width="100%"
style={{ display: 'block', background: 'rgba(0,0,0,0.25)' }}
onClick={() => setSelectedNode(null)}>
<defs>
<marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="rgba(255,255,255,0.18)" />
</marker>
</defs>
{/* 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 (
<g key={i}>
<line
x1={src.x}
y1={src.y}
x2={tgt.x}
y2={tgt.y}
stroke={isHighlighted ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.05)'}
strokeWidth={isHighlighted ? 1.5 : 1}
markerEnd="url(#arrowhead)"
style={{ cursor: 'pointer', transition: 'stroke 0.15s' }}
onMouseEnter={() => setHoveredEdge(i)}
onMouseLeave={() => setHoveredEdge(null)}
/>
{/* Edge label */}
<text
x={midX}
y={midY - 4}
textAnchor="middle"
fontSize={9}
fill={
hoveredEdge === i
? 'rgba(255,255,255,0.85)'
: isHighlighted
? 'rgba(255,255,255,0.3)'
: 'rgba(255,255,255,0.08)'
}
style={{ pointerEvents: 'none', userSelect: 'none', transition: 'fill 0.15s' }}>
{truncate(edge.predicate, 18)}
</text>
</g>
);
})}
{/* 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 (
<g
key={node.id}
transform={`translate(${node.x},${node.y})`}
style={{ cursor: 'pointer' }}
onClick={e => {
e.stopPropagation();
setSelectedNode(selectedNode === node.id ? null : node.id);
}}>
{(isCenter || isSelected) && (
<circle r={r + 5} fill="none" stroke={color} strokeWidth={2} opacity={0.4} />
)}
<circle
r={r}
fill={color}
opacity={isDimmed ? 0.15 : isSelected ? 1 : 0.82}
stroke={isSelected ? 'white' : 'rgba(255,255,255,0.18)'}
strokeWidth={isSelected ? 2 : 1}
style={{ transition: 'opacity 0.15s' }}
/>
<text
y={r + 11}
textAnchor="middle"
fontSize={isCenter ? 11 : 9}
fontWeight={isCenter ? 600 : 400}
fill={isDimmed ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.85)'}
style={{ pointerEvents: 'none', userSelect: 'none', transition: 'fill 0.15s' }}>
{isCenter && node.id !== 'you' ? 'You' : truncate(node.label)}
</text>
</g>
);
})}
</svg>
</div>
{/* Legend */}
{namespaceEntries.length > 0 && (
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5">
{namespaceEntries.map(([ns, color]) => (
<div key={ns} className="flex items-center gap-1.5">
<div
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: color }}
/>
<span className="text-xs text-stone-400 truncate max-w-[120px]">{ns}</span>
</div>
))}
{namespacePalette.has('__none__') && (
<div className="flex items-center gap-1.5">
<div
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: namespacePalette.get('__none__') }}
/>
<span className="text-xs text-stone-400">uncategorized</span>
</div>
)}
</div>
)}
<p className="mt-2 text-xs text-stone-500">
{nodes.length} entities · {edges.length} relations · click a node to highlight connections
</p>
</div>
);
}
@@ -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<string, number>();
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 (
<div className="rounded-xl border border-white/10 bg-black/20 p-5">
<h3 className="text-sm font-semibold text-white mb-3">Ingestion Activity</h3>
<div className="h-28 rounded-lg bg-white/5 animate-pulse" />
</div>
);
}
return (
<div className="rounded-xl border border-white/10 bg-black/20 p-5">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-semibold text-white">Ingestion Activity</h3>
<p className="text-xs text-stone-500 mt-0.5">
{totalEvents} event{totalEvents !== 1 ? 's' : ''} over the last {MONTHS} months
{maxDailyCount > 0 && <> · peak: {maxDailyCount}/day</>}
</p>
</div>
<div className="flex items-center gap-1 text-[10px] text-stone-500">
<span>Less</span>
{INTENSITY_COLORS.map((color, i) => (
<div
key={i}
className="w-[10px] h-[10px] rounded-[2px]"
style={{ backgroundColor: color }}
/>
))}
<span>More</span>
</div>
</div>
<svg
width="100%"
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
preserveAspectRatio="xMinYMin meet"
className="block">
{/* Day labels */}
{DAY_LABELS.map((label, i) =>
label ? (
<text
key={i}
x={0}
y={22 + i * (cellSize + CELL_GAP) + cellSize * 0.75}
fontSize={9}
fill="rgba(255,255,255,0.3)"
style={{ userSelect: 'none' }}>
{label}
</text>
) : null
)}
{/* Month labels */}
{monthLabels.map((m, i) => (
<text
key={i}
x={DAY_LABEL_WIDTH + m.weekIdx * (cellSize + CELL_GAP)}
y={12}
fontSize={9}
fill="rgba(255,255,255,0.3)"
style={{ userSelect: 'none' }}>
{m.label}
</text>
))}
{/* 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 (
<rect
key={i}
x={x}
y={y}
width={cellSize}
height={cellSize}
rx={2}
fill={INTENSITY_COLORS[intensity]}
stroke={
hoveredCell?.date.getTime() === cell.date.getTime()
? 'rgba(255,255,255,0.4)'
: 'transparent'
}
strokeWidth={1}
style={{ cursor: 'pointer', transition: 'fill 0.1s' }}
onMouseEnter={e => {
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)}
/>
);
})}
</svg>
{/* Tooltip */}
{hoveredCell && (
<div
className="fixed z-50 px-2 py-1 rounded-md bg-stone-800 border border-white/10 text-[11px] text-white shadow-lg pointer-events-none"
style={{ left: hoveredCell.x, top: hoveredCell.y - 32, transform: 'translateX(-50%)' }}>
<span className="font-medium">
{hoveredCell.count} event{hoveredCell.count !== 1 ? 's' : ''}
</span>{' '}
<span className="text-stone-400">on {formatDate(hoveredCell.date)}</span>
</div>
)}
</div>
);
}
@@ -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<string, InsightCategory> = {
// 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<InsightCategory | null>(null);
const groups = useMemo<InsightGroup[]>(() => {
const buckets = new Map<InsightCategory, InsightItem[]>();
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 (
<div className="rounded-xl border border-white/10 bg-black/20 p-5">
<h3 className="text-sm font-semibold text-white mb-4">Intelligent Insights</h3>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{[1, 2, 3].map(i => (
<div key={i} className="h-28 rounded-lg bg-white/5 animate-pulse" />
))}
</div>
</div>
);
}
if (groups.length === 0) {
return (
<div className="rounded-xl border border-white/10 bg-black/20 p-5">
<h3 className="text-sm font-semibold text-white mb-2">Intelligent Insights</h3>
<p className="text-sm text-stone-400">
No insights yet. Ingest documents to extract facts, preferences, and relationships.
</p>
</div>
);
}
return (
<div className="rounded-xl border border-white/10 bg-black/20 p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-sm font-semibold text-white">Intelligent Insights</h3>
<p className="text-xs text-stone-500 mt-0.5">
Extracted knowledge organized by type {relations.length} total relations
</p>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{groups.map(group => {
const isExpanded = expandedCategory === group.category;
const displayItems = isExpanded ? group.items.slice(0, 20) : group.items.slice(0, 3);
return (
<div
key={group.category}
className={`rounded-lg border ${group.borderColor} ${group.bgColor} p-3 transition-all ${
isExpanded ? 'col-span-2 lg:col-span-3' : ''
}`}>
<button
onClick={() => setExpandedCategory(isExpanded ? null : group.category)}
className="flex items-center gap-2 w-full text-left mb-2">
<div
className={`w-7 h-7 rounded-md ${group.bgColor} flex items-center justify-center flex-shrink-0`}>
<svg
className={`w-4 h-4 ${group.color}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d={group.icon}
/>
</svg>
</div>
<div className="min-w-0 flex-1">
<div className={`text-xs font-medium ${group.color}`}>{group.label}</div>
<div className="text-[10px] text-stone-500">{group.items.length} items</div>
</div>
<svg
className={`w-3.5 h-3.5 text-stone-500 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<div className={`space-y-1.5 ${isExpanded ? 'max-h-80 overflow-y-auto pr-1' : ''}`}>
{displayItems.map((item, idx) => (
<div
key={`${item.subject}-${item.predicate}-${item.object}-${idx}`}
className="flex items-start gap-1.5 text-[11px] leading-relaxed">
<span
className="text-white/80 font-medium shrink-0 max-w-[30%] truncate"
title={item.subject}>
{item.subject}
</span>
<span className="text-stone-500 shrink-0 italic">{item.predicate}</span>
<span className="text-white/60 truncate" title={item.object}>
{item.object}
</span>
{item.evidenceCount > 1 && (
<span className="ml-auto text-[9px] text-stone-600 shrink-0 tabular-nums">
x{item.evidenceCount}
</span>
)}
</div>
))}
{!isExpanded && group.items.length > 3 && (
<div className="text-[10px] text-stone-500 pt-0.5">
+{group.items.length - 3} more
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
@@ -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 (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{stats.map(stat => (
<div
key={stat.label}
className="rounded-xl border border-white/10 bg-black/20 p-3 transition-colors hover:bg-black/30">
<div className="text-[11px] uppercase tracking-wide text-stone-500 mb-1">
{stat.label}
</div>
<div className={`text-xl font-semibold ${stat.color}`}>
{loading ? <div className="h-7 w-16 rounded bg-white/5 animate-pulse" /> : stat.value}
</div>
{stat.sub && <div className="text-[11px] text-stone-500 mt-0.5">{stat.sub}</div>}
</div>
))}
</div>
);
}
@@ -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<ToastNotification, 'id'>) => void;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function asRecord(value: unknown): Record<string, unknown> | 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<string, unknown>;
}
function pickString(record: Record<string, unknown>, 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<MemoryDoc[]>([]);
const [memoryNamespaces, setMemoryNamespaces] = useState<string[]>([]);
@@ -144,6 +127,11 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
const [memoryWorkspaceLoading, setMemoryWorkspaceLoading] = useState(false);
const [memoryWorkspaceError, setMemoryWorkspaceError] = useState<string | null>(null);
const [graphRelations, setGraphRelations] = useState<GraphRelation[]>([]);
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<GraphRelation[]>([]);
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<string, number>();
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<MemoryNode[]>(() => {
const namespaceNodes = topNamespaces.map(item => ({
label: item.namespace,
value: `${item.count} docs`,
}));
// Build entity nodes from local graph store relations
const entityCounts = new Map<string, number>();
// 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<string, string> | undefined)?.subject ?? 'entity';
const objectType =
(rel.attrs?.entity_types as Record<string, string> | 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 (
<section className="glass rounded-2xl p-5 border border-white/10 animate-fade-up">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<h2 className="text-lg font-semibold text-white">Memory Workspace</h2>
<p className="text-sm text-stone-400">
Browse memory files, inspect graph relationships, and manage stored context.
</p>
<section className="space-y-4 animate-fade-up">
{/* Header */}
<div className="glass rounded-2xl p-5 border border-white/10">
<div className="flex items-start justify-between gap-4 mb-5">
<div>
<h2 className="text-lg font-semibold text-white">Memory</h2>
<p className="text-sm text-stone-400">
Your AI's knowledge graph, extracted insights, and ingestion activity.
</p>
</div>
<button
onClick={() => {
void Promise.all([loadWorkspace(), refetchStats()]);
}}
disabled={memoryWorkspaceLoading || statsLoading}
className="px-3 py-1.5 text-xs bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg text-stone-300 disabled:opacity-40 transition-colors">
{memoryWorkspaceLoading ? 'Loading...' : 'Refresh'}
</button>
</div>
{/* Stats bar */}
<MemoryStatsBar
totalDocs={memoryDocs.length}
totalFiles={memoryFilesList.length}
totalNamespaces={memoryNamespaces.length}
totalRelations={graphRelations.length}
totalSessions={sessions?.total ?? null}
totalTokens={sessions?.totalTokens ?? null}
estimatedStorageBytes={estimatedStorageBytes}
oldestDocTimestamp={oldestTimestamp}
newestDocTimestamp={newestTimestamp}
docsToday={docsToday}
loading={memoryWorkspaceLoading || statsLoading}
/>
</div>
{/* Knowledge Graph */}
<MemoryGraphMap relations={graphRelations} loading={graphRelationsLoading} />
{/* Intelligent Insights */}
<MemoryInsights relations={graphRelations} loading={graphRelationsLoading} />
{/* Activity Heatmap */}
<MemoryHeatmap timestamps={heatmapTimestamps} loading={memoryWorkspaceLoading} />
{/* Collapsible: Files & Management */}
<div className="rounded-xl border border-white/10 bg-black/20">
<button
onClick={() => {
void Promise.all([loadWorkspace(), refetchStats()]);
}}
disabled={memoryWorkspaceLoading || statsLoading}
className="px-3 py-1.5 text-xs bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg text-stone-300 disabled:opacity-40">
Refresh Memory
</button>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5">
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] uppercase tracking-wide text-stone-500">Files (All-Time)</div>
<div className="text-xl text-white font-semibold mt-1">
{memoryFiles !== null ? formatNumber(memoryFiles) : '--'}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] uppercase tracking-wide text-stone-500">Files (Today)</div>
<div className="text-xl text-white font-semibold mt-1">
{formatNumber(memoryFilesToday)}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] uppercase tracking-wide text-stone-500">Docs (All-Time)</div>
<div className="text-xl text-white font-semibold mt-1">
{formatNumber(memoryDocs.length)}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] uppercase tracking-wide text-stone-500">Docs (Today)</div>
<div className="text-xl text-white font-semibold mt-1">
{formatNumber(memoryDocsToday)}
</div>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
<h3 className="text-sm font-semibold text-white mb-3">Sample Memory Graph</h3>
<div className="relative h-64 rounded-lg bg-stone-950/70 overflow-hidden border border-white/5">
onClick={() => setManageOpen(!manageOpen)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-white/5 transition-colors rounded-xl">
<div className="flex items-center gap-2">
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 100 100"
preserveAspectRatio="none">
{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 (
<line
key={`line-${index}`}
x1="50"
y1="50"
x2={x}
y2={y}
stroke="rgba(74, 131, 221, 0.45)"
strokeWidth="0.6"
/>
);
})}
className={`w-4 h-4 text-stone-400 transition-transform ${manageOpen ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<h3 className="text-sm font-semibold text-white">Files & Management</h3>
<span className="text-xs text-stone-500">
{memoryFilesList.length} files · {memoryNamespaces.length} namespaces ·{' '}
{memoryDocs.length} docs
</span>
</div>
</button>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 px-2.5 py-1.5 rounded-full bg-primary-500/20 border border-primary-400/40 text-[11px] text-primary-200 whitespace-nowrap">
You (Core Memory)
</div>
{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 (
<div
key={`node-${node.label}`}
className={`absolute ${positions[index] || positions[0]} max-w-28 px-2 py-1 rounded-md bg-white/5 border border-white/10`}>
<div className="text-[11px] text-white truncate">{node.label}</div>
{node.value && (
<div className="text-[10px] text-stone-400 truncate">{node.value}</div>
{manageOpen && (
<div className="px-4 pb-4 space-y-4 animate-fade-up">
{/* File browser */}
<div>
<h4 className="text-xs font-medium text-stone-400 mb-2">Memory Files</h4>
<div className="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-3">
<div className="rounded-lg border border-white/10 bg-stone-950/50 p-2 h-52 overflow-y-auto">
{memoryFilesList.length === 0 ? (
<div className="text-xs text-stone-500 p-2">No files found.</div>
) : (
memoryFilesList.map(filePath => (
<button
key={filePath}
onClick={() => setSelectedFile(filePath)}
className={`w-full text-left px-2 py-1.5 rounded text-xs mb-1 border transition-colors ${
selectedFile === filePath
? 'border-primary-400/40 bg-primary-500/20 text-primary-200'
: 'border-transparent hover:border-white/10 hover:bg-white/5 text-stone-300'
}`}>
{filePath}
</button>
))
)}
</div>
<div className="rounded-lg border border-white/10 bg-stone-950/50 p-3 h-52 overflow-auto">
{selectedFileLoading ? (
<div className="text-xs text-stone-400">Loading file...</div>
) : selectedFileError ? (
<div className="text-xs text-coral-300">{selectedFileError}</div>
) : (
<pre className="text-[11px] leading-5 text-stone-200 whitespace-pre-wrap">
{selectedFileContent || 'Empty file'}
</pre>
)}
</div>
);
})}
{graphNodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center text-xs text-stone-500">
No graph nodes yet. Refresh memory to load.
</div>
)}
</div>
<div className="mt-3 text-xs text-stone-400">
Nodes show top namespaces and entity buckets connected to your core profile.
</div>
</div>
<div className="mt-3">
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-stone-400">
Graph relations{' '}
{graphRelations.length > 0 && (
<span className="text-stone-500">({graphRelations.length})</span>
{/* Namespace query & management */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
<div>
<h4 className="text-xs font-medium text-stone-400 mb-2">Manage Namespace</h4>
<select
value={selectedNamespace}
onChange={e => setSelectedNamespace(e.target.value)}
className="w-full px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white mb-3 focus:outline-none focus:border-primary-500/50">
{memoryNamespaces.length === 0 ? (
<option value="">No namespaces</option>
) : (
memoryNamespaces.map(ns => (
<option key={ns} value={ns}>
{ns}
</option>
))
)}
</select>
<label className="block text-xs text-stone-400 mb-1">Query</label>
<textarea
value={queryInput}
onChange={e => setQueryInput(e.target.value)}
rows={2}
className="w-full px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white mb-2 focus:outline-none focus:border-primary-500/50"
placeholder="Search this namespace..."
/>
<div className="flex gap-2 mb-3">
<button
onClick={() => void handleQueryNamespace()}
disabled={!selectedNamespace || !queryInput.trim() || queryLoading}
className="px-3 py-1.5 text-xs rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 text-stone-200 disabled:opacity-40">
{queryLoading ? 'Querying...' : 'Run Query'}
</button>
<button
onClick={() => void handleRecallNamespace()}
disabled={!selectedNamespace || recallLoading}
className="px-3 py-1.5 text-xs rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 text-stone-200 disabled:opacity-40">
{recallLoading ? 'Recalling...' : 'Run Recall'}
</button>
</div>
<label className="block text-xs text-stone-400 mb-1">Add Note</label>
<textarea
value={memoryNote}
onChange={e => setMemoryNote(e.target.value)}
rows={2}
className="w-full px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white mb-2 focus:outline-none focus:border-primary-500/50"
placeholder="Store a durable user fact, preference, or decision"
/>
<button
onClick={() => void handleSaveMemoryNote()}
disabled={!memoryNote.trim() || memoryNoteSaving}
className="px-3 py-1.5 text-xs rounded-lg border border-primary-500/40 bg-primary-500/20 hover:bg-primary-500/30 text-primary-200 disabled:opacity-40">
{memoryNoteSaving ? 'Saving...' : 'Save Note'}
</button>
{memoryActionError && (
<div className="mt-2 text-xs text-coral-300 border border-coral-500/30 bg-coral-500/10 rounded p-2">
{memoryActionError}
</div>
)}
</div>
{graphRelationsLoading && (
<div className="text-[10px] text-stone-500">Loading...</div>
)}
</div>
<div className="space-y-1 max-h-36 overflow-y-auto pr-1">
{graphRelations.length === 0 ? (
<div className="text-[11px] text-stone-500">
{graphRelationsLoading
? 'Loading graph...'
: 'No relations yet. Ingest documents to populate the graph.'}
</div>
) : (
graphRelations.slice(0, 20).map((rel, idx) => (
<div
key={`${rel.subject}-${rel.predicate}-${rel.object}-${idx}`}
className="flex items-center gap-1.5 rounded border border-white/5 bg-stone-950/50 px-2 py-1 text-[11px]">
<span className="text-primary-300 truncate max-w-20" title={rel.subject}>
{rel.subject}
</span>
<span className="text-stone-500 shrink-0">&rarr;</span>
<span className="text-amber-300/80 truncate max-w-20" title={rel.predicate}>
{rel.predicate}
</span>
<span className="text-stone-500 shrink-0">&rarr;</span>
<span className="text-emerald-300/80 truncate max-w-20" title={rel.object}>
{rel.object}
</span>
{rel.evidenceCount > 1 && (
<span className="ml-auto text-[10px] text-stone-500 shrink-0">
x{rel.evidenceCount}
</span>
)}
<div className="xl:col-span-2">
<h4 className="text-xs font-medium text-stone-400 mb-2">Namespace Responses</h4>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3 mb-3">
<div>
<div className="text-[11px] text-stone-500 mb-1">Query response</div>
<pre className="rounded-lg border border-white/10 bg-stone-950/50 p-2 h-28 overflow-auto text-[11px] leading-5 text-stone-200 whitespace-pre-wrap">
{queryResult || 'No query result yet.'}
</pre>
</div>
))
)}
</div>
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-4 xl:col-span-2">
<h3 className="text-sm font-semibold text-white mb-3">Memory Files</h3>
<div className="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-3">
<div className="rounded-lg border border-white/10 bg-stone-950/50 p-2 h-64 overflow-y-auto">
{memoryFilesList.length === 0 ? (
<div className="text-xs text-stone-500 p-2">No files found.</div>
) : (
memoryFilesList.map(filePath => (
<button
key={filePath}
onClick={() => setSelectedFile(filePath)}
className={`w-full text-left px-2 py-1.5 rounded text-xs mb-1 border transition-colors ${
selectedFile === filePath
? 'border-primary-400/40 bg-primary-500/20 text-primary-200'
: 'border-transparent hover:border-white/10 hover:bg-white/5 text-stone-300'
}`}>
{filePath}
</button>
))
)}
</div>
<div className="rounded-lg border border-white/10 bg-stone-950/50 p-3 h-64 overflow-auto">
{selectedFileLoading ? (
<div className="text-xs text-stone-400">Loading file...</div>
) : selectedFileError ? (
<div className="text-xs text-coral-300">{selectedFileError}</div>
) : (
<pre className="text-[11px] leading-5 text-stone-200 whitespace-pre-wrap">
{selectedFileContent || 'Empty file'}
</pre>
)}
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 mt-4">
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
<h3 className="text-sm font-semibold text-white mb-3">Manage Memory</h3>
<label className="block text-xs text-stone-400 mb-2">Namespace</label>
<select
value={selectedNamespace}
onChange={event => setSelectedNamespace(event.target.value)}
className="w-full px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white mb-3 focus:outline-none focus:border-primary-500/50">
{memoryNamespaces.length === 0 ? (
<option value="">No namespaces</option>
) : (
memoryNamespaces.map(namespace => (
<option key={namespace} value={namespace}>
{namespace}
</option>
))
)}
</select>
<label className="block text-xs text-stone-400 mb-2">Query</label>
<textarea
value={queryInput}
onChange={event => setQueryInput(event.target.value)}
rows={3}
className="w-full px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white mb-3 focus:outline-none focus:border-primary-500/50"
placeholder="Search this namespace..."
/>
<div className="flex gap-2 mb-3">
<button
onClick={() => void handleQueryNamespace()}
disabled={!selectedNamespace || !queryInput.trim() || queryLoading}
className="px-3 py-1.5 text-xs rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 text-stone-200 disabled:opacity-40">
{queryLoading ? 'Querying...' : 'Run Query'}
</button>
<button
onClick={() => void handleRecallNamespace()}
disabled={!selectedNamespace || recallLoading}
className="px-3 py-1.5 text-xs rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 text-stone-200 disabled:opacity-40">
{recallLoading ? 'Recalling...' : 'Run Recall'}
</button>
</div>
<label className="block text-xs text-stone-400 mb-2">Add Note to memory.md</label>
<textarea
value={memoryNote}
onChange={event => setMemoryNote(event.target.value)}
rows={3}
className="w-full px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white mb-3 focus:outline-none focus:border-primary-500/50"
placeholder="Store a durable user fact, preference, or decision"
/>
<button
onClick={() => void handleSaveMemoryNote()}
disabled={!memoryNote.trim() || memoryNoteSaving}
className="px-3 py-1.5 text-xs rounded-lg border border-primary-500/40 bg-primary-500/20 hover:bg-primary-500/30 text-primary-200 disabled:opacity-40">
{memoryNoteSaving ? 'Saving...' : 'Save Note'}
</button>
{memoryActionError && (
<div className="mt-3 text-xs text-coral-300 border border-coral-500/30 bg-coral-500/10 rounded p-2">
{memoryActionError}
</div>
)}
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-4 xl:col-span-2">
<h3 className="text-sm font-semibold text-white mb-3">Namespace Responses</h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3 mb-3">
<div>
<div className="text-xs text-stone-400 mb-1">Query response</div>
<pre className="rounded-lg border border-white/10 bg-stone-950/50 p-2 h-36 overflow-auto text-[11px] leading-5 text-stone-200 whitespace-pre-wrap">
{queryResult || 'No query result yet.'}
</pre>
</div>
<div>
<div className="text-xs text-stone-400 mb-1">Recall response</div>
<pre className="rounded-lg border border-white/10 bg-stone-950/50 p-2 h-36 overflow-auto text-[11px] leading-5 text-stone-200 whitespace-pre-wrap">
{recallResult || 'No recall result yet.'}
</pre>
</div>
</div>
<div className="text-xs text-stone-400 mb-2">Top namespaces by document volume</div>
<div className="flex flex-wrap gap-2 mb-3">
{topNamespaces.length === 0 ? (
<div className="text-xs text-stone-500">No namespace data yet.</div>
) : (
topNamespaces.map(item => (
<div
key={item.namespace}
className="px-2 py-1 rounded-full border border-white/10 bg-white/5 text-xs text-stone-300">
{item.namespace}: {item.count}
</div>
))
)}
</div>
<div className="text-xs text-stone-400 mb-2">Recent documents</div>
<div className="space-y-2 max-h-40 overflow-y-auto pr-1">
{memoryDocs.slice(0, 8).map(doc => (
<div
key={`${doc.namespace}:${doc.documentId}`}
className="flex items-center justify-between gap-3 rounded-lg border border-white/10 bg-stone-950/50 px-3 py-2">
<div className="min-w-0">
<div className="text-xs text-white truncate">{doc.title || doc.documentId}</div>
<div className="text-[11px] text-stone-400 truncate">
{doc.namespace} · {doc.documentId}
<div>
<div className="text-[11px] text-stone-500 mb-1">Recall response</div>
<pre className="rounded-lg border border-white/10 bg-stone-950/50 p-2 h-28 overflow-auto text-[11px] leading-5 text-stone-200 whitespace-pre-wrap">
{recallResult || 'No recall result yet.'}
</pre>
</div>
</div>
<button
onClick={() => void handleDeleteMemoryDoc(doc)}
className="text-[11px] px-2 py-1 rounded border border-coral-500/30 text-coral-300 hover:bg-coral-500/10">
Delete
</button>
<div className="text-[11px] text-stone-500 mb-2">Recent documents</div>
<div className="space-y-1.5 max-h-40 overflow-y-auto pr-1">
{memoryDocs.slice(0, 8).map(doc => (
<div
key={`${doc.namespace}:${doc.documentId}`}
className="flex items-center justify-between gap-3 rounded-lg border border-white/10 bg-stone-950/50 px-3 py-2">
<div className="min-w-0">
<div className="text-xs text-white truncate">
{doc.title || doc.documentId}
</div>
<div className="text-[11px] text-stone-400 truncate">
{doc.namespace} · {doc.documentId}
</div>
</div>
<button
onClick={() => void handleDeleteMemoryDoc(doc)}
className="text-[11px] px-2 py-1 rounded border border-coral-500/30 text-coral-300 hover:bg-coral-500/10 shrink-0">
Delete
</button>
</div>
))}
{memoryDocs.length === 0 && (
<div className="text-xs text-stone-500">No documents available.</div>
)}
</div>
</div>
))}
{memoryDocs.length === 0 && (
<div className="text-xs text-stone-500">No documents available.</div>
)}
</div>
</div>
</div>
)}
</div>
{/* Warnings */}
{(memoryWorkspaceError || (!isTauri() && !memoryWorkspaceLoading)) && (
<div className="mt-4 text-xs text-amber-300 border border-amber-500/30 bg-amber-500/10 rounded p-2">
<div className="text-xs text-amber-300 border border-amber-500/30 bg-amber-500/10 rounded-lg p-3">
{memoryWorkspaceError ||
'Memory workspace requires the desktop Tauri runtime to load real data.'}
</div>
)}
<div className="mt-3 text-[11px] text-stone-500">
Sessions: {sessions ? formatNumber(sessions.total) : '--'} · Token volume:{' '}
{sessions ? formatNumber(sessions.totalTokens) : '--'}
{graphRelations.length > 0 && <> · Relations: {formatNumber(graphRelations.length)}</>}
{entities && <> · Entity buckets: {formatNumber(Object.keys(entities).length)}</>}
</div>
</section>
);
}
@@ -62,9 +62,9 @@ vi.mock('../../../utils/tauriCommands', () => ({
describe('MemoryWorkspace', () => {
const onToast = vi.fn();
it('renders the Memory Workspace heading', async () => {
it('renders the Memory heading', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
expect(screen.getByText('Memory Workspace')).toBeInTheDocument();
expect(screen.getByText('Memory')).toBeInTheDocument();
});
it('displays graph relations after loading', async () => {
@@ -93,30 +93,20 @@ describe('MemoryWorkspace', () => {
expect(screen.queryByText('x1')).not.toBeInTheDocument();
});
it('shows relations count in footer stats', async () => {
it('shows Relations stat in the stats bar', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
// The stats bar has a "Relations" label
await waitFor(() => {
expect(screen.getByText(/Relations: 2/)).toBeInTheDocument();
expect(screen.getByText('Relations')).toBeInTheDocument();
});
});
it('displays Graph relations header with count', async () => {
it('renders the Memory Graph section', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => {
expect(screen.getByText('(2)')).toBeInTheDocument();
});
});
it('builds entity nodes from graph relation attrs', async () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
// Entity nodes are built from entity_types in attrs
// person appears 2x (Alice and Bob as subject), document appears 2x (Paper A as object)
await waitFor(() => {
// Entity nodes should include counts from relation entity_types
expect(screen.getByText('2 entities')).toBeInTheDocument();
expect(screen.getByText('Memory Graph')).toBeInTheDocument();
});
});
});
@@ -132,9 +122,7 @@ describe('MemoryWorkspace no graph relations', () => {
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
await waitFor(() => {
expect(
screen.getByText('No relations yet. Ingest documents to populate the graph.')
).toBeInTheDocument();
expect(screen.getByText('No memory graph data yet')).toBeInTheDocument();
});
});
});