@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.
@@ -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
- if (handle && typeof handle.screencast === 'function') handle.screencast(onFrame);
1651
- else if (driver.cdp && typeof driver.cdp.screencast === 'function') driver.cdp.screencast(handle, onFrame);
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
- applyWebChatStatus(session, { status: 'idle', statusDetail: 'Ready' });
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-small',
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
- // Regex per BRIEF (loosened for multiple suffixes):
25
- // ^### \[(T\d+(?:-[A-Z0-9-]+)?|ORCH)\] (FINDING|PROPOSE|LANDED|DONE|AUDIT-RED|AUDIT-CONCERN|CHECKPOINT|FINAL-VERDICT|STATUS|RULING) (\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}) ET — (.*?)$
26
- const POST_RE = /^### \[(T\d+(?:-[A-Z0-9-]+)?|ORCH)\] (FINDING|PROPOSE|LANDED|DONE|AUDIT-RED|AUDIT-CONCERN|CHECKPOINT|FINAL-VERDICT|STATUS|RULING) (\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}) ET — (.*?)$/;
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.Stop`. The installer prompts you before doing this; default is
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 `Stop` hook
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. **The default table is
26
- intentionally empty** — see "Customizing the project map" below to
27
- add your own entries.
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-small` (1,536-dim).
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 an **empty `PROJECT_MAP`** by default — every
74
- session lands under `project: 'global'` until you add entries. To add
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.Stop` that references `memory-session-end.js`. Leave the
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 OpenAI text-embedding-3-small.
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>` below is read by both
47
- * stack-installer's installPreCompactHook (version-aware overwrite under
48
- * --yes) and `termdeck init --mnestra` (refreshBundledPreCompactHookIfNewer
49
- * step). Bump the integer whenever a change here should overwrite an
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
- * @termdeck/stack-installer-hook v1
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 text-embedding-3-small
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-small.
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>` below is read by both
47
- * stack-installer's installSessionEndHook (version-aware overwrite under
48
- * --yes) and `termdeck init --mnestra` (refreshBundledHookIfNewer step).
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
- * @termdeck/stack-installer-hook v3
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-small
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 text-embedding-3-small accepts up to 8192 tokens (~32K chars).
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({ model: 'text-embedding-3-small', input: text }),
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`) and any older
661
- // callers from being dropped to 'claude' by the allowlist gate. Applied (after
662
- // lowercasing) before the ALLOWED_SOURCE_AGENTS membership test.
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). The raw-byte floor is
829
- // calibrated for verbose on-disk JSONL (claude/codex/gemini/grok session files
830
- // run 10s of KB even when short). Antigravity has no on-disk transcript — its
831
- // capture is a synthesized COMPACT stdout-tee envelope (cleaned, de-chromed
832
- // content only), so a genuinely-substantive short agy session is legitimately
833
- // <5KB and the byte floor would wrongly drop it (false zero-row). Exempt
834
- // sessionType==='antigravity' from the byte floor and gate on parsed CONTENT
835
- // instead require >= 1 assistant turn so an empty / no-model-output capture
836
- // still no-ops. Do NOT lower the global floor; it correctly filters trivial
837
- // verbose sessions for every other agent.
838
- if (sessionType === 'antigravity') {
839
- let agyRaw = '';
840
- try { agyRaw = readFileSync(transcriptPath, 'utf8'); }
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 agyTurns = selectTranscriptParser(sessionType).parser(agyRaw);
843
- const assistantTurns = agyTurns.filter((m) => m && m.role === 'assistant').length;
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(`antigravity-no-assistant-turn: ${agyTurns.length} parsed, 0 assistant — skipping`);
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,