@assuredloop/orca-bot-kit 0.11.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/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 +25 -9
- package/src/cli.js +49 -16
- package/src/conversations.js +24 -0
- package/src/permissions.js +95 -2
- package/src/usage.js +79 -39
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.12.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 ?? [];
|
|
@@ -289,6 +289,21 @@ export function allowRules(bots, bot, rules) {
|
|
|
289
289
|
return { bot, home, allow, added };
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Take the permission rules the user said yes to taking back, `rules`, out of
|
|
294
|
+
* the bot's `allow` (#360). Returns { bot, home, allow, disallowed }: the whole
|
|
295
|
+
* list now, and what this call took out. The caller has refused a rule `allow`
|
|
296
|
+
* does not hold, and takes them out of the harness's files first. With `write`
|
|
297
|
+
* false it only refuses what the edit would refuse, and writes nothing.
|
|
298
|
+
*/
|
|
299
|
+
export function disallowRules(bots, bot, rules, { write = true } = {}) {
|
|
300
|
+
const { home, was } = allowedNow(bots, bot, rules, '--disallow');
|
|
301
|
+
const disallowed = [...new Set(rules)];
|
|
302
|
+
const allow = was.filter((rule) => !disallowed.includes(rule));
|
|
303
|
+
editBot(bots, bot, `take back ${disallowed.join(', ')} for ${bot}`, (doc) => doc.set('allow', allow), (before) => ({ ...before, allow }), write);
|
|
304
|
+
return { bot, home, allow, disallowed };
|
|
305
|
+
}
|
|
306
|
+
|
|
292
307
|
/**
|
|
293
308
|
* Change the settings of the session `name` of the bot `bot`. Returns
|
|
294
309
|
* { bot, home, session } — the session as it now reads.
|
|
@@ -387,9 +402,10 @@ function editSession(bots, bot, index, session, what) {
|
|
|
387
402
|
|
|
388
403
|
/**
|
|
389
404
|
* 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
|
|
405
|
+
* only if exactly the change `expected` describes is what came out, and
|
|
406
|
+
* `write` is not false.
|
|
391
407
|
*/
|
|
392
|
-
function editBot(bots, bot, what, edit, expected) {
|
|
408
|
+
function editBot(bots, bot, what, edit, expected, write = true) {
|
|
393
409
|
const file = path.join(existingBot(bots, bot), BOT_YAML);
|
|
394
410
|
const source = readFileSync(file, 'utf8');
|
|
395
411
|
const doc = parseDocument(source);
|
|
@@ -398,7 +414,7 @@ function editBot(bots, bot, what, edit, expected) {
|
|
|
398
414
|
if (!changesExactly(source, text, expected)) {
|
|
399
415
|
throw new Error(`${file} cannot be changed to ${what} without changing something else in it, so nothing was written. Make the change by hand.`);
|
|
400
416
|
}
|
|
401
|
-
if (text !== source) writeFileSync(file, text);
|
|
417
|
+
if (write && text !== source) writeFileSync(file, text);
|
|
402
418
|
}
|
|
403
419
|
|
|
404
420
|
/** 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, 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,8 +610,11 @@ 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.
|
|
@@ -614,6 +623,12 @@ const commands = {
|
|
|
614
623
|
refuseBroad(home, values.allow);
|
|
615
624
|
refuseNoCodexForm(readBot(home, values.bot), values.allow);
|
|
616
625
|
}
|
|
626
|
+
let takeBackFrom;
|
|
627
|
+
if (values.disallow !== undefined) {
|
|
628
|
+
const { home } = allowedNow(bots, values.bot, values.disallow, '--disallow');
|
|
629
|
+
takeBackFrom = takeBack(bots, home, readBot(home, values.bot), values.disallow);
|
|
630
|
+
disallowRules(bots, values.bot, values.disallow, { write: false });
|
|
631
|
+
}
|
|
617
632
|
|
|
618
633
|
const answer = { bots, bot: values.bot, home: botDir(bots, values.bot) };
|
|
619
634
|
const lines = [];
|
|
@@ -637,7 +652,7 @@ const commands = {
|
|
|
637
652
|
// An allow list that is not a list is named by rules build and health,
|
|
638
653
|
// and a charter already written is not undone for it.
|
|
639
654
|
const bot = readBot(changed.home, changed.bot);
|
|
640
|
-
if (values.allow === undefined && (runsOnClaude(bot) || runsOnCodex(bot))) {
|
|
655
|
+
if (values.allow === undefined && values.disallow === undefined && (runsOnClaude(bot) || runsOnCodex(bot))) {
|
|
641
656
|
try {
|
|
642
657
|
answer.beyondDefaults = beyondDefaults(bots, changed.home, bot);
|
|
643
658
|
lines.push(...charterRulesLines(bots, bot, answer.beyondDefaults));
|
|
@@ -664,6 +679,23 @@ const commands = {
|
|
|
664
679
|
: `${allowed.bot}'s allow list in ${path.join('bots', allowed.bot, 'bot.yaml')} holds the user's yes.`,
|
|
665
680
|
);
|
|
666
681
|
}
|
|
682
|
+
if (values.disallow !== undefined) {
|
|
683
|
+
// The harness's files first, bot.yaml last: a write that fails leaves the
|
|
684
|
+
// rules in `allow`, so the same command can take them back again (#360).
|
|
685
|
+
const permissions = takeBackFrom();
|
|
686
|
+
const taken = disallowRules(bots, values.bot, values.disallow);
|
|
687
|
+
Object.assign(answer, { allow: taken.allow, disallowed: taken.disallowed, permissions });
|
|
688
|
+
const codex = permissions.find((entry) => entry.unwritten !== undefined);
|
|
689
|
+
lines.push(
|
|
690
|
+
...taken.disallowed.map((rule) => `${'took back'.padEnd(9)} ${rule}`),
|
|
691
|
+
...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`),
|
|
692
|
+
...permissionsLines(permissions, bots),
|
|
693
|
+
...(codex === undefined || codex.removed.length === 0 ? [] : [
|
|
694
|
+
`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).`,
|
|
695
|
+
]),
|
|
696
|
+
`${taken.bot}'s allow list in ${path.join('bots', taken.bot, 'bot.yaml')} no longer holds ${taken.disallowed.length === 1 ? 'it' : 'them'}.`,
|
|
697
|
+
);
|
|
698
|
+
}
|
|
667
699
|
return { answer, lines, code: trouble ? 1 : 0 };
|
|
668
700
|
},
|
|
669
701
|
|
|
@@ -1053,7 +1085,7 @@ function usageLines(usage, bots) {
|
|
|
1053
1085
|
for (const entry of usage) {
|
|
1054
1086
|
lines.push(`${'bot'.padEnd(9)} ${entry.bot}`);
|
|
1055
1087
|
for (const session of entry.sessions) {
|
|
1056
|
-
lines.push(`${'session'.padEnd(9)} ${session.name}`);
|
|
1088
|
+
lines.push(`${'session'.padEnd(9)} ${session.name}${'retired' in session ? ` retired ${session.retired}` : ''}`);
|
|
1057
1089
|
const notCounted = notCountedLine(session.not_counted);
|
|
1058
1090
|
lines.push(...session.conversations.map(conversationLine));
|
|
1059
1091
|
if (session.conversations.length === 0 && notCounted.length === 0) lines.push(' nothing on record');
|
|
@@ -1093,6 +1125,7 @@ function conversationLine(one) {
|
|
|
1093
1125
|
.map(([kind, count]) => `${kind} ${count}`)
|
|
1094
1126
|
.join(' ');
|
|
1095
1127
|
return ` ${one.id} ${one.calls} call${one.calls === 1 ? '' : 's'}`
|
|
1128
|
+
+ `${one.subagent_calls > 0 ? ` of which subagents: ${one.subagent_calls} call${one.subagent_calls === 1 ? '' : 's'}` : ''}`
|
|
1096
1129
|
+ `${ran === '' ? '' : ` ${ran}`}`
|
|
1097
1130
|
+ `${used === '' ? '' : ` ${used}`}`
|
|
1098
1131
|
+ `${one.compactions > 0 ? ` compacted ${one.compactions}` : ''}`;
|
|
@@ -1264,7 +1297,7 @@ function charterRulesLines(bots, bot, rules) {
|
|
|
1264
1297
|
return [
|
|
1265
1298
|
`${name} is allowed these permission rules beyond the kit's defaults, from before this change:`,
|
|
1266
1299
|
...rules.map((rule) => ` ${rule}`),
|
|
1267
|
-
`They stay allowed, whatever the user answers, until the user
|
|
1300
|
+
`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
1301
|
];
|
|
1269
1302
|
}
|
|
1270
1303
|
|
package/src/conversations.js
CHANGED
|
@@ -59,6 +59,30 @@ export function hasConversation(harness, home, id) {
|
|
|
59
59
|
*/
|
|
60
60
|
export const claudeTranscript = (home, id) => path.join(claudeDir(home), `${id}.jsonl`);
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The transcripts of the subagents a Claude Code conversation started, given the
|
|
64
|
+
* file the conversation is in. They sit beside it, in a folder named by its id,
|
|
65
|
+
* one `agent-<name>.jsonl` each, with a `.meta.json` beside each that is not a
|
|
66
|
+
* transcript (tech notes, section 2).
|
|
67
|
+
*
|
|
68
|
+
* No folder is a conversation that started no subagent. A folder that is there
|
|
69
|
+
* and cannot be listed is `unreadable`: what is in it cannot be counted, and
|
|
70
|
+
* taking it as empty would say nothing of that.
|
|
71
|
+
*/
|
|
72
|
+
export function claudeSubagentTranscripts(file) {
|
|
73
|
+
const dir = path.join(file.slice(0, -'.jsonl'.length), 'subagents');
|
|
74
|
+
let names;
|
|
75
|
+
try {
|
|
76
|
+
names = readdirSync(dir);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return { files: [], unreadable: error.code === 'ENOENT' ? 0 : 1 };
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
files: names.filter((name) => name.endsWith('.jsonl')).sort().map((name) => path.join(dir, name)),
|
|
82
|
+
unreadable: 0,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
62
86
|
/**
|
|
63
87
|
* The same conversations, each with the file the harness keeps it in, for a
|
|
64
88
|
* caller that has to read what is inside one rather than only know it is there,
|
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, writeFileSync } from 'node:fs';
|
|
22
23
|
import path from 'node:path';
|
|
23
24
|
|
|
24
25
|
import { leadsOutside } from './bot.js';
|
|
@@ -304,6 +305,98 @@ function writeClaude(bots, home, bot) {
|
|
|
304
305
|
return { ...answer, written: missing };
|
|
305
306
|
}
|
|
306
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Take the rules the user said yes to taking back out of the files of the
|
|
310
|
+
* harnesses the bot runs on (#360): from its Claude settings every entry of
|
|
311
|
+
* that exact text and nothing else, and its Codex rules rewritten from what
|
|
312
|
+
* `allow` still holds. Only rules `allow` holds are taken back; one it does not
|
|
313
|
+
* hold is refused, since the kit did not write it (ADR 0029).
|
|
314
|
+
*
|
|
315
|
+
* Everything is checked before anything is written, a file the kit could not
|
|
316
|
+
* write included, bot.yaml too. What comes back writes the files: one entry per file,
|
|
317
|
+
* Claude's first, with `removed` the rules taken out of it. The caller takes
|
|
318
|
+
* them out of `allow` after, so a write that fails leaves them there to take
|
|
319
|
+
* back again.
|
|
320
|
+
*/
|
|
321
|
+
export function takeBack(bots, home, bot, rules) {
|
|
322
|
+
const allowed = allowOf(home, bot);
|
|
323
|
+
const foreign = rules.find((rule) => !allowed.includes(rule));
|
|
324
|
+
if (foreign !== undefined) throw new Error(notAllowed(home, bot, foreign));
|
|
325
|
+
const after = { ...bot, allow: allowed.filter((rule) => !rules.includes(rule)) };
|
|
326
|
+
const taken = [...new Set(rules)];
|
|
327
|
+
// bot.yaml is written last, and a harness file taken out of step with it
|
|
328
|
+
// would leave nothing to say a running Codex session still has the rule.
|
|
329
|
+
refuseUnwritable(path.join(home, 'bot.yaml'));
|
|
330
|
+
|
|
331
|
+
const file = path.join(home, FILE);
|
|
332
|
+
let claude;
|
|
333
|
+
if (runsOnClaude(bot)) {
|
|
334
|
+
refuseOutside(home, file);
|
|
335
|
+
const settings = readSettings(file, 'the permission rules the user allowed');
|
|
336
|
+
const present = presentIn(settings, file);
|
|
337
|
+
claude = { settings, present, removed: taken.filter((rule) => present.includes(rule)) };
|
|
338
|
+
if (claude.removed.length > 0) refuseUnwritable(file);
|
|
339
|
+
}
|
|
340
|
+
const codex = path.join(home, CODEX_FILE);
|
|
341
|
+
let before = [];
|
|
342
|
+
if (runsOnCodex(bot)) {
|
|
343
|
+
refuseOutside(home, codex);
|
|
344
|
+
if (existsSync(codex)) {
|
|
345
|
+
before = ruleLines(readFileSync(codex, 'utf8'));
|
|
346
|
+
refuseUnwritable(codex);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return () => {
|
|
351
|
+
const entries = [];
|
|
352
|
+
if (claude !== undefined) {
|
|
353
|
+
const { settings, present, removed } = claude;
|
|
354
|
+
if (removed.length > 0) {
|
|
355
|
+
const allow = present.filter((rule) => !removed.includes(rule));
|
|
356
|
+
writeFileSync(file, `${JSON.stringify({ ...settings, permissions: { ...settings.permissions, allow } }, null, 2)}\n`);
|
|
357
|
+
}
|
|
358
|
+
entries.push({ bot: bot.name, file, written: [], removed, waiting: waitingFor(bots, home, after, 'claude') });
|
|
359
|
+
}
|
|
360
|
+
if (runsOnCodex(after)) {
|
|
361
|
+
const entry = writeCodex(bots, home, after);
|
|
362
|
+
// A line goes only when no rule still allowed gives it.
|
|
363
|
+
const kept = [...codexOf(home, after).lines.keys()];
|
|
364
|
+
const removed = taken.filter((rule) => {
|
|
365
|
+
const { line } = codexForm(rule);
|
|
366
|
+
return line !== undefined && before.includes(line) && !kept.includes(line);
|
|
367
|
+
});
|
|
368
|
+
entries.push({ ...entry, written: [], removed });
|
|
369
|
+
}
|
|
370
|
+
return entries;
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** A refusal, before anything is written, of a file the kit could not write. */
|
|
375
|
+
function refuseUnwritable(file) {
|
|
376
|
+
try {
|
|
377
|
+
accessSync(file, constants.W_OK);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
throw new Error(`${file} cannot be written (${error.code ?? error.message}), so nothing was changed. Let the kit write it, then run the command again.`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Why `--disallow` refuses a rule `allow` does not hold, and where one of that text is. */
|
|
384
|
+
function notAllowed(home, bot, rule) {
|
|
385
|
+
const yaml = path.join(home, 'bot.yaml');
|
|
386
|
+
const file = path.join(home, FILE);
|
|
387
|
+
let byHand = false;
|
|
388
|
+
try {
|
|
389
|
+
byHand = runsOnClaude(bot) && leadsOutside(home, file) === undefined
|
|
390
|
+
&& presentIn(readSettings(file), file).includes(rule);
|
|
391
|
+
} catch {
|
|
392
|
+
// A file the kit cannot read holds nothing it would take out.
|
|
393
|
+
}
|
|
394
|
+
const where = byHand
|
|
395
|
+
? ` ${file} allows it, which the user added by hand, not the kit: it stays theirs, to take out themselves.`
|
|
396
|
+
: '';
|
|
397
|
+
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.`;
|
|
398
|
+
}
|
|
399
|
+
|
|
307
400
|
/**
|
|
308
401
|
* The Codex form of what the bot is allowed: the file's rule lines, each once,
|
|
309
402
|
* with the rules that gave them, and the rules Codex has no form for.
|
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
|
//
|
|
@@ -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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
383
|
+
const total = info.total_token_usage;
|
|
342
384
|
let used;
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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);
|