@gamegeek-saikel/dsh-cost-meter 0.3.0 → 0.3.1

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.md CHANGED
@@ -47,7 +47,7 @@ Chat costs in DeepSeek pricing change over time (list prices, USD→CNY exchange
47
47
  | Peak pricing | 2026-08-17 00:00 Beijing rollout; peak windows apply Monday–Friday only (the zh and en pages may state different timezones; fallback 09:00–12:00 / 14:00–18:00 Beijing), so Saturdays, Sundays, and all other hours are off-peak at half price |
48
48
  | Cost formula | Uncached input + cache reads (hit rate) + cache writes (billed at uncached input rate) + output, per 1M tokens, CNY |
49
49
  | Account balance | Official `GET /user/balance`, cached 60 s, single in-flight request, trust-fenced route |
50
- | Subagent support | Enumerates the conversation's durable subagent tree through `subagents.listDescendants` (nested delegations, settled children, and cold subagent sessions included, with no depth cap); falls back to a BFS over the live agent tree when that service is not mounted |
50
+ | Subagent support | Enumerates the conversation's durable subagent tree through `subagents.listDescendants` (nested delegations, settled children, and cold subagent sessions included, with no depth cap); a ledger is read from the resident session's projection when available and otherwise cold-restored from the child's stored log (`sessionQuery.readSession` + `sessionProjections.restore`), so historical subagent spend is also counted after a host restart; falls back to a BFS over the live agent tree when neither service is mounted |
51
51
  | Conversation total | Main session + every descendant subagent (any depth); subagent totals still render while the main session's own ledger is not materialized |
52
52
  | UI surfaces | Composer dock · Cost tab · per-reply chip · header pill (live estimate) · settings card |
53
53
  | Locale | Simplified Chinese (source) + English |
package/README.zh-CN.md CHANGED
@@ -47,7 +47,7 @@ DeepSeek 的价格随时间变化(官方价目表、USD→CNY 汇率、2026-08
47
47
  | 峰值定价 | 2026-08-17 00:00 北京生效;高峰窗口仅周一至周五(中英文页面时区可能不同,缺省 09:00–12:00 / 14:00–18:00 北京),因此周六、周日全天及其余时段均为半价闲时 |
48
48
  | 成本公式 | 未命中输入 + 缓存命中(命中价)+ 缓存写入(按未命中输入价计)+ 输出,每百万 tokens,CNY |
49
49
  | 账户余额 | 官方 `GET /user/balance`,缓存 60 秒,单飞请求,路由带信任围栏 |
50
- | 子代理支持 | 沿 `subagents.listDescendants` 枚举本会话持久子代理树(子代理再派生的孙代理、已结束或已冷启动的子会话都计入,无层数上限);未挂载该服务时回退到活跃代理树 BFS |
50
+ | 子代理支持 | 沿 `subagents.listDescendants` 枚举本会话持久子代理树(子代理再派生的孙代理、已结束或已冷启动的子会话都计入,无层数上限);账本优先取常驻会话的投影,未常驻的经 `sessionQuery.readSession` + `sessionProjections.restore` 从其持久日志冷读——因此主机重启后,旧会话的历史子代理花费同样会被补算;未挂载该服务时回退到活跃代理树 BFS |
51
51
  | 对话总花费 | 主会话 + 全部后代子代理(任意层数);主会话自身账本尚未就绪时仍展示子代理合计 |
52
52
  | UI 表面 | 输入框读数 · 花费标签页 · 回复小标签 · 头部胶囊(实时估算)· 设置卡 |
53
53
  | 本地化 | 简体中文(键源)+ 英文 |
package/lib/index.js CHANGED
@@ -2265,8 +2265,53 @@ const ZERO_TOTALS = {
2265
2265
  unpricedSteps: 0,
2266
2266
  steps: 0
2267
2267
  };
2268
- /** Resolve the ledger of one session, or undefined when it carries none yet. */
2269
- function totalsOf(session, projections, projectionKey) {
2268
+ /** How long a cold fold is reused before the persisted log is read again. */
2269
+ const COLD_LEDGER_TTL_MS = 3e4;
2270
+ /** How many cold folds stay cached across requests. */
2271
+ const COLD_LEDGER_CACHE_LIMIT = 256;
2272
+ /** Cross-request cold-ledger cache, keyed by session id, insertion-ordered. */
2273
+ const coldLedgerCache = /* @__PURE__ */ new Map();
2274
+ /**
2275
+ * Drop every cached cold fold. The cache is process-global and keyed by
2276
+ * session id; tests (and any caller that knowingly replaced a session log)
2277
+ * use this instead of compensating with synthetic ids.
2278
+ */
2279
+ function resetColdLedgerCache() {
2280
+ coldLedgerCache.clear();
2281
+ }
2282
+ /**
2283
+ * Fold one stored subagent log into its ledger totals through the framework's
2284
+ * own cold-read recipe: an empty checkpoint at seq 0, every stored event, and
2285
+ * the exact inherited cut. Cached briefly so the route's poll cadence does not
2286
+ * refold unchanged logs.
2287
+ * @param sessionId - the subagent session to read.
2288
+ * @param query - the session query service.
2289
+ * @param projections - the projection registry (live + restore face).
2290
+ * @param projectionKey - the currency-specific ledger key.
2291
+ * @returns the restored totals, or undefined without a cold-read seam.
2292
+ */
2293
+ async function readColdLedger(sessionId, query, projections, projectionKey) {
2294
+ if (query === void 0 || typeof query.readSession !== "function") return void 0;
2295
+ if (typeof projections.restore !== "function") return void 0;
2296
+ const cached = coldLedgerCache.get(sessionId);
2297
+ if (cached !== void 0 && Date.now() - cached.at < COLD_LEDGER_TTL_MS) return cached.totals;
2298
+ const log = await query.readSession(sessionId);
2299
+ const restored = projections.restore({}, log.events, 0, log.session, log.inheritedEventCount);
2300
+ const totals = restored.snapshot.values[projectionKey]?.totals;
2301
+ if (totals === void 0) return void 0;
2302
+ coldLedgerCache.set(sessionId, {
2303
+ totals,
2304
+ asOfSeq: Number(restored.snapshot.asOfSeq),
2305
+ at: Date.now()
2306
+ });
2307
+ if (coldLedgerCache.size > COLD_LEDGER_CACHE_LIMIT) {
2308
+ const oldest = coldLedgerCache.keys().next();
2309
+ if (!oldest.done) coldLedgerCache.delete(oldest.value);
2310
+ }
2311
+ return totals;
2312
+ }
2313
+ /** Resolve the ledger of one resident session, or undefined when it carries none. */
2314
+ function liveTotalsOf(session, projections, projectionKey) {
2270
2315
  if (session === void 0) return void 0;
2271
2316
  let snapshot;
2272
2317
  try {
@@ -2276,10 +2321,7 @@ function totalsOf(session, projections, projectionKey) {
2276
2321
  }
2277
2322
  return snapshot.values[projectionKey]?.totals;
2278
2323
  }
2279
- /**
2280
- * Index the live session store by id. Read once per request so the durable
2281
- * listing resolves every row without a per-child store lookup.
2282
- */
2324
+ /** Index the live session store by id, so the durable walk resolves rows cheaply. */
2283
2325
  function liveSessionsById(sessions) {
2284
2326
  const index = /* @__PURE__ */ new Map();
2285
2327
  for (const session of sessions.list()) {
@@ -2289,30 +2331,35 @@ function liveSessionsById(sessions) {
2289
2331
  return index;
2290
2332
  }
2291
2333
  /**
2292
- * Fold the durable subagent tree into cost rows. A child whose session carries
2293
- * no ledger yet (never used a model, or a cold session the live store does not
2294
- * hold) is skipped, as is a `diagnostic` row, which names no interpreted
2295
- * child.
2334
+ * Fold the durable subagent tree into cost rows: the live ledger when the
2335
+ * child is resident, else its persisted-log restore. A `diagnostic` row names
2336
+ * no interpreted child and is skipped.
2296
2337
  * @param rootSessionId - the root conversation's session id.
2297
2338
  * @param subagents - the subagents service (durable tree enumeration).
2298
2339
  * @param sessions - the sessions service (live session store).
2299
2340
  * @param projections - the sessionProjections service.
2300
2341
  * @param projectionKey - the currency-specific ledger key.
2342
+ * @param query - the session query service (cold-ledger source), when mounted.
2301
2343
  * @returns one entry per descendant that has a ledger.
2302
2344
  * @throws whatever the listing throws; the caller decides the fallback.
2303
2345
  */
2304
- async function collectDurableSubagentCosts(rootSessionId, subagents, sessions, projections, projectionKey) {
2346
+ async function collectDurableSubagentCosts(rootSessionId, subagents, sessions, projections, projectionKey, query) {
2305
2347
  const entries = await subagents.listDescendants(rootSessionId);
2306
2348
  if (entries.length === 0) return [];
2307
2349
  const live = liveSessionsById(sessions);
2308
2350
  const result = [];
2309
2351
  const seen = /* @__PURE__ */ new Set();
2310
2352
  for (const entry of entries) {
2311
- if (entry.kind !== "child") continue;
2353
+ if (entry.kind === "diagnostic") continue;
2312
2354
  const sessionId = String(entry.id);
2313
2355
  if (sessionId.length === 0 || sessionId === rootSessionId || seen.has(sessionId)) continue;
2314
2356
  seen.add(sessionId);
2315
- const totals = totalsOf(live.get(sessionId), projections, projectionKey);
2357
+ let totals = liveTotalsOf(live.get(sessionId), projections, projectionKey);
2358
+ if (totals === void 0) try {
2359
+ totals = await readColdLedger(sessionId, query, projections, projectionKey);
2360
+ } catch {
2361
+ totals = void 0;
2362
+ }
2316
2363
  if (totals === void 0) continue;
2317
2364
  result.push({
2318
2365
  sessionId,
@@ -2350,7 +2397,7 @@ function collectRuntimeSubagentCosts(rootSessionId, agents, sessions, projection
2350
2397
  if (!agents.isOwnedBy(candidateId, parent)) continue;
2351
2398
  seen.add(candidateId);
2352
2399
  queue.push(candidate);
2353
- const totals = totalsOf(sessions.get(candidateId), projections, projectionKey);
2400
+ const totals = liveTotalsOf(sessions.get(candidateId), projections, projectionKey);
2354
2401
  if (totals === void 0) continue;
2355
2402
  result.push({
2356
2403
  sessionId: candidateId,
@@ -2364,21 +2411,23 @@ function collectRuntimeSubagentCosts(rootSessionId, agents, sessions, projection
2364
2411
  /**
2365
2412
  * Collect every session-backed descendant of `rootSessionId` with its
2366
2413
  * anchored cost totals: the durable subagent tree first (nested, settled, and
2367
- * cold children included), then the live runtime ownership walk for whatever
2368
- * the listing could not reach.
2414
+ * cold children included a resident child through its registered
2415
+ * projection, a stored one through the cold restore), then the live runtime
2416
+ * ownership walk for whatever the listing could not reach.
2369
2417
  * @param rootSessionId - the root conversation's session id.
2370
2418
  * @param agents - the agents service (live registry).
2371
2419
  * @param sessions - the sessions service (session store).
2372
2420
  * @param projections - the sessionProjections service.
2373
2421
  * @param projectionKey - the currency-specific ledger key.
2374
2422
  * @param subagents - the subagents service; omitted, only the runtime walk runs.
2423
+ * @param query - the session query service; omitted, cold children are skipped.
2375
2424
  * @returns one entry per descendant with a priced or unpriced ledger; empty
2376
2425
  * when the conversation has no subagents.
2377
2426
  */
2378
- async function collectSubagentCosts(rootSessionId, agents, sessions, projections, projectionKey = "sessionCost", subagents) {
2427
+ async function collectSubagentCosts(rootSessionId, agents, sessions, projections, projectionKey = "sessionCost", subagents, query) {
2379
2428
  let durable = [];
2380
2429
  if (subagents !== void 0) try {
2381
- durable = await collectDurableSubagentCosts(rootSessionId, subagents, sessions, projections, projectionKey);
2430
+ durable = await collectDurableSubagentCosts(rootSessionId, subagents, sessions, projections, projectionKey, query);
2382
2431
  } catch {
2383
2432
  durable = [];
2384
2433
  }
@@ -2699,9 +2748,10 @@ async function apply(ctx, config) {
2699
2748
  const agents = ctx.get("agents");
2700
2749
  const sessionsStore = ctx.get("sessions");
2701
2750
  const subagentTree = ctx.get("subagents");
2751
+ const sessionQuery = ctx.get("sessionQuery");
2702
2752
  const pricebook = currency === "USD" ? pricebookUsd : pricebookCny;
2703
2753
  const projectionKey = currency === "USD" ? "sessionCostUsd" : "sessionCost";
2704
- const subagents = rootSessionId === void 0 || sessionsStore === void 0 ? [] : await collectSubagentCosts(rootSessionId, agents, sessionsStore, ctx.sessionProjections, projectionKey, subagentTree);
2754
+ const subagents = rootSessionId === void 0 || sessionsStore === void 0 ? [] : await collectSubagentCosts(rootSessionId, agents, sessionsStore, ctx.sessionProjections, projectionKey, subagentTree, sessionQuery);
2705
2755
  return {
2706
2756
  balance: await refresh(),
2707
2757
  pricebook: pricebook.view(),
@@ -2745,4 +2795,4 @@ async function apply(ctx, config) {
2745
2795
  ctx.effect(() => ctx.webServer.register(route), "cost-meter: /cost-meter route");
2746
2796
  }
2747
2797
  //#endregion
2748
- export { Config, DEFAULT_ALIASES, DEFAULT_API_KEY_ENV, DEFAULT_CACHE_READ_DISCOUNT, DEFAULT_FX_RATE, DEFAULT_PRICING_REFRESH_HOURS, DEFAULT_REFRESH_MS, DEFAULT_SNAPSHOT_HISTORY_LIMIT, FALLBACK_CURRENT, FALLBACK_CURRENT_USD, FALLBACK_PEAK, FALLBACK_PEAK_USD, FX_API_URL, OPENROUTER_MODELS_URL, PEAK_PRICING_START_MS, PEAK_SCHEDULE_EN, PEAK_SCHEDULE_ZH, PRICEBOOK_DOMAIN, PRICEBOOK_DOMAIN_USD, PRICING_URL, PRICING_URL_EN, PUBLIC_BASE_URL, PricebookHandle, SETTINGS_NAMESPACE, apply, bandForTime, bucketOf, collectSubagentCosts, computePricebook, effectiveBucket, effectivePricing, effectiveRate, fetchBalance, fetchFxRate, fetchOpenRouter, fetchPricing, foldSessionCost, foldSessionCostIndex, initialPricebookState, inject, isLoopbackHostname, isPeakHour, isTrustedRequest, modelKeys, name, officialInputOf, parseCurrentTable, parseCurrentTableEn, parsePeakSchedule, parsePeakTable, parsePeakTableEn, pickBalanceInfo, priceAt, pricesEqual, pricingKeyOfModel, resolveApiKey, sessionCostIndexProjection, sessionCostProjection, snapshotForTime, stepCost, stripHtml, sumTotals, usageBuckets, viewSessionCost };
2798
+ export { Config, DEFAULT_ALIASES, DEFAULT_API_KEY_ENV, DEFAULT_CACHE_READ_DISCOUNT, DEFAULT_FX_RATE, DEFAULT_PRICING_REFRESH_HOURS, DEFAULT_REFRESH_MS, DEFAULT_SNAPSHOT_HISTORY_LIMIT, FALLBACK_CURRENT, FALLBACK_CURRENT_USD, FALLBACK_PEAK, FALLBACK_PEAK_USD, FX_API_URL, OPENROUTER_MODELS_URL, PEAK_PRICING_START_MS, PEAK_SCHEDULE_EN, PEAK_SCHEDULE_ZH, PRICEBOOK_DOMAIN, PRICEBOOK_DOMAIN_USD, PRICING_URL, PRICING_URL_EN, PUBLIC_BASE_URL, PricebookHandle, SETTINGS_NAMESPACE, apply, bandForTime, bucketOf, collectSubagentCosts, computePricebook, effectiveBucket, effectivePricing, effectiveRate, fetchBalance, fetchFxRate, fetchOpenRouter, fetchPricing, foldSessionCost, foldSessionCostIndex, initialPricebookState, inject, isLoopbackHostname, isPeakHour, isTrustedRequest, modelKeys, name, officialInputOf, parseCurrentTable, parseCurrentTableEn, parsePeakSchedule, parsePeakTable, parsePeakTableEn, pickBalanceInfo, priceAt, pricesEqual, pricingKeyOfModel, resetColdLedgerCache, resolveApiKey, sessionCostIndexProjection, sessionCostProjection, snapshotForTime, stepCost, stripHtml, sumTotals, usageBuckets, viewSessionCost };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,WAAW,CAAA;AAChE,OAAO,CAAC,MAAM,0BAA0B,CAAA;AAKxC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAA;AAqBlE,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAA4B,UAAU,EAAiB,mBAAmB,EAAE,MAAM,YAAY,CAAA;AAExI,mBAAmB,YAAY,CAAA;AAC/B,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,8BAA8B,CAAA;AAC5C,cAAc,yBAAyB,CAAA;AACvC,cAAc,oBAAoB,CAAA;AAElC,iCAAiC;AACjC,eAAO,MAAM,IAAI,eAAe,CAAA;AAChC,0EAA0E;AAC1E,eAAO,MAAM,MAAM,UAAsC,CAAA;AAEzD,mFAAmF;AACnF,eAAO,MAAM,eAAe,6BAA6B,CAAA;AACzD,uEAAuE;AACvE,eAAO,MAAM,mBAAmB,qBAAqB,CAAA;AACrD,yEAAyE;AACzE,eAAO,MAAM,kBAAkB,QAAS,CAAA;AAKxC,yEAAyE;AACzE,eAAO,MAAM,6BAA6B,IAAI,CAAA;AAI9C,gFAAgF;AAChF,MAAM,WAAW,MAAM;IACrB,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,+FAA+F;IAC/F,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,+DAA+D;IAC/D,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,oEAAoE;IACpE,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B,iGAAiG;IACjG,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;IACvB,wFAAwF;IACxF,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,8EAA8E;IAC9E,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,8DAA8D;IAC9D,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B,kFAAkF;IAClF,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,6DAA6D;IAC7D,oBAAoB,CAAC,EAAE,MAAM,CAAA;IAC7B,gFAAgF;IAChF,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAA;IACtC,2FAA2F;IAC3F,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,oFAAoF;IACpF,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,gEAAgE;IAChE,MAAM,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAA;IAC1B,8EAA8E;IAC9E,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,sDAAsD;IACtD,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAoBD,eAAO,MAAM,MAAM,EAiBb,CAAC,CAAC,MAAM,CAAC,CAAA;AAEf,mDAAmD;AACnD,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,MAAM,CAAA;IACjB,mBAAmB,EAAE,MAAM,CAAA;IAC3B,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;IAC/B,QAAQ,EAAE,MAAM,CAAA;IAChB,aAAa,EAAE,MAAM,CAAA;IACrB,mBAAmB,EAAE,MAAM,CAAA;IAC3B,iBAAiB,EAAE,OAAO,CAAA;IAC1B,oBAAoB,EAAE,MAAM,CAAA;CAC7B;AAYD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAM5D;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,eAAe,CAAC,SAAS,CAAC,EAAE,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAU9G;AAED;;;;;;GAMG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAQ1F;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,mBAAmB,GAAG,WAAW,GAAG,IAAI,CAS7E;AAED;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,eAAe,CAAC,CA6CjG;AAuBD,uEAAuE;AACvE,eAAO,MAAM,kBAAkB,EAAmB,iBAAiB,CAAA;AAEnE;;;;;;;;;;;GAWG;AACH,wBAAsB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA+LxE"}
@@ -361,11 +361,12 @@ export async function apply(ctx, config) {
361
361
  const agents = ctx.get('agents');
362
362
  const sessionsStore = ctx.get('sessions');
363
363
  const subagentTree = ctx.get('subagents');
364
+ const sessionQuery = ctx.get('sessionQuery');
364
365
  const pricebook = currency === 'USD' ? pricebookUsd : pricebookCny;
365
366
  const projectionKey = currency === 'USD' ? 'sessionCostUsd' : 'sessionCost';
366
367
  const subagents = rootSessionId === undefined || sessionsStore === undefined
367
368
  ? []
368
- : await collectSubagentCosts(rootSessionId, agents, sessionsStore, ctx.sessionProjections, projectionKey, subagentTree);
369
+ : await collectSubagentCosts(rootSessionId, agents, sessionsStore, ctx.sessionProjections, projectionKey, subagentTree, sessionQuery);
369
370
  return {
370
371
  balance: await refresh(),
371
372
  pricebook: pricebook.view(),
@@ -5,14 +5,19 @@
5
5
  * conversation spend (root + every nesting level) and a per-subagent
6
6
  * breakdown.
7
7
  *
8
- * Enumeration is the durable, session-backed subagent tree
9
- * (`subagents.listDescendants`, the same listing the product's own subagent
10
- * catalog uses): it walks `parentSession` lineage recorded on every session
11
- * header, so a subagent that delegates further, a one-shot child, and a child
12
- * that has already settled out of the live agent registry are all counted at
13
- * any depth. The corpus is live-preferred but persistent-backed, which is why
14
- * nested and finished children no longer depend on an in-memory Agent
15
- * surviving.
8
+ * Two seams, in this order:
9
+ *
10
+ * 1. **Enumeration** the durable, session-backed subagent tree
11
+ * (`subagents.listDescendants`, the same listing the product's own subagent
12
+ * catalog uses): it walks `parentSession` lineage recorded on every session
13
+ * header, so a subagent that delegates further, a one-shot child, and a
14
+ * child whose Agent was unloaded are all found at any depth.
15
+ * 2. **Ledger read** — a resident child's registered projection, otherwise a
16
+ * COLD restore over its stored log through the session query service
17
+ * (`readSession` + `sessionProjections.restore`: the framework's own
18
+ * checkpoint-plus-tail recipe). A freshly restarted process holds no live
19
+ * session at all, which is exactly the case where counting only resident
20
+ * children reported zero subagents for a conversation full of them.
16
21
  *
17
22
  * The runtime ownership walk over the live agent registry (`agents.isOwnedBy`
18
23
  * + `agents.list`) remains as a fallback for a deployment that mounts no
@@ -21,7 +26,7 @@
21
26
  *
22
27
  * @module @gamegeek-saikel/dsh-cost-meter/subagent-cost
23
28
  */
24
- import type { Session } from '@deepseek-ai/dsh-session';
29
+ import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session';
25
30
  import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection';
26
31
  import type { SessionCostTotals, SubagentCost } from './types.ts';
27
32
  /** The subset of the agents service the fallback walk needs. */
@@ -41,9 +46,36 @@ export interface SubagentSessionsService {
41
46
  get(id: string): Session | undefined;
42
47
  list(): Session[];
43
48
  }
44
- /** The subset of the sessionProjections service the aggregation needs. */
49
+ /**
50
+ * The subset of the sessionProjections service the aggregation needs: the live
51
+ * read face plus the cold `restore` face when the registry exposes it.
52
+ */
45
53
  export interface SubagentProjectionsService {
46
54
  snapshot(session: Session): ProjectionSnapshot;
55
+ /**
56
+ * Restore one projection cut from stored events without a live session.
57
+ * @param checkpoint - persisted rows, or `{}` for a full fold.
58
+ * @param events - stored events from `baseSeq`, in seq order.
59
+ * @param baseSeq - the seq `events` starts at.
60
+ * @param header - the stored session header.
61
+ * @param inheritedEventCount - exact fork-inherited prefix length.
62
+ * @returns the restored snapshot.
63
+ */
64
+ restore?(checkpoint: Record<string, unknown>, events: readonly SessionEvent[], baseSeq: unknown, header: SessionHeader, inheritedEventCount: unknown): {
65
+ snapshot: ProjectionSnapshot;
66
+ };
67
+ }
68
+ /**
69
+ * The subset of the sessionQuery service the aggregation needs to read one
70
+ * stored subagent log. Only `readSession` is consulted; a deployment whose
71
+ * query engine does not answer it simply has no cold-ledger source.
72
+ */
73
+ export interface SubagentQueryService {
74
+ readSession(sessionId: string): Promise<{
75
+ session: SessionHeader;
76
+ inheritedEventCount: unknown;
77
+ events: SessionEvent[];
78
+ }>;
47
79
  }
48
80
  /** One session-backed descendant row of the product's subagent listing. */
49
81
  interface SubagentDescendantEntry {
@@ -56,27 +88,34 @@ interface SubagentDescendantEntry {
56
88
  /**
57
89
  * The subset of the subagents service the durable enumeration needs. The real
58
90
  * service answers with branded `SessionId` values and takes one, so the
59
- * interface stays permissive at both ends and the module brands only what it
60
- * hands back into the service.
91
+ * interface stays permissive at both ends.
61
92
  */
62
93
  export interface SubagentTreeService {
63
94
  listDescendants(rootSessionId: string, signal?: AbortSignal): Promise<readonly SubagentDescendantEntry[]>;
64
95
  }
96
+ /**
97
+ * Drop every cached cold fold. The cache is process-global and keyed by
98
+ * session id; tests (and any caller that knowingly replaced a session log)
99
+ * use this instead of compensating with synthetic ids.
100
+ */
101
+ export declare function resetColdLedgerCache(): void;
65
102
  /**
66
103
  * Collect every session-backed descendant of `rootSessionId` with its
67
104
  * anchored cost totals: the durable subagent tree first (nested, settled, and
68
- * cold children included), then the live runtime ownership walk for whatever
69
- * the listing could not reach.
105
+ * cold children included a resident child through its registered
106
+ * projection, a stored one through the cold restore), then the live runtime
107
+ * ownership walk for whatever the listing could not reach.
70
108
  * @param rootSessionId - the root conversation's session id.
71
109
  * @param agents - the agents service (live registry).
72
110
  * @param sessions - the sessions service (session store).
73
111
  * @param projections - the sessionProjections service.
74
112
  * @param projectionKey - the currency-specific ledger key.
75
113
  * @param subagents - the subagents service; omitted, only the runtime walk runs.
114
+ * @param query - the session query service; omitted, cold children are skipped.
76
115
  * @returns one entry per descendant with a priced or unpriced ledger; empty
77
116
  * when the conversation has no subagents.
78
117
  */
79
- export declare function collectSubagentCosts(rootSessionId: string, agents: SubagentAgentsService | undefined, sessions: SubagentSessionsService, projections: SubagentProjectionsService, projectionKey?: 'sessionCost' | 'sessionCostUsd', subagents?: SubagentTreeService): Promise<SubagentCost[]>;
118
+ export declare function collectSubagentCosts(rootSessionId: string, agents: SubagentAgentsService | undefined, sessions: SubagentSessionsService, projections: SubagentProjectionsService, projectionKey?: 'sessionCost' | 'sessionCostUsd', subagents?: SubagentTreeService, query?: SubagentQueryService): Promise<SubagentCost[]>;
80
119
  /** Sum several totals into one (empty list → the zero totals). */
81
120
  export declare function sumTotals(totals: readonly SessionCostTotals[]): SessionCostTotals;
82
121
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"subagent-cost.d.ts","sourceRoot":"","sources":["../../src/subagent-cost.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAA;AACvD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qCAAqC,CAAA;AAC7E,OAAO,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAEjE,gEAAgE;AAChE,MAAM,WAAW,qBAAqB;IACpC,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,GAAG,SAAS,CAAA;IACjD,SAAS,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAA;IAC3D,IAAI,IAAI;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,EAAE,CAAA;CAC/B;AAED,gEAAgE;AAChE,MAAM,WAAW,uBAAuB;IACtC,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,CAAA;IACpC,IAAI,IAAI,OAAO,EAAE,CAAA;CAClB;AAED,0EAA0E;AAC1E,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,OAAO,EAAE,OAAO,GAAG,kBAAkB,CAAA;CAC/C;AAED,2EAA2E;AAC3E,UAAU,uBAAuB;IAC/B,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,YAAY,CAAA;IACrC,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAA;IACpB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAC3B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CACxB;AAED;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IAClC,eAAe,CAAC,aAAa,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,uBAAuB,EAAE,CAAC,CAAA;CAC1G;AAgID;;;;;;;;;;;;;GAaG;AACH,wBAAsB,oBAAoB,CACxC,aAAa,EAAE,MAAM,EACrB,MAAM,EAAE,qBAAqB,GAAG,SAAS,EACzC,QAAQ,EAAE,uBAAuB,EACjC,WAAW,EAAE,0BAA0B,EACvC,aAAa,GAAE,aAAa,GAAG,gBAAgC,EAC/D,SAAS,CAAC,EAAE,mBAAmB,GAC9B,OAAO,CAAC,YAAY,EAAE,CAAC,CAczB;AAED,kEAAkE;AAClE,wBAAgB,SAAS,CAAC,MAAM,EAAE,SAAS,iBAAiB,EAAE,GAAG,iBAAiB,CAYjF"}
1
+ {"version":3,"file":"subagent-cost.d.ts","sourceRoot":"","sources":["../../src/subagent-cost.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AACpF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qCAAqC,CAAA;AAC7E,OAAO,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAEjE,gEAAgE;AAChE,MAAM,WAAW,qBAAqB;IACpC,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,GAAG,SAAS,CAAA;IACjD,SAAS,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAA;IAC3D,IAAI,IAAI;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,EAAE,CAAA;CAC/B;AAED,gEAAgE;AAChE,MAAM,WAAW,uBAAuB;IACtC,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,CAAA;IACpC,IAAI,IAAI,OAAO,EAAE,CAAA;CAClB;AAED;;;GAGG;AACH,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,OAAO,EAAE,OAAO,GAAG,kBAAkB,CAAA;IAC9C;;;;;;;;OAQG;IACH,OAAO,CAAC,CACN,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACnC,MAAM,EAAE,SAAS,YAAY,EAAE,EAC/B,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,aAAa,EACrB,mBAAmB,EAAE,OAAO,GAC3B;QAAE,QAAQ,EAAE,kBAAkB,CAAA;KAAE,CAAA;CACpC;AAED;;;;GAIG;AACH,MAAM,WAAW,oBAAoB;IACnC,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,aAAa,CAAC;QAAC,mBAAmB,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,YAAY,EAAE,CAAA;KAAE,CAAC,CAAA;CAC1H;AAED,2EAA2E;AAC3E,UAAU,uBAAuB;IAC/B,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,YAAY,CAAA;IACrC,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAA;IACpB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAC3B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CACxB;AAED;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,eAAe,CAAC,aAAa,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,uBAAuB,EAAE,CAAC,CAAA;CAC1G;AA8BD;;;;GAIG;AACH,wBAAgB,oBAAoB,IAAI,IAAI,CAE3C;AAyKD;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,oBAAoB,CACxC,aAAa,EAAE,MAAM,EACrB,MAAM,EAAE,qBAAqB,GAAG,SAAS,EACzC,QAAQ,EAAE,uBAAuB,EACjC,WAAW,EAAE,0BAA0B,EACvC,aAAa,GAAE,aAAa,GAAG,gBAAgC,EAC/D,SAAS,CAAC,EAAE,mBAAmB,EAC/B,KAAK,CAAC,EAAE,oBAAoB,GAC3B,OAAO,CAAC,YAAY,EAAE,CAAC,CAczB;AAED,kEAAkE;AAClE,wBAAgB,SAAS,CAAC,MAAM,EAAE,SAAS,iBAAiB,EAAE,GAAG,iBAAiB,CAYjF"}
@@ -5,14 +5,19 @@
5
5
  * conversation spend (root + every nesting level) and a per-subagent
6
6
  * breakdown.
7
7
  *
8
- * Enumeration is the durable, session-backed subagent tree
9
- * (`subagents.listDescendants`, the same listing the product's own subagent
10
- * catalog uses): it walks `parentSession` lineage recorded on every session
11
- * header, so a subagent that delegates further, a one-shot child, and a child
12
- * that has already settled out of the live agent registry are all counted at
13
- * any depth. The corpus is live-preferred but persistent-backed, which is why
14
- * nested and finished children no longer depend on an in-memory Agent
15
- * surviving.
8
+ * Two seams, in this order:
9
+ *
10
+ * 1. **Enumeration** the durable, session-backed subagent tree
11
+ * (`subagents.listDescendants`, the same listing the product's own subagent
12
+ * catalog uses): it walks `parentSession` lineage recorded on every session
13
+ * header, so a subagent that delegates further, a one-shot child, and a
14
+ * child whose Agent was unloaded are all found at any depth.
15
+ * 2. **Ledger read** — a resident child's registered projection, otherwise a
16
+ * COLD restore over its stored log through the session query service
17
+ * (`readSession` + `sessionProjections.restore`: the framework's own
18
+ * checkpoint-plus-tail recipe). A freshly restarted process holds no live
19
+ * session at all, which is exactly the case where counting only resident
20
+ * children reported zero subagents for a conversation full of them.
16
21
  *
17
22
  * The runtime ownership walk over the live agent registry (`agents.isOwnedBy`
18
23
  * + `agents.list`) remains as a fallback for a deployment that mounts no
@@ -30,8 +35,58 @@ const ZERO_TOTALS = {
30
35
  unpricedSteps: 0,
31
36
  steps: 0,
32
37
  };
33
- /** Resolve the ledger of one session, or undefined when it carries none yet. */
34
- function totalsOf(session, projections, projectionKey) {
38
+ /** How long a cold fold is reused before the persisted log is read again. */
39
+ const COLD_LEDGER_TTL_MS = 30_000;
40
+ /** How many cold folds stay cached across requests. */
41
+ const COLD_LEDGER_CACHE_LIMIT = 256;
42
+ /** Cross-request cold-ledger cache, keyed by session id, insertion-ordered. */
43
+ const coldLedgerCache = new Map();
44
+ /**
45
+ * Drop every cached cold fold. The cache is process-global and keyed by
46
+ * session id; tests (and any caller that knowingly replaced a session log)
47
+ * use this instead of compensating with synthetic ids.
48
+ */
49
+ export function resetColdLedgerCache() {
50
+ coldLedgerCache.clear();
51
+ }
52
+ /**
53
+ * Fold one stored subagent log into its ledger totals through the framework's
54
+ * own cold-read recipe: an empty checkpoint at seq 0, every stored event, and
55
+ * the exact inherited cut. Cached briefly so the route's poll cadence does not
56
+ * refold unchanged logs.
57
+ * @param sessionId - the subagent session to read.
58
+ * @param query - the session query service.
59
+ * @param projections - the projection registry (live + restore face).
60
+ * @param projectionKey - the currency-specific ledger key.
61
+ * @returns the restored totals, or undefined without a cold-read seam.
62
+ */
63
+ async function readColdLedger(sessionId, query, projections, projectionKey) {
64
+ if (query === undefined || typeof query.readSession !== 'function')
65
+ return undefined;
66
+ if (typeof projections.restore !== 'function')
67
+ return undefined;
68
+ const cached = coldLedgerCache.get(sessionId);
69
+ if (cached !== undefined && Date.now() - cached.at < COLD_LEDGER_TTL_MS)
70
+ return cached.totals;
71
+ const log = await query.readSession(sessionId);
72
+ // The stored log starts at its own seq 0, so an empty checkpoint and
73
+ // `baseSeq` 0 fold every registered unit from `init` across the whole log —
74
+ // the exact same `apply` path a live session runs.
75
+ const restored = projections.restore({}, log.events, 0, log.session, log.inheritedEventCount);
76
+ const value = restored.snapshot.values[projectionKey];
77
+ const totals = value?.totals;
78
+ if (totals === undefined)
79
+ return undefined;
80
+ coldLedgerCache.set(sessionId, { totals, asOfSeq: Number(restored.snapshot.asOfSeq), at: Date.now() });
81
+ if (coldLedgerCache.size > COLD_LEDGER_CACHE_LIMIT) {
82
+ const oldest = coldLedgerCache.keys().next();
83
+ if (!oldest.done)
84
+ coldLedgerCache.delete(oldest.value);
85
+ }
86
+ return totals;
87
+ }
88
+ /** Resolve the ledger of one resident session, or undefined when it carries none. */
89
+ function liveTotalsOf(session, projections, projectionKey) {
35
90
  if (session === undefined)
36
91
  return undefined;
37
92
  let snapshot;
@@ -44,10 +99,7 @@ function totalsOf(session, projections, projectionKey) {
44
99
  }
45
100
  return snapshot.values[projectionKey]?.totals;
46
101
  }
47
- /**
48
- * Index the live session store by id. Read once per request so the durable
49
- * listing resolves every row without a per-child store lookup.
50
- */
102
+ /** Index the live session store by id, so the durable walk resolves rows cheaply. */
51
103
  function liveSessionsById(sessions) {
52
104
  const index = new Map();
53
105
  for (const session of sessions.list()) {
@@ -58,19 +110,19 @@ function liveSessionsById(sessions) {
58
110
  return index;
59
111
  }
60
112
  /**
61
- * Fold the durable subagent tree into cost rows. A child whose session carries
62
- * no ledger yet (never used a model, or a cold session the live store does not
63
- * hold) is skipped, as is a `diagnostic` row, which names no interpreted
64
- * child.
113
+ * Fold the durable subagent tree into cost rows: the live ledger when the
114
+ * child is resident, else its persisted-log restore. A `diagnostic` row names
115
+ * no interpreted child and is skipped.
65
116
  * @param rootSessionId - the root conversation's session id.
66
117
  * @param subagents - the subagents service (durable tree enumeration).
67
118
  * @param sessions - the sessions service (live session store).
68
119
  * @param projections - the sessionProjections service.
69
120
  * @param projectionKey - the currency-specific ledger key.
121
+ * @param query - the session query service (cold-ledger source), when mounted.
70
122
  * @returns one entry per descendant that has a ledger.
71
123
  * @throws whatever the listing throws; the caller decides the fallback.
72
124
  */
73
- async function collectDurableSubagentCosts(rootSessionId, subagents, sessions, projections, projectionKey) {
125
+ async function collectDurableSubagentCosts(rootSessionId, subagents, sessions, projections, projectionKey, query) {
74
126
  const entries = await subagents.listDescendants(rootSessionId);
75
127
  if (entries.length === 0)
76
128
  return [];
@@ -78,13 +130,25 @@ async function collectDurableSubagentCosts(rootSessionId, subagents, sessions, p
78
130
  const result = [];
79
131
  const seen = new Set();
80
132
  for (const entry of entries) {
81
- if (entry.kind !== 'child')
133
+ // A `diagnostic` row names a candidate the product's listing could not
134
+ // interpret; only interpreted children carry costs.
135
+ if (entry.kind === 'diagnostic')
82
136
  continue;
83
137
  const sessionId = String(entry.id);
84
138
  if (sessionId.length === 0 || sessionId === rootSessionId || seen.has(sessionId))
85
139
  continue;
86
140
  seen.add(sessionId);
87
- const totals = totalsOf(live.get(sessionId), projections, projectionKey);
141
+ let totals = liveTotalsOf(live.get(sessionId), projections, projectionKey);
142
+ if (totals === undefined) {
143
+ try {
144
+ totals = await readColdLedger(sessionId, query, projections, projectionKey);
145
+ }
146
+ catch {
147
+ // One unreadable log (damaged, pruned, or a cancelled read) must not
148
+ // hide the other children's costs.
149
+ totals = undefined;
150
+ }
151
+ }
88
152
  if (totals === undefined)
89
153
  continue;
90
154
  result.push({
@@ -126,7 +190,7 @@ function collectRuntimeSubagentCosts(rootSessionId, agents, sessions, projection
126
190
  continue;
127
191
  seen.add(candidateId);
128
192
  queue.push(candidate);
129
- const totals = totalsOf(sessions.get(candidateId), projections, projectionKey);
193
+ const totals = liveTotalsOf(sessions.get(candidateId), projections, projectionKey);
130
194
  if (totals === undefined)
131
195
  continue;
132
196
  result.push({ sessionId: candidateId, depth: -1, totals });
@@ -137,22 +201,24 @@ function collectRuntimeSubagentCosts(rootSessionId, agents, sessions, projection
137
201
  /**
138
202
  * Collect every session-backed descendant of `rootSessionId` with its
139
203
  * anchored cost totals: the durable subagent tree first (nested, settled, and
140
- * cold children included), then the live runtime ownership walk for whatever
141
- * the listing could not reach.
204
+ * cold children included a resident child through its registered
205
+ * projection, a stored one through the cold restore), then the live runtime
206
+ * ownership walk for whatever the listing could not reach.
142
207
  * @param rootSessionId - the root conversation's session id.
143
208
  * @param agents - the agents service (live registry).
144
209
  * @param sessions - the sessions service (session store).
145
210
  * @param projections - the sessionProjections service.
146
211
  * @param projectionKey - the currency-specific ledger key.
147
212
  * @param subagents - the subagents service; omitted, only the runtime walk runs.
213
+ * @param query - the session query service; omitted, cold children are skipped.
148
214
  * @returns one entry per descendant with a priced or unpriced ledger; empty
149
215
  * when the conversation has no subagents.
150
216
  */
151
- export async function collectSubagentCosts(rootSessionId, agents, sessions, projections, projectionKey = 'sessionCost', subagents) {
217
+ export async function collectSubagentCosts(rootSessionId, agents, sessions, projections, projectionKey = 'sessionCost', subagents, query) {
152
218
  let durable = [];
153
219
  if (subagents !== undefined) {
154
220
  try {
155
- durable = await collectDurableSubagentCosts(rootSessionId, subagents, sessions, projections, projectionKey);
221
+ durable = await collectDurableSubagentCosts(rootSessionId, subagents, sessions, projections, projectionKey, query);
156
222
  }
157
223
  catch {
158
224
  // A listing failure (no session query, a cancelled read, corrupt log)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gamegeek-saikel/dsh-cost-meter",
3
3
  "description": "Cost tracking plugin for the DeepSeek Harness Web GUI — snapshot-anchored per-turn pricing, account balance, and live cost estimates.",
4
- "version": "0.3.0",
4
+ "version": "0.3.1",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.7.0",
7
7
  "engines": {
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ import { sessionCostIndexProjection } from './session-cost-index.ts'
40
40
  import {
41
41
  collectSubagentCosts,
42
42
  type SubagentAgentsService,
43
+ type SubagentQueryService,
43
44
  type SubagentSessionsService,
44
45
  type SubagentTreeService,
45
46
  } from './subagent-cost.ts'
@@ -460,11 +461,20 @@ export async function apply(ctx: Context, config?: Config): Promise<void> {
460
461
  const agents = ctx.get('agents') as SubagentAgentsService | undefined
461
462
  const sessionsStore = ctx.get('sessions') as SubagentSessionsService | undefined
462
463
  const subagentTree = ctx.get('subagents') as SubagentTreeService | undefined
464
+ const sessionQuery = ctx.get('sessionQuery') as SubagentQueryService | undefined
463
465
  const pricebook = currency === 'USD' ? pricebookUsd : pricebookCny
464
466
  const projectionKey = currency === 'USD' ? 'sessionCostUsd' : 'sessionCost'
465
467
  const subagents = rootSessionId === undefined || sessionsStore === undefined
466
468
  ? []
467
- : await collectSubagentCosts(rootSessionId, agents, sessionsStore, ctx.sessionProjections, projectionKey, subagentTree)
469
+ : await collectSubagentCosts(
470
+ rootSessionId,
471
+ agents,
472
+ sessionsStore,
473
+ ctx.sessionProjections,
474
+ projectionKey,
475
+ subagentTree,
476
+ sessionQuery,
477
+ )
468
478
  return {
469
479
  balance: await refresh(),
470
480
  pricebook: pricebook.view(),
@@ -5,14 +5,19 @@
5
5
  * conversation spend (root + every nesting level) and a per-subagent
6
6
  * breakdown.
7
7
  *
8
- * Enumeration is the durable, session-backed subagent tree
9
- * (`subagents.listDescendants`, the same listing the product's own subagent
10
- * catalog uses): it walks `parentSession` lineage recorded on every session
11
- * header, so a subagent that delegates further, a one-shot child, and a child
12
- * that has already settled out of the live agent registry are all counted at
13
- * any depth. The corpus is live-preferred but persistent-backed, which is why
14
- * nested and finished children no longer depend on an in-memory Agent
15
- * surviving.
8
+ * Two seams, in this order:
9
+ *
10
+ * 1. **Enumeration** the durable, session-backed subagent tree
11
+ * (`subagents.listDescendants`, the same listing the product's own subagent
12
+ * catalog uses): it walks `parentSession` lineage recorded on every session
13
+ * header, so a subagent that delegates further, a one-shot child, and a
14
+ * child whose Agent was unloaded are all found at any depth.
15
+ * 2. **Ledger read** — a resident child's registered projection, otherwise a
16
+ * COLD restore over its stored log through the session query service
17
+ * (`readSession` + `sessionProjections.restore`: the framework's own
18
+ * checkpoint-plus-tail recipe). A freshly restarted process holds no live
19
+ * session at all, which is exactly the case where counting only resident
20
+ * children reported zero subagents for a conversation full of them.
16
21
  *
17
22
  * The runtime ownership walk over the live agent registry (`agents.isOwnedBy`
18
23
  * + `agents.list`) remains as a fallback for a deployment that mounts no
@@ -22,7 +27,7 @@
22
27
  * @module @gamegeek-saikel/dsh-cost-meter/subagent-cost
23
28
  */
24
29
 
25
- import type { Session } from '@deepseek-ai/dsh-session'
30
+ import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
26
31
  import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection'
27
32
  import type { SessionCostTotals, SubagentCost } from './types.ts'
28
33
 
@@ -39,9 +44,37 @@ export interface SubagentSessionsService {
39
44
  list(): Session[]
40
45
  }
41
46
 
42
- /** The subset of the sessionProjections service the aggregation needs. */
47
+ /**
48
+ * The subset of the sessionProjections service the aggregation needs: the live
49
+ * read face plus the cold `restore` face when the registry exposes it.
50
+ */
43
51
  export interface SubagentProjectionsService {
44
52
  snapshot(session: Session): ProjectionSnapshot
53
+ /**
54
+ * Restore one projection cut from stored events without a live session.
55
+ * @param checkpoint - persisted rows, or `{}` for a full fold.
56
+ * @param events - stored events from `baseSeq`, in seq order.
57
+ * @param baseSeq - the seq `events` starts at.
58
+ * @param header - the stored session header.
59
+ * @param inheritedEventCount - exact fork-inherited prefix length.
60
+ * @returns the restored snapshot.
61
+ */
62
+ restore?(
63
+ checkpoint: Record<string, unknown>,
64
+ events: readonly SessionEvent[],
65
+ baseSeq: unknown,
66
+ header: SessionHeader,
67
+ inheritedEventCount: unknown,
68
+ ): { snapshot: ProjectionSnapshot }
69
+ }
70
+
71
+ /**
72
+ * The subset of the sessionQuery service the aggregation needs to read one
73
+ * stored subagent log. Only `readSession` is consulted; a deployment whose
74
+ * query engine does not answer it simply has no cold-ledger source.
75
+ */
76
+ export interface SubagentQueryService {
77
+ readSession(sessionId: string): Promise<{ session: SessionHeader; inheritedEventCount: unknown; events: SessionEvent[] }>
45
78
  }
46
79
 
47
80
  /** One session-backed descendant row of the product's subagent listing. */
@@ -56,8 +89,7 @@ interface SubagentDescendantEntry {
56
89
  /**
57
90
  * The subset of the subagents service the durable enumeration needs. The real
58
91
  * service answers with branded `SessionId` values and takes one, so the
59
- * interface stays permissive at both ends and the module brands only what it
60
- * hands back into the service.
92
+ * interface stays permissive at both ends.
61
93
  */
62
94
  export interface SubagentTreeService {
63
95
  listDescendants(rootSessionId: string, signal?: AbortSignal): Promise<readonly SubagentDescendantEntry[]>
@@ -73,8 +105,75 @@ const ZERO_TOTALS: SessionCostTotals = {
73
105
  steps: 0,
74
106
  }
75
107
 
76
- /** Resolve the ledger of one session, or undefined when it carries none yet. */
77
- function totalsOf(
108
+ /** One cold session's folded ledger, with the log watermark it describes. */
109
+ interface ColdLedger {
110
+ /** Totals of the restored cut. */
111
+ readonly totals: SessionCostTotals
112
+ /** The restored cut's watermark (never read today; kept for diagnostics). */
113
+ readonly asOfSeq: number
114
+ /** Epoch millis when this fold was computed. */
115
+ readonly at: number
116
+ }
117
+
118
+ /** How long a cold fold is reused before the persisted log is read again. */
119
+ const COLD_LEDGER_TTL_MS = 30_000
120
+ /** How many cold folds stay cached across requests. */
121
+ const COLD_LEDGER_CACHE_LIMIT = 256
122
+
123
+ /** Cross-request cold-ledger cache, keyed by session id, insertion-ordered. */
124
+ const coldLedgerCache = new Map<string, ColdLedger>()
125
+
126
+ /**
127
+ * Drop every cached cold fold. The cache is process-global and keyed by
128
+ * session id; tests (and any caller that knowingly replaced a session log)
129
+ * use this instead of compensating with synthetic ids.
130
+ */
131
+ export function resetColdLedgerCache(): void {
132
+ coldLedgerCache.clear()
133
+ }
134
+
135
+ /**
136
+ * Fold one stored subagent log into its ledger totals through the framework's
137
+ * own cold-read recipe: an empty checkpoint at seq 0, every stored event, and
138
+ * the exact inherited cut. Cached briefly so the route's poll cadence does not
139
+ * refold unchanged logs.
140
+ * @param sessionId - the subagent session to read.
141
+ * @param query - the session query service.
142
+ * @param projections - the projection registry (live + restore face).
143
+ * @param projectionKey - the currency-specific ledger key.
144
+ * @returns the restored totals, or undefined without a cold-read seam.
145
+ */
146
+ async function readColdLedger(
147
+ sessionId: string,
148
+ query: SubagentQueryService | undefined,
149
+ projections: SubagentProjectionsService,
150
+ projectionKey: 'sessionCost' | 'sessionCostUsd',
151
+ ): Promise<SessionCostTotals | undefined> {
152
+ if (query === undefined || typeof query.readSession !== 'function') return undefined
153
+ if (typeof projections.restore !== 'function') return undefined
154
+
155
+ const cached = coldLedgerCache.get(sessionId)
156
+ if (cached !== undefined && Date.now() - cached.at < COLD_LEDGER_TTL_MS) return cached.totals
157
+
158
+ const log = await query.readSession(sessionId)
159
+ // The stored log starts at its own seq 0, so an empty checkpoint and
160
+ // `baseSeq` 0 fold every registered unit from `init` across the whole log —
161
+ // the exact same `apply` path a live session runs.
162
+ const restored = projections.restore({}, log.events, 0, log.session, log.inheritedEventCount)
163
+ const value = restored.snapshot.values[projectionKey] as { totals?: SessionCostTotals } | undefined
164
+ const totals = value?.totals
165
+ if (totals === undefined) return undefined
166
+
167
+ coldLedgerCache.set(sessionId, { totals, asOfSeq: Number(restored.snapshot.asOfSeq), at: Date.now() })
168
+ if (coldLedgerCache.size > COLD_LEDGER_CACHE_LIMIT) {
169
+ const oldest = coldLedgerCache.keys().next()
170
+ if (!oldest.done) coldLedgerCache.delete(oldest.value)
171
+ }
172
+ return totals
173
+ }
174
+
175
+ /** Resolve the ledger of one resident session, or undefined when it carries none. */
176
+ function liveTotalsOf(
78
177
  session: Session | undefined,
79
178
  projections: SubagentProjectionsService,
80
179
  projectionKey: 'sessionCost' | 'sessionCostUsd',
@@ -90,10 +189,7 @@ function totalsOf(
90
189
  return snapshot.values[projectionKey]?.totals
91
190
  }
92
191
 
93
- /**
94
- * Index the live session store by id. Read once per request so the durable
95
- * listing resolves every row without a per-child store lookup.
96
- */
192
+ /** Index the live session store by id, so the durable walk resolves rows cheaply. */
97
193
  function liveSessionsById(sessions: SubagentSessionsService): Map<string, Session> {
98
194
  const index = new Map<string, Session>()
99
195
  for (const session of sessions.list()) {
@@ -104,15 +200,15 @@ function liveSessionsById(sessions: SubagentSessionsService): Map<string, Sessio
104
200
  }
105
201
 
106
202
  /**
107
- * Fold the durable subagent tree into cost rows. A child whose session carries
108
- * no ledger yet (never used a model, or a cold session the live store does not
109
- * hold) is skipped, as is a `diagnostic` row, which names no interpreted
110
- * child.
203
+ * Fold the durable subagent tree into cost rows: the live ledger when the
204
+ * child is resident, else its persisted-log restore. A `diagnostic` row names
205
+ * no interpreted child and is skipped.
111
206
  * @param rootSessionId - the root conversation's session id.
112
207
  * @param subagents - the subagents service (durable tree enumeration).
113
208
  * @param sessions - the sessions service (live session store).
114
209
  * @param projections - the sessionProjections service.
115
210
  * @param projectionKey - the currency-specific ledger key.
211
+ * @param query - the session query service (cold-ledger source), when mounted.
116
212
  * @returns one entry per descendant that has a ledger.
117
213
  * @throws whatever the listing throws; the caller decides the fallback.
118
214
  */
@@ -122,6 +218,7 @@ async function collectDurableSubagentCosts(
122
218
  sessions: SubagentSessionsService,
123
219
  projections: SubagentProjectionsService,
124
220
  projectionKey: 'sessionCost' | 'sessionCostUsd',
221
+ query: SubagentQueryService | undefined,
125
222
  ): Promise<SubagentCost[]> {
126
223
  const entries = await subagents.listDescendants(rootSessionId)
127
224
  if (entries.length === 0) return []
@@ -130,12 +227,25 @@ async function collectDurableSubagentCosts(
130
227
  const result: SubagentCost[] = []
131
228
  const seen = new Set<string>()
132
229
  for (const entry of entries) {
133
- if (entry.kind !== 'child') continue
230
+ // A `diagnostic` row names a candidate the product's listing could not
231
+ // interpret; only interpreted children carry costs.
232
+ if (entry.kind === 'diagnostic') continue
134
233
  const sessionId = String(entry.id)
135
234
  if (sessionId.length === 0 || sessionId === rootSessionId || seen.has(sessionId)) continue
136
235
  seen.add(sessionId)
137
- const totals = totalsOf(live.get(sessionId), projections, projectionKey)
236
+
237
+ let totals = liveTotalsOf(live.get(sessionId), projections, projectionKey)
238
+ if (totals === undefined) {
239
+ try {
240
+ totals = await readColdLedger(sessionId, query, projections, projectionKey)
241
+ } catch {
242
+ // One unreadable log (damaged, pruned, or a cancelled read) must not
243
+ // hide the other children's costs.
244
+ totals = undefined
245
+ }
246
+ }
138
247
  if (totals === undefined) continue
248
+
139
249
  result.push({
140
250
  sessionId,
141
251
  ...(entry.parentId === undefined ? {} : { parentId: String(entry.parentId) }),
@@ -181,7 +291,7 @@ function collectRuntimeSubagentCosts(
181
291
  if (!agents.isOwnedBy(candidateId, parent)) continue
182
292
  seen.add(candidateId)
183
293
  queue.push(candidate)
184
- const totals = totalsOf(sessions.get(candidateId), projections, projectionKey)
294
+ const totals = liveTotalsOf(sessions.get(candidateId), projections, projectionKey)
185
295
  if (totals === undefined) continue
186
296
  result.push({ sessionId: candidateId, depth: -1, totals })
187
297
  }
@@ -192,14 +302,16 @@ function collectRuntimeSubagentCosts(
192
302
  /**
193
303
  * Collect every session-backed descendant of `rootSessionId` with its
194
304
  * anchored cost totals: the durable subagent tree first (nested, settled, and
195
- * cold children included), then the live runtime ownership walk for whatever
196
- * the listing could not reach.
305
+ * cold children included a resident child through its registered
306
+ * projection, a stored one through the cold restore), then the live runtime
307
+ * ownership walk for whatever the listing could not reach.
197
308
  * @param rootSessionId - the root conversation's session id.
198
309
  * @param agents - the agents service (live registry).
199
310
  * @param sessions - the sessions service (session store).
200
311
  * @param projections - the sessionProjections service.
201
312
  * @param projectionKey - the currency-specific ledger key.
202
313
  * @param subagents - the subagents service; omitted, only the runtime walk runs.
314
+ * @param query - the session query service; omitted, cold children are skipped.
203
315
  * @returns one entry per descendant with a priced or unpriced ledger; empty
204
316
  * when the conversation has no subagents.
205
317
  */
@@ -210,11 +322,12 @@ export async function collectSubagentCosts(
210
322
  projections: SubagentProjectionsService,
211
323
  projectionKey: 'sessionCost' | 'sessionCostUsd' = 'sessionCost',
212
324
  subagents?: SubagentTreeService,
325
+ query?: SubagentQueryService,
213
326
  ): Promise<SubagentCost[]> {
214
327
  let durable: SubagentCost[] = []
215
328
  if (subagents !== undefined) {
216
329
  try {
217
- durable = await collectDurableSubagentCosts(rootSessionId, subagents, sessions, projections, projectionKey)
330
+ durable = await collectDurableSubagentCosts(rootSessionId, subagents, sessions, projections, projectionKey, query)
218
331
  } catch {
219
332
  // A listing failure (no session query, a cancelled read, corrupt log)
220
333
  // must not hide the subagents the live registry can still prove.