@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.
@@ -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 count = (result.memories || []).length;
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 = (result.memories || [])[0];
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(JSON.stringify({ type: 'proactive_memory', hit }));
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
- switch (mode) {
250
- case 'webhook':
251
- return queryWebhook({ question, project: effectiveProject, searchAll });
252
- case 'mcp':
253
- return queryMcp({ question, project: effectiveProject, searchAll });
254
- case 'direct':
255
- default:
256
- return queryDirect({ question, project: effectiveProject, searchAll });
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
- error: /(?:^|\n)\s*(?:Error:\s+\S|error:\s+\S|Traceback \(most recent call last\):|npm ERR!|error\[E\d+\]:|Uncaught Exception|Fatal:)/m,
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
- errorLineStart: /^\s*(error|Error|ERROR|exception|Exception|Traceback|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/m,
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
- shellError: /(?:^|\n)(?:[^\n]*:\s+(?:.*?:\s+)?(?:No such file or directory|Permission denied|Is a directory|Not a directory|command not found)\b|[^\n]*?\(\d+\)\s+Could not resolve host\b|\s*ModuleNotFoundError:\s+\S|\s*Segmentation fault\b|\s*fatal:\s+\S)/m
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 pattern = this.meta.type === 'claude-code'
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
- if (!pattern.test(clean) && !PATTERNS.shellError.test(clean)) return;
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
  }