dsh-token-total 1.1.3 → 1.1.5

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/lib/index.js CHANGED
@@ -1526,6 +1526,134 @@ async function resolveModelNames(modelRefs, llm) {
1526
1526
  return names;
1527
1527
  }
1528
1528
  //#endregion
1529
+ //#region src/session-tree.ts
1530
+ function headerOf(item) {
1531
+ if (!item || typeof item !== "object") return null;
1532
+ const header = "header" in item ? item.header : item;
1533
+ return header && typeof header.id === "string" && header.id !== "" ? header : null;
1534
+ }
1535
+ function inheritedCount(value) {
1536
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
1537
+ }
1538
+ function throwIfCanceled(error, signal) {
1539
+ signal?.throwIfAborted();
1540
+ if (error && typeof error === "object" && "name" in error && error.name === "AbortError") throw error;
1541
+ }
1542
+ /** Never send raw exceptions to the client: they may contain paths or secrets. */
1543
+ function readIssue(sessionId, error) {
1544
+ const name = error && typeof error === "object" && "name" in error ? error.name : "";
1545
+ const code = error && typeof error === "object" && "code" in error ? error.code : "";
1546
+ return {
1547
+ sessionId,
1548
+ reason: name === "SessionPersistenceCorruptionError" || name === "SessionFormatError" ? "corrupt" : name === "SessionPersistenceNotFoundError" || code === "ENOENT" ? "missing" : "unavailable"
1549
+ };
1550
+ }
1551
+ async function readSession(persistence, id, header, live, signal) {
1552
+ signal?.throwIfAborted();
1553
+ if (typeof live?.snapshotEvents === "function" && typeof live.inheritedEventCount === "number") return {
1554
+ id,
1555
+ header,
1556
+ live,
1557
+ events: live.snapshotEvents(),
1558
+ inheritedCut: inheritedCount(live.inheritedEventCount)
1559
+ };
1560
+ if (typeof persistence.open === "function") {
1561
+ const options = signal === void 0 ? void 0 : { signal };
1562
+ const handle = await persistence.open(id, "read", options);
1563
+ try {
1564
+ const events = [];
1565
+ for (let offset = 0;;) {
1566
+ signal?.throwIfAborted();
1567
+ const page = await handle.read(offset, 500, options);
1568
+ if (!page.events.length) break;
1569
+ for (const event of page.events) events.push(event);
1570
+ offset += page.events.length;
1571
+ }
1572
+ return {
1573
+ id,
1574
+ header: header ?? handle.header,
1575
+ live,
1576
+ events,
1577
+ inheritedCut: inheritedCount(handle.inheritedEventCount)
1578
+ };
1579
+ } finally {
1580
+ await handle.close().catch(() => {});
1581
+ }
1582
+ }
1583
+ if (typeof persistence.inspect === "function") {
1584
+ const inspection = await persistence.inspect(id, signal);
1585
+ signal?.throwIfAborted();
1586
+ return {
1587
+ id,
1588
+ header: header ?? inspection.meta,
1589
+ live,
1590
+ events: inspection.events,
1591
+ inheritedCut: inheritedCount(inspection.inheritedEventCount)
1592
+ };
1593
+ }
1594
+ throw new Error("no readable persistence seam (need open or inspect)");
1595
+ }
1596
+ async function readUsageTree(persistence, sessions, rootId, signal) {
1597
+ signal?.throwIfAborted();
1598
+ const rows = await persistence.list(typeof persistence.open === "function" ? signal === void 0 ? void 0 : { signal } : signal);
1599
+ const headers = /* @__PURE__ */ new Map();
1600
+ for (const row of rows) {
1601
+ const header = headerOf(row);
1602
+ if (header) headers.set(header.id, header);
1603
+ }
1604
+ const live = new Map((sessions?.list() ?? []).map((handle) => [handle.id, handle]));
1605
+ const children = /* @__PURE__ */ new Map();
1606
+ const link = (id, parent) => {
1607
+ if (typeof parent !== "string" || parent === "" || parent === id) return;
1608
+ const bucket = children.get(parent) ?? /* @__PURE__ */ new Set();
1609
+ bucket.add(id);
1610
+ children.set(parent, bucket);
1611
+ };
1612
+ for (const header of headers.values()) link(header.id, header.parentSession);
1613
+ for (const handle of live.values()) link(handle.id, handle.header?.parentSession ?? handle.parentSession);
1614
+ const ids = [];
1615
+ const seen = /* @__PURE__ */ new Set([rootId]);
1616
+ const queue = [rootId];
1617
+ for (let index = 0; index < queue.length; index++) for (const id of children.get(queue[index]) ?? []) {
1618
+ if (seen.has(id)) continue;
1619
+ seen.add(id);
1620
+ ids.push(id);
1621
+ queue.push(id);
1622
+ }
1623
+ const root = await readSession(persistence, rootId, headers.get(rootId), live.get(rootId), signal);
1624
+ const logs = new Array(ids.length);
1625
+ const issues = new Array(ids.length);
1626
+ let next = 0;
1627
+ const workers = await Promise.allSettled(Array.from({ length: Math.min(4, ids.length) }, async () => {
1628
+ for (;;) {
1629
+ signal?.throwIfAborted();
1630
+ const index = next++;
1631
+ if (index >= ids.length) return;
1632
+ const id = ids[index];
1633
+ try {
1634
+ logs[index] = await readSession(persistence, id, headers.get(id), live.get(id), signal);
1635
+ } catch (error) {
1636
+ throwIfCanceled(error, signal);
1637
+ issues[index] = readIssue(id, error);
1638
+ }
1639
+ }
1640
+ }));
1641
+ for (const worker of workers) if (worker.status === "rejected") throw worker.reason;
1642
+ signal?.throwIfAborted();
1643
+ const descendants = logs.filter((log) => log !== void 0);
1644
+ const unreadableSessions = issues.filter((issue) => issue !== void 0);
1645
+ return {
1646
+ root,
1647
+ descendants,
1648
+ coverage: {
1649
+ complete: unreadableSessions.length === 0,
1650
+ totalSessions: ids.length + 1,
1651
+ readableSessions: descendants.length + 1,
1652
+ unreadableSessions
1653
+ }
1654
+ };
1655
+ }
1656
+ //#endregion
1529
1657
  //#region src/context-window.ts
1530
1658
  /** ASCII-ish characters per token (the host estimator's ratio). */
1531
1659
  const ASCII_CHARS_PER_TOKEN = 4;
@@ -1995,7 +2123,7 @@ function mergeUsageCompositions(parts) {
1995
2123
  }
1996
2124
  //#endregion
1997
2125
  //#region src/session-detail.ts
1998
- const zero = () => ({
2126
+ const zero$1 = () => ({
1999
2127
  uncachedInputTokens: 0,
2000
2128
  outputTokens: 0,
2001
2129
  cacheReadTokens: 0,
@@ -2082,43 +2210,6 @@ function usageOutputTokens(x) {
2082
2210
  const value = (x?.usage ?? x)?.outputTokens;
2083
2211
  return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
2084
2212
  }
2085
- function headerOf$1(item) {
2086
- const x = item;
2087
- if (x?.header?.id && typeof x.header.id === "string") return x.header;
2088
- return typeof x?.id === "string" && x.id ? x : null;
2089
- }
2090
- async function read(p, id, live) {
2091
- if (live?.snapshotEvents && typeof live.inheritedEventCount === "number") return {
2092
- events: live.snapshotEvents(),
2093
- cut: Math.max(0, live.inheritedEventCount)
2094
- };
2095
- if (p.open) {
2096
- const h = await p.open(id, "read");
2097
- const out = [];
2098
- try {
2099
- for (let off = 0;;) {
2100
- const page = await h.read(off, 500);
2101
- if (!page.events.length) break;
2102
- out.push(...page.events);
2103
- off += page.events.length;
2104
- }
2105
- } finally {
2106
- await h.close().catch(() => {});
2107
- }
2108
- return {
2109
- events: out,
2110
- cut: Math.max(0, Number(h.inheritedEventCount) || 0)
2111
- };
2112
- }
2113
- if (p.inspect) {
2114
- const x = await p.inspect(id);
2115
- return {
2116
- events: x.events,
2117
- cut: Math.max(0, Number(x.inheritedEventCount) || 0)
2118
- };
2119
- }
2120
- throw new Error("no readable persistence seam (need open or inspect)");
2121
- }
2122
2213
  function nameOf(h, id) {
2123
2214
  const x = h;
2124
2215
  if (typeof x?.name === "string" && x.name) return x.name;
@@ -2126,10 +2217,13 @@ function nameOf(h, id) {
2126
2217
  if (typeof x?.cwd === "string" && x.cwd) return x.cwd.split(/[\\/]/).filter(Boolean).pop() || id;
2127
2218
  return id;
2128
2219
  }
2129
- async function one(p, id, header, live) {
2130
- const { events, cut } = await read(p, id, live);
2220
+ function ownEventsOf(log) {
2221
+ return log.events.filter((e) => !(log.inheritedCut > 0 && typeof e.seq === "number" && e.seq < log.inheritedCut));
2222
+ }
2223
+ function one(log, parentCatalogLabel) {
2224
+ const { id, header, live } = log;
2131
2225
  const fold = new UsageFold();
2132
- const totals = zero();
2226
+ const totals = zero$1();
2133
2227
  const records = [];
2134
2228
  const byKey = /* @__PURE__ */ new Map();
2135
2229
  let route = live?.requestContext?.() ?? {};
@@ -2138,15 +2232,14 @@ async function one(p, id, header, live) {
2138
2232
  let effort = "";
2139
2233
  let pendingTopic = "";
2140
2234
  const turnTopics = /* @__PURE__ */ new Map();
2141
- const own = events.filter((e) => !(cut > 0 && typeof e.seq === "number" && e.seq < cut));
2235
+ const own = ownEventsOf(log);
2142
2236
  let label = "", title = "";
2143
2237
  for (const ev of own) {
2144
2238
  const d = ev.data ?? {};
2145
2239
  if (!label && ev.type === "subagent/descriptor" && typeof d.label === "string" && d.label.trim()) label = d.label.trim();
2146
- if (!title && ev.type === "session/title" && typeof d.title === "string" && d.title.trim()) title = d.title.trim();
2147
- if (label && title) break;
2240
+ if (ev.type === "session/title" && typeof d.title === "string" && d.title.trim()) title = d.title.trim();
2148
2241
  }
2149
- const name = label || title || nameOf(header, id);
2242
+ const name = label || title || parentCatalogLabel || nameOf(header, id);
2150
2243
  const stepTokens = /* @__PURE__ */ new Map();
2151
2244
  const closedSteps = /* @__PURE__ */ new Set();
2152
2245
  for (const ev of own) {
@@ -2205,14 +2298,14 @@ async function one(p, id, header, live) {
2205
2298
  provider: r.provider || route.provider || "",
2206
2299
  model: r.model || route.model || "",
2207
2300
  status: "running",
2208
- totals: zero(),
2301
+ totals: zero$1(),
2209
2302
  firstTokenMs: null,
2210
2303
  decodeMs: null,
2211
2304
  tokensPerSecond: null,
2212
2305
  turnName: "",
2213
2306
  effort,
2214
2307
  _attempt: ev.type === "llm/retry-started" ? 1 : 0,
2215
- _lastUsage: zero()
2308
+ _lastUsage: zero$1()
2216
2309
  };
2217
2310
  records.push(rec);
2218
2311
  byKey.set(key, rec);
@@ -2232,14 +2325,14 @@ async function one(p, id, header, live) {
2232
2325
  provider: r.provider || route.provider || "",
2233
2326
  model: r.model || route.model || "",
2234
2327
  status: "running",
2235
- totals: zero(),
2328
+ totals: zero$1(),
2236
2329
  firstTokenMs: null,
2237
2330
  decodeMs: null,
2238
2331
  tokensPerSecond: null,
2239
2332
  turnName: "",
2240
2333
  effort,
2241
2334
  _attempt: 0,
2242
- _lastUsage: zero()
2335
+ _lastUsage: zero$1()
2243
2336
  };
2244
2337
  records.push(rec);
2245
2338
  byKey.set(key, rec);
@@ -2326,32 +2419,30 @@ async function one(p, id, header, live) {
2326
2419
  };
2327
2420
  }
2328
2421
  async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
2329
- const headers = (await persistence.list(typeof persistence.open === "function" ? void 0 : void 0)).map(headerOf$1).filter((x) => x !== null);
2330
- const live = new Map((sessions?.list() ?? []).map((x) => [x.id, x]));
2331
- const children = /* @__PURE__ */ new Map();
2332
- for (const h of headers) if (h.parentSession) children.set(h.parentSession, [...children.get(h.parentSession) ?? [], h.id]);
2333
- for (const h of live.values()) {
2334
- const p = h.header?.parentSession ?? h.parentSession;
2335
- if (p) children.set(p, [...(children.get(p) ?? []).filter((x) => x !== h.id), h.id]);
2422
+ const { root, descendants, coverage } = await readUsageTree(persistence, sessions, rootId);
2423
+ const logs = [root, ...descendants];
2424
+ const catalogLabels = /* @__PURE__ */ new Map();
2425
+ for (const log of logs) {
2426
+ const labels = /* @__PURE__ */ new Map();
2427
+ for (const event of ownEventsOf(log)) {
2428
+ if (event.type !== "subagent/catalog") continue;
2429
+ const data = event.data;
2430
+ if (typeof data?.childId === "string" && data.childId && typeof data.label === "string" && data.label.trim()) labels.set(data.childId, data.label.trim());
2431
+ }
2432
+ catalogLabels.set(log.id, labels);
2336
2433
  }
2337
- const ids = [];
2338
- const seen = /* @__PURE__ */ new Set([rootId]);
2339
- const q = [rootId];
2340
- while (q.length) for (const c of children.get(q.shift()) ?? []) if (!seen.has(c)) {
2341
- seen.add(c);
2342
- ids.push(c);
2343
- q.push(c);
2344
- }
2345
- const all = [rootId, ...ids];
2346
- const parts = await Promise.all(all.map((id) => one(persistence, id, headers.find((h) => h.id === id), live.get(id))));
2347
- const totals = zero();
2434
+ const parts = logs.map((log) => {
2435
+ const parentId = log.header?.parentSession ?? log.live?.header?.parentSession ?? log.live?.parentSession;
2436
+ return one(log, parentId === void 0 ? void 0 : catalogLabels.get(parentId)?.get(log.id));
2437
+ });
2438
+ const totals = zero$1();
2348
2439
  const models = /* @__PURE__ */ new Map();
2349
2440
  const records = parts.flatMap((x) => x.records);
2350
2441
  for (const p of parts) add$1(totals, p.totals);
2351
2442
  for (const r of records) {
2352
2443
  const key = r.model || "(unknown)";
2353
2444
  const m = models.get(key) ?? {
2354
- totals: zero(),
2445
+ totals: zero$1(),
2355
2446
  requests: 0,
2356
2447
  providers: /* @__PURE__ */ new Set()
2357
2448
  };
@@ -2405,6 +2496,7 @@ async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
2405
2496
  const firsts = records.filter((x) => x.status === "completed").map((x) => x.firstTokenMs).filter((x) => x !== null);
2406
2497
  return {
2407
2498
  sessionId: rootId,
2499
+ coverage,
2408
2500
  totals,
2409
2501
  requests: parts.reduce((n, p) => n + p.requests, 0),
2410
2502
  averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null,
@@ -2811,82 +2903,25 @@ function buildUsageRoute(ctx, deps) {
2811
2903
  }
2812
2904
  //#endregion
2813
2905
  //#region src/session-usage.ts
2814
- const ZERO = {
2906
+ const zero = () => ({
2815
2907
  uncachedInputTokens: 0,
2816
2908
  outputTokens: 0,
2817
2909
  cacheReadTokens: 0,
2818
2910
  cacheWriteTokens: 0
2819
- };
2911
+ });
2820
2912
  function add(into, from) {
2821
2913
  into.uncachedInputTokens += from.uncachedInputTokens;
2822
2914
  into.outputTokens += from.outputTokens;
2823
2915
  into.cacheReadTokens += from.cacheReadTokens;
2824
2916
  into.cacheWriteTokens += from.cacheWriteTokens;
2825
2917
  }
2826
- /** How long one computed tree stays hot. The pill refreshes on its own clock,
2827
- * and a live session's numbers move at step granularity, not per frame. */
2828
- const CACHE_TTL_MS = 4e3;
2829
- /** Absolute ceiling on cached trees, so a long-lived host with many sessions
2830
- * cannot grow this map without bound. */
2831
- const CACHE_MAX = 64;
2832
- /** Normalize a `list()` row (alpha snapshots or rc.1 header rows). */
2833
- function headerOf(item) {
2834
- const snap = item;
2835
- if (snap !== null && typeof snap === "object" && "header" in snap && snap.header !== null && typeof snap.header === "object" && typeof snap.header.id === "string") return snap.header;
2836
- const row = item;
2837
- return typeof row?.id === "string" && row.id !== "" ? row : null;
2838
- }
2839
- function inheritedCount(cut) {
2840
- return typeof cut === "number" && Number.isSafeInteger(cut) && cut >= 0 ? cut : 0;
2841
- }
2842
- /** Read one stored session's events through whichever persistence seam the
2843
- * host line exposes (alpha open/read/close wins; rc.1 inspect is the tail). */
2844
- async function readEvents(persistence, id, signal) {
2845
- if (typeof persistence.open === "function") {
2846
- const handle = await persistence.open(id, "read", signal === void 0 ? void 0 : { signal });
2847
- try {
2848
- const inheritedCut = inheritedCount(handle.inheritedEventCount);
2849
- const collected = [];
2850
- let offset = 0;
2851
- const PAGE = 500;
2852
- for (;;) {
2853
- signal?.throwIfAborted();
2854
- const slice = await handle.read(offset, PAGE, signal === void 0 ? void 0 : { signal });
2855
- const page = slice && slice.events ? slice.events : [];
2856
- if (page.length === 0) break;
2857
- for (const ev of page) collected.push(ev);
2858
- offset += page.length;
2859
- }
2860
- return {
2861
- events: collected,
2862
- inheritedCut
2863
- };
2864
- } finally {
2865
- await handle.close().catch(() => {});
2866
- }
2867
- }
2868
- if (typeof persistence.inspect === "function") {
2869
- const inspection = await persistence.inspect(id, signal);
2870
- return {
2871
- events: inspection.events,
2872
- inheritedCut: inheritedCount(inspection.inheritedEventCount)
2873
- };
2874
- }
2875
- throw new Error("no readable persistence seam (need open or inspect)");
2876
- }
2877
- /** Sum one session's own usage (its main agent) from its log. */
2878
- async function sumOneSession(persistence, id, signal, live) {
2879
- signal?.throwIfAborted();
2880
- const { events, inheritedCut } = typeof live?.snapshotEvents === "function" && typeof live.inheritedEventCount === "number" ? {
2881
- events: live.snapshotEvents(),
2882
- inheritedCut: inheritedCount(live.inheritedEventCount)
2883
- } : await readEvents(persistence, id, signal);
2918
+ function sumOneSession(log) {
2884
2919
  const fold = new UsageFold();
2885
- const totals = { ...ZERO };
2886
- const compaction = { ...ZERO };
2887
- for (const ev of events) {
2888
- if (inheritedCut > 0 && typeof ev.seq === "number" && ev.seq < inheritedCut) continue;
2889
- const sample = fold.fold(ev);
2920
+ const totals = zero();
2921
+ const compaction = zero();
2922
+ for (const event of log.events) {
2923
+ if (event.seq < log.inheritedCut) continue;
2924
+ const sample = fold.fold(event);
2890
2925
  if (sample === null || sample.turn) continue;
2891
2926
  const buckets = {
2892
2927
  uncachedInputTokens: sample.inputTokens,
@@ -2895,77 +2930,47 @@ async function sumOneSession(persistence, id, signal, live) {
2895
2930
  cacheWriteTokens: sample.cacheWriteTokens
2896
2931
  };
2897
2932
  add(totals, buckets);
2898
- if (ev.type === "compaction/summary") add(compaction, buckets);
2933
+ if (event.type === "compaction/summary") add(compaction, buckets);
2899
2934
  }
2900
2935
  return {
2901
2936
  totals,
2902
2937
  compaction
2903
2938
  };
2904
2939
  }
2940
+ const CACHE_TTL_MS = 4e3;
2941
+ const CACHE_MAX = 64;
2905
2942
  var SessionUsageService = class {
2906
- /** Short-lived tree reads; any live event can invalidate tree membership. */
2907
2943
  cache = /* @__PURE__ */ new Map();
2908
- /** Any event can introduce a new descendant while an async read is running. */
2909
2944
  generation = 0;
2910
- /** Invalidate conservatively: an unseen session may be a newly-created child
2911
- * of ANY cached root, so membership-only invalidation is not safe. */
2945
+ /** New events can introduce descendants of any cached root. */
2912
2946
  invalidate(_sessionId) {
2913
2947
  this.generation++;
2914
2948
  this.cache.clear();
2915
2949
  }
2916
- /** Resolve the whole session tree rooted at `rootId` and sum its usage.
2917
- * `self` is the root's own usage, `subagents` every descendant's, `total`
2918
- * the sum. Unreadable logs reject rather than publishing partial totals;
2919
- * callers can retain their last good display while surfacing the error. */
2950
+ /** A bad descendant leaves the readable totals available with coverage.
2951
+ * Never cache incomplete results: a repaired log must recover on the next read.
2952
+ * Root, catalog and cancellation failures still reject without publishing zero. */
2920
2953
  async tree(persistence, sessions, rootId, signal) {
2921
2954
  signal?.throwIfAborted();
2922
2955
  const hit = this.cache.get(rootId);
2923
2956
  if (hit !== void 0 && Date.now() - hit.at < CACHE_TTL_MS) return hit.value;
2924
2957
  const generation = this.generation;
2925
- const headers = await this.listHeaders(persistence, signal);
2926
- const children = /* @__PURE__ */ new Map();
2927
- for (const header of headers) {
2928
- const parent = header.parentSession;
2929
- if (typeof parent !== "string" || parent === "") continue;
2930
- const bucket = children.get(parent);
2931
- if (bucket === void 0) children.set(parent, [header.id]);
2932
- else bucket.push(header.id);
2933
- }
2934
- const live = new Map((sessions?.list() ?? []).map((handle) => [handle.id, handle]));
2935
- for (const handle of live.values()) {
2936
- const parent = handle.header?.parentSession ?? handle.parentSession;
2937
- if (typeof parent !== "string" || parent === "" || parent === handle.id) continue;
2938
- const bucket = children.get(parent);
2939
- if (bucket === void 0) children.set(parent, [handle.id]);
2940
- else if (!bucket.includes(handle.id)) bucket.push(handle.id);
2941
- }
2942
- const descendants = [];
2943
- const seen = /* @__PURE__ */ new Set([rootId]);
2944
- const queue = [rootId];
2945
- while (queue.length > 0) {
2946
- const current = queue.shift();
2947
- for (const child of children.get(current) ?? []) {
2948
- if (seen.has(child)) continue;
2949
- seen.add(child);
2950
- descendants.push(child);
2951
- queue.push(child);
2952
- }
2953
- }
2954
- const root = await sumOneSession(persistence, rootId, signal, live.get(rootId));
2955
- const self = root.totals;
2956
- const subagents = { ...ZERO };
2957
- for (const id of descendants) add(subagents, (await sumOneSession(persistence, id, signal, live.get(id))).totals);
2958
- const total = { ...self };
2958
+ const { root, descendants, coverage } = await readUsageTree(persistence, sessions, rootId, signal);
2959
+ const own = sumOneSession(root);
2960
+ const subagents = zero();
2961
+ for (const log of descendants) add(subagents, sumOneSession(log).totals);
2962
+ const total = { ...own.totals };
2959
2963
  add(total, subagents);
2960
2964
  const value = {
2961
- self,
2962
- selfCompaction: root.compaction,
2965
+ self: own.totals,
2966
+ selfCompaction: own.compaction,
2963
2967
  subagents,
2964
2968
  total,
2965
- subagentSessions: descendants.length
2969
+ subagentSessions: descendants.length,
2970
+ coverage
2966
2971
  };
2967
2972
  signal?.throwIfAborted();
2968
- if (generation === this.generation) {
2973
+ if (coverage.complete && generation === this.generation) {
2969
2974
  if (this.cache.size >= CACHE_MAX) this.cache.clear();
2970
2975
  this.cache.set(rootId, {
2971
2976
  at: Date.now(),
@@ -2974,9 +2979,6 @@ var SessionUsageService = class {
2974
2979
  }
2975
2980
  return value;
2976
2981
  }
2977
- async listHeaders(persistence, signal) {
2978
- return (typeof persistence.open === "function" ? await persistence.list(signal === void 0 ? void 0 : { signal }) : await persistence.list(signal)).map(headerOf).filter((h) => h !== null);
2979
- }
2980
2982
  };
2981
2983
  //#endregion
2982
2984
  //#region src/index.ts
@@ -7,7 +7,7 @@
7
7
  /** The locale namespace this plugin owns. */
8
8
  export declare const LOCALE_NS = "tokenTotal";
9
9
  /** The typed translator key union for the panel's locale seat. */
10
- export type TokenTotalKey = 'nav' | 'back' | 'range' | 'rangePreset.today' | 'rangePreset.3' | 'rangePreset.7' | 'rangePreset.30' | 'rangePreset.90' | 'rangeCustom' | 'from' | 'to' | 'refresh' | 'loading' | 'tokens' | 'tokensHint' | 'tokensUnit' | 'inputTokens' | 'outputTokens' | 'cacheTokens' | 'cachedTokens' | 'sessions' | 'requests' | 'requestsFailed' | 'activeDays' | 'cacheRate' | 'cacheRateHint' | 'cacheHitRate' | 'hitRateLegend' | 'topModel' | 'topModelHint' | 'heatmap' | 'heatLess' | 'heatMore' | 'dow.mon' | 'dow.tue' | 'dow.wed' | 'dow.thu' | 'dow.fri' | 'dow.sat' | 'dow.sun' | 'month.jan' | 'month.feb' | 'month.mar' | 'month.apr' | 'month.may' | 'month.jun' | 'month.jul' | 'month.aug' | 'month.sep' | 'month.oct' | 'month.nov' | 'month.dec' | 'dailyTrend' | 'hourlyTrend' | 'trendLimited' | 'usageRanking' | 'byModel' | 'byProvider' | 'tokensColumn' | 'cacheRateColumn' | 'providerModels' | 'other' | 'total' | 'percent' | 'asOf' | 'empty' | 'stats.counts' | 'stats.tokensPerSecond' | 'stats.cacheHit' | 'stats.cacheMiss' | 'stats.dialog.title' | 'stats.dialog.usageTitle' | 'stats.dialog.llmTime' | 'stats.dialog.toolTime' | 'stats.dialog.ttft' | 'stats.dialog.speed' | 'stats.dialog.main' | 'stats.dialog.subagents' | 'stats.dialog.subagentCount' | 'stats.dialog.treeTotal' | 'message.turnUsage.count' | 'message.turnUsage.cacheHit' | 'message.turnUsage.input' | 'message.turnUsage.cacheRead' | 'message.turnUsage.cacheWrite' | 'message.turnUsage.output';
10
+ export type TokenTotalKey = 'nav' | 'back' | 'range' | 'rangePreset.today' | 'rangePreset.3' | 'rangePreset.7' | 'rangePreset.30' | 'rangePreset.90' | 'rangeCustom' | 'from' | 'to' | 'refresh' | 'loading' | 'tokens' | 'tokensHint' | 'tokensUnit' | 'inputTokens' | 'outputTokens' | 'cacheTokens' | 'cachedTokens' | 'sessions' | 'requests' | 'requestsFailed' | 'activeDays' | 'cacheRate' | 'cacheRateHint' | 'cacheHitRate' | 'hitRateLegend' | 'topModel' | 'topModelHint' | 'heatmap' | 'heatLess' | 'heatMore' | 'dow.mon' | 'dow.tue' | 'dow.wed' | 'dow.thu' | 'dow.fri' | 'dow.sat' | 'dow.sun' | 'month.jan' | 'month.feb' | 'month.mar' | 'month.apr' | 'month.may' | 'month.jun' | 'month.jul' | 'month.aug' | 'month.sep' | 'month.oct' | 'month.nov' | 'month.dec' | 'dailyTrend' | 'hourlyTrend' | 'trendLimited' | 'usageRanking' | 'byModel' | 'byProvider' | 'tokensColumn' | 'cacheRateColumn' | 'providerModels' | 'other' | 'total' | 'percent' | 'asOf' | 'empty' | 'stats.counts' | 'stats.tokensPerSecond' | 'stats.cacheHit' | 'stats.cacheMiss' | 'stats.dialog.title' | 'stats.dialog.usageTitle' | 'stats.partial' | 'stats.incomplete' | 'stats.dialog.llmTime' | 'stats.dialog.toolTime' | 'stats.dialog.ttft' | 'stats.dialog.speed' | 'stats.dialog.main' | 'stats.dialog.subagents' | 'stats.dialog.subagentCount' | 'stats.dialog.treeTotal' | 'message.turnUsage.count' | 'message.turnUsage.cacheHit' | 'message.turnUsage.input' | 'message.turnUsage.cacheRead' | 'message.turnUsage.cacheWrite' | 'message.turnUsage.output';
11
11
  export declare const en: Record<TokenTotalKey, string>;
12
12
  export declare const zh: Record<TokenTotalKey, string>;
13
13
  export declare const zhTW: Record<TokenTotalKey, string>;
@@ -32,6 +32,8 @@ export declare const usageZh: {
32
32
  noModels: string;
33
33
  scope: string;
34
34
  note: string;
35
+ partialCoverage: string;
36
+ partialTotals: string;
35
37
  failedLoad: string;
36
38
  retry: string;
37
39
  completed: string;
@@ -0,0 +1,16 @@
1
+ import type { UsageSessionEvent, UsageSessionHandle, UsageSessionHeader, UsageSessionPersistence, UsageSessionStore } from './context-types.ts';
2
+ import type { SessionUsageCoverage } from './wire.ts';
3
+ /** Shared membership, ownership and failure semantics for both usage APIs.
4
+ * A bad descendant is isolated; root, catalog and cancellation failures reject. */
5
+ export interface UsageSessionLog {
6
+ id: string;
7
+ header?: UsageSessionHeader;
8
+ live?: UsageSessionHandle;
9
+ events: readonly UsageSessionEvent[];
10
+ inheritedCut: number;
11
+ }
12
+ export declare function readUsageTree(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, signal?: AbortSignal): Promise<{
13
+ root: UsageSessionLog;
14
+ descendants: UsageSessionLog[];
15
+ coverage: SessionUsageCoverage;
16
+ }>;
@@ -1,42 +1,14 @@
1
- /**
2
- * Per-session usage aggregation, INCLUDING subagent sessions.
3
- *
4
- * Why this exists: dsh's native `tokenUsage` projection covers only the main
5
- * agent of the current session — a session that delegates to subagents (whose
6
- * own logs are separate persisted sessions, linked by `header.parentSession`)
7
- * shows only a fraction of what it actually spent. This module answers "what
8
- * did this whole session tree cost".
9
- *
10
- * The store has no per-session dimension (it accumulates per day x model), so
11
- * this is computed ON DEMAND from the session logs: the root session plus every
12
- * descendant whose `parentSession` chain leads back to it, each replayed
13
- * through a fresh {@link UsageFold} (the same dedupe rules the backfill uses).
14
- *
15
- * Two correctness rules carried over from the backfill engine:
16
- * - a forked log begins with the events it copied from its parent
17
- * (`handle.inheritedEventCount`); that prefix is skipped, the parent owns it;
18
- * - the fold dedupes per (turn, step) within one session, so a streaming
19
- * sample followed by the final message counts once.
20
- *
21
- * Results are cached briefly: the composer pill polls, and replaying a long log
22
- * per poll would be wasteful. The cache is invalidated for a session as soon as
23
- * a new event lands (see the collector's live subscription).
24
- */
1
+ /** Per-session owned usage, including every readable descendant. */
25
2
  import type { UsageSessionPersistence, UsageSessionStore } from './context-types.ts';
26
3
  import type { SessionUsageTree } from './wire.ts';
27
4
  export type { SessionUsageTotals, SessionUsageTree } from './wire.ts';
28
5
  export declare class SessionUsageService {
29
- /** Short-lived tree reads; any live event can invalidate tree membership. */
30
6
  private cache;
31
- /** Any event can introduce a new descendant while an async read is running. */
32
7
  private generation;
33
- /** Invalidate conservatively: an unseen session may be a newly-created child
34
- * of ANY cached root, so membership-only invalidation is not safe. */
8
+ /** New events can introduce descendants of any cached root. */
35
9
  invalidate(_sessionId: string): void;
36
- /** Resolve the whole session tree rooted at `rootId` and sum its usage.
37
- * `self` is the root's own usage, `subagents` every descendant's, `total`
38
- * the sum. Unreadable logs reject rather than publishing partial totals;
39
- * callers can retain their last good display while surfacing the error. */
10
+ /** A bad descendant leaves the readable totals available with coverage.
11
+ * Never cache incomplete results: a repaired log must recover on the next read.
12
+ * Root, catalog and cancellation failures still reject without publishing zero. */
40
13
  tree(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, signal?: AbortSignal): Promise<SessionUsageTree>;
41
- private listHeaders;
42
14
  }
@@ -109,6 +109,20 @@ export interface SessionUsageTotals {
109
109
  cacheReadTokens: number;
110
110
  cacheWriteTokens: number;
111
111
  }
112
+ /** A descendant that could not be read. Reasons are stable and sanitized;
113
+ * exception messages and filesystem paths never cross the wire. */
114
+ export interface SessionUsageReadIssue {
115
+ sessionId: string;
116
+ reason: 'corrupt' | 'missing' | 'unavailable';
117
+ }
118
+ /** A partial response is usable, but its totals are explicitly not the whole
119
+ * tree. The root is always readable; older hosts may omit this field. */
120
+ export interface SessionUsageCoverage {
121
+ complete: boolean;
122
+ totalSessions: number;
123
+ readableSessions: number;
124
+ unreadableSessions: SessionUsageReadIssue[];
125
+ }
112
126
  /** What one session tree cost, split into the main agent and its subagents.
113
127
  * The composer stats row renders this so a delegating session shows its whole
114
128
  * spend (the native `tokenUsage` projection only covers the main agent). */
@@ -124,6 +138,7 @@ export interface SessionUsageTree {
124
138
  total: SessionUsageTotals;
125
139
  /** How many descendant sessions were counted. */
126
140
  subagentSessions: number;
141
+ coverage?: SessionUsageCoverage;
127
142
  }
128
143
  /** Request body of POST /token-total/api/session. */
129
144
  export interface SessionUsageRequest {
@@ -132,6 +147,7 @@ export interface SessionUsageRequest {
132
147
  /** Detailed per-session usage view used by the Usage tab. */
133
148
  export interface SessionUsageDetail {
134
149
  sessionId: string;
150
+ coverage?: SessionUsageCoverage;
135
151
  totals: SessionUsageTotals;
136
152
  requests: number;
137
153
  averageFirstTokenMs: number | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-token-total",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "DSH web plugin: token 用量统计看板 + 单会话(主智能体 + 子智能体)用量统计。提供今日/近3天/近7天/近1月/近3月/自定义筛选、六个汇总卡片、活跃热力图、缓存命中率折线与分模型柱状图、按模型/按供应商 Top5 列表。",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
@@ -205,7 +205,12 @@ function Records({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Eleme
205
205
  /** Pure presentation export also used by offline visual regression fixtures. */
206
206
  export function SessionUsageContent({ detail, t, updatedAt }: { detail: SessionUsageDetail; t: T; updatedAt?: number | null }): JSX.Element {
207
207
  return <>
208
- <p className={css.scope}>{t('scope')}</p><Metrics detail={detail} t={t} /><Tables detail={detail} t={t} />
208
+ <p className={css.scope}>{t('scope')}</p>
209
+ {detail.coverage?.complete === false && <div className={css.error} role="status" data-session-usage-partial>
210
+ {t('partialCoverage', { count: String(detail.coverage.unreadableSessions.length) })}
211
+ <span title={detail.coverage.unreadableSessions.map(issue => issue.sessionId).join('\n')}>{t('partialTotals')}</span>
212
+ </div>}
213
+ <Metrics detail={detail} t={t} /><Tables detail={detail} t={t} />
209
214
  {/* The two live cards sit between the per-subagent/model tables and the
210
215
  request records: the left one describes what the next request carries
211
216
  right now, the right one what the whole session spent, while everything