@chenmiao8563/dsh-token-ledger 0.1.1 → 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
@@ -23,6 +23,7 @@
23
23
  */
24
24
 
25
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
 
@@ -248,6 +249,29 @@ export function apply(ctx, config = {}) {
248
249
  })
249
250
  }
250
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
+
251
275
  if (typeof ctx.effect === 'function') {
252
276
  ctx.effect(
253
277
  () => () => {
@@ -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.1",
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
  }