@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.
- package/README.md +68 -2
- package/dist/.source-hash +1 -0
- package/dist/activity-observer.d.ts +186 -0
- package/dist/activity-observer.js +917 -0
- package/dist/api-tool-names.d.ts +12 -0
- package/dist/api-tool-names.js +19 -0
- package/dist/browser.d.ts +29 -0
- package/dist/browser.js +10 -0
- package/dist/catalog-prices.d.ts +27 -0
- package/dist/catalog-prices.js +31 -0
- package/dist/claim-tracker.d.ts +53 -0
- package/dist/claim-tracker.js +213 -0
- package/dist/dispatch-usage.d.ts +321 -0
- package/dist/dispatch-usage.js +657 -0
- package/dist/git-head.d.ts +1 -0
- package/dist/git-head.js +19 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +49 -0
- package/dist/internal-logger.d.ts +16 -0
- package/dist/internal-logger.js +27 -0
- package/dist/ledger-timing.d.ts +26 -0
- package/dist/ledger-timing.js +31 -0
- package/dist/pricing.d.ts +93 -0
- package/dist/pricing.js +77 -0
- package/dist/real-work.d.ts +13 -0
- package/dist/real-work.js +57 -0
- package/dist/session-log-watcher.d.ts +616 -0
- package/dist/session-log-watcher.js +1285 -0
- package/dist/session-title-marks.d.ts +2 -0
- package/dist/session-title-marks.js +16 -0
- package/dist/stats-wire.d.ts +345 -0
- package/dist/stats-wire.js +636 -0
- package/dist/subagent-live.d.ts +36 -0
- package/dist/subagent-live.js +153 -0
- package/dist/tool-summary.d.ts +19 -0
- package/dist/tool-summary.js +58 -0
- package/dist/transcript-timing.d.ts +100 -0
- package/dist/transcript-timing.js +231 -0
- package/dist/types.d.ts +25 -0
- package/dist/types.js +1 -0
- package/dist/usage-ledger.d.ts +256 -0
- package/dist/usage-ledger.js +581 -0
- package/dist/write-queue.d.ts +9 -0
- package/dist/write-queue.js +16 -0
- 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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|