@kidli1412/dsh-token-heatmap 0.1.0 → 0.1.2

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.
package/README.md CHANGED
@@ -17,7 +17,7 @@ A DeepSeek Harness web plugin: a GitHub-style daily token-usage heatmap of the *
17
17
  - ⚙️ **插件配置卡**(设置 → 插件 → 插件配置,随官方"插件配置"页签渲染):
18
18
  - **显示热力图** 开关:关闭后新会话页面不再显示热力图卡片。
19
19
  - **配色方案**:绿色 / 蓝色 / 橙色 / 红色 / 紫色 / 青色,六个色板按钮即时预览。
20
- - 修改后需点"保存"(显示"未保存"徽标提示),"放弃修改"可丢弃草稿;配置经回环端点持久化到 `<DSH_HOME>/storages/token-heatmap-config.json`。
20
+ - 修改后需点"保存"(显示"未保存"徽标提示),"放弃修改"可丢弃草稿;配置经 `token-heatmap` settings namespace 持久化到 `<DSH_HOME>/settings.yaml`(0.1.1 及更早版本存在 `<DSH_HOME>/storages/token-heatmap-config.json` 的旧配置会在启动时自动迁移)。
21
21
 
22
22
  ## 安装 / Install
23
23
 
@@ -49,8 +49,8 @@ dsh plugin --profile web remove @kidli1412/dsh-token-heatmap
49
49
 
50
50
  ## 工作原理 / How it works
51
51
 
52
- - **服务端**(`lib/index.js` + `lib/usage.js` + `lib/config.js`):作为 profile bundle 挂载,增量折叠全部会话事件日志中的 token 用量样本(`assistant/chunk` 的 `usage` 与 `assistant/message` 的 `usage`;同 `(turn, step)` 的重复样本按"替换"语义处理,归属后一天),按天、按模型聚合,缓存到 `<DSH_HOME>/storages/token-heatmap-cache.json`,并通过回环受限端点 `GET /api/token-heatmap/usage` 提供;显示配置(开关 + 配色)经 `GET/POST /api/token-heatmap/config` 读写,持久化到 `<DSH_HOME>/storages/token-heatmap-config.json`。
53
- - **客户端**(`lib/client.js`):手写 `__ModuleLoader__` bundle,注册进会话 `conversation.input.dock` 列表插槽,仅当 `session.composerPhase === "blank"`(新会话 hero 屏)且配置开关开启时渲染。框架真正的"卡片下方"插槽 `conversation.composer.dock` 在 hero 屏被 `!hero` 门控禁用,因此本插件利用 `input.dock` 容器(flex 列)的 CSS `order` 把自己排到输入卡片**之后**。配置卡注册进官方 `settings.plugin.item` 插槽(设置 → 插件 → 插件配置页签);官方 Host 只向 Web 客户端暴露白名单内的设置命名空间,因此本插件不依赖 `settingsScope`,而是直接读写自己的回环配置端点。
52
+ - **服务端**(`lib/index.js` + `lib/usage.js` + `lib/config.js`):作为 profile bundle 挂载,增量折叠全部会话事件日志中的 token 用量样本(`assistant/chunk` 的 `usage` 与 `assistant/message` 的 `usage`;同 `(turn, step)` 的重复样本按"替换"语义处理,归属后一天),按天、按模型聚合,缓存到 `<DSH_HOME>/storages/token-heatmap-cache.json`,并通过回环受限端点 `GET /api/token-heatmap/usage` 提供;显示配置(开关 + 配色)由插件注册的 `token-heatmap` settings namespace 持有(settings.yaml),`GET/POST /api/token-heatmap/config` 作为回环兼容 API 读写同一 namespace,0.1.1 及更早的 `token-heatmap-config.json` 文档在启动时一次性迁移。
53
+ - **客户端**(`lib/client.js`):手写 `__ModuleLoader__` bundle,注册进会话 `conversation.input.dock` 列表插槽,仅当 `session.composerPhase === "blank"`(新会话 hero 屏)且配置开关开启时渲染。框架真正的"卡片下方"插槽 `conversation.composer.dock` 在 hero 屏被 `!hero` 门控禁用,因此本插件利用 `input.dock` 容器(flex 列)的 CSS `order` 把自己排到输入卡片**之后**。配置卡注册进官方 `settings.plugin.item` 插槽(设置 → 插件 → 插件配置页签),经 settings scope 读写 `token-heatmap` namespace(该 namespace 由本插件在服务端注册,官方页签只渲染"Host 实际 serve namespace ∩ 已注册 key"的卡片)。
54
54
  - 语义与 `dsh-token-meter` 的 `tokenUsage` 投影一致(参考插件 [dsh-usage-stats](https://github.com/Ychris12138/dsh-usage-stats),MIT)。
55
55
 
56
56
  ## 说明 / Notes
package/lib/client.js CHANGED
@@ -8,7 +8,8 @@
8
8
  * totals and a ‹year› year selector next to the stats. It also registers a
9
9
  * settings card into the official `settings.plugin.item` seat
10
10
  * (设置 → 插件 → 插件配置) with a display switch and a green/blue palette
11
- * choice, persisted through the server half's loopback config endpoint.
11
+ * choice, persisted through the `token-heatmap` settings namespace (settings
12
+ * scope).
12
13
  *
13
14
  * The card is shown ONLY on the new-session (hero) screen — gated on
14
15
  * `session.composerPhase === "blank"` — and is positioned visually BELOW the
@@ -327,25 +328,88 @@ window.__ModuleLoader__.load({
327
328
  if (payload === null || typeof payload !== "object") throw new Error("unexpected response");
328
329
  return payload;
329
330
  }
331
+ //#endregion
330
332
 
331
- async function postJson(path, body) {
332
- const response = await fetch(path, {
333
- method: "POST",
334
- headers: { "content-type": "application/json" },
335
- body: JSON.stringify(body)
336
- });
337
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
338
- const payload = await response.json();
339
- if (payload === null || typeof payload !== "object") throw new Error("unexpected response");
340
- return payload;
333
+ //#region config store (settings-scope backed)
334
+ /**
335
+ * Display preferences (enabled + colorScheme), persisted through the
336
+ * Host `token-heatmap` settings namespace (settings.yaml) via the bound
337
+ * settings scope — NOT the legacy loopback config endpoint. The
338
+ * heatmap and the settings card read the store through
339
+ * useSyncExternalStore; while the scope is loading or unavailable the
340
+ * store falls back to the defaults (enabled / green), so the UI never
341
+ * blocks on the settings transport.
342
+ */
343
+ const CONFIG_DEFAULTS = { enabled: true, colorScheme: "green" };
344
+ /** Coerce a scheme to a non-blank string, else the default. */
345
+ function sanitizeScheme(value) {
346
+ if (typeof value !== "string") return CONFIG_DEFAULTS.colorScheme;
347
+ const scheme = value.trim();
348
+ return scheme.length > 0 && scheme.length <= 32 ? scheme : CONFIG_DEFAULTS.colorScheme;
341
349
  }
350
+ /**
351
+ * Reactive adapter over one bound settings scope: a
352
+ * useSyncExternalStore-compatible store whose snapshot is
353
+ * `{ status, enabled, colorScheme }` derived from the scope.
354
+ * `set(patch)` writes the touched fields through scope.set (async; the
355
+ * scope fences revisions and reloads on failure), and the snapshot
356
+ * only changes after the Host confirms. `reload()` re-reads from the
357
+ * Host; `dispose()` removes the subscription.
358
+ */
359
+ function createConfigStore(scope) {
360
+ const listeners = [];
361
+ let snapshot = { status: "loading", ...CONFIG_DEFAULTS };
362
+ const compute = () => {
363
+ const s = scope.getSnapshot();
364
+ if (s === void 0 || s === null || s.status !== "ready" || s.value === void 0 || s.value === null) {
365
+ return { status: s === void 0 || s === null ? "unavailable" : s.status, ...CONFIG_DEFAULTS };
366
+ }
367
+ const v = s.value;
368
+ return {
369
+ status: s.status,
370
+ enabled: v.enabled !== false,
371
+ colorScheme: sanitizeScheme(v.colorScheme)
372
+ };
373
+ };
374
+ const notify = () => {
375
+ snapshot = compute();
376
+ for (const listener of listeners.slice()) {
377
+ try { listener(); } catch { /* contained: never break the fan-out */ }
378
+ }
379
+ };
380
+ const unsubscribe = scope.subscribe(notify);
381
+ snapshot = compute();
382
+ return {
383
+ getSnapshot: () => snapshot,
384
+ subscribe: (listener) => {
385
+ listeners.push(listener);
386
+ return () => {
387
+ const index = listeners.indexOf(listener);
388
+ if (index !== -1) listeners.splice(index, 1);
389
+ };
390
+ },
391
+ set: async (patch) => {
392
+ if (Object.hasOwn(patch, "enabled")) {
393
+ await scope.set("enabled", patch.enabled === true);
394
+ }
395
+ if (Object.hasOwn(patch, "colorScheme")) {
396
+ await scope.set("colorScheme", sanitizeScheme(patch.colorScheme));
397
+ }
398
+ },
399
+ reload: () => scope.load(),
400
+ dispose: () => unsubscribe()
401
+ };
402
+ }
403
+ /** Module-level store, created in apply() once the settings scope is bound. */
404
+ let configStore = null;
405
+ //#endregion
342
406
 
343
407
  /**
344
408
  * Cell palettes by scheme, level 0 (empty) → 4 (peak). Green is the
345
409
  * classic GitHub scale; the others are ColorBrewer single-hue ramps
346
410
  * (blue/orange/red/purple) plus a custom teal. The active scheme comes
347
- * from the settings card (配置 → 配色方案), persisted by the server
348
- * half's config endpoint.
411
+ * from the settings card (配置 → 配色方案), persisted through the
412
+ * `token-heatmap` settings namespace.
349
413
  */
350
414
  const COLOR_SCHEMES = {
351
415
  green: ["rgba(128,128,128,0.15)", "#9be9a8", "#40c463", "#30a14e", "#216e39"],
@@ -440,7 +504,6 @@ window.__ModuleLoader__.load({
440
504
  const dict = t !== void 0 ? t : (key) => zh[key] ?? key;
441
505
  const translate = (key, params) => interpolate(dict(key), params);
442
506
  const [data, setData] = react.useState(null);
443
- const [config, setConfig] = react.useState(null);
444
507
  const [error, setError] = react.useState(null);
445
508
  const [loading, setLoading] = react.useState(false);
446
509
  const [year, setYear] = react.useState(() => new Date().getFullYear());
@@ -450,13 +513,9 @@ window.__ModuleLoader__.load({
450
513
  const id = loaderRef.current.start();
451
514
  setLoading(true);
452
515
  try {
453
- const [payload, cfg] = await Promise.all([
454
- fetchJson("/api/token-heatmap/usage"),
455
- fetchJson("/api/token-heatmap/config")
456
- ]);
516
+ const payload = await fetchJson("/api/token-heatmap/usage");
457
517
  if (loaderRef.current.isCurrent(id)) {
458
518
  setData(payload);
459
- setConfig(cfg);
460
519
  setError(null);
461
520
  }
462
521
  } catch (err) {
@@ -478,12 +537,17 @@ window.__ModuleLoader__.load({
478
537
  };
479
538
  }, [load]);
480
539
 
540
+ // Display preferences come from the settings scope (auto-refreshed
541
+ // on settings/document-updated and connection/reset), not from a
542
+ // config fetch — the loopback endpoint remains as a back-compat API.
543
+ const config = react.useSyncExternalStore(configStore.subscribe, configStore.getSnapshot);
544
+
481
545
  // New-session hero only: a blank session whose composer is still in
482
546
  // the guidance phase. Conversed sessions hide the card.
483
547
  if (session === void 0 || session === null || input === void 0 || input === null) return null;
484
548
  if (session.composerPhase !== "blank") return null;
485
549
  // Master switch from the settings card (设置 → 插件 → 插件配置).
486
- if (config !== null && config.enabled === false) return null;
550
+ if (config.enabled === false) return null;
487
551
 
488
552
  const now = Date.now();
489
553
  // Year-selector bounds: the current year at most (no future usage),
@@ -509,7 +573,7 @@ window.__ModuleLoader__.load({
509
573
  const grid = buildGrid(dayMap, now, year);
510
574
  const levelLegend = uiDict().levelLegend;
511
575
  // Palette from the settings card; unknown schemes fall back to green.
512
- const palette = COLOR_SCHEMES[config?.colorScheme] ?? COLOR_SCHEMES.green;
576
+ const palette = COLOR_SCHEMES[config.colorScheme] ?? COLOR_SCHEMES.green;
513
577
 
514
578
  const stats = [
515
579
  { label: dict("today"), value: todayTokens },
@@ -663,11 +727,12 @@ window.__ModuleLoader__.load({
663
727
  * The plugin settings card, rendered inside the official
664
728
  * 设置 → 插件 → 插件配置 tab (the `settings.plugin.item` seat).
665
729
  *
666
- * The Host only exposes its own allowlisted settings namespaces to the
667
- * Web client, so this card does NOT bind a settingsScope; instead it
668
- * reads/writes the plugin's own loopback config endpoint and stages a
669
- * draft until the user saves, mirroring the official card chrome
670
- * (disclosure header, unsaved badge, discard/save footer).
730
+ * The card edits the `token-heatmap` settings namespace through the
731
+ * bound settings scope (the tab only dispatches it while the Host
732
+ * serves that namespace) and stages a draft until the user saves,
733
+ * mirroring the official card chrome (disclosure header, unsaved
734
+ * badge, discard/save footer). The legacy loopback config endpoint
735
+ * remains as a back-compat API, but the card no longer uses it.
671
736
  * @param props - `t` bound by the slot runtime locale seat.
672
737
  */
673
738
  function TokenHeatmapSettingsCard({ t }) {
@@ -677,21 +742,15 @@ window.__ModuleLoader__.load({
677
742
  const [draft, setDraft] = react.useState(null);
678
743
  const [saving, setSaving] = react.useState(false);
679
744
  const [saveFailed, setSaveFailed] = react.useState(false);
680
- const [loadFailed, setLoadFailed] = react.useState(false);
681
- const load = react.useCallback(async () => {
682
- try {
683
- const payload = await fetchJson("/api/token-heatmap/config");
684
- setLoaded(payload);
685
- setDraft(payload);
686
- setLoadFailed(false);
687
- setSaveFailed(false);
688
- } catch {
689
- setLoadFailed(true);
690
- }
691
- }, []);
745
+ const config = react.useSyncExternalStore(configStore.subscribe, configStore.getSnapshot);
746
+ // Seed the staged form from the confirmed scope snapshot once (a
747
+ // later scope change is the card's own save confirmation; external
748
+ // edits land in loaded via the explicit sync in save()).
692
749
  react.useEffect(() => {
693
- load();
694
- }, [load]);
750
+ if (config.status !== "ready" || loaded !== null) return;
751
+ setLoaded({ enabled: config.enabled, colorScheme: config.colorScheme });
752
+ setDraft({ enabled: config.enabled, colorScheme: config.colorScheme });
753
+ }, [config, loaded]);
695
754
 
696
755
  const dirty = loaded !== null && draft !== null && (draft.enabled !== loaded.enabled || draft.colorScheme !== loaded.colorScheme);
697
756
  const save = react.useCallback(async () => {
@@ -699,9 +758,14 @@ window.__ModuleLoader__.load({
699
758
  setSaving(true);
700
759
  setSaveFailed(false);
701
760
  try {
702
- const payload = await postJson("/api/token-heatmap/config", draft);
703
- setLoaded(payload);
704
- setDraft(payload);
761
+ await configStore.set({ enabled: draft.enabled, colorScheme: draft.colorScheme });
762
+ // The scope confirms the write and refreshes its snapshot
763
+ // synchronously before settling; sync the staged form.
764
+ const confirmed = configStore.getSnapshot();
765
+ if (confirmed.status === "ready") {
766
+ setLoaded({ enabled: confirmed.enabled, colorScheme: confirmed.colorScheme });
767
+ setDraft({ enabled: confirmed.enabled, colorScheme: confirmed.colorScheme });
768
+ }
705
769
  } catch {
706
770
  setSaveFailed(true);
707
771
  } finally {
@@ -721,7 +785,7 @@ window.__ModuleLoader__.load({
721
785
  const title = dict("settingsTitle");
722
786
 
723
787
  let bodyContent;
724
- if (loadFailed) {
788
+ if (config.status === "unavailable") {
725
789
  bodyContent = react_jsx_runtime.jsxs("p", {
726
790
  className: S.settingsError,
727
791
  children: [
@@ -730,7 +794,7 @@ window.__ModuleLoader__.load({
730
794
  react_jsx_runtime.jsx("button", {
731
795
  type: "button",
732
796
  className: S.settingsRetry,
733
- onClick: load,
797
+ onClick: () => configStore.reload(),
734
798
  children: dict("settingsRetry")
735
799
  })
736
800
  ]
@@ -871,14 +935,38 @@ window.__ModuleLoader__.load({
871
935
  //#endregion
872
936
 
873
937
  //#region plugin body
938
+ /**
939
+ * Settings namespace this plugin's card edits — must match the
940
+ * namespace the Host registers (lib/index.js). Spelled here rather
941
+ * than imported: a client package must not depend on a Host package.
942
+ */
943
+ const SETTINGS_NS = "token-heatmap";
874
944
  /** Services required by the client plugin body. */
875
- const inject = ["slots", "locale"];
945
+ const inject = ["slots", "locale", "connection", "remote", "settingsScope"];
876
946
 
877
947
  /**
878
- * Client plugin body: register the dictionaries and the dock entry.
948
+ * Client plugin body: register the dictionaries, the input-dock entry
949
+ * (heatmap below the composer card on the new-session screen) and the
950
+ * plugin settings card (设置 → 插件 → 插件配置), all backed by the
951
+ * `token-heatmap` settings namespace through a bound settings scope.
952
+ *
953
+ * Slot-kind contract: `conversation.input.dock` is a LIST slot, so its
954
+ * registration is keyed by `id`; `settings.plugin.item` is a KEYED slot,
955
+ * so its registration must carry `key` (the settings namespace the card
956
+ * edits) — registering with `id` instead throws
957
+ * `keyed slot "settings.plugin.item" requires options.key` and fails
958
+ * the whole plugin load. The card is only dispatched by the official
959
+ * 插件配置 tab when the Host actually SERVES that namespace, which is
960
+ * why this plugin registers it on the Host side too.
879
961
  * @param ctx - client root context.
880
962
  */
881
963
  function apply(ctx) {
964
+ // Bind the namespace scope on THIS plugin's fiber (settingsScope.bind
965
+ // registers the disposer itself); the scope auto-loads and refreshes
966
+ // on settings/document-updated and connection/reset. Requires the
967
+ // injected connection (transport) and remote (invalidation) services.
968
+ const scope = ctx.settingsScope.bind({ namespace: SETTINGS_NS });
969
+ configStore = createConfigStore(scope);
882
970
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "token-heatmap: dictionaries");
883
971
  ctx.slots.inject("conversation.input.dock", () => ctx.slots.register({
884
972
  name: "conversation.input.dock",
@@ -888,7 +976,7 @@ window.__ModuleLoader__.load({
888
976
  }, TokenHeatmap));
889
977
  ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
890
978
  name: "settings.plugin.item",
891
- id: "token-heatmap",
979
+ key: SETTINGS_NS,
892
980
  order: 30,
893
981
  locale: NS
894
982
  }, TokenHeatmapSettingsCard));
@@ -903,6 +991,7 @@ window.__ModuleLoader__.load({
903
991
  exports.levelOf = levelOf;
904
992
  exports.CELL_COLORS = CELL_COLORS;
905
993
  exports.COLOR_SCHEMES = COLOR_SCHEMES;
994
+ exports.createConfigStore = createConfigStore;
906
995
  return module.exports;
907
996
  }
908
997
  });
package/lib/index.js CHANGED
@@ -11,6 +11,12 @@
11
11
  * its own peer-socket loopback fence (the exact route bypasses the RPC trust
12
12
  * fence); Host is checked only as an additional defense.
13
13
  *
14
+ * Display settings (enabled + colorScheme) are owned by the plugin's
15
+ * `token-heatmap` settings namespace — the config card in
16
+ * 设置 → 插件 → 插件配置 reads/writes it through the settings scope, and the
17
+ * legacy `<DSH_HOME>/storages/token-heatmap-config.json` document is
18
+ * migrated into the namespace once at startup.
19
+ *
14
20
  * Usage aggregation is INCREMENTAL: per-session fold state (day/model
15
21
  * buckets plus the last usage sample) is cached in memory and persisted to
16
22
  * `<DSH_HOME>/storages/token-heatmap-cache.json`. On each request only the
@@ -28,15 +34,43 @@
28
34
 
29
35
  import { homedir } from "node:os";
30
36
  import { join, dirname } from "node:path";
31
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
37
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
32
38
  import { applyUsageDelta, createUsageState, mergeInto, renderUsage, zeroBuckets } from "./usage.js";
33
39
  import { DEFAULT_CONFIG, parseConfig } from "./config.js";
40
+ import { settingsNamespace } from "@deepseek-ai/dsh-settings";
41
+ import z from "@deepseek-ai/schemastery";
34
42
 
35
43
  /** Stable Cordis plugin name. */
36
44
  const name = "token-heatmap";
37
45
 
38
46
  /** Services required before this plugin activates. */
39
- const inject = ["webServer", "sessions", "sessionPersistence"];
47
+ const inject = ["webServer", "sessions", "sessionPersistence", "settings"];
48
+
49
+ //#region settings namespace
50
+ /**
51
+ * Settings namespace owned by this plugin. Registering it makes the Host
52
+ * serve a `token-heatmap` section (resolved from schema defaults, then any
53
+ * composition `base`, then the user settings.yaml layer), which is exactly
54
+ * what the official 设置 → 插件 → 插件配置 tab dispatches on: it renders the
55
+ * card registered into `settings.plugin.item` whose `key` matches a served
56
+ * namespace. The card edits `enabled` + `colorScheme` through the settings
57
+ * scope; the legacy `<DSH_HOME>/storages/token-heatmap-config.json` document
58
+ * is migrated once at startup (see migrateLegacyConfig).
59
+ */
60
+ const SETTINGS_NAMESPACE = settingsNamespace("token-heatmap");
61
+
62
+ /**
63
+ * Durable display preferences; also the wire envelope the browser scope
64
+ * validates against. Scheme membership is deliberately NOT enforced (a newer
65
+ * client may know a palette the server does not — the client falls back to
66
+ * green); only the same shape bounds parseConfig applies: a short, non-blank
67
+ * string.
68
+ */
69
+ const TokenHeatmapSettingsSchema = z.object({
70
+ enabled: z.boolean().default(true),
71
+ colorScheme: z.string().min(1).max(32).default("green")
72
+ });
73
+ //#endregion
40
74
 
41
75
  const USAGE_PATH = "/api/token-heatmap/usage";
42
76
  const CONFIG_PATH = "/api/token-heatmap/config";
@@ -273,51 +307,64 @@ function withLock(run) {
273
307
  }
274
308
  //#endregion
275
309
 
276
- //#region config store
277
- /** Config file location under the dsh home. */
310
+ //#region config route + legacy migration
311
+ /** Legacy config file location under the dsh home (pre-0.1.2 storage). */
278
312
  function configPath() {
279
313
  const home = process.env.DSH_HOME ?? join(homedir(), ".dsh");
280
314
  return join(home, "storages", "token-heatmap-config.json");
281
315
  }
282
316
 
283
- let loadedConfig = null;
284
- let configLoadPromise = null;
285
-
286
- /** Read the config document once per process; absence or corruption degrades to defaults. */
287
- async function readConfigFile() {
288
- if (loadedConfig !== null) return loadedConfig;
289
- configLoadPromise ??= (async () => {
317
+ /**
318
+ * One-time migration from the legacy config document
319
+ * (`<DSH_HOME>/storages/token-heatmap-config.json`) into the registered
320
+ * settings namespace. Runs once per process, best-effort: when the user has
321
+ * no settings.yaml section yet, non-default values are imported through the
322
+ * settings write path; the legacy file is removed either way (the namespace
323
+ * becomes the single source of truth). A corrupt legacy document is dropped,
324
+ * never imported. Failures are logged and never fatal.
325
+ * @param ctx - plugin context carrying the settings service.
326
+ */
327
+ async function migrateLegacyConfig(ctx) {
328
+ try {
329
+ const path = configPath();
330
+ let rawText;
290
331
  try {
291
- const raw = await readFile(configPath(), "utf8");
292
- return parseConfig(JSON.parse(raw));
332
+ rawText = await readFile(path, "utf8");
293
333
  } catch {
294
- return { ...DEFAULT_CONFIG };
334
+ return; // no legacy document
295
335
  }
296
- })();
297
- loadedConfig = await configLoadPromise;
298
- return loadedConfig;
299
- }
300
-
301
- /** Persist the config atomically (temp + rename); failures are logged, never fatal. */
302
- async function writeConfigFile(ctx, config) {
303
- try {
304
- const path = configPath();
305
- await mkdir(dirname(path), { recursive: true });
306
- const tmp = `${path}.tmp`;
307
- await writeFile(tmp, JSON.stringify({ version: 1, ...config }, null, 2), "utf8");
308
- await rename(tmp, path);
309
- loadedConfig = config;
336
+ let legacy;
337
+ try {
338
+ legacy = parseConfig(JSON.parse(rawText));
339
+ } catch {
340
+ await rm(path, { force: true });
341
+ return;
342
+ }
343
+ const descriptor = ctx.settings.describe().find((entry) => entry.ns === SETTINGS_NAMESPACE);
344
+ const userExists = descriptor !== void 0 && descriptor.user !== void 0;
345
+ if (!userExists) {
346
+ const patch = {};
347
+ if (legacy.enabled !== DEFAULT_CONFIG.enabled) patch.enabled = legacy.enabled;
348
+ if (legacy.colorScheme !== DEFAULT_CONFIG.colorScheme) patch.colorScheme = legacy.colorScheme;
349
+ if (Object.keys(patch).length > 0) await ctx.settings.update(SETTINGS_NAMESPACE, patch);
350
+ }
351
+ await rm(path, { force: true });
310
352
  } catch (error) {
311
- ctx.logger.warn(`token-heatmap: saving config failed: ${String(error)}`);
312
- throw error;
353
+ ctx.logger.warn(`token-heatmap: migrating legacy config failed: ${String(error)}`);
313
354
  }
314
355
  }
315
356
 
357
+ /** Serve the resolved settings section (schema defaults + user layer). */
358
+ function serveConfig(ctx) {
359
+ const section = ctx.settings.get(SETTINGS_NAMESPACE);
360
+ return { enabled: section?.enabled !== false, colorScheme: typeof section?.colorScheme === "string" && section.colorScheme.length > 0 ? section.colorScheme : DEFAULT_CONFIG.colorScheme };
361
+ }
362
+
316
363
  async function handleConfig(ctx, req, res) {
317
364
  if (rejectForeignConfigCaller(req, res)) return;
318
365
  try {
319
366
  if (req.method === "GET") {
320
- json(res, 200, { ok: true, ...(await readConfigFile()) });
367
+ json(res, 200, { ok: true, ...serveConfig(ctx) });
321
368
  return;
322
369
  }
323
370
  let raw;
@@ -327,9 +374,12 @@ async function handleConfig(ctx, req, res) {
327
374
  json(res, 400, { ok: false, error: "bad-json", message: "request body must be a JSON object" });
328
375
  return;
329
376
  }
377
+ // parseConfig coerces the write the same way the legacy file path did
378
+ // (boolean check, scheme trimmed and shape-bounded, unknown schemes kept
379
+ // verbatim); the settings schema then validates the canonical shape.
330
380
  const config = parseConfig(raw);
331
- await writeConfigFile(ctx, config);
332
- json(res, 200, { ok: true, ...config });
381
+ await ctx.settings.update(SETTINGS_NAMESPACE, config);
382
+ json(res, 200, { ok: true, ...serveConfig(ctx) });
333
383
  } catch (error) {
334
384
  ctx.logger.warn(`token-heatmap: config ${req.method} failed: ${String(error)}`);
335
385
  json(res, 500, { ok: false, error: "internal", message: error instanceof Error ? error.message : String(error) });
@@ -455,10 +505,19 @@ async function handleUsage(ctx, req, res) {
455
505
  }
456
506
 
457
507
  /**
458
- * Plugin body: register the usage and config routes.
459
- * @param ctx - plugin context carrying webServer, sessions, and sessionPersistence.
508
+ * Plugin body: register the usage and config routes, the settings namespace
509
+ * that backs the plugin configuration card (设置 → 插件 → 插件配置), and the
510
+ * one-time legacy-config migration.
511
+ * @param ctx - plugin context carrying webServer, sessions, sessionPersistence, and settings.
460
512
  */
461
513
  function apply(ctx) {
514
+ // The registration is fiber-bound: disposing this plugin removes the
515
+ // namespace and its observers. `settings` is a hard dependency (inject),
516
+ // so ctx.settings is available here unconditionally.
517
+ ctx.settings.register(SETTINGS_NAMESPACE, TokenHeatmapSettingsSchema);
518
+ // Best-effort, fire-and-forget: import the pre-0.1.2 config document into
519
+ // the namespace and drop the file (see migrateLegacyConfig).
520
+ migrateLegacyConfig(ctx);
462
521
  ctx.effect(() => ctx.webServer.register({
463
522
  kind: "exact",
464
523
  path: USAGE_PATH,
@@ -471,4 +530,4 @@ function apply(ctx) {
471
530
  }), "token-heatmap: config route");
472
531
  }
473
532
 
474
- export { apply, inject, name, CONFIG_PATH, USAGE_PATH };
533
+ export { apply, inject, name, CONFIG_PATH, USAGE_PATH, SETTINGS_NAMESPACE, TokenHeatmapSettingsSchema, migrateLegacyConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kidli1412/dsh-token-heatmap",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "DSH web plugin: GitHub-style daily token-usage heatmap on the new-session screen with a selectable calendar-year view, green/blue color schemes and a display switch (设置 → 插件 → 插件配置), plus today / this-month / all-time totals.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -16,6 +16,10 @@
16
16
  "README.md",
17
17
  "LICENSE"
18
18
  ],
19
+ "dependencies": {
20
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
21
+ "@deepseek-ai/schemastery": "^3.18.1"
22
+ },
19
23
  "dsh": {
20
24
  "bundle": {
21
25
  "patch": "./cordis.patch.yml"
@@ -31,7 +35,7 @@
31
35
  },
32
36
  "scripts": {
33
37
  "check": "node --check lib/usage.js && node --check lib/config.js && node --check lib/index.js && node --check lib/client.js",
34
- "test": "node scripts/smoke.mjs",
38
+ "test": "node scripts/smoke.mjs && node scripts/settings-smoke.mjs",
35
39
  "prepublishOnly": "npm run check && npm test"
36
40
  },
37
41
  "license": "MIT",