@jhizzard/termdeck 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/packages/cli/assets/supervise/com.jhizzard.termdeck-supervise.plist +38 -0
- package/packages/cli/assets/supervise/termdeck-supervise.service +27 -0
- package/packages/cli/assets/supervise/termdeck-supervise.sh +146 -0
- package/packages/cli/assets/supervise/termdeck-supervise.timer +14 -0
- package/packages/cli/src/index.js +15 -2
- package/packages/cli/src/init-bridge.js +1270 -0
- package/packages/cli/src/init.js +1 -0
- package/packages/client/public/app.js +135 -9
- package/packages/client/public/index.html +1 -0
- package/packages/client/public/input-guard.js +192 -0
- package/packages/client/public/style.css +63 -0
- package/packages/server/src/agent-adapters/agy.js +21 -30
- package/packages/server/src/agent-adapters/web-chat-grok.js +22 -22
- package/packages/server/src/index.js +98 -4
- package/packages/server/src/sprints/status-parser.js +14 -4
- package/packages/stack-installer/assets/hooks/README.md +25 -15
- package/packages/stack-installer/assets/hooks/memory-pre-compact.js +35 -7
- package/packages/stack-installer/assets/hooks/memory-session-end.js +121 -27
|
@@ -1590,6 +1590,57 @@ function createServer(config) {
|
|
|
1590
1590
|
// sync), wire screencast→WS, completion→capture, disconnect→close. Fail-soft
|
|
1591
1591
|
// at every step — a missing/partial/throwing driver degrades the panel to
|
|
1592
1592
|
// 'errored', never crashes the server.
|
|
1593
|
+
// Render-watchdog: self-heal a wedged web-chat cold-start. On a brand-new
|
|
1594
|
+
// browser profile the first load very occasionally paints nothing (empty
|
|
1595
|
+
// body.innerText) even though attach + screencast are healthy; a full
|
|
1596
|
+
// re-navigation clears it (a reload does NOT). Polls briefly for paint, then
|
|
1597
|
+
// re-navigates up to `attempts` times. Returns true if the page painted (or
|
|
1598
|
+
// we cannot measure — never block readiness on the watchdog itself), false if
|
|
1599
|
+
// it stayed blank. Provider-neutral: "painted" == the body has any visible
|
|
1600
|
+
// text, which empirically separates the white cold-start wedge (innerText
|
|
1601
|
+
// length 0) from a rendered SPA (>0). (Sprint-72 hardening — 2026-06-09.)
|
|
1602
|
+
async function ensureWebChatRendered(session, handle, startUrl, opts = {}) {
|
|
1603
|
+
const settleMs = opts.settleMs || Number(process.env.TERMDECK_WEBCHAT_RENDER_SETTLE_MS) || 8000;
|
|
1604
|
+
const attempts = opts.attempts != null ? opts.attempts
|
|
1605
|
+
: (Number(process.env.TERMDECK_WEBCHAT_RENDER_ATTEMPTS) || 2);
|
|
1606
|
+
const stepMs = opts.stepMs || Number(process.env.TERMDECK_WEBCHAT_RENDER_STEP_MS) || 500;
|
|
1607
|
+
if (!handle || !handle.page || typeof handle.page.evaluate !== 'function') return true;
|
|
1608
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1609
|
+
const painted = async () => {
|
|
1610
|
+
try {
|
|
1611
|
+
return await handle.page.evaluate(
|
|
1612
|
+
() => !!(document && document.body && (document.body.innerText || '').trim().length > 0),
|
|
1613
|
+
);
|
|
1614
|
+
} catch (_e) {
|
|
1615
|
+
return false;
|
|
1616
|
+
}
|
|
1617
|
+
};
|
|
1618
|
+
const settle = async () => {
|
|
1619
|
+
for (let waited = 0; waited < settleMs; waited += stepMs) {
|
|
1620
|
+
if (session._webChatClosed) return true;
|
|
1621
|
+
if (await painted()) return true;
|
|
1622
|
+
await sleep(stepMs);
|
|
1623
|
+
}
|
|
1624
|
+
return painted();
|
|
1625
|
+
};
|
|
1626
|
+
if (await settle()) return true;
|
|
1627
|
+
for (let tries = 1; tries <= attempts; tries++) {
|
|
1628
|
+
if (session._webChatClosed) return true;
|
|
1629
|
+
applyWebChatStatus(session, { status: 'starting', statusDetail: `Recovering blank page (try ${tries}/${attempts})…` });
|
|
1630
|
+
try {
|
|
1631
|
+
if (typeof handle.navigate === 'function') {
|
|
1632
|
+
await handle.navigate(startUrl, { waitUntil: 'domcontentloaded' });
|
|
1633
|
+
} else {
|
|
1634
|
+
await handle.page.goto(startUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
1635
|
+
}
|
|
1636
|
+
} catch (_e) {
|
|
1637
|
+
/* navigation hiccup — re-check paint anyway */
|
|
1638
|
+
}
|
|
1639
|
+
if (await settle()) return true;
|
|
1640
|
+
}
|
|
1641
|
+
return false;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1593
1644
|
function setupWebChatSession(session) {
|
|
1594
1645
|
session.pty = null;
|
|
1595
1646
|
session.pid = null;
|
|
@@ -1647,8 +1698,18 @@ function createServer(config) {
|
|
|
1647
1698
|
catch (_e) { /* never disrupt */ }
|
|
1648
1699
|
}
|
|
1649
1700
|
};
|
|
1650
|
-
|
|
1651
|
-
|
|
1701
|
+
// Render quality (Sprint-72 hardening, 2026-06-09): pass crisp, Retina-friendly
|
|
1702
|
+
// screencast opts. The driver's bare default was a blurry 1280x800 @ jpeg-q60 — fine
|
|
1703
|
+
// on a 1x display, soft on a 2x Mac (the HiDPI canvas then upscales it). Env-tunable
|
|
1704
|
+
// down for slow links: TERMDECK_WEBCHAT_QUALITY / _MAXW / _MAXH / _FORMAT.
|
|
1705
|
+
const scOpts = {
|
|
1706
|
+
format: process.env.TERMDECK_WEBCHAT_FORMAT || 'jpeg',
|
|
1707
|
+
quality: Number(process.env.TERMDECK_WEBCHAT_QUALITY) || 85,
|
|
1708
|
+
maxWidth: Number(process.env.TERMDECK_WEBCHAT_MAXW) || 2560,
|
|
1709
|
+
maxHeight: Number(process.env.TERMDECK_WEBCHAT_MAXH) || 1600,
|
|
1710
|
+
};
|
|
1711
|
+
if (handle && typeof handle.screencast === 'function') handle.screencast(onFrame, scOpts);
|
|
1712
|
+
else if (driver.cdp && typeof driver.cdp.screencast === 'function') driver.cdp.screencast(handle, onFrame, scOpts);
|
|
1652
1713
|
} catch (err) {
|
|
1653
1714
|
console.error('[web-chat] screencast wiring failed:', err && err.message ? err.message : err);
|
|
1654
1715
|
}
|
|
@@ -1673,7 +1734,17 @@ function createServer(config) {
|
|
|
1673
1734
|
}
|
|
1674
1735
|
} catch (_e) { /* optional hook — absence is fine */ }
|
|
1675
1736
|
|
|
1676
|
-
|
|
1737
|
+
// Self-heal a flaky blank cold-start before declaring the panel Ready.
|
|
1738
|
+
let rendered = true;
|
|
1739
|
+
try {
|
|
1740
|
+
rendered = await ensureWebChatRendered(session, handle, startUrl);
|
|
1741
|
+
} catch (err) {
|
|
1742
|
+
console.error('[web-chat] render-watchdog error:', err && err.message ? err.message : err);
|
|
1743
|
+
}
|
|
1744
|
+
if (session._webChatClosed) return;
|
|
1745
|
+
applyWebChatStatus(session, rendered
|
|
1746
|
+
? { status: 'idle', statusDetail: 'Ready' }
|
|
1747
|
+
: { status: 'errored', statusDetail: 'page did not render (blank after retries)' });
|
|
1677
1748
|
})();
|
|
1678
1749
|
}
|
|
1679
1750
|
|
|
@@ -3396,8 +3467,13 @@ function validateSupabase(url, key) {
|
|
|
3396
3467
|
|
|
3397
3468
|
function validateOpenAI(key) {
|
|
3398
3469
|
return new Promise((resolve) => {
|
|
3470
|
+
// Probe with the EXACT request shape the bundled hooks use in production
|
|
3471
|
+
// (session-end v5: 3-large @ dimensions:1536, recall-parity with mnestra)
|
|
3472
|
+
// so a passing preflight means the real capture pipeline's call works —
|
|
3473
|
+
// not some other model the account may gate differently.
|
|
3399
3474
|
const payload = JSON.stringify({
|
|
3400
|
-
model: 'text-embedding-3-
|
|
3475
|
+
model: 'text-embedding-3-large',
|
|
3476
|
+
dimensions: 1536,
|
|
3401
3477
|
input: 'termdeck setup test'
|
|
3402
3478
|
});
|
|
3403
3479
|
const req = https.request({
|
|
@@ -3589,6 +3665,24 @@ if (require.main === module) {
|
|
|
3589
3665
|
process.on('SIGINT', () => handleShutdown('SIGINT'));
|
|
3590
3666
|
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
|
|
3591
3667
|
|
|
3668
|
+
// Fail-soft crash guards (Sprint-72 hardening, 2026-06-09). One bad async
|
|
3669
|
+
// rejection or uncaught error anywhere — a panel handler, a request, a hook —
|
|
3670
|
+
// must NOT crash the whole server and take every live terminal panel (and the
|
|
3671
|
+
// user's work) down with it. We LOG prominently (per-event ISO timestamp, like
|
|
3672
|
+
// the boot banner, so crash boundaries stay greppable) and keep running. This
|
|
3673
|
+
// trades the small risk of continuing in a degraded state for the much larger
|
|
3674
|
+
// cost of losing every panel; a process supervisor is the backstop if the
|
|
3675
|
+
// process ever truly wedges. Shutdown is exempt — let handleShutdown finish.
|
|
3676
|
+
process.on('unhandledRejection', (reason) => {
|
|
3677
|
+
if (shutdownInProgress) return;
|
|
3678
|
+
const msg = (reason && reason.stack) || (reason && reason.message) || String(reason);
|
|
3679
|
+
console.error(`[server] unhandledRejection (kept alive · ${new Date().toISOString()}):\n${msg}`);
|
|
3680
|
+
});
|
|
3681
|
+
process.on('uncaughtException', (err) => {
|
|
3682
|
+
if (shutdownInProgress) return;
|
|
3683
|
+
console.error(`[server] uncaughtException (kept alive · ${new Date().toISOString()}):\n${(err && err.stack) || err}`);
|
|
3684
|
+
});
|
|
3685
|
+
|
|
3592
3686
|
server.listen(port, host, () => {
|
|
3593
3687
|
// Sprint 60 v1.0.14 (Item 5) — per-boot banner with ISO timestamp + PID.
|
|
3594
3688
|
// Brad's 2026-05-07 forensic: a single 260KB termdeck.log spanned Apr 25
|
|
@@ -21,9 +21,19 @@ function parseStatusMd(filePath) {
|
|
|
21
21
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
22
22
|
const lines = content.split('\n');
|
|
23
23
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
24
|
+
// Canonical post header (global CLAUDE.md § lane post-shape uniformity):
|
|
25
|
+
// ### [T<n>] VERB[ (qualifier)] YYYY-MM-DD HH:MM ET — <gist>
|
|
26
|
+
// The verb is HARD-ANCHORED to the header position (immediately after the
|
|
27
|
+
// lane tag), so a verb word appearing in a gist/prose — e.g. "DONE" inside a
|
|
28
|
+
// CHECKPOINT line's gist — is never mis-counted as that verb; it survives
|
|
29
|
+
// only as gist (capture group 5). Verb vocabulary tracks the REAL lane
|
|
30
|
+
// vocabulary, incl. FIX-PROPOSED / FIX-LANDED / AUDIT-PASS / AUDIT-FAIL, plus
|
|
31
|
+
// an optional parenthetical qualifier after the verb (e.g. the real shape
|
|
32
|
+
// `AUDIT-PASS (cdp/render)`). Order longer compounds before their shorter
|
|
33
|
+
// prefixes is unnecessary here (all alternatives are anchored + whitespace-
|
|
34
|
+
// delimited), but FIX-*/AUDIT-* are listed before bare PROPOSE/LANDED for
|
|
35
|
+
// readability.
|
|
36
|
+
const POST_RE = /^### \[(T\d+(?:-[A-Z0-9-]+)?|ORCH)\] (FINDING|FIX-PROPOSED|PROPOSE|FIX-LANDED|LANDED|DONE|AUDIT-RED|AUDIT-CONCERN|AUDIT-PASS|AUDIT-FAIL|CHECKPOINT|FINAL-VERDICT|STATUS|RULING)(?:\s+\([^)]*\))? (\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}) ET — (.*?)$/;
|
|
27
37
|
|
|
28
38
|
const laneLandeds = {}; // laneTag -> latest LANDED timestamp
|
|
29
39
|
const openReds = []; // List of {tag, timestamp, gist}
|
|
@@ -57,7 +67,7 @@ function parseStatusMd(filePath) {
|
|
|
57
67
|
gist
|
|
58
68
|
};
|
|
59
69
|
|
|
60
|
-
if (verb === 'LANDED') {
|
|
70
|
+
if (verb === 'LANDED' || verb === 'FIX-LANDED') {
|
|
61
71
|
laneLandeds[tag] = timestamp;
|
|
62
72
|
}
|
|
63
73
|
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
The `@jhizzard/termdeck-stack` installer can drop `memory-session-end.js`
|
|
4
4
|
into `~/.claude/hooks/` and wire it into `~/.claude/settings.json` under
|
|
5
|
-
`hooks.
|
|
6
|
-
yes.
|
|
5
|
+
`hooks.SessionEnd`. The installer prompts you before doing this; default
|
|
6
|
+
is yes. (Early versions wired `hooks.Stop`, which fires every assistant
|
|
7
|
+
turn — the wizard migrates that to `SessionEnd` automatically.)
|
|
7
8
|
|
|
8
9
|
## What the hook does
|
|
9
10
|
|
|
10
|
-
On every Claude Code session close, Claude Code fires its `
|
|
11
|
-
with a JSON payload on stdin:
|
|
11
|
+
On every Claude Code session close, Claude Code fires its `SessionEnd`
|
|
12
|
+
hook with a JSON payload on stdin:
|
|
12
13
|
|
|
13
14
|
```json
|
|
14
15
|
{ "transcript_path": "/path/to/session.jsonl", "cwd": "/path/where/you/were/working", "session_id": "..." }
|
|
@@ -17,20 +18,28 @@ with a JSON payload on stdin:
|
|
|
17
18
|
The hook:
|
|
18
19
|
|
|
19
20
|
1. Skips transcripts smaller than 5 KB (no signal in tiny sessions —
|
|
20
|
-
override via `TERMDECK_HOOK_MIN_BYTES`).
|
|
21
|
+
override via `TERMDECK_HOOK_MIN_BYTES`). Compact-envelope session
|
|
22
|
+
types (`antigravity`, `web-chat`) are exempt from the byte floor and
|
|
23
|
+
gate on parsed content instead (≥ 1 assistant turn).
|
|
21
24
|
2. Validates env vars (`SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`,
|
|
22
25
|
`OPENAI_API_KEY`); if any are missing, logs the missing list and
|
|
23
26
|
exits cleanly without blocking the session close.
|
|
24
27
|
3. Detects the project from `cwd` against a built-in regex table; falls
|
|
25
|
-
back to `"global"` when nothing matches.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
back to `"global"` when nothing matches. The hook ships with a
|
|
29
|
+
default most-specific-first table — see "Customizing the project
|
|
30
|
+
map" below to extend it with your own entries.
|
|
28
31
|
4. Builds a coarse session summary from the last ~30 messages of the
|
|
29
32
|
transcript (~7 KB cap to stay inside OpenAI's embedding-input
|
|
30
33
|
budget).
|
|
31
|
-
5. Embeds the summary via OpenAI `text-embedding-3-
|
|
34
|
+
5. Embeds the summary via OpenAI `text-embedding-3-large` at
|
|
35
|
+
`dimensions: 1536` — deliberately identical to Mnestra's recall-query
|
|
36
|
+
embedder, so rows and queries share one vector space (rows embedded
|
|
37
|
+
with any other model rank as semantic noise in hybrid search).
|
|
32
38
|
6. POSTs **one row** to Supabase `/rest/v1/memory_items` with
|
|
33
|
-
`source_type='session_summary'
|
|
39
|
+
`source_type='session_summary'` (stamped
|
|
40
|
+
`metadata.embedding_model='text-embedding-3-large@1536'`), plus a
|
|
41
|
+
companion upsert to `/rest/v1/memory_sessions` keyed on
|
|
42
|
+
`session_id`.
|
|
34
43
|
7. Logs every step to `~/.claude/hooks/memory-hook.log`.
|
|
35
44
|
|
|
36
45
|
The hook is **fail-soft**: any error (network, parse, env-var-missing,
|
|
@@ -70,9 +79,9 @@ If any of the three is missing the log line will name them:
|
|
|
70
79
|
|
|
71
80
|
## Customizing the project map
|
|
72
81
|
|
|
73
|
-
The hook ships with
|
|
74
|
-
session lands under `project: 'global'`
|
|
75
|
-
your own:
|
|
82
|
+
The hook ships with a default `PROJECT_MAP` (most-specific-first); a
|
|
83
|
+
session lands under `project: 'global'` only when no entry matches its
|
|
84
|
+
`cwd`. To add your own entries:
|
|
76
85
|
|
|
77
86
|
1. Open `~/.claude/hooks/memory-session-end.js` after the installer
|
|
78
87
|
has dropped it.
|
|
@@ -146,8 +155,8 @@ before overwriting; choose accordingly.
|
|
|
146
155
|
Two options:
|
|
147
156
|
|
|
148
157
|
1. Edit `~/.claude/settings.json` and remove the entry under
|
|
149
|
-
`hooks.
|
|
150
|
-
file in place; it simply won't fire.
|
|
158
|
+
`hooks.SessionEnd` that references `memory-session-end.js`. Leave
|
|
159
|
+
the file in place; it simply won't fire.
|
|
151
160
|
2. Or delete `~/.claude/hooks/memory-session-end.js` AND remove the
|
|
152
161
|
`settings.json` entry. (Removing only the file leaves a broken
|
|
153
162
|
`command` in settings — Claude Code will log a missing-file error
|
|
@@ -162,6 +171,7 @@ re-prompt to install. Decline at the prompt to stay opted out.
|
|
|
162
171
|
|---|---|
|
|
163
172
|
| `TERMDECK_HOOK_DEBUG=1` | Verbose `[debug]` lines in the log |
|
|
164
173
|
| `TERMDECK_HOOK_MIN_BYTES=10000` | Override the 5 KB skip threshold |
|
|
174
|
+
| `TERMDECK_HOOK_MIN_MESSAGES=5` | Override the parsed-message floor (default 1) |
|
|
165
175
|
|
|
166
176
|
## Log file
|
|
167
177
|
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* TermDeck pre-compact memory hook (Mnestra-direct, no rag-system dependency).
|
|
3
3
|
*
|
|
4
|
+
* @termdeck/stack-installer-hook v2
|
|
5
|
+
*
|
|
6
|
+
* ^ Stamp lives at the TOP of the docblock — both readers scan only the first
|
|
7
|
+
* 4096 bytes (Sprint 73 T1 hit this on the session-end hook when its
|
|
8
|
+
* changelog grew past 4 KB and the stamp fell out of the window, silently
|
|
9
|
+
* disabling every refresh path). Keep it above the fold.
|
|
10
|
+
*
|
|
4
11
|
* Vendored into ~/.claude/hooks/memory-pre-compact.js by @jhizzard/termdeck-stack.
|
|
5
12
|
* Wired into ~/.claude/settings.json under hooks.PreCompact — fires BEFORE
|
|
6
13
|
* Claude Code compacts conversation context, capturing the in-flight session
|
|
@@ -32,7 +39,9 @@
|
|
|
32
39
|
* - Load ~/.termdeck/secrets.env on env-var gaps (Sprint 47.5 discipline).
|
|
33
40
|
* - Parse transcript via the adapter parser exported by
|
|
34
41
|
* memory-session-end.js (Sprint 38 module-export contract; no duplication).
|
|
35
|
-
* - Embed via
|
|
42
|
+
* - Embed via the session-end hook's embedText (text-embedding-3-large at
|
|
43
|
+
* dimensions:1536 since v5 there — recall-parity with mnestra's query
|
|
44
|
+
* embedder; this hook has NO embed call of its own).
|
|
36
45
|
* - POST ONE row to /rest/v1/memory_items with
|
|
37
46
|
* source_type='pre_compact_snapshot', category='workflow'.
|
|
38
47
|
*
|
|
@@ -43,18 +52,27 @@
|
|
|
43
52
|
* fail-soft.
|
|
44
53
|
*
|
|
45
54
|
* Version stamp (Sprint 64 T3.2 — initial cut):
|
|
46
|
-
* The marker `@termdeck/stack-installer-hook v<N>`
|
|
47
|
-
* stack-installer's installPreCompactHook
|
|
48
|
-
* --yes) and `termdeck init --mnestra`
|
|
49
|
-
* step)
|
|
55
|
+
* The marker `@termdeck/stack-installer-hook v<N>` at the TOP of this
|
|
56
|
+
* docblock is read by both stack-installer's installPreCompactHook
|
|
57
|
+
* (version-aware overwrite under --yes) and `termdeck init --mnestra`
|
|
58
|
+
* (refreshBundledPreCompactHookIfNewer step) — both scan only the first
|
|
59
|
+
* 4096 bytes. Bump the integer whenever a change here should overwrite an
|
|
50
60
|
* already-installed copy. Comment-only tweaks do not need a bump.
|
|
51
61
|
*
|
|
52
|
-
*
|
|
62
|
+
* v2 (Sprint 73 T1, ORCH handoff — embedding recall-parity marker):
|
|
63
|
+
* - Snapshot rows now stamp metadata.embedding_model with the marker
|
|
64
|
+
* exported by the session-end hook (v5: 'text-embedding-3-large@1536')
|
|
65
|
+
* — Sprint 74 T3's re-embed backfill keys idempotency on it. The marker
|
|
66
|
+
* is stamped ONLY when the loaded helpers export it: an older installed
|
|
67
|
+
* session-end (still embedding 3-small) exports none, the row stays
|
|
68
|
+
* unmarked, and the backfill correctly re-embeds it — a false marker on
|
|
69
|
+
* a mis-embedded row would permanently hide it from repair.
|
|
53
70
|
*
|
|
54
71
|
* Required env vars (validated at entry, after the secrets.env fallback):
|
|
55
72
|
* - SUPABASE_URL e.g. https://<project-ref>.supabase.co
|
|
56
73
|
* - SUPABASE_SERVICE_ROLE_KEY service-role key (needs INSERT on memory_items)
|
|
57
|
-
* - OPENAI_API_KEY sk-... for
|
|
74
|
+
* - OPENAI_API_KEY sk-... for the embed model (see embedText in
|
|
75
|
+
* the session-end hook — 3-large@1536 since v5)
|
|
58
76
|
*
|
|
59
77
|
* Optional:
|
|
60
78
|
* - TERMDECK_HOOK_DEBUG=1 verbose logging
|
|
@@ -126,6 +144,7 @@ async function postPreCompactSnapshot({
|
|
|
126
144
|
content, embedding,
|
|
127
145
|
project, sessionId,
|
|
128
146
|
sourceAgent,
|
|
147
|
+
embeddingModelMarker,
|
|
129
148
|
}) {
|
|
130
149
|
try {
|
|
131
150
|
const res = await fetch(`${supabaseUrl}/rest/v1/memory_items`, {
|
|
@@ -144,6 +163,12 @@ async function postPreCompactSnapshot({
|
|
|
144
163
|
project,
|
|
145
164
|
source_session_id: sessionId || null,
|
|
146
165
|
source_agent: sourceAgent,
|
|
166
|
+
// v2 — backfill-idempotency marker, present ONLY when the loaded
|
|
167
|
+
// helpers export one (i.e. the embed actually ran on that model).
|
|
168
|
+
// See the v2 header note for the stale-helpers rationale.
|
|
169
|
+
...(embeddingModelMarker
|
|
170
|
+
? { metadata: { embedding_model: embeddingModelMarker } }
|
|
171
|
+
: {}),
|
|
147
172
|
}),
|
|
148
173
|
});
|
|
149
174
|
if (!res.ok) {
|
|
@@ -240,6 +265,9 @@ async function processPreCompactPayload(input, helpers) {
|
|
|
240
265
|
supabaseUrl: env.supabaseUrl,
|
|
241
266
|
supabaseKey: env.supabaseKey,
|
|
242
267
|
content, embedding, project, sessionId, sourceAgent,
|
|
268
|
+
// Marker travels with the embedder: undefined on a pre-v5 session-end
|
|
269
|
+
// hook (3-small embeds → row stays unmarked → backfill repairs it).
|
|
270
|
+
embeddingModelMarker: helpers.EMBEDDING_MODEL_MARKER || null,
|
|
243
271
|
});
|
|
244
272
|
|
|
245
273
|
if (ok) {
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* TermDeck session-end memory hook (Mnestra-direct, no rag-system dependency).
|
|
3
3
|
*
|
|
4
|
+
* @termdeck/stack-installer-hook v5
|
|
5
|
+
*
|
|
6
|
+
* ^ The stamp lives HERE, at the top of the docblock — NOT below the changelog
|
|
7
|
+
* notes. Both readers (stack-installer's installSessionEndHook and
|
|
8
|
+
* init-mnestra's refreshBundledHookIfNewer) scan only the first 4096 bytes
|
|
9
|
+
* of the file for the marker; Sprint 73 T1's v4 note grew the header past
|
|
10
|
+
* 4 KB and a stamp positioned below the notes fell out of the scan window,
|
|
11
|
+
* making the bundled hook read as "unsigned" and silently disabling every
|
|
12
|
+
* refresh path. Keep the stamp above the fold; let the changelog grow below.
|
|
13
|
+
*
|
|
4
14
|
* Vendored into ~/.claude/hooks/memory-session-end.js by @jhizzard/termdeck-stack.
|
|
5
15
|
* Wired into ~/.claude/settings.json under hooks.SessionEnd — fires once per
|
|
6
16
|
* Claude Code session close (`/exit`, Ctrl+D, terminal close, or process kill).
|
|
@@ -34,7 +44,9 @@
|
|
|
34
44
|
* JSONL, Codex JSONL, Gemini single-JSON, or auto-detect when sessionType
|
|
35
45
|
* is absent. Builds a coarse summary from the resulting message list
|
|
36
46
|
* (last ~30 message excerpts).
|
|
37
|
-
* 7. Embeds the summary via OpenAI text-embedding-3-
|
|
47
|
+
* 7. Embeds the summary via OpenAI text-embedding-3-large at
|
|
48
|
+
* dimensions:1536 — recall-parity: MUST match mnestra's query-side
|
|
49
|
+
* embedder (engram src/embeddings.ts) or rows are semantically blind.
|
|
38
50
|
* 8. POSTs ONE row to Supabase /rest/v1/memory_items with source_type='session_summary'.
|
|
39
51
|
* 9. (Sprint 51.6 T3) POSTs ONE row to Supabase /rest/v1/memory_sessions with
|
|
40
52
|
* Prefer: resolution=merge-duplicates so SessionEnd-fires-twice resolves
|
|
@@ -43,9 +55,11 @@
|
|
|
43
55
|
* 10. Logs every step to ~/.claude/hooks/memory-hook.log.
|
|
44
56
|
*
|
|
45
57
|
* Version stamp (Sprint 51.6 T3 — hook upgrade gap fix):
|
|
46
|
-
* The marker `@termdeck/stack-installer-hook v<N>`
|
|
47
|
-
* stack-installer's installSessionEndHook
|
|
48
|
-
* --yes) and `termdeck init --mnestra`
|
|
58
|
+
* The marker `@termdeck/stack-installer-hook v<N>` at the TOP of this
|
|
59
|
+
* docblock is read by both stack-installer's installSessionEndHook
|
|
60
|
+
* (version-aware overwrite under --yes) and `termdeck init --mnestra`
|
|
61
|
+
* (refreshBundledHookIfNewer step) — both scan only the first 4096 bytes,
|
|
62
|
+
* which is why it sits above these notes (see the warning beside it).
|
|
49
63
|
* Bump the integer whenever a change to this file should overwrite an
|
|
50
64
|
* already-installed copy on the user's machine — e.g. a new write path,
|
|
51
65
|
* a new transcript parser, a default PROJECT_MAP change. Comment-only
|
|
@@ -61,12 +75,39 @@
|
|
|
61
75
|
* to bundled-v2 always passes the `installed >= bundled` short-circuit
|
|
62
76
|
* at init-mnestra.js:550 and reaches the refresh path.
|
|
63
77
|
*
|
|
64
|
-
*
|
|
78
|
+
* v4 (Sprint 73 T1 — grok-web provenance + web-chat byte-floor exemption):
|
|
79
|
+
* - ALLOWED_SOURCE_AGENTS gains the four web-surface tags ('grok-web',
|
|
80
|
+
* 'claude-web', 'chatgpt-web', 'gemini-web') per the Sprint 74 ORCH
|
|
81
|
+
* one-churn addendum; only 'grok-web' has a live producer today (the
|
|
82
|
+
* web-chat-grok adapter). New alias 'web-chat-grok' → 'grok-web'
|
|
83
|
+
* (registry-name safety net, mirrors 'agy' → 'antigravity').
|
|
84
|
+
* - Byte-floor exemption extended from antigravity-only to a sessionType
|
|
85
|
+
* set {antigravity, web-chat}: both materialize compact synthesized
|
|
86
|
+
* envelopes (<5 KB for short-but-substantive sessions); the gate is
|
|
87
|
+
* parsed content (>= 1 assistant turn), not raw bytes.
|
|
88
|
+
* - ATOMIC with mnestra migration 024 (Sprint 74 T1): rows tagged
|
|
89
|
+
* 'grok-web' are unfilterable via MCP source_agents until that ships.
|
|
90
|
+
* - Stamp moved to the TOP of the docblock (the readers' 4096-byte head
|
|
91
|
+
* scan missed it below these notes — see the warning at the stamp).
|
|
92
|
+
*
|
|
93
|
+
* v5 (Sprint 73 T1, ORCH handoff — embedding recall-parity):
|
|
94
|
+
* - embedText flipped text-embedding-3-small → text-embedding-3-large at
|
|
95
|
+
* dimensions:1536, matching mnestra's recall query embedder (engram
|
|
96
|
+
* src/embeddings.ts) EXACTLY. The two models do not share a vector
|
|
97
|
+
* space, so rows embedded 3-small score semantic noise against 3-large
|
|
98
|
+
* queries — Sprint 74 T3 quantified 544 production rows half-blind.
|
|
99
|
+
* `dimensions:1536` is LOAD-BEARING: 3-large is natively 3072-dim and
|
|
100
|
+
* the DB column is vector(1536) — without the param every insert 400s
|
|
101
|
+
* and capture is silently lost (hooks are fail-soft).
|
|
102
|
+
* - memory_items rows now stamp metadata.embedding_model =
|
|
103
|
+
* 'text-embedding-3-large@1536' — the marker Sprint 74 T3's re-embed
|
|
104
|
+
* backfill keys idempotency on (unmarked rows get re-embedded; marked
|
|
105
|
+
* rows are skipped). memory_items.metadata exists from migration 001.
|
|
65
106
|
*
|
|
66
107
|
* Required env vars (validated at entry, after the secrets.env fallback):
|
|
67
108
|
* - SUPABASE_URL e.g. https://<project-ref>.supabase.co
|
|
68
109
|
* - SUPABASE_SERVICE_ROLE_KEY service-role key (NOT the anon key — needs INSERT on memory_items)
|
|
69
|
-
* - OPENAI_API_KEY sk-... for text-embedding-3-
|
|
110
|
+
* - OPENAI_API_KEY sk-... for text-embedding-3-large (dimensions:1536)
|
|
70
111
|
*
|
|
71
112
|
* Optional:
|
|
72
113
|
* - TERMDECK_HOOK_DEBUG=1 verbose logging
|
|
@@ -148,6 +189,12 @@ const MIN_TRANSCRIPT_BYTES = parseInt(process.env.TERMDECK_HOOK_MIN_BYTES || '50
|
|
|
148
189
|
// filter; sub-5KB drips still get dropped. Env-configurable for operators who
|
|
149
190
|
// want the legacy permissive-to-zero floor or a higher cutoff than 1.
|
|
150
191
|
const MIN_TRANSCRIPT_MESSAGES = parseInt(process.env.TERMDECK_HOOK_MIN_MESSAGES || '1', 10);
|
|
192
|
+
// Sprint 70 T1 (antigravity) + Sprint 73 T1 (web-chat) — sessionTypes whose
|
|
193
|
+
// transcripts are synthesized COMPACT envelopes (no verbose on-disk JSONL), so
|
|
194
|
+
// the raw-byte floor would wrongly drop short-but-substantive sessions. These
|
|
195
|
+
// skip the MIN_TRANSCRIPT_BYTES gate and are gated on parsed content instead
|
|
196
|
+
// (>= 1 assistant turn) — see the exemption branch in processStdinPayload.
|
|
197
|
+
const BYTE_FLOOR_EXEMPT_SESSION_TYPES = new Set(['antigravity', 'web-chat']);
|
|
151
198
|
const DEBUG = process.env.TERMDECK_HOOK_DEBUG === '1';
|
|
152
199
|
|
|
153
200
|
function log(msg) {
|
|
@@ -602,7 +649,7 @@ function buildSummary(transcriptPath, sessionType) {
|
|
|
602
649
|
const summary =
|
|
603
650
|
`Session with ${messages.length} messages.\n\n` +
|
|
604
651
|
tail.map((m) => `[${m.role}] ${m.content}`).join('\n');
|
|
605
|
-
// OpenAI
|
|
652
|
+
// OpenAI v3 embedding models accept up to 8192 tokens (~32K chars).
|
|
606
653
|
// 7000 chars is a safe headroom that survives multibyte expansion.
|
|
607
654
|
|
|
608
655
|
// Sprint 51.7 T2: merge transcript-derived metadata so the caller (
|
|
@@ -618,6 +665,23 @@ function buildSummary(transcriptPath, sessionType) {
|
|
|
618
665
|
};
|
|
619
666
|
}
|
|
620
667
|
|
|
668
|
+
// Sprint 73 T1 (ORCH handoff, v5) — recall-parity embedding contract.
|
|
669
|
+
// MUST match mnestra's query-side embedder (engram src/embeddings.ts:
|
|
670
|
+
// text-embedding-3-large at dimensions:1536) EXACTLY. OpenAI embedding models
|
|
671
|
+
// do NOT share a vector space — rows embedded with a different model than the
|
|
672
|
+
// query score semantic noise in memory_hybrid_search (Sprint 74 T3 measured
|
|
673
|
+
// 544 production rows half-blind from the 3-small era). `dimensions` is
|
|
674
|
+
// LOAD-BEARING: 3-large natively emits 3072 dims and memory_items.embedding
|
|
675
|
+
// is vector(1536); omitting it turns every insert into a fail-soft 400 (rows
|
|
676
|
+
// silently lost). EMBEDDING_MODEL_MARKER is written to
|
|
677
|
+
// metadata.embedding_model on each row — Sprint 74 T3's re-embed backfill
|
|
678
|
+
// keys its idempotent selection on it (rows without the marker get
|
|
679
|
+
// re-embedded; rows with it are skipped). Bump the marker string in lockstep
|
|
680
|
+
// with any future model/dims change.
|
|
681
|
+
const EMBEDDING_MODEL = 'text-embedding-3-large';
|
|
682
|
+
const EMBEDDING_DIMENSIONS = 1536;
|
|
683
|
+
const EMBEDDING_MODEL_MARKER = `${EMBEDDING_MODEL}@${EMBEDDING_DIMENSIONS}`;
|
|
684
|
+
|
|
621
685
|
async function embedText(text, openaiKey) {
|
|
622
686
|
try {
|
|
623
687
|
const res = await fetch('https://api.openai.com/v1/embeddings', {
|
|
@@ -626,7 +690,11 @@ async function embedText(text, openaiKey) {
|
|
|
626
690
|
'Content-Type': 'application/json',
|
|
627
691
|
'Authorization': `Bearer ${openaiKey}`,
|
|
628
692
|
},
|
|
629
|
-
body: JSON.stringify({
|
|
693
|
+
body: JSON.stringify({
|
|
694
|
+
model: EMBEDDING_MODEL,
|
|
695
|
+
dimensions: EMBEDDING_DIMENSIONS,
|
|
696
|
+
input: text,
|
|
697
|
+
}),
|
|
630
698
|
});
|
|
631
699
|
if (!res.ok) {
|
|
632
700
|
const body = await res.text().catch(() => '');
|
|
@@ -653,15 +721,27 @@ async function embedText(text, openaiKey) {
|
|
|
653
721
|
// binary is `agy` but the canonical provenance tag is `antigravity`, so the
|
|
654
722
|
// alias map below folds `agy` → `antigravity` before the allowlist check —
|
|
655
723
|
// an agy panel's memories must not be mis-tagged `claude`.
|
|
724
|
+
//
|
|
725
|
+
// Sprint 73 T1: the Grok WEB panel (web-chat-grok adapter, sessionType
|
|
726
|
+
// 'web-chat') is distinguishable from the Grok CLI — canonical tag 'grok-web'.
|
|
727
|
+
// Per the Sprint 74 ORCH one-churn addendum, the other three web-surface tags
|
|
728
|
+
// ('claude-web', 'chatgpt-web', 'gemini-web') are forward-declared in the same
|
|
729
|
+
// v4 bump; they have NO termdeck producer yet — inert acceptance-gate entries
|
|
730
|
+
// so the next web-chat adapter doesn't need another stamp/refresh cycle.
|
|
731
|
+
// ATOMIC with mnestra migration 024 (Sprint 74 T1), which adds the same four
|
|
732
|
+
// to the read-side source_agents enum + recall filter.
|
|
656
733
|
const ALLOWED_SOURCE_AGENTS = new Set([
|
|
657
734
|
'claude', 'codex', 'gemini', 'grok', 'orchestrator', 'antigravity',
|
|
735
|
+
'grok-web', 'claude-web', 'chatgpt-web', 'gemini-web',
|
|
658
736
|
]);
|
|
659
737
|
|
|
660
|
-
// Alias → canonical source_agent. Keeps the binary name (`agy`)
|
|
661
|
-
//
|
|
662
|
-
//
|
|
738
|
+
// Alias → canonical source_agent. Keeps the binary name (`agy`), the adapter
|
|
739
|
+
// REGISTRY name (`web-chat-grok` ≠ provenance tag), and any older callers from
|
|
740
|
+
// being dropped to 'claude' by the allowlist gate. Applied (after lowercasing)
|
|
741
|
+
// before the ALLOWED_SOURCE_AGENTS membership test.
|
|
663
742
|
const SOURCE_AGENT_ALIASES = {
|
|
664
743
|
agy: 'antigravity',
|
|
744
|
+
'web-chat-grok': 'grok-web',
|
|
665
745
|
};
|
|
666
746
|
|
|
667
747
|
function normalizeSourceAgent(raw) {
|
|
@@ -690,6 +770,9 @@ async function postMemoryItem({ supabaseUrl, supabaseKey, content, embedding, pr
|
|
|
690
770
|
project,
|
|
691
771
|
source_session_id: sessionId || null,
|
|
692
772
|
source_agent: normalizeSourceAgent(sourceAgent),
|
|
773
|
+
// v5 — backfill-idempotency marker (see EMBEDDING_MODEL_MARKER).
|
|
774
|
+
// memory_items.metadata exists from migration 001 (jsonb default '{}').
|
|
775
|
+
metadata: { embedding_model: EMBEDDING_MODEL_MARKER },
|
|
693
776
|
}),
|
|
694
777
|
});
|
|
695
778
|
if (!res.ok) {
|
|
@@ -825,24 +908,26 @@ async function processStdinPayload(input) {
|
|
|
825
908
|
try { stat = statSync(transcriptPath); }
|
|
826
909
|
catch (e) { log(`cannot-stat-transcript: ${transcriptPath} — ${e.message}`); return; }
|
|
827
910
|
|
|
828
|
-
// Sprint 70 T1 (A1 RED fix — ORCH 2026-06-07 19:21 ET)
|
|
829
|
-
// calibrated for verbose on-disk JSONL (claude/codex/
|
|
830
|
-
// run 10s of KB even when short). Antigravity
|
|
831
|
-
//
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
911
|
+
// Sprint 70 T1 (A1 RED fix — ORCH 2026-06-07 19:21 ET) + Sprint 73 T1. The
|
|
912
|
+
// raw-byte floor is calibrated for verbose on-disk JSONL (claude/codex/
|
|
913
|
+
// gemini/grok session files run 10s of KB even when short). Antigravity and
|
|
914
|
+
// web-chat have no on-disk transcript — their captures are synthesized
|
|
915
|
+
// COMPACT envelopes (agy: cleaned, de-chromed stdout-tee; web-chat: the
|
|
916
|
+
// in-memory turn buffer — 48/49 live Sprint-72 envelopes measured <5 KB), so
|
|
917
|
+
// a genuinely-substantive short session is legitimately <5KB and the byte
|
|
918
|
+
// floor would wrongly drop it (false zero-row). Exempt those sessionTypes
|
|
919
|
+
// from the byte floor and gate on parsed CONTENT instead — require >= 1
|
|
920
|
+
// assistant turn so an empty / no-model-output capture still no-ops. Do NOT
|
|
921
|
+
// lower the global floor; it correctly filters trivial verbose sessions for
|
|
922
|
+
// every other agent.
|
|
923
|
+
if (BYTE_FLOOR_EXEMPT_SESSION_TYPES.has(sessionType)) {
|
|
924
|
+
let exemptRaw = '';
|
|
925
|
+
try { exemptRaw = readFileSync(transcriptPath, 'utf8'); }
|
|
841
926
|
catch (e) { log(`cannot-read-transcript: ${transcriptPath} — ${e.message}`); return; }
|
|
842
|
-
const
|
|
843
|
-
const assistantTurns =
|
|
927
|
+
const exemptTurns = selectTranscriptParser(sessionType).parser(exemptRaw);
|
|
928
|
+
const assistantTurns = exemptTurns.filter((m) => m && m.role === 'assistant').length;
|
|
844
929
|
if (assistantTurns < 1) {
|
|
845
|
-
debug(
|
|
930
|
+
debug(`${sessionType}-no-assistant-turn: ${exemptTurns.length} parsed, 0 assistant — skipping`);
|
|
846
931
|
return;
|
|
847
932
|
}
|
|
848
933
|
} else if (stat.size < MIN_TRANSCRIPT_BYTES) {
|
|
@@ -943,6 +1028,15 @@ if (require.main === module) {
|
|
|
943
1028
|
// Sprint 50 T2 — source_agent provenance plumbing.
|
|
944
1029
|
normalizeSourceAgent,
|
|
945
1030
|
ALLOWED_SOURCE_AGENTS,
|
|
1031
|
+
// Sprint 73 T1 — compact-envelope sessionTypes exempt from the byte floor.
|
|
1032
|
+
BYTE_FLOOR_EXEMPT_SESSION_TYPES,
|
|
1033
|
+
// Sprint 73 T1 (v5) — recall-parity embedding contract. The pre-compact
|
|
1034
|
+
// hook reads EMBEDDING_MODEL_MARKER via loadHelpers and stamps it only
|
|
1035
|
+
// when defined, so a stale session-end beside a new pre-compact can never
|
|
1036
|
+
// false-mark rows.
|
|
1037
|
+
EMBEDDING_MODEL,
|
|
1038
|
+
EMBEDDING_DIMENSIONS,
|
|
1039
|
+
EMBEDDING_MODEL_MARKER,
|
|
946
1040
|
// Sprint 51.7 T2 — transcript-metadata extractor for memory_sessions.
|
|
947
1041
|
parseTranscriptMetadata,
|
|
948
1042
|
FACT_TOOL_NAMES,
|