@chenmiao8563/dsh-token-ledger 0.1.0 → 0.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/CHANGELOG.md CHANGED
@@ -8,6 +8,38 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  Nothing yet.
10
10
 
11
+ ## [0.1.1] - 2026-09-10
12
+
13
+ Both fixes come from mounting the plugin in a real DSH host, which is the only
14
+ place either defect was observable. Neither was reachable from the test suite,
15
+ so both now have regression tests.
16
+
17
+ ### Fixed
18
+
19
+ - **`/tokens` never registered.** The command definition passed `input` as a
20
+ bare string, and the command registry requires an object carrying a non-empty
21
+ `hint`. The resulting `TypeError` was thrown during mount, which also aborted
22
+ the rest of `apply()` — so the flush-on-dispose effect was never installed
23
+ either.
24
+ - **Backfill under-counted forked sessions.** The inherited boundary was applied
25
+ as a raw index, but a stored session can arrive in the compact row form where
26
+ several logical events share one record and the declared count overshoots the
27
+ array length by an order of magnitude. Applying it skipped those sessions
28
+ entirely, which under-counted a real 139-session home by hundreds of millions
29
+ of tokens. The boundary is now located by the `session/end-seed` marker, which
30
+ is present and equivalent in both coordinate spaces, with the declared count
31
+ as a fallback that is refused when it cannot index the array it came with. A
32
+ fork with no usable boundary is folded whole and warned about, so the error
33
+ direction is over-counting rather than silent loss.
34
+
35
+ ### Changed
36
+
37
+ - The on-disk ledger format is now version 2. A ledger written by 0.1.0 is
38
+ ignored on start rather than trusted, because its cursors would otherwise
39
+ mark every session as consumed and preserve the mis-counted totals forever.
40
+ The ledger rebuilds itself from the session logs, so the only effect is that
41
+ the first start after upgrading does the backfill again.
42
+
11
43
  ## [0.1.0] - 2026-09-10
12
44
 
13
45
  ### Added
@@ -31,5 +63,6 @@ Nothing yet.
31
63
  - Zero runtime dependencies, zero peer dependencies and no install scripts, so
32
64
  the package installs without a build step.
33
65
 
34
- [Unreleased]: https://github.com/chenmiao8563/dsh-token-ledger/compare/v0.1.0...HEAD
66
+ [Unreleased]: https://github.com/chenmiao8563/dsh-token-ledger/compare/v0.1.1...HEAD
67
+ [0.1.1]: https://github.com/chenmiao8563/dsh-token-ledger/compare/v0.1.0...v0.1.1
35
68
  [0.1.0]: https://github.com/chenmiao8563/dsh-token-ledger/releases/tag/v0.1.0
@@ -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,43 @@ $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
+
154
223
  ## Not verified
155
224
 
156
225
  Stated plainly, because a verification file that only lists successes is not
157
226
  useful:
158
227
 
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.
228
+ - **`/tokens` end to end through the GUI.** The command now satisfies the
229
+ registry contract and is covered by a regression test, but the corrected build
230
+ has not yet been observed answering `/tokens` in a live conversation.
231
+ - **Backfill parity with the CLI on a real host.** Both paths now share one
232
+ boundary function, and each is tested, but the backfill's numbers have not
233
+ been compared against a CLI rebuild on the same host after the 0.1.1 fix.
166
234
  - **Any DSH release other than `0.1.2-rc.1`.** The API surface used is stable
167
235
  across the `0.1.2` line by inspection, not by test.
168
236
  - **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
package/lib/index.js CHANGED
@@ -22,7 +22,7 @@
22
22
  * @module dsh-token-ledger
23
23
  */
24
24
 
25
- import { UsageLedger, inheritedCut } from './ledger.js'
25
+ import { UsageLedger, inheritedCut, isForkSession } from './ledger.js'
26
26
  import { ledgerPaths, loadLedger, saveLedger, writeFileAtomic } from './store.js'
27
27
  import { join } from 'node:path'
28
28
 
@@ -131,12 +131,22 @@ export function apply(ctx, config = {}) {
131
131
  const events = stored?.events
132
132
  if (!Array.isArray(events)) continue
133
133
  const meta = stored?.meta ?? header
134
- ledger.adoptHistory({
135
- sessionId: id,
134
+ // A fork's prefix belongs to its parent, which is counted separately.
135
+ // The boundary is located by the `session/end-seed` marker rather than
136
+ // by a raw count, because a count can be expressed in a different
137
+ // coordinate space than the array we were handed. See inheritedCut.
138
+ const inheritedEventCount = inheritedCut({
139
+ header: meta,
136
140
  events,
137
- // Only a fork's prefix belongs to another session; see inheritedCut.
138
- inheritedEventCount: inheritedCut(meta, stored?.inheritedEventCount ?? meta?.seedLength),
141
+ inheritedEventCount: stored?.inheritedEventCount ?? meta?.seedLength,
139
142
  })
143
+ if (isForkSession(meta) && inheritedEventCount === 0) {
144
+ log.warn(
145
+ '[token-ledger] forked session %s has no usable inheritance boundary; folding it whole, so its totals may include the parent prefix',
146
+ id,
147
+ )
148
+ }
149
+ ledger.adoptHistory({ sessionId: id, events, inheritedEventCount })
140
150
  sessions += 1
141
151
  } catch (error) {
142
152
  errors += 1
@@ -230,7 +240,9 @@ export function apply(ctx, config = {}) {
230
240
  commandCtx.commands.register({
231
241
  name: 'tokens',
232
242
  description: 'Show the cumulative token ledger (per day and per model)',
233
- input: 'summary | export | json | path',
243
+ // `input` must be an object carrying a non-empty `hint` string; a bare
244
+ // string is rejected by the command registry with a TypeError.
245
+ input: { hint: 'summary | export | json | path' },
234
246
  handler: runCommand,
235
247
  })
236
248
  })
package/lib/ledger.js CHANGED
@@ -36,8 +36,17 @@
36
36
  * @module dsh-token-ledger/ledger
37
37
  */
38
38
 
39
- /** Bumped whenever the folded state shape changes, so stale files are ignored. */
40
- export const LEDGER_VERSION = 1
39
+ /**
40
+ * Bumped whenever the folded state shape **or the counting semantics** change.
41
+ *
42
+ * A stored ledger is a cache of a fold, and its per-session cursors claim those
43
+ * sessions are fully consumed. State written by a version that counted
44
+ * differently would therefore keep its wrong totals forever: the fixed version
45
+ * would skip every session as already consumed. Version 2 exists precisely
46
+ * because 0.1.0's boundary rule mis-counted forks, so its files must be
47
+ * discarded and rebuilt rather than trusted.
48
+ */
49
+ export const LEDGER_VERSION = 2
41
50
 
42
51
  /** The four disjoint provider usage buckets, all defaulting to zero. */
43
52
  const BUCKET_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']
@@ -156,15 +165,24 @@ export function dateKeyOf(timeMs, now = new Date()) {
156
165
  }
157
166
 
158
167
  /**
159
- * Decide how much of a session's stored history is somebody else's.
168
+ * Whether a stored session header describes a fork.
169
+ *
170
+ * @param {object} [header] - a `SessionHeader`-shaped object.
171
+ * @returns {boolean} true when the session has a parent.
172
+ */
173
+ export function isForkSession(header) {
174
+ return header?.parentSession !== undefined && header?.parentSession !== null
175
+ }
176
+
177
+ /**
178
+ * Decide how much of a session's event list belongs to another session.
160
179
  *
161
180
  * A session log can carry a prefix of already-recorded history. Two very
162
181
  * different situations produce one, and only one of them must be cut:
163
182
  *
164
183
  * - **Fork** (`header.parentSession` is set): the prefix is the *parent
165
184
  * session's* history, and the parent is counted separately. Folding the
166
- * prefix here would count those tokens twice, so it is cut at
167
- * `inheritedEventCount`.
185
+ * prefix here would count those tokens twice, so it is cut.
168
186
  * - **Resume** (no parent): the prefix is *this session's own* earlier history,
169
187
  * stored once, with no other session to double count it against. Cutting it
170
188
  * would lose tokens, so nothing is cut.
@@ -175,14 +193,42 @@ export function dateKeyOf(timeMs, now = new Date()) {
175
193
  * while for the non-forked logs carrying the same marker the prefix did not
176
194
  * reappear later in the file.
177
195
  *
178
- * @param {object} [header] - a `SessionHeader`-shaped object.
179
- * @param {number} [inheritedEventCount] - the cut storage reports for this session.
196
+ * ## Why the boundary is located by the marker
197
+ *
198
+ * The obvious implementation — skip `inheritedEventCount` leading entries — is
199
+ * wrong, and shipping it under-counted a real 139-session home by hundreds of
200
+ * millions of tokens. A stored session reaches this fold in one of two
201
+ * coordinate spaces:
202
+ *
203
+ * - the **logical** event list, where positions match `seq` and
204
+ * `inheritedEventCount` is directly meaningful;
205
+ * - the **compact row** form the log is stored in, where several logical events
206
+ * share one record, so the declared count overshoots the array length and
207
+ * applying it skipped the entire session.
208
+ *
209
+ * `session/end-seed` is present in both forms and marks the same boundary, so
210
+ * it is authoritative. The declared count is only a fallback, and only when it
211
+ * is a plausible index into the list we were actually handed: a count that
212
+ * reaches past the end proves it is not in this list's space and is refused
213
+ * rather than trusted. Callers are expected to warn when a fork yields a cut of
214
+ * zero, because that over-counts rather than silently losing data.
215
+ *
216
+ * @param {object} input - what is known about the session's stored content.
217
+ * @param {object} [input.header] - stored header/meta carrying `parentSession`.
218
+ * @param {object[]} [input.events] - the stored event list about to be folded.
219
+ * @param {number} [input.inheritedEventCount] - the cut storage declares.
180
220
  * @returns {number} the number of leading events to skip.
181
221
  */
182
- export function inheritedCut(header, inheritedEventCount) {
183
- const isFork = header?.parentSession !== undefined && header?.parentSession !== null
184
- if (!isFork) return 0
185
- return typeof inheritedEventCount === 'number' && inheritedEventCount > 0 ? inheritedEventCount : 0
222
+ export function inheritedCut({ header, events, inheritedEventCount } = {}) {
223
+ if (!isForkSession(header)) return 0
224
+ const list = Array.isArray(events) ? events : []
225
+ const marker = list.findIndex((event) => event?.type === 'session/end-seed')
226
+ if (marker >= 0) return marker + 1
227
+ const declared =
228
+ typeof inheritedEventCount === 'number' && Number.isFinite(inheritedEventCount) && inheritedEventCount > 0
229
+ ? Math.floor(inheritedEventCount)
230
+ : 0
231
+ return declared > 0 && declared < list.length ? declared : 0
186
232
  }
187
233
 
188
234
  /**
@@ -514,13 +560,18 @@ export class UsageLedger {
514
560
  * Merge a snapshot back in. Returns false for missing or incompatible state,
515
561
  * which callers treat as "start fresh" rather than as an error.
516
562
  *
563
+ * The postcondition of a `false` return is an empty ledger, never a partially
564
+ * loaded one: the state is cleared before the snapshot is judged, so a
565
+ * rejected file cannot leave stale totals or cursors behind for the next
566
+ * caller to mistake for real data.
567
+ *
517
568
  * @param {unknown} snapshot - a previously produced snapshot.
518
569
  * @returns {boolean} whether the snapshot was usable.
519
570
  */
520
571
  restore(snapshot) {
572
+ this.reset()
521
573
  if (snapshot === null || typeof snapshot !== 'object') return false
522
574
  if (snapshot.version !== LEDGER_VERSION) return false
523
- this.reset()
524
575
 
525
576
  const absorb = (raw) => countersFromUsage(raw)
526
577
  const totals = absorb(snapshot.totals)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chenmiao8563/dsh-token-ledger",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Transparent, auditable token accounting for DeepSeek Harness: a restart-safe ledger over the durable session log, plus a CLI that recomputes it from raw logs and diffs the result.",
5
5
  "keywords": [
6
6
  "dsh",