@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.
@@ -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 Code Stop payload).
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. Builds a coarse session summary from the transcript (last ~30 message excerpts).
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://luvvbrpaopnblvxdxwzb.supabase.co
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
- function buildSummary(transcriptPath) {
84
- let raw;
85
- try { raw = readFileSync(transcriptPath, 'utf8'); }
86
- catch (e) { log(`read-transcript-failed: ${e.message}`); return null; }
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?.message?.role;
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') text = content;
98
- else if (Array.isArray(content)) {
99
- text = content.filter((c) => c && c.type === 'text').map((c) => c.text).join(' ');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck-stack",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
4
4
  "description": "One-command installer for the TermDeck developer memory stack: TermDeck + Mnestra + Rumen + Supabase MCP",
5
5
  "bin": {
6
6
  "termdeck-stack": "./src/index.js"