@50arms/session-telemetry 0.0.0-stage → 0.1.66

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.
Files changed (45) hide show
  1. package/README.md +68 -2
  2. package/dist/.source-hash +1 -0
  3. package/dist/activity-observer.d.ts +186 -0
  4. package/dist/activity-observer.js +917 -0
  5. package/dist/api-tool-names.d.ts +12 -0
  6. package/dist/api-tool-names.js +19 -0
  7. package/dist/browser.d.ts +29 -0
  8. package/dist/browser.js +10 -0
  9. package/dist/catalog-prices.d.ts +27 -0
  10. package/dist/catalog-prices.js +31 -0
  11. package/dist/claim-tracker.d.ts +53 -0
  12. package/dist/claim-tracker.js +213 -0
  13. package/dist/dispatch-usage.d.ts +321 -0
  14. package/dist/dispatch-usage.js +657 -0
  15. package/dist/git-head.d.ts +1 -0
  16. package/dist/git-head.js +19 -0
  17. package/dist/index.d.ts +35 -0
  18. package/dist/index.js +49 -0
  19. package/dist/internal-logger.d.ts +16 -0
  20. package/dist/internal-logger.js +27 -0
  21. package/dist/ledger-timing.d.ts +26 -0
  22. package/dist/ledger-timing.js +31 -0
  23. package/dist/pricing.d.ts +93 -0
  24. package/dist/pricing.js +77 -0
  25. package/dist/real-work.d.ts +13 -0
  26. package/dist/real-work.js +57 -0
  27. package/dist/session-log-watcher.d.ts +616 -0
  28. package/dist/session-log-watcher.js +1285 -0
  29. package/dist/session-title-marks.d.ts +2 -0
  30. package/dist/session-title-marks.js +16 -0
  31. package/dist/stats-wire.d.ts +345 -0
  32. package/dist/stats-wire.js +636 -0
  33. package/dist/subagent-live.d.ts +36 -0
  34. package/dist/subagent-live.js +153 -0
  35. package/dist/tool-summary.d.ts +19 -0
  36. package/dist/tool-summary.js +58 -0
  37. package/dist/transcript-timing.d.ts +100 -0
  38. package/dist/transcript-timing.js +231 -0
  39. package/dist/types.d.ts +25 -0
  40. package/dist/types.js +1 -0
  41. package/dist/usage-ledger.d.ts +256 -0
  42. package/dist/usage-ledger.js +581 -0
  43. package/dist/write-queue.d.ts +9 -0
  44. package/dist/write-queue.js +16 -0
  45. package/package.json +43 -4
@@ -0,0 +1,581 @@
1
+ import { LEDGER_TIMING_FIELDS, maxTiming, zeroTiming } from "./ledger-timing.js";
2
+ import { SourceTimingReducer, } from "./transcript-timing.js";
3
+ import { hasRealWork } from "./real-work.js";
4
+ /** DX-4922 - what started a compaction: Claude Code's own threshold (`auto`) or the user's `/compact` (`manual`). */
5
+ export const COMPACTION_TRIGGERS = ["auto", "manual"];
6
+ export const MAIN_SOURCE = "main";
7
+ /**
8
+ * DX-3261 — a producer handed this module a line it cannot safely key or
9
+ * date: no valid `byte_offset` on a no-`message.id` line (would collapse
10
+ * every such line in the source onto the same synthetic key, crediting only
11
+ * the largest — see {@link syntheticMessageKey}), or a `timestamp` that
12
+ * doesn't parse (would read as `repliedAt: 0`, invisible to every
13
+ * account-window lookback). Both were silent `0` defaults before this card;
14
+ * thrown here instead, so a broken producer fails immediately and visibly
15
+ * rather than quietly losing or mis-crediting usage.
16
+ */
17
+ export class LedgerLineDataError extends Error {
18
+ }
19
+ /**
20
+ * DX-2963 — a usage line with no `message.id` (never observed in the 22,087
21
+ * usage lines of the 20 newest local transcripts, 2026-09-18) is keyed by its
22
+ * source and the byte offset of its line, so it is credited exactly once
23
+ * however often the file is re-read, instead of once per read.
24
+ */
25
+ export function syntheticMessageKey(source, byteOffset) {
26
+ return `nomsg:${source}:${byteOffset}`;
27
+ }
28
+ export function isSyntheticMessageKey(key) {
29
+ return key.startsWith("nomsg:");
30
+ }
31
+ /**
32
+ * A usage field as a non-negative INTEGER count. A numeric STRING is read as
33
+ * its number (drift-safe: a transcript that ever quotes a count is still
34
+ * counted, never silently zeroed); anything else absent or non-numeric is 0.
35
+ *
36
+ * DX-3261 — rounded to the nearest integer, never passed through fractional.
37
+ * A token count is an integer by definition, so a fractional value here is
38
+ * itself the anomaly (a malformed/synthetic producer), not a legitimate
39
+ * reading to preserve verbatim — and the wire contract this feeds
40
+ * (`parseStatsPayload`'s `isNonNegInt`, `stats-wire.ts`) REQUIRES an
41
+ * integer and fails loud on anything else. Before this fix a fractional
42
+ * value here would sail through the counter, get queued as a changed entry,
43
+ * then 400 on every single stats-write attempt for that dispatch FOREVER —
44
+ * the worker's `flush()` re-queues a failed batch unconditionally, so a
45
+ * value that never changes never stops failing. Rounding at the source
46
+ * closes the gap without loosening the wire's own integer contract.
47
+ */
48
+ function num(v) {
49
+ const n = typeof v === "string" && v.trim() !== "" ? Number(v) : v;
50
+ return typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
51
+ }
52
+ /**
53
+ * Read a raw `message.usage` block into {@link LedgerUsage}. The two
54
+ * cache-write lifetimes come from `usage.cache_creation.ephemeral_5m_input_tokens`
55
+ * / `ephemeral_1h_input_tokens`. A line that carries only the flat
56
+ * `cache_creation_input_tokens` (no split object) wrote with the API's
57
+ * default 5-minute lifetime, so the whole amount is a 5-minute write — that
58
+ * is what the flat field meant before the split existed.
59
+ */
60
+ export function parseUsageBlock(raw) {
61
+ const split = raw.cache_creation;
62
+ const hasSplit = split !== null && typeof split === "object";
63
+ return {
64
+ input: num(raw.input_tokens),
65
+ output: num(raw.output_tokens),
66
+ cacheRead: num(raw.cache_read_input_tokens),
67
+ cacheWrite5m: hasSplit
68
+ ? num(split.ephemeral_5m_input_tokens)
69
+ : num(raw.cache_creation_input_tokens),
70
+ cacheWrite1h: hasSplit ? num(split.ephemeral_1h_input_tokens) : 0,
71
+ };
72
+ }
73
+ export function zeroUsage() {
74
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite5m: 0, cacheWrite1h: 0 };
75
+ }
76
+ export const LEDGER_USAGE_FIELDS = [
77
+ "input",
78
+ "output",
79
+ "cacheRead",
80
+ "cacheWrite5m",
81
+ "cacheWrite1h",
82
+ ];
83
+ export function maxUsage(a, b) {
84
+ const out = zeroUsage();
85
+ for (const f of LEDGER_USAGE_FIELDS)
86
+ out[f] = Math.max(a[f], b[f]);
87
+ return out;
88
+ }
89
+ export function addUsage(a, b) {
90
+ const out = zeroUsage();
91
+ for (const f of LEDGER_USAGE_FIELDS)
92
+ out[f] = a[f] + b[f];
93
+ return out;
94
+ }
95
+ /** Per-field `max(0, a - b)`. */
96
+ export function excessUsage(a, b) {
97
+ const out = zeroUsage();
98
+ for (const f of LEDGER_USAGE_FIELDS)
99
+ out[f] = Math.max(0, a[f] - b[f]);
100
+ return out;
101
+ }
102
+ export function isZeroUsage(u) {
103
+ return LEDGER_USAGE_FIELDS.every((f) => u[f] === 0);
104
+ }
105
+ /** Window tokens: input + output + both cache writes (the account-window unit). */
106
+ export function windowTokensOfUsage(u) {
107
+ return u.input + u.output + u.cacheWrite5m + u.cacheWrite1h;
108
+ }
109
+ /** All five fields summed. */
110
+ export function totalTokensOfUsage(u) {
111
+ return u.input + u.output + u.cacheRead + u.cacheWrite5m + u.cacheWrite1h;
112
+ }
113
+ function readSpeed(usage) {
114
+ const s = usage?.speed;
115
+ return typeof s === "string" && s.length > 0 ? s : "standard";
116
+ }
117
+ /**
118
+ * The per-process counter: one entry per reply, updated line by line.
119
+ * `observe` returns true when the line CHANGED the reply's entry (a new
120
+ * reply, a raised field, or a new tool-use id) — a replay returns false —
121
+ * and the entry is queued for {@link takeChanged}. Holds one small entry per
122
+ * reply for the life of the process; no transcript text is kept.
123
+ */
124
+ export class UsageLedgerCounter {
125
+ entries = new Map();
126
+ changed = new Set();
127
+ /** DX-3964 - one timing reducer per source (transcript file); see `transcript-timing.ts`. */
128
+ reducers = new Map();
129
+ /** DX-4922 - every compaction boundary read, by its id. */
130
+ compactions = new Map();
131
+ compactionsChanged = new Set();
132
+ /** DX-4939 - per source, the compaction whose summary line has not been read yet. */
133
+ openSummary = new Map();
134
+ /**
135
+ * DX-3964 - feed one line of the fed set (assistant, user, attachment, system) in FILE ORDER. An assistant
136
+ * line credits usage exactly as {@link observe} does; every kind also feeds its source's timing reducer,
137
+ * and a reply's timing that RISES - including one raised by a later tool_result, after the reply's last line,
138
+ * with no token growth - counts as a change, so {@link takeChanged} re-sends it.
139
+ */
140
+ observeLine(t) {
141
+ let didChange = t.kind === "assistant" ? this.observe(t.line) : false;
142
+ if (t.kind === "compaction")
143
+ this.observeCompaction(t);
144
+ if (t.kind === "user" && t.compactSummaryChars !== null)
145
+ this.observeCompactSummary(t.source, t.compactSummaryChars);
146
+ const source = t.kind === "assistant" ? t.line.source : t.source;
147
+ let reducer = this.reducers.get(source);
148
+ if (!reducer) {
149
+ reducer = new SourceTimingReducer();
150
+ this.reducers.set(source, reducer);
151
+ }
152
+ for (const [key, timing] of reducer.feed(t)) {
153
+ const entry = this.entries.get(key);
154
+ // A reply the usage counter never opened (no usage and no tool id) has no entry to carry timing.
155
+ if (!entry)
156
+ continue;
157
+ const next = maxTiming(entry.timing, timing);
158
+ if (LEDGER_TIMING_FIELDS.some((f) => next[f] !== entry.timing[f])) {
159
+ entry.timing = next;
160
+ this.changed.add(key);
161
+ didChange = true;
162
+ }
163
+ }
164
+ return didChange;
165
+ }
166
+ observeCompaction(t) {
167
+ const known = this.compactions.get(t.boundary.id);
168
+ if (known !== undefined) {
169
+ // a replay of a boundary already read; its summary line is still awaited when it was not read the first time
170
+ if (known.summaryChars === null)
171
+ this.openSummary.set(t.source, known.id);
172
+ return;
173
+ }
174
+ this.compactions.set(t.boundary.id, { ...t.boundary, source: t.source, at: t.timestamp, summaryChars: null });
175
+ this.compactionsChanged.add(t.boundary.id);
176
+ this.openSummary.set(t.source, t.boundary.id);
177
+ }
178
+ /**
179
+ * DX-4939 - the summary text follows its boundary in the same source's file: the next `isCompactSummary` user line belongs to the
180
+ * compaction last read there. A compaction is reported at its boundary and again (a changed entry) once its summary length is known,
181
+ * so a session that ends right after compacting still reports it.
182
+ */
183
+ observeCompactSummary(source, chars) {
184
+ const id = this.openSummary.get(source);
185
+ if (id === undefined)
186
+ return; // no boundary of this reader's session open here (a replayed or inherited summary)
187
+ this.openSummary.delete(source);
188
+ const entry = this.compactions.get(id);
189
+ entry.summaryChars = chars;
190
+ this.compactionsChanged.add(id);
191
+ }
192
+ /** DX-4922 - the compactions read since the last call, oldest first, as snapshots; clears the queue. */
193
+ takeChangedCompactions() {
194
+ const out = [...this.compactionsChanged].map((id) => ({ ...this.compactions.get(id) }));
195
+ this.compactionsChanged.clear();
196
+ return out;
197
+ }
198
+ /** DX-4922 - put compactions back on the changed queue (a write that failed). */
199
+ requeueCompactions(ids) {
200
+ for (const id of ids)
201
+ if (this.compactions.has(id))
202
+ this.compactionsChanged.add(id);
203
+ }
204
+ /**
205
+ * DX-3964 - a source's file was truncated and is read again from 0: its timing reducer starts fresh and
206
+ * first-line-anchored, so a stale `hwm` of the old file can never credit a gap at the reset. Entries keep their
207
+ * values (max semantics); the re-read can only raise them.
208
+ */
209
+ resetSource(source) {
210
+ this.reducers.delete(source);
211
+ }
212
+ observe(line) {
213
+ const hasUsage = line.usage !== undefined;
214
+ if (!hasUsage && line.toolUseIds.length === 0)
215
+ return false;
216
+ // A tool-use-only line with no id cannot be tied to a reply; it has no
217
+ // usage to credit, and its tool ids would be counted on a key no other
218
+ // line of the reply shares — skip it rather than invent a reply.
219
+ if (line.messageId === undefined && !hasUsage)
220
+ return false;
221
+ const key = line.messageId ?? syntheticMessageKey(line.source, line.byteOffset);
222
+ const usage = hasUsage ? parseUsageBlock(line.usage) : zeroUsage();
223
+ const prior = this.entries.get(key);
224
+ if (!prior) {
225
+ this.entries.set(key, {
226
+ messageId: key,
227
+ source: line.source,
228
+ model: line.model ?? null,
229
+ speed: readSpeed(line.usage),
230
+ usage,
231
+ timing: zeroTiming(),
232
+ repliedAt: line.timestamp,
233
+ toolUseIds: [...new Set(line.toolUseIds)],
234
+ issueId: line.issueId,
235
+ realWork: line.realWork,
236
+ });
237
+ this.changed.add(key);
238
+ return true;
239
+ }
240
+ let didChange = false;
241
+ const next = maxUsage(prior.usage, usage);
242
+ if (LEDGER_USAGE_FIELDS.some((f) => next[f] !== prior.usage[f])) {
243
+ prior.usage = next;
244
+ didChange = true;
245
+ }
246
+ for (const id of line.toolUseIds) {
247
+ if (!prior.toolUseIds.includes(id)) {
248
+ prior.toolUseIds.push(id);
249
+ didChange = true;
250
+ }
251
+ }
252
+ if (line.realWork && !prior.realWork) {
253
+ prior.realWork = true;
254
+ didChange = true;
255
+ }
256
+ if (prior.model === null && line.model) {
257
+ prior.model = line.model;
258
+ didChange = true;
259
+ }
260
+ if (hasUsage && prior.speed !== readSpeed(line.usage) && readSpeed(line.usage) !== "standard") {
261
+ prior.speed = readSpeed(line.usage);
262
+ didChange = true;
263
+ }
264
+ if (didChange)
265
+ this.changed.add(key);
266
+ return didChange;
267
+ }
268
+ /** Entries changed since the last call, as snapshots; clears the queue. */
269
+ takeChanged() {
270
+ const out = [];
271
+ for (const key of this.changed) {
272
+ const e = this.entries.get(key);
273
+ if (e)
274
+ out.push(snapshot(e));
275
+ }
276
+ this.changed.clear();
277
+ return out;
278
+ }
279
+ /** Put entries back on the changed queue (a write that failed). */
280
+ requeue(messageIds) {
281
+ for (const id of messageIds)
282
+ if (this.entries.has(id))
283
+ this.changed.add(id);
284
+ }
285
+ get(messageId) {
286
+ const e = this.entries.get(messageId);
287
+ return e ? snapshot(e) : undefined;
288
+ }
289
+ all() {
290
+ return [...this.entries.values()].map(snapshot);
291
+ }
292
+ /** Per-field sum of every reply's maximum — the transcript's true total. */
293
+ totals() {
294
+ let t = zeroUsage();
295
+ for (const e of this.entries.values())
296
+ t = addUsage(t, e.usage);
297
+ return t;
298
+ }
299
+ /** Distinct tool-use ids across all replies. */
300
+ toolUseCount() {
301
+ let n = 0;
302
+ for (const e of this.entries.values())
303
+ n += e.toolUseIds.length;
304
+ return n;
305
+ }
306
+ }
307
+ function snapshot(e) {
308
+ return { ...e, usage: { ...e.usage }, timing: { ...e.timing }, toolUseIds: [...e.toolUseIds] };
309
+ }
310
+ /** The line types the usage collector is fed; every other `type` is ignored. */
311
+ const FED_TYPES = new Set(["assistant", "user", "attachment", "system"]);
312
+ function parseTimestamp(raw, fn) {
313
+ const ts = typeof raw.timestamp === "string" ? Date.parse(raw.timestamp) : NaN;
314
+ // DX-3261 - an unparseable/missing timestamp used to silently become `repliedAt: 0` (epoch 0), which falls
315
+ // outside every account-window lookback and made that reply's tokens permanently invisible to the gate - with
316
+ // no event, no log, nothing. Fail loud instead. DX-3964: the same for every fed kind, because `hwm` and the
317
+ // request-send time read it, and a `Date.now()` default would put wall clock into them.
318
+ if (!Number.isFinite(ts)) {
319
+ throw new LedgerLineDataError(`${fn}: unparseable/missing timestamp (raw.timestamp=${JSON.stringify(raw.timestamp)})`);
320
+ }
321
+ return ts;
322
+ }
323
+ function blocksOf(message) {
324
+ return message && Array.isArray(message.content)
325
+ ? message.content
326
+ : [];
327
+ }
328
+ /** The text of a message's content: the string itself, or its text blocks joined. */
329
+ function textOfContent(content) {
330
+ return typeof content === "string" ? content : textOfResult(content);
331
+ }
332
+ function textOfResult(content) {
333
+ if (typeof content === "string")
334
+ return content;
335
+ if (!Array.isArray(content))
336
+ return "";
337
+ return content
338
+ .map((b) => b && typeof b === "object" && typeof b.text === "string"
339
+ ? b.text
340
+ : "")
341
+ .join("\n");
342
+ }
343
+ function toolResultsOf(raw, blocks) {
344
+ // An `isMeta` user line is counted as a human prompt, as the reference reducer does.
345
+ if (raw.isMeta === true)
346
+ return [];
347
+ const resultBlocks = blocks.filter((b) => b.type === "tool_result" && typeof b.tool_use_id === "string");
348
+ const tur = raw.toolUseResult;
349
+ // `toolUseResult` is one object per LINE: it can only be attributed to a line carrying exactly one result.
350
+ const patch = resultBlocks.length === 1 && tur && typeof tur === "object" && Array.isArray(tur.structuredPatch)
351
+ ? tur.structuredPatch.map((h) => ({
352
+ lines: Array.isArray(h.lines) ? h.lines.filter((l) => typeof l === "string") : [],
353
+ }))
354
+ : null;
355
+ return resultBlocks.map((b) => ({
356
+ toolUseId: b.tool_use_id,
357
+ isError: b.is_error === true,
358
+ text: textOfResult(b.content),
359
+ structuredPatch: patch,
360
+ }));
361
+ }
362
+ function toolUsesOf(blocks) {
363
+ return blocks
364
+ .filter((b) => b.type === "tool_use" && typeof b.id === "string")
365
+ .map((b) => ({
366
+ id: b.id,
367
+ name: typeof b.name === "string" ? b.name : "",
368
+ input: b.input && typeof b.input === "object" && !Array.isArray(b.input) ? b.input : null,
369
+ }));
370
+ }
371
+ /** The reader ships nothing, so it reports no compactions. */
372
+ export const REPORT_NO_COMPACTIONS = { report: false };
373
+ /** The reader reports the compactions of `sessionId`: the session its transcript FILE belongs to. */
374
+ export function reportCompactionsOf(sessionId) {
375
+ return { report: true, sessionId };
376
+ }
377
+ const NEWLINE = "\n";
378
+ /**
379
+ * The reading for a transcript of a FRESH session read as text: the session id its own lines carry. Only right where
380
+ * nothing is inherited (a bench attempt starts a new session); a fork's file starts with its parent's lines, so a
381
+ * live reader takes the session from the file's location instead (`transcriptSessionId`). A transcript with no line
382
+ * carrying a session id has no compaction boundary either, so it reports none.
383
+ */
384
+ export function reportCompactionsOfFreshTranscript(text) {
385
+ let found = null;
386
+ for (const rawLine of text.split(NEWLINE)) {
387
+ const trimmed = rawLine.trim();
388
+ if (!trimmed)
389
+ continue;
390
+ let sessionId;
391
+ try {
392
+ sessionId = JSON.parse(trimmed).sessionId;
393
+ }
394
+ catch {
395
+ continue; // a truncated line carries no session id; the next one may
396
+ }
397
+ if (typeof sessionId !== "string" || sessionId.length === 0)
398
+ continue;
399
+ // DX-4940: the "fresh session" premise is enforced, not assumed: a transcript carrying a second session id has inherited lines
400
+ // (a fork or resume), and reading it as one session would report the parent's compactions as its own.
401
+ if (found !== null && sessionId !== found) {
402
+ throw new LedgerLineDataError(`reportCompactionsOfFreshTranscript: the transcript carries more than one session id (${JSON.stringify(found)}, ${JSON.stringify(sessionId)}), so it is not a fresh session`);
403
+ }
404
+ found = sessionId;
405
+ }
406
+ return found === null ? REPORT_NO_COMPACTIONS : reportCompactionsOf(found);
407
+ }
408
+ /**
409
+ * Parse one raw transcript line (a parsed JSONL object) of the fed set - assistant, user (tool_result and human
410
+ * prompts), attachment, system - into a {@link TranscriptLine}. Returns `null` for every other `type`
411
+ * (`queue-operation`, `last-prompt`, `file-history-*`, `custom-title`, ...). THE one parser for the usage feed:
412
+ * the live watcher subscription, `observeTranscriptText` (claude-probe) and the tests all go through it.
413
+ */
414
+ export function ledgerLineFromRaw(raw, source, byteOffset, compactions) {
415
+ const type = raw.type;
416
+ if (typeof type !== "string" || !FED_TYPES.has(type))
417
+ return null;
418
+ const message = raw.message;
419
+ if (type === "assistant") {
420
+ if (!message)
421
+ return null;
422
+ const ts = parseTimestamp(raw, "ledgerLineFromRaw");
423
+ const blocks = blocksOf(message);
424
+ const toolUses = toolUsesOf(blocks);
425
+ const messageId = typeof message.id === "string" && message.id.length > 0 ? message.id : undefined;
426
+ const model = typeof message.model === "string" && message.model.length > 0 ? message.model : undefined;
427
+ return {
428
+ kind: "assistant",
429
+ line: {
430
+ messageId,
431
+ source,
432
+ byteOffset,
433
+ model,
434
+ usage: message.usage && typeof message.usage === "object"
435
+ ? message.usage
436
+ : undefined,
437
+ timestamp: ts,
438
+ toolUseIds: toolUses.map((u) => u.id),
439
+ issueId: null,
440
+ realWork: hasRealWork(toolUses),
441
+ },
442
+ replyKey: messageId ?? syntheticMessageKey(source, byteOffset),
443
+ byteOffset,
444
+ synthetic: model === "<synthetic>",
445
+ toolUses,
446
+ };
447
+ }
448
+ const timestamp = parseTimestamp(raw, "ledgerLineFromRaw");
449
+ if (type === "user") {
450
+ const blocks = blocksOf(message);
451
+ return {
452
+ kind: "user",
453
+ source,
454
+ timestamp,
455
+ byteOffset,
456
+ results: toolResultsOf(raw, blocks),
457
+ compactSummaryChars: raw.isCompactSummary === true ? textOfContent(message?.content).length : null,
458
+ };
459
+ }
460
+ // DX-4922: only the session's OWN compactions are reported. A forked or resumed session's transcript starts with
461
+ // copies of its parent's lines, boundary lines included, and those keep the parent's `sessionId`; the parent reports
462
+ // its own, so a copy is read as the plain system line it also is for timing. A reader that reports no compactions
463
+ // reads every boundary that way.
464
+ if (type === "system" && raw.subtype === "compact_boundary" && compactions.report) {
465
+ if (typeof raw.sessionId !== "string" || raw.sessionId.length === 0) {
466
+ throw new LedgerLineDataError("ledgerLineFromRaw: compact_boundary without a sessionId, so whose compaction it is cannot be told");
467
+ }
468
+ if (raw.sessionId === compactions.sessionId) {
469
+ return { kind: "compaction", source, timestamp, byteOffset, boundary: compactionBoundaryOf(raw) };
470
+ }
471
+ }
472
+ return { kind: type, source, timestamp, byteOffset };
473
+ }
474
+ /** DX-4922 - read a `compact_boundary` line; a boundary this reader cannot identify or measure is a loud error, never a guess. */
475
+ function compactionBoundaryOf(raw) {
476
+ const meta = raw.compactMetadata;
477
+ const trigger = meta?.trigger;
478
+ const preTokens = meta?.preTokens;
479
+ if (typeof raw.uuid !== "string" ||
480
+ raw.uuid.length === 0 ||
481
+ !COMPACTION_TRIGGERS.includes(trigger) ||
482
+ typeof preTokens !== "number" ||
483
+ !Number.isFinite(preTokens) ||
484
+ preTokens < 0) {
485
+ throw new LedgerLineDataError(`ledgerLineFromRaw: compact_boundary without a uuid, a known trigger and a preTokens count (uuid=${JSON.stringify(raw.uuid)}, trigger=${JSON.stringify(trigger)}, preTokens=${JSON.stringify(preTokens)})`);
486
+ }
487
+ return {
488
+ id: raw.uuid,
489
+ trigger: trigger,
490
+ contextBefore: Math.round(preTokens),
491
+ postTokens: optionalCount(meta?.postTokens, "postTokens", raw.uuid),
492
+ durationMs: optionalCount(meta?.durationMs, "durationMs", raw.uuid),
493
+ };
494
+ }
495
+ /** DX-4939 - a boundary figure that may be absent (`null`) but is never a wrong type: a present value that is not a non-negative number is a loud error. */
496
+ function optionalCount(value, field, uuid) {
497
+ if (value === undefined)
498
+ return null;
499
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
500
+ throw new LedgerLineDataError(`ledgerLineFromRaw: compact_boundary ${field} is not a non-negative number (uuid=${uuid}, ${field}=${JSON.stringify(value)})`);
501
+ }
502
+ return Math.round(value);
503
+ }
504
+ /**
505
+ * Count a whole transcript text (one file) into a counter. Byte offsets are
506
+ * computed on UTF-8 bytes so a no-id line keys identically to the live
507
+ * watcher's own offset for the same line.
508
+ */
509
+ export function observeTranscriptText(counter, text, source, compactions, startByteOffset = 0) {
510
+ const encoder = new TextEncoder();
511
+ let offset = startByteOffset;
512
+ for (const rawLine of text.split("\n")) {
513
+ const lineBytes = encoder.encode(rawLine).length;
514
+ const lineOffset = offset;
515
+ offset += lineBytes + 1;
516
+ const trimmed = rawLine.trim();
517
+ if (!trimmed)
518
+ continue;
519
+ let parsed;
520
+ try {
521
+ parsed = JSON.parse(trimmed);
522
+ }
523
+ catch {
524
+ continue;
525
+ }
526
+ const line = ledgerLineFromRaw(parsed, source, lineOffset, compactions);
527
+ if (line)
528
+ counter.observeLine(line);
529
+ }
530
+ }
531
+ /**
532
+ * The counter's {@link LedgerLine} for one converted watcher entry
533
+ * (`AgentLogEntry`, as `SessionLogWatcher` emits it: `data.messageId`,
534
+ * `data.model`, `data.usage`, `data.content`, `data.subagent_id`,
535
+ * `data.byte_offset`). `null` for anything that is not an assistant entry.
536
+ * Shared by every counter fed from watcher entries (the worker's dispatch
537
+ * usage, the stall sweep's productivity signal).
538
+ */
539
+ export function ledgerLineFromLogEntry(entry) {
540
+ if (entry.type !== "assistant")
541
+ return null;
542
+ const d = entry.data;
543
+ const content = Array.isArray(d.content)
544
+ ? d.content
545
+ : [];
546
+ const hasMessageId = typeof d.messageId === "string" && d.messageId.length > 0;
547
+ const hasUsage = Boolean(d.usage && typeof d.usage === "object");
548
+ const toolUseIds = content
549
+ .filter((b) => b.type === "tool_use" && typeof b.id === "string")
550
+ .map((b) => b.id);
551
+ // DX-3261 — `byteOffset` only matters as the synthetic key for a line with
552
+ // no `message.id` (`syntheticMessageKey`); a missing/invalid one used to
553
+ // silently default to `0`, collapsing EVERY no-id reply from this source
554
+ // onto one key so only the largest single reply was ever credited. Fail
555
+ // loud rather than repeat that silent collapse — but ONLY when the line
556
+ // will actually reach that keying step: `UsageLedgerCounter.observe()`
557
+ // discards a no-id line with neither usage nor a tool-use id before ever
558
+ // building a key (a tool-call block with no reported `id`, an empty
559
+ // wrapper entry), so requiring a valid `byteOffset` on THOSE too would
560
+ // reject lines the counter was always going to ignore. Harmless when a
561
+ // real `messageId` is present (byteOffset is never used to key that line).
562
+ if (!hasMessageId && (hasUsage || toolUseIds.length > 0) && typeof d.byte_offset !== "number") {
563
+ throw new LedgerLineDataError(`ledgerLineFromLogEntry: missing/invalid byte_offset on a no-message-id line carrying usage or a tool-use id (source=${typeof d.subagent_id === "string" ? d.subagent_id : MAIN_SOURCE})`);
564
+ }
565
+ return {
566
+ messageId: hasMessageId ? d.messageId : undefined,
567
+ source: typeof d.subagent_id === "string" ? d.subagent_id : MAIN_SOURCE,
568
+ byteOffset: typeof d.byte_offset === "number" ? d.byte_offset : 0,
569
+ model: typeof d.model === "string" && d.model.length > 0 ? d.model : undefined,
570
+ usage: hasUsage ? d.usage : undefined,
571
+ timestamp: entry.timestamp,
572
+ toolUseIds,
573
+ issueId: null,
574
+ realWork: hasRealWork(content
575
+ .filter((b) => b.type === "tool_use" && typeof b.id === "string")
576
+ .map((b) => ({
577
+ name: typeof b.name === "string" ? b.name : "",
578
+ input: b.input && typeof b.input === "object" && !Array.isArray(b.input) ? b.input : null,
579
+ }))),
580
+ };
581
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * DX-2398 — a per-tracker FIFO for writes that must land in order: the
3
+ * periodic stats flush and the terminal finalize (a tick already in flight
4
+ * when cleanup stops the timer must not commit after the terminal write).
5
+ * DX-2963 also serializes a dispatch's stats batches through it, so two
6
+ * flushes never interleave their batches. The chain is never left rejected:
7
+ * each writer owns its own error handling.
8
+ */
9
+ export declare function createUsageWriteQueue(): <T>(write: () => Promise<T>) => Promise<T>;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * DX-2398 — a per-tracker FIFO for writes that must land in order: the
3
+ * periodic stats flush and the terminal finalize (a tick already in flight
4
+ * when cleanup stops the timer must not commit after the terminal write).
5
+ * DX-2963 also serializes a dispatch's stats batches through it, so two
6
+ * flushes never interleave their batches. The chain is never left rejected:
7
+ * each writer owns its own error handling.
8
+ */
9
+ export function createUsageWriteQueue() {
10
+ let chain = Promise.resolve();
11
+ return (write) => {
12
+ const queued = chain.then(write);
13
+ chain = queued.then(() => undefined, () => undefined);
14
+ return queued;
15
+ };
16
+ }
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@50arms/session-telemetry",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.66",
4
+ "description": "Shared Claude Code transcript collector (DX-2962) — incremental JSONL tailing for the danxbot worker and the platform-mcp MCP server's relay, so both run identical collector code.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "source": "./src/index.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./browser": {
16
+ "types": "./dist/browser.d.ts",
17
+ "source": "./src/browser.ts",
18
+ "default": "./dist/browser.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "README.md"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/newms87/danxbot.git",
28
+ "directory": "packages/danx-session-telemetry"
29
+ },
30
+ "engines": {
31
+ "node": ">=20"
32
+ },
33
+ "scripts": {
34
+ "prepublishOnly": "bash ../../scripts/assert-tree-contains-origin-main.sh @50arms/session-telemetry",
35
+ "build": "tsc -p tsconfig.json",
36
+ "verify:boot": "bash scripts/verify-boot.sh",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^22.0.0",
42
+ "typescript": "^5.7.0",
43
+ "vitest": "^4.0.18"
44
+ }
45
+ }