agent-relay-server 0.122.0 → 0.122.1

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.
Files changed (52) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +5 -6
  3. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  4. package/runner/src/adapters/claude-delivery.ts +127 -0
  5. package/runner/src/adapters/claude-quota-harvest.ts +92 -0
  6. package/runner/src/adapters/claude-session-probe.ts +225 -0
  7. package/runner/src/adapters/claude-status-detectors.ts +147 -0
  8. package/runner/src/adapters/claude-transcript-tail.ts +128 -0
  9. package/runner/src/adapters/claude-transcript.ts +414 -0
  10. package/runner/src/adapters/claude.ts +672 -0
  11. package/runner/src/adapters/codex-agent-message-capture.ts +139 -0
  12. package/runner/src/adapters/codex-client.ts +279 -0
  13. package/runner/src/adapters/codex.ts +1345 -0
  14. package/runner/src/attachment-cache.ts +189 -0
  15. package/runner/src/busy-reconciler.ts +102 -0
  16. package/runner/src/claim-tracker.ts +140 -0
  17. package/runner/src/claude-prompt-gates.ts +268 -0
  18. package/runner/src/context-probe-state.ts +6 -0
  19. package/runner/src/continuation-archive.ts +179 -0
  20. package/runner/src/control-server.ts +639 -0
  21. package/runner/src/index.ts +396 -0
  22. package/runner/src/launch-assembly.ts +508 -0
  23. package/runner/src/liveness.ts +50 -0
  24. package/runner/src/logger.ts +99 -0
  25. package/runner/src/mcp-outbox.ts +120 -0
  26. package/runner/src/message-body-config/config.ts +3 -0
  27. package/runner/src/message-body-config.ts +1 -0
  28. package/runner/src/native-self-resume.ts +202 -0
  29. package/runner/src/outbox.ts +342 -0
  30. package/runner/src/process-meta.ts +9 -0
  31. package/runner/src/profile-home.ts +260 -0
  32. package/runner/src/profile-projection.ts +305 -0
  33. package/runner/src/providers.ts +20 -0
  34. package/runner/src/provisioning.ts +314 -0
  35. package/runner/src/quota.ts +40 -0
  36. package/runner/src/rate-limit.ts +189 -0
  37. package/runner/src/relay-injection-events.ts +102 -0
  38. package/runner/src/relay-instructions.ts +60 -0
  39. package/runner/src/relay-mcp-proxy.ts +383 -0
  40. package/runner/src/relay-mcp.ts +156 -0
  41. package/runner/src/reply-obligation-cache.ts +136 -0
  42. package/runner/src/response-capture-report.ts +63 -0
  43. package/runner/src/runner-core.ts +2409 -0
  44. package/runner/src/runner-helpers.ts +435 -0
  45. package/runner/src/runner-insights.ts +105 -0
  46. package/runner/src/runner.ts +14 -0
  47. package/runner/src/session-destroy.ts +22 -0
  48. package/runner/src/session-insights.ts +118 -0
  49. package/runner/src/session-scratch.ts +271 -0
  50. package/runner/src/template-resolver.ts +29 -0
  51. package/runner/src/version.ts +43 -0
  52. package/src/gate-resolver.ts +7 -12
@@ -0,0 +1,128 @@
1
+ import { open, stat } from "node:fs/promises";
2
+ import { LatestTurnStepAccumulator, type TurnStep } from "./claude-transcript";
3
+
4
+ const FINGERPRINT_BYTES = 4096;
5
+
6
+ export interface ClaudeTranscriptFileIdentity {
7
+ dev: number;
8
+ ino: number;
9
+ birthtimeMs: number;
10
+ }
11
+
12
+ interface TailFingerprint {
13
+ offset: number;
14
+ bytes: Buffer;
15
+ }
16
+
17
+ export interface IncrementalTranscriptPollResult {
18
+ available: boolean;
19
+ changed: boolean;
20
+ reset: boolean;
21
+ steps: TurnStep[];
22
+ transcriptLooksComplete: boolean;
23
+ }
24
+
25
+ export class IncrementalClaudeTranscriptTail {
26
+ private offset = 0;
27
+ private pending = Buffer.alloc(0);
28
+ private identity: ClaudeTranscriptFileIdentity | undefined;
29
+ private fingerprint: TailFingerprint | undefined;
30
+ private readonly accumulator = new LatestTurnStepAccumulator();
31
+
32
+ async poll(path: string): Promise<IncrementalTranscriptPollResult> {
33
+ let fileStat: Awaited<ReturnType<typeof stat>>;
34
+ try {
35
+ fileStat = await stat(path);
36
+ } catch {
37
+ return this.result({ available: false, changed: false, reset: false });
38
+ }
39
+
40
+ const nextIdentity = { dev: fileStat.dev, ino: fileStat.ino, birthtimeMs: fileStat.birthtimeMs };
41
+ const replaced = this.identity !== undefined && !sameIdentity(this.identity, nextIdentity);
42
+ const truncated = this.offset > fileStat.size;
43
+ let reset = replaced || truncated;
44
+ if (!reset && this.identity && this.fingerprint && fileStat.size >= this.offset) {
45
+ reset = !(await this.fingerprintMatches(path));
46
+ }
47
+ if (reset) this.reset(nextIdentity);
48
+ else if (!this.identity) this.identity = nextIdentity;
49
+
50
+ if (this.offset === fileStat.size) {
51
+ return this.result({ available: true, changed: false, reset });
52
+ }
53
+
54
+ const length = fileStat.size - this.offset;
55
+ const buffer = Buffer.alloc(length);
56
+ let bytesRead = 0;
57
+ const handle = await open(path, "r");
58
+ try {
59
+ const read = await handle.read(buffer, 0, length, this.offset);
60
+ bytesRead = read.bytesRead;
61
+ } finally {
62
+ await handle.close();
63
+ }
64
+
65
+ this.offset += bytesRead;
66
+ const delta = buffer.subarray(0, bytesRead);
67
+ this.updateFingerprint(delta);
68
+ const changed = bytesRead > 0 && this.consume(delta);
69
+ return this.result({ available: true, changed, reset });
70
+ }
71
+
72
+ private reset(identity: ClaudeTranscriptFileIdentity): void {
73
+ this.offset = 0;
74
+ this.pending = Buffer.alloc(0);
75
+ this.fingerprint = undefined;
76
+ this.identity = identity;
77
+ this.accumulator.reset();
78
+ }
79
+
80
+ private async fingerprintMatches(path: string): Promise<boolean> {
81
+ if (!this.fingerprint) return true;
82
+ const buffer = Buffer.alloc(this.fingerprint.bytes.length);
83
+ const handle = await open(path, "r");
84
+ try {
85
+ const read = await handle.read(buffer, 0, buffer.length, this.fingerprint.offset);
86
+ return read.bytesRead === buffer.length && buffer.equals(this.fingerprint.bytes);
87
+ } finally {
88
+ await handle.close();
89
+ }
90
+ }
91
+
92
+ private updateFingerprint(delta: Buffer): void {
93
+ if (!delta.length) return;
94
+ const previous = this.fingerprint?.bytes ?? Buffer.alloc(0);
95
+ const combined = previous.length ? Buffer.concat([previous, delta]) : delta;
96
+ const bytes = Buffer.from(combined.subarray(Math.max(0, combined.length - FINGERPRINT_BYTES)));
97
+ this.fingerprint = { offset: this.offset - bytes.length, bytes };
98
+ }
99
+
100
+ private consume(delta: Buffer): boolean {
101
+ if (!delta.length) return false;
102
+ const combined = this.pending.length ? Buffer.concat([this.pending, delta]) : delta;
103
+ const lastNewline = combined.lastIndexOf(0x0a);
104
+ if (lastNewline === -1) {
105
+ this.pending = Buffer.from(combined);
106
+ return false;
107
+ }
108
+
109
+ const complete = combined.subarray(0, lastNewline + 1);
110
+ this.pending = Buffer.from(combined.subarray(lastNewline + 1));
111
+ this.accumulator.appendJsonl(complete.toString("utf8"));
112
+ return true;
113
+ }
114
+
115
+ private result(input: { available: boolean; changed: boolean; reset: boolean }): IncrementalTranscriptPollResult {
116
+ return {
117
+ ...input,
118
+ steps: this.accumulator.steps,
119
+ transcriptLooksComplete: this.accumulator.transcriptLooksComplete,
120
+ };
121
+ }
122
+ }
123
+
124
+ function sameIdentity(a: ClaudeTranscriptFileIdentity, b: ClaudeTranscriptFileIdentity): boolean {
125
+ return a.dev === b.dev && a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
126
+ }
127
+
128
+ export const sameClaudeTranscriptFileIdentityForTests = sameIdentity;
@@ -0,0 +1,414 @@
1
+ // Phase 1 live-session lane: extract the user-visible assistant turn from a
2
+ // Claude Code transcript JSONL so it can be surfaced in the dashboard chat
3
+ // without the agent re-emitting it via /reply (zero agent tokens).
4
+ //
5
+ // Transcript shape (one JSON object per line):
6
+ // { type: "user", message: { content: "..." | [{type:"text"|"tool_result", ...}] } }
7
+ // { type: "assistant", message: { content: [{type:"thinking"|"text"|"tool_use", ...}], stop_reason } }
8
+ //
9
+ // The "current turn" is everything after the last *real* user prompt (a user
10
+ // entry carrying text, not just tool_result blocks). We collect the assistant
11
+ // `text` blocks from that turn — thinking and tool_use are dropped.
12
+
13
+ import { computeContextRatio, type SessionAnalysis, type SessionEvent } from "../session-insights";
14
+
15
+ interface TranscriptBlock {
16
+ type?: string;
17
+ text?: string;
18
+ thinking?: string;
19
+ name?: string;
20
+ input?: Record<string, unknown>;
21
+ is_error?: boolean;
22
+ }
23
+
24
+ export interface TurnStep {
25
+ type: "narration" | "reasoning" | "tool";
26
+ text: string;
27
+ label?: string;
28
+ }
29
+
30
+ interface TranscriptMessage {
31
+ role?: string;
32
+ content?: string | TranscriptBlock[];
33
+ stop_reason?: string;
34
+ }
35
+
36
+ interface TranscriptEntry {
37
+ type?: string;
38
+ message?: TranscriptMessage;
39
+ // Claude Code stamps every transcript entry with `isSidechain`: true for
40
+ // entries belonging to a Task (subagent) run, false for the root session.
41
+ // Current CC writes sidechains to a separate subagents/*.jsonl so they don't
42
+ // reach the root transcript the runner tails — but older CC inlined them, and
43
+ // the behavior can revert, so the chat-mirror parsers below defensively skip
44
+ // sidechain entries to keep a subagent's reasoning/tools/responses from
45
+ // leaking into the parent agent's chat. Insights parsers (collectClaudeSession-
46
+ // Events/countSubstantiveTurns) intentionally do NOT filter — changing them
47
+ // would shift the #184/#185 baselines, a separate concern.
48
+ isSidechain?: boolean;
49
+ }
50
+
51
+ function blocks(message: TranscriptMessage | undefined): TranscriptBlock[] {
52
+ if (!message || !Array.isArray(message.content)) return [];
53
+ return message.content.filter((b): b is TranscriptBlock => Boolean(b) && typeof b === "object");
54
+ }
55
+
56
+ /** True for a subagent (Task) transcript entry — see the note on TranscriptEntry.isSidechain. */
57
+ function isSidechainEntry(entry: TranscriptEntry): boolean {
58
+ return entry.isSidechain === true;
59
+ }
60
+
61
+ function isRealUserPrompt(entry: TranscriptEntry): boolean {
62
+ if (entry.type !== "user") return false;
63
+ const content = entry.message?.content;
64
+ if (typeof content === "string") return content.trim().length > 0;
65
+ // An array user entry is a real prompt only if it carries a text block;
66
+ // pure tool_result entries are mid-turn plumbing, not a new prompt.
67
+ return blocks(entry.message).some((b) => b.type === "text" && Boolean(b.text?.trim()));
68
+ }
69
+
70
+ function assistantText(entry: TranscriptEntry): string {
71
+ if (entry.type !== "assistant") return "";
72
+ return blocks(entry.message)
73
+ .filter((b) => b.type === "text" && typeof b.text === "string")
74
+ .map((b) => b.text!.trim())
75
+ .filter(Boolean)
76
+ .join("\n\n");
77
+ }
78
+
79
+ /**
80
+ * Returns true when the transcript's final parseable entry is an assistant
81
+ * message with stop_reason "end_turn", meaning the full turn has been flushed.
82
+ * Used to detect a race where the Stop hook reads the file before the last
83
+ * assistant block is written to disk.
84
+ */
85
+ export function transcriptLooksComplete(jsonl: string): boolean {
86
+ const state = new LatestTurnStepAccumulator();
87
+ state.appendJsonl(jsonl);
88
+ return state.transcriptLooksComplete;
89
+ }
90
+
91
+ /**
92
+ * Returns the concatenated assistant `text` for the most recent turn (since the
93
+ * last real user prompt), or "" if there is nothing user-visible to surface.
94
+ */
95
+ export function extractLastAssistantTurn(jsonl: string): string {
96
+ const lines = jsonl.split("\n");
97
+ let collected: string[] = [];
98
+ for (const line of lines) {
99
+ const trimmed = line.trim();
100
+ if (!trimmed) continue;
101
+ let entry: TranscriptEntry;
102
+ try {
103
+ entry = JSON.parse(trimmed) as TranscriptEntry;
104
+ } catch {
105
+ continue;
106
+ }
107
+ if (isSidechainEntry(entry)) continue;
108
+ if (isRealUserPrompt(entry)) {
109
+ collected = [];
110
+ continue;
111
+ }
112
+ const text = assistantText(entry);
113
+ if (text) collected.push(text);
114
+ }
115
+ return collected.join("\n\n").trim();
116
+ }
117
+
118
+ /**
119
+ * Like extractLastAssistantTurn but skips the first `afterEntry` valid JSON entries.
120
+ * Used when a partial transcript flush already emitted entries 1..afterEntry so the
121
+ * Stop-hook full-mode capture doesn't re-emit the already-published text.
122
+ */
123
+ export function extractLastAssistantTurnAfterEntry(jsonl: string, afterEntry: number): string {
124
+ const lines = jsonl.split("\n");
125
+ const collected: string[] = [];
126
+ let entryIndex = 0;
127
+ for (const line of lines) {
128
+ const trimmed = line.trim();
129
+ if (!trimmed) continue;
130
+ let entry: TranscriptEntry;
131
+ try {
132
+ entry = JSON.parse(trimmed) as TranscriptEntry;
133
+ } catch {
134
+ continue;
135
+ }
136
+ entryIndex++;
137
+ if (isSidechainEntry(entry)) continue;
138
+ if (isRealUserPrompt(entry)) {
139
+ collected.length = 0;
140
+ continue;
141
+ }
142
+ if (entryIndex <= afterEntry) continue;
143
+ const text = assistantText(entry);
144
+ if (text) collected.push(text);
145
+ }
146
+ return collected.join("\n\n").trim();
147
+ }
148
+
149
+ /**
150
+ * Count valid JSON entries in a JSONL string. Used to record the high-water-mark
151
+ * after a pre-flush so the Stop hook knows where to resume in full-capture mode.
152
+ */
153
+ export function countTranscriptEntries(jsonl: string): number {
154
+ let count = 0;
155
+ for (const line of jsonl.split("\n")) {
156
+ if (!line.trim()) continue;
157
+ try { JSON.parse(line); count++; } catch {}
158
+ }
159
+ return count;
160
+ }
161
+
162
+ /**
163
+ * Returns only the text from the LAST assistant entry in the current turn.
164
+ * Unlike extractLastAssistantTurn which collects all intermediate text between
165
+ * tool calls, this returns just the final response — what the user sees as the
166
+ * concluding message.
167
+ */
168
+ export function extractFinalAssistantMessage(jsonl: string): string {
169
+ const lines = jsonl.split("\n");
170
+ let lastText = "";
171
+ let pastLastUserPrompt = false;
172
+ for (const line of lines) {
173
+ const trimmed = line.trim();
174
+ if (!trimmed) continue;
175
+ let entry: TranscriptEntry;
176
+ try {
177
+ entry = JSON.parse(trimmed) as TranscriptEntry;
178
+ } catch {
179
+ continue;
180
+ }
181
+ if (isSidechainEntry(entry)) continue;
182
+ if (isRealUserPrompt(entry)) {
183
+ pastLastUserPrompt = true;
184
+ lastText = "";
185
+ continue;
186
+ }
187
+ if (!pastLastUserPrompt) continue;
188
+ const text = assistantText(entry);
189
+ if (text) lastText = text;
190
+ }
191
+ return lastText.trim();
192
+ }
193
+
194
+ /**
195
+ * Like extractFinalAssistantMessage but skips the first `afterEntry` valid JSON
196
+ * entries. Used when a PreToolUse pre-flush already emitted the text before a
197
+ * blocking control, so final-mode Stop capture only sees later assistant text.
198
+ */
199
+ export function extractFinalAssistantMessageAfterEntry(jsonl: string, afterEntry: number): string {
200
+ const lines = jsonl.split("\n");
201
+ let lastText = "";
202
+ let pastLastUserPrompt = afterEntry > 0;
203
+ let entryIndex = 0;
204
+ for (const line of lines) {
205
+ const trimmed = line.trim();
206
+ if (!trimmed) continue;
207
+ let entry: TranscriptEntry;
208
+ try {
209
+ entry = JSON.parse(trimmed) as TranscriptEntry;
210
+ } catch {
211
+ continue;
212
+ }
213
+ entryIndex++;
214
+ if (isSidechainEntry(entry)) continue;
215
+ if (entryIndex <= afterEntry) continue;
216
+ if (isRealUserPrompt(entry)) {
217
+ pastLastUserPrompt = true;
218
+ lastText = "";
219
+ continue;
220
+ }
221
+ if (!pastLastUserPrompt) continue;
222
+ const text = assistantText(entry);
223
+ if (text) lastText = text;
224
+ }
225
+ return lastText.trim();
226
+ }
227
+
228
+ /**
229
+ * Extract text from the `last_assistant_message` field in the Stop hook
230
+ * payload. This is the content of the final assistant message — either a plain
231
+ * string or an array of content blocks (same shape as transcript entries).
232
+ * Thinking and tool_use blocks are dropped, matching extractLastAssistantTurn.
233
+ */
234
+ /**
235
+ * Extract the ordered narration, reasoning, and tool steps for the most recent
236
+ * turn (since the last real user prompt). Used by the reasoning tailer to stream
237
+ * progress into chat while a turn is in flight. `narration` is the assistant's
238
+ * intermediate `text` between tool calls (the terminal's `●` lines); it is the
239
+ * primary, default-visible turn content. Returns steps in transcript order so the
240
+ * tailer can emit only the ones it hasn't seen yet.
241
+ */
242
+ export function extractLatestTurnSteps(jsonl: string): TurnStep[] {
243
+ const state = new LatestTurnStepAccumulator();
244
+ state.appendJsonl(jsonl);
245
+ return state.steps;
246
+ }
247
+
248
+ export class LatestTurnStepAccumulator {
249
+ private latestSteps: TurnStep[] = [];
250
+ private lastAssistantStopReason: string | undefined;
251
+ private parsedEntries = 0;
252
+
253
+ get steps(): TurnStep[] {
254
+ return [...this.latestSteps];
255
+ }
256
+
257
+ get transcriptLooksComplete(): boolean {
258
+ return this.lastAssistantStopReason === "end_turn";
259
+ }
260
+
261
+ get entryCount(): number {
262
+ return this.parsedEntries;
263
+ }
264
+
265
+ reset(): void {
266
+ this.latestSteps = [];
267
+ this.lastAssistantStopReason = undefined;
268
+ this.parsedEntries = 0;
269
+ }
270
+
271
+ appendJsonl(jsonl: string): void {
272
+ for (const line of jsonl.split("\n")) this.appendLine(line);
273
+ }
274
+
275
+ appendLine(line: string): void {
276
+ const trimmed = line.trim();
277
+ if (!trimmed) return;
278
+ let entry: TranscriptEntry;
279
+ try {
280
+ entry = JSON.parse(trimmed) as TranscriptEntry;
281
+ } catch {
282
+ return;
283
+ }
284
+ this.parsedEntries += 1;
285
+ if (isSidechainEntry(entry)) return;
286
+ if (entry.type === "assistant") this.lastAssistantStopReason = entry.message?.stop_reason;
287
+ if (isRealUserPrompt(entry)) {
288
+ this.latestSteps = [];
289
+ return;
290
+ }
291
+ if (entry.type !== "assistant") return;
292
+ for (const b of blocks(entry.message)) {
293
+ if (b.type === "text" && typeof b.text === "string" && b.text.trim()) {
294
+ this.latestSteps.push({ type: "narration", text: b.text.trim() });
295
+ } else if (b.type === "thinking" && typeof b.thinking === "string" && b.thinking.trim()) {
296
+ this.latestSteps.push({ type: "reasoning", text: b.thinking.trim() });
297
+ } else if (b.type === "tool_use" && typeof b.name === "string" && b.name) {
298
+ this.latestSteps.push({ type: "tool", label: b.name, text: summarizeToolUse(b.name, b.input) });
299
+ }
300
+ }
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Stable dedup keys for a turn's steps, in order. Each key is salted with how many
306
+ * identical (type,label,text) steps preceded it in the same window — so running the
307
+ * same tool twice with identical input within a turn yields two distinct keys and
308
+ * both show in the activity trace (#265). Keying on occurrence-within-window rather
309
+ * than raw transcript index keeps the reasoning tailer idempotent when the "latest
310
+ * turn" window shrinks/resets mid-poll: a surviving step recomputes to the same or a
311
+ * lower occurrence, so an already-emitted step never re-fires.
312
+ */
313
+ export function stepDedupKeys(steps: TurnStep[]): string[] {
314
+ const counts = new Map<string, number>();
315
+ return steps.map((step) => {
316
+ const base = JSON.stringify([step.type, step.label ?? "", step.text]);
317
+ const occ = counts.get(base) ?? 0;
318
+ counts.set(base, occ + 1);
319
+ return JSON.stringify([step.type, step.label ?? "", step.text, occ]);
320
+ });
321
+ }
322
+
323
+ /** Compact one-line summary of a tool invocation for the discreet activity row. */
324
+ export function summarizeToolUse(name: string, input: Record<string, unknown> | undefined): string {
325
+ const str = (key: string): string | undefined => (input && typeof input[key] === "string" ? (input[key] as string) : undefined);
326
+ const candidate = str("command") ?? str("file_path") ?? str("path") ?? str("pattern") ?? str("query") ?? str("url") ?? str("description") ?? str("prompt");
327
+ const summary = candidate ? candidate.replace(/\s+/g, " ").trim() : "";
328
+ if (!summary) return name;
329
+ return summary.length > 200 ? `${summary.slice(0, 197)}…` : summary;
330
+ }
331
+
332
+ // --- Insights #184: context-gathering ratio (epic #183, docs/self-improvement.md) ---
333
+ //
334
+ // Computed mechanically from the whole-session transcript at session end — no model
335
+ // involvement, so it costs zero agent tokens and the agent can't game it. The ratio is
336
+ // paired with cheap outcome proxies (user re-prompts, tool errors) so it's never read
337
+ // alone — see the anti-Goodhart constraint in the epic.
338
+
339
+ /**
340
+ * Normalize a full Claude transcript into the provider-agnostic `SessionEvent` stream
341
+ * (#183/#184). The classifier and ratio math live in `session-insights.ts` and are shared
342
+ * across providers; this only knows the Claude JSONL shape. Events are emitted in
343
+ * transcript order so `leadingGather` is meaningful.
344
+ */
345
+ export function collectClaudeSessionEvents(jsonl: string): SessionEvent[] {
346
+ const events: SessionEvent[] = [];
347
+ for (const line of jsonl.split("\n")) {
348
+ const trimmed = line.trim();
349
+ if (!trimmed) continue;
350
+ let entry: TranscriptEntry;
351
+ try {
352
+ entry = JSON.parse(trimmed) as TranscriptEntry;
353
+ } catch {
354
+ continue;
355
+ }
356
+ if (isRealUserPrompt(entry)) events.push({ type: "user_prompt" });
357
+ if (entry.type === "user") {
358
+ for (const b of blocks(entry.message)) {
359
+ if (b.type === "tool_result" && b.is_error === true) events.push({ type: "tool_error" });
360
+ }
361
+ continue;
362
+ }
363
+ if (entry.type !== "assistant") continue;
364
+ let producedSomething = false;
365
+ for (const b of blocks(entry.message)) {
366
+ if (b.type === "text" && b.text?.trim()) producedSomething = true;
367
+ if (b.type !== "tool_use" || typeof b.name !== "string" || !b.name) continue;
368
+ producedSomething = true;
369
+ events.push({ type: "tool", name: b.name });
370
+ }
371
+ if (producedSomething) events.push({ type: "turn" });
372
+ }
373
+ return events;
374
+ }
375
+
376
+ /**
377
+ * Walk the full transcript and compute the context-gathering ratio plus paired outcome
378
+ * proxies. Returns null when there's nothing substantive to measure (no tool calls) —
379
+ * trivial sessions have nothing to learn from and shouldn't pollute the baselines.
380
+ */
381
+ export function analyzeSession(jsonl: string): SessionAnalysis | null {
382
+ return computeContextRatio(collectClaudeSessionEvents(jsonl));
383
+ }
384
+
385
+ /** Count substantive assistant turns — used by the #185 introspection gate. */
386
+ export function countSubstantiveTurns(jsonl: string): number {
387
+ let turns = 0;
388
+ for (const line of jsonl.split("\n")) {
389
+ const trimmed = line.trim();
390
+ if (!trimmed) continue;
391
+ let entry: TranscriptEntry;
392
+ try {
393
+ entry = JSON.parse(trimmed) as TranscriptEntry;
394
+ } catch {
395
+ continue;
396
+ }
397
+ if (entry.type !== "assistant") continue;
398
+ const hasContent = blocks(entry.message).some(
399
+ (b) => (b.type === "text" && b.text?.trim()) || (b.type === "tool_use" && b.name),
400
+ );
401
+ if (hasContent) turns++;
402
+ }
403
+ return turns;
404
+ }
405
+
406
+ export function extractHookAssistantMessage(content: unknown): string {
407
+ if (typeof content === "string") return content.trim();
408
+ if (!Array.isArray(content)) return "";
409
+ return (content as TranscriptBlock[])
410
+ .filter((b) => b.type === "text" && typeof b.text === "string")
411
+ .map((b) => b.text!.trim())
412
+ .filter(Boolean)
413
+ .join("\n\n");
414
+ }