sentinelayer-cli 0.17.0 → 0.18.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.
@@ -17,6 +17,19 @@ const DEFAULT_RECAP_INTERVAL_MS = 300_000;
17
17
  const DEFAULT_RECAP_INACTIVITY_MS = 600_000;
18
18
  const DEFAULT_RECAP_ACTIVITY_THRESHOLD = 5;
19
19
  const DEFAULT_TASK_SUMMARY_LIMIT = 3;
20
+ const RECAP_SOURCE_IGNORED_EVENTS = new Set([
21
+ "agent_heartbeat",
22
+ "agent_join",
23
+ "agent_status",
24
+ "context_briefing",
25
+ "daemon_alert",
26
+ "session_ack",
27
+ "session_checkpoint",
28
+ "session_reaction",
29
+ "session_recap",
30
+ "session_usage",
31
+ "session_view",
32
+ ]);
20
33
  const ACTIVE_TASK_STATUSES = new Set(["PENDING", "ACCEPTED", "BLOCKED"]);
21
34
  const TASK_STATUS_KEYS = {
22
35
  PENDING: "pending",
@@ -64,6 +77,184 @@ function isRecapEvent(event = {}) {
64
77
  return payload.ephemeral === true && normalizeString(payload.style) === RECAP_STYLE;
65
78
  }
66
79
 
80
+ function eventSequenceId(event = {}) {
81
+ const parsed = Number(event.sequenceId ?? event.sequence_id);
82
+ if (!Number.isFinite(parsed) || parsed <= 0) {
83
+ return null;
84
+ }
85
+ return Math.floor(parsed);
86
+ }
87
+
88
+ function eventCursor(event = {}) {
89
+ return normalizeString(event.cursor || event.eventId || event.idempotencyToken || event.ts);
90
+ }
91
+
92
+ function isMeaningfulRecapSourceEvent(event = {}) {
93
+ if (isRecapEvent(event)) {
94
+ return false;
95
+ }
96
+ const eventName = normalizeString(event.event).toLowerCase();
97
+ if (!eventName || RECAP_SOURCE_IGNORED_EVENTS.has(eventName)) {
98
+ return false;
99
+ }
100
+ return true;
101
+ }
102
+
103
+ function isEventAfterRecapAnchor(event = {}, state = {}) {
104
+ const anchorSequence = Number(state.lastSourceSequenceId);
105
+ const sequence = eventSequenceId(event);
106
+ if (Number.isFinite(anchorSequence) && anchorSequence > 0 && sequence !== null) {
107
+ return sequence > anchorSequence;
108
+ }
109
+
110
+ const anchorAt = normalizeString(state.lastSourceEventAt);
111
+ if (!anchorAt) {
112
+ return true;
113
+ }
114
+ const anchorEpoch = toEpoch(anchorAt, anchorAt);
115
+ const eventEpoch = toEpoch(event.ts, anchorAt);
116
+ if (eventEpoch > anchorEpoch) {
117
+ return true;
118
+ }
119
+ if (eventEpoch < anchorEpoch) {
120
+ return false;
121
+ }
122
+
123
+ const anchorCursor = normalizeString(state.lastSourceCursor);
124
+ if (!anchorCursor) {
125
+ return true;
126
+ }
127
+ return eventCursor(event) !== anchorCursor;
128
+ }
129
+
130
+ function rememberRecapSource(state, event = {}, nowIso = new Date().toISOString()) {
131
+ state.lastSourceEventAt = normalizeIsoTimestamp(event.ts, nowIso);
132
+ state.lastSourceSequenceId = eventSequenceId(event);
133
+ state.lastSourceCursor = eventCursor(event);
134
+ }
135
+
136
+ function resolvePeriodicRecapTrigger(state, events = [], nowIso = new Date().toISOString()) {
137
+ const sortedEvents = sortEventsByConversationTime(events, nowIso);
138
+ const sourceEvents = sortedEvents.filter(isMeaningfulRecapSourceEvent);
139
+ const unrecappedSourceEvents = sourceEvents.filter((event) => isEventAfterRecapAnchor(event, state));
140
+ const latestSourceEvent = sourceEvents.length > 0 ? sourceEvents[sourceEvents.length - 1] : null;
141
+ const latestUnrecappedSourceEvent =
142
+ unrecappedSourceEvents.length > 0
143
+ ? unrecappedSourceEvents[unrecappedSourceEvents.length - 1]
144
+ : null;
145
+ const nowEpoch = toEpoch(nowIso, nowIso);
146
+ const latestSourceEventAt = latestSourceEvent
147
+ ? normalizeIsoTimestamp(latestSourceEvent.ts, nowIso)
148
+ : null;
149
+ const latestUnrecappedSourceEventAt = latestUnrecappedSourceEvent
150
+ ? normalizeIsoTimestamp(latestUnrecappedSourceEvent.ts, nowIso)
151
+ : null;
152
+ const sourceIdleMs = latestSourceEvent
153
+ ? Math.max(0, nowEpoch - toEpoch(latestSourceEvent.ts, nowIso))
154
+ : null;
155
+ const unrecappedSourceIdleMs = latestUnrecappedSourceEvent
156
+ ? Math.max(0, nowEpoch - toEpoch(latestUnrecappedSourceEvent.ts, nowIso))
157
+ : null;
158
+ const sinceLastRecapMs = state.lastRecapAt
159
+ ? Math.max(0, nowEpoch - toEpoch(state.lastRecapAt, nowIso))
160
+ : null;
161
+ const policy = {
162
+ intervalMs: state.intervalMs,
163
+ inactivityMs: state.inactivityMs,
164
+ activityThreshold: state.newEventThreshold,
165
+ sourceEventCount: unrecappedSourceEvents.length,
166
+ totalSourceEventCount: sourceEvents.length,
167
+ latestSourceEventAt,
168
+ latestUnrecappedSourceEventAt,
169
+ sourceIdleMs,
170
+ unrecappedSourceIdleMs,
171
+ lastRecapAt: state.lastRecapAt,
172
+ lastSourceEventAt: state.lastSourceEventAt,
173
+ };
174
+
175
+ if (!latestSourceEvent) {
176
+ return {
177
+ shouldEmit: false,
178
+ shouldStop: false,
179
+ stopAfterEmit: false,
180
+ mode: "",
181
+ reason: "recap_no_source_events",
182
+ sourceEvent: null,
183
+ policy,
184
+ };
185
+ }
186
+
187
+ if (!latestUnrecappedSourceEvent) {
188
+ return {
189
+ shouldEmit: false,
190
+ shouldStop: sourceIdleMs !== null && sourceIdleMs >= state.inactivityMs,
191
+ stopAfterEmit: false,
192
+ mode: "",
193
+ reason: "recap_no_new_source_events",
194
+ sourceEvent: null,
195
+ policy,
196
+ };
197
+ }
198
+
199
+ if (unrecappedSourceIdleMs !== null && unrecappedSourceIdleMs >= state.inactivityMs) {
200
+ return {
201
+ shouldEmit: true,
202
+ shouldStop: false,
203
+ stopAfterEmit: true,
204
+ mode: "inactivity",
205
+ reason: "",
206
+ sourceEvent: latestUnrecappedSourceEvent,
207
+ policy,
208
+ };
209
+ }
210
+
211
+ if (!state.lastRecapAt) {
212
+ return {
213
+ shouldEmit: true,
214
+ shouldStop: false,
215
+ stopAfterEmit: false,
216
+ mode: "initial",
217
+ reason: "",
218
+ sourceEvent: latestUnrecappedSourceEvent,
219
+ policy,
220
+ };
221
+ }
222
+
223
+ if (unrecappedSourceEvents.length >= state.newEventThreshold) {
224
+ return {
225
+ shouldEmit: true,
226
+ shouldStop: false,
227
+ stopAfterEmit: false,
228
+ mode: "activity_threshold",
229
+ reason: "",
230
+ sourceEvent: latestUnrecappedSourceEvent,
231
+ policy,
232
+ };
233
+ }
234
+
235
+ if (sinceLastRecapMs !== null && sinceLastRecapMs >= state.intervalMs) {
236
+ return {
237
+ shouldEmit: true,
238
+ shouldStop: false,
239
+ stopAfterEmit: false,
240
+ mode: "periodic",
241
+ reason: "",
242
+ sourceEvent: latestUnrecappedSourceEvent,
243
+ policy,
244
+ };
245
+ }
246
+
247
+ return {
248
+ shouldEmit: false,
249
+ shouldStop: false,
250
+ stopAfterEmit: false,
251
+ mode: "",
252
+ reason: "recap_cadence_wait",
253
+ sourceEvent: latestUnrecappedSourceEvent,
254
+ policy,
255
+ };
256
+ }
257
+
67
258
  function parseFindingSeverity(text = "") {
68
259
  const normalized = normalizeString(text);
69
260
  if (!normalized) {
@@ -704,8 +895,8 @@ export async function shouldEmitRecap(
704
895
  tail: 0,
705
896
  since: normalizeString(lastReadAt) || null,
706
897
  });
707
- const relevantSinceRead = eventsSinceRead.filter((event) => {
708
- if (isRecapEvent(event)) {
898
+ const isRelevantSourceEvent = (event = {}) => {
899
+ if (!isMeaningfulRecapSourceEvent(event)) {
709
900
  return false;
710
901
  }
711
902
  const sourceAgent = normalizeString(event.agent?.id || event.agentId).toLowerCase();
@@ -713,20 +904,23 @@ export async function shouldEmitRecap(
713
904
  return true;
714
905
  }
715
906
  return sourceAgent !== normalizedAgentId;
716
- });
717
- if (relevantSinceRead.length > threshold) {
907
+ };
908
+ const relevantSinceRead = eventsSinceRead.filter(isRelevantSourceEvent);
909
+ if (relevantSinceRead.length >= threshold) {
718
910
  return true;
719
911
  }
720
912
 
721
913
  const latest = await readStream(normalizedSessionId, {
722
914
  targetPath,
723
- tail: 1,
915
+ tail: 200,
724
916
  });
725
- const latestEvent = latest.length > 0 ? latest[latest.length - 1] : null;
726
- if (!latestEvent || isRecapEvent(latestEvent)) {
917
+ const latestRelevant = sortEventsByConversationTime(latest, normalizedNow)
918
+ .filter(isRelevantSourceEvent)
919
+ .at(-1);
920
+ if (!latestRelevant) {
727
921
  return false;
728
922
  }
729
- const idleMs = Math.max(0, toEpoch(normalizedNow, normalizedNow) - toEpoch(latestEvent.ts, normalizedNow));
923
+ const idleMs = Math.max(0, toEpoch(normalizedNow, normalizedNow) - toEpoch(latestRelevant.ts, normalizedNow));
730
924
  return idleMs >= normalizedInactivityMs;
731
925
  }
732
926
 
@@ -735,6 +929,7 @@ export function emitPeriodicRecap(
735
929
  {
736
930
  intervalMs = DEFAULT_RECAP_INTERVAL_MS,
737
931
  inactivityMs = DEFAULT_RECAP_INACTIVITY_MS,
932
+ newEventThreshold = DEFAULT_RECAP_ACTIVITY_THRESHOLD,
738
933
  maxEvents = DEFAULT_RECAP_MAX_EVENTS,
739
934
  targetPath = process.cwd(),
740
935
  nowProvider = () => new Date().toISOString(),
@@ -751,6 +946,10 @@ export function emitPeriodicRecap(
751
946
  inactivityMs,
752
947
  DEFAULT_RECAP_INACTIVITY_MS
753
948
  );
949
+ const normalizedNewEventThreshold = normalizePositiveInteger(
950
+ newEventThreshold,
951
+ DEFAULT_RECAP_ACTIVITY_THRESHOLD
952
+ );
754
953
  const normalizedMaxEvents = normalizePositiveInteger(maxEvents, DEFAULT_RECAP_MAX_EVENTS);
755
954
  const key = buildRecapKey(normalizedSessionId, normalizedTargetPath);
756
955
  const existing = ACTIVE_RECAP_EMITTERS.get(key);
@@ -764,13 +963,18 @@ export function emitPeriodicRecap(
764
963
  startedAt: normalizeIsoTimestamp(nowProvider(), new Date().toISOString()),
765
964
  intervalMs: normalizedIntervalMs,
766
965
  inactivityMs: normalizedInactivityMs,
966
+ newEventThreshold: normalizedNewEventThreshold,
767
967
  maxEvents: normalizedMaxEvents,
768
968
  targetPath: normalizedTargetPath,
769
969
  sessionId: normalizedSessionId,
770
970
  timer: null,
971
+ inFlight: false,
771
972
  lastRecapAt: null,
772
973
  lastSourceEventAt: null,
974
+ lastSourceSequenceId: null,
975
+ lastSourceCursor: null,
773
976
  lastRecapEvent: null,
977
+ lastDecision: null,
774
978
  stoppedReason: null,
775
979
  };
776
980
 
@@ -794,77 +998,87 @@ export function emitPeriodicRecap(
794
998
  sessionId: state.sessionId,
795
999
  reason: state.stoppedReason,
796
1000
  lastRecapAt: state.lastRecapAt,
1001
+ lastDecision: state.lastDecision,
797
1002
  };
798
1003
  };
799
1004
 
800
- const tickNow = async () => {
801
- if (!state.running) {
802
- return null;
803
- }
804
- const nowIso = normalizeIsoTimestamp(nowProvider(), new Date().toISOString());
805
- const nowEpoch = toEpoch(nowIso, nowIso);
806
- const events = await readStream(state.sessionId, {
807
- targetPath: state.targetPath,
808
- tail: state.maxEvents,
809
- });
810
- const nonRecapEvents = events.filter((event) => !isRecapEvent(event));
811
- const latestSourceEvent = nonRecapEvents.length > 0 ? nonRecapEvents[nonRecapEvents.length - 1] : null;
812
- if (!latestSourceEvent) {
813
- return null;
814
- }
815
-
816
- const latestSourceEpoch = toEpoch(latestSourceEvent.ts, nowIso);
817
- const idleMs = Math.max(0, nowEpoch - latestSourceEpoch);
818
- if (idleMs >= state.inactivityMs) {
819
- stop("inactive");
1005
+ const tickNow = async (overrideNowIso = "") => {
1006
+ if (!state.running || state.inFlight) {
820
1007
  return null;
821
1008
  }
822
-
823
- if (state.lastRecapAt) {
824
- const sinceLastRecapMs = Math.max(0, nowEpoch - toEpoch(state.lastRecapAt, nowIso));
825
- if (sinceLastRecapMs < state.intervalMs) {
826
- return null;
827
- }
828
- }
829
- if (state.lastSourceEventAt) {
830
- const previousSourceEpoch = toEpoch(state.lastSourceEventAt, nowIso);
831
- if (latestSourceEpoch <= previousSourceEpoch) {
1009
+ state.inFlight = true;
1010
+ try {
1011
+ const nowIso = normalizeIsoTimestamp(
1012
+ overrideNowIso || nowProvider(),
1013
+ new Date().toISOString()
1014
+ );
1015
+ const events = await readStream(state.sessionId, {
1016
+ targetPath: state.targetPath,
1017
+ tail: state.maxEvents,
1018
+ });
1019
+ const trigger = resolvePeriodicRecapTrigger(state, events, nowIso);
1020
+ state.lastDecision = {
1021
+ emitted: false,
1022
+ mode: trigger.mode,
1023
+ reason: trigger.reason,
1024
+ policy: trigger.policy,
1025
+ };
1026
+ if (!trigger.shouldEmit) {
1027
+ if (trigger.shouldStop) {
1028
+ stop("inactive");
1029
+ }
832
1030
  return null;
833
1031
  }
834
- }
835
1032
 
836
- const recap = await buildSessionRecap(state.sessionId, {
837
- targetPath: state.targetPath,
838
- maxEvents: state.maxEvents,
839
- nowIso,
840
- });
841
- const text = buildPeriodicText(recap);
842
- const event = createAgentEvent({
843
- event: "session_recap",
844
- agentId: SENTI_AGENT_ID,
845
- agentModel: SENTI_MODEL,
846
- sessionId: state.sessionId,
847
- ts: nowIso,
848
- payload: {
849
- mode: "periodic",
850
- recap: text,
851
- ephemeral: true,
852
- style: RECAP_STYLE,
853
- generatedAt: nowIso,
854
- summary: recap.summary,
855
- },
856
- });
857
- const persisted = await appendToStream(state.sessionId, event, {
858
- targetPath: state.targetPath,
859
- });
860
- state.lastRecapAt = nowIso;
861
- state.lastSourceEventAt = normalizeIsoTimestamp(latestSourceEvent.ts, nowIso);
862
- state.lastRecapEvent = persisted;
1033
+ const recap = await buildSessionRecap(state.sessionId, {
1034
+ targetPath: state.targetPath,
1035
+ maxEvents: state.maxEvents,
1036
+ nowIso,
1037
+ });
1038
+ const text = buildPeriodicText(recap);
1039
+ const event = createAgentEvent({
1040
+ event: "session_recap",
1041
+ agentId: SENTI_AGENT_ID,
1042
+ agentModel: SENTI_MODEL,
1043
+ sessionId: state.sessionId,
1044
+ ts: nowIso,
1045
+ payload: {
1046
+ mode: trigger.mode,
1047
+ recap: text,
1048
+ ephemeral: true,
1049
+ style: RECAP_STYLE,
1050
+ generatedAt: nowIso,
1051
+ sourceEventCount: trigger.policy.sourceEventCount,
1052
+ latestSourceEventAt: trigger.policy.latestUnrecappedSourceEventAt,
1053
+ policy: trigger.policy,
1054
+ summary: recap.summary,
1055
+ },
1056
+ });
1057
+ const persisted = await appendToStream(state.sessionId, event, {
1058
+ targetPath: state.targetPath,
1059
+ });
1060
+ state.lastRecapAt = nowIso;
1061
+ rememberRecapSource(state, trigger.sourceEvent, nowIso);
1062
+ state.lastRecapEvent = persisted;
1063
+ state.lastDecision = {
1064
+ emitted: true,
1065
+ mode: trigger.mode,
1066
+ reason: "",
1067
+ eventId: persisted.eventId || null,
1068
+ sourceEventCount: trigger.policy.sourceEventCount,
1069
+ policy: trigger.policy,
1070
+ };
863
1071
 
864
- if (typeof onEmit === "function") {
865
- await onEmit(persisted, recap);
1072
+ if (typeof onEmit === "function") {
1073
+ await onEmit(persisted, recap);
1074
+ }
1075
+ if (trigger.stopAfterEmit) {
1076
+ stop("inactive");
1077
+ }
1078
+ return persisted;
1079
+ } finally {
1080
+ state.inFlight = false;
866
1081
  }
867
- return persisted;
868
1082
  };
869
1083
 
870
1084
  const handle = {
@@ -880,8 +1094,12 @@ export function emitPeriodicRecap(
880
1094
  running: state.running,
881
1095
  intervalMs: state.intervalMs,
882
1096
  inactivityMs: state.inactivityMs,
1097
+ newEventThreshold: state.newEventThreshold,
1098
+ inFlight: state.inFlight,
883
1099
  lastRecapAt: state.lastRecapAt,
884
1100
  lastSourceEventAt: state.lastSourceEventAt,
1101
+ lastSourceSequenceId: state.lastSourceSequenceId,
1102
+ lastDecision: state.lastDecision,
885
1103
  stoppedReason: state.stoppedReason,
886
1104
  }),
887
1105
  };
@@ -906,6 +1124,7 @@ export function getPeriodicRecapEmitter(sessionId, { targetPath = process.cwd()
906
1124
 
907
1125
  export {
908
1126
  ACTIVE_RECAP_EMITTERS,
1127
+ DEFAULT_RECAP_ACTIVITY_THRESHOLD,
909
1128
  DEFAULT_RECAP_INACTIVITY_MS,
910
1129
  DEFAULT_RECAP_INTERVAL_MS,
911
1130
  DEFAULT_RECAP_MAX_EVENTS,
@@ -1901,10 +1901,7 @@ export async function probeSessionAccess(
1901
1901
  }
1902
1902
 
1903
1903
  export function resetSessionSyncStateForTests() {
1904
- outboundCircuit.consecutiveFailures = 0;
1905
- outboundCircuit.openedAtMs = 0;
1906
- inboundCircuit.consecutiveFailures = 0;
1907
- inboundCircuit.openedAtMs = 0;
1904
+ __resetCircuitStateForTests();
1908
1905
  sessionIngestWindowBySessionId.clear();
1909
1906
  humanRelayWindowBySessionId.clear();
1910
1907
  autoGrantAttemptedAgentIds.clear();
@@ -41,6 +41,12 @@ import process from "node:process";
41
41
  import { randomUUID } from "node:crypto";
42
42
 
43
43
  import { createAgentEvent } from "../events/schema.js";
44
+ import { estimateModelCost } from "../cost/tracker.js";
45
+ import {
46
+ DEFAULT_PRICE_BOOK_VERSION,
47
+ buildSessionUsageLedger,
48
+ createSessionUsageLedgerId,
49
+ } from "./pricing-ledger.js";
44
50
  import { resolveSessionPaths } from "./paths.js";
45
51
  import { appendToStream } from "./stream.js";
46
52
 
@@ -55,19 +61,8 @@ function num(value) {
55
61
  return Number.isFinite(v) && v >= 0 ? v : 0;
56
62
  }
57
63
 
58
- function plainObject(value) {
59
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
60
- }
61
-
62
- function firstUsageNumber(payload = {}, keys = []) {
63
- const usage = plainObject(payload.usage);
64
- for (const key of keys) {
65
- const direct = num(payload[key]);
66
- if (direct > 0) return direct;
67
- const nested = num(usage[key]);
68
- if (nested > 0) return nested;
69
- }
70
- return 0;
64
+ function money(value) {
65
+ return Math.round(num(value) * 1_000_000) / 1_000_000;
71
66
  }
72
67
 
73
68
  function clipText(text, max = 4000) {
@@ -76,6 +71,17 @@ function clipText(text, max = 4000) {
76
71
  return `${s.slice(0, max)}…`;
77
72
  }
78
73
 
74
+ function computedCost({ model, inputTokens, outputTokens }) {
75
+ try {
76
+ return {
77
+ costUsd: estimateModelCost({ modelId: model, inputTokens, outputTokens }),
78
+ unpriced: false,
79
+ };
80
+ } catch {
81
+ return { costUsd: 0, unpriced: inputTokens + outputTokens > 0 };
82
+ }
83
+ }
84
+
79
85
  /**
80
86
  * Emit a `session_usage` event into the session's NDJSON stream.
81
87
  *
@@ -87,7 +93,12 @@ function clipText(text, max = 4000) {
87
93
  * @param {number} [params.inputTokens]
88
94
  * @param {number} [params.outputTokens]
89
95
  * @param {number} [params.costUsd]
96
+ * @param {number} [params.customerCostUsd]
90
97
  * @param {number} [params.durationMs]
98
+ * @param {string} [params.action]
99
+ * @param {string} [params.provider]
100
+ * @param {string} [params.billingTier]
101
+ * @param {string} [params.priceBookVersion]
91
102
  * @param {string} [params.prompt] full prompt text (clipped)
92
103
  * @param {string} [params.response] full response text (clipped)
93
104
  * @param {string} [params.interactionId] opaque id for cross-event correlation
@@ -102,8 +113,13 @@ export async function emitLLMInteraction(
102
113
  role = "",
103
114
  inputTokens = 0,
104
115
  outputTokens = 0,
105
- costUsd = 0,
116
+ costUsd = undefined,
117
+ customerCostUsd = undefined,
106
118
  durationMs = 0,
119
+ action = "agent_message",
120
+ provider = "",
121
+ billingTier = "unknown",
122
+ priceBookVersion = DEFAULT_PRICE_BOOK_VERSION,
107
123
  prompt = "",
108
124
  response = "",
109
125
  interactionId = "",
@@ -121,20 +137,44 @@ export async function emitLLMInteraction(
121
137
  const inT = Math.floor(num(inputTokens));
122
138
  const outT = Math.floor(num(outputTokens));
123
139
  const totalT = inT + outT;
124
- const cost = Math.round(num(costUsd) * 1_000_000) / 1_000_000;
140
+ const model = n(agentModel) || "unknown";
141
+ const providedCost = costUsd != null && costUsd !== "";
142
+ const estimate = providedCost
143
+ ? { costUsd: num(costUsd), unpriced: false }
144
+ : computedCost({ model, inputTokens: inT, outputTokens: outT });
145
+ const cost = money(estimate.costUsd);
146
+ const customerCost = customerCostUsd == null || customerCostUsd === "" ? null : money(customerCostUsd);
147
+ const actionName = n(action) || "agent_message";
148
+ const tier = n(billingTier) || "unknown";
149
+ const priceBook = n(priceBookVersion) || DEFAULT_PRICE_BOOK_VERSION;
150
+ const ledgerEntryId = createSessionUsageLedgerId({
151
+ sessionId: paths.sessionId,
152
+ agentId: aid,
153
+ action: actionName,
154
+ idempotencyKey: id,
155
+ });
125
156
 
126
157
  const promptText = clipText(prompt);
127
158
  const responseText = clipText(response);
128
159
 
129
160
  const payload = {
130
161
  interactionId: id,
162
+ idempotencyKey: id,
163
+ ledgerEntryId,
131
164
  agentId: aid,
132
- model: n(agentModel) || "unknown",
165
+ model,
133
166
  role: n(role) || "observer",
167
+ action: actionName,
168
+ provider: n(provider) || undefined,
169
+ billingTier: tier,
170
+ priceBookVersion: priceBook,
134
171
  inputTokens: inT,
135
172
  outputTokens: outT,
136
173
  totalTokens: totalT,
137
174
  costUsd: cost,
175
+ providerCostUsd: cost,
176
+ customerCostUsd: customerCost ?? undefined,
177
+ unpriced: estimate.unpriced,
138
178
  durationMs: Math.max(0, Math.floor(num(durationMs))),
139
179
  prompt: { tokens: inT, chars: promptText.length },
140
180
  response: {
@@ -147,15 +187,24 @@ export async function emitLLMInteraction(
147
187
  usage: {
148
188
  totalTokens: totalT,
149
189
  costUsd: cost,
190
+ providerCostUsd: cost,
191
+ customerCostUsd: customerCost ?? undefined,
150
192
  inputTokens: inT,
151
193
  outputTokens: outT,
194
+ action: actionName,
195
+ provider: n(provider) || undefined,
196
+ billingTier: tier,
197
+ priceBookVersion: priceBook,
198
+ ledgerEntryId,
199
+ idempotencyKey: id,
200
+ unpriced: estimate.unpriced,
152
201
  },
153
202
  };
154
203
 
155
204
  const envelope = createAgentEvent({
156
205
  event: SESSION_USAGE_EVENT,
157
206
  agentId: aid,
158
- agentModel: n(agentModel) || "unknown",
207
+ agentModel: model,
159
208
  sessionId: paths.sessionId,
160
209
  payload,
161
210
  ts,
@@ -165,6 +214,7 @@ export async function emitLLMInteraction(
165
214
  return {
166
215
  event: SESSION_USAGE_EVENT,
167
216
  interactionId: id,
217
+ ledgerEntryId,
168
218
  totalTokens: totalT,
169
219
  costUsd: cost,
170
220
  };
@@ -181,6 +231,7 @@ export async function emitLLMInteraction(
181
231
  * }}
182
232
  */
183
233
  export function aggregateSessionUsage(events = []) {
234
+ const ledger = buildSessionUsageLedger(events);
184
235
  const perAgent = new Map();
185
236
  const totals = {
186
237
  totalTokens: 0,
@@ -189,37 +240,8 @@ export function aggregateSessionUsage(events = []) {
189
240
  costUsd: 0,
190
241
  interactions: 0,
191
242
  };
192
- for (const event of events) {
193
- if (!event || event.event !== SESSION_USAGE_EVENT) continue;
194
- const payload = event.payload || {};
195
- const agentId = n(payload.agentId || event.agent?.id);
196
- if (!agentId) continue;
197
- const model = n(payload.model || event.agent?.model) || "unknown";
198
- const inputTokens = firstUsageNumber(payload, [
199
- "inputTokens",
200
- "input_tokens",
201
- "tokensIn",
202
- "tokens_in",
203
- ]);
204
- const outputTokens = firstUsageNumber(payload, [
205
- "outputTokens",
206
- "output_tokens",
207
- "tokensOut",
208
- "tokens_out",
209
- ]);
210
- const explicitTotalTokens = firstUsageNumber(payload, [
211
- "totalTokens",
212
- "total_tokens",
213
- "tokens",
214
- ]);
215
- const totalTokens = explicitTotalTokens || inputTokens + outputTokens;
216
- const costUsd = firstUsageNumber(payload, [
217
- "costUsd",
218
- "cost_usd",
219
- "providerCostUsd",
220
- "provider_cost_usd",
221
- "cost",
222
- ]);
243
+ for (const entry of ledger.entries) {
244
+ const { agentId, model, inputTokens, outputTokens, totalTokens, providerCostUsd } = entry;
223
245
  if (!perAgent.has(agentId)) {
224
246
  perAgent.set(agentId, {
225
247
  agentId,
@@ -238,13 +260,13 @@ export function aggregateSessionUsage(events = []) {
238
260
  record.totalTokens += totalTokens;
239
261
  record.inputTokens += inputTokens;
240
262
  record.outputTokens += outputTokens;
241
- record.costUsd += costUsd;
263
+ record.costUsd += providerCostUsd;
242
264
  record.interactions += 1;
243
265
 
244
266
  totals.totalTokens += totalTokens;
245
267
  totals.inputTokens += inputTokens;
246
268
  totals.outputTokens += outputTokens;
247
- totals.costUsd += costUsd;
269
+ totals.costUsd += providerCostUsd;
248
270
  totals.interactions += 1;
249
271
  }
250
272
  totals.costUsd = Math.round(totals.costUsd * 1_000_000) / 1_000_000;