@chenmiao8563/dsh-token-ledger 0.1.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.
@@ -0,0 +1,173 @@
1
+ # Verification
2
+
3
+ What was actually verified for `dsh-token-ledger` 0.1.0, on what, and how. The
4
+ point of this file is to be checkable and to state its own gaps.
5
+
6
+ ## Environment
7
+
8
+ | Item | Value |
9
+ | --- | --- |
10
+ | Date | 2026-09-10 |
11
+ | OS | Windows (win32) |
12
+ | Node.js | v24.18.0 |
13
+ | DSH | 0.1.2-rc.1 (packaged desktop build) |
14
+ | Host profile under test | a disposable `web` profile under an isolated `DSH_HOME` |
15
+
16
+ ## Test suite
17
+
18
+ `npm test` — 49 tests, no dependencies to install, no network:
19
+
20
+ | File | Tests | Covers |
21
+ | --- | --- | --- |
22
+ | `test/ledger.test.mjs` | 17 | counting rules, replacement, fork cut, idempotency, snapshot round trip, CSV, and a property-style cross-check against an independent naive implementation over 25 generated logs |
23
+ | `test/cli.test.mjs` | 13 | rebuild/audit/rebuild-write/export over synthetic homes, pending-vs-stale classification, tamper detection, exit codes, torn logs |
24
+ | `test/plugin.test.mjs` | 12 | the host half against a Cordis stand-in: backfill, fork vs resume, live folding, restart cursors, `/tokens` variants, degraded services, config overrides |
25
+ | `test/session-log.test.mjs` | 7 | Zstandard frame walking: exact round trips, multi-frame files, truncation rejection, torn JSONL lines |
26
+
27
+ ## Evidence from real session logs
28
+
29
+ These runs used a real DSH home with 139 stored sessions. They are the basis for
30
+ the counting claims in the README; the numbers are reproducible against any
31
+ populated home with the CLI.
32
+
33
+ ### Frame walking covers every byte
34
+
35
+ All 139 logs decoded with **228,868 frames** and **344,203 events**, and the
36
+ frame boundaries accounted for **100% of every file's bytes** — no magic-number
37
+ scanning, no silently dropped tail.
38
+
39
+ ### `totalTokens` is the four-bucket sum
40
+
41
+ Across **15,778** provider usage reports (messages, usage chunks and compaction
42
+ summaries), the provider's own `totalTokens` field differed from
43
+ `input + output + cacheRead + cacheWrite` **zero** times. This is why the ledger
44
+ derives the total instead of trusting the field, and why the buckets and the
45
+ total cannot drift apart.
46
+
47
+ `cacheWriteTokens` was non-zero in **zero** reports: DeepSeek does not populate
48
+ that bucket, so a zero there is expected rather than a bug.
49
+
50
+ ### Fork versus resume
51
+
52
+ The rule "cut a fork's prefix, never cut a resume's prefix" was derived from the
53
+ data rather than assumed:
54
+
55
+ | Finding | Count |
56
+ | --- | --- |
57
+ | Logs whose first record is a `session` header | 139 / 139 |
58
+ | Logs carrying a `session/end-seed` marker | 72 |
59
+ | ... of those, with a `parentSession` (a fork) | 29 |
60
+ | ... of those, without a parent (a resume) | 43 |
61
+ | Forks whose pre-marker usage fingerprints are a subset of the parent's history | 24, plus 5 whose prefix contains no usage at all (vacuously true) |
62
+ | Resumes whose prefix reappears later in the same file | 1 of 43 (consistent with a fingerprint collision between two identically-sized calls) |
63
+
64
+ Cutting only the 29 forks changes the home's total from **1,284,550,137** to
65
+ **1,204,627,868** tokens — **79,922,269 tokens** of double counting avoided.
66
+
67
+ ### A real audit
68
+
69
+ ```
70
+ $ dsh-token-ledger rebuild --write
71
+ scanned 139 session log(s), 344203 events, 29 fork(s), 0 unreadable
72
+ wrote <DSH_HOME>/token-ledger/ledger.json
73
+
74
+ $ dsh-token-ledger audit
75
+ scanned 139 session log(s), 344203 events, 29 fork(s), 0 unreadable
76
+
77
+ stored 6901 calls 1205685663 tokens
78
+ recomputed 6901 calls 1205685663 tokens
79
+
80
+ audit: match — the stored ledger equals a fresh fold of the raw logs
81
+ ```
82
+
83
+ Running `audit` while the session that was producing this very document kept
84
+ generating produced the intended non-alarming result instead of a false alarm:
85
+
86
+ ```
87
+ audit: match — 1 session(s) advanced after the ledger was written
88
+ (1 calls, 5100 tokens not yet flushed)
89
+ ```
90
+
91
+ ## Profile install, isolated
92
+
93
+ Verified with a disposable `DSH_HOME` so no real profile was touched:
94
+
95
+ ```
96
+ $ DSH_HOME=<tmp>/home <dsh-desktop-cli> plugin --profile web add <repo path>
97
+ dsh: initialized profile web at <tmp>/home/profiles/web
98
+ + @chenmiao8563/dsh-token-ledger link:C:/.../plugins/dsh-token-ledger
99
+ Done in 317ms using pnpm v11.8.0
100
+ ```
101
+
102
+ The profile manifest was updated by the CLI's own reconciler, which only adds a
103
+ dependency whose package declares `dsh.bundle.patch`:
104
+
105
+ ```json
106
+ "dsh": { "profile": { "bundles": [
107
+ "@deepseek-ai/dsh-base",
108
+ "@deepseek-ai/dsh-web-app",
109
+ "@chenmiao8563/dsh-token-ledger"
110
+ ] } }
111
+ ```
112
+
113
+ And the composition really contains the row, with the scoped specifier resolving
114
+ as a Cordis entry name:
115
+
116
+ ```
117
+ $ DSH_HOME=<tmp>/home <dsh-desktop-cli> --profile web --dump-config
118
+ # == @chenmiao8563/dsh-token-ledger
119
+ - id: token-ledger
120
+ name: '@chenmiao8563/dsh-token-ledger'
121
+ ```
122
+
123
+ Installing involved no build step and no `allowBuilds` entry, which is the
124
+ package's central installability claim.
125
+
126
+ ### Why the package is scoped
127
+
128
+ The unscoped name `dsh-token-ledger` was rejected at publish time:
129
+
130
+ ```
131
+ 403 Forbidden - PUT https://registry.npmjs.org/dsh-token-ledger - Package name
132
+ too similar to existing package dsh-tokenledger
133
+ ```
134
+
135
+ npm normalizes separators away before comparing, so `dsh-token-ledger` and the
136
+ existing `dsh-tokenledger` collide. Note that a registry lookup for the name
137
+ returned `E404`, which only proves the name is unclaimed — it does not predict
138
+ this similarity gate, which runs at publish time. The scoped name was
139
+ re-verified end to end as shown above; the CLI command and Cordis entry id are
140
+ unchanged.
141
+
142
+ ### Note for whoever verifies this next
143
+
144
+ On this machine the `dsh` shell shim hardcodes `DSH_HOME`, so exporting
145
+ `DSH_HOME` before calling `dsh` does **not** isolate anything — it edits the real
146
+ home. Isolate by invoking the packaged CLI directly:
147
+
148
+ ```powershell
149
+ $env:ELECTRON_RUN_AS_NODE = '1'
150
+ $env:DSH_HOME = '<tmp>\home'
151
+ & '<install>\DSH Desktop.exe' --expose-internals '<install>\resources\app.asar\lib\desktop-cli.js' plugin --profile web add <path>
152
+ ```
153
+
154
+ ## Not verified
155
+
156
+ Stated plainly, because a verification file that only lists successes is not
157
+ useful:
158
+
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.
166
+ - **Any DSH release other than `0.1.2-rc.1`.** The API surface used is stable
167
+ across the `0.1.2` line by inspection, not by test.
168
+ - **Non-Windows platforms.** The logic is platform-independent and CI runs
169
+ Linux, macOS and Windows jobs, but all manual verification above was on
170
+ Windows.
171
+ - **Provider billing agreement.** The ledger counts what the session log
172
+ records. It makes no claim about what a provider invoices, which can differ
173
+ for failed, retried or partially delivered requests.
package/lib/cli.js ADDED
@@ -0,0 +1,443 @@
1
+ /**
2
+ * `dsh-token-ledger` command line.
3
+ *
4
+ * The CLI is the audit half of the plugin. Because the ledger is a pure fold
5
+ * over session logs, the CLI can recompute the exact same numbers from the raw
6
+ * files on disk and diff them against whatever the running host persisted —
7
+ * without starting DSH, and without trusting the ledger file.
8
+ *
9
+ * @module dsh-token-ledger/cli
10
+ */
11
+
12
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
13
+ import { join } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+
16
+ import { UsageLedger, inheritedCut } from './ledger.js'
17
+ import { readSessionLog } from './session-log.js'
18
+ import { ledgerPaths, loadLedger, saveLedger, writeFileAtomic } from './store.js'
19
+
20
+ const USAGE = `dsh-token-ledger — token accounting for DeepSeek Harness
21
+
22
+ Usage
23
+ dsh-token-ledger [summary] [options] print the stored ledger (default)
24
+ dsh-token-ledger audit [options] recompute from raw logs and diff
25
+ dsh-token-ledger rebuild [options] recompute from raw logs
26
+ dsh-token-ledger export [options] write CSV and JSON exports
27
+
28
+ Options
29
+ --home <path> DSH home to read (default: $DSH_HOME, else ~/.dsh)
30
+ --ledger <path> ledger file to read or write
31
+ --out <path> export destination directory
32
+ --days <n> days to show in the summary (default 7)
33
+ --models <n> models to show in the summary (default 5)
34
+ --write with rebuild: replace the stored ledger
35
+ --json machine-readable output
36
+ --quiet suppress the human summary, keep the exit code
37
+ -h, --help show this help
38
+ -v, --version show the version
39
+
40
+ Exit codes
41
+ 0 success, or the audit matched
42
+ 1 the audit found a difference
43
+ 2 bad usage or an unreadable input
44
+ `
45
+
46
+ /**
47
+ * Read this package's version.
48
+ *
49
+ * @returns {string} the version, or `'unknown'`.
50
+ */
51
+ function packageVersion() {
52
+ try {
53
+ const path = fileURLToPath(new URL('../package.json', import.meta.url))
54
+ return JSON.parse(readFileSync(path, 'utf8')).version ?? 'unknown'
55
+ } catch {
56
+ return 'unknown'
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Parse `argv` into a command and options.
62
+ *
63
+ * @param {string[]} argv - arguments after the executable.
64
+ * @returns {{ command: string, home?: string, ledger?: string, out?: string, days: number, models: number, write: boolean, json: boolean, quiet: boolean, help: boolean, version: boolean, unknown: string[] }} the parsed invocation.
65
+ */
66
+ export function parseArgs(argv) {
67
+ const options = {
68
+ command: 'summary',
69
+ days: 7,
70
+ models: 5,
71
+ write: false,
72
+ json: false,
73
+ quiet: false,
74
+ help: false,
75
+ version: false,
76
+ unknown: [],
77
+ }
78
+ const commands = new Set(['summary', 'audit', 'rebuild', 'export', 'help'])
79
+ let sawCommand = false
80
+
81
+ for (let index = 0; index < argv.length; index += 1) {
82
+ const argument = argv[index]
83
+ const next = () => {
84
+ index += 1
85
+ return argv[index]
86
+ }
87
+ switch (argument) {
88
+ case '-h':
89
+ case '--help':
90
+ options.help = true
91
+ break
92
+ case '-v':
93
+ case '--version':
94
+ options.version = true
95
+ break
96
+ case '--write':
97
+ options.write = true
98
+ break
99
+ case '--json':
100
+ options.json = true
101
+ break
102
+ case '--quiet':
103
+ options.quiet = true
104
+ break
105
+ case '--home':
106
+ options.home = next()
107
+ break
108
+ case '--ledger':
109
+ options.ledger = next()
110
+ break
111
+ case '--out':
112
+ options.out = next()
113
+ break
114
+ case '--days':
115
+ options.days = Number.parseInt(next() ?? '', 10)
116
+ break
117
+ case '--models':
118
+ options.models = Number.parseInt(next() ?? '', 10)
119
+ break
120
+ default: {
121
+ if (argument.startsWith('--home=')) options.home = argument.slice(7)
122
+ else if (argument.startsWith('--ledger=')) options.ledger = argument.slice(9)
123
+ else if (argument.startsWith('--out=')) options.out = argument.slice(6)
124
+ else if (!sawCommand && commands.has(argument)) {
125
+ options.command = argument
126
+ sawCommand = true
127
+ } else options.unknown.push(argument)
128
+ }
129
+ }
130
+ }
131
+ if (options.command === 'help') options.help = true
132
+ return options
133
+ }
134
+
135
+ /**
136
+ * Recursively find every `session.jsonl.zstd` under a directory.
137
+ *
138
+ * @param {string} directory - the directory to scan.
139
+ * @returns {string[]} absolute file paths.
140
+ */
141
+ export function findSessionLogs(directory) {
142
+ const found = []
143
+ const visit = (current) => {
144
+ let entries
145
+ try {
146
+ entries = readdirSync(current, { withFileTypes: true })
147
+ } catch {
148
+ return
149
+ }
150
+ for (const entry of entries) {
151
+ const path = join(current, entry.name)
152
+ if (entry.isDirectory()) visit(path)
153
+ else if (entry.isFile() && entry.name.endsWith('.jsonl.zstd')) found.push(path)
154
+ }
155
+ }
156
+ visit(directory)
157
+ return found
158
+ }
159
+
160
+ /**
161
+ * Rebuild a ledger by folding every raw session log on disk.
162
+ *
163
+ * Fork handling is deliberately explicit here. The stored file is a *compact
164
+ * row* encoding, so an event's `seq` is not its array index, and the inherited
165
+ * boundary must be located structurally: a forked session's own stream starts
166
+ * at the first `session/end-seed` record. Sessions that carry the same marker
167
+ * without a parent are resumes of their own history and are folded in full.
168
+ *
169
+ * @param {string} sessionsDir - the directory holding session logs.
170
+ * @param {{ onWarning?: (message: string) => void }} [options] - diagnostics sink.
171
+ * @returns {{ ledger: UsageLedger, scanned: number, skipped: number, forks: number, events: number, lastTimes: Map<string, number> }} the rebuild.
172
+ */
173
+ export function rebuildFromLogs(sessionsDir, { onWarning = () => {} } = {}) {
174
+ const ledger = new UsageLedger()
175
+ const files = findSessionLogs(sessionsDir)
176
+ const lastTimes = new Map()
177
+ let skipped = 0
178
+ let forks = 0
179
+ let events = 0
180
+
181
+ for (const file of files) {
182
+ let records
183
+ try {
184
+ records = readSessionLog(file)
185
+ } catch (error) {
186
+ skipped += 1
187
+ onWarning(`could not decode ${file}: ${error instanceof Error ? error.message : String(error)}`)
188
+ continue
189
+ }
190
+ if (records.length === 0) {
191
+ skipped += 1
192
+ continue
193
+ }
194
+
195
+ const header = records[0]?.type === 'session' ? records[0] : undefined
196
+ const fallbackId = file.replace(/\\/g, '/').split('/').slice(-2)[0]
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`)
206
+ }
207
+
208
+ // The newest event time lets the audit tell "the ledger is merely behind a
209
+ // session that is still being written" apart from "the ledger is wrong".
210
+ for (const record of records) {
211
+ if (typeof record?.time !== 'number' || !Number.isFinite(record.time)) continue
212
+ const seen = lastTimes.get(sessionId)
213
+ if (seen === undefined || record.time > seen) lastTimes.set(sessionId, record.time)
214
+ }
215
+
216
+ events += records.length
217
+ ledger.adoptHistory({ sessionId, events: records, inheritedEventCount: cut })
218
+ }
219
+
220
+ return { ledger, scanned: files.length, skipped, forks, events, lastTimes }
221
+ }
222
+
223
+ /**
224
+ * Compare two ledgers, reporting where they disagree and why.
225
+ *
226
+ * A running host writes its ledger on a debounce, so a live session is
227
+ * routinely a little ahead of the stored file. That is expected and must not
228
+ * read as corruption. The ledger's own `updatedAt` settles it: a differing
229
+ * session whose newest event is *newer than the ledger* has simply kept
230
+ * running, while a differing session whose newest event predates the ledger is
231
+ * a real disagreement between the file and the logs.
232
+ *
233
+ * @param {UsageLedger} stored - the ledger read from disk.
234
+ * @param {UsageLedger} recomputed - the ledger folded from raw logs.
235
+ * @param {{ updatedAt?: number, lastTimes?: Map<string, number> }} [context] - evidence for classification.
236
+ * @returns {{ equal: boolean, matches: boolean, totals: object, days: object[], models: object[], sessions: object[], stale: object[], pending: object[] }} the diff.
237
+ */
238
+ export function diffLedgers(stored, recomputed, { updatedAt = 0, lastTimes = new Map() } = {}) {
239
+ const rows = (left, right) => {
240
+ const keys = new Set([...left.keys(), ...right.keys()])
241
+ const out = []
242
+ for (const key of keys) {
243
+ const a = left.get(key)?.counters.totalTokens ?? 0
244
+ const b = right.get(key)?.counters.totalTokens ?? 0
245
+ if (a !== b) out.push({ key, stored: a, recomputed: b, delta: b - a })
246
+ }
247
+ return out
248
+ }
249
+
250
+ const days = rows(stored.daily, recomputed.daily).sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta))
251
+ const models = rows(stored.models, recomputed.models).sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta))
252
+ const sessions = rows(stored.sessions, recomputed.sessions).sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta))
253
+
254
+ const pending = []
255
+ const stale = []
256
+ for (const row of sessions) {
257
+ const newest = lastTimes.get(row.key) ?? 0
258
+ if (updatedAt > 0 && newest > updatedAt) pending.push(row)
259
+ else stale.push(row)
260
+ }
261
+
262
+ const totalsEqual =
263
+ stored.totals.totalTokens === recomputed.totals.totalTokens && stored.calls === recomputed.calls
264
+
265
+ // Any disagreement anywhere: the grand totals, a per-session figure, or a
266
+ // day/model row contradicting the fold it should sum to.
267
+ const differences = !totalsEqual || sessions.length > 0 || days.length > 0 || models.length > 0
268
+ // A difference is excused only when every differing session has kept running
269
+ // since the ledger was written. With no such session, a difference in the day
270
+ // or model tables is an internal contradiction being caught, not live traffic.
271
+ const explainedByLiveActivity = pending.length > 0 && stale.length === 0
272
+
273
+ return {
274
+ // Nothing differs anywhere.
275
+ equal: !differences,
276
+ // Nothing differs except activity that happened after the ledger was written.
277
+ matches: !differences || explainedByLiveActivity,
278
+ totals: {
279
+ stored: { calls: stored.calls, totalTokens: stored.totals.totalTokens },
280
+ recomputed: { calls: recomputed.calls, totalTokens: recomputed.totals.totalTokens },
281
+ deltaTokens: recomputed.totals.totalTokens - stored.totals.totalTokens,
282
+ deltaCalls: recomputed.calls - stored.calls,
283
+ },
284
+ days,
285
+ models,
286
+ sessions,
287
+ stale,
288
+ pending,
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Run the CLI.
294
+ *
295
+ * @param {string[]} argv - arguments after the executable.
296
+ * @param {{ stdout?: (text: string) => void, stderr?: (text: string) => void, env?: NodeJS.ProcessEnv }} [io] - output sinks.
297
+ * @returns {number} the process exit code.
298
+ */
299
+ export function run(argv, io = {}) {
300
+ const out = io.stdout ?? ((text) => process.stdout.write(`${text}\n`))
301
+ const err = io.stderr ?? ((text) => process.stderr.write(`${text}\n`))
302
+ const env = io.env ?? process.env
303
+ const options = parseArgs(argv)
304
+
305
+ if (options.help) {
306
+ out(USAGE)
307
+ return 0
308
+ }
309
+ if (options.version) {
310
+ out(packageVersion())
311
+ return 0
312
+ }
313
+ if (options.unknown.length > 0) {
314
+ err(`unknown argument(s): ${options.unknown.join(' ')}\n`)
315
+ err(USAGE)
316
+ return 2
317
+ }
318
+ if (!Number.isFinite(options.days) || options.days <= 0 || !Number.isFinite(options.models) || options.models <= 0) {
319
+ err('--days and --models must be positive integers\n')
320
+ return 2
321
+ }
322
+
323
+ const paths = ledgerPaths(options.home, env)
324
+ const ledgerPath = options.ledger ?? paths.ledger
325
+
326
+ if (options.command === 'audit' || options.command === 'rebuild' || options.command === 'export') {
327
+ const warnings = []
328
+ const { ledger: recomputed, scanned, skipped, forks, events, lastTimes } = rebuildFromLogs(paths.sessionsDir, {
329
+ onWarning: (message) => warnings.push(message),
330
+ })
331
+ // Emit diagnostics before any branch returns.
332
+ for (const warning of warnings) err(`warning: ${warning}\n`)
333
+
334
+ if (options.command === 'export') {
335
+ const destination = options.out ?? paths.exportsDir
336
+ const stamp = new Date().toISOString().slice(0, 10)
337
+ let written
338
+ try {
339
+ written = [
340
+ writeFileAtomic(join(destination, `daily-${stamp}.csv`), recomputed.toCsv('daily')),
341
+ writeFileAtomic(join(destination, `sessions-${stamp}.csv`), recomputed.toCsv('sessions')),
342
+ writeFileAtomic(join(destination, `models-${stamp}.csv`), recomputed.toCsv('models')),
343
+ saveLedger(join(destination, `ledger-${stamp}.json`), recomputed.snapshot()),
344
+ ]
345
+ } catch (error) {
346
+ err(`export failed: ${error instanceof Error ? error.message : String(error)}\n`)
347
+ return 2
348
+ }
349
+ if (options.json) out(JSON.stringify({ scanned, events, forks, skipped, written }, null, 2))
350
+ else {
351
+ out(`scanned ${scanned} session log(s), ${events} events, ${forks} fork(s), ${skipped} unreadable`)
352
+ for (const path of written) out(` ${path}`)
353
+ }
354
+ return 0
355
+ }
356
+
357
+ if (options.command === 'rebuild') {
358
+ if (options.write) {
359
+ try {
360
+ saveLedger(ledgerPath, recomputed.snapshot())
361
+ } catch (error) {
362
+ err(`could not write ${ledgerPath}: ${error instanceof Error ? error.message : String(error)}\n`)
363
+ return 2
364
+ }
365
+ }
366
+ if (options.json) out(JSON.stringify({ scanned, events, forks, skipped, write: options.write, totals: recomputed.snapshot().totals }, null, 2))
367
+ else {
368
+ out(`scanned ${scanned} session log(s), ${events} events, ${forks} fork(s), ${skipped} unreadable`)
369
+ if (options.write) out(`wrote ${ledgerPath}`)
370
+ if (!options.quiet) out(`\n${recomputed.format({ days: options.days, models: options.models })}`)
371
+ }
372
+ return 0
373
+ }
374
+
375
+ // audit
376
+ const storedSnapshot = loadLedger(ledgerPath)
377
+ if (storedSnapshot === undefined) {
378
+ err(`no ledger at ${ledgerPath}; run "dsh-token-ledger rebuild --write" to create one\n`)
379
+ return 2
380
+ }
381
+ const stored = new UsageLedger()
382
+ if (!stored.restore(storedSnapshot)) {
383
+ err(`ledger at ${ledgerPath} has an unsupported version; rerun with rebuild --write\n`)
384
+ return 2
385
+ }
386
+ const diff = diffLedgers(stored, recomputed, {
387
+ updatedAt: typeof storedSnapshot.updatedAt === 'number' ? storedSnapshot.updatedAt : 0,
388
+ lastTimes,
389
+ })
390
+
391
+ if (options.json) {
392
+ out(JSON.stringify({ scanned, events, forks, skipped, warnings, ...diff }, null, 2))
393
+ } else {
394
+ out(`scanned ${scanned} session log(s), ${events} events, ${forks} fork(s), ${skipped} unreadable`)
395
+ out('')
396
+ out(` stored ${stored.calls} calls ${stored.totals.totalTokens} tokens`)
397
+ out(` recomputed ${recomputed.calls} calls ${recomputed.totals.totalTokens} tokens`)
398
+ out('')
399
+ if (diff.equal) {
400
+ out(' audit: match — the stored ledger equals a fresh fold of the raw logs')
401
+ } else if (diff.matches) {
402
+ out(
403
+ ` audit: match — ${diff.pending.length} session(s) advanced after the ledger was written`,
404
+ )
405
+ out(` (${diff.totals.deltaCalls} calls, ${diff.totals.deltaTokens} tokens not yet flushed)`)
406
+ } else {
407
+ out(` audit: MISMATCH — ${diff.totals.deltaCalls} calls, ${diff.totals.deltaTokens} tokens`)
408
+ if (diff.pending.length > 0) {
409
+ out(` ${diff.pending.length} differing session(s) merely ran after the ledger was written`)
410
+ }
411
+ const section = (label, rows) => {
412
+ if (rows.length === 0) return
413
+ out('')
414
+ out(` ${label}`)
415
+ for (const row of rows.slice(0, 10)) {
416
+ out(` ${row.key} stored=${row.stored} recomputed=${row.recomputed} delta=${row.delta}`)
417
+ }
418
+ if (rows.length > 10) out(` … and ${rows.length - 10} more`)
419
+ }
420
+ section('days', diff.days)
421
+ section('models', diff.models)
422
+ section('sessions (stale)', diff.stale)
423
+ }
424
+ }
425
+ return diff.matches ? 0 : 1
426
+ }
427
+
428
+ // summary
429
+ const snapshot = loadLedger(ledgerPath)
430
+ if (snapshot === undefined) {
431
+ err(`no ledger at ${ledgerPath}\n`)
432
+ err('the plugin writes one while DSH runs; "dsh-token-ledger rebuild --write" can build it from raw logs\n')
433
+ return 2
434
+ }
435
+ const ledger = new UsageLedger()
436
+ if (!ledger.restore(snapshot)) {
437
+ err(`ledger at ${ledgerPath} has an unsupported version\n`)
438
+ return 2
439
+ }
440
+ if (options.json) out(JSON.stringify(ledger.snapshot(), null, 2))
441
+ else if (!options.quiet) out(ledger.format({ days: options.days, models: options.models }))
442
+ return 0
443
+ }