@jhizzard/termdeck 0.9.0 → 0.10.2
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/app.js +42 -3
- 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/flashback-diag.js +51 -0
- package/packages/server/src/graph-routes.js +555 -0
- package/packages/server/src/index.js +83 -3
- package/packages/server/src/mnestra-bridge/index.js +63 -9
- package/packages/server/src/preflight.js +82 -0
- package/packages/server/src/rag.js +138 -0
- package/packages/server/src/session.js +95 -5
- 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/mnestra-migrations/011_project_tag_backfill.sql +237 -0
- package/packages/server/src/setup/rumen/migrations/003_graph_inference_schedule.sql +49 -0
|
@@ -56,6 +56,7 @@ const { SessionManager } = require('./session');
|
|
|
56
56
|
const { initDatabase, logCommand, getSessionHistory, getProjectSessions } = require('./database');
|
|
57
57
|
const { RAGIntegration } = require('./rag');
|
|
58
58
|
const { createBridge } = require('./mnestra-bridge');
|
|
59
|
+
const flashbackDiag = require('./flashback-diag');
|
|
59
60
|
const { writeSessionLog } = require('./session-logger');
|
|
60
61
|
const { TranscriptWriter } = require('./transcripts');
|
|
61
62
|
const { createHealthHandler, runPreflight } = require('./preflight');
|
|
@@ -64,6 +65,7 @@ const { themes, statusColors } = require('./themes');
|
|
|
64
65
|
const { loadConfig, addProject, updateConfig } = require('./config');
|
|
65
66
|
const { createAuthMiddleware, verifyWebSocketUpgrade, hasAuth } = require('./auth');
|
|
66
67
|
const { createSprintRoutes } = require('./sprint-routes');
|
|
68
|
+
const { createGraphRoutes } = require('./graph-routes');
|
|
67
69
|
const orchestrationPreview = require('./orchestration-preview');
|
|
68
70
|
|
|
69
71
|
// Sprint 37 T3 — lazy resolution of T2's CLI modules. The orchestration-preview
|
|
@@ -170,6 +172,17 @@ function createServer(config) {
|
|
|
170
172
|
const mnestraBridge = createBridge(config);
|
|
171
173
|
console.log(`[mnestra-bridge] mode=${mnestraBridge.mode}`);
|
|
172
174
|
|
|
175
|
+
// Sprint 38 / T3 — let RAGIntegration delegate vector recall to the
|
|
176
|
+
// bridge so we don't duplicate the embed pipeline. Graph recall stays
|
|
177
|
+
// in rag.js because it's a different RPC and doesn't share the
|
|
178
|
+
// direct/webhook/mcp mode shape.
|
|
179
|
+
rag.setBridge(mnestraBridge);
|
|
180
|
+
if (rag.graphRecall) {
|
|
181
|
+
console.log(
|
|
182
|
+
`[rag] graph-aware recall ENABLED (depth=${rag.graphRecallDepth}, k=${rag.graphRecallK}, half-life=${rag.graphRecallRecencyHalflifeDays}d)`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
173
186
|
// Initialize transcript writer (Session Transcripts — Sprint 6)
|
|
174
187
|
const transcriptConfig = config.transcripts || {};
|
|
175
188
|
const transcriptEnabled = transcriptConfig.enabled !== undefined
|
|
@@ -841,30 +854,69 @@ function createServer(config) {
|
|
|
841
854
|
question,
|
|
842
855
|
project: sess.meta.project,
|
|
843
856
|
searchAll: false,
|
|
857
|
+
cwd: sess.meta.cwd,
|
|
858
|
+
sessionId: sess.id,
|
|
844
859
|
sessionContext: {
|
|
845
860
|
type: sess.meta.type,
|
|
846
861
|
project: sess.meta.project,
|
|
862
|
+
cwd: sess.meta.cwd,
|
|
847
863
|
lastCommands: sess.meta.lastCommands.slice(-5),
|
|
848
864
|
status: 'errored'
|
|
849
865
|
}
|
|
850
866
|
}).then((result) => {
|
|
851
|
-
const
|
|
867
|
+
const memories = (result && result.memories) || [];
|
|
868
|
+
const count = memories.length;
|
|
852
869
|
console.log(`[flashback] query returned ${count} matches for session ${sess.id}`);
|
|
853
|
-
const hit =
|
|
870
|
+
const hit = memories[0];
|
|
871
|
+
const wsReadyState = sess.ws ? sess.ws.readyState : null;
|
|
854
872
|
if (!hit) {
|
|
855
873
|
console.log(`[flashback] no matches — skipping proactive_memory send for session ${sess.id}`);
|
|
874
|
+
flashbackDiag.log({
|
|
875
|
+
sessionId: sess.id,
|
|
876
|
+
event: 'proactive_memory_emit',
|
|
877
|
+
ws_ready_state: wsReadyState,
|
|
878
|
+
frame_size_bytes: 0,
|
|
879
|
+
result_count_in_frame: 0,
|
|
880
|
+
outcome: 'dropped_empty',
|
|
881
|
+
});
|
|
856
882
|
return;
|
|
857
883
|
}
|
|
858
884
|
if (sess.ws && sess.ws.readyState === 1) {
|
|
885
|
+
const frame = JSON.stringify({ type: 'proactive_memory', hit });
|
|
859
886
|
try {
|
|
860
|
-
sess.ws.send(
|
|
887
|
+
sess.ws.send(frame);
|
|
861
888
|
console.log(`[flashback] proactive_memory sent to session ${sess.id} (source_type=${hit.source_type}, project=${hit.project})`);
|
|
889
|
+
flashbackDiag.log({
|
|
890
|
+
sessionId: sess.id,
|
|
891
|
+
event: 'proactive_memory_emit',
|
|
892
|
+
ws_ready_state: 1,
|
|
893
|
+
frame_size_bytes: Buffer.byteLength(frame, 'utf8'),
|
|
894
|
+
result_count_in_frame: 1,
|
|
895
|
+
outcome: 'emitted',
|
|
896
|
+
});
|
|
862
897
|
} catch (err) {
|
|
863
898
|
console.error('[flashback] proactive_memory send failed:', err);
|
|
864
899
|
console.error('[ws] proactive_memory send failed:', err);
|
|
900
|
+
flashbackDiag.log({
|
|
901
|
+
sessionId: sess.id,
|
|
902
|
+
event: 'proactive_memory_emit',
|
|
903
|
+
ws_ready_state: 1,
|
|
904
|
+
frame_size_bytes: Buffer.byteLength(frame, 'utf8'),
|
|
905
|
+
result_count_in_frame: 1,
|
|
906
|
+
outcome: 'error',
|
|
907
|
+
error_message: err && err.message ? err.message : String(err),
|
|
908
|
+
});
|
|
865
909
|
}
|
|
866
910
|
} else {
|
|
867
911
|
console.log(`[flashback] ws not open for session ${sess.id} (readyState=${sess.ws ? sess.ws.readyState : 'null'}) — dropped hit`);
|
|
912
|
+
flashbackDiag.log({
|
|
913
|
+
sessionId: sess.id,
|
|
914
|
+
event: 'proactive_memory_emit',
|
|
915
|
+
ws_ready_state: wsReadyState,
|
|
916
|
+
frame_size_bytes: 0,
|
|
917
|
+
result_count_in_frame: count,
|
|
918
|
+
outcome: 'dropped_no_ws',
|
|
919
|
+
});
|
|
868
920
|
}
|
|
869
921
|
}).catch((err) => {
|
|
870
922
|
console.error(`[flashback] query failed for session ${sess.id}: ${err.message}`);
|
|
@@ -902,6 +954,15 @@ function createServer(config) {
|
|
|
902
954
|
getSession: (id) => sessions.get(id),
|
|
903
955
|
});
|
|
904
956
|
|
|
957
|
+
// Graph endpoints (Sprint 38 T4) — knowledge-graph view backing graph.html.
|
|
958
|
+
// Reuses the petvetbid pg pool (same DATABASE_URL serves memory_items +
|
|
959
|
+
// memory_relationships alongside rumen_*). Graceful-degrades when the pool
|
|
960
|
+
// is absent.
|
|
961
|
+
createGraphRoutes({
|
|
962
|
+
app,
|
|
963
|
+
getPool: getRumenPool,
|
|
964
|
+
});
|
|
965
|
+
|
|
905
966
|
// GET /api/sessions/:id - get session details
|
|
906
967
|
app.get('/api/sessions/:id', (req, res) => {
|
|
907
968
|
const session = sessions.get(req.params.id);
|
|
@@ -1326,6 +1387,23 @@ function createServer(config) {
|
|
|
1326
1387
|
});
|
|
1327
1388
|
});
|
|
1328
1389
|
|
|
1390
|
+
// GET /api/flashback/diag - Sprint 39 T1 diagnostic ring buffer.
|
|
1391
|
+
// Returns the last N Flashback decision-point events so Joshua can trigger
|
|
1392
|
+
// a real-shell error and read the timeline of which gate dropped the toast.
|
|
1393
|
+
// Optional filters: ?sessionId=<uuid>, ?eventType=pattern_match, ?limit=N
|
|
1394
|
+
// (capped at 200, the ring size).
|
|
1395
|
+
app.get('/api/flashback/diag', (req, res) => {
|
|
1396
|
+
const { sessionId, eventType } = req.query || {};
|
|
1397
|
+
const rawLimit = req.query && req.query.limit;
|
|
1398
|
+
const limit = rawLimit != null ? parseInt(rawLimit, 10) : undefined;
|
|
1399
|
+
const events = flashbackDiag.snapshot({
|
|
1400
|
+
sessionId: typeof sessionId === 'string' && sessionId.length ? sessionId : undefined,
|
|
1401
|
+
eventType: typeof eventType === 'string' && eventType.length ? eventType : undefined,
|
|
1402
|
+
limit: Number.isFinite(limit) && limit > 0 ? Math.min(limit, flashbackDiag.RING_SIZE) : undefined,
|
|
1403
|
+
});
|
|
1404
|
+
res.json({ count: events.length, events });
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1329
1407
|
// ==================== Transcript endpoints (Sprint 6 T3) ====================
|
|
1330
1408
|
|
|
1331
1409
|
// GET /api/transcripts/search - FTS across all sessions
|
|
@@ -1547,6 +1625,7 @@ function createServer(config) {
|
|
|
1547
1625
|
const sessionContext = session ? {
|
|
1548
1626
|
type: session.meta.type,
|
|
1549
1627
|
project: session.meta.project,
|
|
1628
|
+
cwd: session.meta.cwd,
|
|
1550
1629
|
lastCommands: session.meta.lastCommands.slice(-5),
|
|
1551
1630
|
status: session.meta.status
|
|
1552
1631
|
} : null;
|
|
@@ -1556,6 +1635,7 @@ function createServer(config) {
|
|
|
1556
1635
|
question,
|
|
1557
1636
|
project,
|
|
1558
1637
|
searchAll,
|
|
1638
|
+
cwd: session ? session.meta.cwd : undefined,
|
|
1559
1639
|
sessionContext
|
|
1560
1640
|
});
|
|
1561
1641
|
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
const { spawn } = require('child_process');
|
|
12
12
|
const { resolveProjectName } = require('../rag');
|
|
13
|
+
const flashbackDiag = require('../flashback-diag');
|
|
13
14
|
|
|
14
15
|
function createBridge(config) {
|
|
15
16
|
const mode = config.rag?.mnestraMode || 'direct';
|
|
@@ -225,7 +226,7 @@ function createBridge(config) {
|
|
|
225
226
|
}
|
|
226
227
|
}
|
|
227
228
|
|
|
228
|
-
async function queryMnestra({ question, project, searchAll, sessionContext, cwd }) {
|
|
229
|
+
async function queryMnestra({ question, project, searchAll, sessionContext, cwd, sessionId }) {
|
|
229
230
|
// Flashback callers pass the session's project (from config.yaml). If that
|
|
230
231
|
// slot is empty — e.g. a session created without an explicit project — fall
|
|
231
232
|
// back to resolving the session's cwd against config.projects so queries
|
|
@@ -246,15 +247,68 @@ function createBridge(config) {
|
|
|
246
247
|
// out-of-repo session-end hook), the mismatch surfaces here at query time.
|
|
247
248
|
console.log(`[mnestra-bridge] query project=${effectiveProject ?? 'ALL'} source=${searchAll ? 'searchAll' : projectSource} mode=${mode}`);
|
|
248
249
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
250
|
+
const projectTagInFilter = searchAll ? null : (effectiveProject || null);
|
|
251
|
+
const t0 = Date.now();
|
|
252
|
+
let result;
|
|
253
|
+
let callError;
|
|
254
|
+
try {
|
|
255
|
+
switch (mode) {
|
|
256
|
+
case 'webhook':
|
|
257
|
+
result = await queryWebhook({ question, project: effectiveProject, searchAll });
|
|
258
|
+
break;
|
|
259
|
+
case 'mcp':
|
|
260
|
+
result = await queryMcp({ question, project: effectiveProject, searchAll });
|
|
261
|
+
break;
|
|
262
|
+
case 'direct':
|
|
263
|
+
default:
|
|
264
|
+
result = await queryDirect({ question, project: effectiveProject, searchAll });
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
} catch (err) {
|
|
268
|
+
callError = err;
|
|
257
269
|
}
|
|
270
|
+
const durationMs = Date.now() - t0;
|
|
271
|
+
|
|
272
|
+
// Sprint 39 T1 — bridge_query / bridge_result diag events. Emitted at
|
|
273
|
+
// queryMnestra's outer boundary so all three backends (direct, webhook,
|
|
274
|
+
// mcp) flow through one observability point. T3 reads project_tag_in_filter
|
|
275
|
+
// (the tag the bridge SENT to the RPC) and top_3_project_tags (the tags
|
|
276
|
+
// it GOT BACK) to confirm or refute the project-mismatch hypothesis.
|
|
277
|
+
flashbackDiag.log({
|
|
278
|
+
sessionId,
|
|
279
|
+
event: 'bridge_query',
|
|
280
|
+
project_tag_in_filter: projectTagInFilter,
|
|
281
|
+
query_text: typeof question === 'string' ? question.slice(0, 200) : '',
|
|
282
|
+
mode,
|
|
283
|
+
rpc_args: {
|
|
284
|
+
project: projectTagInFilter,
|
|
285
|
+
searchAll: !!searchAll,
|
|
286
|
+
project_source: searchAll ? 'searchAll' : projectSource,
|
|
287
|
+
},
|
|
288
|
+
duration_ms: durationMs,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const memories = (result && Array.isArray(result.memories)) ? result.memories : [];
|
|
292
|
+
const tagCounts = {};
|
|
293
|
+
for (const m of memories) {
|
|
294
|
+
const tag = m && m.project != null ? String(m.project) : '(null)';
|
|
295
|
+
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
|
296
|
+
}
|
|
297
|
+
const top3 = Object.entries(tagCounts)
|
|
298
|
+
.sort((a, b) => b[1] - a[1])
|
|
299
|
+
.slice(0, 3)
|
|
300
|
+
.map(([tag, count]) => ({ tag, count }));
|
|
301
|
+
|
|
302
|
+
flashbackDiag.log({
|
|
303
|
+
sessionId,
|
|
304
|
+
event: 'bridge_result',
|
|
305
|
+
result_count: memories.length,
|
|
306
|
+
error_message: callError ? (callError.message || String(callError)) : null,
|
|
307
|
+
top_3_project_tags: top3,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
if (callError) throw callError;
|
|
311
|
+
return result;
|
|
258
312
|
}
|
|
259
313
|
|
|
260
314
|
return { mode, queryMnestra };
|
|
@@ -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) {
|
|
@@ -46,10 +46,28 @@ class RAGIntegration {
|
|
|
46
46
|
this.db = db;
|
|
47
47
|
this.supabaseUrl = config.rag?.supabaseUrl || null;
|
|
48
48
|
this.supabaseKey = config.rag?.supabaseKey || null;
|
|
49
|
+
this.openaiApiKey = config.rag?.openaiApiKey || process.env.OPENAI_API_KEY || null;
|
|
49
50
|
this.enabled = !!(config.rag?.enabled && this.supabaseUrl && this.supabaseKey);
|
|
50
51
|
this.syncInterval = config.rag?.syncIntervalMs || 10000;
|
|
51
52
|
this._syncTimer = null;
|
|
52
53
|
|
|
54
|
+
// Sprint 38 / T3 — graph-aware recall toggle. When true, the recall()
|
|
55
|
+
// method routes through the new memory_recall_graph RPC (vector seed +
|
|
56
|
+
// graph expansion + combined re-rank). When false (default), it
|
|
57
|
+
// delegates to the existing mnestra-bridge vector path. The half-life
|
|
58
|
+
// mirrors the SQL function default (30 days) but is exposed here so
|
|
59
|
+
// callers can override it without re-deploying the migration.
|
|
60
|
+
this.graphRecall = config.rag?.graphRecall === true;
|
|
61
|
+
this.graphRecallDepth = Math.max(1, Math.min(5, config.rag?.graphRecallDepth ?? 2));
|
|
62
|
+
this.graphRecallK = Math.max(1, Math.min(50, config.rag?.graphRecallK ?? 10));
|
|
63
|
+
this.graphRecallRecencyHalflifeDays = config.rag?.graphRecallRecencyHalflifeDays ?? 30;
|
|
64
|
+
|
|
65
|
+
// Bridge reference for the vector-only recall path. Wired in by index.js
|
|
66
|
+
// after the bridge is created so we avoid duplicating the embed → RPC
|
|
67
|
+
// plumbing here. Optional: if absent, recall() with graphRecall=false
|
|
68
|
+
// throws a helpful error instead of silently returning empty.
|
|
69
|
+
this._bridge = null;
|
|
70
|
+
|
|
53
71
|
// Table configuration matching Josh's multi-layer schema
|
|
54
72
|
this.tables = {
|
|
55
73
|
sessionMemory: config.rag?.tables?.session || 'mnestra_session_memory',
|
|
@@ -374,6 +392,126 @@ class RAGIntegration {
|
|
|
374
392
|
this._statusWriteAt.clear();
|
|
375
393
|
}
|
|
376
394
|
|
|
395
|
+
// Sprint 38 / T3 — wire the mnestra-bridge so vector-only recall delegates
|
|
396
|
+
// to the existing direct/webhook/mcp path instead of duplicating the embed
|
|
397
|
+
// pipeline here.
|
|
398
|
+
setBridge(bridge) {
|
|
399
|
+
this._bridge = bridge || null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Sprint 38 / T3 — graph-aware recall entry point. Returns the same shape
|
|
403
|
+
// as bridge.queryMnestra: { memories: [...], total }. Routes through the
|
|
404
|
+
// memory_recall_graph RPC when graphRecall is enabled, otherwise falls
|
|
405
|
+
// back to the bridge's vector path.
|
|
406
|
+
//
|
|
407
|
+
// options: { project?, searchAll?, sessionContext?, cwd?, depth?, k? }
|
|
408
|
+
async recall(query, options = {}) {
|
|
409
|
+
if (this.graphRecall) {
|
|
410
|
+
return this._recallViaGraph(query, options);
|
|
411
|
+
}
|
|
412
|
+
return this._recallViaVectorOnly(query, options);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async _recallViaVectorOnly(query, options) {
|
|
416
|
+
if (!this._bridge) {
|
|
417
|
+
throw new Error('RAGIntegration.recall: no bridge wired (call setBridge first)');
|
|
418
|
+
}
|
|
419
|
+
return this._bridge.queryMnestra({
|
|
420
|
+
question: query,
|
|
421
|
+
project: options.project,
|
|
422
|
+
searchAll: !!options.searchAll,
|
|
423
|
+
sessionContext: options.sessionContext,
|
|
424
|
+
cwd: options.cwd
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Direct REST call to memory_recall_graph (migration 010). Mirrors the
|
|
429
|
+
// bridge.queryDirect pattern: OpenAI embedding → Supabase RPC. Stays in
|
|
430
|
+
// rag.js so callers don't need to know which mnestra mode the bridge is
|
|
431
|
+
// using; graph recall is always direct-against-Postgres because the RPC
|
|
432
|
+
// doesn't ship as a Mnestra MCP tool yet (Sprint 38 / T1 wires the
|
|
433
|
+
// related MCP tools — graph recall lives here for now).
|
|
434
|
+
async _recallViaGraph(query, options) {
|
|
435
|
+
if (!this.supabaseUrl || !this.supabaseKey) {
|
|
436
|
+
throw new Error('graphRecall: supabaseUrl/supabaseKey not configured');
|
|
437
|
+
}
|
|
438
|
+
if (!this.openaiApiKey) {
|
|
439
|
+
throw new Error('graphRecall: OPENAI_API_KEY not configured');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const project = options.searchAll ? null : (options.project || null);
|
|
443
|
+
const depth = options.depth ?? this.graphRecallDepth;
|
|
444
|
+
const k = options.k ?? this.graphRecallK;
|
|
445
|
+
|
|
446
|
+
const embeddingRes = await fetch('https://api.openai.com/v1/embeddings', {
|
|
447
|
+
method: 'POST',
|
|
448
|
+
headers: {
|
|
449
|
+
'Authorization': `Bearer ${this.openaiApiKey}`,
|
|
450
|
+
'Content-Type': 'application/json'
|
|
451
|
+
},
|
|
452
|
+
body: JSON.stringify({
|
|
453
|
+
model: 'text-embedding-3-large',
|
|
454
|
+
input: query,
|
|
455
|
+
dimensions: 1536
|
|
456
|
+
})
|
|
457
|
+
});
|
|
458
|
+
if (!embeddingRes.ok) {
|
|
459
|
+
const err = await embeddingRes.text();
|
|
460
|
+
console.error('[rag:graph] embedding failed:', err);
|
|
461
|
+
throw new Error('graphRecall: embedding generation failed');
|
|
462
|
+
}
|
|
463
|
+
const embeddingData = await embeddingRes.json();
|
|
464
|
+
const embedding = embeddingData.data[0].embedding;
|
|
465
|
+
|
|
466
|
+
console.log(`[rag] using graph recall path project=${project ?? 'ALL'} depth=${depth} k=${k}`);
|
|
467
|
+
|
|
468
|
+
const rpcBody = {
|
|
469
|
+
query_embedding: `[${embedding.join(',')}]`,
|
|
470
|
+
project_filter: project,
|
|
471
|
+
max_depth: depth,
|
|
472
|
+
k
|
|
473
|
+
};
|
|
474
|
+
const rpcRes = await fetch(`${this.supabaseUrl}/rest/v1/rpc/memory_recall_graph`, {
|
|
475
|
+
method: 'POST',
|
|
476
|
+
headers: {
|
|
477
|
+
'Content-Type': 'application/json',
|
|
478
|
+
'apikey': this.supabaseKey,
|
|
479
|
+
'Authorization': `Bearer ${this.supabaseKey}`
|
|
480
|
+
},
|
|
481
|
+
body: JSON.stringify(rpcBody)
|
|
482
|
+
});
|
|
483
|
+
if (!rpcRes.ok) {
|
|
484
|
+
const err = await rpcRes.text();
|
|
485
|
+
console.error(`[rag:graph] RPC failed ${rpcRes.status}:`, err);
|
|
486
|
+
throw new Error(`graphRecall: memory_recall_graph RPC failed (${rpcRes.status})`);
|
|
487
|
+
}
|
|
488
|
+
const rows = await rpcRes.json();
|
|
489
|
+
return {
|
|
490
|
+
memories: rows.map((m) => ({
|
|
491
|
+
content: m.content,
|
|
492
|
+
// graph recall doesn't return source_type; preserve the bridge's
|
|
493
|
+
// shape by returning null so consumers don't crash on chip render.
|
|
494
|
+
source_type: m.source_type ?? null,
|
|
495
|
+
project: m.project,
|
|
496
|
+
// The bridge consumers read `similarity`; pass final_score so they
|
|
497
|
+
// see the combined (vector × edge × recency) signal as the badge.
|
|
498
|
+
// Also expose the underlying scores for callers that want to split
|
|
499
|
+
// them out (graph viz, debugging).
|
|
500
|
+
similarity: m.final_score,
|
|
501
|
+
depth: m.depth,
|
|
502
|
+
vector_score: m.vector_score,
|
|
503
|
+
edge_weight: m.edge_weight,
|
|
504
|
+
recency_score: m.recency_score,
|
|
505
|
+
path: m.path,
|
|
506
|
+
// memory_recall_graph doesn't return created_at — depth-N neighbors
|
|
507
|
+
// come from the graph walk, not a direct timestamp pull. Caller can
|
|
508
|
+
// re-fetch via memory_get if they need it.
|
|
509
|
+
created_at: m.created_at ?? null
|
|
510
|
+
})),
|
|
511
|
+
total: rows.length
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
377
515
|
// Live-toggle for the dashboard RAG settings panel (Sprint 36 T3 Deliverable A).
|
|
378
516
|
// Re-evaluates eligibility — flipping `enabled: true` without configured
|
|
379
517
|
// Supabase creds is a no-op so the live integration never claims to be on
|
|
@@ -14,6 +14,7 @@ const { v4: uuidv4 } = require('uuid');
|
|
|
14
14
|
const os = require('os');
|
|
15
15
|
const path = require('path');
|
|
16
16
|
const { resolveTheme } = require('./theme-resolver');
|
|
17
|
+
const flashbackDiag = require('./flashback-diag');
|
|
17
18
|
|
|
18
19
|
// Strip ANSI escape codes for pattern matching
|
|
19
20
|
function stripAnsi(str) {
|
|
@@ -43,6 +44,13 @@ const PATTERNS = {
|
|
|
43
44
|
django: /Starting development server/,
|
|
44
45
|
httpServer: /Serving HTTP on/,
|
|
45
46
|
request: /(?:^|\s|")(GET|POST|PUT|DELETE|PATCH)\s+\S+.*?\s(\d{3})/m,
|
|
47
|
+
// Sprint 40 T2: HTTP 5xx response in a web-server log line is a real
|
|
48
|
+
// error condition for the application. Used as a python-server-typed
|
|
49
|
+
// fallback in _detectErrors when the prose-shape analyzers miss because
|
|
50
|
+
// the line carries no `Error:` keyword — just `"GET /foo HTTP/1.1" 503`.
|
|
51
|
+
// 5xx only (not 4xx, which are typically client-caused). The leading
|
|
52
|
+
// `(?:^|\s|")` mirrors `request` so colon-quoted log shapes still match.
|
|
53
|
+
serverError: /(?:^|\s|")(?:GET|POST|PUT|DELETE|PATCH)\s+\S+.*?\sHTTP\/\d(?:\.\d)?"?\s+5\d{2}\b/m,
|
|
46
54
|
// Port detection — matches any of:
|
|
47
55
|
// • "port NNNN" phrase (capture group 1)
|
|
48
56
|
// • URL with http/https scheme, optionally prefixed with "on " or "at "
|
|
@@ -65,11 +73,20 @@ const PATTERNS = {
|
|
|
65
73
|
// tools (cat, ls, cd, rm, etc.) report filesystem misses in plain English
|
|
66
74
|
// without ever emitting the ENOENT errno code. Flagged as a gap by Rumen's
|
|
67
75
|
// first production kickstart insight on 2026-04-15.
|
|
68
|
-
|
|
76
|
+
// Sprint 40 T2: added uppercase `ERROR:` (mirrors `Error:` / `error:` for
|
|
77
|
+
// case-symmetry — closes the stripAnsi-ERROR test fixture from Sprint 33)
|
|
78
|
+
// and Node errno-style colon-prefix shapes (`ENOENT:`, `EACCES:`,
|
|
79
|
+
// `ECONNREFUSED:`) so `ENOENT: no such file or directory` shapes from
|
|
80
|
+
// child-process error reporting fire without depending on the line ALSO
|
|
81
|
+
// containing the `No such file or directory` prose phrase.
|
|
82
|
+
error: /(?:^|\n)\s*(?:Error:\s+\S|error:\s+\S|ERROR:\s+\S|Traceback \(most recent call last\):|npm ERR!|error\[E\d+\]:|Uncaught Exception|Fatal:|ENOENT:\s+\S|EACCES:\s+\S|ECONNREFUSED:\s+\S)/m,
|
|
69
83
|
// Stricter line-anchored variant for Claude Code, whose tool output (grep
|
|
70
84
|
// results, test logs, file contents) routinely mentions "Error" mid-line
|
|
71
85
|
// without representing an actual failure of the agent itself.
|
|
72
|
-
|
|
86
|
+
// Sprint 40 T2: added mixed-case `Fatal` (mirrors `fatal` / `FATAL`) and
|
|
87
|
+
// the `npm ERR!` shape (special-cased outside the alternation because
|
|
88
|
+
// `!` is not a word character so `\b` after `npm ERR!` doesn't match).
|
|
89
|
+
errorLineStart: /^\s*(?:(?:error|Error|ERROR|exception|Exception|Traceback|fatal|Fatal|FATAL|segmentation fault|panic|EACCES|ECONNREFUSED|ENOENT|command not found|undefined reference|cannot find module|failed with exit code|No such file or directory|Permission denied)\b|npm ERR!)/m,
|
|
73
90
|
// Sprint 33: PATTERNS.error misses the most common Unix shell errors —
|
|
74
91
|
// `cat: /foo: No such file or directory`, `bash: foo: command not found`,
|
|
75
92
|
// `rm: cannot remove ...: Permission denied`. These have a colon-prefix
|
|
@@ -77,7 +94,27 @@ const PATTERNS = {
|
|
|
77
94
|
// mentioning the same words. Each branch requires either the colon-prefix
|
|
78
95
|
// structure or a stand-alone anchored keyword. Validated against an
|
|
79
96
|
// adversarial prose suite (see tests/analyzer-error-fixtures.test.js).
|
|
80
|
-
|
|
97
|
+
//
|
|
98
|
+
// Sprint 39 T2: separated `command not found` from the other phrases. The
|
|
99
|
+
// unified branch was matching rcfile-noise lines emitted by version
|
|
100
|
+
// managers during shell startup — most notably:
|
|
101
|
+
// `pyenv: pyenv-virtualenv-init: command not found in path`
|
|
102
|
+
// …which has the colon-prefix-with-`command not found` shape but with a
|
|
103
|
+
// descriptive suffix (` in path`) rather than ending the line. The pyenv
|
|
104
|
+
// case confirms the strong rcfile-noise hypothesis for pyenv users: their
|
|
105
|
+
// shell startup burns the 30s onErrorDetected rate limit before the user
|
|
106
|
+
// can type their first command. The dedicated `command not found` branch
|
|
107
|
+
// below requires the keyword to be either:
|
|
108
|
+
// • followed by `:` (the zsh `command not found: <cmd>` form), or
|
|
109
|
+
// • at end-of-line (the bash `<sh>: <cmd>: command not found` form).
|
|
110
|
+
// Suffixes like ` in path`, ` in $PATH`, ` (compinit)` are silenced as
|
|
111
|
+
// rcfile noise.
|
|
112
|
+
// Trade-off: custom command_not_found_handler output that adds a comma-
|
|
113
|
+
// separated "did you mean X" suggestion is silenced — those are cosmetic
|
|
114
|
+
// suggestions, not the error itself, which the user already saw fire.
|
|
115
|
+
// See tests/rcfile-noise.test.js and tests/analyzer-error-fixtures.test.js
|
|
116
|
+
// for the locked corpus.
|
|
117
|
+
shellError: /(?:^|\n)(?:[^\n]*:\s+(?:.*?:\s+)?(?:No such file or directory|Permission denied|Is a directory|Not a directory)\b|[^\n]*:\s+(?:.*?:\s+)?command not found(?::|\s*(?:[\r\n]|$))|[^\n]*?\(\d+\)\s+Could not resolve host\b|\s*ModuleNotFoundError:\s+\S|\s*Segmentation fault\b|\s*fatal:\s+\S)/m
|
|
81
118
|
};
|
|
82
119
|
|
|
83
120
|
class Session {
|
|
@@ -350,14 +387,44 @@ class Session {
|
|
|
350
387
|
// Claude Code's tool output frequently contains "error"/"Error" mid-line
|
|
351
388
|
// (grep matches, test results, log dumps). Use a line-anchored pattern
|
|
352
389
|
// for that session type so we don't flag content as failure.
|
|
353
|
-
const
|
|
390
|
+
const primaryPattern = this.meta.type === 'claude-code'
|
|
354
391
|
? PATTERNS.errorLineStart
|
|
355
392
|
: PATTERNS.error;
|
|
393
|
+
const primaryName = this.meta.type === 'claude-code' ? 'errorLineStart' : 'error';
|
|
356
394
|
// Sprint 33 fix: the structured patterns above miss `cat: /foo: No such
|
|
357
395
|
// file or directory` and friends — the most common Unix shell error
|
|
358
396
|
// shapes Josh hits day-to-day. Fall through to PATTERNS.shellError so
|
|
359
397
|
// the analyzer flips status='errored' and Flashback can fire.
|
|
360
|
-
|
|
398
|
+
const primaryMatch = clean.match(primaryPattern);
|
|
399
|
+
const shellMatch = !primaryMatch ? clean.match(PATTERNS.shellError) : null;
|
|
400
|
+
// Sprint 40 T2: HTTP 5xx fallback for python-server sessions. The prose
|
|
401
|
+
// analyzers miss `"GET /foo HTTP/1.1" 503 -` because it carries no
|
|
402
|
+
// `Error:` keyword — but the response IS the error signal for an
|
|
403
|
+
// HTTP-server session. Gated on session type to avoid flagging 5xx
|
|
404
|
+
// status codes that legitimately appear in unrelated content (e.g. a
|
|
405
|
+
// shell that just printed a copy of an HTTP log).
|
|
406
|
+
const serverMatch = (!primaryMatch && !shellMatch && this.meta.type === 'python-server')
|
|
407
|
+
? clean.match(PATTERNS.pythonServer.serverError)
|
|
408
|
+
: null;
|
|
409
|
+
if (!primaryMatch && !shellMatch && !serverMatch) return;
|
|
410
|
+
|
|
411
|
+
// Sprint 39 T1 — pattern_match diag event. Emitted on every PATTERNS hit,
|
|
412
|
+
// including ones that get rate-limited downstream. T2 reads these to
|
|
413
|
+
// measure the rcfile-noise false-positive rate against real shell output.
|
|
414
|
+
const matchedSrc = primaryMatch || shellMatch || serverMatch;
|
|
415
|
+
const matchedLine = (matchedSrc && typeof matchedSrc[0] === 'string')
|
|
416
|
+
? matchedSrc[0].replace(/^\n+/, '').slice(0, 200)
|
|
417
|
+
: '';
|
|
418
|
+
const matchedPattern = primaryMatch
|
|
419
|
+
? primaryName
|
|
420
|
+
: (shellMatch ? 'shellError' : 'serverError');
|
|
421
|
+
flashbackDiag.log({
|
|
422
|
+
sessionId: this.id,
|
|
423
|
+
event: 'pattern_match',
|
|
424
|
+
pattern: matchedPattern,
|
|
425
|
+
matched_line: matchedLine,
|
|
426
|
+
output_chunk_size: clean.length,
|
|
427
|
+
});
|
|
361
428
|
|
|
362
429
|
const oldStatus = this.meta.status;
|
|
363
430
|
this.meta.status = 'errored';
|
|
@@ -371,7 +438,30 @@ class Session {
|
|
|
371
438
|
|
|
372
439
|
// Server-side rate limit: at most one error_detected event every 30s per session
|
|
373
440
|
const now = Date.now();
|
|
441
|
+
const remainingMs = this._lastErrorFireAt
|
|
442
|
+
? Math.max(0, 30000 - (now - this._lastErrorFireAt))
|
|
443
|
+
: 0;
|
|
444
|
+
|
|
445
|
+
// Sprint 39 T1 — error_detected diag event, before the rate-limit gate.
|
|
446
|
+
// The (error_detected count − rate_limit_blocked count) is the number of
|
|
447
|
+
// errors that actually got dispatched to onErrorDetected. T2/T3 use this
|
|
448
|
+
// to spot rcfile noise burning the rate-limit window before real errors.
|
|
449
|
+
flashbackDiag.log({
|
|
450
|
+
sessionId: this.id,
|
|
451
|
+
event: 'error_detected',
|
|
452
|
+
error_text: matchedLine,
|
|
453
|
+
rate_limit_remaining_ms: remainingMs,
|
|
454
|
+
last_emit_at: this._lastErrorFireAt
|
|
455
|
+
? new Date(this._lastErrorFireAt).toISOString()
|
|
456
|
+
: null,
|
|
457
|
+
});
|
|
458
|
+
|
|
374
459
|
if (now - this._lastErrorFireAt < 30000) {
|
|
460
|
+
flashbackDiag.log({
|
|
461
|
+
sessionId: this.id,
|
|
462
|
+
event: 'rate_limit_blocked',
|
|
463
|
+
rate_limit_remaining_ms: remainingMs,
|
|
464
|
+
});
|
|
375
465
|
console.log(`[flashback] error detected in session ${this.id} but rate-limited (${Math.round((30000 - (now - this._lastErrorFireAt)) / 1000)}s left)`);
|
|
376
466
|
return;
|
|
377
467
|
}
|