@assuredloop/orca-bot-kit 0.4.0 → 0.5.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.
package/src/usage.js CHANGED
@@ -41,6 +41,21 @@ import { harnessOf } from './launch.js';
41
41
  /** The kinds a call's tokens are reported in, the same on either harness. */
42
42
  const KINDS = ['input', 'output', 'cache_read', 'cache_write', 'reasoning'];
43
43
 
44
+ /**
45
+ * What could not be counted, by why. A figure that quietly leaves something out
46
+ * reads as complete, so what is left out is said, and the totals cover only what
47
+ * was counted: nothing missing is guessed at, nor taken as zero, nor placed in a
48
+ * window it has no time to be placed in.
49
+ */
50
+ const GAPS = ['unreadable_transcripts', 'broken_lines', 'records_without_numbers', 'records_without_time'];
51
+
52
+ const noGaps = () => Object.fromEntries(GAPS.map((gap) => [gap, 0]));
53
+
54
+ const addGaps = (into, more) => {
55
+ for (const gap of GAPS) into[gap] += more[gap];
56
+ return into;
57
+ };
58
+
44
59
  /**
45
60
  * What each bot's sessions have used. `since` counts the calls made at or after
46
61
  * that moment rather than the conversations begun after it: a conversation that
@@ -99,20 +114,28 @@ function forBot(bots, name, onlySession, window) {
99
114
 
100
115
  const sessions = bot.sessions
101
116
  .filter((session) => onlySession === undefined || session.name === onlySession)
102
- .map((session) => ({
103
- name: session.name,
104
- conversations: idsIn(book.sessions[session.name] ?? {})
105
- .filter((id) => onRecord.has(id))
106
- .map((id) => counted(onRecord.get(id), window))
107
- .filter((one) => one !== undefined),
108
- }));
109
-
110
- const unclaimed = [...onRecord.values()]
111
- .filter((one) => !claimed.has(one.id))
112
- .map((one) => counted(one, window))
113
- .filter((one) => one !== undefined);
114
-
115
- return { bot: name, home, sessions, unclaimed };
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
+ });
124
+
125
+ const unclaimed = all([...onRecord.values()].filter((one) => !claimed.has(one.id)), window);
126
+
127
+ return { bot: name, home, sessions, unclaimed: unclaimed.conversations, unclaimed_not_counted: unclaimed.gaps };
128
+ }
129
+
130
+ /** Some conversations counted, and what all of them together could not count. */
131
+ function all(transcripts, window) {
132
+ const gaps = noGaps();
133
+ const conversations = transcripts.flatMap((one) => {
134
+ const { row, gaps: its } = counted(one, window);
135
+ addGaps(gaps, its);
136
+ return row === undefined ? [] : [row];
137
+ });
138
+ return { conversations, gaps };
116
139
  }
117
140
 
118
141
  /** The conversations a book entry names: the one it is in, then the ones before. */
@@ -122,9 +145,10 @@ const idsIn = (entry) => [
122
145
  ];
123
146
 
124
147
  /**
125
- * One conversation, counted. A conversation that spent nothing in the window is
126
- * left out rather than reported as a row of zeroes: it is not part of what has
127
- * happened since, and a page of zeroes is harder to read than a shorter page.
148
+ * One conversation, counted, and what in it could not be. A conversation that
149
+ * spent nothing in the window has no row rather than a row of zeroes: it is not
150
+ * part of what has happened since, and a page of zeroes is harder to read than a
151
+ * shorter page. What it could not count is still said.
128
152
  */
129
153
  function counted(one, window) {
130
154
  const read = one.harness === 'claude' ? fromClaude : fromCodex;
@@ -139,12 +163,16 @@ function counted(one, window) {
139
163
  byModel: new Map(),
140
164
  first: undefined,
141
165
  last: undefined,
166
+ gaps: noGaps(),
142
167
  };
143
168
 
144
- read(lines(one.file), window, tally);
145
- if (tally.calls === 0 && KINDS.every((kind) => tally.tokens[kind] === 0)) return undefined;
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);
173
+ if (tally.calls === 0 && KINDS.every((kind) => tally.tokens[kind] === 0)) return { gaps: tally.gaps };
146
174
 
147
- return {
175
+ return { gaps: tally.gaps, row: {
148
176
  id: one.id,
149
177
  calls: tally.calls,
150
178
  tokens: tally.tokens,
@@ -154,7 +182,7 @@ function counted(one, window) {
154
182
  compactions: tally.compactions,
155
183
  first: new Date(tally.first).toISOString(),
156
184
  last: new Date(tally.last).toISOString(),
157
- };
185
+ } };
158
186
  }
159
187
 
160
188
  /**
@@ -175,6 +203,13 @@ function counted(one, window) {
175
203
  * start. A transcript only grows, so one run's end is the next run's start and
176
204
  * the runs add up to the whole, with a call still being written at a boundary
177
205
  * charged once, part to each side (#169).
206
+ *
207
+ * A record with no time, or with a figure missing, is left out whole and said to
208
+ * be; the call it belongs to is counted from its other records, if it has any.
209
+ * But when the record last written before the window's start is one of those,
210
+ * what the call had grown to by then is not known, and what it grew by inside
211
+ * the window cannot be told apart from what it grew by before. So the call is
212
+ * not counted in that window, and is said to be.
178
213
  */
179
214
  function fromClaude(entries, window, tally) {
180
215
  const byCall = new Map();
@@ -182,25 +217,34 @@ function fromClaude(entries, window, tally) {
182
217
  for (const entry of entries) {
183
218
  const when = Date.parse(entry.timestamp ?? '');
184
219
  if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
185
- if (inside(when, window)) tally.compactions += 1;
220
+ if (Number.isNaN(when)) tally.gaps.records_without_time += 1;
221
+ else if (inside(when, window)) tally.compactions += 1;
186
222
  continue;
187
223
  }
188
224
  if (entry.type !== 'assistant') continue;
189
225
 
190
226
  const usage = entry.message?.usage;
191
227
  if (usage === undefined || usage === null) continue;
228
+ if (Number.isNaN(when)) {
229
+ tally.gaps.records_without_time += 1;
230
+ continue;
231
+ }
232
+ const broken = !complete(usage, CLAUDE_FIELDS);
233
+ if (broken && inside(when, window)) tally.gaps.records_without_numbers += 1;
192
234
  const call = `${entry.requestId}\u0000${entry.message?.id}`;
193
235
  if (!byCall.has(call)) byCall.set(call, []);
194
- byCall.get(call).push({ entry, when });
236
+ byCall.get(call).push({ entry, when, broken });
195
237
  }
196
238
 
197
239
  for (const records of byCall.values()) {
198
240
  const made = records[0].when;
199
- // A record with no time of its own says nothing about being outside, as in
200
- // `inside`: it counts as written before the end and not before the start.
201
- const atEnd = records.findLast(({ when }) => Number.isNaN(when) || when < window.to);
241
+ const atEnd = records.findLast(({ when, broken }) => !broken && when < window.to);
202
242
  if (atEnd === undefined) continue;
203
- const atStart = records.findLast(({ when }) => !Number.isNaN(when) && when < window.from);
243
+ const atStart = records.findLast(({ when }) => when < window.from);
244
+ if (atStart?.broken) {
245
+ if (inside(atEnd.when, window)) tally.gaps.records_without_numbers += 1;
246
+ continue;
247
+ }
204
248
 
205
249
  const now = figures(atEnd.entry);
206
250
  const was = atStart === undefined ? undefined : figures(atStart.entry);
@@ -212,6 +256,9 @@ function fromClaude(entries, window, tally) {
212
256
  }
213
257
  }
214
258
 
259
+ /** The figures Claude Code writes on every call, all of which a record needs to be counted. */
260
+ const CLAUDE_FIELDS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens'];
261
+
215
262
  /** What one Claude Code record says its call used so far. */
216
263
  function figures(entry) {
217
264
  const usage = entry.message.usage;
@@ -252,11 +299,23 @@ const CODEX_FIELDS = [
252
299
  *
253
300
  * The running total is followed through events outside the window as well, since
254
301
  * what a call added can only be measured against the event before it.
302
+ *
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.
255
312
  */
256
313
  function fromCodex(entries, window, tally) {
257
314
  let model;
258
315
  let effort;
259
316
  let running;
317
+ // Whether a running total with something missing came since the last complete one.
318
+ let broken = false;
260
319
 
261
320
  for (const entry of entries) {
262
321
  const when = Date.parse(entry.timestamp ?? '');
@@ -269,48 +328,73 @@ function fromCodex(entries, window, tally) {
269
328
  continue;
270
329
  }
271
330
  if (entry.type === 'compacted') {
272
- if (inside(when, window)) tally.compactions += 1;
331
+ if (Number.isNaN(when)) tally.gaps.records_without_time += 1;
332
+ else if (inside(when, window)) tally.compactions += 1;
273
333
  continue;
274
334
  }
275
335
  if (entry.type !== 'event_msg' || entry.payload?.type !== 'token_count') continue;
276
336
 
337
+ // No `info` is a note about rate limits, not a record of usage.
277
338
  const info = entry.payload?.info;
278
339
  if (info === undefined || info === null) continue;
279
340
 
280
- const used = spent(running, info);
281
- if (info.total_token_usage !== undefined && info.total_token_usage !== null) {
282
- running = info.total_token_usage;
341
+ const total = info.total_token_usage ?? undefined;
342
+ 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)) {
349
+ used = null;
350
+ broken = true;
351
+ } else {
352
+ used = broken ? null : spent(running, total, info);
353
+ running = total;
354
+ broken = false;
283
355
  }
356
+
284
357
  // A repeat is not a call, whether or not it is inside the window.
285
358
  if (used === undefined) continue;
359
+ if (Number.isNaN(when)) {
360
+ tally.gaps.records_without_time += 1;
361
+ continue;
362
+ }
286
363
  if (!inside(when, window)) continue;
364
+ if (used === null) {
365
+ tally.gaps.records_without_numbers += 1;
366
+ continue;
367
+ }
287
368
 
288
369
  count(tally, used, model, effort, when);
289
370
  }
290
371
  }
291
372
 
373
+ /** The fields Codex itself writes; it has no cache writes to report. */
374
+ const CODEX_READ = CODEX_FIELDS.filter((field) => field !== 'cache_write_input_tokens').concat('total_tokens');
375
+
292
376
  /**
293
- * What one Codex event says its call used, measured against the event before it.
294
- * `undefined` where the event is the one before written down again.
377
+ * What one Codex event says its call used, measured against the running total
378
+ * before it: `undefined` where the event is the one before written down again,
379
+ * and `null` where what it used cannot be known.
295
380
  */
296
- function spent(running, info) {
297
- const total = info.total_token_usage;
298
- if (total === undefined || total === null) {
299
- // Nothing to measure against: the per-call figure is all there is.
300
- return info.last_token_usage === undefined ? undefined : kindsOf(info.last_token_usage);
301
- }
381
+ function spent(running, total, info) {
302
382
  if (running === undefined) return kindsOf(total);
303
383
 
304
- const moved = number(total.total_tokens) - number(running.total_tokens);
384
+ const moved = total.total_tokens - running.total_tokens;
305
385
  if (moved === 0) return undefined;
306
386
  // A fall is a new window, and the running total is counting again from there.
307
- if (moved < 0) return kindsOf(info.last_token_usage ?? total);
387
+ if (moved < 0) return perCall(info);
308
388
 
309
389
  return kindsOf(Object.fromEntries(
310
390
  CODEX_FIELDS.map((field) => [field, number(total[field]) - number(running[field])]),
311
391
  ));
312
392
  }
313
393
 
394
+ /** A Codex event's own per-call figure, or `null` when something in it is missing. */
395
+ const perCall = (info) =>
396
+ complete(info.last_token_usage, CODEX_READ) ? kindsOf(info.last_token_usage) : null;
397
+
314
398
  /**
315
399
  * One Codex figure in the kinds this command reports. The cached tokens sit
316
400
  * inside `input_tokens` here and outside it on Claude Code, so they come out of
@@ -351,24 +435,32 @@ function count(tally, used, model, effort, when, calls = 1) {
351
435
  }
352
436
 
353
437
  /** The lines of a transcript that are readable JSON; the rest say nothing. */
354
- function lines(file) {
438
+ export const lines = (file) => transcript(file).entries;
439
+
440
+ /**
441
+ * The lines of a transcript that are JSON records, and how many were not. A
442
+ * transcript that cannot be read is said to be, rather than a run that stops.
443
+ */
444
+ function transcript(file) {
355
445
  let text;
356
446
  try {
357
447
  text = readFileSync(file, 'utf8');
358
448
  } catch {
359
- // A transcript that cannot be read is a conversation the kit knows nothing
360
- // about, which is the honest answer rather than a run that stops.
361
- return [];
449
+ return { entries: [], unreadable: 1, broken: 0 };
362
450
  }
363
- return text.split('\n').flatMap((line) => {
451
+ let broken = 0;
452
+ const entries = text.split('\n').flatMap((line) => {
364
453
  if (line.trim() === '') return [];
365
454
  try {
366
455
  const entry = JSON.parse(line);
367
- return entry !== null && typeof entry === 'object' ? [entry] : [];
456
+ if (entry !== null && typeof entry === 'object' && !Array.isArray(entry)) return [entry];
368
457
  } catch {
369
- return [];
458
+ // Counted below with any other line that is not a record.
370
459
  }
460
+ broken += 1;
461
+ return [];
371
462
  });
463
+ return { entries, unreadable: 0, broken };
372
464
  }
373
465
 
374
466
  /** A bot home as the file system knows it, or as it was given when it is not there. */
@@ -382,20 +474,23 @@ function realHome(home) {
382
474
 
383
475
  const number = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
384
476
 
477
+ /** Whether a figure has every field it should, each a number. */
478
+ const complete = (figure, fields) =>
479
+ figure !== undefined && figure !== null
480
+ && fields.every((field) => typeof figure[field] === 'number' && Number.isFinite(figure[field]));
481
+
385
482
  const add = (set, value) => {
386
483
  if (typeof value === 'string' && value !== '') set.add(value);
387
484
  };
388
485
 
389
486
  /**
390
487
  * Whether a moment is in the window: at or after its start, before its end. A
391
- * record with no time of its own is counted, as it always has been: nothing
392
- * says it is outside.
488
+ * record with no time of its own is in no window; it is left out and said to be.
393
489
  */
394
- const inside = (when, { from, to }) => Number.isNaN(when) || (when >= from && when < to);
490
+ const inside = (when, { from, to }) => when >= from && when < to;
395
491
 
396
492
  /** When the first and the last counted call of this conversation were. */
397
493
  function mark(tally, when) {
398
- if (Number.isNaN(when)) return;
399
494
  if (tally.first === undefined || when < tally.first) tally.first = when;
400
495
  if (tally.last === undefined || when > tally.last) tally.last = when;
401
496
  }