@basein/runner 0.3.24 → 0.3.26
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 +27 -26
- package/dist/auth/client.d.ts +4 -1
- package/dist/auth/client.js +83 -14
- package/dist/bin/bir-hooks.js +4 -2
- package/dist/bin/bir.js +153 -37
- package/dist/bin/investigate.js +5 -0
- package/dist/bin/scenario-edit.d.ts +5 -0
- package/dist/bin/scenario-edit.js +17 -1
- package/dist/config/adapters/claude-code.d.ts +51 -7
- package/dist/config/adapters/claude-code.js +105 -18
- package/dist/config/generate.d.ts +61 -13
- package/dist/config/generate.js +108 -20
- package/dist/config/resolve.d.ts +5 -0
- package/dist/config/resolve.js +7 -0
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +28 -0
- package/dist/control/daemon.js +62 -0
- package/dist/control/paths.d.ts +19 -1
- package/dist/control/paths.js +20 -2
- package/dist/control/pin-guard.js +8 -2
- package/dist/control/recorder-main.js +17 -5
- package/dist/control/server.d.ts +63 -9
- package/dist/control/server.js +350 -62
- package/dist/control/transcript.d.ts +64 -3
- package/dist/control/transcript.js +318 -37
- package/dist/jsonrpc/framing.d.ts +18 -3
- package/dist/jsonrpc/framing.js +57 -4
- package/dist/proxy/intercept.d.ts +4 -0
- package/dist/proxy/intercept.js +4 -0
- package/dist/proxy/relay.d.ts +13 -0
- package/dist/proxy/relay.js +12 -1
- package/dist/proxy/session.d.ts +8 -1
- package/dist/proxy/session.js +67 -10
- package/dist/record/recorder.d.ts +6 -1
- package/dist/record/redact.d.ts +18 -4
- package/dist/record/redact.js +538 -79
- package/dist/record/remote-recorder.d.ts +26 -5
- package/dist/record/remote-recorder.js +174 -18
- package/dist/record/truncate.js +3 -1
- package/dist/replay/bash-channel.d.ts +7 -1
- package/dist/replay/bash-channel.js +9 -3
- package/dist/replay/builtins.d.ts +14 -0
- package/dist/replay/builtins.js +460 -95
- package/dist/replay/bundle.d.ts +80 -0
- package/dist/replay/bundle.js +455 -24
- package/dist/replay/controller.d.ts +64 -1
- package/dist/replay/controller.js +238 -38
- package/dist/replay/derive.js +46 -4
- package/dist/replay/handover.d.ts +13 -0
- package/dist/replay/handover.js +18 -0
- package/dist/replay/plan.d.ts +15 -0
- package/dist/replay/plan.js +48 -30
- package/dist/replay/pricing.d.ts +61 -11
- package/dist/replay/pricing.js +135 -42
- package/dist/replay/safety.js +43 -5
- package/dist/upstream/http-client.d.ts +6 -3
- package/dist/upstream/http-client.js +54 -13
- package/dist/upstream/stdio-client.d.ts +7 -0
- package/dist/upstream/stdio-client.js +1 -1
- package/dist/util/atomic-write.d.ts +9 -1
- package/dist/util/atomic-write.js +9 -6
- package/dist/util/service-health.d.ts +7 -0
- package/dist/util/service-health.js +10 -3
- package/docs/calculatedReplay.md +32 -0
- package/package.json +1 -1
- package/production.md +2 -2
- package/scripts/install.ps1 +14 -2
|
@@ -34,6 +34,15 @@
|
|
|
34
34
|
* birth time, mtime, and the bytes at its head and at the offset) and read
|
|
35
35
|
* again from the start, which is exactly what the old code did every time.
|
|
36
36
|
*/
|
|
37
|
+
import { type RateFlags } from "../replay/pricing.js";
|
|
38
|
+
/**
|
|
39
|
+
* The session's own transcript for a path a hook handed over: a sub-agent's
|
|
40
|
+
* file (`<session>/subagents/…/agent-….jsonl`) names its session's, and any
|
|
41
|
+
* other path is itself. A session is measured from its own transcript, which
|
|
42
|
+
* adds its sub-agents' files up ({@link subagentUsage}): were a sub-agent's
|
|
43
|
+
* file taken for it, a turn's marks would be read from two different files.
|
|
44
|
+
*/
|
|
45
|
+
export declare function sessionTranscriptPath(path: string): string;
|
|
37
46
|
/** The day the agent was told it is, and the words that told it (issue 1212). */
|
|
38
47
|
export interface AgentDay {
|
|
39
48
|
/** "Today's date is Thursday, 2023-11-30 and the current time is 00:00:00." */
|
|
@@ -151,17 +160,62 @@ export declare function settledLastAssistantText(transcriptPath: string | undefi
|
|
|
151
160
|
confirmMs?: number;
|
|
152
161
|
pollMs?: number;
|
|
153
162
|
}): Promise<string>;
|
|
163
|
+
/** How many prompts the transcript holds so far: user lines of the main conversation that carry text. */
|
|
164
|
+
export declare function promptCount(transcriptPath: string | undefined): number;
|
|
165
|
+
/**
|
|
166
|
+
* The assistant's last text of the turn that ended before prompt number
|
|
167
|
+
* `prompt` (the first prompt is 1): "" when that turn wrote none, and
|
|
168
|
+
* undefined while that prompt has not been written — or is long past.
|
|
169
|
+
*
|
|
170
|
+
* WHY. A turn's answer is read a moment after its `Stop`, once its last
|
|
171
|
+
* message is in the file. A prompt queued behind the turn can open the next
|
|
172
|
+
* turn inside that moment, and "the last assistant text" is then the next
|
|
173
|
+
* turn's: a recording was finished with the answer to another prompt. The
|
|
174
|
+
* answer of a turn is what stood when the next prompt was written.
|
|
175
|
+
*/
|
|
176
|
+
export declare function answerBeforePrompt(transcriptPath: string | undefined, prompt: number): string | undefined;
|
|
154
177
|
/**
|
|
155
|
-
*
|
|
178
|
+
* Wait for the turn's last message to be in the transcript: until the
|
|
179
|
+
* conversation's newest line is an assistant message that calls no tool, or
|
|
180
|
+
* `timeoutMs` has passed. True when it is there.
|
|
181
|
+
*
|
|
182
|
+
* WHY. `Stop` fires before Claude Code has written the turn's last message (see
|
|
183
|
+
* {@link settledLastAssistantText}), and that message carries its own `usage`.
|
|
184
|
+
* A turn's cost read at `Stop` is therefore one model call short — and the
|
|
185
|
+
* call it lacks is counted in no turn at all, because the next turn's mark is
|
|
186
|
+
* taken after the line has landed. On a recording that is one call in many. On
|
|
187
|
+
* a replay it is most of the turn: the model's one call there is the one that
|
|
188
|
+
* reads the plan's results and answers. The console showed such turns as 93%
|
|
189
|
+
* and 97% saved where Claude Code's own bill said 42% (the service's
|
|
190
|
+
* improvelst.md, item 1).
|
|
191
|
+
*
|
|
192
|
+
* Returns at once when the message is already there, when the file cannot be
|
|
193
|
+
* read (there is no cost to wait for), and when its lines do not say how a
|
|
194
|
+
* message stopped and the newest one calls no tool (an older host, a fixture).
|
|
156
195
|
*/
|
|
157
|
-
export
|
|
196
|
+
export declare function settledTurnEnd(transcriptPath: string | undefined, opts?: {
|
|
197
|
+
timeoutMs?: number;
|
|
198
|
+
pollMs?: number;
|
|
199
|
+
}): Promise<boolean>;
|
|
200
|
+
/**
|
|
201
|
+
* Token usage of one model, summed over the requests that are priced alike:
|
|
202
|
+
* the flags of {@link RateFlags} say which (a long prompt, fast mode, US-only
|
|
203
|
+
* inference), and are absent for the ordinary ones.
|
|
204
|
+
*/
|
|
205
|
+
export interface ModelUsage extends RateFlags {
|
|
158
206
|
model: string;
|
|
159
207
|
inputTokens: number;
|
|
160
208
|
outputTokens: number;
|
|
161
209
|
cacheReadTokens: number;
|
|
210
|
+
/** Every cache write, the 1-hour ones among them. */
|
|
162
211
|
cacheCreationTokens: number;
|
|
212
|
+
/** Of those, the tokens written to the 1-hour cache. Absent for none. */
|
|
213
|
+
cacheCreation1hTokens?: number;
|
|
163
214
|
}
|
|
164
|
-
/**
|
|
215
|
+
/**
|
|
216
|
+
* Cumulative totals, captured when a run starts: one entry per model and way
|
|
217
|
+
* of pricing, under the model's name with its rate key after it.
|
|
218
|
+
*/
|
|
165
219
|
export type UsageMark = Map<string, ModelUsage>;
|
|
166
220
|
/**
|
|
167
221
|
* Snapshot cumulative usage at the moment a run starts.
|
|
@@ -180,6 +234,13 @@ export declare function markTranscriptUsage(transcriptPath: string | undefined):
|
|
|
180
234
|
* rotated transcript degrades to "no usage" rather than to a negative cost.
|
|
181
235
|
*/
|
|
182
236
|
export declare function usageSince(transcriptPath: string | undefined, mark: UsageMark | undefined): ModelUsage[];
|
|
237
|
+
/**
|
|
238
|
+
* The usage between two marks of one transcript, `from` the earlier: what
|
|
239
|
+
* {@link usageSince} gives, with `to` in the place of the transcript as it is
|
|
240
|
+
* now. A turn whose books are closed after the next turn has started ends at
|
|
241
|
+
* that turn's mark, not at whatever the file holds by then.
|
|
242
|
+
*/
|
|
243
|
+
export declare function usageBetween(mark: UsageMark | undefined, now: UsageMark): ModelUsage[];
|
|
183
244
|
/** The first user prompt in the transcript — the run's `input` when a hook omits it. */
|
|
184
245
|
export declare function firstUserPrompt(transcriptPath: string | undefined): string;
|
|
185
246
|
//# sourceMappingURL=transcript.d.ts.map
|
|
@@ -34,9 +34,11 @@
|
|
|
34
34
|
* birth time, mtime, and the bytes at its head and at the offset) and read
|
|
35
35
|
* again from the start, which is exactly what the old code did every time.
|
|
36
36
|
*/
|
|
37
|
-
import { closeSync, openSync, readSync, statSync } from "node:fs";
|
|
37
|
+
import { closeSync, openSync, readdirSync, readSync, statSync } from "node:fs";
|
|
38
|
+
import { join } from "node:path";
|
|
38
39
|
import { redactText } from "../record/redact.js";
|
|
39
40
|
import { truncateString } from "../record/truncate.js";
|
|
41
|
+
import { rateKey, requestRates } from "../replay/pricing.js";
|
|
40
42
|
function blocksOf(line) {
|
|
41
43
|
const content = line.message?.content ?? line.content;
|
|
42
44
|
if (Array.isArray(content))
|
|
@@ -116,8 +118,24 @@ function keepWithin(into, held, piece) {
|
|
|
116
118
|
into.push(kept);
|
|
117
119
|
return held + kept.length;
|
|
118
120
|
}
|
|
121
|
+
/** Turn answers kept per transcript: a turn's books close within seconds of its end. */
|
|
122
|
+
const MAX_ANSWERS_KEPT = 8;
|
|
119
123
|
const indexes = new Map();
|
|
120
|
-
|
|
124
|
+
/**
|
|
125
|
+
* The files of a transcript's sub-agents, read for their tokens only
|
|
126
|
+
* ({@link subagentUsage}): transcript path → the main index they were read
|
|
127
|
+
* beside, one usage-only index per file, and `gone`: what the files whose
|
|
128
|
+
* index was let go of had used (one past {@link MAX_SUBAGENT_FILES}, one read
|
|
129
|
+
* again from its start after a rewrite). Dropped with the main index.
|
|
130
|
+
*/
|
|
131
|
+
const subagentIndexes = new Map();
|
|
132
|
+
/** Sub-agent files read per transcript, at most. */
|
|
133
|
+
const MAX_SUBAGENT_FILES = 256;
|
|
134
|
+
/** How deep under `subagents/` their files are looked for (`workflows/wf_…/agent-….jsonl`). */
|
|
135
|
+
const SUBAGENT_DEPTH = 3;
|
|
136
|
+
/** What a line must hold to be worth parsing for its tokens. */
|
|
137
|
+
const USAGE_KEY = Buffer.from('"usage"');
|
|
138
|
+
function freshIndex(st, usageOnly) {
|
|
121
139
|
return {
|
|
122
140
|
ino: st.ino,
|
|
123
141
|
birthtimeMs: st.birthtimeMs,
|
|
@@ -127,8 +145,11 @@ function freshIndex(st) {
|
|
|
127
145
|
anchor: Buffer.alloc(0),
|
|
128
146
|
skipping: false,
|
|
129
147
|
lineNo: 0,
|
|
148
|
+
usageOnly: usageOnly !== undefined,
|
|
130
149
|
usage: new Map(),
|
|
131
|
-
|
|
150
|
+
// A sub-agent's file shares its transcript's set: a message that a host
|
|
151
|
+
// writes into both files is counted once, whichever is read first.
|
|
152
|
+
usageSeen: usageOnly?.seen ?? new Set(),
|
|
132
153
|
lastAssistantText: "",
|
|
133
154
|
firstUserPrompt: "",
|
|
134
155
|
sawPrompt: false,
|
|
@@ -137,6 +158,10 @@ function freshIndex(st) {
|
|
|
137
158
|
toolUses: new Map(),
|
|
138
159
|
systemDayLine: "",
|
|
139
160
|
hostDay: "",
|
|
161
|
+
tail: "",
|
|
162
|
+
prompts: 0,
|
|
163
|
+
textSincePrompt: false,
|
|
164
|
+
answersBefore: new Map(),
|
|
140
165
|
};
|
|
141
166
|
}
|
|
142
167
|
/** Whether `idx` can be continued for a file that now looks like `st`. */
|
|
@@ -168,41 +193,65 @@ function readAt(fd, position, length) {
|
|
|
168
193
|
* an empty transcript, as the whole-file reader always did.
|
|
169
194
|
*/
|
|
170
195
|
function indexFor(path) {
|
|
196
|
+
return indexIn(indexes, path, MAX_INDEXES, undefined, (dropped) => subagentIndexes.delete(dropped));
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* {@link indexFor} over a store of its own: `usageOnly` for the files of a
|
|
200
|
+
* transcript's sub-agents, which are read for their tokens and nothing else.
|
|
201
|
+
* `onDrop` hears of every path the store lets go of.
|
|
202
|
+
*
|
|
203
|
+
* A sub-agent's file that cannot be looked at for a moment keeps its index:
|
|
204
|
+
* its tokens were used. Let go of, an index was made again later beside the
|
|
205
|
+
* transcript's set of counted messages, which held every one of the file's —
|
|
206
|
+
* and the file counted $0 from then on. Every index such a store does let go
|
|
207
|
+
* of, or replaces, goes to `usageOnly.retire` with what it had counted.
|
|
208
|
+
*/
|
|
209
|
+
function indexIn(store, path, cap, usageOnly, onDrop) {
|
|
171
210
|
let st;
|
|
172
211
|
try {
|
|
173
212
|
st = statSync(path);
|
|
174
213
|
}
|
|
175
214
|
catch {
|
|
176
|
-
|
|
215
|
+
if (usageOnly)
|
|
216
|
+
return store.get(path);
|
|
217
|
+
store.delete(path);
|
|
218
|
+
onDrop?.(path);
|
|
177
219
|
return undefined;
|
|
178
220
|
}
|
|
179
221
|
if (!st.isFile())
|
|
180
|
-
return undefined;
|
|
181
|
-
let idx =
|
|
222
|
+
return usageOnly ? store.get(path) : undefined;
|
|
223
|
+
let idx = store.get(path);
|
|
182
224
|
// Re-inserted on every use, so the Map's order is least-recently-used first.
|
|
183
225
|
if (idx)
|
|
184
|
-
|
|
185
|
-
if (!idx || !continues(idx, st))
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
226
|
+
store.delete(path);
|
|
227
|
+
if (!idx || !continues(idx, st)) {
|
|
228
|
+
if (idx)
|
|
229
|
+
usageOnly?.retire?.(idx);
|
|
230
|
+
idx = freshIndex(st, usageOnly);
|
|
231
|
+
}
|
|
232
|
+
store.set(path, idx);
|
|
233
|
+
while (store.size > cap) {
|
|
234
|
+
const oldest = store.keys().next().value;
|
|
190
235
|
if (oldest === undefined)
|
|
191
236
|
break;
|
|
192
|
-
|
|
237
|
+
const old = store.get(oldest);
|
|
238
|
+
store.delete(oldest);
|
|
239
|
+
if (old)
|
|
240
|
+
usageOnly?.retire?.(old);
|
|
241
|
+
onDrop?.(oldest);
|
|
193
242
|
}
|
|
194
243
|
if (st.size === idx.offset)
|
|
195
244
|
return idx;
|
|
196
245
|
try {
|
|
197
|
-
return catchUp(path, idx, st);
|
|
246
|
+
return catchUp(store, path, idx, st, usageOnly);
|
|
198
247
|
}
|
|
199
248
|
catch {
|
|
200
249
|
// Unreadable mid-way (locked, deleted): what was folded in so far stands.
|
|
201
|
-
return
|
|
250
|
+
return store.get(path);
|
|
202
251
|
}
|
|
203
252
|
}
|
|
204
253
|
/** Fold in what was appended; the index to use from now on (a fresh one after a rewrite). */
|
|
205
|
-
function catchUp(path, stale, st) {
|
|
254
|
+
function catchUp(store, path, stale, st, usageOnly) {
|
|
206
255
|
const fd = openSync(path, "r");
|
|
207
256
|
try {
|
|
208
257
|
let idx = stale;
|
|
@@ -213,8 +262,9 @@ function catchUp(path, stale, st) {
|
|
|
213
262
|
const anchorAt = idx.offset - idx.anchor.length;
|
|
214
263
|
const anchor = readAt(fd, anchorAt, idx.anchor.length);
|
|
215
264
|
if (!head.equals(idx.head) || !anchor.equals(idx.anchor)) {
|
|
216
|
-
idx
|
|
217
|
-
|
|
265
|
+
usageOnly?.retire?.(idx);
|
|
266
|
+
idx = freshIndex(st, usageOnly);
|
|
267
|
+
store.set(path, idx);
|
|
218
268
|
}
|
|
219
269
|
}
|
|
220
270
|
fold(idx, fd, st.size);
|
|
@@ -287,6 +337,104 @@ function fold(idx, fd, size) {
|
|
|
287
337
|
}
|
|
288
338
|
}
|
|
289
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* The files of `transcriptPath`'s sub-agents: every `agent-*.jsonl` under
|
|
342
|
+
* `<the transcript without .jsonl>/subagents/`, sub-folders included
|
|
343
|
+
* (a workflow's agents are in `workflows/wf_…/`). Links are not followed.
|
|
344
|
+
*/
|
|
345
|
+
function subagentFiles(transcriptPath) {
|
|
346
|
+
if (!transcriptPath.endsWith(".jsonl"))
|
|
347
|
+
return [];
|
|
348
|
+
const out = [];
|
|
349
|
+
const walk = (dir, depth) => {
|
|
350
|
+
let entries;
|
|
351
|
+
try {
|
|
352
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return; // no such folder: the session started no sub-agent
|
|
356
|
+
}
|
|
357
|
+
for (const entry of entries) {
|
|
358
|
+
if (out.length >= MAX_SUBAGENT_FILES)
|
|
359
|
+
return;
|
|
360
|
+
if (entry.isFile() && entry.name.startsWith("agent-") && entry.name.endsWith(".jsonl")) {
|
|
361
|
+
out.push(join(dir, entry.name));
|
|
362
|
+
}
|
|
363
|
+
else if (entry.isDirectory() && depth < SUBAGENT_DEPTH) {
|
|
364
|
+
walk(join(dir, entry.name), depth + 1);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
walk(join(transcriptPath.slice(0, -".jsonl".length), "subagents"), 0);
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* The token usage in the files of a transcript's sub-agents, one total per
|
|
373
|
+
* file, each caught up with what was appended since the last call.
|
|
374
|
+
*
|
|
375
|
+
* WHY. Claude Code writes a sub-agent's conversation into a file of its own,
|
|
376
|
+
* and its tokens are on the session's bill. The transcript alone held 30,000
|
|
377
|
+
* tokens of a report whose two Explore agents used 400,000: the run was
|
|
378
|
+
* recorded at a tenth of its cost, and every saving against it measured far
|
|
379
|
+
* too small (the service's imp.md B5).
|
|
380
|
+
*
|
|
381
|
+
* `main` is the transcript's own index: a file read beside another one — the
|
|
382
|
+
* transcript was rewritten — is read again from its start.
|
|
383
|
+
*
|
|
384
|
+
* NEVER LESS THAN BEFORE. Every file read so far counts, listed this time or
|
|
385
|
+
* not — a folder that could not be listed for a moment, a file that could not
|
|
386
|
+
* be looked at, one past {@link MAX_SUBAGENT_FILES} — and so does what the
|
|
387
|
+
* files whose index was let go of had used (`gone`). A file read again is
|
|
388
|
+
* read beside the transcript's set of counted messages, so nothing counts
|
|
389
|
+
* twice. A total that fell made the turn across the fall cost $0 for that
|
|
390
|
+
* model, the main transcript's own tokens with it, and the file $0 for good.
|
|
391
|
+
*/
|
|
392
|
+
function subagentUsage(transcriptPath, main) {
|
|
393
|
+
const files = subagentFiles(transcriptPath);
|
|
394
|
+
let known = subagentIndexes.get(transcriptPath);
|
|
395
|
+
if (known && known.beside !== main) {
|
|
396
|
+
subagentIndexes.delete(transcriptPath);
|
|
397
|
+
known = undefined;
|
|
398
|
+
}
|
|
399
|
+
if (files.length === 0 && !known)
|
|
400
|
+
return [];
|
|
401
|
+
if (!known) {
|
|
402
|
+
known = { beside: main, files: new Map(), gone: new Map() };
|
|
403
|
+
subagentIndexes.set(transcriptPath, known);
|
|
404
|
+
}
|
|
405
|
+
const gone = known.gone;
|
|
406
|
+
const usageOnly = { seen: main.usageSeen, retire: (old) => addUsage(gone, old.usage) };
|
|
407
|
+
for (const file of files)
|
|
408
|
+
indexIn(known.files, file, MAX_SUBAGENT_FILES, usageOnly);
|
|
409
|
+
return [gone, ...[...known.files.values()].map((idx) => idx.usage)];
|
|
410
|
+
}
|
|
411
|
+
/** `from`'s tokens added into `into`, key by key. */
|
|
412
|
+
function addUsage(into, from) {
|
|
413
|
+
for (const [key, u] of from) {
|
|
414
|
+
const sum = into.get(key);
|
|
415
|
+
if (!sum) {
|
|
416
|
+
into.set(key, { ...u });
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
sum.inputTokens += u.inputTokens;
|
|
420
|
+
sum.outputTokens += u.outputTokens;
|
|
421
|
+
sum.cacheReadTokens += u.cacheReadTokens;
|
|
422
|
+
sum.cacheCreationTokens += u.cacheCreationTokens;
|
|
423
|
+
if (u.cacheCreation1hTokens)
|
|
424
|
+
sum.cacheCreation1hTokens = (sum.cacheCreation1hTokens ?? 0) + u.cacheCreation1hTokens;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* The session's own transcript for a path a hook handed over: a sub-agent's
|
|
429
|
+
* file (`<session>/subagents/…/agent-….jsonl`) names its session's, and any
|
|
430
|
+
* other path is itself. A session is measured from its own transcript, which
|
|
431
|
+
* adds its sub-agents' files up ({@link subagentUsage}): were a sub-agent's
|
|
432
|
+
* file taken for it, a turn's marks would be read from two different files.
|
|
433
|
+
*/
|
|
434
|
+
export function sessionTranscriptPath(path) {
|
|
435
|
+
const m = /^(.*)[\\/]subagents[\\/](?:.*[\\/])?agent-[^\\/]+\.jsonl$/.exec(path);
|
|
436
|
+
return m ? `${m[1]}.jsonl` : path;
|
|
437
|
+
}
|
|
290
438
|
function parse(bytes) {
|
|
291
439
|
const text = bytes.toString("utf8").trim();
|
|
292
440
|
if (!text)
|
|
@@ -300,6 +448,10 @@ function parse(bytes) {
|
|
|
300
448
|
}
|
|
301
449
|
}
|
|
302
450
|
function ingest(idx, bytes) {
|
|
451
|
+
// A sub-agent's file is read for its tokens: most of its lines are tool
|
|
452
|
+
// results, some of them megabytes, and none of those is parsed.
|
|
453
|
+
if (idx.usageOnly && !bytes.includes(USAGE_KEY))
|
|
454
|
+
return;
|
|
303
455
|
const line = parse(bytes);
|
|
304
456
|
// A terminated line that does not parse is corrupt, not half-written: skip it.
|
|
305
457
|
if (line)
|
|
@@ -316,9 +468,46 @@ function dayLineIn(text) {
|
|
|
316
468
|
// The whole line, never cut at a full stop: "Nov. 30, 2023" has one inside.
|
|
317
469
|
return redactText(DAY_LINE.exec(whole)?.[0] ?? "").slice(0, 200);
|
|
318
470
|
}
|
|
471
|
+
/** A message's `usage` into the index's totals, deduplicated by message id (see `totalUsage`'s note on why). */
|
|
472
|
+
function foldUsage(idx, message) {
|
|
473
|
+
const usage = message?.usage;
|
|
474
|
+
if (!usage || typeof usage !== "object")
|
|
475
|
+
return;
|
|
476
|
+
const id = message?.id;
|
|
477
|
+
if (id && idx.usageSeen.has(id))
|
|
478
|
+
return;
|
|
479
|
+
if (id)
|
|
480
|
+
idx.usageSeen.add(id);
|
|
481
|
+
const model = message?.model ?? "unknown";
|
|
482
|
+
const input = count(usage.input_tokens);
|
|
483
|
+
const read = count(usage.cache_read_input_tokens);
|
|
484
|
+
const written = count(usage.cache_creation_input_tokens);
|
|
485
|
+
// A message is one request, and some of what sets its price is the
|
|
486
|
+
// request's own: its prompt length, its speed, where it ran. Requests
|
|
487
|
+
// priced differently are summed apart (replay/pricing.ts).
|
|
488
|
+
const rates = requestRates(model, {
|
|
489
|
+
promptTokens: input + read + written,
|
|
490
|
+
speed: usage.speed,
|
|
491
|
+
inferenceGeo: usage.inference_geo,
|
|
492
|
+
});
|
|
493
|
+
const key = model + rateKey(rates);
|
|
494
|
+
const acc = idx.usage.get(key) ?? { ...zeroUsage(model), ...rates };
|
|
495
|
+
acc.inputTokens += input;
|
|
496
|
+
acc.outputTokens += count(usage.output_tokens);
|
|
497
|
+
acc.cacheReadTokens += read;
|
|
498
|
+
acc.cacheCreationTokens += written;
|
|
499
|
+
const written1h = Math.min(written, count(usage.cache_creation?.ephemeral_1h_input_tokens));
|
|
500
|
+
if (written1h > 0)
|
|
501
|
+
acc.cacheCreation1hTokens = (acc.cacheCreation1hTokens ?? 0) + written1h;
|
|
502
|
+
idx.usage.set(key, acc);
|
|
503
|
+
}
|
|
319
504
|
/** What one line contributes to every answer this module gives. */
|
|
320
505
|
function foldLine(idx, line) {
|
|
321
506
|
idx.lineNo += 1;
|
|
507
|
+
if (idx.usageOnly) {
|
|
508
|
+
foldUsage(idx, line.message);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
322
511
|
// The day the agent was told it is (issue 1212): a system prompt's
|
|
323
512
|
// "Today's date is …", and the host's own date reminder beside it.
|
|
324
513
|
if (line.type === "attachment" && line.attachment?.type === "prompt_snapshot") {
|
|
@@ -337,31 +526,43 @@ function foldLine(idx, line) {
|
|
|
337
526
|
const message = line.message;
|
|
338
527
|
const role = message?.role ?? line.type;
|
|
339
528
|
const blocks = blocksOf(line);
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
if (
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
acc.cacheReadTokens += usage.cache_read_input_tokens ?? 0;
|
|
352
|
-
acc.cacheCreationTokens += usage.cache_creation_input_tokens ?? 0;
|
|
353
|
-
idx.usage.set(model, acc);
|
|
529
|
+
foldUsage(idx, message);
|
|
530
|
+
// Where the conversation stands ({@link settledTurnEnd}). A sub-agent's
|
|
531
|
+
// lines are another conversation: its last answer does not end this turn.
|
|
532
|
+
if (line.isSidechain !== true) {
|
|
533
|
+
if (role === "assistant") {
|
|
534
|
+
const stop = message?.stop_reason;
|
|
535
|
+
const calls = stop === "tool_use" || (stop == null && blocks.some((b) => b.type === "tool_use"));
|
|
536
|
+
idx.tail = calls ? "tool_call" : "answer";
|
|
537
|
+
}
|
|
538
|
+
else if (role === "user") {
|
|
539
|
+
idx.tail = "user";
|
|
354
540
|
}
|
|
355
541
|
}
|
|
356
542
|
if (role === "assistant") {
|
|
357
543
|
const text = textOf(blocks);
|
|
358
|
-
if (text)
|
|
544
|
+
if (text) {
|
|
359
545
|
idx.lastAssistantText = text;
|
|
546
|
+
if (line.isSidechain !== true)
|
|
547
|
+
idx.textSincePrompt = true;
|
|
548
|
+
}
|
|
360
549
|
}
|
|
361
550
|
else if (role === "user") {
|
|
362
551
|
// A user line carrying text is a prompt: "this prompt's results" start
|
|
363
552
|
// again from it, its own tool results included.
|
|
364
553
|
const text = textOf(blocks);
|
|
554
|
+
if (text && line.isSidechain !== true) {
|
|
555
|
+
// What the turn before this prompt answered, kept for a turn whose
|
|
556
|
+
// books close after the next one has begun ({@link answerBeforePrompt}).
|
|
557
|
+
idx.prompts += 1;
|
|
558
|
+
idx.answersBefore.set(idx.prompts, idx.textSincePrompt ? idx.lastAssistantText : "");
|
|
559
|
+
idx.textSincePrompt = false;
|
|
560
|
+
if (idx.answersBefore.size > MAX_ANSWERS_KEPT) {
|
|
561
|
+
const oldest = idx.answersBefore.keys().next().value;
|
|
562
|
+
if (oldest !== undefined)
|
|
563
|
+
idx.answersBefore.delete(oldest);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
365
566
|
if (text) {
|
|
366
567
|
if (!idx.sawPrompt)
|
|
367
568
|
idx.firstUserPrompt = text;
|
|
@@ -512,6 +713,7 @@ export async function settledTextAfterToolUse(transcriptPath, toolUseId, opts =
|
|
|
512
713
|
/** Forget every cached index (tests; a caller that knows files were replaced). */
|
|
513
714
|
export function forgetTranscripts() {
|
|
514
715
|
indexes.clear();
|
|
716
|
+
subagentIndexes.clear();
|
|
515
717
|
}
|
|
516
718
|
// ── accessors ───────────────────────────────────────────────────────────────
|
|
517
719
|
/**
|
|
@@ -645,6 +847,66 @@ export async function settledLastAssistantText(transcriptPath, opts = {}) {
|
|
|
645
847
|
}
|
|
646
848
|
return latest || before;
|
|
647
849
|
}
|
|
850
|
+
/** How many prompts the transcript holds so far: user lines of the main conversation that carry text. */
|
|
851
|
+
export function promptCount(transcriptPath) {
|
|
852
|
+
if (!transcriptPath)
|
|
853
|
+
return 0;
|
|
854
|
+
return indexFor(transcriptPath)?.prompts ?? 0;
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* The assistant's last text of the turn that ended before prompt number
|
|
858
|
+
* `prompt` (the first prompt is 1): "" when that turn wrote none, and
|
|
859
|
+
* undefined while that prompt has not been written — or is long past.
|
|
860
|
+
*
|
|
861
|
+
* WHY. A turn's answer is read a moment after its `Stop`, once its last
|
|
862
|
+
* message is in the file. A prompt queued behind the turn can open the next
|
|
863
|
+
* turn inside that moment, and "the last assistant text" is then the next
|
|
864
|
+
* turn's: a recording was finished with the answer to another prompt. The
|
|
865
|
+
* answer of a turn is what stood when the next prompt was written.
|
|
866
|
+
*/
|
|
867
|
+
export function answerBeforePrompt(transcriptPath, prompt) {
|
|
868
|
+
if (!transcriptPath)
|
|
869
|
+
return undefined;
|
|
870
|
+
return indexFor(transcriptPath)?.answersBefore.get(prompt);
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Wait for the turn's last message to be in the transcript: until the
|
|
874
|
+
* conversation's newest line is an assistant message that calls no tool, or
|
|
875
|
+
* `timeoutMs` has passed. True when it is there.
|
|
876
|
+
*
|
|
877
|
+
* WHY. `Stop` fires before Claude Code has written the turn's last message (see
|
|
878
|
+
* {@link settledLastAssistantText}), and that message carries its own `usage`.
|
|
879
|
+
* A turn's cost read at `Stop` is therefore one model call short — and the
|
|
880
|
+
* call it lacks is counted in no turn at all, because the next turn's mark is
|
|
881
|
+
* taken after the line has landed. On a recording that is one call in many. On
|
|
882
|
+
* a replay it is most of the turn: the model's one call there is the one that
|
|
883
|
+
* reads the plan's results and answers. The console showed such turns as 93%
|
|
884
|
+
* and 97% saved where Claude Code's own bill said 42% (the service's
|
|
885
|
+
* improvelst.md, item 1).
|
|
886
|
+
*
|
|
887
|
+
* Returns at once when the message is already there, when the file cannot be
|
|
888
|
+
* read (there is no cost to wait for), and when its lines do not say how a
|
|
889
|
+
* message stopped and the newest one calls no tool (an older host, a fixture).
|
|
890
|
+
*/
|
|
891
|
+
export async function settledTurnEnd(transcriptPath, opts = {}) {
|
|
892
|
+
if (!transcriptPath)
|
|
893
|
+
return false;
|
|
894
|
+
const pollMs = Math.max(10, opts.pollMs ?? 100);
|
|
895
|
+
const deadline = Date.now() + Math.max(0, opts.timeoutMs ?? 2_000);
|
|
896
|
+
for (;;) {
|
|
897
|
+
const tail = indexFor(transcriptPath)?.tail;
|
|
898
|
+
if (tail === undefined)
|
|
899
|
+
return false;
|
|
900
|
+
if (tail === "answer")
|
|
901
|
+
return true;
|
|
902
|
+
const left = deadline - Date.now();
|
|
903
|
+
if (left <= 0)
|
|
904
|
+
return false;
|
|
905
|
+
// Not unref'd: the turn's report waits on this, and a recorder asked to
|
|
906
|
+
// stop meanwhile stays for the moment it takes rather than lose it.
|
|
907
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, left)));
|
|
908
|
+
}
|
|
909
|
+
}
|
|
648
910
|
const zeroUsage = (model) => ({
|
|
649
911
|
model,
|
|
650
912
|
inputTokens: 0,
|
|
@@ -652,6 +914,8 @@ const zeroUsage = (model) => ({
|
|
|
652
914
|
cacheReadTokens: 0,
|
|
653
915
|
cacheCreationTokens: 0,
|
|
654
916
|
});
|
|
917
|
+
/** A token count as a transcript line gives it: a missing or broken one is 0. */
|
|
918
|
+
const count = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
655
919
|
/**
|
|
656
920
|
* Sum every assistant message's `usage` in the whole file, keyed by model.
|
|
657
921
|
*
|
|
@@ -682,6 +946,9 @@ function totalUsage(transcriptPath) {
|
|
|
682
946
|
return totals;
|
|
683
947
|
for (const [model, u] of idx.usage)
|
|
684
948
|
totals.set(model, { ...u });
|
|
949
|
+
// And what the session's sub-agents used, each in a file of its own.
|
|
950
|
+
for (const usage of subagentUsage(transcriptPath, idx))
|
|
951
|
+
addUsage(totals, usage);
|
|
685
952
|
return totals;
|
|
686
953
|
}
|
|
687
954
|
/**
|
|
@@ -718,17 +985,31 @@ export function markTranscriptUsage(transcriptPath) {
|
|
|
718
985
|
export function usageSince(transcriptPath, mark) {
|
|
719
986
|
if (!transcriptPath)
|
|
720
987
|
return [];
|
|
721
|
-
|
|
988
|
+
return usageBetween(mark, totalUsage(transcriptPath));
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* The usage between two marks of one transcript, `from` the earlier: what
|
|
992
|
+
* {@link usageSince} gives, with `to` in the place of the transcript as it is
|
|
993
|
+
* now. A turn whose books are closed after the next turn has started ends at
|
|
994
|
+
* that turn's mark, not at whatever the file holds by then.
|
|
995
|
+
*/
|
|
996
|
+
export function usageBetween(mark, now) {
|
|
722
997
|
const out = [];
|
|
723
|
-
for (const [
|
|
724
|
-
const before = mark?.get(
|
|
998
|
+
for (const [key, current] of now) {
|
|
999
|
+
const before = mark?.get(key);
|
|
725
1000
|
const delta = {
|
|
726
|
-
model
|
|
1001
|
+
// The entry's own model and flags: its key carries the rate key too.
|
|
1002
|
+
...current,
|
|
727
1003
|
inputTokens: Math.max(0, current.inputTokens - (before?.inputTokens ?? 0)),
|
|
728
1004
|
outputTokens: Math.max(0, current.outputTokens - (before?.outputTokens ?? 0)),
|
|
729
1005
|
cacheReadTokens: Math.max(0, current.cacheReadTokens - (before?.cacheReadTokens ?? 0)),
|
|
730
1006
|
cacheCreationTokens: Math.max(0, current.cacheCreationTokens - (before?.cacheCreationTokens ?? 0)),
|
|
731
1007
|
};
|
|
1008
|
+
const written1h = Math.max(0, (current.cacheCreation1hTokens ?? 0) - (before?.cacheCreation1hTokens ?? 0));
|
|
1009
|
+
if (written1h > 0)
|
|
1010
|
+
delta.cacheCreation1hTokens = Math.min(written1h, delta.cacheCreationTokens);
|
|
1011
|
+
else
|
|
1012
|
+
delete delta.cacheCreation1hTokens;
|
|
732
1013
|
if (delta.inputTokens ||
|
|
733
1014
|
delta.outputTokens ||
|
|
734
1015
|
delta.cacheReadTokens ||
|
|
@@ -59,15 +59,30 @@ export interface FrameHandlers {
|
|
|
59
59
|
export interface FrameOptions {
|
|
60
60
|
maxLineBytes?: number;
|
|
61
61
|
}
|
|
62
|
+
/** Note that `msg` was parsed from `text`, so it can be written again as that text. */
|
|
63
|
+
export declare function rememberText(msg: object, text: string): void;
|
|
64
|
+
/** Forget the text `msg` was parsed from: it is serialised again when written, with any change made to it. */
|
|
65
|
+
export declare function forgetText(msg: unknown): void;
|
|
66
|
+
/**
|
|
67
|
+
* The text `msg` was parsed from, when it can be sent again as one line;
|
|
68
|
+
* undefined for a message nobody read off the wire, and for one whose text
|
|
69
|
+
* holds a raw line break (a pretty-printed HTTP body: inside a JSON string a
|
|
70
|
+
* line break is always escaped, so a raw one is layout, and layout cannot
|
|
71
|
+
* cross a line-delimited stream). One trailing CR is a CRLF sender's line
|
|
72
|
+
* ending, not part of the message.
|
|
73
|
+
*/
|
|
74
|
+
export declare function receivedText(msg: unknown): string | undefined;
|
|
62
75
|
/**
|
|
63
76
|
* Read newline-delimited JSON messages off `input`.
|
|
64
77
|
* Returns a detach function that stops the reader (it does not close `input`).
|
|
65
78
|
*/
|
|
66
79
|
export declare function readFrames(input: Readable, handlers: FrameHandlers, opts?: FrameOptions): () => void;
|
|
67
80
|
/**
|
|
68
|
-
* Write one message as a single line
|
|
69
|
-
*
|
|
70
|
-
*
|
|
81
|
+
* Write one message as a single line: the line it was read from when `msg` is
|
|
82
|
+
* the very object a reader produced ({@link receivedText}), its serialisation
|
|
83
|
+
* otherwise. Serialization failure is swallowed after being reported: a proxy
|
|
84
|
+
* that throws while answering the host takes the host's session down with it,
|
|
85
|
+
* which rule 4 of §1 forbids.
|
|
71
86
|
*/
|
|
72
87
|
export declare function writeFrame(output: Writable, msg: unknown, onError?: (err: Error) => void): void;
|
|
73
88
|
/**
|