Leni-More/dsh-conv-calendar

dsh-conv-calendar

把「每天发了多少条对话」画成 GitHub 提交日历那样的方格图,挂在 DSH Web 界面左侧栏里。A GitHub-style contribution calendar for DeepSeek Harness — see how many prompts you sent, how many conversations you had, and how many tokens you burned, one square per day.

待分类

dsh-conv-calendar

English | 中文

A GitHub-style contribution calendar for DeepSeek Harness — see how many prompts you sent, how many conversations you had, and how many tokens you burned, one square per day.

Features

  • A sidebar entry. *Conversation calendar* appears in the Web GUI sidebar; clicking it opens a full-page calendar.
  • GitHub-style grid. The last 53 weeks, weeks starting Monday, five shades, one square per local day.
  • Four metrics. Switch between Prompts, Conversations, Tokens and Output:

Metric · What one square means Prompts · How many messages you sent that day Conversations · How many sessions were active that day Tokens · Total tokens spent that day (input + output + cache reads) Output · Tokens the model produced that day

  • Four stat cards. Total (with active days), current streak (with longest), this month (with last month), busiest day — all of them follow the selected metric.
  • Token breakdown. Under Tokens / Output, hovering a square also shows in · out · cached.
  • Theme-aware. Colors come from DSH theme variables, so light and dark mode both look native.
  • Bilingual. Follows the UI language (English / Chinese).

Requirements

  • DeepSeek Harness 0.2.1-rc.2 or a compatible build (it relies on the sidebar.panellist and main slots, and on ctx.sessionQuery).
  • Node 22.15+ on the Host side (the session logs are zstd-compressed).

Install

# From a local checkout 
dsh plugin --profile desktop add link:/absolute/path/to/dsh-conv-calendar

# From npm (Recommend)
dsh plugin --profile desktop add dsh-conv-calendar

Then restart DeepSeek Harness. Plugins are composed from the profile's bundle list at startup, so a restart is required — the entry cannot hot-load into a running Host.

After the restart:

  1. A *Conversation calendar* icon appears in the sidebar;
  2. Opening it shows history already filled in, and today's square darkens as you send new messages.

To uninstall:

dsh plugin --profile desktop remove dsh-conv-calendar
If a plugin ever prevents the app from starting, remove the "dsh-conv-calendar" line from dsh.profile.bundles in $DSH_HOME/profiles//package.json instead of using the recovery dialog, which also resets unrelated settings.

What gets counted

Prompts

A message counts only when all three hold:

  1. the session event is user/message;
  2. data.source.kind === 'user' — i.e. you sent it. Injected context (runtime context, skill catalog, time context, tool reminders …) carries its own source and is never counted;
  3. it falls on a given day in the machine's local time zone.

Tokens

Taken from data.usage on assistant/message events:

Field · Meaning inputTokens · Input tokens that missed the cache outputTokens · Tokens the model produced cacheReadTokens · Input tokens served from the cache totalTokens · Sum of the three (provider-reported; summed locally when absent)

Older log entries may omit cacheReadTokens (treated as 0); replies with no usage at all are skipped.

Cross-session de-duplication

Forking a session copies its ancestor's log. A copied prompt or reply keeps the same message.id, so hashing it lands in the same set and is counted once. That also makes re-reading a log idempotent — re-scanning can never double count.

Active days can differ per metric. A day where the model replied but you sent nothing counts under *Tokens* but not under *Prompts*. That is intended.

Where the data comes from

Source · Used for · Notes session/event feed · Live counting · Every new message, and every reply carrying usage, is recorded immediately — no waiting for a disk flush ctx.sessionQuery (listSessions / readSession) · Backfill · History is read through the Host's own decoder: the logs are appended frame by frame, so a single whole-file zstdDecompressSync only yields the first frame

A finished session is read once and marked final; it is never re-read. A session that is still live is re-read on every refresh and merged (union of hashes, so it is idempotent). One backfill pass has an 8-second budget — leftovers resume on the next pass.

State file

$DSH_HOME/plugins/dsh-conv-calendar/state.json, written atomically (temp file + rename):

{
  "version": 2,
  "sessions": {
    "session-…": {
      "days": {
        "2026-10-23": {
          "prompts": ["<message-hash>"],
          "replies": { "<reply-hash>": { "input": 10395, "output": 133, "cache": 896, "total": 11424 } }
        }
      },
      "final": true
    }
  }
}

version is the store layout: 1 kept a bare array of prompt hashes per day, 2 adds replies. An incompatible version is dropped and fully re-read — the file is only a cache, so one pass rebuilds it.

Deleting the file clears the statistics; they are rebuilt from the session logs on the next start.

HTTP endpoint

The panel reads one read-only endpoint from the same Host process:

GET /dsh-conv-calendar/api/summary            # current aggregate (kicks off a background backfill if needed)
GET /dsh-conv-calendar/api/summary?refresh=1  # backfill now (the panel's Refresh button)

The response is JSON:

  • days[] — { day, messages, sessions, replies, input, output, cache, total }
  • meta — sessions, activeDays, totalMessages, totalReplies, totalInput, totalOutput, totalCache, totalTokens, updatedAt
  • history — the last backfill report (available, read, cached, failed, partial)
  • historyPending — whether a backfill is currently running

The endpoint uses the Host's connection fence (ctx.connection.requestRejection): a request without the session cookie gets 401, so a loopback-only deployment never exposes the numbers.

How it is built

dsh-conv-calendar/
├─ package.json          # dsh.bundle.patch + dsh.client (platform: web, inject: …)
├─ cordis.patch.yml      # inserts one row into the profile's config tree
├─ lib/
│  ├─ index.js           # Host half: counting, backfill, HTTP endpoint
│  └─ client.js          # Browser half: a hand-written factory bundle (no bundler)
├─ tests/                # offline tests, see below
└─ docs/screenshots/     # README images: en/ for this file, zh-CN/ for the Chinese one

No build step. The browser half is written directly in the platform's factory form: window.__ModuleLoader__.load({ id, factory }) registers a factory, and the module body runs on first use. react is resolved from the Host's shared module table.

Two slot contributions, both declaration-aware:

ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', id: PANEL_ID, order: 20, label: () => t('panel.label'), locale: NS }, CalendarGlyph))
ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID, locale: NS }, CalendarPanel))

ctx.slots.inject(name, …) is deliberate rather than a bare ctx.slots.register(...): a slot's declaration may arrive *after* this plugin, and registering into an undeclared key throws — which the shell surfaces as an entry that "did not activate". inject defers the registration until the declaration exists and re-runs it if the contributing package is replaced.

Two Host-side details worth knowing

  • The Loader does not read a function plugin's namespace-level `inject` export. A function plugin (as opposed to a class plugin) must reach the service imperatively, so the Host half uses ctx.inject(['webServer'], (inner) => …).
  • Client bundles must declare their dependencies in package.json → dsh.client.inject. Without it the module graph treats the bundle as dependency-free, may activate it before ui-renderer / ui-layout / client-locale, and the whole app refuses to start.

Tests

tests/ holds the offline tests. They need their own dependencies, declared in tests/package.json:

cd tests
npm install
npm test        # runs all five

Run one on its own with node token-fold.mjs, and so on.

Test · Needs · What it asserts token-fold.mjs · nothing (Node only) · Folds session logs and compares every day's input/output/cache/total against an independent pass. Uses the real logs under $DSH_HOME/sessions when they exist; otherwise it builds a fixture in a temp directory with the same shape — several zstd frames per file, a forked session repeating its ancestor — and asserts the equivalent totals. Also checks legacy v1 days still read, re-recording is idempotent, and usage without cacheReadTokens normalizes to 0 host-contract.mjs · cordis · Mounts the Host half in the real cordis runtime with stubbed webServer / sessionQuery: route registration, injected messages ignored, finished sessions read once, live prompts and reply usage recorded immediately, replies without usage contributing nothing, HEAD/POST handling, route removal on dispose panel-render.mjs · react, jsdom · 371 cells, month labels, tooltip, metric switching, refresh going through ?refresh=1 panel-states.mjs · react, jsdom · Empty data still draws the grid plus a hint, failure state offers retry, English copy uses correct singular/plural panel-token.mjs · react, jsdom · Token and Output metrics, 496k / 16k formatting, the token breakdown in the tooltip, and that the four metrics shade the grid differently

module-system-emulation.mjs, shell-emulation.mjs and slots-probe.mjs are the diagnostic scripts used to trace the boot failure described above; they are kept as documentation of the investigation and are not part of npm test.

End-to-end verification performed during development: a second Host booted from a temporary profile returned 6 days / 10 sessions / 62 prompts / 253M tokens from real logs, with each day's input/output/cache matching an independent script exactly; the panel rendered that live payload in jsdom, producing four distinct heatmap patterns, with the token tooltip showing the input/output/cache split; a cookie-less request was refused with 401.

Limitations

  • Backfill needs `sessionQuery`. In a composition without it, only the current run is counted and the footer says so.
  • Finished sessions are never re-read. That is safe because a finished log cannot change; if you rewrite history by hand, delete state.json.
  • Only human messages count. Injected context never does — that is the point.
  • The time zone is the machine's, read at startup. After travelling, old dates stay filed under the local date they happened on.

License