@sligqoer/dsh-usage-stats 0.1.2

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,1105 @@
1
+ /**
2
+ * dsh-usage-stats — host half. Aggregates token usage from every session's
3
+ * durable log (the `assistant/message` events carry `usage` accounting plus
4
+ * provider/model provenance) and serves statistics, quota and control routes
5
+ * under /api/dsh-usage-stats for the desktop settings page (用量统计). The renderer half
6
+ * (./client) registers the settings section with the heatmap, the 24-hour
7
+ * token chart, and the per-model breakdown.
8
+ *
9
+ * Provider quotas use the credentials configured in DSH. Optional local
10
+ * plugins are registered as quota adapters, with display-only cache data.
11
+ *
12
+ * Performance model (fixed for slow page loads):
13
+ * - The corpus is aggregated into per-session contributions that are kept
14
+ * in memory AND persisted to <DSH_HOME>/storages/usage-stats-corpus.json,
15
+ * so a server restart does not force a cold full scan.
16
+ * - Refresh uses the SQLite session `f_head_sequence` or legacy JSONL file
17
+ * metadata as a fingerprint when available. Sessions without either
18
+ * fingerprint are re-read on every background refresh.
19
+ * - A background interval keeps the corpus warm in the last requested timezone.
20
+ * - A request waits at most MAX_WAIT_MS for a scan. An unfinished scan or
21
+ * failed session read marks the current snapshot `stale:true`.
22
+ *
23
+ * The desktop renderer sends its system UTC offset. Sessions with source events are
24
+ * re-bucketed exactly; old retained sessions use their stored hourly buckets.
25
+ * `?fresh=1` forces a rescan. No dsh source changes.
26
+ */
27
+ import { mkdir, readFile, rename, stat, writeFile, readdir } from 'node:fs/promises'
28
+ import { dirname, join } from 'node:path'
29
+ import { homedir } from 'node:os'
30
+ import { createProviderQuotaReader } from './provider-quotas.js'
31
+ import { createQuotaControls, workBuddyQuotaSource, QuotaControlError } from './quota-controls.js'
32
+
33
+ /** Stable cordis plugin name (row id `usage-stats`). */
34
+ export const name = 'usage-stats'
35
+
36
+ /** Services required before the surfaces can mount (timer for the warm loop). */
37
+ export const inject = ['profileContext', 'sessionQuery', 'webServer', 'timer']
38
+
39
+ /** Serving a snapshot older than this counts as stale (ms). */
40
+ const CACHE_TTL_MS = 60 * 1000
41
+ /** Background keep-fresh cadence (ms). */
42
+ const SYNC_INTERVAL_MS = 30 * 1000
43
+ /** Upper bound a request waits on a refresh before serving current data (ms). */
44
+ const MAX_WAIT_MS = 1500
45
+ /** Concurrency for reading sessions during a refresh. */
46
+ const SCAN_CONCURRENCY = 6
47
+ /** Persisted corpus file name under <DSH_HOME>/storages. */
48
+ const STORE_FILE = 'usage-stats-corpus.json'
49
+
50
+ /** Fallback for requests without a desktop timezone. */
51
+ const SYSTEM_TZ = new Date().getTimezoneOffset()
52
+
53
+ let sqlitePromise = null
54
+
55
+ /** Lazy `node:sqlite` loader — null when the runtime lacks the builtin. */
56
+ function loadSqlite() {
57
+ if (sqlitePromise === null) {
58
+ sqlitePromise = import('node:sqlite').catch(() => null)
59
+ }
60
+ return sqlitePromise
61
+ }
62
+
63
+ /* ------------------------------------------------------------------ */
64
+ /* Loopback trust fence for /api/dsh-usage-stats (family shared helper) */
65
+ /* ------------------------------------------------------------------ */
66
+ function isLoopbackRequest(req) {
67
+ const addr = req.socket && req.socket.remoteAddress
68
+ if (typeof addr !== 'string') return false
69
+ const normalized = addr.toLowerCase()
70
+ const isLoopback = normalized === '::1' || normalized.startsWith('::ffff:127.') || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized)
71
+ if (!isLoopback) return false
72
+ const host = req.headers.host
73
+ if (typeof host !== 'string') return false
74
+ let hostname
75
+ try {
76
+ hostname = new URL('http://' + host).hostname
77
+ } catch {
78
+ return false
79
+ }
80
+ const hostOk = hostname === 'localhost' || hostname === '[::1]' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)
81
+ if (!hostOk) return false
82
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false
83
+ const origin = req.headers.origin
84
+ if (origin === undefined) return true
85
+ try {
86
+ return new URL(origin).host === hostUrlHost(req)
87
+ } catch {
88
+ return false
89
+ }
90
+ }
91
+ function hostUrlHost(req) {
92
+ try {
93
+ return new URL('http://' + req.headers.host).host
94
+ } catch {
95
+ return ''
96
+ }
97
+ }
98
+
99
+ /* ------------------------------------------------------------------ */
100
+ /* Buckets */
101
+ /* ------------------------------------------------------------------ */
102
+
103
+ function emptyBucket() {
104
+ return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, billed: 0, calls: 0 }
105
+ }
106
+
107
+ function addUsage(bucket, usage) {
108
+ const u = usage || {}
109
+ const input = Number(u.inputTokens) || 0
110
+ const output = Number(u.outputTokens) || 0
111
+ const cacheRead = Number(u.cacheReadTokens) || 0
112
+ const cacheWrite = Number(u.cacheWriteTokens) || 0
113
+ const reasoning = Number(u.reasoningTokens) || 0
114
+ bucket.inputTokens += input
115
+ bucket.outputTokens += output
116
+ bucket.cacheReadTokens += cacheRead
117
+ bucket.cacheWriteTokens += cacheWrite
118
+ bucket.reasoningTokens += reasoning
119
+ bucket.billed += input + output + cacheRead + cacheWrite
120
+ bucket.calls += 1
121
+ }
122
+
123
+ function mergeBucket(into, b) {
124
+ into.inputTokens += b.inputTokens || 0
125
+ into.outputTokens += b.outputTokens || 0
126
+ into.cacheReadTokens += b.cacheReadTokens || 0
127
+ into.cacheWriteTokens += b.cacheWriteTokens || 0
128
+ into.reasoningTokens += b.reasoningTokens || 0
129
+ into.billed += b.billed || 0
130
+ into.calls += b.calls || 0
131
+ }
132
+
133
+ /** Independent copy of a bucket (so folding never mutates the stored corpus). */
134
+ function copyBucket(b) {
135
+ return {
136
+ inputTokens: b.inputTokens || 0,
137
+ outputTokens: b.outputTokens || 0,
138
+ cacheReadTokens: b.cacheReadTokens || 0,
139
+ cacheWriteTokens: b.cacheWriteTokens || 0,
140
+ reasoningTokens: b.reasoningTokens || 0,
141
+ billed: b.billed || 0,
142
+ calls: b.calls || 0,
143
+ }
144
+ }
145
+
146
+ function serializeBucket(b) {
147
+ return {
148
+ inputTokens: b.inputTokens,
149
+ outputTokens: b.outputTokens,
150
+ cacheReadTokens: b.cacheReadTokens,
151
+ cacheWriteTokens: b.cacheWriteTokens,
152
+ reasoningTokens: b.reasoningTokens,
153
+ billed: b.billed,
154
+ calls: b.calls,
155
+ }
156
+ }
157
+
158
+ /** Simple concurrency-limited async map (failures become undefined). */
159
+ async function mapLimit(items, limit, fn) {
160
+ const results = new Array(items.length)
161
+ let next = 0
162
+ async function worker() {
163
+ for (;;) {
164
+ const i = next++
165
+ if (i >= items.length) return
166
+ try {
167
+ results[i] = await fn(items[i])
168
+ } catch {
169
+ results[i] = undefined
170
+ }
171
+ }
172
+ }
173
+ const workers = []
174
+ for (let w = 0; w < Math.min(limit, items.length); w++) workers.push(worker())
175
+ await Promise.all(workers)
176
+ return results
177
+ }
178
+
179
+ /* ------------------------------------------------------------------ */
180
+ /* Date keys under a system UTC offset (minutes: UTC - local) */
181
+ /* ------------------------------------------------------------------ */
182
+
183
+ function clampTz(tz) {
184
+ const n = Number(tz)
185
+ if (!Number.isFinite(n)) return SYSTEM_TZ
186
+ return Math.max(-840, Math.min(840, Math.round(n)))
187
+ }
188
+
189
+ export function localDayKey(timeMs, tzOffsetMin) {
190
+ return new Date(timeMs - tzOffsetMin * 60000).toISOString().slice(0, 10)
191
+ }
192
+ export function localMondayKey(timeMs, tzOffsetMin) {
193
+ const shifted = timeMs - tzOffsetMin * 60000
194
+ const day = (new Date(shifted).getUTCDay() + 6) % 7 // 0 = Monday
195
+ return new Date(shifted - day * 86400000).toISOString().slice(0, 10)
196
+ }
197
+ /** Local hour (0..23) of a timestamp under a system UTC offset. */
198
+ export function localHourKey(timeMs, tzOffsetMin) {
199
+ return new Date(timeMs - tzOffsetMin * 60000).getUTCHours()
200
+ }
201
+ /** Milliseconds of a local date key's midnight. */
202
+ function keyMidnight(dateKey, tzOffsetMin) {
203
+ return Date.parse(dateKey + 'T00:00:00Z') + tzOffsetMin * 60000
204
+ }
205
+
206
+ function byDate(a, b) {
207
+ return a.date < b.date ? -1 : a.date > b.date ? 1 : 0
208
+ }
209
+
210
+ /* ------------------------------------------------------------------ */
211
+ /* Per-session aggregation */
212
+ /* ------------------------------------------------------------------ */
213
+
214
+ function putBucket(map, key, usage) {
215
+ let b = map[key]
216
+ if (b === undefined) {
217
+ b = emptyBucket()
218
+ map[key] = b
219
+ }
220
+ addUsage(b, usage)
221
+ }
222
+
223
+ /**
224
+ * Fold one session's events into a per-session contribution keyed by local
225
+ * date (day/week), date|modelKey, and modelKey. Uses plain object maps so the
226
+ * result JSON-serializes cleanly for persistence.
227
+ * @param events - the session's raw event log.
228
+ * @param tz - UTC offset (minutes) used for day/week keys.
229
+ */
230
+ export function scanSessionEvents(events, tz) {
231
+ const s = {
232
+ messages: 0,
233
+ withUsage: false,
234
+ totals: emptyBucket(),
235
+ byDay: Object.create(null),
236
+ byWeek: Object.create(null),
237
+ byHour: Object.create(null),
238
+ byDayModel: Object.create(null),
239
+ byWeekModel: Object.create(null),
240
+ byHourModel: Object.create(null),
241
+ byModel: Object.create(null),
242
+ usageEvents: [],
243
+ }
244
+ for (const ev of events || []) {
245
+ if (!ev || ev.type !== 'assistant/message') continue
246
+ const usage = ev.data && ev.data.usage
247
+ if (!usage) continue
248
+ const time = ev.time
249
+ if (!(time > 0)) continue
250
+ s.withUsage = true
251
+ s.messages += 1
252
+
253
+ const source = ev.data.message && ev.data.message.source
254
+ const provider = source && source.provider ? String(source.provider) : 'unknown'
255
+ const model = source && source.model ? String(source.model) : 'unknown'
256
+ const modelKey = provider + '/' + model
257
+
258
+ // Persist only the fields needed to re-bucket retained sessions exactly.
259
+ s.usageEvents.push([time, provider, model,
260
+ Number(usage.inputTokens) || 0, Number(usage.outputTokens) || 0,
261
+ Number(usage.cacheReadTokens) || 0, Number(usage.cacheWriteTokens) || 0,
262
+ Number(usage.reasoningTokens) || 0])
263
+
264
+ const dayKey = localDayKey(time, tz)
265
+ const hour = localHourKey(time, tz)
266
+ const hourKey = dayKey + '|' + hour
267
+
268
+ addUsage(s.totals, usage)
269
+ putBucket(s.byDay, dayKey, usage)
270
+ putBucket(s.byWeek, localMondayKey(time, tz), usage)
271
+ putBucket(s.byHour, hourKey, usage)
272
+ putBucket(s.byDayModel, dayKey + '|' + modelKey, usage)
273
+ putBucket(s.byWeekModel, localMondayKey(time, tz) + '|' + modelKey, usage)
274
+ putBucket(s.byHourModel, hourKey + '|' + modelKey, usage)
275
+
276
+ let mb = s.byModel[modelKey]
277
+ if (mb === undefined) {
278
+ mb = { provider, model, lastTime: 0, bucket: emptyBucket() }
279
+ s.byModel[modelKey] = mb
280
+ }
281
+ addUsage(mb.bucket, usage)
282
+ if (time > mb.lastTime) mb.lastTime = time
283
+ }
284
+ return s
285
+ }
286
+
287
+ /** Fingerprint of an unchanged session log: size + mtimeMs. */
288
+ export function fpKey(fp) {
289
+ if (!fp) return null
290
+ return String(fp.size) + ':' + Math.round(fp.mtimeMs)
291
+ }
292
+
293
+ /* ------------------------------------------------------------------ */
294
+ /* Corpus state + persistence */
295
+ /* ------------------------------------------------------------------ */
296
+
297
+ export function defaultState() {
298
+ return { version: 3, tz: SYSTEM_TZ, lastSessionsCount: 0, syncedAt: 0, partial: false, sessions: {} }
299
+ }
300
+
301
+ function resolveHome() {
302
+ return (process.env.DSH_HOME && process.env.DSH_HOME.trim().length > 0) ? process.env.DSH_HOME : join(homedir(), '.dsh')
303
+ }
304
+
305
+ function resolveStorePath() {
306
+ return join(resolveHome(), 'storages', STORE_FILE)
307
+ }
308
+
309
+ export async function persistState(state, storePath) {
310
+ try {
311
+ await mkdir(dirname(storePath), { recursive: true })
312
+ const tmp = storePath + '.tmp'
313
+ await writeFile(tmp, JSON.stringify(state), 'utf8')
314
+ await rename(tmp, storePath)
315
+ } catch (error) {
316
+ // Persistence is best-effort: aggregation still works, it just costs a
317
+ // cold scan after the next restart.
318
+ console.warn('[dsh-usage-stats] persist corpus failed:', String(error && error.message || error))
319
+ }
320
+ }
321
+
322
+ /** Validate + normalize a stored contribution so a corrupt file cannot crash. */
323
+ function normalizeSession(s) {
324
+ if (!s || typeof s !== 'object') return null
325
+ const base = emptyBucket()
326
+ const totals = {
327
+ inputTokens: s.totals && s.totals.inputTokens || 0,
328
+ outputTokens: s.totals && s.totals.outputTokens || 0,
329
+ cacheReadTokens: s.totals && s.totals.cacheReadTokens || 0,
330
+ cacheWriteTokens: s.totals && s.totals.cacheWriteTokens || 0,
331
+ reasoningTokens: s.totals && s.totals.reasoningTokens || 0,
332
+ billed: s.totals && s.totals.billed || 0,
333
+ calls: s.totals && s.totals.calls || 0,
334
+ }
335
+ void base
336
+ const norm = {
337
+ fp: (s.fp && Number.isFinite(s.fp.size) && Number.isFinite(s.fp.mtimeMs)) ? { size: s.fp.size, mtimeMs: s.fp.mtimeMs } : null,
338
+ retained: !!s.retained,
339
+ syncedAt: Number.isFinite(s.syncedAt) ? s.syncedAt : 0,
340
+ messages: Number.isFinite(s.messages) ? Math.max(0, Math.round(s.messages)) : 0,
341
+ withUsage: !!s.withUsage,
342
+ totals,
343
+ byDay: s.byDay && typeof s.byDay === 'object' ? s.byDay : {},
344
+ byWeek: s.byWeek && typeof s.byWeek === 'object' ? s.byWeek : {},
345
+ byHour: s.byHour && typeof s.byHour === 'object' ? s.byHour : {},
346
+ byDayModel: s.byDayModel && typeof s.byDayModel === 'object' ? s.byDayModel : {},
347
+ byWeekModel: s.byWeekModel && typeof s.byWeekModel === 'object' ? s.byWeekModel : {},
348
+ byHourModel: s.byHourModel && typeof s.byHourModel === 'object' ? s.byHourModel : {},
349
+ byModel: s.byModel && typeof s.byModel === 'object' ? s.byModel : {},
350
+ usageEvents: Array.isArray(s.usageEvents) ? s.usageEvents.filter((e) => Array.isArray(e) && e.length === 8 && Number.isFinite(e[0])) : null,
351
+ }
352
+ // Recompute per-session totals from day buckets if they are missing (older snapshots).
353
+ if (!(s.totals && typeof s.totals === 'object' && 'billed' in s.totals)) {
354
+ const recomputed = emptyBucket()
355
+ for (const k of Object.keys(norm.byDay)) mergeBucket(recomputed, norm.byDay[k])
356
+ norm.totals = recomputed
357
+ }
358
+ return norm
359
+ }
360
+
361
+ export async function loadState(storePath) {
362
+ const state = defaultState()
363
+ try {
364
+ const raw = await readFile(storePath, 'utf8')
365
+ const j = JSON.parse(raw)
366
+ if (j && j.version === 3 && j.sessions && typeof j.sessions === 'object') {
367
+ state.tz = clampTz(j.tz)
368
+ state.lastSessionsCount = Number.isFinite(j.lastSessionsCount) ? Math.max(0, Math.round(j.lastSessionsCount)) : 0
369
+ state.syncedAt = Number.isFinite(j.syncedAt) ? j.syncedAt : (Number.isFinite(j.savedAt) ? j.savedAt : 0)
370
+ state.partial = !!j.partial
371
+ for (const id of Object.keys(j.sessions)) {
372
+ const norm = normalizeSession(j.sessions[id])
373
+ if (norm) state.sessions[id] = norm
374
+ }
375
+ }
376
+ } catch {
377
+ // No snapshot yet (first run) or unreadable — start empty; a background
378
+ // sync will rebuild it.
379
+ }
380
+ return state
381
+ }
382
+
383
+ /* ------------------------------------------------------------------ */
384
+ /* Filesystem fingerprinting of persisted session logs */
385
+ /* ------------------------------------------------------------------ */
386
+
387
+ /**
388
+ * Cheap staleness detector. The persisted logs live at
389
+ * <DSH_HOME>/sessions/<encoded-cwd>/<sessionId>/session.jsonl.zstd
390
+ * (dsh-session-persistence-jsonl). We enumerate those files and key them by
391
+ * the owning session id (the immediate parent directory name). Any id not
392
+ * found here is either live-only or behind an unexpected layout — the caller
393
+ * treats it as needing a re-read, which stays correct.
394
+ */
395
+ export async function collectFileFps(sessionsDir) {
396
+ const out = new Map()
397
+ if (!sessionsDir) sessionsDir = join(resolveHome(), 'sessions')
398
+ let workspaces
399
+ try {
400
+ workspaces = await readdir(sessionsDir, { withFileTypes: true })
401
+ } catch {
402
+ return out
403
+ }
404
+ for (const ws of workspaces) {
405
+ if (!ws.isDirectory()) continue
406
+ let entries
407
+ try {
408
+ entries = await readdir(join(sessionsDir, ws.name), { withFileTypes: true })
409
+ } catch {
410
+ continue
411
+ }
412
+ for (const entry of entries) {
413
+ if (!entry.isDirectory()) continue
414
+ const file = join(sessionsDir, ws.name, entry.name, 'session.jsonl.zstd')
415
+ try {
416
+ const st = await stat(file)
417
+ if (st.isFile()) out.set(entry.name, { size: st.size, mtimeMs: st.mtimeMs })
418
+ } catch {
419
+ // not a persisted session dir
420
+ }
421
+ }
422
+ }
423
+ return out
424
+ }
425
+
426
+ /**
427
+ * SQLite-persistence fingerprint source.
428
+ *
429
+ * DSH switched its durable session store from per-session
430
+ * `session.jsonl.zstd` files (dsh-session-persistence-jsonl) to a single
431
+ * `sessions/sessions.sqlite` database (dsh-session-persistence). New sessions
432
+ * therefore have NO jsonl file at all, so `collectFileFps` returns nothing for
433
+ * them and the incremental scan would read each fresh session exactly once —
434
+ * while it is still empty — and never again (its file fingerprint stays null
435
+ * forever). That is why recent usage stopped being recorded.
436
+ *
437
+ * The SQLite store keeps an authoritative, monotonically increasing
438
+ * `f_head_sequence` per session (it equals MAX(f_sequence) of that session's
439
+ * events and is flushed live, even for the running session). We fold it into
440
+ * the same {size, mtimeMs} fingerprint shape the incremental scan already
441
+ * understands, so a session is re-read exactly when its log grows.
442
+ *
443
+ * Returns an empty Map when the DB is missing/unreadable (older jsonl-only
444
+ * deployments, or a transient lock) so the caller can fall back to files.
445
+ */
446
+ export async function collectSqliteFps(sessionsDir) {
447
+ const out = new Map()
448
+ if (!sessionsDir) sessionsDir = join(resolveHome(), 'sessions')
449
+ const dbPath = join(sessionsDir, 'sessions.sqlite')
450
+ const sqlite = await loadSqlite()
451
+ if (!sqlite || typeof sqlite.DatabaseSync !== 'function') return out
452
+ let db
453
+ try {
454
+ db = new sqlite.DatabaseSync(dbPath, { readOnly: true, timeout: 5000 })
455
+ } catch {
456
+ return out
457
+ }
458
+ try {
459
+ const rows = db.prepare('SELECT f_session_id AS id, f_head_sequence AS seq FROM t_sessions').all()
460
+ for (const r of rows) {
461
+ const seq = Number(r.seq)
462
+ if (!r.id || !Number.isFinite(seq)) continue
463
+ // size + mtimeMs both carry head_sequence so fpKey changes whenever the
464
+ // session's event log grows.
465
+ out.set(r.id, { size: seq, mtimeMs: seq })
466
+ }
467
+ } catch {
468
+ // Schema mismatch / query failure — leave whatever we collected.
469
+ } finally {
470
+ try {
471
+ db.close()
472
+ } catch {
473
+ // already closed
474
+ }
475
+ }
476
+ return out
477
+ }
478
+
479
+ /* ------------------------------------------------------------------ */
480
+ /* Incremental sync + in-memory fold for the response */
481
+ /* ------------------------------------------------------------------ */
482
+
483
+ /** Move an old aggregate to another offset when its source log is gone. */
484
+ function rekeyLegacySession(s, fromTz, toTz) {
485
+ if (Array.isArray(s.usageEvents)) {
486
+ const events = s.usageEvents.map((e) => ({
487
+ type: 'assistant/message', time: e[0],
488
+ data: { message: { source: { provider: e[1], model: e[2] } },
489
+ usage: { inputTokens: e[3], outputTokens: e[4], cacheReadTokens: e[5], cacheWriteTokens: e[6], reasoningTokens: e[7] } },
490
+ }))
491
+ return { ...scanSessionEvents(events, toTz), fp: s.fp, retained: s.retained, syncedAt: s.syncedAt }
492
+ }
493
+
494
+ const next = {
495
+ ...s,
496
+ byDay: Object.create(null), byWeek: Object.create(null), byHour: Object.create(null),
497
+ byDayModel: Object.create(null), byWeekModel: Object.create(null), byHourModel: Object.create(null),
498
+ }
499
+ const move = (source, withModel, hourly) => {
500
+ for (const key of Object.keys(source || {})) {
501
+ const parts = key.split('|')
502
+ const date = parts[0]
503
+ const hour = hourly ? Number(parts[1]) : 12
504
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isInteger(hour) || hour < 0 || hour > 23) continue
505
+ // At whole-hour offset differences this is exact. Old hourly aggregates
506
+ // cannot resolve which side of a fractional-hour boundary each event fell.
507
+ const time = Date.parse(date + 'T00:00:00Z') + (hour + (hourly ? 0.5 : 0)) * 3600000 + fromTz * 60000
508
+ const newDay = localDayKey(time, toTz)
509
+ const newWeek = localMondayKey(time, toTz)
510
+ const newHour = localHourKey(time, toTz)
511
+ const model = withModel ? parts.slice(hourly ? 2 : 1).join('|') : ''
512
+ const suffix = withModel ? '|' + model : ''
513
+ const b = source[key]
514
+ putMergedBucket(withModel ? next.byDayModel : next.byDay, newDay + suffix, b)
515
+ putMergedBucket(withModel ? next.byWeekModel : next.byWeek, newWeek + suffix, b)
516
+ if (hourly) putMergedBucket(withModel ? next.byHourModel : next.byHour, newDay + '|' + newHour + suffix, b)
517
+ }
518
+ }
519
+ move(Object.keys(s.byHour || {}).length ? s.byHour : s.byDay, false, Object.keys(s.byHour || {}).length > 0)
520
+ move(Object.keys(s.byHourModel || {}).length ? s.byHourModel : s.byDayModel, true, Object.keys(s.byHourModel || {}).length > 0)
521
+ return next
522
+ }
523
+
524
+ function putMergedBucket(map, key, bucket) {
525
+ if (!map[key]) map[key] = emptyBucket()
526
+ mergeBucket(map[key], bucket)
527
+ }
528
+
529
+ /**
530
+ * One incremental refresh pass. Only changed/new sessions are re-read; the
531
+ * rest keep their previously measured contribution. Deleted sessions are
532
+ * dropped. The per-session contributions then fold into the response in
533
+ * memory.
534
+ */
535
+ export async function syncCorpus(ctx, reqTz, state) {
536
+ const query = ctx.get('sessionQuery')
537
+ if (query === undefined) throw new Error('sessionQuery service is unavailable — cannot aggregate usage statistics')
538
+ const tz = clampTz(reqTz)
539
+ const tzChanged = state.tz !== tz
540
+
541
+ const records = await query.listSessions()
542
+ const ids = new Set()
543
+ for (const rec of records || []) {
544
+ const id = rec && rec.header && rec.header.id
545
+ if (id) ids.add(id)
546
+ }
547
+ // Sessions that no longer exist on disk or in the live corpus KEEP their
548
+ // measured contribution forever — deleting or archiving a conversation must
549
+ // not erase the usage it already generated (real token spend stays real).
550
+ // The session stays in state.sessions with a `retained` flag so it is still
551
+ // folded into every response.
552
+ const nextSessions = { ...state.sessions }
553
+ for (const id of Object.keys(nextSessions)) {
554
+ if (!ids.has(id)) {
555
+ nextSessions[id] = { ...nextSessions[id], retained: true }
556
+ }
557
+ }
558
+ if (tzChanged) {
559
+ for (const id of Object.keys(nextSessions)) nextSessions[id] = rekeyLegacySession(nextSessions[id], state.tz, tz)
560
+ }
561
+
562
+ // Fingerprint source: SQLite persistence is authoritative now (every current
563
+ // session has a row whose head_sequence grows live); the jsonl scan is kept as
564
+ // a fallback for older deployments and for any id SQLite does not cover. The
565
+ // SQLite map is overlaid last so it wins wherever both exist.
566
+ const fps = await collectFileFps()
567
+ const sqlFps = await collectSqliteFps()
568
+ for (const [id, fp] of sqlFps) fps.set(id, fp)
569
+ const toRead = []
570
+ for (const rec of records || []) {
571
+ const id = rec && rec.header && rec.header.id
572
+ if (!id) continue
573
+ const fp = fps.get(id) || null
574
+ const prev = state.sessions[id]
575
+ if (prev !== undefined && prev.retained) nextSessions[id] = { ...nextSessions[id], retained: false }
576
+ if (tzChanged) {
577
+ toRead.push(id)
578
+ continue
579
+ }
580
+ if (prev === undefined) {
581
+ toRead.push(id)
582
+ continue
583
+ }
584
+ // A listed session without a supported persistence fingerprint may still
585
+ // change. Re-read it on each refresh; absent sessions are not in records.
586
+ if (fp === null || fpKey(fp) !== fpKey(prev.fp || null)) toRead.push(id)
587
+ }
588
+
589
+ const readStart = Date.now()
590
+ let changed = 0
591
+ let failed = 0
592
+ if (toRead.length > 0) {
593
+ await mapLimit(toRead, SCAN_CONCURRENCY, async (id) => {
594
+ try {
595
+ const snap = await query.readSession(id)
596
+ const contribution = scanSessionEvents(snap && snap.events ? snap.events : [], tz)
597
+ contribution.fp = fps.get(id) || null
598
+ contribution.syncedAt = Date.now()
599
+ nextSessions[id] = contribution
600
+ changed += 1
601
+ } catch {
602
+ // Keep the previous contribution (or absence) so one bad read never
603
+ // wipes a session from the stats.
604
+ failed += 1
605
+ }
606
+ })
607
+ }
608
+ state.sessions = nextSessions
609
+ state.lastSessionsCount = ids.size
610
+ state.tz = tz
611
+ state.partial = failed > 0
612
+ if (!state.partial) state.syncedAt = Date.now()
613
+ return { tz, tzChanged, changed, failed, readMs: Date.now() - readStart, sessionCount: ids.size }
614
+ }
615
+
616
+ /**
617
+ * Fold the in-memory per-session contributions into the API response body
618
+ * (windowed by `days`, optionally filtered to one `modelKey`). Pure and cheap:
619
+ * O(number of distinct usage dates / models), no I/O.
620
+ */
621
+ export function foldResponse(state, days, modelKey, tz, now) {
622
+ // syncCorpus has already re-keyed all contributions to the requested offset.
623
+ const bucketTz = state.tz
624
+ void tz
625
+ const filterModel = modelKey
626
+ const fromKey = (() => {
627
+ if (!(days > 0)) return null
628
+ const fromMidnight = now - (days - 1) * 86400000
629
+ return localDayKey(fromMidnight, bucketTz)
630
+ })()
631
+ const todayKey = localDayKey(now, bucketTz)
632
+ // Keep enough day buckets for the heatmap even when the selected range is short.
633
+ const heatmapCutoff = localDayKey(now - 364 * 86400000, bucketTz)
634
+ const byDayCutoff = fromKey === null ? null : (fromKey > heatmapCutoff ? heatmapCutoff : fromKey)
635
+
636
+ const daySum = new Map()
637
+ const dayModelSum = new Map()
638
+ const weekSum = new Map()
639
+ const hourSum = new Map()
640
+ const modelSum = new Map()
641
+ const totals = emptyBucket()
642
+ const overviewTotals = emptyBucket()
643
+ const overviewDays = new Set()
644
+ const overviewModels = new Map()
645
+ let overviewTodayBilled = 0
646
+ let messages = 0
647
+ let sessionsWithUsage = 0
648
+ let firstTime = null
649
+ let lastTime = null
650
+
651
+ const bucketInto = (map, date, b) => {
652
+ const cur = map.get(date)
653
+ if (cur === undefined) map.set(date, copyBucket(b))
654
+ else mergeBucket(cur, b)
655
+ }
656
+
657
+ const addModelBucket = (mk, bucket, meta) => {
658
+ const acc = modelSum.get(mk)
659
+ if (acc === undefined) {
660
+ const slash = mk.indexOf('/')
661
+ modelSum.set(mk, {
662
+ provider: meta && meta.provider || (slash >= 0 ? mk.slice(0, slash) : 'unknown'),
663
+ model: meta && meta.model || (slash >= 0 ? mk.slice(slash + 1) : mk),
664
+ lastTime: meta && meta.lastTime || 0,
665
+ bucket: copyBucket(bucket),
666
+ })
667
+ } else {
668
+ mergeBucket(acc.bucket, bucket)
669
+ if (meta && meta.lastTime > acc.lastTime) acc.lastTime = meta.lastTime
670
+ }
671
+ }
672
+
673
+ for (const sid of Object.keys(state.sessions)) {
674
+ const s = state.sessions[sid]
675
+ if (!s) continue
676
+ mergeBucket(overviewTotals, s.totals || emptyBucket())
677
+ for (const date of Object.keys(s.byDay || {})) {
678
+ const bucket = s.byDay[date]
679
+ if ((bucket.calls || 0) > 0) overviewDays.add(date)
680
+ if (date === todayKey) overviewTodayBilled += bucket.billed || 0
681
+ }
682
+ for (const mk of Object.keys(s.byModel || {})) {
683
+ const model = s.byModel[mk]
684
+ const current = overviewModels.get(mk)
685
+ if (current) current.billed += model.bucket && model.bucket.billed || 0
686
+ else overviewModels.set(mk, { key: mk, provider: model.provider, model: model.model, billed: model.bucket && model.bucket.billed || 0 })
687
+ }
688
+ let sessionMessages = 0
689
+ const daySource = filterModel ? (s.byDayModel || {}) : (s.byDay || {})
690
+ for (const key of Object.keys(daySource)) {
691
+ let date = key
692
+ if (filterModel) {
693
+ const sep = key.indexOf('|')
694
+ if (sep < 0 || key.slice(sep + 1) !== filterModel) continue
695
+ date = key.slice(0, sep)
696
+ }
697
+ const bucket = daySource[key]
698
+ if (byDayCutoff === null || date >= byDayCutoff) bucketInto(daySum, date, bucket)
699
+ if (fromKey !== null && date < fromKey) continue
700
+ mergeBucket(totals, bucket)
701
+ sessionMessages += bucket.calls || 0
702
+ const mid = keyMidnight(date, bucketTz) + 43200000
703
+ bucketInto(weekSum, localMondayKey(mid, bucketTz), bucket)
704
+ if (firstTime === null || mid < firstTime) firstTime = mid
705
+ if (lastTime === null || mid > lastTime) lastTime = mid
706
+ }
707
+ for (const key of Object.keys(s.byDayModel || {})) {
708
+ const sep = key.indexOf('|')
709
+ if (sep < 0) continue
710
+ const date = key.slice(0, sep)
711
+ const mk = key.slice(sep + 1)
712
+ if (filterModel && mk !== filterModel) continue
713
+ if (byDayCutoff !== null && date < byDayCutoff) continue
714
+ let perDay = dayModelSum.get(date)
715
+ if (perDay === undefined) {
716
+ perDay = new Map()
717
+ dayModelSum.set(date, perDay)
718
+ }
719
+ bucketInto(perDay, mk, s.byDayModel[key])
720
+ }
721
+ messages += sessionMessages
722
+ if (sessionMessages > 0) sessionsWithUsage += 1
723
+
724
+ if (fromKey === null) {
725
+ for (const mk of Object.keys(s.byModel || {})) {
726
+ if (filterModel && mk !== filterModel) continue
727
+ const mb = s.byModel[mk]
728
+ addModelBucket(mk, mb.bucket || emptyBucket(), mb)
729
+ }
730
+ } else {
731
+ for (const key of Object.keys(s.byDayModel || {})) {
732
+ const sep = key.indexOf('|')
733
+ if (sep < 0 || key.slice(0, sep) < fromKey) continue
734
+ const mk = key.slice(sep + 1)
735
+ if (filterModel && mk !== filterModel) continue
736
+ addModelBucket(mk, s.byDayModel[key], s.byModel && s.byModel[mk])
737
+ }
738
+ }
739
+
740
+ // Hour-of-day sums for the current local day (keys: `date|hour`, or
741
+ // `date|hour|modelKey` when filtered). Each of the 24 bars is one hour of
742
+ // today; hours not yet elapsed (or without usage) stay zero.
743
+ const hourSource = filterModel ? (s.byHourModel || {}) : (s.byHour || {})
744
+ for (const key of Object.keys(hourSource)) {
745
+ let date
746
+ let hour
747
+ if (filterModel) {
748
+ const lastSep = key.lastIndexOf('|')
749
+ if (lastSep < 0 || key.slice(lastSep + 1) !== filterModel) continue
750
+ const midSep = key.lastIndexOf('|', lastSep - 1)
751
+ if (midSep < 0) continue
752
+ date = key.slice(0, midSep)
753
+ hour = key.slice(midSep + 1, lastSep)
754
+ } else {
755
+ const sep = key.lastIndexOf('|')
756
+ if (sep < 0) continue
757
+ date = key.slice(0, sep)
758
+ hour = key.slice(sep + 1)
759
+ }
760
+ if (date !== todayKey) continue
761
+ bucketInto(hourSum, hour, hourSource[key])
762
+ }
763
+ }
764
+
765
+ const byDay = []
766
+ for (const [date, bucket] of daySum) if (byDayCutoff === null || date >= byDayCutoff) byDay.push({ date, bucket })
767
+ byDay.sort(byDate)
768
+ const byDayModels = [...dayModelSum].map(([date, perDay]) => ({
769
+ date,
770
+ models: [...perDay].map(([key, bucket]) => {
771
+ const meta = overviewModels.get(key)
772
+ const slash = key.indexOf('/')
773
+ return {
774
+ key,
775
+ provider: meta && meta.provider || (slash >= 0 ? key.slice(0, slash) : 'unknown'),
776
+ model: meta && meta.model || (slash >= 0 ? key.slice(slash + 1) : key),
777
+ billed: bucket.billed,
778
+ calls: bucket.calls,
779
+ }
780
+ }).sort((a, b) => b.billed - a.billed),
781
+ })).sort(byDate)
782
+ const byWeek = []
783
+ for (const [date, bucket] of weekSum) byWeek.push({ date, bucket })
784
+ byWeek.sort(byDate)
785
+
786
+ // Always 24 bars (0..23) so the chart shows a full day even for empty hours.
787
+ const byHour = []
788
+ for (let h = 0; h < 24; h++) {
789
+ const bucket = hourSum.get(String(h))
790
+ byHour.push({ hour: h, bucket: bucket === undefined ? emptyBucket() : bucket })
791
+ }
792
+
793
+ // Per-model hour sums for the current local day, for the stacked
794
+ // 24-hour chart (keys in the corpus: `date|hour|modelKey`). Colours are
795
+ // assigned client-side per model and persisted (localStorage), so a model
796
+ // keeps one fixed colour across the donut and the bars regardless of rank.
797
+ const hourModelSum = new Map() // hour -> Map(modelKey -> bucket)
798
+ for (const sid of Object.keys(state.sessions)) {
799
+ const s = state.sessions[sid]
800
+ if (!s) continue
801
+ const hm = s.byHourModel || {}
802
+ for (const key of Object.keys(hm)) {
803
+ const lastSep = key.lastIndexOf('|')
804
+ if (lastSep < 0) continue
805
+ const midSep = key.lastIndexOf('|', lastSep - 1)
806
+ if (midSep < 0) continue
807
+ const date = key.slice(0, midSep)
808
+ const hour = key.slice(midSep + 1, lastSep)
809
+ const mk = key.slice(lastSep + 1)
810
+ if (date !== todayKey) continue
811
+ if (filterModel && mk !== filterModel) continue
812
+ let per = hourModelSum.get(hour)
813
+ if (per === undefined) {
814
+ per = new Map()
815
+ hourModelSum.set(hour, per)
816
+ }
817
+ const cur = per.get(mk)
818
+ if (cur === undefined) per.set(mk, copyBucket(hm[key]))
819
+ else mergeBucket(cur, hm[key])
820
+ }
821
+ }
822
+ const byHourModels = []
823
+ for (let h = 0; h < 24; h++) {
824
+ const per = hourModelSum.get(String(h))
825
+ const models = []
826
+ if (per !== undefined) {
827
+ for (const [mk, b] of per) {
828
+ const ref = modelSum.get(mk)
829
+ models.push({ key: mk, provider: ref ? ref.provider : 'unknown', model: ref ? ref.model : mk, billed: b.billed })
830
+ }
831
+ models.sort((a, b) => b.billed - a.billed)
832
+ // Same visual cap as the donut: top 8 + "其他".
833
+ if (models.length > 8) {
834
+ let restBilled = 0
835
+ for (let k = 8; k < models.length; k++) restBilled += models[k].billed
836
+ models.length = 8
837
+ models.push({ key: '__rest__', provider: '', model: '其他', billed: restBilled })
838
+ }
839
+ }
840
+ byHourModels.push({ hour: h, models })
841
+ }
842
+
843
+ const byModel = []
844
+ for (const [key, m] of modelSum) byModel.push({ key, provider: m.provider, model: m.model, lastTime: m.lastTime, bucket: m.bucket })
845
+ byModel.sort((a, b) => b.bucket.billed - a.bucket.billed)
846
+
847
+ const overview = {
848
+ todayBilled: overviewTodayBilled,
849
+ usedDays: overviewDays.size,
850
+ totalBilled: overviewTotals.billed,
851
+ models: [...overviewModels.values()].sort((a, b) => b.billed - a.billed),
852
+ }
853
+ return { totals, messages, sessionsWithUsage, byDay, byDayModels, byWeek, byHour, byHourModels, byModel, overview, firstTime, lastTime }
854
+ }
855
+
856
+ /* ------------------------------------------------------------------ */
857
+ /* Cordis plugin `apply` */
858
+ /* ------------------------------------------------------------------ */
859
+
860
+ export function apply(ctx) {
861
+ if (ctx.get('profileContext')?.name !== 'desktop') return
862
+
863
+ const readProviderQuotas = createProviderQuotaReader(ctx)
864
+ const quotaControls = createQuotaControls(ctx, {
865
+ path: join(resolveHome(), 'storages', 'usage-stats-controls.json'),
866
+ sources: {
867
+ providers: (excluded) => readProviderQuotas(true, excluded),
868
+ workbuddy: workBuddyQuotaSource(ctx),
869
+ 'workbuddy-ai': workBuddyQuotaSource(ctx, undefined, 'workbuddy-ai'),
870
+ },
871
+ })
872
+ const storePath = resolveStorePath()
873
+ let state = defaultState()
874
+ let syncPromise = null
875
+ let syncTz = null
876
+
877
+ let loadPromise = null
878
+ const ensureLoaded = () => {
879
+ if (loadPromise === null) {
880
+ loadPromise = loadState(storePath).then((loaded) => {
881
+ state = loaded
882
+ return loaded
883
+ })
884
+ }
885
+ return loadPromise
886
+ }
887
+
888
+ /** Single-flight refresh; a different offset waits for the active pass. */
889
+ const sync = (tz = state.tz) => {
890
+ if (syncPromise !== null) {
891
+ if (syncTz === tz) return syncPromise
892
+ return syncPromise.then(() => sync(tz))
893
+ }
894
+ syncTz = tz
895
+ syncPromise = syncCorpus(ctx, tz, state)
896
+ .then(async (result) => {
897
+ if (result.changed > 0 || result.tzChanged) await persistState(state, storePath)
898
+ return result
899
+ })
900
+ .catch((error) => {
901
+ state.partial = true
902
+ console.warn('[dsh-usage-stats] sync failed:', String(error && error.message || error))
903
+ return { tz: state.tz, changed: 0, failed: 1, readMs: 0, sessionCount: state.lastSessionsCount, error: String(error && error.message || error) }
904
+ })
905
+ .finally(() => {
906
+ syncPromise = null
907
+ syncTz = null
908
+ })
909
+ return syncPromise
910
+ }
911
+
912
+ /** Start a refresh and wait at most MAX_WAIT_MS before serving current data. */
913
+ const syncWithBudget = (tz) => {
914
+ const p = sync(tz)
915
+ return new Promise((resolve) => {
916
+ let settled = false
917
+ const finish = () => {
918
+ if (settled) return
919
+ settled = true
920
+ clearTimeout(timer)
921
+ resolve()
922
+ }
923
+ const timer = setTimeout(finish, MAX_WAIT_MS)
924
+ timer.unref?.()
925
+ p.then(finish, finish)
926
+ })
927
+ }
928
+
929
+ const writeJson = (res, status, body) => {
930
+ const payload = JSON.stringify(body)
931
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
932
+ res.end(payload)
933
+ }
934
+
935
+ const guard = (req, res, method) => {
936
+ if (!isLoopbackRequest(req)) {
937
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
938
+ return false
939
+ }
940
+ if ((req.method || 'GET') !== method) {
941
+ writeJson(res, 405, { error: 'method not allowed: ' + req.method })
942
+ return false
943
+ }
944
+ return true
945
+ }
946
+
947
+ const readQuery = (req) => {
948
+ try {
949
+ return new URL(req.url, 'http://x').searchParams
950
+ } catch {
951
+ return new URLSearchParams('')
952
+ }
953
+ }
954
+
955
+ const webServer = ctx.get('webServer')
956
+ if (webServer === undefined) return
957
+
958
+ ctx.effect(
959
+ () => webServer.register({
960
+ kind: 'exact',
961
+ path: '/api/dsh-usage-stats/stats',
962
+ handler: async (req, res) => {
963
+ if (!guard(req, res, 'GET')) return
964
+ const t0 = Date.now()
965
+ const params = readQuery(req)
966
+ const daysParam = Number(params.get('days'))
967
+ const days = Number.isFinite(daysParam) && daysParam > 0 ? Math.min(Math.round(daysParam), 3650) : 0
968
+ const modelKey = (params.get('model') || '').trim() || null
969
+ const tzParam = params.get('tz')
970
+ const requestedTz = tzParam === null || tzParam === '' ? SYSTEM_TZ : clampTz(tzParam)
971
+ const fresh = params.get('fresh') === '1'
972
+ try {
973
+ await ensureLoaded()
974
+ const staleByTtl = Date.now() - (state.syncedAt || 0) > CACHE_TTL_MS
975
+ if (state.tz !== requestedTz) {
976
+ // A timezone switch must finish before the snapshot is served.
977
+ await sync(requestedTz)
978
+ } else if (fresh || staleByTtl || state.partial) {
979
+ await syncWithBudget(requestedTz)
980
+ }
981
+ if (state.tz !== requestedTz) throw new Error('requested timezone is not ready')
982
+ const now = Date.now()
983
+ const data = foldResponse(state, days, modelKey, state.tz, now)
984
+ const stale = syncPromise !== null || state.partial || now - (state.syncedAt || 0) > CACHE_TTL_MS
985
+ writeJson(res, 200, {
986
+ ok: true,
987
+ days,
988
+ model: modelKey,
989
+ tz: state.tz,
990
+ totals: serializeBucket(data.totals),
991
+ overview: data.overview,
992
+ messages: data.messages,
993
+ sessions: state.lastSessionsCount,
994
+ sessionsWithUsage: data.sessionsWithUsage,
995
+ firstTime: data.firstTime,
996
+ lastTime: data.lastTime,
997
+ byDay: data.byDay.map((d) => ({ date: d.date, ...serializeBucket(d.bucket) })),
998
+ byDayModels: data.byDayModels,
999
+ byWeek: data.byWeek.map((w) => ({ date: w.date, ...serializeBucket(w.bucket) })),
1000
+ byHour: data.byHour.map((h) => ({ hour: h.hour, ...serializeBucket(h.bucket) })),
1001
+ byHourModels: data.byHourModels,
1002
+ byModel: data.byModel.map((m) => ({ key: m.key, provider: m.provider, model: m.model, lastTime: m.lastTime, ...serializeBucket(m.bucket) })),
1003
+ syncedAt: state.syncedAt || 0,
1004
+ stale,
1005
+ partial: state.partial,
1006
+ scanMs: Date.now() - t0,
1007
+ })
1008
+ } catch {
1009
+ writeJson(res, 500, { ok: false, error: '用量统计暂时不可用,请稍后重试。' })
1010
+ }
1011
+ },
1012
+ }),
1013
+ 'dsh-usage-stats: routes',
1014
+ )
1015
+
1016
+ ctx.effect(
1017
+ () => webServer.register({
1018
+ kind: 'exact',
1019
+ path: '/api/dsh-usage-stats/provider-quotas',
1020
+ handler: async (req, res) => {
1021
+ if (!guard(req, res, 'GET')) return
1022
+ const fresh = readQuery(req).get('fresh') === '1'
1023
+ try {
1024
+ writeJson(res, 200, await quotaControls.read('providers', fresh))
1025
+ } catch {
1026
+ // This route never sends credential or upstream error text to the renderer.
1027
+ writeJson(res, 200, { ok: true, quotas: [] })
1028
+ }
1029
+ },
1030
+ }),
1031
+ 'dsh-usage-stats: official provider quota route',
1032
+ )
1033
+
1034
+ const trustedControlRequest = (req, res) => {
1035
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' }); return false }
1036
+ try {
1037
+ const host = new URL('http://' + req.headers.host).hostname
1038
+ if (!['localhost', '127.0.0.1', '[::1]'].includes(host)) throw new Error()
1039
+ if (req.headers.origin && new URL(req.headers.origin).host !== req.headers.host) throw new Error()
1040
+ if (req.headers['sec-fetch-site'] === 'cross-site') throw new Error()
1041
+ } catch { writeJson(res, 403, { ok: false, error: '请求来源不受信任。' }); return false }
1042
+ return true
1043
+ }
1044
+ const readControlBody = async (req) => {
1045
+ if (!String(req.headers['content-type'] || '').startsWith('application/json')) throw new QuotaControlError('请使用 JSON 请求。')
1046
+ let bytes = 0
1047
+ const chunks = []
1048
+ for await (const chunk of req) {
1049
+ bytes += Buffer.byteLength(chunk)
1050
+ if (bytes > 32_768) throw new QuotaControlError('请求内容超过 32 KB。')
1051
+ chunks.push(Buffer.from(chunk))
1052
+ }
1053
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { throw new QuotaControlError('请求内容不是有效 JSON。') }
1054
+ }
1055
+ const registerControl = (path, handler) => ctx.effect(() => webServer.register({
1056
+ kind: 'exact', path: '/api/dsh-usage-stats/' + path,
1057
+ handler: async (req, res) => {
1058
+ if (!trustedControlRequest(req, res)) return
1059
+ try { await handler(req, res) } catch (error) { writeJson(res, error instanceof QuotaControlError ? 400 : 500, { ok: false, error: error instanceof QuotaControlError ? error.message : '操作失败,请稍后重试。' }) }
1060
+ },
1061
+ }), 'dsh-usage-stats: ' + path)
1062
+ registerControl('controls', async (req, res) => {
1063
+ if (req.method === 'GET') writeJson(res, 200, await quotaControls.get())
1064
+ else if (guard(req, res, 'POST')) writeJson(res, 200, await quotaControls.update(await readControlBody(req)))
1065
+ })
1066
+ registerControl('quota-providers', async (req, res) => {
1067
+ if (guard(req, res, 'GET')) writeJson(res, 200, await quotaControls.providers())
1068
+ })
1069
+ registerControl('quota-test', async (req, res) => {
1070
+ if (!guard(req, res, 'POST')) return
1071
+ const body = await readControlBody(req)
1072
+ writeJson(res, 200, await quotaControls.test(body.provider, body.template))
1073
+ })
1074
+ for (const source of ['workbuddy', 'workbuddy-ai']) {
1075
+ registerControl(source, async (req, res) => {
1076
+ if (guard(req, res, 'GET')) writeJson(res, 200, await quotaControls.read(source, readQuery(req).get('fresh') === '1'))
1077
+ })
1078
+ }
1079
+
1080
+ void quotaControls.tick().catch(() => {})
1081
+ ctx.effect(() => ctx.interval(() => { void quotaControls.tick().catch(() => {}) }, 30_000), 'dsh-usage-stats: automatic quota refresh')
1082
+ ctx.effect(() => () => quotaControls.dispose(), 'dsh-usage-stats: quota controls cleanup')
1083
+
1084
+ // Background keep-fresh: build + persist corpus shortly after boot so the
1085
+ // first page open is served from warm memory, and keep it warm thereafter.
1086
+ void ensureLoaded().then(() => {
1087
+ return sync().catch(() => {})
1088
+ })
1089
+
1090
+ ctx.effect(
1091
+ () => ctx.interval(() => {
1092
+ void sync().catch(() => {})
1093
+ }, SYNC_INTERVAL_MS),
1094
+ 'dsh-usage-stats: keep-fresh',
1095
+ )
1096
+
1097
+ // Persist once at shutdown so the freshest snapshot survives restarts.
1098
+ ctx.effect(() => {
1099
+ return () => {
1100
+ if (Object.keys(state.sessions).length > 0) void persistState(state, storePath)
1101
+ }
1102
+ }, 'dsh-usage-stats: shutdown persist')
1103
+ }
1104
+
1105
+ export default { name, inject, apply }