@vellumai/assistant 0.4.2 → 0.4.4

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 (221) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +124 -10
  3. package/README.md +43 -35
  4. package/docs/trusted-contact-access.md +20 -0
  5. package/package.json +1 -1
  6. package/scripts/ipc/generate-swift.ts +1 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  8. package/src/__tests__/access-request-decision.test.ts +0 -1
  9. package/src/__tests__/actor-token-service.test.ts +1099 -0
  10. package/src/__tests__/agent-loop.test.ts +51 -0
  11. package/src/__tests__/approval-routes-http.test.ts +2 -0
  12. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  13. package/src/__tests__/assistant-id-boundary-guard.test.ts +415 -0
  14. package/src/__tests__/call-controller.test.ts +49 -0
  15. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  16. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  17. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  18. package/src/__tests__/call-routes-http.test.ts +0 -25
  19. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  21. package/src/__tests__/channel-guardian.test.ts +0 -86
  22. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  23. package/src/__tests__/checker.test.ts +33 -12
  24. package/src/__tests__/config-schema.test.ts +6 -0
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  26. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  27. package/src/__tests__/conversation-routes.test.ts +12 -3
  28. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  29. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  30. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  31. package/src/__tests__/guardian-actions-endpoint.test.ts +39 -13
  32. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  33. package/src/__tests__/guardian-outbound-http.test.ts +4 -5
  34. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  35. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  36. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  37. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  38. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  39. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  40. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  41. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  42. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  43. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  44. package/src/__tests__/non-member-access-request.test.ts +159 -9
  45. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  46. package/src/__tests__/notification-decision-strategy.test.ts +106 -2
  47. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  48. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  49. package/src/__tests__/relay-server.test.ts +1475 -33
  50. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  51. package/src/__tests__/session-agent-loop.test.ts +1 -0
  52. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  53. package/src/__tests__/session-init.benchmark.test.ts +0 -2
  54. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  55. package/src/__tests__/session-surfaces-task-progress.test.ts +44 -1
  56. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  57. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  58. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  59. package/src/__tests__/tool-executor.test.ts +21 -2
  60. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  61. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  62. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  63. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  64. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  65. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  66. package/src/__tests__/twilio-config.test.ts +2 -13
  67. package/src/__tests__/twilio-routes.test.ts +4 -3
  68. package/src/__tests__/update-bulletin.test.ts +0 -1
  69. package/src/agent/loop.ts +1 -1
  70. package/src/approvals/guardian-decision-primitive.ts +12 -3
  71. package/src/approvals/guardian-request-resolvers.ts +169 -11
  72. package/src/calls/call-constants.ts +29 -0
  73. package/src/calls/call-controller.ts +11 -3
  74. package/src/calls/call-domain.ts +33 -11
  75. package/src/calls/call-pointer-message-composer.ts +154 -0
  76. package/src/calls/call-pointer-messages.ts +106 -27
  77. package/src/calls/guardian-dispatch.ts +4 -2
  78. package/src/calls/relay-server.ts +921 -112
  79. package/src/calls/twilio-config.ts +4 -11
  80. package/src/calls/twilio-routes.ts +4 -6
  81. package/src/calls/types.ts +3 -1
  82. package/src/calls/voice-session-bridge.ts +4 -3
  83. package/src/cli/core-commands.ts +7 -4
  84. package/src/cli.ts +5 -4
  85. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  86. package/src/config/bundled-skills/app-builder/SKILL.md +309 -10
  87. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  88. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  89. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  90. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  91. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  92. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  93. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  94. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  95. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  96. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  97. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +215 -0
  98. package/src/config/calls-schema.ts +36 -0
  99. package/src/config/env.ts +22 -0
  100. package/src/config/feature-flag-registry.json +8 -8
  101. package/src/config/schema.ts +2 -2
  102. package/src/config/skills.ts +11 -0
  103. package/src/config/system-prompt.ts +11 -1
  104. package/src/config/templates/SOUL.md +2 -0
  105. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  106. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -1
  107. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  108. package/src/daemon/call-pointer-generators.ts +59 -0
  109. package/src/daemon/computer-use-session.ts +2 -5
  110. package/src/daemon/handlers/apps.ts +76 -20
  111. package/src/daemon/handlers/config-channels.ts +9 -61
  112. package/src/daemon/handlers/config-inbox.ts +11 -3
  113. package/src/daemon/handlers/config-ingress.ts +28 -3
  114. package/src/daemon/handlers/config-telegram.ts +12 -0
  115. package/src/daemon/handlers/config.ts +2 -6
  116. package/src/daemon/handlers/index.ts +2 -1
  117. package/src/daemon/handlers/pairing.ts +2 -0
  118. package/src/daemon/handlers/publish.ts +11 -46
  119. package/src/daemon/handlers/sessions.ts +59 -5
  120. package/src/daemon/handlers/shared.ts +17 -2
  121. package/src/daemon/ipc-contract/apps.ts +1 -0
  122. package/src/daemon/ipc-contract/inbox.ts +4 -0
  123. package/src/daemon/ipc-contract/integrations.ts +1 -97
  124. package/src/daemon/ipc-contract/messages.ts +47 -1
  125. package/src/daemon/ipc-contract/notifications.ts +11 -0
  126. package/src/daemon/ipc-contract-inventory.json +2 -4
  127. package/src/daemon/lifecycle.ts +17 -0
  128. package/src/daemon/server.ts +16 -2
  129. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  130. package/src/daemon/session-agent-loop.ts +24 -12
  131. package/src/daemon/session-lifecycle.ts +1 -1
  132. package/src/daemon/session-process.ts +11 -1
  133. package/src/daemon/session-runtime-assembly.ts +6 -1
  134. package/src/daemon/session-surfaces.ts +32 -3
  135. package/src/daemon/session.ts +88 -1
  136. package/src/daemon/tool-side-effects.ts +22 -0
  137. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  138. package/src/home-base/prebuilt/index.html +40 -0
  139. package/src/inbound/platform-callback-registration.ts +157 -0
  140. package/src/memory/canonical-guardian-store.ts +1 -1
  141. package/src/memory/conversation-crud.ts +2 -1
  142. package/src/memory/conversation-title-service.ts +16 -2
  143. package/src/memory/db-init.ts +8 -0
  144. package/src/memory/delivery-crud.ts +2 -1
  145. package/src/memory/guardian-action-store.ts +2 -1
  146. package/src/memory/guardian-approvals.ts +3 -2
  147. package/src/memory/ingress-invite-store.ts +12 -2
  148. package/src/memory/ingress-member-store.ts +4 -3
  149. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  150. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  151. package/src/memory/migrations/index.ts +2 -0
  152. package/src/memory/schema.ts +26 -5
  153. package/src/messaging/provider-types.ts +24 -0
  154. package/src/messaging/provider.ts +7 -0
  155. package/src/messaging/providers/gmail/adapter.ts +127 -0
  156. package/src/messaging/providers/sms/adapter.ts +40 -37
  157. package/src/notifications/adapters/macos.ts +45 -2
  158. package/src/notifications/broadcaster.ts +16 -0
  159. package/src/notifications/copy-composer.ts +50 -2
  160. package/src/notifications/decision-engine.ts +22 -9
  161. package/src/notifications/destination-resolver.ts +16 -2
  162. package/src/notifications/emit-signal.ts +18 -9
  163. package/src/notifications/guardian-question-mode.ts +419 -0
  164. package/src/notifications/signal.ts +14 -3
  165. package/src/permissions/checker.ts +13 -1
  166. package/src/permissions/prompter.ts +14 -0
  167. package/src/providers/anthropic/client.ts +20 -0
  168. package/src/providers/provider-send-message.ts +15 -3
  169. package/src/runtime/access-request-helper.ts +82 -4
  170. package/src/runtime/actor-token-service.ts +234 -0
  171. package/src/runtime/actor-token-store.ts +236 -0
  172. package/src/runtime/actor-trust-resolver.ts +2 -2
  173. package/src/runtime/assistant-scope.ts +10 -0
  174. package/src/runtime/channel-approvals.ts +5 -3
  175. package/src/runtime/channel-readiness-service.ts +23 -64
  176. package/src/runtime/channel-readiness-types.ts +3 -4
  177. package/src/runtime/channel-retry-sweep.ts +4 -1
  178. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  179. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  180. package/src/runtime/guardian-context-resolver.ts +82 -0
  181. package/src/runtime/guardian-outbound-actions.ts +5 -7
  182. package/src/runtime/guardian-reply-router.ts +67 -30
  183. package/src/runtime/guardian-vellum-migration.ts +57 -0
  184. package/src/runtime/http-server.ts +75 -31
  185. package/src/runtime/http-types.ts +13 -0
  186. package/src/runtime/ingress-service.ts +14 -0
  187. package/src/runtime/invite-redemption-service.ts +10 -1
  188. package/src/runtime/local-actor-identity.ts +76 -0
  189. package/src/runtime/middleware/actor-token.ts +271 -0
  190. package/src/runtime/middleware/twilio-validation.ts +2 -4
  191. package/src/runtime/routes/approval-routes.ts +82 -7
  192. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  193. package/src/runtime/routes/call-routes.ts +2 -1
  194. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  195. package/src/runtime/routes/channel-route-shared.ts +3 -3
  196. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  197. package/src/runtime/routes/conversation-routes.ts +142 -53
  198. package/src/runtime/routes/events-routes.ts +22 -8
  199. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  200. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  201. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  202. package/src/runtime/routes/inbound-conversation.ts +4 -3
  203. package/src/runtime/routes/inbound-message-handler.ts +147 -5
  204. package/src/runtime/routes/ingress-routes.ts +2 -0
  205. package/src/runtime/routes/integration-routes.ts +7 -15
  206. package/src/runtime/routes/pairing-routes.ts +163 -0
  207. package/src/runtime/routes/twilio-routes.ts +934 -0
  208. package/src/runtime/tool-grant-request-helper.ts +3 -1
  209. package/src/security/oauth2.ts +27 -2
  210. package/src/security/token-manager.ts +46 -10
  211. package/src/tools/browser/browser-execution.ts +4 -3
  212. package/src/tools/browser/browser-handoff.ts +10 -18
  213. package/src/tools/browser/browser-manager.ts +80 -25
  214. package/src/tools/browser/browser-screencast.ts +35 -119
  215. package/src/tools/calls/call-start.ts +2 -1
  216. package/src/tools/permission-checker.ts +15 -4
  217. package/src/tools/terminal/parser.ts +12 -0
  218. package/src/tools/tool-approval-handler.ts +244 -19
  219. package/src/workspace/git-service.ts +19 -0
  220. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  221. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -0,0 +1,1483 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>🧠 Knowledge Brain</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;0,9..40,800;1,9..40,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet" />
10
+ <script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
11
+ <script type="importmap">
12
+ {
13
+ "imports": {
14
+ "three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
15
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/"
16
+ }
17
+ }
18
+ </script>
19
+ <style>
20
+ :root {
21
+ --v-slate-950: #070D19;
22
+ --v-slate-900: #0F172A;
23
+ --v-slate-800: #1E293B;
24
+ --v-slate-700: #334155;
25
+ --v-slate-600: #475569;
26
+ --v-slate-500: #64748B;
27
+ --v-slate-400: #94A3B8;
28
+ --v-slate-300: #CBD5E1;
29
+ --v-slate-200: #E2E8F0;
30
+ --v-slate-100: #F1F5F9;
31
+ --v-slate-50: #F8FAFC;
32
+
33
+ --v-violet-950: #321669;
34
+ --v-violet-900: #4A2390;
35
+ --v-violet-800: #5C2FB2;
36
+ --v-violet-700: #7240CC;
37
+ --v-violet-600: #8A5BE0;
38
+ --v-violet-500: #9878EA;
39
+ --v-violet-400: #B8A6F1;
40
+ --v-violet-300: #D4C8F7;
41
+ --v-violet-200: #E8E1FB;
42
+ --v-violet-100: #F4F0FD;
43
+
44
+ --bg: var(--v-slate-950);
45
+ --surface: var(--v-slate-900);
46
+ --surface-raised: var(--v-slate-800);
47
+ --border: var(--v-slate-700);
48
+ --border-subtle: rgba(51, 65, 85, 0.5);
49
+ --text: var(--v-slate-50);
50
+ --text-secondary: var(--v-slate-400);
51
+ --text-muted: var(--v-slate-500);
52
+ --accent: var(--v-violet-600);
53
+ --accent-dim: rgba(138, 91, 224, 0.12);
54
+ }
55
+
56
+ * { box-sizing: border-box; margin: 0; padding: 0; }
57
+
58
+ body {
59
+ font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
60
+ background: var(--bg);
61
+ color: var(--text);
62
+ min-height: 100vh;
63
+ -webkit-font-smoothing: antialiased;
64
+ overflow-x: hidden;
65
+ display: flex;
66
+ flex-direction: column;
67
+ }
68
+
69
+ body::before {
70
+ content: "";
71
+ position: fixed;
72
+ top: -20%;
73
+ left: 50%;
74
+ transform: translateX(-50%);
75
+ width: 120%;
76
+ height: 50%;
77
+ background: radial-gradient(
78
+ ellipse at center,
79
+ rgba(138, 91, 224, 0.06) 0%,
80
+ rgba(91, 78, 255, 0.02) 50%,
81
+ transparent 70%
82
+ );
83
+ pointer-events: none;
84
+ z-index: 0;
85
+ }
86
+
87
+ /* ── Header ─────────────────────────────────────────────── */
88
+ .page-header {
89
+ position: relative;
90
+ z-index: 1;
91
+ padding: 24px 32px 16px;
92
+ display: flex;
93
+ flex-direction: column;
94
+ gap: 12px;
95
+ border-bottom: 1px solid var(--border-subtle);
96
+ }
97
+
98
+ .header-top {
99
+ display: flex;
100
+ align-items: center;
101
+ justify-content: space-between;
102
+ flex-wrap: wrap;
103
+ gap: 12px;
104
+ }
105
+
106
+ .header-title-block {
107
+ display: flex;
108
+ flex-direction: column;
109
+ gap: 2px;
110
+ }
111
+
112
+ .header-title {
113
+ font-size: 24px;
114
+ font-weight: 800;
115
+ letter-spacing: -0.02em;
116
+ background: linear-gradient(180deg, #fff 30%, var(--v-slate-300) 100%);
117
+ -webkit-background-clip: text;
118
+ -webkit-text-fill-color: transparent;
119
+ background-clip: text;
120
+ }
121
+
122
+ .header-subtitle {
123
+ font-size: 12px;
124
+ color: var(--text-muted);
125
+ letter-spacing: 0.04em;
126
+ text-transform: uppercase;
127
+ font-weight: 500;
128
+ }
129
+
130
+ .stats-bar {
131
+ display: flex;
132
+ align-items: center;
133
+ gap: 6px;
134
+ flex-wrap: wrap;
135
+ font-size: 13px;
136
+ color: var(--text-secondary);
137
+ }
138
+
139
+ .stat-item {
140
+ display: flex;
141
+ align-items: center;
142
+ gap: 5px;
143
+ }
144
+
145
+ .stat-value {
146
+ font-weight: 700;
147
+ color: var(--v-violet-400);
148
+ font-variant-numeric: tabular-nums;
149
+ }
150
+
151
+ .stat-sep {
152
+ color: var(--border);
153
+ user-select: none;
154
+ }
155
+
156
+ .knowledge-count-badge {
157
+ display: inline-flex;
158
+ align-items: center;
159
+ gap: 6px;
160
+ padding: 4px 12px;
161
+ background: var(--accent-dim);
162
+ border: 1px solid rgba(138, 91, 224, 0.25);
163
+ border-radius: 999px;
164
+ font-size: 12px;
165
+ font-weight: 600;
166
+ color: var(--v-violet-400);
167
+ }
168
+
169
+ .brain-pulse {
170
+ width: 8px;
171
+ height: 8px;
172
+ border-radius: 50%;
173
+ background: var(--accent);
174
+ animation: pulse-dot 2s ease-in-out infinite;
175
+ }
176
+
177
+ @keyframes pulse-dot {
178
+ 0%, 100% { opacity: 1; transform: scale(1); }
179
+ 50% { opacity: 0.5; transform: scale(0.7); }
180
+ }
181
+
182
+ /* ── Mode Toggle ─────────────────────────────────────────── */
183
+ .mode-toggle {
184
+ display: flex;
185
+ background: var(--surface);
186
+ border: 1px solid var(--border-subtle);
187
+ border-radius: 10px;
188
+ padding: 3px;
189
+ gap: 2px;
190
+ }
191
+
192
+ .mode-btn {
193
+ font-family: inherit;
194
+ font-size: 13px;
195
+ font-weight: 600;
196
+ padding: 6px 16px;
197
+ border: none;
198
+ border-radius: 7px;
199
+ cursor: pointer;
200
+ transition: all 0.15s ease;
201
+ background: transparent;
202
+ color: var(--text-muted);
203
+ }
204
+
205
+ .mode-btn.active {
206
+ background: var(--accent);
207
+ color: #fff;
208
+ box-shadow: 0 1px 4px rgba(138, 91, 224, 0.35);
209
+ }
210
+
211
+ .mode-btn:not(.active):hover {
212
+ background: var(--surface-raised);
213
+ color: var(--text-secondary);
214
+ }
215
+
216
+ /* ── Main Content ─────────────────────────────────────────── */
217
+ .main-content {
218
+ position: relative;
219
+ z-index: 1;
220
+ flex: 1;
221
+ display: flex;
222
+ flex-direction: column;
223
+ padding: 16px 24px;
224
+ gap: 16px;
225
+ min-height: 0;
226
+ }
227
+
228
+ /* ── Visualization Panels ─────────────────────────────────── */
229
+ .viz-panel {
230
+ flex: 1;
231
+ display: none;
232
+ position: relative;
233
+ background: var(--surface);
234
+ border: 1px solid var(--border-subtle);
235
+ border-radius: 16px;
236
+ overflow: hidden;
237
+ min-height: 460px;
238
+ }
239
+
240
+ .viz-panel.active {
241
+ display: flex;
242
+ flex-direction: column;
243
+ }
244
+
245
+ /* ── 2D Panel ─────────────────────────────────────────────── */
246
+ #panel-2d {
247
+ align-items: stretch;
248
+ }
249
+
250
+ #graph-svg {
251
+ width: 100%;
252
+ height: 100%;
253
+ min-height: 460px;
254
+ }
255
+
256
+ /* Brain silhouette animation — gentle breathing glow */
257
+ @keyframes brain-breathe {
258
+ 0%, 100% {
259
+ opacity: 0.35;
260
+ filter: drop-shadow(0 0 4px rgba(138, 91, 224, 0.2));
261
+ }
262
+ 50% {
263
+ opacity: 0.75;
264
+ filter: drop-shadow(0 0 12px rgba(138, 91, 224, 0.55));
265
+ }
266
+ }
267
+
268
+ .brain-outline {
269
+ animation: brain-breathe 3.5s ease-in-out infinite;
270
+ }
271
+
272
+ /* ── Refresh button ─────────────────────────────────────────── */
273
+ .refresh-btn {
274
+ font-family: inherit;
275
+ display: inline-flex;
276
+ align-items: center;
277
+ justify-content: center;
278
+ width: 34px;
279
+ height: 34px;
280
+ border-radius: 8px;
281
+ border: 1px solid var(--border);
282
+ background: transparent;
283
+ color: var(--text-muted);
284
+ font-size: 18px;
285
+ cursor: pointer;
286
+ transition: all 0.15s ease;
287
+ line-height: 1;
288
+ padding: 0;
289
+ }
290
+
291
+ .refresh-btn:hover {
292
+ border-color: var(--accent);
293
+ background: var(--accent-dim);
294
+ color: var(--v-violet-400);
295
+ }
296
+
297
+ .refresh-btn:active { transform: scale(0.93); }
298
+
299
+ .refresh-btn.spinning #refresh-icon {
300
+ display: inline-block;
301
+ animation: spin 0.7s linear infinite;
302
+ }
303
+
304
+ @keyframes spin {
305
+ from { transform: rotate(0deg); }
306
+ to { transform: rotate(360deg); }
307
+ }
308
+
309
+ /* ── 3D Panel ─────────────────────────────────────────────── */
310
+ #panel-3d {
311
+ align-items: stretch;
312
+ justify-content: stretch;
313
+ position: relative;
314
+ }
315
+
316
+ #brain-canvas-3d {
317
+ display: block;
318
+ width: 100%;
319
+ height: 100%;
320
+ min-height: 460px;
321
+ border-radius: 16px;
322
+ }
323
+
324
+ /* ── Empty / Error states ─────────────────────────────────── */
325
+ .state-overlay {
326
+ position: absolute;
327
+ inset: 0;
328
+ display: flex;
329
+ flex-direction: column;
330
+ align-items: center;
331
+ justify-content: center;
332
+ gap: 12px;
333
+ text-align: center;
334
+ padding: 48px;
335
+ pointer-events: none;
336
+ }
337
+
338
+ .state-overlay .icon {
339
+ font-size: 48px;
340
+ line-height: 1;
341
+ }
342
+
343
+ @keyframes empty-brain-pulse {
344
+ 0%, 100% { transform: scale(1); opacity: 0.8; }
345
+ 50% { transform: scale(1.12); opacity: 1; }
346
+ }
347
+
348
+ .empty-brain-icon {
349
+ animation: empty-brain-pulse 2.5s ease-in-out infinite;
350
+ display: inline-block;
351
+ }
352
+
353
+ .state-overlay h3 {
354
+ font-size: 18px;
355
+ font-weight: 700;
356
+ color: var(--text);
357
+ }
358
+
359
+ .state-overlay p {
360
+ font-size: 14px;
361
+ color: var(--text-muted);
362
+ line-height: 1.5;
363
+ max-width: 280px;
364
+ }
365
+
366
+ /* ── Tooltip ──────────────────────────────────────────────── */
367
+ #tooltip {
368
+ position: fixed;
369
+ display: none;
370
+ flex-direction: column;
371
+ gap: 4px;
372
+ padding: 10px 14px;
373
+ background: rgba(15, 23, 42, 0.92);
374
+ border: 1px solid rgba(138, 91, 224, 0.35);
375
+ border-radius: 10px;
376
+ backdrop-filter: blur(8px);
377
+ -webkit-backdrop-filter: blur(8px);
378
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
379
+ pointer-events: none;
380
+ z-index: 9999;
381
+ max-width: 220px;
382
+ font-size: 13px;
383
+ }
384
+
385
+ #tooltip.visible {
386
+ display: flex;
387
+ }
388
+
389
+ .tooltip-name {
390
+ font-weight: 700;
391
+ color: var(--text);
392
+ font-size: 14px;
393
+ }
394
+
395
+ .tooltip-type {
396
+ font-size: 11px;
397
+ text-transform: uppercase;
398
+ letter-spacing: 0.05em;
399
+ font-weight: 600;
400
+ color: var(--v-violet-400);
401
+ }
402
+
403
+ .tooltip-row {
404
+ font-size: 12px;
405
+ color: var(--text-secondary);
406
+ display: flex;
407
+ align-items: center;
408
+ gap: 4px;
409
+ }
410
+
411
+ .tooltip-dot {
412
+ width: 8px;
413
+ height: 8px;
414
+ border-radius: 50%;
415
+ flex-shrink: 0;
416
+ }
417
+
418
+ /* ── Legend ───────────────────────────────────────────────── */
419
+ .legend {
420
+ display: flex;
421
+ flex-wrap: wrap;
422
+ gap: 8px 16px;
423
+ padding: 4px 0;
424
+ }
425
+
426
+ .legend-item {
427
+ display: flex;
428
+ align-items: center;
429
+ gap: 6px;
430
+ font-size: 12px;
431
+ color: var(--text-muted);
432
+ }
433
+
434
+ .legend-dot {
435
+ width: 10px;
436
+ height: 10px;
437
+ border-radius: 50%;
438
+ flex-shrink: 0;
439
+ }
440
+
441
+ .legend-label {
442
+ font-size: 12px;
443
+ }
444
+
445
+ .legend-item input[type="checkbox"] {
446
+ width: 13px;
447
+ height: 13px;
448
+ accent-color: var(--accent);
449
+ cursor: pointer;
450
+ flex-shrink: 0;
451
+ }
452
+
453
+ .legend-item {
454
+ cursor: pointer;
455
+ user-select: none;
456
+ }
457
+
458
+ /* Dim legend item when hidden */
459
+ .legend-item.hidden-category {
460
+ opacity: 0.4;
461
+ }
462
+
463
+ /* ── Footer ───────────────────────────────────────────────── */
464
+ .page-footer {
465
+ position: relative;
466
+ z-index: 1;
467
+ padding: 16px 32px;
468
+ border-top: 1px solid var(--border-subtle);
469
+ display: flex;
470
+ align-items: center;
471
+ }
472
+
473
+ .back-link {
474
+ font-size: 13px;
475
+ font-weight: 600;
476
+ color: var(--text-secondary);
477
+ text-decoration: none;
478
+ display: inline-flex;
479
+ align-items: center;
480
+ gap: 6px;
481
+ transition: color 0.15s ease;
482
+ }
483
+
484
+ .back-link:hover {
485
+ color: var(--v-violet-400);
486
+ }
487
+
488
+ /* ── D3 node styles ───────────────────────────────────────── */
489
+ .node-circle {
490
+ cursor: pointer;
491
+ transition: filter 0.15s ease;
492
+ }
493
+
494
+ .node-circle:hover {
495
+ filter: brightness(1.3);
496
+ }
497
+
498
+ .link-line {
499
+ pointer-events: none;
500
+ }
501
+
502
+ .lobe-label {
503
+ pointer-events: none;
504
+ user-select: none;
505
+ }
506
+
507
+ /* ── Responsive ───────────────────────────────────────────── */
508
+ @media (max-width: 600px) {
509
+ .page-header { padding: 16px; }
510
+ .main-content { padding: 12px; }
511
+ .header-title { font-size: 20px; }
512
+ .stats-bar { font-size: 12px; }
513
+ }
514
+ </style>
515
+ </head>
516
+ <body>
517
+
518
+ <!-- ── Header ─────────────────────────────────────────────── -->
519
+ <header class="page-header">
520
+ <div class="header-top">
521
+ <div class="header-title-block">
522
+ <div class="header-title">🧠 Knowledge Brain</div>
523
+ <div class="header-subtitle">Identity Knowledge Graph</div>
524
+ </div>
525
+ <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
526
+ <div class="knowledge-count-badge">
527
+ <div class="brain-pulse"></div>
528
+ <span id="badge-knowledge-count">0 knowledge items</span>
529
+ </div>
530
+ <button class="refresh-btn" id="btn-refresh" onclick="refreshBrainData()" title="Refresh">
531
+ <span id="refresh-icon">&#x21BB;</span>
532
+ </button>
533
+ <div class="mode-toggle">
534
+ <button class="mode-btn active" id="btn-2d" onclick="switchMode('2d')">2D</button>
535
+ <button class="mode-btn" id="btn-3d" onclick="switchMode('3d')">3D</button>
536
+ </div>
537
+ </div>
538
+ </div>
539
+ <div class="stats-bar" id="stats-bar">
540
+ <span class="stat-item">
541
+ <span id="stat-entities" class="stat-value">0</span> entities
542
+ </span>
543
+ <span class="stat-sep">·</span>
544
+ <span class="stat-item">
545
+ <span id="stat-relations" class="stat-value">0</span> relations
546
+ </span>
547
+ <span class="stat-sep">·</span>
548
+ <span class="stat-item">
549
+ <span id="stat-memory" class="stat-value">0</span> memory items
550
+ </span>
551
+ <span class="stat-sep">·</span>
552
+ <span class="stat-item">Last updated: <span id="stat-updated" class="stat-value">—</span></span>
553
+ </div>
554
+ </header>
555
+
556
+ <!-- ── Main Content ───────────────────────────────────────── -->
557
+ <main class="main-content">
558
+
559
+ <!-- 2D Panel -->
560
+ <div class="viz-panel active" id="panel-2d">
561
+ <svg id="graph-svg"></svg>
562
+ </div>
563
+
564
+ <!-- 3D Panel -->
565
+ <div class="viz-panel" id="panel-3d">
566
+ <canvas id="brain-canvas-3d"></canvas>
567
+ </div>
568
+
569
+ <!-- Legend with category filter checkboxes -->
570
+ <div class="legend" id="legend">
571
+ <label class="legend-item" data-category="person">
572
+ <input type="checkbox" checked data-category="person" onchange="toggleCategory(this)" />
573
+ <div class="legend-dot" style="background:#22c55e"></div><span class="legend-label">👤 Person</span>
574
+ </label>
575
+ <label class="legend-item" data-category="project">
576
+ <input type="checkbox" checked data-category="project" onchange="toggleCategory(this)" />
577
+ <div class="legend-dot" style="background:#f97316"></div><span class="legend-label">📁 Project</span>
578
+ </label>
579
+ <label class="legend-item" data-category="tool">
580
+ <input type="checkbox" checked data-category="tool" onchange="toggleCategory(this)" />
581
+ <div class="legend-dot" style="background:#06b6d4"></div><span class="legend-label">🔧 Tool</span>
582
+ </label>
583
+ <label class="legend-item" data-category="organization">
584
+ <input type="checkbox" checked data-category="organization" onchange="toggleCategory(this)" />
585
+ <div class="legend-dot" style="background:#a855f7"></div><span class="legend-label">🏢 Organization</span>
586
+ </label>
587
+ <label class="legend-item" data-category="concept">
588
+ <input type="checkbox" checked data-category="concept" onchange="toggleCategory(this)" />
589
+ <div class="legend-dot" style="background:#eab308"></div><span class="legend-label">💡 Concept</span>
590
+ </label>
591
+ <label class="legend-item" data-category="location">
592
+ <input type="checkbox" checked data-category="location" onchange="toggleCategory(this)" />
593
+ <div class="legend-dot" style="background:#14b8a6"></div><span class="legend-label">📍 Location</span>
594
+ </label>
595
+ <label class="legend-item" data-category="profile">
596
+ <input type="checkbox" checked data-category="profile" onchange="toggleCategory(this)" />
597
+ <div class="legend-dot" style="background:#8b5cf6"></div><span class="legend-label">🟣 Profile</span>
598
+ </label>
599
+ <label class="legend-item" data-category="preference">
600
+ <input type="checkbox" checked data-category="preference" onchange="toggleCategory(this)" />
601
+ <div class="legend-dot" style="background:#3b82f6"></div><span class="legend-label">🔵 Preference</span>
602
+ </label>
603
+ <label class="legend-item" data-category="constraint">
604
+ <input type="checkbox" checked data-category="constraint" onchange="toggleCategory(this)" />
605
+ <div class="legend-dot" style="background:#ef4444"></div><span class="legend-label">🔴 Constraint</span>
606
+ </label>
607
+ <label class="legend-item" data-category="instruction">
608
+ <input type="checkbox" checked data-category="instruction" onchange="toggleCategory(this)" />
609
+ <div class="legend-dot" style="background:#f59e0b"></div><span class="legend-label">🟡 Instruction</span>
610
+ </label>
611
+ <label class="legend-item" data-category="style">
612
+ <input type="checkbox" checked data-category="style" onchange="toggleCategory(this)" />
613
+ <div class="legend-dot" style="background:#ec4899"></div><span class="legend-label">🩷 Style</span>
614
+ </label>
615
+ </div>
616
+
617
+ </main>
618
+
619
+ <!-- ── Footer ─────────────────────────────────────────────── -->
620
+ <footer class="page-footer">
621
+ <a href="/v1/home-base-ui" class="back-link">← Home</a>
622
+ </footer>
623
+
624
+ <!-- ── Tooltip ─────────────────────────────────────────────── -->
625
+ <div id="tooltip">
626
+ <div class="tooltip-name" id="tt-name"></div>
627
+ <div class="tooltip-type" id="tt-type"></div>
628
+ <div class="tooltip-row"><div class="tooltip-dot" id="tt-dot"></div><span id="tt-lobe"></span></div>
629
+ <div class="tooltip-row">🔗 <span id="tt-connections"></span></div>
630
+ <div class="tooltip-row">📌 <span id="tt-mentions"></span></div>
631
+ </div>
632
+
633
+ <script>
634
+ (function () {
635
+ 'use strict';
636
+
637
+ /* ── Category filter state ───────────────────────────────── */
638
+ // Tracks which entity/memory types are currently hidden
639
+ var hiddenCategories = {};
640
+
641
+ function toggleCategory(checkbox) {
642
+ var cat = checkbox.getAttribute('data-category');
643
+ var label = checkbox.closest('.legend-item');
644
+ if (!checkbox.checked) {
645
+ hiddenCategories[cat] = true;
646
+ if (label) label.classList.add('hidden-category');
647
+ } else {
648
+ delete hiddenCategories[cat];
649
+ if (label) label.classList.remove('hidden-category');
650
+ }
651
+ // Apply visibility to 2D D3 nodes
652
+ // Memory nodes carry type='Memory' with the real category in kind, so prefer kind for them.
653
+ d3.selectAll('.node-group').each(function(d) {
654
+ var type = (d.type === 'Memory' ? d.kind : (d.type || d.kind || '')).toLowerCase();
655
+ var visible = !hiddenCategories[type];
656
+ d3.select(this).style('display', visible ? null : 'none');
657
+ });
658
+ // Apply visibility to 3D nodes if they expose a filter hook
659
+ if (typeof window.filterThreeDCategories === 'function') {
660
+ window.filterThreeDCategories(hiddenCategories);
661
+ }
662
+ }
663
+ window.toggleCategory = toggleCategory;
664
+
665
+ /* ── Refresh ─────────────────────────────────────────────── */
666
+ function refreshBrainData() {
667
+ var btn = document.getElementById('btn-refresh');
668
+ if (btn) btn.classList.add('spinning');
669
+ // Clear existing panels
670
+ var panel2d = document.getElementById('panel-2d');
671
+ var panel3d = document.getElementById('panel-3d');
672
+ // Remove overlays (empty/error states)
673
+ panel2d.querySelectorAll('.state-overlay').forEach(function(el) { el.remove(); });
674
+ panel3d.querySelectorAll('.state-overlay').forEach(function(el) { el.remove(); });
675
+ // Reset 3D so it can reinitialise with new data
676
+ if (typeof window.destroyThreeD === 'function') window.destroyThreeD();
677
+ loadBrainData(function () {
678
+ if (btn) btn.classList.remove('spinning');
679
+ });
680
+ }
681
+ window.refreshBrainData = refreshBrainData;
682
+
683
+ /* ── Mode toggle ─────────────────────────────────────────── */
684
+ function switchMode(mode) {
685
+ document.getElementById('panel-2d').classList.toggle('active', mode === '2d');
686
+ document.getElementById('panel-3d').classList.toggle('active', mode === '3d');
687
+ document.getElementById('btn-2d').classList.toggle('active', mode === '2d');
688
+ document.getElementById('btn-3d').classList.toggle('active', mode === '3d');
689
+ if (mode === '3d') {
690
+ if (window.brainData && typeof window.initThreeD === 'function') {
691
+ window.initThreeD(window.brainData);
692
+ }
693
+ // Re-start the animation loop if it was previously stopped (e.g. after returning from 2D)
694
+ if (typeof window.resumeThreeD === 'function') {
695
+ window.resumeThreeD();
696
+ }
697
+ } else {
698
+ // Stop the Three.js render loop when leaving 3D to avoid wasting CPU/GPU
699
+ if (typeof window.stopThreeD === 'function') {
700
+ window.stopThreeD();
701
+ }
702
+ // Hide any lingering 3D tooltip - mouseleave is never fired when the
703
+ // parent panel is hidden via display:none, so we clear it explicitly.
704
+ document.getElementById('tooltip').classList.remove('visible');
705
+ }
706
+ }
707
+ window.switchMode = switchMode;
708
+
709
+ /* ── Stats helpers ────────────────────────────────────────── */
710
+ function updateStats(data) {
711
+ var entities = data.entities || [];
712
+ var relations = data.relations || [];
713
+ var memory = data.memorySummary || [];
714
+ var totalMemory = memory.reduce(function(s, m) { return s + (m.count || 0); }, 0);
715
+ var total = data.totalKnowledgeCount || (entities.length + totalMemory);
716
+
717
+ document.getElementById('stat-entities').textContent = entities.length;
718
+ document.getElementById('stat-relations').textContent = relations.length;
719
+ document.getElementById('stat-memory').textContent = totalMemory;
720
+ document.getElementById('badge-knowledge-count').textContent =
721
+ total + ' knowledge items';
722
+
723
+ if (data.generatedAt) {
724
+ var d = new Date(data.generatedAt);
725
+ document.getElementById('stat-updated').textContent = d.toLocaleString(undefined, {
726
+ month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
727
+ });
728
+ }
729
+ }
730
+
731
+ /* ── Brain SVG silhouette ─────────────────────────────────── */
732
+ function drawBrainSilhouette(svg, W, H) {
733
+ var cx = W / 2;
734
+ var cy = H / 2;
735
+
736
+ // Scale factors — brain occupies ~80% W, ~70% H
737
+ var bw = W * 0.78;
738
+ var bh = H * 0.68;
739
+ var lx = cx - bw * 0.26; // left lobe center x
740
+ var rx = cx + bw * 0.26; // right lobe center x
741
+ var lw = bw * 0.46;
742
+ var rw = bw * 0.46;
743
+ var lh = bh * 0.88;
744
+ var rh = bh * 0.88;
745
+
746
+ var brainG = svg.append('g').attr('class', 'brain-silhouette');
747
+
748
+ // Left lobe outline (organic blob via cubic bezier)
749
+ var leftPath = [
750
+ 'M', cx - 4, cy + lh * 0.42,
751
+ 'C', cx - 4, cy + lh * 0.55, lx - lw * 0.55, cy + lh * 0.55, lx - lw * 0.55, cy + lh * 0.2,
752
+ 'C', lx - lw * 0.6, cy - lh * 0.05, lx - lw * 0.55, cy - lh * 0.35, lx - lw * 0.2, cy - lh * 0.5,
753
+ 'C', lx + lw * 0.1, cy - lh * 0.62, lx + lw * 0.4, cy - lh * 0.58, lx + lw * 0.45, cy - lh * 0.42,
754
+ 'C', lx + lw * 0.52, cy - lh * 0.22, cx - 4, cy - lh * 0.2, cx - 4, cy + lh * 0.42,
755
+ 'Z'
756
+ ].join(' ');
757
+
758
+ // Right lobe outline
759
+ var rightPath = [
760
+ 'M', cx + 4, cy + rh * 0.42,
761
+ 'C', cx + 4, cy + rh * 0.55, rx + rw * 0.55, cy + rh * 0.55, rx + rw * 0.55, cy + rh * 0.2,
762
+ 'C', rx + rw * 0.6, cy - rh * 0.05, rx + rw * 0.55, cy - rh * 0.35, rx + rw * 0.2, cy - rh * 0.5,
763
+ 'C', rx - rw * 0.1, cy - rh * 0.62, rx - rw * 0.4, cy - rh * 0.58, rx - rw * 0.45, cy - rh * 0.42,
764
+ 'C', rx - rw * 0.52, cy - rh * 0.22, cx + 4, cy - rh * 0.2, cx + 4, cy + rh * 0.42,
765
+ 'Z'
766
+ ].join(' ');
767
+
768
+ // Draw fills
769
+ brainG.append('path')
770
+ .attr('d', leftPath)
771
+ .attr('fill', 'rgba(138, 91, 224, 0.04)')
772
+ .attr('stroke', 'rgba(138, 91, 224, 0.4)')
773
+ .attr('stroke-width', 1.5)
774
+ .attr('class', 'brain-outline');
775
+
776
+ brainG.append('path')
777
+ .attr('d', rightPath)
778
+ .attr('fill', 'rgba(138, 91, 224, 0.04)')
779
+ .attr('stroke', 'rgba(138, 91, 224, 0.4)')
780
+ .attr('stroke-width', 1.5)
781
+ .attr('class', 'brain-outline');
782
+
783
+ // Corpus callosum divider (dashed vertical line in center)
784
+ var ccTop = cy - lh * 0.28;
785
+ var ccBottom = cy + lh * 0.35;
786
+ brainG.append('line')
787
+ .attr('x1', cx).attr('y1', ccTop)
788
+ .attr('x2', cx).attr('y2', ccBottom)
789
+ .attr('stroke', 'rgba(138, 91, 224, 0.25)')
790
+ .attr('stroke-width', 1)
791
+ .attr('stroke-dasharray', '4,4');
792
+
793
+ // Lobe labels
794
+ var labelStyle = {
795
+ 'font-family': "'DM Sans', sans-serif",
796
+ 'font-size': '10px',
797
+ 'font-weight': '600',
798
+ 'fill': 'rgba(148, 163, 184, 0.45)',
799
+ 'letter-spacing': '0.06em',
800
+ 'text-transform': 'uppercase',
801
+ 'pointer-events': 'none',
802
+ 'user-select': 'none'
803
+ };
804
+
805
+ function addLabel(x, y, text) {
806
+ var t = brainG.append('text')
807
+ .attr('x', x).attr('y', y)
808
+ .attr('text-anchor', 'middle')
809
+ .attr('class', 'lobe-label')
810
+ .text(text);
811
+ Object.keys(labelStyle).forEach(function(k) { t.attr(k, labelStyle[k]); });
812
+ }
813
+
814
+ addLabel(lx - lw * 0.15, cy + 5, 'Analytical');
815
+ addLabel(rx + rw * 0.15, cy + 5, 'Creative');
816
+ addLabel(lx - lw * 0.1, cy - lh * 0.32, 'Planning');
817
+ addLabel(rx + rw * 0.1, cy - rh * 0.32, 'Social');
818
+ addLabel(cx, cy + lh * 0.5, 'Memory');
819
+
820
+ // Return brain bounds for use in bounding force
821
+ return {
822
+ leftLobe: { cx: lx, cy: cy, rx: lw * 0.52, ry: lh * 0.52 },
823
+ rightLobe: { cx: rx, cy: cy, rx: rw * 0.52, ry: rh * 0.52 },
824
+ centerX: cx,
825
+ centerY: cy,
826
+ width: bw,
827
+ height: bh
828
+ };
829
+ }
830
+
831
+ /* ── Lobe target positions ────────────────────────────────── */
832
+ function lobeTarget(lobeRegion, bounds) {
833
+ var cx = bounds.centerX;
834
+ var cy = bounds.centerY;
835
+ var lx = bounds.leftLobe.cx;
836
+ var rx = bounds.rightLobe.cx;
837
+ var hw = bounds.width * 0.18;
838
+ var hh = bounds.height * 0.18;
839
+ switch (lobeRegion) {
840
+ case 'right-social': return { x: rx + hw * 0.3, y: cy - hh * 1.2 };
841
+ case 'left-planning': return { x: lx - hw * 0.3, y: cy - hh * 1.2 };
842
+ case 'left-technical': return { x: lx - hw * 0.2, y: cy };
843
+ case 'right-creative': return { x: rx + hw * 0.2, y: cy };
844
+ case 'right-spatial': return { x: rx + hw * 0.2, y: cy + hh * 1.2 };
845
+ case 'center':
846
+ default: return { x: cx, y: cy + hh };
847
+ }
848
+ }
849
+
850
+ /* ── Tooltip logic ────────────────────────────────────────── */
851
+ var tooltip = document.getElementById('tooltip');
852
+
853
+ function showTooltip(event, d, connectionCount) {
854
+ document.getElementById('tt-name').textContent = d.name;
855
+ document.getElementById('tt-type').textContent = d.type || d.kind || 'memory';
856
+ document.getElementById('tt-dot').style.background = d.color || '#8A5BE0';
857
+ document.getElementById('tt-lobe').textContent = d.lobeRegion || 'center';
858
+ document.getElementById('tt-connections').textContent = (connectionCount || 0) + ' connections';
859
+ document.getElementById('tt-mentions').textContent = (d.mentionCount || d.count || 0) + ' mentions';
860
+ tooltip.classList.add('visible');
861
+ moveTooltip(event);
862
+ }
863
+
864
+ function moveTooltip(event) {
865
+ var x = event.clientX + 14;
866
+ var y = event.clientY - 10;
867
+ var tw = tooltip.offsetWidth || 200;
868
+ var th = tooltip.offsetHeight || 100;
869
+ if (x + tw > window.innerWidth - 8) x = event.clientX - tw - 14;
870
+ if (y + th > window.innerHeight - 8) y = event.clientY - th - 14;
871
+ tooltip.style.left = x + 'px';
872
+ tooltip.style.top = y + 'px';
873
+ }
874
+
875
+ function hideTooltip() {
876
+ tooltip.classList.remove('visible');
877
+ }
878
+
879
+ /* ── Render graph ─────────────────────────────────────────── */
880
+ function renderBrain(data) {
881
+ var svgEl = document.getElementById('graph-svg');
882
+ var W = svgEl.clientWidth || svgEl.parentElement.clientWidth || 800;
883
+ var H = svgEl.clientHeight || svgEl.parentElement.clientHeight || 500;
884
+
885
+ var svg = d3.select('#graph-svg')
886
+ .attr('viewBox', '0 0 ' + W + ' ' + H)
887
+ .attr('preserveAspectRatio', 'xMidYMid meet');
888
+
889
+ svg.selectAll('*').remove();
890
+
891
+ var entities = data.entities || [];
892
+ var relations = data.relations || [];
893
+ var memorySummary = data.memorySummary || [];
894
+
895
+ // Empty state — improved with pulsing brain emoji
896
+ if (entities.length === 0 && memorySummary.length === 0) {
897
+ var panel = document.getElementById('panel-2d');
898
+ var overlay = document.createElement('div');
899
+ overlay.className = 'state-overlay';
900
+ overlay.innerHTML =
901
+ '<div class="icon empty-brain-icon">&#x1F9E0;</div>' +
902
+ '<h3>Your brain is empty</h3>' +
903
+ '<p>Start a conversation to grow it!</p>';
904
+ panel.appendChild(overlay);
905
+ return;
906
+ }
907
+
908
+ // Draw brain silhouette
909
+ var bounds = drawBrainSilhouette(svg, W, H);
910
+
911
+ // Build node list
912
+ var nodeMap = {};
913
+ var nodes = entities.map(function(e) {
914
+ var n = Object.assign({}, e, {
915
+ _isEntity: true,
916
+ r: Math.max(6, Math.min(20, 6 + (e.mentionCount || 1) * 1.5))
917
+ });
918
+ nodeMap[e.id] = n;
919
+ return n;
920
+ });
921
+
922
+ // Add memory cluster nodes
923
+ memorySummary.forEach(function(m, i) {
924
+ var n = {
925
+ id: 'memory-' + i,
926
+ name: m.kind,
927
+ type: 'Memory',
928
+ kind: m.kind,
929
+ lobeRegion: 'center',
930
+ color: m.color || '#8b5cf6',
931
+ mentionCount: m.count || 0,
932
+ count: m.count || 0,
933
+ _isMemory: true,
934
+ r: Math.max(4, Math.min(8, 4 + Math.sqrt(m.count || 1)))
935
+ };
936
+ nodes.push(n);
937
+ });
938
+
939
+ // Build links
940
+ var links = relations.map(function(rel) {
941
+ return {
942
+ source: rel.sourceId,
943
+ target: rel.targetId,
944
+ relation: rel.relation
945
+ };
946
+ }).filter(function(l) {
947
+ return nodeMap[l.source] && nodeMap[l.target];
948
+ });
949
+
950
+ // Connection count map
951
+ var connCount = {};
952
+ links.forEach(function(l) {
953
+ connCount[l.source] = (connCount[l.source] || 0) + 1;
954
+ connCount[l.target] = (connCount[l.target] || 0) + 1;
955
+ });
956
+
957
+ // Initial positions near lobe targets
958
+ nodes.forEach(function(n) {
959
+ var t = lobeTarget(n.lobeRegion, bounds);
960
+ n.x = t.x + (Math.random() - 0.5) * 60;
961
+ n.y = t.y + (Math.random() - 0.5) * 60;
962
+ });
963
+
964
+ // ── D3 force simulation ──────────────────────────────────
965
+ var sim = d3.forceSimulation(nodes)
966
+ .force('link', d3.forceLink(links)
967
+ .id(function(d) { return d.id; })
968
+ .distance(60)
969
+ .strength(0.3))
970
+ .force('charge', d3.forceManyBody().strength(-80))
971
+ .force('collide', d3.forceCollide().radius(function(d) { return d.r + 4; }).strength(0.8))
972
+ // Lobe attraction force
973
+ .force('lobe', function(alpha) {
974
+ nodes.forEach(function(n) {
975
+ var t = lobeTarget(n.lobeRegion, bounds);
976
+ n.vx += (t.x - n.x) * alpha * 0.12;
977
+ n.vy += (t.y - n.y) * alpha * 0.12;
978
+ });
979
+ })
980
+ // Bounding force — keep nodes inside brain bounding ellipse
981
+ .force('bound', function(alpha) {
982
+ nodes.forEach(function(n) {
983
+ var lobe = bounds.leftLobe;
984
+ var rlobe = bounds.rightLobe;
985
+ // Check if inside either lobe (ellipse test)
986
+ var inLeft = Math.pow((n.x - lobe.cx) / (lobe.rx + n.r), 2) + Math.pow((n.y - lobe.cy) / (lobe.ry + n.r), 2);
987
+ var inRight = Math.pow((n.x - rlobe.cx) / (rlobe.rx + n.r), 2) + Math.pow((n.y - rlobe.cy) / (rlobe.ry + n.r), 2);
988
+ if (inLeft > 1 && inRight > 1) {
989
+ // Push toward closer lobe center
990
+ var dl = Math.hypot(n.x - lobe.cx, n.y - lobe.cy);
991
+ var dr = Math.hypot(n.x - rlobe.cx, n.y - rlobe.cy);
992
+ var target = dl < dr ? lobe : rlobe;
993
+ n.vx += (target.cx - n.x) * alpha * 0.5;
994
+ n.vy += (target.cy - n.y) * alpha * 0.5;
995
+ }
996
+ });
997
+ })
998
+ .alphaDecay(0.025)
999
+ .velocityDecay(0.4);
1000
+
1001
+ // Draw links
1002
+ var linkLayer = svg.append('g').attr('class', 'links');
1003
+ var linkSel = linkLayer.selectAll('line')
1004
+ .data(links)
1005
+ .enter().append('line')
1006
+ .attr('class', 'link-line')
1007
+ .attr('stroke', function(d) {
1008
+ var src = typeof d.source === 'object' ? d.source : nodeMap[d.source];
1009
+ return src ? src.color : 'rgba(138,91,224,0.15)';
1010
+ })
1011
+ .attr('stroke-width', 1)
1012
+ .attr('stroke-opacity', 0.15);
1013
+
1014
+ // Draw nodes
1015
+ var nodeLayer = svg.append('g').attr('class', 'nodes');
1016
+ var nodeSel = nodeLayer.selectAll('g')
1017
+ .data(nodes)
1018
+ .enter().append('g')
1019
+ .attr('class', 'node-group')
1020
+ .call(d3.drag()
1021
+ .on('start', function(event, d) {
1022
+ if (!event.active) sim.alphaTarget(0.3).restart();
1023
+ d.fx = d.x; d.fy = d.y;
1024
+ })
1025
+ .on('drag', function(event, d) {
1026
+ d.fx = event.x; d.fy = event.y;
1027
+ })
1028
+ .on('end', function(event, d) {
1029
+ if (!event.active) sim.alphaTarget(0);
1030
+ d.fx = null; d.fy = null;
1031
+ })
1032
+ );
1033
+
1034
+ nodeSel.append('circle')
1035
+ .attr('class', 'node-circle')
1036
+ .attr('r', function(d) { return d.r; })
1037
+ .attr('fill', function(d) { return d.color || '#8A5BE0'; })
1038
+ .attr('stroke', function(d) { return d.color || '#8A5BE0'; })
1039
+ .attr('stroke-width', 1.5)
1040
+ .attr('stroke-opacity', 0.6)
1041
+ .attr('fill-opacity', 0.85);
1042
+
1043
+ // Node hover events
1044
+ nodeSel
1045
+ .on('mouseenter', function(event, d) {
1046
+ d3.select(this).select('circle')
1047
+ .transition().duration(120)
1048
+ .attr('r', function(d) { return d.r * 1.35; })
1049
+ .attr('fill-opacity', 1);
1050
+ showTooltip(event, d, connCount[d.id] || 0);
1051
+ })
1052
+ .on('mousemove', function(event) {
1053
+ moveTooltip(event);
1054
+ })
1055
+ .on('mouseleave', function(event, d) {
1056
+ d3.select(this).select('circle')
1057
+ .transition().duration(120)
1058
+ .attr('r', function(d) { return d.r; })
1059
+ .attr('fill-opacity', 0.85);
1060
+ hideTooltip();
1061
+ });
1062
+
1063
+ // Tick
1064
+ sim.on('tick', function() {
1065
+ linkSel
1066
+ .attr('x1', function(d) { return d.source.x; })
1067
+ .attr('y1', function(d) { return d.source.y; })
1068
+ .attr('x2', function(d) { return d.target.x; })
1069
+ .attr('y2', function(d) { return d.target.y; });
1070
+
1071
+ nodeSel.attr('transform', function(d) {
1072
+ return 'translate(' + d.x + ',' + d.y + ')';
1073
+ });
1074
+ });
1075
+ }
1076
+
1077
+ /* ── Data fetch ───────────────────────────────────────────── */
1078
+ async function loadBrainData(done) {
1079
+ try {
1080
+ var apiToken = document.querySelector('meta[name="api-token"]')?.content;
1081
+ var headers = {};
1082
+ if (apiToken) headers['Authorization'] = 'Bearer ' + apiToken;
1083
+ var res = await fetch('/v1/brain-graph', { headers });
1084
+ if (!res.ok) throw new Error('HTTP ' + res.status);
1085
+ var data = await res.json();
1086
+ window.brainData = data;
1087
+ updateStats(data);
1088
+ renderBrain(data);
1089
+ // Re-apply active category filters after re-render.
1090
+ // Memory nodes carry type='Memory' with the real category in kind, so prefer kind for them.
1091
+ d3.selectAll('.node-group').each(function(d) {
1092
+ var type = (d.type === 'Memory' ? d.kind : (d.type || d.kind || '')).toLowerCase();
1093
+ if (hiddenCategories[type]) d3.select(this).style('display', 'none');
1094
+ });
1095
+ // If the user switched to 3D before this fetch resolved, the panel is
1096
+ // already active but initThreeD was never called. Kick it off now.
1097
+ if (document.getElementById('panel-3d').classList.contains('active') &&
1098
+ typeof window.initThreeD === 'function') {
1099
+ window.initThreeD(data);
1100
+ }
1101
+ if (typeof done === 'function') done();
1102
+ } catch (err) {
1103
+ console.error('Brain graph load error:', err);
1104
+ var panel = document.getElementById('panel-2d');
1105
+ var overlay = document.createElement('div');
1106
+ overlay.className = 'state-overlay';
1107
+ overlay.innerHTML =
1108
+ '<div class="icon">⚠️</div>' +
1109
+ '<h3>Could not load brain data</h3>' +
1110
+ '<p></p>';
1111
+ overlay.querySelector('p').textContent = err.message || 'An unexpected error occurred.';
1112
+ panel.appendChild(overlay);
1113
+ if (typeof done === 'function') done();
1114
+ }
1115
+ }
1116
+
1117
+ // Kick off on load
1118
+ loadBrainData();
1119
+ })();
1120
+ </script>
1121
+
1122
+ <script type="module">
1123
+ import * as THREE from 'three';
1124
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
1125
+
1126
+ // Lobe region → scatter bounds in 3D space
1127
+ const LOBE_BOUNDS = {
1128
+ 'right-social': { xMin: 0.5, xMax: 1.5, yMin: 0.5, yMax: 1.5, zMin: -1, zMax: 1 },
1129
+ 'left-planning': { xMin: -1.5, xMax: -0.5, yMin: 0.5, yMax: 1.5, zMin: -1, zMax: 1 },
1130
+ 'left-technical': { xMin: -1.5, xMax: -0.5, yMin: -0.5, yMax: 0.5, zMin: -1, zMax: 1 },
1131
+ 'right-creative': { xMin: 0.5, xMax: 1.5, yMin: -0.5, yMax: 0.5, zMin: -1, zMax: 1 },
1132
+ 'right-spatial': { xMin: 0.5, xMax: 1.5, yMin: -1.5, yMax: -0.5, zMin: -1, zMax: 1 },
1133
+ 'center': { xMin: -0.5, xMax: 0.5, yMin: -0.5, yMax: 0.5, zMin: -0.5, zMax: 0.5 },
1134
+ };
1135
+
1136
+ function rand(min, max) {
1137
+ return min + Math.random() * (max - min);
1138
+ }
1139
+
1140
+ function nodePosition(lobeRegion) {
1141
+ const b = LOBE_BOUNDS[lobeRegion] || LOBE_BOUNDS['center'];
1142
+ return new THREE.Vector3(
1143
+ rand(b.xMin, b.xMax) + rand(-0.2, 0.2),
1144
+ rand(b.yMin, b.yMax) + rand(-0.2, 0.2),
1145
+ rand(b.zMin, b.zMax) + rand(-0.2, 0.2)
1146
+ );
1147
+ }
1148
+
1149
+ function hexStringToInt(hex) {
1150
+ // Accepts strings like '#22c55e' or '22c55e'
1151
+ return parseInt(hex.replace('#', ''), 16);
1152
+ }
1153
+
1154
+ let initialized = false;
1155
+ let animFrameId = null;
1156
+ let renderer = null;
1157
+ // animate reference stored here so resumeThreeD can call it without a closure capture issue
1158
+ let animateFn = null;
1159
+ // Stored so destroyThreeD can remove it and prevent stale calls into a null renderer after refresh
1160
+ let resizeHandler = null;
1161
+
1162
+ function stopAnimation() {
1163
+ if (animFrameId !== null) {
1164
+ cancelAnimationFrame(animFrameId);
1165
+ animFrameId = null;
1166
+ }
1167
+ }
1168
+
1169
+ // Exposed so the tab-switch handler in the non-module script can call them
1170
+ window.stopThreeD = stopAnimation;
1171
+ window.resumeThreeD = function resumeThreeD() {
1172
+ // Only restart if the 3D scene is already initialised and loop is not running
1173
+ if (initialized && animFrameId === null && typeof animateFn === 'function') {
1174
+ animateFn();
1175
+ }
1176
+ };
1177
+
1178
+ // Reset 3D state so initThreeD can be called again after a refresh
1179
+ window.destroyThreeD = function destroyThreeD() {
1180
+ stopAnimation();
1181
+ if (resizeHandler) {
1182
+ window.removeEventListener('resize', resizeHandler);
1183
+ resizeHandler = null;
1184
+ }
1185
+ if (renderer) {
1186
+ if (renderer.domElement && renderer.domElement.parentNode) {
1187
+ renderer.domElement.parentNode.removeChild(renderer.domElement);
1188
+ }
1189
+ renderer.dispose();
1190
+ renderer = null;
1191
+ }
1192
+ initialized = false;
1193
+ animateFn = null;
1194
+ };
1195
+
1196
+ window.addEventListener('beforeunload', stopAnimation);
1197
+
1198
+ window.initThreeD = function initThreeD(data) {
1199
+ // Avoid double-initialisation if already running
1200
+ if (initialized) return;
1201
+
1202
+ let canvas = document.getElementById('brain-canvas-3d');
1203
+ const panel = document.getElementById('panel-3d');
1204
+
1205
+ const entities = (data && data.entities) || [];
1206
+ const relations = (data && data.relations) || [];
1207
+ const memorySummary = (data && data.memorySummary) || [];
1208
+ const totalKnowledgeCount = (data && data.totalKnowledgeCount) || (entities.length + memorySummary.reduce((s, m) => s + (m.count || 0), 0));
1209
+
1210
+ // ── Empty state ──────────────────────────────────────────────
1211
+ if (entities.length === 0 && memorySummary.length === 0) {
1212
+ if (!panel.querySelector('.state-overlay')) {
1213
+ const overlay = document.createElement('div');
1214
+ overlay.className = 'state-overlay';
1215
+ overlay.innerHTML = '<div class="icon">🌱</div><h3>No knowledge yet</h3><p>Start a conversation to grow it!</p>';
1216
+ panel.appendChild(overlay);
1217
+ }
1218
+ return;
1219
+ }
1220
+
1221
+ // Mark as initialized only after all early returns, so empty-data calls
1222
+ // don't permanently block a subsequent call with real data.
1223
+ initialized = true;
1224
+
1225
+ // ── Renderer ─────────────────────────────────────────────────
1226
+ // canvas may be null if destroyThreeD removed it from the DOM; in that
1227
+ // case omit the canvas option so Three.js creates a fresh canvas element.
1228
+ const rendererOpts = canvas
1229
+ ? { canvas, antialias: true, alpha: true }
1230
+ : { antialias: true, alpha: true };
1231
+ renderer = new THREE.WebGLRenderer(rendererOpts);
1232
+ if (!canvas) {
1233
+ renderer.domElement.id = 'brain-canvas-3d';
1234
+ panel.appendChild(renderer.domElement);
1235
+ }
1236
+ canvas = canvas || renderer.domElement;
1237
+ renderer.setPixelRatio(window.devicePixelRatio);
1238
+ renderer.setClearColor(0x000000, 0);
1239
+
1240
+ function getSize() {
1241
+ return { w: panel.clientWidth || 800, h: panel.clientHeight || 500 };
1242
+ }
1243
+ const { w, h } = getSize();
1244
+ renderer.setSize(w, h);
1245
+
1246
+ // ── Scene ────────────────────────────────────────────────────
1247
+ const scene = new THREE.Scene();
1248
+
1249
+ // ── Camera ───────────────────────────────────────────────────
1250
+ const camera = new THREE.PerspectiveCamera(60, w / h, 0.1, 100);
1251
+ camera.position.z = 8;
1252
+
1253
+ // ── Lights ───────────────────────────────────────────────────
1254
+ scene.add(new THREE.AmbientLight(0xffffff, 0.4));
1255
+ const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
1256
+ dirLight.position.set(10, 10, 10);
1257
+ scene.add(dirLight);
1258
+ const pointLight = new THREE.PointLight(0x8a5be0, 1.5, 15);
1259
+ pointLight.position.set(0, 3, 3);
1260
+ scene.add(pointLight);
1261
+
1262
+ // ── Brain mesh ───────────────────────────────────────────────
1263
+ const brainGeo = new THREE.SphereGeometry(2.5, 64, 64);
1264
+
1265
+ // Deform to brain-like shape
1266
+ const positions = brainGeo.attributes.position;
1267
+ for (let i = 0; i < positions.count; i++) {
1268
+ const x = positions.getX(i), y = positions.getY(i), z = positions.getZ(i);
1269
+ const noise = 0.3 * Math.sin(3 * x) * Math.cos(3 * y) + 0.1 * Math.sin(7 * z);
1270
+ const scale = 1 + noise;
1271
+ positions.setXYZ(i, x * scale, y * scale, z * scale);
1272
+ }
1273
+ brainGeo.computeVertexNormals();
1274
+
1275
+ // Scale based on knowledge count
1276
+ const rawScale = 0.5 + Math.log10(Math.max(totalKnowledgeCount, 1)) * 0.5;
1277
+ const brainScale = Math.min(rawScale, 2.0);
1278
+
1279
+ // Wireframe shell (outer visual)
1280
+ const wireMat = new THREE.MeshBasicMaterial({
1281
+ color: 0x8a5be0,
1282
+ wireframe: true,
1283
+ transparent: true,
1284
+ opacity: 0.25,
1285
+ });
1286
+ const brainMesh = new THREE.Mesh(brainGeo, wireMat);
1287
+ brainMesh.scale.setScalar(brainScale);
1288
+ scene.add(brainMesh);
1289
+
1290
+ // Solid inner fill (BackSide for depth effect)
1291
+ const solidMat = new THREE.MeshPhongMaterial({
1292
+ color: 0x321669,
1293
+ transparent: true,
1294
+ opacity: 0.15,
1295
+ side: THREE.BackSide,
1296
+ });
1297
+ const solidMesh = new THREE.Mesh(brainGeo, solidMat);
1298
+ solidMesh.scale.setScalar(brainScale);
1299
+ scene.add(solidMesh);
1300
+
1301
+ // ── Build nodes ───────────────────────────────────────────────
1302
+ const nodeMeshes = []; // for raycasting
1303
+ const nodeEntityMap = new WeakMap(); // mesh → entity data
1304
+
1305
+ // Entity nodes
1306
+ entities.forEach(function(entity) {
1307
+ const radius = 0.04 + 0.02 * Math.min(entity.mentionCount || 1, 10);
1308
+ const geo = new THREE.SphereGeometry(radius, 16, 16);
1309
+ const color = hexStringToInt(entity.color || '#8a5be0');
1310
+ const mat = new THREE.MeshPhongMaterial({
1311
+ color,
1312
+ emissive: color,
1313
+ emissiveIntensity: 0.3,
1314
+ });
1315
+ const mesh = new THREE.Mesh(geo, mat);
1316
+ const pos = nodePosition(entity.lobeRegion || 'center');
1317
+ mesh.position.copy(pos);
1318
+ scene.add(mesh);
1319
+ nodeMeshes.push(mesh);
1320
+ nodeEntityMap.set(mesh, entity);
1321
+ });
1322
+
1323
+ // Memory summary nodes (center, smaller)
1324
+ memorySummary.forEach(function(m, i) {
1325
+ const geo = new THREE.SphereGeometry(0.06, 16, 16);
1326
+ const color = hexStringToInt(m.color || '#8b5cf6');
1327
+ const mat = new THREE.MeshPhongMaterial({
1328
+ color,
1329
+ emissive: color,
1330
+ emissiveIntensity: 0.3,
1331
+ });
1332
+ const mesh = new THREE.Mesh(geo, mat);
1333
+ const pos = nodePosition('center');
1334
+ mesh.position.copy(pos);
1335
+ scene.add(mesh);
1336
+ nodeMeshes.push(mesh);
1337
+ nodeEntityMap.set(mesh, {
1338
+ name: m.kind,
1339
+ type: 'Memory',
1340
+ kind: m.kind,
1341
+ lobeRegion: 'center',
1342
+ color: m.color || '#8b5cf6',
1343
+ mentionCount: m.count || 0,
1344
+ count: m.count || 0,
1345
+ });
1346
+ });
1347
+
1348
+ // Build a position lookup by entity id for relation lines
1349
+ const entityPositions = {};
1350
+ let meshIdx = 0;
1351
+ entities.forEach(function(entity) {
1352
+ entityPositions[entity.id] = nodeMeshes[meshIdx].position;
1353
+ meshIdx++;
1354
+ });
1355
+
1356
+ // ── Relation lines ────────────────────────────────────────────
1357
+ relations.forEach(function(rel) {
1358
+ const srcPos = entityPositions[rel.sourceId];
1359
+ const tgtPos = entityPositions[rel.targetId];
1360
+ if (!srcPos || !tgtPos) return;
1361
+
1362
+ const srcEntity = entities.find(function(e) { return e.id === rel.sourceId; });
1363
+ const color = hexStringToInt((srcEntity && srcEntity.color) || '#8a5be0');
1364
+
1365
+ const points = [srcPos.clone(), tgtPos.clone()];
1366
+ const geo = new THREE.BufferGeometry().setFromPoints(points);
1367
+ const mat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.15 });
1368
+ scene.add(new THREE.Line(geo, mat));
1369
+ });
1370
+
1371
+ // ── Controls ──────────────────────────────────────────────────
1372
+ const controls = new OrbitControls(camera, renderer.domElement);
1373
+ controls.enableDamping = true;
1374
+ controls.autoRotate = true;
1375
+ controls.autoRotateSpeed = 0.5;
1376
+
1377
+ // Pause auto-rotate while hovering
1378
+ canvas.addEventListener('mouseenter', function() { controls.autoRotate = false; });
1379
+ canvas.addEventListener('mouseleave', function() { controls.autoRotate = true; });
1380
+
1381
+ // ── Tooltip via raycasting ────────────────────────────────────
1382
+ const raycaster = new THREE.Raycaster();
1383
+ const mouse = new THREE.Vector2();
1384
+ const tooltip = document.getElementById('tooltip');
1385
+
1386
+ canvas.addEventListener('mousemove', function(event) {
1387
+ // Guard against stale listener firing after destroyThreeD nulls the renderer
1388
+ if (!renderer) return;
1389
+ const rect = canvas.getBoundingClientRect();
1390
+ mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
1391
+ mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
1392
+
1393
+ raycaster.setFromCamera(mouse, camera);
1394
+ const intersects = raycaster.intersectObjects(nodeMeshes);
1395
+ if (intersects.length > 0) {
1396
+ const entity = nodeEntityMap.get(intersects[0].object);
1397
+ if (entity) {
1398
+ document.getElementById('tt-name').textContent = entity.name || '';
1399
+ document.getElementById('tt-type').textContent = entity.type || entity.kind || 'memory';
1400
+ document.getElementById('tt-dot').style.background = entity.color || '#8A5BE0';
1401
+ document.getElementById('tt-lobe').textContent = entity.lobeRegion || 'center';
1402
+ document.getElementById('tt-connections').textContent = '— connections';
1403
+ document.getElementById('tt-mentions').textContent = (entity.mentionCount || entity.count || 0) + ' mentions';
1404
+ tooltip.classList.add('visible');
1405
+ // Position near cursor
1406
+ let x = event.clientX + 14;
1407
+ let y = event.clientY - 10;
1408
+ const tw = tooltip.offsetWidth || 200;
1409
+ const th = tooltip.offsetHeight || 100;
1410
+ if (x + tw > window.innerWidth - 8) x = event.clientX - tw - 14;
1411
+ if (y + th > window.innerHeight - 8) y = event.clientY - th - 14;
1412
+ tooltip.style.left = x + 'px';
1413
+ tooltip.style.top = y + 'px';
1414
+ }
1415
+ } else {
1416
+ tooltip.classList.remove('visible');
1417
+ }
1418
+ });
1419
+
1420
+ canvas.addEventListener('mouseleave', function() {
1421
+ tooltip.classList.remove('visible');
1422
+ });
1423
+
1424
+ // ── Category filter for 3D nodes ──────────────────────────────
1425
+ window.filterThreeDCategories = function (hiddenCategories) {
1426
+ nodeMeshes.forEach(function (mesh) {
1427
+ const entity = nodeEntityMap.get(mesh);
1428
+ if (!entity) return;
1429
+ // Memory nodes carry type='Memory' with the real category in kind, so prefer kind for them.
1430
+ const type = (entity.type === 'Memory' ? entity.kind : (entity.type || entity.kind || '')).toLowerCase();
1431
+ mesh.visible = !hiddenCategories[type];
1432
+ });
1433
+ };
1434
+
1435
+ // Replay any checkbox state that was set before 3D was initialised.
1436
+ // If the user unchecked categories while in 2D view, those hidden states
1437
+ // are recorded in the checkboxes but filterThreeDCategories didn't exist yet.
1438
+ (function replayHiddenCategories() {
1439
+ var hidden = {};
1440
+ document.querySelectorAll('.legend-item input[type="checkbox"]').forEach(function(cb) {
1441
+ if (!cb.checked) hidden[cb.dataset.category || cb.value] = true;
1442
+ });
1443
+ if (Object.keys(hidden).length > 0) {
1444
+ window.filterThreeDCategories(hidden);
1445
+ }
1446
+ })();
1447
+
1448
+ // ── Resize handler ────────────────────────────────────────────
1449
+ function onResize() {
1450
+ // Guard against stale listener firing after destroyThreeD nulls the renderer
1451
+ if (!renderer) return;
1452
+ const { w, h } = getSize();
1453
+ if (w === 0 || h === 0) return;
1454
+ camera.aspect = w / h;
1455
+ camera.updateProjectionMatrix();
1456
+ renderer.setSize(w, h);
1457
+ }
1458
+ resizeHandler = onResize;
1459
+ window.addEventListener('resize', resizeHandler);
1460
+
1461
+ // ── Animation loop ────────────────────────────────────────────
1462
+ function animate() {
1463
+ animFrameId = requestAnimationFrame(animate);
1464
+ controls.update();
1465
+
1466
+ // Slow y-rotation of the brain mesh
1467
+ brainMesh.rotation.y += 0.001;
1468
+ solidMesh.rotation.y += 0.001;
1469
+
1470
+ // Gentle breathing pulse
1471
+ const pulse = brainScale * (1 + 0.02 * Math.sin(Date.now() / 1000));
1472
+ brainMesh.scale.setScalar(pulse);
1473
+ solidMesh.scale.setScalar(pulse);
1474
+
1475
+ renderer.render(scene, camera);
1476
+ }
1477
+ // Store reference so resumeThreeD can restart the loop after stopThreeD cancels it
1478
+ animateFn = animate;
1479
+ animate();
1480
+ };
1481
+ </script>
1482
+ </body>
1483
+ </html>