@assuredloop/orca-bot-kit 0.16.0 → 0.18.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.
package/SETUP.md CHANGED
@@ -199,7 +199,7 @@ waiting for:
199
199
  | Codex's `/new`: `Where should the new conversation run?` | `\r` | return: its selection starts on **Current checkout** (bot home) | Codex 0.158.0 |
200
200
  | Codex's update offer, `1. Update now` | `\r` | return: its selection starts on **Update now**, which accepts it | Codex 0.156.1; seen on 0.157.1 (offering 0.158.0) with the selection on **Update now**, not pressed; 0.158.0 has had no newer release to offer |
201
201
  | `[oh-my-zsh] Would you like to update?` | `n` | they update their own shell | oh-my-zsh bf77e35 |
202
- | Claude Code's `Teach auto mode about your environment?` | `\x1b` | Esc, which cancels it. On 2.1.283 it is a form, not a list: `How you use Claude here`, `Also scan shell history`, `Also scan your other repos`, `Continue`, and `Enter to continue · Esc to cancel`. **Never send a return**: Enter is **Continue**, which starts a scan of the project, recent Claude sessions and, by default, the machine's shell history | Not yet proven: Esc cancels in Claude Code 2.1.283's code, not yet seen live (#370); the form was seen live on 2.1.283 (#416); 2.1.278 showed a numbered list, answered with 2. Not now |
202
+ | Claude Code's `Teach auto mode about your environment?` | `\x1b` | Esc, which cancels it (**Not now**). On 2.1.283 it is a form, not a list: `How you use Claude here`, `Also scan shell history`, `Also scan your other repos`, `Continue`, and `Enter to continue · Esc to cancel`. **Never send a return**: Enter is **Continue**, which starts a scan of the project, recent Claude sessions and, by default, the machine's shell history | Not yet proven: Esc cancels in Claude Code 2.1.283's code, not yet seen live (#370); the form was seen live twice on 2.1.283, in #261's live test and #239's live run 1 (#416); 2.1.278 showed a numbered list, answered with 2. Not now |
203
203
 
204
204
  Each key counts on where the selection starts, as it did on the version named.
205
205
  Read the screen in front of you before you send: if the selection is somewhere
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/rules/limits.md CHANGED
@@ -18,5 +18,5 @@ When something you were told to use is missing (a model, a tool, a file), say
18
18
  so and ask. Quietly using a different one hides the change.
19
19
 
20
20
  Permission rules in a harness's settings or rules files are the kit's to write,
21
- after the user's yes to the exact rules: `obk bot change --allow <rule>` or
21
+ after the user's yes to the exact rules: `"${OBK_CLI:-obk}" bot change --allow <rule>` or
22
22
  `--disallow <rule>`. Never edit one in or out yourself, Bot Father included.
package/rules/mail.md CHANGED
@@ -12,5 +12,8 @@ Mail is queued, not an interruption. Read it when you finish what you are on.
12
12
 
13
13
  Ask for a reply when you need one, and send one when you were asked for one.
14
14
 
15
+ A message sent is queued, not read or acted on: the reply, or the work done,
16
+ is what shows it arrived. Look for one before you send it again.
17
+
15
18
  Write to the session that needs it, not to several in the hope that one of
16
19
  them is right.
@@ -19,6 +19,10 @@ own commands write them.
19
19
  These are defaults for work where nothing says otherwise. A user who asks for
20
20
  something else gets what they asked for; say which of these you left and why.
21
21
 
22
+ Where this skill says `obk`, run the kit that started your tab, `"${OBK_CLI:-obk}"`,
23
+ in its place: in a tab the kit launched that is the kit's own path, and anywhere
24
+ else it is plain `obk`.
25
+
22
26
  ## Ask whether this wants a new bot
23
27
 
24
28
  Look at the fleet before you add to it. Where a bot already there could own
@@ -289,7 +293,7 @@ otherwise `/Applications/Orca.app/Contents/Resources/bin/orca`. A bare
289
293
  | Codex's `Where should the new conversation run?` | `\r` | **Current checkout** | Codex 0.158.0 |
290
294
  | Codex's update offer, `1. Update now` | `\r` | **Update now**, where it starts | Codex 0.156.1 |
291
295
  | `[oh-my-zsh] Would you like to update?` | `n` | no: the user updates it | oh-my-zsh bf77e35 |
292
- | Claude Code's `Teach auto mode…` | `\x1b` | Esc cancels; no return | Not yet proven, below |
296
+ | Claude Code's `Teach auto mode…` | `\x1b` | Esc: Not now; no return | Not yet proven, below |
293
297
 
294
298
  Each key counts on where the selection starts on the version named. Read the
295
299
  screen before you send: if the selection is elsewhere or the options have
@@ -302,8 +306,9 @@ Update now, not pressed, and 0.158.0 has had no newer release to offer.
302
306
  Claude Code's `Teach auto mode` on 2.1.283 is a form, not a list, ending
303
307
  `Enter to continue · Esc to cancel`: never send it a return, since
304
308
  Enter is Continue, which scans the project, recent sessions and the machine's
305
- shell history. Esc cancels in its code; that is not yet proven live. On 2.1.278
306
- it was a numbered list, answered with `2. Not now`. Codex's hooks
309
+ shell history. Esc cancels it (Not now) in its code; that is not yet proven
310
+ live, though the form was seen live twice on 2.1.283. On 2.1.278 it was a
311
+ numbered list, answered with `2. Not now`. Codex's hooks
307
312
  question matters most. The kit's hook is how the book learns which
308
313
  conversation the session is running, and until it is answered the
309
314
  conversation has not started. Codex's trust applies to the repository root,
@@ -24,6 +24,10 @@ kind that gets read once.
24
24
  These are defaults for work where nothing says otherwise. A user who asks for
25
25
  something else gets what they asked for; say which of these you left and why.
26
26
 
27
+ Where this skill says `obk`, run the kit that started your tab, `"${OBK_CLI:-obk}"`,
28
+ in its place: in a tab the kit launched that is the kit's own path, and anywhere
29
+ else it is plain `obk`.
30
+
27
31
  ## Start from what was used, not from an impression
28
32
 
29
33
  The kit counts it for you. `obk usage` reports, per session, the conversations
@@ -60,6 +64,32 @@ than metered billing, tokens are not money at all, and the honest answer is what
60
64
  would have cost metered, and what share of the allowance it used where the
61
65
  plan shows that.
62
66
 
67
+ ## When a bill and an estimate cover the same work
68
+
69
+ An invoice, a bill or the provider's usage page is better evidence than an
70
+ estimate made from tokens and a price you looked up today. Where both cover
71
+ the same work, report the charge and say that is where the figure came from.
72
+ The estimate is not added to it: it measures the same spending a second time,
73
+ and is worth keeping beside the charge only to show how far the two differ.
74
+
75
+ Watch for the same thing in smaller pieces. A figure for a whole session
76
+ already includes the calls inside it, so do not add the per-call figures to
77
+ it, or a day's total to the sessions it is made of. Take one level and say
78
+ which.
79
+
80
+ Keep currencies apart. An invoice in euros and an estimate in dollars are two
81
+ figures, each with its currency, until you have a rate you can name, with its
82
+ date and its source. Converting quietly hides a guess inside a number.
83
+
84
+ And an unknown stays unknown. A day with no invoice yet and no price you could
85
+ find is a gap, not a zero: a zero says the work was free.
86
+
87
+ A worked example. For 20 September the provider's invoice says 41.20 USD for
88
+ the API, and the token estimate for the same day, at today's list price, is
89
+ 38.90 USD. Report one figure, 41.20 USD, with its source: the invoice. Add
90
+ that the estimate came to 38.90 USD, so a reader can see the estimate runs
91
+ about six per cent low, and leave it out of the total.
92
+
63
93
  ## Cost a result, not a token
64
94
 
65
95
  Tokens per day tells you nothing about whether the money bought anything. Put
@@ -25,6 +25,10 @@ act on your own, and that is set by the charter you are working under.
25
25
  These are defaults for work where nothing says otherwise. A user who asks for
26
26
  something else gets what they asked for; say which of these you left and why.
27
27
 
28
+ Where this skill says `obk`, run the kit that started your tab, `"${OBK_CLI:-obk}"`,
29
+ in its place: in a tab the kit launched that is the kit's own path, and anywhere
30
+ else it is plain `obk`.
31
+
28
32
  ## Start from what is on disk
29
33
 
30
34
  Read the fleet before you talk about it. `obk roster` reports what each bot
@@ -22,6 +22,10 @@ reports the same thing every morning teaches its reader to stop looking.
22
22
  These are defaults for work where nothing says otherwise. A user who asks for
23
23
  something else gets what they asked for; say which of these you left and why.
24
24
 
25
+ Where this skill says `obk`, run the kit that started your tab, `"${OBK_CLI:-obk}"`,
26
+ in its place: in a tab the kit launched that is the kit's own path, and anywhere
27
+ else it is plain `obk`.
28
+
25
29
  ## Only what has happened since last time
26
30
 
27
31
  Fix the end of the window before you read, not after. Take the moment you are
@@ -33,6 +33,36 @@ they actually gave you: no invented deadline, priority, category or detail.
33
33
  Where an item is too vague to carry a next action, keep it and ask. Do what you
34
34
  can first, then ask once, in your reply rather than in a file.
35
35
 
36
+ ## When the list comes from several places
37
+
38
+ Chats, mail and trackers are full of questions to a group, answered later by
39
+ someone else, or overtaken since. Read only the sources they gave you, and put
40
+ on the list only what is theirs.
41
+
42
+ An item is theirs when it was addressed to them, by name or directly, or when
43
+ they took it on ("I'll look at it"). A question to a group they are in is not
44
+ theirs for being in the group.
45
+
46
+ Before you add one, read what came after it: a later reply may have answered
47
+ it, or someone else may have taken it. Where a tracker keeps its own state,
48
+ that state wins over an old message about it: a closed issue is closed,
49
+ whatever the thread said last week.
50
+
51
+ Tell three kinds apart. Something they have to do goes on the list. A quick
52
+ question they can answer in a line goes to them now, with the answer ready if
53
+ you have it. Information that asks nothing of them does not go on the list at
54
+ all.
55
+
56
+ And their own word beats every source. An item they took off or rewrote stays
57
+ as they left it, even where the chat still says otherwise: do not put it back.
58
+
59
+ For example: on Monday someone asks the team chat "can anyone look at the
60
+ failing build?". They are in the team, and nobody names them. On Tuesday a
61
+ colleague replies "fixed, it was the cache". That is not their item: it was not
62
+ addressed to them, and someone else has answered it. Had it said "@them, can
63
+ you look at the build?" with no reply since, it would be an action on their
64
+ list, with the message as where it came from.
65
+
36
66
  ## Change the entry, do not add another
37
67
 
38
68
  When they revise something, edit the entry that is there and leave every other
@@ -410,6 +410,36 @@ check, untested guard clauses and early returns, error branches, boundaries
410
410
  mutable state, order dependence, the clock, the timezone, the real network, a
411
411
  sleep, an assertion on the order of something unordered.
412
412
 
413
+ ## Generated inputs: is there one the code gets wrong?
414
+
415
+ The mutation check asks whether the tests would notice if the code were wrong.
416
+ This asks the other question: is there an input the code gets wrong? Example
417
+ tests only try the inputs someone thought of. Generated ones try many more.
418
+
419
+ They earn their place where the inputs are many and a rule holds for all of
420
+ them: parsers, encoders and decoders, anything with a round trip, normalisers,
421
+ sorting and merging, and code with an invariant. A function with three cases
422
+ is better served by three example tests.
423
+
424
+ State the property first, as a sentence true of every input: decoding what was
425
+ encoded gives the input back; the output is sorted and holds the same elements;
426
+ running it twice gives what running it once gave; it agrees with a slow, obvious
427
+ version of itself. A property that restates the implementation proves nothing.
428
+
429
+ Generate the inputs with a small loop and a seeded generator. No library is
430
+ needed, and none should become a dependency for this. Weight them towards the
431
+ edges (empty, one element, the largest, odd characters, the boundaries the
432
+ requirement names), bound the count and the time, and print the seed, so a
433
+ failure can be run again exactly.
434
+
435
+ When one fails, make the input as small as it will go while it still fails, and
436
+ keep it as an ordinary example test: red first, then the fix. The generated test
437
+ stays too, but the example is what guards that bug from now on.
438
+
439
+ Report what was generated, how many, the seed, and the property. Say what failed
440
+ and which example tests it became. "Two thousand inputs, seed 7, no failure" is
441
+ evidence for those inputs, not a proof, so say it that way.
442
+
413
443
  ## The mutation check before you call it done
414
444
 
415
445
  The question is the whole point: if a real mistake were made in this code,
@@ -424,9 +454,17 @@ make a test fail, each one put back. Stop when another break would add no
424
454
  evidence, and say why the set you ran was enough: two meaningful breaks beat five
425
455
  padded ones. Reported in three lines.
426
456
 
427
- One break at a time, never two at once: note the original, apply the break, run
428
- the tests, record killed or survived, restore the code immediately, then the
429
- next one.
457
+ Make the breaks in a disposable copy of the change, never in the working tree:
458
+ a git worktree, or a copy of the folder, made for the check and thrown away
459
+ after it. A run that is interrupted, a restore that fails, or a second writer in
460
+ the same checkout would otherwise leave a planted defect in real code. Before you
461
+ start, note what `git status` and `git diff` say about the real tree.
462
+
463
+ One break at a time, never two at once: in the copy, note the original, apply
464
+ the break, run the tests there, record killed or survived, put the code back,
465
+ then the next one. When the check is done, remove the copy, and confirm the
466
+ working tree is untouched: `git status` and `git diff` say what they said before
467
+ the check.
430
468
 
431
469
  What to break, in this order: boundaries (`<` for `<=`, one off), boolean logic
432
470
  (`&&` for `||`, a dropped `not`), returned values (an empty or default return,
@@ -10,10 +10,12 @@
10
10
  // directory, and every session starts at its bot home. Read only, never
11
11
  // written: these are the harness's files (tech notes, sections 2 and 3).
12
12
 
13
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
13
+ import { existsSync, readdirSync, statSync } from 'node:fs';
14
14
  import { homedir } from 'node:os';
15
15
  import path from 'node:path';
16
16
 
17
+ import { eachLine, firstLine } from './lines.js';
18
+
17
19
  /** Claude Code's transcripts: one folder per working directory, one file per session. */
18
20
  const claudeDir = (home) => path.join(homedir(), '.claude', 'projects', slug(home));
19
21
 
@@ -53,12 +55,32 @@ export function hasConversation(harness, home, id) {
53
55
  return codexRollout(id) !== undefined;
54
56
  }
55
57
 
58
+ /** Where Codex moves a rollout the user archives: one flat folder (#396). Other ways it archives are not known. */
59
+ const codexArchive = () => path.join(homedir(), '.codex', 'archived_sessions');
60
+
56
61
  /**
57
62
  * Where Codex keeps the conversation `id`, whatever folder it ran in, found by
58
- * the file name Codex makes from the id; `undefined` when it has none.
63
+ * the file name Codex makes from the id: among its sessions, or else among the
64
+ * ones archived; `undefined` when it has none.
59
65
  */
60
- export const codexRollout = (id) =>
61
- rollouts(codexDir()).find((file) => path.basename(file).endsWith(`-${id}.jsonl`));
66
+ export const codexRollout = (id) => {
67
+ const named = (file) => path.basename(file).endsWith(`-${id}.jsonl`);
68
+ return rollouts(codexDir()).find(named)
69
+ ?? files(codexArchive()).map((name) => path.join(codexArchive(), name)).find(named);
70
+ };
71
+
72
+ /**
73
+ * Where Claude Code keeps the conversation `id`, whatever folder it ran in: the
74
+ * `<id>.jsonl` in any of its project folders; `undefined` when it has none.
75
+ */
76
+ export function claudeTranscriptAnywhere(id) {
77
+ const projects = path.join(homedir(), '.claude', 'projects');
78
+ for (const folder of files(projects)) {
79
+ const file = path.join(projects, folder, `${id}.jsonl`);
80
+ if (existsSync(file)) return file;
81
+ }
82
+ return undefined;
83
+ }
62
84
 
63
85
  /**
64
86
  * Where Claude Code keeps the conversation `id` of this bot home, whether or
@@ -115,14 +137,19 @@ export function transcriptsIn(harness, home, since) {
115
137
  * Its AGENTS.md goes in as a `user` item too, and cannot be the text asked about.
116
138
  */
117
139
  export function heldAsUserTurn(harness, file, text) {
118
- let lines;
140
+ const wanted = text.trim();
141
+ let held = false;
119
142
  try {
120
- lines = readFileSync(file, 'utf8').split('\n');
143
+ // A line at a time, whatever the record's size (#396), and no further than
144
+ // the turn that holds it.
145
+ eachLine(file, (line) => {
146
+ held = line !== undefined && userTexts(harness, line).some((said) => said.trim() === wanted);
147
+ return held;
148
+ });
121
149
  } catch {
122
150
  return false;
123
151
  }
124
- const wanted = text.trim();
125
- return lines.some((line) => userTexts(harness, line).some((said) => said.trim() === wanted));
152
+ return held;
126
153
  }
127
154
 
128
155
  /** The texts of one record line, when it is a turn of the user's. */
@@ -160,8 +187,7 @@ function claudeConversations(home) {
160
187
  /** When a transcript's first line says the conversation was, if it says at all. */
161
188
  function said(file) {
162
189
  try {
163
- const first = readFileSync(file, 'utf8').split('\n', 1)[0];
164
- const when = Date.parse(JSON.parse(first)?.timestamp ?? '');
190
+ const when = Date.parse(JSON.parse(firstLine(file) ?? '')?.timestamp ?? '');
165
191
  return Number.isNaN(when) ? undefined : when;
166
192
  } catch {
167
193
  return undefined;
@@ -201,8 +227,7 @@ function rollouts(dir, depth = 0) {
201
227
  /** A rollout's first line says what the conversation is: its id, folder and time. */
202
228
  function sessionMeta(file) {
203
229
  try {
204
- const first = readFileSync(file, 'utf8').split('\n', 1)[0];
205
- const parsed = JSON.parse(first);
230
+ const parsed = JSON.parse(firstLine(file) ?? '');
206
231
  return parsed?.type === 'session_meta' ? parsed.payload : undefined;
207
232
  } catch {
208
233
  return undefined;
package/src/lines.js ADDED
@@ -0,0 +1,77 @@
1
+ // A transcript read a line at a time, whatever its size (#396).
2
+ //
3
+ // A harness's record of a conversation can outgrow what one string holds: a
4
+ // Codex rollout of 1.28 GB was seen on a live fleet, and reading it whole
5
+ // threw, so the conversation dropped out of the book's lookup and its usage
6
+ // without a word. So nothing here reads a file whole. The first line is read
7
+ // from the start of the file alone, and a whole transcript goes through a
8
+ // chunk at a time, its text decoded across the chunks so that a character
9
+ // split between two of them stays whole.
10
+ //
11
+ // A line longer than any record the kit reads is not held: it comes through as
12
+ // `undefined`, for the caller to count as a line it could not read. A file cut
13
+ // short and padded (a sparse run of NUL bytes, one enormous "line") is read
14
+ // through to its end without being held either.
15
+
16
+ import { closeSync, openSync, readSync } from 'node:fs';
17
+ import { StringDecoder } from 'node:string_decoder';
18
+
19
+ /** How much is read at a time. */
20
+ const CHUNK = 64 * 1024;
21
+
22
+ /** The longest line held whole. */
23
+ const LONGEST = 64 * 1024 * 1024;
24
+
25
+ /**
26
+ * Each line of `file`, in order, to `onLine`: its text, or `undefined` for a
27
+ * line too long to hold. A line `onLine` answers `true` to is the last one
28
+ * read. Throws what the file system throws when the file cannot be opened or
29
+ * read.
30
+ */
31
+ export function eachLine(file, onLine) {
32
+ const fd = openSync(file, 'r');
33
+ try {
34
+ const decoder = new StringDecoder('utf8');
35
+ const buffer = Buffer.allocUnsafe(CHUNK);
36
+ let pending = '';
37
+ let tooLong = false;
38
+ for (;;) {
39
+ const read = readSync(fd, buffer, 0, CHUNK, null);
40
+ const text = read === 0 ? decoder.end() : decoder.write(buffer.subarray(0, read));
41
+ let start = 0;
42
+ for (let at = text.indexOf('\n'); at !== -1; at = text.indexOf('\n', start)) {
43
+ const line = tooLong ? undefined : pending + text.slice(start, at);
44
+ pending = '';
45
+ tooLong = false;
46
+ start = at + 1;
47
+ if (onLine(line) === true) return;
48
+ }
49
+ const rest = text.slice(start);
50
+ if (!tooLong) {
51
+ if (pending.length + rest.length > LONGEST) {
52
+ tooLong = true;
53
+ pending = '';
54
+ } else {
55
+ pending += rest;
56
+ }
57
+ }
58
+ if (read === 0) {
59
+ if (tooLong) onLine(undefined);
60
+ else if (pending !== '') onLine(pending);
61
+ return;
62
+ }
63
+ }
64
+ } finally {
65
+ closeSync(fd);
66
+ }
67
+ }
68
+
69
+ /** The first line of `file`, read from its start alone; `undefined` for an empty file or a first line too long to hold. */
70
+ export function firstLine(file) {
71
+ let first;
72
+ eachLine(file, (line) => {
73
+ first = line;
74
+ return true;
75
+ });
76
+ return first;
77
+ }
package/src/orca.js CHANGED
@@ -65,11 +65,25 @@ function ask(args, timeoutMs) {
65
65
 
66
66
  const answer = parse(asked.stdout);
67
67
  if (answer === null || typeof answer !== 'object') {
68
- throw new Error(`Orca answered ${args.join(' ')} with something that is not JSON.`);
68
+ // What Orca did say, so the failure shows its cause (#438): its exit, and
69
+ // each stream cut short and on one line, so a stack trace in it reads as
70
+ // text and not as the kit's own.
71
+ const exit = asked.status ?? `signal ${asked.signal}`;
72
+ throw new Error(`Orca answered ${args.join(' ')} with something that is not JSON (exit ${exit}; `
73
+ + `stdout ${said(asked.stdout)}; stderr ${said(asked.stderr)}).`);
69
74
  }
70
75
  return answer;
71
76
  }
72
77
 
78
+ /** How much of each of Orca's streams an error carries. */
79
+ const SAID = 500;
80
+
81
+ /** A stream of Orca's as an error carries it: its lines folded into one, cut short with an ellipsis, in quotes. */
82
+ const said = (text) => {
83
+ const one = String(text ?? '').trim().replace(/\s*\r?\n\s*/g, ' ⏎ ');
84
+ return `"${one.length > SAID ? `${one.slice(0, SAID)}…` : one}"`;
85
+ };
86
+
73
87
  function parse(text) {
74
88
  try {
75
89
  return JSON.parse(text);
package/src/usage.js CHANGED
@@ -39,11 +39,12 @@
39
39
  // the same call can be in more than one of those files, so a call counts
40
40
  // once across all of a conversation's files together (#371).
41
41
 
42
- import { readFileSync, realpathSync } from 'node:fs';
42
+ import { realpathSync } from 'node:fs';
43
43
 
44
44
  import { botDir, botNames, readBot } from './bot.js';
45
45
  import { readBook, sessionIdsIn } from './book.js';
46
- import { claudeSubagentTranscripts, codexRollout, transcriptsIn } from './conversations.js';
46
+ import { claudeSubagentTranscripts, claudeTranscriptAnywhere, codexRollout, transcriptsIn } from './conversations.js';
47
+ import { eachLine } from './lines.js';
47
48
  import { HARNESSES, harnessOf } from './launch.js';
48
49
 
49
50
  /** The kinds a call's tokens are reported in, the same on either harness. */
@@ -123,6 +124,20 @@ function forBot(bots, name, onlySession, window) {
123
124
 
124
125
  const claimed = sessionIdsIn(book);
125
126
 
127
+ // A conversation the book names is its session's wherever the harness filed
128
+ // it: the book says whose it is (ADR 0012). One filed under another
129
+ // folder, or whose record cannot be opened to say which folder it ran in, is
130
+ // found by its id instead, and counted or said to be unreadable (#396).
131
+ for (const id of claimed) {
132
+ if (onRecord.has(id)) continue;
133
+ for (const harness of harnesses) {
134
+ const file = harness === 'codex' ? codexRollout(id) : harness === 'claude' ? claudeTranscriptAnywhere(id) : undefined;
135
+ if (file === undefined) continue;
136
+ onRecord.set(id, { id, file, harness, subagent: false });
137
+ break;
138
+ }
139
+ }
140
+
126
141
  // Two entries can name the same conversation, one resumed after its session
127
142
  // was retired, and one entry can name it twice, now and in its history: it is
128
143
  // counted once, in the first block asked for that names it.
@@ -190,7 +205,7 @@ function counted(one, window) {
190
205
  tally.gaps.unreadable_transcripts += subagents.unreadable;
191
206
  const sources = [one.file, ...subagents.files]
192
207
  .map((file, index) => {
193
- const { entries, unreadable, broken } = transcript(file);
208
+ const { entries, unreadable, broken } = transcript(file, countable);
194
209
  tally.gaps.unreadable_transcripts += unreadable;
195
210
  tally.gaps.broken_lines += broken;
196
211
  return { entries, subagent: index > 0 };
@@ -463,7 +478,7 @@ function originOf(entries, tally) {
463
478
  if (typeof id !== 'string' || id === '') return undefined;
464
479
 
465
480
  const file = codexRollout(id);
466
- const origin = file === undefined ? { unreadable: 1 } : transcript(file);
481
+ const origin = file === undefined ? { unreadable: 1 } : transcript(file, countable);
467
482
  // No `info` is a note about rate limits, not a record of usage.
468
483
  const records = origin.unreadable > 0 ? [] : origin.entries
469
484
  .filter((entry) => entry.type === 'event_msg' && entry.payload?.type === 'token_count')
@@ -552,28 +567,44 @@ function count(tally, used, model, effort, when, calls = 1, subagent = false) {
552
567
  export const lines = (file) => transcript(file).entries;
553
568
 
554
569
  /**
555
- * The lines of a transcript that are JSON records, and how many were not. A
556
- * transcript that cannot be read is said to be, rather than a run that stops.
570
+ * The records the counting here reads: Claude Code's `assistant` and `system`
571
+ * lines, Codex's `session_meta`, `turn_context`, `compacted` and `event_msg`.
572
+ * The rest (what was said, tool calls and their output) is most of a long
573
+ * conversation's record and none of what it cost, so it is not held (#396).
574
+ */
575
+ const COUNTED = new Set(['assistant', 'system', 'session_meta', 'turn_context', 'compacted', 'event_msg']);
576
+ const countable = (entry) => COUNTED.has(entry.type);
577
+
578
+ /**
579
+ * The lines of a transcript that are JSON records, and how many were not; with
580
+ * `keep`, only the records it takes. A transcript that cannot be read is said
581
+ * to be, rather than a run that stops. It is read a line at a time, whatever
582
+ * its size, and a line too long to hold counts as one that is not a record
583
+ * (#396).
557
584
  */
558
- function transcript(file) {
559
- let text;
585
+ function transcript(file, keep = () => true) {
586
+ const entries = [];
587
+ let broken = 0;
560
588
  try {
561
- text = readFileSync(file, 'utf8');
589
+ eachLine(file, (line) => {
590
+ if (line === undefined) {
591
+ broken += 1;
592
+ return;
593
+ }
594
+ if (line.trim() === '') return;
595
+ let entry;
596
+ try {
597
+ entry = JSON.parse(line);
598
+ } catch {
599
+ broken += 1;
600
+ return;
601
+ }
602
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) broken += 1;
603
+ else if (keep(entry)) entries.push(entry);
604
+ });
562
605
  } catch {
563
606
  return { entries: [], unreadable: 1, broken: 0 };
564
607
  }
565
- let broken = 0;
566
- const entries = text.split('\n').flatMap((line) => {
567
- if (line.trim() === '') return [];
568
- try {
569
- const entry = JSON.parse(line);
570
- if (entry !== null && typeof entry === 'object' && !Array.isArray(entry)) return [entry];
571
- } catch {
572
- // Counted below with any other line that is not a record.
573
- }
574
- broken += 1;
575
- return [];
576
- });
577
608
  return { entries, unreadable: 0, broken };
578
609
  }
579
610