@thehammer/danx-session-telemetry 0.1.21 → 0.1.23

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.
@@ -0,0 +1 @@
1
+ e6bcf853731747abce107b80449eea1f0a08c13dc90336d527157f0d7bbb0d3a
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
@@ -3,4 +3,6 @@
3
3
  export { truncStr } from "./tool-summary.js";
4
4
  // DX-2963 — counter + pricing (pure arithmetic, no `node:` imports).
5
5
  export * from "./usage-ledger.js";
6
+ export * from "./ledger-timing.js";
7
+ export * from "./transcript-timing.js";
6
8
  export * from "./pricing.js";
@@ -56,7 +56,8 @@ export const ISSUE_ID_MAX_LENGTH = 32;
56
56
  const CLAIM_ACTION = "pickup";
57
57
  /** The transitions that end a sub-agent's hold on a card. */
58
58
  const RELEASE_ACTIONS = new Set(["complete", "cancel", "block", "rollback_pickup"]);
59
- const TRANSITION_PATH = /^\/api\/issues\/([A-Za-z][A-Za-z0-9]*-[0-9]+)\/transition$/;
59
+ // DX-3962: built from ISSUE_ID_PATTERN so the card-id shape lives in one place.
60
+ const TRANSITION_PATH = new RegExp(`^/api/issues/(${ISSUE_ID_PATTERN.source.slice(1, -1)})/transition$`);
60
61
  /**
61
62
  * Bounds that keep one source's persisted state, and the stats write that carries
62
63
  * it, bounded. A real sub-agent holds one or two cards, so neither is reachable;
@@ -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 watcher entry. Only assistant lines carry usage / tool ids.
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
- * to the counter — the ledger is idempotent and dropping it would lose usage a
203
- * restart is meant to recover — but its id is remembered so the local ceiling
204
- * estimate does not count it twice (see {@link DispatchUsage.unacknowledged}).
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
- observeEntry(entry: AgentLogEntry, replay?: boolean): void;
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
@@ -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 { MAIN_SOURCE, UsageLedgerCounter, ledgerLineFromLogEntry, excessUsage, syntheticMessageKey, } from "./usage-ledger.js";
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 watcher entry. Only assistant lines carry usage / tool ids.
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
- * to the counter — the ledger is idempotent and dropping it would lose usage a
287
- * restart is meant to recover — but its id is remembered so the local ceiling
288
- * estimate does not count it twice (see {@link DispatchUsage.unacknowledged}).
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
- observeEntry(entry, replay = false) {
291
- const content = Array.isArray(entry.data.content) ? entry.data.content : [];
292
- const source = typeof entry.data.subagent_id === "string" ? entry.data.subagent_id : MAIN_SOURCE;
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 (entry.type === "user")
295
- this.claims?.observeResultBlocks(source, content);
296
- const line = ledgerLineFromLogEntry(entry);
297
- if (line)
298
- this.observeReply(line, replay);
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 (entry.type === "assistant")
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 ≤ 2×`SCAN_BYTES`). A resumed tag always
288
+ * the whole file, for any file ≤ 2�—`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 ≤ 2×`SCAN_BYTES`). A resumed tag always
226
+ * the whole file, for any file ≤ 2�—`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-5×. See
352
+ // — without it, every multi-block turn over-counts tokens 2-5�—. 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
@@ -219,6 +219,11 @@ export declare const CURSOR_BATCH_CAP = 500;
219
219
  * still-running report queued after a finish must not un-finish the row.
220
220
  */
221
221
  export declare function collapseActivityChanges(changes: readonly ActivityChange[]): ActivityChange[];
222
+ /**
223
+ * DX-3962 - whether `raw` is a well-formed claim state. A STORED cursor from a server that predates attribution has
224
+ * none, and a collector must refuse such an answer instead of dereferencing it.
225
+ */
226
+ export declare function isClaimState(raw: unknown): raw is ClaimState;
222
227
  /**
223
228
  * Strict parse of a stats body (fail loud — a malformed entry is a worker
224
229
  * bug, never coerced). Refuses more than {@link STATS_BATCH_CAP} entries, and
@@ -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
@@ -117,6 +118,21 @@ const PENDING_KEYS = new Set(["op", "issueId"]);
117
118
  function isIssueId(v) {
118
119
  return typeof v === "string" && v.length <= ISSUE_ID_MAX_LENGTH && ISSUE_ID_PATTERN.test(v);
119
120
  }
121
+ /**
122
+ * DX-3962 - whether `raw` is a well-formed claim state. A STORED cursor from a server that predates attribution has
123
+ * none, and a collector must refuse such an answer instead of dereferencing it.
124
+ */
125
+ export function isClaimState(raw) {
126
+ try {
127
+ parseClaimState(raw, "claims");
128
+ return true;
129
+ }
130
+ catch (err) {
131
+ if (err instanceof StatsPayloadError)
132
+ return false;
133
+ throw err;
134
+ }
135
+ }
120
136
  /** DX-3962 - strict parse of one source's claim state (a collector bug, never coerced). */
121
137
  function parseClaimState(raw, where) {
122
138
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
@@ -312,6 +328,17 @@ export function parseStatsPayload(body) {
312
328
  if (!isNonNegInt(u[k]))
313
329
  throw new StatsPayloadError(`${where}.usage.${k}`);
314
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
+ }
315
342
  if (!Array.isArray(e.toolUseIds) || e.toolUseIds.some((t) => typeof t !== "string")) {
316
343
  throw new StatsPayloadError(`${where}.toolUseIds`);
317
344
  }
@@ -332,6 +359,7 @@ export function parseStatsPayload(body) {
332
359
  cacheWrite5m: u.cacheWrite5m,
333
360
  cacheWrite1h: u.cacheWrite1h,
334
361
  },
362
+ timing,
335
363
  toolUseIds: e.toolUseIds,
336
364
  issueId: e.issueId,
337
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
+ }
@@ -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
- * Pull the counter's {@link LedgerLine} fields out of one raw transcript
131
- * line (a parsed JSONL object). Returns `null` for anything that is not an
132
- * assistant line. Shared by every counter that reads a transcript from disk
133
- * (the dashboard's reader, `claude-probe.ts`, the stall sweep).
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): LedgerLine | null;
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
@@ -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
- * Pull the counter's {@link LedgerLine} fields out of one raw transcript
216
- * line (a parsed JSONL object). Returns `null` for anything that is not an
217
- * assistant line. Shared by every counter that reads a transcript from disk
218
- * (the dashboard's reader, `claude-probe.ts`, the stall sweep).
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
- if (raw.type !== "assistant")
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 (!message)
225
- return null;
226
- const content = Array.isArray(message.content)
227
- ? message.content
228
- : [];
229
- const ts = typeof raw.timestamp === "string" ? Date.parse(raw.timestamp) : NaN;
230
- // DX-3261 — an unparseable/missing timestamp used to silently become
231
- // `repliedAt: 0` (epoch 0), which falls outside every account-window
232
- // lookback and made that reply's tokens permanently invisible to the
233
- // gate — with no event, no log, nothing. Fail loud instead.
234
- if (!Number.isFinite(ts)) {
235
- throw new LedgerLineDataError(`ledgerLineFromRaw: unparseable/missing timestamp (raw.timestamp=${JSON.stringify(raw.timestamp)})`);
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
- return {
238
- messageId: typeof message.id === "string" && message.id.length > 0 ? message.id : undefined,
239
- source,
240
- byteOffset,
241
- model: typeof message.model === "string" && message.model.length > 0
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.observe(line);
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.21",
3
+ "version": "0.1.23",
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",