@jhizzard/termdeck 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/packages/cli/src/index.js +53 -16
- package/packages/cli/src/init-mnestra.js +131 -0
- package/packages/cli/src/init.js +617 -0
- package/packages/cli/src/mcp-supabase-provision.js +685 -0
- package/packages/cli/src/os-detect.js +297 -0
- package/packages/client/public/app.js +555 -8
- package/packages/client/public/index.html +28 -6
- package/packages/client/public/style.css +127 -0
- package/packages/server/src/agent-adapters/claude.js +11 -0
- package/packages/server/src/agent-adapters/codex.js +203 -1
- package/packages/server/src/agent-adapters/gemini.js +4 -0
- package/packages/server/src/agent-adapters/grok.js +4 -0
- package/packages/server/src/database.js +20 -1
- package/packages/server/src/index.js +364 -12
- package/packages/server/src/session.js +25 -5
- package/packages/server/src/setup/supabase-mcp.js +42 -3
- package/packages/stack-installer/assets/hooks/memory-pre-compact.js +277 -0
- package/packages/stack-installer/assets/hooks/memory-session-end.js +14 -2
|
@@ -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
|
-
|
|
577
|
-
|
|
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
|
|