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.
@@ -0,0 +1,137 @@
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
+
8
+ /** Legend order, also the color assignment order (see the CSS module). */
9
+ export const CONTEXT_CATEGORY_ORDER: readonly SessionContextCategory[] = [
10
+ 'system', 'tools', 'memory', 'files', 'results', 'conversation',
11
+ ]
12
+
13
+ /** One positioned treemap block in container pixels. */
14
+ export interface TreemapBox {
15
+ key: string
16
+ x: number
17
+ y: number
18
+ width: number
19
+ height: number
20
+ }
21
+
22
+ /** Per-category token totals in legend order, empty categories dropped. */
23
+ export function contextCategoryTotals(
24
+ items: readonly SessionContextItem[],
25
+ ): Array<{ key: SessionContextCategory; tokens: number }> {
26
+ const totals = new Map<SessionContextCategory, number>()
27
+ for (const item of items) totals.set(item.category, (totals.get(item.category) ?? 0) + item.tokens)
28
+ return CONTEXT_CATEGORY_ORDER
29
+ .map(key => ({ key, tokens: totals.get(key) ?? 0 }))
30
+ .filter(row => row.tokens > 0)
31
+ }
32
+
33
+ /** Window occupancy in whole percent, or null when the host never reported the
34
+ * model's window (the panel then says "unknown" instead of guessing one). */
35
+ export function contextWindowPercent(usedTokens: number, contextWindow: number | null): number | null {
36
+ if (contextWindow === null || !(contextWindow > 0) || !(usedTokens >= 0)) return null
37
+ return Math.min(100, Math.round(usedTokens / contextWindow * 100))
38
+ }
39
+
40
+ /** How much the replay estimate may drift from the billed prompt before the
41
+ * panel stops treating them as the same number (rounding, framing tokens). */
42
+ export const CONTEXT_ESTIMATE_TOLERANCE = 0.15
43
+
44
+ /** One reading of the snapshot for the headline: the billed prompt when the log
45
+ * has one (exact), else the replay estimate. `estimateDiffers` marks the case
46
+ * where the log carries text the model was never billed for — an upstream
47
+ * compression plugin, say — so the panel can explain a treemap that sums
48
+ * higher than the total. */
49
+ export interface ContextReading {
50
+ usedTokens: number
51
+ percent: number | null
52
+ estimateTokens: number
53
+ estimateDiffers: boolean
54
+ }
55
+
56
+ export function contextReading(context: SessionContextWindow): ContextReading {
57
+ const estimateTokens = context.totalTokens
58
+ const usedTokens = context.promptTokens ?? estimateTokens
59
+ const gap = estimateTokens > 0 ? Math.abs(estimateTokens - usedTokens) / Math.max(estimateTokens, usedTokens) : 0
60
+ return {
61
+ usedTokens,
62
+ percent: contextWindowPercent(usedTokens, context.contextWindow),
63
+ estimateTokens,
64
+ estimateDiffers: context.promptTokens !== null && gap > CONTEXT_ESTIMATE_TOLERANCE,
65
+ }
66
+ }
67
+
68
+ /** Aspect ratio of the worst tile in a row of `sum` area, given the strip's
69
+ * short side — the squarified-treemap cost function. */
70
+ function worstRatio(sum: number, max: number, min: number, side: number): number {
71
+ const squared = sum * sum
72
+ const sideSquared = side * side
73
+ return Math.max((sideSquared * max) / squared, squared / (sideSquared * min))
74
+ }
75
+
76
+ const round = (value: number): number => Math.round(value * 100) / 100
77
+
78
+ /**
79
+ * Squarified treemap (Bruls, Huizing & van Wijk): tile `width`×`height` with
80
+ * one block per positive weight, proportional to its tokens and as close to
81
+ * square as the greedy row packing allows. Blocks come back in the input's
82
+ * descending-weight order, covering the whole area.
83
+ */
84
+ export function treemapLayout(
85
+ rows: ReadonlyArray<{ key: string; tokens: number }>,
86
+ width: number,
87
+ height: number,
88
+ ): TreemapBox[] {
89
+ const ordered = rows.filter(row => row.tokens > 0 && Number.isFinite(row.tokens)).sort((a, b) => b.tokens - a.tokens)
90
+ if (ordered.length === 0 || !(width > 0) || !(height > 0)) return []
91
+ const total = ordered.reduce((sum, row) => sum + row.tokens, 0)
92
+ const scale = (width * height) / total
93
+ const areas = ordered.map(row => ({ key: row.key, area: row.tokens * scale }))
94
+ const boxes: TreemapBox[] = []
95
+ let x = 0
96
+ let y = 0
97
+ let w = width
98
+ let h = height
99
+ let index = 0
100
+ while (index < areas.length) {
101
+ const side = Math.min(w, h)
102
+ if (!(side > 0)) break
103
+ // Grow one row while the worst aspect ratio keeps improving.
104
+ let end = index + 1
105
+ let sum = areas[index]!.area
106
+ let max = sum
107
+ let min = sum
108
+ let worst = worstRatio(sum, max, min, side)
109
+ while (end < areas.length) {
110
+ const area = areas[end]!.area
111
+ const nextSum = sum + area
112
+ const nextMax = Math.max(max, area)
113
+ const nextMin = Math.min(min, area)
114
+ const nextWorst = worstRatio(nextSum, nextMax, nextMin, side)
115
+ if (nextWorst > worst) break
116
+ sum = nextSum
117
+ max = nextMax
118
+ min = nextMin
119
+ worst = nextWorst
120
+ end++
121
+ }
122
+ const thickness = sum / side
123
+ if (!(thickness > 0)) break
124
+ let offset = 0
125
+ for (let k = index; k < end; k++) {
126
+ const length = areas[k]!.area / thickness
127
+ const box = w >= h
128
+ ? { key: areas[k]!.key, x, y: y + offset, width: thickness, height: length }
129
+ : { key: areas[k]!.key, x: x + offset, y, width: length, height: thickness }
130
+ boxes.push({ key: box.key, x: round(box.x), y: round(box.y), width: round(box.width), height: round(box.height) })
131
+ offset += length
132
+ }
133
+ if (w >= h) { x += thickness; w -= thickness } else { y += thickness; h -= thickness }
134
+ index = end
135
+ }
136
+ return boxes
137
+ }
@@ -16,6 +16,18 @@ export const usageZh = {
16
16
  answering: '正在回答...', firstWait: '首字耗时', sec: '秒', tokenWord: 'token', perSec: 'token/秒',
17
17
  cacheShort: '缓存', avgCache: '平均缓存', avgRate: '平均速率', inProgress: '个进行中',
18
18
  live: '实时更新', asOf: '统计截至', refresh: '刷新',
19
+ contextTitle: '上下文窗口', contextUsed: '已用上下文', contextLive: '实时',
20
+ contextWindowKnown: '模型窗口 {value} · 已用 {percent}%', contextWindowUnknown: '模型的窗口大小未知',
21
+ contextCache: '提示词缓存', contextCacheHit: '命中', contextCacheMiss: '未命中',
22
+ contextCachePending: '供应商回答可见',
23
+ contextSystem: '系统提示词', contextTools: '工具', contextMemory: '记忆',
24
+ contextFiles: '文件', contextResults: '工具结果', contextConversation: '会话内容',
25
+ contextKindSystemPrompt: '系统提示词', contextKindRuntimeContext: '运行时上下文',
26
+ contextKindSkillCatalog: '技能目录', contextKindTimeContext: '时间上下文',
27
+ contextKindCompaction: '压缩摘要', contextKindUser: '用户消息', contextKindAssistant: '助手消息',
28
+ contextKindRest: '另外 {count} 项', contextTimes: '×{count}',
29
+ contextEmpty: '本会话还没有可统计的上下文', contextTipShare: '占上下文 {percent}', contextTipWindow: '占窗口 {percent}',
30
+ contextEstimateNote: '色块按历史重放估算 {estimate};供应商实测提示词 {billed}(上游已压缩或裁剪)',
19
31
  }
20
32
  export type SessionUsageKey = keyof typeof usageZh
21
33
  export const usageEn: Record<SessionUsageKey, string> = {
@@ -34,6 +46,18 @@ export const usageEn: Record<SessionUsageKey, string> = {
34
46
  answering: 'answering...', firstWait: 'first token', sec: 's', tokenWord: 'token', perSec: 'token/s',
35
47
  cacheShort: 'cache', avgCache: 'avg cache', avgRate: 'avg rate', inProgress: 'in progress',
36
48
  live: 'Live', asOf: 'as of', refresh: 'Refresh',
49
+ contextTitle: 'Context window', contextUsed: 'Context used', contextLive: 'Live',
50
+ contextWindowKnown: 'Window {value} · {percent}% used', contextWindowUnknown: 'Model window size unknown',
51
+ contextCache: 'Prompt cache', contextCacheHit: 'hit', contextCacheMiss: 'miss',
52
+ contextCachePending: 'Visible once the provider answers',
53
+ contextSystem: 'System prompt', contextTools: 'Tools', contextMemory: 'Memory',
54
+ contextFiles: 'Files', contextResults: 'Tool results', contextConversation: 'Conversation',
55
+ contextKindSystemPrompt: 'System prompt', contextKindRuntimeContext: 'Runtime context',
56
+ contextKindSkillCatalog: 'Skill catalog', contextKindTimeContext: 'Time context',
57
+ contextKindCompaction: 'Compaction summary', contextKindUser: 'User messages', contextKindAssistant: 'Assistant messages',
58
+ contextKindRest: '{count} more', contextTimes: '×{count}',
59
+ contextEmpty: 'Nothing in this session’s context yet', contextTipShare: '{percent} of context', contextTipWindow: '{percent} of window',
60
+ contextEstimateNote: 'Blocks estimate {estimate} by replay; the provider billed {billed} after upstream compression',
37
61
  }
38
62
  export const usageZhTW: Record<SessionUsageKey, string> = {
39
63
  ...usageZh,
@@ -50,4 +74,16 @@ export const usageZhTW: Record<SessionUsageKey, string> = {
50
74
  answering: '正在回答...', firstWait: '首字耗時', sec: '秒', tokenWord: 'token', perSec: 'token/秒',
51
75
  cacheShort: '快取', avgCache: '平均快取', avgRate: '平均速率', inProgress: '個進行中',
52
76
  live: '即時更新', asOf: '統計截至', refresh: '重新整理',
77
+ contextTitle: '上下文視窗', contextUsed: '已用上下文', contextLive: '即時',
78
+ contextWindowKnown: '模型視窗 {value} · 已用 {percent}%', contextWindowUnknown: '模型的視窗大小未知',
79
+ contextCache: '提示詞快取', contextCacheHit: '命中', contextCacheMiss: '未命中',
80
+ contextCachePending: '供應商回答可見',
81
+ contextSystem: '系統提示詞', contextTools: '工具', contextMemory: '記憶',
82
+ contextFiles: '檔案', contextResults: '工具結果', contextConversation: '會話內容',
83
+ contextKindSystemPrompt: '系統提示詞', contextKindRuntimeContext: '執行環境上下文',
84
+ contextKindSkillCatalog: '技能目錄', contextKindTimeContext: '時間上下文',
85
+ contextKindCompaction: '壓縮摘要', contextKindUser: '使用者訊息', contextKindAssistant: '助手訊息',
86
+ contextKindRest: '另外 {count} 項', contextTimes: '×{count}',
87
+ contextEmpty: '本會話還沒有可統計的上下文', contextTipShare: '佔上下文 {percent}', contextTipWindow: '佔視窗 {percent}',
88
+ contextEstimateNote: '色塊按歷史重放估算 {estimate};供應商實測提示詞 {billed}(上游已壓縮或裁剪)',
53
89
  }
package/src/collector.ts CHANGED
@@ -701,7 +701,10 @@ export class UsageCollector {
701
701
  const writeSample = target === 'hours'
702
702
  ? (sample: UsageSample): Promise<void> => this.store.recordHours(sample)
703
703
  : target === 'failures'
704
- ? (sample: UsageSample): Promise<void> => this.store.recordFailure(sample)
704
+ // The failure pass folds whole logs, yet ONLY failed calls may move
705
+ // its counter (recordFailure guards it again — this early return
706
+ // just skips the no-op write for the vast majority of samples).
707
+ ? (sample: UsageSample): Promise<void> => sample.failed ? this.store.recordFailure(sample) : Promise.resolve()
705
708
  : (sample: UsageSample): Promise<void> => this.store.record(sample)
706
709
  const flushCompleted = async (): Promise<void> => {
707
710
  if (completed.length === 0) return
@@ -0,0 +1,302 @@
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 {
30
+ SessionContextCategory,
31
+ SessionContextItem,
32
+ SessionContextWindow,
33
+ } from './wire.ts'
34
+
35
+ /** ASCII-ish characters per token (the host estimator's ratio). */
36
+ const ASCII_CHARS_PER_TOKEN = 4
37
+ /** Wide (CJK, kana, hangul) characters per token: these bill ~1 token/char. */
38
+ const WIDE_CHARS_PER_TOKEN = 1.5
39
+ /** A supplementary-plane emoji is worth a few wide characters. */
40
+ const EMOJI_TOKEN_EQUIVALENT = 3
41
+ /** Serialized JSON punctuation/keys cost a token or two per schema node. */
42
+ const TOOL_SCHEMA_OVERHEAD_TOKENS = 8
43
+ /** Per-message framing the provider bills on top of the content. */
44
+ const MESSAGE_OVERHEAD_TOKENS = 4
45
+ /** A rendered image is billed in tiles, not characters. */
46
+ const IMAGE_TOKENS = 1_100
47
+ /** A category keeps its biggest rows until this share of its tokens is covered. */
48
+ const KEEP_SHARE = 0.85
49
+ /** Hard cap on rows kept per category before the rest folds into one block. */
50
+ const MAX_ROWS_PER_CATEGORY = 10
51
+ /** Tool names whose results are file payloads (they group by path). */
52
+ const FILE_TOOLS = new Set(['read', 'write', 'edit', 'multiedit', 'notebook_read', 'notebook_edit'])
53
+ /** Legend/treemap order; also the tie-break for equal-sized rows. */
54
+ const CATEGORY_ORDER: readonly SessionContextCategory[] = ['system', 'tools', 'memory', 'files', 'results', 'conversation']
55
+
56
+ /** Approximate token count of one string (see the header comment). */
57
+ export function estimateTokens(text: string): number {
58
+ if (text === '') return 0
59
+ let ascii = 0
60
+ let wide = 0
61
+ for (const ch of text) {
62
+ const code = ch.codePointAt(0) ?? 0
63
+ // Astral pictographs (emoji) bill like a short word, not like one glyph;
64
+ // the rest of the supplementary plane is CJK ideographs, which bill wide.
65
+ if (code >= 0x1f000 && code <= 0x1faff) wide += EMOJI_TOKEN_EQUIVALENT
66
+ else if (code < 0x2e80) ascii++
67
+ else wide++
68
+ }
69
+ return Math.ceil(ascii / ASCII_CHARS_PER_TOKEN + wide / WIDE_CHARS_PER_TOKEN)
70
+ }
71
+
72
+ /** Approximate tokens of any JSON-ish value, counting its strings only. */
73
+ function valueTokens(value: unknown): number {
74
+ if (typeof value === 'string') return estimateTokens(value)
75
+ if (typeof value === 'number' || typeof value === 'boolean') return 1
76
+ if (Array.isArray(value)) return value.reduce<number>((sum, item) => sum + valueTokens(item), 0)
77
+ if (value !== null && typeof value === 'object') {
78
+ let sum = 0
79
+ for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
80
+ // Replay/thinking signatures are opaque provider bookkeeping: the model
81
+ // is not re-sent that text, so counting it would inflate every answer.
82
+ if (key === 'replayState' || key === 'thinkingSignature' || key === 'textSignature') continue
83
+ sum += valueTokens(item)
84
+ }
85
+ return sum
86
+ }
87
+ return 0
88
+ }
89
+
90
+ /** Approximate tokens of one replayed message (content + framing). */
91
+ export function messageTokens(message: unknown): number {
92
+ const content = (message as { content?: unknown } | null | undefined)?.content
93
+ const blocks = Array.isArray(content) ? content : content === undefined ? [] : [content]
94
+ let sum = MESSAGE_OVERHEAD_TOKENS
95
+ for (const block of blocks) {
96
+ const kind = (block as { type?: unknown } | null)?.type
97
+ if (kind === 'image' || kind === 'image-url') { sum += IMAGE_TOKENS; continue }
98
+ // A text block's `type` discriminator is chat framing, not content: counting
99
+ // the literal string "text" would add a phantom token to every block.
100
+ if (kind === 'text' && typeof (block as { text?: unknown }).text === 'string') {
101
+ sum += valueTokens((block as { text: unknown }).text)
102
+ continue
103
+ }
104
+ sum += valueTokens(block)
105
+ }
106
+ return sum
107
+ }
108
+
109
+ interface Draft {
110
+ category: SessionContextCategory
111
+ kind?: string
112
+ label: string
113
+ count: number
114
+ tokens: number
115
+ }
116
+
117
+ interface CompactionSummary {
118
+ seq: number
119
+ tokens: number
120
+ }
121
+
122
+ /** Prompt tokens one answer actually sent, as the provider billed it: its total
123
+ * minus the output it generated, or the uncached plus cached input when the
124
+ * provider reported no total. Null when there is no usage at all. */
125
+ function promptTokensOf(usage: unknown): number | null {
126
+ const record = usage as { totalTokens?: unknown; outputTokens?: unknown; inputTokens?: unknown; cacheReadTokens?: unknown } | null | undefined
127
+ const total = Number(record?.totalTokens) || 0
128
+ const output = Number(record?.outputTokens) || 0
129
+ if (total > output) return total - output
130
+ const input = Number(record?.inputTokens) || 0
131
+ const cached = Number(record?.cacheReadTokens) || 0
132
+ return input + cached > 0 ? input + cached : null
133
+ }
134
+
135
+ /** Fold one category's rows: keep the biggest until KEEP_SHARE is covered (and
136
+ * never more than MAX_ROWS_PER_CATEGORY), then merge the tail into one row so
137
+ * a session with 200 tool calls still yields a readable treemap. */
138
+ function foldDrafts(drafts: Draft[]): SessionContextItem[] {
139
+ const items: SessionContextItem[] = []
140
+ for (const category of CATEGORY_ORDER) {
141
+ const rows = drafts.filter(row => row.category === category).sort((a, b) => b.tokens - a.tokens)
142
+ if (rows.length === 0) continue
143
+ const total = rows.reduce((sum, row) => sum + row.tokens, 0)
144
+ const kept: Draft[] = []
145
+ let covered = 0
146
+ for (const row of rows) {
147
+ if (kept.length >= MAX_ROWS_PER_CATEGORY) break
148
+ if (kept.length > 0 && covered >= total * KEEP_SHARE) break
149
+ kept.push(row)
150
+ covered += row.tokens
151
+ }
152
+ const folded = rows.slice(kept.length)
153
+ for (const row of kept) {
154
+ items.push({ key: `${category}:${row.kind ?? row.label}`, category, ...(row.kind === undefined ? {} : { kind: row.kind }), label: row.label, count: row.count, tokens: row.tokens })
155
+ }
156
+ if (folded.length > 0) {
157
+ items.push({
158
+ key: `${category}:rest`,
159
+ category,
160
+ kind: 'rest',
161
+ label: '',
162
+ count: folded.length,
163
+ tokens: folded.reduce((sum, row) => sum + row.tokens, 0),
164
+ })
165
+ }
166
+ }
167
+ return items.sort((a, b) => b.tokens - a.tokens || CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category))
168
+ }
169
+
170
+ /** Build the context-window snapshot from one session's own events. Returns
171
+ * null for an empty log so the panel can hide itself. */
172
+ export function buildSessionContextWindow(events: readonly UsageSessionEvent[]): SessionContextWindow | null {
173
+ if (events.length === 0) return null
174
+ const ordered = [...events].sort((a, b) => a.seq - b.seq)
175
+ // ── Pass 1: the state the current request is derived from ────────────────
176
+ const shadowed = new Set<number>()
177
+ const summaries: CompactionSummary[] = []
178
+ const callNames = new Map<string, string>()
179
+ let tools: unknown[] = []
180
+ let headerRoute: { provider: string; model: string } | null = null
181
+ let route: { provider: string; model: string; contextWindow: number | null } | null = null
182
+ let cache: { hit: number; miss: number } | null = null
183
+ /** What the newest answer actually sent, which is what the window holds now. */
184
+ let billed: number | null = null
185
+ for (const event of ordered) {
186
+ const data: any = event.data ?? {}
187
+ if (event.type === 'compaction/prune' || event.type === 'compaction/summary') {
188
+ for (const seq of Array.isArray(data.shadowedSeqs) ? data.shadowedSeqs : []) shadowed.add(Number(seq))
189
+ const range = data.shadowedRange
190
+ if (typeof range?.start === 'number' && typeof range?.end === 'number') {
191
+ for (let seq = range.start; seq <= range.end; seq++) shadowed.add(seq)
192
+ }
193
+ if (event.type === 'compaction/summary') {
194
+ summaries.push({ seq: event.seq, tokens: messageTokens({ content: data.summary }) })
195
+ const usage = data.usage
196
+ if (usage) {
197
+ cache = { hit: Number(usage.cacheReadTokens) || 0, miss: (Number(usage.inputTokens) || 0) + (Number(usage.cacheWriteTokens) || 0) }
198
+ const summaryBilled = promptTokensOf(usage)
199
+ if (summaryBilled !== null) billed = summaryBilled
200
+ }
201
+ }
202
+ continue
203
+ }
204
+ if (event.type === 'tool/call' && typeof data.callId === 'string') {
205
+ callNames.set(data.callId, String(data.name ?? ''))
206
+ continue
207
+ }
208
+ if (event.type === 'request/header') {
209
+ const header = data.header ?? {}
210
+ if (Array.isArray(header.tools)) tools = header.tools
211
+ const config = header.config ?? {}
212
+ if (typeof config.provider === 'string' || typeof config.model === 'string') {
213
+ headerRoute = { provider: String(config.provider ?? ''), model: String(config.model ?? '') }
214
+ }
215
+ continue
216
+ }
217
+ if (event.type === 'request/context') {
218
+ route = {
219
+ provider: String(data.provider ?? ''),
220
+ model: String(data.model ?? ''),
221
+ contextWindow: typeof data.contextWindow === 'number' && data.contextWindow > 0 ? data.contextWindow : null,
222
+ }
223
+ continue
224
+ }
225
+ if (event.type === 'assistant/message' && data.usage) {
226
+ const usage = data.usage
227
+ cache = { hit: Number(usage.cacheReadTokens) || 0, miss: (Number(usage.inputTokens) || 0) + (Number(usage.cacheWriteTokens) || 0) }
228
+ billed = promptTokensOf(usage)
229
+ }
230
+ }
231
+ // ── Pass 2: every event still replayed into the window ───────────────────
232
+ const drafts = new Map<string, Draft>()
233
+ const add = (category: SessionContextCategory, label: string, tokens: number, kind?: string): void => {
234
+ const key = `${category}\u0000${kind ?? ''}\u0000${label}`
235
+ const draft = drafts.get(key)
236
+ if (draft === undefined) drafts.set(key, { category, ...(kind === undefined ? {} : { kind }), label, count: 1, tokens })
237
+ else { draft.count++; draft.tokens += tokens }
238
+ }
239
+ for (const event of ordered) {
240
+ if (shadowed.has(event.seq)) continue
241
+ const data: any = event.data ?? {}
242
+ switch (event.type) {
243
+ case 'system/message': {
244
+ const kind = String(data.message?.source?.kind ?? data.source?.kind ?? 'system-prompt')
245
+ const category: SessionContextCategory = kind === 'system-prompt' ? 'system' : 'memory'
246
+ add(category, '', messageTokens(data.message ?? { content: data.content }), kind)
247
+ break
248
+ }
249
+ case 'user/message': {
250
+ const kind = String(data.source?.kind ?? data.message?.source?.kind ?? 'user')
251
+ const message = data.message ?? { content: data.content }
252
+ if (kind === 'user') add('conversation', '', messageTokens(message), 'user')
253
+ else add('memory', '', messageTokens(message), kind)
254
+ break
255
+ }
256
+ case 'assistant/message': {
257
+ add('conversation', '', messageTokens(data.message ?? { content: data.content }), 'assistant')
258
+ break
259
+ }
260
+ case 'tool/result': {
261
+ const message = data.message ?? {}
262
+ const callId = String(message.toolCallId ?? message.source?.callId ?? '')
263
+ const name = callNames.get(callId) ?? ''
264
+ const path = typeof data.meta?.path === 'string' ? data.meta.path : ''
265
+ const tokens = messageTokens(message)
266
+ // A file read is a file payload: several reads of one path are one
267
+ // block. Everything else groups by the tool that produced it.
268
+ if (path !== '' && (FILE_TOOLS.has(name) || name === '')) add('files', path, tokens)
269
+ else add('results', name === '' ? 'tool' : name, tokens)
270
+ break
271
+ }
272
+ default:
273
+ break
274
+ }
275
+ }
276
+ // A compaction summary replaced a pruned range: it is replayed in its place.
277
+ for (const summary of summaries) {
278
+ if (summary.tokens > 0) add('memory', '', summary.tokens, 'compaction-summary')
279
+ }
280
+ // The tool catalogue the next call sends: name + description + JSON schema.
281
+ for (const tool of tools) {
282
+ const record = tool as { name?: unknown; description?: unknown; parameters?: unknown }
283
+ const name = String(record?.name ?? '')
284
+ if (name === '') continue
285
+ const tokens = estimateTokens(name) + valueTokens(record?.description)
286
+ + estimateTokens(JSON.stringify(record?.parameters ?? {})) + TOOL_SCHEMA_OVERHEAD_TOKENS
287
+ add('tools', name, tokens)
288
+ }
289
+ const items = foldDrafts([...drafts.values()])
290
+ const totalTokens = items.reduce((sum, item) => sum + item.tokens, 0)
291
+ return {
292
+ totalTokens,
293
+ contextWindow: route?.contextWindow ?? null,
294
+ provider: route?.provider || headerRoute?.provider || '',
295
+ model: route?.model || headerRoute?.model || '',
296
+ cacheHitTokens: cache?.hit ?? 0,
297
+ cacheMissTokens: cache?.miss ?? 0,
298
+ cacheKnown: cache !== null,
299
+ promptTokens: billed,
300
+ items,
301
+ }
302
+ }
@@ -8,6 +8,7 @@ import type {
8
8
  UsageSessionStore,
9
9
  } from './context-types.ts'
10
10
  import { UsageFold } from './collector.ts'
11
+ import { buildSessionContextWindow } from './context-window.ts'
11
12
  import { resolveModelNames, resolveProviderNames } from './model-names.ts'
12
13
  import { decodeRateOf } from './shared.ts'
13
14
  import type { SessionUsageDetail, SessionUsageTotals } from './wire.ts'
@@ -208,7 +209,7 @@ async function one(p: UsageSessionPersistence, id: string, header: UsageSessionH
208
209
  // a cancelled step is not a wait the reader experienced as an answer.
209
210
  const firsts = records.filter(x => x.status === 'completed').map(x => x.firstTokenMs).filter((x): x is number => x !== null)
210
211
  for (const x of records) { x.turnName = turnTopics.get(x.turn) ?? ''; delete (x as any)._attempt; delete (x as any)._lastUsage }
211
- return { id, name, header, totals, records: records as SessionUsageDetail['records'], requests, averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: decodeRateOf(records) }
212
+ return { id, name, header, totals, records: records as SessionUsageDetail['records'], requests, averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: decodeRateOf(records), context: buildSessionContextWindow(own) }
212
213
  }
213
214
 
214
215
  export async function buildSessionUsageDetail(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, ctx?: Context): Promise<SessionUsageDetail> {
@@ -246,5 +247,5 @@ export async function buildSessionUsageDetail(persistence: UsageSessionPersisten
246
247
  // The card is scoped to this session and every subagent, so its speed and
247
248
  // first-token average fold the same records its token totals do.
248
249
  const firsts = records.filter(x => x.status === 'completed').map(x => x.firstTokenMs).filter((x): x is number => x !== null)
249
- return { sessionId: rootId, totals, requests: parts.reduce((n, p) => n + p.requests, 0), averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: decodeRateOf(records), subagents, models: [...models.entries()].map(([model, x]) => { const name = displayNameOf(model, x.providers); return { model, ...(name ? { name } : {}), providers: [...x.providers].map(provider => providerNames[provider] || provider), totals: x.totals, requests: x.requests } }), records, routes: [...routeMap.values()] }
250
+ return { sessionId: rootId, totals, requests: parts.reduce((n, p) => n + p.requests, 0), averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: decodeRateOf(records), subagents, models: [...models.entries()].map(([model, x]) => { const name = displayNameOf(model, x.providers); return { model, ...(name ? { name } : {}), providers: [...x.providers].map(provider => providerNames[provider] || provider), totals: x.totals, requests: x.requests } }), records, routes: [...routeMap.values()], context: parts[0]?.context ?? null }
250
251
  }
package/src/store.ts CHANGED
@@ -608,8 +608,16 @@ export class UsageStore {
608
608
  /** Fold ONLY the failure count of a sample into the day and hour rows. The
609
609
  * failure upgrade pass uses this: its replay walks logs the token passes
610
610
  * already consumed, so the tokens and the request marker must not be added
611
- * a second time — only the failed-call counter may move. */
611
+ * a second time — only the failed-call counter may move.
612
+ *
613
+ * A sample WITHOUT the `failed` marker counts NOTHING here. The pass folds
614
+ * whole session logs, which are mostly successful calls: passing every
615
+ * folded sample through a write that hardcodes `failed: true` would count
616
+ * each call's request marker AND token sample as two extra "failures" —
617
+ * exactly how stores upgrading through v1.0.2–v1.0.8 ended up with
618
+ * failures ≈ 2 × requests on every pre-upgrade day ("失败的比总的都多"). */
612
619
  async recordFailure(sample: UsageSample): Promise<void> {
620
+ if (!sample.failed) return
613
621
  await this.record({
614
622
  day: sample.day,
615
623
  hour: sample.hour,
package/src/wire.ts CHANGED
@@ -195,6 +195,59 @@ export interface SessionUsageDetail {
195
195
  model: string
196
196
  status: 'idle' | 'running' | 'completed' | 'failed'
197
197
  }>
198
+ /** The root session's live context composition, rendered above the cards.
199
+ * Null when the log has no readable events (or on an older host). */
200
+ context?: SessionContextWindow | null
201
+ }
202
+
203
+ /** The six buckets the context-window panel paints, in legend order. They are
204
+ * the buckets a reader thinks in: the harness prompt, the tool catalogue, the
205
+ * injected memory (runtime context, skill catalog, compaction summaries), the
206
+ * files the agent read, the remaining tool results, and the conversation. */
207
+ export type SessionContextCategory = 'system' | 'tools' | 'memory' | 'files' | 'results' | 'conversation'
208
+
209
+ /** One block of the context-window treemap: a tool, one file path, one folded
210
+ * group of the same tool, or one message bucket. `tokens` is an ESTIMATE from
211
+ * character counts (see context-window.ts), so the panel renders it as ~N. */
212
+ export interface SessionContextItem {
213
+ /** Row key, unique inside one snapshot. */
214
+ key: string
215
+ category: SessionContextCategory
216
+ /** What to print: a tool name, a file path, or (for `kind`-bearing rows) the
217
+ * localized kind label the panel substitutes. */
218
+ label: string
219
+ /** Machine kind the panel localizes ("system-prompt", "user", "rest", …).
220
+ * Absent for rows whose label is itself the data (tool names, paths). */
221
+ kind?: string
222
+ /** How many log entries folded into this row (messages, tool calls). */
223
+ count: number
224
+ tokens: number
225
+ }
226
+
227
+ /** The context-window snapshot: what the session's next provider call would
228
+ * carry, reconstructed from its own log (pruned events dropped, a compaction
229
+ * summary standing in for the range it replaced, the newest request's tool
230
+ * catalogue). Null when the session has no readable events yet. */
231
+ export interface SessionContextWindow {
232
+ /** Sum of every item's estimated tokens. */
233
+ totalTokens: number
234
+ /** The model's window from the newest `request/context`, or null when the
235
+ * host route never reported one (the panel then says "unknown"). */
236
+ contextWindow: number | null
237
+ provider: string
238
+ model: string
239
+ /** Prompt-cache split of the newest answer: cache reads vs new prompt tokens. */
240
+ cacheHitTokens: number
241
+ cacheMissTokens: number
242
+ /** Whether any answer reported usage (the cache bar needs one to draw). */
243
+ cacheKnown: boolean
244
+ /** Prompt tokens the provider billed on the newest answer
245
+ * (`totalTokens − outputTokens`, or uncached + cached input): the real
246
+ * occupancy. Upstream prompt compression can make it smaller than
247
+ * `totalTokens`, which merely replays the stored log. */
248
+ promptTokens: number | null
249
+ /** Treemap blocks, largest first. */
250
+ items: SessionContextItem[]
198
251
  }
199
252
 
200
253
  /** The backfill (historical session scan) progress state. */