@jhizzard/termdeck 0.3.7 → 0.3.9

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/README.md CHANGED
@@ -161,7 +161,7 @@ Honest limits, stated upfront so the skeptic has nothing to chase:
161
161
  - **Not a replacement for reading docs.** It's the shortest path to a memory you already wrote. If the memory isn't there, the feature does nothing.
162
162
  - **Not fully local by default.** Tier 2+ reaches out to Supabase for storage and OpenAI for embeddings. Tier 1 is fully local. A fully-local Tier 2 (local Postgres + local embeddings) is on the roadmap.
163
163
  - **Not free forever.** Tier 2+ pays OpenAI fractions of a cent per memory for embeddings. Self-hosted embeddings via Ollama are on the roadmap.
164
- - **Not proven at scale.** v0.3.7, validated against 3,527 memories in one developer's production store. First full Rumen kickstart on 2026-04-15 processed 111 sessions into 111 insights in one pass. No multi-user data yet. Bug reports and issues welcome.
164
+ - **Not proven at scale.** v0.3.9, validated against 3,527 memories in one developer's production store. First full Rumen kickstart on 2026-04-15 processed 111 sessions into 111 insights in one pass. No multi-user data yet. Bug reports and issues welcome.
165
165
 
166
166
  ---
167
167
 
@@ -7,6 +7,14 @@ shell: /bin/zsh # or /bin/bash
7
7
 
8
8
  defaultTheme: tokyo-night
9
9
 
10
+ # Mnestra (pgvector memory store) integration
11
+ # Controls whether scripts/start.sh launches `mnestra serve` automatically.
12
+ # autoStart: true — start mnestra serve automatically when TermDeck boots
13
+ # autoStart: false — never auto-start (leave Mnestra management to the user)
14
+ # (unset) — start.sh prints a hint but does not launch
15
+ mnestra:
16
+ autoStart: true
17
+
10
18
  # Project definitions
11
19
  # Each project maps a name to a directory + defaults
12
20
  # These appear in the prompt bar dropdown and enable auto-cd + default themes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
5
5
  "bin": {
6
6
  "termdeck": "./packages/cli/src/index.js"
@@ -34,7 +34,7 @@
34
34
  "express": "^4.18.2",
35
35
  "open": "^10.0.0",
36
36
  "pg": "^8.20.0",
37
- "uuid": "^9.0.0",
37
+ "uuid": "^13.0.0",
38
38
  "ws": "^8.16.0",
39
39
  "yaml": "^2.3.4"
40
40
  },
@@ -119,9 +119,17 @@ if (!LOOPBACK.has(host)) {
119
119
  }
120
120
 
121
121
  server.listen(port, host, async () => {
122
+ // Box inner width is 38 (count of ═ between ╔ and ╗). Center the title
123
+ // dynamically so the right border stays aligned regardless of version length.
124
+ const innerWidth = 38;
125
+ const version = require(path.join(__dirname, '..', '..', '..', 'package.json')).version;
126
+ const title = `TermDeck v${version}`;
127
+ const leftPad = Math.max(0, Math.floor((innerWidth - title.length) / 2));
128
+ const titleLine = ' '.repeat(leftPad) + title + ' '.repeat(Math.max(0, innerWidth - leftPad - title.length));
129
+
122
130
  console.log(`
123
131
  ╔══════════════════════════════════════╗
124
- ║ TermDeck v${require(path.join(__dirname, '..', '..', '..', 'package.json')).version.padEnd(14)}║
132
+ ║${titleLine}║
125
133
  ╠══════════════════════════════════════╣
126
134
  ║ ${url.padEnd(34)} ║
127
135
  ║ ║
@@ -122,6 +122,7 @@
122
122
  <span class="panel-type">${getTypeLabel(meta.type)}</span>
123
123
  ${meta.project ? `<span class="panel-project ${projClass}">${meta.project}</span>` : ''}
124
124
  <span class="panel-index" id="idx-${id}"></span>
125
+ <span class="panel-sid" title="Session ID: ${id}">${id.slice(0, 8)}</span>
125
126
  <span class="panel-status" id="status-${id}">${meta.statusDetail || meta.status}</span>
126
127
  </div>
127
128
  <div class="panel-header-right">
@@ -517,17 +518,137 @@
517
518
  });
518
519
  toast.addEventListener('click', () => {
519
520
  dismiss();
520
- focusSessionById(id);
521
- // Open the Memory tab so the user lands directly on the hit list
522
- const entry2 = state.sessions.get(id);
523
- if (entry2 && (!entry2.drawerOpen || entry2.activeTab !== 'memory')) {
524
- toggleDrawerTab(id, 'memory');
525
- }
521
+ showFlashbackModal(hit, id);
526
522
  });
527
523
 
528
524
  toast._autoTimer = setTimeout(dismiss, 30000);
529
525
  }
530
526
 
527
+ // ===== Flashback modal (Sprint 16 T2) =====
528
+ let _flashbackModalEl = null;
529
+ let _flashbackKeyHandler = null;
530
+ let _flashbackPrevFocus = null;
531
+
532
+ function closeFlashbackModal() {
533
+ if (!_flashbackModalEl) return;
534
+ _flashbackModalEl.remove();
535
+ _flashbackModalEl = null;
536
+ if (_flashbackKeyHandler) {
537
+ document.removeEventListener('keydown', _flashbackKeyHandler);
538
+ _flashbackKeyHandler = null;
539
+ }
540
+ if (_flashbackPrevFocus && typeof _flashbackPrevFocus.focus === 'function') {
541
+ try { _flashbackPrevFocus.focus(); } catch {}
542
+ }
543
+ _flashbackPrevFocus = null;
544
+ }
545
+
546
+ function logFlashbackFeedback(hit, sessionId, verdict) {
547
+ // Fire-and-forget; no dedicated endpoint yet.
548
+ const payload = {
549
+ verdict,
550
+ sessionId: sessionId || null,
551
+ project: hit?.project || null,
552
+ sourceType: hit?.source_type || hit?.sourceType || null,
553
+ similarity: typeof hit?.similarity === 'number' ? hit.similarity : null,
554
+ contentPreview: (hit?.content || hit?.text || '').slice(0, 160),
555
+ at: new Date().toISOString(),
556
+ };
557
+ console.log('[flashback] feedback', payload);
558
+ }
559
+
560
+ function showFlashbackModal(hit, sessionId) {
561
+ // Replace any existing modal (new toast wins).
562
+ if (_flashbackModalEl) closeFlashbackModal();
563
+
564
+ _flashbackPrevFocus = document.activeElement;
565
+
566
+ const content = (hit?.content || hit?.text || '').trim();
567
+ const project = hit?.project || '';
568
+ const sourceType = hit?.source_type || hit?.sourceType || '';
569
+ const createdAt = hit?.created_at || hit?.createdAt || '';
570
+ const scoreNum = typeof hit?.similarity === 'number' ? hit.similarity : null;
571
+ const scorePct = scoreNum !== null ? `${(scoreNum * 100).toFixed(0)}%` : '';
572
+
573
+ const overlay = document.createElement('div');
574
+ overlay.className = 'flashback-modal open';
575
+ overlay.setAttribute('role', 'dialog');
576
+ overlay.setAttribute('aria-modal', 'true');
577
+ overlay.setAttribute('aria-labelledby', 'flashbackTitle');
578
+
579
+ const projectChip = project
580
+ ? `<span class="fb-chip fb-chip-project">${escapeHtml(project)}</span>`
581
+ : '';
582
+ const scoreChip = scorePct
583
+ ? `<span class="fb-chip fb-chip-score">${escapeHtml(scorePct)}</span>`
584
+ : '';
585
+ const sourceLine = sourceType
586
+ ? `<span class="fb-meta-item"><span class="fb-meta-label">source</span> ${escapeHtml(sourceType)}</span>`
587
+ : '';
588
+ const timeLine = createdAt
589
+ ? `<span class="fb-meta-item"><span class="fb-meta-label">when</span> ${escapeHtml(timeAgo(createdAt))}</span>`
590
+ : '';
591
+ const projectLine = project
592
+ ? `<span class="fb-meta-item"><span class="fb-meta-label">project</span> ${escapeHtml(project)}</span>`
593
+ : '';
594
+
595
+ overlay.innerHTML = `
596
+ <div class="fb-backdrop"></div>
597
+ <div class="fb-card" tabindex="-1">
598
+ <header>
599
+ <h3 id="flashbackTitle">
600
+ <span class="fb-title-text">Flashback — similar issue found</span>
601
+ <span class="fb-title-chips">${projectChip}${scoreChip}</span>
602
+ </h3>
603
+ <button class="fb-x" type="button" aria-label="Close">×</button>
604
+ </header>
605
+ <div class="fb-body">
606
+ <pre class="fb-content">${escapeHtml(content || '(empty memory)')}</pre>
607
+ <div class="fb-meta">
608
+ ${projectLine}
609
+ ${sourceLine}
610
+ ${timeLine}
611
+ </div>
612
+ </div>
613
+ <footer>
614
+ <div class="fb-feedback">
615
+ <button class="fb-btn fb-helped" type="button">This helped</button>
616
+ <button class="fb-btn fb-not-relevant" type="button">Not relevant</button>
617
+ </div>
618
+ <button class="fb-btn fb-dismiss" type="button">Dismiss</button>
619
+ </footer>
620
+ </div>
621
+ `;
622
+
623
+ document.body.appendChild(overlay);
624
+ _flashbackModalEl = overlay;
625
+
626
+ overlay.querySelector('.fb-backdrop').addEventListener('click', closeFlashbackModal);
627
+ overlay.querySelector('.fb-x').addEventListener('click', closeFlashbackModal);
628
+ overlay.querySelector('.fb-dismiss').addEventListener('click', closeFlashbackModal);
629
+ overlay.querySelector('.fb-helped').addEventListener('click', () => {
630
+ logFlashbackFeedback(hit, sessionId, 'helped');
631
+ closeFlashbackModal();
632
+ });
633
+ overlay.querySelector('.fb-not-relevant').addEventListener('click', () => {
634
+ logFlashbackFeedback(hit, sessionId, 'not_relevant');
635
+ closeFlashbackModal();
636
+ });
637
+
638
+ _flashbackKeyHandler = (e) => {
639
+ if (e.key === 'Escape') {
640
+ e.preventDefault();
641
+ closeFlashbackModal();
642
+ }
643
+ };
644
+ document.addEventListener('keydown', _flashbackKeyHandler);
645
+
646
+ setTimeout(() => {
647
+ const card = overlay.querySelector('.fb-card');
648
+ if (card) card.focus();
649
+ }, 30);
650
+ }
651
+
531
652
  // ===== Reply / send-to-terminal (T1.3) =====
532
653
  // Flip this to false to force the local-WS fallback even when the server
533
654
  // endpoint is available — handy for debugging.
@@ -1912,7 +2033,7 @@
1912
2033
  {
1913
2034
  target: '.topbar-center',
1914
2035
  title: 'Layout modes',
1915
- body: `Seven preset grid layouts — <kbd>1x1</kbd> through <kbd>4x2</kbd> plus <strong>control</strong> (aggregate activity feed). Click any layout to switch instantly; all terminals re-fit to the new grid. Keyboard shortcuts <kbd>Cmd+Shift+1</kbd>–<kbd>Cmd+Shift+6</kbd> (or <kbd>Ctrl+Shift+1</kbd>–<kbd>6</kbd>) do the same.`,
2036
+ body: `Eight preset grid layouts — <kbd>1x1</kbd> through <kbd>4x2</kbd>, <strong>orch</strong> (1 large + stacked, for 4+1 sprints), plus <strong>control</strong> (aggregate activity feed). Click any layout to switch instantly; all terminals re-fit to the new grid. Keyboard shortcuts <kbd>Cmd+Shift+1</kbd>–<kbd>Cmd+Shift+7</kbd> (or <kbd>Ctrl+Shift+1</kbd>–<kbd>7</kbd>) do the same.`,
1916
2037
  },
1917
2038
  {
1918
2039
  target: '#termSwitcher',
@@ -2388,10 +2509,10 @@
2388
2509
  document.getElementById('promptInput').focus();
2389
2510
  }
2390
2511
  }
2391
- // Ctrl+Shift+1-6 OR Cmd+Shift+1-6 → layout switch (Mac friendly)
2392
- if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key >= '1' && e.key <= '6') {
2512
+ // Ctrl+Shift+1-7 OR Cmd+Shift+1-7 → layout switch (Mac friendly)
2513
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key >= '1' && e.key <= '7') {
2393
2514
  e.preventDefault();
2394
- const layouts = ['1x1', '2x1', '2x2', '3x2', '2x4', '4x2'];
2515
+ const layouts = ['1x1', '2x1', '2x2', '3x2', '2x4', '4x2', 'orch'];
2395
2516
  setLayout(layouts[parseInt(e.key) - 1]);
2396
2517
  }
2397
2518
  // Ctrl+Shift+] / [ → cycle between terminals
@@ -41,6 +41,7 @@
41
41
  <button class="layout-btn" data-layout="3x2">3x2</button>
42
42
  <button class="layout-btn" data-layout="2x4">2x4</button>
43
43
  <button class="layout-btn" data-layout="4x2">4x2</button>
44
+ <button class="layout-btn" data-layout="orch" title="Orchestrator: 1 large left + stacked right">orch</button>
44
45
  <button class="layout-btn control-btn" data-layout="control" title="Aggregate activity feed">control</button>
45
46
  </div>
46
47
  </div>
@@ -312,6 +312,16 @@
312
312
  .grid-container.layout-4x2 { grid-template-columns: 1fr 1fr 1fr 1fr; grid-template-rows: 1fr 1fr; }
313
313
  .grid-container.layout-2x4 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr 1fr 1fr; }
314
314
 
315
+ /* Orchestrator: 1 large left panel (60%), remaining stack on the right (40%). */
316
+ .grid-container.layout-orch {
317
+ grid-template-columns: 3fr 2fr;
318
+ grid-auto-rows: 1fr;
319
+ }
320
+ .grid-container.layout-orch .term-panel:first-child {
321
+ grid-row: 1 / -1;
322
+ grid-column: 1;
323
+ }
324
+
315
325
  /* Focus mode: single terminal fills the grid */
316
326
  .grid-container.layout-focus { grid-template-columns: 1fr; grid-template-rows: 1fr; }
317
327
  .grid-container.layout-focus .term-panel:not(.focused) { display: none; }
@@ -393,6 +403,19 @@
393
403
  }
394
404
  .panel-index:empty { display: none; }
395
405
 
406
+ /* Short session ID (first 8 chars) — orchestrator-friendly: lets the
407
+ overseer reference a terminal without hitting the API. */
408
+ .panel-sid {
409
+ font-family: 'SF Mono', 'JetBrains Mono', Consolas, monospace;
410
+ font-size: 10px;
411
+ color: var(--tg-text-dim);
412
+ background: var(--tg-surface-hover);
413
+ padding: 1px 5px;
414
+ border-radius: 3px;
415
+ letter-spacing: 0.3px;
416
+ white-space: nowrap;
417
+ }
418
+
396
419
  .panel-project {
397
420
  font-size: 10px;
398
421
  padding: 1px 7px;
@@ -1350,6 +1373,164 @@
1350
1373
  to { opacity: 1; transform: translateY(0); }
1351
1374
  }
1352
1375
 
1376
+ /* ===== Flashback modal (Sprint 16 T2) ===== */
1377
+ .flashback-modal {
1378
+ display: none;
1379
+ position: fixed;
1380
+ inset: 0;
1381
+ z-index: 3200;
1382
+ align-items: center;
1383
+ justify-content: center;
1384
+ }
1385
+ .flashback-modal.open { display: flex; }
1386
+ .fb-backdrop {
1387
+ position: absolute;
1388
+ inset: 0;
1389
+ background: rgba(0, 0, 0, 0.72);
1390
+ }
1391
+ .fb-card {
1392
+ position: relative;
1393
+ background: var(--tg-surface);
1394
+ border: 1px solid var(--tg-accent-dim);
1395
+ border-left: 3px solid var(--tg-purple);
1396
+ border-radius: 10px;
1397
+ width: 600px;
1398
+ max-width: calc(100vw - 40px);
1399
+ max-height: calc(100vh - 80px);
1400
+ display: flex;
1401
+ flex-direction: column;
1402
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
1403
+ font-family: var(--tg-sans);
1404
+ color: var(--tg-text);
1405
+ outline: none;
1406
+ animation: fb-in 0.14s ease;
1407
+ }
1408
+ @keyframes fb-in {
1409
+ from { opacity: 0; transform: translateY(4px); }
1410
+ to { opacity: 1; transform: translateY(0); }
1411
+ }
1412
+ .fb-card header {
1413
+ display: flex;
1414
+ align-items: flex-start;
1415
+ gap: 10px;
1416
+ padding: 14px 18px 10px;
1417
+ border-bottom: 1px solid var(--tg-border);
1418
+ }
1419
+ .fb-card header h3 {
1420
+ flex: 1;
1421
+ margin: 0;
1422
+ font-size: 13px;
1423
+ font-weight: 600;
1424
+ color: var(--tg-purple);
1425
+ text-transform: uppercase;
1426
+ letter-spacing: 0.5px;
1427
+ display: flex;
1428
+ flex-direction: column;
1429
+ gap: 6px;
1430
+ }
1431
+ .fb-card header .fb-title-chips {
1432
+ display: flex;
1433
+ gap: 6px;
1434
+ flex-wrap: wrap;
1435
+ }
1436
+ .fb-chip {
1437
+ display: inline-block;
1438
+ padding: 2px 8px;
1439
+ border-radius: 8px;
1440
+ font-size: 10px;
1441
+ font-family: var(--tg-mono);
1442
+ text-transform: none;
1443
+ letter-spacing: 0;
1444
+ background: var(--tg-bg);
1445
+ border: 1px solid var(--tg-border);
1446
+ color: var(--tg-text-dim);
1447
+ }
1448
+ .fb-chip-project {
1449
+ color: var(--tg-accent);
1450
+ border-color: var(--tg-accent-dim);
1451
+ }
1452
+ .fb-chip-score {
1453
+ color: var(--tg-purple);
1454
+ border-color: var(--tg-purple);
1455
+ }
1456
+ .fb-x {
1457
+ background: none;
1458
+ border: none;
1459
+ color: var(--tg-text-dim);
1460
+ cursor: pointer;
1461
+ font-size: 20px;
1462
+ line-height: 1;
1463
+ padding: 0 4px;
1464
+ margin-top: -2px;
1465
+ }
1466
+ .fb-x:hover { color: var(--tg-text); }
1467
+ .fb-body {
1468
+ flex: 1;
1469
+ overflow-y: auto;
1470
+ padding: 14px 18px;
1471
+ }
1472
+ .fb-content {
1473
+ margin: 0 0 12px;
1474
+ padding: 12px 14px;
1475
+ background: var(--tg-bg);
1476
+ border: 1px solid var(--tg-border);
1477
+ border-radius: 6px;
1478
+ font-family: var(--tg-mono);
1479
+ font-size: 12px;
1480
+ line-height: 1.5;
1481
+ color: var(--tg-text);
1482
+ white-space: pre-wrap;
1483
+ word-break: break-word;
1484
+ max-height: 320px;
1485
+ overflow-y: auto;
1486
+ }
1487
+ .fb-meta {
1488
+ display: flex;
1489
+ flex-wrap: wrap;
1490
+ gap: 10px 16px;
1491
+ font-family: var(--tg-mono);
1492
+ font-size: 10px;
1493
+ color: var(--tg-text-dim);
1494
+ }
1495
+ .fb-meta-item { display: inline-flex; gap: 5px; align-items: center; }
1496
+ .fb-meta-label {
1497
+ text-transform: uppercase;
1498
+ letter-spacing: 0.4px;
1499
+ opacity: 0.7;
1500
+ }
1501
+ .fb-card footer {
1502
+ padding: 10px 18px 14px;
1503
+ border-top: 1px solid var(--tg-border);
1504
+ display: flex;
1505
+ justify-content: space-between;
1506
+ gap: 10px;
1507
+ flex-wrap: wrap;
1508
+ }
1509
+ .fb-feedback { display: flex; gap: 8px; flex-wrap: wrap; }
1510
+ .fb-btn {
1511
+ background: none;
1512
+ border: 1px solid var(--tg-border);
1513
+ color: var(--tg-text-dim);
1514
+ font-family: var(--tg-mono);
1515
+ font-size: 11px;
1516
+ padding: 5px 14px;
1517
+ border-radius: 3px;
1518
+ cursor: pointer;
1519
+ transition: color 0.12s, border-color 0.12s, background 0.12s;
1520
+ }
1521
+ .fb-btn:hover {
1522
+ color: var(--tg-text);
1523
+ border-color: var(--tg-border-active);
1524
+ }
1525
+ .fb-helped:hover {
1526
+ color: var(--tg-green);
1527
+ border-color: var(--tg-green);
1528
+ }
1529
+ .fb-not-relevant:hover {
1530
+ color: var(--tg-red, #ff6b6b);
1531
+ border-color: var(--tg-red, #ff6b6b);
1532
+ }
1533
+
1353
1534
  /* ===== Control dashboard (T1.6) ===== */
1354
1535
  .control-feed {
1355
1536
  display: none;
@@ -585,11 +585,16 @@ function createServer(config) {
585
585
  const unseen = typeof req.query.unseen === 'string' &&
586
586
  /^(1|true|yes)$/i.test(req.query.unseen);
587
587
 
588
+ let minConfidence = parseFloat(req.query.minConfidence);
589
+ if (!Number.isFinite(minConfidence)) minConfidence = 0.15;
590
+ minConfidence = Math.max(0, Math.min(1, minConfidence));
591
+
588
592
  const where = [];
589
593
  const params = [];
590
594
  if (project) { params.push(project); where.push(`$${params.length} = ANY(projects)`); }
591
595
  if (since) { params.push(since); where.push(`created_at >= $${params.length}`); }
592
596
  if (unseen) { where.push(`acted_upon = FALSE`); }
597
+ params.push(minConfidence); where.push(`confidence >= $${params.length}`);
593
598
  const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
594
599
 
595
600
  try {
@@ -9,6 +9,7 @@
9
9
  // Errors are thrown as plain Error objects; the caller maps them to HTTP responses.
10
10
 
11
11
  const { spawn } = require('child_process');
12
+ const { resolveProjectName } = require('../rag');
12
13
 
13
14
  function createBridge(config) {
14
15
  const mode = config.rag?.mnestraMode || 'direct';
@@ -214,15 +215,27 @@ function createBridge(config) {
214
215
  }
215
216
  }
216
217
 
217
- async function queryMnestra({ question, project, searchAll }) {
218
+ async function queryMnestra({ question, project, searchAll, sessionContext, cwd }) {
219
+ // Flashback callers pass the session's project (from config.yaml). If that
220
+ // slot is empty — e.g. a session created without an explicit project — fall
221
+ // back to resolving the session's cwd against config.projects so queries
222
+ // don't leak into unrelated repos via basename collisions.
223
+ let effectiveProject = project;
224
+ if (!effectiveProject) {
225
+ const ctxCwd = cwd || (sessionContext && sessionContext.cwd);
226
+ if (ctxCwd) {
227
+ effectiveProject = resolveProjectName(ctxCwd, config);
228
+ }
229
+ }
230
+
218
231
  switch (mode) {
219
232
  case 'webhook':
220
- return queryWebhook({ question, project, searchAll });
233
+ return queryWebhook({ question, project: effectiveProject, searchAll });
221
234
  case 'mcp':
222
- return queryMcp({ question, project, searchAll });
235
+ return queryMcp({ question, project: effectiveProject, searchAll });
223
236
  case 'direct':
224
237
  default:
225
- return queryDirect({ question, project, searchAll });
238
+ return queryDirect({ question, project: effectiveProject, searchAll });
226
239
  }
227
240
  }
228
241
 
@@ -2,8 +2,44 @@
2
2
  // Layers: session → project → developer (cross-project)
3
3
  // Syncs to Supabase tables with configurable namespaces
4
4
 
5
+ const path = require('path');
6
+ const os = require('os');
5
7
  const { logRagEvent, getUnsyncedRagEvents, markRagEventsSynced } = require('./database');
6
8
 
9
+ // Resolve a working directory to a canonical project name defined in
10
+ // ~/.termdeck/config.yaml. Sessions without an explicit `project` field
11
+ // otherwise end up tagged with raw directory segments (e.g. "chopin-nashville"
12
+ // from ~/Documents/Graciella/ChopinNashville/...), which pollutes Mnestra
13
+ // memory tagging across unrelated repos that share an ancestor folder.
14
+ //
15
+ // Strategy: walk config.projects and pick the entry whose resolved path is the
16
+ // longest prefix of cwd (supports subdirectories of a registered project).
17
+ // Fallback is the directory basename, which is still better than an arbitrary
18
+ // mid-path segment.
19
+ function resolveProjectName(cwd, config) {
20
+ if (!cwd) return null;
21
+
22
+ const cwdResolved = path.resolve(String(cwd).replace(/^~/, os.homedir()));
23
+ const projects = (config && config.projects) || {};
24
+
25
+ const entries = Object.entries(projects)
26
+ .map(([name, def]) => {
27
+ const rawPath = def && def.path;
28
+ if (!rawPath || typeof rawPath !== 'string') return null;
29
+ const resolved = path.resolve(rawPath.replace(/^~/, os.homedir()));
30
+ return { name, resolved };
31
+ })
32
+ .filter(Boolean)
33
+ .sort((a, b) => b.resolved.length - a.resolved.length);
34
+
35
+ for (const { name, resolved } of entries) {
36
+ if (cwdResolved === resolved) return name;
37
+ if (cwdResolved.startsWith(resolved + path.sep)) return name;
38
+ }
39
+
40
+ return path.basename(cwdResolved) || null;
41
+ }
42
+
7
43
  class RAGIntegration {
8
44
  constructor(config, db) {
9
45
  this.config = config;
@@ -55,6 +91,13 @@ class RAGIntegration {
55
91
  }
56
92
  }
57
93
 
94
+ // Canonical project tag for a session. Prefers the explicit config.yaml name
95
+ // (set at session creation), falls back to cwd → config.projects resolution.
96
+ _projectFor(session) {
97
+ if (session.meta.project) return session.meta.project;
98
+ return resolveProjectName(session.meta.cwd, this.config);
99
+ }
100
+
58
101
  // Event types to record
59
102
  onSessionCreated(session) {
60
103
  this.record(session.id, 'session_created', {
@@ -62,7 +105,7 @@ class RAGIntegration {
62
105
  command: session.meta.command,
63
106
  cwd: session.meta.cwd,
64
107
  reason: session.meta.reason
65
- }, session.meta.project);
108
+ }, this._projectFor(session));
66
109
  }
67
110
 
68
111
  onCommandExecuted(session, command, outputSnippet) {
@@ -70,7 +113,7 @@ class RAGIntegration {
70
113
  command,
71
114
  output_snippet: outputSnippet?.slice(0, 500), // Truncate for storage
72
115
  type: session.meta.type
73
- }, session.meta.project);
116
+ }, this._projectFor(session));
74
117
  }
75
118
 
76
119
  onStatusChanged(session, oldStatus, newStatus) {
@@ -79,7 +122,7 @@ class RAGIntegration {
79
122
  to: newStatus,
80
123
  detail: session.meta.statusDetail,
81
124
  type: session.meta.type
82
- }, session.meta.project);
125
+ }, this._projectFor(session));
83
126
  }
84
127
 
85
128
  onSessionEnded(session) {
@@ -88,7 +131,7 @@ class RAGIntegration {
88
131
  duration_ms: Date.now() - new Date(session.meta.createdAt).getTime(),
89
132
  command_count: session.meta.lastCommands.length,
90
133
  exit_code: session.meta.exitCode
91
- }, session.meta.project);
134
+ }, this._projectFor(session));
92
135
  }
93
136
 
94
137
  onFileEdited(session, filepath, editType) {
@@ -96,7 +139,7 @@ class RAGIntegration {
96
139
  filepath,
97
140
  edit_type: editType,
98
141
  type: session.meta.type
99
- }, session.meta.project);
142
+ }, this._projectFor(session));
100
143
  }
101
144
 
102
145
  // Circuit breaker check — returns true if pushes to this table are disabled
@@ -115,7 +158,7 @@ class RAGIntegration {
115
158
  state.count += 1;
116
159
  if (state.count >= 3 && !state.open) {
117
160
  state.open = true;
118
- console.error(`[rag] circuit breaker open for ${table} — 3 consecutive 404s, disabling pushes until server restart`);
161
+ console.warn(`[rag] circuit breaker open for ${table} — disabling pushes (table may not exist in Supabase)`);
119
162
  }
120
163
  }
121
164
 
@@ -165,7 +208,10 @@ class RAGIntegration {
165
208
  // Success — reset any accumulated 404 count for this table
166
209
  this._resetCircuit(table);
167
210
  } catch (err) {
168
- console.error('[mnestra] Push failed:', err.message);
211
+ // Log at warn (not error) to reduce noise — the circuit breaker handles persistence
212
+ if (!this._isCircuitOpen(table)) {
213
+ console.warn('[rag] push to', table, 'failed:', err.message);
214
+ }
169
215
  throw err; // Propagate to caller so sync loop knows this event failed
170
216
  }
171
217
  }
@@ -200,7 +246,8 @@ class RAGIntegration {
200
246
  });
201
247
  synced.push(event.id);
202
248
  } catch (err) {
203
- console.error('[rag] sync push failed for event', event.id + ':', err);
249
+ // Don't print full stack traces for expected 404s (missing tables)
250
+ console.debug('[rag] sync push failed for event', event.id + ':', err.message);
204
251
  break; // Stop on first failure, retry next cycle
205
252
  }
206
253
  }
@@ -259,4 +306,4 @@ class RAGIntegration {
259
306
  }
260
307
  }
261
308
 
262
- module.exports = { RAGIntegration };
309
+ module.exports = { RAGIntegration, resolveProjectName };
@@ -55,7 +55,11 @@ const PATTERNS = {
55
55
  // tools (cat, ls, cd, rm, etc.) report filesystem misses in plain English
56
56
  // without ever emitting the ENOENT errno code. Flagged as a gap by Rumen's
57
57
  // first production kickstart insight on 2026-04-15.
58
- error: /\b(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|\b5\d\d\b)\b/
58
+ error: /\b(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|\b5\d\d\b)\b/,
59
+ // Stricter line-anchored variant for Claude Code, whose tool output (grep
60
+ // results, test logs, file contents) routinely mentions "Error" mid-line
61
+ // without representing an actual failure of the agent itself.
62
+ 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
59
63
  };
60
64
 
61
65
  class Session {
@@ -291,7 +295,20 @@ class Session {
291
295
  }
292
296
 
293
297
  _detectErrors(clean) {
294
- if (!PATTERNS.error.test(clean)) return;
298
+ // After a clean PTY exit (code 0), the session has already completed
299
+ // successfully — index.js sets status='exited' / exitCode=0 in onExit.
300
+ // Trailing data events that contain error-like strings (Claude Code tool
301
+ // output, log tails) shouldn't retroactively flip the panel back to
302
+ // 'errored'. Real errors surface via non-zero exit codes.
303
+ if (this.meta.exitCode === 0) return;
304
+
305
+ // Claude Code's tool output frequently contains "error"/"Error" mid-line
306
+ // (grep matches, test results, log dumps). Use a line-anchored pattern
307
+ // for that session type so we don't flag content as failure.
308
+ const pattern = this.meta.type === 'claude-code'
309
+ ? PATTERNS.errorLineStart
310
+ : PATTERNS.error;
311
+ if (!pattern.test(clean)) return;
295
312
 
296
313
  const oldStatus = this.meta.status;
297
314
  this.meta.status = 'errored';