ltcai 4.3.0 → 4.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +186 -276
  2. package/bin/ltcai.js +6 -2
  3. package/docs/CHANGELOG.md +124 -3
  4. package/docs/V4_3_2_DEADCODE_AUDIT_REPORT.md +174 -0
  5. package/docs/V4_3_2_DOCUMENTATION_CLEANUP_REPORT.md +81 -0
  6. package/docs/V4_3_2_GITHUB_VERCEL_CHECK_REPORT.md +75 -0
  7. package/docs/V4_3_2_GRAPH_UX_REPORT.md +48 -0
  8. package/docs/V4_3_2_INDEPENDENT_AUDIT_PACKAGE.md +209 -0
  9. package/docs/V4_3_2_PRODUCT_POLISH_REPORT.md +57 -0
  10. package/docs/V4_3_2_SELF_AUDIT_REPORT.md +63 -0
  11. package/docs/V4_3_2_VALIDATION_REPORT.md +97 -0
  12. package/docs/V4_3_3_VALIDATION_REPORT.md +46 -0
  13. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +18 -25
  14. package/frontend/openapi.json +11 -1
  15. package/frontend/src/App.tsx +15 -1
  16. package/frontend/src/api/client.ts +19 -1
  17. package/frontend/src/api/openapi.ts +10 -0
  18. package/frontend/src/components/primitives.tsx +92 -10
  19. package/frontend/src/pages/Act.tsx +72 -9
  20. package/frontend/src/pages/Ask.tsx +2 -2
  21. package/frontend/src/pages/Brain.tsx +607 -65
  22. package/frontend/src/pages/Capture.tsx +11 -7
  23. package/frontend/src/pages/Library.tsx +12 -6
  24. package/frontend/src/pages/System.tsx +186 -23
  25. package/lattice_brain/__init__.py +1 -1
  26. package/lattice_brain/archive.py +3 -3
  27. package/lattice_brain/storage/sqlite.py +15 -2
  28. package/latticeai/__init__.py +1 -1
  29. package/latticeai/api/agents.py +3 -1
  30. package/latticeai/api/models.py +66 -18
  31. package/latticeai/brain/projection.py +12 -2
  32. package/latticeai/brain/retrieval.py +10 -0
  33. package/latticeai/brain/store.py +6 -1
  34. package/latticeai/core/config.py +3 -1
  35. package/latticeai/core/marketplace.py +1 -1
  36. package/latticeai/core/multi_agent.py +1 -1
  37. package/latticeai/core/product_hardening.py +2 -1
  38. package/latticeai/core/workspace_os.py +1 -1
  39. package/latticeai/services/agent_runtime.py +52 -12
  40. package/latticeai/services/model_runtime.py +83 -2
  41. package/ltcai_cli.py +14 -3
  42. package/package.json +5 -7
  43. package/requirements.txt +17 -0
  44. package/scripts/build_vercel_static.mjs +77 -0
  45. package/scripts/check_markdown_links.mjs +75 -0
  46. package/src-tauri/Cargo.lock +1 -1
  47. package/src-tauri/Cargo.toml +1 -1
  48. package/src-tauri/src/main.rs +269 -27
  49. package/src-tauri/tauri.conf.json +20 -1
  50. package/static/app/asset-manifest.json +5 -5
  51. package/static/app/assets/index-CHHal8Zl.css +2 -0
  52. package/static/app/assets/index-pdzil9ac.js +333 -0
  53. package/static/app/assets/index-pdzil9ac.js.map +1 -0
  54. package/static/app/index.html +2 -2
  55. package/latticeai/api/deps.py +0 -15
  56. package/scripts/capture/README.md +0 -28
  57. package/scripts/capture/capture_enterprise.js +0 -8
  58. package/scripts/capture/capture_graph.js +0 -8
  59. package/scripts/capture/capture_onboarding.js +0 -8
  60. package/scripts/capture/capture_page.js +0 -43
  61. package/scripts/capture/capture_release_media.js +0 -125
  62. package/scripts/capture/capture_skills.js +0 -8
  63. package/scripts/capture/capture_v340.js +0 -88
  64. package/scripts/capture/capture_workspace.js +0 -8
  65. package/scripts/generate_diagrams.py +0 -512
  66. package/scripts/release-0.3.1.sh +0 -105
  67. package/scripts/take_screenshots.js +0 -69
  68. package/static/app/assets/index-RiJTJliG.js +0 -333
  69. package/static/app/assets/index-RiJTJliG.js.map +0 -1
  70. package/static/app/assets/index-yZswHE3d.css +0 -2
  71. package/static/css/tokens.3ba22e37.css +0 -260
@@ -1,17 +1,63 @@
1
1
  import * as React from "react";
2
2
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
- import cytoscape, { Core } from "cytoscape";
4
- import { BrainCircuit, DatabaseBackup, Network, Search, Sparkles } from "lucide-react";
3
+ import cytoscape, { Core, ElementDefinition } from "cytoscape";
4
+ import { BrainCircuit, DatabaseBackup, Filter, Focus, Layers3, LocateFixed, Search, Sparkles } from "lucide-react";
5
5
  import { latticeApi } from "@/api/client";
6
- import { ActionButton, DataPanel, EntityList, JsonView, LoadingPanel, StatGrid, Tabs } from "@/components/primitives";
6
+ import { ActionButton, DataPanel, EmptyState, EntityList, LoadingPanel, OperationResult, StatGrid, StructuredView, Tabs } from "@/components/primitives";
7
7
  import { Badge } from "@/components/ui/badge";
8
8
  import { Button } from "@/components/ui/button";
9
9
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
10
10
  import { Input } from "@/components/ui/input";
11
11
  import { Textarea } from "@/components/ui/textarea";
12
- import { asArray, pct } from "@/lib/utils";
12
+ import { asArray, fmtNumber, pct, shortId, titleize } from "@/lib/utils";
13
13
 
14
14
  type BrainTab = "overview" | "graph" | "search" | "memory" | "provenance" | "portability";
15
+ type LabelMode = "important" | "all" | "off";
16
+
17
+ type GraphNode = {
18
+ id: string;
19
+ label: string;
20
+ type: string;
21
+ group: string;
22
+ summary: string;
23
+ source: string;
24
+ importance: number;
25
+ degree: number;
26
+ searchText: string;
27
+ raw: Record<string, unknown>;
28
+ };
29
+
30
+ type GraphEdge = {
31
+ id: string;
32
+ source: string;
33
+ target: string;
34
+ label: string;
35
+ weight: number;
36
+ };
37
+
38
+ type GraphGroup = {
39
+ id: string;
40
+ label: string;
41
+ color: string;
42
+ count: number;
43
+ visibleCount: number;
44
+ collapsed: boolean;
45
+ };
46
+
47
+ type ParsedGraph = {
48
+ nodes: GraphNode[];
49
+ edges: GraphEdge[];
50
+ groups: GraphGroup[];
51
+ };
52
+
53
+ type ExplorerModel = ParsedGraph & {
54
+ elements: ElementDefinition[];
55
+ visibleNodes: GraphNode[];
56
+ visibleEdges: GraphEdge[];
57
+ totalNodes: number;
58
+ totalEdges: number;
59
+ hiddenByFilters: number;
60
+ };
15
61
 
16
62
  const tabs: Array<{ id: BrainTab; label: string }> = [
17
63
  { id: "overview", label: "Overview" },
@@ -22,75 +68,346 @@ const tabs: Array<{ id: BrainTab; label: string }> = [
22
68
  { id: "portability", label: "Portability" },
23
69
  ];
24
70
 
25
- function graphElements(data: unknown) {
26
- const graph = data as { nodes?: Array<Record<string, unknown>>; edges?: Array<Record<string, unknown>> };
27
- const nodes = asArray<Record<string, unknown>>(graph.nodes).slice(0, 160).map((node) => ({
28
- data: {
29
- id: String(node.id || node.node_id || node.title),
30
- label: String(node.title || node.label || node.id || "Node"),
31
- type: String(node.type || "Node"),
32
- },
33
- }));
34
- const nodeIds = new Set(nodes.map((node) => node.data.id));
35
- const edges = asArray<Record<string, unknown>>(graph.edges).slice(0, 260).flatMap((edge, index) => {
36
- const source = String(edge.from || edge.source || edge.source_id || "");
37
- const target = String(edge.to || edge.target || edge.target_id || "");
38
- if (!nodeIds.has(source) || !nodeIds.has(target)) return [];
71
+ const groupDefinitions = [
72
+ { id: "knowledge", label: "Knowledge", color: "#20c997", types: ["topic", "concept", "entity", "decision", "insight", "claim", "fact"] },
73
+ { id: "source", label: "Sources", color: "#60a5fa", types: ["file", "document", "source", "chunk", "note", "url", "page", "image", "transcript"] },
74
+ { id: "activity", label: "Activity", color: "#f59e0b", types: ["task", "workflow", "agent", "run", "approval", "hook"] },
75
+ { id: "memory", label: "Memory", color: "#a78bfa", types: ["memory", "conversation", "message", "chat", "context"] },
76
+ { id: "people", label: "People", color: "#f472b6", types: ["person", "user", "team", "organization", "org"] },
77
+ { id: "system", label: "System", color: "#94a3b8", types: ["model", "skill", "plugin", "setting", "policy", "device", "storage"] },
78
+ { id: "other", label: "Other", color: "#f8fafc", types: [] },
79
+ ] as const;
80
+
81
+ const groupLookup: Map<string, string> = new Map(groupDefinitions.flatMap((group) => group.types.map((type) => [type, group.id])));
82
+
83
+ function isRecord(value: unknown): value is Record<string, unknown> {
84
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
85
+ }
86
+
87
+ function field(record: Record<string, unknown>, keys: string[], fallback = "") {
88
+ for (const key of keys) {
89
+ const value = record[key];
90
+ if (typeof value === "string" && value.trim()) return value;
91
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
92
+ }
93
+ return fallback;
94
+ }
95
+
96
+ function numberField(record: Record<string, unknown>, keys: string[]) {
97
+ for (const key of keys) {
98
+ const value = Number(record[key]);
99
+ if (Number.isFinite(value)) return value;
100
+ }
101
+ return 0;
102
+ }
103
+
104
+ function nestedRecord(record: Record<string, unknown>, key: string) {
105
+ return isRecord(record[key]) ? record[key] as Record<string, unknown> : {};
106
+ }
107
+
108
+ function groupForType(type: string) {
109
+ return groupLookup.get(type.toLowerCase()) || "other";
110
+ }
111
+
112
+ function groupDefinition(id: string) {
113
+ return groupDefinitions.find((group) => group.id === id) || groupDefinitions[groupDefinitions.length - 1];
114
+ }
115
+
116
+ function parseGraph(data: unknown): ParsedGraph {
117
+ const graph = isRecord(data) ? data : {};
118
+ const rawNodes = asArray<Record<string, unknown>>(graph.nodes);
119
+ const rawEdges = asArray<Record<string, unknown>>(graph.edges);
120
+ const ids = new Set(rawNodes.map((node) => field(node, ["id", "node_id", "title", "label"])).filter(Boolean));
121
+ const edges = rawEdges.flatMap((edge, index): GraphEdge[] => {
122
+ const source = field(edge, ["from", "source", "source_id"]);
123
+ const target = field(edge, ["to", "target", "target_id"]);
124
+ if (!source || !target || !ids.has(source) || !ids.has(target)) return [];
125
+ return [{
126
+ id: field(edge, ["id"], `edge-${index}`),
127
+ source,
128
+ target,
129
+ label: field(edge, ["type", "label", "relationship"], "related"),
130
+ weight: Math.max(0.2, numberField(edge, ["weight", "score", "confidence"]) || 1),
131
+ }];
132
+ });
133
+ const degree = new Map<string, number>();
134
+ edges.forEach((edge) => {
135
+ degree.set(edge.source, (degree.get(edge.source) || 0) + 1);
136
+ degree.set(edge.target, (degree.get(edge.target) || 0) + 1);
137
+ });
138
+ const maxDegree = Math.max(1, ...Array.from(degree.values()));
139
+ const nodes = rawNodes.flatMap((node): GraphNode[] => {
140
+ const id = field(node, ["id", "node_id", "title", "label"]);
141
+ if (!id) return [];
142
+ const metadata = nestedRecord(node, "metadata");
143
+ const metrics = nestedRecord(metadata, "graph_metrics");
144
+ const type = field(node, ["type", "kind", "category"], "Node");
145
+ const label = field(node, ["title", "label", "name"], shortId(id, 38));
146
+ const explicitImportance = numberField(node, ["importance_norm", "importance", "score"]) || numberField(metrics, ["importance_norm", "importance", "centrality"]);
147
+ const nodeDegree = degree.get(id) || 0;
148
+ const importance = Math.max(0.08, Math.min(1, explicitImportance || (nodeDegree / maxDegree) * 0.8 + 0.12));
149
+ const summary = field(node, ["summary", "description", "snippet"]) || field(metadata, ["summary", "description", "relative_path", "filename"]);
150
+ const source = field(node, ["source", "path"]) || field(metadata, ["source", "relative_path", "filename"]);
151
+ const searchText = [id, label, type, summary, source, Object.keys(metadata).join(" ")].join(" ").toLowerCase();
152
+ return [{ id, label, type, group: groupForType(type), summary, source, importance, degree: nodeDegree, searchText, raw: node }];
153
+ });
154
+ const groupCounts = new Map<string, number>();
155
+ nodes.forEach((node) => groupCounts.set(node.group, (groupCounts.get(node.group) || 0) + 1));
156
+ const groups = groupDefinitions.map((group) => ({
157
+ id: group.id,
158
+ label: group.label,
159
+ color: group.color,
160
+ count: groupCounts.get(group.id) || 0,
161
+ visibleCount: 0,
162
+ collapsed: false,
163
+ })).filter((group) => group.count > 0);
164
+ return { nodes, edges, groups };
165
+ }
166
+
167
+ function buildExplorerModel({
168
+ graph,
169
+ search,
170
+ groupFilter,
171
+ minImportance,
172
+ collapsedGroups,
173
+ selectedId,
174
+ labelMode,
175
+ maxNodes,
176
+ }: {
177
+ graph: ParsedGraph;
178
+ search: string;
179
+ groupFilter: string;
180
+ minImportance: number;
181
+ collapsedGroups: Set<string>;
182
+ selectedId: string | null;
183
+ labelMode: LabelMode;
184
+ maxNodes: number;
185
+ }): ExplorerModel {
186
+ const query = search.trim().toLowerCase();
187
+ const neighborIds = new Set<string>();
188
+ if (selectedId && !selectedId.startsWith("group:")) {
189
+ neighborIds.add(selectedId);
190
+ graph.edges.forEach((edge) => {
191
+ if (edge.source === selectedId) neighborIds.add(edge.target);
192
+ if (edge.target === selectedId) neighborIds.add(edge.source);
193
+ });
194
+ }
195
+ const filtered = graph.nodes
196
+ .filter((node) => groupFilter === "all" || node.group === groupFilter || node.type === groupFilter)
197
+ .filter((node) => query ? node.searchText.includes(query) : node.importance >= minImportance)
198
+ .filter((node) => !neighborIds.size || neighborIds.has(node.id))
199
+ .sort((a, b) => (b.importance + b.degree / 25) - (a.importance + a.degree / 25));
200
+ const capped = filtered.slice(0, maxNodes);
201
+ const visibleCandidateIds = new Set(capped.map((node) => node.id));
202
+ const aggregateNodes = new Map<string, { id: string; group: GraphGroup; count: number; maxImportance: number }>();
203
+ const visibleNodes = capped.filter((node) => {
204
+ if (!collapsedGroups.has(node.group)) return true;
205
+ const definition = groupDefinition(node.group);
206
+ const aggregateId = `group:${node.group}`;
207
+ const current = aggregateNodes.get(aggregateId);
208
+ aggregateNodes.set(aggregateId, {
209
+ id: aggregateId,
210
+ group: { id: definition.id, label: definition.label, color: definition.color, count: 0, visibleCount: 0, collapsed: true },
211
+ count: (current?.count || 0) + 1,
212
+ maxImportance: Math.max(current?.maxImportance || 0, node.importance),
213
+ });
214
+ return false;
215
+ });
216
+ const visibleNodeIds = new Set(visibleNodes.map((node) => node.id));
217
+ const aggregateIds = new Set(aggregateNodes.keys());
218
+ const visibleEdges = graph.edges.filter((edge) => visibleCandidateIds.has(edge.source) && visibleCandidateIds.has(edge.target));
219
+ const mappedEdges = visibleEdges.flatMap((edge, index): ElementDefinition[] => {
220
+ const sourceNode = graph.nodes.find((node) => node.id === edge.source);
221
+ const targetNode = graph.nodes.find((node) => node.id === edge.target);
222
+ const source = visibleNodeIds.has(edge.source) ? edge.source : sourceNode && aggregateIds.has(`group:${sourceNode.group}`) ? `group:${sourceNode.group}` : "";
223
+ const target = visibleNodeIds.has(edge.target) ? edge.target : targetNode && aggregateIds.has(`group:${targetNode.group}`) ? `group:${targetNode.group}` : "";
224
+ if (!source || !target || source === target) return [];
39
225
  return [{
40
226
  data: {
41
- id: String(edge.id || `edge-${index}`),
227
+ id: `${edge.id}-${index}-${source}-${target}`,
42
228
  source,
43
229
  target,
44
- label: String(edge.type || edge.label || ""),
230
+ label: edge.label,
231
+ width: Math.max(1, Math.min(4, edge.weight)),
45
232
  },
233
+ classes: selectedId && (edge.source === selectedId || edge.target === selectedId) ? "connected" : "",
46
234
  }];
47
235
  });
48
- return [...nodes, ...edges];
236
+ const nodeElements: ElementDefinition[] = visibleNodes.map((node) => {
237
+ const definition = groupDefinition(node.group);
238
+ const matched = query && node.searchText.includes(query);
239
+ const label = labelMode === "off" ? "" : labelMode === "all" || node.importance > 0.55 || node.degree > 1 || matched || selectedId === node.id ? node.label : "";
240
+ return {
241
+ data: {
242
+ id: node.id,
243
+ label: node.label,
244
+ displayLabel: label,
245
+ type: node.type,
246
+ group: definition.label,
247
+ color: definition.color,
248
+ borderColor: selectedId === node.id ? "#ffffff" : definition.color,
249
+ size: Math.round(20 + node.importance * 34 + Math.min(node.degree, 10) * 2),
250
+ },
251
+ classes: [
252
+ selectedId === node.id ? "selected" : "",
253
+ matched ? "match" : "",
254
+ neighborIds.size && !neighborIds.has(node.id) ? "faded" : "",
255
+ ].filter(Boolean).join(" "),
256
+ };
257
+ });
258
+ const aggregateElements: ElementDefinition[] = Array.from(aggregateNodes.values()).map((aggregate) => ({
259
+ data: {
260
+ id: aggregate.id,
261
+ label: aggregate.group.label,
262
+ displayLabel: `${aggregate.group.label} (${aggregate.count})`,
263
+ type: "Cluster",
264
+ group: aggregate.group.label,
265
+ color: aggregate.group.color,
266
+ borderColor: "#ffffff",
267
+ size: Math.round(34 + Math.min(aggregate.count, 28) * 2 + aggregate.maxImportance * 16),
268
+ },
269
+ classes: "cluster",
270
+ }));
271
+ const visibleCounts = new Map<string, number>();
272
+ visibleNodes.forEach((node) => visibleCounts.set(node.group, (visibleCounts.get(node.group) || 0) + 1));
273
+ const groups = graph.groups.map((group) => ({
274
+ ...group,
275
+ visibleCount: (visibleCounts.get(group.id) || 0) + (aggregateNodes.get(`group:${group.id}`)?.count || 0),
276
+ collapsed: collapsedGroups.has(group.id),
277
+ }));
278
+ return {
279
+ ...graph,
280
+ groups,
281
+ elements: [...aggregateElements, ...nodeElements, ...mappedEdges],
282
+ visibleNodes,
283
+ visibleEdges,
284
+ totalNodes: graph.nodes.length,
285
+ totalEdges: graph.edges.length,
286
+ hiddenByFilters: Math.max(0, graph.nodes.length - capped.length),
287
+ };
49
288
  }
50
289
 
51
- function CytoscapeGraph({ data }: { data: unknown }) {
290
+ function CytoscapeGraph({
291
+ model,
292
+ selectedId,
293
+ onSelect,
294
+ fitSignal,
295
+ }: {
296
+ model: ExplorerModel;
297
+ selectedId: string | null;
298
+ onSelect: (id: string | null) => void;
299
+ fitSignal: number;
300
+ }) {
52
301
  const hostRef = React.useRef<HTMLDivElement | null>(null);
53
302
  const cyRef = React.useRef<Core | null>(null);
54
303
  React.useEffect(() => {
55
304
  if (!hostRef.current) return;
56
- const elements = graphElements(data);
57
305
  cyRef.current?.destroy();
58
306
  cyRef.current = cytoscape({
59
307
  container: hostRef.current,
60
- elements,
308
+ elements: model.elements,
61
309
  style: [
62
310
  {
63
311
  selector: "node",
64
312
  style: {
65
- "background-color": "#21c7bd",
66
- "border-color": "#88fff5",
67
- "border-width": 1,
68
- color: "#f7ffff",
69
- label: "data(label)",
70
- "font-size": 9,
313
+ "background-color": "data(color)",
314
+ "border-color": "data(borderColor)",
315
+ "border-width": 1.5,
316
+ color: "#f8fafc",
317
+ label: "data(displayLabel)",
318
+ "font-size": 10,
319
+ "font-weight": 600,
71
320
  "text-outline-color": "#071012",
72
- "text-outline-width": 2,
73
- width: 22,
74
- height: 22,
321
+ "text-outline-width": 2.5,
322
+ width: "data(size)",
323
+ height: "data(size)",
324
+ "overlay-opacity": 0,
325
+ },
326
+ },
327
+ {
328
+ selector: "node.cluster",
329
+ style: {
330
+ shape: "round-rectangle",
331
+ "background-opacity": 0.46,
332
+ "border-width": 2,
333
+ "font-size": 12,
334
+ "text-valign": "center",
335
+ "text-halign": "center",
336
+ },
337
+ },
338
+ {
339
+ selector: "node.selected",
340
+ style: {
341
+ "border-width": 4,
342
+ },
343
+ },
344
+ {
345
+ selector: "node.match",
346
+ style: {
347
+ "border-width": 4,
348
+ "border-color": "#fef08a",
75
349
  },
76
350
  },
77
351
  {
78
352
  selector: "edge",
79
353
  style: {
80
- width: 1.2,
81
- "line-color": "#6b7893",
354
+ width: "data(width)",
355
+ "line-color": "#64748b",
82
356
  "target-arrow-shape": "triangle",
83
- "target-arrow-color": "#6b7893",
357
+ "target-arrow-color": "#64748b",
84
358
  "curve-style": "bezier",
359
+ "arrow-scale": 0.7,
360
+ opacity: 0.72,
361
+ },
362
+ },
363
+ {
364
+ selector: "edge.connected",
365
+ style: {
366
+ width: 2.6,
367
+ "line-color": "#fef08a",
368
+ "target-arrow-color": "#fef08a",
369
+ opacity: 1,
85
370
  },
86
371
  },
87
372
  ],
88
- layout: { name: "cose", animate: false, idealEdgeLength: 110, nodeRepulsion: 4500 },
89
- wheelSensitivity: 0.25,
373
+ layout: {
374
+ name: "cose",
375
+ animate: false,
376
+ idealEdgeLength: 120,
377
+ nodeRepulsion: 5800,
378
+ gravity: 0.28,
379
+ numIter: 1200,
380
+ fit: true,
381
+ padding: 32,
382
+ },
383
+ wheelSensitivity: 0.18,
384
+ });
385
+ cyRef.current.on("tap", "node", (event) => onSelect(String(event.target.id())));
386
+ cyRef.current.on("tap", (event) => {
387
+ if (event.target === cyRef.current) onSelect(null);
90
388
  });
91
389
  return () => cyRef.current?.destroy();
92
- }, [data]);
93
- return <div ref={hostRef} data-testid="brain-cytoscape" className="h-[520px] w-full overflow-hidden rounded-lg border border-border bg-background brain-grid" />;
390
+ }, [model.elements, onSelect]);
391
+
392
+ React.useEffect(() => {
393
+ cyRef.current?.fit(undefined, 32);
394
+ }, [fitSignal]);
395
+
396
+ React.useEffect(() => {
397
+ if (!cyRef.current || !selectedId) return;
398
+ const node = cyRef.current.getElementById(selectedId);
399
+ if (node.length) {
400
+ cyRef.current.animate({ center: { eles: node }, zoom: Math.max(cyRef.current.zoom(), 1.15) }, { duration: 180 });
401
+ }
402
+ }, [selectedId]);
403
+
404
+ return (
405
+ <div
406
+ ref={hostRef}
407
+ data-testid="brain-cytoscape"
408
+ className="h-[620px] min-h-[32rem] w-full overflow-hidden rounded-md border border-border bg-background brain-grid"
409
+ />
410
+ );
94
411
  }
95
412
 
96
413
  export function BrainPage({ initialTab }: { initialTab?: string }) {
@@ -112,10 +429,10 @@ export function BrainPage({ initialTab }: { initialTab?: string }) {
112
429
  <div className="flex items-center gap-2 text-sm text-primary"><BrainCircuit className="h-4 w-4" /> Graph-first Digital Brain</div>
113
430
  <h1 className="mt-2 text-3xl font-semibold tracking-normal">Brain</h1>
114
431
  <p className="mt-2 max-w-3xl text-sm text-muted-foreground">
115
- The visible knowledge substrate: graph, memory, provenance, retrieval, and local portability. Empty states come from API availability, not canned data.
432
+ Explore the knowledge graph, memory, provenance, retrieval, and portable brain state. Empty states reflect live API availability.
116
433
  </p>
117
434
  </div>
118
- <div className="rounded-lg border border-border bg-card p-4">
435
+ <div className="rounded-md border border-border bg-card p-4">
119
436
  <div className="text-xs uppercase text-muted-foreground">Provenance coverage</div>
120
437
  <div className="mt-2 text-3xl font-semibold">{pct((coverage.data?.data as Record<string, unknown>)?.coverage_ratio)}</div>
121
438
  <div className="mt-2 text-sm text-muted-foreground">Source: {coverage.data?.source || "loading"}</div>
@@ -126,18 +443,13 @@ export function BrainPage({ initialTab }: { initialTab?: string }) {
126
443
  {tab === "overview" ? (
127
444
  <div className="grid gap-4 xl:grid-cols-2">
128
445
  <DataPanel title="Brain status" result={stats.data}>
129
- {(data) => <StatGrid stats={[
130
- { label: "Nodes", value: (data as Record<string, unknown>).total_nodes ?? 0 },
131
- { label: "Edges", value: (data as Record<string, unknown>).total_edges ?? 0 },
132
- { label: "Node types", value: Object.keys(((data as Record<string, unknown>).nodes as Record<string, unknown>) || {}).length },
133
- { label: "Edge types", value: Object.keys(((data as Record<string, unknown>).edges as Record<string, unknown>) || {}).length },
134
- ]} />}
446
+ {(data) => <GraphStatus data={data as Record<string, unknown>} />}
135
447
  </DataPanel>
136
448
  <DataPanel title="Retrieval index" result={index.data}>
137
- {(data) => <JsonView value={data} />}
449
+ {(data) => <RetrievalStatus data={data as Record<string, unknown>} />}
138
450
  </DataPanel>
139
451
  <DataPanel title="Memory tiers" result={memory.data}>
140
- {(data) => <EntityList items={(data as Record<string, unknown>).tiers || (data as Record<string, unknown>).sources} titleKey="name" metaKey="health" />}
452
+ {(data) => <MemoryStatus data={data as Record<string, unknown>} />}
141
453
  </DataPanel>
142
454
  <DataPanel title="Recent provenance" result={provenance.data}>
143
455
  {(data) => <EntityList items={(data as Record<string, unknown>).items || data} titleKey="source" metaKey="source_type" />}
@@ -147,8 +459,8 @@ export function BrainPage({ initialTab }: { initialTab?: string }) {
147
459
 
148
460
  {tab === "graph" ? (
149
461
  graph.isLoading ? <LoadingPanel title="Knowledge graph" /> : (
150
- <DataPanel title="Knowledge graph" description="Cytoscape.js explorer backed by /knowledge-graph/graph." result={graph.data}>
151
- {(data) => <CytoscapeGraph data={data} />}
462
+ <DataPanel title="Digital Brain explorer" description="Interactive Cytoscape.js explorer backed by /knowledge-graph/graph." result={graph.data}>
463
+ {(data) => <DigitalBrainExplorer data={data} />}
152
464
  </DataPanel>
153
465
  )
154
466
  ) : null}
@@ -161,18 +473,226 @@ export function BrainPage({ initialTab }: { initialTab?: string }) {
161
473
  );
162
474
  }
163
475
 
476
+ function GraphStatus({ data }: { data: Record<string, unknown> }) {
477
+ const nodeTypes = Object.keys((data.nodes as Record<string, unknown>) || {});
478
+ const edgeTypes = Object.keys((data.edges as Record<string, unknown>) || {});
479
+ return (
480
+ <div className="space-y-3">
481
+ <StatGrid stats={[
482
+ { label: "Nodes", value: data.total_nodes ?? nodeTypes.reduce((sum, key) => sum + Number(((data.nodes as Record<string, unknown>) || {})[key] || 0), 0) },
483
+ { label: "Edges", value: data.total_edges ?? edgeTypes.reduce((sum, key) => sum + Number(((data.edges as Record<string, unknown>) || {})[key] || 0), 0) },
484
+ { label: "Node types", value: nodeTypes.length },
485
+ { label: "Edge types", value: edgeTypes.length },
486
+ ]} />
487
+ <StructuredView value={{ node_types: nodeTypes, edge_types: edgeTypes }} />
488
+ </div>
489
+ );
490
+ }
491
+
492
+ function RetrievalStatus({ data }: { data: Record<string, unknown> }) {
493
+ const pipelines = isRecord(data.pipelines) ? data.pipelines : {};
494
+ const rows = Object.entries(pipelines).map(([name, value]) => ({
495
+ name: titleize(name),
496
+ status: isRecord(value) ? String(value.state || value.status || "reported") : "reported",
497
+ description: isRecord(value) ? Object.entries(value).filter(([key]) => key !== "state" && key !== "status").slice(0, 3).map(([key, item]) => `${titleize(key)}: ${String(item)}`).join(" · ") : String(value),
498
+ }));
499
+ return rows.length ? <EntityList items={rows} titleKey="name" metaKey="status" /> : <StructuredView value={data} />;
500
+ }
501
+
502
+ function MemoryStatus({ data }: { data: Record<string, unknown> }) {
503
+ const usage = isRecord(data.usage) ? data.usage : {};
504
+ return (
505
+ <div className="space-y-3">
506
+ <StatGrid stats={[
507
+ { label: "Sources", value: usage.sources ?? asArray(data.sources).length },
508
+ { label: "Items", value: usage.total_items ?? asArray(data.sources).reduce((sum, item) => sum + Number(isRecord(item) ? item.count || 0 : 0), 0) },
509
+ { label: "Bytes", value: usage.total_bytes ?? 0 },
510
+ { label: "Health", value: data.health || "reported" },
511
+ ]} />
512
+ <EntityList items={data.sources || data.tiers} titleKey="label" metaKey="health" />
513
+ </div>
514
+ );
515
+ }
516
+
517
+ function DigitalBrainExplorer({ data }: { data: unknown }) {
518
+ const parsed = React.useMemo(() => parseGraph(data), [data]);
519
+ const [search, setSearch] = React.useState("");
520
+ const [groupFilter, setGroupFilter] = React.useState("all");
521
+ const [minImportance, setMinImportance] = React.useState(0);
522
+ const [labelMode, setLabelMode] = React.useState<LabelMode>("important");
523
+ const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(new Set());
524
+ const [selectedId, setSelectedId] = React.useState<string | null>(null);
525
+ const [fitSignal, setFitSignal] = React.useState(0);
526
+ const backendSearch = useMutation({ mutationFn: () => latticeApi.hybridSearch(search.trim()) });
527
+ const model = React.useMemo(() => buildExplorerModel({
528
+ graph: parsed,
529
+ search,
530
+ groupFilter,
531
+ minImportance,
532
+ collapsedGroups,
533
+ selectedId,
534
+ labelMode,
535
+ maxNodes: 220,
536
+ }), [parsed, search, groupFilter, minImportance, collapsedGroups, selectedId, labelMode]);
537
+ const selected = parsed.nodes.find((node) => node.id === selectedId);
538
+ const selectedGroup = selectedId?.startsWith("group:") ? groupDefinition(selectedId.replace("group:", "")) : null;
539
+ const toggleGroup = (id: string) => {
540
+ setCollapsedGroups((current) => {
541
+ const next = new Set(current);
542
+ if (next.has(id)) next.delete(id);
543
+ else next.add(id);
544
+ return next;
545
+ });
546
+ };
547
+ if (!parsed.nodes.length) {
548
+ return (
549
+ <EmptyState
550
+ title="No graph records yet"
551
+ detail="Capture a document, note, or local folder to create graph nodes with provenance."
552
+ />
553
+ );
554
+ }
555
+ return (
556
+ <div className="space-y-4">
557
+ <div className="grid gap-3 xl:grid-cols-[1fr_220px_180px_170px]">
558
+ <div className="relative">
559
+ <Search className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
560
+ <Input className="pl-9" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search graph labels, types, provenance..." />
561
+ </div>
562
+ <select className="h-9 rounded-md border border-border bg-background px-3 text-sm" value={groupFilter} onChange={(event) => setGroupFilter(event.target.value)}>
563
+ <option value="all">All semantic groups</option>
564
+ {model.groups.map((group) => <option key={group.id} value={group.id}>{group.label}</option>)}
565
+ </select>
566
+ <select className="h-9 rounded-md border border-border bg-background px-3 text-sm" value={labelMode} onChange={(event) => setLabelMode(event.target.value as LabelMode)}>
567
+ <option value="important">Important labels</option>
568
+ <option value="all">All labels</option>
569
+ <option value="off">Hide labels</option>
570
+ </select>
571
+ <Button variant="outline" onClick={() => setFitSignal((value) => value + 1)}><LocateFixed className="h-4 w-4" /> Fit</Button>
572
+ </div>
573
+ <div className="grid gap-3 lg:grid-cols-[1fr_18rem]">
574
+ <Card>
575
+ <CardHeader className="flex-row items-start justify-between gap-3">
576
+ <div>
577
+ <CardTitle className="flex items-center gap-2"><Layers3 className="h-4 w-4" /> Semantic map</CardTitle>
578
+ <CardDescription>
579
+ Showing {fmtNumber(model.visibleNodes.length)} nodes and {fmtNumber(model.visibleEdges.length)} relationships from {fmtNumber(model.totalNodes)} graph nodes.
580
+ </CardDescription>
581
+ </div>
582
+ <Badge variant={model.hiddenByFilters ? "warning" : "success"}>{model.hiddenByFilters ? `${fmtNumber(model.hiddenByFilters)} filtered` : "all in view"}</Badge>
583
+ </CardHeader>
584
+ <CardContent className="space-y-3">
585
+ <div className="flex flex-wrap items-center gap-2">
586
+ <Filter className="h-4 w-4 text-muted-foreground" />
587
+ <label className="text-sm text-muted-foreground" htmlFor="importance">Importance</label>
588
+ <input
589
+ id="importance"
590
+ type="range"
591
+ min="0"
592
+ max="0.9"
593
+ step="0.05"
594
+ value={minImportance}
595
+ onChange={(event) => setMinImportance(Number(event.target.value))}
596
+ className="w-44"
597
+ aria-label="Minimum graph importance"
598
+ />
599
+ <Badge variant="muted">{Math.round(minImportance * 100)}%+</Badge>
600
+ {selectedId ? <Button variant="outline" size="sm" onClick={() => setSelectedId(null)}>Clear focus</Button> : null}
601
+ {search.trim() ? <Button variant="outline" size="sm" onClick={() => backendSearch.mutate()} disabled={backendSearch.isPending}>Search brain</Button> : null}
602
+ </div>
603
+ <div className="flex flex-wrap gap-2">
604
+ {model.groups.map((group) => (
605
+ <button
606
+ key={group.id}
607
+ onClick={() => toggleGroup(group.id)}
608
+ className="inline-flex items-center gap-2 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs hover:bg-muted"
609
+ >
610
+ <span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: group.color }} />
611
+ <span>{group.label}</span>
612
+ <Badge variant={group.collapsed ? "warning" : "muted"}>{group.collapsed ? "collapsed" : fmtNumber(group.count)}</Badge>
613
+ </button>
614
+ ))}
615
+ </div>
616
+ <CytoscapeGraph model={model} selectedId={selectedId} onSelect={setSelectedId} fitSignal={fitSignal} />
617
+ </CardContent>
618
+ </Card>
619
+ <aside className="space-y-3">
620
+ <Card>
621
+ <CardHeader>
622
+ <CardTitle className="flex items-center gap-2"><Focus className="h-4 w-4" /> Focus</CardTitle>
623
+ <CardDescription>Click a node to inspect its neighborhood.</CardDescription>
624
+ </CardHeader>
625
+ <CardContent className="space-y-3">
626
+ {selected ? (
627
+ <>
628
+ <div>
629
+ <div className="text-lg font-semibold">{selected.label}</div>
630
+ <div className="mt-1 flex flex-wrap gap-1">
631
+ <Badge variant="muted">{selected.type}</Badge>
632
+ <Badge variant="muted">{groupDefinition(selected.group).label}</Badge>
633
+ <Badge variant="success">{Math.round(selected.importance * 100)} importance</Badge>
634
+ </div>
635
+ </div>
636
+ {selected.summary ? <p className="text-sm text-muted-foreground">{selected.summary}</p> : null}
637
+ <StructuredView value={{
638
+ id: selected.id,
639
+ degree: selected.degree,
640
+ source: selected.source || "not reported",
641
+ }} />
642
+ </>
643
+ ) : selectedGroup ? (
644
+ <div className="space-y-2">
645
+ <Badge variant="warning">Collapsed group</Badge>
646
+ <div className="text-lg font-semibold">{selectedGroup.label}</div>
647
+ <Button variant="outline" onClick={() => toggleGroup(selectedGroup.id)}>Expand group</Button>
648
+ </div>
649
+ ) : <EmptyState title="No node selected" detail="Select a node or collapsed group in the graph." />}
650
+ </CardContent>
651
+ </Card>
652
+ <Card>
653
+ <CardHeader>
654
+ <CardTitle>Important nodes</CardTitle>
655
+ <CardDescription>Highest-ranked visible graph records.</CardDescription>
656
+ </CardHeader>
657
+ <CardContent className="space-y-2">
658
+ {model.visibleNodes.slice(0, 8).map((node) => (
659
+ <button
660
+ key={node.id}
661
+ onClick={() => setSelectedId(node.id)}
662
+ className="block w-full rounded-md border border-border bg-background p-2 text-left text-sm hover:bg-muted"
663
+ >
664
+ <div className="flex items-center justify-between gap-2">
665
+ <span className="font-medium">{node.label}</span>
666
+ <Badge variant="muted">{node.type}</Badge>
667
+ </div>
668
+ <div className="mt-1 text-xs text-muted-foreground">{Math.round(node.importance * 100)} importance · {fmtNumber(node.degree)} links</div>
669
+ </button>
670
+ ))}
671
+ </CardContent>
672
+ </Card>
673
+ </aside>
674
+ </div>
675
+ {backendSearch.data ? (
676
+ <DataPanel title="Brain search results" result={backendSearch.data}>
677
+ {(result) => <EntityList items={(result as Record<string, unknown>).matches || result} titleKey="title" metaKey="type" limit={8} />}
678
+ </DataPanel>
679
+ ) : null}
680
+ </div>
681
+ );
682
+ }
683
+
164
684
  function HybridSearch() {
165
- const [query, setQuery] = React.useState("lattice brain");
685
+ const [query, setQuery] = React.useState("");
166
686
  const search = useMutation({ mutationFn: () => latticeApi.hybridSearch(query) });
167
687
  return (
168
688
  <Card>
169
689
  <CardHeader>
170
690
  <CardTitle className="flex items-center gap-2"><Search className="h-4 w-4" /> Hybrid search</CardTitle>
171
- <CardDescription>Calls the backend fused search endpoint and renders per-result source scores when returned.</CardDescription>
691
+ <CardDescription>Calls the backend fused search endpoint and renders records with returned source scores.</CardDescription>
172
692
  </CardHeader>
173
693
  <CardContent className="space-y-3">
174
694
  <div className="flex flex-col gap-2 sm:flex-row">
175
- <Input placeholder="lattice brain" value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && search.mutate()} />
695
+ <Input placeholder="Search memories, graph nodes, and indexed documents" value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && search.mutate()} />
176
696
  <Button onClick={() => search.mutate()} disabled={!query.trim() || search.isPending}>Search</Button>
177
697
  </div>
178
698
  {search.data ? (
@@ -192,7 +712,7 @@ function MemoryPanel() {
192
712
  return (
193
713
  <div className="grid gap-4 xl:grid-cols-[0.9fr_1.1fr]">
194
714
  <DataPanel title="Memory manager" result={manager.data}>
195
- {(data) => <JsonView value={data} />}
715
+ {(data) => <MemoryStatus data={data as Record<string, unknown>} />}
196
716
  </DataPanel>
197
717
  <Card>
198
718
  <CardHeader>
@@ -201,12 +721,12 @@ function MemoryPanel() {
201
721
  </CardHeader>
202
722
  <CardContent className="space-y-3">
203
723
  <Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Recall memories about..." />
204
- <div className="flex gap-2">
724
+ <div className="flex flex-wrap gap-2">
205
725
  <Button disabled={!query.trim() || recall.isPending} onClick={() => recall.mutate()}>Recall</Button>
206
726
  <ActionButton label="Compact" action={() => latticeApi.memoryCompact()} />
207
727
  <ActionButton label="Rebuild vector" action={() => latticeApi.memoryRebuild()} />
208
728
  </div>
209
- {recall.data ? <JsonView value={recall.data.data} /> : null}
729
+ {recall.data ? <OperationResult result={recall.data} successLabel="Recall completed" /> : null}
210
730
  </CardContent>
211
731
  </Card>
212
732
  </div>
@@ -219,7 +739,7 @@ function ProvenancePanel() {
219
739
  return (
220
740
  <div className="grid gap-4 xl:grid-cols-[0.8fr_1.2fr]">
221
741
  <DataPanel title="Coverage" result={coverage.data}>
222
- {(data) => <JsonView value={data} />}
742
+ {(data) => <StructuredView value={data} />}
223
743
  </DataPanel>
224
744
  <DataPanel title="Recent ingestion provenance" result={provenance.data}>
225
745
  {(data) => <EntityList items={(data as Record<string, unknown>).items || data} titleKey="source" metaKey="source_type" limit={14} />}
@@ -233,25 +753,31 @@ function PortabilityPanel() {
233
753
  const [artifact, setArtifact] = React.useState("");
234
754
  const port = useQuery({ queryKey: ["portability"], queryFn: latticeApi.graphPortability });
235
755
  const importMutation = useMutation({
236
- mutationFn: () => latticeApi.graphImport(JSON.parse(artifact), true),
756
+ mutationFn: async () => {
757
+ try {
758
+ return await latticeApi.graphImport(JSON.parse(artifact), true);
759
+ } catch (err) {
760
+ return { ok: false, status: 0, data: {}, source: "unavailable" as const, error: err instanceof Error ? err.message : String(err) };
761
+ }
762
+ },
237
763
  onSuccess: () => qc.invalidateQueries({ queryKey: ["portability"] }),
238
764
  });
239
765
  return (
240
766
  <div className="grid gap-4 xl:grid-cols-2">
241
767
  <DataPanel title="Portability status" result={port.data}>
242
- {(data) => <JsonView value={data} />}
768
+ {(data) => <PortabilityStatus data={data as Record<string, unknown>} />}
243
769
  </DataPanel>
244
770
  <Card>
245
771
  <CardHeader>
246
772
  <CardTitle className="flex items-center gap-2"><DatabaseBackup className="h-4 w-4" /> Export, backup, import</CardTitle>
247
- <CardDescription>Every control calls a real portability endpoint. Import is dry-run by default from pasted JSON.</CardDescription>
773
+ <CardDescription>Every control calls a real portability endpoint. Import is dry-run by default from a pasted export artifact.</CardDescription>
248
774
  </CardHeader>
249
775
  <CardContent className="space-y-3">
250
776
  <div className="flex flex-wrap gap-2">
251
- <ActionButton label="Export graph JSON" action={() => latticeApi.graphExport()} />
777
+ <ActionButton label="Export graph artifact" action={() => latticeApi.graphExport()} />
252
778
  <ActionButton label="Create backup" action={() => latticeApi.graphBackup()} />
253
779
  </div>
254
- <Textarea value={artifact} onChange={(e) => setArtifact(e.target.value)} placeholder="Paste an export artifact JSON for dry-run import" />
780
+ <Textarea value={artifact} onChange={(e) => setArtifact(e.target.value)} placeholder="Paste an exported graph artifact for dry-run import" />
255
781
  <Button
256
782
  variant="outline"
257
783
  disabled={!artifact.trim() || importMutation.isPending}
@@ -259,9 +785,25 @@ function PortabilityPanel() {
259
785
  >
260
786
  Dry-run import
261
787
  </Button>
262
- {importMutation.data ? <JsonView value={importMutation.data.data} /> : null}
788
+ {importMutation.data ? <OperationResult result={importMutation.data} successLabel="Dry run completed" /> : null}
263
789
  </CardContent>
264
790
  </Card>
265
791
  </div>
266
792
  );
267
793
  }
794
+
795
+ function PortabilityStatus({ data }: { data: Record<string, unknown> }) {
796
+ const stats = isRecord(data.stats) ? data.stats : {};
797
+ const storage = isRecord(data.storage) ? data.storage : {};
798
+ return (
799
+ <div className="space-y-3">
800
+ <StatGrid stats={[
801
+ { label: "Schema", value: data.graph_schema_version || data.schema_version || "reported" },
802
+ { label: "Nodes", value: (stats.total_nodes as number) || Object.values((stats.nodes as Record<string, unknown>) || {}).reduce((sum: number, value) => sum + Number(value || 0), 0) },
803
+ { label: "Edges", value: (stats.total_edges as number) || Object.values((stats.edges as Record<string, unknown>) || {}).reduce((sum: number, value) => sum + Number(value || 0), 0) },
804
+ { label: "Storage", value: storage.engine || "reported" },
805
+ ]} />
806
+ <StructuredView value={data} />
807
+ </div>
808
+ );
809
+ }