@rayadesu/dsh-llm-billing 0.3.5 → 0.3.7

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.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # last confirmed-consistent state. Both languages carry equal authority; after
3
3
  # editing either side, bring the other along and re-record both hashes with:
4
4
  # git hash-object README.md README.zh.md
5
- README.md: ed1a2bc7ef768d789ae4e6ba55c92254104b3a14
6
- README.zh.md: 3c84b79a1e1bc48fa43f8577bd81853f658a7087
5
+ README.md: 3c0ae19286d6935190659ec1e1c93b7b63ab5c14
6
+ README.zh.md: 08adb0d018b376fb5fa995b29cbb6790063f4814
package/README.md CHANGED
@@ -31,7 +31,13 @@ Note: the projection path prices a session's history once, at the rates in effec
31
31
 
32
32
  ## Forked sessions
33
33
 
34
- A forked session (DSH's "fork" of a conversation) opens its log with a verbatim copy of its source session's events. Without special handling, the same model outputs would be billed once per copy: the child's session spend would include the inherited prefix, and today's spend would count it a second time alongside the parent's. The plugin prices only a session's OWN events — the durable `header.seedLength` is the fork boundary, and every event with `seq < seedLength` is treated as already billed in the source session. Fork children are therefore billed from their first new exchange onward (a freshly forked session prices to zero), today's spend counts each model output exactly once, and the same lineage-safe rule covers multi-generation forks and subagent forks (spawned with `context: 'fork'`). The boundary is the persisted header value, so a resumed fork child keeps its original boundary, while a session created without a seed — ordinary sessions and cold resumes included — carries no boundary and is billed in full.
34
+ A forked session (DSH's "fork" of a conversation) opens its log with a verbatim copy of its source session's events. Without special handling, the same model outputs would be billed once per copy: the child's session spend would include the inherited prefix, and today's spend would count it a second time alongside the parent's. The plugin prices only a session's OWN events — the fork boundary is the session's persisted state (`header.seedLength` on the 0.1.1-rc.2 runtime, `Session.inheritedEventCount` / `inspect().inheritedEventCount` on 0.1.2-alpha.4+, both read structurally), and every event with `seq < boundary` is treated as already billed in the source session. Fork children are therefore billed from their first new exchange onward (a freshly forked session prices to zero), today's spend counts each model output exactly once, and the same lineage-safe rule covers multi-generation forks and subagent forks (spawned with `context: 'fork'`). The boundary is the persisted value, so a resumed fork child keeps its original boundary, while a session created without a seed — ordinary sessions and cold resumes included — carries no boundary and is billed in full.
35
+
36
+ ## Runtime compatibility
37
+
38
+ Since 0.1.2-alpha.4, DSH replaced the live `Session` log surface `Session.events` with `Session.snapshotEvents()` (no args = the full current log) and `Session.ownEvents()`, and moved `SessionHeader.seedLength` to `Session.inheritedEventCount` (the persistence `inspect()` result carries the value beside `meta`; `listSnapshots()` headers keep only the boolean `isSeeded`). Every log read in the plugin goes through the structural adapters `liveSessionEvents` / `forkBoundaryOf` / `isSeededSession`, which accept both the ≤ 0.1.1-rc.2 and the 0.1.2-alpha.4+ shapes — the npm release baseline (`^0.1.2-alpha.5`) and the ahead-of-npm monorepo runtime both work without modification. On an unknown surface that has neither shape the plugin fails loudly rather than silently pricing an empty log.
39
+
40
+ The persistence service itself changed surface too: 0.1.1-rc.2 exposes `inspect(id)` / `listSnapshots()`, while the 0.1.2-alpha.5 handle-based seam (in the monorepo after the released build) exposes `open(id, 'read')` + `SessionHandle.read()` / `list()`. The scanner reads both families through `persistenceInspect` / `persistenceListSnapshots` (the handle is always closed, including after a failed read), so the same plugin serves the published alpha line and the refactored checkout.
35
41
 
36
42
  ## Configuration
37
43
 
package/README.zh.md CHANGED
@@ -31,7 +31,13 @@
31
31
 
32
32
  ## 分叉会话
33
33
 
34
- 分叉会话(DSH 的「分叉会话」)的日志以来源会话事件的逐字节副本开头。若不特殊处理,同一批模型输出会按副本数重复计费:子会话的会话花费会包含继承前缀,今日花费也会在父会话之外再计一次。插件只对会话的**自有事件**计费——持久的 `header.seedLength` 即分叉边界,凡是 `seq < seedLength` 的事件都视为已在来源会话计费。因此分叉子会话从分叉后的第一次新交流开始计费(刚分叉的会话花费为零),今日花费对每个模型输出只计一次,同一血缘规则同样覆盖多代分叉与 subagent 分叉(`context: 'fork'` 生成)。边界取自已持久化的 header 值,所以恢复后的分叉子会话保持原边界;而创建时没有 seed 的会话——包括普通会话与冷恢复——不带边界,正常全额计费。
34
+ 分叉会话(DSH 的「分叉会话」)的日志以来源会话事件的逐字节副本开头。若不特殊处理,同一批模型输出会按副本数重复计费:子会话的会话花费会包含继承前缀,今日花费也会在父会话之外再计一次。插件只对会话的**自有事件**计费——分叉边界取自已持久化的会话状态(≤ 0.1.1-rc.2 运行时为 `header.seedLength`;0.1.2-alpha.4+ 运行时为 `Session.inheritedEventCount` / `inspect().inheritedEventCount`,两者都以结构方式读取),凡是 `seq < 边界` 的事件都视为已在来源会话计费。因此分叉子会话从分叉后的第一次新交流开始计费(刚分叉的会话花费为零),今日花费对每个模型输出只计一次,同一血缘规则同样覆盖多代分叉与 subagent 分叉(`context: 'fork'` 生成)。边界取自已持久化的值,所以恢复后的分叉子会话保持原边界;而创建时没有 seed 的会话——包括普通会话与冷恢复——不带边界,正常全额计费。
35
+
36
+ ## 运行时兼容性
37
+
38
+ 0.1.2-alpha.4 起,DSH 把 live `Session` 的日志读取表面从 `Session.events` 改为 `Session.snapshotEvents()`(无参 = 当前全量日志)与 `Session.ownEvents()`,并把 `SessionHeader.seedLength` 移至 `Session.inheritedEventCount`(持久化侧 `inspect()` 的结果在 `meta` 之外携带该值,`listSnapshots()` 的 header 只剩布尔 `isSeeded`)。插件的所有日志读取都走结构适配器 `liveSessionEvents` / `forkBoundaryOf` / `isSeededSession`,同时接受 ≤ 0.1.1-rc.2 与 0.1.2-alpha.4+ 两种形状——npm 发布基线(`^0.1.2-alpha.5`)与超前于它的 monorepo 运行时代码均无需改动即可工作。遇到两种形状都没有的未知运行时表面时,插件会显式失败而不是静默按零花费计价。
39
+
40
+ 持久化服务的表面同样换代:0.1.1-rc.2 提供 `inspect(id)` / `listSnapshots()`,而 handle 化的 0.1.2-alpha.5 seam(位于已发布构建之后的 monorepo 中)提供 `open(id, 'read')` + `SessionHandle.read()` / `list()`。扫描器通过 `persistenceInspect` / `persistenceListSnapshots` 同时读取两代表面(handle 总会关闭,读取失败时也一样),因此同一套插件既能服务已发布的 alpha 线,也能服务重构后的 checkout。
35
41
 
36
42
  ## 配置
37
43
 
package/lib/index.js CHANGED
@@ -380,20 +380,28 @@ function beijingDayKey(now) {
380
380
  return beijingParts(now.getTime()).dayKey;
381
381
  }
382
382
  /**
383
- * The durable inherited-prefix boundary of one session: its header's
384
- * `seedLength`, or 0 for a session created without a seed. A forked session
385
- * (or any seeded replay) carries the number of events it inherited verbatim
386
- * from its source session in its header; every event with `seq < seedLength`
387
- * is a copy of an event already billed in that source session, so pricing
388
- * must skip them or the same model output is counted once per copy.
389
- * The value is the PERSISTED header field (not the in-memory constructor
390
- * seed length), so a resumed fork child keeps its original boundary and a
391
- * resume of an unseeded session stays at 0.
392
- * @param header - the session's durable header, when available.
383
+ * The durable inherited-prefix boundary of one session: the number of leading
384
+ * events it inherited verbatim from its fork source, 0 for a session created
385
+ * without a seed. A forked session (or any seeded replay) carries that count
386
+ * in its session state; every event with `seq < seedLength` is a copy of an
387
+ * event already billed in that source session, so pricing must skip them or
388
+ * the same model output is counted once per copy. Accepts the durable field
389
+ * of both DSH runtime families (see {@link ForkBoundarySource}).
390
+ * @param source - the session, inspection result, header slice, or durable
391
+ * header carrying the boundary; `undefined` reads as 0.
393
392
  * @returns the inherited-prefix length; 0 for an unseeded session.
394
393
  */
395
- function forkBoundaryOf(header) {
396
- return header?.seedLength ?? 0;
394
+ function forkBoundaryOf(source) {
395
+ if (source === void 0) return 0;
396
+ const inherited = source.inheritedEventCount;
397
+ if (inherited !== void 0 && Number.isSafeInteger(inherited)) return inherited;
398
+ return source.header?.seedLength ?? source.meta?.seedLength ?? source.seedLength ?? 0;
399
+ }
400
+ /** Whether a durable header marks a fork-inherited (seeded) session across both runtime families. */
401
+ function isSeededSession(header) {
402
+ if (header === void 0) return false;
403
+ if (header.isSeeded === true) return true;
404
+ return (header.seedLength ?? 0) > 0;
397
405
  }
398
406
  /** Whether a Beijing (hour, weekday) pair falls inside any peak-hour window. */
399
407
  function isPeakParts(billing, hour, weekday) {
@@ -782,14 +790,22 @@ function foldOwnBilling(unit, events, seedLength = 0) {
782
790
  * unchanged log provably cannot change the aggregate.
783
791
  *
784
792
  * Forked sessions never double-count: a fork child's log opens with a
785
- * verbatim copy of its source session's events (`header.seedLength` of them),
786
- * so the scanner prices only the child's OWN events (`seq >= seedLength`) on
787
- * every path — the projection path bypasses the eager cell for a seeded
788
- * session and folds its own events instead (the cell covers the inherited
789
- * prefix too), and the cold ladder skips the projection cache for a seeded
790
- * session (its cached row predates the boundary and covers inherited events).
791
- * The boundary is the durable session header, so a resumed fork child keeps
792
- * its original boundary and an unseeded session stays at 0.
793
+ * verbatim copy of its source session's events (its inherited boundary),
794
+ * so the scanner prices only the child's OWN events on every path — the
795
+ * projection path bypasses the eager cell for a seeded session and folds its
796
+ * own events instead (the cell covers the inherited prefix too), and the cold
797
+ * ladder skips the projection cache for a seeded session (its cached row
798
+ * predates the boundary and covers inherited events). The boundary is the
799
+ * durable session state, read across both DSH runtime families — a resumed
800
+ * fork child keeps its original boundary and an unseeded session stays at 0.
801
+ *
802
+ * The live `Session` log surface changed in 0.1.2-alpha.4: `Session.events`
803
+ * was removed and replaced by `Session.snapshotEvents()` / `ownEvents()`, and
804
+ * `SessionHeader.seedLength` moved to `Session.inheritedEventCount` (the
805
+ * persistence `inspect` result carries it alongside `meta`). Reads go through
806
+ * {@link liveSessionEvents} / {@link forkBoundaryOf}, which accept both
807
+ * families structurally, so the scanner runs on the ≤ 0.1.1-rc.2 npm baseline
808
+ * and on the newer runtime.
793
809
  * @module @rayadesu/dsh-llm-billing/today-spend
794
810
  */
795
811
  /**
@@ -810,6 +826,56 @@ function foldSessionTitle(events) {
810
826
  return null;
811
827
  }
812
828
  /**
829
+ * Read one live session's complete event log across both runtime families.
830
+ * @throws when the session exposes neither the legacy `events` snapshot nor
831
+ * the newer `snapshotEvents()` reader — an unknown runtime surface must
832
+ * fail loudly rather than silently price an empty log.
833
+ */
834
+ function liveSessionEvents(session) {
835
+ if (session.events !== void 0) return session.events;
836
+ if (session.snapshotEvents !== void 0) return session.snapshotEvents();
837
+ throw new Error("llm-billing: session log surface is neither Session.events nor Session.snapshotEvents");
838
+ }
839
+ function isHandlePersistence(persistence) {
840
+ return typeof persistence.open === "function";
841
+ }
842
+ /**
843
+ * List every stored session snapshot across both persistence runtime families:
844
+ * `listSnapshots` (≤ 0.1.1-rc.2) or `list` (0.1.2-alpha.5+).
845
+ * @param persistence - the persistence service slice.
846
+ * @returns one snapshot per stored session.
847
+ */
848
+ function persistenceListSnapshots(persistence) {
849
+ return isHandlePersistence(persistence) ? persistence.list() : persistence.listSnapshots();
850
+ }
851
+ /**
852
+ * Read one stored session's complete event log and durable inherited boundary
853
+ * across both persistence runtime families: legacy `inspect` (≤ 0.1.1-rc.2)
854
+ * or `open` + handle `read` (0.1.2-alpha.5+; the handle is closed after the
855
+ * read). Both throw when the session does not exist.
856
+ * @param persistence - the persistence service slice.
857
+ * @param id - the stored session to read.
858
+ * @returns the session's complete event log plus its inherited-prefix boundary.
859
+ */
860
+ async function persistenceInspect(persistence, id) {
861
+ if (isHandlePersistence(persistence)) {
862
+ const handle = await persistence.open(id, "read");
863
+ try {
864
+ return {
865
+ events: await handle.read(),
866
+ seedLength: forkBoundaryOf(handle)
867
+ };
868
+ } finally {
869
+ await handle.close();
870
+ }
871
+ }
872
+ const inspection = await persistence.inspect(id);
873
+ return {
874
+ events: inspection.events,
875
+ seedLength: forkBoundaryOf(inspection)
876
+ };
877
+ }
878
+ /**
813
879
  * Bounded parallel fan-out: run `run` over `items` with at most `limit` in
814
880
  * flight. A shared index counter hands each worker its next job, so the
815
881
  * dispatch is O(n) overall (array `shift()` would be O(n) per pop).
@@ -923,16 +989,19 @@ var TodaySpendScanner = class {
923
989
  * until the session is inspected again. A SEEDED session (fork child)
924
990
  * skips the ladder entirely: its cached row was folded over the inherited
925
991
  * prefix too, so it always detaches through inspect with the durable
926
- * boundary applied to the local fold.
992
+ * boundary (the inspect result's inherited count or `meta.seedLength`,
993
+ * depending on the runtime family) applied to the local fold.
927
994
  * @param id - the cold session's id.
928
- * @param seedLength - the durable inherited-prefix boundary (0 for unseeded).
995
+ * @param seeded - whether the session carries a fork-inherited prefix
996
+ * (from the snapshot header: `isSeeded` on 0.1.2-alpha.4+, `seedLength`
997
+ * at and before the 0.1.1-rc.2 baseline).
929
998
  * @returns the resolved state and title, or `undefined` when unreadable.
930
999
  */
931
- async resolveCold(id, seedLength) {
932
- const { persistence, projectionCache, unit, logger } = this.deps;
1000
+ async resolveCold(id, seeded) {
1001
+ const { persistence, projectionCache, logger } = this.deps;
933
1002
  const persistenceService = persistence?.();
934
1003
  if (persistenceService === void 0) return void 0;
935
- if (seedLength <= 0) {
1004
+ if (!seeded) {
936
1005
  const cache = projectionCache?.();
937
1006
  if (cache !== void 0) try {
938
1007
  const value = (await cache.coldSnapshot(id)).values[BILLING_UNIT_KEY];
@@ -945,10 +1014,10 @@ var TodaySpendScanner = class {
945
1014
  }
946
1015
  }
947
1016
  try {
948
- const inspection = await persistenceService.inspect(id);
1017
+ const read = await persistenceInspect(persistenceService, id);
949
1018
  return {
950
- value: foldOwnBilling(unit, inspection.events, seedLength),
951
- title: foldSessionTitle(inspection.events)
1019
+ value: foldOwnBilling(this.deps.unit, read.events, read.seedLength),
1020
+ title: foldSessionTitle(read.events)
952
1021
  };
953
1022
  } catch (error) {
954
1023
  logger.warn(`llm-billing: skipping unreadable session ${id}: ${String(error)}`);
@@ -992,18 +1061,18 @@ var TodaySpendScanner = class {
992
1061
  const store = sessions();
993
1062
  if (store !== void 0) for (const session of store.list()) {
994
1063
  liveIds.add(session.id);
995
- const seedLength = forkBoundaryOf(session.header);
996
- const state = seedLength > 0 ? this.ownBillingState(session.id, session.events, seedLength) : projectionsService?.stateOf(session, BILLING_UNIT_KEY);
1064
+ const seedLength = forkBoundaryOf(session);
1065
+ const state = seedLength > 0 ? this.ownBillingState(session.id, liveSessionEvents(session), seedLength) : projectionsService?.stateOf(session, BILLING_UNIT_KEY);
997
1066
  if (state !== void 0 && state.dayKey === dayKey) total = mergeTodaySpend(total, state.spend);
998
1067
  }
999
1068
  }
1000
1069
  const persistenceService = persistence?.();
1001
1070
  if (persistenceService === void 0) return total;
1002
- const snapshots = await persistenceService.listSnapshots();
1071
+ const snapshots = await persistenceListSnapshots(persistenceService);
1003
1072
  const pending = [];
1004
1073
  for (const { header, revision } of snapshots) {
1005
1074
  if (liveIds.has(header.id)) continue;
1006
- const seedLength = forkBoundaryOf(header);
1075
+ const seeded = isSeededSession(header);
1007
1076
  const resolved = this.coldResolved.get(header.id);
1008
1077
  if (resolved !== void 0 && resolved.revision === revision) {
1009
1078
  if (resolved.value.dayKey === dayKey) total = mergeTodaySpend(total, resolved.value.spend);
@@ -1012,11 +1081,11 @@ var TodaySpendScanner = class {
1012
1081
  pending.push({
1013
1082
  id: header.id,
1014
1083
  revision,
1015
- seedLength
1084
+ seeded
1016
1085
  });
1017
1086
  }
1018
- await withConcurrency(pending, 8, async ({ id, revision, seedLength }) => {
1019
- const resolved = await this.resolveCold(id, seedLength);
1087
+ await withConcurrency(pending, 8, async ({ id, revision, seeded }) => {
1088
+ const resolved = await this.resolveCold(id, seeded);
1020
1089
  if (resolved !== void 0) this.coldResolved.set(id, {
1021
1090
  revision,
1022
1091
  ...resolved
@@ -1058,19 +1127,19 @@ var TodaySpendScanner = class {
1058
1127
  const store = sessions();
1059
1128
  if (store !== void 0) for (const session of store.list()) {
1060
1129
  liveIds.add(session.id);
1061
- collect(session.events, forkBoundaryOf(session.header));
1130
+ collect(liveSessionEvents(session), forkBoundaryOf(session));
1062
1131
  if (truncated) break;
1063
1132
  }
1064
1133
  }
1065
1134
  const persistenceService = persistence?.();
1066
1135
  if (!truncated && persistenceService !== void 0) {
1067
- const snapshots = await persistenceService.listSnapshots();
1136
+ const snapshots = await persistenceListSnapshots(persistenceService);
1068
1137
  for (const { header, revision } of snapshots) {
1069
1138
  if (liveIds.has(header.id)) continue;
1070
1139
  if (this.lastEventsScan?.get(header.id) === revision) continue;
1071
1140
  try {
1072
- const inspection = await persistenceService.inspect(header.id);
1073
- collect(inspection.events, forkBoundaryOf(inspection.meta));
1141
+ const read = await persistenceInspect(persistenceService, header.id);
1142
+ collect(read.events, read.seedLength);
1074
1143
  } catch (error) {
1075
1144
  logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
1076
1145
  }
@@ -1099,22 +1168,23 @@ var TodaySpendScanner = class {
1099
1168
  const store = sessions();
1100
1169
  if (store !== void 0) for (const session of store.list()) {
1101
1170
  liveIds.add(session.id);
1102
- const seedLength = forkBoundaryOf(session.header);
1103
- const state = seedLength > 0 ? this.ownBillingState(session.id, session.events, seedLength) : projectionsService?.stateOf(session, BILLING_UNIT_KEY);
1171
+ const seedLength = forkBoundaryOf(session);
1172
+ const events = liveSessionEvents(session);
1173
+ const state = seedLength > 0 ? this.ownBillingState(session.id, events, seedLength) : projectionsService?.stateOf(session, BILLING_UNIT_KEY);
1104
1174
  if (state !== void 0 && state.dayKey === dayKey) rows.set(session.id, {
1105
1175
  sessionId: session.id,
1106
- title: foldSessionTitle(session.events),
1176
+ title: foldSessionTitle(events),
1107
1177
  total: state.spend.total
1108
1178
  });
1109
1179
  }
1110
1180
  }
1111
1181
  const persistenceService = persistence?.();
1112
1182
  if (persistenceService === void 0) return [...rows.values()];
1113
- const snapshots = await persistenceService.listSnapshots();
1183
+ const snapshots = await persistenceListSnapshots(persistenceService);
1114
1184
  const pending = [];
1115
1185
  for (const { header, revision } of snapshots) {
1116
1186
  if (liveIds.has(header.id)) continue;
1117
- const seedLength = forkBoundaryOf(header);
1187
+ const seeded = isSeededSession(header);
1118
1188
  const resolved = this.coldResolved.get(header.id);
1119
1189
  if (resolved !== void 0 && resolved.revision === revision) {
1120
1190
  if (resolved.value.dayKey === dayKey) rows.set(header.id, {
@@ -1127,11 +1197,11 @@ var TodaySpendScanner = class {
1127
1197
  pending.push({
1128
1198
  id: header.id,
1129
1199
  revision,
1130
- seedLength
1200
+ seeded
1131
1201
  });
1132
1202
  }
1133
- await withConcurrency(pending, 8, async ({ id, revision, seedLength }) => {
1134
- const resolved = await this.resolveCold(id, seedLength);
1203
+ await withConcurrency(pending, 8, async ({ id, revision, seeded }) => {
1204
+ const resolved = await this.resolveCold(id, seeded);
1135
1205
  if (resolved !== void 0) this.coldResolved.set(id, {
1136
1206
  revision,
1137
1207
  ...resolved
@@ -1190,19 +1260,19 @@ var TodaySpendScanner = class {
1190
1260
  const store = sessions();
1191
1261
  if (store !== void 0) for (const session of store.list()) {
1192
1262
  liveIds.add(session.id);
1193
- collect(session.id, session.events, forkBoundaryOf(session.header));
1263
+ collect(session.id, liveSessionEvents(session), forkBoundaryOf(session));
1194
1264
  if (truncated) break;
1195
1265
  }
1196
1266
  }
1197
1267
  const persistenceService = persistence?.();
1198
1268
  if (!truncated && persistenceService !== void 0) {
1199
- const snapshots = await persistenceService.listSnapshots();
1269
+ const snapshots = await persistenceListSnapshots(persistenceService);
1200
1270
  for (const { header, revision } of snapshots) {
1201
1271
  if (liveIds.has(header.id)) continue;
1202
1272
  if (this.lastEventsScan?.get(header.id) === revision) continue;
1203
1273
  try {
1204
- const inspection = await persistenceService.inspect(header.id);
1205
- collect(header.id, inspection.events, forkBoundaryOf(inspection.meta));
1274
+ const read = await persistenceInspect(persistenceService, header.id);
1275
+ collect(header.id, read.events, read.seedLength);
1206
1276
  } catch (error) {
1207
1277
  logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
1208
1278
  }
@@ -1294,7 +1364,10 @@ const TODAY_SPEND_MAX_EVENTS = 2e5;
1294
1364
  /**
1295
1365
  * Read one session's event log and durable seed boundary: the live
1296
1366
  * SessionStore first, then the persistence backend for a flushed session
1297
- * (inspected directly by id — no header listing).
1367
+ * (inspected directly by id — no header listing). The live-session surface
1368
+ * is read structurally across both DSH runtime families — `Session.events`
1369
+ * (≤ 0.1.1-rc.2) or `Session.snapshotEvents()` + `Session.inheritedEventCount`
1370
+ * (0.1.2-alpha.4+) — via {@link liveSessionEvents} / {@link forkBoundaryOf}.
1298
1371
  * @param ctx - plugin context carrying the SessionStore and optional persistence.
1299
1372
  * @param sessionId - the session to read.
1300
1373
  * @returns the session's complete event log plus its inherited-prefix boundary.
@@ -1303,16 +1376,12 @@ const TODAY_SPEND_MAX_EVENTS = 2e5;
1303
1376
  async function sessionEvents(ctx, sessionId) {
1304
1377
  const live = ctx.get("sessions")?.get(sessionId);
1305
1378
  if (live !== void 0) return {
1306
- events: live.events,
1307
- seedLength: forkBoundaryOf(live.header)
1379
+ events: liveSessionEvents(live),
1380
+ seedLength: forkBoundaryOf(live)
1308
1381
  };
1309
1382
  const persistence = ctx.get("sessionPersistence");
1310
1383
  if (persistence !== void 0) try {
1311
- const inspection = await persistence.inspect(sessionId);
1312
- return {
1313
- events: inspection.events,
1314
- seedLength: forkBoundaryOf(inspection.meta)
1315
- };
1384
+ return await persistenceInspect(persistence, sessionId);
1316
1385
  } catch (error) {
1317
1386
  throw new LlmError(`llm-billing: session ${sessionId} not found`, "NOT_FOUND", { cause: error });
1318
1387
  }
@@ -1406,4 +1475,4 @@ function apply(ctx, config) {
1406
1475
  });
1407
1476
  }
1408
1477
  //#endregion
1409
- export { BILLING_UNIT_KEY, Config, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, DeepSeekBalanceGateway, PUBLIC_BASE_URL, SpendAccumulator, TODAY_SPEND_CACHE_MS, TODAY_SPEND_MAX_EVENTS, TodaySpendCache, TodaySpendScanner, addEventContribution, apply, beijingDayKey, billingTodaySpendDefinition, computeSessionSpend, computeTodaySpend, computeTurnSpend, emptyTodaySpend, fetchDeepSeekBalance, foldBillingUnit, foldOwnBilling, foldSessionTitle, forkBoundaryOf, isPeak, mergeTodaySpend, name, parseDeepSeekBalance, priceEvent, resolveBilling };
1478
+ export { BILLING_UNIT_KEY, Config, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, DeepSeekBalanceGateway, PUBLIC_BASE_URL, SpendAccumulator, TODAY_SPEND_CACHE_MS, TODAY_SPEND_MAX_EVENTS, TodaySpendCache, TodaySpendScanner, addEventContribution, apply, beijingDayKey, billingTodaySpendDefinition, computeSessionSpend, computeTodaySpend, computeTurnSpend, emptyTodaySpend, fetchDeepSeekBalance, foldBillingUnit, foldOwnBilling, foldSessionTitle, forkBoundaryOf, isPeak, isSeededSession, liveSessionEvents, mergeTodaySpend, name, parseDeepSeekBalance, persistenceInspect, persistenceListSnapshots, priceEvent, resolveBilling };
@@ -88,21 +88,54 @@ export declare function resolveBilling(config: BillingConfig | undefined): Resol
88
88
  /** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
89
89
  export declare function beijingDayKey(now: Date): string;
90
90
  /**
91
- * The durable inherited-prefix boundary of one session: its header's
92
- * `seedLength`, or 0 for a session created without a seed. A forked session
93
- * (or any seeded replay) carries the number of events it inherited verbatim
94
- * from its source session in its header; every event with `seq < seedLength`
95
- * is a copy of an event already billed in that source session, so pricing
96
- * must skip them or the same model output is counted once per copy.
97
- * The value is the PERSISTED header field (not the in-memory constructor
98
- * seed length), so a resumed fork child keeps its original boundary and a
99
- * resume of an unseeded session stays at 0.
100
- * @param header - the session's durable header, when available.
91
+ * Structural source of a session's durable inherited-prefix boundary. The
92
+ * field moved between DSH runtimes:
93
+ *
94
+ * - since 0.1.2-alpha.4, `SessionHeader.seedLength` was removed (the header
95
+ * now carries only `isSeeded`: boolean) and the exact cut moved to
96
+ * `Session.inheritedEventCount` / `SessionInspection.inheritedEventCount`;
97
+ * - at and before the 0.1.1-rc.2 npm baseline, the cut lived on the durable
98
+ * header as `seedLength` (optional; absent for an unseeded session) and on
99
+ * the persistence `inspect` result as `meta.seedLength`.
100
+ *
101
+ * The reader accepts any of the three shapes and prefers the newer,
102
+ * exact-count field, so the same plugin code prices correctly on both
103
+ * families of runtime.
104
+ */
105
+ export interface ForkBoundarySource {
106
+ /** 0.1.2-alpha.4+: `Session.inheritedEventCount` / `SessionInspection.inheritedEventCount`. */
107
+ readonly inheritedEventCount?: number;
108
+ /** 0.1.1-rc.2 and earlier: `SessionHeader.seedLength`. */
109
+ readonly seedLength?: number;
110
+ /** ≤ 0.1.1-rc.2: the header slice of a `Session` (its `header.seedLength`). */
111
+ readonly header?: {
112
+ readonly seedLength?: number;
113
+ readonly isSeeded?: boolean;
114
+ };
115
+ /** ≤ 0.1.1-rc.2: the `meta` of a persistence `inspect` result (its `seedLength`). */
116
+ readonly meta?: {
117
+ readonly seedLength?: number;
118
+ readonly isSeeded?: boolean;
119
+ };
120
+ }
121
+ /**
122
+ * The durable inherited-prefix boundary of one session: the number of leading
123
+ * events it inherited verbatim from its fork source, 0 for a session created
124
+ * without a seed. A forked session (or any seeded replay) carries that count
125
+ * in its session state; every event with `seq < seedLength` is a copy of an
126
+ * event already billed in that source session, so pricing must skip them or
127
+ * the same model output is counted once per copy. Accepts the durable field
128
+ * of both DSH runtime families (see {@link ForkBoundarySource}).
129
+ * @param source - the session, inspection result, header slice, or durable
130
+ * header carrying the boundary; `undefined` reads as 0.
101
131
  * @returns the inherited-prefix length; 0 for an unseeded session.
102
132
  */
103
- export declare function forkBoundaryOf(header: {
133
+ export declare function forkBoundaryOf(source: ForkBoundarySource | undefined): number;
134
+ /** Whether a durable header marks a fork-inherited (seeded) session across both runtime families. */
135
+ export declare function isSeededSession(header: {
104
136
  readonly seedLength?: number;
105
- } | undefined): number;
137
+ readonly isSeeded?: boolean;
138
+ } | undefined): boolean;
106
139
  /**
107
140
  * Whether a timestamp falls inside any peak-hour window (Beijing time,
108
141
  * weekdays Monday–Friday only). Weekends (Saturday and Sunday) are always
@@ -81,20 +81,32 @@ export function beijingDayKey(now) {
81
81
  return beijingParts(now.getTime()).dayKey;
82
82
  }
83
83
  /**
84
- * The durable inherited-prefix boundary of one session: its header's
85
- * `seedLength`, or 0 for a session created without a seed. A forked session
86
- * (or any seeded replay) carries the number of events it inherited verbatim
87
- * from its source session in its header; every event with `seq < seedLength`
88
- * is a copy of an event already billed in that source session, so pricing
89
- * must skip them or the same model output is counted once per copy.
90
- * The value is the PERSISTED header field (not the in-memory constructor
91
- * seed length), so a resumed fork child keeps its original boundary and a
92
- * resume of an unseeded session stays at 0.
93
- * @param header - the session's durable header, when available.
84
+ * The durable inherited-prefix boundary of one session: the number of leading
85
+ * events it inherited verbatim from its fork source, 0 for a session created
86
+ * without a seed. A forked session (or any seeded replay) carries that count
87
+ * in its session state; every event with `seq < seedLength` is a copy of an
88
+ * event already billed in that source session, so pricing must skip them or
89
+ * the same model output is counted once per copy. Accepts the durable field
90
+ * of both DSH runtime families (see {@link ForkBoundarySource}).
91
+ * @param source - the session, inspection result, header slice, or durable
92
+ * header carrying the boundary; `undefined` reads as 0.
94
93
  * @returns the inherited-prefix length; 0 for an unseeded session.
95
94
  */
96
- export function forkBoundaryOf(header) {
97
- return header?.seedLength ?? 0;
95
+ export function forkBoundaryOf(source) {
96
+ if (source === undefined)
97
+ return 0;
98
+ const inherited = source.inheritedEventCount;
99
+ if (inherited !== undefined && Number.isSafeInteger(inherited))
100
+ return inherited;
101
+ return source.header?.seedLength ?? source.meta?.seedLength ?? source.seedLength ?? 0;
102
+ }
103
+ /** Whether a durable header marks a fork-inherited (seeded) session across both runtime families. */
104
+ export function isSeededSession(header) {
105
+ if (header === undefined)
106
+ return false;
107
+ if (header.isSeeded === true)
108
+ return true;
109
+ return (header.seedLength ?? 0) > 0;
98
110
  }
99
111
  /** Whether a Beijing (hour, weekday) pair falls inside any peak-hour window. */
100
112
  function isPeakParts(billing, hour, weekday) {
@@ -24,13 +24,13 @@ import type { Context } from '@deepseek-ai/cordis';
24
24
  import z from '@deepseek-ai/schemastery';
25
25
  import type { BillingConfig } from './billing.ts';
26
26
  export { DeepSeekBalanceGateway, fetchDeepSeekBalance, parseDeepSeekBalance } from './balance.ts';
27
- export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, forkBoundaryOf, isPeak, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from './billing.ts';
27
+ export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, forkBoundaryOf, isPeak, isSeededSession, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from './billing.ts';
28
28
  export type { BillingConfig, BillingConfigModel, BillingEventContribution, DeepSeekModelPricing, DeepSeekTokenPrice, PeakHourWindow, ResolvedBilling, } from './billing.ts';
29
29
  export type * from './types.ts';
30
30
  export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit, foldOwnBilling } from './projection.ts';
31
- export type { BillingUnitState } from './projection.ts';
32
- export { foldSessionTitle, TodaySpendCache, TodaySpendScanner } from './today-spend.ts';
33
- export type { ScannerPersistedHeader, ScannerSession, TodaySpendScannerDeps } from './today-spend.ts';
31
+ export type { BillingUnitFold, BillingUnitState } from './projection.ts';
32
+ export { foldSessionTitle, liveSessionEvents, persistenceInspect, persistenceListSnapshots, TodaySpendCache, TodaySpendScanner } from './today-spend.ts';
33
+ export type { ScannerPersistedHeader, ScannerPersistence, ScannerPersistenceHandle, ScannerPersistenceLegacy, ScannerPersistedRead, ScannerSession, TodaySpendScannerDeps } from './today-spend.ts';
34
34
  export declare const name = "llm-billing";
35
35
  /** Public API default; deployments may point elsewhere via $DEEPSEEK_BASE_URL. */
36
36
  export declare const PUBLIC_BASE_URL = "https://api.deepseek.com";
@@ -27,11 +27,11 @@ import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
27
27
  import { DeepSeekBalanceGateway, fetchDeepSeekBalance } from "./balance.js";
28
28
  import { computeSessionSpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, forkBoundaryOf, mergeTodaySpend, resolveBilling, } from "./billing.js";
29
29
  import { billingTodaySpendDefinition } from "./projection.js";
30
- import { TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
30
+ import { liveSessionEvents, persistenceInspect, TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
31
31
  export { DeepSeekBalanceGateway, fetchDeepSeekBalance, parseDeepSeekBalance } from "./balance.js";
32
- export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, forkBoundaryOf, isPeak, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from "./billing.js";
32
+ export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, forkBoundaryOf, isPeak, isSeededSession, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from "./billing.js";
33
33
  export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit, foldOwnBilling } from "./projection.js";
34
- export { foldSessionTitle, TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
34
+ export { foldSessionTitle, liveSessionEvents, persistenceInspect, persistenceListSnapshots, TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
35
35
  export const name = 'llm-billing';
36
36
  const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY';
37
37
  const BASE_URL_ENV = 'DEEPSEEK_BASE_URL';
@@ -75,7 +75,10 @@ export const TODAY_SPEND_MAX_EVENTS = 200_000;
75
75
  /**
76
76
  * Read one session's event log and durable seed boundary: the live
77
77
  * SessionStore first, then the persistence backend for a flushed session
78
- * (inspected directly by id — no header listing).
78
+ * (inspected directly by id — no header listing). The live-session surface
79
+ * is read structurally across both DSH runtime families — `Session.events`
80
+ * (≤ 0.1.1-rc.2) or `Session.snapshotEvents()` + `Session.inheritedEventCount`
81
+ * (0.1.2-alpha.4+) — via {@link liveSessionEvents} / {@link forkBoundaryOf}.
79
82
  * @param ctx - plugin context carrying the SessionStore and optional persistence.
80
83
  * @param sessionId - the session to read.
81
84
  * @returns the session's complete event log plus its inherited-prefix boundary.
@@ -84,13 +87,13 @@ export const TODAY_SPEND_MAX_EVENTS = 200_000;
84
87
  async function sessionEvents(ctx, sessionId) {
85
88
  const sessions = ctx.get('sessions');
86
89
  const live = sessions?.get(sessionId);
87
- if (live !== undefined)
88
- return { events: live.events, seedLength: forkBoundaryOf(live.header) };
90
+ if (live !== undefined) {
91
+ return { events: liveSessionEvents(live), seedLength: forkBoundaryOf(live) };
92
+ }
89
93
  const persistence = ctx.get('sessionPersistence');
90
94
  if (persistence !== undefined) {
91
95
  try {
92
- const inspection = await persistence.inspect(sessionId);
93
- return { events: inspection.events, seedLength: forkBoundaryOf(inspection.meta) };
96
+ return await persistenceInspect(persistence, sessionId);
94
97
  }
95
98
  catch (error) {
96
99
  throw new LlmError(`llm-billing: session ${sessionId} not found`, 'NOT_FOUND', { cause: error });
@@ -64,8 +64,20 @@ export declare function billingTodaySpendDefinition(billing: ResolvedBilling, ca
64
64
  id: string;
65
65
  name: string;
66
66
  }[]): BillingUnitDefinition;
67
+ /** The fold halves of a billing unit, as the detached cold recipes call them. */
68
+ export interface BillingUnitFold {
69
+ /**
70
+ * Initial state for the empty log. DSH ≤ 0.1.1-rc.2 declared `init()` with
71
+ * no parameters; 0.1.2-alpha.5+ passes the Session header and inherited
72
+ * count. The unit ignores both, so the structural type accepts either call
73
+ * shape.
74
+ */
75
+ init(...metadata: never[]): BillingUnitState;
76
+ /** Pure transition: previous state + one committed event → next state. */
77
+ apply(state: BillingUnitState, event: SessionEvent): BillingUnitState;
78
+ }
67
79
  /** Fold a unit from init over one session's event log (the detached cold recipe). */
68
- export declare function foldBillingUnit(unit: Pick<ProjectionDefinition<'billingTodaySpend', BillingUnitState>, 'init' | 'apply'>, events: readonly SessionEvent[]): BillingUnitState;
80
+ export declare function foldBillingUnit(unit: BillingUnitFold, events: readonly SessionEvent[]): BillingUnitState;
69
81
  /**
70
82
  * Fold a unit from init over one session's OWN events only: the complete log
71
83
  * minus its inherited fork prefix (`seq < seedLength`). A forked child's
@@ -78,5 +90,5 @@ export declare function foldBillingUnit(unit: Pick<ProjectionDefinition<'billing
78
90
  * ({@link forkBoundaryOf}); 0 for an unseeded session.
79
91
  * @returns the unit state folded over the session's own events.
80
92
  */
81
- export declare function foldOwnBilling(unit: Pick<ProjectionDefinition<'billingTodaySpend', BillingUnitState>, 'init' | 'apply'>, events: readonly SessionEvent[], seedLength?: number): BillingUnitState;
93
+ export declare function foldOwnBilling(unit: BillingUnitFold, events: readonly SessionEvent[], seedLength?: number): BillingUnitState;
82
94
  //# sourceMappingURL=projection.d.ts.map
@@ -20,22 +20,29 @@
20
20
  * unchanged log provably cannot change the aggregate.
21
21
  *
22
22
  * Forked sessions never double-count: a fork child's log opens with a
23
- * verbatim copy of its source session's events (`header.seedLength` of them),
24
- * so the scanner prices only the child's OWN events (`seq >= seedLength`) on
25
- * every path — the projection path bypasses the eager cell for a seeded
26
- * session and folds its own events instead (the cell covers the inherited
27
- * prefix too), and the cold ladder skips the projection cache for a seeded
28
- * session (its cached row predates the boundary and covers inherited events).
29
- * The boundary is the durable session header, so a resumed fork child keeps
30
- * its original boundary and an unseeded session stays at 0.
23
+ * verbatim copy of its source session's events (its inherited boundary),
24
+ * so the scanner prices only the child's OWN events on every path — the
25
+ * projection path bypasses the eager cell for a seeded session and folds its
26
+ * own events instead (the cell covers the inherited prefix too), and the cold
27
+ * ladder skips the projection cache for a seeded session (its cached row
28
+ * predates the boundary and covers inherited events). The boundary is the
29
+ * durable session state, read across both DSH runtime families — a resumed
30
+ * fork child keeps its original boundary and an unseeded session stays at 0.
31
+ *
32
+ * The live `Session` log surface changed in 0.1.2-alpha.4: `Session.events`
33
+ * was removed and replaced by `Session.snapshotEvents()` / `ownEvents()`, and
34
+ * `SessionHeader.seedLength` moved to `Session.inheritedEventCount` (the
35
+ * persistence `inspect` result carries it alongside `meta`). Reads go through
36
+ * {@link liveSessionEvents} / {@link forkBoundaryOf}, which accept both
37
+ * families structurally, so the scanner runs on the ≤ 0.1.1-rc.2 npm baseline
38
+ * and on the newer runtime.
31
39
  * @module @rayadesu/dsh-llm-billing/today-spend
32
40
  */
33
41
  import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session';
34
42
  import type { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence';
35
43
  import type { ResolvedBilling } from './billing.ts';
36
44
  import type { DeepSeekTodaySessionsSpend, DeepSeekTodaySpend } from './types.ts';
37
- import { BILLING_UNIT_KEY, type BillingUnitState } from './projection.ts';
38
- import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection';
45
+ import { BILLING_UNIT_KEY, type BillingUnitFold, type BillingUnitState } from './projection.ts';
39
46
  /**
40
47
  * Fold one session's durable display title: the latest `session/title`
41
48
  * event's text (last-wins, matching the `title` projection), or `null` before
@@ -45,21 +52,102 @@ import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection';
45
52
  * @returns the session's current title, or `null` when untitled.
46
53
  */
47
54
  export declare function foldSessionTitle(events: readonly SessionEvent[]): string | null;
48
- /** Structural slice of a live session the scanner reads. */
55
+ /**
56
+ * Structural slice of a live session the scanner reads, accepting both DSH
57
+ * runtime families: the ≤ 0.1.1-rc.2 baseline exposes the log as
58
+ * `events` (+ `header.seedLength`), while 0.1.2-alpha.4+ exposes
59
+ * `snapshotEvents()` and `inheritedEventCount` (and dropped the header field).
60
+ */
49
61
  export interface ScannerSession {
50
62
  readonly id: SessionId;
51
- readonly events: readonly SessionEvent[];
52
- /** Durable header slice; `seedLength` marks a fork child's inherited prefix. */
63
+ /** ≤ 0.1.1-rc.2: the full event log snapshot. */
64
+ readonly events?: readonly SessionEvent[];
65
+ /** 0.1.2-alpha.4+: materialize an immutable log snapshot; no args = the full current log. */
66
+ snapshotEvents?(fromSeq?: number, toSeqExclusive?: number): readonly SessionEvent[];
67
+ /** 0.1.2-alpha.4+: the durable inherited-prefix length (0 for an unseeded session). */
68
+ readonly inheritedEventCount?: number;
69
+ /** Durable header slice: `seedLength` (older runtime) or `isSeeded` (newer runtime). */
53
70
  readonly header?: {
54
71
  readonly seedLength?: number;
72
+ readonly isSeeded?: boolean;
55
73
  };
56
74
  }
75
+ /**
76
+ * Read one live session's complete event log across both runtime families.
77
+ * @throws when the session exposes neither the legacy `events` snapshot nor
78
+ * the newer `snapshotEvents()` reader — an unknown runtime surface must
79
+ * fail loudly rather than silently price an empty log.
80
+ */
81
+ export declare function liveSessionEvents(session: ScannerSession): readonly SessionEvent[];
57
82
  /** Structural slice of a listed persisted session (the snapshot header is a full SessionHeader). */
58
83
  export interface ScannerPersistedHeader {
59
84
  readonly id: SessionId;
60
- /** Durable fork boundary carried by the snapshot header; absent for an unseeded session. */
85
+ /** 0.1.1-rc.2: the durable fork boundary carried by the snapshot header; absent for an unseeded session. */
61
86
  readonly seedLength?: number;
87
+ /** 0.1.2-alpha.4+: whether the session has a fork-inherited prefix (exact cut arrives with the inspect result). */
88
+ readonly isSeeded?: boolean;
89
+ }
90
+ /** One stored-session read: the full event log plus the durable inherited boundary. */
91
+ export interface ScannerPersistedRead {
92
+ readonly events: readonly SessionEvent[];
93
+ /** Inherited-prefix length (fork seed length); 0 for an unseeded session. */
94
+ readonly seedLength: number;
62
95
  }
96
+ /** ≤ 0.1.1-rc.2 persistence slice: service-level `inspect` / `listSnapshots`. */
97
+ export interface ScannerPersistenceLegacy {
98
+ listSnapshots(): Promise<readonly {
99
+ header: ScannerPersistedHeader;
100
+ revision: SessionPersistenceRevision;
101
+ }[]>;
102
+ inspect(id: SessionId): Promise<{
103
+ meta?: {
104
+ readonly seedLength?: number;
105
+ readonly isSeeded?: boolean;
106
+ };
107
+ /** 0.1.2-alpha.4+: the exact inherited cut travels beside, not inside, the header. */
108
+ inheritedEventCount?: number;
109
+ events: readonly SessionEvent[];
110
+ }>;
111
+ }
112
+ /** 0.1.2-alpha.5+ handle-based persistence slice: service-level `list` / `open` + `SessionHandle`. */
113
+ export interface ScannerPersistenceHandle {
114
+ list(): Promise<readonly {
115
+ header: ScannerPersistedHeader;
116
+ revision: SessionPersistenceRevision;
117
+ }[]>;
118
+ open(id: SessionId, access: 'read'): Promise<{
119
+ readonly header?: {
120
+ readonly seedLength?: number;
121
+ readonly isSeeded?: boolean;
122
+ };
123
+ /** 0.1.2-alpha.5+: the handle carries the exact inherited cut beside the header. */
124
+ readonly inheritedEventCount?: number;
125
+ read(): Promise<readonly SessionEvent[]>;
126
+ close(): Promise<void>;
127
+ }>;
128
+ }
129
+ /** Structural union the scanner reads through, accepting both persistence runtime families. */
130
+ export type ScannerPersistence = ScannerPersistenceLegacy | ScannerPersistenceHandle;
131
+ /**
132
+ * List every stored session snapshot across both persistence runtime families:
133
+ * `listSnapshots` (≤ 0.1.1-rc.2) or `list` (0.1.2-alpha.5+).
134
+ * @param persistence - the persistence service slice.
135
+ * @returns one snapshot per stored session.
136
+ */
137
+ export declare function persistenceListSnapshots(persistence: ScannerPersistence): Promise<readonly {
138
+ header: ScannerPersistedHeader;
139
+ revision: SessionPersistenceRevision;
140
+ }[]>;
141
+ /**
142
+ * Read one stored session's complete event log and durable inherited boundary
143
+ * across both persistence runtime families: legacy `inspect` (≤ 0.1.1-rc.2)
144
+ * or `open` + handle `read` (0.1.2-alpha.5+; the handle is closed after the
145
+ * read). Both throw when the session does not exist.
146
+ * @param persistence - the persistence service slice.
147
+ * @param id - the stored session to read.
148
+ * @returns the session's complete event log plus its inherited-prefix boundary.
149
+ */
150
+ export declare function persistenceInspect(persistence: ScannerPersistence, id: SessionId): Promise<ScannerPersistedRead>;
63
151
  /** Structural slices of the optional services the scanner reads through. */
64
152
  export interface TodaySpendScannerDeps {
65
153
  /** Resolves the live SessionStore at scan time (absent in headless assemblies). */
@@ -67,18 +155,7 @@ export interface TodaySpendScannerDeps {
67
155
  list(): readonly ScannerSession[];
68
156
  } | undefined;
69
157
  /** Resolves the persistence backend at scan time (absent without persistence). */
70
- persistence?: () => {
71
- listSnapshots(): Promise<readonly {
72
- header: ScannerPersistedHeader;
73
- revision: SessionPersistenceRevision;
74
- }[]>;
75
- inspect(id: SessionId): Promise<{
76
- meta: {
77
- seedLength?: number;
78
- };
79
- events: readonly SessionEvent[];
80
- }>;
81
- } | undefined;
158
+ persistence?: () => ScannerPersistence | undefined;
82
159
  /** Resolves the session-projection registry at scan time (absent → events path). */
83
160
  projections?: () => {
84
161
  stateOf(session: ScannerSession, key: typeof BILLING_UNIT_KEY): BillingUnitState | undefined;
@@ -97,7 +174,7 @@ export interface TodaySpendScannerDeps {
97
174
  */
98
175
  ensureUnit?: () => void;
99
176
  /** The billing unit's fold (the projection path's detached cold recipe). */
100
- unit: Pick<ProjectionDefinition<'billingTodaySpend', BillingUnitState>, 'init' | 'apply'>;
177
+ unit: BillingUnitFold;
101
178
  /** Hard cap on today's events collected by the events path. */
102
179
  maxEvents: number;
103
180
  /** Warn sink for truncation and unreadable sessions. */
@@ -179,9 +256,12 @@ export declare class TodaySpendScanner {
179
256
  * until the session is inspected again. A SEEDED session (fork child)
180
257
  * skips the ladder entirely: its cached row was folded over the inherited
181
258
  * prefix too, so it always detaches through inspect with the durable
182
- * boundary applied to the local fold.
259
+ * boundary (the inspect result's inherited count or `meta.seedLength`,
260
+ * depending on the runtime family) applied to the local fold.
183
261
  * @param id - the cold session's id.
184
- * @param seedLength - the durable inherited-prefix boundary (0 for unseeded).
262
+ * @param seeded - whether the session carries a fork-inherited prefix
263
+ * (from the snapshot header: `isSeeded` on 0.1.2-alpha.4+, `seedLength`
264
+ * at and before the 0.1.1-rc.2 baseline).
185
265
  * @returns the resolved state and title, or `undefined` when unreadable.
186
266
  */
187
267
  private resolveCold;
@@ -20,17 +20,25 @@
20
20
  * unchanged log provably cannot change the aggregate.
21
21
  *
22
22
  * Forked sessions never double-count: a fork child's log opens with a
23
- * verbatim copy of its source session's events (`header.seedLength` of them),
24
- * so the scanner prices only the child's OWN events (`seq >= seedLength`) on
25
- * every path — the projection path bypasses the eager cell for a seeded
26
- * session and folds its own events instead (the cell covers the inherited
27
- * prefix too), and the cold ladder skips the projection cache for a seeded
28
- * session (its cached row predates the boundary and covers inherited events).
29
- * The boundary is the durable session header, so a resumed fork child keeps
30
- * its original boundary and an unseeded session stays at 0.
23
+ * verbatim copy of its source session's events (its inherited boundary),
24
+ * so the scanner prices only the child's OWN events on every path — the
25
+ * projection path bypasses the eager cell for a seeded session and folds its
26
+ * own events instead (the cell covers the inherited prefix too), and the cold
27
+ * ladder skips the projection cache for a seeded session (its cached row
28
+ * predates the boundary and covers inherited events). The boundary is the
29
+ * durable session state, read across both DSH runtime families — a resumed
30
+ * fork child keeps its original boundary and an unseeded session stays at 0.
31
+ *
32
+ * The live `Session` log surface changed in 0.1.2-alpha.4: `Session.events`
33
+ * was removed and replaced by `Session.snapshotEvents()` / `ownEvents()`, and
34
+ * `SessionHeader.seedLength` moved to `Session.inheritedEventCount` (the
35
+ * persistence `inspect` result carries it alongside `meta`). Reads go through
36
+ * {@link liveSessionEvents} / {@link forkBoundaryOf}, which accept both
37
+ * families structurally, so the scanner runs on the ≤ 0.1.1-rc.2 npm baseline
38
+ * and on the newer runtime.
31
39
  * @module @rayadesu/dsh-llm-billing/today-spend
32
40
  */
33
- import { beijingDayKey, emptyTodaySpend, forkBoundaryOf, mergeTodaySpend, priceEvent, SpendAccumulator } from "./billing.js";
41
+ import { beijingDayKey, emptyTodaySpend, forkBoundaryOf, isSeededSession, mergeTodaySpend, priceEvent, SpendAccumulator } from "./billing.js";
34
42
  import { BILLING_UNIT_KEY, foldOwnBilling } from "./projection.js";
35
43
  /**
36
44
  * Fold one session's durable display title: the latest `session/title`
@@ -53,6 +61,56 @@ export function foldSessionTitle(events) {
53
61
  }
54
62
  return null;
55
63
  }
64
+ /**
65
+ * Read one live session's complete event log across both runtime families.
66
+ * @throws when the session exposes neither the legacy `events` snapshot nor
67
+ * the newer `snapshotEvents()` reader — an unknown runtime surface must
68
+ * fail loudly rather than silently price an empty log.
69
+ */
70
+ export function liveSessionEvents(session) {
71
+ if (session.events !== undefined)
72
+ return session.events;
73
+ // Optional-call form keeps the receiver bound (snapshotEvents uses `this`).
74
+ if (session.snapshotEvents !== undefined)
75
+ return session.snapshotEvents();
76
+ throw new Error('llm-billing: session log surface is neither Session.events nor Session.snapshotEvents');
77
+ }
78
+ function isHandlePersistence(persistence) {
79
+ return typeof persistence.open === 'function';
80
+ }
81
+ /**
82
+ * List every stored session snapshot across both persistence runtime families:
83
+ * `listSnapshots` (≤ 0.1.1-rc.2) or `list` (0.1.2-alpha.5+).
84
+ * @param persistence - the persistence service slice.
85
+ * @returns one snapshot per stored session.
86
+ */
87
+ export function persistenceListSnapshots(persistence) {
88
+ return isHandlePersistence(persistence)
89
+ ? persistence.list()
90
+ : persistence.listSnapshots();
91
+ }
92
+ /**
93
+ * Read one stored session's complete event log and durable inherited boundary
94
+ * across both persistence runtime families: legacy `inspect` (≤ 0.1.1-rc.2)
95
+ * or `open` + handle `read` (0.1.2-alpha.5+; the handle is closed after the
96
+ * read). Both throw when the session does not exist.
97
+ * @param persistence - the persistence service slice.
98
+ * @param id - the stored session to read.
99
+ * @returns the session's complete event log plus its inherited-prefix boundary.
100
+ */
101
+ export async function persistenceInspect(persistence, id) {
102
+ if (isHandlePersistence(persistence)) {
103
+ const handle = await persistence.open(id, 'read');
104
+ try {
105
+ return { events: await handle.read(), seedLength: forkBoundaryOf(handle) };
106
+ }
107
+ finally {
108
+ await handle.close();
109
+ }
110
+ }
111
+ const inspection = await persistence.inspect(id);
112
+ return { events: inspection.events, seedLength: forkBoundaryOf(inspection) };
113
+ }
56
114
  /**
57
115
  * Bounded parallel fan-out: run `run` over `items` with at most `limit` in
58
116
  * flight. A shared index counter hands each worker its next job, so the
@@ -176,17 +234,20 @@ export class TodaySpendScanner {
176
234
  * until the session is inspected again. A SEEDED session (fork child)
177
235
  * skips the ladder entirely: its cached row was folded over the inherited
178
236
  * prefix too, so it always detaches through inspect with the durable
179
- * boundary applied to the local fold.
237
+ * boundary (the inspect result's inherited count or `meta.seedLength`,
238
+ * depending on the runtime family) applied to the local fold.
180
239
  * @param id - the cold session's id.
181
- * @param seedLength - the durable inherited-prefix boundary (0 for unseeded).
240
+ * @param seeded - whether the session carries a fork-inherited prefix
241
+ * (from the snapshot header: `isSeeded` on 0.1.2-alpha.4+, `seedLength`
242
+ * at and before the 0.1.1-rc.2 baseline).
182
243
  * @returns the resolved state and title, or `undefined` when unreadable.
183
244
  */
184
- async resolveCold(id, seedLength) {
185
- const { persistence, projectionCache, unit, logger } = this.deps;
245
+ async resolveCold(id, seeded) {
246
+ const { persistence, projectionCache, logger } = this.deps;
186
247
  const persistenceService = persistence?.();
187
248
  if (persistenceService === undefined)
188
249
  return undefined;
189
- if (seedLength <= 0) {
250
+ if (!seeded) {
190
251
  const cache = projectionCache?.();
191
252
  if (cache !== undefined) {
192
253
  try {
@@ -200,8 +261,11 @@ export class TodaySpendScanner {
200
261
  }
201
262
  }
202
263
  try {
203
- const inspection = await persistenceService.inspect(id);
204
- return { value: foldOwnBilling(unit, inspection.events, seedLength), title: foldSessionTitle(inspection.events) };
264
+ const read = await persistenceInspect(persistenceService, id);
265
+ return {
266
+ value: foldOwnBilling(this.deps.unit, read.events, read.seedLength),
267
+ title: foldSessionTitle(read.events),
268
+ };
205
269
  }
206
270
  catch (error) {
207
271
  // One unreadable session must not blank the whole-day aggregate.
@@ -250,11 +314,11 @@ export class TodaySpendScanner {
250
314
  if (store !== undefined) {
251
315
  for (const session of store.list()) {
252
316
  liveIds.add(session.id);
253
- const seedLength = forkBoundaryOf(session.header);
317
+ const seedLength = forkBoundaryOf(session);
254
318
  // A fork child's eager cell covers its inherited prefix too; price
255
319
  // its own events directly instead.
256
320
  const state = seedLength > 0
257
- ? this.ownBillingState(session.id, session.events, seedLength)
321
+ ? this.ownBillingState(session.id, liveSessionEvents(session), seedLength)
258
322
  : projectionsService?.stateOf(session, BILLING_UNIT_KEY);
259
323
  if (state !== undefined && state.dayKey === dayKey) {
260
324
  total = mergeTodaySpend(total, state.spend);
@@ -265,22 +329,22 @@ export class TodaySpendScanner {
265
329
  const persistenceService = persistence?.();
266
330
  if (persistenceService === undefined)
267
331
  return total;
268
- const snapshots = await persistenceService.listSnapshots();
332
+ const snapshots = await persistenceListSnapshots(persistenceService);
269
333
  const pending = [];
270
334
  for (const { header, revision } of snapshots) {
271
335
  if (liveIds.has(header.id))
272
336
  continue;
273
- const seedLength = forkBoundaryOf(header);
337
+ const seeded = isSeededSession(header);
274
338
  const resolved = this.coldResolved.get(header.id);
275
339
  if (resolved !== undefined && resolved.revision === revision) {
276
340
  if (resolved.value.dayKey === dayKey)
277
341
  total = mergeTodaySpend(total, resolved.value.spend);
278
342
  continue;
279
343
  }
280
- pending.push({ id: header.id, revision, seedLength });
344
+ pending.push({ id: header.id, revision, seeded });
281
345
  }
282
- await withConcurrency(pending, 8, async ({ id, revision, seedLength }) => {
283
- const resolved = await this.resolveCold(id, seedLength);
346
+ await withConcurrency(pending, 8, async ({ id, revision, seeded }) => {
347
+ const resolved = await this.resolveCold(id, seeded);
284
348
  if (resolved !== undefined)
285
349
  this.coldResolved.set(id, { revision, ...resolved });
286
350
  });
@@ -326,7 +390,7 @@ export class TodaySpendScanner {
326
390
  if (store !== undefined) {
327
391
  for (const session of store.list()) {
328
392
  liveIds.add(session.id);
329
- collect(session.events, forkBoundaryOf(session.header));
393
+ collect(liveSessionEvents(session), forkBoundaryOf(session));
330
394
  if (truncated)
331
395
  break;
332
396
  }
@@ -334,15 +398,15 @@ export class TodaySpendScanner {
334
398
  }
335
399
  const persistenceService = persistence?.();
336
400
  if (!truncated && persistenceService !== undefined) {
337
- const snapshots = await persistenceService.listSnapshots();
401
+ const snapshots = await persistenceListSnapshots(persistenceService);
338
402
  for (const { header, revision } of snapshots) {
339
403
  if (liveIds.has(header.id))
340
404
  continue;
341
405
  if (this.lastEventsScan?.get(header.id) === revision)
342
406
  continue;
343
407
  try {
344
- const inspection = await persistenceService.inspect(header.id);
345
- collect(inspection.events, forkBoundaryOf(inspection.meta));
408
+ const read = await persistenceInspect(persistenceService, header.id);
409
+ collect(read.events, read.seedLength);
346
410
  }
347
411
  catch (error) {
348
412
  // One unreadable session must not blank the whole-day aggregate.
@@ -381,14 +445,15 @@ export class TodaySpendScanner {
381
445
  if (store !== undefined) {
382
446
  for (const session of store.list()) {
383
447
  liveIds.add(session.id);
384
- const seedLength = forkBoundaryOf(session.header);
448
+ const seedLength = forkBoundaryOf(session);
449
+ const events = liveSessionEvents(session);
385
450
  const state = seedLength > 0
386
- ? this.ownBillingState(session.id, session.events, seedLength)
451
+ ? this.ownBillingState(session.id, events, seedLength)
387
452
  : projectionsService?.stateOf(session, BILLING_UNIT_KEY);
388
453
  if (state !== undefined && state.dayKey === dayKey) {
389
454
  rows.set(session.id, {
390
455
  sessionId: session.id,
391
- title: foldSessionTitle(session.events),
456
+ title: foldSessionTitle(events),
392
457
  total: state.spend.total,
393
458
  });
394
459
  }
@@ -398,12 +463,12 @@ export class TodaySpendScanner {
398
463
  const persistenceService = persistence?.();
399
464
  if (persistenceService === undefined)
400
465
  return [...rows.values()];
401
- const snapshots = await persistenceService.listSnapshots();
466
+ const snapshots = await persistenceListSnapshots(persistenceService);
402
467
  const pending = [];
403
468
  for (const { header, revision } of snapshots) {
404
469
  if (liveIds.has(header.id))
405
470
  continue;
406
- const seedLength = forkBoundaryOf(header);
471
+ const seeded = isSeededSession(header);
407
472
  const resolved = this.coldResolved.get(header.id);
408
473
  if (resolved !== undefined && resolved.revision === revision) {
409
474
  if (resolved.value.dayKey === dayKey) {
@@ -411,10 +476,10 @@ export class TodaySpendScanner {
411
476
  }
412
477
  continue;
413
478
  }
414
- pending.push({ id: header.id, revision, seedLength });
479
+ pending.push({ id: header.id, revision, seeded });
415
480
  }
416
- await withConcurrency(pending, 8, async ({ id, revision, seedLength }) => {
417
- const resolved = await this.resolveCold(id, seedLength);
481
+ await withConcurrency(pending, 8, async ({ id, revision, seeded }) => {
482
+ const resolved = await this.resolveCold(id, seeded);
418
483
  if (resolved !== undefined)
419
484
  this.coldResolved.set(id, { revision, ...resolved });
420
485
  });
@@ -470,7 +535,7 @@ export class TodaySpendScanner {
470
535
  if (store !== undefined) {
471
536
  for (const session of store.list()) {
472
537
  liveIds.add(session.id);
473
- collect(session.id, session.events, forkBoundaryOf(session.header));
538
+ collect(session.id, liveSessionEvents(session), forkBoundaryOf(session));
474
539
  if (truncated)
475
540
  break;
476
541
  }
@@ -478,15 +543,15 @@ export class TodaySpendScanner {
478
543
  }
479
544
  const persistenceService = persistence?.();
480
545
  if (!truncated && persistenceService !== undefined) {
481
- const snapshots = await persistenceService.listSnapshots();
546
+ const snapshots = await persistenceListSnapshots(persistenceService);
482
547
  for (const { header, revision } of snapshots) {
483
548
  if (liveIds.has(header.id))
484
549
  continue;
485
550
  if (this.lastEventsScan?.get(header.id) === revision)
486
551
  continue;
487
552
  try {
488
- const inspection = await persistenceService.inspect(header.id);
489
- collect(header.id, inspection.events, forkBoundaryOf(inspection.meta));
553
+ const read = await persistenceInspect(persistenceService, header.id);
554
+ collect(header.id, read.events, read.seedLength);
490
555
  }
491
556
  catch (error) {
492
557
  // One unreadable session must not blank the whole-day aggregate.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rayadesu/dsh-llm-billing",
3
3
  "description": "Standalone DeepSeek account-balance and session-spend provider exposed through the billing Remote",
4
- "version": "0.3.5",
4
+ "version": "0.3.7",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -52,14 +52,14 @@
52
52
  },
53
53
  "license": "MIT",
54
54
  "peerDependencies": {
55
- "@deepseek-ai/dsh-credentials": "^0.1.1-rc.2",
56
- "@deepseek-ai/dsh-launch-environment": "^0.1.1-rc.2",
57
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
58
- "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
59
- "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
60
- "@deepseek-ai/dsh-session-persistence": "^0.1.1-rc.2",
61
- "@deepseek-ai/dsh-session-projection": "^0.1.1-rc.2",
62
- "@deepseek-ai/dsh-typert-protocol": "^0.1.1-rc.2",
55
+ "@deepseek-ai/dsh-credentials": "^0.1.2-alpha.5",
56
+ "@deepseek-ai/dsh-launch-environment": "^0.1.2-alpha.5",
57
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.5",
58
+ "@deepseek-ai/dsh-llm": "^0.1.2-alpha.5",
59
+ "@deepseek-ai/dsh-session": "^0.1.2-alpha.5",
60
+ "@deepseek-ai/dsh-session-persistence": "^0.1.2-alpha.5",
61
+ "@deepseek-ai/dsh-session-projection": "^0.1.2-alpha.5",
62
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.2-alpha.5",
63
63
  "@deepseek-ai/cordis": "^4.0.1"
64
64
  },
65
65
  "dependencies": {
@@ -67,14 +67,14 @@
67
67
  "zod": "^4.4.3"
68
68
  },
69
69
  "devDependencies": {
70
- "@deepseek-ai/dsh-credentials": "^0.1.1-rc.2",
71
- "@deepseek-ai/dsh-launch-environment": "^0.1.1-rc.2",
72
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
73
- "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
74
- "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
75
- "@deepseek-ai/dsh-session-persistence": "^0.1.1-rc.2",
76
- "@deepseek-ai/dsh-session-projection": "^0.1.1-rc.2",
77
- "@deepseek-ai/dsh-typert-protocol": "^0.1.1-rc.2",
70
+ "@deepseek-ai/dsh-credentials": "^0.1.2-alpha.5",
71
+ "@deepseek-ai/dsh-launch-environment": "^0.1.2-alpha.5",
72
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.5",
73
+ "@deepseek-ai/dsh-llm": "^0.1.2-alpha.5",
74
+ "@deepseek-ai/dsh-session": "^0.1.2-alpha.5",
75
+ "@deepseek-ai/dsh-session-persistence": "^0.1.2-alpha.5",
76
+ "@deepseek-ai/dsh-session-projection": "^0.1.2-alpha.5",
77
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.2-alpha.5",
78
78
  "@deepseek-ai/cordis": "^4.0.1"
79
79
  }
80
80
  }