total-recall-brain 3.1.2 → 3.2.2

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 (63) hide show
  1. package/extension/background.js +99 -0
  2. package/extension/content-script.css +16 -0
  3. package/extension/content-script.js +134 -0
  4. package/extension/icons/README.md +27 -0
  5. package/extension/icons/icon-128.png +0 -0
  6. package/extension/icons/icon-16.png +0 -0
  7. package/extension/icons/icon-48.png +0 -0
  8. package/extension/lib/brain-client.js +47 -0
  9. package/extension/manifest.json +39 -0
  10. package/extension/options/options.css +218 -0
  11. package/extension/options/options.html +72 -0
  12. package/extension/options/options.js +99 -0
  13. package/extension/popup/popup.css +221 -0
  14. package/extension/popup/popup.html +44 -0
  15. package/extension/popup/popup.js +107 -0
  16. package/extension/sidepanel/sidepanel.css +340 -0
  17. package/extension/sidepanel/sidepanel.html +38 -0
  18. package/extension/sidepanel/sidepanel.js +229 -0
  19. package/frontend/README.md +73 -0
  20. package/frontend/index.html +17 -0
  21. package/frontend/package.json +31 -0
  22. package/frontend/src/App.css +2 -0
  23. package/frontend/src/App.tsx +390 -0
  24. package/frontend/src/api.ts +688 -0
  25. package/frontend/src/assets/hero.png +0 -0
  26. package/frontend/src/assets/react.svg +1 -0
  27. package/frontend/src/assets/vite.svg +1 -0
  28. package/frontend/src/components/BrainSelector.tsx +272 -0
  29. package/frontend/src/components/DaemonLogsTab.tsx +54 -0
  30. package/frontend/src/components/Graph3D.tsx +850 -0
  31. package/frontend/src/components/MarkdownUtils.tsx +99 -0
  32. package/frontend/src/components/ResearchAgendaTab.tsx +707 -0
  33. package/frontend/src/components/TaskQueueTab.tsx +185 -0
  34. package/frontend/src/index.css +1589 -0
  35. package/frontend/src/main.tsx +10 -0
  36. package/frontend/src/pages/ApiKeysPage.tsx +288 -0
  37. package/frontend/src/pages/AutomationsPage.tsx +354 -0
  38. package/frontend/src/pages/ChatPage.tsx +831 -0
  39. package/frontend/src/pages/DeploymentsPage.tsx +275 -0
  40. package/frontend/src/pages/FilesPage.tsx +384 -0
  41. package/frontend/src/pages/HealthPage.tsx +91 -0
  42. package/frontend/src/pages/IntegrationsPage.tsx +382 -0
  43. package/frontend/src/pages/LoginPage.tsx +237 -0
  44. package/frontend/src/pages/MemoryPage.tsx +1192 -0
  45. package/frontend/src/pages/SandboxPage.tsx +113 -0
  46. package/frontend/src/pages/SettingsPage.tsx +662 -0
  47. package/frontend/src/pages/SkillsPage.tsx +618 -0
  48. package/frontend/src/pages/TasksPage.tsx +392 -0
  49. package/frontend/src/pages/UsagePage.tsx +260 -0
  50. package/frontend/src/types.ts +163 -0
  51. package/frontend/tsconfig.app.json +25 -0
  52. package/frontend/tsconfig.json +7 -0
  53. package/frontend/tsconfig.node.json +24 -0
  54. package/frontend/vite.config.ts +24 -0
  55. package/package.json +8 -1
  56. package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +35 -8
  57. package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +13 -8
  58. package/scaffold/.agent/skills/total-recall/memory-vault/preferences/always-websearch-gap.md +28 -1
  59. package/scaffold/.agent/skills/total-recall/memory-vault/preferences/topic-research-sop.md +28 -1
  60. package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +830 -81
  61. package/src/cli/backup.mjs +172 -1
  62. package/src/cli/setup.mjs +24 -0
  63. package/src/server/rest.mjs +56 -7
@@ -0,0 +1,850 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { useState, useEffect, useRef, useMemo } from "react"
3
+ import type { MouseEvent as ReactMouseEvent, TouchEvent as ReactTouchEvent, WheelEvent as ReactWheelEvent } from "react"
4
+ import type { MemoryNode, ResearchItem } from "../types"
5
+ import type { ChatThread } from "../api"
6
+ import { renderMarkdown } from "./MarkdownUtils"
7
+
8
+ interface Graph3DProps {
9
+ threads: ChatThread[]
10
+ memoryNodes: MemoryNode[]
11
+ researchItems: ResearchItem[]
12
+ onOpenThread: (threadId: string) => void
13
+ onGroundMemoryNode: (slug: string) => void
14
+ selectedGroundingNodes: string[]
15
+ }
16
+
17
+ interface VisualNode {
18
+ id: string
19
+ type: "thread" | "memory" | "research"
20
+ title: string
21
+ subtitle: string
22
+ status?: string
23
+ excerpt?: string
24
+ originalData: ChatThread | MemoryNode | ResearchItem
25
+ // Initial 3D coordinates
26
+ x: number
27
+ y: number
28
+ z: number
29
+ // Projected screen coordinates
30
+ px?: number
31
+ py?: number
32
+ pz?: number
33
+ scale?: number
34
+ }
35
+
36
+ interface VisualLink {
37
+ source: VisualNode
38
+ target: VisualNode
39
+ opacity: number
40
+ color?: string
41
+ dashPattern?: number[]
42
+ }
43
+
44
+ export default function Graph3D({
45
+ threads,
46
+ memoryNodes,
47
+ researchItems,
48
+ onOpenThread,
49
+ onGroundMemoryNode,
50
+ selectedGroundingNodes,
51
+ }: Graph3DProps) {
52
+ const containerRef = useRef<HTMLDivElement>(null)
53
+ const canvasRef = useRef<HTMLCanvasElement>(null)
54
+
55
+ // Projection configuration state
56
+ const [zoom, setZoom] = useState<number>(1.2)
57
+ const [selectedNode, setSelectedNode] = useState<VisualNode | null>(null)
58
+ const [hoveredNode, setHoveredNode] = useState<VisualNode | null>(null)
59
+ const [dragging, setDragging] = useState<boolean>(false)
60
+ const [visibleTypes, setVisibleTypes] = useState({
61
+ research: true,
62
+ observations: true,
63
+ rules: false,
64
+ threads: false,
65
+ })
66
+
67
+ // Dragging and orbital rotation state (stored in refs for high frame rate access)
68
+ const angleX = useRef<number>(0.3)
69
+ const angleY = useRef<number>(0.5)
70
+ const isDragging = useRef<boolean>(false)
71
+ const lastMousePos = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
72
+ const velocityX = useRef<number>(0.002) // Auto rotation speed
73
+ const velocityY = useRef<number>(0)
74
+
75
+ // Generate deterministic 3D nodes using Fibonacci Sphere layout
76
+ const nodes = useMemo(() => {
77
+ const list: VisualNode[] = []
78
+
79
+ // 1. Thread nodes
80
+ if (visibleTypes.threads) {
81
+ threads.forEach((t) => {
82
+ list.push({
83
+ id: `thread-${t.id}`,
84
+ type: 'thread',
85
+ title: t.title || 'Untitled Thread',
86
+ subtitle: `${t.turns} ${t.turns === 1 ? 'turn' : 'turns'}`,
87
+ excerpt: `Conversation thread last updated ${new Date(t.lastUpdated).toLocaleDateString()}`,
88
+ originalData: t,
89
+ x: 0,
90
+ y: 0,
91
+ z: 0,
92
+ })
93
+ })
94
+ }
95
+
96
+ // 2. Memory nodes
97
+ memoryNodes.forEach((m) => {
98
+ const category = (m.category || 'general').toLowerCase()
99
+ const isRule = ['invariants', 'preferences', 'patterns', 'anti-patterns'].includes(category)
100
+ if (isRule) {
101
+ if (!visibleTypes.rules) return
102
+ } else {
103
+ if (!visibleTypes.observations) return
104
+ }
105
+
106
+ list.push({
107
+ id: `memory-${m.slug}`,
108
+ type: 'memory',
109
+ title: m.title || m.slug,
110
+ subtitle: `Memory Node • ${m.category || 'general'}`,
111
+ status: m.status || 'active',
112
+ excerpt: m.excerpt || m.body || 'No summary details available.',
113
+ originalData: m,
114
+ x: 0,
115
+ y: 0,
116
+ z: 0,
117
+ })
118
+ })
119
+
120
+ // 3. Research nodes
121
+ if (visibleTypes.research) {
122
+ researchItems.forEach((r) => {
123
+ list.push({
124
+ id: `research-${r.id}`,
125
+ type: 'research',
126
+ title: r.topic,
127
+ subtitle: `Research Topic • ${r.priority} priority`,
128
+ status: r.status,
129
+ excerpt: r.notes || `Research task currently ${r.status}. Created on ${new Date(r.created_at).toLocaleDateString()}`,
130
+ originalData: r,
131
+ x: 0,
132
+ y: 0,
133
+ z: 0,
134
+ })
135
+ })
136
+ }
137
+
138
+ // Apply Fibonacci Sphere algorithm for beautiful uniform distribution
139
+ const N = list.length
140
+ const radius = 220 // Size of the sphere volume
141
+
142
+ for (let i = 0; i < N; i++) {
143
+ // Golden ratio spacing
144
+ const phi = Math.acos(1 - (2 * (i + 0.5)) / N)
145
+ const theta = Math.PI * (1 + Math.sqrt(5)) * (i + 0.5)
146
+
147
+ list[i].x = radius * Math.sin(phi) * Math.cos(theta)
148
+ list[i].y = radius * Math.sin(phi) * Math.sin(theta)
149
+ list[i].z = radius * Math.cos(phi)
150
+
151
+ // Add a tiny bit of cluster jitter based on node type to separate planes slightly
152
+ if (list[i].type === 'thread') {
153
+ list[i].y += 30
154
+ } else if (list[i].type === 'research') {
155
+ list[i].x -= 30
156
+ } else {
157
+ list[i].z += 20
158
+ }
159
+ }
160
+
161
+ return list
162
+ }, [threads, memoryNodes, researchItems, visibleTypes])
163
+
164
+ // Establish links/connections between related items
165
+ const links = useMemo(() => {
166
+ const list: VisualLink[] = []
167
+
168
+ // Match criteria for a beautiful connected web
169
+ nodes.forEach((nodeA, idx) => {
170
+ // 1. Link Research nodes to their matching Memory node
171
+ if (nodeA.type === "research") {
172
+ const researchItem = nodeA.originalData as ResearchItem
173
+ if (researchItem.node_slug) {
174
+ const targetNode = nodes.find(n => n.type === "memory" && (n.originalData as any).slug === researchItem.node_slug)
175
+ if (targetNode) {
176
+ list.push({ source: nodeA, target: targetNode, opacity: 0.8 })
177
+ }
178
+ }
179
+ }
180
+
181
+ // 2. Link Memory nodes based on explicit SSSS v2 relations (related, supersedes, contradicts)
182
+ if (nodeA.type === "memory") {
183
+ const memA = nodeA.originalData as MemoryNode
184
+
185
+ // Explicit related links (purple)
186
+ if (Array.isArray(memA.related)) {
187
+ memA.related.forEach(relSlug => {
188
+ const targetNode = nodes.find(n => n.type === "memory" && (n.originalData as any).slug === relSlug)
189
+ if (targetNode) {
190
+ list.push({
191
+ source: nodeA,
192
+ target: targetNode,
193
+ opacity: 0.7,
194
+ color: "rgba(167, 139, 250, 0.55)"
195
+ })
196
+ }
197
+ })
198
+ }
199
+
200
+ // Explicit contradicts links (red)
201
+ if (Array.isArray(memA.contradicts)) {
202
+ memA.contradicts.forEach(conSlug => {
203
+ const targetNode = nodes.find(n => n.type === "memory" && (n.originalData as any).slug === conSlug)
204
+ if (targetNode) {
205
+ list.push({
206
+ source: nodeA,
207
+ target: targetNode,
208
+ opacity: 0.9,
209
+ color: "rgba(239, 68, 68, 0.7)"
210
+ })
211
+ }
212
+ })
213
+ }
214
+
215
+ // Explicit supersedes links (dashed orange)
216
+ if (Array.isArray(memA.supersedes)) {
217
+ memA.supersedes.forEach(supSlug => {
218
+ const targetNode = nodes.find(n => n.type === "memory" && (n.originalData as any).slug === supSlug)
219
+ if (targetNode) {
220
+ list.push({
221
+ source: nodeA,
222
+ target: targetNode,
223
+ opacity: 0.8,
224
+ color: "rgba(245, 158, 11, 0.6)",
225
+ dashPattern: [3, 3]
226
+ })
227
+ }
228
+ })
229
+ }
230
+
231
+ // Fallback: Link Memory nodes of similar categories or tags if no explicit relations exist to maintain beautiful web structure
232
+ if (!memA.related?.length && !memA.contradicts?.length && !memA.supersedes?.length) {
233
+ let count = 0
234
+ for (let j = idx + 1; j < nodes.length; j++) {
235
+ const nodeB = nodes[j]
236
+ if (nodeB.type === "memory" && count < 2) {
237
+ const memB = nodeB.originalData as MemoryNode
238
+ if (memA.category && memA.category === memB.category) {
239
+ list.push({ source: nodeA, target: nodeB, opacity: 0.4 })
240
+ count++
241
+ }
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ // 3. Link Chat threads to grounded memory nodes if titles/slugs align or randomly to create constellations
248
+ if (nodeA.type === "thread") {
249
+ // Find 1-2 memory nodes to anchor the thread node
250
+ let matches = 0
251
+ for (let j = 0; j < nodes.length; j++) {
252
+ const nodeB = nodes[j]
253
+ if (nodeB.type === "memory" && matches < 1) {
254
+ // Draw a thin connection
255
+ list.push({ source: nodeA, target: nodeB, opacity: 0.35 })
256
+ matches++
257
+ }
258
+ }
259
+ }
260
+ })
261
+
262
+ return list
263
+ }, [nodes])
264
+
265
+ // Render loop
266
+ useEffect(() => {
267
+ const canvas = canvasRef.current
268
+ if (!canvas) return
269
+
270
+ const ctx = canvas.getContext('2d')
271
+ if (!ctx) return
272
+
273
+ let animationFrameId: number
274
+ const focalLength = 400
275
+
276
+ // Set dimensions based on client bounds
277
+ const resizeCanvas = () => {
278
+ const rect = containerRef.current?.getBoundingClientRect()
279
+ canvas.width = (rect?.width || window.innerWidth) * window.devicePixelRatio
280
+ canvas.height = (rect?.height || window.innerHeight) * window.devicePixelRatio
281
+ canvas.style.width = '100%'
282
+ canvas.style.height = '100%'
283
+ ctx.scale(window.devicePixelRatio, window.devicePixelRatio)
284
+ }
285
+ resizeCanvas()
286
+ window.addEventListener('resize', resizeCanvas)
287
+
288
+ const draw = () => {
289
+ const width = canvas.width / window.devicePixelRatio
290
+ const height = canvas.height / window.devicePixelRatio
291
+ ctx.clearRect(0, 0, width, height)
292
+
293
+ const centerX = width / 2
294
+ const centerY = height / 2
295
+
296
+ // Apply orbital inertia
297
+ if (!isDragging.current) {
298
+ angleY.current += velocityX.current
299
+ angleX.current += velocityY.current
300
+ // Smooth deceleration
301
+ velocityX.current *= 0.98
302
+ velocityY.current *= 0.98
303
+
304
+ // Maintain gentle orbital motion if rotation dies down completely
305
+ if (Math.abs(velocityX.current) < 0.001) {
306
+ velocityX.current = 0.0015
307
+ }
308
+ }
309
+
310
+ const cosX = Math.cos(angleX.current)
311
+ const sinX = Math.sin(angleX.current)
312
+ const cosY = Math.cos(angleY.current)
313
+ const sinY = Math.sin(angleY.current)
314
+
315
+ // Project all nodes first
316
+ nodes.forEach((node) => {
317
+ // Rotate Y axis
318
+ const x1 = node.x * cosY + node.z * sinY
319
+ const z1 = -node.x * sinY + node.z * cosY
320
+
321
+ // Rotate X axis
322
+ const y2 = node.y * cosX - z1 * sinX
323
+ const z2 = node.y * sinX + z1 * cosX
324
+
325
+ // Perspective Projection
326
+ const scale = focalLength / (focalLength + z2)
327
+ node.px = centerX + x1 * scale * zoom
328
+ node.py = centerY + y2 * scale * zoom
329
+ node.pz = z2
330
+ node.scale = scale
331
+ })
332
+
333
+ // Sort nodes by depth (z-index drawing) to render back elements first
334
+ const sortedNodes = [...nodes].sort((a, b) => (b.pz || 0) - (a.pz || 0))
335
+
336
+ // ─── DRAW LINKS ───
337
+ ctx.lineWidth = 1.0
338
+ links.forEach((link) => {
339
+ const { source, target, opacity, color, dashPattern } = link
340
+ if (source.px === undefined || source.py === undefined || target.px === undefined || target.py === undefined) return
341
+
342
+ // Fade links that are deeper or far
343
+ const avgZ = ((source.pz || 0) + (target.pz || 0)) / 2
344
+ const depthFade = Math.max(0.1, Math.min(1.0, 1 - (avgZ + 200) / 500))
345
+
346
+ // Highlight links connected to the hovered or selected nodes
347
+ const isHighlight =
348
+ hoveredNode?.id === source.id ||
349
+ hoveredNode?.id === target.id ||
350
+ selectedNode?.id === source.id ||
351
+ selectedNode?.id === target.id
352
+
353
+ // Glow connections
354
+ ctx.beginPath()
355
+ ctx.moveTo(source.px, source.py)
356
+ ctx.lineTo(target.px, target.py)
357
+
358
+ if (dashPattern) {
359
+ ctx.setLineDash(dashPattern)
360
+ } else {
361
+ ctx.setLineDash([])
362
+ }
363
+
364
+ if (isHighlight) {
365
+ ctx.strokeStyle = color || (source.type === "memory" ? "rgba(139, 92, 246, 0.65)" : source.type === "thread" ? "rgba(6, 182, 212, 0.65)" : "rgba(245, 158, 11, 0.65)")
366
+ ctx.lineWidth = 1.8
367
+ } else {
368
+ ctx.strokeStyle = color
369
+ ? color.replace(/[\d.]+\)$/, `${opacity * 0.4 * depthFade})`)
370
+ : `rgba(100, 116, 139, ${opacity * 0.25 * depthFade})`
371
+ ctx.lineWidth = 0.8
372
+ }
373
+ ctx.stroke()
374
+ ctx.setLineDash([]) // Reset dash pattern
375
+ })
376
+
377
+ // ─── DRAW NODES ───
378
+ sortedNodes.forEach((node) => {
379
+ if (node.px === undefined || node.py === undefined || node.scale === undefined) return
380
+
381
+ // Support confidence decay mapping to node size
382
+ const confidenceMultiplier = node.type === "memory" && (node.originalData as any).confidence !== undefined
383
+ ? Math.max(0.4, (node.originalData as any).confidence)
384
+ : 1.0
385
+
386
+ const size = Math.max(4, Math.min(22, 10 * node.scale * zoom * confidenceMultiplier))
387
+ const isHovered = hoveredNode?.id === node.id
388
+ const isSelected = selectedNode?.id === node.id
389
+ const isGrounded = node.type === "memory" && selectedGroundingNodes.includes((node.originalData as any).slug)
390
+
391
+ // Draw radial glow
392
+ const gradient = ctx.createRadialGradient(
393
+ node.px,
394
+ node.py,
395
+ size * 0.1,
396
+ node.px,
397
+ node.py,
398
+ size * (isHovered || isSelected ? 2.5 : 1.5)
399
+ )
400
+
401
+ let color = "#8b5cf6" // memory observations (purple)
402
+ let glowColor = "rgba(139, 92, 246, 0.2)"
403
+
404
+ if (node.type === "memory") {
405
+ const category = ((node.originalData as MemoryNode).category || 'general').toLowerCase()
406
+ const isRule = ['invariants', 'preferences', 'patterns', 'anti-patterns'].includes(category)
407
+ if (isRule) {
408
+ color = "#6366f1" // developer rules (indigo)
409
+ glowColor = "rgba(99, 102, 241, 0.2)"
410
+ }
411
+ } else if (node.type === "thread") {
412
+ color = "#06b6d4" // thread nodes (cyan)
413
+ glowColor = "rgba(6, 182, 212, 0.2)"
414
+ } else if (node.type === "research") {
415
+ color = "#f59e0b" // research nodes (gold)
416
+ glowColor = "rgba(245, 158, 11, 0.2)"
417
+ }
418
+
419
+ gradient.addColorStop(0, color)
420
+ gradient.addColorStop(0.4, color)
421
+ gradient.addColorStop(1, glowColor)
422
+
423
+ // Faint shadow/depth sorting opacity and confidence scaling
424
+ const nodeDepthOpacity = Math.max(0.2, Math.min(1.0, 1 - (node.pz || 0 + 200) / 450))
425
+ const confidenceOpacity = node.type === "memory" && (node.originalData as any).confidence !== undefined
426
+ ? Math.max(0.35, (node.originalData as any).confidence)
427
+ : 1.0
428
+
429
+ ctx.globalAlpha = nodeDepthOpacity * confidenceOpacity
430
+
431
+ ctx.beginPath()
432
+ ctx.arc(node.px, node.py, size * (isHovered || isSelected ? 2.5 : 1.5), 0, Math.PI * 2)
433
+ ctx.fillStyle = gradient
434
+ ctx.fill()
435
+
436
+ // Core solid sphere
437
+ ctx.beginPath()
438
+ ctx.arc(node.px, node.py, size * 0.7, 0, Math.PI * 2)
439
+ ctx.fillStyle = isHovered || isSelected ? '#ffffff' : color
440
+ ctx.fill()
441
+
442
+ // Highlight ring around selected node
443
+ if (isSelected || isGrounded) {
444
+ ctx.beginPath()
445
+ ctx.arc(node.px, node.py, size * 1.5, 0, Math.PI * 2)
446
+ ctx.strokeStyle = '#ffffff'
447
+ ctx.lineWidth = 1.5
448
+ ctx.stroke()
449
+ }
450
+
451
+ ctx.globalAlpha = 1.0 // Reset opacity
452
+
453
+ // ─── RENDER LABELS ───
454
+ // Display labels for hovered/selected nodes or very close front nodes
455
+ if (isHovered || isSelected || (node.scale > 1.15 && node.pz && node.pz < -100)) {
456
+ ctx.font = isHovered || isSelected ? 'bold 12px var(--font-sans, system-ui)' : '10px var(--font-sans, system-ui)'
457
+ ctx.fillStyle = isHovered || isSelected ? '#ffffff' : 'rgba(255, 255, 255, 0.75)'
458
+ ctx.shadowColor = 'rgba(0, 0, 0, 0.9)'
459
+ ctx.shadowBlur = 4
460
+
461
+ const labelText = node.title.length > 28 ? node.title.substring(0, 26) + '...' : node.title
462
+ ctx.fillText(labelText, node.px + size * 1.6, node.py + size * 0.4)
463
+
464
+ // Subtitle for hovered/selected nodes
465
+ if (isHovered || isSelected) {
466
+ ctx.font = '9px var(--font-sans, system-ui)'
467
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.55)'
468
+ ctx.fillText(node.subtitle, node.px + size * 1.6, node.py + size * 0.4 + 14)
469
+ }
470
+
471
+ // Reset shadows
472
+ ctx.shadowBlur = 0
473
+ }
474
+ })
475
+
476
+ animationFrameId = requestAnimationFrame(draw)
477
+ }
478
+
479
+ draw()
480
+
481
+ return () => {
482
+ cancelAnimationFrame(animationFrameId)
483
+ window.removeEventListener('resize', resizeCanvas)
484
+ }
485
+ }, [nodes, links, zoom, hoveredNode, selectedNode, selectedGroundingNodes])
486
+
487
+ // Mouse / Touch Interaction Handlers
488
+ const handleStartDrag = (clientX: number, clientY: number) => {
489
+ isDragging.current = true
490
+ setDragging(true)
491
+ lastMousePos.current = { x: clientX, y: clientY }
492
+ velocityX.current = 0
493
+ velocityY.current = 0
494
+ }
495
+
496
+ const handleDrag = (clientX: number, clientY: number) => {
497
+ if (!isDragging.current) return
498
+ const dx = clientX - lastMousePos.current.x
499
+ const dy = clientY - lastMousePos.current.y
500
+
501
+ // Scale rotations based on coordinate displacement
502
+ angleY.current += dx * 0.006
503
+ angleX.current += dy * 0.006
504
+
505
+ // Keep velocities for inertia on release
506
+ velocityX.current = dx * 0.005
507
+ velocityY.current = dy * 0.005
508
+
509
+ lastMousePos.current = { x: clientX, y: clientY }
510
+ }
511
+
512
+ const handleEndDrag = () => {
513
+ isDragging.current = false
514
+ setDragging(false)
515
+ }
516
+
517
+ // Handle canvas mouse move to detect hovering
518
+ const handleMouseMove = (e: ReactMouseEvent<HTMLCanvasElement>) => {
519
+ const canvas = canvasRef.current
520
+ if (!canvas) return
521
+
522
+ const rect = canvas.getBoundingClientRect()
523
+ const mouseX = e.clientX - rect.left
524
+ const mouseY = e.clientY - rect.top
525
+
526
+ if (isDragging.current) {
527
+ handleDrag(e.clientX, e.clientY)
528
+ return
529
+ }
530
+
531
+ // Detect hovered node
532
+ let closestNode: VisualNode | null = null
533
+ let minDistance = 22 // Capture radius
534
+
535
+ nodes.forEach((node) => {
536
+ if (node.px === undefined || node.py === undefined) return
537
+ const distance = Math.hypot(node.px - mouseX, node.py - mouseY)
538
+ if (distance < minDistance) {
539
+ minDistance = distance
540
+ closestNode = node
541
+ }
542
+ })
543
+
544
+ setHoveredNode(closestNode)
545
+ }
546
+
547
+ // Handle clicks to select nodes
548
+ const handleCanvasClick = () => {
549
+ if (isDragging.current && (Math.abs(velocityX.current) > 0.02 || Math.abs(velocityY.current) > 0.02)) {
550
+ // It was a swift rotation drag, not a clean select click
551
+ return
552
+ }
553
+ setSelectedNode(hoveredNode)
554
+ }
555
+
556
+ // Zoom Handler
557
+ const handleWheel = (e: ReactWheelEvent<HTMLCanvasElement>) => {
558
+ e.preventDefault()
559
+ const zoomDelta = e.deltaY * -0.001
560
+ setZoom((prev) => Math.max(0.5, Math.min(3.0, prev + zoomDelta)))
561
+ }
562
+
563
+ // Touch handlers
564
+ const handleTouchStart = (e: ReactTouchEvent<HTMLCanvasElement>) => {
565
+ if (e.touches.length === 1) {
566
+ const touch = e.touches[0]
567
+ handleStartDrag(touch.clientX, touch.clientY)
568
+ }
569
+ }
570
+
571
+ const handleTouchMove = (e: ReactTouchEvent<HTMLCanvasElement>) => {
572
+ if (e.touches.length === 1) {
573
+ const touch = e.touches[0]
574
+ if (isDragging.current) {
575
+ handleDrag(touch.clientX, touch.clientY)
576
+ }
577
+
578
+ // Update hover for touch interaction
579
+ const canvas = canvasRef.current
580
+ if (!canvas) return
581
+ const rect = canvas.getBoundingClientRect()
582
+ const touchX = touch.clientX - rect.left
583
+ const touchY = touch.clientY - rect.top
584
+
585
+ let closestNode: VisualNode | null = null
586
+ let minDistance = 30 // Larger tap radius
587
+
588
+ nodes.forEach((node) => {
589
+ if (node.px === undefined || node.py === undefined) return
590
+ const distance = Math.hypot(node.px - touchX, node.py - touchY)
591
+ if (distance < minDistance) {
592
+ minDistance = distance
593
+ closestNode = node
594
+ }
595
+ })
596
+ setHoveredNode(closestNode)
597
+ }
598
+ }
599
+
600
+ return (
601
+ <div className="graph-3d-wrapper" ref={containerRef} style={{ position: 'relative', width: '100%', height: '100%', background: 'radial-gradient(circle at center, #0b0f19 0%, #030712 100%)', overflow: 'hidden' }}>
602
+
603
+ {/* 3D Projection Canvas */}
604
+ <canvas
605
+ ref={canvasRef}
606
+ onMouseDown={(e) => handleStartDrag(e.clientX, e.clientY)}
607
+ onMouseMove={handleMouseMove}
608
+ onMouseUp={handleEndDrag}
609
+ onMouseLeave={handleEndDrag}
610
+ onClick={handleCanvasClick}
611
+ onWheel={handleWheel}
612
+ onTouchStart={handleTouchStart}
613
+ onTouchMove={handleTouchMove}
614
+ onTouchEnd={handleEndDrag}
615
+ style={{ cursor: dragging ? "grabbing" : hoveredNode ? "pointer" : "grab", display: "block" }}
616
+ />
617
+
618
+ {/* Cosmic Floating Background Dust overlay */}
619
+ <div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, pointerEvents: 'none', background: 'radial-gradient(circle at 50% 50%, rgba(139, 92, 246, 0.05) 0%, transparent 60%)' }} />
620
+
621
+ {/* Floating UI Elements */}
622
+ <div style={{ position: 'absolute', top: 20, left: 20, display: 'flex', flexDirection: 'column', gap: 6, pointerEvents: 'none' }}>
623
+ <h3 style={{ margin: 0, fontSize: 18, color: '#fff', fontWeight: 700, letterSpacing: '0.5px' }}>Sovereign Mind Matrix</h3>
624
+ <p style={{ margin: 0, fontSize: 11, color: 'rgba(255,255,255,0.45)' }}>3D Constellation of Research, Memory & Conversations</p>
625
+ </div>
626
+
627
+ {/* Controls Overlay */}
628
+ <div style={{ position: 'absolute', bottom: 20, left: 20, display: 'flex', gap: 8 }}>
629
+ <button
630
+ onClick={() => setZoom(prev => Math.min(3.0, prev + 0.2))}
631
+ style={{ width: 32, height: 32, background: 'rgba(15,23,42,0.6)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 8, color: '#fff', fontSize: 16, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
632
+ title="Zoom In"
633
+ >
634
+ +
635
+ </button>
636
+ <button
637
+ onClick={() => setZoom(prev => Math.max(0.5, prev - 0.2))}
638
+ style={{ width: 32, height: 32, background: 'rgba(15,23,42,0.6)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 8, color: '#fff', fontSize: 16, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
639
+ title="Zoom Out"
640
+ >
641
+ -
642
+ </button>
643
+ <button
644
+ onClick={() => { angleX.current = 0.3; angleY.current = 0.5; setZoom(1.2) }}
645
+ style={{ width: 32, height: 32, background: 'rgba(15,23,42,0.6)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 8, color: '#fff', fontSize: 11, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
646
+ title="Reset Camera"
647
+ >
648
+ ⟲
649
+ </button>
650
+ </div>
651
+
652
+ {/* Floating Filter Panel (Top Right) */}
653
+ <div
654
+ className="glass"
655
+ style={{
656
+ position: 'absolute',
657
+ top: 20,
658
+ right: 20,
659
+ display: 'flex',
660
+ flexDirection: 'column',
661
+ gap: 10,
662
+ background: 'rgba(10, 15, 30, 0.75)',
663
+ backdropFilter: 'blur(20px)',
664
+ WebkitBackdropFilter: 'blur(20px)',
665
+ padding: '12px 16px',
666
+ borderRadius: 12,
667
+ border: '1px solid rgba(255, 255, 255, 0.08)',
668
+ boxShadow: '0 8px 32px rgba(0,0,0,0.3)',
669
+ width: 220,
670
+ zIndex: 90,
671
+ }}
672
+ >
673
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
674
+ <span style={{ fontSize: 9, fontWeight: 700, color: 'var(--text-tertiary)', textTransform: 'uppercase', letterSpacing: 1 }}>
675
+ Constellation Filters
676
+ </span>
677
+ <span style={{ fontSize: 9, background: 'rgba(255,255,255,0.08)', padding: '1px 5px', borderRadius: 4, color: 'var(--text-secondary)' }}>
678
+ {nodes.length} visible
679
+ </span>
680
+ </div>
681
+
682
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
683
+ {/* 1. Research Projects */}
684
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: 'var(--text-secondary)', cursor: 'pointer', userSelect: 'none' }}>
685
+ <input
686
+ type="checkbox"
687
+ checked={visibleTypes.research}
688
+ onChange={() => setVisibleTypes(prev => ({ ...prev, research: !prev.research }))}
689
+ style={{ accentColor: '#f59e0b', cursor: 'pointer' }}
690
+ />
691
+ <span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: '#f59e0b' }} />
692
+ <span style={{ flex: 1 }}>Research Projects</span>
693
+ <span style={{ fontSize: 9, color: 'var(--text-tertiary)' }}>({researchItems.length})</span>
694
+ </label>
695
+
696
+ {/* 2. Observations & Facts */}
697
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: 'var(--text-secondary)', cursor: 'pointer', userSelect: 'none' }}>
698
+ <input
699
+ type="checkbox"
700
+ checked={visibleTypes.observations}
701
+ onChange={() => setVisibleTypes(prev => ({ ...prev, observations: !prev.observations }))}
702
+ style={{ accentColor: '#8b5cf6', cursor: 'pointer' }}
703
+ />
704
+ <span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: '#8b5cf6' }} />
705
+ <span style={{ flex: 1 }}>Observations</span>
706
+ <span style={{ fontSize: 9, color: 'var(--text-tertiary)' }}>
707
+ ({memoryNodes.filter(m => !['invariants', 'preferences', 'patterns', 'anti-patterns'].includes((m.category || 'general').toLowerCase())).length})
708
+ </span>
709
+ </label>
710
+
711
+ {/* 3. Developer Rules */}
712
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: 'var(--text-secondary)', cursor: 'pointer', userSelect: 'none' }}>
713
+ <input
714
+ type="checkbox"
715
+ checked={visibleTypes.rules}
716
+ onChange={() => setVisibleTypes(prev => ({ ...prev, rules: !prev.rules }))}
717
+ style={{ accentColor: '#6366f1', cursor: 'pointer' }}
718
+ />
719
+ <span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: '#6366f1' }} />
720
+ <span style={{ flex: 1 }}>Developer Rules</span>
721
+ <span style={{ fontSize: 9, color: 'var(--text-tertiary)' }}>
722
+ ({memoryNodes.filter(m => ['invariants', 'preferences', 'patterns', 'anti-patterns'].includes((m.category || 'general').toLowerCase())).length})
723
+ </span>
724
+ </label>
725
+
726
+ {/* 4. Chat Threads */}
727
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: 'var(--text-secondary)', cursor: 'pointer', userSelect: 'none' }}>
728
+ <input
729
+ type="checkbox"
730
+ checked={visibleTypes.threads}
731
+ onChange={() => setVisibleTypes(prev => ({ ...prev, threads: !prev.threads }))}
732
+ style={{ accentColor: '#06b6d4', cursor: 'pointer' }}
733
+ />
734
+ <span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: '#06b6d4' }} />
735
+ <span style={{ flex: 1 }}>Conversations</span>
736
+ <span style={{ fontSize: 9, color: 'var(--text-tertiary)' }}>({threads.length})</span>
737
+ </label>
738
+ </div>
739
+ </div>
740
+
741
+ {/* Selected Node Glass detail overlay drawer */}
742
+ {selectedNode && (
743
+ <div className="node-detail-drawer animate-slide-in">
744
+ <div className="drawer-header">
745
+ <span className={`drawer-badge badge-${selectedNode.type}`}>
746
+ {selectedNode.type === "memory" ? "🧠 Knowledge" : selectedNode.type === "thread" ? "💬 Thread" : "🕵️ Research"}
747
+ </span>
748
+ <button className="drawer-close-btn" onClick={() => setSelectedNode(null)}>×</button>
749
+ </div>
750
+ <div className="drawer-body">
751
+ <h4 className="drawer-title">{selectedNode.title}</h4>
752
+ <span className="drawer-subtitle">{selectedNode.subtitle}</span>
753
+
754
+ {selectedNode.type === "memory" && (
755
+ <div className="drawer-meta-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "8px", margin: "12px 0", fontSize: "11px", background: "rgba(255,255,255,0.03)", padding: "8px", borderRadius: "6px", border: "1px solid rgba(255,255,255,0.05)" }}>
756
+ {(selectedNode.originalData as any).confidence !== undefined && (
757
+ <div>
758
+ <span style={{ color: "rgba(255,255,255,0.45)" }}>Confidence:</span>{" "}
759
+ <strong style={{ color: (selectedNode.originalData as any).confidence > 0.7 ? "#22c55e" : "#f59e0b" }}>
760
+ {Math.round((selectedNode.originalData as any).confidence * 100)}%
761
+ </strong>
762
+ </div>
763
+ )}
764
+ {(selectedNode.originalData as any).importance !== undefined && (
765
+ <div>
766
+ <span style={{ color: "rgba(255,255,255,0.45)" }}>Importance:</span>{" "}
767
+ <strong style={{ color: "#a78bfa" }}>★ {(selectedNode.originalData as any).importance}/5</strong>
768
+ </div>
769
+ )}
770
+ {selectedNode.status && (
771
+ <div style={{ gridColumn: "span 2" }}>
772
+ <span style={{ color: "rgba(255,255,255,0.45)" }}>Status:</span>{" "}
773
+ <span className={`value status-${selectedNode.status.toLowerCase().replace(/_/g, "-")}`} style={{ fontSize: "10px", padding: "2px 6px", borderRadius: "10px", background: "rgba(255,255,255,0.08)", textTransform: "uppercase" }}>
774
+ {selectedNode.status}
775
+ </span>
776
+ </div>
777
+ )}
778
+ </div>
779
+ )}
780
+
781
+ {selectedNode.type === "memory" && ((selectedNode.originalData as any).related?.length || (selectedNode.originalData as any).contradicts?.length || (selectedNode.originalData as any).supersedes?.length) && (
782
+ <div className="drawer-relations" style={{ fontSize: "11px", margin: "12px 0" }}>
783
+ <span style={{ color: "rgba(255,255,255,0.45)", display: "block", marginBottom: "4px" }}>Ontology Relations:</span>
784
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
785
+ {(selectedNode.originalData as any).related?.map((slug: string) => (
786
+ <span key={slug} style={{ color: "#a78bfa", background: "rgba(139,92,246,0.1)", padding: "1px 6px", borderRadius: "4px" }}>🔗 {slug}</span>
787
+ ))}
788
+ {(selectedNode.originalData as any).contradicts?.map((slug: string) => (
789
+ <span key={slug} style={{ color: "#ef4444", background: "rgba(239,68,68,0.1)", padding: "1px 6px", borderRadius: "4px" }}>⚠️ contradicts: {slug}</span>
790
+ ))}
791
+ {(selectedNode.originalData as any).supersedes?.map((slug: string) => (
792
+ <span key={slug} style={{ color: "#f59e0b", background: "rgba(245,158,11,0.1)", padding: "1px 6px", borderRadius: "4px" }}>✓ supersedes: {slug}</span>
793
+ ))}
794
+ </div>
795
+ </div>
796
+ )}
797
+
798
+ {selectedNode.type === "memory" ? (
799
+ <div
800
+ className="drawer-markdown-content"
801
+ style={{
802
+ marginTop: 12,
803
+ borderTop: '1px solid rgba(255,255,255,0.06)',
804
+ paddingTop: 12,
805
+ fontSize: '13px',
806
+ color: 'var(--text-secondary)',
807
+ lineHeight: '1.6',
808
+ }}
809
+ >
810
+ {renderMarkdown((selectedNode.originalData as any).content || (selectedNode.originalData as any).body || selectedNode.excerpt || 'No content details available.')}
811
+ </div>
812
+ ) : (
813
+ <p className="drawer-excerpt" style={{ whiteSpace: "pre-wrap" }}>{selectedNode.excerpt}</p>
814
+ )}
815
+ </div>
816
+ <div className="drawer-footer">
817
+ {selectedNode.type === "thread" && (
818
+ <button
819
+ className="drawer-action-btn btn-cyan"
820
+ onClick={() => {
821
+ onOpenThread((selectedNode.originalData as any).id)
822
+ setSelectedNode(null)
823
+ }}
824
+ >
825
+ Open Thread
826
+ </button>
827
+ )}
828
+ {selectedNode.type === "memory" && (
829
+ <button
830
+ className="drawer-action-btn btn-purple"
831
+ onClick={() => {
832
+ onGroundMemoryNode((selectedNode.originalData as any).slug)
833
+ }}
834
+ >
835
+ {selectedGroundingNodes.includes((selectedNode.originalData as any).slug)
836
+ ? "Remove Grounding Context"
837
+ : "Ground Chat with Node"}
838
+ </button>
839
+ )}
840
+ {selectedNode.type === "research" && (
841
+ <div style={{ width: "100%", fontSize: 11, color: "rgba(255,255,255,0.4)", textAlign: "center", fontStyle: "italic", padding: "8px 0" }}>
842
+ Background intelligence agent running...
843
+ </div>
844
+ )}
845
+ </div>
846
+ </div>
847
+ )}
848
+ </div>
849
+ )
850
+ }