@thehammer/danx-session-telemetry 0.1.43 → 0.1.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/.source-hash CHANGED
@@ -1 +1 @@
1
- 1f7f247f7e5d5039a351835d2525a04c03edbb7a4ddac07cf83d4d599fe7ef07
1
+ 3d2e532980e05f6c38b15f2a85eca3c84c3488c2afec7c285c5ac8a5c20ee254
@@ -68,6 +68,13 @@ export declare class DispatchUsage {
68
68
  * its own resume point instead of regressing it.
69
69
  */
70
70
  private readonly ackedCursor;
71
+ /**
72
+ * DX-3972 - per source, the `acked` the server holds for this dispatch: the `acked` of the last ACKNOWLEDGED
73
+ * report that carried the source. It is NOT {@link DispatchUsage.ackedCursor}: that is what the NEXT report
74
+ * claims (one write behind `delivered`), this is what the LAST one claimed. The terminal catch-up
75
+ * ({@link DispatchUsage.flushFinal}) is needed exactly while this lags the watcher's `delivered`.
76
+ */
77
+ private readonly storedAcked;
71
78
  /**
72
79
  * DX-2954 (AC 34324) — the cursors this process STARTED from, reported
73
80
  * verbatim for every source the watcher has not opened yet until one write
@@ -157,6 +164,18 @@ export declare class DispatchUsage {
157
164
  * excess rule) and every side-effecting subscriber skips it — so the cost is
158
165
  * one window of re-read, never a double count or a duplicate side effect.
159
166
  *
167
+ * ## The terminal write closes the lag (DX-3972)
168
+ *
169
+ * A restart is a crash, and a crash's window is the cost above. A dispatch that
170
+ * ENDS is not one: its watcher is drained and stopped, so every line it read has been
171
+ * fed to this collector and nothing will raise a reply any more. The terminal
172
+ * {@link DispatchUsage.flushFinal} therefore reports `acked` equal to `delivered` for
173
+ * every source it has opened (see {@link DispatchUsage.catchUpReports}), and a
174
+ * `--resume` of the session starts at the end of what this dispatch read instead of
175
+ * replaying the whole file. Without it a dispatch with ONE line-bearing write ended at
176
+ * `{acked: 0, delivered: EOF}`, and every resume of the session re-read the whole
177
+ * transcript and re-shipped it into its own `dispatch_logs`.
178
+ *
160
179
  * ## The process-start write carries the STARTING cursors (AC 34324)
161
180
  *
162
181
  * `attachMonitoringStack` deliberately does NOT await `watcher.start()` —
@@ -195,6 +214,20 @@ export declare class DispatchUsage {
195
214
  * is why a write is not forced merely because cursors EXIST.
196
215
  */
197
216
  private cursorsMoved;
217
+ /**
218
+ * DX-3972 - the TERMINAL write's reports: the tick's reports with every source this process has read claiming its
219
+ * whole `delivered` as `acked`, and the claim state AS OF that offset (the tracker's current state belongs to
220
+ * `delivered`, so it describes the new `acked` exactly, never a later position). Sources the watcher has not
221
+ * opened (a start cursor reported verbatim) are untouched: this process read nothing there to claim.
222
+ */
223
+ private catchUpReports;
224
+ /**
225
+ * DX-3972 - whether the terminal write has anything to say: the server's `acked` still lags what the catch-up now
226
+ * claims. `ackedCursor` (the NEXT tick's claim) cannot tell, because a dispatch whose last write reported
227
+ * `delivered` with an older `acked` has `ackedCursor === delivered` and still lags; {@link DispatchUsage.storedAcked}
228
+ * is what the server holds. A source never reported at all has no entry, so it counts as lagging.
229
+ */
230
+ private storedAckedLags;
198
231
  /**
199
232
  * Feed one raw transcript line of the usage feed set (assistant, user, attachment, system), in file order.
200
233
  * DX-3964 - the usage collector is fed from the watcher's collector-only `onUsageLine` subscription, never from
@@ -221,6 +254,21 @@ export declare class DispatchUsage {
221
254
  * acknowledged ahead of the entries it covers.
222
255
  */
223
256
  flush(sink: StatsSink, dispatchId: string): Promise<FlushOutcome>;
257
+ /**
258
+ * DX-3972 - the TERMINAL write: {@link DispatchUsage.flush} that also catches the stored `acked` up to
259
+ * `delivered` for every source (see {@link DispatchUsage.catchUpReports}). Call it once, when the dispatch ends,
260
+ * after the watcher is drained and stopped (that is the whole precondition: every line it read has reached this
261
+ * collector, so `delivered` is covered by the entries this very write carries), and BEFORE the row goes terminal
262
+ * (the server refuses a terminal dispatch's stats, which would drop the catch-up silently). A write that does not
263
+ * land promotes nothing, exactly as in a tick; a dispatch already caught up sends no write.
264
+ *
265
+ * It does NOT wait for the dispatch's log shipper: the spawn path flushes it before this call, the reattach path
266
+ * after (`reattach.ts` `_cleanup`), so on a reattach `acked` can claim a line whose `dispatch_logs` row is still in
267
+ * the shipper's buffer. The shipper is best-effort today and a resume's shipper skips replays either way, so
268
+ * nothing is lost that was not already; acking only after the log line is acknowledged is DX-2956's change.
269
+ */
270
+ flushFinal(sink: StatsSink, dispatchId: string): Promise<FlushOutcome>;
271
+ private write;
224
272
  /** The dispatch's totals as the server last returned them. */
225
273
  totals(): UsageTotals;
226
274
  /** Window tokens (input + output + cache writes) of the server totals. */
@@ -63,6 +63,13 @@ export class DispatchUsage {
63
63
  * its own resume point instead of regressing it.
64
64
  */
65
65
  ackedCursor = new Map();
66
+ /**
67
+ * DX-3972 - per source, the `acked` the server holds for this dispatch: the `acked` of the last ACKNOWLEDGED
68
+ * report that carried the source. It is NOT {@link DispatchUsage.ackedCursor}: that is what the NEXT report
69
+ * claims (one write behind `delivered`), this is what the LAST one claimed. The terminal catch-up
70
+ * ({@link DispatchUsage.flushFinal}) is needed exactly while this lags the watcher's `delivered`.
71
+ */
72
+ storedAcked = new Map();
66
73
  /**
67
74
  * DX-2954 (AC 34324) — the cursors this process STARTED from, reported
68
75
  * verbatim for every source the watcher has not opened yet until one write
@@ -186,6 +193,18 @@ export class DispatchUsage {
186
193
  * excess rule) and every side-effecting subscriber skips it — so the cost is
187
194
  * one window of re-read, never a double count or a duplicate side effect.
188
195
  *
196
+ * ## The terminal write closes the lag (DX-3972)
197
+ *
198
+ * A restart is a crash, and a crash's window is the cost above. A dispatch that
199
+ * ENDS is not one: its watcher is drained and stopped, so every line it read has been
200
+ * fed to this collector and nothing will raise a reply any more. The terminal
201
+ * {@link DispatchUsage.flushFinal} therefore reports `acked` equal to `delivered` for
202
+ * every source it has opened (see {@link DispatchUsage.catchUpReports}), and a
203
+ * `--resume` of the session starts at the end of what this dispatch read instead of
204
+ * replaying the whole file. Without it a dispatch with ONE line-bearing write ended at
205
+ * `{acked: 0, delivered: EOF}`, and every resume of the session re-read the whole
206
+ * transcript and re-shipped it into its own `dispatch_logs`.
207
+ *
189
208
  * ## The process-start write carries the STARTING cursors (AC 34324)
190
209
  *
191
210
  * `attachMonitoringStack` deliberately does NOT await `watcher.start()` —
@@ -281,6 +300,32 @@ export class DispatchUsage {
281
300
  }
282
301
  return false;
283
302
  }
303
+ /**
304
+ * DX-3972 - the TERMINAL write's reports: the tick's reports with every source this process has read claiming its
305
+ * whole `delivered` as `acked`, and the claim state AS OF that offset (the tracker's current state belongs to
306
+ * `delivered`, so it describes the new `acked` exactly, never a later position). Sources the watcher has not
307
+ * opened (a start cursor reported verbatim) are untouched: this process read nothing there to claim.
308
+ */
309
+ catchUpReports(reports, deliveredAtSend, claimsAtSend) {
310
+ const caughtUp = { ...reports };
311
+ for (const [source, delivered] of deliveredAtSend) {
312
+ caughtUp[source] = { ...reports[source], acked: delivered, claims: claimsAtSend.get(source) };
313
+ }
314
+ return caughtUp;
315
+ }
316
+ /**
317
+ * DX-3972 - whether the terminal write has anything to say: the server's `acked` still lags what the catch-up now
318
+ * claims. `ackedCursor` (the NEXT tick's claim) cannot tell, because a dispatch whose last write reported
319
+ * `delivered` with an older `acked` has `ackedCursor === delivered` and still lags; {@link DispatchUsage.storedAcked}
320
+ * is what the server holds. A source never reported at all has no entry, so it counts as lagging.
321
+ */
322
+ storedAckedLags(reports) {
323
+ for (const [source, report] of Object.entries(reports)) {
324
+ if (report.pastEof !== undefined || report.acked !== this.storedAcked.get(source))
325
+ return true;
326
+ }
327
+ return false;
328
+ }
284
329
  /**
285
330
  * Feed one raw transcript line of the usage feed set (assistant, user, attachment, system), in file order.
286
331
  * DX-3964 - the usage collector is fed from the watcher's collector-only `onUsageLine` subscription, never from
@@ -329,13 +374,36 @@ export class DispatchUsage {
329
374
  * acknowledged ahead of the entries it covers.
330
375
  */
331
376
  flush(sink, dispatchId) {
377
+ return this.write(sink, dispatchId, false);
378
+ }
379
+ /**
380
+ * DX-3972 - the TERMINAL write: {@link DispatchUsage.flush} that also catches the stored `acked` up to
381
+ * `delivered` for every source (see {@link DispatchUsage.catchUpReports}). Call it once, when the dispatch ends,
382
+ * after the watcher is drained and stopped (that is the whole precondition: every line it read has reached this
383
+ * collector, so `delivered` is covered by the entries this very write carries), and BEFORE the row goes terminal
384
+ * (the server refuses a terminal dispatch's stats, which would drop the catch-up silently). A write that does not
385
+ * land promotes nothing, exactly as in a tick; a dispatch already caught up sends no write.
386
+ *
387
+ * It does NOT wait for the dispatch's log shipper: the spawn path flushes it before this call, the reattach path
388
+ * after (`reattach.ts` `_cleanup`), so on a reattach `acked` can claim a line whose `dispatch_logs` row is still in
389
+ * the shipper's buffer. The shipper is best-effort today and a resume's shipper skips replays either way, so
390
+ * nothing is lost that was not already; acking only after the log line is acknowledged is DX-2956's change.
391
+ */
392
+ flushFinal(sink, dispatchId) {
393
+ return this.write(sink, dispatchId, true);
394
+ }
395
+ write(sink, dispatchId, catchUp) {
332
396
  return this.queue(async () => {
333
397
  if (this.refused)
334
398
  return "refused";
335
399
  this.flushBegan = true;
336
400
  const changed = this.counter.takeChanged();
337
401
  const batches = chunkEntries(changed);
338
- const { reports, deliveredAtSend, claimsAtSend, reportedResets, carriesStartCursors } = this.buildCursorReports();
402
+ const built = this.buildCursorReports();
403
+ const { deliveredAtSend, claimsAtSend, reportedResets, carriesStartCursors } = built;
404
+ const reports = catchUp
405
+ ? this.catchUpReports(built.reports, deliveredAtSend, claimsAtSend)
406
+ : built.reports;
339
407
  // TAKE the queued side data, exactly as `counter.takeChanged()` does, so a
340
408
  // hook that fires during the await lands in the NEXT write rather than
341
409
  // being silently dropped by a post-write reset of the live array.
@@ -361,7 +429,7 @@ export class DispatchUsage {
361
429
  // whose last batch failed must still deliver it on a later, otherwise idle
362
430
  // tick.
363
431
  const haveSideData = carriesStartCursors ||
364
- this.cursorsMoved(reports) ||
432
+ (catchUp ? this.storedAckedLags(reports) : this.cursorsMoved(reports)) ||
365
433
  activities.length > 0 ||
366
434
  (this.transcriptPath !== null && !this.transcriptPathSent);
367
435
  if (batches.length === 0 && (!this.started || haveSideData))
@@ -390,6 +458,11 @@ export class DispatchUsage {
390
458
  // there. A source the server REFUSED as a regression keeps its own
391
459
  // stored value, so we must not promote ours past it.
392
460
  const refused = new Set(res.cursorRegressions);
461
+ // DX-3972 - what the server now holds as each reported source's `acked` (a refused source kept its own).
462
+ for (const [source, report] of Object.entries(reports)) {
463
+ if (!refused.has(source))
464
+ this.storedAcked.set(source, report.acked);
465
+ }
393
466
  // A source whose file was found truncated WHILE this write was in
394
467
  // flight must NOT have its floor promoted. `recordPastEof` dropped
395
468
  // that floor to 0 on purpose; re-raising it here would make the next
package/dist/pricing.d.ts CHANGED
@@ -114,15 +114,28 @@ export type PriceResult = {
114
114
  * cleanly, whatever its id looks like.
115
115
  */
116
116
  export declare function priceUsage(model: string, speed: string, tokens: PricedTokens): PriceResult;
117
+ /**
118
+ * The list price `priceUsage` charges for a model at a speed (a fast request on a model that bills fast at the standard rate reads the
119
+ * standard table), or `null` when the tables have none. The default `PriceLookup` of {@link priceLedgerCredit}.
120
+ */
121
+ export declare function listedPrice(model: string, speed: string): ModelPrice | null;
122
+ /**
123
+ * DX-4595 - dollars for `tokens` at `price` (USD per million tokens): each bucket with its own price. The ONE copy of the formula: `priceUsage`
124
+ * charges the list price with it, and spend pacing charges an account's own (editable) prices with it.
125
+ */
126
+ export declare function costOfTokens(tokens: PricedTokens, price: ModelPrice): number;
117
127
  /** True when `model` has a standard-speed price (used by tests + guards). */
118
128
  export declare function isPricedModel(model: string): boolean;
129
+ /** What a model costs at a speed, per million tokens; `null` when there is no price. */
130
+ export type PriceLookup = (model: string, speed: string) => ModelPrice | null;
119
131
  /**
120
132
  * DX-4508 — one ledger credit's cost, exactly as the server's usage-ledger writer stores it (moved here from
121
133
  * `src/dashboard/usage-ledger.ts` so the live sub-agent view, `subagent-live.ts`, prices a reply with the SAME rule
122
134
  * instead of a copy): a `<synthetic>` reply costs nothing and is never unpriced; a reply with no model is unpriced only
123
- * when it carries tokens; any other model goes through {@link priceUsage}, an unpriced one costing 0 and naming itself.
135
+ * when it carries tokens; any other model is priced by `priceOf` (the list prices by default, so as {@link priceUsage} does), an unpriced
136
+ * one costing 0 and naming itself. DX-4595: spend pacing passes an account's own prices as `priceOf` and so gets the same rule.
124
137
  */
125
- export declare function priceLedgerCredit(model: string | null, speed: string, tokens: PricedTokens): {
138
+ export declare function priceLedgerCredit(model: string | null, speed: string, tokens: PricedTokens, priceOf?: PriceLookup): {
126
139
  costUsd: number;
127
140
  unpriced: {
128
141
  model: string;
package/dist/pricing.js CHANGED
@@ -163,20 +163,29 @@ export function listListPrices() {
163
163
  * cleanly, whatever its id looks like.
164
164
  */
165
165
  export function priceUsage(model, speed, tokens) {
166
+ const price = listedPrice(model, speed);
167
+ return price === null ? { kind: "unpriced", model, speed } : { kind: "priced", costUsd: costOfTokens(tokens, price) };
168
+ }
169
+ /**
170
+ * The list price `priceUsage` charges for a model at a speed (a fast request on a model that bills fast at the standard rate reads the
171
+ * standard table), or `null` when the tables have none. The default `PriceLookup` of {@link priceLedgerCredit}.
172
+ */
173
+ export function listedPrice(model, speed) {
166
174
  const useFastTable = speed === FAST_SPEED && !FAST_BILLS_AT_STANDARD_RATE.has(model);
167
175
  const table = useFastTable ? FAST_PRICING : PRICING;
168
- if (!Object.hasOwn(table, model))
169
- return { kind: "unpriced", model, speed };
170
- const p = table[model];
171
- return {
172
- kind: "priced",
173
- costUsd: (tokens.input * p.input +
174
- tokens.output * p.output +
175
- tokens.cacheRead * p.cacheRead +
176
- tokens.cacheWrite5m * p.cacheWrite5m +
177
- tokens.cacheWrite1h * p.cacheWrite1h) /
178
- 1_000_000,
179
- };
176
+ return Object.hasOwn(table, model) ? table[model] : null;
177
+ }
178
+ /**
179
+ * DX-4595 - dollars for `tokens` at `price` (USD per million tokens): each bucket with its own price. The ONE copy of the formula: `priceUsage`
180
+ * charges the list price with it, and spend pacing charges an account's own (editable) prices with it.
181
+ */
182
+ export function costOfTokens(tokens, price) {
183
+ return ((tokens.input * price.input +
184
+ tokens.output * price.output +
185
+ tokens.cacheRead * price.cacheRead +
186
+ tokens.cacheWrite5m * price.cacheWrite5m +
187
+ tokens.cacheWrite1h * price.cacheWrite1h) /
188
+ 1_000_000);
180
189
  }
181
190
  /** True when `model` has a standard-speed price (used by tests + guards). */
182
191
  export function isPricedModel(model) {
@@ -188,15 +197,16 @@ export function isPricedModel(model) {
188
197
  * DX-4508 — one ledger credit's cost, exactly as the server's usage-ledger writer stores it (moved here from
189
198
  * `src/dashboard/usage-ledger.ts` so the live sub-agent view, `subagent-live.ts`, prices a reply with the SAME rule
190
199
  * instead of a copy): a `<synthetic>` reply costs nothing and is never unpriced; a reply with no model is unpriced only
191
- * when it carries tokens; any other model goes through {@link priceUsage}, an unpriced one costing 0 and naming itself.
200
+ * when it carries tokens; any other model is priced by `priceOf` (the list prices by default, so as {@link priceUsage} does), an unpriced
201
+ * one costing 0 and naming itself. DX-4595: spend pacing passes an account's own prices as `priceOf` and so gets the same rule.
192
202
  */
193
- export function priceLedgerCredit(model, speed, tokens) {
203
+ export function priceLedgerCredit(model, speed, tokens, priceOf = listedPrice) {
194
204
  if (model === SYNTHETIC_MODEL)
195
205
  return { costUsd: 0, unpriced: null };
196
206
  if (model === null) {
197
207
  const zero = tokens.input === 0 && tokens.output === 0 && tokens.cacheRead === 0 && tokens.cacheWrite5m === 0 && tokens.cacheWrite1h === 0;
198
208
  return zero ? { costUsd: 0, unpriced: null } : { costUsd: 0, unpriced: { model: "(none)", speed } };
199
209
  }
200
- const r = priceUsage(model, speed, tokens);
201
- return r.kind === "priced" ? { costUsd: r.costUsd, unpriced: null } : { costUsd: 0, unpriced: { model: r.model, speed: r.speed } };
210
+ const price = priceOf(model, speed);
211
+ return price === null ? { costUsd: 0, unpriced: { model, speed } } : { costUsd: costOfTokens(tokens, price), unpriced: null };
202
212
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-session-telemetry",
3
- "version": "0.1.43",
3
+ "version": "0.1.45",
4
4
  "description": "Shared Claude Code transcript collector (DX-2962) — incremental JSONL tailing for the danxbot worker and the danx-dashboard-mcp MCP server's relay, so both run identical collector code.",
5
5
  "license": "MIT",
6
6
  "type": "module",