@chenmiao8563/dsh-token-ledger 0.1.0 → 0.2.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 CHANGED
@@ -22,7 +22,8 @@
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
+ import { createOverviewRoute, OVERVIEW_PATH } from './route.js'
26
27
  import { ledgerPaths, loadLedger, saveLedger, writeFileAtomic } from './store.js'
27
28
  import { join } from 'node:path'
28
29
 
@@ -131,12 +132,22 @@ export function apply(ctx, config = {}) {
131
132
  const events = stored?.events
132
133
  if (!Array.isArray(events)) continue
133
134
  const meta = stored?.meta ?? header
134
- ledger.adoptHistory({
135
- sessionId: id,
135
+ // A fork's prefix belongs to its parent, which is counted separately.
136
+ // The boundary is located by the `session/end-seed` marker rather than
137
+ // by a raw count, because a count can be expressed in a different
138
+ // coordinate space than the array we were handed. See inheritedCut.
139
+ const inheritedEventCount = inheritedCut({
140
+ header: meta,
136
141
  events,
137
- // Only a fork's prefix belongs to another session; see inheritedCut.
138
- inheritedEventCount: inheritedCut(meta, stored?.inheritedEventCount ?? meta?.seedLength),
142
+ inheritedEventCount: stored?.inheritedEventCount ?? meta?.seedLength,
139
143
  })
144
+ if (isForkSession(meta) && inheritedEventCount === 0) {
145
+ log.warn(
146
+ '[token-ledger] forked session %s has no usable inheritance boundary; folding it whole, so its totals may include the parent prefix',
147
+ id,
148
+ )
149
+ }
150
+ ledger.adoptHistory({ sessionId: id, events, inheritedEventCount })
140
151
  sessions += 1
141
152
  } catch (error) {
142
153
  errors += 1
@@ -230,12 +241,37 @@ export function apply(ctx, config = {}) {
230
241
  commandCtx.commands.register({
231
242
  name: 'tokens',
232
243
  description: 'Show the cumulative token ledger (per day and per model)',
233
- input: 'summary | export | json | path',
244
+ // `input` must be an object carrying a non-empty `hint` string; a bare
245
+ // string is rejected by the command registry with a TypeError.
246
+ input: { hint: 'summary | export | json | path' },
234
247
  handler: runCommand,
235
248
  })
236
249
  })
237
250
  }
238
251
 
252
+ // The browser half reads this route, which is a read-only view of the live
253
+ // ledger. Without a web server the plugin is still fully usable through
254
+ // /tokens and the CLI, so the service is optional rather than injected.
255
+ //
256
+ // Registration is logged because the settings page can only report that it
257
+ // could not read the route. The log is what distinguishes "this profile has no
258
+ // web server" from "the route failed to register"; from the browser the two
259
+ // look identical.
260
+ if (typeof ctx.inject === 'function') {
261
+ ctx.inject(['webServer'], (webCtx) => {
262
+ ctx.effect(() => {
263
+ const dispose = webCtx.webServer.register(
264
+ createOverviewRoute({
265
+ ledger,
266
+ options: { onError: (error) => log.warn('[token-ledger] overview route failed: %o', error) },
267
+ }),
268
+ )
269
+ log.info('[token-ledger] settings page route ready at %s', OVERVIEW_PATH)
270
+ return dispose
271
+ }, 'token-ledger: overview route')
272
+ })
273
+ }
274
+
239
275
  if (typeof ctx.effect === 'function') {
240
276
  ctx.effect(
241
277
  () => () => {
package/lib/ledger.js CHANGED
@@ -36,8 +36,17 @@
36
36
  * @module dsh-token-ledger/ledger
37
37
  */
38
38
 
39
- /** Bumped whenever the folded state shape changes, so stale files are ignored. */
40
- export const LEDGER_VERSION = 1
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
- * Decide how much of a session's stored history is somebody else's.
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 at
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
- * @param {object} [header] - a `SessionHeader`-shaped object.
179
- * @param {number} [inheritedEventCount] - the cut storage reports for this session.
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
- const isFork = header?.parentSession !== undefined && header?.parentSession !== null
184
- if (!isFork) return 0
185
- return typeof inheritedEventCount === 'number' && inheritedEventCount > 0 ? inheritedEventCount : 0
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)
@@ -0,0 +1,228 @@
1
+ /**
2
+ * The overview payload the browser half renders.
3
+ *
4
+ * Kept separate from the plugin so it is a pure function of a ledger snapshot
5
+ * and a clock, and therefore testable without a host, a browser or a socket.
6
+ * The host route is then a thin wrapper: take the live snapshot, call this,
7
+ * serialize.
8
+ *
9
+ * ## Why the payload is shaped like this
10
+ *
11
+ * The page the client draws has three parts — range totals, today, and a
12
+ * calendar. Each is answered here rather than in the browser so the client
13
+ * stays dumb and two clients cannot disagree:
14
+ *
15
+ * - `ranges` carries the totals for the three selectable ranges, plus the
16
+ * derived cache-hit rate, so switching a tab is a re-render and not a
17
+ * re-fetch.
18
+ * - `today` carries the current local day's numbers, which is what "live"
19
+ * means for a ledger: a value that moves as steps complete.
20
+ * - `daily` is a **contiguous** day series including zero-token days, because
21
+ * a calendar grid needs every cell, and gap-filling in one place is better
22
+ * than in every view.
23
+ *
24
+ * @module dsh-token-ledger/overview
25
+ */
26
+
27
+ /** How many days of daily series to publish, bounding the payload. */
28
+ export const SERIES_DAYS = 366
29
+
30
+ /**
31
+ * Render a local calendar day as `YYYY-MM-DD`.
32
+ *
33
+ * @param {Date} date - the day.
34
+ * @returns {string} the key.
35
+ */
36
+ export function dayKey(date) {
37
+ const pad = (value) => (value < 10 ? `0${value}` : String(value))
38
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
39
+ }
40
+
41
+ /**
42
+ * Parse a `YYYY-MM-DD` key back into a local midnight `Date`.
43
+ *
44
+ * @param {string} key - the day key.
45
+ * @returns {Date} local midnight of that day.
46
+ */
47
+ export function parseDayKey(key) {
48
+ const [year, month, day] = String(key).split('-').map((part) => Number.parseInt(part, 10))
49
+ return new Date(year, (month ?? 1) - 1, day ?? 1)
50
+ }
51
+
52
+ /**
53
+ * The local date a range starts on.
54
+ *
55
+ * `week` is the trailing seven days including today, because "7 天" reads as a
56
+ * rolling window; `month` and `year` are calendar periods, because "本月" and
57
+ * "本年" read as the period you are in.
58
+ *
59
+ * @param {'week'|'month'|'year'} kind - the range.
60
+ * @param {Date} now - the current time.
61
+ * @returns {Date} local midnight of the first day in range.
62
+ */
63
+ export function rangeStart(kind, now) {
64
+ if (kind === 'week') {
65
+ const start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
66
+ start.setDate(start.getDate() - 6)
67
+ return start
68
+ }
69
+ if (kind === 'month') return new Date(now.getFullYear(), now.getMonth(), 1)
70
+ if (kind === 'year') return new Date(now.getFullYear(), 0, 1)
71
+ throw new Error(`unknown range "${kind}" (expected week, month, or year)`)
72
+ }
73
+
74
+ /**
75
+ * An empty counter set in the shape the ledger emits.
76
+ *
77
+ * @returns {{ inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheWriteTokens: number, totalTokens: number, reasoningTokens: number }} zeroed counters.
78
+ */
79
+ function emptyTotals() {
80
+ return {
81
+ inputTokens: 0,
82
+ outputTokens: 0,
83
+ cacheReadTokens: 0,
84
+ cacheWriteTokens: 0,
85
+ totalTokens: 0,
86
+ reasoningTokens: 0,
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Add one day row into an accumulator.
92
+ *
93
+ * @param {ReturnType<typeof emptyTotals>} target - mutated accumulator.
94
+ * @param {object} row - a ledger daily row.
95
+ * @returns {void}
96
+ */
97
+ function accumulate(target, row) {
98
+ for (const key of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'totalTokens', 'reasoningTokens']) {
99
+ const value = row?.[key]
100
+ if (typeof value === 'number' && Number.isFinite(value)) target[key] += value
101
+ }
102
+ }
103
+
104
+ /**
105
+ * The share of input tokens served from the prompt cache.
106
+ *
107
+ * Defined over input only: a cache read is an input token that did not have to
108
+ * be re-sent, so `cacheRead / (cacheRead + uncachedInput)` is the fraction of
109
+ * input the cache absorbed. Returns `null` when there was no input at all,
110
+ * which the client renders as a dash rather than as a misleading 0%.
111
+ *
112
+ * @param {{ inputTokens: number, cacheReadTokens: number }} totals - the counters.
113
+ * @returns {number|null} a fraction between 0 and 1, or null when undefined.
114
+ */
115
+ export function cacheHitRate(totals) {
116
+ const denominator = (totals.cacheReadTokens ?? 0) + (totals.inputTokens ?? 0)
117
+ if (denominator <= 0) return null
118
+ return totals.cacheReadTokens / denominator
119
+ }
120
+
121
+ /**
122
+ * Fold a set of day rows into one range summary.
123
+ *
124
+ * @param {object[]} rows - day rows inside the range.
125
+ * @returns {{ totals: ReturnType<typeof emptyTotals>, calls: number, activeDays: number, cacheHitRate: number|null }} the summary.
126
+ */
127
+ export function summarize(rows) {
128
+ const totals = emptyTotals()
129
+ let calls = 0
130
+ let activeDays = 0
131
+ for (const row of rows) {
132
+ accumulate(totals, row)
133
+ if (typeof row?.calls === 'number' && Number.isFinite(row.calls)) calls += row.calls
134
+ if ((row?.totalTokens ?? 0) > 0) activeDays += 1
135
+ }
136
+ return { totals, calls, activeDays, cacheHitRate: cacheHitRate(totals) }
137
+ }
138
+
139
+ /**
140
+ * A contiguous day series ending today, gaps filled with zeroes.
141
+ *
142
+ * @param {Map<string, object>} byDay - ledger daily rows keyed by day.
143
+ * @param {Date} now - the current time.
144
+ * @param {number} [limit] - maximum number of days emitted.
145
+ * @returns {object[]} ascending day rows, one per calendar day.
146
+ */
147
+ export function buildSeries(byDay, now, limit = SERIES_DAYS) {
148
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
149
+ const keys = [...byDay.keys()].sort()
150
+ const earliest = keys.length > 0 ? parseDayKey(keys[0]) : today
151
+ const span = Math.min(limit, Math.max(1, Math.round((today - earliest) / 86400000) + 1))
152
+
153
+ const series = []
154
+ for (let offset = span - 1; offset >= 0; offset -= 1) {
155
+ const date = new Date(today.getFullYear(), today.getMonth(), today.getDate() - offset)
156
+ const key = dayKey(date)
157
+ const row = byDay.get(key)
158
+ series.push({
159
+ date: key,
160
+ calls: typeof row?.calls === 'number' ? row.calls : 0,
161
+ inputTokens: row?.inputTokens ?? 0,
162
+ outputTokens: row?.outputTokens ?? 0,
163
+ cacheReadTokens: row?.cacheReadTokens ?? 0,
164
+ cacheWriteTokens: row?.cacheWriteTokens ?? 0,
165
+ totalTokens: row?.totalTokens ?? 0,
166
+ reasoningTokens: row?.reasoningTokens ?? 0,
167
+ })
168
+ }
169
+ return series
170
+ }
171
+
172
+ /**
173
+ * Build the whole overview payload.
174
+ *
175
+ * @param {object} snapshot - a `UsageLedger#snapshot()` value.
176
+ * @param {{ now?: Date }} [options] - clock override for tests.
177
+ * @returns {object} the JSON payload the client renders. Every field is a
178
+ * primitive, a plain object or an array, so it serializes losslessly.
179
+ */
180
+ export function buildOverview(snapshot, { now = new Date() } = {}) {
181
+ const dailyRows = Array.isArray(snapshot?.daily) ? snapshot.daily : []
182
+ const byDay = new Map()
183
+ for (const row of dailyRows) {
184
+ if (typeof row?.date === 'string') byDay.set(row.date, row)
185
+ }
186
+
187
+ const series = buildSeries(byDay, now)
188
+ const todayKey = dayKey(now)
189
+
190
+ const ranges = {}
191
+ for (const kind of ['week', 'month', 'year']) {
192
+ const startKey = dayKey(rangeStart(kind, now))
193
+ const inRange = [...byDay.entries()]
194
+ .filter(([key]) => key >= startKey && key <= todayKey)
195
+ .map(([, row]) => row)
196
+ ranges[kind] = { kind, from: startKey, to: todayKey, ...summarize(inRange) }
197
+ }
198
+
199
+ const todayRow = byDay.get(todayKey)
200
+ const todaySummary = summarize(todayRow === undefined ? [] : [todayRow])
201
+
202
+ const models = (Array.isArray(snapshot?.models) ? snapshot.models : [])
203
+ .slice(0, 12)
204
+ .map((model) => ({
205
+ model: String(model?.model ?? 'unknown'),
206
+ calls: typeof model?.calls === 'number' ? model.calls : 0,
207
+ totalTokens: model?.totalTokens ?? 0,
208
+ cacheReadTokens: model?.cacheReadTokens ?? 0,
209
+ inputTokens: model?.inputTokens ?? 0,
210
+ cacheHitRate: cacheHitRate({ cacheReadTokens: model?.cacheReadTokens ?? 0, inputTokens: model?.inputTokens ?? 0 }),
211
+ }))
212
+
213
+ return {
214
+ plugin: 'token-ledger',
215
+ generatedAt: now.getTime(),
216
+ ledgerUpdatedAt: typeof snapshot?.updatedAt === 'number' ? snapshot.updatedAt : null,
217
+ totals: {
218
+ ...emptyTotals(),
219
+ ...(snapshot?.totals ?? {}),
220
+ cacheHitRate: cacheHitRate(snapshot?.totals ?? {}),
221
+ },
222
+ ranges,
223
+ today: { date: todayKey, lastEventAt: typeof snapshot?.updatedAt === 'number' ? snapshot.updatedAt : null, ...todaySummary },
224
+ series,
225
+ models,
226
+ sessionCount: Array.isArray(snapshot?.sessions) ? snapshot.sessions.length : 0,
227
+ }
228
+ }
package/lib/route.js ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The host route that feeds the browser half.
3
+ *
4
+ * ## Why a route instead of the settings namespace
5
+ *
6
+ * The obvious channel for plugin data is a settings namespace, and the reference
7
+ * UI plugins use one. This plugin deliberately does not, for two reasons:
8
+ *
9
+ * 1. A settings namespace needs a schema, and the schema library is a real
10
+ * dependency. This package's whole installability claim is that it has none.
11
+ * 2. Settings are persisted. Publishing token aggregates through them would
12
+ * rewrite `settings.yaml` on every debounce and grow it with data that is
13
+ * derived and reproducible, which is exactly the mistake that left a 185 kB
14
+ * settings backup on the machine this was developed against.
15
+ *
16
+ * The ledger file stays the store of record; this route is a read-only view of
17
+ * the live in-memory ledger.
18
+ *
19
+ * ## The guard
20
+ *
21
+ * The route exposes local usage counts, which are not secrets but are also
22
+ * nobody else's business. The web server binds loopback by default, but an
23
+ * operator may bind `0.0.0.0`, so the handler checks the peer itself: an
24
+ * address is accepted only when it is loopback, and an `Origin` header is
25
+ * accepted only when it is loopback or absent.
26
+ *
27
+ * Absent is allowed because the desktop build loads the front end over
28
+ * `file://` and carries `fetch` over an IPC bridge, where a socket address may
29
+ * be unavailable. Refusing that case would break the desktop app to protect
30
+ * against a request that never left the machine.
31
+ *
32
+ * @module dsh-token-ledger/route
33
+ */
34
+
35
+ import { buildOverview } from './overview.js'
36
+
37
+ /** The path the browser half fetches. */
38
+ export const OVERVIEW_PATH = '/api/token-ledger/summary'
39
+
40
+ /**
41
+ * Whether a peer address is loopback.
42
+ *
43
+ * @param {string|undefined} address - `req.socket.remoteAddress`.
44
+ * @returns {boolean} true for IPv4 or IPv6 loopback.
45
+ */
46
+ export function isLoopbackAddress(address) {
47
+ if (typeof address !== 'string') return false
48
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
49
+ }
50
+
51
+ /**
52
+ * Whether an `Origin` header belongs to a local page.
53
+ *
54
+ * @param {string|undefined} origin - the header value.
55
+ * @returns {boolean} true when absent, `null`, `file://`, or a loopback host.
56
+ */
57
+ export function isLocalOrigin(origin) {
58
+ if (origin === undefined || origin === '' || origin === 'null') return true
59
+ if (origin.startsWith('file://')) return true
60
+ try {
61
+ const url = new URL(origin)
62
+ // WHATWG URL keeps the brackets on an IPv6 host: `new URL('http://[::1]/').hostname`
63
+ // is `'[::1]'`, so they have to come off before comparing.
64
+ const hostname = url.hostname.replace(/^\[|\]$/g, '')
65
+ return hostname === '127.0.0.1' || hostname === '::1' || hostname === 'localhost'
66
+ } catch {
67
+ return false
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Decide whether a request may read the overview.
73
+ *
74
+ * @param {object} req - a node `IncomingMessage`-shaped object.
75
+ * @returns {boolean} whether to serve it.
76
+ */
77
+ export function isAllowedPeer(req) {
78
+ const address = req?.socket?.remoteAddress
79
+ // An unrecognized address means the request did not arrive over a socket we
80
+ // can judge (the desktop IPC bridge), not that it came from a stranger.
81
+ if (typeof address === 'string' && address !== '' && !isLoopbackAddress(address)) return false
82
+ return isLocalOrigin(req?.headers?.origin)
83
+ }
84
+
85
+ /**
86
+ * Write a JSON response.
87
+ *
88
+ * @param {object} res - a node `ServerResponse`-shaped object.
89
+ * @param {number} status - the HTTP status.
90
+ * @param {unknown} body - the JSON value.
91
+ * @returns {void}
92
+ */
93
+ function sendJson(res, status, body) {
94
+ const text = JSON.stringify(body)
95
+ res.writeHead(status, {
96
+ 'content-type': 'application/json; charset=utf-8',
97
+ 'content-length': Buffer.byteLength(text),
98
+ // The numbers move as steps complete, so a cached copy is always wrong.
99
+ 'cache-control': 'no-store',
100
+ })
101
+ res.end(text)
102
+ }
103
+
104
+ /**
105
+ * Build the route definition to hand to `ctx.webServer.register`.
106
+ *
107
+ * The handler never throws: a fault becomes a 500 with a JSON body, because the
108
+ * web server logs a warning and destroys the socket when a handler throws, and
109
+ * a client that cannot reach the plugin should be able to say why.
110
+ *
111
+ * @param {object} input - the route's dependencies.
112
+ * @param {{ snapshot: () => object }} input.ledger - the live ledger.
113
+ * @param {{ now?: () => Date, onError?: (error: unknown) => void }} [input.options] - clock and diagnostics.
114
+ * @returns {{ kind: 'exact', path: string, handler: (req: object, res: object) => void }} the route.
115
+ */
116
+ export function createOverviewRoute({ ledger, options = {} }) {
117
+ const now = options.now ?? (() => new Date())
118
+ const onError = options.onError ?? (() => {})
119
+
120
+ return {
121
+ kind: 'exact',
122
+ path: OVERVIEW_PATH,
123
+ handler: (req, res) => {
124
+ if (!isAllowedPeer(req)) {
125
+ sendJson(res, 403, { error: 'forbidden' })
126
+ return
127
+ }
128
+ const method = req?.method ?? 'GET'
129
+ if (method !== 'GET' && method !== 'HEAD') {
130
+ sendJson(res, 405, { error: 'method not allowed' })
131
+ return
132
+ }
133
+ try {
134
+ sendJson(res, 200, buildOverview(ledger.snapshot(), { now: now() }))
135
+ } catch (error) {
136
+ onError(error)
137
+ sendJson(res, 500, { error: 'overview unavailable' })
138
+ }
139
+ },
140
+ }
141
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chenmiao8563/dsh-token-ledger",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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",
@@ -26,6 +26,9 @@
26
26
  "./ledger": "./lib/ledger.js",
27
27
  "./session-log": "./lib/session-log.js",
28
28
  "./store": "./lib/store.js",
29
+ "./overview": "./lib/overview.js",
30
+ "./route": "./lib/route.js",
31
+ "./client": "./lib/client.js",
29
32
  "./cordis.patch.yml": "./cordis.patch.yml",
30
33
  "./package.json": "./package.json"
31
34
  },
@@ -52,11 +55,23 @@
52
55
  "verify": "node scripts/verify-package.mjs",
53
56
  "prepublishOnly": "npm run verify && npm test"
54
57
  },
58
+ "devDependencies": {
59
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.2-rc.1",
60
+ "react": "18.3.1",
61
+ "react-dom": "18.3.1"
62
+ },
55
63
  "dsh": {
56
64
  "pluginType": "feature",
57
65
  "bundle": {
58
66
  "patch": "./cordis.patch.yml"
59
67
  },
68
+ "client": {
69
+ "platform": "web",
70
+ "inject": [
71
+ "@deepseek-ai/dsh-client-locale",
72
+ "@deepseek-ai/dsh-client-ui-settings-general"
73
+ ]
74
+ },
60
75
  "compatibility": {
61
76
  "dsh": ">=0.1.2-alpha.1"
62
77
  }