@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.
- package/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +225 -0
- package/README.zh.md +208 -0
- package/bin/dsh-token-ledger.mjs +12 -0
- package/cordis.patch.yml +20 -0
- package/docs/VERIFICATION.md +173 -0
- package/lib/cli.js +443 -0
- package/lib/index.js +253 -0
- package/lib/ledger.js +653 -0
- package/lib/session-log.js +142 -0
- package/lib/store.js +101 -0
- package/package.json +75 -0
|
@@ -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
|
+
}
|