@assuredloop/orca-bot-kit 0.10.0 → 0.12.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/src/usage.js CHANGED
@@ -9,7 +9,9 @@
9
9
  //
10
10
  // Which conversation belongs to which session is the book's to say and is never
11
11
  // guessed, as everywhere else in the kit (ADR 0012). A session's conversations
12
- // are the one the book names for it now and the ones in its history. One in the
12
+ // are the one the book names for it now and the ones in its history. A session
13
+ // since retired keeps a block of its own, marked retired, so a window that spans
14
+ // a retirement loses nothing (#379). One in the
13
15
  // bot's folder that no session claims is reported under the bot instead, with
14
16
  // its figures, so that what it spent is visible rather than quietly dropped.
15
17
  //
@@ -29,14 +31,17 @@
29
31
  // uncached input on both. Taken as written, one is many times the other for
30
32
  // the same work, and the cached tokens get priced at full rate.
31
33
  // 3. Claude Code writes the same call down more than once, so a call counts
32
- // once per `requestId` and `message.id`.
34
+ // once per `requestId` and `message.id`. A subagent's calls are in a file
35
+ // of their own beside the conversation that started it, and belong to it;
36
+ // the same call can be in more than one of those files, so a call counts
37
+ // once across all of a conversation's files together (#371).
33
38
 
34
39
  import { readFileSync, realpathSync } from 'node:fs';
35
40
 
36
41
  import { botDir, botNames, readBot } from './bot.js';
37
42
  import { readBook, sessionIdsIn } from './book.js';
38
- import { transcriptsIn } from './conversations.js';
39
- import { harnessOf } from './launch.js';
43
+ import { claudeSubagentTranscripts, transcriptsIn } from './conversations.js';
44
+ import { HARNESSES, harnessOf } from './launch.js';
40
45
 
41
46
  /** The kinds a call's tokens are reported in, the same on either harness. */
42
47
  const KINDS = ['input', 'output', 'cache_read', 'cache_write', 'reasoning'];
@@ -98,6 +103,9 @@ function forBot(bots, name, onlySession, window) {
98
103
  // them runs on is asked. The bot's own is the answer when it has no sessions.
99
104
  const harnesses = new Set(bot.sessions.map((session) => harnessOf(session, bot.harness)));
100
105
  if (harnesses.size === 0) harnesses.add(bot.harness);
106
+ // The book does not say which harness a retired session ran on, so any is.
107
+ const retired = Array.isArray(book.retired) ? book.retired : [];
108
+ if (retired.length > 0) for (const harness of HARNESSES) harnesses.add(harness);
101
109
 
102
110
  const onRecord = new Map();
103
111
  for (const harness of harnesses) {
@@ -112,15 +120,22 @@ function forBot(bots, name, onlySession, window) {
112
120
 
113
121
  const claimed = sessionIdsIn(book);
114
122
 
115
- const sessions = bot.sessions
116
- .filter((session) => onlySession === undefined || session.name === onlySession)
117
- .map((session) => {
118
- const { conversations, gaps } = all(
119
- idsIn(book.sessions[session.name] ?? {}).filter((id) => onRecord.has(id)).map((id) => onRecord.get(id)),
120
- window,
121
- );
122
- return { name: session.name, conversations, not_counted: gaps };
123
- });
123
+ // Two entries can name the same conversation, one resumed after its session
124
+ // was retired, and one entry can name it twice, now and in its history: it is
125
+ // counted once, in the first block asked for that names it.
126
+ const taken = new Set();
127
+ const block = (entry) => {
128
+ const ids = [...new Set(idsIn(entry))].filter((id) => onRecord.has(id) && !taken.has(id));
129
+ for (const id of ids) taken.add(id);
130
+ const { conversations, gaps } = all(ids.map((id) => onRecord.get(id)), window);
131
+ return { conversations, not_counted: gaps };
132
+ };
133
+ const sessions = [
134
+ ...bot.sessions.map((session) => ({ name: session.name, entry: book.sessions[session.name] ?? {} })),
135
+ ...retired.map((entry) => ({ name: entry?.name, retired: entry?.retired, entry: entry ?? {} })),
136
+ ]
137
+ .filter(({ name }) => onlySession === undefined || name === onlySession)
138
+ .map(({ name, entry, ...was }) => ({ name, ...('retired' in was ? { retired: was.retired } : {}), ...block(entry) }));
124
139
 
125
140
  const unclaimed = all([...onRecord.values()].filter((one) => !claimed.has(one.id)), window);
126
141
 
@@ -151,9 +166,9 @@ const idsIn = (entry) => [
151
166
  * shorter page. What it could not count is still said.
152
167
  */
153
168
  function counted(one, window) {
154
- const read = one.harness === 'claude' ? fromClaude : fromCodex;
155
169
  const tally = {
156
170
  calls: 0,
171
+ subagentCalls: 0,
157
172
  compactions: 0,
158
173
  tokens: Object.fromEntries(KINDS.map((kind) => [kind, 0])),
159
174
  models: new Set(),
@@ -166,15 +181,25 @@ function counted(one, window) {
166
181
  gaps: noGaps(),
167
182
  };
168
183
 
169
- const { entries, unreadable, broken } = transcript(one.file);
170
- tally.gaps.unreadable_transcripts = unreadable;
171
- tally.gaps.broken_lines = broken;
172
- read(entries, window, tally);
184
+ // A Claude Code conversation's subagents wrote their calls to files of their
185
+ // own, and what they spent is the conversation's.
186
+ const subagents = one.harness === 'claude' ? claudeSubagentTranscripts(one.file) : { files: [], unreadable: 0 };
187
+ tally.gaps.unreadable_transcripts += subagents.unreadable;
188
+ const sources = [one.file, ...subagents.files]
189
+ .map((file, index) => {
190
+ const { entries, unreadable, broken } = transcript(file);
191
+ tally.gaps.unreadable_transcripts += unreadable;
192
+ tally.gaps.broken_lines += broken;
193
+ return { entries, subagent: index > 0 };
194
+ });
195
+ if (one.harness === 'claude') fromClaude(sources, window, tally);
196
+ else fromCodex(sources[0].entries, window, tally);
173
197
  if (tally.calls === 0 && KINDS.every((kind) => tally.tokens[kind] === 0)) return { gaps: tally.gaps };
174
198
 
175
199
  return { gaps: tally.gaps, row: {
176
200
  id: one.id,
177
201
  calls: tally.calls,
202
+ subagent_calls: tally.subagentCalls,
178
203
  tokens: tally.tokens,
179
204
  by_model: [...tally.byModel].map(([model, its]) => ({ model, calls: its.calls, tokens: its.tokens })),
180
205
  models: [...tally.models],
@@ -204,6 +229,15 @@ function counted(one, window) {
204
229
  * the runs add up to the whole, with a call still being written at a boundary
205
230
  * charged once, part to each side (#169).
206
231
  *
232
+ * A call is one `requestId` and `message.id` across the main transcript and its
233
+ * subagents' files together, since the same call can be in more than one of
234
+ * them, and its records are taken in the order of their times. Copies written
235
+ * at the very same moment are taken smallest first, since a call's figures only
236
+ * grow: the largest is the latest. One with a figure missing may be the latest
237
+ * of them, so it is taken as that, and what it hides is said rather than
238
+ * measured past it. It is a subagent's when the main transcript has no record
239
+ * of it.
240
+ *
207
241
  * A record with no time, or with a figure missing, is left out whole and said to
208
242
  * be; the call it belongs to is counted from its other records, if it has any.
209
243
  * But when the record last written before the window's start is one of those,
@@ -211,10 +245,10 @@ function counted(one, window) {
211
245
  * the window cannot be told apart from what it grew by before. So the call is
212
246
  * not counted in that window, and is said to be.
213
247
  */
214
- function fromClaude(entries, window, tally) {
248
+ function fromClaude(sources, window, tally) {
215
249
  const byCall = new Map();
216
250
 
217
- for (const entry of entries) {
251
+ for (const { entries, subagent } of sources) for (const entry of entries) {
218
252
  const when = Date.parse(entry.timestamp ?? '');
219
253
  if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
220
254
  if (Number.isNaN(when)) tally.gaps.records_without_time += 1;
@@ -233,10 +267,11 @@ function fromClaude(entries, window, tally) {
233
267
  if (broken && inside(when, window)) tally.gaps.records_without_numbers += 1;
234
268
  const call = `${entry.requestId}\u0000${entry.message?.id}`;
235
269
  if (!byCall.has(call)) byCall.set(call, []);
236
- byCall.get(call).push({ entry, when, broken });
270
+ byCall.get(call).push({ entry, when, broken, subagent });
237
271
  }
238
272
 
239
273
  for (const records of byCall.values()) {
274
+ records.sort((one, other) => one.when - other.when || one.broken - other.broken || size(one.entry) - size(other.entry));
240
275
  const made = records[0].when;
241
276
  const atEnd = records.findLast(({ when, broken }) => !broken && when < window.to);
242
277
  if (atEnd === undefined) continue;
@@ -252,10 +287,17 @@ function fromClaude(entries, window, tally) {
252
287
  const isNew = inside(made, window);
253
288
  if (!isNew && KINDS.every((kind) => grew[kind] === 0)) continue;
254
289
 
255
- count(tally, grew, atEnd.entry.message?.model, atEnd.entry.effort, isNew ? made : atEnd.when, isNew ? 1 : 0);
290
+ const subagent = records.every((record) => record.subagent);
291
+ count(tally, grew, atEnd.entry.message?.model, atEnd.entry.effort, isNew ? made : atEnd.when, isNew ? 1 : 0, subagent);
256
292
  }
257
293
  }
258
294
 
295
+ /** How much one Claude Code record says its call has used so far, all kinds together. */
296
+ const size = (entry) => {
297
+ const now = figures(entry);
298
+ return KINDS.reduce((sum, kind) => sum + now[kind], 0);
299
+ };
300
+
259
301
  /** The figures Claude Code writes on every call, all of which a record needs to be counted. */
260
302
  const CLAUDE_FIELDS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens'];
261
303
 
@@ -300,15 +342,15 @@ const CODEX_FIELDS = [
300
342
  * The running total is followed through events outside the window as well, since
301
343
  * what a call added can only be measured against the event before it.
302
344
  *
303
- * An event whose running total has a figure missing is left out and said to be,
304
- * and after it the running total is not known, so neither is what the next
305
- * complete event added. Measured against the event before the broken one, it
306
- * would take in what the broken one used, at its own time and perhaps in
307
- * another window; taken by its own per-call figure, it may be the broken one
308
- * written down again. Telling those apart is guessing, so it is not counted
309
- * either, and is said to be; the running total follows on from it. A call whose
310
- * figure is needed and has something missing is not counted, and is said to
311
- * be; nothing missing is taken as zero.
345
+ * An event whose running total has a figure missing, or has none at all, is left
346
+ * out and said to be, and after it the running total is not known, so neither
347
+ * is what the next complete event added. Measured against the event before the
348
+ * broken one, it would take in what the broken one used, at its own time and
349
+ * perhaps in another window; taken by its own per-call figure, it may be the
350
+ * broken one written down again. Telling those apart is guessing, so it is not
351
+ * counted either, and is said to be; the running total follows on from it. A
352
+ * call whose figure is needed and has something missing is not counted, and is
353
+ * said to be; nothing missing is taken as zero.
312
354
  */
313
355
  function fromCodex(entries, window, tally) {
314
356
  let model;
@@ -338,14 +380,11 @@ function fromCodex(entries, window, tally) {
338
380
  const info = entry.payload?.info;
339
381
  if (info === undefined || info === null) continue;
340
382
 
341
- const total = info.total_token_usage ?? undefined;
383
+ const total = info.total_token_usage;
342
384
  let used;
343
- if (total === undefined) {
344
- // Nothing to measure against: the per-call figure is all there is. After a
345
- // broken running total it may be the broken one written again, so it is
346
- // not counted either until a complete running total says where things are.
347
- used = broken ? null : perCall(info);
348
- } else if (!complete(total, CODEX_READ)) {
385
+ // No running total at all is a running total with every figure missing:
386
+ // the next one's rise would take this call in again (#302).
387
+ if (!complete(total, CODEX_READ)) {
349
388
  used = null;
350
389
  broken = true;
351
390
  } else {
@@ -418,8 +457,9 @@ function kindsOf(raw) {
418
457
  * the total that has to be complete, and a row headed by nothing would be worse
419
458
  * than no row.
420
459
  */
421
- function count(tally, used, model, effort, when, calls = 1) {
460
+ function count(tally, used, model, effort, when, calls = 1, subagent = false) {
422
461
  tally.calls += calls;
462
+ if (subagent) tally.subagentCalls += calls;
423
463
  for (const kind of KINDS) tally.tokens[kind] += used[kind];
424
464
  add(tally.models, model);
425
465
  add(tally.efforts, effort);