@chenmiao8563/dsh-token-ledger 0.1.0 → 0.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/CHANGELOG.md CHANGED
@@ -8,6 +8,63 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  Nothing yet.
10
10
 
11
+ ## [0.2.0] - 2026-09-10
12
+
13
+ ### Added
14
+
15
+ - **A settings page.** The browser half registers a `settings.section`, so the
16
+ ledger appears in the settings sidebar beside every other section. It shows:
17
+ - range totals for **this month**, **this year** and the **last 7 days**, each
18
+ with total tokens, cache hit rate and call count, plus the bucket breakdown;
19
+ - **today**, live, which moves as steps complete;
20
+ - a **usage calendar** switchable between year, month and week, where the week
21
+ view is a bar chart rather than a heatmap;
22
+ - the per-model breakdown with its own cache hit rates.
23
+ - `lib/overview.js`, a pure function from a ledger snapshot to the payload the
24
+ page renders, and `lib/route.js`, the read-only
25
+ `GET /api/token-ledger/summary` route that serves it.
26
+ - The browser half is hand-written against the client's module loader and
27
+ requires nothing but `react`, so the package still installs with no build step.
28
+
29
+ ### Changed
30
+
31
+ - The overview is served over an HTTP route rather than a settings namespace.
32
+ A namespace would need a schema — a real dependency — and would rewrite
33
+ `settings.yaml` on every debounce with data that is derived and reproducible.
34
+ The ledger file remains the only store of record.
35
+
36
+ ## [0.1.1] - 2026-09-10
37
+
38
+ Both fixes come from mounting the plugin in a real DSH host, which is the only
39
+ place either defect was observable. Neither was reachable from the test suite,
40
+ so both now have regression tests.
41
+
42
+ ### Fixed
43
+
44
+ - **`/tokens` never registered.** The command definition passed `input` as a
45
+ bare string, and the command registry requires an object carrying a non-empty
46
+ `hint`. The resulting `TypeError` was thrown during mount, which also aborted
47
+ the rest of `apply()` — so the flush-on-dispose effect was never installed
48
+ either.
49
+ - **Backfill under-counted forked sessions.** The inherited boundary was applied
50
+ as a raw index, but a stored session can arrive in the compact row form where
51
+ several logical events share one record and the declared count overshoots the
52
+ array length by an order of magnitude. Applying it skipped those sessions
53
+ entirely, which under-counted a real 139-session home by hundreds of millions
54
+ of tokens. The boundary is now located by the `session/end-seed` marker, which
55
+ is present and equivalent in both coordinate spaces, with the declared count
56
+ as a fallback that is refused when it cannot index the array it came with. A
57
+ fork with no usable boundary is folded whole and warned about, so the error
58
+ direction is over-counting rather than silent loss.
59
+
60
+ ### Changed
61
+
62
+ - The on-disk ledger format is now version 2. A ledger written by 0.1.0 is
63
+ ignored on start rather than trusted, because its cursors would otherwise
64
+ mark every session as consumed and preserve the mis-counted totals forever.
65
+ The ledger rebuilds itself from the session logs, so the only effect is that
66
+ the first start after upgrading does the backfill again.
67
+
11
68
  ## [0.1.0] - 2026-09-10
12
69
 
13
70
  ### Added
@@ -31,5 +88,7 @@ Nothing yet.
31
88
  - Zero runtime dependencies, zero peer dependencies and no install scripts, so
32
89
  the package installs without a build step.
33
90
 
34
- [Unreleased]: https://github.com/chenmiao8563/dsh-token-ledger/compare/v0.1.0...HEAD
91
+ [Unreleased]: https://github.com/chenmiao8563/dsh-token-ledger/compare/v0.2.0...HEAD
92
+ [0.2.0]: https://github.com/chenmiao8563/dsh-token-ledger/compare/v0.1.1...v0.2.0
93
+ [0.1.1]: https://github.com/chenmiao8563/dsh-token-ledger/compare/v0.1.0...v0.1.1
35
94
  [0.1.0]: https://github.com/chenmiao8563/dsh-token-ledger/releases/tag/v0.1.0
package/README.md CHANGED
@@ -144,6 +144,33 @@ audit useless. The ledger's own `updatedAt` settles it:
144
144
  (1 calls, 5100 tokens not yet flushed)
145
145
  ```
146
146
 
147
+ ## Settings page
148
+
149
+ The browser half adds a **Token ledger** section to the settings sidebar:
150
+
151
+ - **Range totals** for this month, this year and the last 7 days — total tokens,
152
+ cache hit rate, call count, and the bucket breakdown behind them. The cache hit
153
+ rate is `cacheRead / (cacheRead + uncachedInput)`: the share of input the prompt
154
+ cache absorbed, so a route with no caching reads 0% rather than undefined.
155
+ - **Today, live** — today's tokens, hit rate and calls, which move as steps
156
+ complete.
157
+ - **A usage calendar** switchable between year, month and week. Year and month are
158
+ heatmaps; the week view is a bar chart. Heat levels are relative to the busiest
159
+ day in the window and square-rooted, so one huge day cannot flatten the rest.
160
+ - **Per model** totals, each with its own hit rate.
161
+
162
+ The page reads one read-only route, `GET /api/token-ledger/summary`, and polls it
163
+ every 15 seconds. The route refuses a non-loopback peer, so it stays private even
164
+ if the web server is bound to `0.0.0.0`.
165
+
166
+ It needs a profile with a web server (the `web` or `desktop` profile). Without one,
167
+ `/tokens` and the CLI still work, and the section says so instead of failing.
168
+
169
+ The overview is deliberately **not** published through a settings namespace. That
170
+ would need a schema — a real dependency, and this package has none — and would
171
+ rewrite `settings.yaml` on every debounce with data that is derived and
172
+ reproducible. The ledger file stays the only store of record.
173
+
147
174
  ## Configuration
148
175
 
149
176
  Override the composition entry by its `id`:
@@ -186,7 +213,8 @@ The ledger file is left alone on purpose — delete
186
213
  ## Development
187
214
 
188
215
  ```bash
189
- npm test # 49 tests, no dependencies to install
216
+ npm install # devDependencies only: react, react-dom, and the slot registry DSH runs
217
+ npm test # 106 tests
190
218
  npm run verify # packaging invariants (dependency-free, no install scripts, no bare imports)
191
219
  ```
192
220
 
@@ -194,6 +222,15 @@ npm run verify # packaging invariants (dependency-free, no install scripts,
194
222
  spawning a child process per test file is blocked, use
195
223
  `npm run test:single-process`.
196
224
 
225
+ The devDependencies are **test-only**. They are never installed for a consumer:
226
+ the package ships with no dependencies, no peer dependencies and no install
227
+ scripts, and `pnpm` does not install a dependency's devDependencies. They exist
228
+ so the browser half can be checked against the real thing — React renders it, so
229
+ a hook-order or DOM-prop mistake fails instead of passing silently, and
230
+ `@deepseek-ai/dsh-client-ui-slots` validates the registration against the very
231
+ registry that DSH runs. Without them installed the affected tests skip with a
232
+ stated reason, so `npm test` still works from a fresh clone with no network.
233
+
197
234
  See [docs/VERIFICATION.md](docs/VERIFICATION.md) for what was actually verified
198
235
  and how, including the evidence behind the fork rule.
199
236
 
package/README.zh.md CHANGED
@@ -136,6 +136,29 @@ dsh-token-ledger export [选项] 导出 CSV 与 JSON
136
136
  (1 calls, 5100 tokens not yet flushed)
137
137
  ```
138
138
 
139
+ ## 设置页
140
+
141
+ 浏览器端会在设置侧边栏注册一个 **用量账本** 分区:
142
+
143
+ - **区间总计**:本月 / 本年 / 近 7 天三档可切换,每档显示 Token 总计、缓存命中率、
144
+ 调用次数,以及背后的四桶明细。缓存命中率定义为
145
+ `缓存读入 / (缓存读入 + 未命中输入)`——即输入中被缓存吸收的比例,因此完全不缓存的
146
+ 路由读数是 0%,而不是空白。
147
+ - **今日实时**:今天的 Token、命中率与调用次数,随每一步完成而变动。
148
+ - **用量热力图**:年 / 月 / 一周三档可切换;年和月是热力图,切到一周改为柱状图。
149
+ 热力档位相对窗口内最忙的一天取平方根,避免某一天特别大把其余全部压成最淡档。
150
+ - **按模型**:各模型的用量与各自命中率。
151
+
152
+ 页面只读一个路由 `GET /api/token-ledger/summary`,每 15 秒轮询一次。
153
+ 该路由会拒绝非回环来源,因此即使 web 服务器绑定到 `0.0.0.0` 也不会外泄。
154
+
155
+ 它需要带 web 服务器的 profile(`web` 或 `desktop`)。没有的话 `/tokens` 与 CLI
156
+ 照常可用,分区会明确说明而不是直接失败。
157
+
158
+ 概览**刻意不**通过设置命名空间下发:那需要 schema(一个真实依赖,而本包零依赖),
159
+ 而且每次防抖都会用可推导、可重放的数据重写一遍 `settings.yaml`。
160
+ 账本文件始终是唯一的记录来源。
161
+
139
162
  ## 配置
140
163
 
141
164
  按 `id` 覆盖组合条目:
@@ -172,13 +195,20 @@ dsh plugin --profile web remove @chenmiao8563/dsh-token-ledger
172
195
  ## 开发
173
196
 
174
197
  ```bash
175
- npm test # 49 个测试,无需安装任何依赖
198
+ npm install # 两个 devDependency:react 与 react-dom,供渲染测试使用
199
+ npm test # 96 个测试
176
200
  npm run verify # 打包不变式(零依赖、无安装脚本、无裸模块说明符)
177
201
  ```
178
202
 
179
203
  `npm test` 使用 Node 内置测试运行器。在禁止逐文件 spawn 子进程的受限环境里,
180
204
  改用 `npm run test:single-process`。
181
205
 
206
+ React **只是 devDependency**,消费者永远不会安装它:本包不带任何依赖、任何 peer
207
+ 依赖、任何安装脚本,而 pnpm 不会为依赖安装其 devDependencies。它在这里的作用是让
208
+ 浏览器端能用**真库**渲染并断言——这能抓到替身抓不到的东西:hook 顺序违规与非法
209
+ DOM 属性,React 会报出来,而手写的 `createElement` 会默默接受。没装它时这些渲染
210
+ 测试会带明确原因**跳过而不是失败**,所以全新克隆、无网络也能跑 `npm test`。
211
+
182
212
  实际验证了什么、怎么验证的(包括 fork 规则背后的证据)见
183
213
  [docs/VERIFICATION.md](docs/VERIFICATION.md)。
184
214
 
@@ -139,6 +139,49 @@ this similarity gate, which runs at publish time. The scoped name was
139
139
  re-verified end to end as shown above; the CLI command and Cordis entry id are
140
140
  unchanged.
141
141
 
142
+ ### Install from the npm registry, isolated
143
+
144
+ The published artifact was installed the way a stranger would install it, into a
145
+ disposable `DSH_HOME`:
146
+
147
+ ```
148
+ $ DSH_HOME=<tmp>/home <dsh-desktop-cli> plugin --profile web add @chenmiao8563/dsh-token-ledger
149
+ dsh: initialized profile web at <tmp>/home/profiles/web
150
+ Progress: resolved 1, reused 0, downloaded 0, added 0
151
+ dependencies:
152
+ + @chenmiao8563/dsh-token-ledger ^0.1.0
153
+ Packages: +1
154
+ Progress: resolved 1, reused 0, downloaded 1, added 1, done
155
+ Done in 2.7s using pnpm v11.8.0
156
+ ```
157
+
158
+ The lockfile records a real registry resolution with its integrity hash and the
159
+ declared engine range:
160
+
161
+ ```
162
+ '@chenmiao8563/dsh-token-ledger@0.1.0':
163
+ resolution: {integrity: sha512-yApGruS1Hcyq0oIOq8t6Y8gAJFefAoiHLzDsvPsecND6+DIj878x8YtQ5Ggz+PVYL9yTvaPAgIVcOhyBNvPGnw==}
164
+ engines: {node: '>=22.15.0'}
165
+ hasBin: true
166
+ ```
167
+
168
+ The reconciler added it to the bundle stack, and `pnpm-workspace.yaml` was left
169
+ untouched — **no `allowBuilds` entry was needed**, which is the installability
170
+ claim this document exists to check:
171
+
172
+ ```json
173
+ "dsh": { "profile": { "bundles": [
174
+ "@deepseek-ai/dsh-base",
175
+ "@deepseek-ai/dsh-web-app",
176
+ "@chenmiao8563/dsh-token-ledger"
177
+ ] } }
178
+ ```
179
+
180
+ The packument itself took roughly two minutes to become readable after the first
181
+ publish in the new scope — during that window `dist-tags` and the tarball were
182
+ already live while `GET /@chenmiao8563%2Fdsh-token-ledger` still returned 404.
183
+ That is registry propagation, not a failed publish.
184
+
142
185
  ### Note for whoever verifies this next
143
186
 
144
187
  On this machine the `dsh` shell shim hardcodes `DSH_HOME`, so exporting
@@ -151,18 +194,120 @@ $env:DSH_HOME = '<tmp>\home'
151
194
  & '<install>\DSH Desktop.exe' --expose-internals '<install>\resources\app.asar\lib\desktop-cli.js' plugin --profile web add <path>
152
195
  ```
153
196
 
197
+ ## Live host mount
198
+
199
+ Version 0.1.0 was installed into a real desktop profile, the host was restarted,
200
+ and the host log recorded the mount:
201
+
202
+ ```
203
+ 20:24:52 [I] [token-ledger] started a new ledger at <DSH_HOME>\token-ledger\ledger.json
204
+ 20:24:52 [E] [token-ledger] TypeError: command "tokens" input hint must be a string
205
+ 20:25:00 [I] [token-ledger] backfilled 139 stored session(s), 0 unreadable; total 790455336 tokens over 4518 calls
206
+ ```
207
+
208
+ This step was worth doing precisely because it failed. Both defects it exposed
209
+ were unreachable from the 49-test suite, and both are fixed in 0.1.1 with
210
+ regression tests:
211
+
212
+ | Defect | Evidence | Cause |
213
+ | --- | --- | --- |
214
+ | `/tokens` never registered | the `TypeError` above, and no `command/*` event when `/tokens` was typed | `input` was passed as a bare string; the registry requires `{ hint: string }`. The throw also aborted the rest of `apply()`, so the flush-on-dispose effect was never installed |
215
+ | backfill under-counted forks | 4,518 calls / 790,455,336 tokens against the CLI's 6,901 / 1,205,685,663 on the same logs | the inherited boundary was applied as a raw index, but the row-form log packs several logical events per record, so the declared count overshot the array and skipped those sessions entirely |
216
+
217
+ The diagnosis came from the ledger's own artifacts rather than from guesswork:
218
+ the host log line above, and a cross-tabulation of the ledger's 65 recorded
219
+ sessions against the 72 logs carrying a `session/end-seed` marker and the 29
220
+ declaring a parent. The 61 sessions outside both sets turned out to be
221
+ four-record logs with no usage at all, which is why they correctly have no entry.
222
+
223
+ ## Browser half (0.2.0)
224
+
225
+ What is checked here and what is not, because the difference is large.
226
+
227
+ **Checked by test.** `lib/overview.js` and `lib/route.js` are pure or near-pure
228
+ and are tested directly: range boundaries, cache-hit-rate arithmetic, gap-filled
229
+ series, the loopback and origin guard, method handling, and the 500-on-fault
230
+ path. The browser half is loaded through a stand-in module loader with a
231
+ stand-in React, which exercises the module wrapper, the registration contract,
232
+ and the whole render tree — formatting, heat levels, week-bar slicing, view
233
+ switching, and the empty, malformed and stale-data paths.
234
+ `scripts/verify-package.mjs` additionally fails the build if the browser half
235
+ requires anything the loader does not provide, if its module id stops matching
236
+ the package name, if it stops compiling as a classic script, or if a declared
237
+ client dependency is outside the client-half namespace.
238
+
239
+ **Checked by reading DSH's own source, not by inference.** The contracts the
240
+ browser half depends on were read out of the shipped bundles rather than copied
241
+ from a third-party plugin:
242
+
243
+ | Contract | Where it was read | Result |
244
+ | --- | --- | --- |
245
+ | The `settings.section` registration shape | the sections DSH ships — `dsh-client-ui-settings-models` (order 10), `-settings-plugins` (15) and `-ui-agent-preset` (20) — plus the shell in `-settings-general` that renders them via `renderSlot("settings.section", …, { only: active })` | identical field set; `locale` is optional, proven by `-settings-models` omitting it; the sidebar rows come from `id`, `order` and `label`, all of which this plugin supplies |
246
+ | The client module format | every `@deepseek-ai/dsh-client-*/lib/client.js` | byte-identical wrapper: `window.__ModuleLoader__.load({ id, factory })` with `var module = { exports: {} }` and `exports.apply`/`exports.inject` |
247
+ | When the factory runs | `dsh-client-modules` module docs | only registration happens at script execution; body side effects, styles included, run at materialization — which is why the stylesheet is installed inside `apply()` |
248
+ | The `dsh.client` declaration | `parseDshClient` in `dsh-client-modules/lib/index.js` | `platform` must be a string and only `web` is loaded; `inject` and `external` are optional string arrays; `external` — not `inject` — is what orders the module graph |
249
+ | What `dsh.client.inject` **contains** | the five `dsh.client` declarations DSH ships, two of them non-empty: `dsh-client-locale` and `dsh-client-ui-settings-general` | **package/entry ids, not service names.** A module's own exported `inject` is the separate service list. This plugin lists the two packages whose services it uses, which is the same convention, and an empty list is also legal (`dsh-client-connection` uses one) |
250
+ | What `external` contains | the module-graph walk in `dsh-client-modules/lib/client.js`, which runs every entry through `stripClientSuffix` before resolving it | module specifiers for *other rows*. This plugin requires only `react`, which the loader provides and no row owns, so it declares none |
251
+ | How `exports["./client"]` is located | `clientExportOf` in `dsh-client-modules/lib/index.js` | a plain string or an object with a string `default`; anything else throws. A plain string is used here, and `verify-package.mjs` pins it |
252
+ | Whether a **scoped** package id survives | `stripClientSuffix` in `dsh-client-modules/lib/client.js` | it removes only a trailing `/client`, so `@chenmiao8563/dsh-token-ledger` passes through intact. DSH's own client packages are scoped too, so the transport already carries ids containing `/` |
253
+
254
+ That reading corrected two real mistakes in the first draft of this half, both of
255
+ which would have been a silent failure rather than an error:
256
+
257
+ - `@deepseek-ai/dsh-client-ui-slots` was listed as a client dependency. It is a
258
+ pure core library (its manifest exports only `.`, and it declares no
259
+ `dsh.client`), so it is never a client module row.
260
+ - The settings shell package was listed as `-ui-settings`, which holds the
261
+ namespace-scope service; the section slot is declared and rendered by
262
+ `-ui-settings-general`.
263
+
264
+ **Checked under the real React.** `test/client-render.test.mjs` renders the
265
+ presentation component with React 18.3.1 — the same major version DSH bundles —
266
+ through `react-dom/server`, and asserts the actual markup: the range tabs and
267
+ their active state, the totals, hit rate and call count for each range, today's
268
+ block surviving a year-range selection, the year heat grid (one cell per day plus
269
+ five legend swatches), the month grid (every day of March), the week bars (seven,
270
+ with inline heights), bounded heat levels drawn from a five-entry palette,
271
+ proportional model bars, and the loading, error, stale and empty states. It also
272
+ captures `console.error` and `console.warn` and **fails if React complained at
273
+ all**, which is what catches an invalid DOM prop or a hook-order violation — the
274
+ class of defect a hand-rolled `createElement` accepts silently.
275
+
276
+ React is a devDependency, so this runs in CI and under `prepublishOnly`. Without
277
+ it installed the file skips with a stated reason rather than failing, which keeps
278
+ `npm test` working from a fresh clone with no network.
279
+
280
+ **Checked against the real slot registry.** `test/slot-registration.test.mjs`
281
+ feeds the registration `apply` actually builds into
282
+ `@deepseek-ai/dsh-client-ui-slots` — the registry DSH itself runs, pinned as a
283
+ devDependency to `0.1.2-rc.1`, the exact version this DSH bundles (`latest` on
284
+ npm is an older `0.0.1-rc.1`, so the version is pinned rather than ranged). It
285
+ asserts that the registry accepts the entry, that `resolveSlotLabel` resolves the
286
+ label thunk to a non-empty string — a label it could not resolve would render as
287
+ a blank sidebar row — that the entry id does not collide with the three sections
288
+ DSH ships, that a duplicate id is refused, that disposing retires the entry, and
289
+ that registering into an undeclared slot is refused, which is why the plugin uses
290
+ `slots.inject` rather than registering blind.
291
+
292
+ **Not checked.** Whether the client module loader accepts the file, whether the
293
+ shell renders the section, and how any of it looks. Nothing here executes
294
+ `window.__ModuleLoader__` or mounts the component into a DOM: the module's shape
295
+ is asserted to match every bundle DSH ships, and the registration is validated by
296
+ the real registry, but no single process loads the plugin the way the browser
297
+ does. The remaining risk is integration and visual — a first-run surprise, not a
298
+ contractual one.
299
+
154
300
  ## Not verified
155
301
 
156
302
  Stated plainly, because a verification file that only lists successes is not
157
303
  useful:
158
304
 
159
- - **Live in-process mount inside a running DSH host.** The composition is proven
160
- (`--dump-config`), and the plugin's behaviour against a Cordis stand-in is
161
- covered by tests, but the packaged desktop CLI exposes no way to boot the
162
- plugin tree without serving the GUI, so the plugin was not observed mounting
163
- inside a real host process, and `/tokens` was not exercised in a live
164
- conversation. The remaining risk is confined to the host's exact service
165
- contract at mount time, not to the counting logic.
305
+ - **`/tokens` end to end through the GUI.** The command now satisfies the
306
+ registry contract and is covered by a regression test, but the corrected build
307
+ has not yet been observed answering `/tokens` in a live conversation.
308
+ - **Backfill parity with the CLI on a real host.** Both paths now share one
309
+ boundary function, and each is tested, but the backfill's numbers have not
310
+ been compared against a CLI rebuild on the same host after the 0.1.1 fix.
166
311
  - **Any DSH release other than `0.1.2-rc.1`.** The API surface used is stable
167
312
  across the `0.1.2` line by inspection, not by test.
168
313
  - **Non-Windows platforms.** The logic is platform-independent and CI runs
package/lib/cli.js CHANGED
@@ -13,7 +13,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs'
13
13
  import { join } from 'node:path'
14
14
  import { fileURLToPath } from 'node:url'
15
15
 
16
- import { UsageLedger, inheritedCut } from './ledger.js'
16
+ import { UsageLedger, inheritedCut, isForkSession } from './ledger.js'
17
17
  import { readSessionLog } from './session-log.js'
18
18
  import { ledgerPaths, loadLedger, saveLedger, writeFileAtomic } from './store.js'
19
19
 
@@ -195,14 +195,17 @@ export function rebuildFromLogs(sessionsDir, { onWarning = () => {} } = {}) {
195
195
  const header = records[0]?.type === 'session' ? records[0] : undefined
196
196
  const fallbackId = file.replace(/\\/g, '/').split('/').slice(-2)[0]
197
197
  const sessionId = String(header?.id ?? fallbackId)
198
- const markerIndex = records.findIndex((record) => record.type === 'session/end-seed')
199
- const isFork = header?.parentSession !== undefined && header?.parentSession !== null
200
-
201
- let cut = 0
202
- if (isFork) {
203
- forks += 1
204
- if (markerIndex >= 0) cut = markerIndex
205
- else onWarning(`forked session ${sessionId} has no session/end-seed marker; folding it whole`)
198
+ const isFork = isForkSession(header)
199
+ if (isFork) forks += 1
200
+
201
+ // This file is the compact row form, so a stored `seq` is not an array
202
+ // index; the boundary is located structurally, by the marker. See
203
+ // inheritedCut for why the declared count cannot be trusted here.
204
+ const cut = inheritedCut({ header, events: records, inheritedEventCount: header?.seedLength })
205
+ if (isFork && cut === 0) {
206
+ onWarning(
207
+ `forked session ${sessionId} has no usable inheritance boundary; folding it whole, so its totals may include the parent prefix`,
208
+ )
206
209
  }
207
210
 
208
211
  // The newest event time lets the audit tell "the ledger is merely behind a