From 62516ba418c40cd613d8a5a25d7ee0679cab0d6a Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Tue, 5 May 2026 09:22:56 +0530 Subject: [PATCH] feat(memory_tree): cloud-default LLM, queue priority, entity filter, Memory tab UI (#1198) Co-authored-by: Claude Opus 4.7 (1M context) --- .../intelligence/BackendChooser.tsx | 133 ++ .../intelligence/IntelligenceSettingsTab.tsx | 181 ++ .../intelligence/MemoryChunkDetail.tsx | 134 ++ .../intelligence/MemoryChunkLetterhead.tsx | 72 + .../intelligence/MemoryChunkMentioned.tsx | 38 + .../intelligence/MemoryChunkScoreBars.tsx | 62 + .../intelligence/MemoryEmptyPlaceholder.tsx | 19 + .../intelligence/MemoryGraphMap.tsx | 402 ----- .../intelligence/MemoryNavigator.tsx | 270 +++ .../intelligence/MemoryResultList.tsx | 175 ++ .../intelligence/MemoryWorkspace.tsx | 1063 +++-------- .../intelligence/ModelAssignment.tsx | 150 ++ .../components/intelligence/ModelCatalog.tsx | 252 +++ .../IntelligenceSettingsTab.test.tsx | 257 +++ .../__tests__/MemoryChunkLetterhead.test.tsx | 60 + .../__tests__/MemoryChunkMentioned.test.tsx | 39 + .../__tests__/MemoryChunkScoreBars.test.tsx | 49 + .../__tests__/MemoryWorkspace.test.tsx | 472 ++--- .../__tests__/ModelCatalog.test.tsx | 141 ++ .../intelligence/memory-workspace.css | 617 +++++++ .../__tests__/settingsApi.test.ts | 174 ++ app/src/lib/intelligence/settingsApi.ts | 260 +++ app/src/pages/Intelligence.tsx | 244 +-- app/src/utils/tauriCommands/index.ts | 1 + .../utils/tauriCommands/memoryTree.test.ts | 260 +++ app/src/utils/tauriCommands/memoryTree.ts | 418 +++++ src/core/jsonrpc.rs | 34 + src/openhuman/config/mod.rs | 18 +- src/openhuman/config/schema/load.rs | 42 +- src/openhuman/config/schema/mod.rs | 3 +- src/openhuman/config/schema/storage_memory.rs | 118 ++ src/openhuman/memory/tree/chat/cloud.rs | 119 ++ src/openhuman/memory/tree/chat/local.rs | 263 +++ src/openhuman/memory/tree/chat/mod.rs | 258 +++ src/openhuman/memory/tree/jobs/store.rs | 14 +- src/openhuman/memory/tree/jobs/worker.rs | 10 +- src/openhuman/memory/tree/mod.rs | 2 + src/openhuman/memory/tree/read_rpc.rs | 1585 +++++++++++++++++ src/openhuman/memory/tree/schemas.rs | 520 +++++- .../memory/tree/score/extract/llm.rs | 193 +- .../memory/tree/score/extract/llm_tests.rs | 138 +- .../memory/tree/score/extract/mod.rs | 112 +- src/openhuman/memory/tree/score/mod.rs | 72 +- .../memory/tree/tree_source/summariser/llm.rs | 325 ++-- .../memory/tree/tree_source/summariser/mod.rs | 107 +- tests/json_rpc_e2e.rs | 36 +- 46 files changed, 7761 insertions(+), 2151 deletions(-) create mode 100644 app/src/components/intelligence/BackendChooser.tsx create mode 100644 app/src/components/intelligence/IntelligenceSettingsTab.tsx create mode 100644 app/src/components/intelligence/MemoryChunkDetail.tsx create mode 100644 app/src/components/intelligence/MemoryChunkLetterhead.tsx create mode 100644 app/src/components/intelligence/MemoryChunkMentioned.tsx create mode 100644 app/src/components/intelligence/MemoryChunkScoreBars.tsx create mode 100644 app/src/components/intelligence/MemoryEmptyPlaceholder.tsx delete mode 100644 app/src/components/intelligence/MemoryGraphMap.tsx create mode 100644 app/src/components/intelligence/MemoryNavigator.tsx create mode 100644 app/src/components/intelligence/MemoryResultList.tsx create mode 100644 app/src/components/intelligence/ModelAssignment.tsx create mode 100644 app/src/components/intelligence/ModelCatalog.tsx create mode 100644 app/src/components/intelligence/__tests__/IntelligenceSettingsTab.test.tsx create mode 100644 app/src/components/intelligence/__tests__/MemoryChunkLetterhead.test.tsx create mode 100644 app/src/components/intelligence/__tests__/MemoryChunkMentioned.test.tsx create mode 100644 app/src/components/intelligence/__tests__/MemoryChunkScoreBars.test.tsx create mode 100644 app/src/components/intelligence/__tests__/ModelCatalog.test.tsx create mode 100644 app/src/components/intelligence/memory-workspace.css create mode 100644 app/src/lib/intelligence/__tests__/settingsApi.test.ts create mode 100644 app/src/lib/intelligence/settingsApi.ts create mode 100644 app/src/utils/tauriCommands/memoryTree.test.ts create mode 100644 app/src/utils/tauriCommands/memoryTree.ts create mode 100644 src/openhuman/memory/tree/chat/cloud.rs create mode 100644 src/openhuman/memory/tree/chat/local.rs create mode 100644 src/openhuman/memory/tree/chat/mod.rs create mode 100644 src/openhuman/memory/tree/read_rpc.rs diff --git a/app/src/components/intelligence/BackendChooser.tsx b/app/src/components/intelligence/BackendChooser.tsx new file mode 100644 index 000000000..2356365ce --- /dev/null +++ b/app/src/components/intelligence/BackendChooser.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react'; + +import type { Backend } from '../../lib/intelligence/settingsApi'; + +interface BackendChooserProps { + /** Currently selected backend. */ + value: Backend; + /** Called when the user clicks a different card. */ + onChange: (next: Backend) => void; + /** Optional cloud-cost estimate. Mock value until cost-tracker hook lands. */ + costEstimate?: string; + /** Disabled while a backend switch is in flight. */ + busy?: boolean; +} + +/** + * Two large cards — Cloud (default, recommended) vs Local (advanced). + * + * Visual style intentionally matches the rest of the Intelligence page: + * `bg-white` + `border-stone-200` + `rounded-2xl`, primary blue for the + * selected accent. The inline tokens from the brief + * (paper, hairline, ocean) map onto the existing stone/primary scale — + * we keep the existing scale to avoid forking the design system. + */ +export default function BackendChooser({ + value, + onChange, + costEstimate = '$0.42 / mo est.', + busy = false, +}: BackendChooserProps) { + const [hoveredCloud, setHoveredCloud] = useState(false); + + const cardBase = + 'flex-1 min-h-[160px] px-6 py-5 rounded-2xl text-left transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed'; + + return ( +
+ {/* Cloud */} + + + {/* Local */} + +
+ ); +} + +function RadioDot({ active }: { active: boolean }) { + return ( + + + + ); +} diff --git a/app/src/components/intelligence/IntelligenceSettingsTab.tsx b/app/src/components/intelligence/IntelligenceSettingsTab.tsx new file mode 100644 index 000000000..43bf66304 --- /dev/null +++ b/app/src/components/intelligence/IntelligenceSettingsTab.tsx @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { + type Backend, + capabilityForModel, + DEFAULT_EXTRACT_MODEL, + downloadAsset, + fetchInstalledModels, + getMemoryTreeLlm, + type ModelDescriptor, + REQUIRED_EMBEDDER_MODEL, + setMemoryTreeLlm, +} from '../../lib/intelligence/settingsApi'; +import BackendChooser from './BackendChooser'; +import ModelAssignment from './ModelAssignment'; +import ModelCatalog from './ModelCatalog'; + +/** + * Settings tab for the Intelligence page. + * + * Layout (top → bottom): + * 1. AI Backend — Cloud / Local toggle + * 2. Model Assignment — per-role dropdowns (visible only in Local mode) + * 3. Model Catalog — full curated list with download / use / delete + * 4. Currently Loaded — live `/api/ps`-style readout + * + * The orchestrator owns the cross-section state (backend, role assignments, + * cached installed-models / status). Sections themselves stay presentational. + */ +export default function IntelligenceSettingsTab() { + const [backend, setBackend] = useState('cloud'); + const [backendBusy, setBackendBusy] = useState(false); + // Single Memory LLM that drives both extractor and summariser. Most + // users want one model for both; the rare case of mixing them is not + // worth the second dropdown's cognitive cost. + const [memoryModel, setMemoryModel] = useState(DEFAULT_EXTRACT_MODEL); + const [installedModels, setInstalledModels] = useState([]); + + // One-shot bootstrap — pull current backend and the installed-model list. + useEffect(() => { + let cancelled = false; + void (async () => { + try { + console.debug('[intelligence-settings] bootstrap'); + const [bk, models] = await Promise.all([getMemoryTreeLlm(), fetchInstalledModels()]); + if (cancelled) return; + setBackend(bk); + setInstalledModels(models.map(m => m.name)); + } catch (err) { + if (!cancelled) { + // Bootstrap failure leaves the tab on its useState defaults + // (cloud backend, empty installed list) rather than throwing + // an unhandled rejection. The user can still flip the backend + // chooser; subsequent reads will retry the RPCs. + console.error('[intelligence-settings] bootstrap failed', err); + } + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const handleBackendChange = useCallback(async (next: Backend) => { + setBackendBusy(true); + try { + const { effective } = await setMemoryTreeLlm(next); + setBackend(effective); + } catch (err) { + console.error('[intelligence-settings] backend switch failed', err); + } finally { + setBackendBusy(false); + } + }, []); + + // Persist Memory LLM changes to config.toml. Fans out to both + // extractor and summariser keys in a single atomic write — the unified + // UI is one dropdown, but the underlying schema retains both keys so + // power users can still split them via the RPC directly if needed. + const handleMemoryModelChange = useCallback( + async (id: string) => { + console.debug('[intelligence-settings] memory model -> %s', id); + const previous = memoryModel; + setMemoryModel(id); + try { + await setMemoryTreeLlm('local', { extractModel: id, summariserModel: id }); + } catch (err) { + // Persistence failed → roll back the optimistic UI update so the + // dropdown reflects the value that's actually saved on disk + // rather than the one the user just attempted. + setMemoryModel(previous); + console.error('[intelligence-settings] persist memory model failed', err); + } + }, + [memoryModel] + ); + + const handleDownload = useCallback(async (model: ModelDescriptor) => { + const cap = capabilityForModel(model); + if (!cap) { + console.debug('[intelligence-settings] no capability for model', { id: model.id }); + return; + } + try { + await downloadAsset(cap); + } catch (err) { + console.error('[intelligence-settings] model download failed', err); + } finally { + // Refresh installed list after any download attempt — even on + // failure, Ollama may have partially landed assets we should + // surface; if it hasn't, the next bootstrap tick will catch up. + const refreshed = await fetchInstalledModels(); + setInstalledModels(refreshed.map(m => m.name)); + } + }, []); + + const handleUse = useCallback( + (model: ModelDescriptor) => { + if (model.roles.includes('extract') || model.roles.includes('summariser')) { + void handleMemoryModelChange(model.id); + } + }, + [handleMemoryModelChange] + ); + + const activeModelIds = useMemo(() => { + const ids = new Set(); + ids.add(memoryModel); + ids.add(REQUIRED_EMBEDDER_MODEL); + return [...ids]; + }, [memoryModel]); + + return ( +
+
+ +
+ + {/* All local-model sections (assignment, catalog, currently-loaded) + are gated on local backend. Cloud users get just the backend + chooser + the explanatory copy that lives inside it — they don't + need to see Ollama-related UI at all. */} + {backend === 'local' && ( + <> +
+ +
+ +
+ +
+ + )} +
+ ); +} + +interface SectionProps { + title: string; + children: React.ReactNode; +} + +function Section({ title, children }: SectionProps) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} diff --git a/app/src/components/intelligence/MemoryChunkDetail.tsx b/app/src/components/intelligence/MemoryChunkDetail.tsx new file mode 100644 index 000000000..2753cfcca --- /dev/null +++ b/app/src/components/intelligence/MemoryChunkDetail.tsx @@ -0,0 +1,134 @@ +/** + * Right pane — single-chunk detail rendered as correspondence (a letter): + * + * 1. Letterhead (from / to / date) + * 2. Subject + body (markdown-ish prose; entities highlighted) + * 3. Mentioned (entity index for the chunk) + * 4. Why kept (signal breakdown + threshold) + * 5. Footer (source_ref, chunk id, embedder info) + */ +import { useEffect, useState } from 'react'; + +import { + type Chunk, + type EntityRef, + memoryTreeChunkScore, + memoryTreeEntityIndexFor, + type ScoreBreakdown, +} from '../../utils/tauriCommands'; +import { MemoryChunkLetterhead } from './MemoryChunkLetterhead'; +import { MemoryChunkMentioned } from './MemoryChunkMentioned'; +import { MemoryChunkScoreBars } from './MemoryChunkScoreBars'; +import { MemoryTextWithEntities } from './MemoryTextWithEntities'; + +interface MemoryChunkDetailProps { + chunk: Chunk; + onSelectEntity: (entity: EntityRef) => void; +} + +function deriveSubject(chunk: Chunk): string { + const preview = (chunk.content_preview ?? '').trim(); + if (!preview) return chunk.id; + const firstLine = preview.split(/[.!?\n]/)[0]?.trim() ?? ''; + if (firstLine.length === 0) return chunk.id; + return firstLine.length > 200 ? `${firstLine.slice(0, 197)}…` : firstLine; +} + +function deriveBody(chunk: Chunk): string { + const preview = (chunk.content_preview ?? '').trim(); + if (!preview) return ''; + // Drop the subject (first sentence/line) when there's more content after it. + const firstBreak = preview.search(/[.!?\n](?:\s|$)/); + if (firstBreak > 0 && preview.length > firstBreak + 2) { + return preview.slice(firstBreak + 1).trim(); + } + return preview; +} + +function shortChunkId(id: string): string { + // Trim "chunk-" prefix if present, then take 8 chars. + const stripped = id.startsWith('chunk-') ? id.slice('chunk-'.length) : id; + return stripped.slice(0, 8); +} + +export function MemoryChunkDetail({ chunk, onSelectEntity }: MemoryChunkDetailProps) { + const [entities, setEntities] = useState([]); + const [breakdown, setBreakdown] = useState(null); + const [copied, setCopied] = useState(false); + + useEffect(() => { + let cancelled = false; + console.debug('[ui-flow][memory-workspace] loading detail for chunk', chunk.id); + void Promise.all([memoryTreeEntityIndexFor(chunk.id), memoryTreeChunkScore(chunk.id)]).then( + ([ents, score]) => { + if (cancelled) return; + setEntities(ents); + setBreakdown(score); + console.debug( + '[ui-flow][memory-workspace] detail loaded', + chunk.id, + 'entities=', + ents.length, + 'score_total=', + score?.total ?? null + ); + } + ); + return () => { + cancelled = true; + }; + }, [chunk.id]); + + const handleCopyId = async () => { + try { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(chunk.id); + } + setCopied(true); + setTimeout(() => setCopied(false), 1200); + } catch (err) { + console.warn('[ui-flow][memory-workspace] copy chunk id failed', err); + } + }; + + const subject = deriveSubject(chunk); + const body = deriveBody(chunk); + + return ( +
+
+
+ + +
+ +

{subject}

+ {body && ( +
+ +
+ )} + + {entities.length > 0 &&
} + + + + {breakdown &&
} + + {breakdown && } + +
+ {chunk.source_ref && {chunk.source_ref}} + · + + · + {chunk.has_embedding ? 'bge-m3 1024dim' : 'no embedding'} +
+
+
+
+ ); +} diff --git a/app/src/components/intelligence/MemoryChunkLetterhead.tsx b/app/src/components/intelligence/MemoryChunkLetterhead.tsx new file mode 100644 index 000000000..69b06687b --- /dev/null +++ b/app/src/components/intelligence/MemoryChunkLetterhead.tsx @@ -0,0 +1,72 @@ +/** + * Letterhead: the from / to / date frontmatter of a chunk, rendered + * as correspondence (dl-style with monospace labels in a fixed column). + */ +import type { Chunk } from '../../utils/tauriCommands'; + +interface LetterheadParts { + fromName: string; + fromAddress?: string; + toAddress: string; +} + +function parseSourceParts(chunk: Chunk): LetterheadParts { + const left = chunk.source_id.split('|'); + const senderRaw = left[0]; + const recipient = left[1] ?? chunk.owner; + const afterColon = senderRaw.includes(':') ? senderRaw.split(':').slice(1).join(':') : senderRaw; + + // Heuristic for known prefixes: prefer the human-readable display when we have one, + // else fall back to the raw email/handle. + const isEmailish = /@/.test(afterColon); + // Try to recover a personalized name from the chunk's tags (first person/* tag) + const personTag = chunk.tags.find(t => t.startsWith('person/')); + const personName = personTag ? personTag.slice('person/'.length).replace(/-/g, ' ') : null; + + if (isEmailish && personName) { + return { fromName: personName, fromAddress: afterColon, toAddress: recipient }; + } + if (isEmailish) { + return { fromName: afterColon, toAddress: recipient }; + } + if (personName) { + return { fromName: personName, fromAddress: afterColon, toAddress: recipient }; + } + return { fromName: afterColon || chunk.source_kind, toAddress: recipient }; +} + +function formatLetterDate(ms: number): string { + const d = new Date(ms); + const yyyy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); + const hh = String(d.getUTCHours()).padStart(2, '0'); + const mi = String(d.getUTCMinutes()).padStart(2, '0'); + return `${yyyy}·${mm}·${dd} · ${hh}:${mi} utc`; +} + +export function MemoryChunkLetterhead({ chunk }: { chunk: Chunk }) { + const parts = parseSourceParts(chunk); + return ( +
+
+
+
from
+
+ {parts.fromName} + {parts.fromAddress && parts.fromAddress !== parts.fromName && ( + {parts.fromAddress} + )} +
+
+
+
to
+
+ {parts.toAddress} +
+
+
+
{formatLetterDate(chunk.timestamp_ms)}
+
+ ); +} diff --git a/app/src/components/intelligence/MemoryChunkMentioned.tsx b/app/src/components/intelligence/MemoryChunkMentioned.tsx new file mode 100644 index 000000000..9a9f9b094 --- /dev/null +++ b/app/src/components/intelligence/MemoryChunkMentioned.tsx @@ -0,0 +1,38 @@ +/** + * "Mentioned" entity list — the marginalia of a chunk's letter view. + * + * Each row is `[kind label mono] [surface] [chunk count]`. Clicking a row + * activates the corresponding entity in the Navigator, filtering the + * result list to chunks tagged with that entity. + */ +import type { EntityRef } from '../../utils/tauriCommands'; + +interface MemoryChunkMentionedProps { + entities: EntityRef[]; + onSelectEntity: (entity: EntityRef) => void; +} + +export function MemoryChunkMentioned({ entities, onSelectEntity }: MemoryChunkMentionedProps) { + if (entities.length === 0) return null; + + return ( +
+

m e n t i o n e d

+
+ {entities.map(ent => ( + + ))} +
+
+ ); +} diff --git a/app/src/components/intelligence/MemoryChunkScoreBars.tsx b/app/src/components/intelligence/MemoryChunkScoreBars.tsx new file mode 100644 index 000000000..c9fa87585 --- /dev/null +++ b/app/src/components/intelligence/MemoryChunkScoreBars.tsx @@ -0,0 +1,62 @@ +/** + * "Why kept" score bars — SVG-rendered (not CSS divs) for crisp pixel + * alignment regardless of zoom or DPR. + */ +import type { ScoreBreakdown } from '../../utils/tauriCommands'; + +interface MemoryChunkScoreBarsProps { + breakdown: ScoreBreakdown; +} + +const TRACK_WIDTH = 200; +const TRACK_HEIGHT = 8; + +function clamp01(v: number): number { + if (Number.isNaN(v)) return 0; + return Math.max(0, Math.min(1, v)); +} + +export function MemoryChunkScoreBars({ breakdown }: MemoryChunkScoreBarsProps) { + return ( +
+

w h y   k e p t

+
+ {breakdown.signals.map(sig => { + const pct = clamp01(sig.value); + return ( +
+ {sig.name} + + + + + {pct.toFixed(2)} +
+ ); + })} +
+
+ ─── {breakdown.kept ? 'kept' : 'dropped'} at {breakdown.threshold.toFixed(2)} ─── +
+
+ ); +} diff --git a/app/src/components/intelligence/MemoryEmptyPlaceholder.tsx b/app/src/components/intelligence/MemoryEmptyPlaceholder.tsx new file mode 100644 index 000000000..be91dbfc8 --- /dev/null +++ b/app/src/components/intelligence/MemoryEmptyPlaceholder.tsx @@ -0,0 +1,19 @@ +/** + * Right-pane placeholder shown to brand-new users (zero chunks). + * + * Centered, generous whitespace, no call-to-action buttons — the only path + * forward is connecting an integration in Settings, so we point there in + * prose without an explicit link to keep the surface meditative. + */ +export function MemoryEmptyPlaceholder() { + return ( +
+

Nothing yet.

+

+ Connect an integration in Settings to start +
+ building your memory tree. +

+
+ ); +} diff --git a/app/src/components/intelligence/MemoryGraphMap.tsx b/app/src/components/intelligence/MemoryGraphMap.tsx deleted file mode 100644 index 8d888c715..000000000 --- a/app/src/components/intelligence/MemoryGraphMap.tsx +++ /dev/null @@ -1,402 +0,0 @@ -import debug from 'debug'; -import { useCallback, useMemo, useState } from 'react'; - -import type { GraphRelation } from '../../utils/tauriCommands'; - -const log = debug('openhuman:memory-graph'); - -interface MemoryGraphMapProps { - relations: GraphRelation[]; - loading?: boolean; -} - -interface GraphNode { - id: string; - label: string; - namespace: string | null; - entityType: 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; entityType: string | null } - >(); - - for (const r of cappedRelations) { - const subKey = r.subject.toLowerCase(); - const objKey = r.object.toLowerCase(); - const entityTypes = (r.attrs?.entity_types ?? {}) as Record; - - const existing = entitySet.get(subKey); - entitySet.set(subKey, { - namespace: r.namespace, - count: (existing?.count ?? 0) + 1, - entityType: existing?.entityType ?? entityTypes.subject ?? null, - }); - - const existingObj = entitySet.get(objKey); - entitySet.set(objKey, { - namespace: existingObj?.namespace ?? r.namespace, - count: (existingObj?.count ?? 0) + 1, - entityType: existingObj?.entityType ?? entityTypes.object ?? null, - }); - } - - // 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, - entityType: info.entityType, - 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 [hoveredEdge, setHoveredEdge] = useState(null); - const [selectedNode, setSelectedNode] = useState(null); - // Build graph data from relations (synchronous, deterministic) - const { nodes, edges, namespacePalette } = useMemo(() => { - log('[memory-graph] recomputing graph relations=%d', relations.length); - if (relations.length === 0) { - return { - nodes: [] as GraphNode[], - edges: [] as GraphEdge[], - namespacePalette: 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); - log('[memory-graph] graph built nodes=%d edges=%d', simulated.length, rawEdges.length); - return { nodes: simulated, edges: rawEdges, namespacePalette: p }; - }, [relations]); - - 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])); - - // Guard against stale selectedNode — if the node no longer exists in the current - // graph (e.g. after a relations refresh), treat it as deselected so no dimming occurs. - const activeSelectedNode = - selectedNode !== null && nodeMap.has(selectedNode) ? selectedNode : null; - - 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 = activeSelectedNode - ? new Set( - edges - .filter(e => e.source === activeSelectedNode || e.target === activeSelectedNode) - .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 = - activeSelectedNode === null || - edge.source === activeSelectedNode || - edge.target === activeSelectedNode; - - 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 = activeSelectedNode === node.id; - const isDimmed = activeSelectedNode !== null && !connectedIds?.has(node.id); - - return ( - { - e.stopPropagation(); - setSelectedNode(activeSelectedNode === node.id ? null : node.id); - }}> - {(isCenter || isSelected) && ( - - )} - - - {isCenter && node.id !== 'you' ? 'You' : truncate(node.label)} - - {node.entityType && ( - - {node.entityType} - - )} - - ); - })} - -
- - {/* 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/MemoryNavigator.tsx b/app/src/components/intelligence/MemoryNavigator.tsx new file mode 100644 index 000000000..6be9794ec --- /dev/null +++ b/app/src/components/intelligence/MemoryNavigator.tsx @@ -0,0 +1,270 @@ +/** + * Left pane of MemoryWorkspace — search box, slim heatmap header, and four + * collapsible lens sections (recent / sources / people / topics). + * + * Selections are NOT mutually exclusive: multiple selected items intersect + * the result list filter. Sections may be collapsed independently. + */ +import { useEffect, useMemo, useState } from 'react'; + +import type { Chunk, EntityRef, Source } from '../../utils/tauriCommands'; +import { MemoryHeatmap } from './MemoryHeatmap'; + +export interface NavigatorSelection { + sourceIds: string[]; + entityIds: string[]; +} + +interface MemoryNavigatorProps { + chunks: Chunk[]; + sources: Source[]; + topPeople: EntityRef[]; + topTopics: EntityRef[]; + selection: NavigatorSelection; + onSelectionChange: (next: NavigatorSelection) => void; + searchQuery: string; + onSearchChange: (next: string) => void; +} + +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; + +function dotClassFor(status: string | undefined): string { + switch (status) { + case 'admitted': + return 'mw-dot dot-admitted'; + case 'pending_extraction': + return 'mw-dot dot-pending'; + case 'buffered': + return 'mw-dot dot-buffered'; + case 'dropped': + return 'mw-dot dot-dropped'; + default: + return 'mw-dot'; + } +} + +interface SectionProps { + label: string; + defaultOpen?: boolean; + countSummary?: string; + children: React.ReactNode; +} + +function NavSection({ label, defaultOpen = true, countSummary, children }: SectionProps) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+ + {open && children} +
+ ); +} + +export function MemoryNavigator({ + chunks, + sources, + topPeople, + topTopics, + selection, + onSelectionChange, + searchQuery, + onSearchChange, +}: MemoryNavigatorProps) { + const heatmapTimestamps = useMemo( + () => chunks.map(c => Math.floor(c.timestamp_ms / 1000)), + [chunks] + ); + + // Wall-clock-derived counts. Computed in an effect to keep render pure + // (the `react-hooks/components-and-hooks-must-be-pure` rule rejects a + // raw `Date.now()` call inside a `useMemo` body, since two equivalent + // renders could produce different values). + const [recentCounts, setRecentCounts] = useState<{ today: number; week: number }>({ + today: 0, + week: 0, + }); + useEffect(() => { + const now = Date.now(); + const startOfDay = new Date(now); + startOfDay.setHours(0, 0, 0, 0); + const startOfDayMs = startOfDay.getTime(); + const startOfWeekMs = now - 7 * DAY_MS; + let today = 0; + let week = 0; + for (const c of chunks) { + if (c.timestamp_ms >= startOfDayMs) today++; + if (c.timestamp_ms >= startOfWeekMs) week++; + } + setRecentCounts({ today, week }); + }, [chunks]); + const { today: todayCount, week: weekCount } = recentCounts; + + const toggleSource = (id: string) => { + const has = selection.sourceIds.includes(id); + onSelectionChange({ + ...selection, + sourceIds: has ? selection.sourceIds.filter(s => s !== id) : [...selection.sourceIds, id], + }); + }; + + const toggleEntity = (id: string) => { + const has = selection.entityIds.includes(id); + const next = has ? selection.entityIds.filter(s => s !== id) : [...selection.entityIds, id]; + console.debug( + '[ui-flow][memory-navigator] toggleEntity id=%s wasActive=%o next=%o', + id, + has, + next + ); + onSelectionChange({ ...selection, entityIds: next }); + }; + + const renderEntityList = (refs: EntityRef[]) => ( +
    + {refs.map(ref => { + const tag = `${ref.kind}/${ref.surface.replace(/\s+/g, '-')}`; + // For tag-based selection we use the raw tag string; for entity_id we + // compare against tags on chunks. Match either form to be lenient. + const isActive = + selection.entityIds.includes(tag) || selection.entityIds.includes(ref.entity_id); + return ( +
  • + +
  • + ); + })} + {refs.length === 0 && ( +
  • + )} +
+ ); + + return ( + + ); +} diff --git a/app/src/components/intelligence/MemoryResultList.tsx b/app/src/components/intelligence/MemoryResultList.tsx new file mode 100644 index 000000000..38cea0fba --- /dev/null +++ b/app/src/components/intelligence/MemoryResultList.tsx @@ -0,0 +1,175 @@ +/** + * Middle pane of MemoryWorkspace — time-grouped chunk rows. + * + * Sections: TODAY / YESTERDAY / THIS WEEK / OLDER, headers are sticky + * so the user always knows which time bucket is on screen. + * + * Auto-scrolls to the active row on mount and on selection change. + * + * The list is intentionally non-virtualized for now — mock fixtures + * top out at ~30 rows. Once real data lands we can swap in react-window + * (or similar) without changing the public API. + */ +import { useEffect, useMemo, useRef } from 'react'; + +import type { Chunk } from '../../utils/tauriCommands'; + +interface MemoryResultListProps { + chunks: Chunk[]; + selectedChunkId: string | null; + onSelectChunk: (id: string) => void; +} + +type GroupKey = 'TODAY' | 'YESTERDAY' | 'THIS WEEK' | 'OLDER'; + +interface Group { + key: GroupKey; + chunks: Chunk[]; +} + +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; + +function startOfLocalDay(d: Date): Date { + const out = new Date(d); + out.setHours(0, 0, 0, 0); + return out; +} + +function bucketFor( + ts: number, + todayMs: number, + yesterdayMs: number, + weekStartMs: number +): GroupKey { + if (ts >= todayMs) return 'TODAY'; + if (ts >= yesterdayMs) return 'YESTERDAY'; + if (ts >= weekStartMs) return 'THIS WEEK'; + return 'OLDER'; +} + +function pad2(n: number): string { + return n < 10 ? `0${n}` : String(n); +} + +const WEEKDAY_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const MONTH_SHORT = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +function formatTime(ts: number, group: GroupKey): string { + const d = new Date(ts); + if (group === 'TODAY' || group === 'YESTERDAY') { + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; + } + if (group === 'THIS WEEK') { + return `${WEEKDAY_SHORT[d.getDay()]} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`; + } + return `${MONTH_SHORT[d.getMonth()]} ${d.getDate()}`; +} + +function chunkSubject(chunk: Chunk): string { + const preview = (chunk.content_preview ?? '').trim(); + if (!preview) return chunk.id; + // Use the first sentence/line as the subject + const firstLine = preview.split('\n')[0]; + const sentenceEnd = firstLine.search(/[.!?](?:\s|$)/); + if (sentenceEnd > 16 && sentenceEnd < 120) { + return firstLine.slice(0, sentenceEnd + 1).trim(); + } + return firstLine.length > 140 ? `${firstLine.slice(0, 137)}…` : firstLine; +} + +function chunkSenderLabel(chunk: Chunk): string { + // Try to derive a sender from source_id; fall back to source kind. + const left = chunk.source_id.split('|')[0]; + const after = left.includes(':') ? left.split(':').slice(1).join(':') : left; + return after || chunk.source_kind; +} + +export function MemoryResultList({ + chunks, + selectedChunkId, + onSelectChunk, +}: MemoryResultListProps) { + const groups = useMemo(() => { + const today = startOfLocalDay(new Date()).getTime(); + const yesterday = today - DAY_MS; + const weekStart = today - 7 * DAY_MS; + + const buckets: Record = { + TODAY: [], + YESTERDAY: [], + 'THIS WEEK': [], + OLDER: [], + }; + for (const c of chunks) { + buckets[bucketFor(c.timestamp_ms, today, yesterday, weekStart)].push(c); + } + const order: GroupKey[] = ['TODAY', 'YESTERDAY', 'THIS WEEK', 'OLDER']; + return order.map(key => ({ key, chunks: buckets[key] })).filter(g => g.chunks.length > 0); + }, [chunks]); + + const activeRowRef = useRef(null); + + useEffect(() => { + if (activeRowRef.current) { + activeRowRef.current.scrollIntoView({ block: 'nearest' }); + } + }, [selectedChunkId]); + + if (chunks.length === 0) { + return ( +
+
No matching chunks.
+
+ ); + } + + return ( +
+
+ {groups.map(group => ( +
+
{group.key}
+ {group.chunks.map(chunk => { + const isActive = chunk.id === selectedChunkId; + return ( + + ); + })} +
+ ))} +
+
+ ); +} diff --git a/app/src/components/intelligence/MemoryWorkspace.tsx b/app/src/components/intelligence/MemoryWorkspace.tsx index cb89d4907..3fe0f585a 100644 --- a/app/src/components/intelligence/MemoryWorkspace.tsx +++ b/app/src/components/intelligence/MemoryWorkspace.tsx @@ -1,837 +1,300 @@ +/** + * Two-pane memory_tree browser with overlay detail. + * + * ┌─────────────┬──────────────────────────────────────┐ + * │ NAVIGATOR │ RESULT LIST │ ← default 2-pane + * │ 240px │ flex │ + * └─────────────┴──────────────────────────────────────┘ + * + * ┌──────────────────────────────────────────────────[✕]┐ + * │ CHUNK DETAIL (full card width) │ ← when chunk selected + * │ Subject · sender · entities · body · score │ + * └─────────────────────────────────────────────────────┘ + * + * ResultList finally gets ~970px to breathe (multi-line rows with + * sender, time, entity chips, preview). When a chunk is selected the + * detail layer absolute-positions over the 2-pane base so list scroll + * state is preserved on close. Esc + close button + (later) backdrop + * click all dismiss. + * + * Talks to the real `openhuman.memory_tree_*` JSON-RPC surface via the + * `utils/tauriCommands/memoryTree` wrappers. + */ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useIntelligenceStats } from '../../hooks/useIntelligenceStats'; -import { channelConnectionsApi } from '../../services/api/channelConnectionsApi'; -import type { ChannelStatusEntry } from '../../types/channels'; import type { ToastNotification } from '../../types/intelligence'; import { - aiListMemoryFiles, - aiReadMemoryFile, - aiWriteMemoryFile, - type GraphRelation, - isTauri, - memoryDeleteDocument, - memoryGraphQuery, - memoryLearnAll, - type MemoryLearnAllResult, - memoryListDocuments, - memoryListNamespaces, - memoryQueryNamespace, - type MemoryQueryResult, - memoryRecallNamespace, - memorySyncAll, - memorySyncChannel, + type Chunk, + type ChunkFilter, + type EntityRef, + memoryTreeChunksForEntity, + memoryTreeListChunks, + memoryTreeListSources, + memoryTreeTopEntities, + type Source, } from '../../utils/tauriCommands'; -import { MemoryGraphMap } from './MemoryGraphMap'; -import { MemoryHeatmap } from './MemoryHeatmap'; -import { MemoryInsights } from './MemoryInsights'; -import { MemoryStatsBar } from './MemoryStatsBar'; -import { MemoryTextWithEntities } from './MemoryTextWithEntities'; - -type MemoryDoc = { documentId: string; namespace: string; title?: string; raw: unknown }; +import './memory-workspace.css'; +import { MemoryChunkDetail } from './MemoryChunkDetail'; +import { MemoryEmptyPlaceholder } from './MemoryEmptyPlaceholder'; +import { MemoryNavigator, type NavigatorSelection } from './MemoryNavigator'; +import { MemoryResultList } from './MemoryResultList'; interface MemoryWorkspaceProps { - onToast: (toast: Omit) => void; + onToast?: (toast: Omit) => void; } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function asRecord(value: unknown): Record | 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; - } - return undefined; -} - -function findDocumentRows(payload: unknown): unknown[] { - if (Array.isArray(payload)) return payload; - const root = asRecord(payload); - if (!root) return []; - 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 ['documents', 'items', 'results']) { - const value = data[key]; - if (Array.isArray(value)) return value; - } - return []; -} - -function normalizeMemoryDocuments(payload: unknown): MemoryDoc[] { - 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 extractTimestamp(raw: unknown): number | null { - const record = asRecord(raw); - if (!record) return null; - for (const key of [ - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', - 'timestamp', - 'insertedAt', - 'inserted_at', - ]) { - const value = record[key]; - if (typeof value === 'number' && Number.isFinite(value)) { - return value > 9999999999 ? value / 1000 : value; - } - if (typeof value === 'string') { - const date = new Date(value); - 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() && - left.getMonth() === right.getMonth() && - left.getDate() === right.getDate() - ); -} - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { - const { sessions, isLoading: statsLoading, refetch: refetchStats } = useIntelligenceStats(); - - const [memoryDocs, setMemoryDocs] = useState([]); - const [memoryNamespaces, setMemoryNamespaces] = useState([]); - const [memoryFilesList, setMemoryFilesList] = useState([]); - const [memoryWorkspaceLoading, setMemoryWorkspaceLoading] = useState(false); - const [memoryWorkspaceError, setMemoryWorkspaceError] = useState(null); - - const [graphRelations, setGraphRelations] = useState([]); - const [graphRelationsLoading, setGraphRelationsLoading] = useState(false); - - // Sync section - const [syncOpen, setSyncOpen] = useState(false); - const [connectedChannels, setConnectedChannels] = useState([]); - const [syncingAll, setSyncingAll] = useState(false); - const [syncingChannelId, setSyncingChannelId] = useState(null); - - // Learn section - const [learnOpen, setLearnOpen] = useState(false); - const [learning, setLearning] = useState(false); - const [learnResult, setLearnResult] = useState(null); - const [learnErrorOpen, setLearnErrorOpen] = 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(''); - const [selectedFileLoading, setSelectedFileLoading] = useState(false); - const [selectedFileError, setSelectedFileError] = useState(null); - - const [queryInput, setQueryInput] = useState('important user preferences and active goals'); - const [queryResult, setQueryResult] = useState(null); - const [queryLoading, setQueryLoading] = useState(false); - const [recallResult, setRecallResult] = useState(null); - const [recallLoading, setRecallLoading] = useState(false); - const [memoryActionError, setMemoryActionError] = useState(null); - - const [memoryNote, setMemoryNote] = useState(''); - const [memoryNoteSaving, setMemoryNoteSaving] = useState(false); - - // --------------------------------------------------------------------------- - // Data loading - // --------------------------------------------------------------------------- - - const loadWorkspace = useCallback(async () => { - if (!isTauri()) return; - setMemoryWorkspaceLoading(true); - setMemoryWorkspaceError(null); - - try { - const [documentsPayload, namespacesPayload, memoryDirFiles] = await Promise.all([ - memoryListDocuments(), - memoryListNamespaces(), - aiListMemoryFiles('memory'), - ]); - - setGraphRelationsLoading(true); - try { - const relations = await memoryGraphQuery(selectedNamespace || undefined); - setGraphRelations(relations); - } catch (err) { - console.error('[MemoryWorkspace] memoryGraphQuery failed:', err); - setGraphRelations([]); - } finally { - setGraphRelationsLoading(false); - } - - const docs = normalizeMemoryDocuments(documentsPayload); - const combinedFiles = ['memory.md', ...memoryDirFiles.map(file => `memory/${file}`)]; - - // Load connected channels for the Sync section. - try { - const statuses = await channelConnectionsApi.listStatus(); - setConnectedChannels(statuses.filter(s => s.connected)); - } catch (err) { - console.debug('[MemoryWorkspace] listStatus failed (non-fatal):', err); - setConnectedChannels([]); - } - - setMemoryDocs(docs); - setMemoryNamespaces(namespacesPayload); - setMemoryFilesList(combinedFiles); - - if (!selectedNamespace && namespacesPayload.length > 0) { - setSelectedNamespace(namespacesPayload[0]); - } - if (!combinedFiles.includes(selectedFile)) { - setSelectedFile(combinedFiles[0] || 'memory.md'); - } - } catch (error) { - setMemoryWorkspaceError(error instanceof Error ? error.message : 'Failed to load memory'); - setMemoryDocs([]); - setMemoryNamespaces([]); - setMemoryFilesList([]); - } finally { - setMemoryWorkspaceLoading(false); - } - }, [selectedFile, selectedNamespace]); - - const loadSelectedFile = useCallback(async () => { - if (!isTauri() || !selectedFile) return; - setSelectedFileLoading(true); - setSelectedFileError(null); - try { - const content = await aiReadMemoryFile(selectedFile); - setSelectedFileContent(content); - } catch (error) { - setSelectedFileError(error instanceof Error ? error.message : 'Failed to load file'); - setSelectedFileContent(''); - } finally { - setSelectedFileLoading(false); - } - }, [selectedFile]); +export function MemoryWorkspace({ onToast: _onToast }: MemoryWorkspaceProps) { + const [allChunks, setAllChunks] = useState([]); + const [sources, setSources] = useState([]); + const [topPeople, setTopPeople] = useState([]); + const [topTopics, setTopTopics] = useState([]); + const [selectedChunkId, setSelectedChunkId] = useState(null); + const [selection, setSelection] = useState({ sourceIds: [], entityIds: [] }); + const [searchQuery, setSearchQuery] = useState(''); + // Initial data load. 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; + console.debug('[ui-flow][memory-workspace] initial load (2-pane + overlay)'); + let cancelled = false; + const run = async () => { try { - await memoryDeleteDocument(doc.documentId, doc.namespace); - await loadWorkspace(); - await refetchStats(); - onToast({ - type: 'success', - title: 'Document Deleted', - message: `${doc.documentId} removed from ${doc.namespace}`, - }); - } catch (error) { - setMemoryActionError(error instanceof Error ? error.message : 'Delete failed'); - } - }, - [loadWorkspace, onToast, refetchStats] - ); - - const handleQueryNamespace = useCallback(async () => { - if (!selectedNamespace || !queryInput.trim()) return; - setQueryLoading(true); - setMemoryActionError(null); - try { - const response = await memoryQueryNamespace(selectedNamespace, queryInput.trim(), 10); - setQueryResult(response); - } catch (error) { - setMemoryActionError(error instanceof Error ? error.message : 'Query failed'); - setQueryResult(null); - } finally { - setQueryLoading(false); - } - }, [queryInput, selectedNamespace]); - - const handleRecallNamespace = useCallback(async () => { - if (!selectedNamespace) return; - setRecallLoading(true); - setMemoryActionError(null); - try { - const response = await memoryRecallNamespace(selectedNamespace, 10); - setRecallResult(response); - } catch (error) { - setMemoryActionError(error instanceof Error ? error.message : 'Recall failed'); - setRecallResult(null); - } finally { - setRecallLoading(false); - } - }, [selectedNamespace]); - - const handleSaveMemoryNote = useCallback(async () => { - if (!memoryNote.trim()) return; - setMemoryNoteSaving(true); - setMemoryActionError(null); - try { - let existing = ''; - try { - existing = await aiReadMemoryFile('memory.md'); - } 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', - message: 'Your note was saved to memory.md', - }); - } catch (error) { - setMemoryActionError(error instanceof Error ? error.message : 'Failed to save note'); - } finally { - setMemoryNoteSaving(false); - } - }, [loadSelectedFile, loadWorkspace, memoryNote, onToast, refetchStats]); - - // --------------------------------------------------------------------------- - // Sync handlers - // --------------------------------------------------------------------------- - - const handleSyncAll = useCallback(async () => { - setSyncingAll(true); - try { - await memorySyncAll(); - onToast({ - type: 'success', - title: 'Sync Requested', - message: 'Sync requested for all channels.', - }); - } catch (err) { - onToast({ - type: 'error', - title: 'Sync Failed', - message: err instanceof Error ? err.message : 'Sync all failed.', - }); - } finally { - setSyncingAll(false); - } - }, [onToast]); - - const handleSyncChannel = useCallback( - async (channelId: string) => { - setSyncingChannelId(channelId); - try { - await memorySyncChannel(channelId); - onToast({ - type: 'success', - title: 'Sync Requested', - message: `Sync requested for channel ${channelId}.`, - }); + const [chunkResult, srcs, people, anyEntities] = await Promise.all([ + memoryTreeListChunks({ limit: 500 }), + memoryTreeListSources(), + memoryTreeTopEntities('person', 12), + memoryTreeTopEntities(undefined, 40), + ]); + if (cancelled) return; + const topicKinds = new Set(['technology', 'product', 'event']); + const topics = anyEntities.filter(e => topicKinds.has(e.kind)).slice(0, 12); + setAllChunks(chunkResult.chunks); + setSources(srcs); + setTopPeople(people); + setTopTopics(topics); } catch (err) { - onToast({ - type: 'error', - title: 'Sync Failed', - message: err instanceof Error ? err.message : 'Sync failed.', - }); - } finally { - setSyncingChannelId(null); + if (cancelled) return; + // Initial-load failure leaves the panes empty rather than + // half-populated with stale state. The console line lets us + // diagnose without blocking the user behind a modal — they can + // still navigate the tab and retry by reloading. + console.error('[ui-flow][memory-workspace] initial load failed', err); } - }, - [onToast] + }; + void run(); + return () => { + cancelled = true; + }; + }, []); + + // Resolve entity selection → set of chunk ids via the dedicated + // `memory_tree_chunks_for_entity` RPC. The chunks' `tags` column only + // stores high-level category tags (`["gmail", "ingested"]`), NOT + // per-chunk entity refs — those live in `mem_tree_entity_index`. + // Calling the inverse-index RPC gives us the real chunk ids that + // mention each selected entity. Union across the selection (chunk + // mentions ANY of the selected entities is enough — same semantics + // as a multi-select OR filter in Mail.app's people sidebar). + const [entityChunkIds, setEntityChunkIds] = useState | null>(null); + useEffect(() => { + console.debug( + '[ui-flow][memory-workspace] entity-effect fire entityIds=%o', + selection.entityIds + ); + let cancelled = false; + const run = async () => { + if (selection.entityIds.length === 0) { + setEntityChunkIds(null); + return; + } + try { + const results = await Promise.all( + selection.entityIds.map(id => memoryTreeChunksForEntity(id)) + ); + if (cancelled) { + console.debug('[ui-flow][memory-workspace] entity-effect cancelled before commit'); + return; + } + const union = new Set(); + for (const ids of results) for (const id of ids) union.add(id); + console.debug( + '[ui-flow][memory-workspace] entity-effect commit set_size=%d sample=%o', + union.size, + [...union].slice(0, 3) + ); + setEntityChunkIds(union); + } catch (err) { + if (cancelled) return; + // If the inverse-index lookup rejects, do NOT keep filtering by + // the previously-resolved set — that would leave the result list + // showing chunks tied to the old selection while the user thinks + // they've moved on. Reset to "no entity filter" so they at least + // see the unfiltered timeline; the navigator selection is left + // alone so they can retry by reselecting. + console.error('[ui-flow][memory-workspace] entity-effect lookup failed', err); + setEntityChunkIds(null); + } + }; + void run(); + return () => { + cancelled = true; + }; + }, [selection.entityIds]); + + // Apply navigator selection + search. + const filteredChunks = useMemo(() => { + const filter: ChunkFilter = { + source_ids: selection.sourceIds.length > 0 ? selection.sourceIds : undefined, + query: searchQuery.trim() || undefined, + }; + const out = allChunks.filter(c => { + if (filter.source_ids && !filter.source_ids.includes(c.source_id)) return false; + if (entityChunkIds && !entityChunkIds.has(c.id)) return false; + if (filter.query) { + const needle = filter.query.toLowerCase(); + const hay = `${c.content_preview ?? ''} ${c.tags.join(' ')}`.toLowerCase(); + if (!hay.includes(needle)) return false; + } + return true; + }); + console.debug( + '[ui-flow][memory-workspace] filteredChunks recompute all=%d entitySet=%s out=%d', + allChunks.length, + entityChunkIds ? `Set(${entityChunkIds.size})` : 'null', + out.length + ); + return out; + }, [allChunks, selection.sourceIds, entityChunkIds, searchQuery]); + + const selectedChunk = useMemo( + () => allChunks.find(c => c.id === selectedChunkId) ?? null, + [allChunks, selectedChunkId] ); - // --------------------------------------------------------------------------- - // Learn handler - // --------------------------------------------------------------------------- + const handleSelectChunk = useCallback((id: string) => { + console.debug('[ui-flow][memory-workspace] open chunk overlay', id); + setSelectedChunkId(id); + }, []); - const handleLearnAll = useCallback(async () => { - setLearning(true); - setLearnResult(null); - setLearnErrorOpen(false); - try { - const result = await memoryLearnAll(); - setLearnResult(result); - onToast({ - type: 'success', - title: 'Learn Complete', - message: `${result.namespaces_processed} namespace(s) processed.`, - }); - // Refresh workspace data after a successful learn; errors here are - // non-fatal (learn already completed) so we don't surface them as - // "Learn Failed". - void Promise.allSettled([loadWorkspace(), refetchStats()]); - } catch (err) { - onToast({ - type: 'error', - title: 'Learn Failed', - message: err instanceof Error ? err.message : 'Learn failed.', - }); - } finally { - setLearning(false); - } - }, [loadWorkspace, onToast, refetchStats]); + const handleCloseDetail = useCallback(() => { + setSelectedChunkId(null); + }, []); - // --------------------------------------------------------------------------- - // Derived data - // --------------------------------------------------------------------------- + const handleSelectionChange = useCallback((next: NavigatorSelection) => { + setSelection(next); + }, []); - const today = new Date(); - const docsToday = memoryDocs.filter(doc => { - const ts = extractTimestamp(doc.raw); - return ts ? isSameLocalDay(new Date(ts * 1000), today) : false; - }).length; + const handleSearchChange = useCallback((q: string) => { + setSearchQuery(q); + }, []); - const estimatedStorageBytes = useMemo( - () => memoryDocs.reduce((sum, doc) => sum + estimateContentSize(doc.raw), 0), - [memoryDocs] - ); + const handleSelectEntity = useCallback((entity: EntityRef) => { + console.debug('[ui-flow][memory-workspace] entity click → activate lens', entity.entity_id); + setSelection(prev => { + if (prev.entityIds.includes(entity.entity_id)) return prev; + return { ...prev, entityIds: [...prev.entityIds, entity.entity_id] }; + }); + // Closing detail surfaces the filtered list immediately. + setSelectedChunkId(null); + }, []); - const docTimestamps = useMemo( - () => memoryDocs.map(doc => extractTimestamp(doc.raw)).filter((t): t is number => t !== null), - [memoryDocs] - ); + // Esc key dismisses the detail overlay. + useEffect(() => { + if (!selectedChunkId) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + handleCloseDetail(); + } + }; + window.addEventListener('keydown', handleKey); + return () => window.removeEventListener('keydown', handleKey); + }, [selectedChunkId, handleCloseDetail]); - const oldestTimestamp = docTimestamps.length > 0 ? Math.min(...docTimestamps) : null; - const newestTimestamp = docTimestamps.length > 0 ? Math.max(...docTimestamps) : null; + const isEmpty = allChunks.length === 0; - // Combine doc timestamps + graph relation updated_at for heatmap - const heatmapTimestamps = useMemo(() => { - const timestamps = [...docTimestamps]; - for (const rel of graphRelations) { - if (rel.updatedAt) timestamps.push(rel.updatedAt); - } - return timestamps; - }, [docTimestamps, graphRelations]); - - // --------------------------------------------------------------------------- - // Render - // --------------------------------------------------------------------------- + if (isEmpty) { + return ( +
+ +
+ ); + } return ( -
- {/* Header */} -
-
-
-

Memory

-

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

-
- -
- - {/* Stats bar */} - + {/* 2-pane base */} +
+ - {/* Knowledge Graph */} - +
+ +
- {/* Intelligent Insights */} - - - {/* Activity Heatmap */} - - - {/* Collapsible: Files & Management */} -
- - - {manageOpen && ( -
- {/* File browser */} -
-

Memory Files

-
-
- {memoryFilesList.length === 0 ? ( -
No files found.
- ) : ( - memoryFilesList.map(filePath => ( - - )) - )} -
-
- {selectedFileLoading ? ( -
Loading file...
- ) : selectedFileError ? ( -
{selectedFileError}
- ) : ( -
-                      {selectedFileContent || 'Empty file'}
-                    
- )} -
-
-
- - {/* Namespace query & management */} -
-
-

Manage Namespace

- - - -