@jhizzard/termdeck 1.1.1 → 1.3.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.
@@ -17,6 +17,34 @@ const DEFAULT_TIMEOUT_MS = 8000;
17
17
  const PACKAGE_SPEC = '@supabase/mcp-server-supabase';
18
18
  const BINARY_NAME = 'mcp-server-supabase';
19
19
 
20
+ // Sprint 64 T1 — source-side credential redaction per ORCH SCOPE 16:14 ET.
21
+ //
22
+ // JSON-RPC error messages and child stderr tails from the MCP server may
23
+ // echo back the JWT (anon/service_role keys) or the Supabase PAT that the
24
+ // caller passed in. Wrapping every error string with `redactSecrets()`
25
+ // before throwing prevents accidental leakage into stderr / logs at the
26
+ // source — defense-in-depth complementing caller-side
27
+ // `sanitizeErrorForLogs()` in `packages/cli/src/mcp-supabase-provision.js`.
28
+ //
29
+ // Patterns (greedy match — longest token wins):
30
+ // • JWT-shaped (anon / service_role keys) — `eyJ<>.<>.<>` triple-base64
31
+ // with `[A-Za-z0-9_-]{10,}` per segment. Lower bound at 10 chars per
32
+ // segment avoids false-positive matches on three-part JSON identifiers.
33
+ // • PAT-shaped (`sbp_...`) — Supabase Personal Access Tokens start with
34
+ // `sbp_` and carry 40+ URL-safe characters.
35
+ //
36
+ // Output replaces matches with `[REDACTED:JWT]` / `[REDACTED:PAT]` so a
37
+ // downstream caller can see WHAT shape was redacted without seeing the value.
38
+ const JWT_PATTERN = /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g;
39
+ const PAT_PATTERN = /sbp_[A-Za-z0-9]{40,}/g;
40
+
41
+ function redactSecrets(message) {
42
+ if (typeof message !== 'string' || message.length === 0) return message;
43
+ return message
44
+ .replace(JWT_PATTERN, '[REDACTED:JWT]')
45
+ .replace(PAT_PATTERN, '[REDACTED:PAT]');
46
+ }
47
+
20
48
  // Detect whether @supabase/mcp-server-supabase can be invoked on this host.
21
49
  // Resolution order:
22
50
  // 1. A globally installed `mcp-server-supabase` binary on PATH.
@@ -167,8 +195,12 @@ async function callTool(pat, method, params, opts) {
167
195
  }
168
196
  if (msg && msg.id === id) {
169
197
  if (msg.error) {
198
+ // Sprint 64 T1 (ORCH SCOPE 16:14 ET): redact JWT / PAT-shaped
199
+ // substrings from the propagated error message before throwing.
200
+ // Source-side defense; complements caller-side
201
+ // `sanitizeErrorForLogs()` in mcp-supabase-provision.js.
170
202
  const detail = msg.error.message || JSON.stringify(msg.error);
171
- settle(reject, new Error(detail));
203
+ settle(reject, new Error(redactSecrets(detail)));
172
204
  } else {
173
205
  settle(resolve, msg.result);
174
206
  }
@@ -179,7 +211,11 @@ async function callTool(pat, method, params, opts) {
179
211
 
180
212
  child.on('exit', (code, signal) => {
181
213
  if (settled) return;
182
- const tail = stderrBuf.slice(-512).trim();
214
+ // Sprint 64 T1 (ORCH SCOPE 16:14 ET): redact JWT / PAT-shaped
215
+ // substrings from the stderr tail before throwing. A misbehaving
216
+ // MCP child could echo back the SUPABASE_ACCESS_TOKEN env or one of
217
+ // the keys we passed in via params; the redact pass scrubs them.
218
+ const tail = redactSecrets(stderrBuf.slice(-512).trim());
183
219
  const why = signal ? `signal=${signal}` : `code=${code}`;
184
220
  settle(reject, new Error(`mcp exited (${why})${tail ? ': ' + tail : ''}`));
185
221
  });
@@ -192,4 +228,7 @@ async function callTool(pat, method, params, opts) {
192
228
  });
193
229
  }
194
230
 
195
- module.exports = { callTool, detectMcp };
231
+ module.exports = { callTool, detectMcp, redactSecrets };
232
+ // Sprint 64 T1 — exposed for fence tests in packages/cli/tests/mcp-supabase-provision.test.js.
233
+ module.exports.JWT_PATTERN = JWT_PATTERN;
234
+ module.exports.PAT_PATTERN = PAT_PATTERN;
@@ -0,0 +1,277 @@
1
+ /**
2
+ * TermDeck pre-compact memory hook (Mnestra-direct, no rag-system dependency).
3
+ *
4
+ * Vendored into ~/.claude/hooks/memory-pre-compact.js by @jhizzard/termdeck-stack.
5
+ * Wired into ~/.claude/settings.json under hooks.PreCompact — fires BEFORE
6
+ * Claude Code compacts conversation context, capturing the in-flight session
7
+ * state into Mnestra before the harness vaporizes it.
8
+ *
9
+ * Why this hook exists (Sprint 64 Investigation 2 of
10
+ * docs/CRITICAL-READ-FIRST-2026-05-07.md): every long Claude Code session that
11
+ * crosses the auto-compaction boundary loses in-context state. The global
12
+ * CLAUDE.md "Before Context Gets Long" rule was advisory and unreliably
13
+ * followed under sprint pressure. PreCompact is the deterministic signal —
14
+ * see the canonical hooks docs at https://code.claude.com/docs/en/hooks for
15
+ * the event contract — and this hook captures a session_summary-shaped row
16
+ * with `source_type='pre_compact_snapshot'` so post-compaction `memory_recall`
17
+ * can resurface what was about to be lost.
18
+ *
19
+ * Two firing modes:
20
+ *
21
+ * 1. Claude Code PreCompact (primary). STDIN shape per the docs:
22
+ * { session_id, transcript_path, cwd, hook_event_name: "PreCompact",
23
+ * trigger: "auto"|"manual" }.
24
+ *
25
+ * 2. TermDeck server periodic-capture timer (Sprint 64 T3.4). Non-Claude
26
+ * panels (Codex/Gemini/Grok) have no PreCompact-equivalent. The TermDeck
27
+ * server spawns this hook every N minutes for each active non-Claude
28
+ * panel, draining the rolling transcript to Mnestra. STDIN shape adds:
29
+ * { sessionType, source_agent, mode: "periodic_checkpoint" }.
30
+ *
31
+ * Both modes share the rest of the pipeline:
32
+ * - Load ~/.termdeck/secrets.env on env-var gaps (Sprint 47.5 discipline).
33
+ * - Parse transcript via the adapter parser exported by
34
+ * memory-session-end.js (Sprint 38 module-export contract; no duplication).
35
+ * - Embed via OpenAI text-embedding-3-small.
36
+ * - POST ONE row to /rest/v1/memory_items with
37
+ * source_type='pre_compact_snapshot', category='workflow'.
38
+ *
39
+ * Fail-soft contract: any error (network, parse, env-var-missing, malformed
40
+ * transcript) logs and exits 0. NEVER blocks compaction — PreCompact CAN block
41
+ * via exit-2/decision:block, but losing the checkpoint is bad while blocking
42
+ * compaction would be worse (the user gets stuck). Match memory-session-end.js
43
+ * fail-soft.
44
+ *
45
+ * 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
50
+ * already-installed copy. Comment-only tweaks do not need a bump.
51
+ *
52
+ * @termdeck/stack-installer-hook v1
53
+ *
54
+ * Required env vars (validated at entry, after the secrets.env fallback):
55
+ * - SUPABASE_URL e.g. https://<project-ref>.supabase.co
56
+ * - SUPABASE_SERVICE_ROLE_KEY service-role key (needs INSERT on memory_items)
57
+ * - OPENAI_API_KEY sk-... for text-embedding-3-small
58
+ *
59
+ * Optional:
60
+ * - TERMDECK_HOOK_DEBUG=1 verbose logging
61
+ * - TERMDECK_PRECOMPACT_MIN_BYTES=5000 transcript size threshold (same default as
62
+ * memory-session-end.js — sub-5KB transcripts
63
+ * don't compact anyway, but the guard keeps
64
+ * synthetic test fixtures honest)
65
+ * - TERMDECK_HOOK_HELPERS_PATH=... override the memory-session-end.js path the
66
+ * hook require()s helpers from (tests use this)
67
+ *
68
+ * Co-existence with memory-session-end.js:
69
+ * - memory-session-end.js writes source_type='session_summary' (one per SessionEnd).
70
+ * - this hook writes source_type='pre_compact_snapshot' (one per PreCompact OR
71
+ * per periodic-capture tick).
72
+ * - Same source_session_id ties them together; future `memory_recall` filters
73
+ * can include or exclude pre-compact rows independently.
74
+ */
75
+
76
+ 'use strict';
77
+
78
+ const { existsSync, readFileSync, appendFileSync, statSync } = require('fs');
79
+ const path = require('path');
80
+ const os = require('os');
81
+
82
+ const LOG_FILE = path.join(os.homedir(), '.claude', 'hooks', 'memory-hook.log');
83
+
84
+ function log(msg) {
85
+ try { appendFileSync(LOG_FILE, `[${new Date().toISOString()}] [pre-compact] ${msg}\n`); }
86
+ catch (_) { /* fail-soft */ }
87
+ }
88
+ const DEBUG = process.env.TERMDECK_HOOK_DEBUG === '1';
89
+ function debug(msg) { if (DEBUG) log(`[debug] ${msg}`); }
90
+
91
+ // Load the SessionEnd hook's helpers via the Sprint 38 module-export contract
92
+ // (`require.main === module` ⇒ CLI; else exports object). Resolved in priority:
93
+ // 1. TERMDECK_HOOK_HELPERS_PATH env var (tests).
94
+ // 2. The installed SessionEnd hook at ~/.claude/hooks/memory-session-end.js —
95
+ // production path; vendored by installSessionEndHook from the same
96
+ // stack-installer assets dir this file lives in.
97
+ // 3. The bundled SessionEnd source sibling — used when this hook is exercised
98
+ // directly from the source tree (fence tests, dev repro).
99
+ function loadHelpers() {
100
+ const override = process.env.TERMDECK_HOOK_HELPERS_PATH;
101
+ const candidates = [
102
+ override,
103
+ path.join(os.homedir(), '.claude', 'hooks', 'memory-session-end.js'),
104
+ path.join(__dirname, 'memory-session-end.js'),
105
+ ].filter(Boolean);
106
+ for (const p of candidates) {
107
+ if (!existsSync(p)) continue;
108
+ try { return require(p); }
109
+ catch (err) {
110
+ log(`helper-load-failed: ${p} — ${err && err.message ? err.message : String(err)}`);
111
+ }
112
+ }
113
+ log('helpers-not-found: pre-compact hook needs memory-session-end.js bundled at ~/.claude/hooks/. Install via `npx @jhizzard/termdeck-stack`.');
114
+ return null;
115
+ }
116
+
117
+ const MIN_TRANSCRIPT_BYTES_PRE_COMPACT =
118
+ parseInt(process.env.TERMDECK_PRECOMPACT_MIN_BYTES || '5000', 10);
119
+
120
+ // Inline POST to /rest/v1/memory_items so we can set source_type='pre_compact_snapshot'
121
+ // without touching memory-session-end.js's postMemoryItem (which hardcodes
122
+ // source_type='session_summary'). Inlining keeps the Sprint 62 close-out path
123
+ // untouched — zero regression risk for SessionEnd flow.
124
+ async function postPreCompactSnapshot({
125
+ supabaseUrl, supabaseKey,
126
+ content, embedding,
127
+ project, sessionId,
128
+ sourceAgent,
129
+ }) {
130
+ try {
131
+ const res = await fetch(`${supabaseUrl}/rest/v1/memory_items`, {
132
+ method: 'POST',
133
+ headers: {
134
+ 'Content-Type': 'application/json',
135
+ 'apikey': supabaseKey,
136
+ 'Authorization': `Bearer ${supabaseKey}`,
137
+ 'Prefer': 'return=minimal',
138
+ },
139
+ body: JSON.stringify({
140
+ content,
141
+ embedding: `[${embedding.join(',')}]`,
142
+ source_type: 'pre_compact_snapshot',
143
+ category: 'workflow',
144
+ project,
145
+ source_session_id: sessionId || null,
146
+ source_agent: sourceAgent,
147
+ }),
148
+ });
149
+ if (!res.ok) {
150
+ const body = await res.text().catch(() => '');
151
+ log(`supabase-insert-failed: HTTP ${res.status} ${body.slice(0, 200)}`);
152
+ return false;
153
+ }
154
+ return true;
155
+ } catch (e) {
156
+ log(`supabase-insert-exception: ${e.message}`);
157
+ return false;
158
+ }
159
+ }
160
+
161
+ // Distinguishes the two firing modes from the STDIN payload shape:
162
+ // - { hook_event_name: "PreCompact", trigger } → Claude Code harness
163
+ // - { mode: "periodic_checkpoint", sessionType } → TermDeck server timer
164
+ // Returns { mode, trigger, sessionType, sourceAgent } resolved per mode.
165
+ function resolveFiringContext(data, helpers) {
166
+ const isClaudePreCompact = data && data.hook_event_name === 'PreCompact';
167
+ if (isClaudePreCompact) {
168
+ return {
169
+ mode: 'pre_compact',
170
+ trigger: data.trigger === 'manual' ? 'manual' : 'auto',
171
+ // Claude Code's PreCompact payload doesn't carry sessionType; default
172
+ // to the canonical adapter name so buildSummary's parser dispatch picks
173
+ // parseClaudeJsonl. Codex/Gemini/Grok never reach this branch — they
174
+ // route through the server-side periodic-capture branch below.
175
+ sessionType: 'claude-code',
176
+ sourceAgent: helpers.normalizeSourceAgent(data.source_agent || 'claude'),
177
+ };
178
+ }
179
+ return {
180
+ mode: 'periodic_checkpoint',
181
+ trigger: 'periodic',
182
+ sessionType: data.sessionType || data.session_type || 'claude-code',
183
+ sourceAgent: helpers.normalizeSourceAgent(data.source_agent || 'claude'),
184
+ };
185
+ }
186
+
187
+ async function processPreCompactPayload(input, helpers) {
188
+ let data;
189
+ try { data = JSON.parse(input); }
190
+ catch (e) { log(`parse-stdin-failed: ${e.message}`); return { status: 'parse-failed' }; }
191
+
192
+ const transcriptPath = data.transcript_path;
193
+ const cwd = data.cwd || '';
194
+ const sessionId = data.session_id || null;
195
+ if (!transcriptPath) { log('no-transcript-path: skipping'); return { status: 'no-transcript-path' }; }
196
+ if (!sessionId) { log('no-session-id: skipping'); return { status: 'no-session-id' }; }
197
+
198
+ const { mode, trigger, sessionType, sourceAgent } = resolveFiringContext(data, helpers);
199
+
200
+ let stat;
201
+ try { stat = statSync(transcriptPath); }
202
+ catch (e) {
203
+ log(`cannot-stat-transcript: ${transcriptPath} — ${e.message}`);
204
+ return { status: 'cannot-stat-transcript' };
205
+ }
206
+
207
+ if (stat.size < MIN_TRANSCRIPT_BYTES_PRE_COMPACT) {
208
+ debug(`small-transcript: ${stat.size} bytes — skipping ${mode} checkpoint`);
209
+ return { status: 'small-transcript' };
210
+ }
211
+
212
+ const env = helpers.readEnv();
213
+ if (!env) return { status: 'env-missing' };
214
+
215
+ const project = helpers.detectProject(cwd);
216
+ const built = helpers.buildSummary(transcriptPath, sessionType);
217
+ if (!built) {
218
+ debug(`buildSummary-skipped: <5 messages (parser=${sessionType}) — ${mode} hook bailing gracefully`);
219
+ return { status: 'too-few-messages' };
220
+ }
221
+ const { summary: baseSummary, messagesCount, durationMinutes, factsExtracted } = built;
222
+
223
+ // Prepend a checkpoint header so memory_recall results read clearly. Plain
224
+ // text (not JSON) so the line survives the embed roundtrip intact and is
225
+ // operator-readable in recall output. Fields chosen for at-a-glance
226
+ // recovery semantics: mode tells you what fired, trigger distinguishes
227
+ // auto-vs-manual-vs-periodic, agent says whose context this slice is from.
228
+ const header =
229
+ `[CHECKPOINT mode=${mode} trigger=${trigger} ` +
230
+ `agent=${sourceAgent} ` +
231
+ `messages=${messagesCount} ` +
232
+ `duration=${durationMinutes === null ? 'unknown' : `${durationMinutes}m`} ` +
233
+ `facts_remembered=${factsExtracted}]`;
234
+ const content = `${header}\n\n${baseSummary}`.slice(0, 7000);
235
+
236
+ const embedding = await helpers.embedText(content, env.openaiKey);
237
+ if (!embedding) return { status: 'embed-failed' };
238
+
239
+ const ok = await postPreCompactSnapshot({
240
+ supabaseUrl: env.supabaseUrl,
241
+ supabaseKey: env.supabaseKey,
242
+ content, embedding, project, sessionId, sourceAgent,
243
+ });
244
+
245
+ if (ok) {
246
+ log(`ingested-${mode}: project="${project}" session=${sessionId} trigger=${trigger} agent=${sourceAgent} bytes=${content.length} messages=${messagesCount} factsExtracted=${factsExtracted}`);
247
+ return { status: 'ingested', project, sessionId, sourceAgent, mode, trigger, messagesCount };
248
+ }
249
+ return { status: 'insert-failed' };
250
+ }
251
+
252
+ // Module-export contract — when run as a script, read stdin and process; when
253
+ // require()'d (tests + the TermDeck server's periodic-capture spawn helper),
254
+ // expose the inner functions.
255
+ if (require.main === module) {
256
+ let input = '';
257
+ process.stdin.setEncoding('utf8');
258
+ process.stdin.on('data', (chunk) => { input += chunk; });
259
+ process.stdin.on('end', () => {
260
+ const helpers = loadHelpers();
261
+ if (!helpers) { process.exit(0); return; }
262
+ processPreCompactPayload(input, helpers)
263
+ .catch((e) => log(`hook-error: ${e && e.message ? e.message : String(e)}`))
264
+ // Fail-soft: ALWAYS exit 0. Blocking compaction (exit 2 per Claude Code's
265
+ // hook contract) costs more than skipping a checkpoint.
266
+ .finally(() => process.exit(0));
267
+ });
268
+ } else {
269
+ module.exports = {
270
+ loadHelpers,
271
+ postPreCompactSnapshot,
272
+ processPreCompactPayload,
273
+ resolveFiringContext,
274
+ LOG_FILE,
275
+ MIN_TRANSCRIPT_BYTES_PRE_COMPACT,
276
+ };
277
+ }
@@ -138,6 +138,16 @@ const PROJECT_MAP = [
138
138
  ];
139
139
 
140
140
  const MIN_TRANSCRIPT_BYTES = parseInt(process.env.TERMDECK_HOOK_MIN_BYTES || '5000', 10);
141
+ // Sprint 64 T2 (carve-out 2.2) — Sprint 63 EXIT-CAPTURE-VERIFICATION.md
142
+ // Finding #3 documented Brad's grok-canary panel silent-skipped at 4 msgs /
143
+ // 6,713 bytes of canary content because the legacy hard-coded `< 5 messages`
144
+ // gate fired before the MIN_TRANSCRIPT_BYTES floor could even matter. Codex
145
+ // audit posts are similarly content-rich-but-message-count-poor (one canonical
146
+ // turn). Default lowered to 1 — the 5 KB byte gate at `:140 + :795 + the
147
+ // `MIN_TRANSCRIPT_BYTES`-based skip at line 795` is now the sole primary noise
148
+ // filter; sub-5KB drips still get dropped. Env-configurable for operators who
149
+ // want the legacy permissive-to-zero floor or a higher cutoff than 1.
150
+ const MIN_TRANSCRIPT_MESSAGES = parseInt(process.env.TERMDECK_HOOK_MIN_MESSAGES || '1', 10);
141
151
  const DEBUG = process.env.TERMDECK_HOOK_DEBUG === '1';
142
152
 
143
153
  function log(msg) {
@@ -573,8 +583,10 @@ function buildSummary(transcriptPath, sessionType) {
573
583
 
574
584
  const messages = parser(raw);
575
585
 
576
- if (messages.length < 5) {
577
- debug(`session-too-short: ${messages.length} messages (parser=${resolvedType}), skipping`);
586
+ // Sprint 64 T2 (carve-out 2.2) env-configurable floor, default 1. See
587
+ // `MIN_TRANSCRIPT_MESSAGES` declaration for the Brad grok-canary rationale.
588
+ if (messages.length < MIN_TRANSCRIPT_MESSAGES) {
589
+ debug(`session-too-short: ${messages.length} messages (parser=${resolvedType}), skipping (TERMDECK_HOOK_MIN_MESSAGES=${MIN_TRANSCRIPT_MESSAGES})`);
578
590
  return null;
579
591
  }
580
592