diff --git a/catalog.json b/catalog.json
index dfed967..adb975c 100644
--- a/catalog.json
+++ b/catalog.json
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"providerId": "official",
"name": "PI-Desktop Official Plugins",
- "updatedAt": "2026-09-14T04:38:39Z",
+ "updatedAt": "2026-09-17T16:33:01Z",
"homepage": "https://github.com/vastsa/pi-desktop-plugins",
"plugins": [
{
@@ -347,6 +347,50 @@
}
]
},
+ {
+ "id": "io.github.muzimu217.usage-dashboard",
+ "name": "Usage Dashboard",
+ "description": "A native PI-Desktop usage dashboard built on the official read-only usage contract (pi.usage.listTurns): 365-day activity heatmap, model-share donut, daily trend lines, top sessions and streaks, in a light and dark panel.",
+ "i18n": {
+ "en": {
+ "name": "Usage Dashboard",
+ "description": "A native PI-Desktop usage dashboard built on the official read-only usage contract (pi.usage.listTurns): 365-day activity heatmap, model-share donut, daily trend lines, top sessions and streaks, in a light and dark panel.",
+ "safetyNotes": "Read-only aggregation over the host's official usage interface (pi.usage.listTurns). Counts tokens per turn only \u2014 never reads message text, tool arguments, project paths or credentials; keeps at most an 8-character session id prefix; writes nothing outside its own plugin settings; makes no network requests. Covers PI-Desktop's native usage only and reads no other tool's files."
+ },
+ "zh-CN": {
+ "name": "\u539f\u751f\u7528\u91cf\u770b\u677f",
+ "description": "\u57fa\u4e8e PI-Desktop \u5b98\u65b9\u53ea\u8bfb\u7528\u91cf\u63a5\u53e3\uff08pi.usage.listTurns\uff09\u6784\u5efa\u7684\u539f\u751f\u7528\u91cf\u770b\u677f\uff1a365 \u5929\u6d3b\u52a8\u70ed\u529b\u56fe\u3001\u6a21\u578b\u5360\u6bd4\u73af\u5f62\u56fe\u3001\u6bcf\u65e5\u8d8b\u52bf\u6298\u7ebf\u3001Top \u4f1a\u8bdd\u4e0e\u8fde\u7eed\u4f7f\u7528\u5929\u6570\uff0c\u6df1\u6d45\u4e24\u5957\u4e3b\u9898\u3002",
+ "safetyNotes": "\u53ea\u8bfb\u805a\u5408\u5bbf\u4e3b\u5b98\u65b9\u7528\u91cf\u63a5\u53e3\uff08pi.usage.listTurns\uff09\u8fd4\u56de\u7684\u8ba1\u6570\uff1a\u4e0d\u8bfb\u53d6\u6d88\u606f\u6b63\u6587\u3001\u5de5\u5177\u53c2\u6570\u3001\u9879\u76ee\u8def\u5f84\u6216\u51ed\u636e\uff0c\u6700\u591a\u4fdd\u7559 8 \u4f4d session id \u524d\u7f00\uff0c\u9664\u81ea\u8eab\u63d2\u4ef6\u8bbe\u7f6e\u5916\u4e0d\u5199\u4efb\u4f55\u4f4d\u7f6e\uff0c\u4e0d\u53d1\u8d77\u7f51\u7edc\u8bf7\u6c42\u3002\u4ec5\u8986\u76d6 PI-Desktop \u539f\u751f\u7528\u91cf\uff0c\u4e0d\u8bfb\u53d6\u5176\u4ed6\u5de5\u5177\u7684\u4efb\u4f55\u6587\u4ef6\u3002"
+ }
+ },
+ "author": "muzimu217",
+ "categories": [
+ "productivity",
+ "developer-tools"
+ ],
+ "verified": true,
+ "downloads": 0,
+ "homepage": "https://github.com/vastsa/pi-desktop-plugins/tree/main/plugins/io.github.muzimu217.usage-dashboard",
+ "repository": "https://github.com/vastsa/pi-desktop-plugins",
+ "readmeMarkdown": "# Usage Dashboard\uff08\u539f\u751f\u7528\u91cf\u770b\u677f\uff09\n\nA native PI-Desktop usage dashboard. It is the first consumer of the host's\nofficial read-only usage contract `pi.usage.listTurns` (PI-Desktop host\nPR #503) and renders that fact stream as a local board: metric cards, a\n365-day activity heatmap, a model-share donut, per-model daily trend lines,\ntop sessions and activity streaks \u2014 in a light and a dark palette.\n\n\u539f\u751f\u7528\u91cf\u770b\u677f\uff1aPI-Desktop \u5b98\u65b9\u53ea\u8bfb\u7528\u91cf\u63a5\u53e3 `pi.usage.listTurns`\uff08\u5bbf\u4e3b PR #503\uff09\n\u7684\u9996\u4e2a\u6d88\u8d39\u8005\u3002\u628a\u5b98\u65b9\u4e8b\u5b9e\u9762\u6e32\u67d3\u6210\u672c\u5730\u770b\u677f\uff1a\u6307\u6807\u5361\u3001365 \u5929\u6d3b\u52a8\u70ed\u529b\u56fe\u3001\u6a21\u578b\u5360\u6bd4\n\u73af\u5f62\u56fe\u3001\u5206\u6a21\u578b\u6bcf\u65e5\u8d8b\u52bf\u7ebf\u3001Top \u4f1a\u8bdd\u4e0e\u8fde\u7eed\u4f7f\u7528\u5929\u6570\uff0c\u6df1\u6d45\u4e24\u5957\u4e3b\u9898\u3002\n\n## Scope and differentiation / \u5b9a\u4f4d\u4e0e\u5dee\u5f02\u5316\n\n- **Official contract only.** All data comes from `pi.usage.listTurns`. The\n plugin never reads transcript files, the host database, or any other AI\n tool's local data. It covers PI-Desktop's native usage only.\n \u53ea\u7528\u6b63\u5f0f\u5951\u7ea6\uff1a\u6570\u636e\u5168\u90e8\u6765\u81ea `pi.usage.listTurns`\uff0c\u4e0d\u76f4\u8bfb\u8f6c\u5f55\u672c\u3001\u5bbf\u4e3b\u6570\u636e\u5e93\u6216\n \u5176\u4ed6\u5de5\u5177\u7684\u672c\u5730\u6587\u4ef6\uff1b\u4ec5\u8986\u76d6 PI-Desktop \u539f\u751f\u7528\u91cf\u3002\n- Differs from `pi.token-insights`, which aggregates multiple tools and reads\n host records directly: this plugin is a thin, permission-minimal renderer of\n the official fact interface.\n \u4e0e pi.token-insights\uff08\u591a\u5de5\u5177\u805a\u5408\u3001\u76f4\u8bfb\u5bbf\u4e3b\u5e93\uff09\u5dee\u5f02\u5316\uff1a\u672c\u63d2\u4ef6\u662f\u6b63\u5f0f\u4e8b\u5b9e\u63a5\u53e3\n \u4e4b\u4e0a\u7684\u8584\u6e32\u67d3\u5c42\uff0c\u6743\u9650\u6700\u5c0f\u5316\u3002\n\n## Data channel / \u6570\u636e\u901a\u9053\n\nThe panel bridge is read-only, so the plugin process does all host access\n(the pattern `pi.token-insights` established):\n\n1. `onLoad` (and the `usageDashboard.open` command) walks the full history:\n windows of 365 days walked backwards from now (`toMs = previous fromMs \u2212 1`),\n each window paged by cursor until `nextCursor = null`; the first fully empty\n window ends the walk; a `turnId` set dedupes across window seams.\n2. The deduped turns go through the pure aggregator `lib/aggregate.js`\n (`aggregateCube(turns, nowMs)`, clock injected for testability).\n3. The cube is published via `pi.plugin.setSettings({ usageCube, scanState })`;\n the panel polls plugin settings and renders. Range switching (7/30/90/365\n days) is a renderer-side filter over the cube's daily rows \u2014 no second walk.\n\n## Accounting / \u805a\u5408\u53e3\u5f84\uff08v3.1\uff09\n\n- **Headline tokens = `inputTokens + outputTokens`.** Cache read, cache write\n and reasoning tokens appear only in a diagnostics line and never enter the\n headline. headline \u4ec5\u8ba1\u8f93\u5165 + \u8f93\u51fa\uff1b\u7f13\u5b58\u8bfb/\u5199\u4e0e\u63a8\u7406 token \u53ea\u8fdb\u8bca\u65ad\u884c\u3002\n- Cards: total tokens, turn count, session count (deduped `sessionId`),\n peak day (max daily headline + its date), current/longest streak.\n- Daily keys are **local-calendar days** built from `endedAt`, so the heatmap\n is not shifted by timezone conversion.\n- Streaks (v1, simple): a day is active when its headline > 0; the current\n streak anchors on today and falls back to yesterday; any one-day gap breaks\n a run. \u53e3\u5f84\u6700\u7b80\u5b9e\u73b0\uff0c\u89c1 `lib/aggregate.js` \u6ce8\u91ca\u3002\n- The panel recomputes everything that *can* be derived from daily rows for\n the selected range (totals, turns, peak, model shares, trend). Session count,\n the diagnostics line and the Top-sessions list are all-history figures and\n labelled as such \u2014 a session spans days, so it cannot be counted from daily\n rows without lying.\n\n## Privacy / \u9690\u79c1\n\nRead-only aggregation over the official interface. Counts only \u2014 never message\ntext, tool arguments, project paths or credentials. At most an 8-character\nsession id prefix is kept. Nothing is written outside this plugin's own\nsettings, and no network request is made. Every number stays on this device.\n\n\u53ea\u8bfb\u805a\u5408\u3001\u4ec5\u8ba1\u6570\uff1a\u4e0d\u8bfb\u53d6\u6d88\u606f\u6b63\u6587/\u5de5\u5177\u53c2\u6570/\u9879\u76ee\u8def\u5f84/\u51ed\u636e\uff0c\u6700\u591a\u4fdd\u7559 8 \u4f4d\nsession id \u524d\u7f00\uff1b\u9664\u81ea\u8eab\u63d2\u4ef6\u8bbe\u7f6e\u5916\u4e0d\u5199\u4efb\u4f55\u4f4d\u7f6e\uff0c\u4e0d\u53d1\u8d77\u7f51\u7edc\u8bf7\u6c42\u3002\n\n## Permissions / \u6743\u9650\n\n- `ui.panel` \u2014 the dashboard panel\n- `usage.read` \u2014 read aggregate token usage through the host's official interface\n\n## Panel\n\n- 7/30/90/365-day range switch (renderer-side filtering), language follows the\n system with an EN/\u4e2d\u6587 override, theme follows `prefers-color-scheme` with a\n light/dark override (both persisted locally).\n- Charts are hand-rolled SVG: week-column heatmap with month axis and legend,\n donut with center total and a two-column legend, daily trend with per-model\n toggleable lines.\n- Accessibility: `sr-only` data tables twin the heatmap and the trend chart,\n the donut carries an aria-label summarizing the shares, and all controls are\n focusable buttons.\n\n## Host requirement / \u5bbf\u4e3b\u8981\u6c42\n\n`pi.usage.listTurns` is provided by PI-Desktop (host PR #503). On builds\nwithout it the panel shows an explicit \"host interface not available\" state\ninstead of an empty board. \u9762\u677f\u5bf9\u672a\u63d0\u4f9b\u8be5\u63a5\u53e3\u7684\u65e7\u7248\u672c\u5bbf\u4e3b\u663e\u793a\u660e\u786e\u63d0\u793a\uff0c\n\u800c\u4e0d\u662f\u4f2a\u88c5\u6210\u201c\u6ca1\u6709\u7528\u91cf\u201d\u3002\n\n## Development\n\n```bash\nnode --check plugins/io.github.muzimu217.usage-dashboard/main.js\nnode --check plugins/io.github.muzimu217.usage-dashboard/lib/aggregate.js\nnode --test tests/usage-dashboard.test.mjs\n```\n",
+ "safetyNotes": "Read-only aggregation over the host's official usage interface (pi.usage.listTurns). Counts tokens per turn only \u2014 never reads message text, tool arguments, project paths or credentials; keeps at most an 8-character session id prefix; writes nothing outside its own plugin settings; makes no network requests. Covers PI-Desktop's native usage only and reads no other tool's files.",
+ "versions": [
+ {
+ "version": "0.1.0",
+ "publishedAt": "2026-09-17T16:33:01Z",
+ "changelog": "Release 0.1.0: first release. Aggregates the full PI-Desktop usage history through the official pi.usage.listTurns contract (365-day windows walked backwards, cursor pagination, turnId dedupe) into a local dashboard: metric cards, 365-day heatmap, model donut, per-model trend lines, top sessions and streaks. Headline tokens are input + output; cache and reasoning tokens stay in a diagnostics line. 0.1.0\uff1a\u9996\u4e2a\u7248\u672c\u3002\u901a\u8fc7\u5b98\u65b9 pi.usage.listTurns \u63a5\u53e3\uff08\u6309 365 \u5929\u7a97\u53e3\u56de\u8d70 + \u6e38\u6807\u7ffb\u9875 + turnId \u53bb\u91cd\uff09\u805a\u5408\u5168\u90e8\u539f\u751f\u7528\u91cf\u5386\u53f2\uff0c\u5448\u73b0\u6307\u6807\u5361\u3001\u70ed\u529b\u56fe\u3001\u6a21\u578b\u73af\u5f62\u56fe\u3001\u5206\u6a21\u578b\u8d8b\u52bf\u7ebf\u3001Top \u4f1a\u8bdd\u4e0e\u8fde\u7eed\u5929\u6570\uff1bheadline \u4ec5\u8ba1\u8f93\u5165+\u8f93\u51fa\uff0c\u7f13\u5b58\u4e0e\u63a8\u7406 token \u5355\u5217\u8bca\u65ad\u884c\u3002",
+ "minPiDesktop": ">=0.14.3",
+ "shasum": "ac03b7748e9ae4f51a58ba55c68921ad26cd4fefe5b323a965311e8640e00941",
+ "url": "packages/io.github.muzimu217.usage-dashboard-0.1.0.piplug",
+ "sizeBytes": 89310,
+ "permissions": [
+ "ui.panel",
+ "usage.read"
+ ],
+ "fs": {}
+ }
+ ]
+ },
{
"id": "pi.bianqian",
"name": "\u4fbf\u7b7e",
diff --git a/packages/io.github.muzimu217.usage-dashboard-0.1.0.piplug b/packages/io.github.muzimu217.usage-dashboard-0.1.0.piplug
new file mode 100644
index 0000000..245b886
Binary files /dev/null and b/packages/io.github.muzimu217.usage-dashboard-0.1.0.piplug differ
diff --git a/plugins/io.github.muzimu217.usage-dashboard/README.md b/plugins/io.github.muzimu217.usage-dashboard/README.md
new file mode 100644
index 0000000..e60313b
--- /dev/null
+++ b/plugins/io.github.muzimu217.usage-dashboard/README.md
@@ -0,0 +1,99 @@
+# Usage Dashboard(原生用量看板)
+
+A native PI-Desktop usage dashboard. It is the first consumer of the host's
+official read-only usage contract `pi.usage.listTurns` (PI-Desktop host
+PR #503) and renders that fact stream as a local board: metric cards, a
+365-day activity heatmap, a model-share donut, per-model daily trend lines,
+top sessions and activity streaks — in a light and a dark palette.
+
+原生用量看板:PI-Desktop 官方只读用量接口 `pi.usage.listTurns`(宿主 PR #503)
+的首个消费者。把官方事实面渲染成本地看板:指标卡、365 天活动热力图、模型占比
+环形图、分模型每日趋势线、Top 会话与连续使用天数,深浅两套主题。
+
+## Scope and differentiation / 定位与差异化
+
+- **Official contract only.** All data comes from `pi.usage.listTurns`. The
+ plugin never reads transcript files, the host database, or any other AI
+ tool's local data. It covers PI-Desktop's native usage only.
+ 只用正式契约:数据全部来自 `pi.usage.listTurns`,不直读转录本、宿主数据库或
+ 其他工具的本地文件;仅覆盖 PI-Desktop 原生用量。
+- Differs from `pi.token-insights`, which aggregates multiple tools and reads
+ host records directly: this plugin is a thin, permission-minimal renderer of
+ the official fact interface.
+ 与 pi.token-insights(多工具聚合、直读宿主库)差异化:本插件是正式事实接口
+ 之上的薄渲染层,权限最小化。
+
+## Data channel / 数据通道
+
+The panel bridge is read-only, so the plugin process does all host access
+(the pattern `pi.token-insights` established):
+
+1. `onLoad` (and the `usageDashboard.open` command) walks the full history:
+ windows of 365 days walked backwards from now (`toMs = previous fromMs − 1`),
+ each window paged by cursor until `nextCursor = null`; the first fully empty
+ window ends the walk; a `turnId` set dedupes across window seams.
+2. The deduped turns go through the pure aggregator `lib/aggregate.js`
+ (`aggregateCube(turns, nowMs)`, clock injected for testability).
+3. The cube is published via `pi.plugin.setSettings({ usageCube, scanState })`;
+ the panel polls plugin settings and renders. Range switching (7/30/90/365
+ days) is a renderer-side filter over the cube's daily rows — no second walk.
+
+## Accounting / 聚合口径(v3.1)
+
+- **Headline tokens = `inputTokens + outputTokens`.** Cache read, cache write
+ and reasoning tokens appear only in a diagnostics line and never enter the
+ headline. headline 仅计输入 + 输出;缓存读/写与推理 token 只进诊断行。
+- Cards: total tokens, turn count, session count (deduped `sessionId`),
+ peak day (max daily headline + its date), current/longest streak.
+- Daily keys are **local-calendar days** built from `endedAt`, so the heatmap
+ is not shifted by timezone conversion.
+- Streaks (v1, simple): a day is active when its headline > 0; the current
+ streak anchors on today and falls back to yesterday; any one-day gap breaks
+ a run. 口径最简实现,见 `lib/aggregate.js` 注释。
+- The panel recomputes everything that *can* be derived from daily rows for
+ the selected range (totals, turns, peak, model shares, trend). Session count,
+ the diagnostics line and the Top-sessions list are all-history figures and
+ labelled as such — a session spans days, so it cannot be counted from daily
+ rows without lying.
+
+## Privacy / 隐私
+
+Read-only aggregation over the official interface. Counts only — never message
+text, tool arguments, project paths or credentials. At most an 8-character
+session id prefix is kept. Nothing is written outside this plugin's own
+settings, and no network request is made. Every number stays on this device.
+
+只读聚合、仅计数:不读取消息正文/工具参数/项目路径/凭据,最多保留 8 位
+session id 前缀;除自身插件设置外不写任何位置,不发起网络请求。
+
+## Permissions / 权限
+
+- `ui.panel` — the dashboard panel
+- `usage.read` — read aggregate token usage through the host's official interface
+
+## Panel
+
+- 7/30/90/365-day range switch (renderer-side filtering), language follows the
+ system with an EN/中文 override, theme follows `prefers-color-scheme` with a
+ light/dark override (both persisted locally).
+- Charts are hand-rolled SVG: week-column heatmap with month axis and legend,
+ donut with center total and a two-column legend, daily trend with per-model
+ toggleable lines.
+- Accessibility: `sr-only` data tables twin the heatmap and the trend chart,
+ the donut carries an aria-label summarizing the shares, and all controls are
+ focusable buttons.
+
+## Host requirement / 宿主要求
+
+`pi.usage.listTurns` is provided by PI-Desktop (host PR #503). On builds
+without it the panel shows an explicit "host interface not available" state
+instead of an empty board. 面板对未提供该接口的旧版本宿主显示明确提示,
+而不是伪装成“没有用量”。
+
+## Development
+
+```bash
+node --check plugins/io.github.muzimu217.usage-dashboard/main.js
+node --check plugins/io.github.muzimu217.usage-dashboard/lib/aggregate.js
+node --test tests/usage-dashboard.test.mjs
+```
diff --git a/plugins/io.github.muzimu217.usage-dashboard/lib/aggregate.js b/plugins/io.github.muzimu217.usage-dashboard/lib/aggregate.js
new file mode 100644
index 0000000..15f63b1
--- /dev/null
+++ b/plugins/io.github.muzimu217.usage-dashboard/lib/aggregate.js
@@ -0,0 +1,309 @@
+/**
+ * Shared aggregation layer for the Usage Dashboard plugin.
+ *
+ * The same file is required by the plugin process (main.js) and loaded as a
+ * plain script by the panel, so a number shown on screen and a number computed
+ * in the plugin can never drift apart. The module is a pure function layer:
+ * turns in, cube out, no host access, no clock reads (time is always injected).
+ *
+ * Aggregation contract (v3.1, this plugin's own accounting):
+ * - Headline tokens = inputTokens + outputTokens. Cache reads, cache writes
+ * and reasoning are diagnostic figures only and never enter the headline.
+ * - Day keys are local-calendar days (YYYY-MM-DD) taken from `endedAt`,
+ * because the reader runs on the same machine as the conversations. An
+ * ISO-UTC key would shift early-morning turns into the previous column.
+ * - A day is "active" when its headline total is > 0. Streaks (v1, simple):
+ * the current streak counts consecutive active days ending today, or
+ * ending yesterday when today is not active yet; the longest streak is the
+ * longest run anywhere in history. Any gap of one day breaks a run.
+ */
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === "object" && module.exports) module.exports = api;
+ else root.UsageDashboardAggregate = api;
+})(typeof globalThis !== "undefined" ? globalThis : this, function () {
+ "use strict";
+
+ const SCHEMA_VERSION = 1;
+ const DAY_MS = 86_400_000;
+ /** Model id used when a turn arrives without one. Localized in the panel. */
+ const UNKNOWN_MODEL = "unknown";
+ /** Sessions ranked in the panel; the cube keeps the top 8 by headline. */
+ const TOP_SESSIONS = 8;
+ /** Donut slices drawn before everything else collapses into "other". */
+ const TOP_MODELS_DRAWN = 6;
+
+ function count(value) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
+ }
+
+ function pad2(value) {
+ return String(value).padStart(2, "0");
+ }
+
+ /** Local-calendar day key from an epoch-ms timestamp. */
+ function dayKeyFromTimestamp(timestamp) {
+ const date = new Date(timestamp);
+ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
+ }
+
+ function dayKeyFromDate(date) {
+ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
+ }
+
+ /** Parse `YYYY-MM-DD` into a local-midnight Date (never `new Date(key)`,
+ * which parses as UTC midnight and re-shifts through the local zone). */
+ function dateFromDayKey(key) {
+ const [year, month, day] = String(key).split("-").map(Number);
+ return new Date(year, (month || 1) - 1, day || 1);
+ }
+
+ /** Local-timezone day shift; `setDate` normalises month/year rollover. */
+ function shiftDayKey(key, days) {
+ const date = dateFromDayKey(key);
+ date.setDate(date.getDate() + days);
+ return dayKeyFromDate(date);
+ }
+
+ function todayKey() {
+ return dayKeyFromDate(new Date());
+ }
+
+ /** Whole days between two day keys (positive when `toKey` is later). */
+ function daysBetweenKeys(fromKey, toKey) {
+ const from = dateFromDayKey(fromKey);
+ const to = dateFromDayKey(toKey);
+ return Math.round((to.getTime() - from.getTime()) / DAY_MS);
+ }
+
+ /**
+ * Streaks over daily rows. v1 rule: activity is a day with headline > 0;
+ * the current streak anchors on today and falls back to yesterday, so a
+ * morning check does not read "streak broken" before the first coffee.
+ */
+ function streakFromDays(daily, today) {
+ const active = new Set(
+ (Array.isArray(daily) ? daily : [])
+ .filter((day) => count(day && day.tokens) > 0)
+ .map((day) => day.date),
+ );
+ if (!active.size) return { current: 0, longest: 0, includesToday: false };
+
+ let current = 0;
+ let includesToday = false;
+ const cursor = dateFromDayKey(today);
+ if (active.has(dayKeyFromDate(cursor))) {
+ includesToday = true;
+ } else {
+ cursor.setDate(cursor.getDate() - 1);
+ }
+ while (active.has(dayKeyFromDate(cursor))) {
+ current += 1;
+ cursor.setDate(cursor.getDate() - 1);
+ }
+
+ const keys = [...active].sort();
+ let longest = 0;
+ let run = 0;
+ let previous = null;
+ for (const key of keys) {
+ run = previous && daysBetweenKeys(previous, key) === 1 ? run + 1 : 1;
+ if (run > longest) longest = run;
+ previous = key;
+ }
+ return { current, longest, includesToday };
+ }
+
+ /** Headline figure for one turn: input + output, nothing else. */
+ function headlineOf(turn) {
+ return count(turn && turn.inputTokens) + count(turn && turn.outputTokens);
+ }
+
+ /** Defensive copy of one host turn; hostile or missing fields become 0/"". */
+ function normalizeTurn(turn, index) {
+ const source = turn && typeof turn === "object" ? turn : {};
+ const startedAt = count(source.startedAt) || 0;
+ const endedAt = count(source.endedAt) || startedAt;
+ const turnId = String(source.turnId ?? "").trim();
+ return {
+ turnId: turnId || `turn:${String(source.sessionId ?? "")}:${endedAt}:${index}`,
+ sessionId: String(source.sessionId ?? "").trim(),
+ sessionTitle: String(source.sessionTitle ?? "").trim(),
+ projectId: String(source.projectId ?? "").trim(),
+ providerId: String(source.providerId ?? "").trim(),
+ modelId: String(source.modelId ?? "").trim(),
+ startedAt,
+ endedAt,
+ inputTokens: count(source.inputTokens),
+ outputTokens: count(source.outputTokens),
+ cacheReadTokens: count(source.cacheReadTokens),
+ cacheWriteTokens: count(source.cacheWriteTokens),
+ reasoningTokens: count(source.reasoningTokens),
+ };
+ }
+
+ function emptyCube(nowMs) {
+ return {
+ schemaVersion: SCHEMA_VERSION,
+ generatedAt: nowMs,
+ cards: {
+ totalTokens: 0,
+ turnCount: 0,
+ sessionCount: 0,
+ peakDay: null,
+ currentStreak: 0,
+ longestStreak: 0,
+ streakIncludesToday: false,
+ },
+ daily: [],
+ dailyByModel: [],
+ models: [],
+ topSessions: [],
+ diagnostics: { cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 },
+ depth: { firstEndedAt: 0, lastEndedAt: 0, activeDays: 0 },
+ };
+ }
+
+ function isCube(value) {
+ return Boolean(
+ value &&
+ typeof value === "object" &&
+ Number(value.schemaVersion) === SCHEMA_VERSION &&
+ Array.isArray(value.daily) &&
+ Array.isArray(value.models) &&
+ value.cards &&
+ typeof value.cards.totalTokens === "number",
+ );
+ }
+
+ /**
+ * Aggregate normalized turns into the published cube.
+ *
+ * @param turns raw turn objects from the host's listTurns (deduped already,
+ * but a second belt-and-braces dedupe by turnId happens here)
+ * @param nowMs injected clock so "today" and the streaks are testable
+ */
+ function aggregateCube(turns, nowMs) {
+ const cube = emptyCube(nowMs);
+ const rows = (Array.isArray(turns) ? turns : []).map(normalizeTurn);
+ const seen = new Set();
+
+ const dailyMap = new Map(); // date -> { tokens, turns }
+ const dailyModelMap = new Map(); // date -> Map(modelId -> tokens)
+ const modelMap = new Map(); // modelId -> tokens
+ const sessions = new Map(); // sessionId -> aggregate
+
+ for (const turn of rows) {
+ if (seen.has(turn.turnId)) continue;
+ seen.add(turn.turnId);
+ if (turn.endedAt <= 0) continue;
+
+ const headline = headlineOf(turn);
+ cube.cards.turnCount += 1;
+ cube.cards.totalTokens += headline;
+ cube.diagnostics.cacheReadTokens += turn.cacheReadTokens;
+ cube.diagnostics.cacheWriteTokens += turn.cacheWriteTokens;
+ cube.diagnostics.reasoningTokens += turn.reasoningTokens;
+ if (cube.depth.firstEndedAt === 0 || turn.endedAt < cube.depth.firstEndedAt) {
+ cube.depth.firstEndedAt = turn.endedAt;
+ }
+ if (turn.endedAt > cube.depth.lastEndedAt) cube.depth.lastEndedAt = turn.endedAt;
+
+ const date = dayKeyFromTimestamp(turn.endedAt);
+ const day = dailyMap.get(date) || { tokens: 0, turns: 0 };
+ day.tokens += headline;
+ day.turns += 1;
+ dailyMap.set(date, day);
+
+ const modelId = turn.modelId || UNKNOWN_MODEL;
+ modelMap.set(modelId, (modelMap.get(modelId) || 0) + headline);
+ const byModel = dailyModelMap.get(date) || new Map();
+ byModel.set(modelId, (byModel.get(modelId) || 0) + headline);
+ dailyModelMap.set(date, byModel);
+
+ const sessionKey = turn.sessionId || `turn:${turn.turnId}`;
+ const session = sessions.get(sessionKey) || {
+ sessionId: sessionKey,
+ sessionTitle: turn.sessionTitle,
+ tokens: 0,
+ turnCount: 0,
+ lastEndedAt: 0,
+ };
+ session.tokens += headline;
+ session.turnCount += 1;
+ if (!session.sessionTitle && turn.sessionTitle) session.sessionTitle = turn.sessionTitle;
+ if (turn.endedAt > session.lastEndedAt) session.lastEndedAt = turn.endedAt;
+ sessions.set(sessionKey, session);
+ }
+
+ cube.cards.sessionCount = sessions.size;
+ cube.depth.activeDays = dailyMap.size;
+
+ // Daily series, ascending. Local day keys compare correctly as strings.
+ cube.daily = [...dailyMap.entries()]
+ .map(([date, day]) => ({ date, tokens: day.tokens, turns: day.turns }))
+ .sort((left, right) => (left.date < right.date ? -1 : left.date > right.date ? 1 : 0));
+
+ cube.dailyByModel = [...dailyModelMap.entries()]
+ .flatMap(([date, byModel]) =>
+ [...byModel.entries()].map(([modelId, tokens]) => ({ date, modelId, tokens })),
+ )
+ .sort((left, right) =>
+ left.date < right.date ? -1 : left.date > right.date ? 1 : left.modelId < right.modelId ? -1 : 1,
+ );
+
+ const total = cube.cards.totalTokens;
+ cube.models = [...modelMap.entries()]
+ .map(([modelId, tokens]) => ({
+ modelId,
+ tokens,
+ share: total > 0 ? Math.round((tokens / total) * 1000) / 10 : 0,
+ }))
+ .sort((left, right) => right.tokens - left.tokens);
+
+ cube.topSessions = [...sessions.values()]
+ .sort((left, right) => right.tokens - left.tokens)
+ .slice(0, TOP_SESSIONS)
+ .map((session) => ({
+ sessionId: session.sessionId.slice(0, 8),
+ sessionTitle: session.sessionTitle,
+ tokens: session.tokens,
+ turnCount: session.turnCount,
+ lastEndedAt: session.lastEndedAt,
+ }));
+
+ const peak = cube.daily.reduce(
+ (best, day) => (day.tokens > best.tokens ? { date: day.date, tokens: day.tokens } : best),
+ { date: null, tokens: 0 },
+ );
+ cube.cards.peakDay = peak.tokens > 0 ? peak : null;
+
+ const streak = streakFromDays(cube.daily, dayKeyFromTimestamp(nowMs));
+ cube.cards.currentStreak = streak.current;
+ cube.cards.longestStreak = streak.longest;
+ cube.cards.streakIncludesToday = streak.includesToday;
+
+ return cube;
+ }
+
+ return {
+ SCHEMA_VERSION,
+ DAY_MS,
+ UNKNOWN_MODEL,
+ TOP_SESSIONS,
+ TOP_MODELS_DRAWN,
+ aggregateCube,
+ count,
+ dayKeyFromDate,
+ dayKeyFromTimestamp,
+ dateFromDayKey,
+ daysBetweenKeys,
+ headlineOf,
+ isCube,
+ normalizeTurn,
+ shiftDayKey,
+ streakFromDays,
+ todayKey,
+ };
+});
diff --git a/plugins/io.github.muzimu217.usage-dashboard/main.js b/plugins/io.github.muzimu217.usage-dashboard/main.js
new file mode 100644
index 0000000..bc21df1
--- /dev/null
+++ b/plugins/io.github.muzimu217.usage-dashboard/main.js
@@ -0,0 +1,190 @@
+/**
+ * Usage Dashboard — a native PI-Desktop usage board built on the official
+ * read-only usage contract (`pi.usage.listTurns`, host PR #503).
+ *
+ * The panel bridge is read-only, so — exactly like the data-channel pattern
+ * this repo established — the plugin process is the only side that talks to
+ * the host. It walks the full history in 365-day windows, aggregates the
+ * turns into a cube and publishes the cube through plugin settings:
+ *
+ * usageCube the aggregated cube the panel filters and renders locally
+ * scanState whether a walk is running / finished / failed / unsupported
+ *
+ * This plugin covers PI-Desktop's native usage only, through the formal
+ * contract. It never reads message text, transcript files or any host
+ * database directly, never touches the network, and writes nothing outside
+ * its own plugin settings.
+ */
+
+const { aggregateCube, isCube, normalizeTurn } = require("./lib/aggregate");
+
+const DAY_MS = 86_400_000;
+/** One walk window, the widest the contract allows. */
+const WINDOW_DAYS = 365;
+/** A window with zero turns ends the walk; this cap bounds a lying host. */
+const MAX_WINDOWS = 8;
+/** Contract maximum page size — fewest round trips per window. */
+const PAGE_LIMIT = 500;
+
+let walking = null;
+
+/* ---------------------------------------------------------------- scanning */
+
+/** The host feature this plugin is built on; absent in older builds. */
+function listTurnsApi() {
+ const usage = globalThis.pi?.usage;
+ return typeof usage?.listTurns === "function" ? usage.listTurns.bind(usage) : null;
+}
+
+/**
+ * Walk the whole history backwards from `nowMs` in 365-day windows:
+ * the next window ends one millisecond before the previous one began, each
+ * window pages by cursor until the host reports `nextCursor: null`, and the
+ * first fully empty window proves there is nothing older to find. A turnId
+ * set dedupes across window boundaries, since an inclusive-endpoint window
+ * can re-serve a turn whose `endedAt` sits exactly on the seam.
+ */
+async function collectTurns(listTurns, nowMs, onWindowDone) {
+ const seen = new Set();
+ const turns = [];
+ let toMs = nowMs;
+ let windowsWalked = 0;
+
+ for (let window = 0; window < MAX_WINDOWS; window += 1) {
+ const fromMs = toMs - (WINDOW_DAYS * DAY_MS - 1);
+ let cursor = null;
+ let rowsInWindow = 0;
+
+ do {
+ // `cursor` is omitted rather than null: the contract calls it opaque,
+ // so the first page is sent without it instead of with a null value.
+ const page = await listTurns({ fromMs, toMs, cursor: cursor || undefined, limit: PAGE_LIMIT });
+ for (const raw of page?.turns || []) {
+ rowsInWindow += 1;
+ const turn = normalizeTurn(raw, turns.length);
+ if (seen.has(turn.turnId)) continue;
+ seen.add(turn.turnId);
+ turns.push(turn);
+ }
+ cursor = typeof page?.nextCursor === "string" && page.nextCursor ? page.nextCursor : null;
+ } while (cursor);
+
+ windowsWalked += 1;
+ if (onWindowDone) onWindowDone({ windowsWalked, turnsFound: turns.length });
+ if (rowsInWindow === 0) break;
+ toMs = fromMs - 1;
+ }
+
+ return { turns, windowsWalked };
+}
+
+/**
+ * Rescan and publish. Concurrent callers share one walk: the load hook and
+ * the open command both want the same fresh cube, not two of them.
+ */
+function refreshCube(reason = "manual") {
+ if (walking) return walking;
+ walking = (async () => {
+ const startedAt = Date.now();
+ try {
+ const listTurns = listTurnsApi();
+ if (!listTurns) {
+ // Older host without the contract: say so instead of showing an empty
+ // board that reads as "no usage", which would be a lie.
+ await pi.plugin.setSettings({
+ scanState: {
+ status: "unsupported",
+ startedAt,
+ finishedAt: Date.now(),
+ reason,
+ message:
+ "This PI-Desktop build does not expose pi.usage.listTurns yet. Update the app, then run Usage Dashboard: Open again.",
+ },
+ });
+ return null;
+ }
+
+ await pi.plugin.setSettings({
+ scanState: { status: "scanning", startedAt, reason, windowsWalked: 0, turnsFound: 0 },
+ });
+
+ const { turns, windowsWalked } = await collectTurns(listTurns, startedAt, (progress) => {
+ void pi.plugin.setSettings({ scanState: { status: "scanning", startedAt, reason, ...progress } }).catch(
+ () => undefined,
+ );
+ });
+
+ const cube = aggregateCube(turns, Date.now());
+ cube.scan = { reason, windowsWalked, turnsScanned: turns.length, durationMs: Date.now() - startedAt };
+ await pi.plugin.setSettings({
+ usageCube: cube,
+ scanState: {
+ status: "ready",
+ startedAt,
+ finishedAt: Date.now(),
+ reason,
+ windowsWalked,
+ turnsFound: turns.length,
+ },
+ });
+ return cube;
+ } catch (error) {
+ await pi.plugin
+ .setSettings({
+ scanState: {
+ status: "failed",
+ startedAt,
+ finishedAt: Date.now(),
+ reason,
+ message: String(error?.message || error),
+ },
+ })
+ .catch(() => undefined);
+ throw error;
+ } finally {
+ walking = null;
+ }
+ })();
+ return walking;
+}
+
+/* --------------------------------------------------------------- lifecycle */
+
+async function onLoad() {
+ await pi.commands.register({
+ id: "usageDashboard.open",
+ title: "Usage Dashboard: Open",
+ keywords: ["usage", "tokens", "stats", "dashboard", "用量", "统计", "看板"],
+ category: "Productivity",
+ run: async () => {
+ // Open now, walk behind it: the panel renders the previous cube and
+ // switches to the fresh one as soon as it lands in settings.
+ await pi.ui.openPanel();
+ void refreshCube("command").catch((error) =>
+ pi.ui.showToast(`Usage Dashboard walk failed: ${error.message}`, "warn"),
+ );
+ },
+ });
+
+ void refreshCube("load").catch(() => undefined);
+}
+
+async function onUnload() {
+ await pi.commands.unregister("usageDashboard.open");
+}
+
+module.exports = {
+ onLoad,
+ onUnload,
+ __test: {
+ aggregateCube,
+ collectTurns,
+ isCube,
+ normalizeTurn,
+ refreshCube,
+ DAY_MS,
+ MAX_WINDOWS,
+ PAGE_LIMIT,
+ WINDOW_DAYS,
+ },
+};
diff --git a/plugins/io.github.muzimu217.usage-dashboard/manifest.json b/plugins/io.github.muzimu217.usage-dashboard/manifest.json
new file mode 100644
index 0000000..904c5db
--- /dev/null
+++ b/plugins/io.github.muzimu217.usage-dashboard/manifest.json
@@ -0,0 +1,61 @@
+{
+ "schemaVersion": 1,
+ "id": "io.github.muzimu217.usage-dashboard",
+ "name": "Usage Dashboard",
+ "version": "0.1.0",
+ "description": "A native PI-Desktop usage dashboard built on the official read-only usage contract (pi.usage.listTurns): 365-day activity heatmap, model-share donut, daily trend lines, top sessions and streaks, in a light and dark panel.",
+ "i18n": {
+ "en": {
+ "name": "Usage Dashboard",
+ "description": "A native PI-Desktop usage dashboard built on the official read-only usage contract (pi.usage.listTurns): 365-day activity heatmap, model-share donut, daily trend lines, top sessions and streaks, in a light and dark panel.",
+ "safetyNotes": "Read-only aggregation over the host's official usage interface (pi.usage.listTurns). Counts tokens per turn only — never reads message text, tool arguments, project paths or credentials; keeps at most an 8-character session id prefix; writes nothing outside its own plugin settings; makes no network requests. Covers PI-Desktop's native usage only and reads no other tool's files."
+ },
+ "zh-CN": {
+ "name": "原生用量看板",
+ "description": "基于 PI-Desktop 官方只读用量接口(pi.usage.listTurns)构建的原生用量看板:365 天活动热力图、模型占比环形图、每日趋势折线、Top 会话与连续使用天数,深浅两套主题。",
+ "safetyNotes": "只读聚合宿主官方用量接口(pi.usage.listTurns)返回的计数:不读取消息正文、工具参数、项目路径或凭据,最多保留 8 位 session id 前缀,除自身插件设置外不写任何位置,不发起网络请求。仅覆盖 PI-Desktop 原生用量,不读取其他工具的任何文件。"
+ }
+ },
+ "author": "muzimu217",
+ "main": "main.js",
+ "categories": [
+ "productivity",
+ "developer-tools"
+ ],
+ "changelog": "Release 0.1.0: first release. Aggregates the full PI-Desktop usage history through the official pi.usage.listTurns contract (365-day windows walked backwards, cursor pagination, turnId dedupe) into a local dashboard: metric cards, 365-day heatmap, model donut, per-model trend lines, top sessions and streaks. Headline tokens are input + output; cache and reasoning tokens stay in a diagnostics line. 0.1.0:首个版本。通过官方 pi.usage.listTurns 接口(按 365 天窗口回走 + 游标翻页 + turnId 去重)聚合全部原生用量历史,呈现指标卡、热力图、模型环形图、分模型趋势线、Top 会话与连续天数;headline 仅计输入+输出,缓存与推理 token 单列诊断行。",
+ "safetyNotes": "Read-only aggregation over the host's official usage interface (pi.usage.listTurns). Counts tokens per turn only — never reads message text, tool arguments, project paths or credentials; keeps at most an 8-character session id prefix; writes nothing outside its own plugin settings; makes no network requests. Covers PI-Desktop's native usage only and reads no other tool's files.",
+ "ui": {
+ "panel": "renderer/index.html",
+ "width": 960,
+ "height": 720,
+ "title": {
+ "en": "Usage Dashboard",
+ "zh-CN": "原生用量看板"
+ }
+ },
+ "contributes": {
+ "commands": [
+ {
+ "id": "usageDashboard.open",
+ "title": "Usage Dashboard: Open",
+ "keywords": [
+ "usage",
+ "tokens",
+ "stats",
+ "dashboard",
+ "用量",
+ "统计",
+ "看板"
+ ],
+ "category": "Productivity"
+ }
+ ]
+ },
+ "permissions": [
+ "ui.panel",
+ "usage.read"
+ ],
+ "engines": {
+ "piDesktop": ">=0.14.3"
+ }
+}
diff --git a/plugins/io.github.muzimu217.usage-dashboard/renderer/index.html b/plugins/io.github.muzimu217.usage-dashboard/renderer/index.html
new file mode 100644
index 0000000..031225f
--- /dev/null
+++ b/plugins/io.github.muzimu217.usage-dashboard/renderer/index.html
@@ -0,0 +1,134 @@
+
+
+