dsh-token-total 1.0.7 → 1.0.9

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
@@ -772,8 +772,16 @@ var UsageStore = class {
772
772
  /** Fold ONLY the failure count of a sample into the day and hour rows. The
773
773
  * failure upgrade pass uses this: its replay walks logs the token passes
774
774
  * already consumed, so the tokens and the request marker must not be added
775
- * a second time — only the failed-call counter may move. */
775
+ * a second time — only the failed-call counter may move.
776
+ *
777
+ * A sample WITHOUT the `failed` marker counts NOTHING here. The pass folds
778
+ * whole session logs, which are mostly successful calls: passing every
779
+ * folded sample through a write that hardcodes `failed: true` would count
780
+ * each call's request marker AND token sample as two extra "failures" —
781
+ * exactly how stores upgrading through v1.0.2–v1.0.8 ended up with
782
+ * failures ≈ 2 × requests on every pre-upgrade day ("失败的比总的都多"). */
776
783
  async recordFailure(sample) {
784
+ if (!sample.failed) return;
777
785
  await this.record({
778
786
  day: sample.day,
779
787
  hour: sample.hour,
@@ -1316,7 +1324,7 @@ var UsageCollector = class UsageCollector {
1316
1324
  const concurrency = this.options.backfillConcurrency ?? 4;
1317
1325
  let next = 0;
1318
1326
  const completed = [];
1319
- const writeSample = target === "hours" ? (sample) => this.store.recordHours(sample) : target === "failures" ? (sample) => this.store.recordFailure(sample) : (sample) => this.store.record(sample);
1327
+ const writeSample = target === "hours" ? (sample) => this.store.recordHours(sample) : target === "failures" ? (sample) => sample.failed ? this.store.recordFailure(sample) : Promise.resolve() : (sample) => this.store.record(sample);
1320
1328
  const flushCompleted = async () => {
1321
1329
  if (completed.length === 0) return;
1322
1330
  const batch = completed.splice(0, completed.length);
@@ -1518,6 +1526,273 @@ async function resolveModelNames(modelRefs, llm) {
1518
1526
  return names;
1519
1527
  }
1520
1528
  //#endregion
1529
+ //#region src/context-window.ts
1530
+ /** ASCII-ish characters per token (the host estimator's ratio). */
1531
+ const ASCII_CHARS_PER_TOKEN = 4;
1532
+ /** Wide (CJK, kana, hangul) characters per token: these bill ~1 token/char. */
1533
+ const WIDE_CHARS_PER_TOKEN = 1.5;
1534
+ /** A supplementary-plane emoji is worth a few wide characters. */
1535
+ const EMOJI_TOKEN_EQUIVALENT = 3;
1536
+ /** Serialized JSON punctuation/keys cost a token or two per schema node. */
1537
+ const TOOL_SCHEMA_OVERHEAD_TOKENS = 8;
1538
+ /** Per-message framing the provider bills on top of the content. */
1539
+ const MESSAGE_OVERHEAD_TOKENS = 4;
1540
+ /** A rendered image is billed in tiles, not characters. */
1541
+ const IMAGE_TOKENS = 1100;
1542
+ /** A category keeps its biggest rows until this share of its tokens is covered. */
1543
+ const KEEP_SHARE = .85;
1544
+ /** Hard cap on rows kept per category before the rest folds into one block. */
1545
+ const MAX_ROWS_PER_CATEGORY = 10;
1546
+ /** Tool names whose results are file payloads (they group by path). */
1547
+ const FILE_TOOLS = /* @__PURE__ */ new Set([
1548
+ "read",
1549
+ "write",
1550
+ "edit",
1551
+ "multiedit",
1552
+ "notebook_read",
1553
+ "notebook_edit"
1554
+ ]);
1555
+ /** Legend/treemap order; also the tie-break for equal-sized rows. */
1556
+ const CATEGORY_ORDER = [
1557
+ "system",
1558
+ "tools",
1559
+ "memory",
1560
+ "files",
1561
+ "results",
1562
+ "conversation"
1563
+ ];
1564
+ /** Approximate token count of one string (see the header comment). */
1565
+ function estimateTokens(text) {
1566
+ if (text === "") return 0;
1567
+ let ascii = 0;
1568
+ let wide = 0;
1569
+ for (const ch of text) {
1570
+ const code = ch.codePointAt(0) ?? 0;
1571
+ if (code >= 126976 && code <= 129791) wide += EMOJI_TOKEN_EQUIVALENT;
1572
+ else if (code < 11904) ascii++;
1573
+ else wide++;
1574
+ }
1575
+ return Math.ceil(ascii / ASCII_CHARS_PER_TOKEN + wide / WIDE_CHARS_PER_TOKEN);
1576
+ }
1577
+ /** Approximate tokens of any JSON-ish value, counting its strings only. */
1578
+ function valueTokens(value) {
1579
+ if (typeof value === "string") return estimateTokens(value);
1580
+ if (typeof value === "number" || typeof value === "boolean") return 1;
1581
+ if (Array.isArray(value)) return value.reduce((sum, item) => sum + valueTokens(item), 0);
1582
+ if (value !== null && typeof value === "object") {
1583
+ let sum = 0;
1584
+ for (const [key, item] of Object.entries(value)) {
1585
+ if (key === "replayState" || key === "thinkingSignature" || key === "textSignature") continue;
1586
+ sum += valueTokens(item);
1587
+ }
1588
+ return sum;
1589
+ }
1590
+ return 0;
1591
+ }
1592
+ /** Approximate tokens of one replayed message (content + framing). */
1593
+ function messageTokens(message) {
1594
+ const content = message?.content;
1595
+ const blocks = Array.isArray(content) ? content : content === void 0 ? [] : [content];
1596
+ let sum = MESSAGE_OVERHEAD_TOKENS;
1597
+ for (const block of blocks) {
1598
+ const kind = block?.type;
1599
+ if (kind === "image" || kind === "image-url") {
1600
+ sum += IMAGE_TOKENS;
1601
+ continue;
1602
+ }
1603
+ if (kind === "text" && typeof block.text === "string") {
1604
+ sum += valueTokens(block.text);
1605
+ continue;
1606
+ }
1607
+ sum += valueTokens(block);
1608
+ }
1609
+ return sum;
1610
+ }
1611
+ /** Prompt tokens one answer actually sent, as the provider billed it: its total
1612
+ * minus the output it generated, or the uncached plus cached input when the
1613
+ * provider reported no total. Null when there is no usage at all. */
1614
+ function promptTokensOf(usage) {
1615
+ const record = usage;
1616
+ const total = Number(record?.totalTokens) || 0;
1617
+ const output = Number(record?.outputTokens) || 0;
1618
+ if (total > output) return total - output;
1619
+ const input = Number(record?.inputTokens) || 0;
1620
+ const cached = Number(record?.cacheReadTokens) || 0;
1621
+ return input + cached > 0 ? input + cached : null;
1622
+ }
1623
+ /** Fold one category's rows: keep the biggest until KEEP_SHARE is covered (and
1624
+ * never more than MAX_ROWS_PER_CATEGORY), then merge the tail into one row so
1625
+ * a session with 200 tool calls still yields a readable treemap. */
1626
+ function foldDrafts(drafts) {
1627
+ const items = [];
1628
+ for (const category of CATEGORY_ORDER) {
1629
+ const rows = drafts.filter((row) => row.category === category).sort((a, b) => b.tokens - a.tokens);
1630
+ if (rows.length === 0) continue;
1631
+ const total = rows.reduce((sum, row) => sum + row.tokens, 0);
1632
+ const kept = [];
1633
+ let covered = 0;
1634
+ for (const row of rows) {
1635
+ if (kept.length >= MAX_ROWS_PER_CATEGORY) break;
1636
+ if (kept.length > 0 && covered >= total * KEEP_SHARE) break;
1637
+ kept.push(row);
1638
+ covered += row.tokens;
1639
+ }
1640
+ const folded = rows.slice(kept.length);
1641
+ for (const row of kept) items.push({
1642
+ key: `${category}:${row.kind ?? row.label}`,
1643
+ category,
1644
+ ...row.kind === void 0 ? {} : { kind: row.kind },
1645
+ label: row.label,
1646
+ count: row.count,
1647
+ tokens: row.tokens
1648
+ });
1649
+ if (folded.length > 0) items.push({
1650
+ key: `${category}:rest`,
1651
+ category,
1652
+ kind: "rest",
1653
+ label: "",
1654
+ count: folded.length,
1655
+ tokens: folded.reduce((sum, row) => sum + row.tokens, 0)
1656
+ });
1657
+ }
1658
+ return items.sort((a, b) => b.tokens - a.tokens || CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category));
1659
+ }
1660
+ /** Build the context-window snapshot from one session's own events. Returns
1661
+ * null for an empty log so the panel can hide itself. */
1662
+ function buildSessionContextWindow(events) {
1663
+ if (events.length === 0) return null;
1664
+ const ordered = [...events].sort((a, b) => a.seq - b.seq);
1665
+ const shadowed = /* @__PURE__ */ new Set();
1666
+ const summaries = [];
1667
+ const callNames = /* @__PURE__ */ new Map();
1668
+ let tools = [];
1669
+ let headerRoute = null;
1670
+ let route = null;
1671
+ let cache = null;
1672
+ /** What the newest answer actually sent, which is what the window holds now. */
1673
+ let billed = null;
1674
+ for (const event of ordered) {
1675
+ const data = event.data ?? {};
1676
+ if (event.type === "compaction/prune" || event.type === "compaction/summary") {
1677
+ for (const seq of Array.isArray(data.shadowedSeqs) ? data.shadowedSeqs : []) shadowed.add(Number(seq));
1678
+ const range = data.shadowedRange;
1679
+ if (typeof range?.start === "number" && typeof range?.end === "number") for (let seq = range.start; seq <= range.end; seq++) shadowed.add(seq);
1680
+ if (event.type === "compaction/summary") {
1681
+ summaries.push({
1682
+ seq: event.seq,
1683
+ tokens: messageTokens({ content: data.summary })
1684
+ });
1685
+ const usage = data.usage;
1686
+ if (usage) {
1687
+ cache = {
1688
+ hit: Number(usage.cacheReadTokens) || 0,
1689
+ miss: (Number(usage.inputTokens) || 0) + (Number(usage.cacheWriteTokens) || 0)
1690
+ };
1691
+ const summaryBilled = promptTokensOf(usage);
1692
+ if (summaryBilled !== null) billed = summaryBilled;
1693
+ }
1694
+ }
1695
+ continue;
1696
+ }
1697
+ if (event.type === "tool/call" && typeof data.callId === "string") {
1698
+ callNames.set(data.callId, String(data.name ?? ""));
1699
+ continue;
1700
+ }
1701
+ if (event.type === "request/header") {
1702
+ const header = data.header ?? {};
1703
+ if (Array.isArray(header.tools)) tools = header.tools;
1704
+ const config = header.config ?? {};
1705
+ if (typeof config.provider === "string" || typeof config.model === "string") headerRoute = {
1706
+ provider: String(config.provider ?? ""),
1707
+ model: String(config.model ?? "")
1708
+ };
1709
+ continue;
1710
+ }
1711
+ if (event.type === "request/context") {
1712
+ route = {
1713
+ provider: String(data.provider ?? ""),
1714
+ model: String(data.model ?? ""),
1715
+ contextWindow: typeof data.contextWindow === "number" && data.contextWindow > 0 ? data.contextWindow : null
1716
+ };
1717
+ continue;
1718
+ }
1719
+ if (event.type === "assistant/message" && data.usage) {
1720
+ const usage = data.usage;
1721
+ cache = {
1722
+ hit: Number(usage.cacheReadTokens) || 0,
1723
+ miss: (Number(usage.inputTokens) || 0) + (Number(usage.cacheWriteTokens) || 0)
1724
+ };
1725
+ billed = promptTokensOf(usage);
1726
+ }
1727
+ }
1728
+ const drafts = /* @__PURE__ */ new Map();
1729
+ const add = (category, label, tokens, kind) => {
1730
+ const key = `${category}\u0000${kind ?? ""}\u0000${label}`;
1731
+ const draft = drafts.get(key);
1732
+ if (draft === void 0) drafts.set(key, {
1733
+ category,
1734
+ ...kind === void 0 ? {} : { kind },
1735
+ label,
1736
+ count: 1,
1737
+ tokens
1738
+ });
1739
+ else {
1740
+ draft.count++;
1741
+ draft.tokens += tokens;
1742
+ }
1743
+ };
1744
+ for (const event of ordered) {
1745
+ if (shadowed.has(event.seq)) continue;
1746
+ const data = event.data ?? {};
1747
+ switch (event.type) {
1748
+ case "system/message": {
1749
+ const kind = String(data.message?.source?.kind ?? data.source?.kind ?? "system-prompt");
1750
+ add(kind === "system-prompt" ? "system" : "memory", "", messageTokens(data.message ?? { content: data.content }), kind);
1751
+ break;
1752
+ }
1753
+ case "user/message": {
1754
+ const kind = String(data.source?.kind ?? data.message?.source?.kind ?? "user");
1755
+ const message = data.message ?? { content: data.content };
1756
+ if (kind === "user") add("conversation", "", messageTokens(message), "user");
1757
+ else add("memory", "", messageTokens(message), kind);
1758
+ break;
1759
+ }
1760
+ case "assistant/message":
1761
+ add("conversation", "", messageTokens(data.message ?? { content: data.content }), "assistant");
1762
+ break;
1763
+ case "tool/result": {
1764
+ const message = data.message ?? {};
1765
+ const callId = String(message.toolCallId ?? message.source?.callId ?? "");
1766
+ const name = callNames.get(callId) ?? "";
1767
+ const path = typeof data.meta?.path === "string" ? data.meta.path : "";
1768
+ const tokens = messageTokens(message);
1769
+ if (path !== "" && (FILE_TOOLS.has(name) || name === "")) add("files", path, tokens);
1770
+ else add("results", name === "" ? "tool" : name, tokens);
1771
+ break;
1772
+ }
1773
+ }
1774
+ }
1775
+ for (const summary of summaries) if (summary.tokens > 0) add("memory", "", summary.tokens, "compaction-summary");
1776
+ for (const tool of tools) {
1777
+ const record = tool;
1778
+ const name = String(record?.name ?? "");
1779
+ if (name === "") continue;
1780
+ add("tools", name, estimateTokens(name) + valueTokens(record?.description) + estimateTokens(JSON.stringify(record?.parameters ?? {})) + TOOL_SCHEMA_OVERHEAD_TOKENS);
1781
+ }
1782
+ const items = foldDrafts([...drafts.values()]);
1783
+ return {
1784
+ totalTokens: items.reduce((sum, item) => sum + item.tokens, 0),
1785
+ contextWindow: route?.contextWindow ?? null,
1786
+ provider: route?.provider || headerRoute?.provider || "",
1787
+ model: route?.model || headerRoute?.model || "",
1788
+ cacheHitTokens: cache?.hit ?? 0,
1789
+ cacheMissTokens: cache?.miss ?? 0,
1790
+ cacheKnown: cache !== null,
1791
+ promptTokens: billed,
1792
+ items
1793
+ };
1794
+ }
1795
+ //#endregion
1521
1796
  //#region src/session-detail.ts
1522
1797
  const zero = () => ({
1523
1798
  uncachedInputTokens: 0,
@@ -1844,7 +2119,8 @@ async function one(p, id, header, live) {
1844
2119
  records,
1845
2120
  requests,
1846
2121
  averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null,
1847
- tokensPerSecond: decodeRateOf(records)
2122
+ tokensPerSecond: decodeRateOf(records),
2123
+ context: buildSessionContextWindow(own)
1848
2124
  };
1849
2125
  }
1850
2126
  async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
@@ -1943,7 +2219,8 @@ async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
1943
2219
  };
1944
2220
  }),
1945
2221
  records,
1946
- routes: [...routeMap.values()]
2222
+ routes: [...routeMap.values()],
2223
+ context: parts[0]?.context ?? null
1947
2224
  };
1948
2225
  }
1949
2226
  //#endregion
@@ -0,0 +1,18 @@
1
+ import type { SessionContextCategory, SessionContextWindow } from '../wire.ts';
2
+ import type { SessionUsageKey } from './session-usage-locales.ts';
3
+ type T = (key: SessionUsageKey, params?: Record<string, string>) => string;
4
+ /** The context-window panel above the Usage cards, in magpie's shape: the used
5
+ * total and the model's window on the left, the newest answer's prompt-cache
6
+ * split on the right, a treemap of what occupies the window below (with a
7
+ * hover card per tile), and one legend row naming the six buckets with their
8
+ * sizes. Every figure is an estimate, so the panel never claims an exact
9
+ * count — and the live pill stamps when the estimate was last refreshed. */
10
+ export declare function ContextWindowPanel({ context, t, updatedAt }: {
11
+ context: SessionContextWindow;
12
+ t: T;
13
+ /** Wall-clock ms of the last successful read, when the host reports one. */
14
+ updatedAt?: number | null;
15
+ }): JSX.Element;
16
+ /** Legend order shared with the CSS tone classes (kept exported for tests). */
17
+ export declare const CONTEXT_LEGEND_ORDER: readonly SessionContextCategory[];
18
+ export {};
@@ -1,14 +1,15 @@
1
1
  import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots';
2
2
  import type { SessionUsageDetail } from '../wire.ts';
3
3
  import { SESSION_USAGE_NS, type SessionUsageKey } from './session-usage-locales.ts';
4
- type T = (key: SessionUsageKey) => string;
4
+ type T = (key: SessionUsageKey, params?: Record<string, string>) => string;
5
5
  export type SessionUsageViewProps = PropsLocale<typeof SESSION_USAGE_NS> & {
6
6
  sessionId: string;
7
7
  };
8
8
  /** Pure presentation export also used by offline visual regression fixtures. */
9
- export declare function SessionUsageContent({ detail, t }: {
9
+ export declare function SessionUsageContent({ detail, t, updatedAt }: {
10
10
  detail: SessionUsageDetail;
11
11
  t: T;
12
+ updatedAt?: number | null;
12
13
  }): JSX.Element;
13
14
  export declare function SessionUsageView({ sessionId, t }: SessionUsageViewProps): JSX.Element;
14
15
  export {};
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Pure helpers behind the context-window panel: the treemap squarifier, the
3
+ * per-category roll-up the legend prints, and the occupancy reading. Kept
4
+ * separate from the component so both are testable without a DOM.
5
+ */
6
+ import type { SessionContextCategory, SessionContextItem, SessionContextWindow } from '../wire.ts';
7
+ /** Legend order, also the color assignment order (see the CSS module). */
8
+ export declare const CONTEXT_CATEGORY_ORDER: readonly SessionContextCategory[];
9
+ /** One positioned treemap block in container pixels. */
10
+ export interface TreemapBox {
11
+ key: string;
12
+ x: number;
13
+ y: number;
14
+ width: number;
15
+ height: number;
16
+ }
17
+ /** Per-category token totals in legend order, empty categories dropped. */
18
+ export declare function contextCategoryTotals(items: readonly SessionContextItem[]): Array<{
19
+ key: SessionContextCategory;
20
+ tokens: number;
21
+ }>;
22
+ /** Window occupancy in whole percent, or null when the host never reported the
23
+ * model's window (the panel then says "unknown" instead of guessing one). */
24
+ export declare function contextWindowPercent(usedTokens: number, contextWindow: number | null): number | null;
25
+ /** How much the replay estimate may drift from the billed prompt before the
26
+ * panel stops treating them as the same number (rounding, framing tokens). */
27
+ export declare const CONTEXT_ESTIMATE_TOLERANCE = 0.15;
28
+ /** One reading of the snapshot for the headline: the billed prompt when the log
29
+ * has one (exact), else the replay estimate. `estimateDiffers` marks the case
30
+ * where the log carries text the model was never billed for — an upstream
31
+ * compression plugin, say — so the panel can explain a treemap that sums
32
+ * higher than the total. */
33
+ export interface ContextReading {
34
+ usedTokens: number;
35
+ percent: number | null;
36
+ estimateTokens: number;
37
+ estimateDiffers: boolean;
38
+ }
39
+ export declare function contextReading(context: SessionContextWindow): ContextReading;
40
+ /**
41
+ * Squarified treemap (Bruls, Huizing & van Wijk): tile `width`×`height` with
42
+ * one block per positive weight, proportional to its tokens and as close to
43
+ * square as the greedy row packing allows. Blocks come back in the input's
44
+ * descending-weight order, covering the whole area.
45
+ */
46
+ export declare function treemapLayout(rows: ReadonlyArray<{
47
+ key: string;
48
+ tokens: number;
49
+ }>, width: number, height: number): TreemapBox[];
@@ -59,6 +59,34 @@ export declare const usageZh: {
59
59
  live: string;
60
60
  asOf: string;
61
61
  refresh: string;
62
+ contextTitle: string;
63
+ contextUsed: string;
64
+ contextLive: string;
65
+ contextWindowKnown: string;
66
+ contextWindowUnknown: string;
67
+ contextCache: string;
68
+ contextCacheHit: string;
69
+ contextCacheMiss: string;
70
+ contextCachePending: string;
71
+ contextSystem: string;
72
+ contextTools: string;
73
+ contextMemory: string;
74
+ contextFiles: string;
75
+ contextResults: string;
76
+ contextConversation: string;
77
+ contextKindSystemPrompt: string;
78
+ contextKindRuntimeContext: string;
79
+ contextKindSkillCatalog: string;
80
+ contextKindTimeContext: string;
81
+ contextKindCompaction: string;
82
+ contextKindUser: string;
83
+ contextKindAssistant: string;
84
+ contextKindRest: string;
85
+ contextTimes: string;
86
+ contextEmpty: string;
87
+ contextTipShare: string;
88
+ contextTipWindow: string;
89
+ contextEstimateNote: string;
62
90
  };
63
91
  export type SessionUsageKey = keyof typeof usageZh;
64
92
  export declare const usageEn: Record<SessionUsageKey, string>;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Context-window composition: what the session's NEXT provider call carries.
3
+ *
4
+ * The panel above the Usage cards answers "what is inside the window right
5
+ * now", which is not what the log accumulated. So this walks one session's own
6
+ * events (fork-inherited ones never reach here) and rebuilds the message list a
7
+ * live request would replay:
8
+ * - `compaction/prune` / `compaction/summary` shadowed sequences drop out — the
9
+ * compacted turn is gone from the request, not merely folded in the store;
10
+ * - a compaction summary stands in for the range it replaced, as injected
11
+ * memory rather than conversation (the model never wrote it);
12
+ * - the tool catalogue is the NEWEST `request/header`'s list, since that is the
13
+ * set the next call will send;
14
+ * - file reads group by path and the remaining tool results by tool name, so a
15
+ * session that ran `pwsh` sixty times shows one block with its call count.
16
+ *
17
+ * Token counts are ESTIMATES from character counts: the host's own estimator
18
+ * bills ASCII at four characters per token, while CJK text bills closer to one
19
+ * token per character, so both rates are applied per character class and every
20
+ * figure is presented as approximate. Exact counts are unavailable here — the
21
+ * provider only reports a whole-prompt number after the call — but that billed
22
+ * number IS exact for the request it answered, so it ships alongside the
23
+ * estimate (`promptTokens`) and the panel prints it as the used total whenever
24
+ * the log has one. The two disagree when something upstream rewrites the prompt
25
+ * (a compression plugin, a gateway that trims history), because the stored log
26
+ * then holds more text than the model was ever billed for.
27
+ */
28
+ import type { UsageSessionEvent } from './context-types.ts';
29
+ import type { SessionContextWindow } from './wire.ts';
30
+ /** Approximate token count of one string (see the header comment). */
31
+ export declare function estimateTokens(text: string): number;
32
+ /** Approximate tokens of one replayed message (content + framing). */
33
+ export declare function messageTokens(message: unknown): number;
34
+ /** Build the context-window snapshot from one session's own events. Returns
35
+ * null for an empty log so the panel can hide itself. */
36
+ export declare function buildSessionContextWindow(events: readonly UsageSessionEvent[]): SessionContextWindow | null;
@@ -228,7 +228,14 @@ export declare class UsageStore {
228
228
  /** Fold ONLY the failure count of a sample into the day and hour rows. The
229
229
  * failure upgrade pass uses this: its replay walks logs the token passes
230
230
  * already consumed, so the tokens and the request marker must not be added
231
- * a second time — only the failed-call counter may move. */
231
+ * a second time — only the failed-call counter may move.
232
+ *
233
+ * A sample WITHOUT the `failed` marker counts NOTHING here. The pass folds
234
+ * whole session logs, which are mostly successful calls: passing every
235
+ * folded sample through a write that hardcodes `failed: true` would count
236
+ * each call's request marker AND token sample as two extra "failures" —
237
+ * exactly how stores upgrading through v1.0.2–v1.0.8 ended up with
238
+ * failures ≈ 2 × requests on every pre-upgrade day ("失败的比总的都多"). */
232
239
  recordFailure(sample: UsageSample): Promise<void>;
233
240
  /** The row identity a sample lands on: turn markers use the synthetic
234
241
  * (turns) model, unlabelled calls the (unknown) one. */
@@ -188,6 +188,56 @@ export interface SessionUsageDetail {
188
188
  model: string;
189
189
  status: 'idle' | 'running' | 'completed' | 'failed';
190
190
  }>;
191
+ /** The root session's live context composition, rendered above the cards.
192
+ * Null when the log has no readable events (or on an older host). */
193
+ context?: SessionContextWindow | null;
194
+ }
195
+ /** The six buckets the context-window panel paints, in legend order. They are
196
+ * the buckets a reader thinks in: the harness prompt, the tool catalogue, the
197
+ * injected memory (runtime context, skill catalog, compaction summaries), the
198
+ * files the agent read, the remaining tool results, and the conversation. */
199
+ export type SessionContextCategory = 'system' | 'tools' | 'memory' | 'files' | 'results' | 'conversation';
200
+ /** One block of the context-window treemap: a tool, one file path, one folded
201
+ * group of the same tool, or one message bucket. `tokens` is an ESTIMATE from
202
+ * character counts (see context-window.ts), so the panel renders it as ~N. */
203
+ export interface SessionContextItem {
204
+ /** Row key, unique inside one snapshot. */
205
+ key: string;
206
+ category: SessionContextCategory;
207
+ /** What to print: a tool name, a file path, or (for `kind`-bearing rows) the
208
+ * localized kind label the panel substitutes. */
209
+ label: string;
210
+ /** Machine kind the panel localizes ("system-prompt", "user", "rest", …).
211
+ * Absent for rows whose label is itself the data (tool names, paths). */
212
+ kind?: string;
213
+ /** How many log entries folded into this row (messages, tool calls). */
214
+ count: number;
215
+ tokens: number;
216
+ }
217
+ /** The context-window snapshot: what the session's next provider call would
218
+ * carry, reconstructed from its own log (pruned events dropped, a compaction
219
+ * summary standing in for the range it replaced, the newest request's tool
220
+ * catalogue). Null when the session has no readable events yet. */
221
+ export interface SessionContextWindow {
222
+ /** Sum of every item's estimated tokens. */
223
+ totalTokens: number;
224
+ /** The model's window from the newest `request/context`, or null when the
225
+ * host route never reported one (the panel then says "unknown"). */
226
+ contextWindow: number | null;
227
+ provider: string;
228
+ model: string;
229
+ /** Prompt-cache split of the newest answer: cache reads vs new prompt tokens. */
230
+ cacheHitTokens: number;
231
+ cacheMissTokens: number;
232
+ /** Whether any answer reported usage (the cache bar needs one to draw). */
233
+ cacheKnown: boolean;
234
+ /** Prompt tokens the provider billed on the newest answer
235
+ * (`totalTokens − outputTokens`, or uncached + cached input): the real
236
+ * occupancy. Upstream prompt compression can make it smaller than
237
+ * `totalTokens`, which merely replays the stored log. */
238
+ promptTokens: number | null;
239
+ /** Treemap blocks, largest first. */
240
+ items: SessionContextItem[];
191
241
  }
192
242
  /** The backfill (historical session scan) progress state. */
193
243
  export interface BackfillStatus {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-token-total",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "DSH web plugin: token 用量统计看板 + 单会话(主智能体 + 子智能体)用量统计。提供今日/近3天/近7天/近1月/近3月/自定义筛选、六个汇总卡片、活跃热力图、缓存命中率折线与分模型柱状图、按模型/按供应商 Top5 列表。",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
@@ -0,0 +1,77 @@
1
+ /* The context-window panel: magpie's shape on this page's surfaces — a header
2
+ * with the model and a live pill, the used total beside the newest answer's
3
+ * prompt-cache split, a treemap of what occupies the window, and one legend row
4
+ * naming the six buckets. Category colors live as custom properties on the tone
5
+ * classes so the tile and the legend dot share one palette. */
6
+ .panel { background:#fff; border:1px solid #e4e7ee; border-radius:12px; margin:0 0 16px; overflow:hidden; }
7
+ .head { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:12px 16px 0; }
8
+ .headCollapsed { padding-bottom:12px; }
9
+ .toggle { display:flex; align-items:center; gap:6px; min-width:0; background:none; border:0; padding:0; font:inherit; color:#394354; cursor:pointer; }
10
+ .toggle h2 { font-size:13px; font-weight:600; margin:0; white-space:nowrap; }
11
+ .chevron { flex:none; color:#99a1af; transition:transform .2s; }
12
+ .chevronOpen { transform:rotate(90deg); }
13
+ .headRight { display:flex; align-items:center; gap:8px; min-width:0; }
14
+ .model { min-width:0; max-width:220px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; color:#8b93a4; }
15
+ .livePill { display:inline-flex; align-items:center; gap:4px; flex:none; padding:1px 8px 1px 6px; border-radius:999px; background:#e9f8f1; color:#128a63; font-size:10px; font-weight:500; }
16
+ .liveDot { width:5px; height:5px; border-radius:50%; background:#18a97b; box-shadow:0 0 0 2px #d6f2e5; }
17
+ .liveTime { font-variant-numeric:tabular-nums; opacity:.85; }
18
+ .toggle:focus-visible { outline:2px solid #2ab1c6; outline-offset:2px; }
19
+ .body { padding:10px 16px 14px; }
20
+ .body[hidden] { display:none; }
21
+ .summary { display:grid; grid-template-columns:minmax(0,1fr) minmax(180px,260px); align-items:end; gap:18px; padding-bottom:12px; }
22
+ .used { display:flex; flex-direction:column; gap:1px; min-width:0; }
23
+ .label { font-size:11px; color:#8790a3; }
24
+ .total { font-size:28px; font-weight:650; line-height:1.15; letter-spacing:-.6px; color:#1b2130; font-variant-numeric:tabular-nums; white-space:nowrap; }
25
+ .total small { margin-left:4px; font-size:11px; font-weight:500; letter-spacing:0; color:#98a0ae; }
26
+ .window { font-size:11px; color:#939aab; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
27
+ .cache { display:flex; flex-direction:column; gap:6px; min-width:0; }
28
+ .cacheBar { display:flex; width:100%; height:5px; border-radius:999px; overflow:hidden; background:#eef0f4; }
29
+ .cacheHit { height:100%; background:#2ec4a0; }
30
+ .cacheMiss { height:100%; background:#dfe3ea; }
31
+ .cacheEmpty { display:block; width:100%; height:100%; background:repeating-linear-gradient(90deg,#e6e9ef 0 3px,transparent 3px 7px); }
32
+ .cacheLegend { display:flex; flex-wrap:wrap; gap:2px 10px; font-size:10px; color:#8b93a4; font-variant-numeric:tabular-nums; }
33
+ .cacheLegend i { display:inline-block; width:6px; height:6px; margin-right:4px; border-radius:50%; }
34
+ .cacheHitDot { background:#2ec4a0; }
35
+ .cacheMissDot { background:#dfe3ea; }
36
+ .cachePending { color:#9aa3b3; }
37
+ .note { margin:0 0 10px; padding:5px 8px; border:1px solid #f2e4c6; border-radius:6px; background:#fdf7ea; font-size:10.5px; line-height:1.5; color:#8a6a2a; font-variant-numeric:tabular-nums; }
38
+ .map { position:relative; height:268px; border-radius:9px; overflow:hidden; }
39
+ /* Slots glide into their new geometry when a refresh re-writes the estimate,
40
+ * so a change in the data reads as motion instead of a silent repaint. */
41
+ .slot { position:absolute; box-sizing:border-box; padding:1.5px; transition:left .35s, top .35s, width .35s, height .35s; }
42
+ .tile { display:flex; flex-direction:column; gap:1px; box-sizing:border-box; width:100%; height:100%; padding:5px 6px; border:1px solid var(--ctx-line); border-radius:7px; background:var(--ctx-soft); color:var(--ctx-ink); overflow:hidden; }
43
+ .tileLabel { font-size:11px; font-weight:600; line-height:1.25; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
44
+ .tileSize { font-size:10px; opacity:.72; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-variant-numeric:tabular-nums; }
45
+ /* The hover card: exact figures a tile has no room to print. */
46
+ .tip { position:absolute; z-index:2; box-sizing:border-box; width:220px; padding:8px 10px; border:1px solid #e0e4ec; border-radius:8px; background:#fff; box-shadow:0 8px 20px rgba(23,32,54,.12); font-size:11px; line-height:1.55; pointer-events:none; }
47
+ .tipName { font-weight:600; color:#1b2130; overflow-wrap:anywhere; }
48
+ .tipMeta { color:#6f7889; font-variant-numeric:tabular-nums; }
49
+ .tipShare { color:#4a5567; font-variant-numeric:tabular-nums; }
50
+ .legend { display:flex; flex-wrap:wrap; gap:5px 14px; list-style:none; margin:0; padding:10px 0 0; border-top:1px solid #f2f4f8; margin-top:10px; font-size:11px; color:#6f7889; }
51
+ .legend li { display:flex; align-items:center; gap:5px; }
52
+ .legendName { white-space:nowrap; }
53
+ .legendValue { color:#98a0ae; font-variant-numeric:tabular-nums; }
54
+ .swatch { display:inline-block; flex:none; box-sizing:border-box; width:9px; height:9px; border-radius:3px; background:var(--ctx-soft); background:color-mix(in srgb, var(--ctx) 50%, var(--ctx-soft)); border:1px solid var(--ctx-line); }
55
+ .empty { margin:0; padding:24px 0; text-align:center; font-size:12px; color:#9aa2b1; }
56
+ /* Category palette: `--ctx` is the accent tint, `--ctx-soft` the tile fill (and
57
+ * the legend swatch, so the dots match the tiles exactly), `--ctx-ink` the tile
58
+ * text, `--ctx-line` its hairline border. */
59
+ .toneSystem { --ctx:#a346e6; --ctx-soft:#f6ecfd; --ctx-ink:#6c2e9e; --ctx-line:#ecdafa; }
60
+ .toneTools { --ctx:#4f79f6; --ctx-soft:#eaf0fe; --ctx-ink:#3a5aa8; --ctx-line:#d9e3fb; }
61
+ .toneMemory { --ctx:#8fb21e; --ctx-soft:#f1f5d8; --ctx-ink:#5a7012; --ctx-line:#dfe8bd; }
62
+ .toneFiles { --ctx:#18a97b; --ctx-soft:#e6f6ee; --ctx-ink:#1c7a56; --ctx-line:#d2ecdf; }
63
+ .toneResults { --ctx:#e8853f; --ctx-soft:#fdeee3; --ctx-ink:#a35d24; --ctx-line:#f7ddc8; }
64
+ .toneConversation { --ctx:#c2327f; --ctx-soft:#fbe9f3; --ctx-ink:#8c2259; --ctx-line:#f2d3e4; }
65
+ @media (max-width:700px) {
66
+ .head { padding:11px 12px 0; }
67
+ .headCollapsed { padding-bottom:11px; }
68
+ .body { padding:9px 12px 12px; }
69
+ .summary { grid-template-columns:minmax(0,1fr); gap:10px; }
70
+ .total { font-size:24px; }
71
+ .map { height:220px; }
72
+ .model { display:none; }
73
+ }
74
+ @media (prefers-reduced-motion:reduce) {
75
+ .chevron { transition:none; }
76
+ .slot { transition:none; }
77
+ }