dsh-desktop-statusbar 2.0.2 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -11,22 +11,39 @@
11
11
  * 4. `sessionTiming` 投影 — 逐次模型调用的首字延迟与输出速度极值(最快/最慢)。
12
12
  * 官方 sessionStats 只有累计值(ttftMs/ttftSteps、decodeMs/decodeTokens),
13
13
  * 单次极值推不出来,所以这里按同一套事件口径自己折一份。
14
- * 5. `/dsh-desktop-statusbar/api/balance` — DeepSeek 账户余额(含充值与赠金两项)。
15
- * 6. `/dsh-desktop-statusbar/api/holidays` — 抓国务院的放假通知,解析出放假日。
14
+ * 5. `/dsh-desktop-statusbar/api/balance` — 当前会话实际计费通道的账户余额。
15
+ * DeepSeek 有两条通道:`deepseek-account`(登录账号额度)与 `deepseek-official`
16
+ * (模型页那把 API key 的账户),按会话实际用的 provider 选,互不顶替;
17
+ * 其它平台(GLM、Kimi、OpenRouter 等)按其适配器的端点与字段查。
18
+ * 6. `/dsh-desktop-statusbar/api/daily-usage` — 本周(周一 00:00 起)每天的 token 用量。
19
+ * 来源是一份本地台账(`<DSH_HOME>/dsh-status-bar/usage-ledger.json`):启动时把会话日志里
20
+ * 新产生的用量并进去,以后只在打开活跃总览面板时(带 force=1)现扫一次,30 秒内复用缓存。
21
+ * 台账只增不减:删掉会话或整个工作区,已记下的数字都不缩水;当时没有单价的用量记成
22
+ * "欠账",补上单价后只结算欠账,不重算已经算好的费用。只取 token 数字,不读正文。
23
+ * 7. `/dsh-desktop-statusbar/api/holidays` — 抓国务院的放假通知,解析出放假日。
16
24
  * (客户端跨域抓不到 gov.cn,只能由 host 代抓;正文解析在 lib/holiday.js。)
25
+ * 8. `/dsh-desktop-statusbar/api/subagent-usage` — 按 `parentSession` 聚合子智能体
26
+ * (Agent Teams 的队友)的用量。子智能体是**独立会话**,它的消耗不在本会话的投影里,
27
+ * 底栏「总计」「本轮」要算上就只能由 host 读会话日志补出来。可选参数 `since`(毫秒)
28
+ * 只统计该时刻之后的用量:客户端传了就按它(`sinceSource='client'`),没传(或非法:
29
+ * 非有限数 / 负数 / 未来时刻)则由 host 读**父会话日志**推起点(`sinceSource='host'`),
30
+ * 连父会话都没有就本轮不并队友(`sinceSource='none'`);要整会话显式传 `since=0`。
31
+ * 返回里 `total.stats` 是后代**整会话**的首字/解码累计(与窗口无关),给底栏
32
+ * 「首字平均 / 综合速度」并进会话级数值用。只读,绝不写台账。
17
33
  *
18
- * 凭据来源按可靠性依次尝试:环境变量 DEEPSEEK_API_KEY → `<DSH_HOME>/.credentials.yaml`
19
- * → `ctx.credentials`(本部署里 records 只有 client-connection 记录,故常为空)。
20
- * key 只在本进程内存中用于调用官方余额接口,不落盘、不转发、不写日志。
34
+ * 凭据来源:DeepSeek 走 `ctx.credentials`(模型页写入的权威来源),退回环境变量
35
+ * DEEPSEEK_API_KEY 与 `<DSH_HOME>/.credentials.yaml`;其它平台按适配器的 refs 逐个解析。
36
+ * key 只在本进程内存中用于调用对应平台的余额接口,不落盘、不转发、不写日志。
21
37
  *
22
38
  * @module dsh-desktop-statusbar
23
39
  */
24
40
  import { z } from 'zod'
25
- import { adapterFor } from './balance-api.js'
41
+ import { adapterFor, adaptersFor, pluginRoutesIn } from './balance-api.js'
26
42
  import { holidayHtmlToText, parseHolidayNotice } from './holiday.js'
27
- import { readFileSync } from 'node:fs'
28
- import { join } from 'node:path'
43
+ import { appendFileSync, closeSync, copyFileSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs'
44
+ import { basename, dirname, join } from 'node:path'
29
45
  import { homedir } from 'node:os'
46
+ import * as zlib from 'node:zlib'
30
47
 
31
48
  export const name = 'dsh-desktop-statusbar'
32
49
 
@@ -72,8 +89,10 @@ const sessionModelProjection = {
72
89
  init: () => ({ provider: null, model: null, updatedAt: null }),
73
90
  apply: (state, event) => {
74
91
  if (event.type !== 'assistant/message') return state
75
- const source = event.data.message.source
76
- if (source.kind !== 'model') return state
92
+ const source = event.data === undefined || event.data === null || event.data.message === undefined || event.data.message === null
93
+ ? null
94
+ : event.data.message.source
95
+ if (source === null || source === undefined || source.kind !== 'model') return state
77
96
  const { provider, model } = source
78
97
  if (provider === state.provider && model === state.model) return state
79
98
  return { provider, model, updatedAt: event.time }
@@ -82,6 +101,198 @@ const sessionModelProjection = {
82
101
  stateVersion: 1,
83
102
  }
84
103
 
104
+ /* ------------------------------------------------------------ sessionCapacity */
105
+
106
+ const sessionCapacitySchema = z.object({
107
+ contextWindow: z.number().nullable(),
108
+ maxOutputTokens: z.number().nullable(),
109
+ updatedAt: z.number().nullable(),
110
+ }).strict()
111
+
112
+ /**
113
+ * 上下文压缩的触发点要两个入参,它们只出现在请求事件里:
114
+ * request/header → data.header.config.maxTokens(本次请求的输出上限,随推理档位浮动)
115
+ * request/context → data.contextWindow(只在 provider/model/窗口变化时才记,比 header 稀疏得多)
116
+ * 两者都得折:少一个客户端就算不出压缩点,圆环上就不开缺口。
117
+ */
118
+ const sessionCapacityProjection = {
119
+ key: 'desktopStatusbarCapacity',
120
+ stateSchema: sessionCapacitySchema,
121
+ init: () => ({ contextWindow: null, maxOutputTokens: null, updatedAt: null }),
122
+ apply: (state, event) => {
123
+ const data = event.data === undefined || event.data === null ? {} : event.data
124
+ let contextWindow = state.contextWindow
125
+ let maxOutputTokens = state.maxOutputTokens
126
+ if (event.type === 'request/context') {
127
+ if (typeof data.contextWindow !== 'number') return state
128
+ contextWindow = data.contextWindow
129
+ } else if (event.type === 'request/header') {
130
+ const config = data.header === undefined || data.header === null ? null : data.header.config
131
+ if (config === null || typeof config.maxTokens !== 'number') return state
132
+ maxOutputTokens = config.maxTokens
133
+ } else {
134
+ return state
135
+ }
136
+ if (contextWindow === state.contextWindow && maxOutputTokens === state.maxOutputTokens) return state
137
+ return { contextWindow, maxOutputTokens, updatedAt: event.time }
138
+ },
139
+ wire: { viewSchema: sessionCapacitySchema, view: (state) => state },
140
+ stateVersion: 1,
141
+ }
142
+
143
+ /* -------------------------------------------------------- sessionProgress */
144
+
145
+ /** 一次压缩落在哪一轮哪一步(压缩事件只带轮号,步号取压缩前最后一步;取不到给 null)。 */
146
+ const compactMark = z.object({
147
+ turn: z.number(),
148
+ step: z.number().nullable(),
149
+ }).strict()
150
+
151
+ const sessionProgressSchema = z.object({
152
+ turn: z.number(),
153
+ turnSteps: z.number(),
154
+ toolCalls: z.number(),
155
+ /* 工具名 → 调用次数:气泡里「工具调用」那一行点开要逐个列出来 */
156
+ toolCounts: z.record(z.string(), z.number()),
157
+ skills: z.array(z.string()),
158
+ /* 注入进会话的全局提示词文件名(~/.dsh/AGENTS.md → 'AGENTS.md');没注入给 null */
159
+ globalInstruction: z.string().nullable(),
160
+ compactCount: z.number(),
161
+ compactTurn: z.number().nullable(),
162
+ compactStep: z.number().nullable(),
163
+ /* 每一次压缩的落点,按发生顺序排:气泡里「上下文压缩」那一行点开逐条列 */
164
+ compacts: z.array(compactMark),
165
+ }).strict()
166
+
167
+ /** 从 skill 工具的参数里取技能名;参数是 JSON 字符串,坏掉就当没这个名字。 */
168
+ function skillNameOf(data) {
169
+ let args = data.arguments
170
+ if (typeof args === 'string') {
171
+ try {
172
+ args = JSON.parse(args)
173
+ } catch (error) {
174
+ return null
175
+ }
176
+ }
177
+ if (args === null || args === undefined || typeof args !== 'object') return null
178
+ return typeof args.name === 'string' && args.name.length > 0 ? args.name : null
179
+ }
180
+
181
+ /**
182
+ * 技能还有一条斜杠命令注入的路:/i-have-adhd 这类不产生 skill 工具调用,
183
+ * 只补一条 user/message,source 形如 { kind: 'skill-invocation', name: 'i-have-adhd', form: 'instructions' }。
184
+ * skill-catalog(可用技能清单,每次会话都注入)不是「这次注入的技能」,必须排除。
185
+ */
186
+ function skillInvocationOf(data) {
187
+ if (data === null || data === undefined) return null
188
+ const source = data.source
189
+ if (source === null || source === undefined || source.kind !== 'skill-invocation') return null
190
+ return typeof source.name === 'string' && source.name.length > 0 ? source.name : null
191
+ }
192
+
193
+ /**
194
+ * 全局提示词是靠 agent-instructions 注入的一条 user/message 进来的,source 形如:
195
+ * { kind: 'agent-instructions', form: 'instructions', changes: [{ action: 'set',
196
+ * scope: 'user-global\u0000AGENTS.md', path: '~/.dsh/AGENTS.md', digest: '…' }] }
197
+ * 只认 user-global 作用域(项目的 AGENTS.md 不算「全局提示词」),名字取路径的文件名。
198
+ */
199
+ function globalInstructionOf(data) {
200
+ if (data === null || data === undefined) return null
201
+ const source = data.source
202
+ if (source === null || source === undefined || source.kind !== 'agent-instructions') return null
203
+ const changes = Array.isArray(source.changes) ? source.changes : []
204
+ for (const change of changes) {
205
+ if (change === null || change === undefined) continue
206
+ const scope = typeof change.scope === 'string' ? change.scope : ''
207
+ /* 作用域是「作用域\u0000文件名」,只认 user-global 那一段 */
208
+ if (scope.split('\u0000')[0] !== 'user-global') continue
209
+ const path = typeof change.path === 'string' ? change.path : ''
210
+ const name = path.split(/[\\/]/).pop()
211
+ if (typeof name === 'string' && name.length > 0) return name
212
+ }
213
+ return null
214
+ }
215
+
216
+ /**
217
+ * 轮次项气泡要的几个数官方 sessionStats 一个都没有(它只有 turns / steps 与各类耗时),
218
+ * 所以在 host 侧按事件折一份:
219
+ * step/start → 本轮走到第几步(轮号一变就归零,step 是轮内序号不是会话累计)
220
+ * tool/call → 整个会话的工具调用次数 + 每个工具名各自的次数;
221
+ * 工具名是 skill 的那种再把技能名按注入顺序记进 skills
222
+ * user/message → 全局提示词(agent-instructions 注入的那种)的文件名;
223
+ * 斜杠命令注入的技能(skill-invocation)也补进 skills
224
+ * compaction/start → 压缩次数 + 这一次的落点;压缩事件只带轮号,
225
+ * 压缩发生在哪一步取压缩前最后一条同轮 step/start
226
+ * 这几种事件在真实日志里都齐(75 个会话:11208 次工具调用、22 个压缩事件)。
227
+ */
228
+ const sessionProgressProjection = {
229
+ key: 'desktopStatusbarProgress',
230
+ stateSchema: sessionProgressSchema,
231
+ init: () => ({
232
+ turn: 0,
233
+ turnSteps: 0,
234
+ toolCalls: 0,
235
+ toolCounts: {},
236
+ skills: [],
237
+ globalInstruction: null,
238
+ compactCount: 0,
239
+ compactTurn: null,
240
+ compactStep: null,
241
+ compacts: [],
242
+ }),
243
+ apply: (state, event) => {
244
+ if (event.type === 'step/start') {
245
+ const data = event.data === undefined || event.data === null ? {} : event.data
246
+ if (typeof data.step !== 'number') return state
247
+ const turn = typeof data.turn === 'number' ? data.turn : state.turn
248
+ const turnSteps = turn === state.turn ? Math.max(state.turnSteps, data.step) : data.step
249
+ if (turn === state.turn && turnSteps === state.turnSteps) return state
250
+ return { ...state, turn: turn, turnSteps: turnSteps }
251
+ }
252
+ if (event.type === 'user/message') {
253
+ const skill = skillInvocationOf(event.data)
254
+ if (skill !== null) return { ...state, skills: state.skills.concat([skill]) }
255
+ const name = globalInstructionOf(event.data)
256
+ if (name === null || state.globalInstruction === name) return state
257
+ return { ...state, globalInstruction: name }
258
+ }
259
+ if (event.type === 'tool/call') {
260
+ const data = event.data === undefined || event.data === null ? {} : event.data
261
+ const name = typeof data.name === 'string' && data.name.length > 0 ? data.name : null
262
+ const skill = name === 'skill' ? skillNameOf(data) : null
263
+ const counts = name === null ? state.toolCounts : {
264
+ ...state.toolCounts,
265
+ [name]: (state.toolCounts[name] === undefined ? 0 : state.toolCounts[name]) + 1,
266
+ }
267
+ return {
268
+ ...state,
269
+ toolCalls: state.toolCalls + 1,
270
+ toolCounts: counts,
271
+ /* 技能有两种注入来源:调 skill 工具(这里)与斜杠命令(user/message 分支);
272
+ 名字按注入顺序排,重复注入也照记(数量 = 数组长度) */
273
+ skills: skill === null ? state.skills : state.skills.concat([skill]),
274
+ }
275
+ }
276
+ if (event.type === 'compaction/start') {
277
+ const data = event.data === undefined || event.data === null ? {} : event.data
278
+ const turn = typeof data.turn === 'number' ? data.turn : state.turn
279
+ /* 压缩前没有同轮的步(压缩落在轮首)时给 null,客户端那一格显示横杠 */
280
+ const step = turn === state.turn && state.turnSteps > 0 ? state.turnSteps : null
281
+ return {
282
+ ...state,
283
+ compactCount: state.compactCount + 1,
284
+ compactTurn: turn,
285
+ compactStep: step,
286
+ compacts: state.compacts.concat([{ turn: turn, step: step }]),
287
+ }
288
+ }
289
+ return state
290
+ },
291
+ wire: { viewSchema: sessionProgressSchema, view: (state) => state },
292
+ // 字段变了(加 globalInstruction、技能多一条斜杠命令来源),提升版本重建旧状态
293
+ stateVersion: 6,
294
+ }
295
+
85
296
  /* ------------------------------------------------------------ sessionUsage */
86
297
 
87
298
  const usageBucket = z.object({
@@ -124,6 +335,27 @@ function usageOf(event) {
124
335
  return usage
125
336
  }
126
337
 
338
+ /**
339
+ * 这两类事件带着"这次请求用的是哪个模型":`session/title-llm-request` 的 route、
340
+ * `model/selection` 的 model。assistant/attempt 只有 stream、没有 message,取不到型号,
341
+ * 靠投影里记下的最近一次请求补上 —— 否则那条事件会让整条投影抛错并永久停摆。
342
+ */
343
+ function routedModelOf(event) {
344
+ const data = event.data === null || event.data === undefined ? null : event.data
345
+ if (data === null || typeof data !== 'object') return null
346
+ if (event.type === 'session/title-llm-request') {
347
+ const route = data.route
348
+ if (route === null || route === undefined || typeof route !== 'object') return null
349
+ if (typeof route.model !== 'string' || route.model.length === 0) return null
350
+ return { model: route.model, provider: typeof route.provider === 'string' ? route.provider : '' }
351
+ }
352
+ if (event.type === 'model/selection') {
353
+ if (typeof data.model !== 'string' || data.model.length === 0) return null
354
+ return { model: data.model, provider: typeof data.provider === 'string' ? data.provider : '' }
355
+ }
356
+ return null
357
+ }
358
+
127
359
  /** 取数值字段,兼容几种命名;取不到返回 0。 */
128
360
  const numOf = (source, names) => {
129
361
  for (const name of names) {
@@ -178,12 +410,16 @@ const sessionUsageSchema = z.object({
178
410
  turn: z.number().nullable(),
179
411
  current: lastUsage.nullable(),
180
412
  last: lastUsage.nullable(),
413
+ /* 最近一次请求用的模型(见 routedModelOf):不带 message 的 assistant/attempt 靠它补型号。
414
+ 可空且可选 —— 万一某条路径没带上这两个键,也不至于让整个 state 过不了校验。 */
415
+ routeModel: z.string().nullable().optional(),
416
+ routeProvider: z.string().nullable().optional(),
181
417
  }).strict()
182
418
 
183
419
  const sessionUsageProjection = {
184
420
  key: 'desktopStatusbarUsage',
185
421
  stateSchema: sessionUsageSchema,
186
- init: () => ({ models: {}, calls: [], current: null, last: null, turn: null }),
422
+ init: () => ({ models: {}, calls: [], current: null, last: null, turn: null, routeModel: null, routeProvider: null }),
187
423
  apply: (state, event) => {
188
424
  const calls = Array.isArray(state.calls) ? state.calls : []
189
425
  const turnOf = (event) => {
@@ -197,12 +433,26 @@ const sessionUsageProjection = {
197
433
  if (state.current === null) return { ...state, turn: started }
198
434
  return { ...state, calls, current: null, last: state.current, turn: started }
199
435
  }
436
+ // 先记下"这次请求用的是哪个模型":没有 message 的事件(assistant/attempt)靠它补型号
437
+ const routed = routedModelOf(event)
438
+ const base = routed === null ? state : {
439
+ ...state,
440
+ routeModel: routed.model,
441
+ routeProvider: routed.provider,
442
+ }
200
443
  const usage = usageOf(event)
201
- if (usage === undefined || usage === null) return state
202
- const source = event.data.message.source
203
- if (source.kind !== 'model') return state
204
- const model = source.model
205
- const currentModel = state.models[model]
444
+ if (usage === undefined || usage === null) return base
445
+ // 型号两条路:常规事件从 message.source 取;没有 message 的用上面记下的最近一次请求
446
+ const message = event.data === null || event.data === undefined ? null : event.data.message
447
+ const source = message === null || message === undefined ? null : message.source
448
+ const fromMessage = source !== null && source.kind === 'model' && typeof source.model === 'string'
449
+ const model = fromMessage ? source.model : (typeof base.routeModel === 'string' ? base.routeModel : '')
450
+ // 两条路都拿不到型号:放弃这一笔,好过抛错把整条投影(以及同一次驱动的其它投影)带停
451
+ if (model.length === 0) return base
452
+ const provider = fromMessage && typeof source.provider === 'string'
453
+ ? source.provider
454
+ : (typeof base.routeProvider === 'string' ? base.routeProvider : '')
455
+ const currentModel = base.models[model]
206
456
  const parts = usageParts(usage)
207
457
  const bucket = {
208
458
  input: (currentModel === undefined ? 0 : currentModel.input) + parts.input,
@@ -220,12 +470,12 @@ const sessionUsageProjection = {
220
470
  }
221
471
  const turn = turnOf(event)
222
472
  // 轮次变化就重开本轮汇总:不能假设一定有 turn/start
223
- const sameTurn = state.current !== null && Array.isArray(state.current.calls)
473
+ const sameTurn = base.current !== null && Array.isArray(base.current.calls)
224
474
  && (turn === null || stateTurn === null || turn === stateTurn)
225
- const prev = sameTurn ? state.current : null
475
+ const prev = sameTurn ? base.current : null
226
476
  const callsForTurn = prev === null ? [] : prev.calls
227
477
  const summary = {
228
- provider: source.provider,
478
+ provider,
229
479
  model,
230
480
  time: event.time,
231
481
  input: (prev === null ? 0 : prev.input) + one.input,
@@ -235,13 +485,13 @@ const sessionUsageProjection = {
235
485
  }
236
486
  const current = { ...summary, calls: callsForTurn.concat([one]) }
237
487
  return {
238
- ...state,
488
+ ...base,
239
489
  turn: turn === null ? stateTurn : turn,
240
490
  calls: appendCalls(calls, [one]),
241
491
  current,
242
492
  // 轮次变化时把上一轮结算进 last
243
- last: prev === null && state.current !== null ? state.current : state.last,
244
- models: { ...state.models, [model]: bucket },
493
+ last: prev === null && base.current !== null ? base.current : base.last,
494
+ models: { ...base.models, [model]: bucket },
245
495
  }
246
496
  },
247
497
  wire: {
@@ -251,7 +501,9 @@ const sessionUsageProjection = {
251
501
  // schema 加了 current(本轮累计),提升版本以重建旧状态。
252
502
  // 3 → 4:历史缓存里有按旧口径算出来的逐条/本轮用量(未缓存输入少掉一截),
253
503
  // 界面上的「本轮」因此与「总计」对不上,提升版本强制从事件日志重放。
254
- stateVersion: 4,
504
+ // 4 → 5:旧缓存里缺「会话起标题」那几笔 —— attempt 事件没有 message,取不到型号时
505
+ // 整条投影抛错、该会话的账从此停摆。提升版本强制重放,把这些笔补回账本。
506
+ stateVersion: 5,
255
507
  }
256
508
 
257
509
  /* -------------------------------------------------------- sessionTimeRange */
@@ -433,8 +685,10 @@ const sessionTimingProjection = {
433
685
  }),
434
686
  apply: (state, event) => {
435
687
  if (event.type === 'step/start') {
688
+ const data = event.data === undefined || event.data === null ? {} : event.data
689
+ if (typeof data.turn !== 'number') return state
436
690
  /* 换轮就把本轮累计清零:气泡里的"本轮平均…"只算当前这一轮 */
437
- const turn = event.data.turn
691
+ const turn = data.turn
438
692
  const sameTurn = state.turn === turn
439
693
  return {
440
694
  ...state,
@@ -444,7 +698,7 @@ const sessionTimingProjection = {
444
698
  turnDecodeMs: sameTurn ? state.turnDecodeMs : 0,
445
699
  turnDecodeTokens: sameTurn ? state.turnDecodeTokens : 0,
446
700
  openStep: {
447
- turn: event.data.turn, step: event.data.step, startTime: event.time,
701
+ turn: data.turn, step: data.step, startTime: event.time,
448
702
  firstTokenTime: null, firstTokenTool: false,
449
703
  },
450
704
  }
@@ -513,8 +767,6 @@ const sessionTimingProjection = {
513
767
 
514
768
  /* 不缓存余额:每次请求都问一次官方账号服务,和设置页「账号与余额」打开时的取值节奏一致。
515
769
  * 之前这里是 60 秒缓存,叠上客户端 60 秒轮询,底栏最坏会落后近两分钟。 */
516
- const BALANCE_TTL_MS = 0
517
- let balanceCache = { at: 0, value: null }
518
770
 
519
771
  /* 诊断:account 服务的探测结论,随 /api/balance 一起返回(客户端不读它,只用来查为什么回退)。 */
520
772
  let accountProbeNote = '未探测'
@@ -583,48 +835,43 @@ function readJsonBody(req) {
583
835
  })
584
836
  }
585
837
 
586
- /** 环境变量是最高优先级的来源。 */
587
- function keyFromEnv() {
588
- const value = process.env.DEEPSEEK_API_KEY
838
+ /** 环境变量是最高优先级的来源;refName 为空时按 DeepSeek 的引用名。 */
839
+ function keyFromEnv(refName) {
840
+ const name = typeof refName === 'string' && refName.length > 0 ? refName : 'DEEPSEEK_API_KEY'
841
+ const value = process.env[name]
589
842
  return typeof value === 'string' && value.length > 10 ? value : null
590
843
  }
591
844
 
592
845
  /**
593
- * `<DSH_HOME>/.credentials.yaml`。该文件的 records 在本部署里只有
594
- * client-connection 记录,真正的 key 由 refs 段的 DEEPSEEK_API_KEY 引用,
595
- * 因此直接按名字匹配取值,必要时退回 records 里的 secret。
846
+ * `<DSH_HOME>/.credentials.yaml` 的 refs 段按引用名取值(模型页写入的 key 就落在那里)。
847
+ * 只认引用名本身:records 里的 `secret` 是 client-connection 的授权口令,不是任何平台的
848
+ * API key —— 先前拿它兜底,会让没配 key 的平台把这个口令发给对方接口(实测换回 401)。
596
849
  */
597
- function keyFromCredentialsFile() {
850
+ function keyFromCredentialsFile(refName) {
851
+ const name = typeof refName === 'string' && refName.length > 0 ? refName : 'DEEPSEEK_API_KEY'
598
852
  try {
599
853
  const home = process.env.DSH_HOME !== undefined && process.env.DSH_HOME.length > 0
600
854
  ? process.env.DSH_HOME
601
855
  : join(homedir(), '.dsh')
602
856
  const text = readFileSync(join(home, '.credentials.yaml'), 'utf8')
603
- const patterns = [
604
- /DEEPSEEK_API_KEY\s*:\s*["']?([^"'\s#]+)["']?/,
605
- /SECRET\s*:\s*["']?([^"'\s#]+)["']?/i,
606
- /secret\s*:\s*["']?([^"'\s#]+)["']?/,
607
- ]
608
- for (const pattern of patterns) {
609
- const match = text.match(pattern)
610
- if (match !== null && typeof match[1] === 'string' && match[1].length > 10) return match[1]
611
- }
857
+ const quoted = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
858
+ const pattern = new RegExp('(?:^|[\\s{,])["\']?' + quoted + '["\']?\\s*:\\s*["\']?([^"\'\\s#}]+)')
859
+ const match = text.match(pattern)
860
+ if (match !== null && typeof match[1] === 'string' && match[1].length > 10) return match[1]
612
861
  } catch (error) {
613
862
  /* 文件缺失或不可读 */
614
863
  }
615
864
  return null
616
865
  }
617
866
 
618
- function keyFromCredentialStore(ctx) {
867
+ function keyFromCredentialStore(ctx, refName) {
619
868
  const store = ctx.credentials
620
869
  if (store === undefined || store === null) return null
621
- const ids = []
622
- try {
623
- for (const id of Object.keys(store)) ids.push(id)
624
- } catch (error) {
625
- /* ignore */
626
- }
627
- if (ids.length === 0) ids.push('deepseek-official', 'deepseek')
870
+ /* 只按引用名取;没有引用名时才回落到 DeepSeek 的历史 id。
871
+ 逐个遍历 store 的键会串到别的凭据上,第三方平台拿到的就不是自己的 key。 */
872
+ const ids = typeof refName === 'string' && refName.length > 0
873
+ ? [refName]
874
+ : ['deepseek-official', 'deepseek']
628
875
  for (const id of ids) {
629
876
  let credential
630
877
  try {
@@ -660,8 +907,8 @@ async function resolveAdapterKey(ctx, adapter) {
660
907
  }
661
908
  }
662
909
  }
663
- /* 没挂凭据服务时的旧路径:环境变量 / .credentials.yaml */
664
- const fallback = findApiKey(ctx)
910
+ /* 没挂凭据服务时的旧路径:环境变量 / .credentials.yaml(都按该适配器的引用名取) */
911
+ const fallback = findApiKey(ctx, adapter.refs[0])
665
912
  return { key: fallback.key, source: fallback.source }
666
913
  }
667
914
 
@@ -717,12 +964,12 @@ async function queryWithAdapter(ctx, adapter, now) {
717
964
  }
718
965
  }
719
966
 
720
- function findApiKey(ctx) {
721
- const fromEnv = keyFromEnv()
967
+ function findApiKey(ctx, refName) {
968
+ const fromEnv = keyFromEnv(refName)
722
969
  if (fromEnv !== null) return { key: fromEnv, source: 'env' }
723
- const fromFile = keyFromCredentialsFile()
970
+ const fromFile = keyFromCredentialsFile(refName)
724
971
  if (fromFile !== null) return { key: fromFile, source: 'credentials.yaml' }
725
- const fromStore = keyFromCredentialStore(ctx)
972
+ const fromStore = keyFromCredentialStore(ctx, refName)
726
973
  if (fromStore !== null) return { key: fromStore, source: 'credentials-service' }
727
974
  return { key: null, source: null }
728
975
  }
@@ -730,7 +977,7 @@ function findApiKey(ctx) {
730
977
  /**
731
978
  * 官方账号服务里的余额(设置页「账号与余额」用的就是它):
732
979
  * getBalance 返回充值钱包 value[] 与赠金钱包 bonusWallets[],两者相加正是底栏要的"充值 + 赠送"合计。
733
- * 服务取不到、账号未登录或调用抛错,一律返回 null,交给下面 API key 的余额接口兜底。
980
+ * 只在会话没有 API key、纯账号登录模式时采用;取不到、账号未登录或调用抛错一律返回 null。
734
981
  */
735
982
  async function queryAccountBalance(ctx) {
736
983
  if (ctx === null || ctx === undefined || typeof ctx.get !== 'function') {
@@ -807,33 +1054,260 @@ async function queryAccountBalance(ctx) {
807
1054
  }
808
1055
  }
809
1056
 
1057
+ /**
1058
+ * 本机路由取余额的适配器(当前只有 WorkBuddy 积分)。
1059
+ *
1060
+ * 为什么不直接调上游:WorkBuddy 的凭据由 connect 类插件解密持有,
1061
+ * 上游的鉴权头、计费接口与响应解析也都在它那边。它把这些结论挂在本机 Web 路由上,
1062
+ * 这里只读那条路由 —— 本插件既不碰凭据,也不用把上游协议再实现一遍。
1063
+ *
1064
+ * 连的是 loopback,Host 头是 127.0.0.1:port,不带 Origin,正好过对方路由的 loopback 校验。
1065
+ */
1066
+
1067
+ /** 从 webServer 服务上取本机端口;取不到返回 null。 */
1068
+ function localWebPort(ctx) {
1069
+ /* inject 里声明过 webServer,所以 ctx.webServer 与 ctx.get('webServer') 是同一个对象;
1070
+ 两条都试,取第一个真带端口号的,免得宿主哪天改注册方式、或某个入口没挂服务时就取不到。 */
1071
+ const candidates = []
1072
+ if (ctx !== null && ctx !== undefined) {
1073
+ if (ctx.webServer !== undefined && ctx.webServer !== null) candidates.push(ctx.webServer)
1074
+ if (typeof ctx.get === 'function') {
1075
+ try {
1076
+ const viaGet = ctx.get('webServer')
1077
+ if (viaGet !== undefined && viaGet !== null) candidates.push(viaGet)
1078
+ } catch (error) {
1079
+ /* 服务没注册时 get 可能抛错,忽略 */
1080
+ }
1081
+ }
1082
+ }
1083
+ const withPort = candidates.filter((one) => typeof one.port === 'number' && one.port > 0)[0]
1084
+ const port = withPort !== undefined ? withPort.port : Number(process.env.DSH_WEB_PORT)
1085
+ return Number.isFinite(port) && port > 0 ? port : null
1086
+ }
1087
+
1088
+ /**
1089
+ * 探测一条本机路由:返回 `{ reachable, data }`。
1090
+ *
1091
+ * `reachable` 的判据来自可行性调研的实测(这一步很关键,否则会误判):
1092
+ * · 200 + JSON → 可达,`data` 是解析好的文档
1093
+ * · 405 / 403 / 400 + JSON → **路由存在**(只是方法/来源/参数不对)⇒ 可达但不可读
1094
+ * · 404 **且**无 Content-Type **且**空体 → 宿主兜底「这条路由没注册」⇒ 不可达
1095
+ * · 其它(网络、超时、5xx) → 环境问题 ⇒ 不可达,且调用方**不该**据此换下一条路径
1096
+ * 为什么 404 要连 Content-Type 与空体一起判:插件自己的 handler 也会返回 404+JSON
1097
+ * (本插件的 `/api` 就是),只看状态码会把「插件在、但没这条数据」当成「插件不在」。
1098
+ */
1099
+ async function probeLocalRoute(url) {
1100
+ try {
1101
+ const response = await fetch(url, {
1102
+ method: 'GET',
1103
+ headers: { accept: 'application/json' },
1104
+ signal: AbortSignal.timeout(10000),
1105
+ })
1106
+ const type = response.headers === undefined || typeof response.headers.get !== 'function'
1107
+ ? null
1108
+ : response.headers.get('content-type')
1109
+ if (response.status === 404 && (type === null || type.indexOf('json') < 0)) {
1110
+ return { reachable: false, data: null, reason: 'http-404' }
1111
+ }
1112
+ if (!response.ok) {
1113
+ return { reachable: true, data: null, reason: 'http-' + String(response.status) }
1114
+ }
1115
+ let data = null
1116
+ try {
1117
+ data = await response.json()
1118
+ } catch (error) {
1119
+ data = null
1120
+ }
1121
+ return { reachable: true, data: data, reason: null }
1122
+ } catch (error) {
1123
+ return { reachable: false, data: null, reason: 'network' }
1124
+ }
1125
+ }
1126
+
1127
+ /**
1128
+ * 候选路径:适配器自己声明的(新旧插件各一条)+ **通用发现**到的(同族插件)。
1129
+ *
1130
+ * 通用发现读的是宿主 webServer 的 `exact` / `prefixes` 两张路由表。它们是**未公开内部字段**
1131
+ * (全宿主没有任何消费方枚举过),所以整段必须 try/catch:拿不到就当"发现不可用",
1132
+ * 静默回退到声明表 —— 宿主哪天改名,这里只是少发现几个插件,不会让余额整块崩掉。
1133
+ */
1134
+ function localCandidatePaths(ctx, adapter, provider) {
1135
+ const declared = typeof adapter.pathsOf === 'function'
1136
+ ? adapter.pathsOf(provider)
1137
+ : [adapter.request(null, null, provider).path]
1138
+ const out = declared.slice()
1139
+ if (typeof pluginRoutesIn !== 'function') return out
1140
+ let found = []
1141
+ try {
1142
+ const server = ctx === null || ctx === undefined ? null : ctx.webServer
1143
+ const tables = server === null || server === undefined ? [] : [server.exact, server.prefixes]
1144
+ for (let i = 0; i < tables.length; i += 1) {
1145
+ found = found.concat(pluginRoutesIn(tables[i]))
1146
+ }
1147
+ } catch (error) {
1148
+ /* 未公开内部字段:拿不到就只用声明表(正常路径,不是错误) */
1149
+ return out
1150
+ }
1151
+ /* 通用发现只补**同族**(connect 类)的只读路由,且不重复声明过的。
1152
+ 只认插件 id 里含本适配器 keys 的那些(workbuddy / codebuddy):
1153
+ 这样换插件作者、换包名(dsh-workbuddy-connect → dsh-connect-workbuddy)时能自动跟上,
1154
+ 而不会把无关插件的 /usage 也拉进来盲探 —— 那既慢(实测单次 0.5-0.7s,含上游读取),
1155
+ 又可能碰到别人的私有路由。 */
1156
+ const keys = Array.isArray(adapter.keys) ? adapter.keys : []
1157
+ for (let i = 0; i < found.length; i += 1) {
1158
+ const one = found[i]
1159
+ if (out.indexOf(one.path) >= 0) continue
1160
+ const idText = String(one.pluginId).toLowerCase()
1161
+ const related = keys.some((key) => idText.indexOf(String(key).toLowerCase()) >= 0)
1162
+ if (related !== true) continue
1163
+ out.push(one.path)
1164
+ }
1165
+ return out
1166
+ }
1167
+
1168
+ async function queryLocalAdapter(ctx, adapter, now, provider) {
1169
+ const port = localWebPort(ctx)
1170
+ if (port === null) {
1171
+ logHost(ctx, 'balance(' + adapter.id + '): 拿不到本机 Web 端口')
1172
+ return { ok: false, at: now, reason: 'no-local-port', provider: adapter.id }
1173
+ }
1174
+ const paths = localCandidatePaths(ctx, adapter, provider)
1175
+ let last = null
1176
+ for (let i = 0; i < paths.length; i += 1) {
1177
+ const path = paths[i]
1178
+ const url = 'http://127.0.0.1:' + String(port) + path
1179
+ const probed = await probeLocalRoute(url)
1180
+ /* 环境类失败(网络/超时)不换下一条:换路径不会变好,反而多发请求、
1181
+ 还可能把真实的网络问题掩盖成"两个插件都没有"。 */
1182
+ if (probed.reachable !== true && probed.reason !== 'http-404') {
1183
+ logHost(ctx, 'balance(' + adapter.id + '): 本机路由请求失败 ' + String(probed.reason))
1184
+ return { ok: false, at: now, reason: probed.reason, provider: adapter.id }
1185
+ }
1186
+ if (probed.reachable !== true) {
1187
+ /* 这条路径没注册(裸 404):试下一条候选 */
1188
+ last = { ok: false, at: now, reason: 'http-404', provider: adapter.id }
1189
+ continue
1190
+ }
1191
+ if (probed.reason !== null) {
1192
+ /* 路由存在但当前读不了(405/403/400):记下来继续试下一条,最后如实报出 */
1193
+ logHost(ctx, 'balance(' + adapter.id + '): 本机路由 ' + path + ' 返回 ' + String(probed.reason))
1194
+ last = { ok: false, at: now, reason: probed.reason, provider: adapter.id }
1195
+ continue
1196
+ }
1197
+ const data = probed.data
1198
+ /* 未登录时对方给的是 status: 'signed-out',没有 credits 字段,解析自然落到失败分支 */
1199
+ if (data !== null && data !== undefined && data.creditsError !== undefined && data.creditsError !== null) {
1200
+ logHost(ctx, 'balance(' + adapter.id + '): 上游积分查询失败 ' + String(data.creditsError).slice(0, 120))
1201
+ last = { ok: false, at: now, reason: 'credits-error', provider: adapter.id }
1202
+ continue
1203
+ }
1204
+ const parsed = adapter.parse(data)
1205
+ if (parsed === null) {
1206
+ logHost(ctx, 'balance(' + adapter.id + '): 本机路由里没有积分(未登录或上游未返回)')
1207
+ last = { ok: false, at: now, reason: 'no-credits', provider: adapter.id }
1208
+ continue
1209
+ }
1210
+ logHost(ctx, 'balance(' + adapter.id + '): 积分 ' + String(parsed.total) + '(来自 ' + path + ')')
1211
+ return {
1212
+ ok: true,
1213
+ at: now,
1214
+ source: 'local-route',
1215
+ provider: adapter.id,
1216
+ currency: parsed.currency,
1217
+ total: parsed.total,
1218
+ isCredits: parsed.isCredits === true,
1219
+ isPercent: parsed.isPercent === true,
1220
+ parts: parsed.parts,
1221
+ toppedUp: partAmount(parsed.parts, 'toppedUp'),
1222
+ granted: partAmount(parsed.parts, 'granted'),
1223
+ spent: partAmount(parsed.parts, 'spent'),
1224
+ }
1225
+ }
1226
+ return last !== null ? last : { ok: false, at: now, reason: 'no-credits', provider: adapter.id }
1227
+ }
1228
+
1229
+ /* force 目前不改变行为(余额不缓存,每次请求都现查),但客户端点开气泡时会带 force=1
1230
+ 表达"强制刷新",保留该形参以维持 HTTP 契约,避免为省一个参数去动客户端回调。 */
810
1231
  async function queryBalance(ctx, force, provider, model) {
811
1232
  const now = Date.now()
812
- if (force !== true && balanceCache.value !== null && now - balanceCache.at < BALANCE_TTL_MS) {
813
- return balanceCache.value
814
- }
815
1233
 
816
1234
  /* 当前会话用的是第三方平台就交给适配器:每个平台有自己的端点、鉴权与字段。
817
- DeepSeek(含识别不出来的情况)继续走下面的账号服务 + API key 老路。 */
818
- const adapter = adapterFor(provider, model)
1235
+ 走**适配器链**:智谱的"现金余额"与"coding plan 配额"是两条独立的路、keys 完全重叠,
1236
+ 单选的 adapterFor 只会命中前者,于是 coding plan 用户永远拿不到配额(实测症状:
1237
+ 底栏余额恒为 ¥-)。链上按顺序逐个请求,**第一个解析出结果的胜出** —— 判定交给
1238
+ 各家自己的 parse(那是已核实上游协议的唯一权威),这里不另立规则。
1239
+ 非智谱平台链长恒为 1,行为与旧版逐字节一致。 */
1240
+ const chain = adaptersFor(provider, model)
1241
+ const adapter = chain.length > 0 ? chain[0] : null
1242
+ if (adapter !== null && adapter.local === true) {
1243
+ const local = await queryLocalAdapter(ctx, adapter, now, provider)
1244
+ /* 这个平台的计量单位是积分,失败态也要带上:客户端据此决定不拼货币符号 */
1245
+ const value = local.ok === true ? local : Object.assign({}, local, { isCredits: true })
1246
+ return value
1247
+ }
819
1248
  if (adapter !== null && adapter.id !== 'deepseek') {
820
- const third = await queryWithAdapter(ctx, adapter, now)
821
- balanceCache = { at: now, value: third, provider: adapter.id }
822
- return third
1249
+ let last = null
1250
+ for (let i = 0; i < chain.length; i += 1) {
1251
+ const one = chain[i]
1252
+ /* 本地路由型适配器(积分)不走这条:它没有外部端点,且上面已经单独处理过 */
1253
+ if (one.local === true) continue
1254
+ const attempt = await queryWithAdapter(ctx, one, now)
1255
+ if (attempt.ok === true) {
1256
+ if (i > 0) {
1257
+ logHost(ctx, 'balance(' + one.id + '): 主适配器 ' + adapter.id + ' 没给出余额,回退到这条命中')
1258
+ }
1259
+ return attempt
1260
+ }
1261
+ last = attempt
1262
+ /* 只有"端点答了、但响应结构不是这一路"(no-balance-info)才值得换下一条:
1263
+ 按量计费账号在配额接口上拿不到 limits,coding plan 在余额接口上拿不到 code=200
1264
+ —— 这两种正是要靠回退解决的。HTTP / 网络 / 缺凭据是**环境**问题,换端点不会
1265
+ 变好,反而多发一次请求、还可能把真实的鉴权失败掩盖成"配额也没有"。 */
1266
+ if (attempt.reason !== 'no-balance-info') return attempt
1267
+ }
1268
+ return last !== null ? last : { ok: false, at: now, reason: 'unknown-platform' }
1269
+ }
1270
+
1271
+ /* 会话明确在用别的平台、但这个平台认不出来时不硬凑:硬凑会把 DeepSeek 的余额
1272
+ 当成对方的余额显示(比不显示更误导)。provider 还没上报(空)时才按 DeepSeek 处理。 */
1273
+ const hasProvider = typeof provider === 'string' && provider.trim().length > 0
1274
+ if (adapter === null && hasProvider) {
1275
+ const unknown = { ok: false, at: now, reason: 'unknown-platform' }
1276
+ logHost(ctx, 'balance: 认不出 ' + provider + ' 属于哪个平台,不显示余额')
1277
+ return unknown
823
1278
  }
824
1279
 
825
- /* 先问官方账号服务:与设置页同一份数据(充值 + 赠金),拿不到才走下面的 API key 查询 */
826
- const fromAccount = await queryAccountBalance(ctx)
827
- if (fromAccount !== null) {
828
- balanceCache = { at: now, value: fromAccount }
829
- return fromAccount
1280
+ /* DeepSeek 在 DSH 里有两条计费通道,余额必须跟当前会话实际走的那条:
1281
+ * deepseek-account → 登录账号的额度(官方账号服务)
1282
+ * deepseek-official → 模型页那把 API key 的账户(官方余额接口)
1283
+ * 判据只看 provider,与本机配没配 key 无关 —— 装了 key 的机器同样可能在跑账号模式的会话,
1284
+ * 反过来拿账号钱包顶替 API key 的账,就会出现「一直在花钱、余额却不动」。 */
1285
+ const usesAccountChannel = adapter !== null
1286
+ && typeof provider === 'string'
1287
+ && provider.toLowerCase().includes('account')
1288
+ if (usesAccountChannel) {
1289
+ const fromAccount = await queryAccountBalance(ctx)
1290
+ if (fromAccount !== null) {
1291
+ return fromAccount
1292
+ }
1293
+ const noAccount = { ok: false, at: now, reason: 'account-unavailable' }
1294
+ logHost(ctx, 'balance: 账号通道(' + provider + ')但账号服务不可用')
1295
+ return noAccount
830
1296
  }
831
1297
 
832
- const found = findApiKey(ctx)
1298
+ /* 其余 DeepSeek 会话按 API key 计费:余额取这把 key 的账户。
1299
+ * 模型页配了 key(credentials 服务 / 环境变量 / .credentials.yaml 任一处有值)就用它;
1300
+ * 确实没有 key 时才是"账号登录但 provider 名没带 account"的兜底,退回账号钱包。 */
1301
+ const deepseekAdapter = adapter === null ? adapterFor('deepseek', '') : adapter
1302
+ const found = await resolveAdapterKey(ctx, deepseekAdapter)
1303
+
833
1304
  if (found.key === null) {
1305
+ const fromAccount = await queryAccountBalance(ctx)
1306
+ if (fromAccount !== null) {
1307
+ return fromAccount
1308
+ }
834
1309
  const missing = { ok: false, at: now, reason: 'no-credential' }
835
- logHost(ctx, 'balance: 环境变量 / credentials.yaml / credentials 服务都没取到 key')
836
- balanceCache = { at: now, value: missing }
1310
+ logHost(ctx, 'balance: 环境变量 / credentials.yaml / credentials 服务都没取到 key,账号服务也不可用')
837
1311
  return missing
838
1312
  }
839
1313
 
@@ -848,7 +1322,6 @@ async function queryBalance(ctx, force, provider, model) {
848
1322
  if (!response.ok) {
849
1323
  const failed = { ok: false, at: now, reason: 'http-' + String(response.status), source: found.source }
850
1324
  logHost(ctx, 'balance: HTTP ' + String(response.status))
851
- balanceCache = { at: now, value: failed }
852
1325
  return failed
853
1326
  }
854
1327
  const data = await response.json()
@@ -857,7 +1330,6 @@ async function queryBalance(ctx, force, provider, model) {
857
1330
  if (picked === undefined) {
858
1331
  const empty = { ok: false, at: now, reason: 'no-balance-info', source: found.source }
859
1332
  logHost(ctx, 'balance: 响应里没有 balance_infos')
860
- balanceCache = { at: now, value: empty }
861
1333
  return empty
862
1334
  }
863
1335
  const value = {
@@ -873,107 +1345,2128 @@ async function queryBalance(ctx, force, provider, model) {
873
1345
  available: data.is_available === true,
874
1346
  }
875
1347
  logHost(ctx, 'balance: OK ' + String(value.total) + ' ' + String(value.currency))
876
- balanceCache = { at: now, value }
877
1348
  return value
878
1349
  } catch (error) {
879
1350
  const detail = error !== null && error !== undefined && typeof error.message === 'string' ? error.message : 'request-failed'
880
1351
  const failed = { ok: false, at: now, reason: 'request-failed', detail, source: found.source }
881
1352
  logHost(ctx, 'balance: 请求异常 ' + detail)
882
- balanceCache = { at: now, value: failed }
883
1353
  return failed
884
1354
  }
885
1355
  }
886
1356
 
887
- /* ------------------------------------------------------------ 节假日抓取 */
1357
+ /* ------------------------------------------------------------ 活跃总览 */
888
1358
 
889
- const GOV_SEARCH_URL = 'https://sousuo.www.gov.cn/search-gov/data'
890
- const GOV_TITLE_PREFIX = '国务院办公厅关于'
891
- const HOLIDAY_FETCH_TIMEOUT_MS = 15000
892
- const BROWSER_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
1359
+ /**
1360
+ * 峰谷那个字段点开是「活跃总览」:左边看本周每天用了多少,右边看当月每天的活跃度,
1361
+ * 而 session 投影只覆盖当前会话,
1362
+ * 所以这里扫会话日志把用量收进一份本地台账(`<DSH_HOME>/dsh-status-bar/usage-ledger.json`)。
1363
+ *
1364
+ * 为什么必须落盘:会话一旦被删除,日志就没了,而周总计必须把已经发生的消耗留住。
1365
+ * 台账按「事件」去重(同一 turn/step 只算一次、assistant/message 优先),于是重复扫描不会
1366
+ * 重复计数,删掉会话也不会让已记下的数字缩水;窗口是自然周(周一 00:00 起)。
1367
+ * 全程只读 token 数字,不读正文,台账里也只有数字、没有对话内容。
1368
+ */
893
1369
 
894
1370
  /**
895
- * 抓「国务院办公厅关于<year>年部分节假日安排的通知」并解析出放假日。
896
- * 两步:先走政府网政策文件库的搜索接口拿通知页 URL,再取正文解析(解析在 lib/holiday.js)。
897
- * 未发布、HTTP 失败、解析为空都返回 { ok: false, reason },交给客户端提示后重试。
1371
+ * 柱状窗口的起点:今天 00:00(北京时间)往前推 6 天,也就是「近 7 天」。
1372
+ * 这里用的是滚动窗口,不再对齐自然周 —— 用户看到的是最近七天,而不是本周一到现在。
898
1373
  */
899
- async function fetchHolidayDays(year) {
900
- const title = GOV_TITLE_PREFIX + String(year) + '年部分节假日安排的通知'
901
- const search = new URL(GOV_SEARCH_URL)
902
- search.searchParams.set('t', 'zhengcelibrary_gw')
903
- search.searchParams.set('q', title)
904
- search.searchParams.set('searchfield', 'title')
905
- search.searchParams.set('p', '1')
906
- search.searchParams.set('n', '5')
907
- search.searchParams.set('sort', 'score')
908
- search.searchParams.set('sortType', '1')
909
- const headers = { 'user-agent': BROWSER_UA }
910
- /* 每次请求各自设超时:AbortSignal 不能复用 */
911
- const timeout = () => AbortSignal.timeout(HOLIDAY_FETCH_TIMEOUT_MS)
1374
+ function recentStartMs(now) {
1375
+ const BEIJING = 8 * 3600 * 1000
1376
+ const shifted = new Date(now + BEIJING)
1377
+ const todayStart = Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()) - BEIJING
1378
+ return todayStart - 6 * 24 * 3600 * 1000
1379
+ }
912
1380
 
913
- const found = await fetch(search.toString(), { headers, signal: timeout() })
914
- if (found.ok !== true) return { ok: false, reason: 'search-http-' + String(found.status) }
915
- const payload = await found.json()
916
- const list = payload !== null && payload !== undefined && payload.searchVO !== null && payload.searchVO !== undefined
917
- && Array.isArray(payload.searchVO.listVO)
918
- ? payload.searchVO.listVO
1381
+ /** 本月 1 号 00:00(北京时间)的毫秒时间戳。 */
1382
+ function monthStartMs(now) {
1383
+ const BEIJING = 8 * 3600 * 1000
1384
+ const shifted = new Date(now + BEIJING)
1385
+ return Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), 1) - BEIJING
1386
+ }
1387
+
1388
+ const padTwo = (value) => (value < 10 ? '0' + String(value) : String(value))
1389
+
1390
+ /** 当年 1 月 1 日 00:00(北京时间)的毫秒时间戳 —— 12 个月的统计要从这里扫起。 */
1391
+ function yearStartMs(now) {
1392
+ const BEIJING = 8 * 3600 * 1000
1393
+ const shifted = new Date(now + BEIJING)
1394
+ return Date.UTC(shifted.getUTCFullYear(), 0, 1) - BEIJING
1395
+ }
1396
+
1397
+ /** 北京时间(UTC+8)的日期键 'YYYY-MM-DD' —— DeepSeek 的峰谷与账单都按北京时间。 */
1398
+ function localDateKey(ms) {
1399
+ const date = new Date(ms + 8 * 3600 * 1000)
1400
+ return date.getUTCFullYear() + '-' + padTwo(date.getUTCMonth() + 1) + '-' + padTwo(date.getUTCDate())
1401
+ }
1402
+
1403
+ /* 官方参考价(CNY / 每百万 tokens),与客户端价格表同源、与官方定价页一致。
1404
+ 只列有明确价格的 DeepSeek 模型;其它 provider(智谱、小米等)没有可靠价格,不计入花费。 */
1405
+ const TOKEN_PRICES = {
1406
+ peak: {
1407
+ 'deepseek-flash': { input: 2, cacheRead: 0.04, cacheWrite: 0, output: 8 },
1408
+ 'deepseek-v4-pro': { input: 9, cacheRead: 0.3, cacheWrite: 0, output: 27 },
1409
+ },
1410
+ offPeak: {
1411
+ 'deepseek-flash': { input: 1, cacheRead: 0.02, cacheWrite: 0, output: 4 },
1412
+ 'deepseek-v4-pro': { input: 4.5, cacheRead: 0.15, cacheWrite: 0, output: 13.5 },
1413
+ },
1414
+ }
1415
+
1416
+ /**
1417
+ * 客户端上报的价格库(localStorage 里那份,用户可增删改)。
1418
+ *
1419
+ * 为什么 host 要收下它:活跃总览的 cost 是 host 扫会话日志时**当场算好写进台账**的,
1420
+ * 以前只认上面那张硬编码表 —— 用户在设置页加的模型(如 WorkBuddy 的
1421
+ * `Deepseek-V4.1-Flash`)在底栏能算,在活跃总览却恒为 0(实测 2026-10-07)。
1422
+ * 客户端启动/改价时 POST 过来,两边就用同一份价格。
1423
+ *
1424
+ * 形状:{ 模型名: { peak: {...}, offPeak: {...} } },与客户端 normalizePrice 之后一致。
1425
+ * 为 null 表示还没收到,此时回退到 TOKEN_PRICES。
1426
+ */
1427
+ let clientPrices = null
1428
+
1429
+ /* 当前**真正生效**的那份价格库的指纹:只在 queueLedgerJob 里读写。
1430
+ 价格上报靠它判断"这次上报是不是真的改了价" —— 快照若取在队列外,
1431
+ 两个并排上报会各拿一份旧快照、双双报"已变更"(实测残留,只影响返回值与日志)。 */
1432
+ let activePriceFingerprint = null
1433
+
1434
+ /* ---------------------------------------- 模型名匹配(与客户端 lib/client.js 同规则) */
1435
+
1436
+ /**
1437
+ * 模型名切成词元:忽略大小写,分隔符(- . _ 空格等)都当边界。
1438
+ * `DeepSeek-V4.1-Flash-SG` → ['deepseek','v4','1','flash','sg']
1439
+ */
1440
+ function modelNameTokens(name) {
1441
+ return typeof name === 'string'
1442
+ ? name.toLowerCase().split(/[^a-z0-9]+/).filter((one) => one.length > 0)
919
1443
  : []
920
- /* 搜索是按相关度排的,标题里必须正好有这一年的年份,否则宁可当没搜到。
921
- 标题可能带 <em> 高亮标签,比较前先剥掉。 */
922
- const wanted = String(year) + '年部分节假日安排'
923
- const plainTitle = (item) => String(item.title).replace(/<[^>]+>/g, '')
924
- const hit = list.filter((item) => item !== null && typeof item === 'object'
925
- && typeof item.title === 'string' && plainTitle(item).indexOf(wanted) !== -1
926
- && typeof item.url === 'string')[0]
927
- if (hit === undefined) return { ok: false, reason: 'not-found' }
1444
+ }
928
1445
 
929
- const page = await fetch(hit.url, { headers, signal: timeout() })
930
- if (page.ok !== true) return { ok: false, reason: 'page-http-' + String(page.status) }
931
- const days = parseHolidayNotice(holidayHtmlToText(await page.text()), year)
932
- if (days.length === 0) return { ok: false, reason: 'parse-empty' }
933
- return { ok: true, year, days, title: plainTitle(hit), source: hit.url }
1446
+ /** 词元是否对得上:**完全相等**(大小写已经在切词时归一)。 */
1447
+ function tokenMatches(needle, hay) {
1448
+ return needle === hay
934
1449
  }
935
1450
 
936
- /* ------------------------------------------------------------------ apply */
1451
+ /** 一条价格是不是"什么都没填":两档四项加起来是 0。用来把未定价的条目挡在匹配之外。 */
1452
+ function priceIsBlank(entry) {
1453
+ if (entry === null || entry === undefined || typeof entry !== 'object') return true
1454
+ const tier = (one) => (one === null || one === undefined ? 0
1455
+ : numOrZero(one.input) + numOrZero(one.cacheRead) + numOrZero(one.cacheWrite) + numOrZero(one.output))
1456
+ return tier(entry.peak) + tier(entry.offPeak) === 0
1457
+ }
937
1458
 
938
- export function apply(ctx) {
939
- ctxRef = ctx
940
- ctx.effect(
941
- () => ctx.sessionProjections.register(sessionModelProjection),
942
- 'dsh-desktop-statusbar: sessionModel projection',
943
- )
944
- ctx.effect(
945
- () => ctx.sessionProjections.register(sessionUsageProjection),
946
- 'dsh-desktop-statusbar: sessionUsage projection',
947
- )
948
- ctx.effect(
949
- () => ctx.sessionProjections.register(sessionTimeRangeProjection),
950
- 'dsh-desktop-statusbar: sessionTimeRange projection',
951
- )
952
- ctx.effect(
953
- () => ctx.sessionProjections.register(sessionTimingProjection),
954
- 'dsh-desktop-statusbar: sessionTiming projection',
955
- )
1459
+ /**
1460
+ * 这条价格里**有没有任何一格是数字**(= 用户真的填过)。八格全是 null(只在设置页点过
1461
+ * 「添加」还没填价就收起了)的条目**两轮匹配都不该算数**,否则第二轮兜底会把它当成
1462
+ * "明确设 0",该模型的费用被静默算成 ¥0。要区分 null 与 0,`sanitizePriceBook`
1463
+ * 必须把 null 原样带进来(不能压成 0)。
1464
+ */
1465
+ function hasAnyPriceValue(entry) {
1466
+ if (entry === null || entry === undefined || typeof entry !== 'object') return false
1467
+ const tiers = [entry.peak, entry.offPeak]
1468
+ for (let i = 0; i < tiers.length; i += 1) {
1469
+ const one = tiers[i]
1470
+ if (one === null || one === undefined || typeof one !== 'object') continue
1471
+ if (typeof one.input === 'number' || typeof one.cacheRead === 'number'
1472
+ || typeof one.cacheWrite === 'number' || typeof one.output === 'number') return true
1473
+ }
1474
+ return false
1475
+ }
956
1476
 
957
- ctx.effect(() => ctx.webServer.register({
958
- kind: 'prefix',
959
- path: '/dsh-desktop-statusbar/api',
960
- handler: async (req, res) => {
961
- const url = new URL(req.url ?? '/', 'http://127.0.0.1')
962
- if (url.pathname === '/dsh-desktop-statusbar/api/prices') {
963
- sendJson(res, 200, {
964
- models: {
965
- 'deepseek-flash': {
966
- peak: { input: 2, cacheRead: 0.04, cacheWrite: 0, output: 8 },
967
- offPeak: { input: 1, cacheRead: 0.02, cacheWrite: 0, output: 4 },
968
- },
969
- 'deepseek-v4-pro': {
970
- peak: { input: 9, cacheRead: 0.3, cacheWrite: 0, output: 27 },
971
- offPeak: { input: 4.5, cacheRead: 0.15, cacheWrite: 0, output: 13.5 },
972
- },
973
- },
974
- note: 'CNY per 1M tokens, 高峰=北京时间周一至周五 9-12、14-18(法定节假日与周末全天按空闲价)',
975
- })
976
- return
1477
+ /**
1478
+ * 一轮匹配:① 同名单独先行;②③(子串命中与词元全含命中)**合并评选**,
1479
+ * 按 weight = 词元数 * 1000 + 键名长度 取最具体的那个 —— 不能分档跑,
1480
+ * 否则模型名里含一个短键(如 flash)就会把更具体的长键挤掉。
1481
+ * 含数字的键只允许子串命中,不走词元档。
1482
+ * skipBlank=true 时跳过"还没定价"(整条全 0)的条目。
1483
+ */
1484
+ function matchPriceKeyOnce(book, model, skipBlank) {
1485
+ const keys = Object.keys(book)
1486
+ const lower = model.toLowerCase()
1487
+ /* 空键不参选;八格全 null 的条目两轮都不参选;skipBlank 那轮再跳过"有数字但总和 0"的条目 */
1488
+ const usable = (key) => key.length > 0
1489
+ && hasAnyPriceValue(book[key]) === true
1490
+ && (skipBlank !== true || priceIsBlank(book[key]) !== true)
1491
+ /* ① 同名:单独先行,最高优先 */
1492
+ for (let i = 0; i < keys.length; i += 1) {
1493
+ if (keys[i].toLowerCase() === lower && usable(keys[i])) return keys[i]
1494
+ }
1495
+ /* ②③ 一次评选:子串命中的键与词元全含命中的键放在一起比 weight,取最具体的那个。
1496
+ 不能按"先全部子串、再全部词元"两轮跑 —— 那样模型名里只要含一个短键(如 flash),
1497
+ 就会把更具体的 deepseek-flash(只能靠词元档命中)挤掉。
1498
+ 含数字的键(如 deepseek-v4-pro)只允许子串命中,不走词元档。 */
1499
+ const tokens = modelNameTokens(model)
1500
+ const weightOf = (key) => modelNameTokens(key).length * 1000 + key.length
1501
+ let best = null
1502
+ let bestWeight = -1
1503
+ for (let i = 0; i < keys.length; i += 1) {
1504
+ const key = keys[i]
1505
+ if (usable(key) !== true) continue
1506
+ let hit = lower.indexOf(key.toLowerCase()) !== -1
1507
+ if (hit !== true && /[0-9]/.test(key) !== true) {
1508
+ const want = modelNameTokens(key)
1509
+ if (want.length > 0) {
1510
+ hit = true
1511
+ for (let k = 0; k < want.length; k += 1) {
1512
+ let found = false
1513
+ for (let m = 0; m < tokens.length; m += 1) {
1514
+ if (tokenMatches(want[k], tokens[m]) === true) { found = true; break }
1515
+ }
1516
+ if (found !== true) { hit = false; break }
1517
+ }
1518
+ }
1519
+ }
1520
+ if (hit !== true) continue
1521
+ const weight = weightOf(key)
1522
+ if (weight > bestWeight) { bestWeight = weight; best = key }
1523
+ }
1524
+ return best
1525
+ }
1526
+
1527
+ /**
1528
+ * 价格键 → 模型名:① 同名最优先;② 子串命中与③ 词元全含命中**合并评选**,取最具体的那个;
1529
+ * 名字里含数字的键(如 `deepseek-v4-pro`)只认子串,不做词元匹配 ——
1530
+ * 版本号不同的 pro 是另一个模型、另一个价,不该被旧版号吃掉。
1531
+ *
1532
+ * 为什么必须有词元档:默认键叫 `deepseek-flash`,而会话日志里记的是
1533
+ * `deepseek-v4.1-flash-sg` / `deepseek-v41-flash` —— 版本号插在中间,子串匹配命不中,
1534
+ * 那笔用量就会被当成"没有单价"(记欠账),默认价等于白配。
1535
+ *
1536
+ * **分两轮**:第一轮跳过"还没填价"(整条全 0)的条目 —— 用户加了条目但没填价,
1537
+ * 不该把该模型的费用算成 0;第一轮一无所获时才放开,让"明确把某模型定为 0 价"仍算得出来。
1538
+ * **必须与客户端 lib/client.js 的 pickPriceKey 逐条同规则**,否则底栏与活跃总览会算出两个价。
1539
+ */
1540
+ function pickPriceKey(book, model) {
1541
+ if (book === null || book === undefined || typeof book !== 'object') return null
1542
+ if (typeof model !== 'string' || model.length === 0) return null
1543
+ const keys = Object.keys(book)
1544
+ if (keys.length === 0) return null
1545
+ const strict = matchPriceKeyOnce(book, model, true)
1546
+ if (strict !== null) return strict
1547
+ return matchPriceKeyOnce(book, model, false)
1548
+ }
1549
+
1550
+ /**
1551
+ * 在一份价格库里按模型名查单价(匹配规则见 pickPriceKey)。
1552
+ *
1553
+ * 收 book 而不是直接读全局:迁移回扫要用**台账指纹还原出来的旧价目表**判断
1554
+ * "当时这个模型有没有价",与"现在有没有价"是两回事。
1555
+ * 只从 Object.keys 里取键,模型名撞上 'constructor' / 'toString' 这类原型属性也拿不到东西。
1556
+ */
1557
+ function priceEntryIn(book, model) {
1558
+ const key = pickPriceKey(book, model)
1559
+ return key === null ? null : book[key]
1560
+ }
1561
+
1562
+ /**
1563
+ * 回扫描专用:按**当时那套规则**判断"这个模型当时有没有价"。
1564
+ *
1565
+ * v1/v2 的计价只认精确键名(内置表 `TOKEN_PRICES.peak[model]`、客户端价格库 `book[model]`),
1566
+ * 到本次改造之前只多了一步子串匹配;词元匹配与"最具体评选"都是这次才加的。
1567
+ * 用新规则回扫,会把"当年因名字对不上而记 0"的用量误判成"当时有价"直接跳过 ——
1568
+ * 那批钱就永远补不回来,而它们恰恰是这次加词元匹配要救的那类模型(实测缺陷)。
1569
+ */
1570
+ function legacyPriceEntryIn(book, model) {
1571
+ if (book === null || book === undefined || typeof book !== 'object') return null
1572
+ if (typeof model !== 'string' || model.length === 0) return null
1573
+ if (Object.prototype.hasOwnProperty.call(book, model) === true) return book[model]
1574
+ const lower = model.toLowerCase()
1575
+ const keys = Object.keys(book)
1576
+ for (let i = 0; i < keys.length; i += 1) {
1577
+ if (keys[i].length > 0 && lower.indexOf(keys[i].toLowerCase()) !== -1) return book[keys[i]]
1578
+ }
1579
+ return null
1580
+ }
1581
+
1582
+ /** 当前生效价格库里的单价(记账与补算都走它)。 */
1583
+ function priceEntryFor(model) {
1584
+ return priceEntryIn(activePriceBook(), model)
1585
+ }
1586
+
1587
+ /* 2026 年法定节假日(国务院通知,与 /api/holidays 抓到的是同一份):这些天全天按空闲价 */
1588
+ const CN_HOLIDAYS_2026 = [
1589
+ '2026-01-01', '2026-01-02', '2026-02-16', '2026-02-17', '2026-02-18', '2026-02-19', '2026-02-20',
1590
+ '2026-02-23', '2026-04-06', '2026-05-01', '2026-05-04', '2026-05-05', '2026-06-19', '2026-09-25',
1591
+ '2026-10-01', '2026-10-02', '2026-10-05', '2026-10-06', '2026-10-07',
1592
+ ]
1593
+
1594
+ /** 高峰时段:北京时间周一至周五 9:00-12:00、14:00-18:00,法定节假日与周末全天按空闲价。 */
1595
+ const PEAK_WINDOWS = [[9, 12], [14, 18]]
1596
+
1597
+ /* 抓到的法定节假日会落到 <DSH_HOME>/dsh-status-bar/holidays.json。
1598
+ host 的时段判定必须与客户端用同一份假期表:内置表只覆盖 2026,客户端却能把新一年的
1599
+ 假期存进配置 —— 两边不一致时,2027 年起同一天底栏按空闲价、活跃总览按高峰价,差一倍
1600
+ (实测缺陷,用户点「更新节假峰谷」也修不了 host)。 */
1601
+ function holidaysPath() {
1602
+ return join(dshHome(), 'dsh-status-bar', 'holidays.json')
1603
+ }
1604
+
1605
+ let extraHolidays = null
1606
+
1607
+ /** 落盘过的法定节假日(带缓存);文件不存在或读不动就当没有这回事。 */
1608
+ function extraHolidayDays() {
1609
+ if (extraHolidays !== null) return extraHolidays
1610
+ const parsed = readLedgerFile(holidaysPath())
1611
+ extraHolidays = parsed !== null && Array.isArray(parsed.days)
1612
+ ? parsed.days.filter((one) => typeof one === 'string')
1613
+ : []
1614
+ return extraHolidays
1615
+ }
1616
+
1617
+ /** 把抓到/同步来的节假日并进落盘文件(按日期去重排序),下次启动仍然生效。 */
1618
+ function saveHolidayDays(days) {
1619
+ const merged = extraHolidayDays().slice()
1620
+ for (let i = 0; i < days.length; i += 1) {
1621
+ if (typeof days[i] === 'string' && merged.indexOf(days[i]) === -1) merged.push(days[i])
1622
+ }
1623
+ merged.sort()
1624
+ try {
1625
+ const dir = join(dshHome(), 'dsh-status-bar')
1626
+ const target = holidaysPath()
1627
+ const temp = target + '.tmp'
1628
+ mkdirSync(dir, { recursive: true })
1629
+ /* 原子写:直接覆盖时进程被中断会留下截断文件,下次启动读出空表,
1630
+ 再保存就把原有假期永久丢掉(writeLedger 用的是同一套 temp+rename)。 */
1631
+ writeFileSync(temp, JSON.stringify({ updatedAt: Date.now(), days: merged }), 'utf8')
1632
+ renameSync(temp, target)
1633
+ } catch (error) {
1634
+ /* 写不进去不影响本次使用:内存里已经记下了 */
1635
+ }
1636
+ extraHolidays = merged
1637
+ return merged
1638
+ }
1639
+
1640
+ /** 这一天是不是法定节假日:内置 2026 表 ∪ 抓取落盘的表。 */
1641
+ function isLegalHoliday(key) {
1642
+ if (CN_HOLIDAYS_2026.indexOf(key) >= 0) return true
1643
+ return extraHolidayDays().indexOf(key) >= 0
1644
+ }
1645
+
1646
+ function isPeakTime(ms) {
1647
+ const date = new Date(ms + 8 * 3600 * 1000)
1648
+ const key = date.getUTCFullYear() + '-' + padTwo(date.getUTCMonth() + 1) + '-' + padTwo(date.getUTCDate())
1649
+ if (isLegalHoliday(key) === true) return false
1650
+ const weekday = date.getUTCDay()
1651
+ if (weekday === 0 || weekday === 6) return false
1652
+ const hour = date.getUTCHours()
1653
+ for (let i = 0; i < PEAK_WINDOWS.length; i += 1) {
1654
+ if (hour >= PEAK_WINDOWS[i][0] && hour < PEAK_WINDOWS[i][1]) return true
1655
+ }
1656
+ return false
1657
+ }
1658
+
1659
+ /**
1660
+ * 内置参考价 → 价格库的形状({模型: {peak, offPeak}})。
1661
+ *
1662
+ * TOKEN_PRICES 是两张按档分开的表(`peak[模型]` / `offPeak[模型]`),而价格库是
1663
+ * 一条模型一份两档。两者混用会让查价静默失败 —— 内置模型也算不出钱、回扫时把
1664
+ * 已经计过价的用量当成"当时缺价"再补一遍(费用翻倍)。所以内置表必须先转一次。
1665
+ */
1666
+ let builtinBookCache = null
1667
+
1668
+ function builtinPriceBook() {
1669
+ if (builtinBookCache !== null) return builtinBookCache
1670
+ const out = {}
1671
+ const names = Object.keys(TOKEN_PRICES.peak)
1672
+ for (let i = 0; i < names.length; i += 1) {
1673
+ const peak = TOKEN_PRICES.peak[names[i]]
1674
+ const offPeak = TOKEN_PRICES.offPeak[names[i]]
1675
+ if (peak === undefined && offPeak === undefined) continue
1676
+ out[names[i]] = {
1677
+ peak: peak !== undefined ? peak : offPeak,
1678
+ offPeak: offPeak !== undefined ? offPeak : peak,
1679
+ }
1680
+ }
1681
+ builtinBookCache = out
1682
+ return out
1683
+ }
1684
+
1685
+ /** 当前生效的价格库:收到过客户端上报就用它,否则回退到内置参考价(同一形状)。 */
1686
+ function activePriceBook() {
1687
+ return clientPrices !== null ? clientPrices : builtinPriceBook()
1688
+ }
1689
+
1690
+ /** 把任意来源的金额压成有限数;给不出数就当 0。 */
1691
+ function numOrZero(value) {
1692
+ const n = Number(value)
1693
+ return Number.isFinite(n) ? n : 0
1694
+ }
1695
+
1696
+ /**
1697
+ * 从客户端价格库里认出一份干净的 {模型: {peak, offPeak}}。
1698
+ * 客户端来的东西一律当不可信输入:只收形状对的条目,非数一律压成 0,别的字段丢掉。
1699
+ * 空库返回 null(视为"没有信息",不用它覆盖已有价格)。
1700
+ */
1701
+ function sanitizePriceBook(raw) {
1702
+ if (raw === null || raw === undefined || typeof raw !== 'object') return null
1703
+ /* 空字符串 / null / undefined 一律保留成 null —— "没填"必须与"明确填 0"区分开,
1704
+ 压成 0 的话匹配与回扫都会把空条目当成"用户把价定成了 0"。 */
1705
+ const keep = (value) => (value === null || value === undefined || value === '' ? null : numOrZero(value))
1706
+ const tier = (value) => (value !== null && value !== undefined && typeof value === 'object'
1707
+ ? {
1708
+ input: keep(value.input),
1709
+ cacheRead: keep(value.cacheRead),
1710
+ cacheWrite: keep(value.cacheWrite),
1711
+ output: keep(value.output),
1712
+ }
1713
+ : null)
1714
+ const out = {}
1715
+ const names = Object.keys(raw)
1716
+ for (let i = 0; i < names.length; i += 1) {
1717
+ /* 空键会命中一切模型名(子串判定里空串人人都"包含"),直接丢掉 */
1718
+ if (names[i].length === 0) continue
1719
+ const entry = raw[names[i]]
1720
+ if (entry === null || entry === undefined || typeof entry !== 'object') continue
1721
+ const peak = tier(entry.peak)
1722
+ const offPeak = tier(entry.offPeak)
1723
+ if (peak === null && offPeak === null) continue
1724
+ /* 保留「是否峰谷计价」:没勾的条目是**全天一个价**,计价时两档都要取 peak,
1725
+ 否则空闲时段会挑到那个没填的档、把费用算成 0(实测缺陷)。缺省按两档。 */
1726
+ const code = entry.currency === 'USD' ? 'USD' : (entry.currency === 'CNY' ? 'CNY' : null)
1727
+ out[names[i]] = {
1728
+ peak: peak !== null ? peak : offPeak,
1729
+ offPeak: offPeak !== null ? offPeak : peak,
1730
+ tiered: entry.tiered !== false,
1731
+ /* 条目币种:**带了就原样保留**(`CNY` / `USD`),没带就**不补**——
1732
+ host 算钱只认数值、不看币种(台账与历史都不受影响),币种是客户端用来
1733
+ 决定"这条按哪种货币填的";缺省由客户端按它自己的默认币种解释,
1734
+ host 不替它决定(否则往返一次多出一个字段,深比较基准会被悄悄改掉)。
1735
+ 注意:必须保留,不保留就等于清洗时把币种丢了。 */
1736
+ ...(code === null ? {} : { currency: code }),
1737
+ }
1738
+ }
1739
+ return Object.keys(out).length === 0 ? null : out
1740
+ }
1741
+
1742
+ /**
1743
+ * 价格库指纹:把一份价格库压成一行文本。两个用途 ——
1744
+ * ① 判断"价格有没有变"(变了才去结算欠账);
1745
+ * ② 迁移回扫时把**当时那份价目表**还原出来(判断某个模型当时有没有价)。
1746
+ *
1747
+ * 它以前的作用是"不一致就把台账整本丢丢掉重扫",那会连已经算好的费用一起改掉,
1748
+ * 会话日志一旦被删,历史数字就再也算不回来。现在它只读不写、只用来触发补算。
1749
+ */
1750
+ function fingerprintOf(book) {
1751
+ const names = Object.keys(book === null || book === undefined ? {} : book).sort()
1752
+ const parts = []
1753
+ for (let i = 0; i < names.length; i += 1) {
1754
+ const one = book[names[i]]
1755
+ const pick = (t) => (t === null || t === undefined ? 'x' : [numOrZero(t.input), numOrZero(t.cacheRead), numOrZero(t.cacheWrite), numOrZero(t.output)].join(','))
1756
+ parts.push(names[i] + ':' + pick(one.peak) + ':' + pick(one.offPeak))
1757
+ }
1758
+ return parts.join('|')
1759
+ }
1760
+
1761
+ function priceFingerprint() {
1762
+ return fingerprintOf(activePriceBook())
1763
+ }
1764
+
1765
+ /**
1766
+ * 指纹 → 价格库(fingerprintOf 的逆运算),迁移回扫用它还原"当时的价格库"。
1767
+ * 单条形状对不上(段数不是 3、数字不合法)就跳过那一条;整体为空的返回 null。
1768
+ */
1769
+ function parseFingerprint(text) {
1770
+ if (typeof text !== 'string' || text.length === 0) return null
1771
+ const out = {}
1772
+ const segments = text.split('|')
1773
+ const tier = (raw) => {
1774
+ if (raw === 'x') return null
1775
+ const nums = raw.split(',')
1776
+ if (nums.length !== 4) return null
1777
+ const one = {
1778
+ input: Number(nums[0]),
1779
+ cacheRead: Number(nums[1]),
1780
+ cacheWrite: Number(nums[2]),
1781
+ output: Number(nums[3]),
1782
+ }
1783
+ if (Number.isFinite(one.input) !== true || Number.isFinite(one.cacheRead) !== true
1784
+ || Number.isFinite(one.cacheWrite) !== true || Number.isFinite(one.output) !== true) return null
1785
+ return one
1786
+ }
1787
+ for (let i = 0; i < segments.length; i += 1) {
1788
+ const fields = segments[i].split(':')
1789
+ if (fields.length !== 3) continue
1790
+ const peak = tier(fields[1])
1791
+ const offPeak = tier(fields[2])
1792
+ if (peak === null && offPeak === null) continue
1793
+ out[fields[0]] = { peak: peak !== null ? peak : offPeak, offPeak: offPeak !== null ? offPeak : peak }
1794
+ }
1795
+ return Object.keys(out).length === 0 ? null : out
1796
+ }
1797
+
1798
+ /** 一笔用量按单价折成人民币;模型没有价格表就返回 0(不计入花费)。 */
1799
+ function tokenCost(model, at, parts) {
1800
+ const entry = priceEntryFor(model)
1801
+ if (entry === null) return 0
1802
+ /* 没勾「峰谷计价」= 全天一个价:两档都取 peak,否则空闲时段会挑到没填的那档、算成 0 */
1803
+ const price = entry.tiered === false ? entry.peak : (isPeakTime(at) ? entry.peak : entry.offPeak)
1804
+ const use = price !== null && price !== undefined ? price : entry.peak
1805
+ if (use === null || use === undefined) return 0
1806
+ return (parts.input * numOrZero(use.input) + parts.cacheRead * numOrZero(use.cacheRead)
1807
+ + parts.cacheWrite * numOrZero(use.cacheWrite) + parts.output * numOrZero(use.output)) / 1e6
1808
+ }
1809
+
1810
+ /**
1811
+ * 这个模型的条目是按哪种货币填的(`CNY` / `USD`,缺省 `CNY`)。
1812
+ *
1813
+ * 只服务于"按币种分桶汇总":**算钱本身不看币种**(台账、历史数字都不受影响),
1814
+ * 客户端拿到分桶后按参考价比例折成底栏显示币种,混币时才能给出一个数字。
1815
+ */
1816
+ function tokenCostCurrency(model) {
1817
+ const entry = priceEntryFor(model)
1818
+ return entry !== null && entry.currency === 'USD' ? 'USD' : 'CNY'
1819
+ }
1820
+
1821
+ /** zstd 帧头(0x28 B5 2F FD):会话日志是多帧拼接,按它切开逐帧解。 */
1822
+ const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
1823
+
1824
+ /** 解出会话日志文本;运行时不带 zstd 时返回 null(客户端按空数据画)。 */
1825
+ function zstdText(buf) {
1826
+ if (typeof zlib.zstdDecompressSync !== 'function') return null
1827
+ const starts = []
1828
+ let at = 0
1829
+ while ((at = buf.indexOf(ZSTD_MAGIC, at)) >= 0) {
1830
+ starts.push(at)
1831
+ at += 4
1832
+ }
1833
+ if (starts.length === 0) return null
1834
+ const parts = []
1835
+ for (let i = 0; i < starts.length; i += 1) {
1836
+ const end = i + 1 < starts.length ? starts[i + 1] : buf.length
1837
+ try {
1838
+ parts.push(zlib.zstdDecompressSync(buf.subarray(starts[i], end)))
1839
+ } catch (error) {
1840
+ /* 坏帧跳过,其余帧照常 */
1841
+ }
1842
+ }
1843
+ return parts.length === 0 ? null : Buffer.concat(parts).toString('utf8')
1844
+ }
1845
+
1846
+ /** `<DSH_HOME>/sessions` —— 会话日志根目录。 */
1847
+ function sessionsRoot() {
1848
+ return join(dshHome(), 'sessions')
1849
+ }
1850
+
1851
+ /** 递归收集会话日志文件(目录缺失时给空数组)。 */
1852
+ function collectSessionFiles(dir, out) {
1853
+ let entries
1854
+ try {
1855
+ entries = readdirSync(dir, { withFileTypes: true })
1856
+ } catch (error) {
1857
+ return out
1858
+ }
1859
+ for (const entry of entries) {
1860
+ const full = join(dir, entry.name)
1861
+ if (entry.isDirectory()) collectSessionFiles(full, out)
1862
+ else if (entry.isFile() && entry.name.endsWith('.jsonl.zstd')) out.push(full)
1863
+ }
1864
+ return out
1865
+ }
1866
+
1867
+ /* ------------------------------------------------- 会话头部(认父子关系用) */
1868
+
1869
+ /**
1870
+ * 只解压**第一帧**。会话日志是多帧拼接,头部事件一定落在第一帧里;
1871
+ * 而一个会话的日志可能几 MB —— 为了读一行 header 把整份解压一遍太贵。
1872
+ * 解不出来(帧被截断、运行时没有 zstd)返回 null,调用方退回全量解压。
1873
+ */
1874
+ function firstFrameText(buf) {
1875
+ if (typeof zlib.zstdDecompressSync !== 'function') return null
1876
+ const start = buf.indexOf(ZSTD_MAGIC)
1877
+ if (start < 0) return null
1878
+ const next = buf.indexOf(ZSTD_MAGIC, start + 4)
1879
+ try {
1880
+ return zlib.zstdDecompressSync(buf.subarray(start, next < 0 ? buf.length : next)).toString('utf8')
1881
+ } catch (error) {
1882
+ return null
1883
+ }
1884
+ }
1885
+
1886
+ /**
1887
+ * 会话日志第一行的身份:`{ id, parentSession }`。
1888
+ * `parentSession` 只在**子智能体**的会话里出现(主会话没有这个字段),
1889
+ * 所以它就是"谁是谁的队友"的唯一线索。第一行不是头部事件、或 JSON 解不出来时返回 null。
1890
+ */
1891
+ function sessionHeaderOf(text) {
1892
+ if (typeof text !== 'string' || text.length === 0) return null
1893
+ const end = text.indexOf('\n')
1894
+ const line = end < 0 ? text : text.slice(0, end)
1895
+ /* 先按子串筛一道:整行 JSON.parse 比 indexOf 贵得多,而绝大多数行都不是头部事件 */
1896
+ if (line.indexOf('"session"') < 0) return null
1897
+ let event
1898
+ try {
1899
+ event = JSON.parse(line)
1900
+ } catch (error) {
1901
+ return null
1902
+ }
1903
+ if (event === null || typeof event !== 'object' || event.type !== 'session') return null
1904
+ if (typeof event.id !== 'string' || event.id.length === 0) return null
1905
+ return {
1906
+ id: event.id,
1907
+ parentSession: typeof event.parentSession === 'string' && event.parentSession.length > 0
1908
+ ? event.parentSession
1909
+ : null,
1910
+ }
1911
+ }
1912
+
1913
+ /* 读头部时先只看文件最前面这么多字节:第一帧几乎总在这一段里,
1914
+ 而真实会话日志单个能到几 MB —— 为了读一行 header 把整份读进内存太浪费。 */
1915
+ const HEADER_HEAD_BYTES = 64 * 1024
1916
+
1917
+ /** 读文件开头的一小段(拿不到就返回 null,交给调用方退回全文读)。 */
1918
+ function readFileHead(file, bytes) {
1919
+ let fd = null
1920
+ try {
1921
+ fd = openSync(file, 'r')
1922
+ const buf = Buffer.allocUnsafe(bytes)
1923
+ const read = readSync(fd, buf, 0, bytes, 0)
1924
+ return read > 0 ? buf.subarray(0, read) : null
1925
+ } catch (error) {
1926
+ return null
1927
+ } finally {
1928
+ if (fd !== null) {
1929
+ try {
1930
+ closeSync(fd)
1931
+ } catch (error) {
1932
+ /* 关不上不影响已经读到的内容 */
1933
+ }
1934
+ }
1935
+ }
1936
+ }
1937
+
1938
+ /** 读一个会话日志文件的头部。读不出 / 解不出 / 没有头部一律返回 null,绝不抛。 */
1939
+ function sessionHeaderOfFile(file) {
1940
+ /* 快路径:只读前 64 KB。首帧完整落在这一段里时就直接拿到头部;否则退回全文读一遍。 */
1941
+ const head = readFileHead(file, HEADER_HEAD_BYTES)
1942
+ if (head !== null) {
1943
+ const fast = firstFrameText(head)
1944
+ const parsed = sessionHeaderOf(fast === null ? '' : fast)
1945
+ if (parsed !== null) return parsed
1946
+ }
1947
+ let buf
1948
+ try {
1949
+ buf = readFileSync(file)
1950
+ } catch (error) {
1951
+ return null
1952
+ }
1953
+ const text = zstdText(buf)
1954
+ return text === null ? null : sessionHeaderOf(text)
1955
+ }
1956
+
1957
+ /**
1958
+ * 一条事件里的 token 用量:`data.usage` 是兜底,stream 里的 usage chunk 才是权威值
1959
+ * (与 sessionUsage 投影同口径)。assistant/attempt 这类事件只有 stream 里带 usage。
1960
+ */
1961
+ function usageOfEvent(event) {
1962
+ const data = event.data
1963
+ if (data === null || data === undefined || typeof data !== 'object') return null
1964
+ let usage = data.usage !== undefined ? data.usage : event.usage
1965
+ if (Array.isArray(data.stream)) {
1966
+ for (const node of data.stream) {
1967
+ if (node === null || node === undefined) continue
1968
+ if (node.chunk !== undefined && node.chunk !== null && node.chunk.type === 'usage' && node.chunk.usage !== undefined) {
1969
+ usage = node.chunk.usage
1970
+ }
1971
+ if (node.type === 'usage' && node.usage !== undefined) usage = node.usage
1972
+ }
1973
+ }
1974
+ if (usage === undefined || usage === null || typeof usage !== 'object') return null
1975
+ return usage
1976
+ }
1977
+
1978
+ /**
1979
+ * 一笔消耗在日志里的身份:一次模型调用只该对应一个 key。
1980
+ * turn/step 是最自然的粒度(一个 step 一次模型调用);压缩摘要是独立调用,按 compactionId;
1981
+ * 两者都没有时退回 seq。
1982
+ */
1983
+ function usageEventKey(event) {
1984
+ const data = event.data !== null && event.data !== undefined && typeof event.data === 'object' ? event.data : {}
1985
+ if (event.type === 'compaction/summary') {
1986
+ return 'compact:' + String(data.compactionId !== undefined && data.compactionId !== null ? data.compactionId : event.seq)
1987
+ }
1988
+ if (typeof data.turn === 'number' && typeof data.step === 'number') {
1989
+ return 'turn:' + String(data.turn) + ':' + String(data.step)
1990
+ }
1991
+ return 'seq:' + String(event.seq)
1992
+ }
1993
+
1994
+ /**
1995
+ * 从一段日志文本里挑出窗口内的用量事件。
1996
+ * 同一个 key 只留一条,且 `assistant/message` 优先 —— 这样重试的 attempt 与最终消息
1997
+ * 不会被算两次,而「压缩摘要」这类独立消耗照常计入。
1998
+ */
1999
+ function pickUsageEvents(text, fromMs) {
2000
+ const picked = new Map()
2001
+ for (const line of text.split('\n')) {
2002
+ if (line.indexOf('"usage"') < 0) continue
2003
+ let event
2004
+ try {
2005
+ event = JSON.parse(line)
2006
+ } catch (error) {
2007
+ continue
2008
+ }
2009
+ if (event === null || typeof event !== 'object') continue
2010
+ if (typeof event.time !== 'number' || event.time < fromMs) continue
2011
+ const usage = usageOfEvent(event)
2012
+ if (usage === null) continue
2013
+ const parts = usageParts(usage)
2014
+ /* 被中断的尝试会留下一条全 0 的 usage:没花钱,也不该占一次调用 */
2015
+ if (parts.input === 0 && parts.cacheRead === 0 && parts.cacheWrite === 0 && parts.output === 0) continue
2016
+ const isMessage = event.type === 'assistant/message'
2017
+ const key = usageEventKey(event)
2018
+ const prev = picked.get(key)
2019
+ if (prev === undefined || (prev.isMessage !== true && isMessage)) {
2020
+ const data = event.data !== null && event.data !== undefined && typeof event.data === 'object' ? event.data : {}
2021
+ const source = data.message !== null && data.message !== undefined && typeof data.message === 'object'
2022
+ && data.message.source !== null && data.message.source !== undefined && typeof data.message.source === 'object'
2023
+ ? data.message.source
2024
+ : null
2025
+ /* 模型名的两个来源:assistant/message 在 message.source.model,
2026
+ 而 compaction/summary(压缩摘要本身是一次真实模型调用)把模型放在顶层 data.model ——
2027
+ 只认前者会让整笔摘要用量落进「(未知模型)」欠账,永远结算不出来。 */
2028
+ const model = source !== null && typeof source.model === 'string'
2029
+ ? source.model
2030
+ : (typeof data.model === 'string' && data.model.length > 0 ? data.model : null)
2031
+ picked.set(key, {
2032
+ seq: typeof event.seq === 'number' ? event.seq : Math.round(event.time),
2033
+ time: event.time,
2034
+ parts: parts,
2035
+ isMessage: isMessage,
2036
+ /* 计价要用模型名(不同模型单价不同) */
2037
+ model: model,
2038
+ })
2039
+ }
2040
+ }
2041
+ return picked
2042
+ }
2043
+
2044
+ /* ------------------------------------------- 子智能体用量(「总计」要算上队友) */
2045
+
2046
+ /* 后代上限:**不是产品限制**,只用来防畸形数据(例如被污染成上千个会话目录的 sessions 树)
2047
+ 把后台拖死 —— 用户明确要求"所有队友的对话都要算",所以这个值取得远大于任何真实
2048
+ Agent Teams 树(真实场景是几十个会话),正常情况永远不触发;真触发了才带 truncated: true。 */
2049
+ const SUBAGENT_MAX_DESCENDANTS = 2000
2050
+
2051
+ /* file → `{ mtimeMs, size, header }`(header 为 null = 这个文件读不出头部)。
2052
+ **失败也缓存**:坏文件、非日志文件、正在写的文件不该每次请求都重读两遍(64KB 快路径 + 全文退回)。
2053
+ 靠 mtime+size 判断"还是那一份":文件一变(正常写入都会让 size/mtime 变)就自动重试 ——
2054
+ 刚建好、header 还没落盘的文件下一次请求就能认出来,而真正的坏文件不会一直被反复读。
2055
+ 头部里的 id / parentSession 是会话创建时写死的,所以读成功的那份不会失效,只有 stat 变了才重读。
2056
+ 上限 SUBAGENT_HEADER_CACHE_MAX 条,超出丢最久没被重新写入的(Map 的插入顺序)。 */
2057
+ const SUBAGENT_HEADER_CACHE_MAX = 512
2058
+ const subagentHeaders = new Map()
2059
+
2060
+ /** 两份同名会话文件谁更新:先比 mtime,一样再比 size(更大的一般更完整)。 */
2061
+ function headerEntryNewer(candidate, current) {
2062
+ if (candidate.mtimeMs !== current.mtimeMs) return candidate.mtimeMs > current.mtimeMs
2063
+ return candidate.size > current.size
2064
+ }
2065
+
2066
+ /**
2067
+ * 会话文件 → `parentSession → [子会话]` 邻接表(外加 `duplicates` 计数,只用来打日志)。
2068
+ *
2069
+ * 为什么要拆成"先建索引、再算用量":索引只需要读每个文件的**第一帧**(很便宜),
2070
+ * 而用量要把后代日志整份解压(很贵)。索引按 `文件 + mtimeMs + size` 增量维护(失败也缓存),
2071
+ * 用量那份按 `(父会话, 参与文件 mtime+size, 窗口, 价格指纹)` 缓存,两层各管一层失效。
2072
+ */
2073
+ async function subagentIndexSnapshot(files) {
2074
+ const live = new Set(files)
2075
+ for (const file of Array.from(subagentHeaders.keys())) {
2076
+ if (live.has(file) !== true) subagentHeaders.delete(file)
2077
+ }
2078
+ for (let i = 0; i < files.length; i += 1) {
2079
+ const file = files[i]
2080
+ let stat = null
2081
+ try {
2082
+ stat = statSync(file)
2083
+ } catch (error) {
2084
+ stat = null
2085
+ }
2086
+ if (stat === null) {
2087
+ /* 文件在遍历中消失:连同缓存一起丢掉,下次它再出现就是新的一份 */
2088
+ subagentHeaders.delete(file)
2089
+ continue
2090
+ }
2091
+ const cached = subagentHeaders.get(file)
2092
+ if (cached !== undefined && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) continue
2093
+ /* 读失败(含"没有头部")也记下来:同一个 mtime/size 的下一问直接跳过,不再重读 */
2094
+ const header = sessionHeaderOfFile(file)
2095
+ subagentHeaders.delete(file)
2096
+ subagentHeaders.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, header })
2097
+ while (subagentHeaders.size > SUBAGENT_HEADER_CACHE_MAX) {
2098
+ const oldest = subagentHeaders.keys().next()
2099
+ if (oldest.done === true) break
2100
+ subagentHeaders.delete(oldest.value)
2101
+ }
2102
+ /* 首次请求要把所有会话都读一遍头部:每 8 个让出一次事件循环,别把 host 卡住 */
2103
+ if (i % 8 === 7) await new Promise((resolve) => setImmediate(resolve))
2104
+ }
2105
+ /* 一个会话 id 可能出现在两个文件里:① 日志格式升级时同目录留下旧版本文件;
2106
+ ② 手工复制会话目录;③ 异常产生的重复目录。只认**最新的那一份**参与聚合
2107
+ (mtime 相同则取更大的那份):既防同一批用量算两遍,也不让旧文件里的过期数字
2108
+ 盖掉新文件。完全相同(mtime 与 size 都一样)时保留先读到的那份,保证结果稳定。 */
2109
+ const picked = new Map()
2110
+ subagentHeaders.forEach((entry, file) => {
2111
+ if (entry.header === null) return
2112
+ const one = {
2113
+ id: entry.header.id, file: file, parentSession: entry.header.parentSession,
2114
+ mtimeMs: entry.mtimeMs, size: entry.size,
2115
+ }
2116
+ const prev = picked.get(one.id)
2117
+ if (prev === undefined || headerEntryNewer(one, prev) === true) picked.set(one.id, one)
2118
+ })
2119
+ const children = new Map()
2120
+ picked.forEach((one) => {
2121
+ if (one.parentSession === null) return
2122
+ const list = children.get(one.parentSession) ?? []
2123
+ /* 把 stat 一起带上:后面算用量时就不必再对同一批文件 stat 一遍
2124
+ (队友多时这一遍是实打实的开销),mtime/size 也就是缓存键要用的那两个值 */
2125
+ list.push({ id: one.id, file: one.file, parentSession: one.parentSession, mtimeMs: one.mtimeMs, size: one.size })
2126
+ children.set(one.parentSession, list)
2127
+ })
2128
+ let withHeader = 0
2129
+ subagentHeaders.forEach((entry) => { if (entry.header !== null) withHeader += 1 })
2130
+ return { children, duplicates: withHeader - picked.size, byId: picked }
2131
+ }
2132
+
2133
+ /**
2134
+ * 一段父会话日志里的"本轮起点":最后一条 `turn/start` 的时间 —— 但**和最后一条
2135
+ * `user/message` 取较大的那个**。都找不到就返回 null(调用方据此判 sinceSource='none')。
2136
+ *
2137
+ * 为什么不是"有 turn/start 就优先用它":用户消息比最后一条 turn/start 还新,说明
2138
+ * **新一轮已经开始、而它的 `turn/start` 还没落盘**;此时沿用旧的 turn/start 会把
2139
+ * **上一轮**的队友用量算进"本轮",方向正是用户不允许的"偏大"。取较新的那个时间
2140
+ * 只会把窗口往回收(宁少不多),符合"本轮只能小不能大"。
2141
+ * 取**最大值**而不是"文件里最后一行":正常日志是追加写的,两者一致;万一有乱序行,
2142
+ * 取最大值更稳。
2143
+ */
2144
+ function lastTurnStartOfText(text) {
2145
+ if (typeof text !== 'string' || text.length === 0) return null
2146
+ let at = null
2147
+ const lines = text.split('\n')
2148
+ for (let i = 0; i < lines.length; i += 1) {
2149
+ const line = lines[i]
2150
+ if (line.indexOf('"turn/start"') < 0 && line.indexOf('"user/message"') < 0) continue
2151
+ let event
2152
+ try {
2153
+ event = JSON.parse(line)
2154
+ } catch (error) {
2155
+ continue
2156
+ }
2157
+ if (event === null || typeof event !== 'object' || typeof event.time !== 'number') continue
2158
+ if (event.type !== 'turn/start' && event.type !== 'user/message') continue
2159
+ if (at === null || event.time > at) at = event.time
2160
+ }
2161
+ return at
2162
+ }
2163
+
2164
+ /**
2165
+ * 一段父会话日志里**最后一条用量事件**的时间(`assistant/message` 或 `compaction/summary`
2166
+ * 且带非空 usage)。只服务于一个问题:"这个窗口起点之后,父会话自己到底产出过没有?"
2167
+ *
2168
+ * 只留**最大值**(一个数字)而不是把整串事件时间攒下来:要回答的是存在性
2169
+ * ("有没有 time >= fromMs 的用量事件" ⟺ "最大用量时间 >= fromMs"),
2170
+ * 而父会话日志是整棵树里最大的那一份,不该为一道布尔判断在内存里留一串时间戳。
2171
+ * 全 0 的 usage(被中断的尝试)不算产出 —— 与 pickUsageEvents 的口径一致。
2172
+ */
2173
+ function lastUsageEventTimeOfText(text) {
2174
+ if (typeof text !== 'string' || text.length === 0) return null
2175
+ let at = null
2176
+ const lines = text.split('\n')
2177
+ for (let i = 0; i < lines.length; i += 1) {
2178
+ const line = lines[i]
2179
+ /* 两道便宜的字符串筛(与 pickUsageEvents / foldSessionTiming 同一套省法):
2180
+ 绝大多数行既没有 usage 也不是这两类事件,逐行 JSON.parse 太贵 */
2181
+ if (line.indexOf('"usage"') < 0) continue
2182
+ if (line.indexOf('"assistant/message"') < 0 && line.indexOf('"compaction/summary"') < 0) continue
2183
+ let event
2184
+ try {
2185
+ event = JSON.parse(line)
2186
+ } catch (error) {
2187
+ continue
2188
+ }
2189
+ if (event === null || typeof event !== 'object' || typeof event.time !== 'number') continue
2190
+ if (event.type !== 'assistant/message' && event.type !== 'compaction/summary') continue
2191
+ const usage = usageOfEvent(event)
2192
+ if (usage === null) continue
2193
+ const parts = usageParts(usage)
2194
+ if (parts.input === 0 && parts.cacheRead === 0 && parts.cacheWrite === 0 && parts.output === 0) continue
2195
+ if (at === null || event.time > at) at = event.time
2196
+ }
2197
+ return at
2198
+ }
2199
+
2200
+ /* 父会话日志 → 它自己的"本轮事实":①本轮起点 ②窗口内有没有它自己的用量事件。
2201
+ 这个文件每次对话都在写,但只是解压一份日志、折两个标量,代价很低;照样按
2202
+ `文件 + mtime + size` 记忆化(内容一变就重折)—— 两件事**共用同一次解压**,
2203
+ 免得每秒的请求为了校验起点再把这棵树上最大的日志解第二遍。
2204
+ 上限 SUBAGENT_SINCE_CACHE_MAX 条,超出丢最久没被重新写入的。 */
2205
+ const SUBAGENT_SINCE_CACHE_MAX = 32
2206
+ const subagentSinceCache = new Map()
2207
+
2208
+ function parentLogFactsOf(one) {
2209
+ const key = one.file + '|' + String(one.mtimeMs) + '|' + String(one.size)
2210
+ const hit = subagentSinceCache.get(key)
2211
+ if (hit !== undefined) return hit
2212
+ let text = null
2213
+ try {
2214
+ text = zstdText(readFileSync(one.file))
2215
+ } catch (error) {
2216
+ text = null
2217
+ }
2218
+ const value = text === null
2219
+ ? { since: null, lastUsageAt: null }
2220
+ : { since: lastTurnStartOfText(text), lastUsageAt: lastUsageEventTimeOfText(text) }
2221
+ subagentSinceCache.set(key, value)
2222
+ while (subagentSinceCache.size > SUBAGENT_SINCE_CACHE_MAX) {
2223
+ const oldest = subagentSinceCache.keys().next()
2224
+ if (oldest.done === true) break
2225
+ subagentSinceCache.delete(oldest.value)
2226
+ }
2227
+ return value
2228
+ }
2229
+
2230
+ /** 父会话日志 → 它自己的"本轮起点"(推不出来给 null)。薄壳,只为既有调用点读起来更直白。 */
2231
+ function parentTurnStartOf(one) {
2232
+ return parentLogFactsOf(one).since
2233
+ }
2234
+
2235
+ /* 每个父会话一份结果:`sessionId → { key, value }`,key 见 collectSubagentUsage。
2236
+ 底栏可能同时开着好几个会话,单槽缓存会被别的会话的请求挤掉 —— 那就等于没有缓存。
2237
+ 最多留 8 份,超出丢最久没用的(Map 的插入顺序)。 */
2238
+ const SUBAGENT_CACHE_MAX = 8
2239
+ const subagentUsageCache = new Map()
2240
+
2241
+ /** 会话日志里的一笔用量折成钱(6 位小数,与台账的金额口径一致)。 */
2242
+ function roundCost(value) {
2243
+ return Math.round(value * 1e6) / 1e6
2244
+ }
2245
+
2246
+ /** 四项 token 的空桶。 */
2247
+ function zeroTokens() {
2248
+ return { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
2249
+ }
2250
+
2251
+ /**
2252
+ * 给返回值补上 `sinceClamped`:**本次请求**的起点是否被 host 收敛过。
2253
+ *
2254
+ * 为什么不在缓存里存:收敛后的窗口与"客户端本来就给对了"的窗口是同一个值,
2255
+ * 两条请求共用同一条缓存(这是有意的,避免同一窗口算两遍),所以"是否收敛"
2256
+ * 是**每次请求自己的事实**,不能烘进缓存对象里 —— 否则先来的那次收敛会把标记
2257
+ * 传染给后面给对了起点的请求。不收敛时原样返回(走缓存的那条零拷贝路径)。
2258
+ */
2259
+ function withClampFlag(value, clamped) {
2260
+ if (clamped !== true) return value
2261
+ return Object.assign({}, value, { sinceClamped: true })
2262
+ }
2263
+
2264
+ /**
2265
+ * 一条会话日志的「首字 / 解码」累计:给底栏那三项(首字平均、综合速度、缓存命中)里
2266
+ * 属于子智能体的那一份用。
2267
+ *
2268
+ * 子会话借不到官方 `sessionStats`(那是 **per-session** 的),只能照 `sessionTimingProjection`
2269
+ * 那套逐次折法自己再来一遍,规则必须与它**逐条一致**,否则底栏那个"会话级"数字
2270
+ * 与气泡里的极值会是两套口径:
2271
+ * · `step/start` 记下 startTime;
2272
+ * · 首个 token 时间来自 `assistant/attempt` / `assistant/message` 的 stream(工具调用分片也算);
2273
+ * · `ttft = 首个 token 时间 − startTime`,每个组装出 message 的 step 记一次;
2274
+ * · `decode = message 时间 − 首个 token 时间`,`output` 取 `data.usage.outputTokens`;
2275
+ * 只把「非纯工具调用、decode > 0、有 output」的 step 计入速度分子分母(纯工具调用的分片
2276
+ * 时间戳挤在同一刻,算出来是假值 —— 首字照旧统计)。
2277
+ * 与投影一样只在有对应 `step/start` 的前提下计数,重试的第二条 message 不会重复计入。
2278
+ * 返回 `{ ttftMs, ttftSteps, decodeMs, decodeTokens }`;坏行/坏 JSON 一律跳过。
2279
+ */
2280
+ function foldSessionTiming(text) {
2281
+ const out = { ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 }
2282
+ if (typeof text !== 'string' || text.length === 0) return out
2283
+ const lines = text.split('\n')
2284
+ let open = null
2285
+ for (let i = 0; i < lines.length; i += 1) {
2286
+ const line = lines[i]
2287
+ /* 先按类型子串筛一道:整份日志里绝大多数行既不是 step/start 也不是 assistant/*,
2288
+ 逐行 JSON.parse 太贵 */
2289
+ if (line.indexOf('"step/start"') < 0 && line.indexOf('"assistant/') < 0) continue
2290
+ let event
2291
+ try {
2292
+ event = JSON.parse(line)
2293
+ } catch (error) {
2294
+ continue
2295
+ }
2296
+ if (event === null || typeof event !== 'object') continue
2297
+ const data = event.data !== null && event.data !== undefined && typeof event.data === 'object' ? event.data : null
2298
+ if (event.type === 'step/start') {
2299
+ open = data === null || typeof event.time !== 'number'
2300
+ ? null
2301
+ : { turn: data.turn, step: data.step, startTime: event.time, firstTokenTime: null, firstTokenTool: false }
2302
+ continue
2303
+ }
2304
+ if (open === null || data === null) continue
2305
+ /* 与投影一样:只认当前这个 step 的事件(跨轮/跨步的事件不参与) */
2306
+ if (data.turn !== open.turn || data.step !== open.step) continue
2307
+ if (event.type === 'assistant/attempt') {
2308
+ if (open.firstTokenTime !== null) continue
2309
+ const first = firstStreamToken(data.stream)
2310
+ if (first === undefined || typeof first.time !== 'number') continue
2311
+ open.firstTokenTime = first.time
2312
+ open.firstTokenTool = first.tool === true
2313
+ continue
2314
+ }
2315
+ if (event.type !== 'assistant/message') continue
2316
+ const started = open.startTime
2317
+ const first = open.firstTokenTime !== null
2318
+ ? { time: open.firstTokenTime, tool: open.firstTokenTool === true }
2319
+ : firstStreamToken(data.stream)
2320
+ open = null
2321
+ if (first === undefined || typeof first.time !== 'number' || typeof event.time !== 'number') continue
2322
+ out.ttftMs += Math.max(0, first.time - started)
2323
+ out.ttftSteps += 1
2324
+ const output = stepOutputTokens(data.usage)
2325
+ const decode = Math.max(0, event.time - first.time)
2326
+ if (output !== null && decode > 0 && first.tool !== true) {
2327
+ out.decodeMs += decode
2328
+ out.decodeTokens += output
2329
+ }
2330
+ }
2331
+ return out
2332
+ }
2333
+
2334
+ /* 单个后代文件在"某个 mtime/size + 某个窗口起点 + 某个价格指纹"下的汇总。
2335
+ 队友一边写、底栏一边每秒一问:一次变化只该重解压**变了的那一份**,
2336
+ 其余文件直接复用上次的结果 —— 否则每问一次就要把所有队友的日志重解一遍。
2337
+ 键里带 mtime/size(文件一变就换键)与价格指纹(改价后必须按新价重算,
2338
+ 否则「总计」里本会话用新价、队友用旧价,一个数字混两套价)。
2339
+ 上限必须 ≥「后代数 × 窗口数」:客户端固定问两个窗口(整会话 + 本轮),
2340
+ 所以 64 这种小上限在几十个队友时就会互相驱逐,**每次有文件变化都退化成全量解压**
2341
+ (实测 120 个队友改 1 个文件:上限 64 时读 121 份 / 75ms)。取 4096 = 后代上限 2000 × 2,
2342
+ 正常永远不淘汰;每个条目只有几个数字,就算填满也就几十万个数字量级的内存。 */
2343
+ const SUBAGENT_FILE_CACHE_MAX = 4096
2344
+ const subagentFileCache = new Map()
2345
+
2346
+ /** 一个后代文件的用量汇总(带记忆化,键 = 文件 + mtime + size + 窗口起点 + 价格指纹)。
2347
+ 返回里带 `stats`(首字/解码累计,与窗口和价格都无关,只是搭同一次读一起折出来)。 */
2348
+ function subagentFileUsage(one, fromMs, fingerprint) {
2349
+ const aggKey = one.file + '|' + String(one.mtimeMs) + '|' + String(one.size) + '|' + String(fromMs)
2350
+ + '|' + fingerprint
2351
+ const hit = subagentFileCache.get(aggKey)
2352
+ if (hit !== undefined) return hit
2353
+ const agg = { cost: 0, byCurrency: {}, tokens: zeroTokens(), calls: 0, stats: { ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 } }
2354
+ let text = null
2355
+ try {
2356
+ text = zstdText(readFileSync(one.file))
2357
+ } catch (error) {
2358
+ /* 解不动 / 文件没了:这一份当 0,其余后代照算 */
2359
+ text = null
2360
+ }
2361
+ if (text !== null) {
2362
+ /* fromMs = 客户端要的窗口起点(本轮起点);pickUsageEvents 就按 event.time 过滤 */
2363
+ const picked = pickUsageEvents(text, fromMs)
2364
+ picked.forEach((event) => {
2365
+ const parts = event.parts
2366
+ agg.tokens.input += parts.input
2367
+ agg.tokens.cacheRead += parts.cacheRead
2368
+ agg.tokens.cacheWrite += parts.cacheWrite
2369
+ agg.tokens.output += parts.output
2370
+ /* 一次 usage 事件 = 一次调用(pickUsageEvents 已按 turn/step 去过重、全 0 的不算) */
2371
+ agg.calls += 1
2372
+ const amount = tokenCost(event.model, event.time, parts)
2373
+ agg.cost += amount
2374
+ /* 同一笔同时按"模型条目的币种"分桶:客户端混币时要折成一个数字(见 tokenCostCurrency) */
2375
+ const code = tokenCostCurrency(event.model)
2376
+ agg.byCurrency[code] = (agg.byCurrency[code] ?? 0) + amount
2377
+ })
2378
+ agg.stats = foldSessionTiming(text)
2379
+ }
2380
+ subagentFileCache.set(aggKey, agg)
2381
+ while (subagentFileCache.size > SUBAGENT_FILE_CACHE_MAX) {
2382
+ const oldest = subagentFileCache.keys().next()
2383
+ if (oldest.done === true) break
2384
+ subagentFileCache.delete(oldest.value)
2385
+ }
2386
+ return agg
2387
+ }
2388
+
2389
+ /**
2390
+ * 从 `sessionId` 出发,把它**全部后代**(多层:队友再开队友)的用量汇总起来。
2391
+ *
2392
+ * 只统计后代,不统计父会话自己 —— 本会话的费用客户端已经算在「总计」里了。
2393
+ * 计价走当前价格库(`tokenCost` → `priceEntryFor`),与底栏、台账同一套单价。
2394
+ *
2395
+ * 窗口起点(`sinceMs`,毫秒)的三种来源,见 `sinceSource`:
2396
+ * · `client`:客户端传了(它精确知道自己那一轮)→ 只看 `event.time >= sinceMs`;
2397
+ * **但客户端的起点可能过期**(新一轮已写 `turn/start`、客户端节点还没跟上,
2398
+ * 它会把上一轮的起点送上来),所以再用父会话日志推的起点**按 max 收敛一道**
2399
+ * (只收紧、不放大;`since=0` 是显式整会话,不收敛);真收敛了会带
2400
+ * `sinceClamped: true`(`sinceSource` 仍是 `client`);
2401
+ * · `host`:没传 → host 读**父会话自己**的日志推一个起点(最后一条 `turn/start`,
2402
+ * 但和最后一条 `user/message` 取较新的那个 —— 后者更新说明新一轮已开始、其
2403
+ * `turn/start` 还没落盘,沿用旧起点会把上一轮算进来);而且**推出来的起点之后
2404
+ * 必须真的有用量事件**,否则说明起点无从验证,同样当 `none`(宁少不多);
2405
+ * · `none`:父会话日志缺失/读不出,或推出来的起点事后没有事件 → 本轮**什么都不并**
2406
+ * (整条返回零值),绝不退回整会话 —— 用户规则:本轮只能小不能大;
2407
+ * 要整会话(「总计」那一项)就显式传 `since=0`。
2408
+ * 全程只读会话日志,不碰台账、不写任何文件。
2409
+ *
2410
+ * **排进台账队列**(不是怕写冲突,是怕混价):价格库的换入换出发生在 `queueLedgerJob` 里,
2411
+ * 排在同一条队列上就不会"扫到一半换了价"(否则同一个数字里前几笔按旧价、后几笔按新价)。
2412
+ * 顺带还解决了并发去重:客户端一次到期会并发问两个窗口,串行执行时后一个必然命中
2413
+ * 前一个刚写好的缓存,同一批日志不会被重复冷解压。
2414
+ */
2415
+ function collectSubagentUsage(sessionId, sinceMs) {
2416
+ return queueLedgerJob(() => collectSubagentUsageNow(sessionId, sinceMs))
2417
+ }
2418
+
2419
+ async function collectSubagentUsageNow(sessionId, sinceMs) {
2420
+ /* 价格指纹:并进两个缓存键。改价后必须按新价重算 —— 否则队友那段会一直挂着旧价,
2421
+ 同一个「总计」里本会话用新价、队友用旧价(实测缺陷)。 */
2422
+ const fingerprint = priceFingerprint()
2423
+ const clientSince = typeof sinceMs === 'number' && Number.isFinite(sinceMs) && sinceMs >= 0 ? sinceMs : null
2424
+ const files = collectSessionFiles(sessionsRoot(), [])
2425
+ const index = await subagentIndexSnapshot(files)
2426
+ /* BFS 收全部后代:环形日志(A→B→A)靠 visited 兜住,不会转不出来 */
2427
+ const found = []
2428
+ const visited = new Set([sessionId])
2429
+ const queue = [sessionId]
2430
+ let truncated = false
2431
+ while (queue.length > 0) {
2432
+ const parent = queue.shift()
2433
+ const kids = index.children.get(parent)
2434
+ if (kids === undefined) continue
2435
+ for (let i = 0; i < kids.length; i += 1) {
2436
+ const child = kids[i]
2437
+ if (visited.has(child.id) === true) continue
2438
+ visited.add(child.id)
2439
+ if (found.length >= SUBAGENT_MAX_DESCENDANTS) {
2440
+ truncated = true
2441
+ break
2442
+ }
2443
+ found.push(child)
2444
+ queue.push(child.id)
2445
+ }
2446
+ if (truncated === true) break
2447
+ }
2448
+
2449
+ /* 窗口起点从哪来:① 客户端给了(它精确知道自己那一轮)→ `client`;
2450
+ ② 没给就问 host 自己 —— 读**父会话自己**的日志取最后一条 turn/start(或更新的
2451
+ user/message,见 lastTurnStartOfText)→ `host`;③ 两者都没有(父会话文件缺失/读不出)
2452
+ → `none`:**本轮什么都不并**(用户规则:本轮只能小不能大,绝不能拿整会话的量顶上)。
2453
+ 另外 host 推的起点还要过一道"事后有事件"的验证(见下面聚合之后的那段)。 */
2454
+ const parentEntry = index.byId.get(sessionId)
2455
+ let sinceSource = 'none'
2456
+ let fromMs = null
2457
+ let clamped = false
2458
+ if (clientSince !== null) {
2459
+ sinceSource = 'client'
2460
+ fromMs = clientSince
2461
+ /* 客户端给的起点**可能已经过期**:它折叠的是对话节点,而新一轮从父会话写
2462
+ `turn/start` 到"本轮第一个 assistant 节点出现"之间有延迟(首字延迟量级,
2463
+ 长思考/失败时更久),这期间客户端还会把**上一轮的起点**当 since 送上来,
2464
+ 于是上一轮的队友用量被算进"本轮"(偏大)。这里用父会话日志推出来的起点
2465
+ 兜一道:**只在收紧方向**生效(取较新的那个),绝不把窗口放大。
2466
+ `since=0` 是客户端显式要的整会话(「总计」那一问),不参与收敛。 */
2467
+ if (clientSince > 0 && parentEntry !== undefined) {
2468
+ const hostSince = parentTurnStartOf(parentEntry)
2469
+ if (hostSince !== null && hostSince > fromMs) {
2470
+ fromMs = hostSince
2471
+ clamped = true
2472
+ }
2473
+ }
2474
+ } else if (parentEntry !== undefined) {
2475
+ const hostSince = parentTurnStartOf(parentEntry)
2476
+ if (hostSince !== null) {
2477
+ sinceSource = 'host'
2478
+ fromMs = hostSince
2479
+ }
2480
+ }
2481
+ let hasWindow = fromMs !== null
2482
+
2483
+ /* 每个后代文件的 mtime+size 直接用索引里刚 stat 到的那一份:它既是"要不要重算"的缓存键,
2484
+ 也顺手滤掉了已经消失的文件(索引阶段已经 stat 过一遍,这里不再重复 statSync ——
2485
+ 队友上百个时那一遍是实打实的开销)。索引与这里只差几微秒,真在这中间被写入的话,
2486
+ 下一次请求(每秒一回)就会重新 stat 到。 */
2487
+ const parts = []
2488
+ const reading = []
2489
+ for (let i = 0; i < found.length; i += 1) {
2490
+ reading.push({ id: found[i].id, file: found[i].file, mtimeMs: found[i].mtimeMs, size: found[i].size })
2491
+ parts.push(found[i].id + ':' + String(found[i].mtimeMs) + ':' + String(found[i].size))
2492
+ }
2493
+ /* 新出现的子会话会改变文件集合(索引重建后出现在 found 里),键自然就变了;
2494
+ 价格指纹也在键里:改价后这一段必须按新价重算,否则队友那段会一直挂着旧价。
2495
+ 例外:窗口是 **host 推的**(含推不出来)时,父会话日志也必须进键 ——
2496
+ 否则"新一轮开始了"改变不了键,缓存会一直沿用旧窗口(或一直沿用推不出来的空结果)。 */
2497
+ const parentTag = parentEntry === undefined
2498
+ ? 'parent:none'
2499
+ : 'parent:' + String(parentEntry.mtimeMs) + ':' + String(parentEntry.size)
2500
+ const cacheKey = sessionId + '@' + (hasWindow ? String(fromMs) : 'none') + '#' + fingerprint
2501
+ const keyParts = sinceSource === 'client' ? parts : parts.concat([parentTag])
2502
+ const key = cacheKey + '|' + keyParts.join('|')
2503
+ const cached = subagentUsageCache.get(cacheKey)
2504
+ if (cached !== undefined && cached.key === key) return withClampFlag(cached.value, clamped)
2505
+
2506
+ const total = { cost: 0, byCurrency: {}, tokens: zeroTokens(), calls: 0, stats: { ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 } }
2507
+ const children = []
2508
+ for (let i = 0; i < reading.length; i += 1) {
2509
+ if (hasWindow !== true) {
2510
+ /* 没有窗口(sinceSource='none'):一个字节都不读 —— 本轮为空,但队友仍然列出来(都是 0),
2511
+ 与"窗口里没有事件"保持同一种形状。total.stats 也随之为 0(它只服务于会话级那三项,
2512
+ 客户端只从**整会话**那一份里读它)。 */
2513
+ children.push({ id: reading[i].id, cost: 0, tokens: zeroTokens(), calls: 0 })
2514
+ continue
2515
+ }
2516
+ const agg = subagentFileUsage(reading[i], fromMs, fingerprint)
2517
+ children.push({ id: reading[i].id, cost: roundCost(agg.cost), tokens: agg.tokens, calls: agg.calls })
2518
+ total.cost += agg.cost
2519
+ /* 分币种累计:客户端按参考价比例折成底栏显示币种后再相加(混币时一个数字) */
2520
+ for (const code of Object.keys(agg.byCurrency)) {
2521
+ total.byCurrency[code] = (total.byCurrency[code] ?? 0) + agg.byCurrency[code]
2522
+ }
2523
+ total.tokens.input += agg.tokens.input
2524
+ total.tokens.cacheRead += agg.tokens.cacheRead
2525
+ total.tokens.cacheWrite += agg.tokens.cacheWrite
2526
+ total.tokens.output += agg.tokens.output
2527
+ total.calls += agg.calls
2528
+ /* 首字 / 解码的累计:这三项客户端只用在"会话级"那三个数上,
2529
+ 所以它是**整会话**口径、与 since 窗口无关(窗口那边客户端不看它)。 */
2530
+ total.stats.ttftMs += agg.stats.ttftMs
2531
+ total.stats.ttftSteps += agg.stats.ttftSteps
2532
+ total.stats.decodeMs += agg.stats.decodeMs
2533
+ total.stats.decodeTokens += agg.stats.decodeTokens
2534
+ if (i % 4 === 3) await new Promise((resolve) => setImmediate(resolve))
2535
+ }
2536
+ /* host 推出来的起点必须有"事后证据"才算有效。**证据有两条,任一条成立即可**:
2537
+ ① 父会话**自己**在窗口内有用量事件(fromMs 之后有 assistant/message 或
2538
+ compaction/summary 且带非空 usage);
2539
+ ② 队友在窗口内有用量事件(`total.calls`,就是上面聚合出来的事件数)。
2540
+ 为什么必须补上 ①:`total` 只累加**队友**,不含父会话自己。于是"新一轮已起、
2541
+ 父会话主模型已产出、队友还没产出"这几秒里 `total.calls === 0`,旧实现把起点
2542
+ 判成无效(sinceSource 从 host 掉成 none)—— 实测复现的缺陷。父会话自己那条
2543
+ assistant/message 就是"这个 turn/start 是活的"的直接证据。
2544
+ 为什么保留 ②:**不许因此丢掉真实存在的队友用量**。队友在窗口内已经花了钱,
2545
+ 说明这个起点确实是本轮起点(否则那笔钱会被静默抹掉,方向正是"少算")。
2546
+ 实测约束(2026-10-08,本机 41 份会话日志):父会话最后一条 turn/start 之后
2547
+ "队友有、父会话自己没有用量事件"的窗口 0 个,但用例夹具 p-1 就是这一形态,
2548
+ 且它钉住的契约是"host 代推 = 客户端传同一起点"—— 保留 ② 才能两边都不破。
2549
+ 两条都不成立时才按用户的"宁少不多"当没有窗口 —— 本轮不含队友。
2550
+ 判断只用已经折好的标量(父会话日志的最大用量时间、同一个窗口的聚合结果),
2551
+ 没有再解压一遍。 */
2552
+ const parentFacts = sinceSource === 'host' && parentEntry !== undefined ? parentLogFactsOf(parentEntry) : null
2553
+ const parentProduced = parentFacts !== null && parentFacts.lastUsageAt !== null
2554
+ && fromMs !== null && parentFacts.lastUsageAt >= fromMs
2555
+ if (sinceSource === 'host' && parentProduced !== true && total.calls === 0) {
2556
+ sinceSource = 'none'
2557
+ hasWindow = false
2558
+ fromMs = null
2559
+ children.length = 0
2560
+ for (let i = 0; i < reading.length; i += 1) {
2561
+ children.push({ id: reading[i].id, cost: 0, tokens: zeroTokens(), calls: 0 })
2562
+ }
2563
+ total.cost = 0
2564
+ total.byCurrency = {}
2565
+ total.tokens = zeroTokens()
2566
+ total.calls = 0
2567
+ total.stats = { ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 }
2568
+ }
2569
+ /* 分币种那几笔一起舍入(与 total.cost 同一口径):客户端据此折算 */
2570
+ const costByCurrency = {}
2571
+ for (const code of Object.keys(total.byCurrency)) costByCurrency[code] = roundCost(total.byCurrency[code])
2572
+ const value = {
2573
+ ok: true,
2574
+ sessionId,
2575
+ /* 窗口起点来自哪里:client(客户端传的)/ host(host 按父会话日志推的)/
2576
+ none(都没有,或 host 推出来的起点事后没有任何用量事件 → 本轮不并队友)。
2577
+ `sinceSource` 说不出"客户端给的起点被 host 收紧了",那个事实用 `sinceClamped` 表达
2578
+ (只在真收敛时才出现,风格与 truncated 一致)。既有字段形状不变。 */
2579
+ sinceSource,
2580
+ children,
2581
+ /* `costByCurrency` 是这次新增的可选字段:按每个队友模型**自己条目的币种**分桶。
2582
+ 客户端混币时用它折成底栏显示币种;老客户端不看它,`total.cost` 语义不变。 */
2583
+ total: {
2584
+ cost: roundCost(total.cost),
2585
+ tokens: total.tokens,
2586
+ calls: total.calls,
2587
+ stats: total.stats,
2588
+ costByCurrency: costByCurrency,
2589
+ },
2590
+ }
2591
+ if (truncated === true) value.truncated = true
2592
+ /* 只在这条路径上打一行:命中缓存时不说话。队友在干活时它出现的频率就是"重算"的频率,
2593
+ 排查"总计怎么没变"时看这一行就知道 host 是算了还是直接用缓存给了旧结果。
2594
+ 只写会话 id 与数字,不碰日志正文。 */
2595
+ logHost(ctxRef, 'subagent-usage: 会话 ' + sessionId + ' 的队友 ' + String(children.length)
2596
+ + ' 个,重算 ¥' + String(value.total.cost)
2597
+ + '(窗口来源 ' + sinceSource + (hasWindow ? ' @' + String(fromMs) : '')
2598
+ + (clamped === true ? ',sinceClamped(客户端起点被 host 收紧到父会话日志推的起点)' : '') + ')'
2599
+ + (index.duplicates > 0 ? '(同 id 重复文件 ' + String(index.duplicates) + ' 份,已取最新)' : '')
2600
+ + (truncated === true ? '(超过上限已截断)' : ''))
2601
+ /* delete + set:让这条键变成 Map 里最新的一个(Map 按插入顺序淘汰)。
2602
+ 键含窗口起点:同一个会话的"整会话"与"本轮"各占一条,互不挤掉。 */
2603
+ subagentUsageCache.delete(cacheKey)
2604
+ subagentUsageCache.set(cacheKey, { key, value })
2605
+ if (subagentUsageCache.size > SUBAGENT_CACHE_MAX) {
2606
+ const oldest = subagentUsageCache.keys().next()
2607
+ if (oldest.done !== true) subagentUsageCache.delete(oldest.value)
2608
+ }
2609
+ return withClampFlag(value, clamped)
2610
+ }
2611
+
2612
+ /** `<DSH_HOME>`。 */
2613
+ function dshHome() {
2614
+ return process.env.DSH_HOME !== undefined && process.env.DSH_HOME.length > 0
2615
+ ? process.env.DSH_HOME
2616
+ : join(homedir(), '.dsh')
2617
+ }
2618
+
2619
+ /** 台账文件:与状态栏其它数据同目录(`<DSH_HOME>/dsh-status-bar/`)。 */
2620
+ function ledgerPath() {
2621
+ return join(dshHome(), 'dsh-status-bar', 'usage-ledger.json')
2622
+ }
2623
+
2624
+ /**
2625
+ * 台账形状:
2626
+ * days 每个本地日期的累计用量(只增不减 —— 会话被删掉也不会缩水)
2627
+ * days[日期].cost 已结算的费用(人民币);一旦记下就不再改动,
2628
+ * 改价也不重算(重算历史曾经把用户的数据弄丢过)
2629
+ * days[日期].unpriced 当时没有单价、还没算钱的用量,按模型名与峰谷分档攒着;
2630
+ * 用户补上价后结算成 cost,条目随即移除
2631
+ * seen 每个会话已计入的 seq,用来防重复
2632
+ * sessions 每个会话最后一条用量事件的时间,用来淘汰老会话的去重记录
2633
+ * files 每个日志文件上次处理时的 mtime+size,没变就跳过解压
2634
+ * priceFingerprint 上一次结算用的价格库指纹:判断价格有没有变,也给回扫还原旧价目表
2635
+ * unpricedBackfill 'pending' = 还需要一次性回扫,把历史缺价明细补全;'done' = 已补完
2636
+ */
2637
+ const LEDGER_VERSION = 3
2638
+
2639
+ function emptyLedger() {
2640
+ return {
2641
+ version: LEDGER_VERSION,
2642
+ updatedAt: 0,
2643
+ priceFingerprint: '',
2644
+ unpricedBackfill: '',
2645
+ days: {},
2646
+ seen: {},
2647
+ sessions: {},
2648
+ files: {},
2649
+ }
2650
+ }
2651
+
2652
+ /**
2653
+ * 读一个台账文件;文件不存在、读不出或不是合法 JSON 对象时返回 null。
2654
+ */
2655
+ function readLedgerFile(file) {
2656
+ try {
2657
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
2658
+ return parsed !== null && typeof parsed === 'object' ? parsed : null
2659
+ } catch (error) {
2660
+ return null
2661
+ }
2662
+ }
2663
+
2664
+ /**
2665
+ * 老版本台账 → 当前版本:**只补字段、不动 days 里的任何数字**。
2666
+ * 版本比当前新(用户装过更新的版本又回退)时也照样收下能用的部分,宁可少字段也不丢数据。
2667
+ */
2668
+ function migrateLedger(parsed) {
2669
+ const pick = (value) => (value !== null && value !== undefined && typeof value === 'object' ? value : {})
2670
+ const ledger = {
2671
+ version: LEDGER_VERSION,
2672
+ updatedAt: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : 0,
2673
+ priceFingerprint: typeof parsed.priceFingerprint === 'string' ? parsed.priceFingerprint : '',
2674
+ unpricedBackfill: typeof parsed.unpricedBackfill === 'string' ? parsed.unpricedBackfill : '',
2675
+ days: pick(parsed.days),
2676
+ seen: pick(parsed.seen),
2677
+ sessions: pick(parsed.sessions),
2678
+ files: pick(parsed.files),
2679
+ }
2680
+ /* v1/v2 台账没记过"哪些 token 当时没有单价",那部分钱因此永远补不上。
2681
+ 标记待办,下次扫描时回扫一遍会话日志把欠账明细补出来 —— 回扫只写 unpriced,
2682
+ 不改历史 token 与 cost(见 backfillUnpriced)。 */
2683
+ if (typeof parsed.version !== 'number' || parsed.version < LEDGER_VERSION) {
2684
+ if (ledger.unpricedBackfill !== 'done') ledger.unpricedBackfill = 'pending'
2685
+ }
2686
+ return ledger
2687
+ }
2688
+
2689
+ /**
2690
+ * 读台账。**任何情况下都不清空 days** —— 台账一旦记账就是历史,
2691
+ * 改价格、删对话、删工作区都不许把它抹掉(重算历史曾经把用户两天的数据弄丢过一次)。
2692
+ * · 主文件读不出来(不存在 / JSON 坏了)→ 退回上一次自动留的 .bak
2693
+ * · 老版本 → migrateLedger 原样收下
2694
+ */
2695
+ /** 读台账时手边没有 ctx,宿主日志函数用不了,就直接写一行 stderr(失败也不影响功能)。 */
2696
+ function logLedgerNote(message) {
2697
+ try {
2698
+ if (typeof process !== 'undefined' && process.stderr !== undefined && typeof process.stderr.write === 'function') {
2699
+ process.stderr.write('[dsh-desktop-statusbar] ' + message + '\n')
2700
+ }
2701
+ } catch (error) {
2702
+ /* 日志失败不影响记账 */
2703
+ }
2704
+ }
2705
+
2706
+ function readLedger() {
2707
+ const parsed = readLedgerFile(ledgerPath())
2708
+ if (parsed !== null) return migrateLedger(parsed)
2709
+ const backup = readLedgerFile(ledgerPath() + '.bak')
2710
+ if (backup !== null) {
2711
+ logLedgerNote('usage-ledger: 主文件读不出来,已回退到自动备份')
2712
+ const recovered = migrateLedger(backup)
2713
+ /* 顺手把好数据写回主文件:否则它会一直保持损坏态,每次读都回退备份并打一行日志。
2714
+ writeLedger 只在"上一份能解析"时才覆盖 .bak,所以这份好备份不会被写坏。 */
2715
+ writeLedger(recovered)
2716
+ return recovered
2717
+ }
2718
+ return emptyLedger()
2719
+ }
2720
+
2721
+ /**
2722
+ * 先写临时文件再改名,避免读到写了一半的 JSON;改名之前把上一份留成 .bak。
2723
+ * 台账是会话日志被删之后**唯一**留得住历史的地方,所以这层备份不省。
2724
+ */
2725
+ function writeLedger(ledger) {
2726
+ const target = ledgerPath()
2727
+ const temp = target + '.tmp'
2728
+ try {
2729
+ mkdirSync(join(dshHome(), 'dsh-status-bar'), { recursive: true })
2730
+ let kept = false
2731
+ /* 只有"上一份是能解析的好数据"才留成备份:主文件损坏时我们刚回退过 .bak,
2732
+ 若这时把损坏内容复制成新 .bak,备份就只能救命一次(实测缺陷)。 */
2733
+ if (readLedgerFile(target) !== null) {
2734
+ try {
2735
+ copyFileSync(target, target + '.bak')
2736
+ kept = true
2737
+ } catch (error) {
2738
+ /* 复制失败不影响本次落盘 */
2739
+ }
2740
+ }
2741
+ writeFileSync(temp, JSON.stringify(ledger), 'utf8')
2742
+ renameSync(temp, target)
2743
+ /* 首次写入没有"上一份"可留,就补一份同内容的备份 —— 保护不该从第二次才开始生效 */
2744
+ if (kept !== true) {
2745
+ try {
2746
+ copyFileSync(target, target + '.bak')
2747
+ } catch (error) {
2748
+ /* 备份失败不影响本次落盘 */
2749
+ }
2750
+ }
2751
+ } catch (error) {
2752
+ /* 落盘失败不影响本次读数 */
2753
+ }
2754
+ }
2755
+
2756
+ /* ------------------------------------------------ 缺价用量(欠账)与补算 */
2757
+
2758
+ /** 日桶里"欠账"那一格:{ 模型名: { peak: 四项, offPeak: 四项 } }。 */
2759
+ function addUnpriced(day, model, parts, peakTime) {
2760
+ const name = typeof model === 'string' && model.length > 0 ? model : '(未知模型)'
2761
+ const bucket = day.unpriced ?? {}
2762
+ const one = bucket[name] ?? {
2763
+ peak: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 },
2764
+ offPeak: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 },
2765
+ }
2766
+ const tier = peakTime ? one.peak : one.offPeak
2767
+ tier.input += parts.input
2768
+ tier.cacheRead += parts.cacheRead
2769
+ tier.cacheWrite += parts.cacheWrite
2770
+ tier.output += parts.output
2771
+ bucket[name] = one
2772
+ day.unpriced = bucket
2773
+ }
2774
+
2775
+ /** 一档用量按一档单价折成人民币(用量或单价缺失按 0 算)。 */
2776
+ function costOfTier(parts, price) {
2777
+ if (parts === null || parts === undefined) return 0
2778
+ if (price === null || price === undefined) return 0
2779
+ return (numOrZero(parts.input) * numOrZero(price.input) + numOrZero(parts.cacheRead) * numOrZero(price.cacheRead)
2780
+ + numOrZero(parts.cacheWrite) * numOrZero(price.cacheWrite) + numOrZero(parts.output) * numOrZero(price.output)) / 1e6
2781
+ }
2782
+
2783
+ /**
2784
+ * 结算欠账:把 days[].unpriced 里**现在能算出价**的用量按当前价格库折成人民币,
2785
+ * 加到当天的 cost 上,条目随即从欠账里移除;仍缺价的条目原样留着等下次补价。
2786
+ *
2787
+ * 只碰欠账,绝不重算已经算过的费用 —— 这是与上一版"整本作废重扫"的关键区别:
2788
+ * 用户补一次价,不该把过去按旧价算好的钱全部改写。
2789
+ */
2790
+ function settleUnpriced(ledger) {
2791
+ let entries = 0
2792
+ let amount = 0
2793
+ const dates = Object.keys(ledger.days)
2794
+ for (let i = 0; i < dates.length; i += 1) {
2795
+ const day = ledger.days[dates[i]]
2796
+ if (day === null || day === undefined || typeof day !== 'object') continue
2797
+ const bucket = day.unpriced
2798
+ if (bucket === null || bucket === undefined || typeof bucket !== 'object') continue
2799
+ const names = Object.keys(bucket)
2800
+ for (let k = 0; k < names.length; k += 1) {
2801
+ const entry = priceEntryFor(names[k])
2802
+ if (entry === null) continue
2803
+ const one = bucket[names[k]]
2804
+ /* 单档条目(没勾「峰谷计价」):两个桶都按 peak 价结算,否则空闲桶会被算成 0 */
2805
+ const peakPrice = entry.peak !== null && entry.peak !== undefined ? entry.peak : entry.offPeak
2806
+ const offPeakPrice = entry.tiered === false
2807
+ ? peakPrice
2808
+ : (entry.offPeak !== null && entry.offPeak !== undefined ? entry.offPeak : entry.peak)
2809
+ const cost = costOfTier(one.peak, peakPrice) + costOfTier(one.offPeak, offPeakPrice)
2810
+ day.cost = (typeof day.cost === 'number' && Number.isFinite(day.cost) ? day.cost : 0) + cost
2811
+ amount += cost
2812
+ entries += 1
2813
+ delete bucket[names[k]]
2814
+ }
2815
+ if (Object.keys(bucket).length === 0) delete day.unpriced
2816
+ else day.unpriced = bucket
2817
+ }
2818
+ return { entries, amount: Math.round(amount * 1e6) / 1e6 }
2819
+ }
2820
+
2821
+ /**
2822
+ * 一次性回扫(升级后只做一次):v1/v2 台账没记过"哪些 token 当时没有单价",
2823
+ * 只有会话日志里找得到。回扫只补这一件事:
2824
+ * ① 用**台账指纹还原出来的旧价目表**判断每个事件当时有没有价;
2825
+ * ② 当时没价的,交给 settleUnpriced 用**现在的价格库**结算 —— 能算就把钱补进当天 cost,
2826
+ * 仍缺价的进 unpriced 等以后再补。
2827
+ *
2828
+ * 两条安全线:
2829
+ * · 只处理 `seen` 里记过账的事件(没入过账的归正常扫描路径),避免重复计价;
2830
+ * · 当时就有价的一律跳过,历史 cost 一分不动。
2831
+ * 每个文件补完打 unpricedAt 标记,中断了下次接着做,不会重复补算。
2832
+ */
2833
+ async function backfillUnpriced(ledger) {
2834
+ const parsedBook = parseFingerprint(ledger.priceFingerprint)
2835
+ /* 老台账没有指纹字段(更早的版本用的就是那张内置参考价)→ 按内置表还原"当时的价格库" */
2836
+ const historyBook = parsedBook !== null ? parsedBook : builtinPriceBook()
2837
+ const files = collectSessionFiles(sessionsRoot(), [])
2838
+ const keepFrom = Date.now() - 400 * 24 * 3600 * 1000
2839
+ let scanned = 0
2840
+ for (let i = 0; i < files.length; i += 1) {
2841
+ const file = files[i]
2842
+ const mark = ledger.files[file]
2843
+ let stat = null
2844
+ try {
2845
+ stat = statSync(file)
2846
+ } catch (error) {
2847
+ continue
2848
+ }
2849
+ /* 很久没动过、也从没扫过:它的内容在保留期之外,正常扫描也不看它 */
2850
+ if (mark === undefined && stat.mtimeMs < keepFrom) continue
2851
+ if (mark !== undefined && mark.unpricedAt === LEDGER_VERSION) continue
2852
+ let text = null
2853
+ try {
2854
+ text = zstdText(readFileSync(file))
2855
+ } catch (error) {
2856
+ text = null
2857
+ }
2858
+ if (text === null) continue
2859
+ scanned += 1
2860
+ const sessionId = basename(dirname(file))
2861
+ const seen = new Set(Array.isArray(ledger.seen[sessionId]) ? ledger.seen[sessionId] : [])
2862
+ const picked = pickUsageEvents(text, keepFrom)
2863
+ const keys = []
2864
+ picked.forEach((one, key) => keys.push(key))
2865
+ for (let k = 0; k < keys.length; k += 1) {
2866
+ const one = picked.get(keys[k])
2867
+ if (seen.has(one.seq) !== true) continue
2868
+ /* 必须用**当年那套规则**判"当时有没有价":新规则(词元匹配)会把这些事件
2869
+ 误判成"当时有价"直接跳过,历史欠账就永远补不出来了(实测缺陷) */
2870
+ if (legacyPriceEntryIn(historyBook, one.model) !== null) continue
2871
+ const day = ledger.days[localDateKey(one.time)]
2872
+ if (day === null || day === undefined || typeof day !== 'object') continue
2873
+ addUnpriced(day, one.model, one.parts, isPeakTime(one.time))
2874
+ }
2875
+ const next = mark !== undefined && mark !== null && typeof mark === 'object'
2876
+ ? mark
2877
+ : { mtimeMs: 0, size: 0, fromMs: 0 }
2878
+ next.unpricedAt = LEDGER_VERSION
2879
+ ledger.files[file] = next
2880
+ if (i % 4 === 3) await new Promise((resolve) => setImmediate(resolve))
2881
+ }
2882
+ return { files: files.length, scanned }
2883
+ }
2884
+
2885
+ /**
2886
+ * 把会话日志里的用量并进台账。文件没变(mtime+size 相同)就直接跳过,空闲时几乎没有开销;
2887
+ * 每 4 个文件让出一次事件循环,别把 host 卡住。
2888
+ */
2889
+ async function collectIntoLedger(ledger, fromMs) {
2890
+ const files = collectSessionFiles(sessionsRoot(), [])
2891
+ let scannedFiles = 0
2892
+ let skippedFiles = 0
2893
+ let added = 0
2894
+ for (let i = 0; i < files.length; i += 1) {
2895
+ const file = files[i]
2896
+ let stat
2897
+ try {
2898
+ stat = statSync(file)
2899
+ } catch (error) {
2900
+ delete ledger.files[file]
2901
+ continue
2902
+ }
2903
+ const mark = ledger.files[file]
2904
+ /* 跳过条件必须连"上次扫描用的窗口起点"一起比。
2905
+ 窗口只覆盖最近几天时,文件里更早的事件会被 fromMs 过滤掉,但文件照样被标记成已扫;
2906
+ 之后窗口放宽(柱改成近 7 天、活跃度要整月、12 个月要整年)如果不重扫,
2907
+ 那些被过滤掉的数据就永远补不回来 —— 这正是 9/26 少掉 2.8 亿 token 的原因。
2908
+ mark.fromMs 缺失(老记录)时视为"不满足",于是会自动重扫一遍把历史补齐。 */
2909
+ if (mark !== undefined && mark.mtimeMs === stat.mtimeMs && mark.size === stat.size
2910
+ && typeof mark.fromMs === 'number' && mark.fromMs <= fromMs) {
2911
+ skippedFiles += 1
2912
+ continue
2913
+ }
2914
+ /* 窗口起点之前就没再动过、也从没扫过:窗口外的内容与我们无关 */
2915
+ if (stat.mtimeMs < fromMs && mark === undefined) {
2916
+ skippedFiles += 1
2917
+ continue
2918
+ }
2919
+ let text = null
2920
+ try {
2921
+ text = zstdText(readFileSync(file))
2922
+ } catch (error) {
2923
+ text = null
2924
+ }
2925
+ /* 连同这次的窗口起点一起记:下次只有当窗口不晚于它时才允许整文件跳过。
2926
+ 顺手保住 unpricedAt —— 那是"这个文件的欠账明细已经回扫过"的标记,
2927
+ 丢了会让下次启动再回扫一遍,同一批用量被记两次欠账。 */
2928
+ const nextMark = { mtimeMs: stat.mtimeMs, size: stat.size, fromMs }
2929
+ if (mark !== undefined && mark !== null && typeof mark.unpricedAt === 'number') nextMark.unpricedAt = mark.unpricedAt
2930
+ ledger.files[file] = nextMark
2931
+ if (text === null) continue
2932
+ scannedFiles += 1
2933
+ const sessionId = basename(dirname(file))
2934
+ const picked = pickUsageEvents(text, fromMs)
2935
+ if (picked.size === 0) continue
2936
+ const seen = new Set(Array.isArray(ledger.seen[sessionId]) ? ledger.seen[sessionId] : [])
2937
+ const record = ledger.sessions[sessionId]
2938
+ let last = record !== undefined && typeof record.last === 'number' ? record.last : 0
2939
+ for (const one of picked.values()) {
2940
+ last = Math.max(last, one.time)
2941
+ if (seen.has(one.seq)) continue
2942
+ seen.add(one.seq)
2943
+ const key = localDateKey(one.time)
2944
+ const day = ledger.days[key] ?? { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, calls: 0, cost: 0, peak: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 } }
2945
+ day.input += one.parts.input
2946
+ day.cacheRead += one.parts.cacheRead
2947
+ day.cacheWrite += one.parts.cacheWrite
2948
+ day.output += one.parts.output
2949
+ day.calls += 1
2950
+ /* 高峰时段(北京时间工作日 09-12 / 14-18,排除法定节假日)那一份单独记,
2951
+ 柱状图会把它截出来放在柱子顶部 */
2952
+ const peakTime = isPeakTime(one.time)
2953
+ if (peakTime) {
2954
+ const peak = day.peak ?? { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
2955
+ peak.input += one.parts.input
2956
+ peak.cacheRead += one.parts.cacheRead
2957
+ peak.cacheWrite += one.parts.cacheWrite
2958
+ peak.output += one.parts.output
2959
+ day.peak = peak
2960
+ }
2961
+ /* 有单价就当场折成人民币;**还没有单价的用量单独记成"欠账"**,
2962
+ 等用户在设置页把价补上再结算(补价只结算欠账,不动已经算好的费用)。 */
2963
+ if (priceEntryFor(one.model) === null) addUnpriced(day, one.model, one.parts, peakTime)
2964
+ else day.cost = (day.cost || 0) + tokenCost(one.model, one.time, one.parts)
2965
+ ledger.days[key] = day
2966
+ added += 1
2967
+ }
2968
+ ledger.seen[sessionId] = Array.from(seen)
2969
+ ledger.sessions[sessionId] = { last }
2970
+ if (i % 4 === 3) await new Promise((resolve) => setImmediate(resolve))
2971
+ }
2972
+ return { files: files.length, scannedFiles, skippedFiles, added }
2973
+ }
2974
+
2975
+ /** 丢掉太久以前的日桶与去重记录。保留期是滚动的「今天往前 400 天」,与统计窗口无关。 */
2976
+ function pruneLedger(ledger) {
2977
+ const keepFrom = localDateKey(Date.now() - 400 * 24 * 3600 * 1000)
2978
+ for (const key of Object.keys(ledger.days)) {
2979
+ if (key < keepFrom) delete ledger.days[key]
2980
+ }
2981
+ for (const sessionId of Object.keys(ledger.sessions)) {
2982
+ const one = ledger.sessions[sessionId]
2983
+ if (one === undefined || typeof one.last !== 'number' || localDateKey(one.last) < keepFrom) {
2984
+ delete ledger.sessions[sessionId]
2985
+ delete ledger.seen[sessionId]
2986
+ }
2987
+ }
2988
+ }
2989
+
2990
+ /** 把台账里的日桶铺成「近 7 天」固定 7 格(最早一天在最左、今天是最后一格),并算合计;另附当月与全年。 */
2991
+ function weekUsagePayload(ledger, rangeStart, monthStart, meta) {
2992
+ const DAY = 24 * 3600 * 1000
2993
+ const days = []
2994
+ const totals = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, total: 0, calls: 0, cost: 0 }
2995
+ for (let i = 0; i < 7; i += 1) {
2996
+ /* 按北京时间逐日推进,不受机器时区影响 */
2997
+ const key = localDateKey(rangeStart + i * DAY)
2998
+ const bucket = ledger.days[key] ?? { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, calls: 0, cost: 0, peak: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 } }
2999
+ const total = bucket.input + bucket.cacheRead + bucket.cacheWrite + bucket.output
3000
+ const cost = typeof bucket.cost === 'number' && Number.isFinite(bucket.cost) ? bucket.cost : 0
3001
+ /* 高峰那一份:老台账没有 peak 字段时按 0 处理 */
3002
+ const pk = bucket.peak ?? { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
3003
+ const peak = {
3004
+ input: Number(pk.input) || 0,
3005
+ cacheRead: Number(pk.cacheRead) || 0,
3006
+ cacheWrite: Number(pk.cacheWrite) || 0,
3007
+ output: Number(pk.output) || 0,
3008
+ }
3009
+ peak.total = peak.input + peak.cacheRead + peak.cacheWrite + peak.output
3010
+ days.push({
3011
+ date: key,
3012
+ /* 窗口内第几天(0 = 最早那天,6 = 今天);不再是星期几 */
3013
+ offset: i,
3014
+ input: bucket.input,
3015
+ cacheRead: bucket.cacheRead,
3016
+ cacheWrite: bucket.cacheWrite,
3017
+ output: bucket.output,
3018
+ total,
3019
+ calls: bucket.calls,
3020
+ /* 高峰时段那一份(四项 + 合计),客户端把它截出来画在柱子顶部 */
3021
+ peak,
3022
+ /* 人民币,保留 4 位小数,客户端自己截到分 */
3023
+ cost: Math.round(cost * 10000) / 10000,
3024
+ })
3025
+ totals.input += bucket.input
3026
+ totals.cacheRead += bucket.cacheRead
3027
+ totals.cacheWrite += bucket.cacheWrite
3028
+ totals.output += bucket.output
3029
+ totals.calls += bucket.calls
3030
+ totals.total += total
3031
+ totals.cost += cost
3032
+ }
3033
+ totals.cost = Math.round(totals.cost * 10000) / 10000
3034
+ return Object.assign({
3035
+ ok: true,
3036
+ at: Date.now(),
3037
+ rangeStart,
3038
+ days,
3039
+ totals,
3040
+ month: monthUsagePayload(ledger, monthStart),
3041
+ year: yearUsagePayload(ledger, Date.now()),
3042
+ }, meta)
3043
+ }
3044
+
3045
+ /**
3046
+ * 当月活跃度日历:从「1 号所在那一周的周一」排到「月末所在那一周的周日」。
3047
+ * 跨月的头尾几天照样带上(周底条要统计整周,不能只看当月),用 inMonth 标出归属;
3048
+ * 「月内最高的一天」(要标紫的那天)只在当月天里评,避免被上月的量抢走。
3049
+ */
3050
+ function monthUsagePayload(ledger, monthStart) {
3051
+ const BEIJING = 8 * 3600 * 1000
3052
+ const DAY = 24 * 3600 * 1000
3053
+ const first = new Date(monthStart + BEIJING)
3054
+ const year = first.getUTCFullYear()
3055
+ const month = first.getUTCMonth() + 1
3056
+ const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate()
3057
+ const firstWeekday = (first.getUTCDay() + 6) % 7 /* 周一=0 */
3058
+ const gridStart = monthStart - firstWeekday * DAY
3059
+ const monthEnd = monthStart + (daysInMonth - 1) * DAY
3060
+ const lastWeekday = (new Date(monthEnd + BEIJING).getUTCDay() + 6) % 7
3061
+ const gridDays = daysInMonth + firstWeekday + (6 - lastWeekday)
3062
+ const days = []
3063
+ let max = 0
3064
+ let maxDate = null
3065
+ for (let i = 0; i < gridDays; i += 1) {
3066
+ const at = gridStart + i * DAY
3067
+ const key = localDateKey(at)
3068
+ const inMonth = at >= monthStart && at <= monthEnd
3069
+ const bucket = ledger.days[key]
3070
+ /* 三段明细一并带上:客户端悬停每天的方块时要显示命中 / 未命中 / 输出 */
3071
+ const input = bucket === undefined ? 0 : bucket.input || 0
3072
+ const cacheRead = bucket === undefined ? 0 : bucket.cacheRead || 0
3073
+ const cacheWrite = bucket === undefined ? 0 : bucket.cacheWrite || 0
3074
+ const output = bucket === undefined ? 0 : bucket.output || 0
3075
+ const total = input + cacheRead + cacheWrite + output
3076
+ const calls = bucket === undefined ? 0 : bucket.calls || 0
3077
+ const rawCost = bucket === undefined ? 0 : bucket.cost
3078
+ const cost = typeof rawCost === 'number' && Number.isFinite(rawCost) ? rawCost : 0
3079
+ days.push({
3080
+ date: key,
3081
+ inMonth,
3082
+ total,
3083
+ calls,
3084
+ cost: Math.round(cost * 10000) / 10000,
3085
+ input,
3086
+ cacheRead,
3087
+ cacheWrite,
3088
+ output,
3089
+ })
3090
+ if (inMonth && total > max) {
3091
+ max = total
3092
+ maxDate = key
3093
+ }
3094
+ }
3095
+ return { year, month, max, maxDate, days }
3096
+ }
3097
+
3098
+ /**
3099
+ * 当年的 12 个月聚合:每月的三段 token、调用次数与花费。
3100
+ * 客户端拿它画「活跃总览」标题下面那 12 个方块(配色规则与右侧月历一致)。
3101
+ */
3102
+ function yearUsagePayload(ledger, now) {
3103
+ const BEIJING = 8 * 3600 * 1000
3104
+ const year = new Date(now + BEIJING).getUTCFullYear()
3105
+ const months = []
3106
+ let max = 0
3107
+ let maxMonth = null
3108
+ for (let m = 1; m <= 12; m += 1) {
3109
+ const daysInMonth = new Date(Date.UTC(year, m, 0)).getUTCDate()
3110
+ let input = 0
3111
+ let cacheRead = 0
3112
+ let cacheWrite = 0
3113
+ let output = 0
3114
+ let calls = 0
3115
+ let cost = 0
3116
+ for (let d = 1; d <= daysInMonth; d += 1) {
3117
+ const bucket = ledger.days[localDateKey(Date.UTC(year, m - 1, d) - BEIJING)]
3118
+ if (bucket === undefined) continue
3119
+ input += bucket.input || 0
3120
+ cacheRead += bucket.cacheRead || 0
3121
+ cacheWrite += bucket.cacheWrite || 0
3122
+ output += bucket.output || 0
3123
+ calls += bucket.calls || 0
3124
+ const raw = bucket.cost
3125
+ cost += typeof raw === 'number' && Number.isFinite(raw) ? raw : 0
3126
+ }
3127
+ const total = input + cacheRead + cacheWrite + output
3128
+ months.push({
3129
+ month: m,
3130
+ total,
3131
+ calls,
3132
+ cost: Math.round(cost * 10000) / 10000,
3133
+ input,
3134
+ cacheRead,
3135
+ cacheWrite,
3136
+ output,
3137
+ })
3138
+ if (total > max) {
3139
+ max = total
3140
+ maxMonth = m
3141
+ }
3142
+ }
3143
+ return { year, max, maxMonth, months }
3144
+ }
3145
+
3146
+ /**
3147
+ * 气泡定位诊断:客户端把「模式 / 基准 / 结果」回传到这里,落到
3148
+ * `<DSH_HOME>/dsh-status-bar/tip-diag.log`。用途是在没有控制台的环境下核对气泡为什么越界。
3149
+ * 只收几个数字与短字符串,不写任何会话内容;超过 64KB 就重开,避免无限增长。
3150
+ */
3151
+ function appendTipDiag(body) {
3152
+ if (body === null || body === undefined || typeof body !== 'object') return
3153
+ const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : null)
3154
+ const text = (value) => (typeof value === 'string' ? value.slice(0, 40) : null)
3155
+ const line = JSON.stringify({
3156
+ at: Date.now(),
3157
+ tip: text(body.tip),
3158
+ mode: text(body.mode),
3159
+ left: num(body.left),
3160
+ ring: num(body.ring),
3161
+ ringFound: body.ringFound === true,
3162
+ anchor: num(body.anchor),
3163
+ bar: num(body.bar),
3164
+ width: num(body.width),
3165
+ viewport: num(body.viewport),
3166
+ }) + '\n'
3167
+ try {
3168
+ const dir = join(dshHome(), 'dsh-status-bar')
3169
+ const file = join(dir, 'tip-diag.log')
3170
+ let size = 0
3171
+ try {
3172
+ size = statSync(file).size
3173
+ } catch (error) {
3174
+ size = 0
3175
+ }
3176
+ mkdirSync(dir, { recursive: true })
3177
+ if (size > 65536) writeFileSync(file, line, 'utf8')
3178
+ else appendFileSync(file, line, 'utf8')
3179
+ } catch (error) {
3180
+ /* 落盘失败不影响功能 */
3181
+ }
3182
+ }
3183
+
3184
+ /* 台账只在「有新数据」时才重扫(文件 mtime+size 没变就跳过),因此这层只是省掉重复的 stat。
3185
+ 不挂定时器:打开活跃总览面板时会带 force=1 现扫一次,数据只在被看的时候才需要最新。 */
3186
+ const WEEK_USAGE_CACHE_MS = 30000
3187
+ let weekUsageCache = { at: 0, value: null }
3188
+ /* 扫描可能被并发触发:复用同一次进行中的扫描。 */
3189
+ let weekUsageScan = null
3190
+
3191
+ /* 台账是"读进来 → 改 → 写回去"。扫描与补算(价格上报)可能同时发生,
3192
+ 串成一条队列逐个跑,免得两边各读到同一份旧内容、后写的把先写的盖掉。 */
3193
+ let ledgerQueue = Promise.resolve()
3194
+
3195
+ function queueLedgerJob(job) {
3196
+ const run = ledgerQueue.then(job, job)
3197
+ ledgerQueue = run.then(() => undefined, () => undefined)
3198
+ return run
3199
+ }
3200
+
3201
+ async function refreshWeekUsage(force, ctx) {
3202
+ const now = Date.now()
3203
+ const rangeStart = recentStartMs(now)
3204
+ const monthStart = monthStartMs(now)
3205
+ const yearStart = yearStartMs(now)
3206
+ /* 扫描与保留窗口取「近 7 天起点」「本月 1 号」「当年 1 月 1 日」里最早的那个:
3207
+ 柱要看近 7 天、活跃度要整月、12 个月统计要整年。
3208
+ 台账一旦记过就留着(保留期已放宽),所以全量解压只在首次或很久没开时发生。 */
3209
+ const windowStart = Math.min(rangeStart, monthStart, yearStart)
3210
+ const note = (message) => { if (ctx !== undefined && ctx !== null) logHost(ctx, message) }
3211
+ if (force !== true && weekUsageCache.value !== null && now - weekUsageCache.at < WEEK_USAGE_CACHE_MS) {
3212
+ return weekUsageCache.value
3213
+ }
3214
+ if (weekUsageScan === null) {
3215
+ const run = queueLedgerJob(async () => {
3216
+ const ledger = readLedger()
3217
+ /* 升级后第一次扫描:回扫一遍日志,把"当时缺价、没算钱"的用量找出来。
3218
+ 只补欠账清单,历史 token 与 cost 一个都不动。 */
3219
+ let backfilled = null
3220
+ if (ledger.unpricedBackfill === 'pending') {
3221
+ backfilled = await backfillUnpriced(ledger)
3222
+ ledger.unpricedBackfill = 'done'
3223
+ note('usage-ledger: 回扫补全缺价明细,处理了 ' + String(backfilled.scanned) + ' 个日志文件')
3224
+ }
3225
+ /* 价格补上来了就把欠账结算掉(只碰欠账,不重算已经算好的费用) */
3226
+ const settled = settleUnpriced(ledger)
3227
+ if (settled.entries > 0) {
3228
+ note('usage-ledger: 结算欠账 ' + String(settled.entries) + ' 项,补记 ¥' + String(settled.amount))
3229
+ }
3230
+ ledger.priceFingerprint = priceFingerprint()
3231
+ const stats = await collectIntoLedger(ledger, windowStart)
3232
+ pruneLedger(ledger)
3233
+ if (stats.added > 0 || stats.scannedFiles > 0 || ledger.updatedAt === 0
3234
+ || settled.entries > 0 || backfilled !== null) {
3235
+ ledger.updatedAt = Date.now()
3236
+ writeLedger(ledger)
3237
+ }
3238
+ return { ledger, stats }
3239
+ })
3240
+ weekUsageScan = run
3241
+ const clear = () => { if (weekUsageScan === run) weekUsageScan = null }
3242
+ void run.then(clear, clear)
3243
+ }
3244
+ const result = await weekUsageScan
3245
+ const value = weekUsagePayload(result.ledger, rangeStart, monthStart, {
3246
+ files: result.stats.files,
3247
+ scannedFiles: result.stats.scannedFiles,
3248
+ skippedFiles: result.stats.skippedFiles,
3249
+ added: result.stats.added,
3250
+ zstd: typeof zlib.zstdDecompressSync === 'function',
3251
+ })
3252
+ weekUsageCache = { at: Date.now(), value }
3253
+ return value
3254
+ }
3255
+
3256
+ /* ------------------------------------------------------------ 节假日抓取 */
3257
+
3258
+ const GOV_SEARCH_URL = 'https://sousuo.www.gov.cn/search-gov/data'
3259
+ const GOV_TITLE_PREFIX = '国务院办公厅关于'
3260
+ const HOLIDAY_FETCH_TIMEOUT_MS = 15000
3261
+ const BROWSER_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
3262
+
3263
+ /**
3264
+ * 抓「国务院办公厅关于<year>年部分节假日安排的通知」并解析出放假日。
3265
+ * 两步:先走政府网政策文件库的搜索接口拿通知页 URL,再取正文解析(解析在 lib/holiday.js)。
3266
+ * 未发布、HTTP 失败、解析为空都返回 { ok: false, reason },交给客户端提示后重试。
3267
+ */
3268
+ async function fetchHolidayDays(year) {
3269
+ const title = GOV_TITLE_PREFIX + String(year) + '年部分节假日安排的通知'
3270
+ const search = new URL(GOV_SEARCH_URL)
3271
+ search.searchParams.set('t', 'zhengcelibrary_gw')
3272
+ search.searchParams.set('q', title)
3273
+ search.searchParams.set('searchfield', 'title')
3274
+ search.searchParams.set('p', '1')
3275
+ search.searchParams.set('n', '5')
3276
+ search.searchParams.set('sort', 'score')
3277
+ search.searchParams.set('sortType', '1')
3278
+ const headers = { 'user-agent': BROWSER_UA }
3279
+ /* 每次请求各自设超时:AbortSignal 不能复用 */
3280
+ const timeout = () => AbortSignal.timeout(HOLIDAY_FETCH_TIMEOUT_MS)
3281
+
3282
+ const found = await fetch(search.toString(), { headers, signal: timeout() })
3283
+ if (found.ok !== true) return { ok: false, reason: 'search-http-' + String(found.status) }
3284
+ const payload = await found.json()
3285
+ const list = payload !== null && payload !== undefined && payload.searchVO !== null && payload.searchVO !== undefined
3286
+ && Array.isArray(payload.searchVO.listVO)
3287
+ ? payload.searchVO.listVO
3288
+ : []
3289
+ /* 搜索是按相关度排的,标题里必须正好有这一年的年份,否则宁可当没搜到。
3290
+ 标题可能带 <em> 高亮标签,比较前先剥掉。 */
3291
+ const wanted = String(year) + '年部分节假日安排'
3292
+ const plainTitle = (item) => String(item.title).replace(/<[^>]+>/g, '')
3293
+ const hit = list.filter((item) => item !== null && typeof item === 'object'
3294
+ && typeof item.title === 'string' && plainTitle(item).indexOf(wanted) !== -1
3295
+ && typeof item.url === 'string')[0]
3296
+ if (hit === undefined) return { ok: false, reason: 'not-found' }
3297
+
3298
+ const page = await fetch(hit.url, { headers, signal: timeout() })
3299
+ if (page.ok !== true) return { ok: false, reason: 'page-http-' + String(page.status) }
3300
+ const days = parseHolidayNotice(holidayHtmlToText(await page.text()), year)
3301
+ if (days.length === 0) return { ok: false, reason: 'parse-empty' }
3302
+ return { ok: true, year, days, title: plainTitle(hit), source: hit.url }
3303
+ }
3304
+
3305
+ /* --------------------------------------------------------------- 版本自检 */
3306
+
3307
+ /** registry 的最新版条目地址:只读它的 version 字段,不下载包、不写任何文件。 */
3308
+ const REGISTRY_LATEST_URL = 'https://registry.npmjs.org/dsh-desktop-statusbar/latest'
3309
+ const VERSION_CHECK_TIMEOUT_MS = 15000
3310
+
3311
+ /** a 比 b 新返回正数。按数字段比(2.10.0 > 2.9.0);带预发布后缀的排在同号正式版之前。 */
3312
+ function compareVersions(a, b) {
3313
+ const parts = (value) => String(value).split('-')[0].split('.').map((one) => Number(one) || 0)
3314
+ const left = parts(a)
3315
+ const right = parts(b)
3316
+ for (let i = 0; i < 3; i += 1) {
3317
+ const one = left[i] === undefined ? 0 : left[i]
3318
+ const other = right[i] === undefined ? 0 : right[i]
3319
+ if (one !== other) return one - other
3320
+ }
3321
+ const leftPre = String(a).indexOf('-') >= 0
3322
+ const rightPre = String(b).indexOf('-') >= 0
3323
+ if (leftPre === rightPre) return 0
3324
+ return leftPre ? -1 : 1
3325
+ }
3326
+
3327
+ /* 启动自检一次的结果。查不到(离线、被墙、包没发过)时 latest 为 null,界面什么都不显示。 */
3328
+ let latestVersion = { checkedAt: 0, latest: null, error: null }
3329
+ let versionCheck = null
3330
+
3331
+ async function checkLatestVersion() {
3332
+ try {
3333
+ const response = await fetch(REGISTRY_LATEST_URL, {
3334
+ headers: { 'user-agent': BROWSER_UA },
3335
+ signal: AbortSignal.timeout(VERSION_CHECK_TIMEOUT_MS),
3336
+ })
3337
+ if (response.ok !== true) {
3338
+ return { checkedAt: Date.now(), latest: null, error: 'http-' + String(response.status) }
3339
+ }
3340
+ const payload = await response.json()
3341
+ const latest = payload !== null && typeof payload === 'object' && typeof payload.version === 'string'
3342
+ ? payload.version
3343
+ : null
3344
+ return { checkedAt: Date.now(), latest, error: latest === null ? 'no-version' : null }
3345
+ } catch (error) {
3346
+ return { checkedAt: Date.now(), latest: null, error: 'fetch-failed' }
3347
+ }
3348
+ }
3349
+
3350
+ /* ------------------------------------------------------------------ apply */
3351
+
3352
+ export function apply(ctx) {
3353
+ ctxRef = ctx
3354
+ ctx.effect(
3355
+ () => ctx.sessionProjections.register(sessionModelProjection),
3356
+ 'dsh-desktop-statusbar: sessionModel projection',
3357
+ )
3358
+ ctx.effect(
3359
+ () => ctx.sessionProjections.register(sessionCapacityProjection),
3360
+ 'dsh-desktop-statusbar: sessionCapacity projection',
3361
+ )
3362
+ ctx.effect(
3363
+ () => ctx.sessionProjections.register(sessionProgressProjection),
3364
+ 'dsh-desktop-statusbar: sessionProgress projection',
3365
+ )
3366
+ ctx.effect(
3367
+ () => ctx.sessionProjections.register(sessionUsageProjection),
3368
+ 'dsh-desktop-statusbar: sessionUsage projection',
3369
+ )
3370
+ ctx.effect(
3371
+ () => ctx.sessionProjections.register(sessionTimeRangeProjection),
3372
+ 'dsh-desktop-statusbar: sessionTimeRange projection',
3373
+ )
3374
+ ctx.effect(
3375
+ () => ctx.sessionProjections.register(sessionTimingProjection),
3376
+ 'dsh-desktop-statusbar: sessionTiming projection',
3377
+ )
3378
+
3379
+ /* 会话日志一旦被删就没了,台账是唯一留得住的地方:启动时先并一次作为预热,
3380
+ 之后只在用户打开活跃总览面板时(带 force=1)现扫。 */
3381
+ ctx.effect(() => {
3382
+ void refreshWeekUsage(false, ctx).catch(() => {})
3383
+ }, 'dsh-desktop-statusbar: usage ledger')
3384
+
3385
+ /* 启动自检一次:只问 registry 最新版号是多少(不下载包)。结果给 /api/version 用,
3386
+ 决定导航项后面那颗「发现新版本」胶囊显不显示。查不到就安静地什么都不显示。 */
3387
+ ctx.effect(() => {
3388
+ versionCheck = checkLatestVersion().then((result) => {
3389
+ latestVersion = result
3390
+ logHost(ctxRef, result.latest === null
3391
+ ? 'version: 自检没拿到结果(' + String(result.error) + ')'
3392
+ : 'version: registry 最新版 ' + String(result.latest))
3393
+ return result
3394
+ })
3395
+ }, 'dsh-desktop-statusbar: version self-check')
3396
+
3397
+ ctx.effect(() => ctx.webServer.register({
3398
+ kind: 'prefix',
3399
+ path: '/dsh-desktop-statusbar/api',
3400
+ handler: async (req, res) => {
3401
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
3402
+ if (url.pathname === '/dsh-desktop-statusbar/api/prices') {
3403
+ /* POST = 客户端把它的价格库(localStorage 那份,用户可增删改)报上来。
3404
+ 活跃总览的 cost 由 host 记账时算出,必须用同一份价格,否则用户在设置页
3405
+ 加/改的单价只对底栏生效、活跃总览恒为旧值(实测 2026-10-07)。 */
3406
+ if (req.method === 'POST') {
3407
+ const body = await readJsonBody(req)
3408
+ const book = body === null ? null : sanitizePriceBook(body.models)
3409
+ if (book === null) {
3410
+ sendJson(res, 400, { ok: false, reason: 'bad-models' })
3411
+ return
3412
+ }
3413
+ /* 判断"价格是否变了"用的快照在队列里取(见下):并排上报不会双双报"已变更" */
3414
+ /* **在队列里换价格库**:换库必须与"读台账→结算→写盘"是同一段原子操作,
3415
+ 否则在途扫描会一半用旧价、一半用新价,同一天的数字混两套价(实测缺陷)。 */
3416
+ let settled = { entries: 0, amount: 0 }
3417
+ let changed = false
3418
+ const swapped = await queueLedgerJob(async () => {
3419
+ const before = activePriceFingerprint
3420
+ clientPrices = book
3421
+ const nowFingerprint = priceFingerprint()
3422
+ activePriceFingerprint = nowFingerprint
3423
+ const nowChanged = before !== nowFingerprint
3424
+ /* 价格变了只做一件事:把"当时没有单价、还没算钱"的欠账按新价结算掉。
3425
+ 已经算好的费用一律不动,台账也不重建 —— 所以会话日志被删也不影响已有数字。 */
3426
+ if (nowChanged !== true) return { changed: false, settled: { entries: 0, amount: 0 } }
3427
+ const ledger = readLedger()
3428
+ const done = settleUnpriced(ledger)
3429
+ /* 两种情况才落盘:真结算了欠账;或者盘上还是老版本,顺手把迁移结果固化一次
3430
+ (免得每次都重迁一遍)。没有台账时不在这里创建。
3431
+ **绝不改写 priceFingerprint** —— 那个字段记的是"上次记账用的那份价目表",
3432
+ 是回扫判断"当时有没有价"的唯一依据;价格上报抢先改写它,历史缺价明细就永远补不出来。 */
3433
+ const onDisk = readLedgerFile(ledgerPath())
3434
+ const stale = onDisk !== null && onDisk.version !== LEDGER_VERSION
3435
+ if (done.entries > 0 || stale) {
3436
+ ledger.updatedAt = Date.now()
3437
+ writeLedger(ledger)
3438
+ }
3439
+ return { changed: true, settled: done }
3440
+ })
3441
+ changed = swapped.changed
3442
+ settled = swapped.settled
3443
+ if (changed) {
3444
+ /* 面板的数字要重新取一次(payload 缓存在上面) */
3445
+ weekUsageCache = { at: 0, value: null }
3446
+ weekUsageScan = null
3447
+ }
3448
+ logHost(ctx, 'prices: 收到客户端价格库 ' + String(Object.keys(book).length) + ' 条'
3449
+ + (changed
3450
+ ? '(已变更,结算欠账 ' + String(settled.entries) + ' 项 ¥' + String(settled.amount) + ')'
3451
+ : '(与当前一致)'))
3452
+ sendJson(res, 200, { ok: true, changed: changed, count: Object.keys(book).length, settled: settled })
3453
+ return
3454
+ }
3455
+ /* GET = 官方参考价(价格库为空时客户端拿它自愈),与用户价格库无关 */
3456
+ sendJson(res, 200, {
3457
+ models: {
3458
+ 'deepseek-flash': TOKEN_PRICES.peak['deepseek-flash'] === undefined ? null : {
3459
+ peak: TOKEN_PRICES.peak['deepseek-flash'],
3460
+ offPeak: TOKEN_PRICES.offPeak['deepseek-flash'],
3461
+ },
3462
+ 'deepseek-v4-pro': {
3463
+ peak: TOKEN_PRICES.peak['deepseek-v4-pro'],
3464
+ offPeak: TOKEN_PRICES.offPeak['deepseek-v4-pro'],
3465
+ },
3466
+ },
3467
+ note: 'CNY per 1M tokens, 高峰=北京时间周一至周五 9-12、14-18(法定节假日与周末全天按空闲价)',
3468
+ })
3469
+ return
977
3470
  }
978
3471
  if (url.pathname === '/dsh-desktop-statusbar/api/holidays') {
979
3472
  const raw = url.searchParams.get('year')
@@ -984,7 +3477,12 @@ export function apply(ctx) {
984
3477
  }
985
3478
  try {
986
3479
  const result = await fetchHolidayDays(year)
987
- if (result.ok === true) logHost(ctxRef, 'holidays: ' + String(year) + ' 年 ' + String(result.days.length) + ' 天')
3480
+ if (result.ok === true) {
3481
+ /* 落盘:host 自己也要用这份假期表判峰谷,否则 2027 年起底栏与活跃总览口径不一致 */
3482
+ const merged = saveHolidayDays(result.days)
3483
+ logHost(ctxRef, 'holidays: ' + String(year) + ' 年 ' + String(result.days.length)
3484
+ + ' 天(累计 ' + String(merged.length) + ' 天,已落盘)')
3485
+ }
988
3486
  sendJson(res, 200, result)
989
3487
  } catch (error) {
990
3488
  const detail = error !== null && error !== undefined && typeof error.message === 'string' ? error.message : 'fetch-failed'
@@ -993,6 +3491,29 @@ export function apply(ctx) {
993
3491
  }
994
3492
  return
995
3493
  }
3494
+ if (url.pathname === '/dsh-desktop-statusbar/api/version') {
3495
+ /* 启动自检可能还没回来:等这一次在途的查询,别让客户端误判成「没有新版」 */
3496
+ if (latestVersion.checkedAt === 0 && versionCheck !== null) {
3497
+ try {
3498
+ latestVersion = await versionCheck
3499
+ } catch (error) {
3500
+ /* 保持 latest 为 null,下面按「查不到」处理 */
3501
+ }
3502
+ }
3503
+ const raw = url.searchParams.get('current')
3504
+ const current = raw === null ? '' : raw.trim()
3505
+ sendJson(res, 200, {
3506
+ ok: latestVersion.latest !== null,
3507
+ current,
3508
+ latest: latestVersion.latest,
3509
+ /* 只有确实拿到版本号、且它比本地这个新,才算「有更新」 */
3510
+ hasUpdate: latestVersion.latest !== null && current.length > 0
3511
+ && compareVersions(latestVersion.latest, current) > 0,
3512
+ checkedAt: latestVersion.checkedAt,
3513
+ error: latestVersion.error,
3514
+ })
3515
+ return
3516
+ }
996
3517
  if (url.pathname === '/dsh-desktop-statusbar/api/active-model') {
997
3518
  if (req.method === 'POST') {
998
3519
  const body = await readJsonBody(req)
@@ -1017,6 +3538,55 @@ export function apply(ctx) {
1017
3538
  })
1018
3539
  return
1019
3540
  }
3541
+ if (url.pathname === '/dsh-desktop-statusbar/api/subagent-usage') {
3542
+ /* 底栏「总计」和「本轮」的队友部分:子智能体是独立会话,它的用量只能这样按 parentSession
3543
+ 找回来。`since` 是可选的窗口起点(毫秒):客户端拿"本轮起点"传进来,「本轮」就只算
3544
+ 本轮里队友花了多少;不传就是整会话(「总计」用)。
3545
+ 参数不对(客户端拿不到会话 id / 传了别的东西)给 400;会话没有队友、或队友日志
3546
+ 全都解不出来时给零值而不是 404 —— 客户端按"加上 0"处理,界面不该因此报错。 */
3547
+ const raw = url.searchParams.get('sessionId')
3548
+ const sessionId = raw === null ? '' : raw.trim()
3549
+ if (sessionId.length === 0 || sessionId.length > 200) {
3550
+ sendJson(res, 400, { ok: false, reason: 'bad-session' })
3551
+ return
3552
+ }
3553
+ /* 非法/缺失的 since 一律当"没传":空串、非有限数、负数、以及**未来时刻**。
3554
+ 本轮起点必然在过去,未来时刻只可能是客户端传了坏值;真按它过滤会把队友的
3555
+ 数字静默清零(少算钱),所以宁可忽略。留 5 分钟余量容忍时钟抖动。
3556
+ "当没传"的新语义(task-18 起):由 host 读父会话日志代推起点;连父会话都没有
3557
+ 就本轮为空(`sinceSource='none'`),**不**退回整会话 —— 要整会话就显式传 `since=0`。 */
3558
+ const rawSince = url.searchParams.get('since')
3559
+ const sinceText = rawSince === null ? '' : rawSince.trim()
3560
+ const parsedSince = sinceText.length === 0 ? NaN : Number(sinceText)
3561
+ const usableSince = Number.isFinite(parsedSince) === true && parsedSince >= 0
3562
+ && parsedSince <= Date.now() + 5 * 60 * 1000
3563
+ /* 这个接口只读日志、不排队:它不碰台账,没有与扫描抢写盘的问题 */
3564
+ let value
3565
+ try {
3566
+ value = await collectSubagentUsage(sessionId, usableSince === true ? parsedSince : undefined)
3567
+ } catch (error) {
3568
+ logHost(ctx, 'subagent-usage: 汇总失败 ' + probeDetail(error))
3569
+ value = { ok: false, reason: 'scan-failed' }
3570
+ }
3571
+ sendJson(res, 200, value)
3572
+ return
3573
+ }
3574
+ if (url.pathname === '/dsh-desktop-statusbar/api/daily-usage') {
3575
+ let value
3576
+ try {
3577
+ value = await refreshWeekUsage(url.searchParams.get('force') === '1', ctx)
3578
+ } catch (error) {
3579
+ logHost(ctx, 'usage: 台账刷新失败 ' + probeDetail(error))
3580
+ value = { ok: false, reason: 'ledger-failed' }
3581
+ }
3582
+ sendJson(res, 200, value)
3583
+ return
3584
+ }
3585
+ if (url.pathname === '/dsh-desktop-statusbar/api/tip-diag') {
3586
+ if (req.method === 'POST') appendTipDiag(await readJsonBody(req))
3587
+ sendJson(res, 200, { ok: true })
3588
+ return
3589
+ }
1020
3590
  if (url.pathname !== '/dsh-desktop-statusbar/api/balance') {
1021
3591
  sendJson(res, 404, { ok: false, reason: 'not-found' })
1022
3592
  return