sentinelayer-cli 0.19.0 → 0.21.0

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.
@@ -428,6 +428,95 @@ async function fetchWithTimeout(url, options, timeoutMs) {
428
428
  }
429
429
  }
430
430
 
431
+ function isAbortLike(error) {
432
+ return Boolean(error?.name === "AbortError" || error?.code === "ABORT_ERR");
433
+ }
434
+
435
+ async function* readResponseTextChunks(response) {
436
+ const body = response?.body;
437
+ if (!body) return;
438
+
439
+ if (typeof body.getReader === "function") {
440
+ const reader = body.getReader();
441
+ try {
442
+ while (true) {
443
+ const { done, value } = await reader.read();
444
+ if (done) break;
445
+ if (value) yield value;
446
+ }
447
+ } finally {
448
+ try {
449
+ reader.releaseLock();
450
+ } catch {
451
+ // Best-effort cleanup only.
452
+ }
453
+ }
454
+ return;
455
+ }
456
+
457
+ if (typeof body[Symbol.asyncIterator] === "function") {
458
+ for await (const chunk of body) {
459
+ if (chunk) yield chunk;
460
+ }
461
+ }
462
+ }
463
+
464
+ function extractSseErrorReason(parsed) {
465
+ const error = parsed?.error && typeof parsed.error === "object" ? parsed.error : {};
466
+ return (
467
+ normalizeString(error.code) ||
468
+ normalizeString(error.message) ||
469
+ normalizeString(error.detail) ||
470
+ "session_stream_error"
471
+ );
472
+ }
473
+
474
+ async function processSseBlock(block, handlers) {
475
+ const normalizedBlock = normalizeString(block);
476
+ if (!normalizedBlock) return;
477
+
478
+ const dataLines = [];
479
+ let commentOnly = true;
480
+ for (const rawLine of String(block).split("\n")) {
481
+ const line = rawLine.trimEnd();
482
+ if (!line) continue;
483
+ if (line.startsWith(":")) continue;
484
+ commentOnly = false;
485
+ if (line.startsWith("data:")) {
486
+ dataLines.push(line.slice(5).trimStart());
487
+ }
488
+ }
489
+
490
+ if (dataLines.length === 0) {
491
+ if (commentOnly && typeof handlers.onHeartbeat === "function") {
492
+ await handlers.onHeartbeat();
493
+ }
494
+ return;
495
+ }
496
+
497
+ const rawData = dataLines.join("\n").trim();
498
+ if (!rawData) return;
499
+
500
+ let parsed;
501
+ try {
502
+ parsed = JSON.parse(rawData);
503
+ } catch {
504
+ await handlers.onError?.({ reason: "malformed_stream_event", cursor: handlers.cursor() });
505
+ return;
506
+ }
507
+
508
+ if (parsed?.type === "error") {
509
+ await handlers.onError?.({
510
+ reason: extractSseErrorReason(parsed),
511
+ cursor: handlers.cursor(),
512
+ error: parsed.error || null,
513
+ });
514
+ return;
515
+ }
516
+
517
+ await handlers.onEvent?.(parsed);
518
+ }
519
+
431
520
  function sanitizeHumanMessage(rawMessage) {
432
521
  const stripped = String(rawMessage || "")
433
522
  .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
@@ -1131,6 +1220,195 @@ export async function pollSessionEvents(
1131
1220
  }
1132
1221
  }
1133
1222
 
1223
+ /**
1224
+ * Consume the API's durable session SSE stream.
1225
+ *
1226
+ * This is the wakeup-first companion to `pollSessionEvents`: the stream uses
1227
+ * Redis wakeups server-side, while the listener can still fall back to durable
1228
+ * `/events` polling if the stream is unavailable or closes.
1229
+ *
1230
+ * @param {string} sessionId
1231
+ * @param {object} [options]
1232
+ * @param {string|null} [options.since] - durable cursor to resume after
1233
+ * @param {AbortSignal} [options.signal]
1234
+ * @param {(event: object) => Promise<void>|void} [options.onEvent]
1235
+ * @param {(payload: object) => Promise<void>|void} [options.onError]
1236
+ * @param {() => Promise<void>|void} [options.onHeartbeat]
1237
+ * @returns {Promise<{ok: boolean, reason: string, cursor: string|null, eventCount: number, errorCount: number, status?: number, aborted?: boolean}>}
1238
+ */
1239
+ export async function streamSessionEvents(
1240
+ sessionId,
1241
+ {
1242
+ targetPath = process.cwd(),
1243
+ since = null,
1244
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1245
+ signal = undefined,
1246
+ resolveAuthSession = resolveActiveAuthSession,
1247
+ fetchImpl = fetch,
1248
+ onEvent = async () => {},
1249
+ onError = async () => {},
1250
+ onHeartbeat = async () => {},
1251
+ } = {}
1252
+ ) {
1253
+ const normalizedSessionId = normalizeString(sessionId);
1254
+ const normalizedSince = normalizeString(since) || null;
1255
+ if (!normalizedSessionId) {
1256
+ return {
1257
+ ok: false,
1258
+ reason: "invalid_session_id",
1259
+ cursor: normalizedSince,
1260
+ eventCount: 0,
1261
+ errorCount: 0,
1262
+ };
1263
+ }
1264
+
1265
+ let session = null;
1266
+ try {
1267
+ session = await resolveAuthSession({
1268
+ cwd: targetPath,
1269
+ env: process.env,
1270
+ autoRotate: false,
1271
+ });
1272
+ } catch {
1273
+ return {
1274
+ ok: false,
1275
+ reason: "no_session",
1276
+ cursor: normalizedSince,
1277
+ eventCount: 0,
1278
+ errorCount: 0,
1279
+ };
1280
+ }
1281
+ if (!session || !session.token) {
1282
+ return {
1283
+ ok: false,
1284
+ reason: "not_authenticated",
1285
+ cursor: normalizedSince,
1286
+ eventCount: 0,
1287
+ errorCount: 0,
1288
+ };
1289
+ }
1290
+
1291
+ const apiBaseUrl = resolveApiBaseUrl(session);
1292
+ const query = new URLSearchParams();
1293
+ if (normalizedSince) {
1294
+ query.set("after", normalizedSince);
1295
+ }
1296
+ const suffix = query.toString() ? `?${query.toString()}` : "";
1297
+ const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/stream${suffix}`;
1298
+ const controller = new AbortController();
1299
+ const normalizedTimeoutMs = normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS);
1300
+ const timeoutHandle = setTimeout(() => controller.abort(), normalizedTimeoutMs);
1301
+ if (typeof timeoutHandle.unref === "function") {
1302
+ timeoutHandle.unref();
1303
+ }
1304
+ const forwardAbort = () => controller.abort(signal?.reason);
1305
+ if (signal) {
1306
+ if (signal.aborted) {
1307
+ controller.abort(signal.reason);
1308
+ } else {
1309
+ signal.addEventListener("abort", forwardAbort, { once: true });
1310
+ }
1311
+ }
1312
+
1313
+ let response;
1314
+ try {
1315
+ response = await fetchImpl(
1316
+ endpoint,
1317
+ {
1318
+ method: "GET",
1319
+ headers: {
1320
+ Accept: "text/event-stream",
1321
+ Authorization: `Bearer ${session.token}`,
1322
+ },
1323
+ signal: controller.signal,
1324
+ },
1325
+ normalizedTimeoutMs
1326
+ );
1327
+ } catch (error) {
1328
+ clearTimeout(timeoutHandle);
1329
+ if (signal) signal.removeEventListener("abort", forwardAbort);
1330
+ return {
1331
+ ok: false,
1332
+ reason: isAbortLike(error) || signal?.aborted ? "aborted" : normalizeString(error?.message) || "stream_failed",
1333
+ cursor: normalizedSince,
1334
+ eventCount: 0,
1335
+ errorCount: 0,
1336
+ aborted: Boolean(signal?.aborted || isAbortLike(error)),
1337
+ };
1338
+ }
1339
+ clearTimeout(timeoutHandle);
1340
+
1341
+ let cursor = normalizedSince;
1342
+ let eventCount = 0;
1343
+ let errorCount = 0;
1344
+ let lastErrorReason = "";
1345
+ const decoder = new TextDecoder();
1346
+ let buffer = "";
1347
+
1348
+ const handlers = {
1349
+ cursor: () => cursor,
1350
+ onHeartbeat,
1351
+ onError: async (payload) => {
1352
+ errorCount += 1;
1353
+ lastErrorReason = normalizeString(payload?.reason) || "session_stream_error";
1354
+ await onError(payload);
1355
+ },
1356
+ onEvent: async (event) => {
1357
+ const eventCursor = normalizeString(event?.cursor);
1358
+ if (eventCursor) cursor = eventCursor;
1359
+ eventCount += 1;
1360
+ await onEvent(event);
1361
+ },
1362
+ };
1363
+
1364
+ try {
1365
+ if (!response || !response.ok || !response.body) {
1366
+ return {
1367
+ ok: false,
1368
+ reason: `api_${response ? response.status : "no_response"}`,
1369
+ cursor,
1370
+ eventCount,
1371
+ errorCount,
1372
+ status: response?.status,
1373
+ };
1374
+ }
1375
+
1376
+ for await (const chunk of readResponseTextChunks(response)) {
1377
+ buffer += decoder.decode(chunk, { stream: true });
1378
+ buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1379
+ const blocks = buffer.split("\n\n");
1380
+ buffer = blocks.pop() || "";
1381
+ for (const block of blocks) {
1382
+ await processSseBlock(block, handlers);
1383
+ }
1384
+ }
1385
+ buffer += decoder.decode();
1386
+ if (normalizeString(buffer)) {
1387
+ await processSseBlock(buffer, handlers);
1388
+ }
1389
+ } catch (error) {
1390
+ return {
1391
+ ok: false,
1392
+ reason: isAbortLike(error) || signal?.aborted ? "aborted" : normalizeString(error?.message) || "stream_failed",
1393
+ cursor,
1394
+ eventCount,
1395
+ errorCount,
1396
+ aborted: Boolean(signal?.aborted || isAbortLike(error)),
1397
+ };
1398
+ } finally {
1399
+ if (signal) signal.removeEventListener("abort", forwardAbort);
1400
+ }
1401
+
1402
+ return {
1403
+ ok: !lastErrorReason,
1404
+ reason: lastErrorReason,
1405
+ cursor,
1406
+ eventCount,
1407
+ errorCount,
1408
+ aborted: Boolean(signal?.aborted),
1409
+ };
1410
+ }
1411
+
1134
1412
  /**
1135
1413
  * Poll the latest durable session events page via the reverse-history endpoint.
1136
1414
  *
@@ -1365,6 +1643,123 @@ export async function listSessionMessageActions(
1365
1643
  }
1366
1644
  }
1367
1645
 
1646
+ function pinnedEventContentText(event = {}) {
1647
+ const payload = event && typeof event === "object" ? event.payload || {} : {};
1648
+ return (
1649
+ normalizeString(payload.note) ||
1650
+ normalizeString(payload.message) ||
1651
+ normalizeString(payload.text) ||
1652
+ normalizeString(payload.content) ||
1653
+ normalizeString(payload.summary) ||
1654
+ ""
1655
+ );
1656
+ }
1657
+
1658
+ function pinnedEventAuthorId(event = {}) {
1659
+ const agent = event && typeof event === "object" ? event.agent || {} : {};
1660
+ return normalizeString(agent.id || agent.agentId) || normalizeString(event.agentId) || "";
1661
+ }
1662
+
1663
+ /**
1664
+ * Resolve the session's active pinned messages, enriched with each pinned
1665
+ * message's author and content so a CLI agent can actually read them (not just
1666
+ * see sequence numbers). Pins come from the action projection
1667
+ * (`projection.pinnedMessages`, capped at `projection.pinLimit`); content is
1668
+ * resolved per pinned sequence via `/events/before`. Bounded by the pin cap
1669
+ * (<= 10), so at most ~10 single-event lookups.
1670
+ */
1671
+ export async function fetchSessionPinnedMessages(
1672
+ sessionId,
1673
+ {
1674
+ targetPath = process.cwd(),
1675
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1676
+ resolveAuthSession = resolveActiveAuthSession,
1677
+ fetchImpl = fetchWithTimeout,
1678
+ nowMs = Date.now,
1679
+ listActions = listSessionMessageActions,
1680
+ fetchEventsBefore = pollSessionEventsBefore,
1681
+ } = {}
1682
+ ) {
1683
+ const normalizedSessionId = normalizeString(sessionId);
1684
+ if (!normalizedSessionId) {
1685
+ return { ok: false, reason: "invalid_session_id", pins: [], pinLimit: 0, count: 0 };
1686
+ }
1687
+
1688
+ const actionsResult = await listActions(normalizedSessionId, {
1689
+ targetPath,
1690
+ timeoutMs,
1691
+ resolveAuthSession,
1692
+ fetchImpl,
1693
+ nowMs,
1694
+ });
1695
+ if (!actionsResult || !actionsResult.ok) {
1696
+ return {
1697
+ ok: false,
1698
+ reason: normalizeString(actionsResult?.reason) || "actions_read_failed",
1699
+ pins: [],
1700
+ pinLimit: 0,
1701
+ count: 0,
1702
+ };
1703
+ }
1704
+
1705
+ const projection = actionsResult.projection && typeof actionsResult.projection === "object"
1706
+ ? actionsResult.projection
1707
+ : {};
1708
+ const pinLimit = Number(projection.pinLimit) || 0;
1709
+ const pinnedActions = Array.isArray(projection.pinnedMessages) ? projection.pinnedMessages : [];
1710
+
1711
+ const pins = await Promise.all(
1712
+ pinnedActions.map(async (action) => {
1713
+ const targetSequenceId = Number(action?.targetSequenceId) || 0;
1714
+ const base = {
1715
+ targetSequenceId,
1716
+ targetCursor: normalizeString(action?.targetCursor) || null,
1717
+ pinnedBy: normalizeString(action?.actorId) || "",
1718
+ pinnedByKind: normalizeString(action?.actorKind) || "",
1719
+ pinnedAt: normalizeString(action?.createdAt) || "",
1720
+ author: "",
1721
+ content: "",
1722
+ resolved: false,
1723
+ };
1724
+ if (targetSequenceId <= 0) {
1725
+ return base;
1726
+ }
1727
+ const eventsResult = await fetchEventsBefore(normalizedSessionId, {
1728
+ targetPath,
1729
+ beforeSequence: targetSequenceId + 1,
1730
+ limit: 1,
1731
+ timeoutMs,
1732
+ resolveAuthSession,
1733
+ fetchImpl,
1734
+ nowMs,
1735
+ });
1736
+ if (eventsResult && eventsResult.ok && Array.isArray(eventsResult.events)) {
1737
+ const match =
1738
+ eventsResult.events.find(
1739
+ (event) => Number(event?.sequenceId) === targetSequenceId,
1740
+ ) ||
1741
+ eventsResult.events[eventsResult.events.length - 1] ||
1742
+ null;
1743
+ if (match) {
1744
+ base.author = pinnedEventAuthorId(match);
1745
+ base.content = pinnedEventContentText(match);
1746
+ base.resolved = true;
1747
+ }
1748
+ }
1749
+ return base;
1750
+ }),
1751
+ );
1752
+
1753
+ return {
1754
+ ok: true,
1755
+ reason: "",
1756
+ sessionId: normalizedSessionId,
1757
+ pins,
1758
+ pinLimit,
1759
+ count: pins.length,
1760
+ };
1761
+ }
1762
+
1368
1763
  export async function createSessionMessageAction(
1369
1764
  sessionId,
1370
1765
  {
@@ -7,7 +7,8 @@
7
7
  * Adds at render time:
8
8
  * - Per-agent active duration (first → last event with that agent id)
9
9
  * - Total session live-for (createdAt → last event)
10
- * - Token + cost roll-up if events carry usage payloads
10
+ * - Token + cost roll-up from session_usage events through the
11
+ * pricing ledger, including idempotency dedupe
11
12
  * - Avatar per speaker, picked from PERSONA_VISUALS / CLIENT_FAMILY_AVATARS,
12
13
  * or a deterministic letter-tile fallback
13
14
  * - Senti-orchestrator events tagged with the orchestrator avatar so
@@ -18,6 +19,7 @@
18
19
  */
19
20
 
20
21
  import { PERSONA_VISUALS, ORCHESTRATOR_VISUALS } from "../agents/persona-visuals.js";
22
+ import { buildSessionUsageLedger } from "./pricing-ledger.js";
21
23
 
22
24
  /**
23
25
  * Avatar map for client families (the OUTSIDE-the-persona-set agents
@@ -232,45 +234,39 @@ function eventBody(event) {
232
234
  * Compute deterministic activity stats from the event log:
233
235
  * - sessionLiveSeconds: created → last event
234
236
  * - perAgent[agentId]: { firstSeen, lastSeen, eventCount, activeSeconds, family, displayName, model }
235
- * - totals: { tokenTotal, costTotalUsd } summed from any payload.usage hints
237
+ * - totals: { tokenTotal, costTotalUsd } summed through the pricing ledger
236
238
  * - sentiActions: count of orchestrator events
237
239
  */
238
240
  export function computeTranscriptStats({ sessionMeta = {}, events = [], speakerProfiles = new Map() } = {}) {
239
241
  const perAgent = new Map();
240
242
  let firstEventTs = null;
241
243
  let lastEventTs = null;
242
- let tokenTotal = 0;
243
- let costTotalUsd = 0;
244
244
  let sentiActions = 0;
245
245
 
246
- for (const event of events) {
247
- const ts = eventTimestamp(event);
248
- if (!ts) continue;
249
- const epoch = Date.parse(ts);
250
- if (!Number.isFinite(epoch)) continue;
251
- if (firstEventTs == null || epoch < firstEventTs) firstEventTs = epoch;
252
- if (lastEventTs == null || epoch > lastEventTs) lastEventTs = epoch;
253
-
254
- const agentId = normalize(event.agent?.id || event.agentId);
255
- if (!agentId) continue;
256
- const lowerId = agentId.toLowerCase();
257
- if (lowerId === "senti" || lowerId === "kai-chen") sentiActions += 1;
258
-
259
- if (!perAgent.has(agentId)) {
260
- const profile = speakerProfiles.get(agentId) || null;
246
+ const ensureAgentRecord = ({
247
+ agentId,
248
+ agentModel = "",
249
+ epoch,
250
+ } = {}) => {
251
+ const normalizedAgentId = normalize(agentId);
252
+ if (!normalizedAgentId || !Number.isFinite(epoch)) {
253
+ return null;
254
+ }
255
+ if (!perAgent.has(normalizedAgentId)) {
256
+ const profile = speakerProfiles.get(normalizedAgentId) || null;
261
257
  const identity = resolveSpeakerIdentity({
262
- agentId,
263
- agentModel: event.agent?.model || event.agentModel || "",
258
+ agentId: normalizedAgentId,
259
+ agentModel,
264
260
  profile,
265
261
  });
266
- perAgent.set(agentId, {
267
- agentId,
262
+ perAgent.set(normalizedAgentId, {
263
+ agentId: normalizedAgentId,
268
264
  family: identity.family,
269
265
  displayName: identity.displayName,
270
266
  avatar: identity.avatar,
271
267
  avatarUrl: identity.avatarUrl,
272
268
  color: identity.color,
273
- model: event.agent?.model || event.agentModel || "",
269
+ model: agentModel,
274
270
  firstSeenMs: epoch,
275
271
  lastSeenMs: epoch,
276
272
  eventCount: 0,
@@ -278,21 +274,59 @@ export function computeTranscriptStats({ sessionMeta = {}, events = [], speakerP
278
274
  costUsd: 0,
279
275
  });
280
276
  }
281
- const record = perAgent.get(agentId);
277
+ const record = perAgent.get(normalizedAgentId);
278
+ if (!record.model && agentModel) {
279
+ record.model = agentModel;
280
+ }
281
+ return record;
282
+ };
283
+
284
+ for (const event of events) {
285
+ const ts = eventTimestamp(event);
286
+ if (!ts) continue;
287
+ const epoch = Date.parse(ts);
288
+ if (!Number.isFinite(epoch)) continue;
289
+ if (firstEventTs == null || epoch < firstEventTs) firstEventTs = epoch;
290
+ if (lastEventTs == null || epoch > lastEventTs) lastEventTs = epoch;
291
+
292
+ const agentId = normalize(event.agent?.id || event.agentId);
293
+ if (!agentId) continue;
294
+ const lowerId = agentId.toLowerCase();
295
+ if (lowerId === "senti" || lowerId === "kai-chen") sentiActions += 1;
296
+
297
+ const record = ensureAgentRecord({
298
+ agentId,
299
+ agentModel: event.agent?.model || event.agentModel || "",
300
+ epoch,
301
+ });
302
+ if (!record) continue;
282
303
  record.eventCount += 1;
283
304
  if (epoch < record.firstSeenMs) record.firstSeenMs = epoch;
284
305
  if (epoch > record.lastSeenMs) record.lastSeenMs = epoch;
306
+ }
285
307
 
286
- const usage = event?.payload?.usage;
287
- if (usage && typeof usage === "object") {
288
- const t =
289
- Number(usage.totalTokens || usage.total_tokens || usage.tokens || 0) || 0;
290
- const c = Number(usage.costUsd || usage.cost_usd || usage.cost || 0) || 0;
291
- record.tokens += t;
292
- record.costUsd += c;
293
- tokenTotal += t;
294
- costTotalUsd += c;
308
+ const usageLedger = buildSessionUsageLedger(events, {
309
+ sessionId: normalize(sessionMeta.sessionId),
310
+ });
311
+ const fallbackUsageEpoch =
312
+ lastEventTs ??
313
+ firstEventTs ??
314
+ (Number.isFinite(Date.parse(sessionMeta?.createdAt)) ? Date.parse(sessionMeta.createdAt) : 0);
315
+ for (const entry of usageLedger.entries) {
316
+ const entryEpoch = Number.isFinite(Date.parse(entry.timestamp))
317
+ ? Date.parse(entry.timestamp)
318
+ : fallbackUsageEpoch;
319
+ const record = ensureAgentRecord({
320
+ agentId: entry.agentId,
321
+ agentModel: entry.model,
322
+ epoch: entryEpoch,
323
+ });
324
+ if (!record) continue;
325
+ if ((!record.model || record.model === "unknown") && entry.model && entry.model !== "unknown") {
326
+ record.model = entry.model;
295
327
  }
328
+ record.tokens += entry.totalTokens;
329
+ record.costUsd = Math.round((record.costUsd + entry.providerCostUsd) * 1_000_000) / 1_000_000;
296
330
  }
297
331
 
298
332
  const createdAtMs = sessionMeta?.createdAt
@@ -329,7 +363,19 @@ export function computeTranscriptStats({ sessionMeta = {}, events = [], speakerP
329
363
  endedAt: lastEventTs ? new Date(lastEventTs).toISOString() : null,
330
364
  sessionLiveSeconds,
331
365
  agents,
332
- totals: { tokenTotal, costTotalUsd },
366
+ totals: {
367
+ tokenTotal: usageLedger.totals.totalTokens,
368
+ inputTokens: usageLedger.totals.inputTokens,
369
+ outputTokens: usageLedger.totals.outputTokens,
370
+ costTotalUsd: usageLedger.totals.providerCostUsd,
371
+ customerCostTotalUsd: usageLedger.totals.hasCustomerCost
372
+ ? usageLedger.totals.customerCostUsd
373
+ : null,
374
+ usageEntries: usageLedger.entries.length,
375
+ duplicatesSkipped: usageLedger.duplicatesSkipped,
376
+ unpriced: usageLedger.totals.unpriced,
377
+ priceBookVersions: usageLedger.priceBookVersions,
378
+ },
333
379
  sentiActions,
334
380
  };
335
381
  }
@@ -396,8 +442,12 @@ export function buildTranscriptMarkdown({
396
442
  lines.push(`Live for: ${formatDuration(stats.sessionLiveSeconds)}`);
397
443
  lines.push(`Senti actions: ${stats.sentiActions}`);
398
444
  if (stats.totals.tokenTotal > 0 || stats.totals.costTotalUsd > 0) {
445
+ const billableText =
446
+ stats.totals.customerCostTotalUsd == null
447
+ ? ""
448
+ : ` · Billable: $${stats.totals.customerCostTotalUsd.toFixed(4)}`;
399
449
  lines.push(
400
- `Tokens: ${stats.totals.tokenTotal.toLocaleString("en-US")} · Cost: $${stats.totals.costTotalUsd.toFixed(4)}`,
450
+ `Tokens: ${stats.totals.tokenTotal.toLocaleString("en-US")} · Cost: $${stats.totals.costTotalUsd.toFixed(4)}${billableText}`,
401
451
  );
402
452
  }
403
453
  lines.push("");
@@ -39,9 +39,9 @@
39
39
  * }
40
40
  *
41
41
  * Design choice: emit BOTH the convenient flat fields AND a
42
- * `payload.usage` block, so transcript.js's existing usage roll-up
43
- * picks it up without changes, while web UIs can display the structured
44
- * fields directly without re-parsing.
42
+ * `payload.usage` block. Transcript/download totals flow through
43
+ * pricing-ledger idempotency semantics, while web UIs can display the
44
+ * structured fields directly without re-parsing.
45
45
  */
46
46
 
47
47
  import process from "node:process";
@@ -191,8 +191,8 @@ export async function emitLLMInteraction(
191
191
  chars: responseText.length,
192
192
  text: responseText || undefined,
193
193
  },
194
- // Mirror into payload.usage so transcript.js + telemetry sync pick
195
- // it up via the same code path used for ad-hoc agent_response usage.
194
+ // Mirror into payload.usage for legacy readers and telemetry sync;
195
+ // transcript/download totals use the canonical pricing-ledger fields.
196
196
  usage: {
197
197
  totalTokens: totalT,
198
198
  costUsd: cost,