dsh-cost-meter 1.5.32 → 1.5.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  Per-conversation cost · daily totals · OpenCode Go subscription quota display · budget with usage percentage · official account balance · custom provider balance · balance progress bar · history · peak/off-peak pricing hours display (peak hours UTC 01:00–04:00, 06:00–10:00) · pre-switch popup & system-notification alerts for peak/off-peak changes (position / lead time / alert type configurable) · one-click price sync from the official docs · Codex-style token usage heat grid · multi-vendor model pricing (built-in 90+ model price catalog with auto-matching) · mainstream Coding Plan quota queries & display (Anthropic / Z.ai / MiniMax / Kimi / OpenRouter / SiliconFlow / CommandCode / SCNet) · quota strip above the input box (budget / Go / coding-plan usage in one row, toggleable)
8
8
 
9
- [![version](https://img.shields.io/badge/version-1.5.32-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
9
+ [![version](https://img.shields.io/badge/version-1.5.34-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
10
10
  [![npm](https://img.shields.io/npm/v/dsh-cost-meter?label=npm)](https://www.npmjs.com/package/dsh-cost-meter)
11
11
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
12
12
  [![dsh](https://img.shields.io/badge/DeepSeek%20Harness-dsh--plugin-4176E6)](https://github.com/deepseek-ai/deepseek-harness)
@@ -228,22 +228,22 @@ Real captures from an actual DSH sidebar of the period strip and collapsed verti
228
228
  dsh plugin --profile web add dsh-cost-meter
229
229
  ```
230
230
 
231
- **PowerShell one-click script** (copy the whole line, paste, press Enter; pnpm is provisioned automatically, git is auto-detected — no clone needed; the install chain is **pinned to the release tag `v1.5.32`** — review the script before running):
231
+ **PowerShell one-click script** (copy the whole line, paste, press Enter; pnpm is provisioned automatically, git is auto-detected — no clone needed; the install chain is **pinned to the release tag `v1.5.34`** — review the script before running):
232
232
 
233
233
  ```powershell
234
- irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.5.32/install.ps1 | iex
234
+ irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.5.34/install.ps1 | iex
235
235
  ```
236
236
 
237
237
  **Or a plain command line** (the machine must already have pnpm and git; also pinned to the tag):
238
238
 
239
239
  ```sh
240
- dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.5.32
240
+ dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.5.34
241
241
  ```
242
242
 
243
243
  Without git, use the GitHub tag archive:
244
244
 
245
245
  ```sh
246
- dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.5.32.tar.gz
246
+ dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.5.34.tar.gz
247
247
  ```
248
248
 
249
249
  After installing, **restart** `dsh web` (plugin rows, the Typert manifest and the client bundle are all scanned at startup):
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  本会话费用 · 当日费用 · OpenCode Go 订阅额度显示 · 预算与已用百分比 · 官方账户余额 · 自定义 Provider 余额查询(可配任意 HTTP 端点) · 余额三段进度条 · 历史记录 · 峰谷计价时段显示(UTC 01:00–04:00、06:00–10:00 为峰时段) · 峰/谷切换前弹窗与系统通知提醒(位置/提前量/提醒类型可配) · 官方价格一键同步 · 类 Codex Token 用量热图 · 多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配) · 主流 Coding Plan 额度查询与显示(Anthropic / Z.ai / MiniMax / Kimi / OpenRouter / SiliconFlow / CommandCode / SCNet 八家) · 输入框上方额度横条(预算/Go/Coding Plan 用量一条横排显示,可开关)
8
8
 
9
- [![version](https://img.shields.io/badge/version-1.5.32-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
9
+ [![version](https://img.shields.io/badge/version-1.5.34-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
10
10
  [![npm](https://img.shields.io/npm/v/dsh-cost-meter?label=npm)](https://www.npmjs.com/package/dsh-cost-meter)
11
11
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
12
12
  [![dsh](https://img.shields.io/badge/DeepSeek%20Harness-dsh--plugin-4176E6)](https://github.com/deepseek-ai/deepseek-harness)
@@ -230,22 +230,22 @@
230
230
  dsh plugin --profile web add dsh-cost-meter
231
231
  ```
232
232
 
233
- **PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.5.32`**,建议先下载审阅再运行):
233
+ **PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.5.34`**,建议先下载审阅再运行):
234
234
 
235
235
  ```powershell
236
- irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.5.32/install.ps1 | iex
236
+ irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.5.34/install.ps1 | iex
237
237
  ```
238
238
 
239
239
  **或直接命令行**(机器上需已有 pnpm 与 git;同样固定到 tag):
240
240
 
241
241
  ```sh
242
- dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.5.32
242
+ dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.5.34
243
243
  ```
244
244
 
245
245
  没有 git 时可用 GitHub tag 打包直链:
246
246
 
247
247
  ```sh
248
- dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.5.32.tar.gz
248
+ dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.5.34.tar.gz
249
249
  ```
250
250
 
251
251
  安装后**重启** `dsh web`(插件行、Typert 清单与客户端 bundle 均在启动时扫描):
package/lib/backfill.js CHANGED
@@ -138,10 +138,23 @@ export function listSessionLogs(root) {
138
138
  * 回放单个会话的事件流,重建逐次用量。与 costUsage 投影同规则:
139
139
  * request/header 切换当前 provider/model;usage 块按 (turn, step) 去重,
140
140
  * 同键最终样本替换流式样本(先减后加);按事件时刻计价。
141
+ *
142
+ * fork 种子分段(issue #38):DSH 的 fork 把父会话事件流整段拷贝进子会话
143
+ * 日志(header 带 parentSession / seedLength),拷贝事件的时间戳早于
144
+ * header.createdAt。回放时把 time < createdAt 的种子事件单独聚合进
145
+ * seedDays(供一次性清洗扣账),不再计入 days——父会话已计过,重复计费
146
+ * 即虚高。非 fork 会话 createdAt 早于全部事件,判定不生效,无回归影响。
147
+ *
148
+ * 状态机与旧版逐字一致(header 一律切换 provider/model、单一 (turn,step)
149
+ * 去重跨种子/own 段连续生效),仅聚合目标按事件分段路由:旧版把种子段
150
+ * 写进账本的量与新算的 seedDays 逐字段相等,清洗扣账才不偏不漏;若 own
151
+ * 段样本复用了种子段最后的 (turn,step) 键,先减项路由回 seedDays,days
152
+ * 只含 own 部分——同样与旧版账本内容对齐。
153
+ *
141
154
  * @param records - readSessionRecords 的输出。
142
155
  * @param config - 账本配置(prices / peak* / priceMatch / priceOverrides)。
143
156
  * @param wantDates - 只统计这些日期键(YYYY-MM-DD)内的调用;null = 全部。
144
- * @returns { sessionId, title, createdAt, days: { date: { providerKey: 桶 } } }
157
+ * @returns { sessionId, title, createdAt, days, seedDays }。
145
158
  */
146
159
  export function replaySessionRecords(records, config, wantDates = null) {
147
160
  const zeroBuckets = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, calls: 0, cost: 0 })
@@ -156,8 +169,9 @@ export function replaySessionRecords(records, config, wantDates = null) {
156
169
  let model = 'default'
157
170
  let last = null
158
171
  const days = {}
159
- const shift = (sample, sign) => {
160
- const dayMap = days[sample.date] ?? (days[sample.date] = {})
172
+ const seedDays = {}
173
+ const shift = (sample, sign, target) => {
174
+ const dayMap = target[sample.date] ?? (target[sample.date] = {})
161
175
  const current = dayMap[sample.providerKey] ?? zeroBuckets()
162
176
  dayMap[sample.providerKey] = {
163
177
  input: current.input + sign * sample.buckets.input,
@@ -184,7 +198,13 @@ export function replaySessionRecords(records, config, wantDates = null) {
184
198
  continue
185
199
  }
186
200
  if (PACKED_ROW_TYPES.has(event.type)) continue
201
+ const eventMs = Number(event.time)
202
+ // fork 种子事件判定:时间戳早于会话创建时刻 = 从父会话拷来的历史。
203
+ const isSeed = createdAt > 0 && Number.isFinite(eventMs) && eventMs > 0 && eventMs < createdAt
187
204
  if (event.type === 'request/header') {
205
+ // header 一律更新计费口径(与旧版状态机一致):种子段用量必须按
206
+ // 父会话当时的模型计价,seedDays 才能与旧版写入账本的污染量对齐,
207
+ // 清洗扣账逐字段精确;fork 后自己首轮也几乎总带着新 header,不受影响。
188
208
  const nextModel = event.data?.header?.config?.model
189
209
  const nextProvider = event.data?.header?.config?.provider
190
210
  model = typeof nextModel === 'string' && nextModel.length > 0 ? nextModel : 'default'
@@ -205,7 +225,7 @@ export function replaySessionRecords(records, config, wantDates = null) {
205
225
  } else {
206
226
  continue
207
227
  }
208
- const atMs = Number(event.time)
228
+ const atMs = eventMs
209
229
  if (!Number.isFinite(atMs) || atMs <= 0) continue
210
230
  const date = localDayKey(atMs)
211
231
  if (wantDates !== null && !wantDates.has(date)) continue
@@ -217,6 +237,7 @@ export function replaySessionRecords(records, config, wantDates = null) {
217
237
  reasoning: num(usage.reasoningTokens),
218
238
  }
219
239
  const key = `${turn}:${step}`
240
+ // 去重与旧版完全一致:单一 (turn,step) 状态机跨种子/own 段连续生效。
220
241
  const prev = last !== null && last.key === key ? last : null
221
242
  if (prev !== null && prev.providerKey === `${provider}:${model}`
222
243
  && prev.buckets.input === buckets.input && prev.buckets.output === buckets.output
@@ -236,12 +257,14 @@ export function replaySessionRecords(records, config, wantDates = null) {
236
257
  }
237
258
  const cost = resolved.priced ? costOf(buckets, resolved.entry, atMs, peak) : 0
238
259
  const providerKey = `${provider}:${model}`
239
- if (prev !== null) shift(prev, -1)
240
- const sample = { key, date, providerKey, buckets, cost }
241
- shift(sample, 1)
260
+ // 聚合目标按事件分段路由;被替换的旧样本从它当时所在的段扣回(与旧版
261
+ // 单流先减后加的净效果一致,仅种子段的量落到 seedDays 而非 days)。
262
+ if (prev !== null) shift(prev, -1, prev.seed ? seedDays : days)
263
+ const sample = { key, date, providerKey, buckets, cost, seed: isSeed }
264
+ shift(sample, 1, isSeed ? seedDays : days)
242
265
  last = sample
243
266
  }
244
- return { sessionId, title, createdAt, days }
267
+ return { sessionId, title, createdAt, days, seedDays }
245
268
  }
246
269
 
247
270
  /**
@@ -581,3 +604,81 @@ export async function importLegacyHistory(ledger, sessionsRoot) {
581
604
  }
582
605
  return result
583
606
  }
607
+
608
+ /** 从目标桶(可变)中减去源桶,逐字段 clamp ≥ 0(清洗不产生负数)。 */
609
+ function subtractBucketInto(target, source) {
610
+ target.input = Math.max(0, (target.input ?? 0) - (source.input ?? 0))
611
+ target.output = Math.max(0, (target.output ?? 0) - (source.output ?? 0))
612
+ target.cacheRead = Math.max(0, (target.cacheRead ?? 0) - (source.cacheRead ?? 0))
613
+ target.cacheWrite = Math.max(0, (target.cacheWrite ?? 0) - (source.cacheWrite ?? 0))
614
+ target.reasoning = Math.max(0, (target.reasoning ?? 0) - (source.reasoning ?? 0))
615
+ target.calls = Math.max(0, (target.calls ?? 0) - (source.calls ?? 0))
616
+ target.cost = Math.max(0, (target.cost ?? 0) - (source.cost ?? 0))
617
+ }
618
+
619
+ /**
620
+ * fork 种子重复计费一次性清洗(issue #38):旧版把 fork 会话日志里从父会话
621
+ * 拷贝的种子事件(time < createdAt)也计入了账本(该会话条目与所在日期合
622
+ * 计均虚高)。本函数扫描全部会话日志,对 header 带 parentSession 的 fork
623
+ * 会话重算种子聚合(seedDays),从账本对应会话条目与日合计中扣除等量污染。
624
+ *
625
+ * 幂等由调用方用账本 migrations 标记(fork-seed-dedup-v1)保证只跑一次;
626
+ * 减法逐字段 clamp ≥ 0,即使账本未被污染(种子量为 0)也无副作用。
627
+ * @param ledger - 已打开的账本。
628
+ * @param sessionsRoot - 宿主会话根目录($DSH_HOME/sessions)。
629
+ * @returns { sessions, days, scanned } 受影响的 fork 会话数、扣除的日期数与扫描文件数。
630
+ */
631
+ export function repairForkSeed(ledger, sessionsRoot) {
632
+ const result = { sessions: 0, days: 0, scanned: 0 }
633
+ for (const path of listSessionLogs(sessionsRoot)) {
634
+ result.scanned += 1
635
+ let records
636
+ try {
637
+ records = readSessionRecords(path)
638
+ } catch {
639
+ continue // 单文件损坏不阻断整体清洗。
640
+ }
641
+ // 只处理 fork 会话(header 带 parentSession);普通会话日志无种子段。
642
+ const header = records.find(r => r?.type === 'session')
643
+ if (header === undefined || header === null || typeof header.parentSession !== 'string' || header.parentSession.length === 0) continue
644
+ let replayed
645
+ try {
646
+ replayed = replaySessionRecords(records, ledger.config, null)
647
+ } catch {
648
+ continue
649
+ }
650
+ if (replayed.sessionId.length === 0) continue
651
+ let touched = false
652
+ for (const [date, pm] of Object.entries(replayed.seedDays)) {
653
+ const totals = sumDayMap(pm)
654
+ if (totals.calls <= 0) continue
655
+ const day = ledger.days[date]
656
+ if (day === null || typeof day !== 'object') continue
657
+ const session = Array.isArray(day.sessions) ? day.sessions.find(s => s?.id === replayed.sessionId) : null
658
+ if (session === undefined || session === null) continue
659
+ // 会话条目:顶层字段与 byProviderModel 拆分同步扣除种子污染。
660
+ subtractBucketInto(session, totals)
661
+ if (session.byProviderModel !== null && typeof session.byProviderModel === 'object') {
662
+ for (const [providerKey, bucket] of Object.entries(pm)) {
663
+ const current = session.byProviderModel[providerKey]
664
+ if (current !== null && typeof current === 'object') subtractBucketInto(current, bucket)
665
+ }
666
+ }
667
+ // 日合计:顶层字段与 byProviderModel 同步扣除。
668
+ subtractBucketInto(day, totals)
669
+ if (day.byProviderModel !== null && typeof day.byProviderModel === 'object') {
670
+ for (const [providerKey, bucket] of Object.entries(pm)) {
671
+ const current = day.byProviderModel[providerKey]
672
+ if (current !== null && typeof current === 'object') subtractBucketInto(current, bucket)
673
+ }
674
+ }
675
+ touched = true
676
+ result.days += 1
677
+ }
678
+ if (touched) {
679
+ result.sessions += 1
680
+ ledger.scheduleWrite()
681
+ }
682
+ }
683
+ return result
684
+ }
package/lib/client.js CHANGED
@@ -2751,11 +2751,22 @@ window.__ModuleLoader__.load({
2751
2751
  // 更新后的提醒(issue #37):告知侧边栏余额/额度图框支持点击立即刷新;
2752
2752
  // 「知道了」写回 balance.clickHintSeen=true 后永久消失(挂常驻 sidebar.footer.action)。
2753
2753
  // 仅在侧边栏确有可点击图框(官方余额 / 自定义余额 / Coding Plan)时展示,避免噪音。
2754
+ // 已读标记双通道:配置标记(跨设备) + localStorage 本地兜底。后者防御版本错位——
2755
+ // 服务端/网关若运行旧版 typert schema(decode 时 zod parse 会剥离 schema 未声明的键),
2756
+ // 新增的配置标记在到达客户端前就被剥掉,卡片会在每次刷新后重现;localStorage 不经 RPC 链路,不受影响。
2757
+ const BALANCE_CLICK_HINT_LS_KEY = 'dsh-cost-meter:balance-click-hint-seen'
2758
+ const readBalanceClickHintLS = () => {
2759
+ try { return window.localStorage.getItem(BALANCE_CLICK_HINT_LS_KEY) === '1' } catch (_) { return false } // 隐私模式等场景静默
2760
+ }
2761
+ const writeBalanceClickHintLS = () => {
2762
+ try { window.localStorage.setItem(BALANCE_CLICK_HINT_LS_KEY, '1') } catch (_) { /* 配置标记仍是主通道 */ }
2763
+ }
2754
2764
  function BalanceClickGuide(props) {
2755
2765
  // Hook 全部前置(promptSeen 类标记翻转时会提前 return null,顺序必须稳定)。
2756
2766
  const costStore = props.useCost ? props.useCost(s => s) : undefined
2757
2767
  const busyRef = useRef(false)
2758
2768
  const [dismissed, setDismissed] = useState(false)
2769
+ const [lsSeen] = useState(readBalanceClickHintLS)
2759
2770
  const state = costStore?.state
2760
2771
  if (!state) return null
2761
2772
  const config = state.config
@@ -2766,7 +2777,7 @@ window.__ModuleLoader__.load({
2766
2777
  const entry = config.codingPlans?.[r.id]
2767
2778
  return entry?.enabled === true && (entry.display === 'sidebar' || entry.display === 'both')
2768
2779
  })
2769
- if (dismissed || config.balance?.clickHintSeen === true || (!sidebarBalanceOn && !sidebarCustomOn && !sidebarPlansOn)) return null
2780
+ if (dismissed || lsSeen || config.balance?.clickHintSeen === true || (!sidebarBalanceOn && !sidebarCustomOn && !sidebarPlansOn)) return null
2770
2781
  // 串行展示:横条引导(QuotaStripGuide)与本卡同为 fixed 顶部卡片,同屏会完全重叠,
2771
2782
  // 点掉一张露出另一张,视觉上像「点了没反应」。等横条引导处理完(promptSeen)再出现。
2772
2783
  if (config.quotaStrip?.promptSeen !== true) return null
@@ -2776,6 +2787,7 @@ window.__ModuleLoader__.load({
2776
2787
  // 乐观消失:点击立即隐藏,落盘确认失败才恢复可见(避免宿主渲染时序让卡片看起来「点不掉」)。
2777
2788
  setDismissed(true)
2778
2789
  busyRef.current = true
2790
+ writeBalanceClickHintLS() // 本地兜底同步落点,不依赖 RPC 成败
2779
2791
  props.api.updateConfig({ balance: { ...(config.balance ?? {}), clickHintSeen: true } })
2780
2792
  .catch(() => { busyRef.current = false; setDismissed(false) })
2781
2793
  }
package/lib/index.js CHANGED
@@ -19,7 +19,7 @@ import { join } from 'node:path'
19
19
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
20
20
  import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
21
21
  import { Ledger, applyConfigPatch, localDayKey, pickBalanceInfo, reconcileBalanceDelta, zeroDay } from './store.js'
22
- import { backfillLegacyLedger, importLegacyHistory } from './backfill.js'
22
+ import { backfillLegacyLedger, importLegacyHistory, repairForkSeed } from './backfill.js'
23
23
  import { OFFICIAL_PRICING_URL, normalizePrice, parsePricingHtml, costOf, priceEntryFor, providerPriceEntryFor, buildPriceCatalog } from './pricing.js'
24
24
  import { CODING_PLAN_PROVIDERS, CODING_PLAN_PROVIDER_IDS, queryCodingPlan, scnetTokenPlanWindows, emptyCustomBalance, queryCustomBalance } from './coding-plans.js'
25
25
  import { fetchWithRetry } from './net.js'
@@ -153,6 +153,64 @@ const usageProjectionSchema = z.object({
153
153
  })).optional(),
154
154
  })
155
155
 
156
+ /** 投影内部 state 的持久化校验 schema(dsh 0.1.1-rc.1 起的 stateSchema 契约)。 */
157
+ const usageProjectionStateSchema = z.object({
158
+ provider: z.string(),
159
+ model: z.string(),
160
+ totals: z.object({
161
+ input: z.number(),
162
+ output: z.number(),
163
+ cacheRead: z.number(),
164
+ cacheWrite: z.number(),
165
+ reasoning: z.number(),
166
+ cost: z.number(),
167
+ }),
168
+ byModel: z.record(z.string(), z.object({
169
+ input: z.number(),
170
+ output: z.number(),
171
+ cacheRead: z.number(),
172
+ cacheWrite: z.number(),
173
+ reasoning: z.number().optional(),
174
+ cost: z.number(),
175
+ })),
176
+ byProviderModel: z.record(z.string(), z.object({
177
+ input: z.number(),
178
+ output: z.number(),
179
+ cacheRead: z.number(),
180
+ cacheWrite: z.number(),
181
+ reasoning: z.number(),
182
+ cost: z.number(),
183
+ })).optional(),
184
+ last: z.object({
185
+ key: z.string(),
186
+ provider: z.string(),
187
+ model: z.string(),
188
+ buckets: z.object({
189
+ input: z.number(),
190
+ output: z.number(),
191
+ cacheRead: z.number(),
192
+ cacheWrite: z.number(),
193
+ reasoning: z.number(),
194
+ }),
195
+ cost: z.number(),
196
+ }).nullable(),
197
+ createdAt: z.number(),
198
+ })
199
+
200
+ /** state → wire payload 读侧投影(新版 wire.view 与旧版 view 共用同一实现)。 */
201
+ function projectionView(state) {
202
+ return {
203
+ input: state.totals.input,
204
+ output: state.totals.output,
205
+ cacheRead: state.totals.cacheRead,
206
+ cacheWrite: state.totals.cacheWrite,
207
+ reasoning: state.totals.reasoning,
208
+ cost: state.totals.cost,
209
+ byModel: state.byModel,
210
+ byProviderModel: state.byProviderModel,
211
+ }
212
+ }
213
+
156
214
  /**
157
215
  * costUsage 会话投影工厂:闭包账本,按事件时刻(event.time)用当时的价格档位
158
216
  * 逐次计费(峰谷时代前按 legacyBase,之后按峰谷两档),保证会话徽章历史正确。
@@ -166,17 +224,35 @@ function makeCostUsageProjection(ledger) {
166
224
  })
167
225
  return {
168
226
  key: 'costUsage',
227
+ // 新旧宿主双兼容:dsh 0.1.1-rc.1 起契约为 stateSchema + wire(见下),
228
+ // 0.1.0 及更早版本读取 schema + view——两套字段并存,各自宿主各取所需。
169
229
  schema: usageProjectionSchema,
170
- stateVersion: 3,
171
- init: () => ({ provider: 'deepseek', model: 'default', totals: zeroBuckets(), byModel: {}, byProviderModel: {}, last: null }),
230
+ stateSchema: usageProjectionStateSchema,
231
+ stateVersion: 4,
232
+ init: () => ({ provider: 'deepseek', model: 'default', totals: zeroBuckets(), byModel: {}, byProviderModel: {}, last: null, createdAt: 0 }),
172
233
  apply(state, event) {
234
+ // fork 种子过滤(issue #38):DSH 的 fork 把父会话事件流整段拷贝进子会话
235
+ // 日志,time < header.createdAt 的事件是拷贝(父会话已计费),一律跳过;
236
+ // 非 fork 会话 createdAt 早于全部事件,判定不生效。stateVersion 3→4
237
+ // 触发宿主重放重建,已受污染的投影状态随之自愈。
238
+ if (event.type === 'session') {
239
+ const created = Number(event.createdAt)
240
+ if (!Number.isFinite(created) || created <= 0 || created === state.createdAt) return state
241
+ return { ...state, createdAt: created }
242
+ }
243
+ const eventMs = Number(event.time)
244
+ const isSeed = state.createdAt > 0 && Number.isFinite(eventMs) && eventMs > 0 && eventMs < state.createdAt
173
245
  if (event.type === 'request/header') {
246
+ // header 一律更新计费口径(与回放器/旧版状态机一致):种子 header
247
+ // 只推进 provider/model 状态;fork 后自己首轮若未带新 header,
248
+ // 沿用父会话最后的模型比回退 default 更接近真实计费。
174
249
  const model = event.data?.header?.config?.model
175
250
  const provider = event.data?.header?.config?.provider
176
251
  const nextModel = typeof model === 'string' && model.length > 0 ? model : 'default'
177
252
  const nextProvider = typeof provider === 'string' && provider.length > 0 ? provider : 'deepseek'
178
253
  return nextModel === state.model && nextProvider === state.provider ? state : { ...state, model: nextModel, provider: nextProvider }
179
254
  }
255
+ if (isSeed) return state
180
256
  let usage = null
181
257
  let turn = 0
182
258
  let step = 0
@@ -248,19 +324,19 @@ function makeCostUsageProjection(ledger) {
248
324
  }
249
325
  if (prev !== null) shift(prev.provider, prev.model, prev.buckets, prev.cost, -1)
250
326
  shift(state.provider, state.model, buckets, billed, 1)
251
- return { provider: state.provider, model: state.model, totals, byModel, byProviderModel, last: { key, provider: state.provider, model: state.model, buckets, cost: billed } }
327
+ // createdAt 必须随状态携带:usage 样本更新不能丢掉 fork 过滤基准。
328
+ return { provider: state.provider, model: state.model, totals, byModel, byProviderModel, createdAt: state.createdAt, last: { key, provider: state.provider, model: state.model, buckets, cost: billed } }
252
329
  },
253
- view(state) {
254
- return {
255
- input: state.totals.input,
256
- output: state.totals.output,
257
- cacheRead: state.totals.cacheRead,
258
- cacheWrite: state.totals.cacheWrite,
259
- reasoning: state.totals.reasoning,
260
- cost: state.totals.cost,
261
- byModel: state.byModel,
262
- byProviderModel: state.byProviderModel,
263
- }
330
+ view: projectionView,
331
+ // DSH 0.1.1-rc.1 起会话投影需声明 wire 才会向客户端推送(PR #39 by
332
+ // @aaronlei):无 wire 的投影在 snapshot/onChanged/refold 中被跳过,
333
+ // 客户端 useProjection('costUsage') 恒为空,输入区下方的会话费用随之
334
+ // 隐藏。stateSchema 同为新契约必填——持久化恢复路径 restore() 会调用
335
+ // stateSchema.parse(row.val) 且无 try-catch,缺省会在 checkpoint 恢复
336
+ // 时 TypeError。wire.view 与外层 view 复用同一实现,避免两份取值漂移。
337
+ wire: {
338
+ viewSchema: usageProjectionSchema,
339
+ view: projectionView,
264
340
  },
265
341
  }
266
342
  }
@@ -1036,6 +1112,16 @@ export async function runStartupImports(ledger, sessionsRoot) {
1036
1112
  ].filter(Boolean).join(',')
1037
1113
  console.log(`[dsh-cost-meter] 历史按模型统计回填完成:${extras}(扫描 ${filled.scanned} 份会话日志)`)
1038
1114
  }
1115
+ // fork 种子重复计费一次性清洗(issue #38):账本 migrations 标记保证只跑一次;
1116
+ // 回放过滤(replaySessionRecords 跳过种子)已堵住新污染,此处只清历史存量。
1117
+ if (!ledger.migrations.includes('fork-seed-dedup-v1')) {
1118
+ const repaired = repairForkSeed(ledger, sessionsRoot)
1119
+ ledger.migrations.push('fork-seed-dedup-v1')
1120
+ ledger.scheduleWrite()
1121
+ if (repaired.sessions > 0) {
1122
+ console.log(`[dsh-cost-meter] fork 会话重复计费清洗完成:扣除 ${repaired.sessions} 个会话 / ${repaired.days} 天的种子污染(扫描 ${repaired.scanned} 份会话日志)`)
1123
+ }
1124
+ }
1039
1125
  if (!(Number(ledger.config?.legacyAutoImportedAt) > 0)) {
1040
1126
  const stats = await importLegacyHistory(ledger, sessionsRoot)
1041
1127
  if (stats.days > 0 || stats.sessions > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cost-meter",
3
- "version": "1.5.32",
3
+ "version": "1.5.34",
4
4
  "description": "DeepSeek Harness 会话费用统计插件:本会话成本、当日费用、历史记录与官方价格同步,支持多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配)、主流 Coding Plan 订阅额度查询与显示(7 家,含 SCNet Token Plan 本地 Credits 计量)、自定义 Provider 余额查询(可配任意 HTTP 端点)与余额进度条、峰谷计价时段显示与切换前弹窗/系统通知提醒(位置/提前量/类型可配),界面中英双语。Session cost tracking plugin for DeepSeek Harness: per-conversation cost, daily totals, history and official price sync, with multi-vendor model pricing (built-in 90+ model catalog with auto-matching), Coding Plan quota queries & display for 7 vendors (incl. local credits metering for the SCNet Token Plan), custom provider balance lookup (configurable HTTP endpoint) and balance progress bar, plus peak/off-peak pricing display with pre-switch popup & system-notification alerts (position / lead time / type configurable), in a bilingual (Chinese/English) UI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",