@addai/node 0.8.2 → 0.9.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.
@@ -1,292 +1,292 @@
1
- #!/usr/bin/env node
2
- // +Ai Memory Phase 4, T7 — PreCompact capture hook (claude agent only).
3
- //
4
- // Invoked by Claude Code as a PreCompact command hook — see
5
- // src/precompact-hook.ts for how/when the daemon writes this into a
6
- // session's <cwd>/.claude/settings.json (memory_v2-flagged entities only).
7
- // Runs as a plain child process of claude, so it inherits claude's (and
8
- // therefore the daemon's PTY spawn's) environment: ENTITY_STUDIO_ENTITY_ID
9
- // and RUNTIME_REQUEST_ID are threaded in by claude-spawn.ts's extraEnv for
10
- // exactly this purpose; SUPABASE_URL/SUPABASE_ANON_KEY are NOT threaded as
11
- // env vars — they're read straight from dist/config.js (the same
12
- // build-time-public constants session-runner.ts itself uses), which lives
13
- // one directory up from this script in every install shape (package.json
14
- // "files": ["dist","scripts", ...]).
15
- //
16
- // Best-effort, fire-and-forget, NEVER blocks or fails compaction: every
17
- // error path here logs to stderr and exits 0 regardless, so Claude Code's
18
- // own compaction proceeds unaffected whether the capture succeeds, fails,
19
- // or never had anything to send.
20
- //
21
- // Idempotency: this does NOT invent its own turn-hash dedup. It posts the
22
- // same shape entity-memory-capture already accepts from the normal
23
- // completed-turn path (surface differs: 'precompact' vs 'chat'/issued_via),
24
- // and relies on entity-memory-capture's existing episode/turn-hash dedup
25
- // (T3, sha256(entity_id|user_text|assistant_text)) to make a later
26
- // completed-turn capture of the SAME exchange a safe no-op.
27
- //
28
- // For that to actually hold, this hook's assistantText must match — byte
29
- // for byte — what the completed-turn path will eventually read back from
30
- // entity_runtime_requests.assistant_text. That column is populated by the
31
- // live DB trigger _entity_studio_aggregate_assistant_text (see
32
- // supabase/migrations/20260720130000_entity_guardrails.sql in the studio
33
- // repo for the current body), which on the request's terminal transition
34
- // runs:
35
- //
36
- // select string_agg((e.payload ->> 'delta'), '' order by e.id)
37
- // from public.entity_runtime_request_events e
38
- // where e.request_id = new.id and e.type = 'assistant_text'
39
- //
40
- // i.e. every assistant_text delta event emitted for the WHOLE request row,
41
- // concatenated in emission order with NO separator, tool_use/tool_result/
42
- // thinking events excluded entirely. lastTurnFromTranscript below mirrors
43
- // that by walking back to the last genuine user turn (a role:'user' entry
44
- // that carries real text — NOT a tool_result-only "user" message, which
45
- // Claude Code also stamps role:'user') and concatenating every subsequent
46
- // top-level assistant entry's text blocks, in order, with '' — matching
47
- // string_agg's join exactly.
48
- //
49
- // Sidechain (subagent/Task-tool) entries are skipped (the `entry.isSidechain`
50
- // check below) as a defensive belt-and-braces measure, but on this daemon's
51
- // current Claude Code transcript layout they never fire in practice: a
52
- // subagent's own turns are written to a SEPARATE file tree
53
- // (`<project>/<session>/subagents/agent-<id>.jsonl`), never interleaved into
54
- // the top-level `<session>.jsonl` this hook's `transcript_path` and this
55
- // daemon's own live-turn tailer (jsonl-tail.ts's startJsonlTail, fed the
56
- // identical spawn.jsonlPath — see session-runner.ts) both read. The DB side
57
- // (entity_runtime_request_events) is built from that SAME top-level-only
58
- // tail via events.ts's normalizeJsonlLine, which has no isSidechain check of
59
- // its own — it doesn't need one, because the file it reads structurally
60
- // never contains sidechain lines. (Corrected 2026-07-21: this comment
61
- // previously cited claude-print.ts, the separate --print-mode adapter,
62
- // which this PreCompact-hooked TUI code path never runs through at all.)
63
- //
64
- // assistantText.trim() below (T8 ship-gate fix) matches the completed-turn
65
- // capture path's own normalization — notifyCaptureIfEnabled in
66
- // session-runner.ts computes `(row.assistant_text ?? '').trim()` before
67
- // calling buildCapturePayload — so a genuinely-identical exchange hashes to
68
- // the SAME turn_hash (sha256(entity_id|user_text|assistant_text), computed
69
- // server-side in entity-memory-capture/index.ts over exactly the strings
70
- // this payload sends, no server-side trim) on both paths. Before this fix, a
71
- // transcript-derived assistantText with incidental leading/trailing
72
- // whitespace (a trailing newline from the JSONL's own text-block boundary,
73
- // for instance) would hash differently from the SAME text read back post-
74
- // trim by the completed-turn path, defeating the dedup this whole mechanism
75
- // exists to rely on — not just for the documented mid-turn-prefix case
76
- // below, but for a full, complete, otherwise-identical turn too.
77
- //
78
- // Residual (honest, not fixed): PreCompact can still fire MID-turn, before
79
- // the agent has emitted its final assistant message(s) for this request.
80
- // When that happens this hook captures a PREFIX of the eventual full
81
- // assistant_text — same userText, shorter assistantText — which hashes to
82
- // a DIFFERENT dedup key than the later completed-turn capture. That is a
83
- // genuine possible duplicate (a "prefix episode" alongside the full one),
84
- // bounded to at most one extra episode per PreCompact-interrupted turn. We
85
- // deliberately do NOT special-case this by weakening the dedup key (e.g.
86
- // hashing on entity_id|user_text alone) — that would blind the general
87
- // dedup to legitimately different answers to a repeated question.
88
- //
89
- // Residual class 2 — user_text normalization: userText here is read
90
- // straight off the transcript's last real user-turn content block(s)
91
- // (extractText, '\n'-joined if Claude Code ever splits one turn's content
92
- // into multiple 'text' blocks) and is NOT trimmed, matching the completed-
93
- // turn path's own `row.prompt ?? ''` (also untrimmed, read back from the
94
- // DB's stored prompt column, a plain string with no aggregation step of its
95
- // own — unlike assistant_text there is no DB trigger to mirror here). Both
96
- // paths are internally consistent in NOT trimming user_text, so this is not
97
- // currently a known dedup-breaking mismatch — but it is an unverified
98
- // assumption: nothing here proves the transcript's literal user-message
99
- // text is always byte-identical to the stored prompt column (a future
100
- // prompt-construction change, or a Claude Code content-block split, could
101
- // silently diverge them). Documented rather than asserted-safe.
102
- //
103
- // Residual class 3 — guardrail retry turns (guardrail_meta.final_text):
104
- // when a guardrail check fails, runGuardrailRetryTurn (session-runner.ts)
105
- // feeds the violation back as a NEW real user-role message on the SAME
106
- // claude session/transcript, then accumulates the revised reply. The DB's
107
- // assistant_text trigger aggregates every assistant_text event emitted for
108
- // the whole request_id regardless of which sub-turn produced it — original
109
- // attempt AND revision both — and guardrail_meta.final_text is what
110
- // actually gets pinned as canonical. If PreCompact fires during or after a
111
- // guardrail retry, lastTurnFromTranscript walks back to that INTERNAL
112
- // feedback message as "the last genuine user turn" (it has real text and no
113
- // way to be distinguished from a genuine human prompt at this layer) — so
114
- // BOTH userText (the guardrail feedback prompt, not the original caller's
115
- // text) and assistantText (only the revision, not original+revision) can
116
- // diverge from what the completed-turn path eventually captures. Unlike the
117
- // mid-turn-prefix residual above, this is not a strict prefix relationship
118
- // and not bounded to a whitespace/length difference — it is a different
119
- // exchange shape entirely. Same posture as the other two residuals: honest,
120
- // documented, not papered over with a weakened dedup key.
121
-
122
- const fs = require('fs');
123
- const path = require('path');
124
- const os = require('os');
125
-
126
- function readStdinSync() {
127
- try {
128
- return fs.readFileSync(0, 'utf-8');
129
- } catch {
130
- return '';
131
- }
132
- }
133
-
134
- function extractText(content) {
135
- if (typeof content === 'string') return content;
136
- if (Array.isArray(content)) {
137
- return content
138
- .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
139
- .map((b) => b.text)
140
- .join('\n');
141
- }
142
- return '';
143
- }
144
-
145
- // Same block-filtering as extractText, but joined with '' — the DB's
146
- // string_agg(delta, '') separator — for the assistant-side aggregation
147
- // specifically. In practice a single assistant message content array never
148
- // carries two adjacent 'text' blocks (the API always merges them), so this
149
- // only differs from extractText's '\n' join in that hypothetical case; kept
150
- // separate anyway so the assistant path is exactly, not approximately,
151
- // faithful to the trigger it's mirroring.
152
- function extractAssistantText(content) {
153
- if (typeof content === 'string') return content;
154
- if (Array.isArray(content)) {
155
- return content
156
- .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
157
- .map((b) => b.text)
158
- .join('');
159
- }
160
- return '';
161
- }
162
-
163
- // A "real" user turn: role:'user' carrying actual typed text. Claude Code
164
- // also stamps role:'user' on tool_result submissions (content is an array
165
- // of tool_result blocks only) — extractText() already filters those out to
166
- // '' since it only pulls type:'text' blocks, so they never look like a
167
- // turn boundary here.
168
- function isRealUserTurn(msg) {
169
- return !!msg && msg.role === 'user' && extractText(msg.content).trim() !== '';
170
- }
171
-
172
- // Walks the transcript JSONL backwards to find the last genuine user turn
173
- // (the prompt that started the CURRENT request), then walks forward from
174
- // there aggregating every top-level assistant entry's text in order — the
175
- // full "turn" for this request, matching how the DB's
176
- // _entity_studio_aggregate_assistant_text trigger aggregates
177
- // entity_runtime_request_events for the same request_id (concatenation in
178
- // emission order, no separator, tool_use/tool_result/thinking excluded).
179
- // Multi-assistant-message turns (text → tool_use → tool_result → text →
180
- // ... → final text) are the norm for agentic sessions — PreCompact can
181
- // fire mid-turn — so aggregating only the single most recent assistant
182
- // entry (the old behavior) systematically under-captured relative to what
183
- // the completed-turn path later reads back, producing a different dedup
184
- // hash for the same exchange. See the Idempotency comment above for the
185
- // residual this fix does NOT (and should not) paper over.
186
- function lastTurnFromTranscript(transcriptPath) {
187
- try {
188
- const raw = fs.readFileSync(transcriptPath, 'utf-8');
189
- const lines = raw.split('\n').filter(Boolean);
190
- const entries = [];
191
- for (const line of lines) {
192
- try {
193
- entries.push(JSON.parse(line));
194
- } catch {
195
- continue;
196
- }
197
- }
198
-
199
- let turnStart = -1;
200
- let userText = '';
201
- for (let i = entries.length - 1; i >= 0; i--) {
202
- const entry = entries[i];
203
- if (entry && entry.isSidechain) continue; // subagent/Task-tool chatter — DB side never sees it either
204
- const msg = entry && entry.message;
205
- if (isRealUserTurn(msg)) {
206
- turnStart = i;
207
- userText = extractText(msg.content);
208
- break;
209
- }
210
- }
211
- if (turnStart === -1) {
212
- return { userText: '', assistantText: '' };
213
- }
214
-
215
- let assistantText = '';
216
- for (let i = turnStart + 1; i < entries.length; i++) {
217
- const entry = entries[i];
218
- if (entry && entry.isSidechain) continue;
219
- const msg = entry && entry.message;
220
- if (!msg || msg.role !== 'assistant') continue;
221
- assistantText += extractAssistantText(msg.content); // '' separator — matches string_agg(delta, '')
222
- }
223
- return { userText, assistantText };
224
- } catch (err) {
225
- process.stderr.write(`[precompact-capture] transcript read failed: ${err.message}\n`);
226
- return { userText: '', assistantText: '' };
227
- }
228
- }
229
-
230
- async function main() {
231
- const entityId = process.env.ENTITY_STUDIO_ENTITY_ID || '';
232
- const requestId = process.env.RUNTIME_REQUEST_ID || '';
233
- if (!entityId || !requestId) {
234
- // Not a daemon-spawned, entity-attributed session (or the daemon
235
- // didn't thread identity through) — nothing to attribute a capture
236
- // to. Exit clean, never block compaction.
237
- process.exit(0);
238
- return;
239
- }
240
-
241
- const stdin = readStdinSync();
242
- let hookInput = {};
243
- try {
244
- hookInput = JSON.parse(stdin || '{}');
245
- } catch {
246
- /* malformed/empty stdin — fall through with {} */
247
- }
248
- const transcriptPath = hookInput.transcript_path;
249
- if (!transcriptPath || !fs.existsSync(transcriptPath)) {
250
- process.exit(0);
251
- return;
252
- }
253
-
254
- const { userText, assistantText: rawAssistantText } = lastTurnFromTranscript(transcriptPath);
255
- // T8 ship-gate fix: trim to match notifyCaptureIfEnabled's own
256
- // `(row.assistant_text ?? '').trim()` — see the module-header comment's
257
- // trim-alignment note for why an untrimmed value here would silently
258
- // defeat the turn_hash dedup this hook depends on, not just for the
259
- // documented mid-turn-prefix residual but for complete matching turns too.
260
- const assistantText = rawAssistantText.trim();
261
- if (!assistantText) {
262
- process.exit(0); // nothing produced yet this turn
263
- return;
264
- }
265
-
266
- try {
267
- const { SUPABASE_URL, SUPABASE_ANON_KEY } = require(path.join(__dirname, '..', 'dist', 'config.js'));
268
- const { buildCapturePayload, postCapture } = require(path.join(__dirname, '..', 'dist', 'memory-capture.js'));
269
- const payload = buildCapturePayload(
270
- { entityId, requestId, surface: 'precompact' },
271
- userText,
272
- assistantText,
273
- { kind: 'jsonl_ref', path: transcriptPath, host: os.hostname() },
274
- );
275
- await postCapture(SUPABASE_URL, SUPABASE_ANON_KEY, payload);
276
- } catch (err) {
277
- process.stderr.write(`[precompact-capture] capture failed (non-fatal): ${err.message}\n`);
278
- }
279
- process.exit(0);
280
- }
281
-
282
- // Guarded so `require()`ing this file for unit tests (of extractText /
283
- // extractAssistantText / lastTurnFromTranscript) doesn't also run main()
284
- // — main() reads real stdin/env and makes a real network call.
285
- if (require.main === module) {
286
- main().catch((err) => {
287
- process.stderr.write(`[precompact-capture] fatal (swallowed): ${err && err.message}\n`);
288
- process.exit(0);
289
- });
290
- }
291
-
292
- module.exports = { extractText, extractAssistantText, lastTurnFromTranscript, isRealUserTurn };
1
+ #!/usr/bin/env node
2
+ // +Ai Memory Phase 4, T7 — PreCompact capture hook (claude agent only).
3
+ //
4
+ // Invoked by Claude Code as a PreCompact command hook — see
5
+ // src/precompact-hook.ts for how/when the daemon writes this into a
6
+ // session's <cwd>/.claude/settings.json (memory_v2-flagged entities only).
7
+ // Runs as a plain child process of claude, so it inherits claude's (and
8
+ // therefore the daemon's PTY spawn's) environment: ENTITY_STUDIO_ENTITY_ID
9
+ // and RUNTIME_REQUEST_ID are threaded in by claude-spawn.ts's extraEnv for
10
+ // exactly this purpose; SUPABASE_URL/SUPABASE_ANON_KEY are NOT threaded as
11
+ // env vars — they're read straight from dist/config.js (the same
12
+ // build-time-public constants session-runner.ts itself uses), which lives
13
+ // one directory up from this script in every install shape (package.json
14
+ // "files": ["dist","scripts", ...]).
15
+ //
16
+ // Best-effort, fire-and-forget, NEVER blocks or fails compaction: every
17
+ // error path here logs to stderr and exits 0 regardless, so Claude Code's
18
+ // own compaction proceeds unaffected whether the capture succeeds, fails,
19
+ // or never had anything to send.
20
+ //
21
+ // Idempotency: this does NOT invent its own turn-hash dedup. It posts the
22
+ // same shape entity-memory-capture already accepts from the normal
23
+ // completed-turn path (surface differs: 'precompact' vs 'chat'/issued_via),
24
+ // and relies on entity-memory-capture's existing episode/turn-hash dedup
25
+ // (T3, sha256(entity_id|user_text|assistant_text)) to make a later
26
+ // completed-turn capture of the SAME exchange a safe no-op.
27
+ //
28
+ // For that to actually hold, this hook's assistantText must match — byte
29
+ // for byte — what the completed-turn path will eventually read back from
30
+ // entity_runtime_requests.assistant_text. That column is populated by the
31
+ // live DB trigger _entity_studio_aggregate_assistant_text (see
32
+ // supabase/migrations/20260720130000_entity_guardrails.sql in the studio
33
+ // repo for the current body), which on the request's terminal transition
34
+ // runs:
35
+ //
36
+ // select string_agg((e.payload ->> 'delta'), '' order by e.id)
37
+ // from public.entity_runtime_request_events e
38
+ // where e.request_id = new.id and e.type = 'assistant_text'
39
+ //
40
+ // i.e. every assistant_text delta event emitted for the WHOLE request row,
41
+ // concatenated in emission order with NO separator, tool_use/tool_result/
42
+ // thinking events excluded entirely. lastTurnFromTranscript below mirrors
43
+ // that by walking back to the last genuine user turn (a role:'user' entry
44
+ // that carries real text — NOT a tool_result-only "user" message, which
45
+ // Claude Code also stamps role:'user') and concatenating every subsequent
46
+ // top-level assistant entry's text blocks, in order, with '' — matching
47
+ // string_agg's join exactly.
48
+ //
49
+ // Sidechain (subagent/Task-tool) entries are skipped (the `entry.isSidechain`
50
+ // check below) as a defensive belt-and-braces measure, but on this daemon's
51
+ // current Claude Code transcript layout they never fire in practice: a
52
+ // subagent's own turns are written to a SEPARATE file tree
53
+ // (`<project>/<session>/subagents/agent-<id>.jsonl`), never interleaved into
54
+ // the top-level `<session>.jsonl` this hook's `transcript_path` and this
55
+ // daemon's own live-turn tailer (jsonl-tail.ts's startJsonlTail, fed the
56
+ // identical spawn.jsonlPath — see session-runner.ts) both read. The DB side
57
+ // (entity_runtime_request_events) is built from that SAME top-level-only
58
+ // tail via events.ts's normalizeJsonlLine, which has no isSidechain check of
59
+ // its own — it doesn't need one, because the file it reads structurally
60
+ // never contains sidechain lines. (Corrected 2026-07-21: this comment
61
+ // previously cited claude-print.ts, the separate --print-mode adapter,
62
+ // which this PreCompact-hooked TUI code path never runs through at all.)
63
+ //
64
+ // assistantText.trim() below (T8 ship-gate fix) matches the completed-turn
65
+ // capture path's own normalization — notifyCaptureIfEnabled in
66
+ // session-runner.ts computes `(row.assistant_text ?? '').trim()` before
67
+ // calling buildCapturePayload — so a genuinely-identical exchange hashes to
68
+ // the SAME turn_hash (sha256(entity_id|user_text|assistant_text), computed
69
+ // server-side in entity-memory-capture/index.ts over exactly the strings
70
+ // this payload sends, no server-side trim) on both paths. Before this fix, a
71
+ // transcript-derived assistantText with incidental leading/trailing
72
+ // whitespace (a trailing newline from the JSONL's own text-block boundary,
73
+ // for instance) would hash differently from the SAME text read back post-
74
+ // trim by the completed-turn path, defeating the dedup this whole mechanism
75
+ // exists to rely on — not just for the documented mid-turn-prefix case
76
+ // below, but for a full, complete, otherwise-identical turn too.
77
+ //
78
+ // Residual (honest, not fixed): PreCompact can still fire MID-turn, before
79
+ // the agent has emitted its final assistant message(s) for this request.
80
+ // When that happens this hook captures a PREFIX of the eventual full
81
+ // assistant_text — same userText, shorter assistantText — which hashes to
82
+ // a DIFFERENT dedup key than the later completed-turn capture. That is a
83
+ // genuine possible duplicate (a "prefix episode" alongside the full one),
84
+ // bounded to at most one extra episode per PreCompact-interrupted turn. We
85
+ // deliberately do NOT special-case this by weakening the dedup key (e.g.
86
+ // hashing on entity_id|user_text alone) — that would blind the general
87
+ // dedup to legitimately different answers to a repeated question.
88
+ //
89
+ // Residual class 2 — user_text normalization: userText here is read
90
+ // straight off the transcript's last real user-turn content block(s)
91
+ // (extractText, '\n'-joined if Claude Code ever splits one turn's content
92
+ // into multiple 'text' blocks) and is NOT trimmed, matching the completed-
93
+ // turn path's own `row.prompt ?? ''` (also untrimmed, read back from the
94
+ // DB's stored prompt column, a plain string with no aggregation step of its
95
+ // own — unlike assistant_text there is no DB trigger to mirror here). Both
96
+ // paths are internally consistent in NOT trimming user_text, so this is not
97
+ // currently a known dedup-breaking mismatch — but it is an unverified
98
+ // assumption: nothing here proves the transcript's literal user-message
99
+ // text is always byte-identical to the stored prompt column (a future
100
+ // prompt-construction change, or a Claude Code content-block split, could
101
+ // silently diverge them). Documented rather than asserted-safe.
102
+ //
103
+ // Residual class 3 — guardrail retry turns (guardrail_meta.final_text):
104
+ // when a guardrail check fails, runGuardrailRetryTurn (session-runner.ts)
105
+ // feeds the violation back as a NEW real user-role message on the SAME
106
+ // claude session/transcript, then accumulates the revised reply. The DB's
107
+ // assistant_text trigger aggregates every assistant_text event emitted for
108
+ // the whole request_id regardless of which sub-turn produced it — original
109
+ // attempt AND revision both — and guardrail_meta.final_text is what
110
+ // actually gets pinned as canonical. If PreCompact fires during or after a
111
+ // guardrail retry, lastTurnFromTranscript walks back to that INTERNAL
112
+ // feedback message as "the last genuine user turn" (it has real text and no
113
+ // way to be distinguished from a genuine human prompt at this layer) — so
114
+ // BOTH userText (the guardrail feedback prompt, not the original caller's
115
+ // text) and assistantText (only the revision, not original+revision) can
116
+ // diverge from what the completed-turn path eventually captures. Unlike the
117
+ // mid-turn-prefix residual above, this is not a strict prefix relationship
118
+ // and not bounded to a whitespace/length difference — it is a different
119
+ // exchange shape entirely. Same posture as the other two residuals: honest,
120
+ // documented, not papered over with a weakened dedup key.
121
+
122
+ const fs = require('fs');
123
+ const path = require('path');
124
+ const os = require('os');
125
+
126
+ function readStdinSync() {
127
+ try {
128
+ return fs.readFileSync(0, 'utf-8');
129
+ } catch {
130
+ return '';
131
+ }
132
+ }
133
+
134
+ function extractText(content) {
135
+ if (typeof content === 'string') return content;
136
+ if (Array.isArray(content)) {
137
+ return content
138
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
139
+ .map((b) => b.text)
140
+ .join('\n');
141
+ }
142
+ return '';
143
+ }
144
+
145
+ // Same block-filtering as extractText, but joined with '' — the DB's
146
+ // string_agg(delta, '') separator — for the assistant-side aggregation
147
+ // specifically. In practice a single assistant message content array never
148
+ // carries two adjacent 'text' blocks (the API always merges them), so this
149
+ // only differs from extractText's '\n' join in that hypothetical case; kept
150
+ // separate anyway so the assistant path is exactly, not approximately,
151
+ // faithful to the trigger it's mirroring.
152
+ function extractAssistantText(content) {
153
+ if (typeof content === 'string') return content;
154
+ if (Array.isArray(content)) {
155
+ return content
156
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
157
+ .map((b) => b.text)
158
+ .join('');
159
+ }
160
+ return '';
161
+ }
162
+
163
+ // A "real" user turn: role:'user' carrying actual typed text. Claude Code
164
+ // also stamps role:'user' on tool_result submissions (content is an array
165
+ // of tool_result blocks only) — extractText() already filters those out to
166
+ // '' since it only pulls type:'text' blocks, so they never look like a
167
+ // turn boundary here.
168
+ function isRealUserTurn(msg) {
169
+ return !!msg && msg.role === 'user' && extractText(msg.content).trim() !== '';
170
+ }
171
+
172
+ // Walks the transcript JSONL backwards to find the last genuine user turn
173
+ // (the prompt that started the CURRENT request), then walks forward from
174
+ // there aggregating every top-level assistant entry's text in order — the
175
+ // full "turn" for this request, matching how the DB's
176
+ // _entity_studio_aggregate_assistant_text trigger aggregates
177
+ // entity_runtime_request_events for the same request_id (concatenation in
178
+ // emission order, no separator, tool_use/tool_result/thinking excluded).
179
+ // Multi-assistant-message turns (text → tool_use → tool_result → text →
180
+ // ... → final text) are the norm for agentic sessions — PreCompact can
181
+ // fire mid-turn — so aggregating only the single most recent assistant
182
+ // entry (the old behavior) systematically under-captured relative to what
183
+ // the completed-turn path later reads back, producing a different dedup
184
+ // hash for the same exchange. See the Idempotency comment above for the
185
+ // residual this fix does NOT (and should not) paper over.
186
+ function lastTurnFromTranscript(transcriptPath) {
187
+ try {
188
+ const raw = fs.readFileSync(transcriptPath, 'utf-8');
189
+ const lines = raw.split('\n').filter(Boolean);
190
+ const entries = [];
191
+ for (const line of lines) {
192
+ try {
193
+ entries.push(JSON.parse(line));
194
+ } catch {
195
+ continue;
196
+ }
197
+ }
198
+
199
+ let turnStart = -1;
200
+ let userText = '';
201
+ for (let i = entries.length - 1; i >= 0; i--) {
202
+ const entry = entries[i];
203
+ if (entry && entry.isSidechain) continue; // subagent/Task-tool chatter — DB side never sees it either
204
+ const msg = entry && entry.message;
205
+ if (isRealUserTurn(msg)) {
206
+ turnStart = i;
207
+ userText = extractText(msg.content);
208
+ break;
209
+ }
210
+ }
211
+ if (turnStart === -1) {
212
+ return { userText: '', assistantText: '' };
213
+ }
214
+
215
+ let assistantText = '';
216
+ for (let i = turnStart + 1; i < entries.length; i++) {
217
+ const entry = entries[i];
218
+ if (entry && entry.isSidechain) continue;
219
+ const msg = entry && entry.message;
220
+ if (!msg || msg.role !== 'assistant') continue;
221
+ assistantText += extractAssistantText(msg.content); // '' separator — matches string_agg(delta, '')
222
+ }
223
+ return { userText, assistantText };
224
+ } catch (err) {
225
+ process.stderr.write(`[precompact-capture] transcript read failed: ${err.message}\n`);
226
+ return { userText: '', assistantText: '' };
227
+ }
228
+ }
229
+
230
+ async function main() {
231
+ const entityId = process.env.ENTITY_STUDIO_ENTITY_ID || '';
232
+ const requestId = process.env.RUNTIME_REQUEST_ID || '';
233
+ if (!entityId || !requestId) {
234
+ // Not a daemon-spawned, entity-attributed session (or the daemon
235
+ // didn't thread identity through) — nothing to attribute a capture
236
+ // to. Exit clean, never block compaction.
237
+ process.exit(0);
238
+ return;
239
+ }
240
+
241
+ const stdin = readStdinSync();
242
+ let hookInput = {};
243
+ try {
244
+ hookInput = JSON.parse(stdin || '{}');
245
+ } catch {
246
+ /* malformed/empty stdin — fall through with {} */
247
+ }
248
+ const transcriptPath = hookInput.transcript_path;
249
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) {
250
+ process.exit(0);
251
+ return;
252
+ }
253
+
254
+ const { userText, assistantText: rawAssistantText } = lastTurnFromTranscript(transcriptPath);
255
+ // T8 ship-gate fix: trim to match notifyCaptureIfEnabled's own
256
+ // `(row.assistant_text ?? '').trim()` — see the module-header comment's
257
+ // trim-alignment note for why an untrimmed value here would silently
258
+ // defeat the turn_hash dedup this hook depends on, not just for the
259
+ // documented mid-turn-prefix residual but for complete matching turns too.
260
+ const assistantText = rawAssistantText.trim();
261
+ if (!assistantText) {
262
+ process.exit(0); // nothing produced yet this turn
263
+ return;
264
+ }
265
+
266
+ try {
267
+ const { SUPABASE_URL, SUPABASE_ANON_KEY } = require(path.join(__dirname, '..', 'dist', 'config.js'));
268
+ const { buildCapturePayload, postCapture } = require(path.join(__dirname, '..', 'dist', 'memory-capture.js'));
269
+ const payload = buildCapturePayload(
270
+ { entityId, requestId, surface: 'precompact' },
271
+ userText,
272
+ assistantText,
273
+ { kind: 'jsonl_ref', path: transcriptPath, host: os.hostname() },
274
+ );
275
+ await postCapture(SUPABASE_URL, SUPABASE_ANON_KEY, payload);
276
+ } catch (err) {
277
+ process.stderr.write(`[precompact-capture] capture failed (non-fatal): ${err.message}\n`);
278
+ }
279
+ process.exit(0);
280
+ }
281
+
282
+ // Guarded so `require()`ing this file for unit tests (of extractText /
283
+ // extractAssistantText / lastTurnFromTranscript) doesn't also run main()
284
+ // — main() reads real stdin/env and makes a real network call.
285
+ if (require.main === module) {
286
+ main().catch((err) => {
287
+ process.stderr.write(`[precompact-capture] fatal (swallowed): ${err && err.message}\n`);
288
+ process.exit(0);
289
+ });
290
+ }
291
+
292
+ module.exports = { extractText, extractAssistantText, lastTurnFromTranscript, isRealUserTurn };