dsh-desktop-statusbar 2.1.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -143,10 +143,29 @@ const sessionCapacityProjection = {
143
143
 
144
144
  /* -------------------------------------------------------- sessionProgress */
145
145
 
146
- /** 一次压缩落在哪一轮哪一步(压缩事件只带轮号,步号取压缩前最后一步;取不到给 null)。 */
146
+ /**
147
+ * 一次**成功**压缩落在哪一轮哪一步。
148
+ * turn 允许 null:用户手动 /compact 是「轮间压缩」(standalone),真实日志里 turn 就是 null
149
+ *(见 ~/.dsh/sessions 的 cmd-* 事务);自动压缩才带当前轮号。
150
+ * step 取压缩前最后一步;取不到(轮间压缩 / 轮首)给 null。
151
+ * afterTurn:只有轮间压缩才有值 —— 压缩发生时最后已知的轮号,客户端据此显示「第 N 轮后」,
152
+ * 不用再把 turn=null 显示成孤零零的横杠;自动压缩有自己的轮号,afterTurn 恒为 null。
153
+ * manual:用户手动(斜杠命令)触发;自动压缩没有 sourceCommandId。
154
+ * 成功/失败**只**看 compaction/end 的 data.error:带 error 是失败,不带就是成功。
155
+ * 不要去数 compaction/summary —— summary 缺失不能反过来否决一次没有 error 的 end
156
+ *(宿主 invariant 会把「成功 end 没有 summary」当成日志损坏拒绝,那是宿主的事,投影不承担)。
157
+ */
147
158
  const compactMark = z.object({
148
- turn: z.number(),
159
+ turn: z.number().nullable(),
149
160
  step: z.number().nullable(),
161
+ afterTurn: z.number().nullable(),
162
+ manual: z.boolean(),
163
+ }).strict()
164
+
165
+ /** 一次技能注入:名字 + 是不是用户手动(斜杠命令)触发。重复注入也各记一条,客户端自己聚合。 */
166
+ const skillMark = z.object({
167
+ name: z.string(),
168
+ manual: z.boolean(),
150
169
  }).strict()
151
170
 
152
171
  const sessionProgressSchema = z.object({
@@ -155,13 +174,14 @@ const sessionProgressSchema = z.object({
155
174
  toolCalls: z.number(),
156
175
  /* 工具名 → 调用次数:气泡里「工具调用」那一行点开要逐个列出来 */
157
176
  toolCounts: z.record(z.string(), z.number()),
158
- skills: z.array(z.string()),
177
+ /* 每次注入一条,按发生顺序;manual 区分斜杠命令注入与 skill 工具调用 */
178
+ skills: z.array(skillMark),
159
179
  /* 注入进会话的全局提示词文件名(~/.dsh/AGENTS.md → 'AGENTS.md');没注入给 null */
160
180
  globalInstruction: z.string().nullable(),
161
181
  compactCount: z.number(),
162
182
  compactTurn: z.number().nullable(),
163
183
  compactStep: z.number().nullable(),
164
- /* 每一次压缩的落点,按发生顺序排:气泡里「上下文压缩」那一行点开逐条列 */
184
+ /* 只列成功压缩,按发生顺序:气泡里「上下文压缩」那一行点开逐条列;失败的不进来 */
165
185
  compacts: z.array(compactMark),
166
186
  }).strict()
167
187
 
@@ -219,12 +239,18 @@ function globalInstructionOf(data) {
219
239
  * 所以在 host 侧按事件折一份:
220
240
  * step/start → 本轮走到第几步(轮号一变就归零,step 是轮内序号不是会话累计)
221
241
  * tool/call → 整个会话的工具调用次数 + 每个工具名各自的次数;
222
- * 工具名是 skill 的那种再把技能名按注入顺序记进 skills
242
+ * 工具名是 skill 的那种再把技能名(manual:false)按注入顺序记进 skills
223
243
  * user/message → 全局提示词(agent-instructions 注入的那种)的文件名;
224
- * 斜杠命令注入的技能(skill-invocation)也补进 skills
225
- * compaction/start → 压缩次数 + 这一次的落点;压缩事件只带轮号,
226
- * 压缩发生在哪一步取压缩前最后一条同轮 step/start
227
- * 这几种事件在真实日志里都齐(75 个会话:11208 次工具调用、22 个压缩事件)。
244
+ * 斜杠命令注入的技能(skill-invocation)以 manual:true 补进 skills
245
+ * compaction/end → 只认**成功**的压缩:判定只看 end 的 data.error(有 error=失败,无 error=成功)。
246
+ * 宿主语义(app.asar 里 dsh-compaction 的 invariant):成功时 end 不带 error,
247
+ * 且日志里会有一条 compaction/summary;失败(模型 400/429 等)只发 end + error、
248
+ * 没有 summary。**但不数 summary**:没 summary 的 end 也不能反过来判失败,
249
+ * 否则就成了「压缩后没有 summary 就算失败」这种没验证过的启发式。
250
+ * 轮号取 end.data.turn(用户手动 /compact 是轮间压缩,turn=null),
251
+ * 步号取压缩前最后一条同轮 step/start;手动触发看 end.data.sourceCommandId
252
+ * (start/summary/end 三个事件都带同一个命令 id,自动压缩没有)。
253
+ * 这几种事件在真实日志里都齐(62 个会话:17 次压缩事务,其中成功 4、失败 13,手动 11)。
228
254
  */
229
255
  const sessionProgressProjection = {
230
256
  key: 'desktopStatusbarProgress',
@@ -252,7 +278,8 @@ const sessionProgressProjection = {
252
278
  }
253
279
  if (event.type === 'user/message') {
254
280
  const skill = skillInvocationOf(event.data)
255
- if (skill !== null) return { ...state, skills: state.skills.concat([skill]) }
281
+ /* 用户输入 /技能名 走的就是这条:算手动触发 */
282
+ if (skill !== null) return { ...state, skills: state.skills.concat([{ name: skill, manual: true }]) }
256
283
  const name = globalInstructionOf(event.data)
257
284
  if (name === null || state.globalInstruction === name) return state
258
285
  return { ...state, globalInstruction: name }
@@ -269,29 +296,41 @@ const sessionProgressProjection = {
269
296
  ...state,
270
297
  toolCalls: state.toolCalls + 1,
271
298
  toolCounts: counts,
272
- /* 技能有两种注入来源:调 skill 工具(这里)与斜杠命令(user/message 分支);
273
- 名字按注入顺序排,重复注入也照记(数量 = 数组长度) */
274
- skills: skill === null ? state.skills : state.skills.concat([skill]),
299
+ /* 技能有两种注入来源:调 skill 工具(这里,自动/模型触发)与斜杠命令(user/message 分支,
300
+ 用户手动触发);名字按注入顺序排,重复注入也照记(数量 = 数组长度) */
301
+ skills: skill === null ? state.skills : state.skills.concat([{ name: skill, manual: false }]),
275
302
  }
276
303
  }
277
- if (event.type === 'compaction/start') {
304
+ if (event.type === 'compaction/end') {
278
305
  const data = event.data === undefined || event.data === null ? {} : event.data
279
- const turn = typeof data.turn === 'number' ? data.turn : state.turn
280
- /* 压缩前没有同轮的步(压缩落在轮首)时给 null,客户端那一格显示横杠 */
281
- const step = turn === state.turn && state.turnSteps > 0 ? state.turnSteps : null
306
+ /* 失败压缩不发 summary、end 带 error:不计次数、不落档,客户端只显示成功的那些 */
307
+ if (data.error !== undefined && data.error !== null) return state
308
+ /* 轮间压缩(用户手动 /compact)turn=null;自动压缩用事件自己的轮号 */
309
+ const hasTurn = typeof data.turn === 'number'
310
+ const turn = hasTurn ? data.turn : null
311
+ /* 轮间压缩没有轮号,但客户端要显示「第 N 轮后」:N 取压缩发生时最后已知的轮号。
312
+ state.turn 初值是 0(还没跑过任何轮),这时没有「第 N 轮后」可讲,给 null。 */
313
+ const afterTurn = hasTurn ? null : (typeof state.turn === 'number' && state.turn > 0 ? state.turn : null)
314
+ /* 步号取压缩前最后一条同轮 step/start:压缩事务中间不会插 step/start(真实日志里
315
+ 成功事务中间只有一条 compact-checkpoint 的 user/message),end 到达时 turnSteps 仍是压缩前步数 */
316
+ const step = turn !== null && turn === state.turn && state.turnSteps > 0 ? state.turnSteps : null
317
+ /* 手动 /compact 的三个压缩事件都带 sourceCommandId(命令 id),自动压缩没有 */
318
+ const manual = data.sourceCommandId !== undefined && data.sourceCommandId !== null
282
319
  return {
283
320
  ...state,
284
321
  compactCount: state.compactCount + 1,
285
322
  compactTurn: turn,
286
323
  compactStep: step,
287
- compacts: state.compacts.concat([{ turn: turn, step: step }]),
324
+ compacts: state.compacts.concat([{ turn: turn, step: step, afterTurn: afterTurn, manual: manual }]),
288
325
  }
289
326
  }
290
327
  return state
291
328
  },
292
329
  wire: { viewSchema: sessionProgressSchema, view: (state) => state },
293
- // 字段变了(加 globalInstruction、技能多一条斜杠命令来源),提升版本重建旧状态
294
- stateVersion: 6,
330
+ // 字段变了(skills 变成 {name, manual}、compacts 加 manual 且只记成功、压缩 turn 可为 null),提升版本重建旧状态
331
+ // 7 → 8:compacts 新增 afterTurn(轮间/手动压缩显示「第 N 轮后」,替代原来的横杠)。
332
+ // 旧缓存里每条压缩记录没有这个字段,必须重放才能补出来。
333
+ stateVersion: 8,
295
334
  }
296
335
 
297
336
  /* ------------------------------------------------------------ sessionUsage */
@@ -328,8 +367,11 @@ function findUsageInStream(stream) {
328
367
  }
329
368
 
330
369
  function usageOf(event) {
331
- if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') return undefined
370
+ if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt' && event.type !== 'compaction/summary') return undefined
332
371
  const data = event.data === null || event.data === undefined ? {} : event.data
372
+ /* compaction/summary 是压缩引擎自己发起的一次摘要调用:data.usage 就是它的用量(没有 stream)。
373
+ 真实会话里这 4 笔摘要都是带用量的,漏掉会让「本轮/总计费用」都比实际少。 */
374
+ if (event.type === 'compaction/summary') return data.usage
333
375
  let usage = event.type === 'assistant/message' ? data.usage : undefined
334
376
  const fromStream = findUsageInStream(data.stream)
335
377
  if (fromStream !== undefined) usage = fromStream
@@ -441,6 +483,38 @@ function addOverflow(overflow, model, parts, peak) {
441
483
  }
442
484
  }
443
485
 
486
+ /**
487
+ * 把一笔调用并进总计:models 按模型累加、calls 追加,
488
+ * 超过 MAX_CALLS 被挤掉的最老几条按「模型 + 峰谷档」补进 overflow。
489
+ * 正常 assistant 调用与 compaction/summary 两处共用一份实现 —— 两条路各写一遍迟早对不齐口径。
490
+ */
491
+ function addCallTotals(base, one) {
492
+ const calls = Array.isArray(base.calls) ? base.calls : []
493
+ const prevModel = base.models[one.model]
494
+ const bucket = {
495
+ input: (prevModel === undefined ? 0 : prevModel.input) + one.input,
496
+ cacheRead: (prevModel === undefined ? 0 : prevModel.cacheRead) + one.cacheRead,
497
+ cacheWrite: (prevModel === undefined ? 0 : prevModel.cacheWrite) + one.cacheWrite,
498
+ output: (prevModel === undefined ? 0 : prevModel.output) + one.output,
499
+ }
500
+ /* 追加后若超过 MAX_CALLS,最前面那几条会被丢掉。丢掉的是**真实用过的 token**,
501
+ 必须把它们按档累计进 overflow,否则客户端逐条计价时会少算(见 addOverflow 说明)。
502
+ 用合并前后的长度差算出丢了几条 —— 它们一定是最老的,也就是 calls 队首那几条。 */
503
+ const merged = appendCalls(calls, [one])
504
+ const droppedCount = calls.length + 1 - merged.length
505
+ let overflow = typeof base.overflow === 'object' && base.overflow !== null ? base.overflow : {}
506
+ for (let i = 0; i < droppedCount; i += 1) {
507
+ const gone = calls[i]
508
+ if (gone === undefined) break
509
+ overflow = addOverflow(overflow, gone.model, gone, isPeakTime(gone.at))
510
+ }
511
+ return {
512
+ calls: merged,
513
+ overflow,
514
+ models: { ...base.models, [one.model]: bucket },
515
+ }
516
+ }
517
+
444
518
  const sessionUsageSchema = z.object({
445
519
  models: z.record(z.string(), usageBucket),
446
520
  calls: z.array(usageCall),
@@ -453,12 +527,27 @@ const sessionUsageSchema = z.object({
453
527
  可空且可选 —— 万一某条路径没带上这两个键,也不至于让整个 state 过不了校验。 */
454
528
  routeModel: z.string().nullable().optional(),
455
529
  routeProvider: z.string().nullable().optional(),
530
+ /* compactionId → 该次压缩的轮号与开始时刻。compaction/summary 没有 turn,只能靠 start 记下的映射
531
+ 才能判断这笔摘要属于哪一轮;at 用来圈定「防双计」的时间窗。老缓存没有这个键,所以可选。 */
532
+ compactions: z.record(z.string(), z.object({
533
+ turn: z.number().nullable(),
534
+ at: z.number(),
535
+ }).strict()).optional(),
536
+ /* 已经计过费的 compactionId:同一笔摘要只计一次。老缓存没有这个键,所以可选。 */
537
+ countedCompactions: z.array(z.string()).optional(),
538
+ /* 压缩摘要**累计**的 token 四段(只含摘要、不含普通调用)。
539
+ 为什么需要它:「总计用量」那一栏的数据来自官方 tokenUsage 投影,而官方只折
540
+ assistant 事件、**不含压缩摘要**;「总计费用」是我们自己逐条算的、**含摘要**。
541
+ 同一面板里两个数字口径不一致(费用算进去了、用量没算)。用户拍板「官方投影继续用,
542
+ 只把摘要这部分额外加上」—— 这份累计就是那个「额外加上」的部分。
543
+ 老缓存没有这个键,所以可选。 */
544
+ summaryParts: usageBucket.optional(),
456
545
  }).strict()
457
546
 
458
547
  const sessionUsageProjection = {
459
548
  key: 'desktopStatusbarUsage',
460
549
  stateSchema: sessionUsageSchema,
461
- init: () => ({ models: {}, calls: [], overflow: {}, current: null, last: null, turn: null, routeModel: null, routeProvider: null }),
550
+ init: () => ({ models: {}, calls: [], overflow: {}, current: null, last: null, turn: null, routeModel: null, routeProvider: null, compactions: {}, countedCompactions: [], summaryParts: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 } }),
462
551
  apply: (state, event) => {
463
552
  const calls = Array.isArray(state.calls) ? state.calls : []
464
553
  const turnOf = (event) => {
@@ -472,6 +561,20 @@ const sessionUsageProjection = {
472
561
  if (state.current === null) return { ...state, turn: started }
473
562
  return { ...state, calls, current: null, last: state.current, turn: started }
474
563
  }
564
+ /* 压缩事务开始时先记下 compactionId → 轮号:compaction/summary 事件本身没有 turn,
565
+ 只能靠这条映射判断这笔摘要费用算不算「本轮」。turn 允许 null(轮间/手动压缩)。 */
566
+ if (event.type === 'compaction/start') {
567
+ const data = event.data === null || event.data === undefined ? {} : event.data
568
+ if (typeof data.compactionId !== 'string' || data.compactionId.length === 0) return state
569
+ const compactions = typeof state.compactions === 'object' && state.compactions !== null ? state.compactions : {}
570
+ return {
571
+ ...state,
572
+ compactions: {
573
+ ...compactions,
574
+ [data.compactionId]: { turn: typeof data.turn === 'number' ? data.turn : null, at: event.time },
575
+ },
576
+ }
577
+ }
475
578
  // 先记下"这次请求用的是哪个模型":没有 message 的事件(assistant/attempt)靠它补型号
476
579
  const routed = routedModelOf(event)
477
580
  const base = routed === null ? state : {
@@ -482,23 +585,24 @@ const sessionUsageProjection = {
482
585
  const usage = usageOf(event)
483
586
  if (usage === undefined || usage === null) return base
484
587
  // 型号两条路:常规事件从 message.source 取;没有 message 的用上面记下的最近一次请求
485
- const message = event.data === null || event.data === undefined ? null : event.data.message
588
+ const data = event.data === null || event.data === undefined ? {} : event.data
589
+ const message = data.message
486
590
  const source = message === null || message === undefined ? null : message.source
487
591
  const fromMessage = source !== null && source.kind === 'model' && typeof source.model === 'string'
488
- const model = fromMessage ? source.model : (typeof base.routeModel === 'string' ? base.routeModel : '')
489
- // 两条路都拿不到型号:放弃这一笔,好过抛错把整条投影(以及同一次驱动的其它投影)带停
592
+ /* 摘要调用是压缩引擎自己发起的(没有 message),型号只认 summary 自己的 data.model;
593
+ 不能退回 routeModel —— 那是用户上一轮对话的模型,摘要往往跑在另一个模型上。 */
594
+ const isCompactionSummary = event.type === 'compaction/summary'
595
+ const model = isCompactionSummary
596
+ ? (typeof data.model === 'string' && data.model.length > 0 ? data.model : '')
597
+ : (fromMessage ? source.model : (typeof base.routeModel === 'string' ? base.routeModel : ''))
598
+ // 拿不到型号:放弃这一笔,好过抛错把整条投影(以及同一次驱动的其它投影)带停
490
599
  if (model.length === 0) return base
491
- const provider = fromMessage && typeof source.provider === 'string'
492
- ? source.provider
493
- : (typeof base.routeProvider === 'string' ? base.routeProvider : '')
494
- const currentModel = base.models[model]
600
+ const provider = isCompactionSummary
601
+ ? (typeof data.provider === 'string' ? data.provider : '')
602
+ : (fromMessage && typeof source.provider === 'string'
603
+ ? source.provider
604
+ : (typeof base.routeProvider === 'string' ? base.routeProvider : ''))
495
605
  const parts = usageParts(usage)
496
- const bucket = {
497
- input: (currentModel === undefined ? 0 : currentModel.input) + parts.input,
498
- cacheRead: (currentModel === undefined ? 0 : currentModel.cacheRead) + parts.cacheRead,
499
- cacheWrite: (currentModel === undefined ? 0 : currentModel.cacheWrite) + parts.cacheWrite,
500
- output: (currentModel === undefined ? 0 : currentModel.output) + parts.output,
501
- }
502
606
  const one = {
503
607
  at: event.time,
504
608
  model,
@@ -507,13 +611,102 @@ const sessionUsageProjection = {
507
611
  cacheWrite: parts.cacheWrite,
508
612
  output: parts.output,
509
613
  }
614
+ /* 摘要事件走单独的归轮路径:它没有 turn,绝不能套用下面"轮次变化就重开 current"的逻辑,
615
+ 否则上一轮/轮间压缩的摘要会并进当前轮,或者把 current/last 打乱。 */
616
+ if (isCompactionSummary) {
617
+ /* 摘要的去重身份:正常情况下用 upstream 给的 compactionId;
618
+ 拿不到时退回 `event.seq`(**与本文件 usageEventKey 的既有做法一致**,见
619
+ `'compact:' + (compactionId ?? event.seq)`)。为什么不直接跳过空 id:
620
+ 那会把一笔真实用量彻底丢掉(费用和用量都少算),而重复计费比漏算更糟但
621
+ 两者都有代价 —— 用 seq 兜底能做到"同一事件重复到达只计一次",
622
+ 因为同一事件的 seq 相同、不同事件必然不同。 */
623
+ const rawId = typeof data.compactionId === 'string' ? data.compactionId : ''
624
+ const compactionId = rawId.length > 0
625
+ ? rawId
626
+ : (typeof event.seq === 'number' ? 'seq:' + event.seq : '')
627
+ const counted = Array.isArray(base.countedCompactions) ? base.countedCompactions : []
628
+ // 同一笔压缩只计一次:重放或重复事件不能让费用翻倍
629
+ if (compactionId.length > 0 && counted.includes(compactionId)) return base
630
+ const compactions = typeof base.compactions === 'object' && base.compactions !== null ? base.compactions : {}
631
+ /* 只有 upstream 真的给了 id 时才有 start 映射可查(空 id 的 start 在更早的分支就被丢弃了) */
632
+ const opened = rawId.length > 0 ? compactions[rawId] : undefined
633
+ /* 防旧宿主双计:旧版本可能把同一笔用量既落成 assistant/message 又落成 summary。
634
+ 窗口 = compaction/start 到本 summary 之间;四条用量分量完全相同才算同一笔
635
+ (token 数精确到个位,不同请求撞上的概率可以忽略)。 */
636
+ const duplicate = opened !== undefined && calls.some((call) =>
637
+ call.at >= opened.at && call.at <= event.time
638
+ && call.input === one.input && call.cacheRead === one.cacheRead
639
+ && call.cacheWrite === one.cacheWrite && call.output === one.output)
640
+ if (duplicate) return base
641
+ const totals = addCallTotals(base, one)
642
+ /* 摘要用量单独累计一份(与按模型的 models 那份分开):
643
+ 客户端「总计用量」用官方 tokenUsage 投影(不含摘要),需要这一份把摘要补回去,
644
+ 才能让「总计用量」与「总计费用」口径一致。老 state 没有这个键,按 0 起算。 */
645
+ const prevSummary = base.summaryParts !== undefined && base.summaryParts !== null
646
+ && typeof base.summaryParts === 'object' ? base.summaryParts : null
647
+ const summaryParts = {
648
+ input: (prevSummary === null ? 0 : Number(prevSummary.input) || 0) + one.input,
649
+ cacheRead: (prevSummary === null ? 0 : Number(prevSummary.cacheRead) || 0) + one.cacheRead,
650
+ cacheWrite: (prevSummary === null ? 0 : Number(prevSummary.cacheWrite) || 0) + one.cacheWrite,
651
+ output: (prevSummary === null ? 0 : Number(prevSummary.output) || 0) + one.output,
652
+ }
653
+ /* 轮归属三条规则:
654
+ ① 映射到数字 turn 且等于当前 turn → 同时进总计与本轮;
655
+ ② 映射到数字 turn 但不等当前轮 → 只进总计;
656
+ ③ 映射为 null(**轮间压缩**,手动 / 自动都是 null)→ 只进总计,
657
+ **并把「本轮」刷新成这一笔**;
658
+ 没有映射(日志截断/旧版本)→ 兜底归当前轮,不抛错。
659
+
660
+ 为什么叫「轮间压缩」而不是「手动压缩」:宿主语义上两者是同一件事 ——
661
+ 手动 `/compact` 必须等这一轮回复结束、用户才能输入,所以它**必然**是轮间压缩,
662
+ `compaction/start` 的 turn 就是 null(见 tests/verify-host.js 里「用户手动 /compact:
663
+ 轮间压缩(start/summary/end 都带 sourceCommandId,turn=null)」那条夹具)。
664
+
665
+ 反过来说:**自动压缩**(对话进行中达到阈值时触发)的 turn 是当前轮号,
666
+ 所以它根本不会走到这个分支 —— 它由上面的 `belongsToCurrent` 命中、
667
+ **累加进当前轮**(那笔费用属于本轮,用户明确要求过)。
668
+
669
+ 用户对这一条的原话:「手动压缩的时候,上一轮对话明显已经结束了,此时本轮显示的
670
+ 数字依旧是上一轮对话产生的费用,而不是本次手动压缩产生的费用。」 */
671
+ const mappedTurn = opened === undefined ? undefined : opened.turn
672
+ const isInterTurnCompaction = mappedTurn === null
673
+ const belongsToCurrent = mappedTurn === undefined
674
+ ? base.current !== null
675
+ : (mappedTurn !== null && stateTurn !== null && mappedTurn === stateTurn)
676
+ let current = base.current
677
+ if (belongsToCurrent || isInterTurnCompaction) {
678
+ /* belongsToCurrent:并进当前轮(累加)—— 自动压缩走这条;
679
+ isInterTurnCompaction:**替换**(不累加)—— 手动 /compact 走这条,
680
+ 它代表"上一轮已结束,现在这笔是本轮"。 */
681
+ const prev = belongsToCurrent && base.current !== null && Array.isArray(base.current.calls)
682
+ ? base.current
683
+ : null
684
+ current = {
685
+ provider,
686
+ model,
687
+ time: event.time,
688
+ input: (prev === null ? 0 : prev.input) + one.input,
689
+ cacheRead: (prev === null ? 0 : prev.cacheRead) + one.cacheRead,
690
+ cacheWrite: (prev === null ? 0 : prev.cacheWrite) + one.cacheWrite,
691
+ output: (prev === null ? 0 : prev.output) + one.output,
692
+ calls: (prev === null ? [] : prev.calls).concat([one]),
693
+ }
694
+ }
695
+ return {
696
+ ...base,
697
+ ...totals,
698
+ current,
699
+ summaryParts,
700
+ countedCompactions: compactionId.length > 0 ? counted.concat([compactionId]) : counted,
701
+ }
702
+ }
510
703
  const turn = turnOf(event)
511
704
  // 轮次变化就重开本轮汇总:不能假设一定有 turn/start
512
705
  const sameTurn = base.current !== null && Array.isArray(base.current.calls)
513
706
  && (turn === null || stateTurn === null || turn === stateTurn)
514
707
  const prev = sameTurn ? base.current : null
515
708
  const callsForTurn = prev === null ? [] : prev.calls
516
- const summary = {
709
+ const turnSummary = {
517
710
  provider,
518
711
  model,
519
712
  time: event.time,
@@ -522,27 +715,14 @@ const sessionUsageProjection = {
522
715
  cacheWrite: (prev === null ? 0 : prev.cacheWrite) + one.cacheWrite,
523
716
  output: (prev === null ? 0 : prev.output) + one.output,
524
717
  }
525
- const current = { ...summary, calls: callsForTurn.concat([one]) }
526
- /* 追加后若超过 MAX_CALLS,最前面那几条会被丢掉。丢掉的是**真实用过的 token**,
527
- 必须把它们按档累计进 overflow,否则客户端逐条计价时会少算(见 addOverflow 说明)。
528
- 用合并前后的长度差算出丢了几条 —— 它们一定是最老的,也就是 calls 队首那几条。 */
529
- const merged = appendCalls(calls, [one])
530
- const droppedCount = calls.length + 1 - merged.length
531
- let overflow = typeof base.overflow === 'object' && base.overflow !== null ? base.overflow : {}
532
- for (let i = 0; i < droppedCount; i += 1) {
533
- const gone = calls[i]
534
- if (gone === undefined) break
535
- overflow = addOverflow(overflow, gone.model, gone, isPeakTime(gone.at))
536
- }
718
+ const current = { ...turnSummary, calls: callsForTurn.concat([one]) }
537
719
  return {
538
720
  ...base,
721
+ ...addCallTotals(base, one),
539
722
  turn: turn === null ? stateTurn : turn,
540
- calls: merged,
541
- overflow,
542
723
  current,
543
724
  // 轮次变化时把上一轮结算进 last
544
725
  last: prev === null && base.current !== null ? base.current : base.last,
545
- models: { ...base.models, [model]: bucket },
546
726
  }
547
727
  },
548
728
  wire: {
@@ -556,7 +736,14 @@ const sessionUsageProjection = {
556
736
  // 整条投影抛错、该会话的账从此停摆。提升版本强制重放,把这些笔补回账本。
557
737
  // 5 → 6:新增 overflow(被 MAX_CALLS 截掉那段的按档累计)。旧缓存里这个字段不存在,
558
738
  // 长会话的费用会一直少算截掉的部分,必须重放才能补出来。
559
- stateVersion: 6,
739
+ // 6 → 7:compaction/summary 的摘要调用(真实会话里有 4 笔带用量)以前整笔漏掉,
740
+ // 「本轮/总计费用」都比实际少。新增 compactions/countedCompactions 两个字段做归轮与防双计,
741
+ // 旧缓存里没有它们,必须重放才会把这 4 笔补进账本。
742
+ // 7 → 8:新增 summaryParts(压缩摘要的 token 累计),供客户端把它加到「总计用量」上 ——
743
+ // 官方 tokenUsage 投影不含摘要,导致「总计费用」含摘要而「总计用量」不含,同一面板两个口径。
744
+ // 旧缓存没有这个字段,不重放的话历史会话的「总计用量」会永远少那几笔摘要。
745
+ // 同一次 bump 也覆盖「轮间/手动压缩刷新本轮」的新语义(current 的重建规则变了)。
746
+ stateVersion: 8,
560
747
  }
561
748
 
562
749
  /* -------------------------------------------------------- sessionTimeRange */
@@ -1327,6 +1514,10 @@ async function queryLocalAdapter(ctx, adapter, now, provider) {
1327
1514
  isCredits: parsed.isCredits === true,
1328
1515
  isPercent: parsed.isPercent === true,
1329
1516
  parts: parsed.parts,
1517
+ /* 积分套餐明细(workbuddy 新插件的 credits.packages 投影,见 balance-api.js 的 parse)。
1518
+ 字段名固定 rewards;**只有适配器真的给了才带上** —— 老插件/非积分平台没有这个键,
1519
+ 客户端据此走原来的纯总数显示。失败态(上面几个分支)一律不带。 */
1520
+ ...(parsed.rewards === undefined ? {} : { rewards: parsed.rewards }),
1330
1521
  toppedUp: partAmount(parsed.parts, 'toppedUp'),
1331
1522
  granted: partAmount(parsed.parts, 'granted'),
1332
1523
  spent: partAmount(parsed.parts, 'spent'),
@@ -2339,23 +2530,48 @@ async function subagentIndexSnapshot(files) {
2339
2530
  }
2340
2531
 
2341
2532
  /**
2342
- * 一段父会话日志里的"本轮起点":最后一条 `turn/start` 的时间 —— 但**和最后一条
2343
- * `user/message` 取较大的那个**。都找不到就返回 null(调用方据此判 sinceSource='none')。
2533
+ * 一段父会话日志里的"本轮起点"。三条时间取 max,规则见下:
2534
+ * ① 最后一条 `turn/start`;
2535
+ * ② 最后一条**真实用户输入**的 `user/message` —— 但**只有**它落在
2536
+ * 「最后一条 `turn/end` 之后、且最后一条 `turn/end` 晚于最后一条 `turn/start`」
2537
+ * 这一段里,才算"新一轮已经开始、而它的 `turn/start` 还没落盘"的候选;
2538
+ * ③ 日志里完全没有 `turn/start` 时,退回用最后一条真实用户消息(老兜底)。
2539
+ * 都找不到就返回 null(调用方据此判 sinceSource='none')。
2540
+ *
2541
+ * **为什么只认 `source.kind === 'user'` 的 `user/message`**(2026-10-17 修复):
2542
+ * 同一轮里 DSH 会往父会话日志注入很多**不是用户输入**的 `user/message` ——
2543
+ * 子代理结算(`subagent-settled`)、队友消息转发(`team-message` / `agent-message`)、
2544
+ * 后台任务完成(`tool-jobs`)、技能目录刷新(`skill-catalog`)、审批回答
2545
+ * (`user-approval`)、运行时上下文(`runtime-context`)……它们**都不改轮次**。
2546
+ * 旧实现把所有 `user/message` 都算进来,于是"最后一条"常常是这些通知:本轮窗口
2547
+ * 起点被推到**子代理结算那一刻**,队友在本轮已经花掉的钱整段被排除(用户实测
2548
+ * 「所有子代理运行完之后『本轮』卡住不涨」;本机真实日志复现:本轮应 ¥34.62 →
2549
+ * 实际 ¥10.58)。只认真实用户输入才能既保住"新一轮已开始"的兜底,又不被注入
2550
+ * 通知推进起点。
2551
+ *
2552
+ * **为什么真人中途插话(steering)也不能算新轮起点**(2026-10-17 补边界):
2553
+ * DSH 支持同一轮里用户再发消息(本轮日志里就有:唯一一条 `turn/start` 之后 7 分钟
2554
+ * 又来了一条 `source.kind='user'` 的消息,`turn/end` 还没出现)。这种消息只改变
2555
+ * 这一轮的上下文、**不开新轮**;若把它当起点,「本轮」会从插话那一刻重新算,把插话
2556
+ * 之前子代理在本轮花掉的钱再次抹掉(实测:本轮 ¥4.06,而总计 ¥5.03,残差 ¥0.97)。
2557
+ * 所以判据是"**轮已经关了**"才认用户消息:看 `turn/end` 是否晚于最后一条
2558
+ * `turn/start`(关轮 = 上一轮已结束,此时新 USER 消息代表新轮已起、`turn/start`
2559
+ * 还没落盘);turn 还开着就只认 `turn/start`。
2344
2560
  *
2345
- * 为什么不是"有 turn/start 就优先用它":用户消息比最后一条 turn/start 还新,说明
2346
- * **新一轮已经开始、而它的 `turn/start` 还没落盘**;此时沿用旧的 turn/start 会把
2347
- * **上一轮**的队友用量算进"本轮",方向正是用户不允许的"偏大"。取较新的那个时间
2348
- * 只会把窗口往回收(宁少不多),符合"本轮只能小不能大"。
2349
2561
  * 取**最大值**而不是"文件里最后一行":正常日志是追加写的,两者一致;万一有乱序行,
2350
2562
  * 取最大值更稳。
2351
2563
  */
2352
2564
  function lastTurnStartOfText(text) {
2353
2565
  if (typeof text !== 'string' || text.length === 0) return null
2354
- let at = null
2566
+ let lastTurnStart = null
2567
+ let lastTurnEnd = null
2568
+ let lastUser = null
2355
2569
  const lines = text.split('\n')
2356
2570
  for (let i = 0; i < lines.length; i += 1) {
2357
2571
  const line = lines[i]
2358
- if (line.indexOf('"turn/start"') < 0 && line.indexOf('"user/message"') < 0) continue
2572
+ /* 便宜的预筛:只解析可能相关的三种事件 */
2573
+ if (line.indexOf('"turn/start"') < 0 && line.indexOf('"turn/end"') < 0
2574
+ && line.indexOf('"user/message"') < 0) continue
2359
2575
  let event
2360
2576
  try {
2361
2577
  event = JSON.parse(line)
@@ -2363,10 +2579,29 @@ function lastTurnStartOfText(text) {
2363
2579
  continue
2364
2580
  }
2365
2581
  if (event === null || typeof event !== 'object' || typeof event.time !== 'number') continue
2366
- if (event.type !== 'turn/start' && event.type !== 'user/message') continue
2367
- if (at === null || event.time > at) at = event.time
2582
+ if (event.type === 'turn/start') {
2583
+ if (lastTurnStart === null || event.time > lastTurnStart) lastTurnStart = event.time
2584
+ continue
2585
+ }
2586
+ if (event.type === 'turn/end') {
2587
+ if (lastTurnEnd === null || event.time > lastTurnEnd) lastTurnEnd = event.time
2588
+ continue
2589
+ }
2590
+ if (event.type !== 'user/message') continue
2591
+ /* 只认真实用户输入:source 缺失(老日志/坏行)也宁可不当成新轮 —— 少算一侧
2592
+ 的代价是多算上一轮末的队友用量(偏大),比"整轮花费被抹掉"可接受。 */
2593
+ const data = event.data !== null && event.data !== undefined && typeof event.data === 'object' ? event.data : {}
2594
+ const source = data.source !== null && data.source !== undefined && typeof data.source === 'object' ? data.source : null
2595
+ if (source === null || source.kind !== 'user') continue
2596
+ if (lastUser === null || event.time > lastUser) lastUser = event.time
2368
2597
  }
2369
- return at
2598
+ /* 完全没有 turn/start:老日志/异常日志的兜底 —— 用最后一条真实用户消息(原来就是这样) */
2599
+ if (lastTurnStart === null) return lastUser
2600
+ /* 用户消息只有在"上一轮已结束(turn/end 晚于 turn/start)、且消息晚于那个 turn/end"
2601
+ 时才是"新轮已开始、turn/start 还没落盘"的证据;turn 还开着就只是本轮插话,不算 */
2602
+ const turnClosed = lastTurnEnd !== null && lastTurnEnd > lastTurnStart
2603
+ const userStartedNewTurn = turnClosed === true && lastUser !== null && lastUser > lastTurnEnd
2604
+ return userStartedNewTurn === true && lastUser > lastTurnStart ? lastUser : lastTurnStart
2370
2605
  }
2371
2606
 
2372
2607
  /**
@@ -2607,9 +2842,11 @@ function subagentFileUsage(one, fromMs, fingerprint) {
2607
2842
  * (只收紧、不放大;`since=0` 是显式整会话,不收敛);真收敛了会带
2608
2843
  * `sinceClamped: true`(`sinceSource` 仍是 `client`);
2609
2844
  * · `host`:没传 → host 读**父会话自己**的日志推一个起点(最后一条 `turn/start`,
2610
- * 但和最后一条 `user/message` 取较新的那个 —— 后者更新说明新一轮已开始、其
2611
- * `turn/start` 还没落盘,沿用旧起点会把上一轮算进来);而且**推出来的起点之后
2612
- * 必须真的有用量事件**,否则说明起点无从验证,同样当 `none`(宁少不多);
2845
+ * 但和最后一条**真实用户输入**的 `user/message` 取较新的那个 —— 后者更新说明新一轮
2846
+ * 已开始、其 `turn/start` 还没落盘,沿用旧起点会把上一轮算进来;同轮注入的
2847
+ * subagent-settled / team-message / tool-jobs 等通知不算,见 lastTurnStartOfText);
2848
+ * 而且**推出来的起点之后必须真的有用量事件**,否则说明起点无从验证,同样当
2849
+ * `none`(宁少不多);
2613
2850
  * · `none`:父会话日志缺失/读不出,或推出来的起点事后没有事件 → 本轮**什么都不并**
2614
2851
  * (整条返回零值),绝不退回整会话 —— 用户规则:本轮只能小不能大;
2615
2852
  * 要整会话(「总计」那一项)就显式传 `since=0`。
@@ -2656,8 +2893,9 @@ async function collectSubagentUsageNow(sessionId, sinceMs) {
2656
2893
 
2657
2894
  /* 窗口起点从哪来:① 客户端给了(它精确知道自己那一轮)→ `client`;
2658
2895
  ② 没给就问 host 自己 —— 读**父会话自己**的日志取最后一条 turn/start(或更新的
2659
- user/message,见 lastTurnStartOfText)→ `host`;③ 两者都没有(父会话文件缺失/读不出)
2660
- → `none`:**本轮什么都不并**(用户规则:本轮只能小不能大,绝不能拿整会话的量顶上)。
2896
+ 真实用户 user/message,见 lastTurnStartOfText;同轮注入的通知不算)→ `host`;
2897
+ ③ 两者都没有(父会话文件缺失/读不出)→ `none`:**本轮什么都不并**
2898
+ (用户规则:本轮只能小不能大,绝不能拿整会话的量顶上)。
2661
2899
  另外 host 推的起点还要过一道"事后有事件"的验证(见下面聚合之后的那段)。 */
2662
2900
  const parentEntry = index.byId.get(sessionId)
2663
2901
  let sinceSource = 'none'
@@ -2739,7 +2977,9 @@ async function collectSubagentUsageNow(sessionId, sinceMs) {
2739
2977
  total.stats.ttftSteps += agg.stats.ttftSteps
2740
2978
  total.stats.decodeMs += agg.stats.decodeMs
2741
2979
  total.stats.decodeTokens += agg.stats.decodeTokens
2742
- if (i % 4 === 3) await new Promise((resolve) => setImmediate(resolve))
2980
+ /* 每 16 个让出一次事件循环:146+ 个队友时 4 个一让太碎,宿主每秒都在被问,
2981
+ 让出次数少一点能明显压低单次聚合的调度开销(文件用量已按文件缓存,重算只是少数)。 */
2982
+ if (i % 16 === 15) await new Promise((resolve) => setImmediate(resolve))
2743
2983
  }
2744
2984
  /* host 推出来的起点必须有"事后证据"才算有效。**证据有两条,任一条成立即可**:
2745
2985
  ① 父会话**自己**在窗口内有用量事件(fromMs 之后有 assistant/message 或
@@ -3424,6 +3664,44 @@ function appendTipDiag(body) {
3424
3664
  }
3425
3665
  }
3426
3666
 
3667
+ /**
3668
+ * 把客户端上报的「更新失败」描述成一条能直接拿去问 AI 的日志文本。
3669
+ *
3670
+ * 字段都是**客户端自己知道、宿主不知道**的:它在哪个环节失败、目标版本是谁、
3671
+ * 浏览器抛了什么异常。刻意不在这里做任何判断或修复 —— 只把事实摊平成一行,
3672
+ * 让用户把日志贴给 AI 时信息是完整的(这是用户提「补日志」的原始诉求)。
3673
+ *
3674
+ * 只取有限长度的字符串,避免恶意/异常的超长 body 把日志撑爆。
3675
+ *
3676
+ * @param {object|null} body 客户端 POST 上来的 JSON
3677
+ * @returns {string}
3678
+ */
3679
+ function describeClientUpdateFailure(body) {
3680
+ const src = body !== null && body !== undefined && typeof body === 'object' ? body : {}
3681
+ const text = (value, max) => {
3682
+ if (value === null || value === undefined) return null
3683
+ const raw = typeof value === 'string' ? value : String(value)
3684
+ const limit = max === undefined ? 200 : max
3685
+ return raw.length > limit ? raw.slice(0, limit) + '…' : raw
3686
+ }
3687
+ const parts = []
3688
+ const reason = text(src.reason, 60)
3689
+ parts.push('reason=' + (reason === null ? 'unknown' : reason))
3690
+ parts.push('target=' + (text(src.target, 40) ?? 'null'))
3691
+ parts.push('current=' + (text(src.current, 40) ?? 'null'))
3692
+ /* 时间戳取客户端那一刻:宿主收到的时间可能晚很多(比如用户在断网时才点) */
3693
+ parts.push('at=' + (typeof src.at === 'number' && Number.isFinite(src.at) ? new Date(src.at).toISOString() : 'null'))
3694
+ const stage = text(src.stage, 60)
3695
+ if (stage !== null) parts.push('stage=' + stage)
3696
+ /* HTTP 状态码:fetch 对 4xx/5xx 是 resolve 不是 reject,必须单独记 */
3697
+ if (typeof src.httpStatus === 'number' && Number.isFinite(src.httpStatus)) parts.push('http=' + String(src.httpStatus))
3698
+ const detail = text(src.detail, 300)
3699
+ if (detail !== null && detail.length > 0) parts.push('detail=' + detail)
3700
+ /* 失败那一次尝试了多久:能区分"秒失败(被拒/被拦)"与"等很久才超时" */
3701
+ if (typeof src.elapsedMs === 'number' && Number.isFinite(src.elapsedMs)) parts.push('elapsed=' + String(Math.round(src.elapsedMs)) + 'ms')
3702
+ return parts.join(' ')
3703
+ }
3704
+
3427
3705
  /* 台账只在「有新数据」时才重扫(文件 mtime+size 没变就跳过),因此这层只是省掉重复的 stat。
3428
3706
  不挂定时器:打开活跃总览面板时会带 force=1 现扫一次,数据只在被看的时候才需要最新。 */
3429
3707
  const WEEK_USAGE_CACHE_MS = 30000
@@ -3911,6 +4189,20 @@ async function handleApi(req, res, ctx) {
3911
4189
  sendJson(res, 200, { ok: true })
3912
4190
  return
3913
4191
  }
4192
+ if (url.pathname === '/dsh-desktop-statusbar/api/update-failure') {
4193
+ /* 客户端侧更新失败的上报口。为什么需要它:宿主侧失败已经在
4194
+ `update: 结果 {...}` 那条日志里了,但**客户端自己**的失败(fetch 被代理拦、
4195
+ 响应解不出来、网络断)发生在浏览器里,宿主完全不知道 —— 用户点完只看到
4196
+ 「更新失败」,日志里查不到任何痕迹,想用 AI 排查也无从下手。
4197
+ 这里把它补成一条**结构化日志行**:既有给人看的中文摘要,也有完整 JSON。
4198
+ 不做任何状态变更、不重试安装,只落日志,所以是纯诊断面。 */
4199
+ if (req.method === 'POST') {
4200
+ const body = await readJsonBody(req)
4201
+ logHost(ctx, 'update: 客户端失败 ' + describeClientUpdateFailure(body))
4202
+ }
4203
+ sendJson(res, 200, { ok: true })
4204
+ return
4205
+ }
3914
4206
  if (url.pathname !== '/dsh-desktop-statusbar/api/balance') {
3915
4207
  sendJson(res, 404, { ok: false, reason: 'not-found' })
3916
4208
  return