@jhizzard/termdeck 1.0.1 → 1.0.2
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 -1
- package/packages/cli/src/init-mnestra.js +109 -0
- package/packages/cli/src/init-rumen.js +35 -8
- package/packages/server/src/setup/audit-upgrade.js +126 -3
- package/packages/server/src/setup/mnestra-migrations/017_memory_sessions_session_metadata.sql +94 -0
- package/packages/stack-installer/README.md +73 -0
- package/packages/stack-installer/assets/hooks/README.md +172 -0
- package/packages/stack-installer/assets/hooks/memory-session-end.js +740 -0
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TermDeck session-end memory hook (Mnestra-direct, no rag-system dependency).
|
|
3
|
+
*
|
|
4
|
+
* Vendored into ~/.claude/hooks/memory-session-end.js by @jhizzard/termdeck-stack.
|
|
5
|
+
* Wired into ~/.claude/settings.json under hooks.SessionEnd — fires once per
|
|
6
|
+
* Claude Code session close (`/exit`, Ctrl+D, terminal close, or process kill).
|
|
7
|
+
*
|
|
8
|
+
* History: this hook was originally registered under hooks.Stop, which fires
|
|
9
|
+
* after every assistant turn. That meant the same transcript got embedded and
|
|
10
|
+
* INSERTed dozens of times per session (and most fired with env-var-missing
|
|
11
|
+
* because Claude Code launched outside TermDeck doesn't have SUPABASE_URL in
|
|
12
|
+
* scope). Sprint 48 close-out moved registration to SessionEnd (one row per
|
|
13
|
+
* session, fires deterministically on /exit) AND added the secrets-env
|
|
14
|
+
* fallback below so a standalone-Claude-Code launch picks up the credentials
|
|
15
|
+
* without needing them in the parent shell.
|
|
16
|
+
*
|
|
17
|
+
* Behavior:
|
|
18
|
+
* 1. Reads {transcript_path, cwd, session_id, sessionType?, source_agent?}
|
|
19
|
+
* from stdin (Claude Code SessionEnd payload, or — Sprint 50 T1 — a
|
|
20
|
+
* server-driven invocation for non-Claude agents). source_agent
|
|
21
|
+
* defaults to 'claude' when absent (Claude Code's existing hook
|
|
22
|
+
* payload doesn't carry it; the TermDeck server's per-adapter
|
|
23
|
+
* onPanelClose interceptor sets it explicitly for codex/gemini/grok).
|
|
24
|
+
* 2. Loads ~/.termdeck/secrets.env into process.env if any required key is
|
|
25
|
+
* absent OR is a literal `${VAR}` placeholder (Sprint 47.5 hotfix
|
|
26
|
+
* discipline — Claude Code does not expand `${VAR}` in MCP env, and we
|
|
27
|
+
* can't trust the parent shell to have sourced secrets.env).
|
|
28
|
+
* 3. Skips small transcripts (< MIN_TRANSCRIPT_BYTES, default 5KB).
|
|
29
|
+
* 4. Validates env vars; logs and exits cleanly if any required key is still
|
|
30
|
+
* missing after the secrets.env fallback.
|
|
31
|
+
* 5. Detects project from cwd against PROJECT_MAP (else "global"). Extend the
|
|
32
|
+
* map by editing the array below — see assets/hooks/README.md for guidance.
|
|
33
|
+
* 6. Dispatches to a transcript parser by sessionType (Sprint 45 T4): Claude
|
|
34
|
+
* JSONL, Codex JSONL, Gemini single-JSON, or auto-detect when sessionType
|
|
35
|
+
* is absent. Builds a coarse summary from the resulting message list
|
|
36
|
+
* (last ~30 message excerpts).
|
|
37
|
+
* 7. Embeds the summary via OpenAI text-embedding-3-small.
|
|
38
|
+
* 8. POSTs ONE row to Supabase /rest/v1/memory_items with source_type='session_summary'.
|
|
39
|
+
* 9. (Sprint 51.6 T3) POSTs ONE row to Supabase /rest/v1/memory_sessions with
|
|
40
|
+
* Prefer: resolution=merge-duplicates so SessionEnd-fires-twice resolves
|
|
41
|
+
* to a single row. Requires Mnestra migration 017 on canonical installs;
|
|
42
|
+
* petvetbid already has the rich schema from rag-system bootstrap.
|
|
43
|
+
* 10. Logs every step to ~/.claude/hooks/memory-hook.log.
|
|
44
|
+
*
|
|
45
|
+
* Version stamp (Sprint 51.6 T3 — hook upgrade gap fix):
|
|
46
|
+
* The marker `@termdeck/stack-installer-hook v<N>` below is read by both
|
|
47
|
+
* stack-installer's installSessionEndHook (version-aware overwrite under
|
|
48
|
+
* --yes) and `termdeck init --mnestra` (refreshBundledHookIfNewer step).
|
|
49
|
+
* Bump the integer whenever a change to this file should overwrite an
|
|
50
|
+
* already-installed copy on the user's machine — e.g. a new write path,
|
|
51
|
+
* a new transcript parser, a default PROJECT_MAP change. Comment-only
|
|
52
|
+
* tweaks do not need a bump.
|
|
53
|
+
*
|
|
54
|
+
* @termdeck/stack-installer-hook v1
|
|
55
|
+
*
|
|
56
|
+
* Required env vars (validated at entry, after the secrets.env fallback):
|
|
57
|
+
* - SUPABASE_URL e.g. https://<project-ref>.supabase.co
|
|
58
|
+
* - SUPABASE_SERVICE_ROLE_KEY service-role key (NOT the anon key — needs INSERT on memory_items)
|
|
59
|
+
* - OPENAI_API_KEY sk-... for text-embedding-3-small
|
|
60
|
+
*
|
|
61
|
+
* Optional:
|
|
62
|
+
* - TERMDECK_HOOK_DEBUG=1 verbose logging
|
|
63
|
+
* - TERMDECK_HOOK_MIN_BYTES=5000 transcript size threshold
|
|
64
|
+
* - TERMDECK_SESSION_TYPE=... override sessionType when payload lacks it
|
|
65
|
+
*
|
|
66
|
+
* Fail-soft contract: any error (network, parse, env-var-missing, malformed transcript)
|
|
67
|
+
* logs and exits 0. Never blocks Claude Code session close.
|
|
68
|
+
*
|
|
69
|
+
* Co-existence with Joshua's personal rag-system hook: this bundled hook writes
|
|
70
|
+
* source_type='session_summary' (one row per session). Joshua's personal hook
|
|
71
|
+
* writes source_type='fact' (multiple rows from extractFacts pipeline). Different
|
|
72
|
+
* source_types coexist in memory_items without dedup collisions.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
'use strict';
|
|
76
|
+
|
|
77
|
+
const { existsSync, statSync, appendFileSync, readFileSync } = require('fs');
|
|
78
|
+
const { join } = require('path');
|
|
79
|
+
const os = require('os');
|
|
80
|
+
|
|
81
|
+
const LOG_FILE = join(os.homedir(), '.claude', 'hooks', 'memory-hook.log');
|
|
82
|
+
|
|
83
|
+
// Resolved per-call so tests can override via TERMDECK_HOOK_SECRETS_PATH
|
|
84
|
+
// (the const-at-load-time pattern would freeze the path before any test
|
|
85
|
+
// that mutates HOME or the override env var gets a chance to take effect).
|
|
86
|
+
function resolveSecretsPath() {
|
|
87
|
+
return process.env.TERMDECK_HOOK_SECRETS_PATH
|
|
88
|
+
|| join(os.homedir(), '.termdeck', 'secrets.env');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// PROJECT_MAP — most-specific-first ordering (Sprint 41 design).
|
|
92
|
+
// Patterns match against the cwd reported by Claude Code at SessionEnd.
|
|
93
|
+
// First match wins; falls through to "global".
|
|
94
|
+
//
|
|
95
|
+
// Sprint 51.6 (T1 side finding b): a previous version shipped this array
|
|
96
|
+
// empty, which caused every session to tag as "global" — orphaning rows
|
|
97
|
+
// from project-scoped memory_recall queries. The default below restores
|
|
98
|
+
// the most-specific-first taxonomy from Sprint 41 T1, generalized for
|
|
99
|
+
// universal shipping. Users still extend in place by editing this array.
|
|
100
|
+
//
|
|
101
|
+
// Patterns NOT specific to Joshua's filesystem (e.g. /\/PVB\//i, /\/DOR\//i)
|
|
102
|
+
// are kept because they're benign on other machines — the regex simply
|
|
103
|
+
// doesn't fire on cwds that don't contain those segments. The chopin-
|
|
104
|
+
// nashville catch-all stays LAST (structural invariant) so a TermDeck cwd
|
|
105
|
+
// inside ChopinNashville/SideHustles/ resolves to "termdeck", not the
|
|
106
|
+
// catch-all.
|
|
107
|
+
const PROJECT_MAP = [
|
|
108
|
+
// ── Active code projects (most-specific FIRST) ──
|
|
109
|
+
{ pattern: /\/SideHustles\/TermDeck\/termdeck/i, project: 'termdeck' },
|
|
110
|
+
{ pattern: /\/Graciella\/engram(\/|$)/i, project: 'mnestra' },
|
|
111
|
+
{ pattern: /\/Graciella\/rumen(\/|$)/i, project: 'rumen' },
|
|
112
|
+
{ pattern: /\/Graciella\/rag-system(\/|$)/i, project: 'rag-system' },
|
|
113
|
+
{ pattern: /\/ChopinInBohemia\/podium(\/|$)/i, project: 'podium' },
|
|
114
|
+
{ pattern: /\/ChopinInBohemia(\/|$)/i, project: 'chopin-in-bohemia' },
|
|
115
|
+
{ pattern: /\/SideHustles\/SchedulingApp(\/|$)/i, project: 'chopin-scheduler' },
|
|
116
|
+
{ pattern: /\/ChopinNashville\/SchedulingApp(\/|$)/i, project: 'chopin-scheduler' },
|
|
117
|
+
{ pattern: /\/Graciella\/PVB(\/|$)|\/PVB\/pvb(\/|$)/i, project: 'pvb' },
|
|
118
|
+
{ pattern: /\/Unagi\/gorgias-ticket-monitor(\/|$)/i, project: 'claimguard' },
|
|
119
|
+
{ pattern: /\/ChopinNashville\/SideHustles\/ClaimGuard(\/|$)/i, project: 'claimguard' },
|
|
120
|
+
{ pattern: /\/Documents\/DOR(\/|$)/i, project: 'dor' },
|
|
121
|
+
{ pattern: /\/Graciella\/joshuaizzard-dev(\/|$)/i, project: 'portfolio' },
|
|
122
|
+
{ pattern: /\/Graciella\/imessage-reader(\/|$)/i, project: 'imessage-reader' },
|
|
123
|
+
|
|
124
|
+
// ── chopin-nashville catch-all (MUST be LAST among /ChopinNashville/ matchers).
|
|
125
|
+
// Sprint 35 + 41 lesson: any /ChopinNashville/-matching pattern placed below
|
|
126
|
+
// this entry gets shadowed and the row mis-tags as 'chopin-nashville'.
|
|
127
|
+
{ pattern: /\/ChopinNashville(\/|$)/i, project: 'chopin-nashville' },
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
const MIN_TRANSCRIPT_BYTES = parseInt(process.env.TERMDECK_HOOK_MIN_BYTES || '5000', 10);
|
|
131
|
+
const DEBUG = process.env.TERMDECK_HOOK_DEBUG === '1';
|
|
132
|
+
|
|
133
|
+
function log(msg) {
|
|
134
|
+
try { appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`); }
|
|
135
|
+
catch (_) { /* fail-soft */ }
|
|
136
|
+
}
|
|
137
|
+
function debug(msg) { if (DEBUG) log(`[debug] ${msg}`); }
|
|
138
|
+
|
|
139
|
+
function detectProject(cwd) {
|
|
140
|
+
for (const { pattern, project } of PROJECT_MAP) {
|
|
141
|
+
if (pattern.test(cwd)) return project;
|
|
142
|
+
}
|
|
143
|
+
return 'global';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Treat values shaped like `${VAR}` as unset. Claude Code does not expand
|
|
147
|
+
// shell placeholders in MCP env or hook env, so a literal `${SUPABASE_URL}`
|
|
148
|
+
// is non-empty-but-invalid — the same trap that caused the Sprint 47.5
|
|
149
|
+
// hotfix on the stack-installer + mnestra MCP. Mirroring that discipline
|
|
150
|
+
// here keeps the hook resilient if any future tooling regresses to the
|
|
151
|
+
// placeholder pattern.
|
|
152
|
+
function isUnexpandedPlaceholder(v) {
|
|
153
|
+
return typeof v === 'string' && v.startsWith('${') && v.endsWith('}');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Load ~/.termdeck/secrets.env into process.env when keys are absent or
|
|
157
|
+
// hold an unexpanded `${VAR}` placeholder. Concrete values already in
|
|
158
|
+
// process.env always win — the fallback only fills gaps. Silent no-op if
|
|
159
|
+
// the file is missing. Mirrors mnestra's loadTermdeckSecretsFallback so
|
|
160
|
+
// the hook works in three launch contexts:
|
|
161
|
+
// 1. Inside TermDeck PTY (Sprint 48 T4 PTY env merge supplies the vars).
|
|
162
|
+
// 2. Standalone Claude Code launched from a shell with secrets.env sourced.
|
|
163
|
+
// 3. Standalone Claude Code launched from a vanilla shell (this fallback).
|
|
164
|
+
function loadTermdeckSecretsFallback() {
|
|
165
|
+
const secretsPath = resolveSecretsPath();
|
|
166
|
+
if (!existsSync(secretsPath)) return;
|
|
167
|
+
let raw;
|
|
168
|
+
try { raw = readFileSync(secretsPath, 'utf8'); }
|
|
169
|
+
catch (err) {
|
|
170
|
+
log(`secrets-env-read-failed: ${err && err.message ? err.message : String(err)}`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
let loaded = 0;
|
|
174
|
+
for (const line of raw.split('\n')) {
|
|
175
|
+
const trimmed = line.trim();
|
|
176
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
177
|
+
const m = trimmed.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
|
178
|
+
if (!m) continue;
|
|
179
|
+
const key = m[1];
|
|
180
|
+
const cur = process.env[key];
|
|
181
|
+
if (cur && !isUnexpandedPlaceholder(cur)) continue;
|
|
182
|
+
let v = m[2];
|
|
183
|
+
if (v.length >= 2 && (v[0] === '"' || v[0] === "'") && v[v.length - 1] === v[0]) {
|
|
184
|
+
v = v.slice(1, -1);
|
|
185
|
+
}
|
|
186
|
+
process.env[key] = v;
|
|
187
|
+
loaded++;
|
|
188
|
+
}
|
|
189
|
+
if (loaded > 0) debug(`secrets-env-loaded: ${loaded} keys from ${secretsPath}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function readEnv() {
|
|
193
|
+
loadTermdeckSecretsFallback();
|
|
194
|
+
const required = ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'OPENAI_API_KEY'];
|
|
195
|
+
const missing = required.filter((k) => {
|
|
196
|
+
const v = process.env[k];
|
|
197
|
+
return !v || isUnexpandedPlaceholder(v);
|
|
198
|
+
});
|
|
199
|
+
if (missing.length) {
|
|
200
|
+
log(`env-var-missing: ${missing.join(', ')} — set these in ~/.termdeck/secrets.env or your shell to enable Mnestra ingestion. Skipping.`);
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
supabaseUrl: process.env.SUPABASE_URL.replace(/\/$/, ''),
|
|
205
|
+
supabaseKey: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
|
206
|
+
openaiKey: process.env.OPENAI_API_KEY,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
211
|
+
// Sprint 45 T4 — adapter-pluggable transcript parsers.
|
|
212
|
+
//
|
|
213
|
+
// Each parser takes raw transcript file contents (string) and returns a
|
|
214
|
+
// `{ role: 'user'|'assistant', content: string }[]` array — the shape
|
|
215
|
+
// buildSummary() consumes. Adapters in packages/server/src/agent-adapters/
|
|
216
|
+
// own the canonical parser logic; this file inlines copies because the
|
|
217
|
+
// hook ships standalone to ~/.claude/hooks/ where it can't `require()`
|
|
218
|
+
// from the TermDeck server package. When new agents add adapters, mirror
|
|
219
|
+
// their parseTranscript function body here — keep the two in sync.
|
|
220
|
+
// (Sprint 46 candidate: a sync script that codegens this section from
|
|
221
|
+
// agent-adapters/*.js, analogous to scripts/sync-agent-instructions.js
|
|
222
|
+
// for CLAUDE.md / AGENTS.md / GEMINI.md mirroring.)
|
|
223
|
+
//
|
|
224
|
+
// When sessionType is absent or unknown, parseAutoDetect runs a per-line
|
|
225
|
+
// best-effort that handles Claude JSONL, Codex JSONL, AND Gemini's single
|
|
226
|
+
// JSON-object shape. This is the pre-T4 stop-gap T1+T2 landed inline —
|
|
227
|
+
// preserved as the fallback so existing hook payloads (Claude Code Stop,
|
|
228
|
+
// no sessionType field) continue working for any of the three agents.
|
|
229
|
+
// Once Sprint 46 wires sessionType into payloads, the auto path narrows
|
|
230
|
+
// to a legacy compatibility role.
|
|
231
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
function parseClaudeJsonl(raw) {
|
|
234
|
+
if (typeof raw !== 'string' || raw.length === 0) return [];
|
|
235
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
236
|
+
const messages = [];
|
|
237
|
+
for (const line of lines) {
|
|
238
|
+
let msg;
|
|
239
|
+
try { msg = JSON.parse(line); } catch (_) { continue; }
|
|
240
|
+
const role = msg && msg.message && msg.message.role;
|
|
241
|
+
if (role !== 'user' && role !== 'assistant') continue;
|
|
242
|
+
const content = msg.message.content;
|
|
243
|
+
let text = '';
|
|
244
|
+
if (typeof content === 'string') {
|
|
245
|
+
text = content;
|
|
246
|
+
} else if (Array.isArray(content)) {
|
|
247
|
+
text = content
|
|
248
|
+
.filter((c) => c && c.type === 'text')
|
|
249
|
+
.map((c) => c.text || '')
|
|
250
|
+
.join(' ');
|
|
251
|
+
}
|
|
252
|
+
if (text) messages.push({ role, content: text.slice(0, 400) });
|
|
253
|
+
}
|
|
254
|
+
return messages;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function parseCodexJsonl(raw) {
|
|
258
|
+
if (typeof raw !== 'string' || raw.length === 0) return [];
|
|
259
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
260
|
+
const messages = [];
|
|
261
|
+
for (const line of lines) {
|
|
262
|
+
let msg;
|
|
263
|
+
try { msg = JSON.parse(line); } catch (_) { continue; }
|
|
264
|
+
if (!msg || msg.type !== 'response_item') continue;
|
|
265
|
+
const payload = msg.payload;
|
|
266
|
+
if (!payload || payload.type !== 'message') continue;
|
|
267
|
+
const role = payload.role;
|
|
268
|
+
// Codex's `developer` role carries the sandbox/permissions prelude — skip.
|
|
269
|
+
if (role !== 'user' && role !== 'assistant') continue;
|
|
270
|
+
const content = payload.content;
|
|
271
|
+
let text = '';
|
|
272
|
+
if (typeof content === 'string') {
|
|
273
|
+
text = content;
|
|
274
|
+
} else if (Array.isArray(content)) {
|
|
275
|
+
// Codex uses `input_text` (user) and `output_text` (assistant); accept
|
|
276
|
+
// plain `text` for forward-compat with future Codex CLI versions.
|
|
277
|
+
text = content
|
|
278
|
+
.filter((c) => c && (c.type === 'input_text' || c.type === 'output_text' || c.type === 'text'))
|
|
279
|
+
.map((c) => c.text || '')
|
|
280
|
+
.join(' ');
|
|
281
|
+
}
|
|
282
|
+
if (text) messages.push({ role, content: text.slice(0, 400) });
|
|
283
|
+
}
|
|
284
|
+
return messages;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function parseGeminiJson(raw) {
|
|
288
|
+
// Gemini CLI persists each session as a single JSON object (NOT JSONL):
|
|
289
|
+
// { sessionId, projectHash, startTime, lastUpdated, kind,
|
|
290
|
+
// messages: [{ id, timestamp, type: 'user'|'gemini', content }] }
|
|
291
|
+
// user content: [{ text }]; gemini content: string. Map type='gemini' →
|
|
292
|
+
// role='assistant' to match the rest of the dispatch shape.
|
|
293
|
+
if (typeof raw !== 'string' || raw.length === 0) return [];
|
|
294
|
+
let obj;
|
|
295
|
+
try { obj = JSON.parse(raw); } catch (_) { return []; }
|
|
296
|
+
if (!obj || !Array.isArray(obj.messages)) return [];
|
|
297
|
+
const messages = [];
|
|
298
|
+
for (const msg of obj.messages) {
|
|
299
|
+
if (!msg || typeof msg !== 'object') continue;
|
|
300
|
+
let role;
|
|
301
|
+
if (msg.type === 'user') role = 'user';
|
|
302
|
+
else if (msg.type === 'gemini' || msg.type === 'assistant') role = 'assistant';
|
|
303
|
+
else continue;
|
|
304
|
+
const content = msg.content;
|
|
305
|
+
let text = '';
|
|
306
|
+
if (typeof content === 'string') {
|
|
307
|
+
text = content;
|
|
308
|
+
} else if (Array.isArray(content)) {
|
|
309
|
+
text = content
|
|
310
|
+
.filter((c) => c && typeof c.text === 'string')
|
|
311
|
+
.map((c) => c.text)
|
|
312
|
+
.join(' ');
|
|
313
|
+
}
|
|
314
|
+
if (text) messages.push({ role, content: text.slice(0, 400) });
|
|
315
|
+
}
|
|
316
|
+
return messages;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Sprint 50 T1 — Grok parser. Mirrors packages/server/src/agent-adapters/grok.js
|
|
320
|
+
// parseTranscript: accepts either a JSON array or JSONL of `{role, content}`
|
|
321
|
+
// objects, where content is a string OR an array of `{type, text, ...}` parts
|
|
322
|
+
// (AI SDK provider shape). Tool-call / tool-result / reasoning parts are
|
|
323
|
+
// skipped — only the `type:'text'` parts contribute to the summary.
|
|
324
|
+
//
|
|
325
|
+
// The JSON envelope is produced server-side by the Grok adapter's
|
|
326
|
+
// `resolveTranscriptPath` (which extracts from ~/.grok/grok.db SQLite via
|
|
327
|
+
// better-sqlite3 and writes a tempfile). The hook itself never opens grok.db
|
|
328
|
+
// — that would require better-sqlite3 to be reachable from ~/.claude/hooks/,
|
|
329
|
+
// which isn't part of the install contract. The transcript_path the server
|
|
330
|
+
// hands the hook is the tempfile, and the sessionType in the payload is
|
|
331
|
+
// 'grok' so this parser is the one selected.
|
|
332
|
+
function parseGrokJson(raw) {
|
|
333
|
+
if (typeof raw !== 'string' || raw.length === 0) return [];
|
|
334
|
+
let messages = null;
|
|
335
|
+
try {
|
|
336
|
+
const parsed = JSON.parse(raw);
|
|
337
|
+
if (Array.isArray(parsed)) messages = parsed;
|
|
338
|
+
} catch (_) { /* fall through to JSONL */ }
|
|
339
|
+
if (!messages) {
|
|
340
|
+
messages = [];
|
|
341
|
+
for (const line of raw.split('\n')) {
|
|
342
|
+
const trimmed = line.trim();
|
|
343
|
+
if (!trimmed) continue;
|
|
344
|
+
try {
|
|
345
|
+
const obj = JSON.parse(trimmed);
|
|
346
|
+
if (obj && typeof obj === 'object') messages.push(obj);
|
|
347
|
+
} catch (_) { continue; }
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const out = [];
|
|
351
|
+
for (const msg of messages) {
|
|
352
|
+
if (!msg || typeof msg !== 'object') continue;
|
|
353
|
+
const role = msg.role;
|
|
354
|
+
if (role !== 'user' && role !== 'assistant') continue;
|
|
355
|
+
const content = msg.content;
|
|
356
|
+
let text = '';
|
|
357
|
+
if (typeof content === 'string') {
|
|
358
|
+
text = content;
|
|
359
|
+
} else if (Array.isArray(content)) {
|
|
360
|
+
text = content
|
|
361
|
+
.filter((c) => c && c.type === 'text' && typeof c.text === 'string')
|
|
362
|
+
.map((c) => c.text)
|
|
363
|
+
.join(' ');
|
|
364
|
+
}
|
|
365
|
+
if (text) out.push({ role, content: text.slice(0, 400) });
|
|
366
|
+
}
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function parseAutoDetect(raw) {
|
|
371
|
+
// Fallback when sessionType is absent. Tries Gemini's single-JSON shape
|
|
372
|
+
// first (cheap to detect — starts with `{` and has a top-level `messages`
|
|
373
|
+
// array), then falls through to per-line Claude/Codex JSONL detection.
|
|
374
|
+
// This preserves T1+T2's pre-T4 multi-shape stop-gap so any Claude Code
|
|
375
|
+
// Stop payload (which doesn't carry sessionType) keeps ingesting whichever
|
|
376
|
+
// CLI's transcript path landed there.
|
|
377
|
+
if (typeof raw !== 'string' || raw.length === 0) return [];
|
|
378
|
+
|
|
379
|
+
const trimmed = raw.trim();
|
|
380
|
+
if (trimmed.startsWith('{')) {
|
|
381
|
+
const geminiTry = parseGeminiJson(raw);
|
|
382
|
+
if (geminiTry.length > 0) return geminiTry;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
386
|
+
const messages = [];
|
|
387
|
+
for (const line of lines) {
|
|
388
|
+
let msg;
|
|
389
|
+
try { msg = JSON.parse(line); } catch (_) { continue; }
|
|
390
|
+
|
|
391
|
+
let role;
|
|
392
|
+
let content;
|
|
393
|
+
let textBlockType = 'text';
|
|
394
|
+
|
|
395
|
+
if (msg && msg.message && (msg.message.role === 'user' || msg.message.role === 'assistant')) {
|
|
396
|
+
role = msg.message.role;
|
|
397
|
+
content = msg.message.content;
|
|
398
|
+
} else if (msg && msg.type === 'response_item' && msg.payload && msg.payload.type === 'message') {
|
|
399
|
+
role = msg.payload.role;
|
|
400
|
+
if (role !== 'user' && role !== 'assistant') continue;
|
|
401
|
+
content = msg.payload.content;
|
|
402
|
+
textBlockType = null; // Codex content blocks use input_text/output_text
|
|
403
|
+
} else {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
let text = '';
|
|
408
|
+
if (typeof content === 'string') {
|
|
409
|
+
text = content;
|
|
410
|
+
} else if (Array.isArray(content)) {
|
|
411
|
+
text = content
|
|
412
|
+
.filter((c) => c && (
|
|
413
|
+
textBlockType === null
|
|
414
|
+
? (c.type === 'input_text' || c.type === 'output_text' || c.type === 'text')
|
|
415
|
+
: c.type === textBlockType
|
|
416
|
+
))
|
|
417
|
+
.map((c) => c.text || '')
|
|
418
|
+
.join(' ');
|
|
419
|
+
}
|
|
420
|
+
if (text) messages.push({ role, content: text.slice(0, 400) });
|
|
421
|
+
}
|
|
422
|
+
return messages;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const TRANSCRIPT_PARSERS = {
|
|
426
|
+
'claude-code': parseClaudeJsonl,
|
|
427
|
+
'codex': parseCodexJsonl,
|
|
428
|
+
'gemini': parseGeminiJson,
|
|
429
|
+
// Sprint 50 T1 — grok parser. Server-side `resolveTranscriptPath` extracts
|
|
430
|
+
// ~/.grok/grok.db rows via better-sqlite3 and writes a JSON envelope to a
|
|
431
|
+
// tempfile; the hook reads that tempfile with parseGrokJson here.
|
|
432
|
+
'grok': parseGrokJson,
|
|
433
|
+
};
|
|
434
|
+
const DEFAULT_SESSION_TYPE = 'auto';
|
|
435
|
+
|
|
436
|
+
function selectTranscriptParser(sessionType) {
|
|
437
|
+
if (sessionType && TRANSCRIPT_PARSERS[sessionType]) {
|
|
438
|
+
return { parser: TRANSCRIPT_PARSERS[sessionType], sessionType };
|
|
439
|
+
}
|
|
440
|
+
return { parser: parseAutoDetect, sessionType: 'auto' };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Sprint 51.6 T3: returns `{ summary, messagesCount }` instead of just the
|
|
444
|
+
// summary string. messagesCount feeds the new memory_sessions write path
|
|
445
|
+
// (postMemorySession), which needs the parser-derived count without
|
|
446
|
+
// reparsing the transcript. Returns null when the transcript is unreadable
|
|
447
|
+
// or has fewer than 5 messages — same skip semantics as before.
|
|
448
|
+
function buildSummary(transcriptPath, sessionType) {
|
|
449
|
+
let raw;
|
|
450
|
+
try { raw = readFileSync(transcriptPath, 'utf8'); }
|
|
451
|
+
catch (e) { log(`read-transcript-failed: ${e.message}`); return null; }
|
|
452
|
+
|
|
453
|
+
const { parser, sessionType: resolvedType } = selectTranscriptParser(sessionType);
|
|
454
|
+
if (sessionType && resolvedType !== sessionType) {
|
|
455
|
+
debug(`unknown-session-type="${sessionType}", falling back to ${resolvedType}`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const messages = parser(raw);
|
|
459
|
+
|
|
460
|
+
if (messages.length < 5) {
|
|
461
|
+
debug(`session-too-short: ${messages.length} messages (parser=${resolvedType}), skipping`);
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const tail = messages.slice(-30);
|
|
466
|
+
const summary =
|
|
467
|
+
`Session with ${messages.length} messages.\n\n` +
|
|
468
|
+
tail.map((m) => `[${m.role}] ${m.content}`).join('\n');
|
|
469
|
+
// OpenAI text-embedding-3-small accepts up to 8192 tokens (~32K chars).
|
|
470
|
+
// 7000 chars is a safe headroom that survives multibyte expansion.
|
|
471
|
+
return { summary: summary.slice(0, 7000), messagesCount: messages.length };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function embedText(text, openaiKey) {
|
|
475
|
+
try {
|
|
476
|
+
const res = await fetch('https://api.openai.com/v1/embeddings', {
|
|
477
|
+
method: 'POST',
|
|
478
|
+
headers: {
|
|
479
|
+
'Content-Type': 'application/json',
|
|
480
|
+
'Authorization': `Bearer ${openaiKey}`,
|
|
481
|
+
},
|
|
482
|
+
body: JSON.stringify({ model: 'text-embedding-3-small', input: text }),
|
|
483
|
+
});
|
|
484
|
+
if (!res.ok) {
|
|
485
|
+
const body = await res.text().catch(() => '');
|
|
486
|
+
log(`openai-embed-failed: HTTP ${res.status} ${body.slice(0, 200)}`);
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
const data = await res.json();
|
|
490
|
+
return data?.data?.[0]?.embedding || null;
|
|
491
|
+
} catch (e) {
|
|
492
|
+
log(`openai-embed-exception: ${e.message}`);
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Sprint 50 T2: every row written by this hook carries an LLM-provenance
|
|
498
|
+
// tag (memory_items.source_agent). Defaults to 'claude' for backwards
|
|
499
|
+
// compat with Claude Code's existing SessionEnd payload, which doesn't
|
|
500
|
+
// supply the field; TermDeck server's per-adapter onPanelClose
|
|
501
|
+
// interceptor (Sprint 50 T1) sets it explicitly to 'codex'/'gemini'/'grok'
|
|
502
|
+
// for non-Claude panels. The set is open-ended on the server side; this
|
|
503
|
+
// constant gates only the spelling-mistake/empty-string case.
|
|
504
|
+
const ALLOWED_SOURCE_AGENTS = new Set([
|
|
505
|
+
'claude', 'codex', 'gemini', 'grok', 'orchestrator',
|
|
506
|
+
]);
|
|
507
|
+
|
|
508
|
+
function normalizeSourceAgent(raw) {
|
|
509
|
+
if (typeof raw !== 'string') return 'claude';
|
|
510
|
+
const v = raw.trim().toLowerCase();
|
|
511
|
+
if (!v) return 'claude';
|
|
512
|
+
return ALLOWED_SOURCE_AGENTS.has(v) ? v : 'claude';
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function postMemoryItem({ supabaseUrl, supabaseKey, content, embedding, project, sessionId, sourceAgent }) {
|
|
516
|
+
try {
|
|
517
|
+
const res = await fetch(`${supabaseUrl}/rest/v1/memory_items`, {
|
|
518
|
+
method: 'POST',
|
|
519
|
+
headers: {
|
|
520
|
+
'Content-Type': 'application/json',
|
|
521
|
+
'apikey': supabaseKey,
|
|
522
|
+
'Authorization': `Bearer ${supabaseKey}`,
|
|
523
|
+
'Prefer': 'return=minimal',
|
|
524
|
+
},
|
|
525
|
+
body: JSON.stringify({
|
|
526
|
+
content,
|
|
527
|
+
embedding: `[${embedding.join(',')}]`,
|
|
528
|
+
source_type: 'session_summary',
|
|
529
|
+
category: 'workflow',
|
|
530
|
+
project,
|
|
531
|
+
source_session_id: sessionId || null,
|
|
532
|
+
source_agent: normalizeSourceAgent(sourceAgent),
|
|
533
|
+
}),
|
|
534
|
+
});
|
|
535
|
+
if (!res.ok) {
|
|
536
|
+
const body = await res.text().catch(() => '');
|
|
537
|
+
log(`supabase-insert-failed: HTTP ${res.status} ${body.slice(0, 200)}`);
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
return true;
|
|
541
|
+
} catch (e) {
|
|
542
|
+
log(`supabase-insert-exception: ${e.message}`);
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Sprint 51.6 T3 — companion write to memory_sessions.
|
|
548
|
+
//
|
|
549
|
+
// History: the bundled hook never wrote memory_sessions until v1.0.2. Joshua's
|
|
550
|
+
// PRIOR personal rag-system hook spawned process-session.ts which inserted
|
|
551
|
+
// memory_sessions rows; the Sprint 38 P0 rewrite replaced that hook with a
|
|
552
|
+
// Mnestra-direct hook that only wrote memory_items. Result: from 2026-05-02
|
|
553
|
+
// 13:24 ET (when bundled overwrote personal) until v1.0.2, no memory_sessions
|
|
554
|
+
// rows accumulated. Sprint 51.6 T1+T2+T3 documented the gap; this function
|
|
555
|
+
// closes it.
|
|
556
|
+
//
|
|
557
|
+
// Schema target: Mnestra migration 017 brings canonical engram in line with
|
|
558
|
+
// petvetbid's rag-system flavor (session_id, summary_embedding, started_at,
|
|
559
|
+
// ended_at, duration_minutes, messages_count, transcript_path, etc). The
|
|
560
|
+
// bundled hook writes the rich shape on every install — fresh-canonical
|
|
561
|
+
// (post-mig-017) and petvetbid alike.
|
|
562
|
+
//
|
|
563
|
+
// Idempotency: Prefer: resolution=merge-duplicates relies on the
|
|
564
|
+
// memory_sessions_session_id_key unique constraint. Mig 017 adds it where
|
|
565
|
+
// absent. SessionEnd-fires-twice (e.g. /exit then PTY close) resolves to a
|
|
566
|
+
// single row.
|
|
567
|
+
async function postMemorySession({
|
|
568
|
+
supabaseUrl, supabaseKey,
|
|
569
|
+
summary, summaryEmbedding,
|
|
570
|
+
project, sessionId,
|
|
571
|
+
transcriptPath, messagesCount,
|
|
572
|
+
endedAt
|
|
573
|
+
}) {
|
|
574
|
+
if (!sessionId) {
|
|
575
|
+
log('memory-sessions-skip: sessionId missing — cannot satisfy session_id NOT NULL/UNIQUE.');
|
|
576
|
+
return false;
|
|
577
|
+
}
|
|
578
|
+
try {
|
|
579
|
+
// Sprint 51.6 T3 / T4-CODEX audit 20:23 ET: PostgREST requires both
|
|
580
|
+
// `Prefer: resolution=merge-duplicates` AND `?on_conflict=<column>`
|
|
581
|
+
// on the URL to trigger an UPSERT. Without `on_conflict=session_id`
|
|
582
|
+
// a duplicate fire would error against memory_sessions_session_id_key.
|
|
583
|
+
const res = await fetch(`${supabaseUrl}/rest/v1/memory_sessions?on_conflict=session_id`, {
|
|
584
|
+
method: 'POST',
|
|
585
|
+
headers: {
|
|
586
|
+
'Content-Type': 'application/json',
|
|
587
|
+
'apikey': supabaseKey,
|
|
588
|
+
'Authorization': `Bearer ${supabaseKey}`,
|
|
589
|
+
'Prefer': 'resolution=merge-duplicates,return=minimal',
|
|
590
|
+
},
|
|
591
|
+
body: JSON.stringify({
|
|
592
|
+
session_id: sessionId,
|
|
593
|
+
summary,
|
|
594
|
+
summary_embedding: Array.isArray(summaryEmbedding)
|
|
595
|
+
? `[${summaryEmbedding.join(',')}]`
|
|
596
|
+
: null,
|
|
597
|
+
project,
|
|
598
|
+
ended_at: (endedAt instanceof Date ? endedAt : new Date()).toISOString(),
|
|
599
|
+
messages_count: typeof messagesCount === 'number' ? messagesCount : 0,
|
|
600
|
+
transcript_path: transcriptPath || null,
|
|
601
|
+
// started_at, duration_minutes, facts_extracted, files_changed, topics
|
|
602
|
+
// intentionally omitted — column defaults apply on petvetbid; nullable
|
|
603
|
+
// on canonical (post-mig-017). Future sprint may parse per-message
|
|
604
|
+
// timestamps to derive started_at + duration; v1.0.2 ships the
|
|
605
|
+
// minimum viable row.
|
|
606
|
+
}),
|
|
607
|
+
});
|
|
608
|
+
if (!res.ok) {
|
|
609
|
+
const body = await res.text().catch(() => '');
|
|
610
|
+
log(`memory-sessions-insert-failed: HTTP ${res.status} ${body.slice(0, 200)}`);
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
return true;
|
|
614
|
+
} catch (e) {
|
|
615
|
+
log(`memory-sessions-insert-exception: ${e.message}`);
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function processStdinPayload(input) {
|
|
621
|
+
let data;
|
|
622
|
+
try { data = JSON.parse(input); }
|
|
623
|
+
catch (e) { log(`parse-stdin-failed: ${e.message}`); return; }
|
|
624
|
+
|
|
625
|
+
const transcriptPath = data.transcript_path;
|
|
626
|
+
const cwd = data.cwd || '';
|
|
627
|
+
const sessionId =
|
|
628
|
+
data.session_id ||
|
|
629
|
+
(transcriptPath ? transcriptPath.split('/').pop().replace('.jsonl', '') : null);
|
|
630
|
+
|
|
631
|
+
// Sprint 45 T4: sessionType drives buildSummary's parser dispatch.
|
|
632
|
+
// Read order: payload (server-driven invocations) → env var (TermDeck
|
|
633
|
+
// server can set TERMDECK_SESSION_TYPE in the spawned PTY's env) →
|
|
634
|
+
// 'auto' default (parseAutoDetect handles Claude + Codex + Gemini).
|
|
635
|
+
const sessionType =
|
|
636
|
+
data.sessionType ||
|
|
637
|
+
data.session_type ||
|
|
638
|
+
process.env.TERMDECK_SESSION_TYPE ||
|
|
639
|
+
DEFAULT_SESSION_TYPE;
|
|
640
|
+
|
|
641
|
+
// Sprint 50 T2: provenance tag the row with the LLM that produced it.
|
|
642
|
+
// Default 'claude' — Claude Code's native SessionEnd payload doesn't
|
|
643
|
+
// carry source_agent, so any unset path is implicitly Claude. The
|
|
644
|
+
// TermDeck server's per-adapter onPanelClose interceptor (Sprint 50 T1)
|
|
645
|
+
// sets it explicitly for non-Claude panels.
|
|
646
|
+
const sourceAgent =
|
|
647
|
+
data.source_agent ||
|
|
648
|
+
data.sourceAgent ||
|
|
649
|
+
process.env.TERMDECK_SOURCE_AGENT ||
|
|
650
|
+
'claude';
|
|
651
|
+
|
|
652
|
+
if (!transcriptPath) { log('no-transcript-path: skipping'); return; }
|
|
653
|
+
|
|
654
|
+
let stat;
|
|
655
|
+
try { stat = statSync(transcriptPath); }
|
|
656
|
+
catch (e) { log(`cannot-stat-transcript: ${transcriptPath} — ${e.message}`); return; }
|
|
657
|
+
|
|
658
|
+
if (stat.size < MIN_TRANSCRIPT_BYTES) {
|
|
659
|
+
debug(`small-transcript: ${stat.size} bytes < ${MIN_TRANSCRIPT_BYTES}, skipping`);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const env = readEnv();
|
|
664
|
+
if (!env) return;
|
|
665
|
+
|
|
666
|
+
const project = detectProject(cwd);
|
|
667
|
+
debug(`project="${project}", session=${sessionId}, sessionType=${sessionType}`);
|
|
668
|
+
|
|
669
|
+
const built = buildSummary(transcriptPath, sessionType);
|
|
670
|
+
if (!built) return;
|
|
671
|
+
const { summary, messagesCount } = built;
|
|
672
|
+
|
|
673
|
+
const embedding = await embedText(summary, env.openaiKey);
|
|
674
|
+
if (!embedding) return;
|
|
675
|
+
|
|
676
|
+
const itemOk = await postMemoryItem({
|
|
677
|
+
supabaseUrl: env.supabaseUrl,
|
|
678
|
+
supabaseKey: env.supabaseKey,
|
|
679
|
+
content: summary,
|
|
680
|
+
embedding,
|
|
681
|
+
project,
|
|
682
|
+
sessionId,
|
|
683
|
+
sourceAgent,
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// Sprint 51.6 T3: companion memory_sessions write. Independent of the
|
|
687
|
+
// memory_items write — a memory_items failure shouldn't suppress the
|
|
688
|
+
// memory_sessions row, and vice versa. Both errors fail-soft.
|
|
689
|
+
const sessionOk = await postMemorySession({
|
|
690
|
+
supabaseUrl: env.supabaseUrl,
|
|
691
|
+
supabaseKey: env.supabaseKey,
|
|
692
|
+
summary,
|
|
693
|
+
summaryEmbedding: embedding,
|
|
694
|
+
project,
|
|
695
|
+
sessionId,
|
|
696
|
+
transcriptPath,
|
|
697
|
+
messagesCount,
|
|
698
|
+
endedAt: new Date(),
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
if (itemOk || sessionOk) {
|
|
702
|
+
log(`ingested: project="${project}" session=${sessionId} bytes=${summary.length} messages=${messagesCount} sessionType=${sessionType} sourceAgent=${normalizeSourceAgent(sourceAgent)} memory_items=${itemOk ? 'ok' : 'fail'} memory_sessions=${sessionOk ? 'ok' : 'fail'}`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Module-export contract for testability. When run as a script (require.main === module),
|
|
707
|
+
// read stdin and process. When require()d (tests), expose helpers.
|
|
708
|
+
if (require.main === module) {
|
|
709
|
+
let input = '';
|
|
710
|
+
process.stdin.setEncoding('utf8');
|
|
711
|
+
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
712
|
+
process.stdin.on('end', () => {
|
|
713
|
+
processStdinPayload(input).catch((e) => log(`hook-error: ${e.message}`));
|
|
714
|
+
});
|
|
715
|
+
} else {
|
|
716
|
+
module.exports = {
|
|
717
|
+
PROJECT_MAP,
|
|
718
|
+
detectProject,
|
|
719
|
+
readEnv,
|
|
720
|
+
buildSummary,
|
|
721
|
+
embedText,
|
|
722
|
+
postMemoryItem,
|
|
723
|
+
// Sprint 51.6 T3 — memory_sessions write companion.
|
|
724
|
+
postMemorySession,
|
|
725
|
+
processStdinPayload,
|
|
726
|
+
LOG_FILE,
|
|
727
|
+
// Sprint 45 T4 — adapter-pluggable transcript-parser surface.
|
|
728
|
+
TRANSCRIPT_PARSERS,
|
|
729
|
+
DEFAULT_SESSION_TYPE,
|
|
730
|
+
parseClaudeJsonl,
|
|
731
|
+
parseCodexJsonl,
|
|
732
|
+
parseGeminiJson,
|
|
733
|
+
parseGrokJson,
|
|
734
|
+
parseAutoDetect,
|
|
735
|
+
selectTranscriptParser,
|
|
736
|
+
// Sprint 50 T2 — source_agent provenance plumbing.
|
|
737
|
+
normalizeSourceAgent,
|
|
738
|
+
ALLOWED_SOURCE_AGENTS,
|
|
739
|
+
};
|
|
740
|
+
}
|