@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 +34 -1
- package/docs/VERIFICATION.md +75 -7
- package/lib/cli.js +12 -9
- package/lib/index.js +18 -6
- package/lib/ledger.js +63 -12
- package/package.json +1 -1
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.
|
|
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
|
package/docs/VERIFICATION.md
CHANGED
|
@@ -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
|
-
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
40
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
179
|
-
*
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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.
|
|
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",
|