@linxin666/dsh-usage 0.3.7

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.
@@ -0,0 +1,530 @@
1
+ /**
2
+ * The dsh-usage host service: folds live session usage into the persistent
3
+ * ledger, probes each configured provider's balance/coding-plan endpoint on
4
+ * a poll cycle, and announces the current provider's status to the pet
5
+ * bubble. Secrets stay in the host process; the browser only ever sees the
6
+ * overview document.
7
+ * @module @linxin666/dsh-usage/host/usage-service
8
+ */
9
+
10
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
11
+ import { dirname, join } from 'node:path'
12
+ import type { Context } from '@deepseek-ai/cordis'
13
+ import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials'
14
+ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
15
+ import type { TokenUsage } from '@deepseek-ai/dsh-llm'
16
+ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
17
+ import { dshHome } from '../dsh-home.ts'
18
+ import { adapterFor, providerErrorMessage } from '../core/adapters.ts'
19
+ import type { BalanceParse, PlanParse } from '../core/adapters.ts'
20
+ import { createLedgerDocument, deserializeLedger, foldUsage, ledgerDayKeys, localDateKey, pruneLedger, totalTokens } from '../core/ledger.ts'
21
+ import type { BalanceView, CredentialKind, PlanView, ProviderSnapshotView, UsageLedgerDocument, UsageOverviewView, UsageTokenTotals } from '../core/types.ts'
22
+ import { emptyTotals } from '../core/types.ts'
23
+
24
+ /** Source tag the plugin stamps onto pet announcements. */
25
+ export const USAGE_ANNOUNCE_SOURCE = 'dsh-usage'
26
+
27
+ /** Per-route credential fact the probes run against. */
28
+ interface ResolvedCredential {
29
+ kind: CredentialKind
30
+ key?: string
31
+ }
32
+
33
+ /** One live LLM provider route the service knows about. */
34
+ interface ProviderRoute {
35
+ id: string
36
+ displayName: string
37
+ }
38
+
39
+ /** Poll-loop and announce options; re-applied live on settings change. */
40
+ export interface UsageServiceOptions {
41
+ pollIntervalSec: number
42
+ bubbleMode: 'always' | 'change' | 'off'
43
+ retainDays: number
44
+ }
45
+
46
+ /** Probe timeout per HTTP call. */
47
+ const PROBE_TIMEOUT_MS = 10_000
48
+
49
+ /** Ledger flush debounce. */
50
+ const FLUSH_DEBOUNCE_MS = 3_000
51
+
52
+ /** How many trend days the overview serves. */
53
+ const TREND_DAYS = 30
54
+
55
+ /** Currency symbols the bubble and section render inline; other codes render as `12.00 EUR`. */
56
+ const CURRENCY_SYMBOLS: Readonly<Record<string, string>> = { CNY: '¥', USD: '$', EUR: '€', GBP: '£' }
57
+
58
+ /** Format a balance for display: symbol prefix when known, code suffix otherwise. */
59
+ export function formatMoney(currency: string, totalBalance: string): string {
60
+ const symbol = CURRENCY_SYMBOLS[currency.toUpperCase()]
61
+ if (symbol !== undefined) return symbol + totalBalance
62
+ return `${totalBalance} ${currency.toUpperCase()}`
63
+ }
64
+
65
+ /** Map a used percent to the announcement tone. */
66
+ export function planTone(percent: number): 'ok' | 'warn' | 'low' {
67
+ if (percent >= 90) return 'low'
68
+ if (percent >= 70) return 'warn'
69
+ return 'ok'
70
+ }
71
+
72
+ /** Best-effort typed service read: absent services resolve to undefined at runtime. */
73
+ function service<T>(ctx: Context, name: string): T | undefined {
74
+ try {
75
+ return (ctx as unknown as { get(name: string): unknown }).get(name) as T | undefined
76
+ } catch {
77
+ return undefined
78
+ }
79
+ }
80
+
81
+ /** Atomic JSON write through a temp file + rename. */
82
+ async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
83
+ const temp = path + '.tmp'
84
+ await writeFile(temp, JSON.stringify(value, null, 1), 'utf8')
85
+ await rename(temp, path)
86
+ }
87
+
88
+ /**
89
+ * Read a foreign settings namespace's resolved value (the llm adapter
90
+ * profiles, the agent default model). Unregistered namespaces read as
91
+ * undefined; nothing here throws into the poll loop.
92
+ */
93
+ function readNamespace(ctx: Context, ns: string): unknown {
94
+ try {
95
+ const settings = service<{ get(ns: unknown): unknown }>(ctx, 'settings')
96
+ if (settings === undefined) return undefined
97
+ return settings.get(settingsNamespace(ns))
98
+ } catch {
99
+ return undefined
100
+ }
101
+ }
102
+
103
+ export class UsageService {
104
+ private readonly ctx: Context
105
+ private options: UsageServiceOptions
106
+ private readonly persistDir: string
107
+ private readonly ledgerPath: string
108
+ private readonly snapshotsPath: string
109
+
110
+ private ledger: UsageLedgerDocument = createLedgerDocument()
111
+ private readonly snapshots = new Map<string, ProviderSnapshotView>()
112
+ /** Per-live-session route attribution (WeakMap: disposed sessions age out). */
113
+ private readonly sessionRoutes = new WeakMap<Session, { provider: string; model: string }>()
114
+ /** The most recent route seen this boot; the pet bubble follows it. */
115
+ private current: { provider?: string; model?: string; source: 'live' | 'default' } = { source: 'default' }
116
+
117
+ private sessionListenerDisposer: (() => void) | undefined
118
+ private pollTimer: ReturnType<typeof setTimeout> | undefined
119
+ private flushTimer: ReturnType<typeof setTimeout> | undefined
120
+ private pollInFlight = false
121
+ private disposed = false
122
+ private lastSignature: string | undefined
123
+
124
+ constructor(ctx: Context, options: UsageServiceOptions) {
125
+ this.ctx = ctx
126
+ this.options = options
127
+ this.persistDir = join(dshHome(), 'dsh-usage')
128
+ this.ledgerPath = join(this.persistDir, 'usage-ledger.json')
129
+ this.snapshotsPath = join(this.persistDir, 'provider-snapshots.json')
130
+ }
131
+
132
+ /** Start the listeners, load persisted state, and arm the first poll. */
133
+ start(): void {
134
+ this.sessionListenerDisposer = this.ctx.on('session/event', (session, event) => this.onSessionEvent(session, event))
135
+ void this.loadPersisted()
136
+ this.rearmPoll(2_000)
137
+ }
138
+
139
+ /** Stop timers and flush pending ledger writes. */
140
+ stop(): void {
141
+ this.disposed = true
142
+ if (this.pollTimer !== undefined) clearTimeout(this.pollTimer)
143
+ if (this.flushTimer !== undefined) clearTimeout(this.flushTimer)
144
+ this.sessionListenerDisposer?.()
145
+ void this.flushLedger()
146
+ }
147
+
148
+ /** Re-apply options live (settings change); the poll cycle picks them up. */
149
+ applyOptions(options: UsageServiceOptions): void {
150
+ this.options = options
151
+ }
152
+
153
+ /** Force one poll now (manual refresh route). */
154
+ async refresh(): Promise<void> {
155
+ await this.pollNow()
156
+ }
157
+
158
+ /** Assemble the overview document the browser section renders. */
159
+ overview(): UsageOverviewView {
160
+ const todayKey = localDateKey(Date.now())
161
+ const routes = this.listProviderRoutes()
162
+ const providers: ProviderSnapshotView[] = []
163
+ for (const route of routes) {
164
+ const adapter = adapterFor(route.id)
165
+ const snapshot = this.snapshots.get(route.id)
166
+ providers.push({
167
+ provider: route.id,
168
+ displayName: route.displayName || adapter?.displayName || route.id,
169
+ credential: snapshot?.credential ?? 'none',
170
+ supported: adapter !== undefined && (adapter.balance !== undefined || adapter.plan !== undefined),
171
+ ...(snapshot?.balance !== undefined ? { balance: snapshot.balance } : {}),
172
+ ...(snapshot?.plan !== undefined ? { plan: snapshot.plan } : {}),
173
+ ...(snapshot?.error !== undefined ? { error: snapshot.error } : {}),
174
+ ...(snapshot?.updatedAt !== undefined ? { updatedAt: snapshot.updatedAt } : {}),
175
+ })
176
+ }
177
+ providers.sort((a, b) => Number(b.supported) - Number(a.supported) || a.displayName.localeCompare(b.displayName))
178
+ const days = ledgerDayKeys(this.ledger).slice(-TREND_DAYS)
179
+ return {
180
+ updatedAt: Date.now(),
181
+ providers,
182
+ current: { ...this.current },
183
+ usage: {
184
+ today: this.daySummary(todayKey),
185
+ days: days.map((date) => {
186
+ const summary = this.daySummary(date)
187
+ return { date, totals: summary.totals }
188
+ }),
189
+ },
190
+ }
191
+ }
192
+
193
+ /** One local day aggregated per provider. */
194
+ private daySummary(dateKey: string): UsageOverviewView['usage']['today'] {
195
+ const day = this.ledger.days[dateKey] ?? {}
196
+ const providers = Object.entries(day).map(([provider, models]) => {
197
+ const totals = emptyTotals()
198
+ const modelRows = Object.entries(models).map(([model, modelTotals]) => ({ model, totals: modelTotals }))
199
+ for (const row of modelRows) {
200
+ totals.inputTokens += row.totals.inputTokens
201
+ totals.outputTokens += row.totals.outputTokens
202
+ totals.cacheReadTokens += row.totals.cacheReadTokens
203
+ totals.cacheWriteTokens += row.totals.cacheWriteTokens
204
+ totals.reasoningTokens += row.totals.reasoningTokens
205
+ totals.calls += row.totals.calls
206
+ }
207
+ modelRows.sort((a, b) => totalTokens(b.totals) - totalTokens(a.totals))
208
+ return { provider, totals, models: modelRows.slice(0, 12) }
209
+ })
210
+ providers.sort((a, b) => totalTokens(b.totals) - totalTokens(a.totals))
211
+ const totals = emptyTotals()
212
+ for (const row of providers) {
213
+ totals.inputTokens += row.totals.inputTokens
214
+ totals.outputTokens += row.totals.outputTokens
215
+ totals.cacheReadTokens += row.totals.cacheReadTokens
216
+ totals.cacheWriteTokens += row.totals.cacheWriteTokens
217
+ totals.reasoningTokens += row.totals.reasoningTokens
218
+ totals.calls += row.totals.calls
219
+ }
220
+ return { date: dateKey, totals, providers }
221
+ }
222
+
223
+ // ------------------------------------------------------------------
224
+ // Session usage fold
225
+ // ------------------------------------------------------------------
226
+
227
+ private onSessionEvent(session: Session, event: SessionEvent): void {
228
+ try {
229
+ if (event.type === 'request/header') {
230
+ const config = (event.data as { header?: { config?: { provider?: string; model?: string } } }).header?.config
231
+ if (config?.provider !== undefined) {
232
+ this.sessionRoutes.set(session, { provider: config.provider, model: config.model ?? '' })
233
+ this.current = { provider: config.provider, model: config.model, source: 'live' }
234
+ }
235
+ } else if (event.type === 'request/context') {
236
+ const data = event.data as { provider?: string; model?: string }
237
+ if (data.provider !== undefined) {
238
+ this.sessionRoutes.set(session, { provider: data.provider, model: data.model ?? '' })
239
+ this.current = { provider: data.provider, model: data.model, source: 'live' }
240
+ }
241
+ } else if (event.type === 'assistant/message') {
242
+ const usage = (event.data as { usage?: TokenUsage }).usage
243
+ if (usage === undefined) return
244
+ const route = this.sessionRoutes.get(session)
245
+ if (route === undefined || route.provider === '') return
246
+ foldUsage(this.ledger, Date.now(), route.provider, route.model || 'unknown', this.totalsFrom(usage))
247
+ this.scheduleFlush()
248
+ }
249
+ } catch {
250
+ // A malformed event must never break the session loop.
251
+ }
252
+ }
253
+
254
+ /** Normalize a provider TokenUsage into the ledger bucket (one call). */
255
+ private totalsFrom(usage: TokenUsage): UsageTokenTotals {
256
+ const totals = emptyTotals()
257
+ totals.inputTokens = usage.inputTokens
258
+ totals.outputTokens = usage.outputTokens
259
+ totals.cacheReadTokens = usage.cacheReadTokens ?? 0
260
+ totals.cacheWriteTokens = usage.cacheWriteTokens ?? 0
261
+ totals.reasoningTokens = usage.reasoningTokens ?? 0
262
+ totals.calls = 1
263
+ return totals
264
+ }
265
+
266
+ // ------------------------------------------------------------------
267
+ // Persistence
268
+ // ------------------------------------------------------------------
269
+
270
+ private async loadPersisted(): Promise<void> {
271
+ try {
272
+ const [rawLedger, rawSnapshots] = await Promise.all([
273
+ readFile(this.ledgerPath, 'utf8').catch(() => undefined),
274
+ readFile(this.snapshotsPath, 'utf8').catch(() => undefined),
275
+ ])
276
+ if (rawLedger !== undefined) {
277
+ this.ledger = deserializeLedger(JSON.parse(rawLedger))
278
+ pruneLedger(this.ledger, localDateKey(Date.now()), this.options.retainDays)
279
+ }
280
+ if (rawSnapshots !== undefined) {
281
+ const parsed = JSON.parse(rawSnapshots) as { providers?: Record<string, ProviderSnapshotView> }
282
+ if (typeof parsed === 'object' && parsed !== null && typeof parsed.providers === 'object' && parsed.providers !== null) {
283
+ for (const [provider, snapshot] of Object.entries(parsed.providers)) {
284
+ if (typeof snapshot === 'object' && snapshot !== null && typeof snapshot.provider === 'string') {
285
+ this.snapshots.set(provider, snapshot)
286
+ }
287
+ }
288
+ }
289
+ }
290
+ } catch {
291
+ // Corrupt or unreadable state starts fresh; the next flush rewrites it.
292
+ }
293
+ }
294
+
295
+ private scheduleFlush(): void {
296
+ if (this.flushTimer !== undefined || this.disposed) return
297
+ this.flushTimer = setTimeout(() => {
298
+ this.flushTimer = undefined
299
+ void this.flushLedger()
300
+ }, FLUSH_DEBOUNCE_MS)
301
+ }
302
+
303
+ private async flushLedger(): Promise<void> {
304
+ try {
305
+ await mkdir(dirname(this.ledgerPath), { recursive: true })
306
+ await writeJsonAtomic(this.ledgerPath, this.ledger)
307
+ } catch {
308
+ // Persistence failures degrade silently; the in-memory ledger keeps counting.
309
+ }
310
+ }
311
+
312
+ private async persistSnapshots(): Promise<void> {
313
+ try {
314
+ await mkdir(dirname(this.snapshotsPath), { recursive: true })
315
+ await writeJsonAtomic(this.snapshotsPath, { version: 1, providers: Object.fromEntries(this.snapshots) })
316
+ } catch {
317
+ // Same silent degradation as the ledger.
318
+ }
319
+ }
320
+
321
+ // ------------------------------------------------------------------
322
+ // Poll loop
323
+ // ------------------------------------------------------------------
324
+
325
+ private rearmPoll(delayMs: number): void {
326
+ if (this.disposed) return
327
+ if (this.pollTimer !== undefined) clearTimeout(this.pollTimer)
328
+ this.pollTimer = setTimeout(() => {
329
+ this.pollTimer = undefined
330
+ void this.pollNow().finally(() => this.rearmPoll(Math.max(30, this.options.pollIntervalSec) * 1000))
331
+ }, delayMs)
332
+ }
333
+
334
+ /** One poll cycle: enumerate routes, resolve credentials, probe, announce. */
335
+ async pollNow(): Promise<void> {
336
+ if (this.pollInFlight || this.disposed) return
337
+ this.pollInFlight = true
338
+ try {
339
+ const routes = this.listProviderRoutes()
340
+ const seen = new Set<string>()
341
+ for (const route of routes) {
342
+ seen.add(route.id)
343
+ const adapter = adapterFor(route.id)
344
+ if (adapter === undefined || (adapter.balance === undefined && adapter.plan === undefined)) continue
345
+ await this.probeRoute(route, adapter)
346
+ }
347
+ for (const id of [...this.snapshots.keys()]) {
348
+ if (!seen.has(id)) this.snapshots.delete(id)
349
+ }
350
+ await this.persistSnapshots()
351
+ this.announceCurrent()
352
+ } finally {
353
+ this.pollInFlight = false
354
+ }
355
+ }
356
+
357
+ private listProviderRoutes(): ProviderRoute[] {
358
+ const routes = new Map<string, string>()
359
+ const runtime = service<{ listProviders(): Array<{ id: string; name: string }>; listConfigurableProviders(): Array<{ provider: string; displayName: string }> }>(this.ctx, 'llm')
360
+ if (runtime !== undefined) {
361
+ try {
362
+ for (const provider of runtime.listProviders()) {
363
+ if (provider.id !== '') routes.set(provider.id, provider.name)
364
+ }
365
+ for (const provider of runtime.listConfigurableProviders()) {
366
+ if (!routes.has(provider.provider)) routes.set(provider.provider, provider.displayName)
367
+ }
368
+ } catch {
369
+ // Registry hiccups degrade to an empty list; the next poll retries.
370
+ }
371
+ }
372
+ return [...routes].map(([id, displayName]) => ({ id, displayName }))
373
+ }
374
+
375
+ private async probeRoute(route: ProviderRoute, adapter: NonNullable<ReturnType<typeof adapterFor>>): Promise<void> {
376
+ const credential = await this.resolveCredential(route.id)
377
+ const previous = this.snapshots.get(route.id)
378
+ const snapshot: ProviderSnapshotView = {
379
+ provider: route.id,
380
+ displayName: route.displayName || adapter.displayName,
381
+ credential: credential.kind,
382
+ supported: true,
383
+ ...(previous?.balance !== undefined ? { balance: previous.balance } : {}),
384
+ ...(previous?.plan !== undefined ? { plan: previous.plan } : {}),
385
+ updatedAt: Date.now(),
386
+ }
387
+ if (credential.key === undefined) {
388
+ // Nothing to probe with (oauth grant, no credential): keep any previous
389
+ // facts visible and stay quiet about it.
390
+ snapshot.error = undefined
391
+ this.snapshots.set(route.id, snapshot)
392
+ return
393
+ }
394
+ let probed = false
395
+ const runProbe = async (kind: 'balance' | 'plan'): Promise<BalanceView | PlanView | undefined> => {
396
+ const half = adapter[kind]
397
+ if (half === undefined) return undefined
398
+ try {
399
+ const spec = half.build({ apiKey: credential.key as string })
400
+ const response = await fetch(spec.url, { headers: spec.headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) })
401
+ const body: unknown = await response.json().catch(() => undefined)
402
+ if (!response.ok) throw new Error(providerErrorMessage(response.status, body))
403
+ const parsed = half.parse(response.status, body)
404
+ if (parsed === undefined) throw new Error('unrecognized response shape')
405
+ probed = true
406
+ const updatedAt = Date.now()
407
+ if (kind === 'balance') {
408
+ const fact = parsed as BalanceParse
409
+ return { currency: fact.currency, totalBalance: fact.totalBalance, updatedAt }
410
+ }
411
+ const plan = parsed as PlanParse
412
+ return { planName: plan.planName, windows: plan.windows, updatedAt }
413
+ } catch (error) {
414
+ snapshot.error = error instanceof Error ? error.message.slice(0, 200) : String(error)
415
+ return undefined
416
+ }
417
+ }
418
+ const balance = await runProbe('balance')
419
+ // A failed probe keeps the previous fact visible (stale but useful), with
420
+ // the error line explaining why it is stale.
421
+ if (balance !== undefined) snapshot.balance = balance as BalanceView
422
+ const plan = await runProbe('plan')
423
+ if (plan !== undefined) snapshot.plan = plan as PlanView
424
+ if (probed) snapshot.error = undefined
425
+ if (snapshot.balance === undefined && snapshot.plan === undefined && snapshot.error === undefined) {
426
+ snapshot.error = 'no credential configured'
427
+ }
428
+ this.snapshots.set(route.id, snapshot)
429
+ }
430
+
431
+ /**
432
+ * Resolve the credential backing one route: pi-ai credential records
433
+ * first, then the profile's apiKeyEnv reference, then the DeepSeek
434
+ * official adapter's env reference. OAuth grants resolve to a kind with
435
+ * no key (this plugin does not spend third-party OAuth budgets).
436
+ */
437
+ private async resolveCredential(provider: string): Promise<ResolvedCredential> {
438
+ const credentials = service<{
439
+ readRecord(key: unknown): Promise<{ kind: string; key?: string; env?: Record<string, string> } | undefined>
440
+ resolve(ref: unknown): Promise<{ value: string } | undefined>
441
+ }>(this.ctx, 'credentials')
442
+ if (credentials === undefined) return { kind: 'none' }
443
+ try {
444
+ const record = await credentials.readRecord(credentialKey('llm-pi-ai', provider))
445
+ if (record?.kind === 'api-key' && typeof record.key === 'string' && record.key !== '') {
446
+ return { kind: 'api-key', key: record.key }
447
+ }
448
+ if (record?.kind === 'grant') return { kind: 'oauth' }
449
+ } catch {
450
+ // Invalid record ids read as absent.
451
+ }
452
+ try {
453
+ const profile = this.piAiProfile(provider)
454
+ const envName = profile?.apiKeyEnv ?? (provider === 'deepseek' ? this.deepseekApiKeyEnv() : undefined)
455
+ if (typeof envName === 'string' && envName !== '') {
456
+ const resolved = await credentials.resolve(credentialRef(envName))
457
+ if (resolved?.value !== undefined && resolved.value !== '') return { kind: 'env', key: resolved.value }
458
+ }
459
+ } catch {
460
+ // Malformed references read as absent.
461
+ }
462
+ return { kind: 'none' }
463
+ }
464
+
465
+ /** The llm-pi-ai profile object for one provider route, when configured. */
466
+ private piAiProfile(provider: string): { apiKeyEnv?: string; baseURL?: string } | undefined {
467
+ const section = readNamespace(this.ctx, 'llm-pi-ai') as { providers?: Record<string, { apiKeyEnv?: string; baseURL?: string }> } | undefined
468
+ return section?.providers?.[provider]
469
+ }
470
+
471
+ /** The DeepSeek official adapter's credential reference name. */
472
+ private deepseekApiKeyEnv(): string {
473
+ const section = readNamespace(this.ctx, 'llm-deepseek') as { apiKeyEnv?: string } | undefined
474
+ return section?.apiKeyEnv ?? 'DEEPSEEK_API_KEY'
475
+ }
476
+
477
+ // ------------------------------------------------------------------
478
+ // Pet bubble
479
+ // ------------------------------------------------------------------
480
+
481
+ /**
482
+ * Announce the current provider's balance or plan usage to the pet. In
483
+ * `change` mode only meaningful value changes re-announce; `off` skips.
484
+ */
485
+ private announceCurrent(): void {
486
+ if (this.options.bubbleMode === 'off') return
487
+ let provider = this.current.provider
488
+ if (provider === undefined) {
489
+ const fallback = readNamespace(this.ctx, 'agent-default-model') as { provider?: string; model?: string } | undefined
490
+ if (fallback?.provider !== undefined) {
491
+ provider = fallback.provider
492
+ this.current = { provider: fallback.provider, model: fallback.model, source: 'default' }
493
+ } else {
494
+ return
495
+ }
496
+ }
497
+ const snapshot = this.snapshots.get(provider)
498
+ if (snapshot === undefined) return
499
+ let announcement: Record<string, unknown> | undefined
500
+ if (snapshot.balance !== undefined) {
501
+ announcement = {
502
+ kind: 'balance',
503
+ title: snapshot.displayName,
504
+ amount: formatMoney(snapshot.balance.currency, snapshot.balance.totalBalance),
505
+ tone: 'ok',
506
+ }
507
+ } else if (snapshot.plan !== undefined && snapshot.plan.windows.length > 0) {
508
+ const window = [...snapshot.plan.windows].sort((a, b) => (b.percent ?? 0) - (a.percent ?? 0))[0]
509
+ announcement = {
510
+ kind: 'plan',
511
+ title: snapshot.displayName,
512
+ ...(window.percent !== undefined ? { percent: window.percent } : {}),
513
+ ...(window.resetsAt !== undefined ? { resetAt: window.resetsAt } : {}),
514
+ ...(snapshot.plan.planName !== undefined ? { note: snapshot.plan.planName } : {}),
515
+ tone: window.percent !== undefined ? planTone(window.percent) : 'ok',
516
+ }
517
+ } else {
518
+ return
519
+ }
520
+ const signature = JSON.stringify(announcement)
521
+ if (this.options.bubbleMode === 'change' && signature === this.lastSignature) return
522
+ this.lastSignature = signature
523
+ const pet = service<{ announce(input: Record<string, unknown>): void }>(this.ctx, 'pet')
524
+ try {
525
+ pet?.announce({ source: USAGE_ANNOUNCE_SOURCE, ttlMs: undefined, ...announcement })
526
+ } catch {
527
+ // A missing or failing pet service must never break the poll loop.
528
+ }
529
+ }
530
+ }
package/src/index.ts ADDED
@@ -0,0 +1,90 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import type {} from '@deepseek-ai/dsh-host-webserver'
3
+ import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
4
+ import z from 'schemastery'
5
+ import { mountOnce } from './mount-once.ts'
6
+ import { UsageService, type UsageServiceOptions } from './host/usage-service.ts'
7
+ import { makeUsageOverviewRoute, makeUsageRefreshRoute } from './host/routes.ts'
8
+
9
+ export const name = 'dsh-usage'
10
+ export const inject = ['webServer']
11
+ export const USAGE_SETTINGS_NAMESPACE = settingsNamespace('dsh-usage')
12
+
13
+ export interface Config {
14
+ enabled?: boolean
15
+ /** Provider probe cycle in seconds; 30-3600. */
16
+ pollIntervalSec?: number
17
+ /** Pet bubble mode: always (refreshes each poll), change (only on value change), off. */
18
+ bubbleMode?: string
19
+ /** Ledger retention in local days. */
20
+ retainDays?: number
21
+ }
22
+
23
+ export const Config: z<Config> = z.object({
24
+ enabled: z.boolean().default(true),
25
+ pollIntervalSec: z.number().min(30).max(3600).default(60),
26
+ bubbleMode: z.string().default('always'),
27
+ retainDays: z.number().min(7).max(730).default(180),
28
+ })
29
+
30
+ export interface ResolvedConfig extends UsageServiceOptions {
31
+ enabled: boolean
32
+ }
33
+
34
+ export function resolveConfig(config?: Config): ResolvedConfig {
35
+ const bubbleMode = config?.bubbleMode === 'change' || config?.bubbleMode === 'off' ? config.bubbleMode : 'always'
36
+ return {
37
+ enabled: config?.enabled ?? true,
38
+ pollIntervalSec: typeof config?.pollIntervalSec === 'number' ? config.pollIntervalSec : 60,
39
+ bubbleMode,
40
+ retainDays: typeof config?.retainDays === 'number' ? config.retainDays : 180,
41
+ }
42
+ }
43
+
44
+ export const apply = mountOnce('@linxin666/dsh-usage', (ctx: Context, config?: Config): void => {
45
+ let source: () => Config = () => config ?? {}
46
+ let service: UsageService | undefined
47
+ let disposeRoutes: (() => void) | undefined
48
+
49
+ const rearm = (): void => {
50
+ const value = resolveConfig(source())
51
+ if (!value.enabled) {
52
+ service?.stop()
53
+ service = undefined
54
+ disposeRoutes?.()
55
+ disposeRoutes = undefined
56
+ return
57
+ }
58
+ if (service === undefined) {
59
+ service = new UsageService(ctx, value)
60
+ service.start()
61
+ const disposers = [makeUsageOverviewRoute(service), makeUsageRefreshRoute(service)]
62
+ .map((route) => ctx.webServer.register(route))
63
+ disposeRoutes = () => {
64
+ for (const dispose of disposers) {
65
+ try {
66
+ dispose()
67
+ } catch {
68
+ // Route fiber already gone during shutdown.
69
+ }
70
+ }
71
+ }
72
+ } else {
73
+ service.applyOptions(value)
74
+ }
75
+ }
76
+
77
+ installSettingsSection(ctx, USAGE_SETTINGS_NAMESPACE, Config, config ?? {}, {
78
+ setSource: (next) => { source = next; rearm() },
79
+ onChange: rearm,
80
+ })
81
+
82
+ ctx.effect(() => {
83
+ rearm()
84
+ return () => {
85
+ disposeRoutes?.()
86
+ service?.stop()
87
+ service = undefined
88
+ }
89
+ }, 'dsh-usage: runtime')
90
+ })
@@ -0,0 +1,48 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/mount-once.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Host single-instance guard shared by the plugin family. The family bundle
4
+ * (dsh-web-all / dsh-skins) namespaces every child row id (web-ui-*), so
5
+ * the loader accepts a standalone install of the same package side by side;
6
+ * without this guard the second instance would still re-register the same
7
+ * webserver routes, tools, settings namespaces, and system-prompt sections
8
+ * and fail the boot. mountOnce makes the second host apply a no-op for the
9
+ * lifetime of the first instance (the browser half is already deduped by
10
+ * package name in the client module host).
11
+ *
12
+ * The registry rides a global symbol so two module instances of the same
13
+ * package (npm copy vs repository link) still share one verdict. cordis
14
+ * `ctx.effect` runs its callback immediately and treats the callback's
15
+ * return value as the fiber disposer, so the unmarker is returned, not run.
16
+ */
17
+
18
+ const MOUNTED = Symbol.for('dsh-web.mounted-plugins')
19
+
20
+ interface MountRegistry {
21
+ [MOUNTED]?: Set<string>
22
+ }
23
+
24
+ function mountedSet(): Set<string> {
25
+ const registry = globalThis as MountRegistry
26
+ return (registry[MOUNTED] ??= new Set())
27
+ }
28
+
29
+ /**
30
+ * Wrap a cordis plugin apply so the package runs at most once per process.
31
+ * The first mount registers normally and unmarks when its fiber disposes;
32
+ * any later mount of the same package name is a no-op.
33
+ * @param packageName - npm package identity shared by every install source.
34
+ * @param fn - the original plugin apply.
35
+ * @returns an apply of the same shape.
36
+ */
37
+ export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
38
+ return ((...args: unknown[]) => {
39
+ const mounted = mountedSet()
40
+ if (mounted.has(packageName)) return
41
+ mounted.add(packageName)
42
+ const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
43
+ ctx?.effect?.(() => () => {
44
+ mounted.delete(packageName)
45
+ })
46
+ return fn(...args)
47
+ }) as T
48
+ }