@jhizzard/termdeck 1.2.0 → 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.
@@ -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