@jhizzard/termdeck-stack 0.4.8 → 0.4.9
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/assets/hooks/memory-session-end.js +212 -15
- package/package.json +1 -1
|
@@ -5,24 +5,30 @@
|
|
|
5
5
|
* Wired into ~/.claude/settings.json under hooks.Stop. Fires on Claude Code Stop event.
|
|
6
6
|
*
|
|
7
7
|
* Behavior:
|
|
8
|
-
* 1. Reads {transcript_path, cwd, session_id} from stdin (Claude
|
|
8
|
+
* 1. Reads {transcript_path, cwd, session_id, sessionType?} from stdin (Claude
|
|
9
|
+
* Code Stop payload, or a future server-driven invocation for non-Claude
|
|
10
|
+
* agents).
|
|
9
11
|
* 2. Skips small transcripts (< MIN_TRANSCRIPT_BYTES, default 5KB).
|
|
10
12
|
* 3. Validates env vars; logs and exits cleanly if any required key is missing.
|
|
11
13
|
* 4. Detects project from cwd against PROJECT_MAP (else "global"). Extend the
|
|
12
14
|
* map by editing the array below — see assets/hooks/README.md for guidance.
|
|
13
|
-
* 5.
|
|
15
|
+
* 5. Dispatches to a transcript parser by sessionType (Sprint 45 T4): Claude
|
|
16
|
+
* JSONL, Codex JSONL, Gemini single-JSON, or auto-detect when sessionType
|
|
17
|
+
* is absent. Builds a coarse summary from the resulting message list
|
|
18
|
+
* (last ~30 message excerpts).
|
|
14
19
|
* 6. Embeds the summary via OpenAI text-embedding-3-small.
|
|
15
20
|
* 7. POSTs ONE row to Supabase /rest/v1/memory_items with source_type='session_summary'.
|
|
16
21
|
* 8. Logs every step to ~/.claude/hooks/memory-hook.log.
|
|
17
22
|
*
|
|
18
23
|
* Required env vars (validated at entry):
|
|
19
|
-
* - SUPABASE_URL e.g. https
|
|
24
|
+
* - SUPABASE_URL e.g. https://<project-ref>.supabase.co
|
|
20
25
|
* - SUPABASE_SERVICE_KEY service-role key (NOT the anon key — needs INSERT on memory_items)
|
|
21
26
|
* - OPENAI_API_KEY sk-... for text-embedding-3-small
|
|
22
27
|
*
|
|
23
28
|
* Optional:
|
|
24
29
|
* - TERMDECK_HOOK_DEBUG=1 verbose logging
|
|
25
30
|
* - TERMDECK_HOOK_MIN_BYTES=5000 transcript size threshold
|
|
31
|
+
* - TERMDECK_SESSION_TYPE=... override sessionType when payload lacks it
|
|
26
32
|
*
|
|
27
33
|
* Fail-soft contract: any error (network, parse, env-var-missing, malformed transcript)
|
|
28
34
|
* logs and exits 0. Never blocks Claude Code session close.
|
|
@@ -80,29 +86,202 @@ function readEnv() {
|
|
|
80
86
|
};
|
|
81
87
|
}
|
|
82
88
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
89
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
90
|
+
// Sprint 45 T4 — adapter-pluggable transcript parsers.
|
|
91
|
+
//
|
|
92
|
+
// Each parser takes raw transcript file contents (string) and returns a
|
|
93
|
+
// `{ role: 'user'|'assistant', content: string }[]` array — the shape
|
|
94
|
+
// buildSummary() consumes. Adapters in packages/server/src/agent-adapters/
|
|
95
|
+
// own the canonical parser logic; this file inlines copies because the
|
|
96
|
+
// hook ships standalone to ~/.claude/hooks/ where it can't `require()`
|
|
97
|
+
// from the TermDeck server package. When new agents add adapters, mirror
|
|
98
|
+
// their parseTranscript function body here — keep the two in sync.
|
|
99
|
+
// (Sprint 46 candidate: a sync script that codegens this section from
|
|
100
|
+
// agent-adapters/*.js, analogous to scripts/sync-agent-instructions.js
|
|
101
|
+
// for CLAUDE.md / AGENTS.md / GEMINI.md mirroring.)
|
|
102
|
+
//
|
|
103
|
+
// When sessionType is absent or unknown, parseAutoDetect runs a per-line
|
|
104
|
+
// best-effort that handles Claude JSONL, Codex JSONL, AND Gemini's single
|
|
105
|
+
// JSON-object shape. This is the pre-T4 stop-gap T1+T2 landed inline —
|
|
106
|
+
// preserved as the fallback so existing hook payloads (Claude Code Stop,
|
|
107
|
+
// no sessionType field) continue working for any of the three agents.
|
|
108
|
+
// Once Sprint 46 wires sessionType into payloads, the auto path narrows
|
|
109
|
+
// to a legacy compatibility role.
|
|
110
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
87
111
|
|
|
112
|
+
function parseClaudeJsonl(raw) {
|
|
113
|
+
if (typeof raw !== 'string' || raw.length === 0) return [];
|
|
88
114
|
const lines = raw.split('\n').filter(Boolean);
|
|
89
115
|
const messages = [];
|
|
90
116
|
for (const line of lines) {
|
|
91
117
|
let msg;
|
|
92
118
|
try { msg = JSON.parse(line); } catch (_) { continue; }
|
|
93
|
-
const role = msg
|
|
119
|
+
const role = msg && msg.message && msg.message.role;
|
|
94
120
|
if (role !== 'user' && role !== 'assistant') continue;
|
|
95
121
|
const content = msg.message.content;
|
|
96
122
|
let text = '';
|
|
97
|
-
if (typeof content === 'string')
|
|
98
|
-
|
|
99
|
-
|
|
123
|
+
if (typeof content === 'string') {
|
|
124
|
+
text = content;
|
|
125
|
+
} else if (Array.isArray(content)) {
|
|
126
|
+
text = content
|
|
127
|
+
.filter((c) => c && c.type === 'text')
|
|
128
|
+
.map((c) => c.text || '')
|
|
129
|
+
.join(' ');
|
|
100
130
|
}
|
|
101
131
|
if (text) messages.push({ role, content: text.slice(0, 400) });
|
|
102
132
|
}
|
|
133
|
+
return messages;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseCodexJsonl(raw) {
|
|
137
|
+
if (typeof raw !== 'string' || raw.length === 0) return [];
|
|
138
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
139
|
+
const messages = [];
|
|
140
|
+
for (const line of lines) {
|
|
141
|
+
let msg;
|
|
142
|
+
try { msg = JSON.parse(line); } catch (_) { continue; }
|
|
143
|
+
if (!msg || msg.type !== 'response_item') continue;
|
|
144
|
+
const payload = msg.payload;
|
|
145
|
+
if (!payload || payload.type !== 'message') continue;
|
|
146
|
+
const role = payload.role;
|
|
147
|
+
// Codex's `developer` role carries the sandbox/permissions prelude — skip.
|
|
148
|
+
if (role !== 'user' && role !== 'assistant') continue;
|
|
149
|
+
const content = payload.content;
|
|
150
|
+
let text = '';
|
|
151
|
+
if (typeof content === 'string') {
|
|
152
|
+
text = content;
|
|
153
|
+
} else if (Array.isArray(content)) {
|
|
154
|
+
// Codex uses `input_text` (user) and `output_text` (assistant); accept
|
|
155
|
+
// plain `text` for forward-compat with future Codex CLI versions.
|
|
156
|
+
text = content
|
|
157
|
+
.filter((c) => c && (c.type === 'input_text' || c.type === 'output_text' || c.type === 'text'))
|
|
158
|
+
.map((c) => c.text || '')
|
|
159
|
+
.join(' ');
|
|
160
|
+
}
|
|
161
|
+
if (text) messages.push({ role, content: text.slice(0, 400) });
|
|
162
|
+
}
|
|
163
|
+
return messages;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseGeminiJson(raw) {
|
|
167
|
+
// Gemini CLI persists each session as a single JSON object (NOT JSONL):
|
|
168
|
+
// { sessionId, projectHash, startTime, lastUpdated, kind,
|
|
169
|
+
// messages: [{ id, timestamp, type: 'user'|'gemini', content }] }
|
|
170
|
+
// user content: [{ text }]; gemini content: string. Map type='gemini' →
|
|
171
|
+
// role='assistant' to match the rest of the dispatch shape.
|
|
172
|
+
if (typeof raw !== 'string' || raw.length === 0) return [];
|
|
173
|
+
let obj;
|
|
174
|
+
try { obj = JSON.parse(raw); } catch (_) { return []; }
|
|
175
|
+
if (!obj || !Array.isArray(obj.messages)) return [];
|
|
176
|
+
const messages = [];
|
|
177
|
+
for (const msg of obj.messages) {
|
|
178
|
+
if (!msg || typeof msg !== 'object') continue;
|
|
179
|
+
let role;
|
|
180
|
+
if (msg.type === 'user') role = 'user';
|
|
181
|
+
else if (msg.type === 'gemini' || msg.type === 'assistant') role = 'assistant';
|
|
182
|
+
else continue;
|
|
183
|
+
const content = msg.content;
|
|
184
|
+
let text = '';
|
|
185
|
+
if (typeof content === 'string') {
|
|
186
|
+
text = content;
|
|
187
|
+
} else if (Array.isArray(content)) {
|
|
188
|
+
text = content
|
|
189
|
+
.filter((c) => c && typeof c.text === 'string')
|
|
190
|
+
.map((c) => c.text)
|
|
191
|
+
.join(' ');
|
|
192
|
+
}
|
|
193
|
+
if (text) messages.push({ role, content: text.slice(0, 400) });
|
|
194
|
+
}
|
|
195
|
+
return messages;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function parseAutoDetect(raw) {
|
|
199
|
+
// Fallback when sessionType is absent. Tries Gemini's single-JSON shape
|
|
200
|
+
// first (cheap to detect — starts with `{` and has a top-level `messages`
|
|
201
|
+
// array), then falls through to per-line Claude/Codex JSONL detection.
|
|
202
|
+
// This preserves T1+T2's pre-T4 multi-shape stop-gap so any Claude Code
|
|
203
|
+
// Stop payload (which doesn't carry sessionType) keeps ingesting whichever
|
|
204
|
+
// CLI's transcript path landed there.
|
|
205
|
+
if (typeof raw !== 'string' || raw.length === 0) return [];
|
|
206
|
+
|
|
207
|
+
const trimmed = raw.trim();
|
|
208
|
+
if (trimmed.startsWith('{')) {
|
|
209
|
+
const geminiTry = parseGeminiJson(raw);
|
|
210
|
+
if (geminiTry.length > 0) return geminiTry;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
214
|
+
const messages = [];
|
|
215
|
+
for (const line of lines) {
|
|
216
|
+
let msg;
|
|
217
|
+
try { msg = JSON.parse(line); } catch (_) { continue; }
|
|
218
|
+
|
|
219
|
+
let role;
|
|
220
|
+
let content;
|
|
221
|
+
let textBlockType = 'text';
|
|
222
|
+
|
|
223
|
+
if (msg && msg.message && (msg.message.role === 'user' || msg.message.role === 'assistant')) {
|
|
224
|
+
role = msg.message.role;
|
|
225
|
+
content = msg.message.content;
|
|
226
|
+
} else if (msg && msg.type === 'response_item' && msg.payload && msg.payload.type === 'message') {
|
|
227
|
+
role = msg.payload.role;
|
|
228
|
+
if (role !== 'user' && role !== 'assistant') continue;
|
|
229
|
+
content = msg.payload.content;
|
|
230
|
+
textBlockType = null; // Codex content blocks use input_text/output_text
|
|
231
|
+
} else {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let text = '';
|
|
236
|
+
if (typeof content === 'string') {
|
|
237
|
+
text = content;
|
|
238
|
+
} else if (Array.isArray(content)) {
|
|
239
|
+
text = content
|
|
240
|
+
.filter((c) => c && (
|
|
241
|
+
textBlockType === null
|
|
242
|
+
? (c.type === 'input_text' || c.type === 'output_text' || c.type === 'text')
|
|
243
|
+
: c.type === textBlockType
|
|
244
|
+
))
|
|
245
|
+
.map((c) => c.text || '')
|
|
246
|
+
.join(' ');
|
|
247
|
+
}
|
|
248
|
+
if (text) messages.push({ role, content: text.slice(0, 400) });
|
|
249
|
+
}
|
|
250
|
+
return messages;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const TRANSCRIPT_PARSERS = {
|
|
254
|
+
'claude-code': parseClaudeJsonl,
|
|
255
|
+
'codex': parseCodexJsonl,
|
|
256
|
+
'gemini': parseGeminiJson,
|
|
257
|
+
// Sprint 45 T3 — grok parser entry goes here once the adapter lands.
|
|
258
|
+
// Source-of-truth lives in packages/server/src/agent-adapters/grok.js;
|
|
259
|
+
// mirror that adapter's parseTranscript function body into this dispatch
|
|
260
|
+
// table at sprint close so the bundled hook can ingest grok transcripts.
|
|
261
|
+
};
|
|
262
|
+
const DEFAULT_SESSION_TYPE = 'auto';
|
|
263
|
+
|
|
264
|
+
function selectTranscriptParser(sessionType) {
|
|
265
|
+
if (sessionType && TRANSCRIPT_PARSERS[sessionType]) {
|
|
266
|
+
return { parser: TRANSCRIPT_PARSERS[sessionType], sessionType };
|
|
267
|
+
}
|
|
268
|
+
return { parser: parseAutoDetect, sessionType: 'auto' };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function buildSummary(transcriptPath, sessionType) {
|
|
272
|
+
let raw;
|
|
273
|
+
try { raw = readFileSync(transcriptPath, 'utf8'); }
|
|
274
|
+
catch (e) { log(`read-transcript-failed: ${e.message}`); return null; }
|
|
275
|
+
|
|
276
|
+
const { parser, sessionType: resolvedType } = selectTranscriptParser(sessionType);
|
|
277
|
+
if (sessionType && resolvedType !== sessionType) {
|
|
278
|
+
debug(`unknown-session-type="${sessionType}", falling back to ${resolvedType}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const messages = parser(raw);
|
|
103
282
|
|
|
104
283
|
if (messages.length < 5) {
|
|
105
|
-
debug(`session-too-short: ${messages.length} messages, skipping`);
|
|
284
|
+
debug(`session-too-short: ${messages.length} messages (parser=${resolvedType}), skipping`);
|
|
106
285
|
return null;
|
|
107
286
|
}
|
|
108
287
|
|
|
@@ -180,6 +359,16 @@ async function processStdinPayload(input) {
|
|
|
180
359
|
data.session_id ||
|
|
181
360
|
(transcriptPath ? transcriptPath.split('/').pop().replace('.jsonl', '') : null);
|
|
182
361
|
|
|
362
|
+
// Sprint 45 T4: sessionType drives buildSummary's parser dispatch.
|
|
363
|
+
// Read order: payload (server-driven invocations) → env var (TermDeck
|
|
364
|
+
// server can set TERMDECK_SESSION_TYPE in the spawned PTY's env) →
|
|
365
|
+
// 'auto' default (parseAutoDetect handles Claude + Codex + Gemini).
|
|
366
|
+
const sessionType =
|
|
367
|
+
data.sessionType ||
|
|
368
|
+
data.session_type ||
|
|
369
|
+
process.env.TERMDECK_SESSION_TYPE ||
|
|
370
|
+
DEFAULT_SESSION_TYPE;
|
|
371
|
+
|
|
183
372
|
if (!transcriptPath) { log('no-transcript-path: skipping'); return; }
|
|
184
373
|
|
|
185
374
|
let stat;
|
|
@@ -195,9 +384,9 @@ async function processStdinPayload(input) {
|
|
|
195
384
|
if (!env) return;
|
|
196
385
|
|
|
197
386
|
const project = detectProject(cwd);
|
|
198
|
-
debug(`project="${project}", session=${sessionId}`);
|
|
387
|
+
debug(`project="${project}", session=${sessionId}, sessionType=${sessionType}`);
|
|
199
388
|
|
|
200
|
-
const summary = buildSummary(transcriptPath);
|
|
389
|
+
const summary = buildSummary(transcriptPath, sessionType);
|
|
201
390
|
if (!summary) return;
|
|
202
391
|
|
|
203
392
|
const embedding = await embedText(summary, env.openaiKey);
|
|
@@ -212,7 +401,7 @@ async function processStdinPayload(input) {
|
|
|
212
401
|
sessionId,
|
|
213
402
|
});
|
|
214
403
|
|
|
215
|
-
if (ok) log(`ingested: project="${project}" session=${sessionId} bytes=${summary.length}`);
|
|
404
|
+
if (ok) log(`ingested: project="${project}" session=${sessionId} bytes=${summary.length} sessionType=${sessionType}`);
|
|
216
405
|
}
|
|
217
406
|
|
|
218
407
|
// Module-export contract for testability. When run as a script (require.main === module),
|
|
@@ -234,5 +423,13 @@ if (require.main === module) {
|
|
|
234
423
|
postMemoryItem,
|
|
235
424
|
processStdinPayload,
|
|
236
425
|
LOG_FILE,
|
|
426
|
+
// Sprint 45 T4 — adapter-pluggable transcript-parser surface.
|
|
427
|
+
TRANSCRIPT_PARSERS,
|
|
428
|
+
DEFAULT_SESSION_TYPE,
|
|
429
|
+
parseClaudeJsonl,
|
|
430
|
+
parseCodexJsonl,
|
|
431
|
+
parseGeminiJson,
|
|
432
|
+
parseAutoDetect,
|
|
433
|
+
selectTranscriptParser,
|
|
237
434
|
};
|
|
238
435
|
}
|
package/package.json
CHANGED