@assuredloop/orca-bot-kit 0.16.0 → 0.18.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/SETUP.md +1 -1
- package/package.json +1 -1
- package/rules/limits.md +1 -1
- package/rules/mail.md +3 -0
- package/skills/obk-bot-building/SKILL.md +8 -3
- package/skills/obk-finops/SKILL.md +30 -0
- package/skills/obk-fleet-review/SKILL.md +4 -0
- package/skills/obk-grooming/SKILL.md +4 -0
- package/skills/obk-personal-facilitation/SKILL.md +30 -0
- package/skills/obk-tdd/SKILL.md +41 -3
- package/src/conversations.js +37 -12
- package/src/lines.js +77 -0
- package/src/orca.js +15 -1
- package/src/usage.js +52 -21
package/SETUP.md
CHANGED
|
@@ -199,7 +199,7 @@ waiting for:
|
|
|
199
199
|
| Codex's `/new`: `Where should the new conversation run?` | `\r` | return: its selection starts on **Current checkout** (bot home) | Codex 0.158.0 |
|
|
200
200
|
| Codex's update offer, `1. Update now` | `\r` | return: its selection starts on **Update now**, which accepts it | Codex 0.156.1; seen on 0.157.1 (offering 0.158.0) with the selection on **Update now**, not pressed; 0.158.0 has had no newer release to offer |
|
|
201
201
|
| `[oh-my-zsh] Would you like to update?` | `n` | they update their own shell | oh-my-zsh bf77e35 |
|
|
202
|
-
| Claude Code's `Teach auto mode about your environment?` | `\x1b` | Esc, which cancels it. On 2.1.283 it is a form, not a list: `How you use Claude here`, `Also scan shell history`, `Also scan your other repos`, `Continue`, and `Enter to continue · Esc to cancel`. **Never send a return**: Enter is **Continue**, which starts a scan of the project, recent Claude sessions and, by default, the machine's shell history | Not yet proven: Esc cancels in Claude Code 2.1.283's code, not yet seen live (#370); the form was seen live on 2.1.283 (#416); 2.1.278 showed a numbered list, answered with 2. Not now |
|
|
202
|
+
| Claude Code's `Teach auto mode about your environment?` | `\x1b` | Esc, which cancels it (**Not now**). On 2.1.283 it is a form, not a list: `How you use Claude here`, `Also scan shell history`, `Also scan your other repos`, `Continue`, and `Enter to continue · Esc to cancel`. **Never send a return**: Enter is **Continue**, which starts a scan of the project, recent Claude sessions and, by default, the machine's shell history | Not yet proven: Esc cancels in Claude Code 2.1.283's code, not yet seen live (#370); the form was seen live twice on 2.1.283, in #261's live test and #239's live run 1 (#416); 2.1.278 showed a numbered list, answered with 2. Not now |
|
|
203
203
|
|
|
204
204
|
Each key counts on where the selection starts, as it did on the version named.
|
|
205
205
|
Read the screen in front of you before you send: if the selection is somewhere
|
package/package.json
CHANGED
package/rules/limits.md
CHANGED
|
@@ -18,5 +18,5 @@ When something you were told to use is missing (a model, a tool, a file), say
|
|
|
18
18
|
so and ask. Quietly using a different one hides the change.
|
|
19
19
|
|
|
20
20
|
Permission rules in a harness's settings or rules files are the kit's to write,
|
|
21
|
-
after the user's yes to the exact rules: `obk bot change --allow <rule>` or
|
|
21
|
+
after the user's yes to the exact rules: `"${OBK_CLI:-obk}" bot change --allow <rule>` or
|
|
22
22
|
`--disallow <rule>`. Never edit one in or out yourself, Bot Father included.
|
package/rules/mail.md
CHANGED
|
@@ -12,5 +12,8 @@ Mail is queued, not an interruption. Read it when you finish what you are on.
|
|
|
12
12
|
|
|
13
13
|
Ask for a reply when you need one, and send one when you were asked for one.
|
|
14
14
|
|
|
15
|
+
A message sent is queued, not read or acted on: the reply, or the work done,
|
|
16
|
+
is what shows it arrived. Look for one before you send it again.
|
|
17
|
+
|
|
15
18
|
Write to the session that needs it, not to several in the hope that one of
|
|
16
19
|
them is right.
|
|
@@ -19,6 +19,10 @@ own commands write them.
|
|
|
19
19
|
These are defaults for work where nothing says otherwise. A user who asks for
|
|
20
20
|
something else gets what they asked for; say which of these you left and why.
|
|
21
21
|
|
|
22
|
+
Where this skill says `obk`, run the kit that started your tab, `"${OBK_CLI:-obk}"`,
|
|
23
|
+
in its place: in a tab the kit launched that is the kit's own path, and anywhere
|
|
24
|
+
else it is plain `obk`.
|
|
25
|
+
|
|
22
26
|
## Ask whether this wants a new bot
|
|
23
27
|
|
|
24
28
|
Look at the fleet before you add to it. Where a bot already there could own
|
|
@@ -289,7 +293,7 @@ otherwise `/Applications/Orca.app/Contents/Resources/bin/orca`. A bare
|
|
|
289
293
|
| Codex's `Where should the new conversation run?` | `\r` | **Current checkout** | Codex 0.158.0 |
|
|
290
294
|
| Codex's update offer, `1. Update now` | `\r` | **Update now**, where it starts | Codex 0.156.1 |
|
|
291
295
|
| `[oh-my-zsh] Would you like to update?` | `n` | no: the user updates it | oh-my-zsh bf77e35 |
|
|
292
|
-
| Claude Code's `Teach auto mode…` | `\x1b` | Esc
|
|
296
|
+
| Claude Code's `Teach auto mode…` | `\x1b` | Esc: Not now; no return | Not yet proven, below |
|
|
293
297
|
|
|
294
298
|
Each key counts on where the selection starts on the version named. Read the
|
|
295
299
|
screen before you send: if the selection is elsewhere or the options have
|
|
@@ -302,8 +306,9 @@ Update now, not pressed, and 0.158.0 has had no newer release to offer.
|
|
|
302
306
|
Claude Code's `Teach auto mode` on 2.1.283 is a form, not a list, ending
|
|
303
307
|
`Enter to continue · Esc to cancel`: never send it a return, since
|
|
304
308
|
Enter is Continue, which scans the project, recent sessions and the machine's
|
|
305
|
-
shell history. Esc cancels in its code; that is not yet proven
|
|
306
|
-
|
|
309
|
+
shell history. Esc cancels it (Not now) in its code; that is not yet proven
|
|
310
|
+
live, though the form was seen live twice on 2.1.283. On 2.1.278 it was a
|
|
311
|
+
numbered list, answered with `2. Not now`. Codex's hooks
|
|
307
312
|
question matters most. The kit's hook is how the book learns which
|
|
308
313
|
conversation the session is running, and until it is answered the
|
|
309
314
|
conversation has not started. Codex's trust applies to the repository root,
|
|
@@ -24,6 +24,10 @@ kind that gets read once.
|
|
|
24
24
|
These are defaults for work where nothing says otherwise. A user who asks for
|
|
25
25
|
something else gets what they asked for; say which of these you left and why.
|
|
26
26
|
|
|
27
|
+
Where this skill says `obk`, run the kit that started your tab, `"${OBK_CLI:-obk}"`,
|
|
28
|
+
in its place: in a tab the kit launched that is the kit's own path, and anywhere
|
|
29
|
+
else it is plain `obk`.
|
|
30
|
+
|
|
27
31
|
## Start from what was used, not from an impression
|
|
28
32
|
|
|
29
33
|
The kit counts it for you. `obk usage` reports, per session, the conversations
|
|
@@ -60,6 +64,32 @@ than metered billing, tokens are not money at all, and the honest answer is what
|
|
|
60
64
|
would have cost metered, and what share of the allowance it used where the
|
|
61
65
|
plan shows that.
|
|
62
66
|
|
|
67
|
+
## When a bill and an estimate cover the same work
|
|
68
|
+
|
|
69
|
+
An invoice, a bill or the provider's usage page is better evidence than an
|
|
70
|
+
estimate made from tokens and a price you looked up today. Where both cover
|
|
71
|
+
the same work, report the charge and say that is where the figure came from.
|
|
72
|
+
The estimate is not added to it: it measures the same spending a second time,
|
|
73
|
+
and is worth keeping beside the charge only to show how far the two differ.
|
|
74
|
+
|
|
75
|
+
Watch for the same thing in smaller pieces. A figure for a whole session
|
|
76
|
+
already includes the calls inside it, so do not add the per-call figures to
|
|
77
|
+
it, or a day's total to the sessions it is made of. Take one level and say
|
|
78
|
+
which.
|
|
79
|
+
|
|
80
|
+
Keep currencies apart. An invoice in euros and an estimate in dollars are two
|
|
81
|
+
figures, each with its currency, until you have a rate you can name, with its
|
|
82
|
+
date and its source. Converting quietly hides a guess inside a number.
|
|
83
|
+
|
|
84
|
+
And an unknown stays unknown. A day with no invoice yet and no price you could
|
|
85
|
+
find is a gap, not a zero: a zero says the work was free.
|
|
86
|
+
|
|
87
|
+
A worked example. For 20 September the provider's invoice says 41.20 USD for
|
|
88
|
+
the API, and the token estimate for the same day, at today's list price, is
|
|
89
|
+
38.90 USD. Report one figure, 41.20 USD, with its source: the invoice. Add
|
|
90
|
+
that the estimate came to 38.90 USD, so a reader can see the estimate runs
|
|
91
|
+
about six per cent low, and leave it out of the total.
|
|
92
|
+
|
|
63
93
|
## Cost a result, not a token
|
|
64
94
|
|
|
65
95
|
Tokens per day tells you nothing about whether the money bought anything. Put
|
|
@@ -25,6 +25,10 @@ act on your own, and that is set by the charter you are working under.
|
|
|
25
25
|
These are defaults for work where nothing says otherwise. A user who asks for
|
|
26
26
|
something else gets what they asked for; say which of these you left and why.
|
|
27
27
|
|
|
28
|
+
Where this skill says `obk`, run the kit that started your tab, `"${OBK_CLI:-obk}"`,
|
|
29
|
+
in its place: in a tab the kit launched that is the kit's own path, and anywhere
|
|
30
|
+
else it is plain `obk`.
|
|
31
|
+
|
|
28
32
|
## Start from what is on disk
|
|
29
33
|
|
|
30
34
|
Read the fleet before you talk about it. `obk roster` reports what each bot
|
|
@@ -22,6 +22,10 @@ reports the same thing every morning teaches its reader to stop looking.
|
|
|
22
22
|
These are defaults for work where nothing says otherwise. A user who asks for
|
|
23
23
|
something else gets what they asked for; say which of these you left and why.
|
|
24
24
|
|
|
25
|
+
Where this skill says `obk`, run the kit that started your tab, `"${OBK_CLI:-obk}"`,
|
|
26
|
+
in its place: in a tab the kit launched that is the kit's own path, and anywhere
|
|
27
|
+
else it is plain `obk`.
|
|
28
|
+
|
|
25
29
|
## Only what has happened since last time
|
|
26
30
|
|
|
27
31
|
Fix the end of the window before you read, not after. Take the moment you are
|
|
@@ -33,6 +33,36 @@ they actually gave you: no invented deadline, priority, category or detail.
|
|
|
33
33
|
Where an item is too vague to carry a next action, keep it and ask. Do what you
|
|
34
34
|
can first, then ask once, in your reply rather than in a file.
|
|
35
35
|
|
|
36
|
+
## When the list comes from several places
|
|
37
|
+
|
|
38
|
+
Chats, mail and trackers are full of questions to a group, answered later by
|
|
39
|
+
someone else, or overtaken since. Read only the sources they gave you, and put
|
|
40
|
+
on the list only what is theirs.
|
|
41
|
+
|
|
42
|
+
An item is theirs when it was addressed to them, by name or directly, or when
|
|
43
|
+
they took it on ("I'll look at it"). A question to a group they are in is not
|
|
44
|
+
theirs for being in the group.
|
|
45
|
+
|
|
46
|
+
Before you add one, read what came after it: a later reply may have answered
|
|
47
|
+
it, or someone else may have taken it. Where a tracker keeps its own state,
|
|
48
|
+
that state wins over an old message about it: a closed issue is closed,
|
|
49
|
+
whatever the thread said last week.
|
|
50
|
+
|
|
51
|
+
Tell three kinds apart. Something they have to do goes on the list. A quick
|
|
52
|
+
question they can answer in a line goes to them now, with the answer ready if
|
|
53
|
+
you have it. Information that asks nothing of them does not go on the list at
|
|
54
|
+
all.
|
|
55
|
+
|
|
56
|
+
And their own word beats every source. An item they took off or rewrote stays
|
|
57
|
+
as they left it, even where the chat still says otherwise: do not put it back.
|
|
58
|
+
|
|
59
|
+
For example: on Monday someone asks the team chat "can anyone look at the
|
|
60
|
+
failing build?". They are in the team, and nobody names them. On Tuesday a
|
|
61
|
+
colleague replies "fixed, it was the cache". That is not their item: it was not
|
|
62
|
+
addressed to them, and someone else has answered it. Had it said "@them, can
|
|
63
|
+
you look at the build?" with no reply since, it would be an action on their
|
|
64
|
+
list, with the message as where it came from.
|
|
65
|
+
|
|
36
66
|
## Change the entry, do not add another
|
|
37
67
|
|
|
38
68
|
When they revise something, edit the entry that is there and leave every other
|
package/skills/obk-tdd/SKILL.md
CHANGED
|
@@ -410,6 +410,36 @@ check, untested guard clauses and early returns, error branches, boundaries
|
|
|
410
410
|
mutable state, order dependence, the clock, the timezone, the real network, a
|
|
411
411
|
sleep, an assertion on the order of something unordered.
|
|
412
412
|
|
|
413
|
+
## Generated inputs: is there one the code gets wrong?
|
|
414
|
+
|
|
415
|
+
The mutation check asks whether the tests would notice if the code were wrong.
|
|
416
|
+
This asks the other question: is there an input the code gets wrong? Example
|
|
417
|
+
tests only try the inputs someone thought of. Generated ones try many more.
|
|
418
|
+
|
|
419
|
+
They earn their place where the inputs are many and a rule holds for all of
|
|
420
|
+
them: parsers, encoders and decoders, anything with a round trip, normalisers,
|
|
421
|
+
sorting and merging, and code with an invariant. A function with three cases
|
|
422
|
+
is better served by three example tests.
|
|
423
|
+
|
|
424
|
+
State the property first, as a sentence true of every input: decoding what was
|
|
425
|
+
encoded gives the input back; the output is sorted and holds the same elements;
|
|
426
|
+
running it twice gives what running it once gave; it agrees with a slow, obvious
|
|
427
|
+
version of itself. A property that restates the implementation proves nothing.
|
|
428
|
+
|
|
429
|
+
Generate the inputs with a small loop and a seeded generator. No library is
|
|
430
|
+
needed, and none should become a dependency for this. Weight them towards the
|
|
431
|
+
edges (empty, one element, the largest, odd characters, the boundaries the
|
|
432
|
+
requirement names), bound the count and the time, and print the seed, so a
|
|
433
|
+
failure can be run again exactly.
|
|
434
|
+
|
|
435
|
+
When one fails, make the input as small as it will go while it still fails, and
|
|
436
|
+
keep it as an ordinary example test: red first, then the fix. The generated test
|
|
437
|
+
stays too, but the example is what guards that bug from now on.
|
|
438
|
+
|
|
439
|
+
Report what was generated, how many, the seed, and the property. Say what failed
|
|
440
|
+
and which example tests it became. "Two thousand inputs, seed 7, no failure" is
|
|
441
|
+
evidence for those inputs, not a proof, so say it that way.
|
|
442
|
+
|
|
413
443
|
## The mutation check before you call it done
|
|
414
444
|
|
|
415
445
|
The question is the whole point: if a real mistake were made in this code,
|
|
@@ -424,9 +454,17 @@ make a test fail, each one put back. Stop when another break would add no
|
|
|
424
454
|
evidence, and say why the set you ran was enough: two meaningful breaks beat five
|
|
425
455
|
padded ones. Reported in three lines.
|
|
426
456
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
457
|
+
Make the breaks in a disposable copy of the change, never in the working tree:
|
|
458
|
+
a git worktree, or a copy of the folder, made for the check and thrown away
|
|
459
|
+
after it. A run that is interrupted, a restore that fails, or a second writer in
|
|
460
|
+
the same checkout would otherwise leave a planted defect in real code. Before you
|
|
461
|
+
start, note what `git status` and `git diff` say about the real tree.
|
|
462
|
+
|
|
463
|
+
One break at a time, never two at once: in the copy, note the original, apply
|
|
464
|
+
the break, run the tests there, record killed or survived, put the code back,
|
|
465
|
+
then the next one. When the check is done, remove the copy, and confirm the
|
|
466
|
+
working tree is untouched: `git status` and `git diff` say what they said before
|
|
467
|
+
the check.
|
|
430
468
|
|
|
431
469
|
What to break, in this order: boundaries (`<` for `<=`, one off), boolean logic
|
|
432
470
|
(`&&` for `||`, a dropped `not`), returned values (an empty or default return,
|
package/src/conversations.js
CHANGED
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
// directory, and every session starts at its bot home. Read only, never
|
|
11
11
|
// written: these are the harness's files (tech notes, sections 2 and 3).
|
|
12
12
|
|
|
13
|
-
import { existsSync, readdirSync,
|
|
13
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
14
14
|
import { homedir } from 'node:os';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
|
|
17
|
+
import { eachLine, firstLine } from './lines.js';
|
|
18
|
+
|
|
17
19
|
/** Claude Code's transcripts: one folder per working directory, one file per session. */
|
|
18
20
|
const claudeDir = (home) => path.join(homedir(), '.claude', 'projects', slug(home));
|
|
19
21
|
|
|
@@ -53,12 +55,32 @@ export function hasConversation(harness, home, id) {
|
|
|
53
55
|
return codexRollout(id) !== undefined;
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
/** Where Codex moves a rollout the user archives: one flat folder (#396). Other ways it archives are not known. */
|
|
59
|
+
const codexArchive = () => path.join(homedir(), '.codex', 'archived_sessions');
|
|
60
|
+
|
|
56
61
|
/**
|
|
57
62
|
* Where Codex keeps the conversation `id`, whatever folder it ran in, found by
|
|
58
|
-
* the file name Codex makes from the id
|
|
63
|
+
* the file name Codex makes from the id: among its sessions, or else among the
|
|
64
|
+
* ones archived; `undefined` when it has none.
|
|
59
65
|
*/
|
|
60
|
-
export const codexRollout = (id) =>
|
|
61
|
-
|
|
66
|
+
export const codexRollout = (id) => {
|
|
67
|
+
const named = (file) => path.basename(file).endsWith(`-${id}.jsonl`);
|
|
68
|
+
return rollouts(codexDir()).find(named)
|
|
69
|
+
?? files(codexArchive()).map((name) => path.join(codexArchive(), name)).find(named);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Where Claude Code keeps the conversation `id`, whatever folder it ran in: the
|
|
74
|
+
* `<id>.jsonl` in any of its project folders; `undefined` when it has none.
|
|
75
|
+
*/
|
|
76
|
+
export function claudeTranscriptAnywhere(id) {
|
|
77
|
+
const projects = path.join(homedir(), '.claude', 'projects');
|
|
78
|
+
for (const folder of files(projects)) {
|
|
79
|
+
const file = path.join(projects, folder, `${id}.jsonl`);
|
|
80
|
+
if (existsSync(file)) return file;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
62
84
|
|
|
63
85
|
/**
|
|
64
86
|
* Where Claude Code keeps the conversation `id` of this bot home, whether or
|
|
@@ -115,14 +137,19 @@ export function transcriptsIn(harness, home, since) {
|
|
|
115
137
|
* Its AGENTS.md goes in as a `user` item too, and cannot be the text asked about.
|
|
116
138
|
*/
|
|
117
139
|
export function heldAsUserTurn(harness, file, text) {
|
|
118
|
-
|
|
140
|
+
const wanted = text.trim();
|
|
141
|
+
let held = false;
|
|
119
142
|
try {
|
|
120
|
-
|
|
143
|
+
// A line at a time, whatever the record's size (#396), and no further than
|
|
144
|
+
// the turn that holds it.
|
|
145
|
+
eachLine(file, (line) => {
|
|
146
|
+
held = line !== undefined && userTexts(harness, line).some((said) => said.trim() === wanted);
|
|
147
|
+
return held;
|
|
148
|
+
});
|
|
121
149
|
} catch {
|
|
122
150
|
return false;
|
|
123
151
|
}
|
|
124
|
-
|
|
125
|
-
return lines.some((line) => userTexts(harness, line).some((said) => said.trim() === wanted));
|
|
152
|
+
return held;
|
|
126
153
|
}
|
|
127
154
|
|
|
128
155
|
/** The texts of one record line, when it is a turn of the user's. */
|
|
@@ -160,8 +187,7 @@ function claudeConversations(home) {
|
|
|
160
187
|
/** When a transcript's first line says the conversation was, if it says at all. */
|
|
161
188
|
function said(file) {
|
|
162
189
|
try {
|
|
163
|
-
const
|
|
164
|
-
const when = Date.parse(JSON.parse(first)?.timestamp ?? '');
|
|
190
|
+
const when = Date.parse(JSON.parse(firstLine(file) ?? '')?.timestamp ?? '');
|
|
165
191
|
return Number.isNaN(when) ? undefined : when;
|
|
166
192
|
} catch {
|
|
167
193
|
return undefined;
|
|
@@ -201,8 +227,7 @@ function rollouts(dir, depth = 0) {
|
|
|
201
227
|
/** A rollout's first line says what the conversation is: its id, folder and time. */
|
|
202
228
|
function sessionMeta(file) {
|
|
203
229
|
try {
|
|
204
|
-
const
|
|
205
|
-
const parsed = JSON.parse(first);
|
|
230
|
+
const parsed = JSON.parse(firstLine(file) ?? '');
|
|
206
231
|
return parsed?.type === 'session_meta' ? parsed.payload : undefined;
|
|
207
232
|
} catch {
|
|
208
233
|
return undefined;
|
package/src/lines.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// A transcript read a line at a time, whatever its size (#396).
|
|
2
|
+
//
|
|
3
|
+
// A harness's record of a conversation can outgrow what one string holds: a
|
|
4
|
+
// Codex rollout of 1.28 GB was seen on a live fleet, and reading it whole
|
|
5
|
+
// threw, so the conversation dropped out of the book's lookup and its usage
|
|
6
|
+
// without a word. So nothing here reads a file whole. The first line is read
|
|
7
|
+
// from the start of the file alone, and a whole transcript goes through a
|
|
8
|
+
// chunk at a time, its text decoded across the chunks so that a character
|
|
9
|
+
// split between two of them stays whole.
|
|
10
|
+
//
|
|
11
|
+
// A line longer than any record the kit reads is not held: it comes through as
|
|
12
|
+
// `undefined`, for the caller to count as a line it could not read. A file cut
|
|
13
|
+
// short and padded (a sparse run of NUL bytes, one enormous "line") is read
|
|
14
|
+
// through to its end without being held either.
|
|
15
|
+
|
|
16
|
+
import { closeSync, openSync, readSync } from 'node:fs';
|
|
17
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
18
|
+
|
|
19
|
+
/** How much is read at a time. */
|
|
20
|
+
const CHUNK = 64 * 1024;
|
|
21
|
+
|
|
22
|
+
/** The longest line held whole. */
|
|
23
|
+
const LONGEST = 64 * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Each line of `file`, in order, to `onLine`: its text, or `undefined` for a
|
|
27
|
+
* line too long to hold. A line `onLine` answers `true` to is the last one
|
|
28
|
+
* read. Throws what the file system throws when the file cannot be opened or
|
|
29
|
+
* read.
|
|
30
|
+
*/
|
|
31
|
+
export function eachLine(file, onLine) {
|
|
32
|
+
const fd = openSync(file, 'r');
|
|
33
|
+
try {
|
|
34
|
+
const decoder = new StringDecoder('utf8');
|
|
35
|
+
const buffer = Buffer.allocUnsafe(CHUNK);
|
|
36
|
+
let pending = '';
|
|
37
|
+
let tooLong = false;
|
|
38
|
+
for (;;) {
|
|
39
|
+
const read = readSync(fd, buffer, 0, CHUNK, null);
|
|
40
|
+
const text = read === 0 ? decoder.end() : decoder.write(buffer.subarray(0, read));
|
|
41
|
+
let start = 0;
|
|
42
|
+
for (let at = text.indexOf('\n'); at !== -1; at = text.indexOf('\n', start)) {
|
|
43
|
+
const line = tooLong ? undefined : pending + text.slice(start, at);
|
|
44
|
+
pending = '';
|
|
45
|
+
tooLong = false;
|
|
46
|
+
start = at + 1;
|
|
47
|
+
if (onLine(line) === true) return;
|
|
48
|
+
}
|
|
49
|
+
const rest = text.slice(start);
|
|
50
|
+
if (!tooLong) {
|
|
51
|
+
if (pending.length + rest.length > LONGEST) {
|
|
52
|
+
tooLong = true;
|
|
53
|
+
pending = '';
|
|
54
|
+
} else {
|
|
55
|
+
pending += rest;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (read === 0) {
|
|
59
|
+
if (tooLong) onLine(undefined);
|
|
60
|
+
else if (pending !== '') onLine(pending);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} finally {
|
|
65
|
+
closeSync(fd);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The first line of `file`, read from its start alone; `undefined` for an empty file or a first line too long to hold. */
|
|
70
|
+
export function firstLine(file) {
|
|
71
|
+
let first;
|
|
72
|
+
eachLine(file, (line) => {
|
|
73
|
+
first = line;
|
|
74
|
+
return true;
|
|
75
|
+
});
|
|
76
|
+
return first;
|
|
77
|
+
}
|
package/src/orca.js
CHANGED
|
@@ -65,11 +65,25 @@ function ask(args, timeoutMs) {
|
|
|
65
65
|
|
|
66
66
|
const answer = parse(asked.stdout);
|
|
67
67
|
if (answer === null || typeof answer !== 'object') {
|
|
68
|
-
|
|
68
|
+
// What Orca did say, so the failure shows its cause (#438): its exit, and
|
|
69
|
+
// each stream cut short and on one line, so a stack trace in it reads as
|
|
70
|
+
// text and not as the kit's own.
|
|
71
|
+
const exit = asked.status ?? `signal ${asked.signal}`;
|
|
72
|
+
throw new Error(`Orca answered ${args.join(' ')} with something that is not JSON (exit ${exit}; `
|
|
73
|
+
+ `stdout ${said(asked.stdout)}; stderr ${said(asked.stderr)}).`);
|
|
69
74
|
}
|
|
70
75
|
return answer;
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
/** How much of each of Orca's streams an error carries. */
|
|
79
|
+
const SAID = 500;
|
|
80
|
+
|
|
81
|
+
/** A stream of Orca's as an error carries it: its lines folded into one, cut short with an ellipsis, in quotes. */
|
|
82
|
+
const said = (text) => {
|
|
83
|
+
const one = String(text ?? '').trim().replace(/\s*\r?\n\s*/g, ' ⏎ ');
|
|
84
|
+
return `"${one.length > SAID ? `${one.slice(0, SAID)}…` : one}"`;
|
|
85
|
+
};
|
|
86
|
+
|
|
73
87
|
function parse(text) {
|
|
74
88
|
try {
|
|
75
89
|
return JSON.parse(text);
|
package/src/usage.js
CHANGED
|
@@ -39,11 +39,12 @@
|
|
|
39
39
|
// the same call can be in more than one of those files, so a call counts
|
|
40
40
|
// once across all of a conversation's files together (#371).
|
|
41
41
|
|
|
42
|
-
import {
|
|
42
|
+
import { realpathSync } from 'node:fs';
|
|
43
43
|
|
|
44
44
|
import { botDir, botNames, readBot } from './bot.js';
|
|
45
45
|
import { readBook, sessionIdsIn } from './book.js';
|
|
46
|
-
import { claudeSubagentTranscripts, codexRollout, transcriptsIn } from './conversations.js';
|
|
46
|
+
import { claudeSubagentTranscripts, claudeTranscriptAnywhere, codexRollout, transcriptsIn } from './conversations.js';
|
|
47
|
+
import { eachLine } from './lines.js';
|
|
47
48
|
import { HARNESSES, harnessOf } from './launch.js';
|
|
48
49
|
|
|
49
50
|
/** The kinds a call's tokens are reported in, the same on either harness. */
|
|
@@ -123,6 +124,20 @@ function forBot(bots, name, onlySession, window) {
|
|
|
123
124
|
|
|
124
125
|
const claimed = sessionIdsIn(book);
|
|
125
126
|
|
|
127
|
+
// A conversation the book names is its session's wherever the harness filed
|
|
128
|
+
// it: the book says whose it is (ADR 0012). One filed under another
|
|
129
|
+
// folder, or whose record cannot be opened to say which folder it ran in, is
|
|
130
|
+
// found by its id instead, and counted or said to be unreadable (#396).
|
|
131
|
+
for (const id of claimed) {
|
|
132
|
+
if (onRecord.has(id)) continue;
|
|
133
|
+
for (const harness of harnesses) {
|
|
134
|
+
const file = harness === 'codex' ? codexRollout(id) : harness === 'claude' ? claudeTranscriptAnywhere(id) : undefined;
|
|
135
|
+
if (file === undefined) continue;
|
|
136
|
+
onRecord.set(id, { id, file, harness, subagent: false });
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
126
141
|
// Two entries can name the same conversation, one resumed after its session
|
|
127
142
|
// was retired, and one entry can name it twice, now and in its history: it is
|
|
128
143
|
// counted once, in the first block asked for that names it.
|
|
@@ -190,7 +205,7 @@ function counted(one, window) {
|
|
|
190
205
|
tally.gaps.unreadable_transcripts += subagents.unreadable;
|
|
191
206
|
const sources = [one.file, ...subagents.files]
|
|
192
207
|
.map((file, index) => {
|
|
193
|
-
const { entries, unreadable, broken } = transcript(file);
|
|
208
|
+
const { entries, unreadable, broken } = transcript(file, countable);
|
|
194
209
|
tally.gaps.unreadable_transcripts += unreadable;
|
|
195
210
|
tally.gaps.broken_lines += broken;
|
|
196
211
|
return { entries, subagent: index > 0 };
|
|
@@ -463,7 +478,7 @@ function originOf(entries, tally) {
|
|
|
463
478
|
if (typeof id !== 'string' || id === '') return undefined;
|
|
464
479
|
|
|
465
480
|
const file = codexRollout(id);
|
|
466
|
-
const origin = file === undefined ? { unreadable: 1 } : transcript(file);
|
|
481
|
+
const origin = file === undefined ? { unreadable: 1 } : transcript(file, countable);
|
|
467
482
|
// No `info` is a note about rate limits, not a record of usage.
|
|
468
483
|
const records = origin.unreadable > 0 ? [] : origin.entries
|
|
469
484
|
.filter((entry) => entry.type === 'event_msg' && entry.payload?.type === 'token_count')
|
|
@@ -552,28 +567,44 @@ function count(tally, used, model, effort, when, calls = 1, subagent = false) {
|
|
|
552
567
|
export const lines = (file) => transcript(file).entries;
|
|
553
568
|
|
|
554
569
|
/**
|
|
555
|
-
* The
|
|
556
|
-
*
|
|
570
|
+
* The records the counting here reads: Claude Code's `assistant` and `system`
|
|
571
|
+
* lines, Codex's `session_meta`, `turn_context`, `compacted` and `event_msg`.
|
|
572
|
+
* The rest (what was said, tool calls and their output) is most of a long
|
|
573
|
+
* conversation's record and none of what it cost, so it is not held (#396).
|
|
574
|
+
*/
|
|
575
|
+
const COUNTED = new Set(['assistant', 'system', 'session_meta', 'turn_context', 'compacted', 'event_msg']);
|
|
576
|
+
const countable = (entry) => COUNTED.has(entry.type);
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* The lines of a transcript that are JSON records, and how many were not; with
|
|
580
|
+
* `keep`, only the records it takes. A transcript that cannot be read is said
|
|
581
|
+
* to be, rather than a run that stops. It is read a line at a time, whatever
|
|
582
|
+
* its size, and a line too long to hold counts as one that is not a record
|
|
583
|
+
* (#396).
|
|
557
584
|
*/
|
|
558
|
-
function transcript(file) {
|
|
559
|
-
|
|
585
|
+
function transcript(file, keep = () => true) {
|
|
586
|
+
const entries = [];
|
|
587
|
+
let broken = 0;
|
|
560
588
|
try {
|
|
561
|
-
|
|
589
|
+
eachLine(file, (line) => {
|
|
590
|
+
if (line === undefined) {
|
|
591
|
+
broken += 1;
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (line.trim() === '') return;
|
|
595
|
+
let entry;
|
|
596
|
+
try {
|
|
597
|
+
entry = JSON.parse(line);
|
|
598
|
+
} catch {
|
|
599
|
+
broken += 1;
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) broken += 1;
|
|
603
|
+
else if (keep(entry)) entries.push(entry);
|
|
604
|
+
});
|
|
562
605
|
} catch {
|
|
563
606
|
return { entries: [], unreadable: 1, broken: 0 };
|
|
564
607
|
}
|
|
565
|
-
let broken = 0;
|
|
566
|
-
const entries = text.split('\n').flatMap((line) => {
|
|
567
|
-
if (line.trim() === '') return [];
|
|
568
|
-
try {
|
|
569
|
-
const entry = JSON.parse(line);
|
|
570
|
-
if (entry !== null && typeof entry === 'object' && !Array.isArray(entry)) return [entry];
|
|
571
|
-
} catch {
|
|
572
|
-
// Counted below with any other line that is not a record.
|
|
573
|
-
}
|
|
574
|
-
broken += 1;
|
|
575
|
-
return [];
|
|
576
|
-
});
|
|
577
608
|
return { entries, unreadable: 0, broken };
|
|
578
609
|
}
|
|
579
610
|
|