@jhizzard/termdeck 1.8.1 → 1.10.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.
@@ -1956,6 +1956,69 @@
1956
1956
  to { opacity: 1; transform: translateY(0); }
1957
1957
  }
1958
1958
 
1959
+ /* ===== Input-guard toast (Sprint 73 T3, termdeck#12) =====
1960
+ Same chrome as .proactive-toast but red-accented (it reports suppressed
1961
+ input, not a memory hit) and not cursor-pointer (clicking it does
1962
+ nothing; actions are the explicit buttons). */
1963
+ .input-guard-toast {
1964
+ position: absolute;
1965
+ right: 10px;
1966
+ bottom: 44px;
1967
+ max-width: 320px;
1968
+ padding: 8px 10px 8px 12px;
1969
+ background: rgba(23, 15, 15, 0.95);
1970
+ border: 1px solid var(--tg-accent-dim);
1971
+ border-left: 3px solid #f7768e;
1972
+ border-radius: var(--tg-radius-sm);
1973
+ box-shadow: 0 6px 18px rgba(0,0,0,0.35);
1974
+ color: var(--tg-text);
1975
+ font-size: 11px;
1976
+ z-index: 26;
1977
+ animation: toast-in 0.18s ease;
1978
+ }
1979
+ .input-guard-toast .t-title {
1980
+ font-size: 10px;
1981
+ text-transform: uppercase;
1982
+ letter-spacing: 0.4px;
1983
+ color: #f7768e;
1984
+ margin-bottom: 3px;
1985
+ }
1986
+ .input-guard-toast .t-body {
1987
+ font-size: 11px;
1988
+ line-height: 1.35;
1989
+ color: var(--tg-text);
1990
+ }
1991
+ .input-guard-toast .t-meta {
1992
+ margin-top: 4px;
1993
+ font-size: 9px;
1994
+ color: var(--tg-text-dim);
1995
+ }
1996
+ .input-guard-toast .t-dismiss {
1997
+ position: absolute;
1998
+ top: 2px;
1999
+ right: 4px;
2000
+ background: none;
2001
+ border: none;
2002
+ color: var(--tg-text-dim);
2003
+ cursor: pointer;
2004
+ font-size: 12px;
2005
+ padding: 0 4px;
2006
+ }
2007
+ .input-guard-toast .t-dismiss:hover { color: var(--tg-text); }
2008
+ .input-guard-toast .t-send-anyway {
2009
+ margin-top: 6px;
2010
+ padding: 2px 8px;
2011
+ background: none;
2012
+ border: 1px solid #f7768e;
2013
+ border-radius: var(--tg-radius-sm);
2014
+ color: #f7768e;
2015
+ font-size: 10px;
2016
+ cursor: pointer;
2017
+ }
2018
+ .input-guard-toast .t-send-anyway:hover {
2019
+ background: rgba(247, 118, 142, 0.15);
2020
+ }
2021
+
1959
2022
  /* ===== Flashback modal (Sprint 16 T2) ===== */
1960
2023
  .flashback-modal {
1961
2024
  display: none;
@@ -13,8 +13,8 @@
13
13
  // Grok's reasoning model, which the CLI rejects (`reasoningEffort` → HTTP 400;
14
14
  // see grok-models.js). Same provider, different runtime + different cost
15
15
  // realization, so it is a separate `sessionType:'web-chat'`. Provenance is
16
- // tagged `sourceAgent:'grok'` this sprint (ORCH zero-touch decision — see the
17
- // "source_agent attribution" section); a distinct 'grok-web' tag is deferred.
16
+ // tagged `sourceAgent:'grok-web'` (Sprint 73 T1 — see the "source_agent
17
+ // attribution" section), distinguishing web rows from Grok-CLI rows in Mnestra.
18
18
  //
19
19
  // ── The one hard constraint: NO node-pty, NO on-disk transcript ──────────────
20
20
  // There is no PTY stream and no conversation file on disk. The server seam
@@ -39,22 +39,22 @@
39
39
  // error) — index.js does not route web-chat text through `_detectErrors`.
40
40
  //
41
41
  // ── source_agent attribution ─────────────────────────────────────────────────
42
- // `sourceAgent:'grok'` (ORCH decision 2026-06-08, Blocker 3): we reuse the
43
- // already-allow-listed 'grok' tag so this sprint touches ZERO release-sensitive
44
- // surface the bundled hooks in packages/stack-installer/assets/hooks/* (Brad
45
- // runs the installed copy) stay pristine. The provenance is still accurate
46
- // (it IS Grok producing the content); it just doesn't yet distinguish web from
47
- // CLI. A distinct 'grok-web' tag which WOULD require adding 'grok-web' to the
48
- // hook's ALLOWED_SOURCE_AGENTS (else normalizeSourceAgent coerces the row to
49
- // 'claude') + a hook-version-stamp bump + an install refresh is deferred to a
50
- // follow-up. onPanelClose emits `adapter.sourceAgent || adapter.name`.
42
+ // `sourceAgent:'grok-web'` (Sprint 73 T1 flips the Sprint 72 ORCH zero-touch
43
+ // decision that shipped 'grok' to keep that sprint off the release-sensitive
44
+ // hook surface). Web and CLI Grok rows are now distinguishable in Mnestra:
45
+ // onPanelClose/periodic emit `adapter.sourceAgent || adapter.name`, the bundled
46
+ // hook allow-lists 'grok-web' (stamp v4, plus a `web-chat-grok` registry-name
47
+ // alias as the agy→antigravity-style safety net), and mnestra's source_agents
48
+ // enum + recall filter gain 'grok-web' via migration 024 (Sprint 74 T1
49
+ // ATOMIC release partner; neither side ships without the other, else rows are
50
+ // unfilterable or, on a stale installed hook, coerced to 'claude').
51
51
  //
52
- // Byte-floor note (also deferred with grok-web): the bundled hook skips
53
- // transcripts < 5 KB unless sessionType is specifically exempted (only
54
- // 'antigravity' is today). Our materialized envelope is compact, so a SHORT
55
- // (<5 KB) web-chat session is currently dropped — substantive auditor/worker
56
- // sessions (the real use case) run well past 5 KB and capture normally. The
57
- // exemption is a hook edit, so it rides the same deferred follow-up.
52
+ // Byte-floor (shipped with the flip, hook v4): the bundled hook skips
53
+ // transcripts < 5 KB unless the sessionType is exempted. Our materialized
54
+ // envelope is compact synthesized turn content, no JSONL metadata bloat;
55
+ // 48/49 live Sprint-72 envelopes were <5 KB — so 'web-chat' is exempted
56
+ // alongside 'antigravity', gated on parsed content (≥1 assistant turn)
57
+ // instead of raw bytes.
58
58
  //
59
59
  // Contract — see ./claude.js header for the full annotated adapter shape.
60
60
 
@@ -211,11 +211,11 @@ function bootPromptTemplate(lane = {}, sprint = {}) {
211
211
  const webChatGrokAdapter = {
212
212
  name: 'web-chat-grok',
213
213
  sessionType: 'web-chat',
214
- // ORCH decision 2026-06-08 (Blocker 3): reuse the already-allow-listed 'grok'
215
- // tag so this sprint touches zero release-sensitive hook surface. A distinct
216
- // 'grok-web' tag is deferred (needs a hook allowlist edit + version bump).
217
- // See the "source_agent attribution" header for the full rationale.
218
- sourceAgent: 'grok',
214
+ // Sprint 73 T1 distinct web provenance (was 'grok', the Sprint 72 ORCH
215
+ // zero-touch decision). Pairs ATOMICALLY with hook v4 (ALLOWED_SOURCE_AGENTS
216
+ // + byte-floor exemption) and mnestra migration 024 (Sprint 74 T1). See the
217
+ // "source_agent attribution" header for the full rationale.
218
+ sourceAgent: 'grok-web',
219
219
  // Sprint 50 T3 — human-readable label for launcher buttons + panel headers.
220
220
  displayName: 'Grok (Web)',
221
221
  // Provider URL the CDP driver navigates the dedicated-profile tab to on
@@ -64,6 +64,21 @@
64
64
 
65
65
  const http = require('http');
66
66
  const https = require('https');
67
+ // Sprint 75 T2 (part C): endpoint-shape classifier — could-not-connect
68
+ // envelopes name the IPv6-only direct endpoint as the likely cause when
69
+ // DATABASE_URL has that shape. Warn-only suffix; never changes categories.
70
+ const { classifyDbEndpoint } = require('./setup/supabase-url');
71
+
72
+ // Suffix appended to connect-failure details when DATABASE_URL is the
73
+ // IPv6-only direct endpoint (db.<project-ref>.supabase.co — AAAA-only DNS).
74
+ function directEndpointSuffix(databaseUrl) {
75
+ try {
76
+ if (classifyDbEndpoint(databaseUrl).kind === 'direct') {
77
+ return ' (DATABASE_URL is the IPv6-only db.<project-ref> direct endpoint — on IPv4-only hosts pg clients hang until a pool/connect timeout; use the Shared Pooler URL)';
78
+ }
79
+ } catch (_e) { /* warn-only */ }
80
+ return '';
81
+ }
67
82
 
68
83
  const TTL_SECONDS = 30;
69
84
  const TTL_MS = TTL_SECONDS * 1000;
@@ -276,9 +291,10 @@ async function runPgChecks({ databaseUrl, _pgClient }) {
276
291
  } else {
277
292
  // URL set but connect failed → classify by code (timeout vs unreachable).
278
293
  const cat = classifyDbFailure(connectEnvelope || {});
279
- const why = connectEnvelope && connectEnvelope.error
294
+ const why = (connectEnvelope && connectEnvelope.error
280
295
  ? `could not connect to Postgres using DATABASE_URL — ${connectEnvelope.error}`
281
- : 'could not connect to Postgres using DATABASE_URL';
296
+ : 'could not connect to Postgres using DATABASE_URL')
297
+ + directEndpointSuffix(databaseUrl);
282
298
  pushPgUnavailableChecks(checks, 'mnestra-pg', cat, why, 'pg unavailable — connect failed');
283
299
  }
284
300
  return checks;
@@ -479,7 +495,9 @@ async function checkRumenPool(config, options) {
479
495
  return warnCheck('rumen-pool', CATEGORIES.DEPENDENCY_DOWN, 'SELECT 1 returned unexpected result');
480
496
  } catch (err) {
481
497
  const cat = classifyDbFailure(err);
482
- return warnCheck('rumen-pool', cat, err && err.message ? err.message : String(err));
498
+ const detail = (err && err.message ? err.message : String(err))
499
+ + directEndpointSuffix(dbUrl);
500
+ return warnCheck('rumen-pool', cat, detail);
483
501
  } finally {
484
502
  try { await pool.end(); } catch (_e) { /* ignore */ }
485
503
  }
@@ -3467,8 +3467,13 @@ function validateSupabase(url, key) {
3467
3467
 
3468
3468
  function validateOpenAI(key) {
3469
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.
3470
3474
  const payload = JSON.stringify({
3471
- model: 'text-embedding-3-small',
3475
+ model: 'text-embedding-3-large',
3476
+ dimensions: 1536,
3472
3477
  input: 'termdeck setup test'
3473
3478
  });
3474
3479
  const req = https.request({
@@ -10,6 +10,9 @@ const http = require('http');
10
10
  const fs = require('fs');
11
11
  const os = require('os');
12
12
  const path = require('path');
13
+ // Sprint 75 T2 (part C): endpoint-shape classifier — used to explain
14
+ // connect failures against the IPv6-only direct endpoint. Warn-only.
15
+ const { classifyDbEndpoint } = require('./setup/supabase-url');
13
16
 
14
17
  // Cache preflight results for 60s
15
18
  let _cachedResult = null;
@@ -352,10 +355,16 @@ async function runPreflight(config) {
352
355
  name: 'rumen_recent', passed: false,
353
356
  detail: `check failed — ${err.message}`,
354
357
  })),
355
- checkDatabase().catch((err) => ({
356
- name: 'database_url', passed: false,
357
- detail: `connection failed ${err.message}`,
358
- })),
358
+ checkDatabase().catch((err) => {
359
+ // Sprint 75 T2 (part C): a connect failure against the IPv6-only
360
+ // direct endpoint (db.<project-ref>.supabase.co) on an IPv4-only
361
+ // host presents as a timeout — name the likely cause in the detail.
362
+ let detail = `connection failed — ${err.message}`;
363
+ if (classifyDbEndpoint(process.env.DATABASE_URL).kind === 'direct') {
364
+ detail += ' (DATABASE_URL is the IPv6-only db.<project-ref> direct endpoint — on IPv4-only hosts pg clients hang until a pool/connect timeout; use the Shared Pooler URL)';
365
+ }
366
+ return { name: 'database_url', passed: false, detail };
367
+ }),
359
368
  checkProjectPaths(config).catch((err) => ({
360
369
  name: 'project_paths', passed: false,
361
370
  detail: `check failed — ${err.message}`,
@@ -413,7 +422,7 @@ const REMEDIATION = {
413
422
  mnestra_reachable: 'Start Mnestra with `mnestra serve`',
414
423
  mnestra_has_memories: 'Run `mnestra ingest` to populate the memory store',
415
424
  rumen_recent: 'Check Rumen Edge Function deployment or run `termdeck init --rumen`',
416
- database_url: 'Set DATABASE_URL in ~/.termdeck/secrets.env',
425
+ database_url: 'Set DATABASE_URL in ~/.termdeck/secrets.env (IPv4-only hosts: use the Shared Pooler URL)',
417
426
  project_paths: 'Fix paths in ~/.termdeck/config.yaml → projects',
418
427
  shell_sanity: 'Check $SHELL and your login profile (~/.zshrc or ~/.bashrc)',
419
428
  graph_health: 'Run T2 inference cron or apply migrations 009/010 to populate edges',
@@ -0,0 +1,105 @@
1
+ // Rumen Sprint 76 — inbox-promote Supabase Edge Function entry point.
2
+ //
3
+ // Drains Mnestra's memory_inbox quarantine: web-chat proposals (written via
4
+ // the bridge's memory_propose channel into engram migration 026's table) are
5
+ // promoted to canonical memory_items or rejected with an audit trail. One
6
+ // promotion pass per invocation; see src/promote.ts in @jhizzard/rumen for
7
+ // the gate sequence (caps -> source whitelist -> rate cap -> dedup ->
8
+ // kitchen-vs-recipe) and the doctrine amendment notes.
9
+ //
10
+ // Sibling of rumen-tick by design (NOT a step inside it): budget isolation
11
+ // (the tick already spends its wall-clock on the insight cycle), independent
12
+ // cadence, and failure isolation. Same thin-wrapper pattern: the npm:
13
+ // specifier freezes the package version at DEPLOY time — upgrading
14
+ // @jhizzard/rumen does nothing until this function is redeployed (the
15
+ // Sprint 66 Brad-Rumen-zero lesson).
16
+ //
17
+ // IMPORTANT: This file targets the Deno runtime, NOT Node. It will not
18
+ // compile under the root tsconfig.json — it is intentionally excluded.
19
+ // A sibling tsconfig.json in this directory keeps the types sane for
20
+ // editors, but the canonical build target is Deno's own type checker
21
+ // (`deno check`) and Supabase's `supabase functions deploy`.
22
+ //
23
+ // Deployment (ORCH at sprint close — deployable, NOT deployed from a lane):
24
+ // supabase functions deploy inbox-promote
25
+ // supabase secrets set DATABASE_URL="$DATABASE_URL" # Shared Pooler IPv4 URL
26
+ // supabase secrets set OPENAI_API_KEY="$OPENAI_API_KEY" # dedup-gate embeddings (3-large@1536)
27
+ // supabase secrets set ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" # kitchen-vs-recipe Haiku gate
28
+ // # Optional tuning (defaults shown):
29
+ // supabase secrets set RUMEN_PROMOTE_BATCH=25
30
+ // supabase secrets set RUMEN_PROMOTE_RATE_CAP_24H=50
31
+ // supabase secrets set RUMEN_PROMOTE_MAX_ATTEMPTS=5
32
+ // supabase secrets set RUMEN_PROMOTE_CLAIM_LEASE_MINUTES=10
33
+ //
34
+ // Both model keys are REQUIRED: without them the pass skips (HTTP 503 below)
35
+ // rather than claiming rows it cannot gate — config absence must not burn
36
+ // promotion attempts across the inbox.
37
+ //
38
+ // Triggered on a schedule by pg_cron — see migrations/003_pg_cron_inbox_promote.sql.
39
+
40
+ // @ts-ignore Deno std import resolved at runtime.
41
+ import { serve } from 'https://deno.land/std@0.224.0/http/server.ts';
42
+ // @ts-ignore npm specifier resolved at runtime. Version is stamped at
43
+ // publish/deploy time by ORCH at sprint close — must be >= 0.6.0, the first
44
+ // version exporting promoteInbox.
45
+ import { promoteInbox, createPoolFromUrl } from 'npm:@jhizzard/rumen@0.6.0';
46
+
47
+ // @ts-ignore Deno global available at runtime.
48
+ declare const Deno: { env: { get: (k: string) => string | undefined } };
49
+
50
+ serve(async (_req: Request) => {
51
+ // Same fallback as rumen-tick: Supabase auto-injects SUPABASE_DB_URL.
52
+ const url = Deno.env.get('DATABASE_URL') ?? Deno.env.get('SUPABASE_DB_URL');
53
+ if (!url) {
54
+ console.error('[rumen-promote] DATABASE_URL / SUPABASE_DB_URL not set in Edge Function secrets');
55
+ return new Response(
56
+ JSON.stringify({ ok: false, error: 'DATABASE_URL not set' }),
57
+ { status: 500, headers: { 'Content-Type': 'application/json' } },
58
+ );
59
+ }
60
+
61
+ const pool = createPoolFromUrl(url);
62
+
63
+ try {
64
+ console.log('[rumen-promote] edge function pass starting');
65
+ const summary = await promoteInbox(pool);
66
+
67
+ if (summary.skipped_reason) {
68
+ // Config-level skip (missing model key, rate-accounting failure):
69
+ // surface as a non-200 so pg_cron/operator dashboards notice, but the
70
+ // inbox is untouched and simply drains on a later pass.
71
+ console.error('[rumen-promote] pass skipped: ' + summary.skipped_reason);
72
+ return new Response(
73
+ JSON.stringify({ ok: false, skipped: summary.skipped_reason, summary }),
74
+ { status: 503, headers: { 'Content-Type': 'application/json' } },
75
+ );
76
+ }
77
+
78
+ console.log(
79
+ '[rumen-promote] edge function pass complete claimed=' +
80
+ summary.claimed +
81
+ ' promoted=' +
82
+ summary.promoted +
83
+ ' rejected=' +
84
+ summary.rejected,
85
+ );
86
+ // Row-level failures are fail-soft by design — the pass itself succeeded.
87
+ return new Response(
88
+ JSON.stringify({ ok: true, summary }),
89
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
90
+ );
91
+ } catch (err) {
92
+ const message = err instanceof Error ? err.message : String(err);
93
+ console.error('[rumen-promote] edge function pass threw:', err);
94
+ return new Response(
95
+ JSON.stringify({ ok: false, error: message }),
96
+ { status: 500, headers: { 'Content-Type': 'application/json' } },
97
+ );
98
+ } finally {
99
+ try {
100
+ await pool.end();
101
+ } catch (err) {
102
+ console.error('[rumen-promote] pool.end() failed:', err);
103
+ }
104
+ }
105
+ });
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022", "DOM"],
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "allowImportingTsExtensions": false,
11
+ "types": []
12
+ },
13
+ "include": ["index.ts"]
14
+ }
@@ -186,6 +186,104 @@ function normalizeDatabaseUrl(url) {
186
186
  return { url: u.toString(), modified: true };
187
187
  }
188
188
 
189
+ // ── DATABASE_URL endpoint-shape classification (Sprint 75 T2) ──────────────
190
+ //
191
+ // Ported from engram src/db-endpoint.ts (Sprint 74 T2 — Brad's Dell R730
192
+ // field report, 2026-06-09). Supabase's direct endpoint
193
+ // `db.<project-ref>.supabase.co` — which also hosts the Dedicated Pooler on
194
+ // :6543 — publishes ONLY an AAAA record. On a host without IPv6 (many CI
195
+ // runners and VPSes) pg clients don't fail fast; they hang until a pool
196
+ // timeout. The IPv4-compatible alternative is the Shared Pooler:
197
+ //
198
+ // postgres://postgres.<project-ref>:<pw>@aws-<n>-<region>.pooler.supabase.com:6543/postgres
199
+ //
200
+ // This classifier lets every DATABASE_URL ingress warn BEFORE the first
201
+ // hang. It never rewrites or rejects anything — `looksLikePostgresUrl`
202
+ // stays the blocking validator; direct URLs remain accepted because
203
+ // IPv6-capable hosts use them legitimately. Warn ≠ reject.
204
+
205
+ const LOCAL_DB_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', '[::1]']);
206
+
207
+ // Classify a raw DATABASE_URL string by endpoint family. Returns
208
+ // { kind, host?, port?, username?, poolerUserMismatch? } where kind is one of:
209
+ // 'absent' — nothing usable provided
210
+ // 'invalid' — set but not parseable as postgres:// / postgresql://
211
+ // 'direct' — db.<project-ref>.supabase.co|in (IPv6-only: AAAA, no A
212
+ // record; covers BOTH :5432 direct and :6543 Dedicated
213
+ // Pooler — same hostname, same IPv4 unreachability)
214
+ // 'shared-pooler' — *.pooler.supabase.com (IPv4-compatible)
215
+ // 'local' — loopback/local Postgres
216
+ // 'other' — self-hosted, RDS, IPv6 literal, … (no Supabase concerns)
217
+ // poolerUserMismatch is true when the host is the Shared Pooler but the
218
+ // username lacks the mandatory `.<project-ref>` suffix — the documented
219
+ // "Tenant or user not found" failure.
220
+ function classifyDbEndpoint(raw) {
221
+ if (raw === undefined || raw === null || typeof raw !== 'string') {
222
+ return { kind: 'absent' };
223
+ }
224
+ const trimmed = stripSurroundingQuotes(raw.trim());
225
+ if (trimmed === '') return { kind: 'absent' };
226
+
227
+ let u;
228
+ try {
229
+ u = new URL(trimmed);
230
+ } catch (_err) {
231
+ return { kind: 'invalid' };
232
+ }
233
+ if (u.protocol !== 'postgres:' && u.protocol !== 'postgresql:') {
234
+ return { kind: 'invalid' };
235
+ }
236
+
237
+ // Normalize: lowercase, drop a trailing FQDN dot.
238
+ const host = u.hostname.toLowerCase().replace(/\.$/, '');
239
+ let username = '';
240
+ try {
241
+ username = decodeURIComponent(u.username);
242
+ } catch (_err) {
243
+ username = u.username;
244
+ }
245
+ const base = { host, port: u.port, username };
246
+
247
+ if (LOCAL_DB_HOSTS.has(host)) return { kind: 'local', ...base };
248
+
249
+ if (/^db\.[a-z0-9-]+\.supabase\.(co|in)$/.test(host)) {
250
+ return { kind: 'direct', ...base };
251
+ }
252
+
253
+ if (host.endsWith('.pooler.supabase.com')) {
254
+ // Shared Pooler logins are `postgres.<project-ref>` — a dotless
255
+ // username means the URL was hand-assembled from direct-connection
256
+ // parts and will fail with "Tenant or user not found".
257
+ const poolerUserMismatch = username !== '' && !username.includes('.');
258
+ return { kind: 'shared-pooler', ...base, poolerUserMismatch };
259
+ }
260
+
261
+ return { kind: 'other', ...base };
262
+ }
263
+
264
+ // Warning lines for a classification — [] when there is nothing to say.
265
+ // Wording kept byte-similar to engram's doctor probe messages so grep /
266
+ // troubleshooting stays consistent across the stack. Print-only: callers
267
+ // write these to stdout after a PASSING validation and never change exit
268
+ // codes on their account.
269
+ function directEndpointWarningLines(classification) {
270
+ if (!classification || typeof classification !== 'object') return [];
271
+ if (classification.kind === 'direct') {
272
+ return [
273
+ '⚠ this is the IPv6-only endpoint (db.<project-ref>.supabase.co — AAAA-only DNS, no IPv4)',
274
+ 'on IPv4-only hosts pg clients hang until a pool/connect timeout',
275
+ 'IPv4-safe: Connect modal → Transaction pooler → toggle ON "Use IPv4 connection (Shared Pooler)"',
276
+ 'postgres://postgres.<project-ref>:<password>@aws-<n>-<region>.pooler.supabase.com:6543/postgres'
277
+ ];
278
+ }
279
+ if (classification.kind === 'shared-pooler' && classification.poolerUserMismatch) {
280
+ return [
281
+ `⚠ Shared Pooler host but username "${classification.username}" — pooler logins must be postgres.<project-ref>; fails with "Tenant or user not found"`
282
+ ];
283
+ }
284
+ return [];
285
+ }
286
+
189
287
  // Mask all but the last 4 chars of a secret for logging.
190
288
  function maskSecret(value) {
191
289
  if (!value || typeof value !== 'string') return '';
@@ -202,5 +300,7 @@ module.exports = {
202
300
  isTransactionPoolerUrl,
203
301
  normalizeDatabaseUrl,
204
302
  maskSecret,
205
- stripSurroundingQuotes
303
+ stripSurroundingQuotes,
304
+ classifyDbEndpoint,
305
+ directEndpointWarningLines
206
306
  };
@@ -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) {