# DSH Usage Stats (dsh-usage-stats)

English | [中文](README.md)

Usage statistics plugin for DSH: adds a **Usage Statistics** section to Settings — token/request counters, daily streaks, a 1-year activity heatmap, per-model daily line charts, and per-model monthly stacked-bar charts. **API request counts are merged in alongside tokens** rather than being a separate mode. Works in both the `web` profile and the `desktop` (DeepSeek Harness desktop app) profile.

## Features

### Tokens and request counts side by side — no metric toggle

**Tokens are the primary metric**: stat-bar values, heatmap shading and both chart axes use them.
**Request counts appear alongside** in the chart tooltips (`1.2万 tokens · 16 次`), precise
against a specific date and model instead of occupying the top of the page.

Why it is split this way: tokens say *how much was consumed*, request counts say *how many calls
were made*. The latter reads better as an exact number in a specific context; making both of them
switchable primary views invites misreading which metric you are currently looking at.

### Stat bar

The top of the page is a **single stat bar**: five slots split by hairline vertical dividers,
large value on top, muted label below. Token values use Chinese magnitude units (`万` / `亿`):

| Slot | Value |
|---|---|
| Cumulative tokens | total tokens |
| Peak tokens | highest tokens in a day |
| Today's tokens | today's tokens |
| Current streak | days |
| Longest streak | days |

Request counts no longer occupy the card subtitles — exact numbers moved into the chart tooltips,
where the surrounding date/model context gives them meaning; the top of the page keeps only the
overall scale and rhythm.

"Average tokens per request" is the most direct signal of context bloat: if the request count is
flat while this value keeps climbing, each request is carrying a heavier context.

### 1-year activity heatmap (GitHub contribution graph style)

- **Shading uses the DeepSeek blue scale from the DSH theme** (`--dsw-static-deepseek-100/300/450/500`), i.e. the brand blue rather than an invented palette; empty days use the theme surface token `--dsw-alias-bg-layer-2`
- Larger cells (15 design units with a 4-unit gap), a full year on screen with no horizontal scrolling
- Month labels on the x-axis (at each month start, with automatic overlap guard)
- Hover a cell for a "date · tokens · requests" tooltip (matches the native DSH Tooltip look)

> Dark-mode adaptation keys off `body[data-ds-dark-theme]` (DSH toggles dark mode with that
> attribute rather than a `prefers-color-scheme` media query), so the heatmap follows an in-app
> theme switch correctly even when the app theme differs from the OS theme.

### Per-model line chart (daily, 7 / 30 / 90 day ranges)

- One smooth curve per model (by tokens; monotone cubic spline — passes through every data point without overshoot); data-point dots appear only on hover, aligned with the hover guide line and tooltip
- Hover for that day's per-model breakdown plus the total, each row showing tokens and requests

### Per-model stacked-bar chart (monthly, 6 / 12 / 24 month ranges)

- One bar per calendar month, stacked by model (by tokens); hover for "month + per-model tokens · calls + total + MoM change"
- The legend shows each model's window token total and share — **click a legend item to hide/show that model**
- Monthly summary cards: window total, current month (with MoM and request count), single-month peak, average active month
- The month axis is fixed-length and continuous: idle months stay as gaps instead of collapsing, and bar width adapts to the range

Light/dark theme aware · respects reduced motion

## Install

### desktop (DeepSeek Harness app)

```powershell
# Option 1: official CLI
dsh plugin --profile desktop add @kindred7/dsh-usage-stats -w

# Option 2: link straight from this repo (development; edits apply immediately)
node scripts/install-profile.mjs desktop
```

### web

```powershell
dsh plugin --profile web add @kindred7/dsh-usage-stats -w
dsh web    # restart to take effect
```

Install a pinned version from GitHub:

```powershell
dsh plugin --profile desktop add github:kindred-7/dsh-usage-stats#v0.4.7 -w
```

Uninstall:

```powershell
dsh plugin --profile desktop remove @kindred7/dsh-usage-stats -w
```

After installing, **restart DSH** (quit and reopen the desktop app; for web, hard-refresh once with **Ctrl+F5**) so it picks up the new plugin script.

### Desktop troubleshooting: installed, but no entry in Settings

Since DSH 0.2.0 a profile bundle passes a **compatibility gate**: if the plugin's
`peerDependencies` (only `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*` entries are considered) do not
satisfy the running runtime version, the host **silently skips the whole bundle** — its
`cordis.patch.yml` is never applied, so not even an entry appears in Settings (no error, no dialog;
only a single `skipping profile bundle ...` line on the launcher's stderr).

This project declares `>=0.1.1-rc.2 <0.3.0`, covering both 0.1.1-rc.2 (web) and 0.2.0-rc.2 (desktop).
A self-check script runs the decision through the **real gate code from the packaged app.asar**:

```powershell
# 1) extract the packaged app-boot and its deps (once)
node scripts/asar-extract.cjs "$env:TEMP\asar-pkg" @deepseek-ai/dsh-app-boot
# 2) decide whether the desktop profile actually loads this plugin
node scripts/verify-desktop-load.mjs "$env:TEMP\asar-pkg"
```

It verifies the compatibility gate, `resolveBundleDir` locating the plugin, the patch using the
`insert:` form, `dsh.client.platform === "web"`, the bundle appearing among the loaded layers and
**not** in `skippedBundles`, and the composed entry list containing `ui-usage-stats`.

### The two session-history RPC contracts

The plugin probes newest-first on the first reconciliation and caches the contract per session.
The two hosts differ in more than the method name:

| | Current host (Typert gateway) | Legacy host (api-proxy) |
|---|---|---|
| Endpoint | `session/list`, `session/page` (`namespace/method`, `/`-separated) | `session.list`, `session.history` (`.`-separated) |
| Request body | `{ args: { request: … } }`, keyed by parameter name | the request object itself |
| List parameter key | `_request` | — |
| Result field | `{ records: [{ event }] }` | `{ events: [event] }` |

**Endpoint segmentation is the easy thing to get wrong**: the gateway's `/api` channel accepts
exactly two segments (`namespace/method`), so a single-segment method name such as
`session.history` returns **404** — every candidate fails and the panel stays permanently empty.

`session/page` also requires `throughSeq` to be a seq that actually exists in the log; `-1` is read
as "empty log" and yields an empty page. The plugin therefore fetches `asOfSeq` from
`session/projections` first (once per session). When that cursor is unavailable the page shows
`log cursor unavailable: …`, because that is *cannot read*, not *no usage*.

`session/list` also returns subagent sessions, and `session/page` requires their durable parent
address `{ kind: "subagent", parentSessionId, childSessionId, mode }` for them — an ordinary
session address is rejected with `session/agent-busy`. `mode` must be `"unknown"` (the caller knows
the parent/child identity but not the delegation mode); naming a concrete mode is rejected with
`subagent/unauthorized`.

## Data model & privacy

- Token count = provider-reported usage per assistant reply (input + output + cache read + cache write; reasoning tokens are already included in output, not double-counted)
- Request count = one usage-bearing assistant reply counts as 1, i.e. one provider request. It is accumulated in the same fold as tokens (same event, same watermark), so the two cannot drift apart; events without usage only advance the watermark and are not counted
- Model attribution uses the `source.model` recorded in the request log; switching models mid-session bills each request to its own model
- The **monthly view** folds the daily ledger by local calendar month: month boundaries are simply the months the logged days fall into, with no extra timezone math
- All data stays in browser localStorage — nothing is uploaded
- The ledger advances per-session watermarks: opening the Settings page **enumerates every session** and reconciles each against its log (history included)

### Upgrading (v0.3 → v0.4)

The v0.3 ledger had no per-model request counts, and its storage key is incompatible with the new
shape. v0.4 writes a new key, `dsh-usage-stats/ledger/v3`: the old key is left untouched (never read
or written, which keeps rollback possible), and the new ledger starts empty and is refilled by the
session-history reconciliation. **Right after upgrading the numbers start at 0 and backfill** —
nothing in the logs is lost.

### Why "no data"

Usage does not come from thin air — it is folded from the `assistant` replies in each session's log
(the usage the provider reported). So "no data" can only mean one of three things, and the
**collection diagnostic** at the bottom of the settings page tells you which:

| Diagnostic shows | Meaning | What to do |
|---|---|---|
| "listed N / scanned N" but tokens = 0 | Scan succeeded, but those session logs contain no usage-bearing replies | Nothing to record yet |
| `failed: HTTP 404` | The history RPC endpoint isn't accepted by this host (see the two contracts above) | Use the host's endpoint segmentation |
| `failed: HTTP 401` | The history RPC was rejected (auth / stale session) | Restart the app |
| `log cursor unavailable: …` | `session/projections` returned no `asOfSeq`, so `session/page` can only read an empty page | This is *cannot read*, not *no usage* |
| Stuck on "enumerating sessions…" | List/backfill still running | Click "重新扫描" (Rescan) |

Key point one: earlier versions only reconciled the **currently open** session, so historical
sessions never contributed unless you opened each one — which is exactly why the panel stayed
empty. Now opening the Settings page enumerates and scans **all** sessions in the background,
deduplicated by watermark, so repeated opens don't recompute.

Key point two (the real failure seen on 0.2.0): the plugin used to try only **single-segment**
endpoints such as `session.history` and `session-controller.history`, while the current host's
gateway channel only accepts `namespace/method` — every page request 404'd and the panel stayed
empty forever.

## How it works

| Slot | Role |
|---|---|
| `conversation.composer.dock` | Invisible collector (renders null): watches the session snapshot and triggers a log reconciliation |
| `settings.section` | The "Usage Statistics" settings page (triggers the full backfill on open) |

Data comes from two complementary paths:

1. **Full backfill** (on opening Settings): the session-list RPC pages through every session, and
   `reconcileSession` folds each one's history into the ledger.
2. **Incremental reconcile** (while a session runs): the composer collector detects assistant
   messages past the watermark and debounce-triggers that session's log reconciliation.

Reconciliation pages backwards through the full log via the session-history RPC, folding every
`assistant/message` event (usage / time / `source.model`) with the event seq as a watermark.
Unreadable logs leave a diagnostic and are retried on the next trigger.

Monthly statistics add no new collection path: `computeMonthlyUsage(months)` simply accumulates
`ledger.days[YYYY-MM-DD].byModel` into `YYYY-MM` buckets, so the daily and monthly views cannot
drift apart (asserted in the tests).

## Development

```powershell
node --check lib/client.js        # syntax check
npm test                          # everything
node tests/harness.cjs            # ledger fold + daily/monthly consistency
node tests/monthly-harness.cjs    # monthly aggregation (axis, window, overflow, MoM)
node tests/client-smoke.cjs       # slot registration + render smoke (stacked segments don't overlap)
node tests/history-rpc.cjs        # both history-RPC contracts / cursor diagnostic / subagent address / backfill
node tests/resolve-check.cjs      # package resolution per profile
node tests/import-check.mjs       # host half imports
```

`lib/client.js` is the browser-side plugin; `lib/index.js` is the host half (a no-op that only exists so client-modules can see the `dsh.client` declaration); `cordis.patch.yml` declares the host-side assembly; `scripts/install-profile.mjs` links the plugin into a profile. The install location under `.dsh/profiles/<profile>/node_modules` is a junction to this directory, so edits take effect immediately.

## License

MIT
