@assuredloop/orca-bot-kit 0.12.0 → 0.14.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 +1 -1
- package/package.json +1 -1
- package/rules/temporary.md +21 -0
- package/src/book.js +3 -0
- package/src/bot.js +13 -7
- package/src/cli.js +101 -13
- package/src/conversations.js +8 -1
- package/src/health.js +5 -1
- package/src/init.js +3 -2
- package/src/launch.js +2 -2
- package/src/message.js +43 -7
- package/src/orca.js +19 -6
- package/src/permissions.js +66 -25
- package/src/retire.js +31 -10
- package/src/temp.js +173 -0
- package/src/usage.js +80 -6
package/README.md
CHANGED
|
@@ -508,7 +508,7 @@ verdict: which of these matter, and in what order, is for you or for Bot Father.
|
|
|
508
508
|
## Sessions and bots that talk
|
|
509
509
|
|
|
510
510
|
Ask the kit how to reach a session, and it answers with the road and the
|
|
511
|
-
address ([ADR
|
|
511
|
+
address ([ADR 0030](docs/adr/0030-messaging-transport.md)):
|
|
512
512
|
|
|
513
513
|
```sh
|
|
514
514
|
obk message to --bots /path/to/my-bots --to api-bot/daily
|
package/package.json
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: temporary
|
|
3
|
+
title: Temporary sessions of your own
|
|
4
|
+
applies: all
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
For a piece of work that wants a helper, a long-lived session may make a
|
|
8
|
+
temporary session of its own bot, without asking Bot Father:
|
|
9
|
+
`"${OBK_CLI:-obk}" temp make --bots <bots> --name <name> --prompt <the task>`,
|
|
10
|
+
run in your own tab. It takes your settings unless you say otherwise, and gets
|
|
11
|
+
a work dir, a tab and a mailbox.
|
|
12
|
+
|
|
13
|
+
A new tab can stop on a first-run screen. Answer it yourself, from the table
|
|
14
|
+
the command's output points to.
|
|
15
|
+
|
|
16
|
+
Retire it when its work is done:
|
|
17
|
+
`"${OBK_CLI:-obk}" temp retire --bots <bots> --name <name>`. Before you make
|
|
18
|
+
another, first retire any of yours whose work is done.
|
|
19
|
+
|
|
20
|
+
You manage only the temporary sessions you made; a temporary session makes
|
|
21
|
+
none of its own. Every other session is Bot Father's to make, change or retire.
|
package/src/book.js
CHANGED
|
@@ -30,6 +30,9 @@ const HEADER = `# What Orca calls this bot on this machine, and where each of it
|
|
|
30
30
|
#
|
|
31
31
|
# \`retired\` holds what the book knew about each session \`obk retire\` took off
|
|
32
32
|
# the bot, with when, so the conversations it had are still accounted for.
|
|
33
|
+
#
|
|
34
|
+
# \`temporary\` marks a session another session of this bot made with
|
|
35
|
+
# \`obk temp make\`: which session made it, and when. Its maker retires it.
|
|
33
36
|
`;
|
|
34
37
|
|
|
35
38
|
export const bookFile = (home) => path.join(home, 'sessions.yaml');
|
package/src/bot.js
CHANGED
|
@@ -21,7 +21,7 @@ export const botsDir = (bots) => path.join(bots, 'bots');
|
|
|
21
21
|
export const botDir = (bots, name) => path.join(botsDir(bots), name);
|
|
22
22
|
|
|
23
23
|
/** A bot's name: the folder it lives in, and safe to be one. */
|
|
24
|
-
const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
24
|
+
export const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
25
25
|
|
|
26
26
|
/** The file that says what a bot is. A folder without one is not a bot. */
|
|
27
27
|
const BOT_YAML = 'bot.yaml';
|
|
@@ -277,14 +277,15 @@ export function allowedNow(bots, bot, rules, flag = '--allow') {
|
|
|
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
|
}
|
|
@@ -410,10 +411,15 @@ function editBot(bots, bot, what, edit, expected, write = true) {
|
|
|
410
411
|
const source = readFileSync(file, 'utf8');
|
|
411
412
|
const doc = parseDocument(source);
|
|
412
413
|
edit(doc);
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
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;
|
|
416
421
|
}
|
|
422
|
+
if (!changesExactly(source, text, expected)) throw refusal;
|
|
417
423
|
if (write && text !== source) writeFileSync(file, text);
|
|
418
424
|
}
|
|
419
425
|
|
package/src/cli.js
CHANGED
|
@@ -17,9 +17,9 @@ 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
|
-
import { checkMail, lookUp, noMailboxYet, sendMessage } from './message.js';
|
|
21
|
-
import { orcaCli, orcaTrouble, RELOAD_LINE } from './orca.js';
|
|
22
|
-
import { allowCommand, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
|
|
20
|
+
import { checkMail, lookUp, noMailboxYet, sendMessage, sessionInTab } from './message.js';
|
|
21
|
+
import { orcaCli, orcaTrouble, RELOAD_LINE, TERMINAL_ENV } from './orca.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';
|
|
@@ -28,6 +28,7 @@ import { readRoster } from './roster.js';
|
|
|
28
28
|
import { buildAgents, buildRules, CODEX_CAP } from './rules.js';
|
|
29
29
|
import { addSkill, buildSkills, linkSkills, removeSkill } from './skills.js';
|
|
30
30
|
import { addSource, fetchSources } from './sources.js';
|
|
31
|
+
import { makeTemp, retireTemp } from './temp.js';
|
|
31
32
|
import { readUsage } from './usage.js';
|
|
32
33
|
import { BOT_FATHER, bringUp, ownMailbox } from './up.js';
|
|
33
34
|
|
|
@@ -95,6 +96,21 @@ Usage:
|
|
|
95
96
|
bot whose Orca project holds a tab your book does
|
|
96
97
|
not name, and moves the folder only once Orca no
|
|
97
98
|
longer lists the project.
|
|
99
|
+
obk temp make --bots <path> --name <session>
|
|
100
|
+
(--prompt <text> | --prompt-file <path>) [--harness claude|codex]
|
|
101
|
+
[--model <m>] [--effort <e>] [--context <c>]
|
|
102
|
+
[--approval ${APPROVALS.join('|')}]
|
|
103
|
+
Run in a long-lived session's own tab: make a
|
|
104
|
+
temporary session of that session's bot for a piece
|
|
105
|
+
of work, and bring it up. It takes your harness,
|
|
106
|
+
model, effort, context and approval unless you say
|
|
107
|
+
otherwise, works in work/<session>, and has the task
|
|
108
|
+
as its start prompt. The book records it as
|
|
109
|
+
temporary, made by you.
|
|
110
|
+
obk temp retire --bots <path> --name <session>
|
|
111
|
+
Run in the maker's own tab: retire a temporary
|
|
112
|
+
session it made, as obk retire does. It refuses a
|
|
113
|
+
long-lived session, or one another session made.
|
|
98
114
|
obk rules build --bots <path> [--bot <bot>]
|
|
99
115
|
Build every bot's AGENTS.md from its charter and
|
|
100
116
|
the rule units it carries, or just the one you
|
|
@@ -242,6 +258,8 @@ const COMMANDS = {
|
|
|
242
258
|
'message check': ['bots'],
|
|
243
259
|
'session record': ['bots', 'bot'],
|
|
244
260
|
'session mailbox': ['bots', 'bot', 'session'],
|
|
261
|
+
'temp make': ['bots', 'name'],
|
|
262
|
+
'temp retire': ['bots', 'name'],
|
|
245
263
|
};
|
|
246
264
|
|
|
247
265
|
/** What each flag is for, in the sentence a caller reads when it is missing. */
|
|
@@ -335,9 +353,9 @@ async function run(argv) {
|
|
|
335
353
|
return 1;
|
|
336
354
|
}
|
|
337
355
|
|
|
338
|
-
// `bot`, `rules`, `skills`, `session`, `source` and `
|
|
339
|
-
// two words; the rest are one.
|
|
340
|
-
const words = ['bot', 'rules', 'skills', 'session', 'source', 'message'].includes(positionals[0]) ? 2 : 1;
|
|
356
|
+
// `bot`, `rules`, `skills`, `session`, `source`, `message` and `temp` are
|
|
357
|
+
// commands of two words; the rest are one.
|
|
358
|
+
const words = ['bot', 'rules', 'skills', 'session', 'source', 'message', 'temp'].includes(positionals[0]) ? 2 : 1;
|
|
341
359
|
const command = positionals.slice(0, words).join(' ');
|
|
342
360
|
const extra = positionals.slice(words);
|
|
343
361
|
|
|
@@ -534,8 +552,6 @@ const commands = {
|
|
|
534
552
|
|
|
535
553
|
async retire(bots, values) {
|
|
536
554
|
refuseWhenOrcaIsDown();
|
|
537
|
-
const closedLines = (closed) => closed.map((tab) => `closed ${tab.bot} ${tab.name} tab ${tab.tabId} terminal ${tab.terminal}`);
|
|
538
|
-
|
|
539
555
|
if (values.session !== undefined) {
|
|
540
556
|
const retired = await retireSession(bots, { bot: values.bot, session: values.session });
|
|
541
557
|
return {
|
|
@@ -543,6 +559,7 @@ const commands = {
|
|
|
543
559
|
lines: [
|
|
544
560
|
...closedLines(retired.closed),
|
|
545
561
|
`retired ${retired.bot} ${retired.session}: off ${path.join('bots', retired.bot, 'bot.yaml')}, and its conversations kept in the book under retired`,
|
|
562
|
+
...leftLines(retired.promptsLeft),
|
|
546
563
|
],
|
|
547
564
|
};
|
|
548
565
|
}
|
|
@@ -571,6 +588,39 @@ const commands = {
|
|
|
571
588
|
retired.windowReloaded ? "reloaded Orca's window, so its sidebar no longer shows the project" : RELOAD_LINE,
|
|
572
589
|
]),
|
|
573
590
|
`retired ${retired.bot}: moved to ${path.relative(bots, retired.moved)}, with its book, charter and memory`,
|
|
591
|
+
...leftLines(retired.promptsLeft),
|
|
592
|
+
],
|
|
593
|
+
};
|
|
594
|
+
},
|
|
595
|
+
|
|
596
|
+
async 'temp make'(bots, values) {
|
|
597
|
+
const tab = callerTab(bots, true);
|
|
598
|
+
refuseWhenOrcaIsDown();
|
|
599
|
+
const given = { name: values.name };
|
|
600
|
+
for (const [flag, key] of [['harness', 'harness'], ['model', 'model'], ['effort', 'effort'], ['approval', 'approval'], ['prompt', 'prompt'], ['prompt-file', 'prompt_file']]) {
|
|
601
|
+
if (values[flag] !== undefined) given[key] = values[flag];
|
|
602
|
+
}
|
|
603
|
+
if (values.context !== undefined) given.context = asNumberOrText(values.context);
|
|
604
|
+
const made = await makeTemp(bots, { tab, ...given });
|
|
605
|
+
const { tabs, rules, skills, permissions, paused, projects } = made.up;
|
|
606
|
+
const answer = { bots, bot: made.bot, session: made.session, maker: made.maker, settings: made.settings, created: [], completed: [], rules, skills, permissions, tabs, paused, projects };
|
|
607
|
+
const retire = `${shellWord(ownCli())} temp retire --bots ${shellWord(bots)} --name ${made.session}`;
|
|
608
|
+
return {
|
|
609
|
+
answer,
|
|
610
|
+
lines: tabLines(answer, `Made ${made.bot}/${made.session}, a temporary session of ${made.maker}'s, working in work/${made.session}. Retire it when its work is done: ${retire}`),
|
|
611
|
+
};
|
|
612
|
+
},
|
|
613
|
+
|
|
614
|
+
async 'temp retire'(bots, values) {
|
|
615
|
+
const tab = callerTab(bots, true);
|
|
616
|
+
refuseWhenOrcaIsDown();
|
|
617
|
+
const retired = await retireTemp(bots, { tab, name: values.name });
|
|
618
|
+
return {
|
|
619
|
+
answer: { bots, ...retired },
|
|
620
|
+
lines: [
|
|
621
|
+
...closedLines(retired.closed),
|
|
622
|
+
`retired ${retired.bot} ${retired.session}, a temporary session of ${retired.maker}'s: off ${path.join('bots', retired.bot, 'bot.yaml')}, and its conversations kept in the book under retired`,
|
|
623
|
+
...leftLines(retired.promptsLeft),
|
|
574
624
|
],
|
|
575
625
|
};
|
|
576
626
|
},
|
|
@@ -618,10 +668,14 @@ const commands = {
|
|
|
618
668
|
}
|
|
619
669
|
// Refused before anything is written, so a bad rule, or a bad list already
|
|
620
670
|
// there, leaves the charter as it was too.
|
|
671
|
+
let allowInto;
|
|
621
672
|
if (values.allow !== undefined) {
|
|
622
673
|
const { home } = allowedNow(bots, values.bot, values.allow);
|
|
623
674
|
refuseBroad(home, values.allow);
|
|
624
|
-
|
|
675
|
+
const bot = readBot(home, values.bot);
|
|
676
|
+
refuseNoCodexForm(bot, values.allow);
|
|
677
|
+
const { allow } = allowRules(bots, values.bot, values.allow, { write: false });
|
|
678
|
+
allowInto = allowIn(bots, home, { ...bot, allow });
|
|
625
679
|
}
|
|
626
680
|
let takeBackFrom;
|
|
627
681
|
if (values.disallow !== undefined) {
|
|
@@ -662,9 +716,11 @@ const commands = {
|
|
|
662
716
|
}
|
|
663
717
|
}
|
|
664
718
|
if (values.allow !== undefined) {
|
|
719
|
+
// Written at once, as the charter's rules are built at once (#344): the
|
|
720
|
+
// harness's files first, bot.yaml last, so a write that fails leaves
|
|
721
|
+
// `allow` as it was, and the same command can run again (#383).
|
|
722
|
+
const permissions = allowInto();
|
|
665
723
|
const allowed = allowRules(bots, values.bot, values.allow);
|
|
666
|
-
// Written at once, as the charter's rules are built at once (#344).
|
|
667
|
-
const permissions = writePermissions(bots, allowed.home, readBot(allowed.home));
|
|
668
724
|
Object.assign(answer, { allow: allowed.allow, permissions });
|
|
669
725
|
const codex = permissions.find((entry) => entry.unwritten !== undefined);
|
|
670
726
|
lines.push(
|
|
@@ -826,11 +882,12 @@ const commands = {
|
|
|
826
882
|
},
|
|
827
883
|
|
|
828
884
|
'message send'(bots, values) {
|
|
885
|
+
const tab = callerTab(bots, values.from === undefined);
|
|
829
886
|
refuseWhenOrcaIsDown();
|
|
830
887
|
const answer = sendMessage(bots, {
|
|
831
888
|
to: values.to,
|
|
832
889
|
from: values.from,
|
|
833
|
-
tab
|
|
890
|
+
tab,
|
|
834
891
|
subject: values.subject,
|
|
835
892
|
text: values.text,
|
|
836
893
|
textFile: values['text-file'],
|
|
@@ -859,11 +916,13 @@ const commands = {
|
|
|
859
916
|
},
|
|
860
917
|
|
|
861
918
|
'message check'(bots, values) {
|
|
919
|
+
// Named or not, the mail is read as the caller's own terminal when it has one.
|
|
920
|
+
const tab = callerTab(bots, values.bot === undefined || process.env[TERMINAL_ENV] !== undefined);
|
|
862
921
|
refuseWhenOrcaIsDown();
|
|
863
922
|
const answer = checkMail(bots, {
|
|
864
923
|
bot: values.bot,
|
|
865
924
|
session: values.session,
|
|
866
|
-
tab
|
|
925
|
+
tab,
|
|
867
926
|
peek: values.peek === true,
|
|
868
927
|
});
|
|
869
928
|
const where = `${answer.bot}/${answer.session}`;
|
|
@@ -967,6 +1026,12 @@ function toLines(answer, bots, where) {
|
|
|
967
1026
|
* about the message.
|
|
968
1027
|
*/
|
|
969
1028
|
function nudgeLine(answer, where) {
|
|
1029
|
+
if (answer.nudged && answer.nudgeWatched === false) {
|
|
1030
|
+
return ` its tab was typed into, and Orca could not say whether it was taken: ${answer.nudgeUnseen} The message waits in its mailbox for its next check either way.`;
|
|
1031
|
+
}
|
|
1032
|
+
if (answer.nudged && answer.nudgeUnseen !== undefined) {
|
|
1033
|
+
return ' its tab was typed into, but Orca did not see the line start a turn: it may be queued behind the work in hand, or lost. The message waits in its mailbox for its next check either way.';
|
|
1034
|
+
}
|
|
970
1035
|
if (answer.nudged) return ' its tab was told to look; it will read it when it is done with what it is doing.';
|
|
971
1036
|
if (answer.blocked !== undefined) {
|
|
972
1037
|
return ` its tab has something waiting to be answered (${answer.blocked}), so nothing was typed into it. Settle that, and the mail is there.`;
|
|
@@ -1004,11 +1069,33 @@ function settingsOf(values) {
|
|
|
1004
1069
|
return settings;
|
|
1005
1070
|
}
|
|
1006
1071
|
|
|
1072
|
+
/** The tabs a command closed, one line each. */
|
|
1073
|
+
const closedLines = (closed) => closed.map((tab) => `closed ${tab.bot} ${tab.name} tab ${tab.tabId} terminal ${tab.terminal}`);
|
|
1074
|
+
|
|
1075
|
+
/** What a retire could not remove, and how to: nothing reads these files now (#393). */
|
|
1076
|
+
const leftLines = (left = []) => left.flatMap(({ file, reason }) => [
|
|
1077
|
+
`left ${file}: it could not be removed (${reason}). Nothing reads it now.`,
|
|
1078
|
+
` Remove it with: rm ${shellWord(file)}`,
|
|
1079
|
+
]);
|
|
1080
|
+
|
|
1007
1081
|
/** A context window written as a plain number stays one in the file. */
|
|
1008
1082
|
const asNumberOrText = (value) => (/^\d+$/.test(value) ? Number(value) : value);
|
|
1009
1083
|
|
|
1010
1084
|
const oneLine = (value) => (Array.isArray(value) ? value.join(' ') : String(value)).replace(/\s+/g, ' ').trim();
|
|
1011
1085
|
|
|
1086
|
+
/**
|
|
1087
|
+
* The Orca tab this command runs in, with the caller taken to be the session
|
|
1088
|
+
* there only once sessionInTab has shown it is, when the command takes its
|
|
1089
|
+
* caller from the tab (#408). Asked before Orca is: a Codex that Orca brought
|
|
1090
|
+
* back by itself may run in a sandbox that cannot reach Orca at all, and
|
|
1091
|
+
* "Orca is not ready" would send its user the wrong way.
|
|
1092
|
+
*/
|
|
1093
|
+
function callerTab(bots, takesCaller) {
|
|
1094
|
+
const tab = process.env[TAB_ENV];
|
|
1095
|
+
if (takesCaller && tab !== undefined) sessionInTab(bots, tab);
|
|
1096
|
+
return tab;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1012
1099
|
function refuseWhenOrcaIsDown() {
|
|
1013
1100
|
const trouble = orcaTrouble();
|
|
1014
1101
|
if (trouble !== undefined) throw new Error(trouble);
|
|
@@ -1258,6 +1345,7 @@ function sessionLines(session) {
|
|
|
1258
1345
|
...(book.tab === undefined
|
|
1259
1346
|
? []
|
|
1260
1347
|
: [` tab ${book.tab}${book.launched === undefined ? '' : ` launched ${book.launched}`}`]),
|
|
1348
|
+
...(book.temporary === undefined ? [] : [` temporary, made by ${book.temporary.maker}${book.temporary.made === undefined ? '' : ` at ${book.temporary.made}`}`]),
|
|
1261
1349
|
...(book.session === undefined ? [] : [` conversation ${book.session}`]),
|
|
1262
1350
|
...(book.history ?? [])
|
|
1263
1351
|
.filter((was) => was?.session !== undefined)
|
package/src/conversations.js
CHANGED
|
@@ -50,9 +50,16 @@ 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.
|
package/src/health.js
CHANGED
|
@@ -281,7 +281,11 @@ function runningOn(bots, home, bot, book, handles, sessions) {
|
|
|
281
281
|
// see may still match, by the harness's own defaults; the rest of what
|
|
282
282
|
// bot.yaml asks for is not on it either way.
|
|
283
283
|
const drift = off.length === 0 ? '' : ` ${parts}, as the harness's own record of its conversation ${conversation} says.`;
|
|
284
|
-
|
|
284
|
+
// A Codex brought back this way also runs its commands in Codex's shared
|
|
285
|
+
// background server, under whichever tab started it (#408).
|
|
286
|
+
const daemon = harness !== 'codex' ? '' : ` Without the kit's line it also runs without --no-daemon, so its commands run in Codex's shared background server, under the tab of whichever session started that server: another tab's identity, not its own. So the kit refuses its commands that take the caller from its tab, such as reading its mail.`;
|
|
287
|
+
const held = harness !== 'codex' ? '' : ` If Codex then shows "This conversation is open in another app", Codex's shared background server still holds the conversation, and it can outlive the session that started it: once every such session is back on the kit's line, stop the server with Codex's own \`codex app-server daemon stop\`, then restart this session again. That stops the server for everything using it, Codex sessions outside the kit included, so first check that nothing else needs it.`;
|
|
288
|
+
found.push(finding('session', entry.tab, `${bot.name}'s session ${session.name} was not started by the kit: the harness in its tab ${entry.tab} carries nothing of the kit's launch line, as when Orca brings its tabs back by itself after a restart or an update. So it runs on the harness's own defaults and whatever Orca added, not on what ${path.join(home, 'bot.yaml')} asks for.${drift}${daemon} ${restart} starts it on bot.yaml.${held}`, bot.name));
|
|
285
289
|
} else if (off.length > 0) {
|
|
286
290
|
found.push(finding('session', file, `${bot.name}'s session ${session.name} does not run on what ${path.join(home, 'bot.yaml')} asks for. ${parts}. That is what the harness's own record of its conversation ${conversation} says. A session takes these when it starts, so if bot.yaml changed after it started, ${restart} starts it on them; if not, something else set them, such as a default of the harness's own or a change made inside the session.`, bot.name));
|
|
287
291
|
}
|
package/src/init.js
CHANGED
|
@@ -86,8 +86,9 @@ charter: |
|
|
|
86
86
|
Good looks like: every bot has a charter its owner recognises, the skills it
|
|
87
87
|
needs and no more, and sessions that come back after a restart.
|
|
88
88
|
|
|
89
|
-
Ask first before: retiring a bot or a session, restarting
|
|
90
|
-
changing a bot's rules in a way its owner did not ask for.
|
|
89
|
+
Ask first before: retiring a bot or a long-lived session, restarting
|
|
90
|
+
anything, or changing a bot's rules in a way its owner did not ask for. A
|
|
91
|
+
temporary session is its maker's to retire, without asking.
|
|
91
92
|
|
|
92
93
|
When the kit itself goes wrong (an obk command fails in a way its own message
|
|
93
94
|
does not explain, or does something other than what it says), tell your owner
|
package/src/launch.js
CHANGED
|
@@ -51,7 +51,7 @@ export const harnessOf = (session, botHarness) => (set(session.harness) ? sessio
|
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* A session's name on Claude Code, which is also the address another Claude
|
|
54
|
-
* session writes to (ADR
|
|
54
|
+
* session writes to (ADR 0030). It goes on the launch line as `-n`, and it is
|
|
55
55
|
* re-applied every time the session is started: a resume keeps the name by
|
|
56
56
|
* itself (tech notes, section 2), and the kit does not depend on that.
|
|
57
57
|
*
|
|
@@ -224,7 +224,7 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
|
|
|
224
224
|
|
|
225
225
|
if (harness === 'claude') {
|
|
226
226
|
// The name is the address other Claude sessions write to, so it goes on
|
|
227
|
-
// every launch line, a resume included (ADR
|
|
227
|
+
// every launch line, a resume included (ADR 0030).
|
|
228
228
|
if (address !== undefined) words.push('-n', address);
|
|
229
229
|
// The context window rides on the model name: `sonnet[1m]`.
|
|
230
230
|
if (set(session.model)) words.push('--model', set(session.context) ? `${session.model}[${session.context}]` : session.model);
|
package/src/message.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `obk message`: one session writing to another (PRD 6.9, ADR
|
|
1
|
+
// `obk message`: one session writing to another (PRD 6.9, ADR 0030).
|
|
2
2
|
//
|
|
3
3
|
// Two roads, and the bot never picks. Claude to Claude in the same approval
|
|
4
4
|
// class is the harness's own messaging, which no command line can send for it —
|
|
@@ -20,7 +20,7 @@ import path from 'node:path';
|
|
|
20
20
|
|
|
21
21
|
import { MAILBOX_WAIT_MS, readBook, takeMailboxTurn } from './book.js';
|
|
22
22
|
import { botDir, botNames, readBot } from './bot.js';
|
|
23
|
-
import { harnessOf, isAddressOf, ownCli, reachesMail, shellWord } from './launch.js';
|
|
23
|
+
import { harnessOf, isAddressOf, ownCli, reachesMail, SHELL_ENV, shellWord } from './launch.js';
|
|
24
24
|
import { ackMailbox, coordinatorOf, postMessage, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, typeIntoTab, useMailbox } from './orca.js';
|
|
25
25
|
|
|
26
26
|
/**
|
|
@@ -360,17 +360,37 @@ function whoIsWriting(bots, sender, tab, asked = '--from <bot>/<session>: which
|
|
|
360
360
|
return found;
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
-
/**
|
|
364
|
-
|
|
363
|
+
/**
|
|
364
|
+
* The session the book says is in this Orca tab, wherever in the fleet it is,
|
|
365
|
+
* when the caller is that session.
|
|
366
|
+
*
|
|
367
|
+
* A Codex session is the caller only when the kit's launch line started the
|
|
368
|
+
* Codex it runs under: `shell` is the mark that line leaves, and everything the
|
|
369
|
+
* harness runs inherits it. A Codex that Orca brought back by itself has no
|
|
370
|
+
* mark and no `--no-daemon`, so it runs its commands in Codex's shared
|
|
371
|
+
* background server, whose every process carries the tab of whichever session
|
|
372
|
+
* started it (#408). Its tab then says nothing about who is asking. Inside
|
|
373
|
+
* Codex's sandbox `ps` does not run, so the environment is all there is to go
|
|
374
|
+
* on. A Claude session has no such server, and one Orca brought back is still
|
|
375
|
+
* the session in its tab.
|
|
376
|
+
*/
|
|
377
|
+
export function sessionInTab(bots, tab, shell = process.env[SHELL_ENV]) {
|
|
365
378
|
for (const name of botNames(bots)) {
|
|
366
379
|
const book = readBook(botDir(bots, name));
|
|
367
380
|
for (const [session, entry] of Object.entries(book.sessions)) {
|
|
368
|
-
if (entry?.tab
|
|
381
|
+
if (entry?.tab !== tab) continue;
|
|
382
|
+
const found = findSession(bots, `${name}/${session}`);
|
|
383
|
+
if (found.harness === 'codex' && (shell === undefined || shell === '')) throw new Error(notTheCodexInTab(found, tab));
|
|
384
|
+
return found;
|
|
369
385
|
}
|
|
370
386
|
}
|
|
371
387
|
return undefined;
|
|
372
388
|
}
|
|
373
389
|
|
|
390
|
+
/** Why a caller in a Codex session's tab is not taken to be that session, and what puts it right. */
|
|
391
|
+
const notTheCodexInTab = (found, tab) =>
|
|
392
|
+
`this command takes its caller from the Orca tab it runs in, ${tab}, which is ${found.bot}/${found.session}'s, a Codex session; but it did not come from a Codex the kit started there: nothing of the kit's launch line is in its environment. A Codex that Orca brought back by itself, after a restart or an update, runs its commands in Codex's shared background server, under the tab of whichever session started that server, so the kit cannot tell which session is asking. Nothing was done. \`${shellWord(ownCli())} restart --bots ${shellWord(found.bots)} --bot <bot> --session <name>\` starts each such session on the kit's line; \`${shellWord(ownCli())} health --bots ${shellWord(found.bots)}\` names them. If Codex then shows "This conversation is open in another app", Codex's shared background server still holds that conversation, and it can outlive the session that started it: once every such session is back on the kit's line, stop the server with Codex's own \`codex app-server daemon stop\`, then restart the stuck session again. That stops the server for everything using it, Codex sessions outside the kit included, so first check that nothing else needs it.`;
|
|
393
|
+
|
|
374
394
|
/**
|
|
375
395
|
* The message as it will travel: the text itself when it is short enough, and
|
|
376
396
|
* otherwise a file beside the bots folder with a body that names it.
|
|
@@ -442,11 +462,19 @@ function nudge(to, from, subject) {
|
|
|
442
462
|
if (found.unsure !== undefined) return { nudged: false, nudgeTrouble: found.unsure };
|
|
443
463
|
if (found.handle === undefined) return { nudged: false };
|
|
444
464
|
|
|
445
|
-
typeIntoTab(
|
|
465
|
+
const sent = typeIntoTab(
|
|
446
466
|
found.handle,
|
|
447
467
|
`Fleet mail from ${from.bot}/${from.session}: ${subject}. Read it with ${shellWord(ownCli())} message check --bots ${shellWord(to.bots)} --bot ${shellWord(to.bot)} --session ${shellWord(to.session)}`,
|
|
468
|
+
{ watch: true },
|
|
448
469
|
);
|
|
449
|
-
|
|
470
|
+
// Typed is not taken. A harness busy with a turn queues the line and gives
|
|
471
|
+
// it no turn of its own, and a line can be lost, and Orca's receipt looks
|
|
472
|
+
// the same for both: only a turn start it saw says the line landed (#394).
|
|
473
|
+
if (sent?.send?.prompt?.stages?.includes('turn_started')) return { nudged: true };
|
|
474
|
+
// Where Orca did not watch the line at all, as when its own notice has
|
|
475
|
+
// just started a turn in the tab, it saw nothing either way, and says so.
|
|
476
|
+
if (sent?.send?.prompt?.observation === 'unsupported') return { nudged: true, nudgeUnseen: unseen(sent), nudgeWatched: false };
|
|
477
|
+
return { nudged: true, nudgeUnseen: unseen(sent) };
|
|
450
478
|
} catch (error) {
|
|
451
479
|
// The message is already queued, and it is waiting whatever Orca says
|
|
452
480
|
// about the tab. So this is reported rather than thrown: a send that ends
|
|
@@ -457,6 +485,14 @@ function nudge(to, from, subject) {
|
|
|
457
485
|
}
|
|
458
486
|
}
|
|
459
487
|
|
|
488
|
+
/** Orca's words about a line it did not see start a turn, or what its receipt says when it gives none. */
|
|
489
|
+
function unseen(sent) {
|
|
490
|
+
const warnings = Array.isArray(sent?.warnings) ? sent.warnings.filter((warning) => typeof warning === 'string') : [];
|
|
491
|
+
if (warnings.length > 0) return warnings.join(' ');
|
|
492
|
+
const stages = sent?.send?.prompt?.stages;
|
|
493
|
+
return `Orca's receipt shows ${Array.isArray(stages) ? stages.join(', ') : 'no stages'} and no turn start`;
|
|
494
|
+
}
|
|
495
|
+
|
|
460
496
|
/**
|
|
461
497
|
* The look before the nudge. Orca now and then refuses a handle it has just
|
|
462
498
|
* listed as stale, and a fresh listing hands out one that works (tech notes,
|
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
|
|
|
@@ -579,20 +584,28 @@ export const closeTab = (handle) =>
|
|
|
579
584
|
* The wait is short because the caller is telling a session it has mail, not
|
|
580
585
|
* handing it work: mail that has to wait for the next check is a smaller cost
|
|
581
586
|
* than a command that hangs for half a minute.
|
|
587
|
+
*
|
|
588
|
+
* With `watch`, the line is sent with that wait from the start, and Orca's
|
|
589
|
+
* receipt says whether it saw the line start a turn (#394). It gives back
|
|
590
|
+
* Orca's `result`: the receipt is in its `send.prompt`, and Orca's words about
|
|
591
|
+
* a turn it did not see in its `warnings`.
|
|
582
592
|
*/
|
|
583
|
-
export function typeIntoTab(handle, text) {
|
|
593
|
+
export function typeIntoTab(handle, text, { watch = false } = {}) {
|
|
584
594
|
const args = ['terminal', 'send', '--terminal', handle, '--text', text, '--enter'];
|
|
585
|
-
const answer = ask(args);
|
|
586
|
-
if (answer.ok === true) return answer.result
|
|
595
|
+
const answer = ask(watch ? [...args, '--wait-submit', String(SUBMIT_WAIT_S)] : args);
|
|
596
|
+
if (answer.ok === true) return answer.result;
|
|
587
597
|
|
|
588
598
|
const again = answer.error?.data?.orchestrationRequestId;
|
|
589
599
|
if (answer.error?.code !== 'agent_prompt_blocked' || typeof again !== 'string') {
|
|
590
600
|
throw new Error(`Orca refused ${args.join(' ')}: ${answer.error?.message ?? 'no reason given'}`);
|
|
591
601
|
}
|
|
592
|
-
return orca([...args, '--retry-request', again, '--wait-submit', String(SUBMIT_WAIT_S)])
|
|
602
|
+
return orca([...args, '--retry-request', again, '--wait-submit', String(SUBMIT_WAIT_S)]);
|
|
593
603
|
}
|
|
594
604
|
|
|
595
|
-
/**
|
|
605
|
+
/**
|
|
606
|
+
* How long a watched or re-issued line is given to be submitted before Orca
|
|
607
|
+
* gives up on it. An idle harness started its turn in about 2 s, live (#394).
|
|
608
|
+
*/
|
|
596
609
|
const SUBMIT_WAIT_S = 5;
|
|
597
610
|
|
|
598
611
|
/** Where Orca names the terminal a process runs in, in every pane it opens. */
|
package/src/permissions.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// `Bash(<words>:*)` becomes a `prefix_rule` of those words in
|
|
20
20
|
// `.codex/rules/obk.rules`, a file the kit owns whole and rewrites from `allow`.
|
|
21
21
|
|
|
22
|
-
import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
|
|
25
25
|
import { leadsOutside } from './bot.js';
|
|
@@ -267,10 +267,10 @@ export const allowCommand = (bots, bot, rules) => [
|
|
|
267
267
|
* the kit may not write is an entry with its `trouble` rather than a throw.
|
|
268
268
|
*/
|
|
269
269
|
export function writePermissions(bots, home, bot, { keepGoing = false } = {}) {
|
|
270
|
-
const writers = [[runsOnClaude, FILE,
|
|
271
|
-
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]) => {
|
|
272
272
|
try {
|
|
273
|
-
return
|
|
273
|
+
return plan(bots, home, bot).write();
|
|
274
274
|
} catch (error) {
|
|
275
275
|
if (!keepGoing) throw error;
|
|
276
276
|
return { bot: bot.name, file: path.join(home, file), written: [], waiting: [], trouble: error.message };
|
|
@@ -279,30 +279,55 @@ export function writePermissions(bots, home, bot, { keepGoing = false } = {}) {
|
|
|
279
279
|
}
|
|
280
280
|
|
|
281
281
|
/**
|
|
282
|
-
*
|
|
283
|
-
*
|
|
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.
|
|
284
302
|
*
|
|
285
303
|
* Only rules `allow` holds are written. Everything else in the file stays as it
|
|
286
304
|
* is, the user's own entries and their order included; a rule missing from the
|
|
287
305
|
* file goes after them, and a run with nothing to add writes nothing.
|
|
288
306
|
*/
|
|
289
|
-
function
|
|
307
|
+
function planClaude(bots, home, bot) {
|
|
290
308
|
const file = path.join(home, FILE);
|
|
291
309
|
const allowed = allowOf(home, bot);
|
|
292
310
|
const waiting = waitingFor(bots, home, bot, 'claude');
|
|
293
311
|
const answer = { bot: bot.name, file, written: [], waiting };
|
|
294
|
-
|
|
312
|
+
const none = { answer, writes: false, write: () => answer };
|
|
313
|
+
if (allowed.length === 0) return none;
|
|
295
314
|
|
|
296
315
|
refuseOutside(home, file);
|
|
297
316
|
const settings = readSettings(file, 'the permission rules the user allowed');
|
|
298
317
|
const present = presentIn(settings, file);
|
|
299
318
|
const missing = [...new Set(allowed)].filter((rule) => !present.includes(rule));
|
|
300
|
-
if (missing.length === 0) return
|
|
319
|
+
if (missing.length === 0) return none;
|
|
301
320
|
|
|
302
321
|
const wanted = { ...settings, permissions: { ...settings.permissions, allow: [...present, ...missing] } };
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
+
};
|
|
306
331
|
}
|
|
307
332
|
|
|
308
333
|
/**
|
|
@@ -358,7 +383,7 @@ export function takeBack(bots, home, bot, rules) {
|
|
|
358
383
|
entries.push({ bot: bot.name, file, written: [], removed, waiting: waitingFor(bots, home, after, 'claude') });
|
|
359
384
|
}
|
|
360
385
|
if (runsOnCodex(after)) {
|
|
361
|
-
const entry =
|
|
386
|
+
const entry = planCodex(bots, home, after).write();
|
|
362
387
|
// A line goes only when no rule still allowed gives it.
|
|
363
388
|
const kept = [...codexOf(home, after).lines.keys()];
|
|
364
389
|
const removed = taken.filter((rule) => {
|
|
@@ -371,12 +396,20 @@ export function takeBack(bots, home, bot, rules) {
|
|
|
371
396
|
};
|
|
372
397
|
}
|
|
373
398
|
|
|
374
|
-
/**
|
|
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
|
+
*/
|
|
375
404
|
function refuseUnwritable(file) {
|
|
405
|
+
let where = file;
|
|
406
|
+
while (!existsSync(where) && path.dirname(where) !== where) where = path.dirname(where);
|
|
376
407
|
try {
|
|
377
|
-
|
|
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);
|
|
378
410
|
} catch (error) {
|
|
379
|
-
|
|
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.`);
|
|
380
413
|
}
|
|
381
414
|
}
|
|
382
415
|
|
|
@@ -417,16 +450,18 @@ function codexOf(home, bot) {
|
|
|
417
450
|
const ruleLines = (text) => text.split('\n').map((line) => line.trim()).filter((line) => line !== '' && !line.startsWith('#'));
|
|
418
451
|
|
|
419
452
|
/**
|
|
420
|
-
*
|
|
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
|
|
421
455
|
* `{ bot, file, written, waiting, unwritten }`, with `written` every rule in
|
|
422
|
-
* the file when
|
|
423
|
-
*
|
|
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.
|
|
424
458
|
*/
|
|
425
|
-
function
|
|
459
|
+
function planCodex(bots, home, bot) {
|
|
426
460
|
const file = path.join(home, CODEX_FILE);
|
|
427
461
|
const { lines, unwritten } = codexOf(home, bot);
|
|
428
462
|
const answer = { bot: bot.name, file, written: [], waiting: waitingFor(bots, home, bot, 'codex'), unwritten };
|
|
429
|
-
|
|
463
|
+
const none = { answer, writes: false, write: () => answer };
|
|
464
|
+
if (lines.size === 0 && !existsSync(file)) return none;
|
|
430
465
|
|
|
431
466
|
refuseOutside(home, file);
|
|
432
467
|
const text = [
|
|
@@ -434,10 +469,16 @@ function writeCodex(bots, home, bot) {
|
|
|
434
469
|
'# obk rewrites this whole file: put rules of your own in another file in this folder.',
|
|
435
470
|
...lines.keys(),
|
|
436
471
|
].join('\n');
|
|
437
|
-
if (existsSync(file) && readFileSync(file, 'utf8') === `${text}\n`) return
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
+
};
|
|
441
482
|
}
|
|
442
483
|
|
|
443
484
|
/**
|
package/src/retire.js
CHANGED
|
@@ -9,12 +9,15 @@
|
|
|
9
9
|
// What goes is what the kit made for it: its tabs, closed one at a time by
|
|
10
10
|
// their own handles; its Orca project, after the tabs and never before; its
|
|
11
11
|
// start-prompt files beside the bots folder; and, for a bot, the skill links
|
|
12
|
-
// the kit made in its folder. A
|
|
12
|
+
// the kit made in its folder. A prompt file that cannot be removed, as under a
|
|
13
|
+
// harness's sandbox that may not write beside the bots folder, does not stop a
|
|
14
|
+
// retire halfway: nothing reads it once its session is gone, so the retire
|
|
15
|
+
// finishes and names it, with how to remove it (#393). A tab the book does not name is the user's, so a
|
|
13
16
|
// bot whose project holds one is not retired until they have dealt with it.
|
|
14
17
|
//
|
|
15
18
|
// Closing a tab here ends the conversation in it on purpose, so unlike a
|
|
16
19
|
// restart or a pause it does not wait for the book to know which one it was.
|
|
17
|
-
// The mailbox Runs stay: Orca has no way to remove one (ADR
|
|
20
|
+
// The mailbox Runs stay: Orca has no way to remove one (ADR 0030).
|
|
18
21
|
|
|
19
22
|
import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
20
23
|
import path from 'node:path';
|
|
@@ -31,7 +34,8 @@ import { promptPath, sessionsOf } from './up.js';
|
|
|
31
34
|
export const retiredDir = (bots) => path.join(bots, 'retired');
|
|
32
35
|
|
|
33
36
|
/**
|
|
34
|
-
* Retire the session `session` of the bot `bot`. Returns `{ bot, session,
|
|
37
|
+
* Retire the session `session` of the bot `bot`. Returns `{ bot, session,
|
|
38
|
+
* closed }`, and `promptsLeft` when its prompt file could not be removed.
|
|
35
39
|
*/
|
|
36
40
|
export async function retireSession(bots, { bot, session }) {
|
|
37
41
|
const home = fleetMember(bots, bot, 'retire', session);
|
|
@@ -47,15 +51,16 @@ export async function retireSession(bots, { bot, session }) {
|
|
|
47
51
|
delete book.sessions[session];
|
|
48
52
|
book.retired = [...(Array.isArray(book.retired) ? book.retired : []), { name: session, ...entry, retired: at }];
|
|
49
53
|
});
|
|
50
|
-
|
|
54
|
+
const left = removePrompts([promptPath(bots, bot, session)]);
|
|
51
55
|
|
|
52
|
-
return { bot, session, closed };
|
|
56
|
+
return { bot, session, closed, ...left };
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
/**
|
|
56
60
|
* Retire the bot `bot`. Returns `{ bot, closed, project, windowReloaded, moved }`:
|
|
57
61
|
* the tabs it closed, the Orca project it took away (if it had one), whether
|
|
58
|
-
* Orca's window was reloaded after that, and where the bot is now
|
|
62
|
+
* Orca's window was reloaded after that, and where the bot is now; and
|
|
63
|
+
* `promptsLeft` when a prompt file could not be removed.
|
|
59
64
|
* When Orca does not confirm the project gone, it returns `{ bot, closed,
|
|
60
65
|
* project, trouble }` instead, and the bot is left where it was.
|
|
61
66
|
*/
|
|
@@ -103,12 +108,28 @@ export async function retireBot(bots, { bot }) {
|
|
|
103
108
|
windowReloaded = reloadWindow();
|
|
104
109
|
}
|
|
105
110
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
111
|
+
const names = new Set([...known.sessions, ...booked].map((session) => session.name));
|
|
112
|
+
const left = removePrompts([...names].map((name) => promptPath(bots, bot, name)));
|
|
109
113
|
unlinkSkills(home);
|
|
110
114
|
mkdirSync(retiredDir(bots), { recursive: true });
|
|
111
115
|
renameSync(botDir(bots, bot), moved);
|
|
112
116
|
|
|
113
|
-
return { bot, closed, ...(project === undefined ? {} : { project: project.id, windowReloaded }), moved };
|
|
117
|
+
return { bot, closed, ...(project === undefined ? {} : { project: project.id, windowReloaded }), moved, ...left };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Remove start-prompt files, and say which could not be: `{ promptsLeft: [{
|
|
122
|
+
* file, reason }] }`, or nothing when every one went. One that is not there is
|
|
123
|
+
* nothing to remove.
|
|
124
|
+
*/
|
|
125
|
+
function removePrompts(files) {
|
|
126
|
+
const promptsLeft = files.flatMap((file) => {
|
|
127
|
+
try {
|
|
128
|
+
rmSync(file, { force: true });
|
|
129
|
+
return [];
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return [{ file, reason: error.message }];
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
return promptsLeft.length === 0 ? {} : { promptsLeft };
|
|
114
135
|
}
|
package/src/temp.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// `obk temp make` and `obk temp retire`: a session's own temporary sessions
|
|
2
|
+
// (PRD 6.4, #227).
|
|
3
|
+
//
|
|
4
|
+
// Any long-lived session can make a temporary session of its own bot for a
|
|
5
|
+
// piece of work, and retire it when the work is done, without Bot Father, who
|
|
6
|
+
// stays the manager of long-lived sessions. A temporary session is an ordinary
|
|
7
|
+
// session in every other way: in bot.yaml, brought up with a tab and a mailbox,
|
|
8
|
+
// its conversations kept in the book. What makes it temporary is one field of
|
|
9
|
+
// its book entry, `temporary: { maker, made }`, and the book is the one place
|
|
10
|
+
// that says so (ADR 0012).
|
|
11
|
+
//
|
|
12
|
+
// The caller is the session whose tab this runs in, as the book records it: the
|
|
13
|
+
// tab says who is calling now. The maker is kept by its session name, not its
|
|
14
|
+
// tab, so it still owns what it made after a restart gives it a new tab.
|
|
15
|
+
//
|
|
16
|
+
// Everything that can refuse is checked before anything is written, so a
|
|
17
|
+
// refusal leaves bot.yaml, the book and Orca as they were. The one write that
|
|
18
|
+
// may fall outside what a harness's sandbox lets a session write, its start
|
|
19
|
+
// prompt's file beside the bots folder, is made first, the way #383 puts the
|
|
20
|
+
// risky write before bot.yaml: refused there, nothing else has changed, and the
|
|
21
|
+
// same command run again with that access goes through.
|
|
22
|
+
|
|
23
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
|
|
26
|
+
import { readBook, updateBook } from './book.js';
|
|
27
|
+
import { addSession, dropSession, NAME, readBot } from './bot.js';
|
|
28
|
+
import { isShortPrompt, ownCli, shellWord, startPrompt, workDirOf } from './launch.js';
|
|
29
|
+
import { sessionInTab } from './message.js';
|
|
30
|
+
import { retireSession } from './retire.js';
|
|
31
|
+
import { bringUp, promptPath } from './up.js';
|
|
32
|
+
|
|
33
|
+
/** The settings a temporary session takes from its maker unless told otherwise. */
|
|
34
|
+
const INHERITED = ['model', 'effort', 'context', 'approval'];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Make a temporary session of the caller's own bot and bring it up. `given`
|
|
38
|
+
* holds what the caller said: `name`, `prompt` or `prompt_file`, and any of
|
|
39
|
+
* `harness` and the inherited settings. Returns `{ bot, session, maker,
|
|
40
|
+
* settings, up }`, where `up` is what bringing it up answered.
|
|
41
|
+
*/
|
|
42
|
+
export async function makeTemp(bots, { tab, ...given }) {
|
|
43
|
+
const caller = callerIn(bots, tab, 'make');
|
|
44
|
+
if (caller.temporary !== undefined) {
|
|
45
|
+
throw new Error(`${caller.bot}/${caller.session} is a temporary session, and only a long-lived session makes one. Nothing was made.`);
|
|
46
|
+
}
|
|
47
|
+
// The name is a folder under work/ and a file beside the bots folder, so it
|
|
48
|
+
// is held to the rule a bot's name is: one plain name, never a path.
|
|
49
|
+
if (!NAME.test(given.name)) {
|
|
50
|
+
throw new Error(`${given.name} cannot be a session's name: a name is lower-case letters, digits and single hyphens, such as review-250. Nothing was made.`);
|
|
51
|
+
}
|
|
52
|
+
if (given.prompt === undefined && given.prompt_file === undefined) {
|
|
53
|
+
throw new Error(`temp make needs the task: --prompt <text> or --prompt-file <path>. Nothing was made.`);
|
|
54
|
+
}
|
|
55
|
+
if (given.prompt !== undefined && given.prompt_file !== undefined) {
|
|
56
|
+
throw new Error(`temp make was given the task twice, as --prompt and as --prompt-file, and a session is told its duty once. Give it one way. Nothing was made.`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const bot = readBot(caller.home, caller.bot);
|
|
60
|
+
const maker = bot.sessions.find((session) => session.name === caller.session);
|
|
61
|
+
const settings = { name: given.name };
|
|
62
|
+
const harness = given.harness ?? maker?.harness;
|
|
63
|
+
if (harness !== undefined) settings.harness = harness;
|
|
64
|
+
for (const field of INHERITED) {
|
|
65
|
+
const value = given[field] ?? maker?.[field];
|
|
66
|
+
if (value !== undefined) settings[field] = value;
|
|
67
|
+
}
|
|
68
|
+
if (given.prompt !== undefined) settings.prompt = given.prompt;
|
|
69
|
+
else settings.prompt_file = given.prompt_file;
|
|
70
|
+
settings.work_dir = `work/${given.name}`;
|
|
71
|
+
|
|
72
|
+
const written = bot.sessions.some((session) => session.name === given.name) ? undefined : writePromptFirst(bots, caller, settings);
|
|
73
|
+
// Refuses a name the bot already has, and a setting that will not work,
|
|
74
|
+
// before it writes anything; the prompt written first goes with a refusal.
|
|
75
|
+
let added;
|
|
76
|
+
try {
|
|
77
|
+
added = addSession(bots, caller.bot, settings);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (written !== undefined) rmSync(written, { force: true });
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
const made = new Date().toISOString();
|
|
83
|
+
try {
|
|
84
|
+
await updateBook(caller.home, (book) => {
|
|
85
|
+
book.sessions[given.name] = { ...book.sessions[given.name], temporary: { maker: caller.session, made } };
|
|
86
|
+
});
|
|
87
|
+
} catch (error) {
|
|
88
|
+
// A session in bot.yaml that the book does not call temporary would be a
|
|
89
|
+
// long-lived one to everything that reads it, its maker's retire included,
|
|
90
|
+
// so it is taken back off rather than left that way.
|
|
91
|
+
const failed = `${caller.bot}/${given.name} could not be recorded in the book as temporary (${error.message})`;
|
|
92
|
+
try {
|
|
93
|
+
dropSession(bots, caller.bot, given.name);
|
|
94
|
+
} catch (undo) {
|
|
95
|
+
throw new Error(`${failed}, and taking it back off bot.yaml failed too (${undo.message}). It is in bot.yaml as a session the book does not call temporary. Take it off with ${shellWord(ownCli())} retire --bots ${shellWord(bots)} --bot ${caller.bot} --session ${given.name}`);
|
|
96
|
+
}
|
|
97
|
+
// Off bot.yaml, nothing is made; a prompt file that stays is only a file,
|
|
98
|
+
// and nothing reads it (#393).
|
|
99
|
+
try {
|
|
100
|
+
if (written !== undefined) rmSync(written, { force: true });
|
|
101
|
+
} catch (left) {
|
|
102
|
+
throw new Error(`${failed}, so it was taken back off bot.yaml and nothing was made. Its start prompt ${written} could not be removed (${left.message}), and nothing reads it. Remove it with rm ${shellWord(written)}`);
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`${failed}, so it was taken back off bot.yaml and nothing was made. Run this again once the book can be written.`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let up;
|
|
108
|
+
try {
|
|
109
|
+
up = await bringUp(bots, { bot: caller.bot, session: given.name });
|
|
110
|
+
} catch (error) {
|
|
111
|
+
const cli = shellWord(ownCli());
|
|
112
|
+
throw new Error(`${caller.bot}/${given.name} is made, in bot.yaml and in the book as temporary with its maker ${caller.session}, and could not be brought up: ${error.message} Bring it up with ${cli} up --bots ${shellWord(bots)} --bot ${caller.bot} --session ${given.name} or retire it with ${cli} temp retire --bots ${shellWord(bots)} --name ${given.name}`);
|
|
113
|
+
}
|
|
114
|
+
return { bot: caller.bot, session: given.name, maker: caller.session, settings: added.session, up };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Write the start prompt's file where bringing the session up will write it,
|
|
119
|
+
* when its prompt goes to the harness out of a file at all, and say where. A
|
|
120
|
+
* prompt that cannot be read yet is left to `addSession`, which refuses it in
|
|
121
|
+
* its own words.
|
|
122
|
+
*/
|
|
123
|
+
function writePromptFirst(bots, caller, settings) {
|
|
124
|
+
let prompt;
|
|
125
|
+
try {
|
|
126
|
+
prompt = startPrompt(settings, { home: caller.home, workDir: workDirOf(settings, caller.home) });
|
|
127
|
+
} catch {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
if (isShortPrompt(prompt)) return undefined;
|
|
131
|
+
|
|
132
|
+
const file = promptPath(bots, caller.bot, settings.name);
|
|
133
|
+
try {
|
|
134
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
135
|
+
writeFileSync(file, prompt);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw new Error(`${caller.bot}/${settings.name}'s start prompt could not be written to ${file} (${error.message}), and it is written first, so nothing was made. Run this again where that folder can be written: from a harness's sandbox, with the access to write it.`);
|
|
138
|
+
}
|
|
139
|
+
return file;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Retire a temporary session the caller made. Returns what `retireSession`
|
|
144
|
+
* does, with the maker.
|
|
145
|
+
*/
|
|
146
|
+
export async function retireTemp(bots, { tab, name }) {
|
|
147
|
+
const caller = callerIn(bots, tab, 'retire');
|
|
148
|
+
const bot = readBot(caller.home, caller.bot);
|
|
149
|
+
if (!bot.sessions.some((session) => session.name === name)) {
|
|
150
|
+
throw new Error(`${caller.bot} has no session called ${name}, so there is nothing of yours to retire. Nothing was retired.`);
|
|
151
|
+
}
|
|
152
|
+
const temporary = readBook(caller.home).sessions[name]?.temporary;
|
|
153
|
+
if (temporary === undefined) {
|
|
154
|
+
throw new Error(`${caller.bot}/${name} is a long-lived session, and those are Bot Father's to retire. Nothing was retired.`);
|
|
155
|
+
}
|
|
156
|
+
if (temporary.maker !== caller.session) {
|
|
157
|
+
throw new Error(`${caller.bot}/${name} is a temporary session ${temporary.maker} made, not ${caller.session}, so it is ${temporary.maker}'s to retire. Nothing was retired.`);
|
|
158
|
+
}
|
|
159
|
+
return { ...(await retireSession(bots, { bot: caller.bot, session: name })), maker: caller.session };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The session this runs in, as the book knows it, with its home and whether it
|
|
164
|
+
* is itself temporary.
|
|
165
|
+
*/
|
|
166
|
+
function callerIn(bots, tab, what) {
|
|
167
|
+
const found = tab === undefined ? undefined : sessionInTab(bots, tab);
|
|
168
|
+
if (found === undefined) {
|
|
169
|
+
throw new Error(`temp ${what} is run by a session in its own tab, and this is not one of the fleet's tabs, so there is no session here to ${what} it for. Nothing was done.`);
|
|
170
|
+
}
|
|
171
|
+
const entry = readBook(found.home).sessions[found.session] ?? {};
|
|
172
|
+
return { bot: found.bot, session: found.session, home: found.home, temporary: entry.temporary };
|
|
173
|
+
}
|
package/src/usage.js
CHANGED
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
// `last_token_usage` counts a call written down twice twice. So a call is
|
|
26
26
|
// the difference in the running total from the event before: none is a
|
|
27
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).
|
|
28
31
|
// 2. The two harnesses do not mean the same thing by `input_tokens`. Claude
|
|
29
32
|
// Code leaves the cache reads out of it; Codex counts them inside it. So
|
|
30
33
|
// the Codex figure has its cached tokens taken back out, and `input` means
|
|
@@ -40,7 +43,7 @@ import { readFileSync, realpathSync } from 'node:fs';
|
|
|
40
43
|
|
|
41
44
|
import { botDir, botNames, readBot } from './bot.js';
|
|
42
45
|
import { readBook, sessionIdsIn } from './book.js';
|
|
43
|
-
import { claudeSubagentTranscripts, transcriptsIn } from './conversations.js';
|
|
46
|
+
import { claudeSubagentTranscripts, codexRollout, transcriptsIn } from './conversations.js';
|
|
44
47
|
import { HARNESSES, harnessOf } from './launch.js';
|
|
45
48
|
|
|
46
49
|
/** The kinds a call's tokens are reported in, the same on either harness. */
|
|
@@ -193,7 +196,10 @@ function counted(one, window) {
|
|
|
193
196
|
return { entries, subagent: index > 0 };
|
|
194
197
|
});
|
|
195
198
|
if (one.harness === 'claude') fromClaude(sources, window, tally);
|
|
196
|
-
else
|
|
199
|
+
else {
|
|
200
|
+
const copied = originOf(sources[0].entries, tally);
|
|
201
|
+
if (copied !== null) fromCodex(sources[0].entries, window, tally, copied);
|
|
202
|
+
}
|
|
197
203
|
if (tally.calls === 0 && KINDS.every((kind) => tally.tokens[kind] === 0)) return { gaps: tally.gaps };
|
|
198
204
|
|
|
199
205
|
return { gaps: tally.gaps, row: {
|
|
@@ -351,13 +357,22 @@ const CODEX_FIELDS = [
|
|
|
351
357
|
* counted either, and is said to be; the running total follows on from it. A
|
|
352
358
|
* call whose figure is needed and has something missing is not counted, and is
|
|
353
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.
|
|
354
367
|
*/
|
|
355
|
-
function fromCodex(entries, window, tally) {
|
|
368
|
+
function fromCodex(entries, window, tally, copied) {
|
|
356
369
|
let model;
|
|
357
370
|
let effort;
|
|
358
371
|
let running;
|
|
359
372
|
// Whether a running total with something missing came since the last complete one.
|
|
360
373
|
let broken = false;
|
|
374
|
+
// Whether the records so far are all the origin's, copied into a fork.
|
|
375
|
+
let copying = copied !== undefined;
|
|
361
376
|
|
|
362
377
|
for (const entry of entries) {
|
|
363
378
|
const when = Date.parse(entry.timestamp ?? '');
|
|
@@ -381,10 +396,26 @@ function fromCodex(entries, window, tally) {
|
|
|
381
396
|
if (info === undefined || info === null) continue;
|
|
382
397
|
|
|
383
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
|
+
|
|
384
411
|
let used;
|
|
385
412
|
// No running total at all is a running total with every figure missing:
|
|
386
413
|
// the next one's rise would take this call in again (#302).
|
|
387
|
-
if (
|
|
414
|
+
if (unknown && complete(total, CODEX_READ)) {
|
|
415
|
+
used = null;
|
|
416
|
+
running = total;
|
|
417
|
+
broken = false;
|
|
418
|
+
} else if (!complete(total, CODEX_READ)) {
|
|
388
419
|
used = null;
|
|
389
420
|
broken = true;
|
|
390
421
|
} else {
|
|
@@ -412,13 +443,56 @@ function fromCodex(entries, window, tally) {
|
|
|
412
443
|
/** The fields Codex itself writes; it has no cache writes to report. */
|
|
413
444
|
const CODEX_READ = CODEX_FIELDS.filter((field) => field !== 'cache_write_input_tokens').concat('total_tokens');
|
|
414
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
|
+
|
|
415
481
|
/**
|
|
416
482
|
* What one Codex event says its call used, measured against the running total
|
|
417
483
|
* before it: `undefined` where the event is the one before written down again,
|
|
418
|
-
* 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).
|
|
419
489
|
*/
|
|
420
490
|
function spent(running, total, info) {
|
|
421
|
-
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
|
+
}
|
|
422
496
|
|
|
423
497
|
const moved = total.total_tokens - running.total_tokens;
|
|
424
498
|
if (moved === 0) return undefined;
|