@basein/runner 0.2.12 → 0.3.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 (142) hide show
  1. package/README.md +540 -369
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -21
  9. package/dist/bin/bir-scenario.js +593 -122
  10. package/dist/bin/bir.d.ts +12 -6
  11. package/dist/bin/bir.js +2935 -489
  12. package/dist/bin/investigate.js +40 -15
  13. package/dist/bin/scenario-edit.d.ts +7 -1
  14. package/dist/bin/scenario-edit.js +43 -21
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2787 -302
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/recorder.d.ts +51 -3
  72. package/dist/record/recorder.js +3 -0
  73. package/dist/record/redact.d.ts +103 -9
  74. package/dist/record/redact.js +659 -30
  75. package/dist/record/remote-recorder.d.ts +327 -32
  76. package/dist/record/remote-recorder.js +1517 -187
  77. package/dist/record/switchable-recorder.d.ts +126 -0
  78. package/dist/record/switchable-recorder.js +351 -0
  79. package/dist/record/truncate.d.ts +40 -1
  80. package/dist/record/truncate.js +111 -4
  81. package/dist/replay/bash-channel.d.ts +35 -0
  82. package/dist/replay/bash-channel.js +133 -0
  83. package/dist/replay/bundle.d.ts +10 -0
  84. package/dist/replay/bundle.js +10 -0
  85. package/dist/replay/controller.d.ts +369 -20
  86. package/dist/replay/controller.js +1176 -107
  87. package/dist/replay/derive.d.ts +1 -0
  88. package/dist/replay/derive.js +81 -24
  89. package/dist/replay/executor.d.ts +51 -16
  90. package/dist/replay/executor.js +114 -41
  91. package/dist/replay/handover.d.ts +54 -1
  92. package/dist/replay/handover.js +55 -5
  93. package/dist/replay/logic.d.ts +62 -10
  94. package/dist/replay/logic.js +140 -22
  95. package/dist/replay/plan.d.ts +201 -27
  96. package/dist/replay/plan.js +581 -141
  97. package/dist/replay/safety.d.ts +72 -0
  98. package/dist/replay/safety.js +303 -0
  99. package/dist/replay/source-run.d.ts +10 -0
  100. package/dist/replay/source-run.js +80 -35
  101. package/dist/replay/targets.d.ts +15 -1
  102. package/dist/replay/targets.js +62 -4
  103. package/dist/replay/tool-error.d.ts +15 -1
  104. package/dist/replay/tool-error.js +33 -1
  105. package/dist/replay/types.d.ts +7 -4
  106. package/dist/upstream/client.d.ts +56 -3
  107. package/dist/upstream/client.js +61 -4
  108. package/dist/upstream/headers.d.ts +43 -0
  109. package/dist/upstream/headers.js +90 -0
  110. package/dist/upstream/http-client.d.ts +213 -22
  111. package/dist/upstream/http-client.js +1034 -168
  112. package/dist/upstream/http-transport.d.ts +143 -0
  113. package/dist/upstream/http-transport.js +391 -0
  114. package/dist/upstream/lazy-client.d.ts +8 -0
  115. package/dist/upstream/lazy-client.js +29 -1
  116. package/dist/upstream/sse.d.ts +78 -0
  117. package/dist/upstream/sse.js +189 -0
  118. package/dist/upstream/stdio-client.d.ts +61 -1
  119. package/dist/upstream/stdio-client.js +356 -33
  120. package/dist/util/atomic-write.d.ts +29 -0
  121. package/dist/util/atomic-write.js +96 -0
  122. package/dist/util/journal.d.ts +15 -0
  123. package/dist/util/journal.js +48 -3
  124. package/dist/util/log.d.ts +10 -0
  125. package/dist/util/log.js +28 -1
  126. package/dist/util/off-switch.d.ts +46 -0
  127. package/dist/util/off-switch.js +91 -0
  128. package/dist/util/service-health.d.ts +133 -0
  129. package/dist/util/service-health.js +217 -0
  130. package/docs/BaseInstRunner.md +694 -621
  131. package/docs/calculatedReplay.md +1625 -1304
  132. package/docs/calculatedReplayGuide.md +1212 -1087
  133. package/docs/installRun.md +1013 -772
  134. package/docs/loginWeb.md +627 -607
  135. package/docs/mcpmark.md +49 -16
  136. package/docs/my-first-sample.md +569 -545
  137. package/docs/quickstart.md +384 -311
  138. package/docs/t-bench.md +406 -394
  139. package/package.json +58 -57
  140. package/production.md +1371 -0
  141. package/scripts/install.ps1 +97 -6
  142. package/scripts/install.sh +104 -7
@@ -17,7 +17,25 @@
17
17
  * defensive: an unknown shape yields `""` rather than throwing. Nothing here is
18
18
  * ever required for a recording to be valid — a missing context is a missing
19
19
  * nicety, not a failed step.
20
+ *
21
+ * READ INCREMENTALLY, NEVER WHOLE. Every hook used to `readFileSync` the whole
22
+ * transcript and `JSON.parse` every line of it — on the daemon's one thread,
23
+ * which also answers every other session's hooks. A transcript is append-only
24
+ * and grows for the life of a session; with screenshots and big tool outputs
25
+ * it reaches tens, then hundreds of MB. Measured: ~105 ms per parse at 31 MB,
26
+ * ~550 ms and a 1.3 GB heap at 156 MB, paid on *every* tool call and several
27
+ * times per Stop, with parallel tool calls queueing behind each other.
28
+ *
29
+ * So each path keeps a {@link TranscriptIndex}: the byte offset it has folded in
30
+ * and the few running answers the hooks ask for (usage totals, the last
31
+ * assistant text, the recent messages' text and thinking, this prompt's tool
32
+ * results). A call reads only the bytes appended since the last one. A file
33
+ * that shrank, was replaced, or was rewritten in place is noticed (size, inode,
34
+ * birth time, mtime, and the bytes at its head and at the offset) and read
35
+ * again from the start, which is exactly what the old code did every time.
20
36
  */
37
+ /** Forget every cached index (tests; a caller that knows files were replaced). */
38
+ export declare function forgetTranscripts(): void;
21
39
  /**
22
40
  * The last `n` tool results of the *current* prompt, redacted and capped.
23
41
  *
@@ -29,7 +47,9 @@
29
47
  * A transcript compacted mid-turn yields whatever follows that line, which may
30
48
  * be nothing; a transcript with no user prompt at all (a run opened by a proxy
31
49
  * step) yields nothing. Results with `is_error: true` are kept: a failure is
32
- * still the agent's evidence about what it is working on.
50
+ * still the agent's evidence about what it is working on. At most the last
51
+ * {@link MAX_RESULTS} are kept, each clipped to {@link MAX_RESULT_CHARS}
52
+ * characters before it is redacted and capped to `maxBytes`.
33
53
  */
34
54
  export declare function recentToolResults(transcriptPath: string | undefined, n?: number, maxBytes?: number): string[];
35
55
  /** Which rung of R-INTENT-1 a step's intent came from. */
@@ -17,32 +17,26 @@
17
17
  * defensive: an unknown shape yields `""` rather than throwing. Nothing here is
18
18
  * ever required for a recording to be valid — a missing context is a missing
19
19
  * nicety, not a failed step.
20
+ *
21
+ * READ INCREMENTALLY, NEVER WHOLE. Every hook used to `readFileSync` the whole
22
+ * transcript and `JSON.parse` every line of it — on the daemon's one thread,
23
+ * which also answers every other session's hooks. A transcript is append-only
24
+ * and grows for the life of a session; with screenshots and big tool outputs
25
+ * it reaches tens, then hundreds of MB. Measured: ~105 ms per parse at 31 MB,
26
+ * ~550 ms and a 1.3 GB heap at 156 MB, paid on *every* tool call and several
27
+ * times per Stop, with parallel tool calls queueing behind each other.
28
+ *
29
+ * So each path keeps a {@link TranscriptIndex}: the byte offset it has folded in
30
+ * and the few running answers the hooks ask for (usage totals, the last
31
+ * assistant text, the recent messages' text and thinking, this prompt's tool
32
+ * results). A call reads only the bytes appended since the last one. A file
33
+ * that shrank, was replaced, or was rewritten in place is noticed (size, inode,
34
+ * birth time, mtime, and the bytes at its head and at the offset) and read
35
+ * again from the start, which is exactly what the old code did every time.
20
36
  */
21
- import { readFileSync } from "node:fs";
37
+ import { closeSync, openSync, readSync, statSync } from "node:fs";
22
38
  import { redactText } from "../record/redact.js";
23
39
  import { truncateString } from "../record/truncate.js";
24
- function readLines(transcriptPath) {
25
- let raw;
26
- try {
27
- raw = readFileSync(transcriptPath, "utf8");
28
- }
29
- catch {
30
- return [];
31
- }
32
- const out = [];
33
- for (const line of raw.split("\n")) {
34
- const trimmed = line.trim();
35
- if (!trimmed)
36
- continue;
37
- try {
38
- out.push(JSON.parse(trimmed));
39
- }
40
- catch {
41
- // Transcripts are appended concurrently; a half-written line is normal.
42
- }
43
- }
44
- return out;
45
- }
46
40
  function blocksOf(line) {
47
41
  const content = line.message?.content ?? line.content;
48
42
  if (Array.isArray(content))
@@ -69,6 +63,344 @@ function resultTextOf(block) {
69
63
  return block.text;
70
64
  return "";
71
65
  }
66
+ // ── the incremental index ───────────────────────────────────────────────────
67
+ /** Bytes read per `readSync`. A line longer than this spans chunks. */
68
+ const CHUNK_BYTES = 4 * 1024 * 1024;
69
+ /**
70
+ * A line past this is skipped unparsed. Only a tool result carrying an image or
71
+ * a huge log gets here, and nothing read from a transcript needs more of one
72
+ * than its first few KB — while holding and parsing it would put a spike of
73
+ * that size on the daemon's heap.
74
+ */
75
+ const MAX_LINE_BYTES = 32 * 1024 * 1024;
76
+ /** How many bytes of the head, and of the tail before the offset, prove the file is the same file. */
77
+ const PROBE_BYTES = 64;
78
+ /** Transcripts indexed at once. One per live session; the oldest is forgotten first. */
79
+ const MAX_INDEXES = 16;
80
+ /*
81
+ * RETENTION. A recorder lives for days and holds up to {@link MAX_INDEXES}
82
+ * indexes, so what each one keeps is bounded by what the hooks actually ask
83
+ * for — not by what a transcript happens to contain. Extended thinking runs
84
+ * to tens of KB a message and tool outputs to megabytes; kept whole, sixteen
85
+ * busy sessions came to hundreds of MB that were never released.
86
+ */
87
+ /**
88
+ * Messages whose text and thinking are kept, newest last. A `PreToolUse` asks
89
+ * about the call being made *now*, whose message is at the tail — every hook
90
+ * folds in what was appended since the last — so a hundred is generous.
91
+ */
92
+ const MAX_MESSAGES = 128;
93
+ /**
94
+ * Characters of one message's text, and separately of its thinking, kept.
95
+ * Text becomes a step's `context`; thinking is only embedded, and the intent
96
+ * probe sends 4,000 characters of either.
97
+ */
98
+ const MAX_MESSAGE_CHARS = 16 * 1024;
99
+ /** `tool_use_id`s remembered, newest last. */
100
+ const MAX_TOOL_USES = 4_096;
101
+ /** This prompt's tool results kept for derivation (R-PARAM-2 asks for 5 by default). */
102
+ const MAX_RESULTS = 32;
103
+ /** Characters of one tool result kept. Derivation reads 2 KB of each. */
104
+ const MAX_RESULT_CHARS = 8 * 1024;
105
+ /**
106
+ * Append `piece` to `into` while the total stays within
107
+ * {@link MAX_MESSAGE_CHARS}; the piece that crosses it is clipped, and
108
+ * anything after is dropped. Returns the new total.
109
+ */
110
+ function keepWithin(into, held, piece) {
111
+ const room = MAX_MESSAGE_CHARS - held;
112
+ if (room <= 0)
113
+ return held;
114
+ const kept = piece.length > room ? piece.slice(0, room) : piece;
115
+ into.push(kept);
116
+ return held + kept.length;
117
+ }
118
+ const indexes = new Map();
119
+ function freshIndex(st) {
120
+ return {
121
+ ino: st.ino,
122
+ birthtimeMs: st.birthtimeMs,
123
+ mtimeMs: st.mtimeMs,
124
+ offset: 0,
125
+ head: Buffer.alloc(0),
126
+ anchor: Buffer.alloc(0),
127
+ skipping: false,
128
+ lineNo: 0,
129
+ usage: new Map(),
130
+ usageSeen: new Set(),
131
+ lastAssistantText: "",
132
+ firstUserPrompt: "",
133
+ sawPrompt: false,
134
+ results: [],
135
+ messages: new Map(),
136
+ toolUses: new Map(),
137
+ };
138
+ }
139
+ /** Whether `idx` can be continued for a file that now looks like `st`. */
140
+ function continues(idx, st) {
141
+ if (st.ino !== idx.ino || st.birthtimeMs !== idx.birthtimeMs)
142
+ return false; // replaced
143
+ if (st.size < idx.offset)
144
+ return false; // truncated, or compacted into a new file
145
+ // Same size, different mtime: rewritten in place. An append changes the size.
146
+ if (st.size === idx.offset && st.mtimeMs !== idx.mtimeMs)
147
+ return false;
148
+ return true;
149
+ }
150
+ /** Read exactly `length` bytes at `position`, or fewer at end of file. */
151
+ function readAt(fd, position, length) {
152
+ const buf = Buffer.allocUnsafe(length);
153
+ let got = 0;
154
+ while (got < length) {
155
+ const n = readSync(fd, buf, got, length - got, position + got);
156
+ if (n <= 0)
157
+ break;
158
+ got += n;
159
+ }
160
+ return got === length ? buf : buf.subarray(0, got);
161
+ }
162
+ /**
163
+ * The index for `path`, caught up with whatever was appended since the last
164
+ * call. Undefined when the file cannot be read — which every caller treats as
165
+ * an empty transcript, as the whole-file reader always did.
166
+ */
167
+ function indexFor(path) {
168
+ let st;
169
+ try {
170
+ st = statSync(path);
171
+ }
172
+ catch {
173
+ indexes.delete(path);
174
+ return undefined;
175
+ }
176
+ if (!st.isFile())
177
+ return undefined;
178
+ let idx = indexes.get(path);
179
+ // Re-inserted on every use, so the Map's order is least-recently-used first.
180
+ if (idx)
181
+ indexes.delete(path);
182
+ if (!idx || !continues(idx, st))
183
+ idx = freshIndex(st);
184
+ indexes.set(path, idx);
185
+ while (indexes.size > MAX_INDEXES) {
186
+ const oldest = indexes.keys().next().value;
187
+ if (oldest === undefined)
188
+ break;
189
+ indexes.delete(oldest);
190
+ }
191
+ if (st.size === idx.offset)
192
+ return idx;
193
+ try {
194
+ return catchUp(path, idx, st);
195
+ }
196
+ catch {
197
+ // Unreadable mid-way (locked, deleted): what was folded in so far stands.
198
+ return indexes.get(path);
199
+ }
200
+ }
201
+ /** Fold in what was appended; the index to use from now on (a fresh one after a rewrite). */
202
+ function catchUp(path, stale, st) {
203
+ const fd = openSync(path, "r");
204
+ try {
205
+ let idx = stale;
206
+ if (idx.offset > 0) {
207
+ // The same size-and-inode can hide a rewrite: prove the bytes we already
208
+ // folded in are still there, at the head and just before the offset.
209
+ const head = readAt(fd, 0, idx.head.length);
210
+ const anchorAt = idx.offset - idx.anchor.length;
211
+ const anchor = readAt(fd, anchorAt, idx.anchor.length);
212
+ if (!head.equals(idx.head) || !anchor.equals(idx.anchor)) {
213
+ idx = freshIndex(st);
214
+ indexes.set(path, idx);
215
+ }
216
+ }
217
+ fold(idx, fd, st.size);
218
+ idx.mtimeMs = st.mtimeMs;
219
+ if (idx.head.length < PROBE_BYTES)
220
+ idx.head = readAt(fd, 0, Math.min(PROBE_BYTES, idx.offset));
221
+ const anchorLength = Math.min(PROBE_BYTES, idx.offset);
222
+ idx.anchor = readAt(fd, idx.offset - anchorLength, anchorLength);
223
+ return idx;
224
+ }
225
+ finally {
226
+ closeSync(fd);
227
+ }
228
+ }
229
+ /**
230
+ * Fold the bytes from `idx.offset` to `size` into `idx`, line by line.
231
+ *
232
+ * Only whole lines advance the offset. The last, unterminated line is folded in
233
+ * when it already parses — a writer that ends the file without a newline has
234
+ * still written a complete line — and otherwise left for the next call:
235
+ * transcripts are appended concurrently, and a half-written line is normal.
236
+ */
237
+ function fold(idx, fd, size) {
238
+ let position = idx.offset;
239
+ /** Bytes of the line in progress, from earlier chunks of this pass. */
240
+ let pending = [];
241
+ let pendingBytes = 0;
242
+ while (position < size) {
243
+ const chunk = readAt(fd, position, Math.min(CHUNK_BYTES, size - position));
244
+ if (chunk.length === 0)
245
+ break;
246
+ const chunkStart = position;
247
+ position += chunk.length;
248
+ let start = 0;
249
+ for (;;) {
250
+ const nl = chunk.indexOf(0x0a, start);
251
+ if (nl === -1)
252
+ break;
253
+ if (idx.skipping) {
254
+ idx.skipping = false;
255
+ }
256
+ else {
257
+ const line = pendingBytes > 0 ? Buffer.concat([...pending, chunk.subarray(start, nl)]) : chunk.subarray(start, nl);
258
+ ingest(idx, line);
259
+ }
260
+ pending = [];
261
+ pendingBytes = 0;
262
+ start = nl + 1;
263
+ idx.offset = chunkStart + start;
264
+ }
265
+ if (start < chunk.length && !idx.skipping) {
266
+ // Copied: a subarray would pin the whole 4 MB chunk.
267
+ pending.push(Buffer.from(chunk.subarray(start)));
268
+ pendingBytes += chunk.length - start;
269
+ if (pendingBytes > MAX_LINE_BYTES) {
270
+ idx.skipping = true;
271
+ pending = [];
272
+ pendingBytes = 0;
273
+ }
274
+ }
275
+ if (idx.skipping)
276
+ idx.offset = position; // skipped bytes are consumed bytes
277
+ }
278
+ if (pendingBytes > 0 && !idx.skipping) {
279
+ const tail = Buffer.concat(pending);
280
+ const parsed = parse(tail);
281
+ if (parsed) {
282
+ foldLine(idx, parsed);
283
+ idx.offset = position;
284
+ }
285
+ }
286
+ }
287
+ function parse(bytes) {
288
+ const text = bytes.toString("utf8").trim();
289
+ if (!text)
290
+ return undefined;
291
+ try {
292
+ const value = JSON.parse(text);
293
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
294
+ }
295
+ catch {
296
+ return undefined;
297
+ }
298
+ }
299
+ function ingest(idx, bytes) {
300
+ const line = parse(bytes);
301
+ // A terminated line that does not parse is corrupt, not half-written: skip it.
302
+ if (line)
303
+ foldLine(idx, line);
304
+ }
305
+ /** What one line contributes to every answer this module gives. */
306
+ function foldLine(idx, line) {
307
+ idx.lineNo += 1;
308
+ const message = line.message;
309
+ const role = message?.role ?? line.type;
310
+ const blocks = blocksOf(line);
311
+ // Usage, deduplicated by message id (see `totalUsage`'s note on why).
312
+ const usage = message?.usage;
313
+ if (usage && typeof usage === "object") {
314
+ const id = message?.id;
315
+ if (!id || !idx.usageSeen.has(id)) {
316
+ if (id)
317
+ idx.usageSeen.add(id);
318
+ const model = message?.model ?? "unknown";
319
+ const acc = idx.usage.get(model) ?? zeroUsage(model);
320
+ acc.inputTokens += usage.input_tokens ?? 0;
321
+ acc.outputTokens += usage.output_tokens ?? 0;
322
+ acc.cacheReadTokens += usage.cache_read_input_tokens ?? 0;
323
+ acc.cacheCreationTokens += usage.cache_creation_input_tokens ?? 0;
324
+ idx.usage.set(model, acc);
325
+ }
326
+ }
327
+ if (role === "assistant") {
328
+ const text = textOf(blocks);
329
+ if (text)
330
+ idx.lastAssistantText = text;
331
+ }
332
+ else if (role === "user") {
333
+ // A user line carrying text is a prompt: "this prompt's results" start
334
+ // again from it, its own tool results included.
335
+ const text = textOf(blocks);
336
+ if (text) {
337
+ if (!idx.sawPrompt)
338
+ idx.firstUserPrompt = text;
339
+ idx.sawPrompt = true;
340
+ idx.results = [];
341
+ }
342
+ if (idx.sawPrompt) {
343
+ for (const block of blocks) {
344
+ if (block.type !== "tool_result")
345
+ continue;
346
+ const result = resultTextOf(block);
347
+ if (!result)
348
+ continue;
349
+ idx.results.push(result.length > MAX_RESULT_CHARS ? result.slice(0, MAX_RESULT_CHARS) : result);
350
+ if (idx.results.length > MAX_RESULTS)
351
+ idx.results.shift();
352
+ }
353
+ }
354
+ }
355
+ // Intent: every line of a multi-block message repeats its `message.id`, so a
356
+ // tool call's text and thinking are gathered from its siblings (R-INTENT-3).
357
+ const toolUses = blocks.filter((b) => b.type === "tool_use" && typeof b.id === "string");
358
+ const messageId = message?.id;
359
+ // No `message.id`: an older transcript or a fixture, where the line is the
360
+ // whole message. Only such a line holding a tool call is worth keeping.
361
+ if (!messageId && toolUses.length === 0)
362
+ return;
363
+ const key = messageId ?? `#line${idx.lineNo}`;
364
+ let entry = idx.messages.get(key);
365
+ if (!entry) {
366
+ entry = { texts: [], thinkings: [], textChars: 0, thinkingChars: 0 };
367
+ idx.messages.set(key, entry);
368
+ if (idx.messages.size > MAX_MESSAGES) {
369
+ const oldest = idx.messages.keys().next().value;
370
+ if (oldest !== undefined)
371
+ idx.messages.delete(oldest);
372
+ }
373
+ }
374
+ for (const b of blocks) {
375
+ if (b.type === "text" && typeof b.text === "string") {
376
+ entry.textChars = keepWithin(entry.texts, entry.textChars, b.text);
377
+ }
378
+ else if (b.type === "thinking" && typeof b.thinking === "string") {
379
+ entry.thinkingChars = keepWithin(entry.thinkings, entry.thinkingChars, b.thinking);
380
+ }
381
+ }
382
+ if (toolUses.length === 0)
383
+ return;
384
+ const whole = textOf(blocks);
385
+ const ownText = whole.length > MAX_MESSAGE_CHARS ? whole.slice(0, MAX_MESSAGE_CHARS) : whole;
386
+ for (const use of toolUses) {
387
+ const id = use.id;
388
+ // The first line holding the call is the one that counts, as before.
389
+ if (idx.toolUses.has(id))
390
+ continue;
391
+ idx.toolUses.set(id, { message: key, ownText });
392
+ if (idx.toolUses.size > MAX_TOOL_USES) {
393
+ const oldest = idx.toolUses.keys().next().value;
394
+ if (oldest !== undefined)
395
+ idx.toolUses.delete(oldest);
396
+ }
397
+ }
398
+ }
399
+ /** Forget every cached index (tests; a caller that knows files were replaced). */
400
+ export function forgetTranscripts() {
401
+ indexes.clear();
402
+ }
403
+ // ── accessors ───────────────────────────────────────────────────────────────
72
404
  /**
73
405
  * The last `n` tool results of the *current* prompt, redacted and capped.
74
406
  *
@@ -80,38 +412,17 @@ function resultTextOf(block) {
80
412
  * A transcript compacted mid-turn yields whatever follows that line, which may
81
413
  * be nothing; a transcript with no user prompt at all (a run opened by a proxy
82
414
  * step) yields nothing. Results with `is_error: true` are kept: a failure is
83
- * still the agent's evidence about what it is working on.
415
+ * still the agent's evidence about what it is working on. At most the last
416
+ * {@link MAX_RESULTS} are kept, each clipped to {@link MAX_RESULT_CHARS}
417
+ * characters before it is redacted and capped to `maxBytes`.
84
418
  */
85
419
  export function recentToolResults(transcriptPath, n = 5, maxBytes = 2048) {
86
420
  if (!transcriptPath || n <= 0)
87
421
  return [];
88
- const lines = readLines(transcriptPath);
89
- let start = -1;
90
- for (let i = lines.length - 1; i >= 0; i -= 1) {
91
- const role = lines[i].message?.role ?? lines[i].type;
92
- if (role !== "user")
93
- continue;
94
- if (textOf(blocksOf(lines[i]))) {
95
- start = i;
96
- break;
97
- }
98
- }
99
- if (start === -1)
422
+ const idx = indexFor(transcriptPath);
423
+ if (!idx || !idx.sawPrompt)
100
424
  return [];
101
- const out = [];
102
- for (let i = start; i < lines.length; i += 1) {
103
- const role = lines[i].message?.role ?? lines[i].type;
104
- if (role !== "user")
105
- continue;
106
- for (const block of blocksOf(lines[i])) {
107
- if (block.type !== "tool_result")
108
- continue;
109
- const text = resultTextOf(block);
110
- if (text)
111
- out.push(text);
112
- }
113
- }
114
- return out.slice(-n).map((t) => truncateString(redactText(t), maxBytes));
425
+ return idx.results.slice(-n).map((t) => truncateString(redactText(t), maxBytes));
115
426
  }
116
427
  /**
117
428
  * The intent of one tool call, read the way the transcript really stores it
@@ -134,28 +445,18 @@ export function recentToolResults(transcriptPath, n = 5, maxBytes = 2048) {
134
445
  export function intentForToolUse(transcriptPath, toolUseId, sources = new Set(["text", "thinking", "tool"])) {
135
446
  if (!transcriptPath || !toolUseId)
136
447
  return undefined;
137
- const lines = readLines(transcriptPath);
138
- const holder = lines.find((line) => blocksOf(line).some((b) => b.type === "tool_use" && b.id === toolUseId));
139
- if (!holder)
448
+ const idx = indexFor(transcriptPath);
449
+ const holder = idx?.toolUses.get(toolUseId);
450
+ const message = holder ? idx?.messages.get(holder.message) : undefined;
451
+ if (!message)
140
452
  return undefined;
141
- // No `message.id` means an older transcript, or a hand-written fixture: the
142
- // line is the whole message and there are no siblings to gather.
143
- const messageId = holder.message?.id;
144
- const siblings = messageId
145
- ? lines.filter((line) => line.message?.id === messageId)
146
- : [holder];
147
453
  if (sources.has("text")) {
148
- const text = textOf(siblings.flatMap((line) => blocksOf(line)));
454
+ const text = message.texts.join("\n").trim();
149
455
  if (text)
150
456
  return { text, source: "text" };
151
457
  }
152
458
  if (sources.has("thinking")) {
153
- const thinking = siblings
154
- .flatMap((line) => blocksOf(line))
155
- .filter((b) => b.type === "thinking" && typeof b.thinking === "string")
156
- .map((b) => b.thinking)
157
- .join("\n")
158
- .trim();
459
+ const thinking = message.thinkings.join("\n").trim();
159
460
  if (thinking)
160
461
  return { text: thinking, source: "thinking" };
161
462
  }
@@ -165,29 +466,13 @@ export function intentForToolUse(transcriptPath, toolUseId, sources = new Set(["
165
466
  export function contextForToolUse(transcriptPath, toolUseId) {
166
467
  if (!transcriptPath || !toolUseId)
167
468
  return "";
168
- for (const line of readLines(transcriptPath)) {
169
- const blocks = blocksOf(line);
170
- if (blocks.some((b) => b.type === "tool_use" && b.id === toolUseId)) {
171
- return textOf(blocks);
172
- }
173
- }
174
- return "";
469
+ return indexFor(transcriptPath)?.toolUses.get(toolUseId)?.ownText ?? "";
175
470
  }
176
471
  /** The last assistant text in the transcript — the turn's final answer. */
177
472
  export function lastAssistantText(transcriptPath) {
178
473
  if (!transcriptPath)
179
474
  return "";
180
- const lines = readLines(transcriptPath);
181
- for (let i = lines.length - 1; i >= 0; i -= 1) {
182
- const line = lines[i];
183
- const role = line.message?.role ?? line.type;
184
- if (role !== "assistant")
185
- continue;
186
- const text = textOf(blocksOf(line));
187
- if (text)
188
- return text;
189
- }
190
- return "";
475
+ return indexFor(transcriptPath)?.lastAssistantText ?? "";
191
476
  }
192
477
  /**
193
478
  * The turn's final answer, waiting briefly for the transcript to catch up.
@@ -251,28 +536,17 @@ const zeroUsage = (model) => ({
251
536
  *
252
537
  * A line with no `message.id` is counted once on its own (older transcripts, and
253
538
  * anything hand-written in a test).
539
+ *
540
+ * The running totals live in the index ({@link foldLine}); this returns a copy,
541
+ * because a mark is kept and compared long after the index has moved on.
254
542
  */
255
543
  function totalUsage(transcriptPath) {
256
544
  const totals = new Map();
257
- const seen = new Set();
258
- for (const line of readLines(transcriptPath)) {
259
- const usage = line.message?.usage;
260
- if (!usage)
261
- continue;
262
- const id = line.message?.id;
263
- if (id) {
264
- if (seen.has(id))
265
- continue;
266
- seen.add(id);
267
- }
268
- const model = line.message?.model ?? "unknown";
269
- const acc = totals.get(model) ?? zeroUsage(model);
270
- acc.inputTokens += usage.input_tokens ?? 0;
271
- acc.outputTokens += usage.output_tokens ?? 0;
272
- acc.cacheReadTokens += usage.cache_read_input_tokens ?? 0;
273
- acc.cacheCreationTokens += usage.cache_creation_input_tokens ?? 0;
274
- totals.set(model, acc);
275
- }
545
+ const idx = indexFor(transcriptPath);
546
+ if (!idx)
547
+ return totals;
548
+ for (const [model, u] of idx.usage)
549
+ totals.set(model, { ...u });
276
550
  return totals;
277
551
  }
278
552
  /**
@@ -333,14 +607,6 @@ export function usageSince(transcriptPath, mark) {
333
607
  export function firstUserPrompt(transcriptPath) {
334
608
  if (!transcriptPath)
335
609
  return "";
336
- for (const line of readLines(transcriptPath)) {
337
- const role = line.message?.role ?? line.type;
338
- if (role !== "user")
339
- continue;
340
- const text = textOf(blocksOf(line));
341
- if (text)
342
- return text;
343
- }
344
- return "";
610
+ return indexFor(transcriptPath)?.firstUserPrompt ?? "";
345
611
  }
346
612
  //# sourceMappingURL=transcript.js.map
package/dist/index.d.ts CHANGED
@@ -26,7 +26,7 @@ export * from "./control/discovery.js";
26
26
  export * from "./control/paths.js";
27
27
  export * from "./record/recorder.js";
28
28
  export { RemoteRecorder } from "./record/remote-recorder.js";
29
- export { redact, redactText, REDACTED } from "./record/redact.js";
29
+ export { redact, redactText, redactJsonText, REDACTED } from "./record/redact.js";
30
30
  export { serializeCapped, truncateString, truncateStrings, DEFAULT_MAX_PAYLOAD_BYTES, } from "./record/truncate.js";
31
31
  export { StepQueue } from "./record/queue.js";
32
32
  export * from "./config/resolve.js";
@@ -34,4 +34,8 @@ export * from "./config/generate.js";
34
34
  export { authenticate, loadCredentials, saveCredentials, clearCredentials } from "./auth/client.js";
35
35
  export type { AuthSession, AuthUser } from "./auth/client.js";
36
36
  export { logLine, logDetail } from "./util/log.js";
37
+ export { isRunnerOff, offSource, setRunnerOff, offFlagPath, projectOffFlagPath } from "./util/off-switch.js";
38
+ export type { OffSource } from "./util/off-switch.js";
39
+ export { serviceHealth, ServiceHealth, awaitWithin, timeoutSignal, serviceTimeoutMs, isUnhealthyStatus } from "./util/service-health.js";
40
+ export type { HealthSnapshot, ServiceHealthOptions } from "./util/service-health.js";
37
41
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -22,11 +22,15 @@ export * from "./control/discovery.js";
22
22
  export * from "./control/paths.js";
23
23
  export * from "./record/recorder.js";
24
24
  export { RemoteRecorder } from "./record/remote-recorder.js";
25
- export { redact, redactText, REDACTED } from "./record/redact.js";
25
+ export { redact, redactText, redactJsonText, REDACTED } from "./record/redact.js";
26
26
  export { serializeCapped, truncateString, truncateStrings, DEFAULT_MAX_PAYLOAD_BYTES, } from "./record/truncate.js";
27
27
  export { StepQueue } from "./record/queue.js";
28
28
  export * from "./config/resolve.js";
29
29
  export * from "./config/generate.js";
30
30
  export { authenticate, loadCredentials, saveCredentials, clearCredentials } from "./auth/client.js";
31
31
  export { logLine, logDetail } from "./util/log.js";
32
+ // An embedder hosting its own control server honours the same kill switch
33
+ // (`bir off`) and reads the same verdict on the service as the binaries do.
34
+ export { isRunnerOff, offSource, setRunnerOff, offFlagPath, projectOffFlagPath } from "./util/off-switch.js";
35
+ export { serviceHealth, ServiceHealth, awaitWithin, timeoutSignal, serviceTimeoutMs, isUnhealthyStatus } from "./util/service-health.js";
32
36
  //# sourceMappingURL=index.js.map