@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.
- package/package.json +1 -1
- package/packages/client/public/graph.html +104 -0
- package/packages/client/public/graph.js +683 -0
- package/packages/client/public/index.html +1 -0
- package/packages/client/public/style.css +427 -0
- package/packages/server/src/graph-routes.js +555 -0
- package/packages/server/src/index.js +21 -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
|
@@ -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) {
|