moonlight2893267956/dsh-usage-stats

usage-stats

统计所有已存 DSH 会话的 token 用量:Host 端把持久化会话日志折叠进检查点,并经一条只读 HTTP 路由提供;「用量」设置页先立即绘出已保存的聚合结果,再在后台更新。当日视图按小时分桶(带日期选择器),旁边是近 7/30 天窗口,含分模型明细、请求数与联网搜索次数;不含费用/定价模型。

用量与计费

description: "Per-day token-usage totals across every session on this device, folded from the durable session logs and served to clients over one read-only HTTP route." kind: "package-reference"

@moonlight2893267956/dsh-usage-stats

English | 中文

Summary

Use this package to report how many tokens this device has spent, aggregated by day across every session rather than only the one on screen. It folds the durable session logs into per-day totals of input, cache-read, and output tokens plus web-search counts, and serves them to clients through one read-only HTTP route. Because the log is the source of truth, totals survive restarts and backfill history; a storage checkpoint makes a warm start fold only new events. Deleted sessions keep their contribution, and unreadable logs are skipped rather than failing the query.

Install

Install into a profile; the plugin belongs to the profile rather than to a dsh checkout.

dsh plugin --profile web add https://github.com/moonlight2893267956/dsh-usage-stats/releases/download/v0.1.1/moonlight2893267956-dsh-usage-stats-0.1.1.tgz
dsh plugin --profile web add github:moonlight2893267956/dsh-usage-stats#path:packages/usage-stats
dsh plugin --profile web add /absolute/path/to/dsh-usage-stats/packages/usage-stats

The release tarball is prebuilt, so it installs in one step. The git spec builds the package through its prepare script, which pnpm blocks until you allow it: run that command once, copy the exact key it prints into allowBuilds in the profile's pnpm-workspace.yaml, then run it again; its trailing #path: selects the package inside this repository, because the repository root is a workspace rather than the plugin. Restart dsh afterwards; the Usage page appears under Settings.

Table of Contents


Use this package

Mount this plugin beside the session persistence it reads; the read route it registers then serves the Web GUI's Usage page.

Minimal configuration

- id: usage-stats
  name: '@deepseek-ai/dsh-usage-stats'

A deployment that wants the SQLite medium for the checkpoint adds both rows below; without them the usage_stats domain stays on the composition's default backend, which is the shared json one.

- id: storage-sqlite
  name: '@deepseek-ai/dsh-storage-sqlite'
  config:
    path: .../storages/usage-stats.db
- id: storage-domain
  config:
    backend: json
    routes:
      usage_stats: sqlite

refreshPollIntervalMs controls browser polling during reconciliation (default 1000 ms, integer range 250–10000 ms). It injects sessionPersistence and storageDomain: without sessionPersistence the fiber stays pending, and without storageDomain activation fails loud. The shipped Web composition leaves usage_stats on the json default, because storage-sqlite opens its database on activation and that composition also packs the browser-only Worker deployment, whose node:sqlite stub refuses the constructor; routing the domain to SQLite is a later patch layer's choice.

storage-sqlite is not part of every runtime: the bundled desktop application ships dsh-storage, dsh-storage-json, and dsh-storage-domain, but not this backend, so a profile there installs the package itself and pins it to the runtime's version, because the backend peers on @deepseek-ai/dsh-storage at exactly that version. A missing or mismatched install fails at activation: the backend does not import, and every plugin that injects storageDomain — this one included — stays pending.

What the figures mean

FieldMeaning
inputFull prompt input: uncached input plus both cache compartments (cache read and cache write), so it already contains cacheRead
cacheReadPrompt-cache hits — the reused prefix
outputOutput (completion) tokens
requestsAssistant messages carrying usage, one per model completion request; the card's subline reports the search count
searchesweb_search tool calls made that day

The four token counts are disjoint, so input is the complete prompt side of a request and input + output is its whole billed total.

A single-day request also carries 24 hourly buckets; a trailing window carries per-day buckets only. The window length is clamped to [1, 370], and an unusable value defaults to 30.

Fold semantics

The fold reads the durable log, not the model-visible surface, so tokens a later compaction hid from the model still count because they were consumed. The first query in a process reconciles the durable session catalog; later queries inspect only sessions that crossed a durability checkpoint, so reopening Usage does not enumerate every stored session. A per-lifecycle file revision still skips logs whose bytes did not advance.

Reading the chart

Each column is one bucket — an hour for a single day, a date for a window. A column stacks input misses, cache hits, and output; a bucket with no usage keeps a mark on the baseline rail, so an untouched hour reads as a measured zero instead of missing. Hovering a column, or moving keyboard focus to it, shows that bucket's exact figures. The entrance cascade and hover motion are dropped under prefers-reduced-motion.

Cached display and freshness

The Usage page reads GET /dsh-usage-stats/snapshot with the same days, date, and models query fields as the strict GET /dsh-usage-stats/stats route. A snapshot returns value, freshness, revision, error, and refreshPollIntervalMs. It displays the last complete saved aggregate immediately while freshness: pending labels it as updating. No checkpoint means value: null until the initial backfill completes; an unreconciled zero is not an empty-history result. While a first response is outstanding the four metric cards show a dash rather than 0, so a cold backfill never reads as a measured empty window; a window switch drops the previous window's buckets for the same reason. Polling stops after reconciliation or a failure and when the page closes. retry=1 explicitly retries a failed background scan.

Reconciliation shares one task and publishes the full totals and cursors only after their atomic save succeeds. A failed scan or save retains the previous committed view and its progress; the page keeps that chart visible with Retry. The checkpoint stays at schema version 1. Cached display removes reconciliation from first paint, not from the total background workload; old-generation revisions may still require expensive migration validation.

The settings nav glyph

The settings shell draws each nav row's icon from its own list of section ids and falls back to the generic settings gear for every other one, and a settings.section registration has no icon field to pass — so the plugin claims its own row once the dialog is mounted and swaps the gear for a bar-chart mark matching the page's chart. Only the row whose visible text equals this plugin's current section label is marked, so no other section loses its icon; a locale switch re-claims it. Uninstalling the plugin or unloading its fiber removes both the mark and the stylesheet it injects.

The plugin icon

icon.png is the mark the plugin manager shows in its list. It is a raster image rather than an SVG on purpose: the manager renders a manifest icon through an <img>, and an isolated image document gives currentColor no cascade to resolve against, so an SVG drawn that way renders black in both themes. The PNG carries its own colours, a transparent background, and sits well under the manifest's 256 KiB limit — about 41 KiB at 256px.

Failure and recovery

A query never fails because one session log is corrupt: the fold warns, skips that session, and records its revision in memory only, so a fresh process retries it. A lost or stale checkpoint costs a longer tail replay on the next cold read and never loses data, because the logs remain the authority. A session deleted from the device keeps its contribution.


Understand the implementation

Implementation internals — click to expand

UsageStatsService keeps one in-memory aggregate: per-day buckets keyed by local calendar day, each carrying 24 hourly buckets and a per-model map, plus a fold cursor and last-seen revision per session lifecycle. Its first fold walks sessionPersistence.list(); later folds inspect only sessions invalidated by session/flush. It skips unchanged revisions, opens each remaining log for reading, skips the fork-inherited prefix (inheritedEventCount) so a forked child never double-counts its parent, and folds every event after the cursor. foldEvent adds assistant/message usage to the day, hour, and model accumulators, and counts tool/call events named web_search. Concurrent queries share one lifecycle-owned task; strict callers also reconcile flushes that arrive while they wait. Cached readers see only the last committed state. After a mutated fold the service writes the whole checkpoint back through the usage_stats storage domain as one global value — accumulated totals plus per-session progress, with unreadable sessions excluded so a restart re-attempts them.


Further Exploration

  • dsh-session-persistence — the durable log store whose listings, revisions, and reads the fold consumes.
  • dsh-storage-domain — the checkpoint domain facility and its backend routes.
  • dsh-client-ui-usage — the Usage settings page that renders these figures.

Those three live in the DeepSeek Harness monorepo, not in this repository, so they are named rather than linked.


Model Experience

None, as the plugin only computes a client-facing read model of already-logged session events and touches no prompt, message, schema, stream, or tool result.

KV Cache effect

None; the plugin never assembles or sends provider requests.

Known Limitations and Deferred Work

These limits define what the figures cover and what the checkpoint can recover. They are current package constraints.

  • One global checkpoint record — the checkpoint is a single global value holding accumulated totals and per-session fold progress, so one mutated fold rewrites all of it. The fold clones days on first write rather than up front, so a fold that changes nothing clones none; at the 370-day ceiling the previous eager clone cost about 29 ms per fold regardless of outcome, against about 0.1 ms for the map copy alone. A schema change raises the domain version, and both backends then reject the stale unit with version-mismatch at open — json included, which keeps the file rather than backing it up — leaving every plugin that injects storageDomain pending until the unit is cleared. The totals are fully re-derivable from the logs, so recovery is clearing the unit rather than migrating it.
  • No pruning of days past the query window — every day ever folded stays in the checkpoint, and the window clamp of [1, 370] days does not delete anything. Days older than the clamp are unreachable through a trailing window but reachable through the date picker, whose prevMonth has no lower bound, so pruning would turn a real past day into a reading of zero. At the measured 2.4 KiB per day the checkpoint reaches roughly 900 KiB at the ceiling.
  • Fixed-size hour buckets — each day always materializes 24 hourly buckets even though most are empty: on the real checkpoint 89% of hour slots held no usage. A sparse encoding would cut the hour payload by about 68%, but it changes the stored shape, so it needs a domain version bump and therefore a cleared unit.
  • Fold arithmetic changes apply only to new events — the fold is incremental by per-session cursor, so an already-folded event is never re-read. Changing how a record is accumulated therefore leaves every day already in the checkpoint on the previous arithmetic; correcting history means clearing the unit and re-folding the whole corpus, which also drops the contribution of sessions deleted from the device.
  • Unreadable-log retry across restarts — only readable sessions enter the checkpoint, so each fresh process re-attempts and re-warns on a corrupt log; within one process it is warned once and skipped.
  • No per-purpose split — buckets split by token kind and by model, but not by call purpose (conversation, compaction, session title), because the durable assistant/message record does not carry the request's purpose; splitting further requires logging that field first.

Dev Note

Working context for maintainers — click to expand

None.

Runtime invariant: No companion is published. The package only aggregates assistant/message usage records and tool/call names whose shapes and append-only ordering are owned and runtime-checked by dsh-session and dsh-agent-loop, and reads them through dsh-session-persistence, whose contiguity and durability are checked there; it owns no event relation of its own to assert.