sentinelayer-cli 0.30.10 → 0.30.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.30.10",
3
+ "version": "0.30.12",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -9,7 +9,7 @@
9
9
  "docs:build": "node scripts/docs-build.mjs",
10
10
  "test": "npm run test:unit && npm run test:e2e",
11
11
  "test:unit": "node --import ./tests/setup-env.mjs --test tests/unit*.test.mjs",
12
- "test:e2e": "node --import ./tests/setup-env.mjs --test tests/e2e.test.mjs",
12
+ "test:e2e": "node --import ./tests/setup-env.mjs --test tests/e2e*.test.mjs",
13
13
  "test:coverage": "c8 node --import ./tests/setup-env.mjs --test tests/unit*.test.mjs",
14
14
  "verify": "npm run check && npm run docs:build && npm run test:e2e && npm run test:coverage && npm pack --dry-run",
15
15
  "release:publish": "node scripts/release-publish.mjs"
@@ -1712,6 +1712,47 @@ function canPublishListenerPresence(agentId) {
1712
1712
  );
1713
1713
  }
1714
1714
 
1715
+ const SESSION_OBSERVATION_SEVERITIES = new Set(["info", "p3", "p2", "p1", "p0"]);
1716
+ const SESSION_OBSERVATION_KINDS = new Set([
1717
+ "ux",
1718
+ "process",
1719
+ "reliability",
1720
+ "security",
1721
+ "billing",
1722
+ "coordination",
1723
+ "architecture",
1724
+ "testing",
1725
+ "release",
1726
+ "other",
1727
+ ]);
1728
+ const SESSION_OBSERVATION_SUMMARY_MAX_LENGTH = 4_000;
1729
+ const SESSION_OBSERVATION_OPTION_MAX_LENGTH = 512;
1730
+ const SESSION_OBSERVATION_PROPOSAL_MAX_LENGTH = 2_000;
1731
+
1732
+ function requireGrantedNonHumanAgentId(rawAgentId, commandName) {
1733
+ const agentId = normalizeAgentId(rawAgentId, "");
1734
+ if (!agentId || !canPublishListenerPresence(agentId)) {
1735
+ throw new Error(`${commandName} requires a granted non-human agent id.`);
1736
+ }
1737
+ return agentId;
1738
+ }
1739
+
1740
+ function normalizeSessionObservationChoice(rawValue, allowedValues, fallbackValue, fieldName) {
1741
+ const value = normalizeString(rawValue || fallbackValue).toLowerCase();
1742
+ if (!allowedValues.has(value)) {
1743
+ throw new Error(`${fieldName} must be one of: ${[...allowedValues].join(", ")}.`);
1744
+ }
1745
+ return value;
1746
+ }
1747
+
1748
+ function normalizeSessionObservationText(rawValue, fieldName, maxLength) {
1749
+ const value = normalizeString(rawValue);
1750
+ if (value.length > maxLength) {
1751
+ throw new Error(`${fieldName} must be ${maxLength} characters or fewer.`);
1752
+ }
1753
+ return value;
1754
+ }
1755
+
1715
1756
  function listenerLifecycleEventName(type = "") {
1716
1757
  const normalized = normalizeString(type).toLowerCase();
1717
1758
  if (normalized === "started") return "session_listener_started";
@@ -3660,6 +3701,174 @@ export function registerSessionCommand(program) {
3660
3701
  console.log(formatEventLine(persisted));
3661
3702
  });
3662
3703
 
3704
+ session
3705
+ .command("observe <sessionId> [summary...]")
3706
+ .description("Record a durable product/process observation in the session")
3707
+ .requiredOption("--agent <id>", "Granted non-human agent id to record the observation as")
3708
+ .option("--model <model>", "Agent model/provider hint", "cli")
3709
+ .option("--display-name <name>", "Human-readable agent display name")
3710
+ .option("--role <role>", "Agent role metadata", "observer")
3711
+ .option(
3712
+ "--kind <kind>",
3713
+ `Observation kind: ${[...SESSION_OBSERVATION_KINDS].join(", ")}`,
3714
+ "process",
3715
+ )
3716
+ .option(
3717
+ "--severity <level>",
3718
+ `Observation severity: ${[...SESSION_OBSERVATION_SEVERITIES].join(", ")}`,
3719
+ "info",
3720
+ )
3721
+ .option("--owner <owner>", "Suggested owner/team for follow-up")
3722
+ .option("--batch <batch>", "Suggested PR batch or backlog lane")
3723
+ .option("--target-sequence <n>", "Anchor the observation to a session sequence id")
3724
+ .option("--target-cursor <cursor>", "Anchor the observation to a session event cursor")
3725
+ .option("--proposal <text>", "Suggested remediation or next step")
3726
+ .option("--message-file <path>", "Read the observation summary from a UTF-8 file")
3727
+ .option("--stdin", "Read the observation summary from stdin")
3728
+ .option("--path <path>", "Workspace path for the session", ".")
3729
+ .option("--json", "Emit machine-readable output")
3730
+ .action(async (sessionId, summaryParts, options, command) => {
3731
+ const normalizedSessionId = normalizeString(sessionId);
3732
+ if (!normalizedSessionId) {
3733
+ throw new Error("session id is required.");
3734
+ }
3735
+ const summary = normalizeSessionObservationText(
3736
+ await resolveSessionSayMessageInput(summaryParts, options),
3737
+ "summary",
3738
+ SESSION_OBSERVATION_SUMMARY_MAX_LENGTH,
3739
+ );
3740
+ if (!summary) {
3741
+ throw new Error("summary is required.");
3742
+ }
3743
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
3744
+ const agentId = requireGrantedNonHumanAgentId(options.agent, "observe");
3745
+ const kind = normalizeSessionObservationChoice(
3746
+ options.kind,
3747
+ SESSION_OBSERVATION_KINDS,
3748
+ "process",
3749
+ "kind",
3750
+ );
3751
+ const severity = normalizeSessionObservationChoice(
3752
+ options.severity,
3753
+ SESSION_OBSERVATION_SEVERITIES,
3754
+ "info",
3755
+ "severity",
3756
+ );
3757
+ const owner = normalizeSessionObservationText(
3758
+ options.owner,
3759
+ "owner",
3760
+ SESSION_OBSERVATION_OPTION_MAX_LENGTH,
3761
+ );
3762
+ const proposedBatch = normalizeSessionObservationText(
3763
+ options.batch,
3764
+ "batch",
3765
+ SESSION_OBSERVATION_OPTION_MAX_LENGTH,
3766
+ );
3767
+ const proposal = normalizeSessionObservationText(
3768
+ options.proposal,
3769
+ "proposal",
3770
+ SESSION_OBSERVATION_PROPOSAL_MAX_LENGTH,
3771
+ );
3772
+ const targetSequenceId = parseOptionalPositiveInteger(options.targetSequence, "target-sequence");
3773
+ const targetCursor = normalizeSessionObservationText(
3774
+ options.targetCursor,
3775
+ "target-cursor",
3776
+ SESSION_OBSERVATION_OPTION_MAX_LENGTH,
3777
+ );
3778
+ const localSession = await ensureLocalSessionForRemoteCommand(normalizedSessionId, {
3779
+ targetPath,
3780
+ });
3781
+ const clientMessageId = `cli-observation-${randomUUID()}`;
3782
+ const eventPayload = {
3783
+ schema: "session_observation/v1",
3784
+ summary,
3785
+ message: summary,
3786
+ kind,
3787
+ severity,
3788
+ channel: "session",
3789
+ source: "session_observe",
3790
+ clientKind: "cli",
3791
+ clientMessageId,
3792
+ };
3793
+ if (owner) eventPayload.owner = owner;
3794
+ if (proposedBatch) eventPayload.proposedBatch = proposedBatch;
3795
+ if (proposal) eventPayload.proposal = proposal;
3796
+ if (targetSequenceId) eventPayload.targetSequenceId = targetSequenceId;
3797
+ if (targetCursor) eventPayload.targetCursor = targetCursor;
3798
+
3799
+ const agent = await resolveSessionAgentEnvelope(normalizedSessionId, agentId, {
3800
+ targetPath,
3801
+ model: options.model,
3802
+ role: normalizeString(options.role) || "observer",
3803
+ displayName: options.displayName,
3804
+ });
3805
+ agent.role = normalizeString(agent.role) || "observer";
3806
+ agent.model = normalizeString(agent.model) || "cli";
3807
+ agent.clientKind = normalizeString(agent.clientKind) || "cli";
3808
+
3809
+ const event = createAgentEvent({
3810
+ event: "session_observation",
3811
+ agent,
3812
+ sessionId: normalizedSessionId,
3813
+ payload: eventPayload,
3814
+ });
3815
+ event.eventId = clientMessageId;
3816
+ event.idempotencyToken = clientMessageId;
3817
+
3818
+ const remoteConfirmationAnchor = await readSessionConfirmationAnchor(normalizedSessionId, {
3819
+ targetPath,
3820
+ });
3821
+ if (!remoteConfirmationAnchor?.ok) {
3822
+ if (remoteConfirmationAnchor?.reason === "api_403") {
3823
+ throw new Error(
3824
+ `Observation failed (api_403). Ensure this user has an active grant for '${agentId}'.`,
3825
+ );
3826
+ }
3827
+ throw new Error(
3828
+ `Observation confirmation anchor failed (${remoteConfirmationAnchor?.reason || "unknown"}); local cache was not updated.`,
3829
+ );
3830
+ }
3831
+ const remoteSync = await syncSessionEventToApi(normalizedSessionId, event, {
3832
+ targetPath,
3833
+ });
3834
+ if (!remoteSync?.synced) {
3835
+ throw new Error(
3836
+ `Observation failed (${remoteSync?.reason || "unknown"}). Ensure this user has an active grant for '${agentId}'.`,
3837
+ );
3838
+ }
3839
+ const remoteConfirmation = await confirmSessionEventVisible(normalizedSessionId, clientMessageId, {
3840
+ targetPath,
3841
+ anchorCursor: remoteConfirmationAnchor?.cursor,
3842
+ });
3843
+ if (!remoteConfirmation?.confirmed) {
3844
+ throw new Error(
3845
+ `Observation was accepted but not visible in canonical session events (${remoteConfirmation?.reason || "not_visible"}); local cache was not updated.`,
3846
+ );
3847
+ }
3848
+
3849
+ const persisted = await appendToStream(normalizedSessionId, event, {
3850
+ targetPath,
3851
+ syncRemote: false,
3852
+ });
3853
+ const payload = {
3854
+ command: "session observe",
3855
+ targetPath,
3856
+ sessionId: normalizedSessionId,
3857
+ agentId,
3858
+ event: persisted,
3859
+ materializedLocalSession: localSession.materialized,
3860
+ refreshedLocalSession: Boolean(localSession.refreshed),
3861
+ remoteSync,
3862
+ remoteConfirmationAnchor,
3863
+ remoteConfirmation,
3864
+ };
3865
+ if (shouldEmitJson(options, command)) {
3866
+ console.log(JSON.stringify(payload, null, 2));
3867
+ return;
3868
+ }
3869
+ console.log(formatEventLine(persisted));
3870
+ });
3871
+
3663
3872
  async function runMessageActionCommand({
3664
3873
  sessionId,
3665
3874
  actionType,
@@ -71,6 +71,7 @@ const CHECKPOINT_MEANINGFUL_EVENT_NAMES = new Set([
71
71
  "help_response",
72
72
  "session_admin_kill",
73
73
  "session_message",
74
+ "session_observation",
74
75
  "task_accepted",
75
76
  "task_assign",
76
77
  "task_blocked",
@@ -12,6 +12,7 @@ const PREVIEW_EVENTS = new Set([
12
12
  "session_message",
13
13
  "session_say",
14
14
  "agent_response",
15
+ "session_observation",
15
16
  "human_relay",
16
17
  "daemon_alert",
17
18
  "session_admin_kill",
@@ -45,6 +46,7 @@ export function pickLatestPreview(events = []) {
45
46
  if (!kind || !PREVIEW_EVENTS.has(kind)) continue;
46
47
  const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
47
48
  const text =
49
+ payload.summary ||
48
50
  payload.message ||
49
51
  payload.response ||
50
52
  payload.alert ||
@@ -138,7 +138,10 @@ export function buildUsageLedgerEntry(
138
138
  { sessionId = "", priceBookVersion = DEFAULT_PRICE_BOOK_VERSION, billingTier = "unknown" } = {},
139
139
  ) {
140
140
  const kind = n(event?.event || event?.type);
141
- const payload = object(event?.payload);
141
+ const rawPayload = event?.payload;
142
+ const payload = object(rawPayload);
143
+ const payloadWasObject = rawPayload && typeof rawPayload === "object" && !Array.isArray(rawPayload);
144
+ if (kind === SESSION_USAGE_EVENT && !payloadWasObject) return null;
142
145
  const schema = n(payload.schema);
143
146
  const usage = object(payload.usage);
144
147
  const legacyUsageEvent = kind !== SESSION_USAGE_EVENT && hasUsageHints(usage);
@@ -47,6 +47,7 @@ const TRANSCRIPT_EVENT_KINDS = new Set([
47
47
  "session_reply",
48
48
  "session_reaction",
49
49
  "session_usage",
50
+ "session_observation",
50
51
  "human_relay",
51
52
  "agent_join",
52
53
  "agent_left",
@@ -209,11 +210,56 @@ function actionBody(event) {
209
210
  return "";
210
211
  }
211
212
 
213
+ function boundedMarkdownText(value, { maxLength = 2_000 } = {}) {
214
+ const text = normalize(value).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
215
+ if (!maxLength || text.length <= maxLength) return text;
216
+ const head = Math.max(120, Math.floor((maxLength - 3) * 0.75));
217
+ const tail = Math.max(40, maxLength - 3 - head);
218
+ return `${text.slice(0, head)}...${text.slice(-tail)}`;
219
+ }
220
+
221
+ function observationTargetLabel(payload = {}) {
222
+ const targetSequence = Number(payload.targetSequenceId || payload.target_sequence_id || 0);
223
+ if (Number.isFinite(targetSequence) && targetSequence > 0) {
224
+ return `#${Math.floor(targetSequence)}`;
225
+ }
226
+ const targetCursor = tableText(payload.targetCursor || payload.target_cursor, { maxLength: 96 });
227
+ return targetCursor ? `cursor ${targetCursor}` : "";
228
+ }
229
+
230
+ function observationBody(event) {
231
+ const payload = event && typeof event.payload === "object" ? event.payload : {};
232
+ const severity = tableText(payload.severity || "info", { maxLength: 16 }).toLowerCase();
233
+ const kind = tableText(payload.kind || "process", { maxLength: 32 }).toLowerCase();
234
+ const summary = boundedMarkdownText(payload.summary || payload.message || payload.text, {
235
+ maxLength: 4_000,
236
+ });
237
+ const proposal = boundedMarkdownText(payload.proposal || payload.recommendation, {
238
+ maxLength: 2_000,
239
+ });
240
+ const owner = tableText(payload.owner || payload.assignee, { maxLength: 96 });
241
+ const proposedBatch = tableText(payload.proposedBatch || payload.batch, { maxLength: 96 });
242
+ const target = observationTargetLabel(payload);
243
+ const metadata = [];
244
+ if (owner) metadata.push(`Owner: \`${owner}\``);
245
+ if (proposedBatch) metadata.push(`Batch: \`${proposedBatch}\``);
246
+ if (target) metadata.push(`Target: \`${target}\``);
247
+
248
+ const lines = [`**Observation:** \`${severity || "info"}\` · \`${kind || "process"}\``];
249
+ if (metadata.length) lines.push(metadata.join(" · "));
250
+ if (summary) lines.push("", summary);
251
+ if (proposal) lines.push("", "**Proposal:**", proposal);
252
+ return lines.join("\n");
253
+ }
254
+
212
255
  function eventBody(event) {
213
256
  const kind = normalize(event?.event || event?.type);
214
257
  if (kind === "session_action" || kind === "session_reply" || kind === "session_reaction") {
215
258
  return actionBody(event);
216
259
  }
260
+ if (kind === "session_observation") {
261
+ return observationBody(event);
262
+ }
217
263
  const payload = event && typeof event.payload === "object" ? event.payload : {};
218
264
  // session_usage carries the response inside payload.response.text
219
265
  const responseText =
@@ -237,7 +283,12 @@ function eventBody(event) {
237
283
  * - totals: { tokenTotal, costTotalUsd } summed through the pricing ledger
238
284
  * - sentiActions: count of orchestrator events
239
285
  */
240
- export function computeTranscriptStats({ sessionMeta = {}, events = [], speakerProfiles = new Map() } = {}) {
286
+ export function computeTranscriptStats({
287
+ sessionMeta = {},
288
+ events = [],
289
+ speakerProfiles = new Map(),
290
+ usageLedger = null,
291
+ } = {}) {
241
292
  const perAgent = new Map();
242
293
  let firstEventTs = null;
243
294
  let lastEventTs = null;
@@ -305,14 +356,14 @@ export function computeTranscriptStats({ sessionMeta = {}, events = [], speakerP
305
356
  if (epoch > record.lastSeenMs) record.lastSeenMs = epoch;
306
357
  }
307
358
 
308
- const usageLedger = buildSessionUsageLedger(events, {
359
+ const resolvedUsageLedger = usageLedger || buildSessionUsageLedger(events, {
309
360
  sessionId: normalize(sessionMeta.sessionId),
310
361
  });
311
362
  const fallbackUsageEpoch =
312
363
  lastEventTs ??
313
364
  firstEventTs ??
314
365
  (Number.isFinite(Date.parse(sessionMeta?.createdAt)) ? Date.parse(sessionMeta.createdAt) : 0);
315
- for (const entry of usageLedger.entries) {
366
+ for (const entry of resolvedUsageLedger.entries) {
316
367
  const entryEpoch = Number.isFinite(Date.parse(entry.timestamp))
317
368
  ? Date.parse(entry.timestamp)
318
369
  : fallbackUsageEpoch;
@@ -364,17 +415,17 @@ export function computeTranscriptStats({ sessionMeta = {}, events = [], speakerP
364
415
  sessionLiveSeconds,
365
416
  agents,
366
417
  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
418
+ tokenTotal: resolvedUsageLedger.totals.totalTokens,
419
+ inputTokens: resolvedUsageLedger.totals.inputTokens,
420
+ outputTokens: resolvedUsageLedger.totals.outputTokens,
421
+ costTotalUsd: resolvedUsageLedger.totals.providerCostUsd,
422
+ customerCostTotalUsd: resolvedUsageLedger.totals.hasCustomerCost
423
+ ? resolvedUsageLedger.totals.customerCostUsd
373
424
  : null,
374
- usageEntries: usageLedger.entries.length,
375
- duplicatesSkipped: usageLedger.duplicatesSkipped,
376
- unpriced: usageLedger.totals.unpriced,
377
- priceBookVersions: usageLedger.priceBookVersions,
425
+ usageEntries: resolvedUsageLedger.entries.length,
426
+ duplicatesSkipped: resolvedUsageLedger.duplicatesSkipped,
427
+ unpriced: resolvedUsageLedger.totals.unpriced,
428
+ priceBookVersions: resolvedUsageLedger.priceBookVersions,
378
429
  },
379
430
  sentiActions,
380
431
  };
@@ -408,6 +459,134 @@ function avatarMd(identity) {
408
459
  return identity.avatar || letterTile(identity.displayName || identity.agentId);
409
460
  }
410
461
 
462
+ function intCell(value) {
463
+ return Math.max(0, Math.floor(Number(value || 0))).toLocaleString("en-US");
464
+ }
465
+
466
+ function usdCell(value) {
467
+ return `$${Number(value || 0).toFixed(6)}`;
468
+ }
469
+
470
+ function tableText(value, { maxLength = 160 } = {}) {
471
+ const escaped = normalize(value)
472
+ .replace(/\r?\n/g, " ")
473
+ .replace(/\|/g, "\\|")
474
+ .replace(/`/g, "'");
475
+ if (!maxLength || escaped.length <= maxLength) return escaped;
476
+ const head = Math.max(24, Math.floor((maxLength - 3) * 0.7));
477
+ const tail = Math.max(12, maxLength - 3 - head);
478
+ return `${escaped.slice(0, head)}...${escaped.slice(-tail)}`;
479
+ }
480
+
481
+ function codeCell(value) {
482
+ const text = tableText(value);
483
+ return text ? `\`${text}\`` : "—";
484
+ }
485
+
486
+ function optionalUsdCell(value, hasValue) {
487
+ return hasValue ? usdCell(value) : "—";
488
+ }
489
+
490
+ function shortenIdempotencyKey(value, maxLength = 36) {
491
+ const text = tableText(value, { maxLength: 0 });
492
+ if (!text || text.length <= maxLength) return text;
493
+ const head = Math.max(8, Math.floor((maxLength - 3) * 0.58));
494
+ const tail = Math.max(6, maxLength - 3 - head);
495
+ return `${text.slice(0, head)}...${text.slice(-tail)}`;
496
+ }
497
+
498
+ function rollupsByCostAndTokens(map) {
499
+ return [...map.values()].sort((a, b) => (
500
+ b.providerCostUsd - a.providerCostUsd ||
501
+ b.totalTokens - a.totalTokens ||
502
+ b.entries - a.entries ||
503
+ tableText(a.label).localeCompare(tableText(b.label))
504
+ ));
505
+ }
506
+
507
+ function recentUsageEntries(entries, limit = 10) {
508
+ return entries
509
+ .map((entry, index) => ({ entry, index }))
510
+ .sort((a, b) => {
511
+ const aSequence = Number.isFinite(a.entry.sequenceId) ? a.entry.sequenceId : null;
512
+ const bSequence = Number.isFinite(b.entry.sequenceId) ? b.entry.sequenceId : null;
513
+ if (aSequence != null && bSequence != null && aSequence !== bSequence) {
514
+ return bSequence - aSequence;
515
+ }
516
+ const aTime = Date.parse(a.entry.timestamp);
517
+ const bTime = Date.parse(b.entry.timestamp);
518
+ if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) {
519
+ return bTime - aTime;
520
+ }
521
+ return b.index - a.index;
522
+ })
523
+ .slice(0, limit)
524
+ .map(({ entry }) => entry);
525
+ }
526
+
527
+ function appendUsageRollupTable(lines, { title, labelHeader, rollups }) {
528
+ lines.push(`### ${title}`);
529
+ lines.push("");
530
+ lines.push(`| ${labelHeader} | Entries | Input | Output | Total | Provider cost | Customer cost | Unpriced |`);
531
+ lines.push("|---|---:|---:|---:|---:|---:|---:|---:|");
532
+ if (rollups.length === 0) {
533
+ lines.push(`| — | 0 | 0 | 0 | 0 | ${usdCell(0)} | — | 0 |`);
534
+ } else {
535
+ for (const rollup of rollups) {
536
+ lines.push(
537
+ `| ${codeCell(rollup.label)} | ${intCell(rollup.entries)} | ${intCell(rollup.inputTokens)} | ${intCell(rollup.outputTokens)} | ${intCell(rollup.totalTokens)} | ${usdCell(rollup.providerCostUsd)} | ${optionalUsdCell(rollup.customerCostUsd, rollup.hasCustomerCost)} | ${intCell(rollup.unpriced)} |`,
538
+ );
539
+ }
540
+ }
541
+ lines.push("");
542
+ }
543
+
544
+ function appendUsageLedgerSection(lines, usageLedger) {
545
+ const totals = usageLedger.totals;
546
+ lines.push("## Usage Ledger");
547
+ lines.push("");
548
+
549
+ if (usageLedger.entries.length === 0 && usageLedger.duplicatesSkipped === 0) {
550
+ lines.push("_No usage telemetry recorded._");
551
+ lines.push("");
552
+ return;
553
+ }
554
+
555
+ const customerCostText = totals.hasCustomerCost
556
+ ? `Customer cost: ${usdCell(totals.customerCostUsd)}`
557
+ : "Customer cost: —";
558
+ lines.push(`Accepted entries: ${intCell(usageLedger.entries.length)}`);
559
+ lines.push(
560
+ `Tokens: ${intCell(totals.totalTokens)} (input ${intCell(totals.inputTokens)} / output ${intCell(totals.outputTokens)})`,
561
+ );
562
+ lines.push(`Provider cost: ${usdCell(totals.providerCostUsd)} · ${customerCostText}`);
563
+ lines.push(`Price books: ${usageLedger.priceBookVersions.map(tableText).filter(Boolean).join(", ") || "—"}`);
564
+ lines.push(`Duplicates skipped: ${intCell(usageLedger.duplicatesSkipped)} · Unpriced entries: ${intCell(totals.unpriced)}`);
565
+ lines.push("");
566
+
567
+ appendUsageRollupTable(lines, {
568
+ title: "Per Agent",
569
+ labelHeader: "Agent",
570
+ rollups: rollupsByCostAndTokens(usageLedger.perAgent),
571
+ });
572
+ appendUsageRollupTable(lines, {
573
+ title: "Per Action",
574
+ labelHeader: "Action",
575
+ rollups: rollupsByCostAndTokens(usageLedger.perAction),
576
+ });
577
+
578
+ lines.push("### Recent Entries");
579
+ lines.push("");
580
+ lines.push("| Time | Agent | Action | Model | Tokens | Provider cost | Customer cost | Idempotency key |");
581
+ lines.push("|---|---|---|---|---:|---:|---:|---|");
582
+ for (const entry of recentUsageEntries(usageLedger.entries)) {
583
+ lines.push(
584
+ `| ${tableText(timestampOnly(entry.timestamp)) || "—"} | ${codeCell(entry.agentId)} | ${codeCell(entry.action)} | ${codeCell(entry.model)} | ${intCell(entry.totalTokens)} | ${usdCell(entry.providerCostUsd)} | ${optionalUsdCell(entry.customerCostUsd, entry.customerCostUsd != null)} | ${codeCell(shortenIdempotencyKey(entry.idempotencyKey))} |`,
585
+ );
586
+ }
587
+ lines.push("");
588
+ }
589
+
411
590
  /**
412
591
  * Build the iMessage-style markdown transcript.
413
592
  *
@@ -429,8 +608,11 @@ export function buildTranscriptMarkdown({
429
608
  speakerProfiles = new Map(),
430
609
  options = {},
431
610
  } = {}) {
611
+ const usageLedger = buildSessionUsageLedger(events, {
612
+ sessionId: normalize(sessionMeta.sessionId),
613
+ });
432
614
  const includeSystemEvents = options.includeSystemEvents !== false;
433
- const stats = computeTranscriptStats({ sessionMeta, events, speakerProfiles });
615
+ const stats = computeTranscriptStats({ sessionMeta, events, speakerProfiles, usageLedger });
434
616
 
435
617
  const lines = [];
436
618
  const sessionId = normalize(sessionMeta.sessionId) || "unknown";
@@ -496,15 +678,17 @@ export function buildTranscriptMarkdown({
496
678
  stats.participantCount = stats.agents.length + silentRegisteredAgents.length;
497
679
  lines.push("");
498
680
 
681
+ appendUsageLedgerSection(lines, usageLedger);
682
+
499
683
  // Conversation
500
684
  lines.push("## Conversation");
501
685
  lines.push("");
502
686
  for (const event of events) {
503
- const kind = normalize(event.event || event.type);
687
+ const kind = normalize(event?.event || event?.type);
504
688
  if (!kind || !TRANSCRIPT_EVENT_KINDS.has(kind)) continue;
505
689
  if (!includeSystemEvents && SYSTEM_EVENT_KINDS.has(kind)) continue;
506
690
 
507
- const agentId = normalize(event.agent?.id || event.agentId);
691
+ const agentId = normalize(event?.agent?.id || event?.agentId);
508
692
  const profile = speakerProfiles.get(agentId) || null;
509
693
  const identity = resolveSpeakerIdentity({
510
694
  agentId,