dsh-token-total 1.0.8 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/lib/client.js +673 -21
- package/lib/index.js +281 -4
- package/lib/types/client/ContextWindowPanel.d.ts +18 -0
- package/lib/types/client/SessionUsageView.d.ts +3 -2
- package/lib/types/client/context-window-core.d.ts +49 -0
- package/lib/types/client/session-usage-locales.d.ts +31 -0
- package/lib/types/context-window.d.ts +36 -0
- package/lib/types/store.d.ts +8 -1
- package/lib/types/wire.d.ts +50 -0
- package/package.json +1 -1
- package/src/client/ContextWindowPanel.module.css +97 -0
- package/src/client/ContextWindowPanel.tsx +260 -0
- package/src/client/SessionUsageView.module.css +81 -58
- package/src/client/SessionUsageView.tsx +13 -4
- package/src/client/context-window-core.ts +137 -0
- package/src/client/session-usage-locales.ts +42 -0
- package/src/collector.ts +4 -1
- package/src/context-window.ts +302 -0
- package/src/session-detail.ts +3 -2
- package/src/store.ts +9 -1
- package/src/wire.ts +53 -0
|
@@ -16,6 +16,20 @@ 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
|
+
contextDigest: '{used} / {window} · {percent}%', contextDigestUnknown: '{used} / 窗口未知',
|
|
22
|
+
contextFree: '空闲',
|
|
23
|
+
contextCache: '提示词缓存', contextCacheHit: '命中', contextCacheMiss: '未命中',
|
|
24
|
+
contextCachePending: '供应商回答可见',
|
|
25
|
+
contextSystem: '系统提示词', contextTools: '工具', contextMemory: '记忆',
|
|
26
|
+
contextFiles: '文件', contextResults: '工具结果', contextConversation: '会话内容',
|
|
27
|
+
contextKindSystemPrompt: '系统提示词', contextKindRuntimeContext: '运行时上下文',
|
|
28
|
+
contextKindSkillCatalog: '技能目录', contextKindTimeContext: '时间上下文',
|
|
29
|
+
contextKindCompaction: '压缩摘要', contextKindUser: '用户消息', contextKindAssistant: '助手消息',
|
|
30
|
+
contextKindRest: '另外 {count} 项', contextTimes: '×{count}',
|
|
31
|
+
contextEmpty: '本会话还没有可统计的上下文', contextTipShare: '占上下文 {percent}', contextTipWindow: '占窗口 {percent}',
|
|
32
|
+
contextEstimateNote: '色块按历史重放估算 {estimate};供应商实测提示词 {billed}(上游已压缩或裁剪)',
|
|
19
33
|
}
|
|
20
34
|
export type SessionUsageKey = keyof typeof usageZh
|
|
21
35
|
export const usageEn: Record<SessionUsageKey, string> = {
|
|
@@ -34,6 +48,20 @@ export const usageEn: Record<SessionUsageKey, string> = {
|
|
|
34
48
|
answering: 'answering...', firstWait: 'first token', sec: 's', tokenWord: 'token', perSec: 'token/s',
|
|
35
49
|
cacheShort: 'cache', avgCache: 'avg cache', avgRate: 'avg rate', inProgress: 'in progress',
|
|
36
50
|
live: 'Live', asOf: 'as of', refresh: 'Refresh',
|
|
51
|
+
contextTitle: 'Context window', contextUsed: 'Context used', contextLive: 'Live',
|
|
52
|
+
contextWindowKnown: 'Window {value} · {percent}% used', contextWindowUnknown: 'Model window size unknown',
|
|
53
|
+
contextDigest: '{used} / {window} · {percent}%', contextDigestUnknown: '{used} / window unknown',
|
|
54
|
+
contextFree: 'free',
|
|
55
|
+
contextCache: 'Prompt cache', contextCacheHit: 'hit', contextCacheMiss: 'miss',
|
|
56
|
+
contextCachePending: 'Visible once the provider answers',
|
|
57
|
+
contextSystem: 'System prompt', contextTools: 'Tools', contextMemory: 'Memory',
|
|
58
|
+
contextFiles: 'Files', contextResults: 'Tool results', contextConversation: 'Conversation',
|
|
59
|
+
contextKindSystemPrompt: 'System prompt', contextKindRuntimeContext: 'Runtime context',
|
|
60
|
+
contextKindSkillCatalog: 'Skill catalog', contextKindTimeContext: 'Time context',
|
|
61
|
+
contextKindCompaction: 'Compaction summary', contextKindUser: 'User messages', contextKindAssistant: 'Assistant messages',
|
|
62
|
+
contextKindRest: '{count} more', contextTimes: '×{count}',
|
|
63
|
+
contextEmpty: 'Nothing in this session’s context yet', contextTipShare: '{percent} of context', contextTipWindow: '{percent} of window',
|
|
64
|
+
contextEstimateNote: 'Blocks estimate {estimate} by replay; the provider billed {billed} after upstream compression',
|
|
37
65
|
}
|
|
38
66
|
export const usageZhTW: Record<SessionUsageKey, string> = {
|
|
39
67
|
...usageZh,
|
|
@@ -50,4 +78,18 @@ export const usageZhTW: Record<SessionUsageKey, string> = {
|
|
|
50
78
|
answering: '正在回答...', firstWait: '首字耗時', sec: '秒', tokenWord: 'token', perSec: 'token/秒',
|
|
51
79
|
cacheShort: '快取', avgCache: '平均快取', avgRate: '平均速率', inProgress: '個進行中',
|
|
52
80
|
live: '即時更新', asOf: '統計截至', refresh: '重新整理',
|
|
81
|
+
contextTitle: '上下文視窗', contextUsed: '已用上下文', contextLive: '即時',
|
|
82
|
+
contextWindowKnown: '模型視窗 {value} · 已用 {percent}%', contextWindowUnknown: '模型的視窗大小未知',
|
|
83
|
+
contextDigest: '{used} / {window} · {percent}%', contextDigestUnknown: '{used} / 視窗未知',
|
|
84
|
+
contextFree: '空閒',
|
|
85
|
+
contextCache: '提示詞快取', contextCacheHit: '命中', contextCacheMiss: '未命中',
|
|
86
|
+
contextCachePending: '供應商回答可見',
|
|
87
|
+
contextSystem: '系統提示詞', contextTools: '工具', contextMemory: '記憶',
|
|
88
|
+
contextFiles: '檔案', contextResults: '工具結果', contextConversation: '會話內容',
|
|
89
|
+
contextKindSystemPrompt: '系統提示詞', contextKindRuntimeContext: '執行環境上下文',
|
|
90
|
+
contextKindSkillCatalog: '技能目錄', contextKindTimeContext: '時間上下文',
|
|
91
|
+
contextKindCompaction: '壓縮摘要', contextKindUser: '使用者訊息', contextKindAssistant: '助手訊息',
|
|
92
|
+
contextKindRest: '另外 {count} 項', contextTimes: '×{count}',
|
|
93
|
+
contextEmpty: '本會話還沒有可統計的上下文', contextTipShare: '佔上下文 {percent}', contextTipWindow: '佔視窗 {percent}',
|
|
94
|
+
contextEstimateNote: '色塊按歷史重放估算 {estimate};供應商實測提示詞 {billed}(上游已壓縮或裁剪)',
|
|
53
95
|
}
|
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
|
-
|
|
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
|
+
}
|
package/src/session-detail.ts
CHANGED
|
@@ -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. */
|