@jhizzard/termdeck 0.9.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.
@@ -0,0 +1,555 @@
1
+ 'use strict';
2
+
3
+ // Graph routes — Sprint 38 T4. Powers the D3 force-directed knowledge-graph
4
+ // view in the dashboard.
5
+ //
6
+ // Endpoints:
7
+ // GET /api/graph/project/:name per-project node + edge set
8
+ // GET /api/graph/memory/:id?depth=2 per-memory N-hop neighborhood
9
+ // GET /api/graph/stats global topology counts
10
+ //
11
+ // All three accept the same { app, getPool } injection so tests can stub the
12
+ // pg pool. getPool() returns null when DATABASE_URL is absent or the pool
13
+ // failed to initialize — endpoints respond `{ enabled: false }` in that case
14
+ // (mirrors the rumen endpoints' graceful-degrade pattern).
15
+ //
16
+ // Schema notes:
17
+ // - memory_items.project is a single text column (not array).
18
+ // - memory_relationships.relationship_type is the edge type (CHECK enforces
19
+ // a vocabulary; T1's migration 009 expands to 8 values).
20
+ // - T2 may add weight + inferred_at + inferred_by mid-sprint. We SELECT them
21
+ // conditionally via to_jsonb so missing columns don't break the query.
22
+
23
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
24
+ const PROJECT_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
25
+ const NODE_LABEL_LEN = 200;
26
+ const MAX_DEPTH = 4;
27
+ const DEFAULT_DEPTH = 2;
28
+ const MAX_NODES_PER_PROJECT = 2000;
29
+
30
+ function snippet(content, len = NODE_LABEL_LEN) {
31
+ if (!content) return '';
32
+ const s = String(content).replace(/\s+/g, ' ').trim();
33
+ return s.length > len ? s.slice(0, len - 1) + '…' : s;
34
+ }
35
+
36
+ function asIso(v) {
37
+ if (!v) return null;
38
+ if (v instanceof Date) return v.toISOString();
39
+ return v;
40
+ }
41
+
42
+ // SELECT shape — keep column list explicit so additions to memory_items
43
+ // (Sprint 39+) don't accidentally leak into the API.
44
+ const NODE_COLUMNS = [
45
+ 'id', 'content', 'source_type', 'category', 'project',
46
+ 'created_at', 'updated_at', 'is_active', 'archived', 'superseded_by',
47
+ ];
48
+ const NODE_COLUMNS_SQL = NODE_COLUMNS.join(', ');
49
+ const NODE_COLUMNS_PREFIXED = (alias) => NODE_COLUMNS.map((c) => `${alias}.${c}`).join(', ');
50
+
51
+ const EDGE_COLUMNS_BASE = ['id', 'source_id', 'target_id', 'relationship_type', 'created_at'];
52
+ const EDGE_COLUMNS_BASE_SQL = EDGE_COLUMNS_BASE.join(', ');
53
+
54
+ // T2 will add weight/inferred_at/inferred_by mid-sprint. Use to_jsonb so a
55
+ // SELECT on a fresh pre-T2 box doesn't error on missing columns. The to_jsonb
56
+ // row carries whichever columns exist; we read weight/inferred_at/inferred_by
57
+ // off the resulting JSON.
58
+ const EDGE_COLUMNS_T2_SQL = `to_jsonb(memory_relationships) AS _row`;
59
+
60
+ function rowToNode(row) {
61
+ const createdAt = asIso(row.created_at);
62
+ const updatedAt = asIso(row.updated_at);
63
+ const ageMs = createdAt ? Date.now() - new Date(createdAt).getTime() : null;
64
+ const ageDays = ageMs == null ? null : ageMs / 86_400_000;
65
+ return {
66
+ id: row.id,
67
+ label: snippet(row.content, 80),
68
+ snippet: snippet(row.content, NODE_LABEL_LEN),
69
+ source_type: row.source_type || null,
70
+ category: row.category || null,
71
+ project: row.project || 'global',
72
+ createdAt,
73
+ updatedAt,
74
+ ageDays: ageDays == null ? null : Number(ageDays.toFixed(2)),
75
+ degree: typeof row.degree === 'number' ? row.degree : Number(row.degree || 0),
76
+ superseded: !!row.superseded_by,
77
+ };
78
+ }
79
+
80
+ function rowToEdge(row) {
81
+ const meta = row._row && typeof row._row === 'object' ? row._row : {};
82
+ return {
83
+ id: row.id,
84
+ source: row.source_id,
85
+ target: row.target_id,
86
+ kind: row.relationship_type,
87
+ createdAt: asIso(row.created_at),
88
+ weight: typeof meta.weight === 'number' ? meta.weight : null,
89
+ inferredAt: asIso(meta.inferred_at),
90
+ inferredBy: meta.inferred_by || null,
91
+ };
92
+ }
93
+
94
+ function rowToFullMemory(row) {
95
+ return {
96
+ id: row.id,
97
+ content: row.content || '',
98
+ source_type: row.source_type || null,
99
+ category: row.category || null,
100
+ project: row.project || 'global',
101
+ createdAt: asIso(row.created_at),
102
+ updatedAt: asIso(row.updated_at),
103
+ isActive: row.is_active !== false,
104
+ archived: !!row.archived,
105
+ supersededBy: row.superseded_by || null,
106
+ };
107
+ }
108
+
109
+ function disabledPayload(extra = {}) {
110
+ return Object.assign({ enabled: false, reason: 'DATABASE_URL not configured' }, extra);
111
+ }
112
+
113
+ async function fetchProjectGraph(pool, projectName) {
114
+ // One round-trip:
115
+ // 1. nodes for the project (with degree computed via subquery so we don't
116
+ // pull edges into JS just to count).
117
+ // 2. edges where BOTH endpoints belong to the project's node set.
118
+ //
119
+ // We deliberately scope to is_active=true and archived=false (matches the
120
+ // mnestra recall convention) so superseded/archived noise stays out of the
121
+ // visualization.
122
+ const nodesSql = `
123
+ WITH proj_nodes AS (
124
+ SELECT ${NODE_COLUMNS_SQL}
125
+ FROM memory_items
126
+ WHERE project = $1
127
+ AND is_active = TRUE
128
+ AND archived = FALSE
129
+ ORDER BY created_at DESC
130
+ LIMIT ${MAX_NODES_PER_PROJECT}
131
+ )
132
+ SELECT
133
+ n.*,
134
+ COALESCE((
135
+ SELECT COUNT(*)::int
136
+ FROM memory_relationships r
137
+ WHERE r.source_id = n.id OR r.target_id = n.id
138
+ ), 0) AS degree
139
+ FROM proj_nodes n
140
+ `;
141
+ const nodesRes = await pool.query(nodesSql, [projectName]);
142
+ const nodes = nodesRes.rows.map(rowToNode);
143
+ const idSet = new Set(nodes.map((n) => n.id));
144
+ if (idSet.size === 0) {
145
+ return { nodes: [], edges: [] };
146
+ }
147
+ const ids = Array.from(idSet);
148
+
149
+ // ANY($1::uuid[]) is the safe way to pass a uuid array param.
150
+ const edgesSql = `
151
+ SELECT ${EDGE_COLUMNS_BASE_SQL}, ${EDGE_COLUMNS_T2_SQL}
152
+ FROM memory_relationships
153
+ WHERE source_id = ANY($1::uuid[])
154
+ AND target_id = ANY($1::uuid[])
155
+ `;
156
+ const edgesRes = await pool.query(edgesSql, [ids]);
157
+ const edges = edgesRes.rows.map(rowToEdge);
158
+ return { nodes, edges };
159
+ }
160
+
161
+ async function fetchNeighborhood(pool, rootId, depth) {
162
+ // Inline recursive CTE so T4 ships independently of T1's
163
+ // expand_memory_neighborhood RPC. When that RPC lands, the CTE here can be
164
+ // replaced with `SELECT * FROM expand_memory_neighborhood($1, $2)` without
165
+ // changing the return shape (memory_id, depth).
166
+ //
167
+ // Cycle-safety: the path[] column accumulates visited ids; we only descend
168
+ // into ids not already in the path. Caps at depth so a runaway loop can't
169
+ // OOM the server.
170
+ const safeDepth = Math.max(1, Math.min(MAX_DEPTH, depth));
171
+ const sql = `
172
+ WITH RECURSIVE walk AS (
173
+ SELECT $1::uuid AS memory_id, 0 AS depth, ARRAY[$1::uuid] AS path
174
+ UNION ALL
175
+ SELECT
176
+ CASE WHEN r.source_id = w.memory_id THEN r.target_id ELSE r.source_id END AS memory_id,
177
+ w.depth + 1 AS depth,
178
+ w.path || (CASE WHEN r.source_id = w.memory_id THEN r.target_id ELSE r.source_id END)
179
+ FROM walk w
180
+ JOIN memory_relationships r
181
+ ON (r.source_id = w.memory_id OR r.target_id = w.memory_id)
182
+ WHERE w.depth < ${safeDepth}
183
+ AND NOT (
184
+ (CASE WHEN r.source_id = w.memory_id THEN r.target_id ELSE r.source_id END)
185
+ = ANY (w.path)
186
+ )
187
+ )
188
+ SELECT DISTINCT memory_id, MIN(depth) AS depth
189
+ FROM walk
190
+ GROUP BY memory_id
191
+ `;
192
+ const walkRes = await pool.query(sql, [rootId]);
193
+ const memoryIds = walkRes.rows.map((r) => r.memory_id);
194
+ const depthByMemoryId = new Map(walkRes.rows.map((r) => [r.memory_id, Number(r.depth)]));
195
+ if (memoryIds.length === 0) {
196
+ return { nodes: [], edges: [], depthByMemoryId };
197
+ }
198
+
199
+ // Fetch full memory rows for each id in the walk + their degree.
200
+ const nodesSql = `
201
+ SELECT
202
+ ${NODE_COLUMNS_PREFIXED('m')},
203
+ COALESCE((
204
+ SELECT COUNT(*)::int
205
+ FROM memory_relationships r
206
+ WHERE r.source_id = m.id OR r.target_id = m.id
207
+ ), 0) AS degree
208
+ FROM memory_items m
209
+ WHERE m.id = ANY($1::uuid[])
210
+ AND m.is_active = TRUE
211
+ AND m.archived = FALSE
212
+ `;
213
+ const nodesRes = await pool.query(nodesSql, [memoryIds]);
214
+ const nodes = nodesRes.rows.map((row) => {
215
+ const node = rowToNode(row);
216
+ node.depth = depthByMemoryId.get(row.id) ?? 0;
217
+ return node;
218
+ });
219
+
220
+ // Edges where BOTH endpoints are in the walk.
221
+ const edgesSql = `
222
+ SELECT ${EDGE_COLUMNS_BASE_SQL}, ${EDGE_COLUMNS_T2_SQL}
223
+ FROM memory_relationships
224
+ WHERE source_id = ANY($1::uuid[])
225
+ AND target_id = ANY($1::uuid[])
226
+ `;
227
+ const edgesRes = await pool.query(edgesSql, [memoryIds]);
228
+ const edges = edgesRes.rows.map(rowToEdge);
229
+ return { nodes, edges, depthByMemoryId };
230
+ }
231
+
232
+ async function fetchStats(pool) {
233
+ const memoriesSql = `
234
+ SELECT
235
+ COUNT(*)::int AS total,
236
+ COUNT(*) FILTER (WHERE is_active = TRUE AND archived = FALSE)::int AS active,
237
+ COUNT(DISTINCT project)::int AS projects
238
+ FROM memory_items
239
+ `;
240
+ const edgesSql = `
241
+ SELECT
242
+ COUNT(*)::int AS total,
243
+ relationship_type AS kind,
244
+ COUNT(*)::int AS by_type
245
+ FROM memory_relationships
246
+ GROUP BY ROLLUP(relationship_type)
247
+ `;
248
+ const projectSql = `
249
+ SELECT project, COUNT(*)::int AS n
250
+ FROM memory_items
251
+ WHERE is_active = TRUE AND archived = FALSE
252
+ GROUP BY project
253
+ ORDER BY n DESC
254
+ LIMIT 30
255
+ `;
256
+ // T2's inferred_at column may not exist yet — guard with a try/catch and
257
+ // fall back to a null lastInferredAt so the endpoint stays alive on a
258
+ // pre-T2 box.
259
+ const lastInferredSqlRaw = `
260
+ SELECT to_jsonb(memory_relationships) - 'embedding' AS _row
261
+ FROM memory_relationships
262
+ ORDER BY id DESC
263
+ LIMIT 1
264
+ `;
265
+
266
+ const [memoriesRes, edgesRes, projectsRes] = await Promise.all([
267
+ pool.query(memoriesSql),
268
+ pool.query(edgesSql),
269
+ pool.query(projectSql),
270
+ ]);
271
+
272
+ const memoriesRow = memoriesRes.rows[0] || { total: 0, active: 0, projects: 0 };
273
+
274
+ let totalEdges = 0;
275
+ const byType = {};
276
+ for (const row of edgesRes.rows) {
277
+ if (row.kind === null) {
278
+ // ROLLUP NULL row carries the grand total.
279
+ totalEdges = Number(row.by_type || 0);
280
+ } else {
281
+ byType[row.kind] = Number(row.by_type || 0);
282
+ }
283
+ }
284
+
285
+ const byProject = {};
286
+ for (const row of projectsRes.rows) {
287
+ byProject[row.project] = Number(row.n || 0);
288
+ }
289
+
290
+ let lastInferredAt = null;
291
+ let lastInferredBy = null;
292
+ try {
293
+ const r = await pool.query(lastInferredSqlRaw);
294
+ const meta = r.rows[0] && r.rows[0]._row ? r.rows[0]._row : null;
295
+ if (meta) {
296
+ lastInferredAt = asIso(meta.inferred_at) || null;
297
+ lastInferredBy = meta.inferred_by || null;
298
+ }
299
+ } catch (_e) {
300
+ // pre-T2: column or extension missing — leave nulls.
301
+ }
302
+
303
+ return {
304
+ enabled: true,
305
+ memories: {
306
+ total: Number(memoriesRow.total || 0),
307
+ active: Number(memoriesRow.active || 0),
308
+ projects: Number(memoriesRow.projects || 0),
309
+ },
310
+ edges: {
311
+ total: totalEdges,
312
+ byType,
313
+ },
314
+ byProject,
315
+ lastInferredAt,
316
+ lastInferredBy,
317
+ generatedAt: new Date().toISOString(),
318
+ };
319
+ }
320
+
321
+ // Sprint 38 T2 — inference-pipeline stats. Distinct from the topology-focused
322
+ // /api/graph/stats above: this answers "is the graph-inference cron healthy?"
323
+ // using memory_relationships' inferred_* columns plus pg_cron's job_run_details.
324
+ //
325
+ // All optional columns are read via to_jsonb so a pre-T1 box (no inferred_at /
326
+ // inferred_by / weight columns) still gets a usable response with nulls.
327
+ async function fetchInferenceStats(pool) {
328
+ const totalsSql = `
329
+ SELECT
330
+ COUNT(*)::int AS total,
331
+ (
332
+ SELECT COUNT(*)::int FROM memory_items
333
+ WHERE is_active = TRUE AND archived = FALSE
334
+ AND id NOT IN (
335
+ SELECT source_id FROM memory_relationships
336
+ UNION
337
+ SELECT target_id FROM memory_relationships
338
+ )
339
+ ) AS orphan_memories
340
+ FROM memory_relationships
341
+ `;
342
+
343
+ // cron-tagged rows + last cron-inferred timestamp. Anonymous to_jsonb dance
344
+ // so a missing column on a pre-T1 box returns nulls rather than errors.
345
+ const cronSummarySql = `
346
+ SELECT to_jsonb(memory_relationships) AS _row
347
+ FROM memory_relationships
348
+ ORDER BY id DESC
349
+ LIMIT 200
350
+ `;
351
+
352
+ const [totalsRes, sampleRes] = await Promise.all([
353
+ pool.query(totalsSql),
354
+ pool.query(cronSummarySql).catch(() => ({ rows: [] })),
355
+ ]);
356
+
357
+ const totalsRow = totalsRes.rows[0] || { total: 0, orphan_memories: 0 };
358
+
359
+ let cronInferredEdges = 0;
360
+ let lastInferenceAt = null;
361
+ for (const row of sampleRes.rows) {
362
+ const meta = row._row && typeof row._row === 'object' ? row._row : null;
363
+ if (!meta) continue;
364
+ const inferredBy = typeof meta.inferred_by === 'string' ? meta.inferred_by : null;
365
+ if (inferredBy && inferredBy.startsWith('cron-')) {
366
+ cronInferredEdges++;
367
+ const at = asIso(meta.inferred_at);
368
+ if (at && (!lastInferenceAt || at > lastInferenceAt)) {
369
+ lastInferenceAt = at;
370
+ }
371
+ }
372
+ }
373
+
374
+ // Authoritative count for cron-inferred edges (separate query so the sample
375
+ // above stays a fast probe; this one only runs after the totals land).
376
+ let cronInferredTotal = cronInferredEdges;
377
+ let lastCronInferredAt = lastInferenceAt;
378
+ try {
379
+ const exactRes = await pool.query(
380
+ `SELECT COUNT(*)::int AS n, MAX(inferred_at) AS last_at
381
+ FROM memory_relationships
382
+ WHERE inferred_by ILIKE 'cron-%'`,
383
+ );
384
+ const exactRow = exactRes.rows[0];
385
+ if (exactRow) {
386
+ cronInferredTotal = Number(exactRow.n || 0);
387
+ lastCronInferredAt = asIso(exactRow.last_at) || lastInferenceAt;
388
+ }
389
+ } catch (_e) {
390
+ // Pre-T1: inferred_by column missing — fall back to the sample probe.
391
+ }
392
+
393
+ // Last 5 graph-inference-tick runs from pg_cron's job_run_details.
394
+ // Wrapped in try/catch because some Supabase plans gate access to cron.*
395
+ // tables and we'd rather return nulls than 500.
396
+ let recentRuns = [];
397
+ let lastRunDurationMs = null;
398
+ try {
399
+ const cronRes = await pool.query(
400
+ `SELECT
401
+ start_time, end_time, status, return_message,
402
+ EXTRACT(EPOCH FROM (end_time - start_time)) * 1000 AS duration_ms
403
+ FROM cron.job_run_details
404
+ WHERE jobname = 'graph-inference-tick'
405
+ ORDER BY start_time DESC
406
+ LIMIT 5`,
407
+ );
408
+ recentRuns = cronRes.rows.map((row) => ({
409
+ startedAt: asIso(row.start_time),
410
+ endedAt: asIso(row.end_time),
411
+ status: row.status || null,
412
+ durationMs: row.duration_ms == null ? null : Number(row.duration_ms),
413
+ message: row.return_message || null,
414
+ }));
415
+ if (recentRuns.length > 0 && recentRuns[0].durationMs != null) {
416
+ lastRunDurationMs = recentRuns[0].durationMs;
417
+ }
418
+ } catch (_e) {
419
+ // pg_cron not enabled or cron schema not readable — recentRuns stays [].
420
+ }
421
+
422
+ return {
423
+ enabled: true,
424
+ totalEdges: Number(totalsRow.total || 0),
425
+ cronInferredEdges: cronInferredTotal,
426
+ orphanMemories: Number(totalsRow.orphan_memories || 0),
427
+ lastInferenceAt: lastCronInferredAt,
428
+ lastRunDurationMs,
429
+ recentRuns,
430
+ generatedAt: new Date().toISOString(),
431
+ };
432
+ }
433
+
434
+ function createGraphRoutes({ app, getPool }) {
435
+ if (!app) throw new Error('app required');
436
+ if (typeof getPool !== 'function') throw new Error('getPool callback required');
437
+
438
+ app.get('/api/graph/project/:name', async (req, res) => {
439
+ const project = req.params.name;
440
+ if (!project || !PROJECT_RE.test(project)) {
441
+ return res.status(400).json({ error: 'invalid project name' });
442
+ }
443
+ const pool = getPool();
444
+ if (!pool) return res.json(disabledPayload({ project, nodes: [], edges: [] }));
445
+
446
+ try {
447
+ const { nodes, edges } = await fetchProjectGraph(pool, project);
448
+ const byType = {};
449
+ for (const e of edges) {
450
+ byType[e.kind] = (byType[e.kind] || 0) + 1;
451
+ }
452
+ res.json({
453
+ enabled: true,
454
+ project,
455
+ stats: {
456
+ nodes: nodes.length,
457
+ edges: edges.length,
458
+ byType,
459
+ truncated: nodes.length >= MAX_NODES_PER_PROJECT,
460
+ },
461
+ nodes,
462
+ edges,
463
+ });
464
+ } catch (err) {
465
+ console.warn('[graph] /api/graph/project failed:', err.message);
466
+ res.status(500).json({ error: 'graph query failed', detail: err.message });
467
+ }
468
+ });
469
+
470
+ app.get('/api/graph/memory/:id', async (req, res) => {
471
+ const id = req.params.id;
472
+ if (!UUID_RE.test(id)) {
473
+ return res.status(400).json({ error: 'invalid memory id' });
474
+ }
475
+ const depthRaw = parseInt(req.query.depth, 10);
476
+ const depth = Number.isFinite(depthRaw) ? depthRaw : DEFAULT_DEPTH;
477
+
478
+ const pool = getPool();
479
+ if (!pool) return res.json(disabledPayload({ root: null, nodes: [], edges: [] }));
480
+
481
+ try {
482
+ // Fetch the root memory in full first so we can return content even when
483
+ // it has no edges yet.
484
+ const rootRes = await pool.query(
485
+ `SELECT ${NODE_COLUMNS} FROM memory_items WHERE id = $1`,
486
+ [id],
487
+ );
488
+ if (rootRes.rows.length === 0) {
489
+ return res.status(404).json({ error: 'memory not found' });
490
+ }
491
+ const root = rowToFullMemory(rootRes.rows[0]);
492
+
493
+ const { nodes, edges } = await fetchNeighborhood(pool, id, depth);
494
+ res.json({
495
+ enabled: true,
496
+ root,
497
+ depth: Math.max(1, Math.min(MAX_DEPTH, depth)),
498
+ nodes,
499
+ edges,
500
+ stats: {
501
+ nodes: nodes.length,
502
+ edges: edges.length,
503
+ },
504
+ });
505
+ } catch (err) {
506
+ console.warn('[graph] /api/graph/memory failed:', err.message);
507
+ res.status(500).json({ error: 'graph query failed', detail: err.message });
508
+ }
509
+ });
510
+
511
+ app.get('/api/graph/stats', async (req, res) => {
512
+ const pool = getPool();
513
+ if (!pool) return res.json(disabledPayload());
514
+ try {
515
+ const stats = await fetchStats(pool);
516
+ res.json(stats);
517
+ } catch (err) {
518
+ console.warn('[graph] /api/graph/stats failed:', err.message);
519
+ res.status(500).json({ error: 'graph stats query failed', detail: err.message });
520
+ }
521
+ });
522
+
523
+ // Sprint 38 T2 — operational view of the graph-inference cron pipeline.
524
+ // Distinct from /api/graph/stats (T4 topology). Express routes by exact
525
+ // path so the more-specific /stats/inference does not shadow /stats.
526
+ app.get('/api/graph/stats/inference', async (req, res) => {
527
+ const pool = getPool();
528
+ if (!pool) return res.json(disabledPayload());
529
+ try {
530
+ const stats = await fetchInferenceStats(pool);
531
+ res.json(stats);
532
+ } catch (err) {
533
+ console.warn('[graph] /api/graph/stats/inference failed:', err.message);
534
+ res.status(500).json({ error: 'graph inference stats query failed', detail: err.message });
535
+ }
536
+ });
537
+ }
538
+
539
+ module.exports = {
540
+ createGraphRoutes,
541
+ // Exported for tests + reuse:
542
+ fetchProjectGraph,
543
+ fetchNeighborhood,
544
+ fetchStats,
545
+ fetchInferenceStats,
546
+ rowToNode,
547
+ rowToEdge,
548
+ rowToFullMemory,
549
+ snippet,
550
+ UUID_RE,
551
+ PROJECT_RE,
552
+ MAX_NODES_PER_PROJECT,
553
+ MAX_DEPTH,
554
+ DEFAULT_DEPTH,
555
+ };
@@ -64,6 +64,7 @@ const { themes, statusColors } = require('./themes');
64
64
  const { loadConfig, addProject, updateConfig } = require('./config');
65
65
  const { createAuthMiddleware, verifyWebSocketUpgrade, hasAuth } = require('./auth');
66
66
  const { createSprintRoutes } = require('./sprint-routes');
67
+ const { createGraphRoutes } = require('./graph-routes');
67
68
  const orchestrationPreview = require('./orchestration-preview');
68
69
 
69
70
  // Sprint 37 T3 — lazy resolution of T2's CLI modules. The orchestration-preview
@@ -170,6 +171,17 @@ function createServer(config) {
170
171
  const mnestraBridge = createBridge(config);
171
172
  console.log(`[mnestra-bridge] mode=${mnestraBridge.mode}`);
172
173
 
174
+ // Sprint 38 / T3 — let RAGIntegration delegate vector recall to the
175
+ // bridge so we don't duplicate the embed pipeline. Graph recall stays
176
+ // in rag.js because it's a different RPC and doesn't share the
177
+ // direct/webhook/mcp mode shape.
178
+ rag.setBridge(mnestraBridge);
179
+ if (rag.graphRecall) {
180
+ console.log(
181
+ `[rag] graph-aware recall ENABLED (depth=${rag.graphRecallDepth}, k=${rag.graphRecallK}, half-life=${rag.graphRecallRecencyHalflifeDays}d)`
182
+ );
183
+ }
184
+
173
185
  // Initialize transcript writer (Session Transcripts — Sprint 6)
174
186
  const transcriptConfig = config.transcripts || {};
175
187
  const transcriptEnabled = transcriptConfig.enabled !== undefined
@@ -902,6 +914,15 @@ function createServer(config) {
902
914
  getSession: (id) => sessions.get(id),
903
915
  });
904
916
 
917
+ // Graph endpoints (Sprint 38 T4) — knowledge-graph view backing graph.html.
918
+ // Reuses the petvetbid pg pool (same DATABASE_URL serves memory_items +
919
+ // memory_relationships alongside rumen_*). Graceful-degrades when the pool
920
+ // is absent.
921
+ createGraphRoutes({
922
+ app,
923
+ getPool: getRumenPool,
924
+ });
925
+
905
926
  // GET /api/sessions/:id - get session details
906
927
  app.get('/api/sessions/:id', (req, res) => {
907
928
  const session = sessions.get(req.params.id);
@@ -136,6 +136,82 @@ async function checkDatabase() {
136
136
  }
137
137
  }
138
138
 
139
+ // Sprint 38 / T3 — graph-health check. Returns:
140
+ // pass : memory_relationships has rows AND last inferred_at < 48h ago
141
+ // warn : has rows but last inference > 48h ago (T2 cron may have drifted)
142
+ // fail : pg unreachable, table missing, or zero edges
143
+ //
144
+ // Reads `inferred_at` (T1's migration 009 column). Falls back to `created_at`
145
+ // for the 749 pre-T2 edges that have no inferred_at value yet, so the check
146
+ // doesn't perma-warn on the substrate that already exists.
147
+ async function checkGraphHealth(config) {
148
+ // Only meaningful when graph features are enabled. Treat as pass with a
149
+ // descriptive detail so the banner doesn't FAIL on installs that haven't
150
+ // opted into graph recall yet.
151
+ const graphEnabled = config.rag?.graphRecall === true;
152
+ if (!graphEnabled) {
153
+ return { name: 'graph_health', passed: true, detail: 'graph recall disabled' };
154
+ }
155
+
156
+ const dbUrl = process.env.DATABASE_URL;
157
+ if (!dbUrl) {
158
+ return { name: 'graph_health', passed: false, detail: 'DATABASE_URL not set — cannot check graph' };
159
+ }
160
+
161
+ let pg;
162
+ try { pg = require('pg'); } catch (err) { pg = null; }
163
+ if (!pg) {
164
+ return { name: 'graph_health', passed: false, detail: 'pg module not installed' };
165
+ }
166
+
167
+ const pool = new pg.Pool({
168
+ connectionString: dbUrl,
169
+ max: 1,
170
+ connectionTimeoutMillis: 5000,
171
+ });
172
+
173
+ try {
174
+ // Single round-trip: edge count + last inference timestamp. coalesce on
175
+ // inferred_at so the substrate's pre-T2 edges register their created_at
176
+ // (otherwise max() returns NULL and the staleness check trips).
177
+ const res = await pool.query(
178
+ `SELECT
179
+ count(*)::int AS edges,
180
+ max(coalesce(inferred_at, created_at)) AS last_inferred_at
181
+ FROM memory_relationships`
182
+ );
183
+ const row = res.rows[0] || {};
184
+ const edges = Number(row.edges || 0);
185
+ if (edges === 0) {
186
+ return {
187
+ name: 'graph_health', passed: false,
188
+ detail: 'memory_relationships is empty — run T2 inference cron or seed edges manually',
189
+ };
190
+ }
191
+
192
+ const last = row.last_inferred_at ? new Date(row.last_inferred_at) : null;
193
+ if (!last) {
194
+ return {
195
+ name: 'graph_health', passed: true,
196
+ detail: `${edges.toLocaleString()} edges, last inference timestamp unknown`,
197
+ };
198
+ }
199
+
200
+ const agoMs = Date.now() - last.getTime();
201
+ const agoH = (agoMs / 3_600_000).toFixed(1);
202
+ const stale = agoMs > 48 * 3_600_000; // 48h cron drift threshold
203
+ return {
204
+ name: 'graph_health',
205
+ passed: !stale,
206
+ detail: stale
207
+ ? `${edges.toLocaleString()} edges, last inference ${agoH}h ago (stale — expected within 48h)`
208
+ : `${edges.toLocaleString()} edges, last inference ${agoH}h ago`,
209
+ };
210
+ } finally {
211
+ await pool.end().catch(() => {});
212
+ }
213
+ }
214
+
139
215
  async function checkProjectPaths(config) {
140
216
  const projects = config.projects || {};
141
217
  const names = Object.keys(projects);
@@ -282,6 +358,10 @@ async function runPreflight(config) {
282
358
  name: 'shell_sanity', passed: false,
283
359
  detail: `check failed — ${err.message}`,
284
360
  })),
361
+ checkGraphHealth(config).catch((err) => ({
362
+ name: 'graph_health', passed: false,
363
+ detail: `check failed — ${err.message}`,
364
+ })),
285
365
  ]);
286
366
 
287
367
  const result = {
@@ -330,6 +410,7 @@ const REMEDIATION = {
330
410
  database_url: 'Set DATABASE_URL in ~/.termdeck/secrets.env',
331
411
  project_paths: 'Fix paths in ~/.termdeck/config.yaml → projects',
332
412
  shell_sanity: 'Check $SHELL and your login profile (~/.zshrc or ~/.bashrc)',
413
+ graph_health: 'Run T2 inference cron or apply migrations 009/010 to populate edges',
333
414
  };
334
415
 
335
416
  const CHECK_LABELS = {
@@ -339,6 +420,7 @@ const CHECK_LABELS = {
339
420
  database_url: 'Database',
340
421
  project_paths: 'Project paths',
341
422
  shell_sanity: 'Shell',
423
+ graph_health: 'Graph',
342
424
  };
343
425
 
344
426
  function printHealthBanner(result) {