@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/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 }