@thehammer/danx-session-telemetry 0.1.22 → 0.1.24
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/dist/.source-hash +1 -0
- package/dist/browser.d.ts +2 -0
- package/dist/browser.js +2 -0
- package/dist/dispatch-usage.d.ts +10 -9
- package/dist/dispatch-usage.js +30 -27
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/ledger-timing.d.ts +26 -0
- package/dist/ledger-timing.js +31 -0
- package/dist/session-log-watcher.d.ts +26 -1
- package/dist/session-log-watcher.js +38 -2
- package/dist/stats-wire.js +13 -0
- package/dist/transcript-timing.d.ts +87 -0
- package/dist/transcript-timing.js +230 -0
- package/dist/usage-ledger.d.ts +24 -5
- package/dist/usage-ledger.js +138 -35
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
89cd1ca4f2b51d45e885401f25cd2013ae9a293b3f3788aa820d45ea63e2a004
|
package/dist/browser.d.ts
CHANGED
|
@@ -22,4 +22,6 @@
|
|
|
22
22
|
export type { AgentLogEntry, AgentLineage } from "./types.js";
|
|
23
23
|
export { truncStr } from "./tool-summary.js";
|
|
24
24
|
export * from "./usage-ledger.js";
|
|
25
|
+
export * from "./ledger-timing.js";
|
|
26
|
+
export * from "./transcript-timing.js";
|
|
25
27
|
export * from "./pricing.js";
|
package/dist/browser.js
CHANGED
package/dist/dispatch-usage.d.ts
CHANGED
|
@@ -17,8 +17,7 @@
|
|
|
17
17
|
* (HTTP on a registered worker, getPool-direct on the dashboard).
|
|
18
18
|
*/
|
|
19
19
|
import { UsageLedgerCounter } from "./usage-ledger.js";
|
|
20
|
-
import type { CursorPastEofInfo } from "./session-log-watcher.js";
|
|
21
|
-
import type { AgentLogEntry } from "./types.js";
|
|
20
|
+
import type { CursorPastEofInfo, EntryMeta } from "./session-log-watcher.js";
|
|
22
21
|
import { type ActivityChange, type SourceCursors, type StatsPayload, type StatsResponse, type UsageTotals } from "./stats-wire.js";
|
|
23
22
|
/** Where stats writes go — both dispatch-row sinks implement it. */
|
|
24
23
|
export interface StatsSink {
|
|
@@ -196,15 +195,17 @@ export declare class DispatchUsage {
|
|
|
196
195
|
*/
|
|
197
196
|
private cursorsMoved;
|
|
198
197
|
/**
|
|
199
|
-
* Feed one
|
|
198
|
+
* Feed one raw transcript line of the usage feed set (assistant, user, attachment, system), in file order.
|
|
199
|
+
* DX-3964 - the usage collector is fed from the watcher's collector-only `onUsageLine` subscription, never from
|
|
200
|
+
* `onEntry`: `convertJsonlEntry` drops human prompts, attachments and system lines, and widening it would hand
|
|
201
|
+
* every other `onEntry` subscriber a new volume of entries.
|
|
200
202
|
*
|
|
201
|
-
* `replay` (DX-2954) marks a line re-read after a restart. The line STILL goes
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
203
|
+
* `meta.replay` (DX-2954) marks a line re-read after a restart. The line STILL goes to the counter - the
|
|
204
|
+
* ledger is idempotent and dropping it would lose usage a restart is meant to recover - but a replayed
|
|
205
|
+
* assistant reply's id is remembered so the local ceiling estimate does not count it twice (see
|
|
206
|
+
* {@link DispatchUsage.unacknowledged}).
|
|
205
207
|
*/
|
|
206
|
-
|
|
207
|
-
private observeReply;
|
|
208
|
+
observeRawLine(raw: Record<string, unknown>, meta: EntryMeta): void;
|
|
208
209
|
/**
|
|
209
210
|
* Ship every changed entry in capped batches, one after another. The first
|
|
210
211
|
* call always writes (even with nothing to send) so the process takes the
|
package/dist/dispatch-usage.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* DB-free: runs in the DB-less worker. The sink decides the transport
|
|
17
17
|
* (HTTP on a registered worker, getPool-direct on the dashboard).
|
|
18
18
|
*/
|
|
19
|
-
import {
|
|
19
|
+
import { UsageLedgerCounter, ledgerLineFromRaw, excessUsage, } from "./usage-ledger.js";
|
|
20
20
|
import { ClaimTracker, emptyClaimState } from "./claim-tracker.js";
|
|
21
21
|
import { ACTIVITY_BATCH_CAP, DispatchUsageTerminalError, chunkEntries, collapseActivityChanges, } from "./stats-wire.js";
|
|
22
22
|
import { createUsageWriteQueue } from "./write-queue.js";
|
|
@@ -129,6 +129,8 @@ export class DispatchUsage {
|
|
|
129
129
|
// that can never be satisfied. The bytes we believed acknowledged do not
|
|
130
130
|
// exist any more, so claiming them is simply false.
|
|
131
131
|
this.ackedCursor.set(info.source, 0);
|
|
132
|
+
// DX-3964 - the source is read again from 0: its timing reducer starts fresh, first-line-anchored.
|
|
133
|
+
this.counter.resetSource(info.source);
|
|
132
134
|
// DX-3962 - the claims recorded against the old file name a transcript that is gone; the source re-earns them
|
|
133
135
|
// from the lines it re-reads from 0 (the server clears the stored claims in the same statement as the cursor).
|
|
134
136
|
this.ackedClaims.set(info.source, emptyClaimState());
|
|
@@ -280,37 +282,38 @@ export class DispatchUsage {
|
|
|
280
282
|
return false;
|
|
281
283
|
}
|
|
282
284
|
/**
|
|
283
|
-
* Feed one
|
|
285
|
+
* Feed one raw transcript line of the usage feed set (assistant, user, attachment, system), in file order.
|
|
286
|
+
* DX-3964 - the usage collector is fed from the watcher's collector-only `onUsageLine` subscription, never from
|
|
287
|
+
* `onEntry`: `convertJsonlEntry` drops human prompts, attachments and system lines, and widening it would hand
|
|
288
|
+
* every other `onEntry` subscriber a new volume of entries.
|
|
284
289
|
*
|
|
285
|
-
* `replay` (DX-2954) marks a line re-read after a restart. The line STILL goes
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
290
|
+
* `meta.replay` (DX-2954) marks a line re-read after a restart. The line STILL goes to the counter - the
|
|
291
|
+
* ledger is idempotent and dropping it would lose usage a restart is meant to recover - but a replayed
|
|
292
|
+
* assistant reply's id is remembered so the local ceiling estimate does not count it twice (see
|
|
293
|
+
* {@link DispatchUsage.unacknowledged}).
|
|
289
294
|
*/
|
|
290
|
-
|
|
291
|
-
const
|
|
292
|
-
|
|
295
|
+
observeRawLine(raw, meta) {
|
|
296
|
+
const line = ledgerLineFromRaw(raw, meta.source, meta.byteOffset);
|
|
297
|
+
if (!line)
|
|
298
|
+
return;
|
|
299
|
+
const message = raw.message;
|
|
300
|
+
const content = Array.isArray(message?.content) ? message.content : [];
|
|
293
301
|
// DX-3962 - a `tool_result` settles a pickup / release the source asked for earlier.
|
|
294
|
-
if (
|
|
295
|
-
this.claims?.observeResultBlocks(source, content);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
302
|
+
if (line.kind === "user")
|
|
303
|
+
this.claims?.observeResultBlocks(meta.source, content);
|
|
304
|
+
if (line.kind === "assistant") {
|
|
305
|
+
line.line.issueId = this.claims?.issueFor(meta.source) ?? null;
|
|
306
|
+
// The counter's OWN key, not `line.messageId`: a usage line with no `message.id` is keyed by
|
|
307
|
+
// `syntheticMessageKey(source, byteOffset)`, and recording a different key here would leave that reply's
|
|
308
|
+
// replay unrecognised - the exact double-count this set prevents.
|
|
309
|
+
if (meta.replay)
|
|
310
|
+
this.replayedMessageIds.add(line.replyKey);
|
|
311
|
+
}
|
|
312
|
+
this.counter.observeLine(line);
|
|
299
313
|
// AFTER the reply is counted: a reply is credited to the card held at its FIRST line, so the pickup this line
|
|
300
314
|
// itself asks for takes effect only on its result, and never on the reply that issued it.
|
|
301
|
-
if (
|
|
302
|
-
this.claims?.observeAssistantBlocks(source, content);
|
|
303
|
-
}
|
|
304
|
-
observeReply(line, replay) {
|
|
305
|
-
line.issueId = this.claims?.issueFor(line.source) ?? null;
|
|
306
|
-
// The counter's OWN key, not `line.messageId`: a usage line with no
|
|
307
|
-
// `message.id` is keyed by `syntheticMessageKey(source, byteOffset)`
|
|
308
|
-
// (`usage-ledger.ts`), and recording a different key here would leave that
|
|
309
|
-
// reply's replay unrecognised — the exact double-count this set prevents.
|
|
310
|
-
if (replay) {
|
|
311
|
-
this.replayedMessageIds.add(line.messageId ?? syntheticMessageKey(line.source, line.byteOffset));
|
|
312
|
-
}
|
|
313
|
-
this.counter.observe(line);
|
|
315
|
+
if (line.kind === "assistant")
|
|
316
|
+
this.claims?.observeAssistantBlocks(meta.source, content);
|
|
314
317
|
}
|
|
315
318
|
/**
|
|
316
319
|
* Ship every changed entry in capped batches, one after another. The first
|
package/dist/index.d.ts
CHANGED
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
* usage display.
|
|
16
16
|
*/
|
|
17
17
|
export { SessionLogWatcher, DISPATCH_TAG_PREFIX, encodeClaudeProjectsCwd, deriveSessionDir, sessionUuidExistsInCleanRoom, findNewestJsonlFile, findSessionFileByDispatchId, convertJsonlEntry, skipReplays, } from "./session-log-watcher.js";
|
|
18
|
-
export type { EntryConsumer, SessionLogWatcherOptions, SourceCursorStart, CursorPastEofInfo, SourceDiscoveredInfo, EntryMeta, } from "./session-log-watcher.js";
|
|
18
|
+
export type { EntryConsumer, UsageLineConsumer, SessionLogWatcherOptions, SourceCursorStart, CursorPastEofInfo, SourceDiscoveredInfo, EntryMeta, } from "./session-log-watcher.js";
|
|
19
19
|
export type { AgentLogEntry, AgentLineage } from "./types.js";
|
|
20
20
|
export { truncStr, summarizeToolInput, buildAssistantSummary, buildToolResultSummary, } from "./tool-summary.js";
|
|
21
21
|
export * from "./usage-ledger.js";
|
|
22
|
+
export * from "./ledger-timing.js";
|
|
23
|
+
export * from "./transcript-timing.js";
|
|
22
24
|
export * from "./pricing.js";
|
|
23
25
|
export * from "./stats-wire.js";
|
|
24
26
|
export * from "./claim-tracker.js";
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,8 @@ export { truncStr, summarizeToolInput, buildAssistantSummary, buildToolResultSum
|
|
|
24
24
|
// DX-2963 — the one usage counter and the one pricing function (also on the
|
|
25
25
|
// browser-safe `./browser` subpath; both are pure arithmetic).
|
|
26
26
|
export * from "./usage-ledger.js";
|
|
27
|
+
export * from "./ledger-timing.js";
|
|
28
|
+
export * from "./transcript-timing.js";
|
|
27
29
|
export * from "./pricing.js";
|
|
28
30
|
// DX-2954 — the ONE stats collector and its wire, shared by the danxbot worker
|
|
29
31
|
// and the desktop bridge (operator sessions) so both report the same stats the
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-3964 - one reply's measured time and code churn. Its own block on
|
|
3
|
+
* {@link LedgerEntry}, never part of {@link LedgerUsage}: `LedgerUsage` is the
|
|
4
|
+
* priced token set that `LEDGER_USAGE_FIELDS` iterates into pricing, per-model
|
|
5
|
+
* buckets and the worker's token estimate, and none of those may see these.
|
|
6
|
+
* Every field only GROWS as the reply's lines (and the tool results that settle
|
|
7
|
+
* them) are read, so the ledger's `excess = max - credited` makes a replay credit
|
|
8
|
+
* nothing twice.
|
|
9
|
+
*/
|
|
10
|
+
export interface LedgerTiming {
|
|
11
|
+
/** Request sent to the reply's last block written, ms (`## Definitions` on DX-3964). */
|
|
12
|
+
apiMs: number;
|
|
13
|
+
/** Credited gaps in the source's own timeline owned by this reply, ms. */
|
|
14
|
+
agentMs: number;
|
|
15
|
+
/** Lines the reply's successful Edit/Write calls added. */
|
|
16
|
+
linesAdded: number;
|
|
17
|
+
/** Lines the reply's successful Edit/Write calls removed. */
|
|
18
|
+
linesRemoved: number;
|
|
19
|
+
}
|
|
20
|
+
export declare const LEDGER_TIMING_FIELDS: readonly ["apiMs", "agentMs", "linesAdded", "linesRemoved"];
|
|
21
|
+
export declare function zeroTiming(): LedgerTiming;
|
|
22
|
+
export declare function maxTiming(a: LedgerTiming, b: LedgerTiming): LedgerTiming;
|
|
23
|
+
export declare function addTiming(a: LedgerTiming, b: LedgerTiming): LedgerTiming;
|
|
24
|
+
/** Per-field `max(0, a - b)`. */
|
|
25
|
+
export declare function excessTiming(a: LedgerTiming, b: LedgerTiming): LedgerTiming;
|
|
26
|
+
export declare function isZeroTiming(t: LedgerTiming): boolean;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const LEDGER_TIMING_FIELDS = [
|
|
2
|
+
"apiMs",
|
|
3
|
+
"agentMs",
|
|
4
|
+
"linesAdded",
|
|
5
|
+
"linesRemoved",
|
|
6
|
+
];
|
|
7
|
+
export function zeroTiming() {
|
|
8
|
+
return { apiMs: 0, agentMs: 0, linesAdded: 0, linesRemoved: 0 };
|
|
9
|
+
}
|
|
10
|
+
export function maxTiming(a, b) {
|
|
11
|
+
const out = zeroTiming();
|
|
12
|
+
for (const f of LEDGER_TIMING_FIELDS)
|
|
13
|
+
out[f] = Math.max(a[f], b[f]);
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
export function addTiming(a, b) {
|
|
17
|
+
const out = zeroTiming();
|
|
18
|
+
for (const f of LEDGER_TIMING_FIELDS)
|
|
19
|
+
out[f] = a[f] + b[f];
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
/** Per-field `max(0, a - b)`. */
|
|
23
|
+
export function excessTiming(a, b) {
|
|
24
|
+
const out = zeroTiming();
|
|
25
|
+
for (const f of LEDGER_TIMING_FIELDS)
|
|
26
|
+
out[f] = Math.max(0, a[f] - b[f]);
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
export function isZeroTiming(t) {
|
|
30
|
+
return LEDGER_TIMING_FIELDS.every((f) => t[f] === 0);
|
|
31
|
+
}
|
|
@@ -36,6 +36,12 @@ export declare const DISPATCH_TAG_PREFIX = "<!-- danxbot-dispatch:";
|
|
|
36
36
|
* re-read line (see {@link EntryMeta.replay}).
|
|
37
37
|
*/
|
|
38
38
|
export type EntryConsumer = (entry: AgentLogEntry, meta: EntryMeta) => void;
|
|
39
|
+
/**
|
|
40
|
+
* DX-3964 - a consumer of the usage feed: the PARSED RAW line (assistant, user, attachment or system - nothing
|
|
41
|
+
* else) with the same {@link EntryMeta} an `onEntry` consumer gets. Collector-only: see
|
|
42
|
+
* {@link SessionLogWatcher.onUsageLine}.
|
|
43
|
+
*/
|
|
44
|
+
export type UsageLineConsumer = (raw: Record<string, unknown>, meta: EntryMeta) => void;
|
|
39
45
|
/**
|
|
40
46
|
* DX-2954 — the ONE replay gate every side-effecting transcript subscriber goes
|
|
41
47
|
* through (Slack progress, the gpt-manager forwarder): it hands on only lines
|
|
@@ -279,7 +285,7 @@ export declare function findNewestJsonlFile(dir: string, sessionId?: string): Pr
|
|
|
279
285
|
* detection for that dispatch.
|
|
280
286
|
*
|
|
281
287
|
* So we scan BOTH a head window and a tail window (they overlap, covering
|
|
282
|
-
* the whole file, for any file ≤
|
|
288
|
+
* the whole file, for any file ≤ 2x`SCAN_BYTES`). A resumed tag always
|
|
283
289
|
* lands in the freshly-appended tail; a fresh tag always lands in the head.
|
|
284
290
|
*
|
|
285
291
|
* Returns the full path to the matching file, or null if no file matches.
|
|
@@ -318,6 +324,7 @@ export declare function convertJsonlEntry(raw: Record<string, unknown>, lastTime
|
|
|
318
324
|
*/
|
|
319
325
|
export declare class SessionLogWatcher {
|
|
320
326
|
private consumers;
|
|
327
|
+
private usageConsumers;
|
|
321
328
|
private pollTimer;
|
|
322
329
|
private parentTail;
|
|
323
330
|
private subagentsDir;
|
|
@@ -385,6 +392,17 @@ export declare class SessionLogWatcher {
|
|
|
385
392
|
mainStartOffset(): number | null;
|
|
386
393
|
/** Register a consumer callback that receives each new AgentLogEntry. */
|
|
387
394
|
onEntry(consumer: EntryConsumer): void;
|
|
395
|
+
/**
|
|
396
|
+
* DX-3964 - register a usage-feed consumer: it receives the parsed raw line of every assistant, user,
|
|
397
|
+
* attachment and system line, in file order, with the same `EntryMeta` as `onEntry`. The ONLY subscribers are
|
|
398
|
+
* the two `DispatchUsage` feed sites (the worker and the operator bridge): `convertJsonlEntry` and every
|
|
399
|
+
* `onEntry` subscriber keep seeing exactly what they saw before, because human prompts, attachments (tens of
|
|
400
|
+
* thousands of hook records) and system lines are not theirs.
|
|
401
|
+
*
|
|
402
|
+
* Each consumer runs in its own try/catch, as in `emitEntry`: a consumer that throws (a
|
|
403
|
+
* `LedgerLineDataError` on a line with no timestamp) never withholds the line from `onEntry` subscribers.
|
|
404
|
+
*/
|
|
405
|
+
onUsageLine(consumer: UsageLineConsumer): void;
|
|
388
406
|
/** Returns the discovered parent session file path, or null if not yet found. */
|
|
389
407
|
getSessionFilePath(): string | null;
|
|
390
408
|
/**
|
|
@@ -526,6 +544,13 @@ export declare class SessionLogWatcher {
|
|
|
526
544
|
* tool-registration check that works across both runtimes.
|
|
527
545
|
*/
|
|
528
546
|
private handleRawEntry;
|
|
547
|
+
/**
|
|
548
|
+
* DX-2954 — a line whose FIRST byte lies below the source's stored
|
|
549
|
+
* `delivered` cursor was already handed to a previous incarnation's
|
|
550
|
+
* subscribers. Strictly `<`: `delivered` is an exclusive end (the next
|
|
551
|
+
* unread byte), so a line starting exactly AT it is new.
|
|
552
|
+
*/
|
|
553
|
+
private entryMeta;
|
|
529
554
|
/**
|
|
530
555
|
* Emit an entry to registered consumers. For sub-agent streams,
|
|
531
556
|
* `state.lineage` decorates `entry.data` so the emitted EventPayload
|
|
@@ -42,6 +42,8 @@ const MAX_FILE_DISCOVERY_ATTEMPTS = 180;
|
|
|
42
42
|
* wrong file (e.g., an active human session).
|
|
43
43
|
*/
|
|
44
44
|
export const DISPATCH_TAG_PREFIX = "<!-- danxbot-dispatch:";
|
|
45
|
+
/** The line `type`s the usage feed carries; every other type never reaches a {@link UsageLineConsumer}. */
|
|
46
|
+
const USAGE_FEED_TYPES = new Set(["assistant", "user", "attachment", "system"]);
|
|
45
47
|
/**
|
|
46
48
|
* DX-2954 — the ONE replay gate every side-effecting transcript subscriber goes
|
|
47
49
|
* through (Slack progress, the gpt-manager forwarder): it hands on only lines
|
|
@@ -221,7 +223,7 @@ export async function findNewestJsonlFile(dir, sessionId) {
|
|
|
221
223
|
* detection for that dispatch.
|
|
222
224
|
*
|
|
223
225
|
* So we scan BOTH a head window and a tail window (they overlap, covering
|
|
224
|
-
* the whole file, for any file ≤
|
|
226
|
+
* the whole file, for any file ≤ 2x`SCAN_BYTES`). A resumed tag always
|
|
225
227
|
* lands in the freshly-appended tail; a fresh tag always lands in the head.
|
|
226
228
|
*
|
|
227
229
|
* Returns the full path to the matching file, or null if no file matches.
|
|
@@ -347,7 +349,7 @@ export function convertJsonlEntry(raw, lastTimestamp) {
|
|
|
347
349
|
// thinking + text + tool_use, etc.) into one JSONL line per content
|
|
348
350
|
// block, but stamps the SAME response-level `message.usage` on every
|
|
349
351
|
// line. Surface `messageId` so downstream usage accumulators can dedupe
|
|
350
|
-
// — without it, every multi-block turn over-counts tokens 2-
|
|
352
|
+
// — without it, every multi-block turn over-counts tokens 2-5x. See
|
|
351
353
|
// `.claude/rules/agent-dispatch.md`.
|
|
352
354
|
const messageId = typeof message.id === "string" && message.id.length > 0
|
|
353
355
|
? message.id
|
|
@@ -479,6 +481,7 @@ export function convertJsonlEntry(raw, lastTimestamp) {
|
|
|
479
481
|
*/
|
|
480
482
|
export class SessionLogWatcher {
|
|
481
483
|
consumers = [];
|
|
484
|
+
usageConsumers = [];
|
|
482
485
|
pollTimer;
|
|
483
486
|
parentTail = null;
|
|
484
487
|
subagentsDir = null;
|
|
@@ -616,6 +619,19 @@ export class SessionLogWatcher {
|
|
|
616
619
|
onEntry(consumer) {
|
|
617
620
|
this.consumers.push(consumer);
|
|
618
621
|
}
|
|
622
|
+
/**
|
|
623
|
+
* DX-3964 - register a usage-feed consumer: it receives the parsed raw line of every assistant, user,
|
|
624
|
+
* attachment and system line, in file order, with the same `EntryMeta` as `onEntry`. The ONLY subscribers are
|
|
625
|
+
* the two `DispatchUsage` feed sites (the worker and the operator bridge): `convertJsonlEntry` and every
|
|
626
|
+
* `onEntry` subscriber keep seeing exactly what they saw before, because human prompts, attachments (tens of
|
|
627
|
+
* thousands of hook records) and system lines are not theirs.
|
|
628
|
+
*
|
|
629
|
+
* Each consumer runs in its own try/catch, as in `emitEntry`: a consumer that throws (a
|
|
630
|
+
* `LedgerLineDataError` on a line with no timestamp) never withholds the line from `onEntry` subscribers.
|
|
631
|
+
*/
|
|
632
|
+
onUsageLine(consumer) {
|
|
633
|
+
this.usageConsumers.push(consumer);
|
|
634
|
+
}
|
|
619
635
|
/** Returns the discovered parent session file path, or null if not yet found. */
|
|
620
636
|
getSessionFilePath() {
|
|
621
637
|
return this.parentTail?.filePath ?? null;
|
|
@@ -1093,6 +1109,17 @@ export class SessionLogWatcher {
|
|
|
1093
1109
|
*/
|
|
1094
1110
|
handleRawEntry(raw, state, lineStart) {
|
|
1095
1111
|
const isSubagent = state.lineage !== undefined;
|
|
1112
|
+
if (typeof raw.type === "string" && USAGE_FEED_TYPES.has(raw.type)) {
|
|
1113
|
+
const meta = this.entryMeta(state, lineStart);
|
|
1114
|
+
for (const consumer of this.usageConsumers) {
|
|
1115
|
+
try {
|
|
1116
|
+
consumer(raw, meta);
|
|
1117
|
+
}
|
|
1118
|
+
catch (err) {
|
|
1119
|
+
log.error("Usage line consumer error:", err);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1096
1123
|
if (!isSubagent && !this.initSynthesized && raw.type === "assistant") {
|
|
1097
1124
|
const message = raw.message;
|
|
1098
1125
|
const model = message?.model;
|
|
@@ -1135,6 +1162,15 @@ export class SessionLogWatcher {
|
|
|
1135
1162
|
result.entry.data = { ...result.entry.data, byte_offset: lineStart };
|
|
1136
1163
|
this.emitEntry(result.entry, state, lineStart);
|
|
1137
1164
|
}
|
|
1165
|
+
/**
|
|
1166
|
+
* DX-2954 — a line whose FIRST byte lies below the source's stored
|
|
1167
|
+
* `delivered` cursor was already handed to a previous incarnation's
|
|
1168
|
+
* subscribers. Strictly `<`: `delivered` is an exclusive end (the next
|
|
1169
|
+
* unread byte), so a line starting exactly AT it is new.
|
|
1170
|
+
*/
|
|
1171
|
+
entryMeta(state, byteOffset) {
|
|
1172
|
+
return { source: state.source, byteOffset, replay: byteOffset < state.replayUntil };
|
|
1173
|
+
}
|
|
1138
1174
|
/**
|
|
1139
1175
|
* Emit an entry to registered consumers. For sub-agent streams,
|
|
1140
1176
|
* `state.lineage` decorates `entry.data` so the emitted EventPayload
|
package/dist/stats-wire.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LEDGER_TIMING_FIELDS } from "./ledger-timing.js";
|
|
1
2
|
import { ISSUE_ID_MAX_LENGTH, ISSUE_ID_PATTERN, MAX_HELD_CLAIMS, MAX_PENDING_TRANSITIONS, } from "./claim-tracker.js";
|
|
2
3
|
/**
|
|
3
4
|
* DX-2963 — cap on entries per stats write. A from-0 backlog (a reattach, a
|
|
@@ -327,6 +328,17 @@ export function parseStatsPayload(body) {
|
|
|
327
328
|
if (!isNonNegInt(u[k]))
|
|
328
329
|
throw new StatsPayloadError(`${where}.usage.${k}`);
|
|
329
330
|
}
|
|
331
|
+
// DX-3964 - REQUIRED: an entry without `timing` comes from a collector that predates it, which the MCP
|
|
332
|
+
// version floor refuses rather than this parser tolerating.
|
|
333
|
+
const t = e.timing;
|
|
334
|
+
if (!t || typeof t !== "object" || Array.isArray(t))
|
|
335
|
+
throw new StatsPayloadError(`${where}.timing`);
|
|
336
|
+
const timing = {};
|
|
337
|
+
for (const k of LEDGER_TIMING_FIELDS) {
|
|
338
|
+
if (!isNonNegInt(t[k]))
|
|
339
|
+
throw new StatsPayloadError(`${where}.timing.${k}`);
|
|
340
|
+
timing[k] = t[k];
|
|
341
|
+
}
|
|
330
342
|
if (!Array.isArray(e.toolUseIds) || e.toolUseIds.some((t) => typeof t !== "string")) {
|
|
331
343
|
throw new StatsPayloadError(`${where}.toolUseIds`);
|
|
332
344
|
}
|
|
@@ -347,6 +359,7 @@ export function parseStatsPayload(body) {
|
|
|
347
359
|
cacheWrite5m: u.cacheWrite5m,
|
|
348
360
|
cacheWrite1h: u.cacheWrite1h,
|
|
349
361
|
},
|
|
362
|
+
timing,
|
|
350
363
|
toolUseIds: e.toolUseIds,
|
|
351
364
|
issueId: e.issueId,
|
|
352
365
|
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-3964 - the per-source reducer that turns a transcript's lines into each reply's
|
|
3
|
+
* API time, agent time and lines added/removed, exactly per the `## Definitions` DX-3975
|
|
4
|
+
* measured on 18 real transcripts. Pure arithmetic (no `node:` imports), so the
|
|
5
|
+
* browser-safe entry exports it too.
|
|
6
|
+
*
|
|
7
|
+
* One reducer per SOURCE (one transcript file). It is fed every assistant, user,
|
|
8
|
+
* attachment and system line of that file in FILE ORDER, and reports, per reply, values
|
|
9
|
+
* that only ever grow as lines are appended (max semantics), so the ledger's
|
|
10
|
+
* `excess = max - credited` makes a resend, resume or restart credit nothing twice.
|
|
11
|
+
*
|
|
12
|
+
* - Never sort by timestamp: `hwm` (the largest timestamp seen) is a running maximum
|
|
13
|
+
* and a line older than it yields a 0 gap and never moves it back.
|
|
14
|
+
* - A reducer that starts mid-file anchors `hwm` on the first assistant or tool_result
|
|
15
|
+
* line it sees (a stale queued attachment at a restart must not be a gap), and
|
|
16
|
+
* credits only what it derived from lines in its own window.
|
|
17
|
+
*/
|
|
18
|
+
import type { LedgerLine } from "./usage-ledger.js";
|
|
19
|
+
import { type LedgerTiming } from "./ledger-timing.js";
|
|
20
|
+
/** A single gap above this is a suspend or an unanswered prompt, never work. */
|
|
21
|
+
export declare const MAX_CREDITED_GAP_MS = 600000;
|
|
22
|
+
export interface TimingToolUse {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
input: Record<string, unknown> | null;
|
|
26
|
+
}
|
|
27
|
+
export interface PatchHunk {
|
|
28
|
+
lines: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
export interface TimingToolResult {
|
|
31
|
+
toolUseId: string;
|
|
32
|
+
isError: boolean;
|
|
33
|
+
/** The result's text (a string content, or its text blocks joined). */
|
|
34
|
+
text: string;
|
|
35
|
+
/** `toolUseResult.structuredPatch` of the line carrying exactly this one result; absent from sub-agent transcripts. */
|
|
36
|
+
structuredPatch: readonly PatchHunk[] | null;
|
|
37
|
+
}
|
|
38
|
+
/** One transcript line of the fed set (assistant, user, attachment, system), parsed. */
|
|
39
|
+
export type TranscriptLine = {
|
|
40
|
+
kind: "assistant";
|
|
41
|
+
line: LedgerLine;
|
|
42
|
+
/** The counter's own key for the reply (`message.id`, or the synthetic per-offset key). */
|
|
43
|
+
replyKey: string;
|
|
44
|
+
byteOffset: number;
|
|
45
|
+
/** `message.model` is `<synthetic>` (API error text): it measures nothing. */
|
|
46
|
+
synthetic: boolean;
|
|
47
|
+
toolUses: readonly TimingToolUse[];
|
|
48
|
+
} | {
|
|
49
|
+
kind: "user";
|
|
50
|
+
source: string;
|
|
51
|
+
timestamp: number;
|
|
52
|
+
byteOffset: number;
|
|
53
|
+
/** Empty for a human prompt (string content, an array with no tool_result, an `isMeta` line). */
|
|
54
|
+
results: readonly TimingToolResult[];
|
|
55
|
+
} | {
|
|
56
|
+
kind: "attachment" | "system";
|
|
57
|
+
source: string;
|
|
58
|
+
timestamp: number;
|
|
59
|
+
byteOffset: number;
|
|
60
|
+
};
|
|
61
|
+
export declare function timestampOf(t: TranscriptLine): number;
|
|
62
|
+
export declare class SourceTimingReducer {
|
|
63
|
+
/** `null` until anchored. */
|
|
64
|
+
private hwm;
|
|
65
|
+
private candidates;
|
|
66
|
+
/** Gap time ending at an attachment or system line: owned by the next reply to start. */
|
|
67
|
+
private carried;
|
|
68
|
+
private readonly replies;
|
|
69
|
+
private readonly tools;
|
|
70
|
+
private readonly pendingEdits;
|
|
71
|
+
/** Feed one line; returns the current values of every reply it touched. */
|
|
72
|
+
feed(t: TranscriptLine): Map<string, LedgerTiming>;
|
|
73
|
+
private feedAssistant;
|
|
74
|
+
private feedUser;
|
|
75
|
+
/** `S`: the largest request-side timestamp since the previous assistant line, clamped to the reply's first line. */
|
|
76
|
+
private sendTimeFor;
|
|
77
|
+
}
|
|
78
|
+
/** Number of lines in `s` (a trailing newline does not start another line). */
|
|
79
|
+
export declare function countLines(s: string): number;
|
|
80
|
+
/**
|
|
81
|
+
* The line diff of one Edit's `old_string` against its `new_string`: split on newline, common prefix and suffix trimmed,
|
|
82
|
+
* then the longest common subsequence. Equals Claude Code's own `structuredPatch` on 908 of 916 measured Edits.
|
|
83
|
+
*/
|
|
84
|
+
export declare function lineDiffCounts(oldText: string, newText: string): {
|
|
85
|
+
added: number;
|
|
86
|
+
removed: number;
|
|
87
|
+
};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { zeroTiming } from "./ledger-timing.js";
|
|
2
|
+
/** A single gap above this is a suspend or an unanswered prompt, never work. */
|
|
3
|
+
export const MAX_CREDITED_GAP_MS = 600_000;
|
|
4
|
+
/** Tools whose result is a parent WAITING on a child that carries that time in its own source. */
|
|
5
|
+
const WAITING_TOOLS = new Set(["Agent", "Task"]);
|
|
6
|
+
export function timestampOf(t) {
|
|
7
|
+
return t.kind === "assistant" ? t.line.timestamp : t.timestamp;
|
|
8
|
+
}
|
|
9
|
+
function snapshot(r) {
|
|
10
|
+
if (r.synthetic)
|
|
11
|
+
return zeroTiming();
|
|
12
|
+
return {
|
|
13
|
+
apiMs: Math.max(0, r.lastTs - r.sendTime),
|
|
14
|
+
agentMs: r.agentMs,
|
|
15
|
+
linesAdded: r.linesAdded,
|
|
16
|
+
linesRemoved: r.linesRemoved,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export class SourceTimingReducer {
|
|
20
|
+
/** `null` until anchored. */
|
|
21
|
+
hwm = null;
|
|
22
|
+
candidates = [];
|
|
23
|
+
/** Gap time ending at an attachment or system line: owned by the next reply to start. */
|
|
24
|
+
carried = 0;
|
|
25
|
+
replies = new Map();
|
|
26
|
+
tools = new Map();
|
|
27
|
+
pendingEdits = new Map();
|
|
28
|
+
/** Feed one line; returns the current values of every reply it touched. */
|
|
29
|
+
feed(t) {
|
|
30
|
+
const touched = new Set();
|
|
31
|
+
const ts = timestampOf(t);
|
|
32
|
+
let gap = 0;
|
|
33
|
+
if (this.hwm === null) {
|
|
34
|
+
// A reducer that starts mid-file anchors on its first assistant or tool_result line; a fresh file's own
|
|
35
|
+
// first line (offset 0) anchors it too. Before the anchor nothing is credited and nothing is remembered.
|
|
36
|
+
const anchors = t.byteOffset === 0 || t.kind === "assistant" || (t.kind === "user" && t.results.length > 0);
|
|
37
|
+
if (!anchors)
|
|
38
|
+
return new Map();
|
|
39
|
+
this.hwm = ts;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
gap = Math.max(0, ts - this.hwm);
|
|
43
|
+
if (gap > MAX_CREDITED_GAP_MS)
|
|
44
|
+
gap = 0;
|
|
45
|
+
this.hwm = Math.max(this.hwm, ts);
|
|
46
|
+
}
|
|
47
|
+
switch (t.kind) {
|
|
48
|
+
case "assistant":
|
|
49
|
+
this.feedAssistant(t, ts, gap, touched);
|
|
50
|
+
break;
|
|
51
|
+
case "user":
|
|
52
|
+
this.feedUser(t, gap, touched);
|
|
53
|
+
this.candidates.push({ ts, attachment: false });
|
|
54
|
+
break;
|
|
55
|
+
case "attachment":
|
|
56
|
+
this.carried += gap;
|
|
57
|
+
this.candidates.push({ ts, attachment: true });
|
|
58
|
+
break;
|
|
59
|
+
case "system":
|
|
60
|
+
this.carried += gap;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
const out = new Map();
|
|
64
|
+
for (const key of touched)
|
|
65
|
+
out.set(key, snapshot(this.replies.get(key)));
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
feedAssistant(t, ts, gap, touched) {
|
|
69
|
+
let reply = this.replies.get(t.replyKey);
|
|
70
|
+
if (!reply) {
|
|
71
|
+
reply = {
|
|
72
|
+
sendTime: this.sendTimeFor(ts),
|
|
73
|
+
lastTs: ts,
|
|
74
|
+
synthetic: t.synthetic,
|
|
75
|
+
agentMs: this.carried,
|
|
76
|
+
linesAdded: 0,
|
|
77
|
+
linesRemoved: 0,
|
|
78
|
+
settled: new Set(),
|
|
79
|
+
};
|
|
80
|
+
this.carried = 0;
|
|
81
|
+
this.replies.set(t.replyKey, reply);
|
|
82
|
+
}
|
|
83
|
+
reply.lastTs = Math.max(reply.lastTs, ts);
|
|
84
|
+
reply.agentMs += gap;
|
|
85
|
+
touched.add(t.replyKey);
|
|
86
|
+
// Request-send candidates are the lines AFTER the previous assistant line, so every assistant line resets them.
|
|
87
|
+
this.candidates = [];
|
|
88
|
+
for (const use of t.toolUses) {
|
|
89
|
+
this.tools.set(use.id, { replyKey: t.replyKey, name: use.name });
|
|
90
|
+
if ((use.name === "Edit" || use.name === "Write") && use.input !== null && !reply.settled.has(use.id)) {
|
|
91
|
+
this.pendingEdits.set(use.id, { replyKey: t.replyKey, name: use.name, input: use.input });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
feedUser(t, gap, touched) {
|
|
96
|
+
if (t.results.length === 0)
|
|
97
|
+
return; // a human prompt: the gap ending here is user or idle time
|
|
98
|
+
const owner = this.tools.get(t.results[0].toolUseId);
|
|
99
|
+
// A gap ending at an Agent/Task result is a parent waiting on a child whose own source carries that time.
|
|
100
|
+
if (owner && !WAITING_TOOLS.has(owner.name)) {
|
|
101
|
+
const reply = this.replies.get(owner.replyKey);
|
|
102
|
+
if (reply) {
|
|
103
|
+
reply.agentMs += gap;
|
|
104
|
+
touched.add(owner.replyKey);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
for (const result of t.results) {
|
|
108
|
+
const pending = this.pendingEdits.get(result.toolUseId);
|
|
109
|
+
if (!pending)
|
|
110
|
+
continue;
|
|
111
|
+
this.pendingEdits.delete(result.toolUseId);
|
|
112
|
+
const reply = this.replies.get(pending.replyKey);
|
|
113
|
+
if (!reply)
|
|
114
|
+
continue;
|
|
115
|
+
reply.settled.add(result.toolUseId);
|
|
116
|
+
if (result.isError)
|
|
117
|
+
continue;
|
|
118
|
+
const lines = linesOf(pending, result);
|
|
119
|
+
reply.linesAdded += lines.added;
|
|
120
|
+
reply.linesRemoved += lines.removed;
|
|
121
|
+
touched.add(pending.replyKey);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** `S`: the largest request-side timestamp since the previous assistant line, clamped to the reply's first line. */
|
|
125
|
+
sendTimeFor(firstTs) {
|
|
126
|
+
let best = null;
|
|
127
|
+
for (const c of this.candidates) {
|
|
128
|
+
// A `deferred_tools_record` attachment is written AFTER the response starts.
|
|
129
|
+
if (c.attachment && c.ts >= firstTs)
|
|
130
|
+
continue;
|
|
131
|
+
if (best === null || c.ts > best)
|
|
132
|
+
best = c.ts;
|
|
133
|
+
}
|
|
134
|
+
return best === null ? firstTs : Math.min(firstTs, best);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Number of lines in `s` (a trailing newline does not start another line). */
|
|
138
|
+
export function countLines(s) {
|
|
139
|
+
if (s === "")
|
|
140
|
+
return 0;
|
|
141
|
+
const n = s.split("\n").length;
|
|
142
|
+
return s.endsWith("\n") ? n - 1 : n;
|
|
143
|
+
}
|
|
144
|
+
function patchCounts(patch) {
|
|
145
|
+
let added = 0;
|
|
146
|
+
let removed = 0;
|
|
147
|
+
for (const hunk of patch) {
|
|
148
|
+
for (const line of hunk.lines) {
|
|
149
|
+
if (line.startsWith("+"))
|
|
150
|
+
added++;
|
|
151
|
+
else if (line.startsWith("-"))
|
|
152
|
+
removed++;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { added, removed };
|
|
156
|
+
}
|
|
157
|
+
function splitLines(s) {
|
|
158
|
+
return s === "" ? [] : s.split("\n");
|
|
159
|
+
}
|
|
160
|
+
/** Myers' shortest edit script length (insertions + deletions) between two line lists. */
|
|
161
|
+
function editDistance(a, b) {
|
|
162
|
+
const n = a.length;
|
|
163
|
+
const m = b.length;
|
|
164
|
+
const max = n + m;
|
|
165
|
+
if (max === 0)
|
|
166
|
+
return 0;
|
|
167
|
+
const off = max;
|
|
168
|
+
const v = new Int32Array(2 * max + 2);
|
|
169
|
+
for (let d = 0; d <= max; d++) {
|
|
170
|
+
for (let k = -d; k <= d; k += 2) {
|
|
171
|
+
let x;
|
|
172
|
+
if (k === -d || (k !== d && v[off + k - 1] < v[off + k + 1]))
|
|
173
|
+
x = v[off + k + 1];
|
|
174
|
+
else
|
|
175
|
+
x = v[off + k - 1] + 1;
|
|
176
|
+
let y = x - k;
|
|
177
|
+
while (x < n && y < m && a[x] === b[y]) {
|
|
178
|
+
x++;
|
|
179
|
+
y++;
|
|
180
|
+
}
|
|
181
|
+
v[off + k] = x;
|
|
182
|
+
if (x >= n && y >= m)
|
|
183
|
+
return d;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
throw new Error("editDistance: unreachable, the edit script is at most n + m long");
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The line diff of one Edit's `old_string` against its `new_string`: split on newline, common prefix and suffix trimmed,
|
|
190
|
+
* then the longest common subsequence. Equals Claude Code's own `structuredPatch` on 908 of 916 measured Edits.
|
|
191
|
+
*/
|
|
192
|
+
export function lineDiffCounts(oldText, newText) {
|
|
193
|
+
const a = splitLines(oldText);
|
|
194
|
+
const b = splitLines(newText);
|
|
195
|
+
let start = 0;
|
|
196
|
+
while (start < a.length && start < b.length && a[start] === b[start])
|
|
197
|
+
start++;
|
|
198
|
+
let endA = a.length;
|
|
199
|
+
let endB = b.length;
|
|
200
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
201
|
+
endA--;
|
|
202
|
+
endB--;
|
|
203
|
+
}
|
|
204
|
+
const ta = a.slice(start, endA);
|
|
205
|
+
const tb = b.slice(start, endB);
|
|
206
|
+
const d = editDistance(ta, tb);
|
|
207
|
+
const lcs = (ta.length + tb.length - d) / 2;
|
|
208
|
+
return { added: tb.length - lcs, removed: ta.length - lcs };
|
|
209
|
+
}
|
|
210
|
+
function stringInput(input, key) {
|
|
211
|
+
const v = input[key];
|
|
212
|
+
return typeof v === "string" ? v : "";
|
|
213
|
+
}
|
|
214
|
+
/** The lines one successful Edit or Write result credits. Any other tool never reaches here. */
|
|
215
|
+
function linesOf(pending, result) {
|
|
216
|
+
if (pending.name === "Edit") {
|
|
217
|
+
if (pending.input.replace_all === true && result.structuredPatch !== null) {
|
|
218
|
+
return patchCounts(result.structuredPatch);
|
|
219
|
+
}
|
|
220
|
+
return lineDiffCounts(stringInput(pending.input, "old_string"), stringInput(pending.input, "new_string"));
|
|
221
|
+
}
|
|
222
|
+
if (result.text.includes("File created successfully")) {
|
|
223
|
+
return { added: countLines(stringInput(pending.input, "content")), removed: 0 };
|
|
224
|
+
}
|
|
225
|
+
// An overwrite: the previous content is not in the input, so only a patch (main and worker transcripts) can say.
|
|
226
|
+
if (result.text.includes("has been updated successfully") && result.structuredPatch !== null) {
|
|
227
|
+
return patchCounts(result.structuredPatch);
|
|
228
|
+
}
|
|
229
|
+
return { added: 0, removed: 0 };
|
|
230
|
+
}
|
package/dist/usage-ledger.d.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* browser-safe `./browser` subpath as well as the main entry.
|
|
19
19
|
*/
|
|
20
20
|
import type { PricedTokens } from "./pricing.js";
|
|
21
|
+
import { type LedgerTiming } from "./ledger-timing.js";
|
|
22
|
+
import { type TranscriptLine } from "./transcript-timing.js";
|
|
21
23
|
import type { AgentLogEntry } from "./types.js";
|
|
22
24
|
/** Per-reply usage, both cache-write lifetimes kept apart (priced apart). */
|
|
23
25
|
export type LedgerUsage = PricedTokens;
|
|
@@ -36,6 +38,8 @@ export interface LedgerEntry {
|
|
|
36
38
|
/** `usage.speed` (`standard` / `fast`); `"standard"` when the line has none. */
|
|
37
39
|
speed: string;
|
|
38
40
|
usage: LedgerUsage;
|
|
41
|
+
/** DX-3964 - the reply's measured time and lines; see {@link LedgerTiming}. */
|
|
42
|
+
timing: LedgerTiming;
|
|
39
43
|
/** The reply's own transcript timestamp (its FIRST line's), epoch ms. */
|
|
40
44
|
repliedAt: number;
|
|
41
45
|
toolUseIds: string[];
|
|
@@ -114,6 +118,21 @@ export declare function totalTokensOfUsage(u: LedgerUsage): number;
|
|
|
114
118
|
export declare class UsageLedgerCounter {
|
|
115
119
|
private readonly entries;
|
|
116
120
|
private readonly changed;
|
|
121
|
+
/** DX-3964 - one timing reducer per source (transcript file); see `transcript-timing.ts`. */
|
|
122
|
+
private readonly reducers;
|
|
123
|
+
/**
|
|
124
|
+
* DX-3964 - feed one line of the fed set (assistant, user, attachment, system) in FILE ORDER. An assistant
|
|
125
|
+
* line credits usage exactly as {@link observe} does; every kind also feeds its source's timing reducer,
|
|
126
|
+
* and a reply's timing that RISES - including one raised by a later tool_result, after the reply's last line,
|
|
127
|
+
* with no token growth - counts as a change, so {@link takeChanged} re-sends it.
|
|
128
|
+
*/
|
|
129
|
+
observeLine(t: TranscriptLine): boolean;
|
|
130
|
+
/**
|
|
131
|
+
* DX-3964 - a source's file was truncated and is read again from 0: its timing reducer starts fresh and
|
|
132
|
+
* first-line-anchored, so a stale `hwm` of the old file can never credit a gap at the reset. Entries keep their
|
|
133
|
+
* values (max semantics); the re-read can only raise them.
|
|
134
|
+
*/
|
|
135
|
+
resetSource(source: string): void;
|
|
117
136
|
observe(line: LedgerLine): boolean;
|
|
118
137
|
/** Entries changed since the last call, as snapshots; clears the queue. */
|
|
119
138
|
takeChanged(): LedgerEntry[];
|
|
@@ -127,12 +146,12 @@ export declare class UsageLedgerCounter {
|
|
|
127
146
|
toolUseCount(): number;
|
|
128
147
|
}
|
|
129
148
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
149
|
+
* Parse one raw transcript line (a parsed JSONL object) of the fed set - assistant, user (tool_result and human
|
|
150
|
+
* prompts), attachment, system - into a {@link TranscriptLine}. Returns `null` for every other `type`
|
|
151
|
+
* (`queue-operation`, `last-prompt`, `file-history-*`, `custom-title`, ...). THE one parser for the usage feed:
|
|
152
|
+
* the live watcher subscription, `observeTranscriptText` (claude-probe) and the tests all go through it.
|
|
134
153
|
*/
|
|
135
|
-
export declare function ledgerLineFromRaw(raw: Record<string, unknown>, source: string, byteOffset: number):
|
|
154
|
+
export declare function ledgerLineFromRaw(raw: Record<string, unknown>, source: string, byteOffset: number): TranscriptLine | null;
|
|
136
155
|
/**
|
|
137
156
|
* Count a whole transcript text (one file) into a counter. Byte offsets are
|
|
138
157
|
* computed on UTF-8 bytes so a no-id line keys identically to the live
|
package/dist/usage-ledger.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { LEDGER_TIMING_FIELDS, maxTiming, zeroTiming } from "./ledger-timing.js";
|
|
2
|
+
import { SourceTimingReducer, } from "./transcript-timing.js";
|
|
1
3
|
export const MAIN_SOURCE = "main";
|
|
2
4
|
/**
|
|
3
5
|
* DX-3261 — a producer handed this module a line it cannot safely key or
|
|
@@ -119,6 +121,44 @@ function readSpeed(usage) {
|
|
|
119
121
|
export class UsageLedgerCounter {
|
|
120
122
|
entries = new Map();
|
|
121
123
|
changed = new Set();
|
|
124
|
+
/** DX-3964 - one timing reducer per source (transcript file); see `transcript-timing.ts`. */
|
|
125
|
+
reducers = new Map();
|
|
126
|
+
/**
|
|
127
|
+
* DX-3964 - feed one line of the fed set (assistant, user, attachment, system) in FILE ORDER. An assistant
|
|
128
|
+
* line credits usage exactly as {@link observe} does; every kind also feeds its source's timing reducer,
|
|
129
|
+
* and a reply's timing that RISES - including one raised by a later tool_result, after the reply's last line,
|
|
130
|
+
* with no token growth - counts as a change, so {@link takeChanged} re-sends it.
|
|
131
|
+
*/
|
|
132
|
+
observeLine(t) {
|
|
133
|
+
let didChange = t.kind === "assistant" ? this.observe(t.line) : false;
|
|
134
|
+
const source = t.kind === "assistant" ? t.line.source : t.source;
|
|
135
|
+
let reducer = this.reducers.get(source);
|
|
136
|
+
if (!reducer) {
|
|
137
|
+
reducer = new SourceTimingReducer();
|
|
138
|
+
this.reducers.set(source, reducer);
|
|
139
|
+
}
|
|
140
|
+
for (const [key, timing] of reducer.feed(t)) {
|
|
141
|
+
const entry = this.entries.get(key);
|
|
142
|
+
// A reply the usage counter never opened (no usage and no tool id) has no entry to carry timing.
|
|
143
|
+
if (!entry)
|
|
144
|
+
continue;
|
|
145
|
+
const next = maxTiming(entry.timing, timing);
|
|
146
|
+
if (LEDGER_TIMING_FIELDS.some((f) => next[f] !== entry.timing[f])) {
|
|
147
|
+
entry.timing = next;
|
|
148
|
+
this.changed.add(key);
|
|
149
|
+
didChange = true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return didChange;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* DX-3964 - a source's file was truncated and is read again from 0: its timing reducer starts fresh and
|
|
156
|
+
* first-line-anchored, so a stale `hwm` of the old file can never credit a gap at the reset. Entries keep their
|
|
157
|
+
* values (max semantics); the re-read can only raise them.
|
|
158
|
+
*/
|
|
159
|
+
resetSource(source) {
|
|
160
|
+
this.reducers.delete(source);
|
|
161
|
+
}
|
|
122
162
|
observe(line) {
|
|
123
163
|
const hasUsage = line.usage !== undefined;
|
|
124
164
|
if (!hasUsage && line.toolUseIds.length === 0)
|
|
@@ -138,6 +178,7 @@ export class UsageLedgerCounter {
|
|
|
138
178
|
model: line.model ?? null,
|
|
139
179
|
speed: readSpeed(line.usage),
|
|
140
180
|
usage,
|
|
181
|
+
timing: zeroTiming(),
|
|
141
182
|
repliedAt: line.timestamp,
|
|
142
183
|
toolUseIds: [...new Set(line.toolUseIds)],
|
|
143
184
|
issueId: line.issueId,
|
|
@@ -209,47 +250,109 @@ export class UsageLedgerCounter {
|
|
|
209
250
|
}
|
|
210
251
|
}
|
|
211
252
|
function snapshot(e) {
|
|
212
|
-
return { ...e, usage: { ...e.usage }, toolUseIds: [...e.toolUseIds] };
|
|
253
|
+
return { ...e, usage: { ...e.usage }, timing: { ...e.timing }, toolUseIds: [...e.toolUseIds] };
|
|
254
|
+
}
|
|
255
|
+
/** The line types the usage collector is fed; every other `type` is ignored. */
|
|
256
|
+
const FED_TYPES = new Set(["assistant", "user", "attachment", "system"]);
|
|
257
|
+
function parseTimestamp(raw, fn) {
|
|
258
|
+
const ts = typeof raw.timestamp === "string" ? Date.parse(raw.timestamp) : NaN;
|
|
259
|
+
// DX-3261 - an unparseable/missing timestamp used to silently become `repliedAt: 0` (epoch 0), which falls
|
|
260
|
+
// outside every account-window lookback and made that reply's tokens permanently invisible to the gate - with
|
|
261
|
+
// no event, no log, nothing. Fail loud instead. DX-3964: the same for every fed kind, because `hwm` and the
|
|
262
|
+
// request-send time read it, and a `Date.now()` default would put wall clock into them.
|
|
263
|
+
if (!Number.isFinite(ts)) {
|
|
264
|
+
throw new LedgerLineDataError(`${fn}: unparseable/missing timestamp (raw.timestamp=${JSON.stringify(raw.timestamp)})`);
|
|
265
|
+
}
|
|
266
|
+
return ts;
|
|
267
|
+
}
|
|
268
|
+
function blocksOf(message) {
|
|
269
|
+
return message && Array.isArray(message.content)
|
|
270
|
+
? message.content
|
|
271
|
+
: [];
|
|
272
|
+
}
|
|
273
|
+
function textOfResult(content) {
|
|
274
|
+
if (typeof content === "string")
|
|
275
|
+
return content;
|
|
276
|
+
if (!Array.isArray(content))
|
|
277
|
+
return "";
|
|
278
|
+
return content
|
|
279
|
+
.map((b) => b && typeof b === "object" && typeof b.text === "string"
|
|
280
|
+
? b.text
|
|
281
|
+
: "")
|
|
282
|
+
.join("\n");
|
|
283
|
+
}
|
|
284
|
+
function toolResultsOf(raw, blocks) {
|
|
285
|
+
// An `isMeta` user line is counted as a human prompt, as the reference reducer does.
|
|
286
|
+
if (raw.isMeta === true)
|
|
287
|
+
return [];
|
|
288
|
+
const resultBlocks = blocks.filter((b) => b.type === "tool_result" && typeof b.tool_use_id === "string");
|
|
289
|
+
const tur = raw.toolUseResult;
|
|
290
|
+
// `toolUseResult` is one object per LINE: it can only be attributed to a line carrying exactly one result.
|
|
291
|
+
const patch = resultBlocks.length === 1 && tur && typeof tur === "object" && Array.isArray(tur.structuredPatch)
|
|
292
|
+
? tur.structuredPatch.map((h) => ({
|
|
293
|
+
lines: Array.isArray(h.lines) ? h.lines.filter((l) => typeof l === "string") : [],
|
|
294
|
+
}))
|
|
295
|
+
: null;
|
|
296
|
+
return resultBlocks.map((b) => ({
|
|
297
|
+
toolUseId: b.tool_use_id,
|
|
298
|
+
isError: b.is_error === true,
|
|
299
|
+
text: textOfResult(b.content),
|
|
300
|
+
structuredPatch: patch,
|
|
301
|
+
}));
|
|
302
|
+
}
|
|
303
|
+
function toolUsesOf(blocks) {
|
|
304
|
+
return blocks
|
|
305
|
+
.filter((b) => b.type === "tool_use" && typeof b.id === "string")
|
|
306
|
+
.map((b) => ({
|
|
307
|
+
id: b.id,
|
|
308
|
+
name: typeof b.name === "string" ? b.name : "",
|
|
309
|
+
input: b.input && typeof b.input === "object" && !Array.isArray(b.input) ? b.input : null,
|
|
310
|
+
}));
|
|
213
311
|
}
|
|
214
312
|
/**
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
313
|
+
* Parse one raw transcript line (a parsed JSONL object) of the fed set - assistant, user (tool_result and human
|
|
314
|
+
* prompts), attachment, system - into a {@link TranscriptLine}. Returns `null` for every other `type`
|
|
315
|
+
* (`queue-operation`, `last-prompt`, `file-history-*`, `custom-title`, ...). THE one parser for the usage feed:
|
|
316
|
+
* the live watcher subscription, `observeTranscriptText` (claude-probe) and the tests all go through it.
|
|
219
317
|
*/
|
|
220
318
|
export function ledgerLineFromRaw(raw, source, byteOffset) {
|
|
221
|
-
|
|
319
|
+
const type = raw.type;
|
|
320
|
+
if (typeof type !== "string" || !FED_TYPES.has(type))
|
|
222
321
|
return null;
|
|
223
322
|
const message = raw.message;
|
|
224
|
-
if (
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
323
|
+
if (type === "assistant") {
|
|
324
|
+
if (!message)
|
|
325
|
+
return null;
|
|
326
|
+
const ts = parseTimestamp(raw, "ledgerLineFromRaw");
|
|
327
|
+
const blocks = blocksOf(message);
|
|
328
|
+
const toolUses = toolUsesOf(blocks);
|
|
329
|
+
const messageId = typeof message.id === "string" && message.id.length > 0 ? message.id : undefined;
|
|
330
|
+
const model = typeof message.model === "string" && message.model.length > 0 ? message.model : undefined;
|
|
331
|
+
return {
|
|
332
|
+
kind: "assistant",
|
|
333
|
+
line: {
|
|
334
|
+
messageId,
|
|
335
|
+
source,
|
|
336
|
+
byteOffset,
|
|
337
|
+
model,
|
|
338
|
+
usage: message.usage && typeof message.usage === "object"
|
|
339
|
+
? message.usage
|
|
340
|
+
: undefined,
|
|
341
|
+
timestamp: ts,
|
|
342
|
+
toolUseIds: toolUses.map((u) => u.id),
|
|
343
|
+
issueId: null,
|
|
344
|
+
},
|
|
345
|
+
replyKey: messageId ?? syntheticMessageKey(source, byteOffset),
|
|
346
|
+
byteOffset,
|
|
347
|
+
synthetic: model === "<synthetic>",
|
|
348
|
+
toolUses,
|
|
349
|
+
};
|
|
236
350
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
source,
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
? message.model
|
|
243
|
-
: undefined,
|
|
244
|
-
usage: message.usage && typeof message.usage === "object"
|
|
245
|
-
? message.usage
|
|
246
|
-
: undefined,
|
|
247
|
-
timestamp: ts,
|
|
248
|
-
toolUseIds: content
|
|
249
|
-
.filter((b) => b.type === "tool_use" && typeof b.id === "string")
|
|
250
|
-
.map((b) => b.id),
|
|
251
|
-
issueId: null,
|
|
252
|
-
};
|
|
351
|
+
const timestamp = parseTimestamp(raw, "ledgerLineFromRaw");
|
|
352
|
+
if (type === "user") {
|
|
353
|
+
return { kind: "user", source, timestamp, byteOffset, results: toolResultsOf(raw, blocksOf(message)) };
|
|
354
|
+
}
|
|
355
|
+
return { kind: type, source, timestamp, byteOffset };
|
|
253
356
|
}
|
|
254
357
|
/**
|
|
255
358
|
* Count a whole transcript text (one file) into a counter. Byte offsets are
|
|
@@ -275,7 +378,7 @@ export function observeTranscriptText(counter, text, source, startByteOffset = 0
|
|
|
275
378
|
}
|
|
276
379
|
const line = ledgerLineFromRaw(parsed, source, lineOffset);
|
|
277
380
|
if (line)
|
|
278
|
-
counter.
|
|
381
|
+
counter.observeLine(line);
|
|
279
382
|
}
|
|
280
383
|
}
|
|
281
384
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thehammer/danx-session-telemetry",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.24",
|
|
4
4
|
"description": "Shared Claude Code transcript collector (DX-2962) — incremental JSONL tailing for the danxbot worker and the danx-dashboard-mcp desktop bridge, so both run identical collector code.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|