@thehammer/danx-session-telemetry 0.1.6 → 0.1.9

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/browser.d.ts CHANGED
@@ -14,9 +14,12 @@
14
14
  * dashboard SPA, a future browser-side usage display) can depend on this
15
15
  * subpath with zero risk of pulling in a Node builtin.
16
16
  *
17
- * DX-2963 adds the first real runtime export here: the one usage counter
18
- * and the pricing function, both pure arithmetic with no I/O. This card
19
- * (DX-2962) only creates the subpath and its no-`node:`-imports guarantee.
17
+ * DX-2963 adds the first real runtime exports here: the one usage counter
18
+ * (`usage-ledger.ts`) and the one pricing function (`pricing.ts`), both pure
19
+ * arithmetic with no I/O — `usage-snapshot.ts` in danxbot root (bundled by the
20
+ * frozen Vue SPA) imports through THIS subpath, never the main entry.
20
21
  */
21
22
  export type { AgentLogEntry, AgentLineage } from "./types.js";
22
23
  export { truncStr } from "./tool-summary.js";
24
+ export * from "./usage-ledger.js";
25
+ export * from "./pricing.js";
package/dist/browser.js CHANGED
@@ -1,3 +1,6 @@
1
1
  // `truncStr` is pure string arithmetic (no `node:` imports, no I/O) — safe
2
2
  // to carry on the browser subpath alongside the two types above.
3
3
  export { truncStr } from "./tool-summary.js";
4
+ // DX-2963 — counter + pricing (pure arithmetic, no `node:` imports).
5
+ export * from "./usage-ledger.js";
6
+ export * from "./pricing.js";
package/dist/index.d.ts CHANGED
@@ -18,3 +18,5 @@ export { SessionLogWatcher, DISPATCH_TAG_PREFIX, encodeClaudeProjectsCwd, derive
18
18
  export type { EntryConsumer, SessionLogWatcherOptions, } 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
+ export * from "./usage-ledger.js";
22
+ export * from "./pricing.js";
package/dist/index.js CHANGED
@@ -21,3 +21,7 @@ export { SessionLogWatcher, DISPATCH_TAG_PREFIX, encodeClaudeProjectsCwd, derive
21
21
  // truncate a string for display but have nothing else to do with the
22
22
  // transcript collector, e.g. `src/dashboard/dispatch-scout.ts`.
23
23
  export { truncStr, summarizeToolInput, buildAssistantSummary, buildToolResultSummary, } from "./tool-summary.js";
24
+ // DX-2963 — the one usage counter and the one pricing function (also on the
25
+ // browser-safe `./browser` subpath; both are pure arithmetic).
26
+ export * from "./usage-ledger.js";
27
+ export * from "./pricing.js";
@@ -0,0 +1,93 @@
1
+ /**
2
+ * DX-2963 — the ONE pricing function, shared by every consumer that turns
3
+ * token counts into dollars: the danxbot server's usage-ledger writer (which
4
+ * prices each credit once, when it is credited), the router's Haiku calls and
5
+ * the skill-eval cost report. Moved here from danxbot root `src/agent/pricing.ts`
6
+ * so the counter and the price list live side by side, and exported through
7
+ * the browser-safe `./browser` subpath (pure arithmetic, no `node:` imports).
8
+ *
9
+ * Source of every row: Anthropic's official pricing page,
10
+ * https://platform.claude.com/docs/en/about-claude/pricing — read 2026-09-23
11
+ * (DX-2963 re-checked every row against it; the previous list had drifted:
12
+ * Sonnet 5 at $3/$15 and Haiku 4.5 at $0.80/$4 where the page says $2/$10 and
13
+ * $1/$5, and Opus 5 / Fable 5.1 / Opus 5.5 were missing).
14
+ *
15
+ * DX-3261 — re-checked the WHOLE table row-by-row against the same page
16
+ * again, 2026-09-24, https://platform.claude.com/docs/en/about-claude/pricing
17
+ * (the "Model pricing" table). Found: `claude-opus-4-7` and `claude-opus-4-5`
18
+ * were both missing (page: $5 in / $25 out / $0.50 cache hit, same row shape
19
+ * as Opus 4.8 / Opus 4.6 — added below). Every other standard-speed row
20
+ * matched exactly: Fable 5.1 ($10/$50, 0.025x hit), Fable 5 ($10/$50, hit
21
+ * $1), Opus 5.5 ($4/$20, 0.05x hit), Opus 5 / Opus 4.8 / Opus 4.6 ($5/$25,
22
+ * $0.50 hit), Opus 4 retired ($15/$75, $1.50 hit), Sonnet 5 ($2/$10, $0.20
23
+ * hit), Sonnet 4.6 / Sonnet 4.5 / Sonnet 4 ($3/$15, $0.30 hit), Haiku 4.5
24
+ * ($1/$5, $0.10 hit), and the derived 1.25x/2h cache-write multipliers on
25
+ * every row. Fast-mode rows unchanged: Opus 5.5 ($8/$40), Opus 5 / Opus 4.8
26
+ * ($10/$50) — Opus 4.7 fast mode returns an error per the page (never
27
+ * priced), and Opus 4.6 fast mode is handled separately below (the page
28
+ * states it runs at standard speed, billed at standard rates — not an
29
+ * unpriced fast row). Claude Mythos 5.1/5 (limited-availability, same rates
30
+ * as their Fable counterparts) and the two retired-elsewhere rows (Opus 4.1,
31
+ * Haiku 3.5) are not in Claude Code's own model-tag surface and are left out
32
+ * on the same basis the table already excludes them.
33
+ *
34
+ * DX-2963 — cache writes are TWO prices, never one: a 5-minute write is
35
+ * 1.25x base input and a 1-hour write is 2x base input. Claude Code reports
36
+ * each lifetime separately (`usage.cache_creation.ephemeral_5m_input_tokens` /
37
+ * `ephemeral_1h_input_tokens`), and both are material (top-level transcripts
38
+ * write almost only 1-hour, sub-agent transcripts mostly 5-minute), so each
39
+ * field is priced with its own price.
40
+ */
41
+ /** Price per MILLION tokens, USD. */
42
+ export interface ModelPrice {
43
+ input: number;
44
+ output: number;
45
+ cacheWrite5m: number;
46
+ cacheWrite1h: number;
47
+ cacheRead: number;
48
+ }
49
+ /** Token counts for one priced unit (a ledger credit, a model bucket, a call). */
50
+ export interface PricedTokens {
51
+ input: number;
52
+ output: number;
53
+ cacheRead: number;
54
+ cacheWrite5m: number;
55
+ cacheWrite1h: number;
56
+ }
57
+ /**
58
+ * Claude Code's literal model tag on an API-error entry (rate limit, context
59
+ * overflow). Its tokens count in a dispatch's flat totals but it is never a
60
+ * real priced model, so it never enters `usage_by_model` or cost.
61
+ */
62
+ export declare const SYNTHETIC_MODEL = "<synthetic>";
63
+ /** The `usage.speed` value a fast-mode reply carries. */
64
+ export declare const FAST_SPEED = "fast";
65
+ export type PriceResult = {
66
+ kind: "priced";
67
+ costUsd: number;
68
+ } | {
69
+ kind: "unpriced";
70
+ model: string;
71
+ speed: string;
72
+ };
73
+ /**
74
+ * Price one unit of usage. NEVER throws (DX-2963 replaces DX-1720's throw):
75
+ * an unknown model — or a fast reply on a model with no fast price — returns
76
+ * `{kind: "unpriced"}` so the caller counts the tokens, leaves the cost out
77
+ * and records a visible `unpriced_model` event, instead of one new model id
78
+ * failing every write (the live tick used to fail every 30 seconds on it).
79
+ * `<synthetic>` is never passed here: callers exclude it before pricing.
80
+ *
81
+ * DX-3261 — both tables are looked up with `Object.hasOwn`, never bracket
82
+ * access + truthiness (`table[model]`): a model id that collides with an
83
+ * inherited `Object.prototype` key (`"toString"`, `"constructor"`, …) would
84
+ * otherwise resolve to that inherited FUNCTION — truthy, so the `!p` guard
85
+ * never fires — and every subsequent `p.input` / `p.output` / … reads
86
+ * `undefined` off it, so the whole cost total becomes `NaN`. `Object.hasOwn`
87
+ * answers "does the row itself have this key" and can never be fooled by
88
+ * the prototype chain, so an unpriced model always returns `unpriced`
89
+ * cleanly, whatever its id looks like.
90
+ */
91
+ export declare function priceUsage(model: string, speed: string, tokens: PricedTokens): PriceResult;
92
+ /** True when `model` has a standard-speed price (used by tests + guards). */
93
+ export declare function isPricedModel(model: string): boolean;
@@ -0,0 +1,157 @@
1
+ /**
2
+ * DX-2963 — the ONE pricing function, shared by every consumer that turns
3
+ * token counts into dollars: the danxbot server's usage-ledger writer (which
4
+ * prices each credit once, when it is credited), the router's Haiku calls and
5
+ * the skill-eval cost report. Moved here from danxbot root `src/agent/pricing.ts`
6
+ * so the counter and the price list live side by side, and exported through
7
+ * the browser-safe `./browser` subpath (pure arithmetic, no `node:` imports).
8
+ *
9
+ * Source of every row: Anthropic's official pricing page,
10
+ * https://platform.claude.com/docs/en/about-claude/pricing — read 2026-09-23
11
+ * (DX-2963 re-checked every row against it; the previous list had drifted:
12
+ * Sonnet 5 at $3/$15 and Haiku 4.5 at $0.80/$4 where the page says $2/$10 and
13
+ * $1/$5, and Opus 5 / Fable 5.1 / Opus 5.5 were missing).
14
+ *
15
+ * DX-3261 — re-checked the WHOLE table row-by-row against the same page
16
+ * again, 2026-09-24, https://platform.claude.com/docs/en/about-claude/pricing
17
+ * (the "Model pricing" table). Found: `claude-opus-4-7` and `claude-opus-4-5`
18
+ * were both missing (page: $5 in / $25 out / $0.50 cache hit, same row shape
19
+ * as Opus 4.8 / Opus 4.6 — added below). Every other standard-speed row
20
+ * matched exactly: Fable 5.1 ($10/$50, 0.025x hit), Fable 5 ($10/$50, hit
21
+ * $1), Opus 5.5 ($4/$20, 0.05x hit), Opus 5 / Opus 4.8 / Opus 4.6 ($5/$25,
22
+ * $0.50 hit), Opus 4 retired ($15/$75, $1.50 hit), Sonnet 5 ($2/$10, $0.20
23
+ * hit), Sonnet 4.6 / Sonnet 4.5 / Sonnet 4 ($3/$15, $0.30 hit), Haiku 4.5
24
+ * ($1/$5, $0.10 hit), and the derived 1.25x/2h cache-write multipliers on
25
+ * every row. Fast-mode rows unchanged: Opus 5.5 ($8/$40), Opus 5 / Opus 4.8
26
+ * ($10/$50) — Opus 4.7 fast mode returns an error per the page (never
27
+ * priced), and Opus 4.6 fast mode is handled separately below (the page
28
+ * states it runs at standard speed, billed at standard rates — not an
29
+ * unpriced fast row). Claude Mythos 5.1/5 (limited-availability, same rates
30
+ * as their Fable counterparts) and the two retired-elsewhere rows (Opus 4.1,
31
+ * Haiku 3.5) are not in Claude Code's own model-tag surface and are left out
32
+ * on the same basis the table already excludes them.
33
+ *
34
+ * DX-2963 — cache writes are TWO prices, never one: a 5-minute write is
35
+ * 1.25x base input and a 1-hour write is 2x base input. Claude Code reports
36
+ * each lifetime separately (`usage.cache_creation.ephemeral_5m_input_tokens` /
37
+ * `ephemeral_1h_input_tokens`), and both are material (top-level transcripts
38
+ * write almost only 1-hour, sub-agent transcripts mostly 5-minute), so each
39
+ * field is priced with its own price.
40
+ */
41
+ /**
42
+ * Claude Code's literal model tag on an API-error entry (rate limit, context
43
+ * overflow). Its tokens count in a dispatch's flat totals but it is never a
44
+ * real priced model, so it never enters `usage_by_model` or cost.
45
+ */
46
+ export const SYNTHETIC_MODEL = "<synthetic>";
47
+ /** The `usage.speed` value a fast-mode reply carries. */
48
+ export const FAST_SPEED = "fast";
49
+ /**
50
+ * Build a row from base input/output with the page's caching multipliers
51
+ * (5m write 1.25x, 1h write 2x) and an explicit cache-hit price — the hit
52
+ * multiplier differs per model (0.1x standard, 0.025x Fable 5.1, 0.05x
53
+ * Opus 5.5), so it is stated per row rather than derived.
54
+ */
55
+ function price(input, output, cacheRead) {
56
+ return {
57
+ input,
58
+ output,
59
+ cacheWrite5m: input * 1.25,
60
+ cacheWrite1h: input * 2,
61
+ cacheRead,
62
+ };
63
+ }
64
+ /** Standard-speed prices, keyed by the model id Claude Code writes in the JSONL. */
65
+ const PRICING = {
66
+ // Claude Fable 5.1 — cache hits at 0.025x input ($0.25).
67
+ "claude-fable-5-1": price(10, 50, 0.25),
68
+ "claude-fable-5": price(10, 50, 1),
69
+ // Claude Opus 5.5 — cache hits at 0.05x input ($0.20).
70
+ "claude-opus-5-5": price(4, 20, 0.2),
71
+ "claude-opus-5": price(5, 25, 0.5),
72
+ "claude-opus-4-8": price(5, 25, 0.5),
73
+ "claude-opus-4-7": price(5, 25, 0.5),
74
+ "claude-opus-4-6": price(5, 25, 0.5),
75
+ "claude-opus-4-6-20250916": price(5, 25, 0.5),
76
+ "claude-opus-4-5": price(5, 25, 0.5),
77
+ "claude-opus-4-20250514": price(15, 75, 1.5),
78
+ "claude-sonnet-5": price(2, 10, 0.2),
79
+ "claude-sonnet-4-6": price(3, 15, 0.3),
80
+ "claude-sonnet-4-5-20250929": price(3, 15, 0.3),
81
+ "claude-sonnet-4-20250514": price(3, 15, 0.3),
82
+ "claude-haiku-4-5": price(1, 5, 0.1),
83
+ "claude-haiku-4-5-20251001": price(1, 5, 0.1),
84
+ };
85
+ /**
86
+ * Fast-mode prices (the page's "Fast mode pricing" section): premium
87
+ * input/output with the prompt-caching multipliers applied ON TOP (so a 5m
88
+ * write is 1.25x and a 1h write 2x of the fast input price, a hit 0.1x — the
89
+ * page states Opus 5.5's own 0.05x hit multiplier only for standard speed and
90
+ * says caching multipliers stack, so its fast hit is 0.05x of $8).
91
+ *
92
+ * DX-2963: the card names Opus 5 and Opus 4.8; the page re-read on
93
+ * 2026-09-23 also lists Opus 5.5 ($8 / $40), so it is priced too. A fast
94
+ * reply on any model NOT in this table is unpriced (tokens count, cost left
95
+ * out, a visible `unpriced_model` event) — never priced at the standard rate.
96
+ */
97
+ const FAST_PRICING = {
98
+ "claude-opus-5-5": price(8, 40, 0.4),
99
+ "claude-opus-5": price(10, 50, 1),
100
+ "claude-opus-4-8": price(10, 50, 1),
101
+ };
102
+ /**
103
+ * DX-3261 — models whose `speed:"fast"` requests are billed at STANDARD
104
+ * rates, per the page's "Fast mode pricing" section: *"Fast mode is not
105
+ * available on Claude Opus 4.7 (requests with `speed: "fast"` return an
106
+ * error) or Claude Opus 4.6 (requests run at standard speed and are billed
107
+ * at standard rates)."* Opus 4.7 correctly falls through to `unpriced` below
108
+ * (it has no row in either table — matches the page's "returns an error").
109
+ * Opus 4.6 does NOT: DX-2963's own wording sent it down the same unpriced
110
+ * path as Opus 4.7, contradicting the page it pins — a fast-mode Opus 4.6
111
+ * reply silently lost its cost. The page wins: look it up in the STANDARD
112
+ * table instead of `unpriced`, never invent a separate fast row for it.
113
+ */
114
+ const FAST_BILLS_AT_STANDARD_RATE = new Set([
115
+ "claude-opus-4-6",
116
+ "claude-opus-4-6-20250916",
117
+ ]);
118
+ /**
119
+ * Price one unit of usage. NEVER throws (DX-2963 replaces DX-1720's throw):
120
+ * an unknown model — or a fast reply on a model with no fast price — returns
121
+ * `{kind: "unpriced"}` so the caller counts the tokens, leaves the cost out
122
+ * and records a visible `unpriced_model` event, instead of one new model id
123
+ * failing every write (the live tick used to fail every 30 seconds on it).
124
+ * `<synthetic>` is never passed here: callers exclude it before pricing.
125
+ *
126
+ * DX-3261 — both tables are looked up with `Object.hasOwn`, never bracket
127
+ * access + truthiness (`table[model]`): a model id that collides with an
128
+ * inherited `Object.prototype` key (`"toString"`, `"constructor"`, …) would
129
+ * otherwise resolve to that inherited FUNCTION — truthy, so the `!p` guard
130
+ * never fires — and every subsequent `p.input` / `p.output` / … reads
131
+ * `undefined` off it, so the whole cost total becomes `NaN`. `Object.hasOwn`
132
+ * answers "does the row itself have this key" and can never be fooled by
133
+ * the prototype chain, so an unpriced model always returns `unpriced`
134
+ * cleanly, whatever its id looks like.
135
+ */
136
+ export function priceUsage(model, speed, tokens) {
137
+ const useFastTable = speed === FAST_SPEED && !FAST_BILLS_AT_STANDARD_RATE.has(model);
138
+ const table = useFastTable ? FAST_PRICING : PRICING;
139
+ if (!Object.hasOwn(table, model))
140
+ return { kind: "unpriced", model, speed };
141
+ const p = table[model];
142
+ return {
143
+ kind: "priced",
144
+ costUsd: (tokens.input * p.input +
145
+ tokens.output * p.output +
146
+ tokens.cacheRead * p.cacheRead +
147
+ tokens.cacheWrite5m * p.cacheWrite5m +
148
+ tokens.cacheWrite1h * p.cacheWrite1h) /
149
+ 1_000_000,
150
+ };
151
+ }
152
+ /** True when `model` has a standard-speed price (used by tests + guards). */
153
+ export function isPricedModel(model) {
154
+ // DX-3261 — `in` walks the prototype chain (`"toString" in {}` is `true`);
155
+ // `Object.hasOwn` can't be fooled by it. See `priceUsage`'s own comment.
156
+ return Object.hasOwn(PRICING, model);
157
+ }
@@ -61,6 +61,17 @@ export interface SessionLogWatcherOptions {
61
61
  * EOF is 0, so it ingests its own full file from the start.
62
62
  */
63
63
  fromEof?: boolean;
64
+ /**
65
+ * DX-2963 — the end-of-file rule for PRE-CARD lineage. When set, every
66
+ * sub-agent file that ALREADY EXISTS on the first sub-agent discovery pass
67
+ * is opened at its end (like the main file under `fromEof`); a sub-agent
68
+ * file that appears later is read from byte 0. Set by the worker when its
69
+ * own dispatch row, or its predecessor's, is pre-card (stored totals with
70
+ * no ledger rows to dedupe against), so replies those totals already count
71
+ * are never credited again. DX-2954 replaces this with its cursor start
72
+ * order and deletes it.
73
+ */
74
+ existingSubagentFilesFromEof?: boolean;
64
75
  }
65
76
  /**
66
77
  * Encode a working directory to the subdir name Claude Code writes JSONL
@@ -186,6 +197,8 @@ export declare class SessionLogWatcher {
186
197
  private dispatchId;
187
198
  private pollIntervalMs;
188
199
  private fromEof;
200
+ private existingSubagentFilesFromEof;
201
+ private subagentDiscoveryDone;
189
202
  private running;
190
203
  private polling;
191
204
  private initSynthesized;
@@ -424,6 +424,8 @@ export class SessionLogWatcher {
424
424
  dispatchId;
425
425
  pollIntervalMs;
426
426
  fromEof;
427
+ existingSubagentFilesFromEof;
428
+ subagentDiscoveryDone = false;
427
429
  running = false;
428
430
  polling = false;
429
431
  // DX-2962: entries are NOT accumulated here — see `emitEntry`. Only
@@ -439,6 +441,8 @@ export class SessionLogWatcher {
439
441
  this.dispatchId = options.dispatchId;
440
442
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
441
443
  this.fromEof = options.fromEof ?? false;
444
+ this.existingSubagentFilesFromEof =
445
+ options.existingSubagentFilesFromEof ?? false;
442
446
  }
443
447
  /** Register a consumer callback that receives each new AgentLogEntry. */
444
448
  onEntry(consumer) {
@@ -577,6 +581,7 @@ export class SessionLogWatcher {
577
581
  filePath,
578
582
  byteOffset,
579
583
  lineBuffer: "",
584
+ lineBufferStartOffset: byteOffset,
580
585
  lastTimestamp: 0,
581
586
  };
582
587
  this.subagentsDir = join(dirname(filePath), basename(filePath, ".jsonl"), "subagents");
@@ -680,6 +685,10 @@ export class SessionLogWatcher {
680
685
  log.error("Failed to enumerate sub-agent directory:", err);
681
686
  return;
682
687
  }
688
+ // DX-2963 — the FIRST discovery pass sees exactly the sub-agent files
689
+ // that existed when this watcher attached; only those may open at EOF.
690
+ const openExistingAtEof = this.existingSubagentFilesFromEof && !this.subagentDiscoveryDone;
691
+ this.subagentDiscoveryDone = true;
683
692
  for (const file of files) {
684
693
  if (!file.endsWith(".jsonl"))
685
694
  continue;
@@ -688,9 +697,19 @@ export class SessionLogWatcher {
688
697
  continue;
689
698
  const subagentId = basename(file, ".jsonl");
690
699
  const agentType = await this.readAgentType(subagentId);
700
+ let startOffset = 0;
701
+ if (openExistingAtEof) {
702
+ try {
703
+ startOffset = (await stat(fullPath)).size;
704
+ }
705
+ catch (err) {
706
+ log.warn(`existingSubagentFilesFromEof: stat failed for ${fullPath}; tailing from 0: ${String(err)}`);
707
+ }
708
+ }
691
709
  this.subagentTails.set(fullPath, {
692
710
  filePath: fullPath,
693
- byteOffset: 0,
711
+ byteOffset: startOffset,
712
+ lineBufferStartOffset: startOffset,
694
713
  lineBuffer: "",
695
714
  lastTimestamp: 0,
696
715
  lineage: {
@@ -759,13 +778,16 @@ export class SessionLogWatcher {
759
778
  state.lineBuffer += text;
760
779
  let newlineIdx;
761
780
  while ((newlineIdx = state.lineBuffer.indexOf("\n")) !== -1) {
762
- const line = state.lineBuffer.substring(0, newlineIdx).trim();
781
+ const rawLine = state.lineBuffer.substring(0, newlineIdx);
782
+ const lineStart = state.lineBufferStartOffset;
783
+ state.lineBufferStartOffset += Buffer.byteLength(rawLine, "utf-8") + 1;
784
+ const line = rawLine.trim();
763
785
  state.lineBuffer = state.lineBuffer.substring(newlineIdx + 1);
764
786
  if (!line)
765
787
  continue;
766
788
  try {
767
789
  const raw = JSON.parse(line);
768
- this.handleRawEntry(raw, state);
790
+ this.handleRawEntry(raw, state, lineStart);
769
791
  }
770
792
  catch {
771
793
  log.warn(`Skipping malformed JSONL line: ${line.substring(0, 100)}`);
@@ -795,7 +817,7 @@ export class SessionLogWatcher {
795
817
  * skill/agent set. The pre-launch probe in `mcp-server-probe.ts` is the
796
818
  * tool-registration check that works across both runtimes.
797
819
  */
798
- handleRawEntry(raw, state) {
820
+ handleRawEntry(raw, state, lineStart) {
799
821
  const isSubagent = state.lineage !== undefined;
800
822
  if (!isSubagent && !this.initSynthesized && raw.type === "assistant") {
801
823
  const message = raw.message;
@@ -835,6 +857,8 @@ export class SessionLogWatcher {
835
857
  this.parentSessionId = sid;
836
858
  }
837
859
  state.lastTimestamp = result.timestamp;
860
+ // DX-2963 — the line's own byte offset (see `TailState.lineBufferStartOffset`).
861
+ result.entry.data = { ...result.entry.data, byte_offset: lineStart };
838
862
  this.emitEntry(result.entry, state);
839
863
  }
840
864
  /**
@@ -0,0 +1,141 @@
1
+ /**
2
+ * DX-2963 — the ONE usage counter. Turns Claude Code transcript lines into
3
+ * per-reply usage-ledger entries; every counter in danxbot (the worker's
4
+ * accumulator, the skill-eval probe, the stall sweep's productivity signal,
5
+ * the cross-consumer tests) uses it instead of its own summing.
6
+ *
7
+ * The rule it implements, measured on real transcripts (DX-2963 card,
8
+ * 2026-09-18): Claude Code writes one assistant reply as SEVERAL lines that
9
+ * share a `message.id`, and the later lines carry the real (larger) usage —
10
+ * the last line held the maximum in 28,121 of 28,124 multi-line replies. A
11
+ * reply's usage is therefore the per-FIELD MAXIMUM over its lines, never its
12
+ * first line (the old rule every counter shared, which undercounted output
13
+ * 3.17x on sub-agent transcripts) and never a sum (which over-counts).
14
+ * Far-later repeats of a `message.id` are replays of re-written history and
15
+ * never raise usage, so a max-based counter credits them nothing for free.
16
+ *
17
+ * Pure arithmetic — no `node:` imports — so it is exported through the
18
+ * browser-safe `./browser` subpath as well as the main entry.
19
+ */
20
+ import type { PricedTokens } from "./pricing.js";
21
+ import type { AgentLogEntry } from "./types.js";
22
+ /** Per-reply usage, both cache-write lifetimes kept apart (priced apart). */
23
+ export type LedgerUsage = PricedTokens;
24
+ /**
25
+ * One reply's ledger entry as the collector reports it: the per-field
26
+ * MAXIMUM usage seen over the reply's lines so far, plus every `tool_use`
27
+ * id seen in it.
28
+ */
29
+ export interface LedgerEntry {
30
+ /** `message.id`, or the synthetic `nomsg:<source>:<byteOffset>` key. */
31
+ messageId: string;
32
+ /** `"main"` for the dispatch's own transcript, else the sub-agent id. */
33
+ source: string;
34
+ /** First non-empty `message.model` seen for the reply; `null` if none. */
35
+ model: string | null;
36
+ /** `usage.speed` (`standard` / `fast`); `"standard"` when the line has none. */
37
+ speed: string;
38
+ usage: LedgerUsage;
39
+ /** The reply's own transcript timestamp (its FIRST line's), epoch ms. */
40
+ repliedAt: number;
41
+ toolUseIds: string[];
42
+ }
43
+ /** The fields of one assistant line the counter needs. */
44
+ export interface LedgerLine {
45
+ messageId: string | undefined;
46
+ source: string;
47
+ /** Byte offset of the line's first byte in its file — keys a line with no id. */
48
+ byteOffset: number;
49
+ model: string | undefined;
50
+ /** The line's raw `message.usage` block, if it carries one. */
51
+ usage: Record<string, unknown> | undefined;
52
+ /** The line's transcript timestamp, epoch ms. */
53
+ timestamp: number;
54
+ /** `tool_use` block ids on this line. */
55
+ toolUseIds: readonly string[];
56
+ }
57
+ export declare const MAIN_SOURCE = "main";
58
+ /**
59
+ * DX-3261 — a producer handed this module a line it cannot safely key or
60
+ * date: no valid `byte_offset` on a no-`message.id` line (would collapse
61
+ * every such line in the source onto the same synthetic key, crediting only
62
+ * the largest — see {@link syntheticMessageKey}), or a `timestamp` that
63
+ * doesn't parse (would read as `repliedAt: 0`, invisible to every
64
+ * account-window lookback). Both were silent `0` defaults before this card;
65
+ * thrown here instead, so a broken producer fails immediately and visibly
66
+ * rather than quietly losing or mis-crediting usage.
67
+ */
68
+ export declare class LedgerLineDataError extends Error {
69
+ }
70
+ /**
71
+ * DX-2963 — a usage line with no `message.id` (never observed in the 22,087
72
+ * usage lines of the 20 newest local transcripts, 2026-09-18) is keyed by its
73
+ * source and the byte offset of its line, so it is credited exactly once
74
+ * however often the file is re-read, instead of once per read.
75
+ */
76
+ export declare function syntheticMessageKey(source: string, byteOffset: number): string;
77
+ export declare function isSyntheticMessageKey(key: string): boolean;
78
+ /**
79
+ * Read a raw `message.usage` block into {@link LedgerUsage}. The two
80
+ * cache-write lifetimes come from `usage.cache_creation.ephemeral_5m_input_tokens`
81
+ * / `ephemeral_1h_input_tokens`. A line that carries only the flat
82
+ * `cache_creation_input_tokens` (no split object) wrote with the API's
83
+ * default 5-minute lifetime, so the whole amount is a 5-minute write — that
84
+ * is what the flat field meant before the split existed.
85
+ */
86
+ export declare function parseUsageBlock(raw: Record<string, unknown>): LedgerUsage;
87
+ export declare function zeroUsage(): LedgerUsage;
88
+ export declare const LEDGER_USAGE_FIELDS: readonly ["input", "output", "cacheRead", "cacheWrite5m", "cacheWrite1h"];
89
+ export declare function maxUsage(a: LedgerUsage, b: LedgerUsage): LedgerUsage;
90
+ export declare function addUsage(a: LedgerUsage, b: LedgerUsage): LedgerUsage;
91
+ /** Per-field `max(0, a - b)`. */
92
+ export declare function excessUsage(a: LedgerUsage, b: LedgerUsage): LedgerUsage;
93
+ export declare function isZeroUsage(u: LedgerUsage): boolean;
94
+ /** Window tokens: input + output + both cache writes (the account-window unit). */
95
+ export declare function windowTokensOfUsage(u: LedgerUsage): number;
96
+ /** All five fields summed. */
97
+ export declare function totalTokensOfUsage(u: LedgerUsage): number;
98
+ /**
99
+ * The per-process counter: one entry per reply, updated line by line.
100
+ * `observe` returns true when the line CHANGED the reply's entry (a new
101
+ * reply, a raised field, or a new tool-use id) — a replay returns false —
102
+ * and the entry is queued for {@link takeChanged}. Holds one small entry per
103
+ * reply for the life of the process; no transcript text is kept.
104
+ */
105
+ export declare class UsageLedgerCounter {
106
+ private readonly entries;
107
+ private readonly changed;
108
+ observe(line: LedgerLine): boolean;
109
+ /** Entries changed since the last call, as snapshots; clears the queue. */
110
+ takeChanged(): LedgerEntry[];
111
+ /** Put entries back on the changed queue (a write that failed). */
112
+ requeue(messageIds: readonly string[]): void;
113
+ get(messageId: string): LedgerEntry | undefined;
114
+ all(): LedgerEntry[];
115
+ /** Per-field sum of every reply's maximum — the transcript's true total. */
116
+ totals(): LedgerUsage;
117
+ /** Distinct tool-use ids across all replies. */
118
+ toolUseCount(): number;
119
+ }
120
+ /**
121
+ * Pull the counter's {@link LedgerLine} fields out of one raw transcript
122
+ * line (a parsed JSONL object). Returns `null` for anything that is not an
123
+ * assistant line. Shared by every counter that reads a transcript from disk
124
+ * (the dashboard's reader, the skill-eval probe, the stall sweep).
125
+ */
126
+ export declare function ledgerLineFromRaw(raw: Record<string, unknown>, source: string, byteOffset: number): LedgerLine | null;
127
+ /**
128
+ * Count a whole transcript text (one file) into a counter. Byte offsets are
129
+ * computed on UTF-8 bytes so a no-id line keys identically to the live
130
+ * watcher's own offset for the same line.
131
+ */
132
+ export declare function observeTranscriptText(counter: UsageLedgerCounter, text: string, source: string, startByteOffset?: number): void;
133
+ /**
134
+ * The counter's {@link LedgerLine} for one converted watcher entry
135
+ * (`AgentLogEntry`, as `SessionLogWatcher` emits it: `data.messageId`,
136
+ * `data.model`, `data.usage`, `data.content`, `data.subagent_id`,
137
+ * `data.byte_offset`). `null` for anything that is not an assistant entry.
138
+ * Shared by every counter fed from watcher entries (the worker's dispatch
139
+ * usage, the stall sweep's productivity signal).
140
+ */
141
+ export declare function ledgerLineFromLogEntry(entry: AgentLogEntry): LedgerLine | null;
@@ -0,0 +1,318 @@
1
+ export const MAIN_SOURCE = "main";
2
+ /**
3
+ * DX-3261 — a producer handed this module a line it cannot safely key or
4
+ * date: no valid `byte_offset` on a no-`message.id` line (would collapse
5
+ * every such line in the source onto the same synthetic key, crediting only
6
+ * the largest — see {@link syntheticMessageKey}), or a `timestamp` that
7
+ * doesn't parse (would read as `repliedAt: 0`, invisible to every
8
+ * account-window lookback). Both were silent `0` defaults before this card;
9
+ * thrown here instead, so a broken producer fails immediately and visibly
10
+ * rather than quietly losing or mis-crediting usage.
11
+ */
12
+ export class LedgerLineDataError extends Error {
13
+ }
14
+ /**
15
+ * DX-2963 — a usage line with no `message.id` (never observed in the 22,087
16
+ * usage lines of the 20 newest local transcripts, 2026-09-18) is keyed by its
17
+ * source and the byte offset of its line, so it is credited exactly once
18
+ * however often the file is re-read, instead of once per read.
19
+ */
20
+ export function syntheticMessageKey(source, byteOffset) {
21
+ return `nomsg:${source}:${byteOffset}`;
22
+ }
23
+ export function isSyntheticMessageKey(key) {
24
+ return key.startsWith("nomsg:");
25
+ }
26
+ /**
27
+ * A usage field as a non-negative INTEGER count. A numeric STRING is read as
28
+ * its number (drift-safe: a transcript that ever quotes a count is still
29
+ * counted, never silently zeroed); anything else absent or non-numeric is 0.
30
+ *
31
+ * DX-3261 — rounded to the nearest integer, never passed through fractional.
32
+ * A token count is an integer by definition, so a fractional value here is
33
+ * itself the anomaly (a malformed/synthetic producer), not a legitimate
34
+ * reading to preserve verbatim — and the wire contract this feeds
35
+ * (`parseStatsPayload`'s `isNonNegInt`, `usage-ledger-wire.ts`) REQUIRES an
36
+ * integer and fails loud on anything else. Before this fix a fractional
37
+ * value here would sail through the counter, get queued as a changed entry,
38
+ * then 400 on every single stats-write attempt for that dispatch FOREVER —
39
+ * the worker's `flush()` re-queues a failed batch unconditionally, so a
40
+ * value that never changes never stops failing. Rounding at the source
41
+ * closes the gap without loosening the wire's own integer contract.
42
+ */
43
+ function num(v) {
44
+ const n = typeof v === "string" && v.trim() !== "" ? Number(v) : v;
45
+ return typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
46
+ }
47
+ /**
48
+ * Read a raw `message.usage` block into {@link LedgerUsage}. The two
49
+ * cache-write lifetimes come from `usage.cache_creation.ephemeral_5m_input_tokens`
50
+ * / `ephemeral_1h_input_tokens`. A line that carries only the flat
51
+ * `cache_creation_input_tokens` (no split object) wrote with the API's
52
+ * default 5-minute lifetime, so the whole amount is a 5-minute write — that
53
+ * is what the flat field meant before the split existed.
54
+ */
55
+ export function parseUsageBlock(raw) {
56
+ const split = raw.cache_creation;
57
+ const hasSplit = split !== null && typeof split === "object";
58
+ return {
59
+ input: num(raw.input_tokens),
60
+ output: num(raw.output_tokens),
61
+ cacheRead: num(raw.cache_read_input_tokens),
62
+ cacheWrite5m: hasSplit
63
+ ? num(split.ephemeral_5m_input_tokens)
64
+ : num(raw.cache_creation_input_tokens),
65
+ cacheWrite1h: hasSplit ? num(split.ephemeral_1h_input_tokens) : 0,
66
+ };
67
+ }
68
+ export function zeroUsage() {
69
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite5m: 0, cacheWrite1h: 0 };
70
+ }
71
+ export const LEDGER_USAGE_FIELDS = [
72
+ "input",
73
+ "output",
74
+ "cacheRead",
75
+ "cacheWrite5m",
76
+ "cacheWrite1h",
77
+ ];
78
+ export function maxUsage(a, b) {
79
+ const out = zeroUsage();
80
+ for (const f of LEDGER_USAGE_FIELDS)
81
+ out[f] = Math.max(a[f], b[f]);
82
+ return out;
83
+ }
84
+ export function addUsage(a, b) {
85
+ const out = zeroUsage();
86
+ for (const f of LEDGER_USAGE_FIELDS)
87
+ out[f] = a[f] + b[f];
88
+ return out;
89
+ }
90
+ /** Per-field `max(0, a - b)`. */
91
+ export function excessUsage(a, b) {
92
+ const out = zeroUsage();
93
+ for (const f of LEDGER_USAGE_FIELDS)
94
+ out[f] = Math.max(0, a[f] - b[f]);
95
+ return out;
96
+ }
97
+ export function isZeroUsage(u) {
98
+ return LEDGER_USAGE_FIELDS.every((f) => u[f] === 0);
99
+ }
100
+ /** Window tokens: input + output + both cache writes (the account-window unit). */
101
+ export function windowTokensOfUsage(u) {
102
+ return u.input + u.output + u.cacheWrite5m + u.cacheWrite1h;
103
+ }
104
+ /** All five fields summed. */
105
+ export function totalTokensOfUsage(u) {
106
+ return u.input + u.output + u.cacheRead + u.cacheWrite5m + u.cacheWrite1h;
107
+ }
108
+ function readSpeed(usage) {
109
+ const s = usage?.speed;
110
+ return typeof s === "string" && s.length > 0 ? s : "standard";
111
+ }
112
+ /**
113
+ * The per-process counter: one entry per reply, updated line by line.
114
+ * `observe` returns true when the line CHANGED the reply's entry (a new
115
+ * reply, a raised field, or a new tool-use id) — a replay returns false —
116
+ * and the entry is queued for {@link takeChanged}. Holds one small entry per
117
+ * reply for the life of the process; no transcript text is kept.
118
+ */
119
+ export class UsageLedgerCounter {
120
+ entries = new Map();
121
+ changed = new Set();
122
+ observe(line) {
123
+ const hasUsage = line.usage !== undefined;
124
+ if (!hasUsage && line.toolUseIds.length === 0)
125
+ return false;
126
+ // A tool-use-only line with no id cannot be tied to a reply; it has no
127
+ // usage to credit, and its tool ids would be counted on a key no other
128
+ // line of the reply shares — skip it rather than invent a reply.
129
+ if (line.messageId === undefined && !hasUsage)
130
+ return false;
131
+ const key = line.messageId ?? syntheticMessageKey(line.source, line.byteOffset);
132
+ const usage = hasUsage ? parseUsageBlock(line.usage) : zeroUsage();
133
+ const prior = this.entries.get(key);
134
+ if (!prior) {
135
+ this.entries.set(key, {
136
+ messageId: key,
137
+ source: line.source,
138
+ model: line.model ?? null,
139
+ speed: readSpeed(line.usage),
140
+ usage,
141
+ repliedAt: line.timestamp,
142
+ toolUseIds: [...new Set(line.toolUseIds)],
143
+ });
144
+ this.changed.add(key);
145
+ return true;
146
+ }
147
+ let didChange = false;
148
+ const next = maxUsage(prior.usage, usage);
149
+ if (LEDGER_USAGE_FIELDS.some((f) => next[f] !== prior.usage[f])) {
150
+ prior.usage = next;
151
+ didChange = true;
152
+ }
153
+ for (const id of line.toolUseIds) {
154
+ if (!prior.toolUseIds.includes(id)) {
155
+ prior.toolUseIds.push(id);
156
+ didChange = true;
157
+ }
158
+ }
159
+ if (prior.model === null && line.model) {
160
+ prior.model = line.model;
161
+ didChange = true;
162
+ }
163
+ if (hasUsage && prior.speed !== readSpeed(line.usage) && readSpeed(line.usage) !== "standard") {
164
+ prior.speed = readSpeed(line.usage);
165
+ didChange = true;
166
+ }
167
+ if (didChange)
168
+ this.changed.add(key);
169
+ return didChange;
170
+ }
171
+ /** Entries changed since the last call, as snapshots; clears the queue. */
172
+ takeChanged() {
173
+ const out = [];
174
+ for (const key of this.changed) {
175
+ const e = this.entries.get(key);
176
+ if (e)
177
+ out.push(snapshot(e));
178
+ }
179
+ this.changed.clear();
180
+ return out;
181
+ }
182
+ /** Put entries back on the changed queue (a write that failed). */
183
+ requeue(messageIds) {
184
+ for (const id of messageIds)
185
+ if (this.entries.has(id))
186
+ this.changed.add(id);
187
+ }
188
+ get(messageId) {
189
+ const e = this.entries.get(messageId);
190
+ return e ? snapshot(e) : undefined;
191
+ }
192
+ all() {
193
+ return [...this.entries.values()].map(snapshot);
194
+ }
195
+ /** Per-field sum of every reply's maximum — the transcript's true total. */
196
+ totals() {
197
+ let t = zeroUsage();
198
+ for (const e of this.entries.values())
199
+ t = addUsage(t, e.usage);
200
+ return t;
201
+ }
202
+ /** Distinct tool-use ids across all replies. */
203
+ toolUseCount() {
204
+ let n = 0;
205
+ for (const e of this.entries.values())
206
+ n += e.toolUseIds.length;
207
+ return n;
208
+ }
209
+ }
210
+ function snapshot(e) {
211
+ return { ...e, usage: { ...e.usage }, toolUseIds: [...e.toolUseIds] };
212
+ }
213
+ /**
214
+ * Pull the counter's {@link LedgerLine} fields out of one raw transcript
215
+ * line (a parsed JSONL object). Returns `null` for anything that is not an
216
+ * assistant line. Shared by every counter that reads a transcript from disk
217
+ * (the dashboard's reader, the skill-eval probe, the stall sweep).
218
+ */
219
+ export function ledgerLineFromRaw(raw, source, byteOffset) {
220
+ if (raw.type !== "assistant")
221
+ return null;
222
+ const message = raw.message;
223
+ if (!message)
224
+ return null;
225
+ const content = Array.isArray(message.content)
226
+ ? message.content
227
+ : [];
228
+ const ts = typeof raw.timestamp === "string" ? Date.parse(raw.timestamp) : NaN;
229
+ // DX-3261 — an unparseable/missing timestamp used to silently become
230
+ // `repliedAt: 0` (epoch 0), which falls outside every account-window
231
+ // lookback and made that reply's tokens permanently invisible to the
232
+ // gate — with no event, no log, nothing. Fail loud instead.
233
+ if (!Number.isFinite(ts)) {
234
+ throw new LedgerLineDataError(`ledgerLineFromRaw: unparseable/missing timestamp (raw.timestamp=${JSON.stringify(raw.timestamp)})`);
235
+ }
236
+ return {
237
+ messageId: typeof message.id === "string" && message.id.length > 0 ? message.id : undefined,
238
+ source,
239
+ byteOffset,
240
+ model: typeof message.model === "string" && message.model.length > 0
241
+ ? message.model
242
+ : undefined,
243
+ usage: message.usage && typeof message.usage === "object"
244
+ ? message.usage
245
+ : undefined,
246
+ timestamp: ts,
247
+ toolUseIds: content
248
+ .filter((b) => b.type === "tool_use" && typeof b.id === "string")
249
+ .map((b) => b.id),
250
+ };
251
+ }
252
+ /**
253
+ * Count a whole transcript text (one file) into a counter. Byte offsets are
254
+ * computed on UTF-8 bytes so a no-id line keys identically to the live
255
+ * watcher's own offset for the same line.
256
+ */
257
+ export function observeTranscriptText(counter, text, source, startByteOffset = 0) {
258
+ const encoder = new TextEncoder();
259
+ let offset = startByteOffset;
260
+ for (const rawLine of text.split("\n")) {
261
+ const lineBytes = encoder.encode(rawLine).length;
262
+ const lineOffset = offset;
263
+ offset += lineBytes + 1;
264
+ const trimmed = rawLine.trim();
265
+ if (!trimmed)
266
+ continue;
267
+ let parsed;
268
+ try {
269
+ parsed = JSON.parse(trimmed);
270
+ }
271
+ catch {
272
+ continue;
273
+ }
274
+ const line = ledgerLineFromRaw(parsed, source, lineOffset);
275
+ if (line)
276
+ counter.observe(line);
277
+ }
278
+ }
279
+ /**
280
+ * The counter's {@link LedgerLine} for one converted watcher entry
281
+ * (`AgentLogEntry`, as `SessionLogWatcher` emits it: `data.messageId`,
282
+ * `data.model`, `data.usage`, `data.content`, `data.subagent_id`,
283
+ * `data.byte_offset`). `null` for anything that is not an assistant entry.
284
+ * Shared by every counter fed from watcher entries (the worker's dispatch
285
+ * usage, the stall sweep's productivity signal).
286
+ */
287
+ export function ledgerLineFromLogEntry(entry) {
288
+ if (entry.type !== "assistant")
289
+ return null;
290
+ const d = entry.data;
291
+ const content = Array.isArray(d.content)
292
+ ? d.content
293
+ : [];
294
+ const hasMessageId = typeof d.messageId === "string" && d.messageId.length > 0;
295
+ // DX-3261 — `byteOffset` only matters as the synthetic key for a line with
296
+ // no `message.id` (`syntheticMessageKey`); a missing/invalid one used to
297
+ // silently default to `0`, collapsing EVERY no-id reply from this source
298
+ // onto one key so only the largest single reply was ever credited. Fail
299
+ // loud rather than repeat that silent collapse. Harmless when a real
300
+ // `messageId` is present (byteOffset is never used to key that line), so
301
+ // only fail when the line actually needs it.
302
+ if (!hasMessageId && typeof d.byte_offset !== "number") {
303
+ throw new LedgerLineDataError(`ledgerLineFromLogEntry: missing/invalid byte_offset on a no-message-id line (source=${typeof d.subagent_id === "string" ? d.subagent_id : MAIN_SOURCE})`);
304
+ }
305
+ return {
306
+ messageId: hasMessageId ? d.messageId : undefined,
307
+ source: typeof d.subagent_id === "string" ? d.subagent_id : MAIN_SOURCE,
308
+ byteOffset: typeof d.byte_offset === "number" ? d.byte_offset : 0,
309
+ model: typeof d.model === "string" && d.model.length > 0 ? d.model : undefined,
310
+ usage: d.usage && typeof d.usage === "object"
311
+ ? d.usage
312
+ : undefined,
313
+ timestamp: entry.timestamp,
314
+ toolUseIds: content
315
+ .filter((b) => b.type === "tool_use" && typeof b.id === "string")
316
+ .map((b) => b.id),
317
+ };
318
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-session-telemetry",
3
- "version": "0.1.6",
3
+ "version": "0.1.9",
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",