@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
package/lib/index.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dsh-token-ledger` host half.
|
|
3
|
+
*
|
|
4
|
+
* A transparent, restart-safe token ledger for DeepSeek Harness. It folds the
|
|
5
|
+
* durable session event stream into per-session, per-day and per-model token
|
|
6
|
+
* totals, keeps the result in `<DSH_HOME>/token-ledger/ledger.json`, and
|
|
7
|
+
* answers `/tokens` in the conversation input.
|
|
8
|
+
*
|
|
9
|
+
* Design constraints, in order of importance:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Installable anywhere.** The module imports nothing but Node builtins and
|
|
12
|
+
* its own files, so it cannot fail on a missing or drifting peer dependency.
|
|
13
|
+
* It also declares no install scripts, so `dsh plugin add` from a git URL
|
|
14
|
+
* works without pre-authorizing a pnpm build.
|
|
15
|
+
* 2. **Never in the model's way.** It registers no prompt section, no message
|
|
16
|
+
* and no model-facing tool, so it cannot change a request prefix and cannot
|
|
17
|
+
* affect KV-cache reuse. The ledger is read by a command and by the CLI.
|
|
18
|
+
* 3. **Never the reason a session fails.** Every hook is wrapped so a ledger
|
|
19
|
+
* fault degrades to a warning; folding is pure and cannot mutate session
|
|
20
|
+
* state.
|
|
21
|
+
*
|
|
22
|
+
* @module dsh-token-ledger
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { UsageLedger, inheritedCut } from './ledger.js'
|
|
26
|
+
import { ledgerPaths, loadLedger, saveLedger, writeFileAtomic } from './store.js'
|
|
27
|
+
import { join } from 'node:path'
|
|
28
|
+
|
|
29
|
+
/** Plugin name, as it appears in the Cordis tree. */
|
|
30
|
+
export const name = 'token-ledger'
|
|
31
|
+
|
|
32
|
+
/** Milliseconds to coalesce ledger writes after activity. */
|
|
33
|
+
const PERSIST_DEBOUNCE_MS = 2000
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Mount the ledger.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} ctx - the Cordis context for this plugin's fiber.
|
|
39
|
+
* @param {{ ledgerPath?: string, backfill?: boolean }} [config] - composition config.
|
|
40
|
+
* @returns {void}
|
|
41
|
+
*/
|
|
42
|
+
export function apply(ctx, config = {}) {
|
|
43
|
+
const paths = ledgerPaths()
|
|
44
|
+
const ledgerPath = typeof config.ledgerPath === 'string' && config.ledgerPath !== '' ? config.ledgerPath : paths.ledger
|
|
45
|
+
const shouldBackfill = config.backfill !== false
|
|
46
|
+
|
|
47
|
+
const ledger = new UsageLedger()
|
|
48
|
+
const restored = ledger.restore(loadLedger(ledgerPath))
|
|
49
|
+
|
|
50
|
+
const log = {
|
|
51
|
+
/** @param {string} message @param {...unknown} rest */
|
|
52
|
+
info: (message, ...rest) => ctx.logger?.info?.(message, ...rest),
|
|
53
|
+
warn: (message, ...rest) => ctx.logger?.warn?.(message, ...rest),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let timer
|
|
57
|
+
let writing = Promise.resolve()
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Write the ledger, serializing writes so two flushes cannot interleave.
|
|
61
|
+
*
|
|
62
|
+
* @returns {Promise<void>} settles when the file is on disk.
|
|
63
|
+
*/
|
|
64
|
+
const flush = () => {
|
|
65
|
+
writing = writing
|
|
66
|
+
.then(() => {
|
|
67
|
+
saveLedger(ledgerPath, ledger.snapshot())
|
|
68
|
+
})
|
|
69
|
+
.catch((error) => {
|
|
70
|
+
log.warn('[token-ledger] could not write %s: %o', ledgerPath, error)
|
|
71
|
+
})
|
|
72
|
+
return writing
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @param {number} [delay] - coalescing delay in milliseconds. */
|
|
76
|
+
const schedule = (delay = PERSIST_DEBOUNCE_MS) => {
|
|
77
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
78
|
+
timer = setTimeout(() => {
|
|
79
|
+
timer = undefined
|
|
80
|
+
void flush()
|
|
81
|
+
}, delay)
|
|
82
|
+
// Do not hold the host process open just to write a ledger.
|
|
83
|
+
timer.unref?.()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Fold a live session's unconsumed events.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} session - a live Session.
|
|
90
|
+
* @returns {void}
|
|
91
|
+
*/
|
|
92
|
+
const adopt = (session) => {
|
|
93
|
+
try {
|
|
94
|
+
ledger.adoptSession(session)
|
|
95
|
+
} catch (error) {
|
|
96
|
+
log.warn('[token-ledger] could not fold session: %o', error)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Sessions already live when this plugin mounts (a profile reload, or a
|
|
101
|
+
// ledger installed into a running host).
|
|
102
|
+
try {
|
|
103
|
+
const sessions = ctx.get?.('sessions')
|
|
104
|
+
if (sessions !== undefined && typeof sessions.list === 'function') {
|
|
105
|
+
for (const session of sessions.list()) adopt(session)
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
log.warn('[token-ledger] could not enumerate live sessions: %o', error)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Fold every session storage knows about, including ones from previous runs.
|
|
113
|
+
*
|
|
114
|
+
* This is what makes a fresh install immediately show real history instead of
|
|
115
|
+
* starting from zero. It is safe to repeat: a session already consumed is
|
|
116
|
+
* skipped by its cursor.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} persistence - the `sessionPersistence` service.
|
|
119
|
+
* @returns {Promise<{ sessions: number, errors: number }>} the backfill tally.
|
|
120
|
+
*/
|
|
121
|
+
const backfill = async (persistence) => {
|
|
122
|
+
let sessions = 0
|
|
123
|
+
let errors = 0
|
|
124
|
+
const headers = await persistence.list()
|
|
125
|
+
for (const header of headers) {
|
|
126
|
+
const id = String(header?.id ?? '')
|
|
127
|
+
if (id === '') continue
|
|
128
|
+
try {
|
|
129
|
+
const read = typeof persistence.inspect === 'function' ? persistence.inspect : persistence.load
|
|
130
|
+
const stored = await read.call(persistence, id)
|
|
131
|
+
const events = stored?.events
|
|
132
|
+
if (!Array.isArray(events)) continue
|
|
133
|
+
const meta = stored?.meta ?? header
|
|
134
|
+
ledger.adoptHistory({
|
|
135
|
+
sessionId: id,
|
|
136
|
+
events,
|
|
137
|
+
// Only a fork's prefix belongs to another session; see inheritedCut.
|
|
138
|
+
inheritedEventCount: inheritedCut(meta, stored?.inheritedEventCount ?? meta?.seedLength),
|
|
139
|
+
})
|
|
140
|
+
sessions += 1
|
|
141
|
+
} catch (error) {
|
|
142
|
+
errors += 1
|
|
143
|
+
log.warn('[token-ledger] could not read stored session %s: %o', id, error)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { sessions, errors }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (shouldBackfill) {
|
|
150
|
+
const onPersistence = (persistenceCtx) => {
|
|
151
|
+
void backfill(persistenceCtx.sessionPersistence)
|
|
152
|
+
.then((tally) => {
|
|
153
|
+
log.info(
|
|
154
|
+
'[token-ledger] backfilled %d stored session(s), %d unreadable; total %d tokens over %d calls',
|
|
155
|
+
tally.sessions,
|
|
156
|
+
tally.errors,
|
|
157
|
+
ledger.totals.totalTokens,
|
|
158
|
+
ledger.calls,
|
|
159
|
+
)
|
|
160
|
+
schedule(0)
|
|
161
|
+
})
|
|
162
|
+
.catch((error) => {
|
|
163
|
+
log.warn('[token-ledger] backfill failed: %o', error)
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
if (typeof ctx.inject === 'function') ctx.inject(['sessionPersistence'], onPersistence)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (typeof ctx.on === 'function') {
|
|
170
|
+
ctx.on('session/created', (session) => {
|
|
171
|
+
adopt(session)
|
|
172
|
+
schedule()
|
|
173
|
+
})
|
|
174
|
+
ctx.on('session/event', (session) => {
|
|
175
|
+
adopt(session)
|
|
176
|
+
schedule()
|
|
177
|
+
})
|
|
178
|
+
ctx.on('session/disposed', (session) => {
|
|
179
|
+
adopt(session)
|
|
180
|
+
schedule(0)
|
|
181
|
+
})
|
|
182
|
+
ctx.on('session/end-seed', () => schedule())
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!restored) log.info('[token-ledger] started a new ledger at %s', ledgerPath)
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Handle `/tokens [summary|export|json|path]`.
|
|
189
|
+
*
|
|
190
|
+
* @param {{ rawInput?: string }} input - the command invocation.
|
|
191
|
+
* @returns {{ kind: 'success'|'error', text: string }} the command result.
|
|
192
|
+
*/
|
|
193
|
+
const runCommand = ({ rawInput = '' } = {}) => {
|
|
194
|
+
const argument = String(rawInput).trim().split(/\s+/)[0]?.toLowerCase() ?? ''
|
|
195
|
+
switch (argument) {
|
|
196
|
+
case '':
|
|
197
|
+
case 'summary': {
|
|
198
|
+
return { kind: 'success', text: ledger.format() }
|
|
199
|
+
}
|
|
200
|
+
case 'path': {
|
|
201
|
+
return { kind: 'success', text: ledgerPath }
|
|
202
|
+
}
|
|
203
|
+
case 'json': {
|
|
204
|
+
return { kind: 'success', text: JSON.stringify(ledger.snapshot(), null, 2) }
|
|
205
|
+
}
|
|
206
|
+
case 'export': {
|
|
207
|
+
const stamp = new Date().toISOString().slice(0, 10)
|
|
208
|
+
const written = []
|
|
209
|
+
try {
|
|
210
|
+
written.push(writeFileAtomic(join(paths.exportsDir, `daily-${stamp}.csv`), ledger.toCsv('daily')))
|
|
211
|
+
written.push(writeFileAtomic(join(paths.exportsDir, `sessions-${stamp}.csv`), ledger.toCsv('sessions')))
|
|
212
|
+
written.push(writeFileAtomic(join(paths.exportsDir, `models-${stamp}.csv`), ledger.toCsv('models')))
|
|
213
|
+
written.push(saveLedger(join(paths.exportsDir, `ledger-${stamp}.json`), ledger.snapshot()))
|
|
214
|
+
} catch (error) {
|
|
215
|
+
return { kind: 'error', text: `export failed: ${error instanceof Error ? error.message : String(error)}` }
|
|
216
|
+
}
|
|
217
|
+
return { kind: 'success', text: `exported:\n${written.map((path) => ` ${path}`).join('\n')}` }
|
|
218
|
+
}
|
|
219
|
+
default: {
|
|
220
|
+
return {
|
|
221
|
+
kind: 'error',
|
|
222
|
+
text: 'usage: /tokens [summary|export|json|path]',
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (typeof ctx.inject === 'function') {
|
|
229
|
+
ctx.inject(['commands'], (commandCtx) => {
|
|
230
|
+
commandCtx.commands.register({
|
|
231
|
+
name: 'tokens',
|
|
232
|
+
description: 'Show the cumulative token ledger (per day and per model)',
|
|
233
|
+
input: 'summary | export | json | path',
|
|
234
|
+
handler: runCommand,
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (typeof ctx.effect === 'function') {
|
|
240
|
+
ctx.effect(
|
|
241
|
+
() => () => {
|
|
242
|
+
if (timer !== undefined) {
|
|
243
|
+
clearTimeout(timer)
|
|
244
|
+
timer = undefined
|
|
245
|
+
}
|
|
246
|
+
return flush()
|
|
247
|
+
},
|
|
248
|
+
'token-ledger: flush on dispose',
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export default { name, apply }
|