@assuredloop/orca-bot-kit 0.3.0 → 0.5.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/README.md +29 -3
- package/SETUP.md +3 -2
- package/package.json +1 -1
- package/skills/obk-bot-building/SKILL.md +27 -12
- package/src/book.js +3 -0
- package/src/cli.js +110 -21
- package/src/conversations.js +58 -4
- package/src/health.js +124 -23
- package/src/message.js +25 -28
- package/src/orca-window.cjs +21 -0
- package/src/orca.js +117 -10
- package/src/record.js +8 -1
- package/src/retire.js +39 -4
- package/src/rules.js +15 -0
- package/src/settings.js +158 -0
- package/src/skills.js +107 -16
- package/src/up.js +65 -20
- package/src/usage.js +147 -52
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
const unclaimed = [...onRecord.values()]
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
126
|
-
*
|
|
127
|
-
* happened since, and a page of zeroes is harder to read than a
|
|
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
|
-
|
|
145
|
-
|
|
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 (
|
|
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
|
-
|
|
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 }) =>
|
|
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 (
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
456
|
+
if (entry !== null && typeof entry === 'object' && !Array.isArray(entry)) return [entry];
|
|
368
457
|
} catch {
|
|
369
|
-
|
|
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
|
|
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 }) =>
|
|
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
|
}
|