@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.
- package/docs/orchestrator-guide.md +335 -0
- package/package.json +3 -1
- package/packages/cli/src/index.js +26 -3
- package/packages/cli/src/init-project.js +213 -0
- package/packages/cli/src/templates.js +84 -0
- package/packages/cli/templates/.claude-settings.json.tmpl +32 -0
- package/packages/cli/templates/.gitignore.tmpl +28 -0
- package/packages/cli/templates/CLAUDE.md.tmpl +35 -0
- package/packages/cli/templates/CONTRADICTIONS.md.tmpl +30 -0
- package/packages/cli/templates/README.md.tmpl +15 -0
- package/packages/cli/templates/RESTART-PROMPT.md.tmpl +38 -0
- package/packages/cli/templates/docs-orchestration-README.md.tmpl +29 -0
- package/packages/cli/templates/project_facts.md.tmpl +39 -0
- package/packages/client/public/app.js +781 -0
- package/packages/client/public/graph.html +104 -0
- package/packages/client/public/graph.js +683 -0
- package/packages/client/public/index.html +145 -0
- package/packages/client/public/style.css +1185 -0
- package/packages/server/src/graph-routes.js +555 -0
- package/packages/server/src/index.js +158 -5
- package/packages/server/src/orchestration-preview.js +256 -0
- package/packages/server/src/preflight.js +82 -0
- package/packages/server/src/rag.js +138 -0
- package/packages/server/src/setup/mnestra-migrations/009_memory_relationship_metadata.sql +126 -0
- package/packages/server/src/setup/mnestra-migrations/010_memory_recall_graph.sql +147 -0
- package/packages/server/src/setup/rumen/migrations/003_graph_inference_schedule.sql +49 -0
- package/packages/server/src/sprint-inject.js +156 -0
- 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
|
+
})();
|