@jhizzard/termdeck 0.3.6 → 0.3.8
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 +1 -1
- package/package.json +1 -1
- package/packages/cli/src/index.js +21 -1
- package/packages/client/public/app.js +133 -9
- package/packages/client/public/style.css +158 -0
- package/packages/server/src/index.js +5 -0
- package/packages/server/src/mnestra-bridge/index.js +17 -4
- package/packages/server/src/rag.js +49 -6
- package/packages/server/src/session.js +19 -2
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.
|
|
164
|
+
- **Not proven at scale.** v0.3.8, 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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
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"
|
|
@@ -106,10 +106,30 @@ const port = config.port || 3000;
|
|
|
106
106
|
const host = config.host || '127.0.0.1';
|
|
107
107
|
const url = `http://${host}:${port}`;
|
|
108
108
|
|
|
109
|
+
// Bind guardrail: refuse non-loopback without auth token
|
|
110
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
111
|
+
if (!LOOPBACK.has(host)) {
|
|
112
|
+
const authToken = config.auth?.token || process.env.TERMDECK_AUTH_TOKEN;
|
|
113
|
+
if (!authToken) {
|
|
114
|
+
console.error('[security] Refusing to bind to ' + host + ' without auth.token set.');
|
|
115
|
+
console.error('[security] Set auth.token in ~/.termdeck/config.yaml or TERMDECK_AUTH_TOKEN env var.');
|
|
116
|
+
console.error('[security] To bind locally only, set host: 127.0.0.1 in config.yaml');
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
109
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
|
+
|
|
110
130
|
console.log(`
|
|
111
131
|
╔══════════════════════════════════════╗
|
|
112
|
-
║
|
|
132
|
+
║${titleLine}║
|
|
113
133
|
╠══════════════════════════════════════╣
|
|
114
134
|
║ ${url.padEnd(34)} ║
|
|
115
135
|
║ ║
|
|
@@ -517,17 +517,137 @@
|
|
|
517
517
|
});
|
|
518
518
|
toast.addEventListener('click', () => {
|
|
519
519
|
dismiss();
|
|
520
|
-
|
|
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
|
-
}
|
|
520
|
+
showFlashbackModal(hit, id);
|
|
526
521
|
});
|
|
527
522
|
|
|
528
523
|
toast._autoTimer = setTimeout(dismiss, 30000);
|
|
529
524
|
}
|
|
530
525
|
|
|
526
|
+
// ===== Flashback modal (Sprint 16 T2) =====
|
|
527
|
+
let _flashbackModalEl = null;
|
|
528
|
+
let _flashbackKeyHandler = null;
|
|
529
|
+
let _flashbackPrevFocus = null;
|
|
530
|
+
|
|
531
|
+
function closeFlashbackModal() {
|
|
532
|
+
if (!_flashbackModalEl) return;
|
|
533
|
+
_flashbackModalEl.remove();
|
|
534
|
+
_flashbackModalEl = null;
|
|
535
|
+
if (_flashbackKeyHandler) {
|
|
536
|
+
document.removeEventListener('keydown', _flashbackKeyHandler);
|
|
537
|
+
_flashbackKeyHandler = null;
|
|
538
|
+
}
|
|
539
|
+
if (_flashbackPrevFocus && typeof _flashbackPrevFocus.focus === 'function') {
|
|
540
|
+
try { _flashbackPrevFocus.focus(); } catch {}
|
|
541
|
+
}
|
|
542
|
+
_flashbackPrevFocus = null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function logFlashbackFeedback(hit, sessionId, verdict) {
|
|
546
|
+
// Fire-and-forget; no dedicated endpoint yet.
|
|
547
|
+
const payload = {
|
|
548
|
+
verdict,
|
|
549
|
+
sessionId: sessionId || null,
|
|
550
|
+
project: hit?.project || null,
|
|
551
|
+
sourceType: hit?.source_type || hit?.sourceType || null,
|
|
552
|
+
similarity: typeof hit?.similarity === 'number' ? hit.similarity : null,
|
|
553
|
+
contentPreview: (hit?.content || hit?.text || '').slice(0, 160),
|
|
554
|
+
at: new Date().toISOString(),
|
|
555
|
+
};
|
|
556
|
+
console.log('[flashback] feedback', payload);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function showFlashbackModal(hit, sessionId) {
|
|
560
|
+
// Replace any existing modal (new toast wins).
|
|
561
|
+
if (_flashbackModalEl) closeFlashbackModal();
|
|
562
|
+
|
|
563
|
+
_flashbackPrevFocus = document.activeElement;
|
|
564
|
+
|
|
565
|
+
const content = (hit?.content || hit?.text || '').trim();
|
|
566
|
+
const project = hit?.project || '';
|
|
567
|
+
const sourceType = hit?.source_type || hit?.sourceType || '';
|
|
568
|
+
const createdAt = hit?.created_at || hit?.createdAt || '';
|
|
569
|
+
const scoreNum = typeof hit?.similarity === 'number' ? hit.similarity : null;
|
|
570
|
+
const scorePct = scoreNum !== null ? `${(scoreNum * 100).toFixed(0)}%` : '';
|
|
571
|
+
|
|
572
|
+
const overlay = document.createElement('div');
|
|
573
|
+
overlay.className = 'flashback-modal open';
|
|
574
|
+
overlay.setAttribute('role', 'dialog');
|
|
575
|
+
overlay.setAttribute('aria-modal', 'true');
|
|
576
|
+
overlay.setAttribute('aria-labelledby', 'flashbackTitle');
|
|
577
|
+
|
|
578
|
+
const projectChip = project
|
|
579
|
+
? `<span class="fb-chip fb-chip-project">${escapeHtml(project)}</span>`
|
|
580
|
+
: '';
|
|
581
|
+
const scoreChip = scorePct
|
|
582
|
+
? `<span class="fb-chip fb-chip-score">${escapeHtml(scorePct)}</span>`
|
|
583
|
+
: '';
|
|
584
|
+
const sourceLine = sourceType
|
|
585
|
+
? `<span class="fb-meta-item"><span class="fb-meta-label">source</span> ${escapeHtml(sourceType)}</span>`
|
|
586
|
+
: '';
|
|
587
|
+
const timeLine = createdAt
|
|
588
|
+
? `<span class="fb-meta-item"><span class="fb-meta-label">when</span> ${escapeHtml(timeAgo(createdAt))}</span>`
|
|
589
|
+
: '';
|
|
590
|
+
const projectLine = project
|
|
591
|
+
? `<span class="fb-meta-item"><span class="fb-meta-label">project</span> ${escapeHtml(project)}</span>`
|
|
592
|
+
: '';
|
|
593
|
+
|
|
594
|
+
overlay.innerHTML = `
|
|
595
|
+
<div class="fb-backdrop"></div>
|
|
596
|
+
<div class="fb-card" tabindex="-1">
|
|
597
|
+
<header>
|
|
598
|
+
<h3 id="flashbackTitle">
|
|
599
|
+
<span class="fb-title-text">Flashback — similar issue found</span>
|
|
600
|
+
<span class="fb-title-chips">${projectChip}${scoreChip}</span>
|
|
601
|
+
</h3>
|
|
602
|
+
<button class="fb-x" type="button" aria-label="Close">×</button>
|
|
603
|
+
</header>
|
|
604
|
+
<div class="fb-body">
|
|
605
|
+
<pre class="fb-content">${escapeHtml(content || '(empty memory)')}</pre>
|
|
606
|
+
<div class="fb-meta">
|
|
607
|
+
${projectLine}
|
|
608
|
+
${sourceLine}
|
|
609
|
+
${timeLine}
|
|
610
|
+
</div>
|
|
611
|
+
</div>
|
|
612
|
+
<footer>
|
|
613
|
+
<div class="fb-feedback">
|
|
614
|
+
<button class="fb-btn fb-helped" type="button">This helped</button>
|
|
615
|
+
<button class="fb-btn fb-not-relevant" type="button">Not relevant</button>
|
|
616
|
+
</div>
|
|
617
|
+
<button class="fb-btn fb-dismiss" type="button">Dismiss</button>
|
|
618
|
+
</footer>
|
|
619
|
+
</div>
|
|
620
|
+
`;
|
|
621
|
+
|
|
622
|
+
document.body.appendChild(overlay);
|
|
623
|
+
_flashbackModalEl = overlay;
|
|
624
|
+
|
|
625
|
+
overlay.querySelector('.fb-backdrop').addEventListener('click', closeFlashbackModal);
|
|
626
|
+
overlay.querySelector('.fb-x').addEventListener('click', closeFlashbackModal);
|
|
627
|
+
overlay.querySelector('.fb-dismiss').addEventListener('click', closeFlashbackModal);
|
|
628
|
+
overlay.querySelector('.fb-helped').addEventListener('click', () => {
|
|
629
|
+
logFlashbackFeedback(hit, sessionId, 'helped');
|
|
630
|
+
closeFlashbackModal();
|
|
631
|
+
});
|
|
632
|
+
overlay.querySelector('.fb-not-relevant').addEventListener('click', () => {
|
|
633
|
+
logFlashbackFeedback(hit, sessionId, 'not_relevant');
|
|
634
|
+
closeFlashbackModal();
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
_flashbackKeyHandler = (e) => {
|
|
638
|
+
if (e.key === 'Escape') {
|
|
639
|
+
e.preventDefault();
|
|
640
|
+
closeFlashbackModal();
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
document.addEventListener('keydown', _flashbackKeyHandler);
|
|
644
|
+
|
|
645
|
+
setTimeout(() => {
|
|
646
|
+
const card = overlay.querySelector('.fb-card');
|
|
647
|
+
if (card) card.focus();
|
|
648
|
+
}, 30);
|
|
649
|
+
}
|
|
650
|
+
|
|
531
651
|
// ===== Reply / send-to-terminal (T1.3) =====
|
|
532
652
|
// Flip this to false to force the local-WS fallback even when the server
|
|
533
653
|
// endpoint is available — handy for debugging.
|
|
@@ -2565,9 +2685,13 @@
|
|
|
2565
2685
|
const TIER23_CHECKS = new Set(['mnestra_reachable', 'mnestra_has_memories', 'rumen_recent', 'database_url']);
|
|
2566
2686
|
|
|
2567
2687
|
function filterChecksByTier(checks) {
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
//
|
|
2688
|
+
// Show Tier 2/3 checks if DATABASE_URL was ATTEMPTED (exists in results),
|
|
2689
|
+
// regardless of pass/fail. Only hide higher-tier checks when the user
|
|
2690
|
+
// has no DATABASE_URL at all (detail says "not set").
|
|
2691
|
+
const dbCheck = checks.find(c => c.name === 'database_url');
|
|
2692
|
+
const dbConfigured = dbCheck && !/not set/i.test(dbCheck.detail || '');
|
|
2693
|
+
if (dbConfigured) return checks; // full stack configured — show everything
|
|
2694
|
+
// No DATABASE_URL configured: only show Tier 1 checks
|
|
2571
2695
|
return checks.filter(c => TIER1_CHECKS.has(c.name));
|
|
2572
2696
|
}
|
|
2573
2697
|
|
|
@@ -1350,6 +1350,164 @@
|
|
|
1350
1350
|
to { opacity: 1; transform: translateY(0); }
|
|
1351
1351
|
}
|
|
1352
1352
|
|
|
1353
|
+
/* ===== Flashback modal (Sprint 16 T2) ===== */
|
|
1354
|
+
.flashback-modal {
|
|
1355
|
+
display: none;
|
|
1356
|
+
position: fixed;
|
|
1357
|
+
inset: 0;
|
|
1358
|
+
z-index: 3200;
|
|
1359
|
+
align-items: center;
|
|
1360
|
+
justify-content: center;
|
|
1361
|
+
}
|
|
1362
|
+
.flashback-modal.open { display: flex; }
|
|
1363
|
+
.fb-backdrop {
|
|
1364
|
+
position: absolute;
|
|
1365
|
+
inset: 0;
|
|
1366
|
+
background: rgba(0, 0, 0, 0.72);
|
|
1367
|
+
}
|
|
1368
|
+
.fb-card {
|
|
1369
|
+
position: relative;
|
|
1370
|
+
background: var(--tg-surface);
|
|
1371
|
+
border: 1px solid var(--tg-accent-dim);
|
|
1372
|
+
border-left: 3px solid var(--tg-purple);
|
|
1373
|
+
border-radius: 10px;
|
|
1374
|
+
width: 600px;
|
|
1375
|
+
max-width: calc(100vw - 40px);
|
|
1376
|
+
max-height: calc(100vh - 80px);
|
|
1377
|
+
display: flex;
|
|
1378
|
+
flex-direction: column;
|
|
1379
|
+
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
|
|
1380
|
+
font-family: var(--tg-sans);
|
|
1381
|
+
color: var(--tg-text);
|
|
1382
|
+
outline: none;
|
|
1383
|
+
animation: fb-in 0.14s ease;
|
|
1384
|
+
}
|
|
1385
|
+
@keyframes fb-in {
|
|
1386
|
+
from { opacity: 0; transform: translateY(4px); }
|
|
1387
|
+
to { opacity: 1; transform: translateY(0); }
|
|
1388
|
+
}
|
|
1389
|
+
.fb-card header {
|
|
1390
|
+
display: flex;
|
|
1391
|
+
align-items: flex-start;
|
|
1392
|
+
gap: 10px;
|
|
1393
|
+
padding: 14px 18px 10px;
|
|
1394
|
+
border-bottom: 1px solid var(--tg-border);
|
|
1395
|
+
}
|
|
1396
|
+
.fb-card header h3 {
|
|
1397
|
+
flex: 1;
|
|
1398
|
+
margin: 0;
|
|
1399
|
+
font-size: 13px;
|
|
1400
|
+
font-weight: 600;
|
|
1401
|
+
color: var(--tg-purple);
|
|
1402
|
+
text-transform: uppercase;
|
|
1403
|
+
letter-spacing: 0.5px;
|
|
1404
|
+
display: flex;
|
|
1405
|
+
flex-direction: column;
|
|
1406
|
+
gap: 6px;
|
|
1407
|
+
}
|
|
1408
|
+
.fb-card header .fb-title-chips {
|
|
1409
|
+
display: flex;
|
|
1410
|
+
gap: 6px;
|
|
1411
|
+
flex-wrap: wrap;
|
|
1412
|
+
}
|
|
1413
|
+
.fb-chip {
|
|
1414
|
+
display: inline-block;
|
|
1415
|
+
padding: 2px 8px;
|
|
1416
|
+
border-radius: 8px;
|
|
1417
|
+
font-size: 10px;
|
|
1418
|
+
font-family: var(--tg-mono);
|
|
1419
|
+
text-transform: none;
|
|
1420
|
+
letter-spacing: 0;
|
|
1421
|
+
background: var(--tg-bg);
|
|
1422
|
+
border: 1px solid var(--tg-border);
|
|
1423
|
+
color: var(--tg-text-dim);
|
|
1424
|
+
}
|
|
1425
|
+
.fb-chip-project {
|
|
1426
|
+
color: var(--tg-accent);
|
|
1427
|
+
border-color: var(--tg-accent-dim);
|
|
1428
|
+
}
|
|
1429
|
+
.fb-chip-score {
|
|
1430
|
+
color: var(--tg-purple);
|
|
1431
|
+
border-color: var(--tg-purple);
|
|
1432
|
+
}
|
|
1433
|
+
.fb-x {
|
|
1434
|
+
background: none;
|
|
1435
|
+
border: none;
|
|
1436
|
+
color: var(--tg-text-dim);
|
|
1437
|
+
cursor: pointer;
|
|
1438
|
+
font-size: 20px;
|
|
1439
|
+
line-height: 1;
|
|
1440
|
+
padding: 0 4px;
|
|
1441
|
+
margin-top: -2px;
|
|
1442
|
+
}
|
|
1443
|
+
.fb-x:hover { color: var(--tg-text); }
|
|
1444
|
+
.fb-body {
|
|
1445
|
+
flex: 1;
|
|
1446
|
+
overflow-y: auto;
|
|
1447
|
+
padding: 14px 18px;
|
|
1448
|
+
}
|
|
1449
|
+
.fb-content {
|
|
1450
|
+
margin: 0 0 12px;
|
|
1451
|
+
padding: 12px 14px;
|
|
1452
|
+
background: var(--tg-bg);
|
|
1453
|
+
border: 1px solid var(--tg-border);
|
|
1454
|
+
border-radius: 6px;
|
|
1455
|
+
font-family: var(--tg-mono);
|
|
1456
|
+
font-size: 12px;
|
|
1457
|
+
line-height: 1.5;
|
|
1458
|
+
color: var(--tg-text);
|
|
1459
|
+
white-space: pre-wrap;
|
|
1460
|
+
word-break: break-word;
|
|
1461
|
+
max-height: 320px;
|
|
1462
|
+
overflow-y: auto;
|
|
1463
|
+
}
|
|
1464
|
+
.fb-meta {
|
|
1465
|
+
display: flex;
|
|
1466
|
+
flex-wrap: wrap;
|
|
1467
|
+
gap: 10px 16px;
|
|
1468
|
+
font-family: var(--tg-mono);
|
|
1469
|
+
font-size: 10px;
|
|
1470
|
+
color: var(--tg-text-dim);
|
|
1471
|
+
}
|
|
1472
|
+
.fb-meta-item { display: inline-flex; gap: 5px; align-items: center; }
|
|
1473
|
+
.fb-meta-label {
|
|
1474
|
+
text-transform: uppercase;
|
|
1475
|
+
letter-spacing: 0.4px;
|
|
1476
|
+
opacity: 0.7;
|
|
1477
|
+
}
|
|
1478
|
+
.fb-card footer {
|
|
1479
|
+
padding: 10px 18px 14px;
|
|
1480
|
+
border-top: 1px solid var(--tg-border);
|
|
1481
|
+
display: flex;
|
|
1482
|
+
justify-content: space-between;
|
|
1483
|
+
gap: 10px;
|
|
1484
|
+
flex-wrap: wrap;
|
|
1485
|
+
}
|
|
1486
|
+
.fb-feedback { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
1487
|
+
.fb-btn {
|
|
1488
|
+
background: none;
|
|
1489
|
+
border: 1px solid var(--tg-border);
|
|
1490
|
+
color: var(--tg-text-dim);
|
|
1491
|
+
font-family: var(--tg-mono);
|
|
1492
|
+
font-size: 11px;
|
|
1493
|
+
padding: 5px 14px;
|
|
1494
|
+
border-radius: 3px;
|
|
1495
|
+
cursor: pointer;
|
|
1496
|
+
transition: color 0.12s, border-color 0.12s, background 0.12s;
|
|
1497
|
+
}
|
|
1498
|
+
.fb-btn:hover {
|
|
1499
|
+
color: var(--tg-text);
|
|
1500
|
+
border-color: var(--tg-border-active);
|
|
1501
|
+
}
|
|
1502
|
+
.fb-helped:hover {
|
|
1503
|
+
color: var(--tg-green);
|
|
1504
|
+
border-color: var(--tg-green);
|
|
1505
|
+
}
|
|
1506
|
+
.fb-not-relevant:hover {
|
|
1507
|
+
color: var(--tg-red, #ff6b6b);
|
|
1508
|
+
border-color: var(--tg-red, #ff6b6b);
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1353
1511
|
/* ===== Control dashboard (T1.6) ===== */
|
|
1354
1512
|
.control-feed {
|
|
1355
1513
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
142
|
+
}, this._projectFor(session));
|
|
100
143
|
}
|
|
101
144
|
|
|
102
145
|
// Circuit breaker check — returns true if pushes to this table are disabled
|
|
@@ -259,4 +302,4 @@ class RAGIntegration {
|
|
|
259
302
|
}
|
|
260
303
|
}
|
|
261
304
|
|
|
262
|
-
module.exports = { RAGIntegration };
|
|
305
|
+
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
|
-
|
|
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';
|