@jhizzard/termdeck 0.8.0 → 0.10.0

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 (28) hide show
  1. package/docs/orchestrator-guide.md +335 -0
  2. package/package.json +3 -1
  3. package/packages/cli/src/index.js +26 -3
  4. package/packages/cli/src/init-project.js +213 -0
  5. package/packages/cli/src/templates.js +84 -0
  6. package/packages/cli/templates/.claude-settings.json.tmpl +32 -0
  7. package/packages/cli/templates/.gitignore.tmpl +28 -0
  8. package/packages/cli/templates/CLAUDE.md.tmpl +35 -0
  9. package/packages/cli/templates/CONTRADICTIONS.md.tmpl +30 -0
  10. package/packages/cli/templates/README.md.tmpl +15 -0
  11. package/packages/cli/templates/RESTART-PROMPT.md.tmpl +38 -0
  12. package/packages/cli/templates/docs-orchestration-README.md.tmpl +29 -0
  13. package/packages/cli/templates/project_facts.md.tmpl +39 -0
  14. package/packages/client/public/app.js +781 -0
  15. package/packages/client/public/graph.html +104 -0
  16. package/packages/client/public/graph.js +683 -0
  17. package/packages/client/public/index.html +145 -0
  18. package/packages/client/public/style.css +1185 -0
  19. package/packages/server/src/graph-routes.js +555 -0
  20. package/packages/server/src/index.js +158 -5
  21. package/packages/server/src/orchestration-preview.js +256 -0
  22. package/packages/server/src/preflight.js +82 -0
  23. package/packages/server/src/rag.js +138 -0
  24. package/packages/server/src/setup/mnestra-migrations/009_memory_relationship_metadata.sql +126 -0
  25. package/packages/server/src/setup/mnestra-migrations/010_memory_recall_graph.sql +147 -0
  26. package/packages/server/src/setup/rumen/migrations/003_graph_inference_schedule.sql +49 -0
  27. package/packages/server/src/sprint-inject.js +156 -0
  28. package/packages/server/src/sprint-routes.js +503 -0
@@ -0,0 +1,683 @@
1
+ /* TermDeck — Knowledge Graph view (Sprint 38 T4)
2
+ *
3
+ * D3.js v7 force-directed graph backed by /api/graph/project and
4
+ * /api/graph/memory. Vanilla JS, no bundler — D3 is loaded via CDN <script>
5
+ * tag in graph.html.
6
+ *
7
+ * View modes:
8
+ * ?project=<name> — every memory_item in that project + edges
9
+ * fully contained in the project's node set
10
+ * ?memory=<uuid>&depth=<N> — N-hop neighborhood around a single memory
11
+ *
12
+ * Interactions:
13
+ * click node → opens drawer with full content + neighbor list
14
+ * hover node → dim non-incident edges + nodes
15
+ * click edge → tooltip with kind / weight / inferredBy
16
+ * edge filter → checkboxes per relationship_type, fade-out animation
17
+ * zoom + pan → d3.zoom on <g class="graph-zoom-root">
18
+ * search → input pulses matching node labels/content
19
+ * focus button → re-center camera on a node, re-heat the simulation
20
+ *
21
+ * The 8-value relationship vocabulary maps to Tokyo-Night palette so node /
22
+ * edge colors stay coherent with the rest of TermDeck.
23
+ */
24
+
25
+ (() => {
26
+ 'use strict';
27
+
28
+ // -------- Constants ------------------------------------------------------
29
+
30
+ // Tokyo-Night-friendly hue cycle for project coloring. Hash project name →
31
+ // index. Saturated enough to read on the dark backdrop, muted enough to
32
+ // avoid the pure-red / pure-green pitfalls the lane brief warns about.
33
+ const PROJECT_HUES = [
34
+ '#7aa2f7', // accent blue
35
+ '#bb9af7', // purple
36
+ '#7dcfff', // cyan
37
+ '#9ece6a', // soft green
38
+ '#e0af68', // amber
39
+ '#f7768e', // muted red
40
+ '#73daca', // teal
41
+ '#ff9e64', // coral
42
+ '#c0caf5', // off-white
43
+ '#9d7cd8', // dusty purple
44
+ '#41a6b5', // sea
45
+ '#b4f9f8', // pale cyan
46
+ ];
47
+
48
+ const EDGE_COLORS = {
49
+ supersedes: '#7aa2f7',
50
+ contradicts: '#f7768e',
51
+ relates_to: '#6b7089',
52
+ elaborates: '#9ece6a',
53
+ caused_by: '#bb9af7',
54
+ blocks: '#ff9e64',
55
+ inspired_by: '#73daca',
56
+ cross_project_link: '#e0af68',
57
+ };
58
+
59
+ const EDGE_LABELS = {
60
+ supersedes: 'supersedes',
61
+ contradicts: 'contradicts',
62
+ relates_to: 'relates to',
63
+ elaborates: 'elaborates',
64
+ caused_by: 'caused by',
65
+ blocks: 'blocks',
66
+ inspired_by: 'inspired by',
67
+ cross_project_link: 'cross-project',
68
+ };
69
+
70
+ const RECENCY_HALF_LIFE_DAYS = 30;
71
+ const NODE_MIN_RADIUS = 5;
72
+ const NODE_MAX_RADIUS = 22;
73
+ const EDGE_BASE_OPACITY = 0.55;
74
+ const EDGE_DIM_OPACITY = 0.08;
75
+ const NODE_DIM_OPACITY = 0.18;
76
+
77
+ // -------- State ----------------------------------------------------------
78
+
79
+ const state = {
80
+ mode: 'project', // 'project' | 'memory'
81
+ project: null,
82
+ memoryId: null,
83
+ depth: 2,
84
+ nodes: [],
85
+ edges: [],
86
+ activeKinds: new Set(Object.keys(EDGE_COLORS)),
87
+ selectedNodeId: null,
88
+ hoverNodeId: null,
89
+ searchTerm: '',
90
+ sim: null,
91
+ width: 0,
92
+ height: 0,
93
+ zoom: null,
94
+ nodeSel: null,
95
+ edgeSel: null,
96
+ labelSel: null,
97
+ config: null,
98
+ projects: [],
99
+ };
100
+
101
+ // -------- DOM refs -------------------------------------------------------
102
+
103
+ const $ = (id) => document.getElementById(id);
104
+ const stage = () => $('graphStage');
105
+ const svg = () => $('graphSvg');
106
+ const root = () => $('graphZoomRoot');
107
+
108
+ // -------- Helpers --------------------------------------------------------
109
+
110
+ function hashHue(s) {
111
+ if (!s) return PROJECT_HUES[0];
112
+ let h = 0;
113
+ for (let i = 0; i < s.length; i++) {
114
+ h = (h * 31 + s.charCodeAt(i)) & 0x7fffffff;
115
+ }
116
+ return PROJECT_HUES[h % PROJECT_HUES.length];
117
+ }
118
+
119
+ function recencyFactor(ageDays) {
120
+ if (ageDays == null || !Number.isFinite(ageDays)) return 0.6;
121
+ // half-life decay; bounded so freshest never blow past 1.0
122
+ return Math.min(1, Math.exp(-ageDays / RECENCY_HALF_LIFE_DAYS) * 0.6 + 0.4);
123
+ }
124
+
125
+ function nodeRadius(n) {
126
+ const deg = Math.sqrt((n.degree || 0) + 1);
127
+ const recency = recencyFactor(n.ageDays);
128
+ const r = (NODE_MIN_RADIUS + deg * 3.5) * recency;
129
+ return Math.max(NODE_MIN_RADIUS, Math.min(NODE_MAX_RADIUS, r));
130
+ }
131
+
132
+ function nodeColor(n) {
133
+ return hashHue(n.project || 'global');
134
+ }
135
+
136
+ function edgeColor(e) {
137
+ return EDGE_COLORS[e.kind] || '#6b7089';
138
+ }
139
+
140
+ function edgeStroke(e) {
141
+ // weight ?? 0.5 → 1–4px
142
+ const w = typeof e.weight === 'number' ? e.weight : 0.5;
143
+ return 1 + Math.max(0, Math.min(1, w)) * 3;
144
+ }
145
+
146
+ function fmtDate(iso) {
147
+ if (!iso) return '—';
148
+ try {
149
+ const d = new Date(iso);
150
+ const days = Math.floor((Date.now() - d.getTime()) / 86_400_000);
151
+ if (days < 1) return 'today';
152
+ if (days < 2) return 'yesterday';
153
+ if (days < 30) return `${days}d ago`;
154
+ const months = Math.floor(days / 30);
155
+ if (months < 12) return `${months}mo ago`;
156
+ return d.toISOString().slice(0, 10);
157
+ } catch {
158
+ return iso;
159
+ }
160
+ }
161
+
162
+ function readUrlState() {
163
+ const qs = new URLSearchParams(window.location.search);
164
+ const project = qs.get('project');
165
+ const memory = qs.get('memory');
166
+ const depth = parseInt(qs.get('depth'), 10);
167
+ if (memory) {
168
+ state.mode = 'memory';
169
+ state.memoryId = memory;
170
+ state.depth = Number.isFinite(depth) ? Math.max(1, Math.min(4, depth)) : 2;
171
+ } else if (project) {
172
+ state.mode = 'project';
173
+ state.project = project;
174
+ }
175
+ }
176
+
177
+ function writeUrlState() {
178
+ const qs = new URLSearchParams();
179
+ if (state.mode === 'memory' && state.memoryId) {
180
+ qs.set('memory', state.memoryId);
181
+ if (state.depth !== 2) qs.set('depth', String(state.depth));
182
+ } else if (state.project) {
183
+ qs.set('project', state.project);
184
+ }
185
+ const url = qs.toString() ? `?${qs.toString()}` : window.location.pathname;
186
+ window.history.replaceState(null, '', url);
187
+ }
188
+
189
+ // -------- API ------------------------------------------------------------
190
+
191
+ async function api(path) {
192
+ const res = await fetch(path, { headers: { 'Accept': 'application/json' } });
193
+ if (!res.ok) {
194
+ const text = await res.text().catch(() => '');
195
+ throw new Error(`HTTP ${res.status}${text ? ': ' + text.slice(0, 200) : ''}`);
196
+ }
197
+ return res.json();
198
+ }
199
+
200
+ async function loadConfig() {
201
+ try {
202
+ state.config = await api('/api/config');
203
+ state.projects = Object.keys(state.config.projects || {});
204
+ } catch {
205
+ state.config = null;
206
+ state.projects = [];
207
+ }
208
+ }
209
+
210
+ async function fetchGraph() {
211
+ setLoading('Loading graph…');
212
+ try {
213
+ let data;
214
+ if (state.mode === 'memory') {
215
+ data = await api(`/api/graph/memory/${encodeURIComponent(state.memoryId)}?depth=${state.depth}`);
216
+ if (data.enabled === false) return showDisabled(data);
217
+ state.nodes = data.nodes;
218
+ state.edges = data.edges;
219
+ // Use the root memory's project as the view's "current project" for
220
+ // the legend / drawer / fallback color when nodes span projects.
221
+ if (data.root && data.root.project) state.project = data.root.project;
222
+ } else {
223
+ const name = state.project || (state.projects[0] || 'termdeck');
224
+ state.project = name;
225
+ data = await api(`/api/graph/project/${encodeURIComponent(name)}`);
226
+ if (data.enabled === false) return showDisabled(data);
227
+ state.nodes = data.nodes;
228
+ state.edges = data.edges;
229
+ }
230
+ writeUrlState();
231
+ hideLoading();
232
+ if (state.nodes.length === 0) {
233
+ showEmpty();
234
+ return;
235
+ }
236
+ hideEmpty();
237
+ renderFilters();
238
+ renderGraph();
239
+ updateStats();
240
+ } catch (err) {
241
+ setLoading(`Failed: ${err.message}`);
242
+ }
243
+ }
244
+
245
+ // -------- Render --------------------------------------------------------
246
+
247
+ function setLoading(msg) {
248
+ const el = $('graphLoading');
249
+ if (!el) return;
250
+ el.hidden = false;
251
+ $('graphLoadingMsg').textContent = msg;
252
+ }
253
+ function hideLoading() {
254
+ const el = $('graphLoading');
255
+ if (el) el.hidden = true;
256
+ }
257
+ function showEmpty() {
258
+ $('graphEmpty').hidden = false;
259
+ if (state.mode === 'project') {
260
+ $('graphEmptyTitle').textContent = `No memories in "${state.project}"`;
261
+ } else {
262
+ $('graphEmptyTitle').textContent = 'No neighbors yet';
263
+ }
264
+ }
265
+ function hideEmpty() {
266
+ $('graphEmpty').hidden = true;
267
+ }
268
+ function showDisabled(data) {
269
+ hideLoading();
270
+ $('graphEmpty').hidden = false;
271
+ $('graphEmptyTitle').textContent = 'Graph backend not configured';
272
+ $('graphEmptyBody').innerHTML = 'TermDeck cannot reach the Postgres database (<code>DATABASE_URL</code> is unset, or the pool is failing). The graph view needs the same Mnestra database used by Flashback.';
273
+ }
274
+
275
+ function updateStats() {
276
+ $('graphStatNodes').textContent = `${state.nodes.length} nodes`;
277
+ $('graphStatEdges').textContent = `${state.edges.length} edges`;
278
+ if (state.mode === 'memory') {
279
+ $('graphStatProject').textContent = `neighborhood · depth ${state.depth}`;
280
+ } else {
281
+ $('graphStatProject').textContent = state.project || 'project';
282
+ }
283
+ }
284
+
285
+ function renderFilters() {
286
+ const present = new Map();
287
+ for (const e of state.edges) {
288
+ present.set(e.kind, (present.get(e.kind) || 0) + 1);
289
+ }
290
+ const wrap = $('graphFilters');
291
+ wrap.innerHTML = '';
292
+ const keys = Object.keys(EDGE_COLORS).filter((k) => present.has(k));
293
+ if (keys.length === 0) {
294
+ wrap.style.display = 'none';
295
+ return;
296
+ }
297
+ wrap.style.display = '';
298
+ for (const k of keys) {
299
+ const chip = document.createElement('button');
300
+ chip.type = 'button';
301
+ chip.className = 'gf-chip';
302
+ chip.dataset.kind = k;
303
+ chip.style.borderColor = EDGE_COLORS[k];
304
+ chip.innerHTML = `
305
+ <span class="gf-chip-dot" style="background:${EDGE_COLORS[k]}"></span>
306
+ <span class="gf-chip-label">${EDGE_LABELS[k]}</span>
307
+ <span class="gf-chip-count">${present.get(k)}</span>
308
+ `;
309
+ if (state.activeKinds.has(k)) chip.classList.add('active');
310
+ chip.addEventListener('click', () => {
311
+ if (state.activeKinds.has(k)) state.activeKinds.delete(k);
312
+ else state.activeKinds.add(k);
313
+ chip.classList.toggle('active');
314
+ applyFilter();
315
+ });
316
+ wrap.appendChild(chip);
317
+ }
318
+ }
319
+
320
+ function applyFilter() {
321
+ if (!state.edgeSel) return;
322
+ state.edgeSel
323
+ .transition()
324
+ .duration(200)
325
+ .attr('stroke-opacity', (e) => state.activeKinds.has(e.kind) ? EDGE_BASE_OPACITY : 0)
326
+ .style('pointer-events', (e) => state.activeKinds.has(e.kind) ? 'all' : 'none');
327
+ }
328
+
329
+ function sizeStage() {
330
+ const stageEl = stage();
331
+ state.width = stageEl.clientWidth;
332
+ state.height = stageEl.clientHeight;
333
+ svg().setAttribute('viewBox', `0 0 ${state.width} ${state.height}`);
334
+ svg().setAttribute('width', state.width);
335
+ svg().setAttribute('height', state.height);
336
+ }
337
+
338
+ function renderGraph() {
339
+ if (!window.d3) {
340
+ setLoading('D3.js failed to load (CDN blocked?)');
341
+ return;
342
+ }
343
+ sizeStage();
344
+
345
+ // d3.forceSimulation mutates node/edge objects. We pass in shallow copies
346
+ // keyed by id so we keep the original API payloads pristine for diffing.
347
+ const nodes = state.nodes.map((n) => Object.assign({}, n));
348
+ const links = state.edges.map((e) => ({
349
+ id: e.id,
350
+ source: e.source,
351
+ target: e.target,
352
+ kind: e.kind,
353
+ weight: e.weight,
354
+ inferredBy: e.inferredBy,
355
+ }));
356
+
357
+ if (state.sim) state.sim.stop();
358
+
359
+ const sim = window.d3.forceSimulation(nodes)
360
+ .force('link', window.d3.forceLink(links).id((d) => d.id).distance((l) => 60 + (1 - (l.weight ?? 0.5)) * 40))
361
+ .force('charge', window.d3.forceManyBody().strength(-260))
362
+ .force('center', window.d3.forceCenter(state.width / 2, state.height / 2))
363
+ .force('collide', window.d3.forceCollide().radius((d) => nodeRadius(d) + 4))
364
+ .alphaDecay(0.02);
365
+
366
+ state.sim = sim;
367
+
368
+ const rootSel = window.d3.select(root());
369
+ rootSel.selectAll('*').remove();
370
+
371
+ const edgesLayer = rootSel.append('g').attr('class', 'graph-edges');
372
+ const nodesLayer = rootSel.append('g').attr('class', 'graph-nodes');
373
+ const labelsLayer = rootSel.append('g').attr('class', 'graph-labels');
374
+
375
+ state.edgeSel = edgesLayer.selectAll('line')
376
+ .data(links, (d) => d.id)
377
+ .join('line')
378
+ .attr('stroke', edgeColor)
379
+ .attr('stroke-width', edgeStroke)
380
+ .attr('stroke-opacity', EDGE_BASE_OPACITY)
381
+ .attr('stroke-linecap', 'round')
382
+ .on('mouseenter', (event, d) => showEdgeTooltip(event, d))
383
+ .on('mouseleave', hideTooltip);
384
+
385
+ state.nodeSel = nodesLayer.selectAll('circle')
386
+ .data(nodes, (d) => d.id)
387
+ .join('circle')
388
+ .attr('r', nodeRadius)
389
+ .attr('fill', nodeColor)
390
+ .attr('stroke', '#0a0c12')
391
+ .attr('stroke-width', 1.2)
392
+ .attr('filter', 'url(#nodeGlow)')
393
+ .style('cursor', 'pointer')
394
+ .on('mouseenter', (event, d) => onNodeHover(d.id))
395
+ .on('mouseleave', () => onNodeHover(null))
396
+ .on('click', (event, d) => onNodeClick(d))
397
+ .call(window.d3.drag()
398
+ .on('start', (event, d) => {
399
+ if (!event.active) sim.alphaTarget(0.3).restart();
400
+ d.fx = d.x;
401
+ d.fy = d.y;
402
+ })
403
+ .on('drag', (event, d) => {
404
+ d.fx = event.x;
405
+ d.fy = event.y;
406
+ })
407
+ .on('end', (event, d) => {
408
+ if (!event.active) sim.alphaTarget(0);
409
+ d.fx = null;
410
+ d.fy = null;
411
+ }));
412
+
413
+ state.labelSel = labelsLayer.selectAll('text')
414
+ .data(nodes, (d) => d.id)
415
+ .join('text')
416
+ .text((d) => truncate(d.label || '', 30))
417
+ .attr('font-size', 10)
418
+ .attr('font-family', 'var(--tg-mono, monospace)')
419
+ .attr('fill', '#c8ccd8')
420
+ .attr('text-anchor', 'middle')
421
+ .attr('pointer-events', 'none')
422
+ .attr('opacity', 0.7);
423
+
424
+ sim.on('tick', () => {
425
+ state.edgeSel
426
+ .attr('x1', (d) => d.source.x)
427
+ .attr('y1', (d) => d.source.y)
428
+ .attr('x2', (d) => d.target.x)
429
+ .attr('y2', (d) => d.target.y);
430
+ state.nodeSel
431
+ .attr('cx', (d) => d.x)
432
+ .attr('cy', (d) => d.y);
433
+ state.labelSel
434
+ .attr('x', (d) => d.x)
435
+ .attr('y', (d) => d.y - nodeRadius(d) - 4);
436
+ });
437
+
438
+ // Zoom + pan.
439
+ const zoom = window.d3.zoom()
440
+ .scaleExtent([0.2, 4])
441
+ .on('zoom', (event) => {
442
+ rootSel.attr('transform', event.transform);
443
+ });
444
+ state.zoom = zoom;
445
+ window.d3.select(svg()).call(zoom);
446
+
447
+ // Initial fit after a short delay (let the simulation settle a bit).
448
+ setTimeout(() => fitToView(), 600);
449
+ }
450
+
451
+ function fitToView() {
452
+ if (!state.nodes.length) return;
453
+ const xs = state.nodes.map((n) => n.x).filter((v) => Number.isFinite(v));
454
+ const ys = state.nodes.map((n) => n.y).filter((v) => Number.isFinite(v));
455
+ if (!xs.length || !ys.length) return;
456
+ const minX = Math.min(...xs), maxX = Math.max(...xs);
457
+ const minY = Math.min(...ys), maxY = Math.max(...ys);
458
+ const w = Math.max(1, maxX - minX);
459
+ const h = Math.max(1, maxY - minY);
460
+ const pad = 0.05;
461
+ const scale = Math.min(state.width / (w * (1 + pad * 2)), state.height / (h * (1 + pad * 2)));
462
+ const k = Math.max(0.2, Math.min(1.5, scale));
463
+ const tx = state.width / 2 - ((minX + maxX) / 2) * k;
464
+ const ty = state.height / 2 - ((minY + maxY) / 2) * k;
465
+ if (state.zoom) {
466
+ window.d3.select(svg()).transition().duration(600)
467
+ .call(state.zoom.transform, window.d3.zoomIdentity.translate(tx, ty).scale(k));
468
+ }
469
+ }
470
+
471
+ function truncate(s, n) {
472
+ s = String(s || '');
473
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
474
+ }
475
+
476
+ // -------- Hover / click -------------------------------------------------
477
+
478
+ function onNodeHover(id) {
479
+ state.hoverNodeId = id;
480
+ if (!state.nodeSel) return;
481
+ if (id == null) {
482
+ state.nodeSel.attr('opacity', 1);
483
+ state.edgeSel.attr('stroke-opacity', (e) => state.activeKinds.has(e.kind) ? EDGE_BASE_OPACITY : 0);
484
+ state.labelSel.attr('opacity', 0.7);
485
+ hideTooltip();
486
+ return;
487
+ }
488
+ const incident = new Set();
489
+ incident.add(id);
490
+ for (const e of state.edges) {
491
+ if (e.source === id || e.target === id) {
492
+ incident.add(e.source);
493
+ incident.add(e.target);
494
+ }
495
+ }
496
+ state.nodeSel.attr('opacity', (d) => incident.has(d.id) ? 1 : NODE_DIM_OPACITY);
497
+ state.edgeSel.attr('stroke-opacity', (d) => {
498
+ if (!state.activeKinds.has(d.kind)) return 0;
499
+ const sId = typeof d.source === 'object' ? d.source.id : d.source;
500
+ const tId = typeof d.target === 'object' ? d.target.id : d.target;
501
+ return (sId === id || tId === id) ? 0.95 : EDGE_DIM_OPACITY;
502
+ });
503
+ state.labelSel.attr('opacity', (d) => incident.has(d.id) ? 1 : 0.15);
504
+ }
505
+
506
+ async function onNodeClick(node) {
507
+ state.selectedNodeId = node.id;
508
+ openDrawer();
509
+ try {
510
+ const data = await api(`/api/graph/memory/${encodeURIComponent(node.id)}?depth=1`);
511
+ renderDrawer(data);
512
+ } catch (err) {
513
+ $('gdContent').textContent = `Failed to load: ${err.message}`;
514
+ }
515
+ }
516
+
517
+ function openDrawer() {
518
+ $('graphDrawer').hidden = false;
519
+ $('graphDrawer').classList.add('open');
520
+ }
521
+ function closeDrawer() {
522
+ $('graphDrawer').classList.remove('open');
523
+ setTimeout(() => { $('graphDrawer').hidden = true; }, 220);
524
+ state.selectedNodeId = null;
525
+ }
526
+
527
+ function renderDrawer(data) {
528
+ const root = data.root || {};
529
+ $('gdProject').textContent = root.project || 'global';
530
+ $('gdProject').style.color = hashHue(root.project || 'global');
531
+ $('gdSourceType').textContent = root.source_type || 'fact';
532
+ $('gdCreated').textContent = fmtDate(root.createdAt);
533
+ $('gdContent').textContent = root.content || '(no content)';
534
+
535
+ const neighbors = (data.nodes || []).filter((n) => n.id !== root.id);
536
+ const list = $('gdNeighbors');
537
+ list.innerHTML = '';
538
+ if (neighbors.length === 0) {
539
+ list.innerHTML = '<div class="gd-empty">No edges from this memory yet. Rumen will infer them on the next nightly run.</div>';
540
+ return;
541
+ }
542
+ for (const n of neighbors) {
543
+ const row = document.createElement('button');
544
+ row.type = 'button';
545
+ row.className = 'gd-neighbor';
546
+ const dot = document.createElement('span');
547
+ dot.className = 'gd-neighbor-dot';
548
+ dot.style.background = hashHue(n.project || 'global');
549
+ const label = document.createElement('span');
550
+ label.className = 'gd-neighbor-label';
551
+ label.textContent = n.label || '(no content)';
552
+ const meta = document.createElement('span');
553
+ meta.className = 'gd-neighbor-meta';
554
+ meta.textContent = `${n.project || 'global'} · ${fmtDate(n.createdAt)}`;
555
+ row.appendChild(dot);
556
+ row.appendChild(label);
557
+ row.appendChild(meta);
558
+ row.addEventListener('click', () => {
559
+ state.mode = 'memory';
560
+ state.memoryId = n.id;
561
+ fetchGraph();
562
+ closeDrawer();
563
+ });
564
+ list.appendChild(row);
565
+ }
566
+ }
567
+
568
+ // -------- Tooltip -------------------------------------------------------
569
+
570
+ function showEdgeTooltip(event, edge) {
571
+ const tip = $('graphTooltip');
572
+ const meta = [];
573
+ meta.push(`<strong style="color:${edgeColor(edge)}">${EDGE_LABELS[edge.kind] || edge.kind}</strong>`);
574
+ if (typeof edge.weight === 'number') meta.push(`weight ${edge.weight.toFixed(2)}`);
575
+ if (edge.inferredBy) meta.push(`by ${edge.inferredBy}`);
576
+ tip.innerHTML = meta.join(' · ');
577
+ tip.hidden = false;
578
+ moveTooltip(event);
579
+ }
580
+ function moveTooltip(event) {
581
+ const tip = $('graphTooltip');
582
+ if (tip.hidden) return;
583
+ tip.style.left = (event.clientX + 12) + 'px';
584
+ tip.style.top = (event.clientY + 12) + 'px';
585
+ }
586
+ function hideTooltip() {
587
+ const tip = $('graphTooltip');
588
+ if (tip) tip.hidden = true;
589
+ }
590
+
591
+ // -------- Search -------------------------------------------------------
592
+
593
+ function applySearch(term) {
594
+ state.searchTerm = (term || '').trim().toLowerCase();
595
+ if (!state.nodeSel) return;
596
+ if (!state.searchTerm) {
597
+ state.nodeSel.classed('graph-node-pulse', false);
598
+ return;
599
+ }
600
+ const matches = (n) =>
601
+ (n.label || '').toLowerCase().includes(state.searchTerm)
602
+ || (n.snippet || '').toLowerCase().includes(state.searchTerm)
603
+ || (n.category || '').toLowerCase().includes(state.searchTerm);
604
+ state.nodeSel.classed('graph-node-pulse', (d) => matches(d));
605
+ }
606
+
607
+ // -------- Init ---------------------------------------------------------
608
+
609
+ async function init() {
610
+ readUrlState();
611
+ await loadConfig();
612
+
613
+ // Project picker
614
+ const sel = $('graphProject');
615
+ sel.innerHTML = '';
616
+ if (state.projects.length === 0) {
617
+ const opt = document.createElement('option');
618
+ opt.value = '';
619
+ opt.textContent = '— add a project first —';
620
+ sel.appendChild(opt);
621
+ sel.disabled = true;
622
+ } else {
623
+ for (const p of state.projects) {
624
+ const opt = document.createElement('option');
625
+ opt.value = p;
626
+ opt.textContent = p;
627
+ sel.appendChild(opt);
628
+ }
629
+ // Pick state.project, or first project from config.
630
+ if (!state.project && state.mode !== 'memory') {
631
+ state.project = state.projects[0];
632
+ }
633
+ if (state.project) sel.value = state.project;
634
+ }
635
+
636
+ sel.addEventListener('change', () => {
637
+ state.mode = 'project';
638
+ state.project = sel.value;
639
+ state.memoryId = null;
640
+ fetchGraph();
641
+ });
642
+
643
+ $('graphSearch').addEventListener('input', (e) => applySearch(e.target.value));
644
+ $('graphReheat').addEventListener('click', () => {
645
+ if (state.sim) state.sim.alpha(0.6).restart();
646
+ });
647
+ $('graphFit').addEventListener('click', () => fitToView());
648
+
649
+ $('gdClose').addEventListener('click', closeDrawer);
650
+ $('gdExpand').addEventListener('click', () => {
651
+ if (!state.selectedNodeId) return;
652
+ state.mode = 'memory';
653
+ state.memoryId = state.selectedNodeId;
654
+ closeDrawer();
655
+ fetchGraph();
656
+ });
657
+ $('gdCopyId').addEventListener('click', () => {
658
+ if (!state.selectedNodeId) return;
659
+ navigator.clipboard.writeText(state.selectedNodeId).catch(() => {});
660
+ });
661
+
662
+ document.addEventListener('keydown', (e) => {
663
+ if (e.key === 'Escape') closeDrawer();
664
+ });
665
+ document.addEventListener('mousemove', moveTooltip);
666
+
667
+ window.addEventListener('resize', () => {
668
+ sizeStage();
669
+ if (state.sim) {
670
+ state.sim.force('center', window.d3.forceCenter(state.width / 2, state.height / 2));
671
+ state.sim.alpha(0.3).restart();
672
+ }
673
+ });
674
+
675
+ fetchGraph();
676
+ }
677
+
678
+ if (document.readyState === 'loading') {
679
+ document.addEventListener('DOMContentLoaded', init);
680
+ } else {
681
+ init();
682
+ }
683
+ })();