@assuredloop/orca-bot-kit 0.11.0 → 0.13.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 +5 -4
- package/package.json +2 -1
- package/rules/limits.md +2 -2
- package/skills/obk-bot-building/SKILL.md +6 -3
- package/src/book.js +10 -5
- package/src/bot.js +37 -15
- package/src/cli.js +58 -19
- package/src/conversations.js +32 -1
- package/src/orca.js +6 -1
- package/src/permissions.js +156 -22
- package/src/usage.js +155 -41
package/README.md
CHANGED
|
@@ -587,10 +587,11 @@ The tests come in two layers, and every test file belongs to one of them.
|
|
|
587
587
|
|
|
588
588
|
`npm test` is `test/*.test.js`: unit tests and end-to-end runs of the CLI in a
|
|
589
589
|
throwaway folder, with a fake `orca` on PATH. It needs nothing but Node, so
|
|
590
|
-
[GitHub Actions](.github/workflows/ci.yml) runs it on every pull request
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
installed
|
|
590
|
+
[GitHub Actions](.github/workflows/ci.yml) runs it on every pull request, split
|
|
591
|
+
into shards that run side by side, on Node 25.8.0, the current line. A release
|
|
592
|
+
runs every shard on 25.8.0 and again on 24.21.0, the floor `engines.node`
|
|
593
|
+
promises users, with only the kit's own dependencies installed, before anything
|
|
594
|
+
is published.
|
|
594
595
|
|
|
595
596
|
`npm run test:system` is `test/system/*.test.js`: the real `obk` against the
|
|
596
597
|
real Orca and the real harnesses on your own machine. No CI runner can do that,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assuredloop/orca-bot-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
26
|
"test": "node --test test/*.test.js",
|
|
27
|
+
"test:shard": "node --test --test-shard=$SHARD test/*.test.js",
|
|
27
28
|
"test:system": "node scripts/test-system.js",
|
|
28
29
|
"mutate": "node scripts/mutate.js"
|
|
29
30
|
},
|
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>`
|
|
22
|
-
|
|
21
|
+
after the user's yes to the exact rules: `obk bot change --allow <rule>` or
|
|
22
|
+
`--disallow <rule>`. Never edit one in or out yourself, Bot Father included.
|
|
@@ -226,9 +226,12 @@ such as an `Edit` rule or one exact command.
|
|
|
226
226
|
Show the user the list word for word, with a line on what each lets the bot do,
|
|
227
227
|
and only after their yes run `obk bot change --allow`, once per rule they said
|
|
228
228
|
yes to. On a no, run nothing. `obk bot change --charter` names the rules the
|
|
229
|
-
bot is allowed now; say which of them the new charter no longer grants,
|
|
230
|
-
|
|
231
|
-
out.
|
|
229
|
+
bot is allowed now; say which of them the new charter no longer grants, show
|
|
230
|
+
each word for word, and ask whether to take it back. Only after their yes run
|
|
231
|
+
`obk bot change --disallow`, once per rule, which takes it out of `bot.yaml`
|
|
232
|
+
and the bot's settings or Codex rules; on a no, run nothing, and it stays
|
|
233
|
+
allowed. A rule the user added to a settings file by hand is theirs to take
|
|
234
|
+
out; the kit refuses it. Keep
|
|
232
235
|
each rule narrow: a program and what it does. The kit refuses a rule that lets
|
|
233
236
|
the bot run any command, a program with any arguments, or a shell or
|
|
234
237
|
interpreter with any arguments; a user who wants one for a bot adds it to that
|
package/src/book.js
CHANGED
|
@@ -191,12 +191,17 @@ function lockOn(file, waitMs) {
|
|
|
191
191
|
* is nobody's to sweep, and the user can see it and delete it.
|
|
192
192
|
*
|
|
193
193
|
* One folder per bots folder, so two bots folders each holding an api-bot cannot
|
|
194
|
-
* take each other's turn.
|
|
194
|
+
* take each other's turn. Resolved first, as the mailbox's is: one writer reaches
|
|
195
|
+
* the bots folder through a link and another by its real path, and two paths
|
|
196
|
+
* would be two locks for one book (#375).
|
|
195
197
|
*/
|
|
196
|
-
const lockFile = (home) =>
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
)
|
|
198
|
+
const lockFile = (home) => {
|
|
199
|
+
const real = realpathSync(home);
|
|
200
|
+
return path.join(
|
|
201
|
+
`${path.dirname(path.dirname(real))}.locks`,
|
|
202
|
+
`${encodeURIComponent(path.basename(real))}.lock`,
|
|
203
|
+
);
|
|
204
|
+
};
|
|
200
205
|
|
|
201
206
|
/**
|
|
202
207
|
* One session's turn at its mailbox (#321): held by `session mailbox` for its
|
package/src/bot.js
CHANGED
|
@@ -254,14 +254,14 @@ export function changeBot(bots, bot, { charter }) {
|
|
|
254
254
|
}
|
|
255
255
|
|
|
256
256
|
/**
|
|
257
|
-
* The bot's `allow` list as it is, after refusing what `allowRules`
|
|
258
|
-
* an empty rule, or a list already there that is
|
|
259
|
-
* before anything is written, so a refused `bot
|
|
260
|
-
* charter included.
|
|
257
|
+
* The bot's `allow` list as it is, after refusing what `allowRules` or
|
|
258
|
+
* `disallowRules` would refuse: an empty rule, or a list already there that is
|
|
259
|
+
* not a list of rules. Asked before anything is written, so a refused `bot
|
|
260
|
+
* change` changes nothing, its charter included.
|
|
261
261
|
*/
|
|
262
|
-
export function allowedNow(bots, bot, rules) {
|
|
262
|
+
export function allowedNow(bots, bot, rules, flag = '--allow') {
|
|
263
263
|
if (rules.some((rule) => rule.trim() === '')) {
|
|
264
|
-
throw new Error(
|
|
264
|
+
throw new Error(`${flag} is empty. Give it the exact permission rule the user said yes to, such as Bash(git add:*).`);
|
|
265
265
|
}
|
|
266
266
|
const home = existingBot(bots, bot);
|
|
267
267
|
const was = readBot(home, bot).allow ?? [];
|
|
@@ -277,18 +277,34 @@ export function allowedNow(bots, bot, rules) {
|
|
|
277
277
|
* added }: the whole list now, and what this call put in it.
|
|
278
278
|
*
|
|
279
279
|
* Only the list: the rules are written into the harness's settings by the
|
|
280
|
-
* caller, as the rules build is run by the caller of `changeBot`.
|
|
280
|
+
* caller, as the rules build is run by the caller of `changeBot`. With `write`
|
|
281
|
+
* false it only refuses what the edit would refuse, and writes nothing.
|
|
281
282
|
*/
|
|
282
|
-
export function allowRules(bots, bot, rules) {
|
|
283
|
+
export function allowRules(bots, bot, rules, { write = true } = {}) {
|
|
283
284
|
const { home, was } = allowedNow(bots, bot, rules);
|
|
284
285
|
const added = [...new Set(rules)].filter((rule) => !was.includes(rule));
|
|
285
286
|
const allow = [...was, ...added];
|
|
286
287
|
if (added.length > 0) {
|
|
287
|
-
editBot(bots, bot, `allow ${added.join(', ')} for ${bot}`, (doc) => doc.set('allow', allow), (before) => ({ ...before, allow }));
|
|
288
|
+
editBot(bots, bot, `allow ${added.join(', ')} for ${bot}`, (doc) => doc.set('allow', allow), (before) => ({ ...before, allow }), write);
|
|
288
289
|
}
|
|
289
290
|
return { bot, home, allow, added };
|
|
290
291
|
}
|
|
291
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Take the permission rules the user said yes to taking back, `rules`, out of
|
|
295
|
+
* the bot's `allow` (#360). Returns { bot, home, allow, disallowed }: the whole
|
|
296
|
+
* list now, and what this call took out. The caller has refused a rule `allow`
|
|
297
|
+
* does not hold, and takes them out of the harness's files first. With `write`
|
|
298
|
+
* false it only refuses what the edit would refuse, and writes nothing.
|
|
299
|
+
*/
|
|
300
|
+
export function disallowRules(bots, bot, rules, { write = true } = {}) {
|
|
301
|
+
const { home, was } = allowedNow(bots, bot, rules, '--disallow');
|
|
302
|
+
const disallowed = [...new Set(rules)];
|
|
303
|
+
const allow = was.filter((rule) => !disallowed.includes(rule));
|
|
304
|
+
editBot(bots, bot, `take back ${disallowed.join(', ')} for ${bot}`, (doc) => doc.set('allow', allow), (before) => ({ ...before, allow }), write);
|
|
305
|
+
return { bot, home, allow, disallowed };
|
|
306
|
+
}
|
|
307
|
+
|
|
292
308
|
/**
|
|
293
309
|
* Change the settings of the session `name` of the bot `bot`. Returns
|
|
294
310
|
* { bot, home, session } — the session as it now reads.
|
|
@@ -387,18 +403,24 @@ function editSession(bots, bot, index, session, what) {
|
|
|
387
403
|
|
|
388
404
|
/**
|
|
389
405
|
* Make one edit to a bot's `bot.yaml` through the YAML library, and write it
|
|
390
|
-
* only if exactly the change `expected` describes is what came out
|
|
406
|
+
* only if exactly the change `expected` describes is what came out, and
|
|
407
|
+
* `write` is not false.
|
|
391
408
|
*/
|
|
392
|
-
function editBot(bots, bot, what, edit, expected) {
|
|
409
|
+
function editBot(bots, bot, what, edit, expected, write = true) {
|
|
393
410
|
const file = path.join(existingBot(bots, bot), BOT_YAML);
|
|
394
411
|
const source = readFileSync(file, 'utf8');
|
|
395
412
|
const doc = parseDocument(source);
|
|
396
413
|
edit(doc);
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
414
|
+
const refusal = new Error(`${file} cannot be changed to ${what} without changing something else in it, so nothing was written. Make the change by hand.`);
|
|
415
|
+
let text;
|
|
416
|
+
try {
|
|
417
|
+
// An edit that leaves an alias with no anchor cannot be written at all.
|
|
418
|
+
text = doc.toString(YAML_OUT);
|
|
419
|
+
} catch {
|
|
420
|
+
throw refusal;
|
|
400
421
|
}
|
|
401
|
-
if (text
|
|
422
|
+
if (!changesExactly(source, text, expected)) throw refusal;
|
|
423
|
+
if (write && text !== source) writeFileSync(file, text);
|
|
402
424
|
}
|
|
403
425
|
|
|
404
426
|
/** A bot that is there, by its home, or the refusal a caller who named another is owed. */
|
package/src/cli.js
CHANGED
|
@@ -12,14 +12,14 @@ import path from 'node:path';
|
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { parseArgs } from 'node:util';
|
|
14
14
|
|
|
15
|
-
import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, leadsOutside, readBot, SESSION_FIELDS } from './bot.js';
|
|
15
|
+
import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, disallowRules, leadsOutside, readBot, SESSION_FIELDS } from './bot.js';
|
|
16
16
|
import { addCommand, groomCommand, grooming, upCommand } from './groom.js';
|
|
17
17
|
import { checkHealth, orcaSettingFindings } from './health.js';
|
|
18
18
|
import { initBots } from './init.js';
|
|
19
19
|
import { APPROVALS, HARNESSES, ownCli, shellWord, workDirOf } from './launch.js';
|
|
20
20
|
import { checkMail, lookUp, noMailboxYet, sendMessage } from './message.js';
|
|
21
21
|
import { orcaCli, orcaTrouble, RELOAD_LINE } from './orca.js';
|
|
22
|
-
import { allowCommand, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, writePermissions } from './permissions.js';
|
|
22
|
+
import { allowCommand, allowIn, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
|
|
23
23
|
import { pauseSessions, unpauseSessions } from './pause.js';
|
|
24
24
|
import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
|
|
25
25
|
import { restartSessions } from './restart.js';
|
|
@@ -56,13 +56,17 @@ Usage:
|
|
|
56
56
|
as ours: --prompt='- a bullet', and
|
|
57
57
|
--extra-arg=--search, once per extra argument.
|
|
58
58
|
obk bot change --bots <path> --bot <bot> [--charter <text>]
|
|
59
|
-
[--allow <rule> ...]
|
|
59
|
+
[--allow <rule> ... | --disallow <rule> ...]
|
|
60
60
|
Give a bot a new charter and rebuild its AGENTS.md.
|
|
61
61
|
A running session reads it when it next starts.
|
|
62
62
|
--allow records a permission rule the user said
|
|
63
63
|
yes to, once per rule, and writes it into the bot's
|
|
64
64
|
Claude settings. A broad rule, such as Bash(gh:*),
|
|
65
65
|
is refused and nothing is written.
|
|
66
|
+
--disallow takes back a rule the bot was allowed,
|
|
67
|
+
once the user has said yes to that, from bot.yaml
|
|
68
|
+
and the bot's settings. A rule the user added by
|
|
69
|
+
hand is not the kit's, and is refused.
|
|
66
70
|
obk session change --bots <path> --bot <bot> --session <session>
|
|
67
71
|
[--model <m>] [--effort <e>] [--context <c>]
|
|
68
72
|
[--approval ${APPROVALS.join('|')}]
|
|
@@ -184,14 +188,15 @@ Usage:
|
|
|
184
188
|
only while its tab is up in Orca.
|
|
185
189
|
obk usage --bots <path> [--bot <bot>] [--session <name>] [--since <time>] [--until <time>]
|
|
186
190
|
Say what your sessions have used: the conversations
|
|
187
|
-
each one had, their calls and tokens
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
191
|
+
each one had, their calls and tokens (their
|
|
192
|
+
subagents' included, and how many were theirs), the
|
|
193
|
+
models and efforts they ran at, and how often they
|
|
194
|
+
were compacted. --since counts the calls made from
|
|
195
|
+
that moment on, and --until the calls made before
|
|
196
|
+
that one: a daily run asks from the last run's end
|
|
197
|
+
up to its own, and the next starts where it stopped,
|
|
198
|
+
so no call is counted twice. It counts tokens and
|
|
199
|
+
never money: what a token costs is looked up live by
|
|
195
200
|
whoever is asking.
|
|
196
201
|
obk session record --bots <path> --bot <bot>
|
|
197
202
|
For the kit's own hook, not for typing: it reads
|
|
@@ -307,6 +312,7 @@ async function run(argv) {
|
|
|
307
312
|
peek: { type: 'boolean' },
|
|
308
313
|
charter: { type: 'string' },
|
|
309
314
|
allow: { type: 'string', multiple: true },
|
|
315
|
+
disallow: { type: 'string', multiple: true },
|
|
310
316
|
...Object.fromEntries(SETTINGS.map(([flag]) => [flag, { type: 'string' }])),
|
|
311
317
|
'extra-arg': { type: 'string', multiple: true },
|
|
312
318
|
json: { type: 'boolean' },
|
|
@@ -604,15 +610,28 @@ const commands = {
|
|
|
604
610
|
if (values.harness !== undefined) {
|
|
605
611
|
throw new Error(`bot change does not change a bot's harness: its sessions' conversations belong to the harness they ran on. To move to ${values.harness}, give it a session on ${values.harness} with obk session add, or retire the bot with obk retire and create a new one.`);
|
|
606
612
|
}
|
|
607
|
-
if (values.charter === undefined && values.allow === undefined) {
|
|
608
|
-
throw new Error('bot change needs --charter <text> or --
|
|
613
|
+
if (values.charter === undefined && values.allow === undefined && values.disallow === undefined) {
|
|
614
|
+
throw new Error('bot change needs --charter <text>, --allow <rule> or --disallow <rule>: what to change.');
|
|
615
|
+
}
|
|
616
|
+
if (values.allow !== undefined && values.disallow !== undefined) {
|
|
617
|
+
throw new Error('bot change takes --allow or --disallow, not both at once, so nothing was changed. Run one, then the other.');
|
|
609
618
|
}
|
|
610
619
|
// Refused before anything is written, so a bad rule, or a bad list already
|
|
611
620
|
// there, leaves the charter as it was too.
|
|
621
|
+
let allowInto;
|
|
612
622
|
if (values.allow !== undefined) {
|
|
613
623
|
const { home } = allowedNow(bots, values.bot, values.allow);
|
|
614
624
|
refuseBroad(home, values.allow);
|
|
615
|
-
|
|
625
|
+
const bot = readBot(home, values.bot);
|
|
626
|
+
refuseNoCodexForm(bot, values.allow);
|
|
627
|
+
const { allow } = allowRules(bots, values.bot, values.allow, { write: false });
|
|
628
|
+
allowInto = allowIn(bots, home, { ...bot, allow });
|
|
629
|
+
}
|
|
630
|
+
let takeBackFrom;
|
|
631
|
+
if (values.disallow !== undefined) {
|
|
632
|
+
const { home } = allowedNow(bots, values.bot, values.disallow, '--disallow');
|
|
633
|
+
takeBackFrom = takeBack(bots, home, readBot(home, values.bot), values.disallow);
|
|
634
|
+
disallowRules(bots, values.bot, values.disallow, { write: false });
|
|
616
635
|
}
|
|
617
636
|
|
|
618
637
|
const answer = { bots, bot: values.bot, home: botDir(bots, values.bot) };
|
|
@@ -637,7 +656,7 @@ const commands = {
|
|
|
637
656
|
// An allow list that is not a list is named by rules build and health,
|
|
638
657
|
// and a charter already written is not undone for it.
|
|
639
658
|
const bot = readBot(changed.home, changed.bot);
|
|
640
|
-
if (values.allow === undefined && (runsOnClaude(bot) || runsOnCodex(bot))) {
|
|
659
|
+
if (values.allow === undefined && values.disallow === undefined && (runsOnClaude(bot) || runsOnCodex(bot))) {
|
|
641
660
|
try {
|
|
642
661
|
answer.beyondDefaults = beyondDefaults(bots, changed.home, bot);
|
|
643
662
|
lines.push(...charterRulesLines(bots, bot, answer.beyondDefaults));
|
|
@@ -647,9 +666,11 @@ const commands = {
|
|
|
647
666
|
}
|
|
648
667
|
}
|
|
649
668
|
if (values.allow !== undefined) {
|
|
669
|
+
// Written at once, as the charter's rules are built at once (#344): the
|
|
670
|
+
// harness's files first, bot.yaml last, so a write that fails leaves
|
|
671
|
+
// `allow` as it was, and the same command can run again (#383).
|
|
672
|
+
const permissions = allowInto();
|
|
650
673
|
const allowed = allowRules(bots, values.bot, values.allow);
|
|
651
|
-
// Written at once, as the charter's rules are built at once (#344).
|
|
652
|
-
const permissions = writePermissions(bots, allowed.home, readBot(allowed.home));
|
|
653
674
|
Object.assign(answer, { allow: allowed.allow, permissions });
|
|
654
675
|
const codex = permissions.find((entry) => entry.unwritten !== undefined);
|
|
655
676
|
lines.push(
|
|
@@ -664,6 +685,23 @@ const commands = {
|
|
|
664
685
|
: `${allowed.bot}'s allow list in ${path.join('bots', allowed.bot, 'bot.yaml')} holds the user's yes.`,
|
|
665
686
|
);
|
|
666
687
|
}
|
|
688
|
+
if (values.disallow !== undefined) {
|
|
689
|
+
// The harness's files first, bot.yaml last: a write that fails leaves the
|
|
690
|
+
// rules in `allow`, so the same command can take them back again (#360).
|
|
691
|
+
const permissions = takeBackFrom();
|
|
692
|
+
const taken = disallowRules(bots, values.bot, values.disallow);
|
|
693
|
+
Object.assign(answer, { allow: taken.allow, disallowed: taken.disallowed, permissions });
|
|
694
|
+
const codex = permissions.find((entry) => entry.unwritten !== undefined);
|
|
695
|
+
lines.push(
|
|
696
|
+
...taken.disallowed.map((rule) => `${'took back'.padEnd(9)} ${rule}`),
|
|
697
|
+
...permissions.filter((entry) => entry.removed?.length > 0).map((entry) => `${'wrote'.padEnd(9)} ${path.relative(bots, entry.file)} ${entry.removed.length} permission rule${entry.removed.length === 1 ? '' : 's'} taken out`),
|
|
698
|
+
...permissionsLines(permissions, bots),
|
|
699
|
+
...(codex === undefined || codex.removed.length === 0 ? [] : [
|
|
700
|
+
`Codex reads ${codex.file} when a session starts: a Codex session of ${taken.bot} that is running now keeps ${codex.removed.length === 1 ? 'this rule' : 'these rules'} until its next start (obk restart).`,
|
|
701
|
+
]),
|
|
702
|
+
`${taken.bot}'s allow list in ${path.join('bots', taken.bot, 'bot.yaml')} no longer holds ${taken.disallowed.length === 1 ? 'it' : 'them'}.`,
|
|
703
|
+
);
|
|
704
|
+
}
|
|
667
705
|
return { answer, lines, code: trouble ? 1 : 0 };
|
|
668
706
|
},
|
|
669
707
|
|
|
@@ -1053,7 +1091,7 @@ function usageLines(usage, bots) {
|
|
|
1053
1091
|
for (const entry of usage) {
|
|
1054
1092
|
lines.push(`${'bot'.padEnd(9)} ${entry.bot}`);
|
|
1055
1093
|
for (const session of entry.sessions) {
|
|
1056
|
-
lines.push(`${'session'.padEnd(9)} ${session.name}`);
|
|
1094
|
+
lines.push(`${'session'.padEnd(9)} ${session.name}${'retired' in session ? ` retired ${session.retired}` : ''}`);
|
|
1057
1095
|
const notCounted = notCountedLine(session.not_counted);
|
|
1058
1096
|
lines.push(...session.conversations.map(conversationLine));
|
|
1059
1097
|
if (session.conversations.length === 0 && notCounted.length === 0) lines.push(' nothing on record');
|
|
@@ -1093,6 +1131,7 @@ function conversationLine(one) {
|
|
|
1093
1131
|
.map(([kind, count]) => `${kind} ${count}`)
|
|
1094
1132
|
.join(' ');
|
|
1095
1133
|
return ` ${one.id} ${one.calls} call${one.calls === 1 ? '' : 's'}`
|
|
1134
|
+
+ `${one.subagent_calls > 0 ? ` of which subagents: ${one.subagent_calls} call${one.subagent_calls === 1 ? '' : 's'}` : ''}`
|
|
1096
1135
|
+ `${ran === '' ? '' : ` ${ran}`}`
|
|
1097
1136
|
+ `${used === '' ? '' : ` ${used}`}`
|
|
1098
1137
|
+ `${one.compactions > 0 ? ` compacted ${one.compactions}` : ''}`;
|
|
@@ -1264,7 +1303,7 @@ function charterRulesLines(bots, bot, rules) {
|
|
|
1264
1303
|
return [
|
|
1265
1304
|
`${name} is allowed these permission rules beyond the kit's defaults, from before this change:`,
|
|
1266
1305
|
...rules.map((rule) => ` ${rule}`),
|
|
1267
|
-
`They stay allowed, whatever the user answers, until the user
|
|
1306
|
+
`They stay allowed, whatever the user answers, until the user says yes to taking one back out of ${path.join('bots', name, 'bot.yaml')}${settings}${codex}. For one the new charter no longer grants, ask the user, and only after their yes run ${shellWord(ownCli())} bot change --bots ${shellWord(bots)} --bot ${shellWord(name)} --disallow <rule>. ${until}`,
|
|
1268
1307
|
];
|
|
1269
1308
|
}
|
|
1270
1309
|
|
package/src/conversations.js
CHANGED
|
@@ -50,15 +50,46 @@ export function conversationsIn(harness, home, since) {
|
|
|
50
50
|
*/
|
|
51
51
|
export function hasConversation(harness, home, id) {
|
|
52
52
|
if (harness === 'claude') return existsSync(claudeTranscript(home, id));
|
|
53
|
-
return
|
|
53
|
+
return codexRollout(id) !== undefined;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* 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.
|
|
59
|
+
*/
|
|
60
|
+
export const codexRollout = (id) =>
|
|
61
|
+
rollouts(codexDir()).find((file) => path.basename(file).endsWith(`-${id}.jsonl`));
|
|
62
|
+
|
|
56
63
|
/**
|
|
57
64
|
* Where Claude Code keeps the conversation `id` of this bot home, whether or
|
|
58
65
|
* not it has written it yet.
|
|
59
66
|
*/
|
|
60
67
|
export const claudeTranscript = (home, id) => path.join(claudeDir(home), `${id}.jsonl`);
|
|
61
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The transcripts of the subagents a Claude Code conversation started, given the
|
|
71
|
+
* file the conversation is in. They sit beside it, in a folder named by its id,
|
|
72
|
+
* one `agent-<name>.jsonl` each, with a `.meta.json` beside each that is not a
|
|
73
|
+
* transcript (tech notes, section 2).
|
|
74
|
+
*
|
|
75
|
+
* No folder is a conversation that started no subagent. A folder that is there
|
|
76
|
+
* and cannot be listed is `unreadable`: what is in it cannot be counted, and
|
|
77
|
+
* taking it as empty would say nothing of that.
|
|
78
|
+
*/
|
|
79
|
+
export function claudeSubagentTranscripts(file) {
|
|
80
|
+
const dir = path.join(file.slice(0, -'.jsonl'.length), 'subagents');
|
|
81
|
+
let names;
|
|
82
|
+
try {
|
|
83
|
+
names = readdirSync(dir);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return { files: [], unreadable: error.code === 'ENOENT' ? 0 : 1 };
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
files: names.filter((name) => name.endsWith('.jsonl')).sort().map((name) => path.join(dir, name)),
|
|
89
|
+
unreadable: 0,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
62
93
|
/**
|
|
63
94
|
* The same conversations, each with the file the harness keeps it in, for a
|
|
64
95
|
* caller that has to read what is inside one rather than only know it is there,
|
package/src/orca.js
CHANGED
|
@@ -180,7 +180,12 @@ export const deleteProject = (setupId) => orca(['project', 'setup-delete', '--se
|
|
|
180
180
|
/** Said after a run that made or renamed a project, whatever `tellWindow` answered, and after a removal `reloadWindow` could not follow. */
|
|
181
181
|
export const RELOAD_LINE = "If Orca's sidebar does not show it, reload the window with Cmd+Shift+R.";
|
|
182
182
|
|
|
183
|
-
/**
|
|
183
|
+
/**
|
|
184
|
+
* How long Orca's client waits for its runtime, and how long the kit waits for
|
|
185
|
+
* the client. Measured beside the full unit suite on Orca 1.4.214, the whole
|
|
186
|
+
* call took at most 721 ms and the call inside the client at most 447 ms
|
|
187
|
+
* (tech notes, #384).
|
|
188
|
+
*/
|
|
184
189
|
const CLIENT_WAIT_MS = 2000;
|
|
185
190
|
const CLIENT_KILL_MS = 3000;
|
|
186
191
|
|
package/src/permissions.js
CHANGED
|
@@ -12,13 +12,14 @@
|
|
|
12
12
|
// editing the file by hand may get the format wrong; code does not.
|
|
13
13
|
//
|
|
14
14
|
// The kit owns only the entries `allow` holds. Any other entry in the file is
|
|
15
|
-
// left where it is, and health names it.
|
|
15
|
+
// left where it is, and health names it. A rule the user says yes to taking
|
|
16
|
+
// back leaves `allow` and the files through `bot change --disallow` (#360).
|
|
16
17
|
//
|
|
17
18
|
// A bot on Codex gets the same yes in Codex's own form (#354, ADR 0028): each
|
|
18
19
|
// `Bash(<words>:*)` becomes a `prefix_rule` of those words in
|
|
19
20
|
// `.codex/rules/obk.rules`, a file the kit owns whole and rewrites from `allow`.
|
|
20
21
|
|
|
21
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
22
23
|
import path from 'node:path';
|
|
23
24
|
|
|
24
25
|
import { leadsOutside } from './bot.js';
|
|
@@ -266,10 +267,10 @@ export const allowCommand = (bots, bot, rules) => [
|
|
|
266
267
|
* the kit may not write is an entry with its `trouble` rather than a throw.
|
|
267
268
|
*/
|
|
268
269
|
export function writePermissions(bots, home, bot, { keepGoing = false } = {}) {
|
|
269
|
-
const writers = [[runsOnClaude, FILE,
|
|
270
|
-
return writers.filter(([runs]) => runs(bot)).map(([, file,
|
|
270
|
+
const writers = [[runsOnClaude, FILE, planClaude], [runsOnCodex, CODEX_FILE, planCodex]];
|
|
271
|
+
return writers.filter(([runs]) => runs(bot)).map(([, file, plan]) => {
|
|
271
272
|
try {
|
|
272
|
-
return
|
|
273
|
+
return plan(bots, home, bot).write();
|
|
273
274
|
} catch (error) {
|
|
274
275
|
if (!keepGoing) throw error;
|
|
275
276
|
return { bot: bot.name, file: path.join(home, file), written: [], waiting: [], trouble: error.message };
|
|
@@ -278,30 +279,155 @@ export function writePermissions(bots, home, bot, { keepGoing = false } = {}) {
|
|
|
278
279
|
}
|
|
279
280
|
|
|
280
281
|
/**
|
|
281
|
-
*
|
|
282
|
-
*
|
|
282
|
+
* Check every file `bot change --allow` writes before any is written (#383):
|
|
283
|
+
* bot.yaml, and the files of the harnesses the bot runs on, read and found
|
|
284
|
+
* writable. `bot` is the bot as it will be, with the new rules in `allow`.
|
|
285
|
+
* What comes back writes the harness files, one entry per file, Claude's
|
|
286
|
+
* first; the caller writes bot.yaml after, so a write that fails leaves
|
|
287
|
+
* `allow` as it was, for the same command to run again.
|
|
288
|
+
*/
|
|
289
|
+
export function allowIn(bots, home, bot) {
|
|
290
|
+
refuseUnwritable(path.join(home, 'bot.yaml'));
|
|
291
|
+
const plans = [[runsOnClaude, planClaude], [runsOnCodex, planCodex]]
|
|
292
|
+
.filter(([runs]) => runs(bot)).map(([, plan]) => plan(bots, home, bot));
|
|
293
|
+
for (const plan of plans) if (plan.writes) refuseUnwritable(plan.answer.file);
|
|
294
|
+
return () => plans.map((plan) => plan.write());
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* What writing the bot's allowed rules into its Claude settings would do,
|
|
299
|
+
* read and refused before anything is written: `{ answer, writes, write }`.
|
|
300
|
+
* `write()` gives `{ bot, file, written, waiting }`, with `written` the rules
|
|
301
|
+
* it added.
|
|
283
302
|
*
|
|
284
303
|
* Only rules `allow` holds are written. Everything else in the file stays as it
|
|
285
304
|
* is, the user's own entries and their order included; a rule missing from the
|
|
286
305
|
* file goes after them, and a run with nothing to add writes nothing.
|
|
287
306
|
*/
|
|
288
|
-
function
|
|
307
|
+
function planClaude(bots, home, bot) {
|
|
289
308
|
const file = path.join(home, FILE);
|
|
290
309
|
const allowed = allowOf(home, bot);
|
|
291
310
|
const waiting = waitingFor(bots, home, bot, 'claude');
|
|
292
311
|
const answer = { bot: bot.name, file, written: [], waiting };
|
|
293
|
-
|
|
312
|
+
const none = { answer, writes: false, write: () => answer };
|
|
313
|
+
if (allowed.length === 0) return none;
|
|
294
314
|
|
|
295
315
|
refuseOutside(home, file);
|
|
296
316
|
const settings = readSettings(file, 'the permission rules the user allowed');
|
|
297
317
|
const present = presentIn(settings, file);
|
|
298
318
|
const missing = [...new Set(allowed)].filter((rule) => !present.includes(rule));
|
|
299
|
-
if (missing.length === 0) return
|
|
319
|
+
if (missing.length === 0) return none;
|
|
300
320
|
|
|
301
321
|
const wanted = { ...settings, permissions: { ...settings.permissions, allow: [...present, ...missing] } };
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
322
|
+
return {
|
|
323
|
+
answer,
|
|
324
|
+
writes: true,
|
|
325
|
+
write: () => {
|
|
326
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
327
|
+
writeFileSync(file, `${JSON.stringify(wanted, null, 2)}\n`);
|
|
328
|
+
return { ...answer, written: missing };
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Take the rules the user said yes to taking back out of the files of the
|
|
335
|
+
* harnesses the bot runs on (#360): from its Claude settings every entry of
|
|
336
|
+
* that exact text and nothing else, and its Codex rules rewritten from what
|
|
337
|
+
* `allow` still holds. Only rules `allow` holds are taken back; one it does not
|
|
338
|
+
* hold is refused, since the kit did not write it (ADR 0029).
|
|
339
|
+
*
|
|
340
|
+
* Everything is checked before anything is written, a file the kit could not
|
|
341
|
+
* write included, bot.yaml too. What comes back writes the files: one entry per file,
|
|
342
|
+
* Claude's first, with `removed` the rules taken out of it. The caller takes
|
|
343
|
+
* them out of `allow` after, so a write that fails leaves them there to take
|
|
344
|
+
* back again.
|
|
345
|
+
*/
|
|
346
|
+
export function takeBack(bots, home, bot, rules) {
|
|
347
|
+
const allowed = allowOf(home, bot);
|
|
348
|
+
const foreign = rules.find((rule) => !allowed.includes(rule));
|
|
349
|
+
if (foreign !== undefined) throw new Error(notAllowed(home, bot, foreign));
|
|
350
|
+
const after = { ...bot, allow: allowed.filter((rule) => !rules.includes(rule)) };
|
|
351
|
+
const taken = [...new Set(rules)];
|
|
352
|
+
// bot.yaml is written last, and a harness file taken out of step with it
|
|
353
|
+
// would leave nothing to say a running Codex session still has the rule.
|
|
354
|
+
refuseUnwritable(path.join(home, 'bot.yaml'));
|
|
355
|
+
|
|
356
|
+
const file = path.join(home, FILE);
|
|
357
|
+
let claude;
|
|
358
|
+
if (runsOnClaude(bot)) {
|
|
359
|
+
refuseOutside(home, file);
|
|
360
|
+
const settings = readSettings(file, 'the permission rules the user allowed');
|
|
361
|
+
const present = presentIn(settings, file);
|
|
362
|
+
claude = { settings, present, removed: taken.filter((rule) => present.includes(rule)) };
|
|
363
|
+
if (claude.removed.length > 0) refuseUnwritable(file);
|
|
364
|
+
}
|
|
365
|
+
const codex = path.join(home, CODEX_FILE);
|
|
366
|
+
let before = [];
|
|
367
|
+
if (runsOnCodex(bot)) {
|
|
368
|
+
refuseOutside(home, codex);
|
|
369
|
+
if (existsSync(codex)) {
|
|
370
|
+
before = ruleLines(readFileSync(codex, 'utf8'));
|
|
371
|
+
refuseUnwritable(codex);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return () => {
|
|
376
|
+
const entries = [];
|
|
377
|
+
if (claude !== undefined) {
|
|
378
|
+
const { settings, present, removed } = claude;
|
|
379
|
+
if (removed.length > 0) {
|
|
380
|
+
const allow = present.filter((rule) => !removed.includes(rule));
|
|
381
|
+
writeFileSync(file, `${JSON.stringify({ ...settings, permissions: { ...settings.permissions, allow } }, null, 2)}\n`);
|
|
382
|
+
}
|
|
383
|
+
entries.push({ bot: bot.name, file, written: [], removed, waiting: waitingFor(bots, home, after, 'claude') });
|
|
384
|
+
}
|
|
385
|
+
if (runsOnCodex(after)) {
|
|
386
|
+
const entry = planCodex(bots, home, after).write();
|
|
387
|
+
// A line goes only when no rule still allowed gives it.
|
|
388
|
+
const kept = [...codexOf(home, after).lines.keys()];
|
|
389
|
+
const removed = taken.filter((rule) => {
|
|
390
|
+
const { line } = codexForm(rule);
|
|
391
|
+
return line !== undefined && before.includes(line) && !kept.includes(line);
|
|
392
|
+
});
|
|
393
|
+
entries.push({ ...entry, written: [], removed });
|
|
394
|
+
}
|
|
395
|
+
return entries;
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* A refusal, before anything is written, of a file the kit could not write: one
|
|
401
|
+
* not there yet is made in the nearest folder that is, which the kit must be
|
|
402
|
+
* able to enter and write in, and which must be a folder.
|
|
403
|
+
*/
|
|
404
|
+
function refuseUnwritable(file) {
|
|
405
|
+
let where = file;
|
|
406
|
+
while (!existsSync(where) && path.dirname(where) !== where) where = path.dirname(where);
|
|
407
|
+
try {
|
|
408
|
+
if (where !== file && !statSync(where).isDirectory()) throw Object.assign(new Error('not a folder'), { code: 'ENOTDIR' });
|
|
409
|
+
accessSync(where, where === file ? constants.W_OK : constants.W_OK | constants.X_OK);
|
|
410
|
+
} catch (error) {
|
|
411
|
+
const at = where === file ? '' : ` at ${where}`;
|
|
412
|
+
throw new Error(`${file} cannot be written (${error.code ?? error.message}${at}), so nothing was changed. Let the kit write it, then run the command again.`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Why `--disallow` refuses a rule `allow` does not hold, and where one of that text is. */
|
|
417
|
+
function notAllowed(home, bot, rule) {
|
|
418
|
+
const yaml = path.join(home, 'bot.yaml');
|
|
419
|
+
const file = path.join(home, FILE);
|
|
420
|
+
let byHand = false;
|
|
421
|
+
try {
|
|
422
|
+
byHand = runsOnClaude(bot) && leadsOutside(home, file) === undefined
|
|
423
|
+
&& presentIn(readSettings(file), file).includes(rule);
|
|
424
|
+
} catch {
|
|
425
|
+
// A file the kit cannot read holds nothing it would take out.
|
|
426
|
+
}
|
|
427
|
+
const where = byHand
|
|
428
|
+
? ` ${file} allows it, which the user added by hand, not the kit: it stays theirs, to take out themselves.`
|
|
429
|
+
: '';
|
|
430
|
+
return `--disallow ${rule} is not in the allow list in ${yaml}, so it is not the kit's to take back, and nothing was changed.${where} The kit takes back only rules that list holds, spelled exactly as they are there.`;
|
|
305
431
|
}
|
|
306
432
|
|
|
307
433
|
/**
|
|
@@ -324,16 +450,18 @@ function codexOf(home, bot) {
|
|
|
324
450
|
const ruleLines = (text) => text.split('\n').map((line) => line.trim()).filter((line) => line !== '' && !line.startsWith('#'));
|
|
325
451
|
|
|
326
452
|
/**
|
|
327
|
-
*
|
|
453
|
+
* What writing the Codex form of what the bot is allowed into
|
|
454
|
+
* `.codex/rules/obk.rules` would do, as `planClaude` gives it. `write()` gives
|
|
328
455
|
* `{ bot, file, written, waiting, unwritten }`, with `written` every rule in
|
|
329
|
-
* the file when
|
|
330
|
-
*
|
|
456
|
+
* the file when it wrote it. The kit owns the file whole and writes it only
|
|
457
|
+
* when it would change; with nothing to write and no file, it makes none.
|
|
331
458
|
*/
|
|
332
|
-
function
|
|
459
|
+
function planCodex(bots, home, bot) {
|
|
333
460
|
const file = path.join(home, CODEX_FILE);
|
|
334
461
|
const { lines, unwritten } = codexOf(home, bot);
|
|
335
462
|
const answer = { bot: bot.name, file, written: [], waiting: waitingFor(bots, home, bot, 'codex'), unwritten };
|
|
336
|
-
|
|
463
|
+
const none = { answer, writes: false, write: () => answer };
|
|
464
|
+
if (lines.size === 0 && !existsSync(file)) return none;
|
|
337
465
|
|
|
338
466
|
refuseOutside(home, file);
|
|
339
467
|
const text = [
|
|
@@ -341,10 +469,16 @@ function writeCodex(bots, home, bot) {
|
|
|
341
469
|
'# obk rewrites this whole file: put rules of your own in another file in this folder.',
|
|
342
470
|
...lines.keys(),
|
|
343
471
|
].join('\n');
|
|
344
|
-
if (existsSync(file) && readFileSync(file, 'utf8') === `${text}\n`) return
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
472
|
+
if (existsSync(file) && readFileSync(file, 'utf8') === `${text}\n`) return none;
|
|
473
|
+
return {
|
|
474
|
+
answer,
|
|
475
|
+
writes: true,
|
|
476
|
+
write: () => {
|
|
477
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
478
|
+
writeFileSync(file, `${text}\n`);
|
|
479
|
+
return { ...answer, written: [...lines.values()].flat() };
|
|
480
|
+
},
|
|
481
|
+
};
|
|
348
482
|
}
|
|
349
483
|
|
|
350
484
|
/**
|
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.
|
|
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
|
//
|
|
@@ -23,20 +25,26 @@
|
|
|
23
25
|
// `last_token_usage` counts a call written down twice twice. So a call is
|
|
24
26
|
// the difference in the running total from the event before: none is a
|
|
25
27
|
// repeat, and a fall is a new window, whose own `last_token_usage` counts.
|
|
28
|
+
// A rollout's first record counts its own figure too: a child's running
|
|
29
|
+
// total can start with its parent's inside it, and a fork can begin with
|
|
30
|
+
// its origin's records copied in, which are the origin's calls (#376).
|
|
26
31
|
// 2. The two harnesses do not mean the same thing by `input_tokens`. Claude
|
|
27
32
|
// Code leaves the cache reads out of it; Codex counts them inside it. So
|
|
28
33
|
// the Codex figure has its cached tokens taken back out, and `input` means
|
|
29
34
|
// uncached input on both. Taken as written, one is many times the other for
|
|
30
35
|
// the same work, and the cached tokens get priced at full rate.
|
|
31
36
|
// 3. Claude Code writes the same call down more than once, so a call counts
|
|
32
|
-
// once per `requestId` and `message.id`.
|
|
37
|
+
// once per `requestId` and `message.id`. A subagent's calls are in a file
|
|
38
|
+
// of their own beside the conversation that started it, and belong to it;
|
|
39
|
+
// the same call can be in more than one of those files, so a call counts
|
|
40
|
+
// once across all of a conversation's files together (#371).
|
|
33
41
|
|
|
34
42
|
import { readFileSync, realpathSync } from 'node:fs';
|
|
35
43
|
|
|
36
44
|
import { botDir, botNames, readBot } from './bot.js';
|
|
37
45
|
import { readBook, sessionIdsIn } from './book.js';
|
|
38
|
-
import { transcriptsIn } from './conversations.js';
|
|
39
|
-
import { harnessOf } from './launch.js';
|
|
46
|
+
import { claudeSubagentTranscripts, codexRollout, transcriptsIn } from './conversations.js';
|
|
47
|
+
import { HARNESSES, harnessOf } from './launch.js';
|
|
40
48
|
|
|
41
49
|
/** The kinds a call's tokens are reported in, the same on either harness. */
|
|
42
50
|
const KINDS = ['input', 'output', 'cache_read', 'cache_write', 'reasoning'];
|
|
@@ -98,6 +106,9 @@ function forBot(bots, name, onlySession, window) {
|
|
|
98
106
|
// them runs on is asked. The bot's own is the answer when it has no sessions.
|
|
99
107
|
const harnesses = new Set(bot.sessions.map((session) => harnessOf(session, bot.harness)));
|
|
100
108
|
if (harnesses.size === 0) harnesses.add(bot.harness);
|
|
109
|
+
// The book does not say which harness a retired session ran on, so any is.
|
|
110
|
+
const retired = Array.isArray(book.retired) ? book.retired : [];
|
|
111
|
+
if (retired.length > 0) for (const harness of HARNESSES) harnesses.add(harness);
|
|
101
112
|
|
|
102
113
|
const onRecord = new Map();
|
|
103
114
|
for (const harness of harnesses) {
|
|
@@ -112,15 +123,22 @@ function forBot(bots, name, onlySession, window) {
|
|
|
112
123
|
|
|
113
124
|
const claimed = sessionIdsIn(book);
|
|
114
125
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
126
|
+
// Two entries can name the same conversation, one resumed after its session
|
|
127
|
+
// was retired, and one entry can name it twice, now and in its history: it is
|
|
128
|
+
// counted once, in the first block asked for that names it.
|
|
129
|
+
const taken = new Set();
|
|
130
|
+
const block = (entry) => {
|
|
131
|
+
const ids = [...new Set(idsIn(entry))].filter((id) => onRecord.has(id) && !taken.has(id));
|
|
132
|
+
for (const id of ids) taken.add(id);
|
|
133
|
+
const { conversations, gaps } = all(ids.map((id) => onRecord.get(id)), window);
|
|
134
|
+
return { conversations, not_counted: gaps };
|
|
135
|
+
};
|
|
136
|
+
const sessions = [
|
|
137
|
+
...bot.sessions.map((session) => ({ name: session.name, entry: book.sessions[session.name] ?? {} })),
|
|
138
|
+
...retired.map((entry) => ({ name: entry?.name, retired: entry?.retired, entry: entry ?? {} })),
|
|
139
|
+
]
|
|
140
|
+
.filter(({ name }) => onlySession === undefined || name === onlySession)
|
|
141
|
+
.map(({ name, entry, ...was }) => ({ name, ...('retired' in was ? { retired: was.retired } : {}), ...block(entry) }));
|
|
124
142
|
|
|
125
143
|
const unclaimed = all([...onRecord.values()].filter((one) => !claimed.has(one.id)), window);
|
|
126
144
|
|
|
@@ -151,9 +169,9 @@ const idsIn = (entry) => [
|
|
|
151
169
|
* shorter page. What it could not count is still said.
|
|
152
170
|
*/
|
|
153
171
|
function counted(one, window) {
|
|
154
|
-
const read = one.harness === 'claude' ? fromClaude : fromCodex;
|
|
155
172
|
const tally = {
|
|
156
173
|
calls: 0,
|
|
174
|
+
subagentCalls: 0,
|
|
157
175
|
compactions: 0,
|
|
158
176
|
tokens: Object.fromEntries(KINDS.map((kind) => [kind, 0])),
|
|
159
177
|
models: new Set(),
|
|
@@ -166,15 +184,28 @@ function counted(one, window) {
|
|
|
166
184
|
gaps: noGaps(),
|
|
167
185
|
};
|
|
168
186
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
187
|
+
// A Claude Code conversation's subagents wrote their calls to files of their
|
|
188
|
+
// own, and what they spent is the conversation's.
|
|
189
|
+
const subagents = one.harness === 'claude' ? claudeSubagentTranscripts(one.file) : { files: [], unreadable: 0 };
|
|
190
|
+
tally.gaps.unreadable_transcripts += subagents.unreadable;
|
|
191
|
+
const sources = [one.file, ...subagents.files]
|
|
192
|
+
.map((file, index) => {
|
|
193
|
+
const { entries, unreadable, broken } = transcript(file);
|
|
194
|
+
tally.gaps.unreadable_transcripts += unreadable;
|
|
195
|
+
tally.gaps.broken_lines += broken;
|
|
196
|
+
return { entries, subagent: index > 0 };
|
|
197
|
+
});
|
|
198
|
+
if (one.harness === 'claude') fromClaude(sources, window, tally);
|
|
199
|
+
else {
|
|
200
|
+
const copied = originOf(sources[0].entries, tally);
|
|
201
|
+
if (copied !== null) fromCodex(sources[0].entries, window, tally, copied);
|
|
202
|
+
}
|
|
173
203
|
if (tally.calls === 0 && KINDS.every((kind) => tally.tokens[kind] === 0)) return { gaps: tally.gaps };
|
|
174
204
|
|
|
175
205
|
return { gaps: tally.gaps, row: {
|
|
176
206
|
id: one.id,
|
|
177
207
|
calls: tally.calls,
|
|
208
|
+
subagent_calls: tally.subagentCalls,
|
|
178
209
|
tokens: tally.tokens,
|
|
179
210
|
by_model: [...tally.byModel].map(([model, its]) => ({ model, calls: its.calls, tokens: its.tokens })),
|
|
180
211
|
models: [...tally.models],
|
|
@@ -204,6 +235,15 @@ function counted(one, window) {
|
|
|
204
235
|
* the runs add up to the whole, with a call still being written at a boundary
|
|
205
236
|
* charged once, part to each side (#169).
|
|
206
237
|
*
|
|
238
|
+
* A call is one `requestId` and `message.id` across the main transcript and its
|
|
239
|
+
* subagents' files together, since the same call can be in more than one of
|
|
240
|
+
* them, and its records are taken in the order of their times. Copies written
|
|
241
|
+
* at the very same moment are taken smallest first, since a call's figures only
|
|
242
|
+
* grow: the largest is the latest. One with a figure missing may be the latest
|
|
243
|
+
* of them, so it is taken as that, and what it hides is said rather than
|
|
244
|
+
* measured past it. It is a subagent's when the main transcript has no record
|
|
245
|
+
* of it.
|
|
246
|
+
*
|
|
207
247
|
* A record with no time, or with a figure missing, is left out whole and said to
|
|
208
248
|
* be; the call it belongs to is counted from its other records, if it has any.
|
|
209
249
|
* But when the record last written before the window's start is one of those,
|
|
@@ -211,10 +251,10 @@ function counted(one, window) {
|
|
|
211
251
|
* the window cannot be told apart from what it grew by before. So the call is
|
|
212
252
|
* not counted in that window, and is said to be.
|
|
213
253
|
*/
|
|
214
|
-
function fromClaude(
|
|
254
|
+
function fromClaude(sources, window, tally) {
|
|
215
255
|
const byCall = new Map();
|
|
216
256
|
|
|
217
|
-
for (const entry of entries) {
|
|
257
|
+
for (const { entries, subagent } of sources) for (const entry of entries) {
|
|
218
258
|
const when = Date.parse(entry.timestamp ?? '');
|
|
219
259
|
if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
|
|
220
260
|
if (Number.isNaN(when)) tally.gaps.records_without_time += 1;
|
|
@@ -233,10 +273,11 @@ function fromClaude(entries, window, tally) {
|
|
|
233
273
|
if (broken && inside(when, window)) tally.gaps.records_without_numbers += 1;
|
|
234
274
|
const call = `${entry.requestId}\u0000${entry.message?.id}`;
|
|
235
275
|
if (!byCall.has(call)) byCall.set(call, []);
|
|
236
|
-
byCall.get(call).push({ entry, when, broken });
|
|
276
|
+
byCall.get(call).push({ entry, when, broken, subagent });
|
|
237
277
|
}
|
|
238
278
|
|
|
239
279
|
for (const records of byCall.values()) {
|
|
280
|
+
records.sort((one, other) => one.when - other.when || one.broken - other.broken || size(one.entry) - size(other.entry));
|
|
240
281
|
const made = records[0].when;
|
|
241
282
|
const atEnd = records.findLast(({ when, broken }) => !broken && when < window.to);
|
|
242
283
|
if (atEnd === undefined) continue;
|
|
@@ -252,10 +293,17 @@ function fromClaude(entries, window, tally) {
|
|
|
252
293
|
const isNew = inside(made, window);
|
|
253
294
|
if (!isNew && KINDS.every((kind) => grew[kind] === 0)) continue;
|
|
254
295
|
|
|
255
|
-
|
|
296
|
+
const subagent = records.every((record) => record.subagent);
|
|
297
|
+
count(tally, grew, atEnd.entry.message?.model, atEnd.entry.effort, isNew ? made : atEnd.when, isNew ? 1 : 0, subagent);
|
|
256
298
|
}
|
|
257
299
|
}
|
|
258
300
|
|
|
301
|
+
/** How much one Claude Code record says its call has used so far, all kinds together. */
|
|
302
|
+
const size = (entry) => {
|
|
303
|
+
const now = figures(entry);
|
|
304
|
+
return KINDS.reduce((sum, kind) => sum + now[kind], 0);
|
|
305
|
+
};
|
|
306
|
+
|
|
259
307
|
/** The figures Claude Code writes on every call, all of which a record needs to be counted. */
|
|
260
308
|
const CLAUDE_FIELDS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens'];
|
|
261
309
|
|
|
@@ -300,22 +348,31 @@ const CODEX_FIELDS = [
|
|
|
300
348
|
* The running total is followed through events outside the window as well, since
|
|
301
349
|
* what a call added can only be measured against the event before it.
|
|
302
350
|
*
|
|
303
|
-
* An event whose running total has a figure missing
|
|
304
|
-
* and after it the running total is not known, so neither
|
|
305
|
-
* complete event added. Measured against the event before the
|
|
306
|
-
* would take in what the broken one used, at its own time and
|
|
307
|
-
* another window; taken by its own per-call figure, it may be the
|
|
308
|
-
* written down again. Telling those apart is guessing, so it is not
|
|
309
|
-
* either, and is said to be; the running total follows on from it. A
|
|
310
|
-
* figure is needed and has something missing is not counted, and is
|
|
311
|
-
* be; nothing missing is taken as zero.
|
|
351
|
+
* An event whose running total has a figure missing, or has none at all, is left
|
|
352
|
+
* out and said to be, and after it the running total is not known, so neither
|
|
353
|
+
* is what the next complete event added. Measured against the event before the
|
|
354
|
+
* broken one, it would take in what the broken one used, at its own time and
|
|
355
|
+
* perhaps in another window; taken by its own per-call figure, it may be the
|
|
356
|
+
* broken one written down again. Telling those apart is guessing, so it is not
|
|
357
|
+
* counted either, and is said to be; the running total follows on from it. A
|
|
358
|
+
* call whose figure is needed and has something missing is not counted, and is
|
|
359
|
+
* said to be; nothing missing is taken as zero.
|
|
360
|
+
*
|
|
361
|
+
* A fork begins with the records of the conversation it was forked from, copied
|
|
362
|
+
* in and stamped at the fork's start, or all at one time in a file Codex rebuilt.
|
|
363
|
+
* Those are the origin's calls, counted with the origin: the leading records
|
|
364
|
+
* that are in `copied`, both figures, are followed and not counted (#376).
|
|
365
|
+
* A leading record with a figure missing may be a copy or the fork's own call;
|
|
366
|
+
* it is not counted, and is said to be.
|
|
312
367
|
*/
|
|
313
|
-
function fromCodex(entries, window, tally) {
|
|
368
|
+
function fromCodex(entries, window, tally, copied) {
|
|
314
369
|
let model;
|
|
315
370
|
let effort;
|
|
316
371
|
let running;
|
|
317
372
|
// Whether a running total with something missing came since the last complete one.
|
|
318
373
|
let broken = false;
|
|
374
|
+
// Whether the records so far are all the origin's, copied into a fork.
|
|
375
|
+
let copying = copied !== undefined;
|
|
319
376
|
|
|
320
377
|
for (const entry of entries) {
|
|
321
378
|
const when = Date.parse(entry.timestamp ?? '');
|
|
@@ -338,13 +395,26 @@ function fromCodex(entries, window, tally) {
|
|
|
338
395
|
const info = entry.payload?.info;
|
|
339
396
|
if (info === undefined || info === null) continue;
|
|
340
397
|
|
|
341
|
-
const total = info.total_token_usage
|
|
398
|
+
const total = info.total_token_usage;
|
|
399
|
+
const whole = complete(total, CODEX_READ) && complete(info.last_token_usage, CODEX_READ);
|
|
400
|
+
if (copying && whole && copied.has(key(info))) {
|
|
401
|
+
running = total;
|
|
402
|
+
broken = false;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
// A record with a figure missing cannot be told from a copy while copies
|
|
406
|
+
// may still be coming: it is not counted and is said to be, and the next
|
|
407
|
+
// record is matched as before.
|
|
408
|
+
const unknown = copying && !whole;
|
|
409
|
+
if (!unknown) copying = false;
|
|
410
|
+
|
|
342
411
|
let used;
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
412
|
+
// No running total at all is a running total with every figure missing:
|
|
413
|
+
// the next one's rise would take this call in again (#302).
|
|
414
|
+
if (unknown && complete(total, CODEX_READ)) {
|
|
415
|
+
used = null;
|
|
416
|
+
running = total;
|
|
417
|
+
broken = false;
|
|
348
418
|
} else if (!complete(total, CODEX_READ)) {
|
|
349
419
|
used = null;
|
|
350
420
|
broken = true;
|
|
@@ -373,13 +443,56 @@ function fromCodex(entries, window, tally) {
|
|
|
373
443
|
/** The fields Codex itself writes; it has no cache writes to report. */
|
|
374
444
|
const CODEX_READ = CODEX_FIELDS.filter((field) => field !== 'cache_write_input_tokens').concat('total_tokens');
|
|
375
445
|
|
|
446
|
+
/**
|
|
447
|
+
* A usage record as one value, both its figures: a call of the child's own can
|
|
448
|
+
* bring the running total back to one the origin once had, but not with the same
|
|
449
|
+
* figure of its own.
|
|
450
|
+
*/
|
|
451
|
+
const key = (info) => [info.total_token_usage, info.last_token_usage]
|
|
452
|
+
.map((figure) => CODEX_READ.map((field) => figure[field]).join('/')).join('|');
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The usage records of the conversation a Codex rollout was forked from, which
|
|
456
|
+
* it may begin with copies of (tech notes, section 3). `undefined` when it names
|
|
457
|
+
* no origin. When the origin cannot be read whole, a line of it broken or a
|
|
458
|
+
* figure missing from a record, where the copies end cannot be told, so none of
|
|
459
|
+
* the fork is counted: `null`, and said to be.
|
|
460
|
+
*/
|
|
461
|
+
function originOf(entries, tally) {
|
|
462
|
+
const id = entries.find((entry) => entry.type === 'session_meta')?.payload?.forked_from_id;
|
|
463
|
+
if (typeof id !== 'string' || id === '') return undefined;
|
|
464
|
+
|
|
465
|
+
const file = codexRollout(id);
|
|
466
|
+
const origin = file === undefined ? { unreadable: 1 } : transcript(file);
|
|
467
|
+
// No `info` is a note about rate limits, not a record of usage.
|
|
468
|
+
const records = origin.unreadable > 0 ? [] : origin.entries
|
|
469
|
+
.filter((entry) => entry.type === 'event_msg' && entry.payload?.type === 'token_count')
|
|
470
|
+
.map((entry) => entry.payload.info)
|
|
471
|
+
.filter((info) => info !== undefined && info !== null);
|
|
472
|
+
const whole = origin.unreadable === 0 && origin.broken === 0 && records.every((info) =>
|
|
473
|
+
complete(info.total_token_usage, CODEX_READ) && complete(info.last_token_usage, CODEX_READ));
|
|
474
|
+
if (!whole) {
|
|
475
|
+
tally.gaps.unreadable_transcripts += 1;
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
return new Set(records.map(key));
|
|
479
|
+
}
|
|
480
|
+
|
|
376
481
|
/**
|
|
377
482
|
* What one Codex event says its call used, measured against the running total
|
|
378
483
|
* before it: `undefined` where the event is the one before written down again,
|
|
379
|
-
* and `null` where what it used cannot be known.
|
|
484
|
+
* and `null` where what it used cannot be known. The first has nothing before
|
|
485
|
+
* it, and its running total can hold what a parent spent before this rollout
|
|
486
|
+
* began, so its own figure is what it used. An own figure of nothing in every
|
|
487
|
+
* field is a note of where the running total stands, not a call: wherever else
|
|
488
|
+
* Codex writes one, the running total does not move (tech notes, section 3).
|
|
380
489
|
*/
|
|
381
490
|
function spent(running, total, info) {
|
|
382
|
-
if (running === undefined)
|
|
491
|
+
if (running === undefined) {
|
|
492
|
+
const own = info.last_token_usage;
|
|
493
|
+
if (complete(own, CODEX_READ) && CODEX_FIELDS.every((field) => number(own[field]) === 0)) return undefined;
|
|
494
|
+
return perCall(info);
|
|
495
|
+
}
|
|
383
496
|
|
|
384
497
|
const moved = total.total_tokens - running.total_tokens;
|
|
385
498
|
if (moved === 0) return undefined;
|
|
@@ -418,8 +531,9 @@ function kindsOf(raw) {
|
|
|
418
531
|
* the total that has to be complete, and a row headed by nothing would be worse
|
|
419
532
|
* than no row.
|
|
420
533
|
*/
|
|
421
|
-
function count(tally, used, model, effort, when, calls = 1) {
|
|
534
|
+
function count(tally, used, model, effort, when, calls = 1, subagent = false) {
|
|
422
535
|
tally.calls += calls;
|
|
536
|
+
if (subagent) tally.subagentCalls += calls;
|
|
423
537
|
for (const kind of KINDS) tally.tokens[kind] += used[kind];
|
|
424
538
|
add(tally.models, model);
|
|
425
539
|
add(tally.efforts, effort);
|