@assuredloop/orca-bot-kit 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/package.json +1 -1
- package/skills/obk-bot-building/SKILL.md +4 -1
- package/src/book.js +3 -0
- package/src/cli.js +52 -8
- package/src/conversations.js +58 -4
- package/src/health.js +124 -23
- package/src/orca.js +15 -0
- package/src/record.js +8 -1
- package/src/rules.js +15 -0
- package/src/settings.js +158 -0
- package/src/up.js +47 -11
- package/src/usage.js +147 -52
package/README.md
CHANGED
|
@@ -443,10 +443,23 @@ What it looks for:
|
|
|
443
443
|
go, and a link with nothing at the end of it;
|
|
444
444
|
- **a session the book knows that Orca does not**: its tab was closed, or the
|
|
445
445
|
machine was restarted, and `obk up` brings it back;
|
|
446
|
+
- **a session whose tab is open and whose harness is gone**: only the tab's
|
|
447
|
+
shell is in front, because the harness quit or crashed. `obk up` leaves an
|
|
448
|
+
open tab alone, so the finding names the `obk restart … --session` that
|
|
449
|
+
brings it back; when the book names no conversation for it, restart would
|
|
450
|
+
refuse, and the finding says to close the tab in Orca and run
|
|
451
|
+
`obk up … --session`. A tab the kit cannot read is never called down;
|
|
446
452
|
- **leftovers no book owns**: an Orca project inside your bots folder with no
|
|
447
453
|
bot in it, a tab in a bot's project that the book does not name, a start
|
|
448
454
|
prompt written for a session that has gone, a clone of a skills source you no
|
|
449
455
|
longer list, and the conversations the kit found and would not assign;
|
|
456
|
+
- **a session running on something other than its bot asks for**: a model,
|
|
457
|
+
effort, context or approval level that the harness's own record of the
|
|
458
|
+
conversation says differs from `bot.yaml`, and a session that started before
|
|
459
|
+
its bot's `AGENTS.md` last changed, with what brings it up to date. Beside the
|
|
460
|
+
findings, each running session gets a line saying, for each setting, whether
|
|
461
|
+
it matches, differs, was not asked for, or cannot be told from the record;
|
|
462
|
+
cannot be told is never taken for a match;
|
|
450
463
|
- **Orca's own default launch arguments**. Orca adds these to the agents it
|
|
451
464
|
launches, relaunches and resumes, so when they carry a permission bypass every
|
|
452
465
|
session runs in that mode whatever the kit asked for. The kit never sets that
|
package/package.json
CHANGED
|
@@ -302,7 +302,10 @@ it started, so a change to a bot's `AGENTS.md` may not reach one that is
|
|
|
302
302
|
already up even when a skill change would, and a session's model, effort or
|
|
303
303
|
approval only changes when it is started again (`obk restart`, on the user's
|
|
304
304
|
word, and then read its tabs as above). Say which you changed, and what that
|
|
305
|
-
means for the sessions running now.
|
|
305
|
+
means for the sessions running now. `obk health` names each running session
|
|
306
|
+
still on the older rules or on other settings, and what brings it up to date: on
|
|
307
|
+
Claude Code a `/clear` reads the rules again, and a start does on both harnesses.
|
|
308
|
+
Where everything needs restarting, remind
|
|
306
309
|
them to do it from Bot Father's ops tab.
|
|
307
310
|
|
|
308
311
|
And tell them. A bot's sessions share its rules and its skills, so a change
|
package/src/book.js
CHANGED
|
@@ -25,6 +25,9 @@ const HEADER = `# What Orca calls this bot on this machine, and where each of it
|
|
|
25
25
|
# and kept, because an address that is a tab dies with the tab. \`address\` is what
|
|
26
26
|
# a Claude session is called, which is what another Claude session writes to.
|
|
27
27
|
#
|
|
28
|
+
# \`rules\` is a stamp of the bot's AGENTS.md as the session last read it, so a
|
|
29
|
+
# change to it after the session started can be told apart.
|
|
30
|
+
#
|
|
28
31
|
# \`retired\` holds what the book knew about each session \`obk retire\` took off
|
|
29
32
|
# the bot, with when, so the conversations it had are still accounted for.
|
|
30
33
|
`;
|
package/src/cli.js
CHANGED
|
@@ -451,11 +451,12 @@ const commands = {
|
|
|
451
451
|
|
|
452
452
|
health(bots, values) {
|
|
453
453
|
refuseWhenOrcaIsDown();
|
|
454
|
-
const found = checkHealth(bots, { bot: values.bot });
|
|
454
|
+
const { found, sessions } = checkHealth(bots, { bot: values.bot });
|
|
455
455
|
return {
|
|
456
|
-
answer: { bots, found },
|
|
456
|
+
answer: { bots, found, sessions },
|
|
457
457
|
lines: [
|
|
458
458
|
...foundLines(found),
|
|
459
|
+
...sessions.map(settingsLine),
|
|
459
460
|
found.length === 0
|
|
460
461
|
? `Nothing to report: everything the kit keeps is where it should be. Your bots folder: ${bots}`
|
|
461
462
|
: `${found.length} thing${found.length === 1 ? '' : 's'} to look at above. What to do about each is yours to decide. Your bots folder: ${bots}`,
|
|
@@ -956,12 +957,15 @@ function usageLines(usage, bots) {
|
|
|
956
957
|
lines.push(`${'bot'.padEnd(9)} ${entry.bot}`);
|
|
957
958
|
for (const session of entry.sessions) {
|
|
958
959
|
lines.push(`${'session'.padEnd(9)} ${session.name}`);
|
|
960
|
+
const notCounted = notCountedLine(session.not_counted);
|
|
959
961
|
lines.push(...session.conversations.map(conversationLine));
|
|
960
|
-
if (session.conversations.length === 0) lines.push(' nothing on record');
|
|
962
|
+
if (session.conversations.length === 0 && notCounted.length === 0) lines.push(' nothing on record');
|
|
963
|
+
lines.push(...notCounted);
|
|
961
964
|
}
|
|
962
|
-
if (entry.unclaimed.length > 0) {
|
|
965
|
+
if (entry.unclaimed.length > 0 || notCountedLine(entry.unclaimed_not_counted).length > 0) {
|
|
963
966
|
lines.push(`${'unclaimed'.padEnd(9)} ${entry.bot}: no session of this bot claims these`);
|
|
964
967
|
lines.push(...entry.unclaimed.map(conversationLine));
|
|
968
|
+
lines.push(...notCountedLine(entry.unclaimed_not_counted));
|
|
965
969
|
}
|
|
966
970
|
}
|
|
967
971
|
|
|
@@ -971,6 +975,19 @@ function usageLines(usage, bots) {
|
|
|
971
975
|
return lines;
|
|
972
976
|
}
|
|
973
977
|
|
|
978
|
+
/** What could not be counted, as one line, or no line when everything was. */
|
|
979
|
+
function notCountedLine(gaps) {
|
|
980
|
+
const said = [
|
|
981
|
+
[gaps.unreadable_transcripts, 'transcript', 'transcripts', 'unreadable'],
|
|
982
|
+
[gaps.broken_lines, 'line', 'lines', 'broken'],
|
|
983
|
+
[gaps.records_without_numbers, 'record', 'records', 'with no usable number'],
|
|
984
|
+
[gaps.records_without_time, 'record', 'records', 'with no time'],
|
|
985
|
+
]
|
|
986
|
+
.filter(([count]) => count > 0)
|
|
987
|
+
.map(([count, one, many, why]) => `${count} ${count === 1 ? one : many} ${why}`);
|
|
988
|
+
return said.length === 0 ? [] : [` not counted: ${said.join(', ')}`];
|
|
989
|
+
}
|
|
990
|
+
|
|
974
991
|
/** One conversation: what it is, what it ran as, and what it used. */
|
|
975
992
|
function conversationLine(one) {
|
|
976
993
|
const ran = [...one.models, ...one.efforts].join(' ');
|
|
@@ -1128,6 +1145,25 @@ function toldLine(bot, { session, harness, state, read, blocked, trouble }) {
|
|
|
1128
1145
|
const foundLines = (found) =>
|
|
1129
1146
|
found.flatMap((one) => [`${one.kind.padEnd(9)} ${one.where}`, ` ${one.says}`]);
|
|
1130
1147
|
|
|
1148
|
+
/**
|
|
1149
|
+
* What one running session runs on, in a line: each setting's state, with what
|
|
1150
|
+
* was asked for and what the harness recorded, and its rules.
|
|
1151
|
+
*/
|
|
1152
|
+
function settingsLine({ bot, session, running, settings, rules }) {
|
|
1153
|
+
const each = Object.entries(settings).map(([name, one]) => {
|
|
1154
|
+
const values = [
|
|
1155
|
+
...(one.configured === undefined ? [] : [`asked ${one.configured}`]),
|
|
1156
|
+
...(one.observed === undefined ? [] : [`runs ${one.observed}`]),
|
|
1157
|
+
];
|
|
1158
|
+
return `${name} ${one.state}${values.length === 0 ? '' : ` (${values.join(', ')})`}`;
|
|
1159
|
+
});
|
|
1160
|
+
// A session whose harness the kit cannot see in its tab is not called running.
|
|
1161
|
+
const [word, who] = running === 'yes'
|
|
1162
|
+
? ['running', `${bot} ${session}`]
|
|
1163
|
+
: ['unsure', `${bot} ${session} cannot tell whether its harness is running:`];
|
|
1164
|
+
return `${word.padEnd(9)} ${who} ${each.join(' ')} rules ${rules.state}`;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1131
1167
|
function tabLines({ bots, created, completed, rules, skills, tabs, paused = [], projects = [], found = [] }, summary) {
|
|
1132
1168
|
const lines = [
|
|
1133
1169
|
...created.map((entry) => `created ${entry}`),
|
|
@@ -1177,6 +1213,7 @@ function harnessLines(tab, bots) {
|
|
|
1177
1213
|
' the harness was typed in, and no session came up in the tab.',
|
|
1178
1214
|
` Look at it: ${lookAt(tab.terminal)}`,
|
|
1179
1215
|
...ANSWER_IT,
|
|
1216
|
+
...promptLines(tab),
|
|
1180
1217
|
];
|
|
1181
1218
|
}
|
|
1182
1219
|
|
|
@@ -1198,14 +1235,21 @@ function harnessLines(tab, bots) {
|
|
|
1198
1235
|
...ANSWER_IT,
|
|
1199
1236
|
];
|
|
1200
1237
|
|
|
1201
|
-
|
|
1202
|
-
if (tab.promptSent === false) {
|
|
1203
|
-
lines.push(' the start prompt was not typed in: the tab was not ready for it.');
|
|
1204
|
-
}
|
|
1238
|
+
lines.push(...promptLines(tab));
|
|
1205
1239
|
if (tab.unclaimed !== undefined) lines.push(...unclaimedLines(tab, bots));
|
|
1206
1240
|
return lines;
|
|
1207
1241
|
}
|
|
1208
1242
|
|
|
1243
|
+
/**
|
|
1244
|
+
* Whether the session got its start prompt, as its own record has it (#274):
|
|
1245
|
+
* received, or not confirmed yet. Nothing for a session that was told nothing.
|
|
1246
|
+
*/
|
|
1247
|
+
function promptLines(tab) {
|
|
1248
|
+
if (tab.promptReceived === true) return [' the start prompt was received: the session\'s own record holds it.'];
|
|
1249
|
+
if (tab.promptReceived === false) return [' the start prompt is not confirmed: the session\'s own record does not hold it yet.'];
|
|
1250
|
+
return [];
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1209
1253
|
try {
|
|
1210
1254
|
process.exitCode = await run(process.argv.slice(2));
|
|
1211
1255
|
} catch (error) {
|
package/src/conversations.js
CHANGED
|
@@ -32,12 +32,17 @@ const codexDir = () => path.join(homedir(), '.codex', 'sessions');
|
|
|
32
32
|
* then that it does not know, which is the honest one.
|
|
33
33
|
*/
|
|
34
34
|
export function conversationsIn(harness, home, since) {
|
|
35
|
-
|
|
35
|
+
// A subagent's conversation ran in the folder too, but no session had it: the
|
|
36
|
+
// harness says it is a helper's, so it is never offered to one to claim.
|
|
37
|
+
return transcriptsIn(harness, home, since)
|
|
38
|
+
.filter((one) => !one.subagent)
|
|
39
|
+
.map((one) => ({ id: one.id, at: one.at }));
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
/**
|
|
39
43
|
* The same conversations, each with the file the harness keeps it in, for a
|
|
40
|
-
* caller that has to read what is inside one rather than only know it is there
|
|
44
|
+
* caller that has to read what is inside one rather than only know it is there,
|
|
45
|
+
* and whether the harness marks it as a subagent's.
|
|
41
46
|
*/
|
|
42
47
|
export function transcriptsIn(harness, home, since) {
|
|
43
48
|
const from = since === undefined ? 0 : Date.parse(since);
|
|
@@ -45,7 +50,47 @@ export function transcriptsIn(harness, home, since) {
|
|
|
45
50
|
return found
|
|
46
51
|
.filter((one) => Number.isNaN(from) || one.at >= from)
|
|
47
52
|
.sort((left, right) => left.at - right.at)
|
|
48
|
-
.map((one) => ({ id: one.id, at: new Date(one.at).toISOString(), file: one.file }));
|
|
53
|
+
.map((one) => ({ id: one.id, at: new Date(one.at).toISOString(), file: one.file, subagent: one.subagent === true }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether a conversation's own record holds `text` as a turn of the user's:
|
|
58
|
+
* what the harness itself wrote down as said to it, not a screen that was up.
|
|
59
|
+
*
|
|
60
|
+
* Only the user's turns count. Claude Code writes them as `user` lines, and its
|
|
61
|
+
* own meta lines and tool results in that same shape are not the user's. Codex
|
|
62
|
+
* writes each as a `user` message item; its `user_message` event is missing
|
|
63
|
+
* from many rollouts, and the item is in every one (tech notes, section 3).
|
|
64
|
+
* Its AGENTS.md goes in as a `user` item too, and cannot be the text asked about.
|
|
65
|
+
*/
|
|
66
|
+
export function heldAsUserTurn(harness, file, text) {
|
|
67
|
+
let lines;
|
|
68
|
+
try {
|
|
69
|
+
lines = readFileSync(file, 'utf8').split('\n');
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
const wanted = text.trim();
|
|
74
|
+
return lines.some((line) => userTexts(harness, line).some((said) => said.trim() === wanted));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The texts of one record line, when it is a turn of the user's. */
|
|
78
|
+
function userTexts(harness, line) {
|
|
79
|
+
let parsed;
|
|
80
|
+
try {
|
|
81
|
+
parsed = JSON.parse(line);
|
|
82
|
+
} catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
if (harness === 'claude') {
|
|
86
|
+
if (parsed?.type !== 'user' || parsed.isMeta === true) return [];
|
|
87
|
+
const content = parsed.message?.content;
|
|
88
|
+
if (typeof content === 'string') return [content];
|
|
89
|
+
return Array.isArray(content) ? content.filter((block) => block?.type === 'text').map((block) => String(block.text)) : [];
|
|
90
|
+
}
|
|
91
|
+
const item = parsed?.type === 'response_item' ? parsed.payload : undefined;
|
|
92
|
+
if (item?.type !== 'message' || item.role !== 'user' || !Array.isArray(item.content)) return [];
|
|
93
|
+
return item.content.filter((block) => block?.type === 'input_text').map((block) => String(block.text));
|
|
49
94
|
}
|
|
50
95
|
|
|
51
96
|
function claudeConversations(home) {
|
|
@@ -87,7 +132,7 @@ function codexConversations(home, from) {
|
|
|
87
132
|
.flatMap((file) => {
|
|
88
133
|
const meta = sessionMeta(file);
|
|
89
134
|
return meta?.cwd === home && typeof meta.id === 'string'
|
|
90
|
-
? [{ id: meta.id, at: Date.parse(meta.timestamp ?? '') || startedAt(file) || 0, file }]
|
|
135
|
+
? [{ id: meta.id, at: Date.parse(meta.timestamp ?? '') || startedAt(file) || 0, file, subagent: isSubagent(meta) }]
|
|
91
136
|
: [];
|
|
92
137
|
});
|
|
93
138
|
}
|
|
@@ -113,6 +158,15 @@ function sessionMeta(file) {
|
|
|
113
158
|
}
|
|
114
159
|
}
|
|
115
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Codex marks a conversation it ran as a subagent under `source`: its own
|
|
163
|
+
* auto-review is `{ subagent: { other: 'guardian' } }`, and one a session spawned
|
|
164
|
+
* is `{ subagent: { thread_spawn: … } }`. A session's own is a plain word, such as
|
|
165
|
+
* `cli` or `exec` (tech notes, section 3).
|
|
166
|
+
*/
|
|
167
|
+
const isSubagent = (meta) =>
|
|
168
|
+
meta.source !== null && typeof meta.source === 'object' && 'subagent' in meta.source;
|
|
169
|
+
|
|
116
170
|
/** When a file was last written, for leaving out what is plainly too old. */
|
|
117
171
|
function lastTouched(file) {
|
|
118
172
|
try {
|
package/src/health.js
CHANGED
|
@@ -21,13 +21,14 @@
|
|
|
21
21
|
import { existsSync, readdirSync, realpathSync } from 'node:fs';
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
|
|
24
|
-
import { bookFile, readBook, tabIdsIn } from './book.js';
|
|
24
|
+
import { bookFile, readBook, sessionIdsIn, tabIdsIn } from './book.js';
|
|
25
25
|
import { botDir, botNames, botsDir, readBot } from './bot.js';
|
|
26
26
|
import { transcriptsIn } from './conversations.js';
|
|
27
27
|
import { hookTrouble } from './hooks.js';
|
|
28
|
-
import { bypassFlags, harnessOf, HARNESSES, sessionTrouble } from './launch.js';
|
|
29
|
-
import { orcaDefaultArgs, projects, tabs } from './orca.js';
|
|
30
|
-
import { agentsTrouble } from './rules.js';
|
|
28
|
+
import { bypassFlags, harnessOf, HARNESSES, ownCli, sessionTrouble, shellWord } from './launch.js';
|
|
29
|
+
import { frontOfTab, orcaDefaultArgs, projects, tabs } from './orca.js';
|
|
30
|
+
import { agentsTrouble, rulesStamp } from './rules.js';
|
|
31
|
+
import { settingsInUse } from './settings.js';
|
|
31
32
|
import { skillsTrouble } from './skills.js';
|
|
32
33
|
import { readSources, sourcesDir } from './sources.js';
|
|
33
34
|
import { BOT_FATHER, botsNamed, promptPath } from './up.js';
|
|
@@ -37,14 +38,18 @@ import { BOT_FATHER, botsNamed, promptPath } from './up.js';
|
|
|
37
38
|
* settings do to every session, what is lying about that no book owns, and then
|
|
38
39
|
* each bot in name order. `bot` narrows the per-bot checks to one; what the
|
|
39
40
|
* whole fleet lives under is reported whichever bot was named.
|
|
41
|
+
*
|
|
42
|
+
* Beside the list, `sessions`: each running session the book knows, with what
|
|
43
|
+
* it runs on against what its bot asks for now, good news included.
|
|
40
44
|
*/
|
|
41
45
|
export function checkHealth(bots, { bot: onlyBot } = {}) {
|
|
42
46
|
const names = botsNamed(bots, onlyBot);
|
|
43
47
|
const setups = projects();
|
|
44
48
|
|
|
45
49
|
const found = [...orcaSettingFindings(), ...leftInOrca(bots, setups), ...leftBeside(bots)];
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
const sessions = [];
|
|
51
|
+
for (const name of names) found.push(...aboutBot(bots, name, setups, sessions));
|
|
52
|
+
return { found, sessions };
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
/** One finding, as the report and the `--json` answer carry it. */
|
|
@@ -165,8 +170,8 @@ function leftBeside(bots) {
|
|
|
165
170
|
return found;
|
|
166
171
|
}
|
|
167
172
|
|
|
168
|
-
/** Everything wrong with one bot. */
|
|
169
|
-
function aboutBot(bots, name, setups) {
|
|
173
|
+
/** Everything wrong with one bot. Its running sessions are added to `sessions`. */
|
|
174
|
+
function aboutBot(bots, name, setups, sessions) {
|
|
170
175
|
const home = botDir(bots, name);
|
|
171
176
|
|
|
172
177
|
let bot;
|
|
@@ -185,7 +190,7 @@ function aboutBot(bots, name, setups) {
|
|
|
185
190
|
...agentsTrouble(bots, home, bot).map(said('config')),
|
|
186
191
|
...hooksOf(bots, home, bot).map(said('config')),
|
|
187
192
|
...skillsTrouble(bots, home, bot).map(said('skill')),
|
|
188
|
-
...inOrca(home, bot, setups),
|
|
193
|
+
...inOrca(bots, home, bot, setups, sessions),
|
|
189
194
|
];
|
|
190
195
|
}
|
|
191
196
|
|
|
@@ -206,12 +211,97 @@ const hooksOf = (bots, home, bot) => [...new Set(bot.sessions.map((session) => h
|
|
|
206
211
|
.map((harness) => hookTrouble(home, harness, { bots, bot: bot.name }))
|
|
207
212
|
.filter((trouble) => trouble !== undefined);
|
|
208
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Each session of this bot running in a tab Orca has, with what it runs on set
|
|
216
|
+
* beside what the bot asks for now, added to `sessions`; a finding for each
|
|
217
|
+
* one that runs on something else (#271, #272); and a finding for each one
|
|
218
|
+
* whose tab is open with its harness gone from it (#300).
|
|
219
|
+
*
|
|
220
|
+
* Two questions, each answered from where the answer is written. The settings
|
|
221
|
+
* from the harness's own record of the conversation the session is in now,
|
|
222
|
+
* which is what the harness really used. The rules from the stamp the kit noted
|
|
223
|
+
* in the book when the session last read them, against `AGENTS.md` now: nothing
|
|
224
|
+
* the harness writes says which instructions it read.
|
|
225
|
+
*
|
|
226
|
+
* Only a mismatch and older rules are findings. What cannot be read is said as
|
|
227
|
+
* unknown in `sessions`, and never taken for agreement.
|
|
228
|
+
*/
|
|
229
|
+
function runningOn(bots, home, bot, book, handles, sessions) {
|
|
230
|
+
const real = realpathOf(home) ?? home;
|
|
231
|
+
const records = new Map();
|
|
232
|
+
const recordOf = (harness, id) => {
|
|
233
|
+
if (!HARNESSES.includes(harness) || id === null) return undefined;
|
|
234
|
+
if (!records.has(harness)) records.set(harness, new Map(transcriptsIn(harness, real).map((one) => [one.id, one.file])));
|
|
235
|
+
return records.get(harness).get(id);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const stamp = rulesStamp(home);
|
|
239
|
+
const found = [];
|
|
240
|
+
for (const session of bot.sessions) {
|
|
241
|
+
const entry = book.sessions[session.name];
|
|
242
|
+
// Running is a tab Orca still has, with the session's harness in front of
|
|
243
|
+
// it. A session whose tab is gone is not running whatever its record says,
|
|
244
|
+
// and the missing tab is said already.
|
|
245
|
+
if (bot.paused === true || session.paused === true || entry === null || typeof entry !== 'object') continue;
|
|
246
|
+
const handle = handles.get(entry.tab);
|
|
247
|
+
if (handle === undefined) continue;
|
|
248
|
+
|
|
249
|
+
// A harness can quit to the tab's shell and leave the tab open (#232), so
|
|
250
|
+
// the tab alone is not the answer: who holds its terminal is, asked the way
|
|
251
|
+
// the kit asks before it types into a tab. The shell in front is a session
|
|
252
|
+
// that is not running, and a finding: `up` finds the tab open and types
|
|
253
|
+
// nothing into it, so only a restart brings it back (#300). Anything else
|
|
254
|
+
// that is not its own harness, or a front that cannot be read, leaves it
|
|
255
|
+
// unsaid, and nothing unsaid is a finding.
|
|
256
|
+
const restart = `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${bot.name} --session ${session.name}`;
|
|
257
|
+
const front = frontOfTab(handle);
|
|
258
|
+
if (front.front === 'shell') {
|
|
259
|
+
// Restart refuses a live tab whose conversation the book cannot name, and
|
|
260
|
+
// there may be none to name: the harness can quit before it has one. Its
|
|
261
|
+
// harness is gone, so closing the tab ends nothing, and `up` then starts
|
|
262
|
+
// the session again as it does any closed tab with no conversation.
|
|
263
|
+
const up = `${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${bot.name} --session ${session.name}`;
|
|
264
|
+
const back = typeof entry.session === 'string'
|
|
265
|
+
? `${restart} does`
|
|
266
|
+
: `nor does obk restart, which refuses to close a tab whose conversation the book does not name, and the book names none for it. Its harness has already quit, so close the tab in Orca yourself; then ${up} starts it again, with a new conversation and its duty. Any conversation it left in this bot's folder is noted in the book as unclaimed, for health to list`;
|
|
267
|
+
found.push(finding('session', entry.tab, `${bot.name}'s session ${session.name} is not running: its tab ${entry.tab} is open with only the tab's shell in front, so its harness quit or crashed. obk up finds the tab open and types nothing into it, so it does not bring the session back; ${back}.`, bot.name));
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const harness = harnessOf(session, bot.harness);
|
|
271
|
+
const running = front.front === 'program' && front.command === harness ? 'yes' : 'unknown';
|
|
272
|
+
const conversation = typeof entry.session === 'string' ? entry.session : null;
|
|
273
|
+
const file = recordOf(harness, conversation);
|
|
274
|
+
const settings = settingsInUse(harness, session, file, typeof entry.launched === 'string' ? entry.launched : undefined);
|
|
275
|
+
const rules = { state: typeof entry.rules !== 'string' ? 'unknown' : entry.rules === stamp ? 'current' : 'older' };
|
|
276
|
+
sessions.push({ bot: bot.name, session: session.name, harness, running, conversation, settings, rules });
|
|
277
|
+
if (running !== 'yes') continue;
|
|
278
|
+
|
|
279
|
+
const off = Object.entries(settings).filter(([, one]) => one.state === 'mismatch');
|
|
280
|
+
if (off.length > 0) {
|
|
281
|
+
const parts = off.map(([name, one]) => `${name}: bot.yaml asks for ${one.configured}, and it runs on ${one.observed}`).join('; ');
|
|
282
|
+
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));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (rules.state === 'older') {
|
|
286
|
+
const agents = path.join(home, 'AGENTS.md');
|
|
287
|
+
// A clear on Claude Code reads the instructions again, as a start does
|
|
288
|
+
// (tech notes, section 2); whether a new conversation on Codex does is
|
|
289
|
+
// not established, so a start is all that is offered there.
|
|
290
|
+
const how = harness === 'claude'
|
|
291
|
+
? `A Claude Code session reads it again at a /clear in its tab, or when it starts: ${restart}`
|
|
292
|
+
: `A Codex session reads it when it starts: ${restart}`;
|
|
293
|
+
found.push(finding('session', agents, `${bot.name}'s session ${session.name} is running on older rules: ${agents} has changed since the kit noted which version this session read. ${how}`, bot.name));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return found;
|
|
297
|
+
}
|
|
298
|
+
|
|
209
299
|
/**
|
|
210
300
|
* What Orca has of this bot against what the book says it should have: a
|
|
211
301
|
* session whose tab is gone, a conversation nobody claims, and a tab in the
|
|
212
302
|
* bot's project that the book does not name.
|
|
213
303
|
*/
|
|
214
|
-
function inOrca(home, bot, setups) {
|
|
304
|
+
function inOrca(bots, home, bot, setups, sessions) {
|
|
215
305
|
// The book is read where it is read, and a book nothing can parse is a
|
|
216
306
|
// finding like any other: it says which tab each session is in and which
|
|
217
307
|
// conversation it is running, so without it nothing else here can be asked —
|
|
@@ -236,19 +326,26 @@ function inOrca(home, bot, setups) {
|
|
|
236
326
|
// what paused means rather than something lost.
|
|
237
327
|
const closed = new Set(bot.sessions.filter((session) => bot.paused === true || session.paused === true).map((session) => session.name));
|
|
238
328
|
|
|
329
|
+
// What the harness has on record in this bot's folder, read once: it is a walk
|
|
330
|
+
// through every conversation on the machine.
|
|
331
|
+
const onRecord = recordIn(real ?? home, bot);
|
|
332
|
+
// A conversation the harness marks as a subagent's was never a session's, so a
|
|
333
|
+
// note that holds one is not a loose end for it (#287). The note stays as it is.
|
|
334
|
+
const helpers = new Set(onRecord.filter((one) => one.subagent).map((one) => one.id));
|
|
335
|
+
|
|
239
336
|
const found = [];
|
|
240
337
|
for (const [name, entry] of Object.entries(book.sessions)) {
|
|
241
338
|
if (typeof entry?.tab === 'string' && !there.has(entry.tab) && !closed.has(name)) {
|
|
242
339
|
found.push(finding('session', entry.tab, `${bot.name}'s session ${name} is in the book with tab ${entry.tab}, and Orca has no tab of that id: the tab was closed, or the machine was restarted. The conversation is in the book, and obk up opens a tab and brings it back.`, bot.name));
|
|
243
340
|
}
|
|
244
341
|
|
|
245
|
-
const unclaimed = Array.isArray(entry?.unclaimed) ? entry.unclaimed : [];
|
|
342
|
+
const unclaimed = (Array.isArray(entry?.unclaimed) ? entry.unclaimed : []).filter((id) => !helpers.has(id));
|
|
246
343
|
if (unclaimed.length > 0) {
|
|
247
344
|
found.push(finding('leftover', bookFile(home), `The book notes conversations under ${bot.name}'s session ${name} that no session of this bot claims: ${unclaimed.join(', ')}. They ran in this bot's folder, and the kit will not say whose they were. To give one back, write it into ${bookFile(home)} under ${name} as session: <id> and run obk up again.`, bot.name));
|
|
248
345
|
}
|
|
249
346
|
}
|
|
250
347
|
|
|
251
|
-
found.push(...offTheBook(real ?? home, bot, book));
|
|
348
|
+
found.push(...offTheBook(real ?? home, bot, book, onRecord));
|
|
252
349
|
|
|
253
350
|
// A tab outside the book is somebody else's, and in Bot Father's project it
|
|
254
351
|
// is the ops tab: the one tab the kit deliberately keeps no record of, so it
|
|
@@ -260,6 +357,7 @@ function inOrca(home, bot, setups) {
|
|
|
260
357
|
}
|
|
261
358
|
}
|
|
262
359
|
|
|
360
|
+
found.push(...runningOn(bots, home, bot, book, new Map(live.map((tab) => [tab.tabId, tab.handle])), sessions));
|
|
263
361
|
return found;
|
|
264
362
|
}
|
|
265
363
|
|
|
@@ -269,27 +367,30 @@ function inOrca(home, bot, setups) {
|
|
|
269
367
|
* fails it fails quietly (ADR 0020), so this is where a stale book shows: the
|
|
270
368
|
* harness's own record set beside it (ADR 0012).
|
|
271
369
|
*/
|
|
272
|
-
function offTheBook(home, bot, book) {
|
|
273
|
-
|
|
370
|
+
function offTheBook(home, bot, book, onRecord) {
|
|
371
|
+
// A retired session's conversations are in the book too, under `retired`.
|
|
372
|
+
const named = sessionIdsIn(book);
|
|
274
373
|
for (const entry of Object.values(book.sessions)) {
|
|
275
|
-
if (typeof entry?.session === 'string') named.add(entry.session);
|
|
276
|
-
for (const was of Array.isArray(entry?.history) ? entry.history : []) named.add(was?.session);
|
|
277
374
|
for (const id of Array.isArray(entry?.unclaimed) ? entry.unclaimed : []) named.add(id);
|
|
278
375
|
}
|
|
279
376
|
|
|
280
|
-
//
|
|
281
|
-
const
|
|
282
|
-
if (harnesses.size === 0) harnesses.add(bot.harness);
|
|
283
|
-
const stray = [...harnesses]
|
|
284
|
-
.filter((harness) => HARNESSES.includes(harness))
|
|
285
|
-
.flatMap((harness) => transcriptsIn(harness, home))
|
|
286
|
-
.filter((one) => !named.has(one.id));
|
|
377
|
+
// A subagent's conversation is no session's, so the book has no reason to name it.
|
|
378
|
+
const stray = onRecord.filter((one) => !one.subagent && !named.has(one.id));
|
|
287
379
|
if (stray.length === 0) return [];
|
|
288
380
|
|
|
289
381
|
const listed = stray.map((one) => `${one.id} (begun ${one.at})`).join(', ');
|
|
290
382
|
return [finding('session', bookFile(home), `The harness has conversations on record in ${bot.name}'s folder that the book does not name: ${listed}. The kit's hook may have missed them: a clear it did not record, or a Codex hooks file trusted late. To give one to a session, write it into ${bookFile(home)} under that session as session: <id> and run obk up again.`, bot.name)];
|
|
291
383
|
}
|
|
292
384
|
|
|
385
|
+
/** Every conversation on record in the bot's folder, from every harness any of its sessions runs on, as `obk usage` reads them. */
|
|
386
|
+
function recordIn(home, bot) {
|
|
387
|
+
const harnesses = new Set(bot.sessions.map((session) => harnessOf(session, bot.harness)));
|
|
388
|
+
if (harnesses.size === 0) harnesses.add(bot.harness);
|
|
389
|
+
return [...harnesses]
|
|
390
|
+
.filter((harness) => HARNESSES.includes(harness))
|
|
391
|
+
.flatMap((harness) => transcriptsIn(harness, home));
|
|
392
|
+
}
|
|
393
|
+
|
|
293
394
|
/** What a directory holds, and nothing at all when there is no directory. */
|
|
294
395
|
function namesIn(dir) {
|
|
295
396
|
try {
|
package/src/orca.js
CHANGED
|
@@ -312,6 +312,21 @@ export function tabToTypeInto(home, tabId, timeoutMs) {
|
|
|
312
312
|
return { handle: live.handle, agent: seen.agent };
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Who holds the terminal of the tab `handle`, as `frontOf` answers, without
|
|
317
|
+
* waiting on the tab: for a caller that asks about every tab and has nothing
|
|
318
|
+
* to type. Orca refusing is an answer that cannot be read, not an error.
|
|
319
|
+
*/
|
|
320
|
+
export function frontOfTab(handle) {
|
|
321
|
+
let shown;
|
|
322
|
+
try {
|
|
323
|
+
shown = orca(['terminal', 'show', '--terminal', handle]).terminal;
|
|
324
|
+
} catch (error) {
|
|
325
|
+
return { unreadable: `Orca would not show the tab: ${error.message}` };
|
|
326
|
+
}
|
|
327
|
+
return frontOf(shown?.ptyId);
|
|
328
|
+
}
|
|
329
|
+
|
|
315
330
|
/** The `ps` this run reads. OBK_PS overrides it, as OBK_ORCA does Orca. */
|
|
316
331
|
const psCli = () => process.env.OBK_PS || '/bin/ps';
|
|
317
332
|
|
package/src/record.js
CHANGED
|
@@ -17,6 +17,7 @@ import { forgetClaimed, readBook, rememberSession, sessionIdsIn, updateBook, wit
|
|
|
17
17
|
import { botDir, readBot } from './bot.js';
|
|
18
18
|
import { conversationsIn } from './conversations.js';
|
|
19
19
|
import { harnessOf, SHELL_ENV, startPrompt, workDirOf } from './launch.js';
|
|
20
|
+
import { rulesStamp } from './rules.js';
|
|
20
21
|
|
|
21
22
|
/** Where Orca says which tab a program is running in (tech notes, section 1). */
|
|
22
23
|
export const TAB_ENV = 'ORCA_TAB_ID';
|
|
@@ -79,6 +80,11 @@ export async function recordSession(bots, name, said, tabId, shellPid) {
|
|
|
79
80
|
// this bot's folder, which every session of the bot shares — so it can say
|
|
80
81
|
// that a conversation nobody claims exists, and never whose it is.
|
|
81
82
|
const unclaimed = unclaimedFor(readBook(home), home, bot, tabId, id);
|
|
83
|
+
// A clear on Claude Code reads the bot's instructions again, as a start does
|
|
84
|
+
// (tech notes, section 2), so the book notes which ones it read. Codex has no
|
|
85
|
+
// word for its clear, and whether its new conversation reads them again is
|
|
86
|
+
// not established, so nothing is noted for it.
|
|
87
|
+
const rules = said.source === CLEARED ? rulesStamp(home) : undefined;
|
|
82
88
|
|
|
83
89
|
let cleared = false;
|
|
84
90
|
let told;
|
|
@@ -100,7 +106,8 @@ export async function recordSession(bots, name, said, tabId, shellPid) {
|
|
|
100
106
|
const uncertain = was.session === undefined && unclaimed.length > 0;
|
|
101
107
|
told = session;
|
|
102
108
|
|
|
103
|
-
|
|
109
|
+
const now = rememberSession(was, id, said.source);
|
|
110
|
+
book.sessions[session] = withUnclaimed(rules === undefined ? now : { ...now, rules }, unclaimed);
|
|
104
111
|
forgetClaimed(book);
|
|
105
112
|
if (uncertain) cleared = true;
|
|
106
113
|
return book;
|
package/src/rules.js
CHANGED
|
@@ -406,3 +406,18 @@ function sameFile(one, other) {
|
|
|
406
406
|
return false;
|
|
407
407
|
}
|
|
408
408
|
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* A stamp of a bot's `AGENTS.md` as it is now, or undefined when there is none
|
|
412
|
+
* to read. It is what a session read when it started, noted in the book at that
|
|
413
|
+
* moment, so that health can tell a session running on older rules from one on
|
|
414
|
+
* the current ones. The whole file, not only the build's block: what the user
|
|
415
|
+
* wrote below the end marker is part of what the session reads.
|
|
416
|
+
*/
|
|
417
|
+
export function rulesStamp(home) {
|
|
418
|
+
try {
|
|
419
|
+
return checksum(readFileSync(path.join(home, AGENTS), 'utf8'));
|
|
420
|
+
} catch {
|
|
421
|
+
return undefined;
|
|
422
|
+
}
|
|
423
|
+
}
|
package/src/settings.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// What a running session really runs with, set beside what its bot.yaml asks for.
|
|
2
|
+
//
|
|
3
|
+
// A session can run on settings other than the ones bot.yaml asks for: it
|
|
4
|
+
// started before a change, a default of the user's own leaked in, or a flag was
|
|
5
|
+
// dropped. Each harness writes down what it used (tech notes, sections 2 and 3),
|
|
6
|
+
// so the two can be put side by side. Read only: these are the harness's files.
|
|
7
|
+
//
|
|
8
|
+
// Four answers, and only one of them is good news. A record that cannot be read,
|
|
9
|
+
// or that does not carry the setting, is not agreement: it is `unknown`. A
|
|
10
|
+
// setting the session never asked for is not a disagreement either, whatever the
|
|
11
|
+
// harness chose: it is `not-asked-for`.
|
|
12
|
+
|
|
13
|
+
import { DEFAULT_APPROVAL } from './launch.js';
|
|
14
|
+
import { lines } from './usage.js';
|
|
15
|
+
|
|
16
|
+
export const SETTINGS = ['model', 'effort', 'context', 'approval'];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Each setting of `session` as `{ state, configured?, observed? }`, judged
|
|
20
|
+
* against `file`, the harness's record of the conversation the session is in
|
|
21
|
+
* now, from `since`, when the kit last started it. No file is no record, and
|
|
22
|
+
* every setting asked for is then unknown.
|
|
23
|
+
*/
|
|
24
|
+
export function settingsInUse(harness, session, file, since) {
|
|
25
|
+
const used = file === undefined ? {} : (harness === 'claude' ? fromClaude : fromCodex)(sinceStart(lines(file), since));
|
|
26
|
+
return Object.fromEntries(SETTINGS.map((name) => [name, judged(harness, name, asked(session, name), used[name])]));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What the record says since the kit last started the session. A start resumes
|
|
31
|
+
* the conversation it had, so the file still holds what the process before it
|
|
32
|
+
* wrote, and that says what that process ran with, not this one. Everything
|
|
33
|
+
* from the first line written at or after the start counts, a line with no time
|
|
34
|
+
* of its own included. With no start to go by, no line can be shown to be this
|
|
35
|
+
* process's, so none counts.
|
|
36
|
+
*/
|
|
37
|
+
function sinceStart(entries, since) {
|
|
38
|
+
const from = Date.parse(since ?? '');
|
|
39
|
+
if (Number.isNaN(from)) return [];
|
|
40
|
+
const first = entries.findIndex((entry) => Date.parse(entry.timestamp ?? '') >= from);
|
|
41
|
+
return first === -1 ? [] : entries.slice(first);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What the session asks for, as bot.yaml has it, or undefined when it asks for nothing. */
|
|
45
|
+
function asked(session, name) {
|
|
46
|
+
const value = session[name];
|
|
47
|
+
const set = value !== undefined && value !== null && String(value).trim() !== '';
|
|
48
|
+
// The kit puts an approval level on every launch line, so a session always
|
|
49
|
+
// asks for one: the default, when it names none (ADR 0015).
|
|
50
|
+
if (name === 'approval') return set ? String(value).trim() : DEFAULT_APPROVAL;
|
|
51
|
+
return set ? String(value).trim() : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function judged(harness, name, configured, observed) {
|
|
55
|
+
const shown = observed === undefined ? {} : { observed: said(name, observed) };
|
|
56
|
+
if (configured === undefined) return { state: 'not-asked-for', ...shown };
|
|
57
|
+
const verdict = same(harness, name, configured, observed);
|
|
58
|
+
if (verdict === undefined) return { state: 'unknown', configured, ...shown };
|
|
59
|
+
return { state: verdict ? 'match' : 'mismatch', configured, ...shown };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether the record says what was asked for; undefined when it cannot say. */
|
|
63
|
+
function same(harness, name, configured, observed) {
|
|
64
|
+
if (observed === undefined) return undefined;
|
|
65
|
+
if (name === 'model') return sameModel(configured, observed);
|
|
66
|
+
// Codex reports the window it was given at 95%, rounded down (tech notes,
|
|
67
|
+
// section 3): 200000 comes back as 190000.
|
|
68
|
+
if (name === 'context') return Number(observed) === Math.floor(Number(configured) * 0.95);
|
|
69
|
+
if (name === 'approval') return harness === 'claude' ? observed === CLAUDE_MODES[configured] : sameCodexApproval(configured, observed);
|
|
70
|
+
return observed === configured;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A model named without a dash is an alias, `sonnet`, and the harness writes the
|
|
75
|
+
* id it stands for, `claude-sonnet-5`: it matches an id it is one part of. A
|
|
76
|
+
* model with a dash is an id already, and has to be the one that ran. The
|
|
77
|
+
* context window Claude Code carries on the model name, `sonnet[1m]`, is not
|
|
78
|
+
* part of what it writes down.
|
|
79
|
+
*/
|
|
80
|
+
function sameModel(configured, observed) {
|
|
81
|
+
const model = configured.replace(/\[[^\]]*\]$/, '');
|
|
82
|
+
return model.includes('-') ? observed === model : observed.split('-').includes(model);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The permission mode Claude Code records for each level (tech notes, section 2). */
|
|
86
|
+
const CLAUDE_MODES = { auto: 'auto', ask: 'default', 'dangerously-skip': 'bypassPermissions' };
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What Codex records for each level, and only the words that level decides: at
|
|
90
|
+
* `ask` the kit passes only the approval policy, so the sandbox is the user's
|
|
91
|
+
* own (ADR 0015), and at `dangerously-skip` nothing is reviewed (tech notes,
|
|
92
|
+
* section 3).
|
|
93
|
+
*/
|
|
94
|
+
const CODEX_WORDS = {
|
|
95
|
+
auto: { policy: 'on-request', reviewer: 'auto_review', sandbox: 'workspace-write' },
|
|
96
|
+
ask: { policy: 'on-request', reviewer: 'user' },
|
|
97
|
+
'dangerously-skip': { policy: 'never', sandbox: 'danger-full-access' },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function sameCodexApproval(configured, observed) {
|
|
101
|
+
const wanted = CODEX_WORDS[configured];
|
|
102
|
+
if (wanted === undefined) return false;
|
|
103
|
+
const pairs = Object.entries(wanted);
|
|
104
|
+
// A word the level decides and the record does not carry leaves it unsaid.
|
|
105
|
+
if (pairs.some(([key]) => observed[key] === undefined)) return undefined;
|
|
106
|
+
return pairs.every(([key, word]) => observed[key] === word);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A value the record carried, as the report shows it. */
|
|
110
|
+
function said(name, observed) {
|
|
111
|
+
if (name !== 'approval' || typeof observed === 'string') return String(observed);
|
|
112
|
+
return [
|
|
113
|
+
`approval_policy ${observed.policy}`,
|
|
114
|
+
...(observed.reviewer === undefined ? [] : [`approvals_reviewer ${observed.reviewer}`]),
|
|
115
|
+
...(observed.sandbox === undefined ? [] : [`sandbox ${observed.sandbox}`]),
|
|
116
|
+
].join(', ');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What a Claude Code transcript says it runs with, the latest of each: the
|
|
121
|
+
* model and effort on its replies, the permission mode wherever it is noted.
|
|
122
|
+
* Claude Code writes nothing that shows the context window.
|
|
123
|
+
*/
|
|
124
|
+
function fromClaude(entries) {
|
|
125
|
+
const used = {};
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
if (entry.type === 'assistant' && entry.isSidechain !== true) {
|
|
128
|
+
const model = entry.message?.model;
|
|
129
|
+
if (typeof model === 'string' && model !== '<synthetic>') used.model = model;
|
|
130
|
+
if (typeof entry.effort === 'string') used.effort = entry.effort;
|
|
131
|
+
}
|
|
132
|
+
if (typeof entry.permissionMode === 'string') used.approval = entry.permissionMode;
|
|
133
|
+
}
|
|
134
|
+
return used;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** What a Codex rollout says it runs with: its latest turn, and its latest count. */
|
|
138
|
+
function fromCodex(entries) {
|
|
139
|
+
const used = {};
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
const payload = entry.payload;
|
|
142
|
+
if (payload === null || typeof payload !== 'object') continue;
|
|
143
|
+
if (entry.type === 'turn_context') {
|
|
144
|
+
if (typeof payload.model === 'string') used.model = payload.model;
|
|
145
|
+
if (typeof payload.effort === 'string') used.effort = payload.effort;
|
|
146
|
+
if (typeof payload.approval_policy === 'string') {
|
|
147
|
+
used.approval = {
|
|
148
|
+
policy: payload.approval_policy,
|
|
149
|
+
reviewer: typeof payload.approvals_reviewer === 'string' ? payload.approvals_reviewer : undefined,
|
|
150
|
+
sandbox: typeof payload.sandbox_policy?.type === 'string' ? payload.sandbox_policy.type : undefined,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const window = payload.info?.model_context_window;
|
|
155
|
+
if (entry.type === 'event_msg' && payload.type === 'token_count' && typeof window === 'number') used.context = window;
|
|
156
|
+
}
|
|
157
|
+
return used;
|
|
158
|
+
}
|
package/src/up.js
CHANGED
|
@@ -6,14 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
+
import { setTimeout as pause } from 'node:timers/promises';
|
|
9
10
|
|
|
10
11
|
import { forgetClaimed, readBook, sessionIdsIn, tabIdsIn, updateBook, withUnclaimed } from './book.js';
|
|
11
12
|
import { botDir, botNames, displayName, readBot } from './bot.js';
|
|
12
|
-
import { conversationsIn } from './conversations.js';
|
|
13
|
+
import { conversationsIn, heldAsUserTurn, transcriptsIn } from './conversations.js';
|
|
13
14
|
import { installHook } from './hooks.js';
|
|
14
15
|
import { addressOf, harnessOf, isShortPrompt, launchCommand, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
|
|
15
16
|
import { asFolderProject, findProject, harnessInTab, makeMailbox, makeProject, openTab, retitleTab, tabs, tellWindow, typeIntoTab, useMailbox } from './orca.js';
|
|
16
|
-
import { buildAgents } from './rules.js';
|
|
17
|
+
import { buildAgents, rulesStamp } from './rules.js';
|
|
17
18
|
import { linkSkills } from './skills.js';
|
|
18
19
|
|
|
19
20
|
/** The one bot with a tab beside its sessions: the ops tab (PRD 6.2). */
|
|
@@ -300,12 +301,17 @@ async function bringUpSession(bots, home, live, session, bot, title) {
|
|
|
300
301
|
// started a harness in it, and a conversation taken over from the harness's
|
|
301
302
|
// own record are new.
|
|
302
303
|
const launched = new Date().toISOString();
|
|
304
|
+
// Which instructions it is about to read: the AGENTS.md this run has just
|
|
305
|
+
// built, noted so that health can say when the file moves on and the session
|
|
306
|
+
// does not (#272).
|
|
307
|
+
const rules = rulesStamp(home);
|
|
303
308
|
await updateBook(home, (current) => {
|
|
304
309
|
// What the harness has in this folder that nobody claims goes on the record,
|
|
305
310
|
// for a person or Bot Father to settle — added to whatever was already noted,
|
|
306
311
|
// because this run's scan cannot see what an earlier one found. The kit never
|
|
307
312
|
// settles it itself.
|
|
308
|
-
const entry = { ...current.sessions[session.name], tab: made.tabId, launched };
|
|
313
|
+
const entry = { ...current.sessions[session.name], tab: made.tabId, launched, rules };
|
|
314
|
+
if (rules === undefined) delete entry.rules;
|
|
309
315
|
current.sessions[session.name] = withUnclaimed(entry, which.unclaimed ?? []);
|
|
310
316
|
forgetClaimed(current);
|
|
311
317
|
});
|
|
@@ -334,24 +340,54 @@ async function bringUpSession(bots, home, live, session, bot, title) {
|
|
|
334
340
|
const tui = first.running ? lookFor(made.handle, SECOND_LOOK_MS) : first;
|
|
335
341
|
|
|
336
342
|
// The start prompt went in with that line, as the harness's own prompt
|
|
337
|
-
// argument,
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
const
|
|
343
|
+
// argument, and a harness running in the tab is not yet a session told its
|
|
344
|
+
// duty: one held on a first-run screen, or one that refused the argument, has
|
|
345
|
+
// been told nothing. So it is received only when the session's own record
|
|
346
|
+
// holds it as a user turn, and otherwise not confirmed (#274).
|
|
347
|
+
const promptReceived = prompt === undefined
|
|
348
|
+
? undefined
|
|
349
|
+
: await heldInRecord(home, session.name, harness, prompt, { launched, running: tui.running === true });
|
|
342
350
|
|
|
343
351
|
return entry(made, {
|
|
344
352
|
bot: bot.name,
|
|
345
353
|
name: session.name,
|
|
346
354
|
created: true,
|
|
347
355
|
...tui,
|
|
348
|
-
|
|
356
|
+
promptReceived,
|
|
349
357
|
promptFile,
|
|
350
358
|
resumed: resume !== undefined,
|
|
351
359
|
unclaimed: which.unclaimed,
|
|
352
360
|
});
|
|
353
361
|
}
|
|
354
362
|
|
|
363
|
+
/**
|
|
364
|
+
* How long a running harness's own record is given to show the start prompt.
|
|
365
|
+
* Seen live (Claude Code 2.1.282, a trusted folder): the hook's id and the
|
|
366
|
+
* user turn landed about as the second look ended, so one read there comes a
|
|
367
|
+
* moment too early. A harness held on a first-run screen costs the whole wait.
|
|
368
|
+
*/
|
|
369
|
+
const RECORD_MS = 5000;
|
|
370
|
+
const RECORD_ASK_MS = 250;
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Whether the conversation the book names for this session holds `prompt` as a
|
|
374
|
+
* user turn in the harness's own record. The name comes from the session's own
|
|
375
|
+
* hook, so a harness that has not reported one — held on a first-run screen,
|
|
376
|
+
* say — has no record to be read, and the answer is that it is not confirmed.
|
|
377
|
+
* Another conversation in the same folder is never asked: it may be a sister
|
|
378
|
+
* session told the same words.
|
|
379
|
+
*/
|
|
380
|
+
async function heldInRecord(home, name, harness, prompt, { launched, running }) {
|
|
381
|
+
const until = Date.now() + (running ? RECORD_MS : 0);
|
|
382
|
+
for (;;) {
|
|
383
|
+
const id = readBook(home).sessions[name]?.session;
|
|
384
|
+
const record = typeof id === 'string' ? transcriptsIn(harness, home, launched).find((one) => one.id === id) : undefined;
|
|
385
|
+
if (record !== undefined && heldAsUserTurn(harness, record.file, prompt)) return true;
|
|
386
|
+
if (Date.now() >= until) return false;
|
|
387
|
+
await pause(RECORD_ASK_MS);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
355
391
|
/**
|
|
356
392
|
* One look at a tab the kit just typed a launch line into: whether a harness
|
|
357
393
|
* is running there, and what it is waiting on if Orca says.
|
|
@@ -490,7 +526,7 @@ const ids = (setup, change) => ({ project: setup.projectId, setup: setup.id, cha
|
|
|
490
526
|
export const promptPath = (bots, bot, session) =>
|
|
491
527
|
path.join(`${bots}.prompts`, `${encodeURIComponent(bot)}.${encodeURIComponent(session)}.txt`);
|
|
492
528
|
|
|
493
|
-
function entry(tab, { bot, name, created, running = false, blockedReason,
|
|
529
|
+
function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, unclaimed }) {
|
|
494
530
|
const made = { bot, name, title: tab.title, tabId: tab.tabId, terminal: tab.handle, created, harnessStarted: running };
|
|
495
531
|
// Whether this run picked the session up where it was or started a new one.
|
|
496
532
|
// Only for a tab this run opened: a tab that was already there was left alone.
|
|
@@ -502,7 +538,7 @@ function entry(tab, { bot, name, created, running = false, blockedReason, prompt
|
|
|
502
538
|
// claims: the caller is told, because the kit will not pick one.
|
|
503
539
|
if (unclaimed !== undefined && unclaimed.length > 0) made.unclaimed = unclaimed;
|
|
504
540
|
// Only for a session this run started that had something to be told.
|
|
505
|
-
if (
|
|
541
|
+
if (promptReceived !== undefined) made.promptReceived = promptReceived;
|
|
506
542
|
// And the file it was told it out of, when it was too long for the line.
|
|
507
543
|
if (promptFile !== undefined) made.promptFile = promptFile;
|
|
508
544
|
return made;
|
package/src/usage.js
CHANGED
|
@@ -41,6 +41,21 @@ import { harnessOf } from './launch.js';
|
|
|
41
41
|
/** The kinds a call's tokens are reported in, the same on either harness. */
|
|
42
42
|
const KINDS = ['input', 'output', 'cache_read', 'cache_write', 'reasoning'];
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* What could not be counted, by why. A figure that quietly leaves something out
|
|
46
|
+
* reads as complete, so what is left out is said, and the totals cover only what
|
|
47
|
+
* was counted: nothing missing is guessed at, nor taken as zero, nor placed in a
|
|
48
|
+
* window it has no time to be placed in.
|
|
49
|
+
*/
|
|
50
|
+
const GAPS = ['unreadable_transcripts', 'broken_lines', 'records_without_numbers', 'records_without_time'];
|
|
51
|
+
|
|
52
|
+
const noGaps = () => Object.fromEntries(GAPS.map((gap) => [gap, 0]));
|
|
53
|
+
|
|
54
|
+
const addGaps = (into, more) => {
|
|
55
|
+
for (const gap of GAPS) into[gap] += more[gap];
|
|
56
|
+
return into;
|
|
57
|
+
};
|
|
58
|
+
|
|
44
59
|
/**
|
|
45
60
|
* What each bot's sessions have used. `since` counts the calls made at or after
|
|
46
61
|
* that moment rather than the conversations begun after it: a conversation that
|
|
@@ -99,20 +114,28 @@ function forBot(bots, name, onlySession, window) {
|
|
|
99
114
|
|
|
100
115
|
const sessions = bot.sessions
|
|
101
116
|
.filter((session) => onlySession === undefined || session.name === onlySession)
|
|
102
|
-
.map((session) =>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
const unclaimed = [...onRecord.values()]
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
117
|
+
.map((session) => {
|
|
118
|
+
const { conversations, gaps } = all(
|
|
119
|
+
idsIn(book.sessions[session.name] ?? {}).filter((id) => onRecord.has(id)).map((id) => onRecord.get(id)),
|
|
120
|
+
window,
|
|
121
|
+
);
|
|
122
|
+
return { name: session.name, conversations, not_counted: gaps };
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const unclaimed = all([...onRecord.values()].filter((one) => !claimed.has(one.id)), window);
|
|
126
|
+
|
|
127
|
+
return { bot: name, home, sessions, unclaimed: unclaimed.conversations, unclaimed_not_counted: unclaimed.gaps };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Some conversations counted, and what all of them together could not count. */
|
|
131
|
+
function all(transcripts, window) {
|
|
132
|
+
const gaps = noGaps();
|
|
133
|
+
const conversations = transcripts.flatMap((one) => {
|
|
134
|
+
const { row, gaps: its } = counted(one, window);
|
|
135
|
+
addGaps(gaps, its);
|
|
136
|
+
return row === undefined ? [] : [row];
|
|
137
|
+
});
|
|
138
|
+
return { conversations, gaps };
|
|
116
139
|
}
|
|
117
140
|
|
|
118
141
|
/** The conversations a book entry names: the one it is in, then the ones before. */
|
|
@@ -122,9 +145,10 @@ const idsIn = (entry) => [
|
|
|
122
145
|
];
|
|
123
146
|
|
|
124
147
|
/**
|
|
125
|
-
* One conversation, counted
|
|
126
|
-
*
|
|
127
|
-
* happened since, and a page of zeroes is harder to read than a
|
|
148
|
+
* One conversation, counted, and what in it could not be. A conversation that
|
|
149
|
+
* spent nothing in the window has no row rather than a row of zeroes: it is not
|
|
150
|
+
* part of what has happened since, and a page of zeroes is harder to read than a
|
|
151
|
+
* shorter page. What it could not count is still said.
|
|
128
152
|
*/
|
|
129
153
|
function counted(one, window) {
|
|
130
154
|
const read = one.harness === 'claude' ? fromClaude : fromCodex;
|
|
@@ -139,12 +163,16 @@ function counted(one, window) {
|
|
|
139
163
|
byModel: new Map(),
|
|
140
164
|
first: undefined,
|
|
141
165
|
last: undefined,
|
|
166
|
+
gaps: noGaps(),
|
|
142
167
|
};
|
|
143
168
|
|
|
144
|
-
|
|
145
|
-
|
|
169
|
+
const { entries, unreadable, broken } = transcript(one.file);
|
|
170
|
+
tally.gaps.unreadable_transcripts = unreadable;
|
|
171
|
+
tally.gaps.broken_lines = broken;
|
|
172
|
+
read(entries, window, tally);
|
|
173
|
+
if (tally.calls === 0 && KINDS.every((kind) => tally.tokens[kind] === 0)) return { gaps: tally.gaps };
|
|
146
174
|
|
|
147
|
-
return {
|
|
175
|
+
return { gaps: tally.gaps, row: {
|
|
148
176
|
id: one.id,
|
|
149
177
|
calls: tally.calls,
|
|
150
178
|
tokens: tally.tokens,
|
|
@@ -154,7 +182,7 @@ function counted(one, window) {
|
|
|
154
182
|
compactions: tally.compactions,
|
|
155
183
|
first: new Date(tally.first).toISOString(),
|
|
156
184
|
last: new Date(tally.last).toISOString(),
|
|
157
|
-
};
|
|
185
|
+
} };
|
|
158
186
|
}
|
|
159
187
|
|
|
160
188
|
/**
|
|
@@ -175,6 +203,13 @@ function counted(one, window) {
|
|
|
175
203
|
* start. A transcript only grows, so one run's end is the next run's start and
|
|
176
204
|
* the runs add up to the whole, with a call still being written at a boundary
|
|
177
205
|
* charged once, part to each side (#169).
|
|
206
|
+
*
|
|
207
|
+
* A record with no time, or with a figure missing, is left out whole and said to
|
|
208
|
+
* be; the call it belongs to is counted from its other records, if it has any.
|
|
209
|
+
* But when the record last written before the window's start is one of those,
|
|
210
|
+
* what the call had grown to by then is not known, and what it grew by inside
|
|
211
|
+
* the window cannot be told apart from what it grew by before. So the call is
|
|
212
|
+
* not counted in that window, and is said to be.
|
|
178
213
|
*/
|
|
179
214
|
function fromClaude(entries, window, tally) {
|
|
180
215
|
const byCall = new Map();
|
|
@@ -182,25 +217,34 @@ function fromClaude(entries, window, tally) {
|
|
|
182
217
|
for (const entry of entries) {
|
|
183
218
|
const when = Date.parse(entry.timestamp ?? '');
|
|
184
219
|
if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
|
|
185
|
-
if (
|
|
220
|
+
if (Number.isNaN(when)) tally.gaps.records_without_time += 1;
|
|
221
|
+
else if (inside(when, window)) tally.compactions += 1;
|
|
186
222
|
continue;
|
|
187
223
|
}
|
|
188
224
|
if (entry.type !== 'assistant') continue;
|
|
189
225
|
|
|
190
226
|
const usage = entry.message?.usage;
|
|
191
227
|
if (usage === undefined || usage === null) continue;
|
|
228
|
+
if (Number.isNaN(when)) {
|
|
229
|
+
tally.gaps.records_without_time += 1;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const broken = !complete(usage, CLAUDE_FIELDS);
|
|
233
|
+
if (broken && inside(when, window)) tally.gaps.records_without_numbers += 1;
|
|
192
234
|
const call = `${entry.requestId}\u0000${entry.message?.id}`;
|
|
193
235
|
if (!byCall.has(call)) byCall.set(call, []);
|
|
194
|
-
byCall.get(call).push({ entry, when });
|
|
236
|
+
byCall.get(call).push({ entry, when, broken });
|
|
195
237
|
}
|
|
196
238
|
|
|
197
239
|
for (const records of byCall.values()) {
|
|
198
240
|
const made = records[0].when;
|
|
199
|
-
|
|
200
|
-
// `inside`: it counts as written before the end and not before the start.
|
|
201
|
-
const atEnd = records.findLast(({ when }) => Number.isNaN(when) || when < window.to);
|
|
241
|
+
const atEnd = records.findLast(({ when, broken }) => !broken && when < window.to);
|
|
202
242
|
if (atEnd === undefined) continue;
|
|
203
|
-
const atStart = records.findLast(({ when }) =>
|
|
243
|
+
const atStart = records.findLast(({ when }) => when < window.from);
|
|
244
|
+
if (atStart?.broken) {
|
|
245
|
+
if (inside(atEnd.when, window)) tally.gaps.records_without_numbers += 1;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
204
248
|
|
|
205
249
|
const now = figures(atEnd.entry);
|
|
206
250
|
const was = atStart === undefined ? undefined : figures(atStart.entry);
|
|
@@ -212,6 +256,9 @@ function fromClaude(entries, window, tally) {
|
|
|
212
256
|
}
|
|
213
257
|
}
|
|
214
258
|
|
|
259
|
+
/** The figures Claude Code writes on every call, all of which a record needs to be counted. */
|
|
260
|
+
const CLAUDE_FIELDS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens'];
|
|
261
|
+
|
|
215
262
|
/** What one Claude Code record says its call used so far. */
|
|
216
263
|
function figures(entry) {
|
|
217
264
|
const usage = entry.message.usage;
|
|
@@ -252,11 +299,23 @@ const CODEX_FIELDS = [
|
|
|
252
299
|
*
|
|
253
300
|
* The running total is followed through events outside the window as well, since
|
|
254
301
|
* what a call added can only be measured against the event before it.
|
|
302
|
+
*
|
|
303
|
+
* An event whose running total has a figure missing is left out and said to be,
|
|
304
|
+
* and after it the running total is not known, so neither is what the next
|
|
305
|
+
* complete event added. Measured against the event before the broken one, it
|
|
306
|
+
* would take in what the broken one used, at its own time and perhaps in
|
|
307
|
+
* another window; taken by its own per-call figure, it may be the broken one
|
|
308
|
+
* written down again. Telling those apart is guessing, so it is not counted
|
|
309
|
+
* either, and is said to be; the running total follows on from it. A call whose
|
|
310
|
+
* figure is needed and has something missing is not counted, and is said to
|
|
311
|
+
* be; nothing missing is taken as zero.
|
|
255
312
|
*/
|
|
256
313
|
function fromCodex(entries, window, tally) {
|
|
257
314
|
let model;
|
|
258
315
|
let effort;
|
|
259
316
|
let running;
|
|
317
|
+
// Whether a running total with something missing came since the last complete one.
|
|
318
|
+
let broken = false;
|
|
260
319
|
|
|
261
320
|
for (const entry of entries) {
|
|
262
321
|
const when = Date.parse(entry.timestamp ?? '');
|
|
@@ -269,48 +328,73 @@ function fromCodex(entries, window, tally) {
|
|
|
269
328
|
continue;
|
|
270
329
|
}
|
|
271
330
|
if (entry.type === 'compacted') {
|
|
272
|
-
if (
|
|
331
|
+
if (Number.isNaN(when)) tally.gaps.records_without_time += 1;
|
|
332
|
+
else if (inside(when, window)) tally.compactions += 1;
|
|
273
333
|
continue;
|
|
274
334
|
}
|
|
275
335
|
if (entry.type !== 'event_msg' || entry.payload?.type !== 'token_count') continue;
|
|
276
336
|
|
|
337
|
+
// No `info` is a note about rate limits, not a record of usage.
|
|
277
338
|
const info = entry.payload?.info;
|
|
278
339
|
if (info === undefined || info === null) continue;
|
|
279
340
|
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
341
|
+
const total = info.total_token_usage ?? undefined;
|
|
342
|
+
let used;
|
|
343
|
+
if (total === undefined) {
|
|
344
|
+
// Nothing to measure against: the per-call figure is all there is. After a
|
|
345
|
+
// broken running total it may be the broken one written again, so it is
|
|
346
|
+
// not counted either until a complete running total says where things are.
|
|
347
|
+
used = broken ? null : perCall(info);
|
|
348
|
+
} else if (!complete(total, CODEX_READ)) {
|
|
349
|
+
used = null;
|
|
350
|
+
broken = true;
|
|
351
|
+
} else {
|
|
352
|
+
used = broken ? null : spent(running, total, info);
|
|
353
|
+
running = total;
|
|
354
|
+
broken = false;
|
|
283
355
|
}
|
|
356
|
+
|
|
284
357
|
// A repeat is not a call, whether or not it is inside the window.
|
|
285
358
|
if (used === undefined) continue;
|
|
359
|
+
if (Number.isNaN(when)) {
|
|
360
|
+
tally.gaps.records_without_time += 1;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
286
363
|
if (!inside(when, window)) continue;
|
|
364
|
+
if (used === null) {
|
|
365
|
+
tally.gaps.records_without_numbers += 1;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
287
368
|
|
|
288
369
|
count(tally, used, model, effort, when);
|
|
289
370
|
}
|
|
290
371
|
}
|
|
291
372
|
|
|
373
|
+
/** The fields Codex itself writes; it has no cache writes to report. */
|
|
374
|
+
const CODEX_READ = CODEX_FIELDS.filter((field) => field !== 'cache_write_input_tokens').concat('total_tokens');
|
|
375
|
+
|
|
292
376
|
/**
|
|
293
|
-
* What one Codex event says its call used, measured against the
|
|
294
|
-
* `undefined` where the event is the one before written down again
|
|
377
|
+
* What one Codex event says its call used, measured against the running total
|
|
378
|
+
* before it: `undefined` where the event is the one before written down again,
|
|
379
|
+
* and `null` where what it used cannot be known.
|
|
295
380
|
*/
|
|
296
|
-
function spent(running, info) {
|
|
297
|
-
const total = info.total_token_usage;
|
|
298
|
-
if (total === undefined || total === null) {
|
|
299
|
-
// Nothing to measure against: the per-call figure is all there is.
|
|
300
|
-
return info.last_token_usage === undefined ? undefined : kindsOf(info.last_token_usage);
|
|
301
|
-
}
|
|
381
|
+
function spent(running, total, info) {
|
|
302
382
|
if (running === undefined) return kindsOf(total);
|
|
303
383
|
|
|
304
|
-
const moved =
|
|
384
|
+
const moved = total.total_tokens - running.total_tokens;
|
|
305
385
|
if (moved === 0) return undefined;
|
|
306
386
|
// A fall is a new window, and the running total is counting again from there.
|
|
307
|
-
if (moved < 0) return
|
|
387
|
+
if (moved < 0) return perCall(info);
|
|
308
388
|
|
|
309
389
|
return kindsOf(Object.fromEntries(
|
|
310
390
|
CODEX_FIELDS.map((field) => [field, number(total[field]) - number(running[field])]),
|
|
311
391
|
));
|
|
312
392
|
}
|
|
313
393
|
|
|
394
|
+
/** A Codex event's own per-call figure, or `null` when something in it is missing. */
|
|
395
|
+
const perCall = (info) =>
|
|
396
|
+
complete(info.last_token_usage, CODEX_READ) ? kindsOf(info.last_token_usage) : null;
|
|
397
|
+
|
|
314
398
|
/**
|
|
315
399
|
* One Codex figure in the kinds this command reports. The cached tokens sit
|
|
316
400
|
* inside `input_tokens` here and outside it on Claude Code, so they come out of
|
|
@@ -351,24 +435,32 @@ function count(tally, used, model, effort, when, calls = 1) {
|
|
|
351
435
|
}
|
|
352
436
|
|
|
353
437
|
/** The lines of a transcript that are readable JSON; the rest say nothing. */
|
|
354
|
-
|
|
438
|
+
export const lines = (file) => transcript(file).entries;
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The lines of a transcript that are JSON records, and how many were not. A
|
|
442
|
+
* transcript that cannot be read is said to be, rather than a run that stops.
|
|
443
|
+
*/
|
|
444
|
+
function transcript(file) {
|
|
355
445
|
let text;
|
|
356
446
|
try {
|
|
357
447
|
text = readFileSync(file, 'utf8');
|
|
358
448
|
} catch {
|
|
359
|
-
|
|
360
|
-
// about, which is the honest answer rather than a run that stops.
|
|
361
|
-
return [];
|
|
449
|
+
return { entries: [], unreadable: 1, broken: 0 };
|
|
362
450
|
}
|
|
363
|
-
|
|
451
|
+
let broken = 0;
|
|
452
|
+
const entries = text.split('\n').flatMap((line) => {
|
|
364
453
|
if (line.trim() === '') return [];
|
|
365
454
|
try {
|
|
366
455
|
const entry = JSON.parse(line);
|
|
367
|
-
|
|
456
|
+
if (entry !== null && typeof entry === 'object' && !Array.isArray(entry)) return [entry];
|
|
368
457
|
} catch {
|
|
369
|
-
|
|
458
|
+
// Counted below with any other line that is not a record.
|
|
370
459
|
}
|
|
460
|
+
broken += 1;
|
|
461
|
+
return [];
|
|
371
462
|
});
|
|
463
|
+
return { entries, unreadable: 0, broken };
|
|
372
464
|
}
|
|
373
465
|
|
|
374
466
|
/** A bot home as the file system knows it, or as it was given when it is not there. */
|
|
@@ -382,20 +474,23 @@ function realHome(home) {
|
|
|
382
474
|
|
|
383
475
|
const number = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
|
|
384
476
|
|
|
477
|
+
/** Whether a figure has every field it should, each a number. */
|
|
478
|
+
const complete = (figure, fields) =>
|
|
479
|
+
figure !== undefined && figure !== null
|
|
480
|
+
&& fields.every((field) => typeof figure[field] === 'number' && Number.isFinite(figure[field]));
|
|
481
|
+
|
|
385
482
|
const add = (set, value) => {
|
|
386
483
|
if (typeof value === 'string' && value !== '') set.add(value);
|
|
387
484
|
};
|
|
388
485
|
|
|
389
486
|
/**
|
|
390
487
|
* Whether a moment is in the window: at or after its start, before its end. A
|
|
391
|
-
* record with no time of its own is
|
|
392
|
-
* says it is outside.
|
|
488
|
+
* record with no time of its own is in no window; it is left out and said to be.
|
|
393
489
|
*/
|
|
394
|
-
const inside = (when, { from, to }) =>
|
|
490
|
+
const inside = (when, { from, to }) => when >= from && when < to;
|
|
395
491
|
|
|
396
492
|
/** When the first and the last counted call of this conversation were. */
|
|
397
493
|
function mark(tally, when) {
|
|
398
|
-
if (Number.isNaN(when)) return;
|
|
399
494
|
if (tally.first === undefined || when < tally.first) tally.first = when;
|
|
400
495
|
if (tally.last === undefined || when > tally.last) tally.last = when;
|
|
401
496
|
}
|