@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,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
|
+
};
|