@kidli1412/dsh-token-heatmap 0.1.5 → 0.1.6

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.
Files changed (3) hide show
  1. package/README.md +3 -2
  2. package/lib/index.js +129 -16
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -49,7 +49,7 @@ 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` 提供;显示配置(开关 + 配色)由插件注册的 `token-heatmap` settings namespace 持有(settings.yaml),`GET/POST /api/token-heatmap/config` 作为回环兼容 API 读写同一 namespace,0.1.1 及更早的 `token-heatmap-config.json` 文档在启动时一次性迁移。
52
+ - **服务端**(`lib/index.js` + `lib/usage.js` + `lib/config.js`):作为 profile bundle 挂载,**实时折叠会话事件**(监听官方 `session/event`,每个 `assistant/chunk`/`assistant/message` 的 `usage` 事件即时写入缓存,不依赖 hero 屏挂载);启动时一次性补折叠已存在的 live 会话(如 resumed 会话);请求时 `collectUsage` 再做一次增量同步兜底,并枚举 **已归档(stored)会话**补齐历史——两种 `sessionPersistence` 接口都支持:0.1.2 线的 `listSnapshots()` + `readFrom()`,以及 0.1.3 起取代它们的 `list()` + `open()`/`handle.read()`。同 `(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
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
 
@@ -65,7 +65,8 @@ dsh plugin --profile web remove @kidli1412/dsh-token-heatmap
65
65
  - **Node**:`^22.19.0 || >=24.0.0`(与 DSH 一致)。
66
66
  - **宿主要求(dsh-market 显示)**:`engines.dsh: ^0.1.2-rc.1`,并将运行时依赖的 lockstep 宿主包声明为 `peerDependencies`(`dsh-host-webserver` / `dsh-session` / `dsh-session-persistence` / `dsh-settings` 与客户端模块 `dsh-api-remotes` / `dsh-client-connection` / `dsh-client-locale` / `dsh-client-ui-conversation` / `dsh-client-ui-settings`,均为 `^0.1.2-rc.1`);插件市场会据此显示"宿主要求"并判断与当前 DSH 是否匹配。
67
67
  - **依赖**:`@deepseek-ai/dsh-settings` 自 0.1.3 起提升为 `^0.1.2-rc.1`、`@deepseek-ai/schemastery` 提升为 `^3.18.2`,与 DSH 0.1.2 版本线对齐。npm 的 prerelease 解析规则下 `^0.1.0-rc.7` 不会解析到 `0.1.2-rc.1`(只会装 `0.1.0-rc.8`),因此较低的范围会拉到与新版 DSH 不同 train 的 settings 副本。
68
- - **0.1.4(DSH 0.1.2 适配)**:rc.1 起 live session 不再携带 `.events` 数组(改用 `session.seq` + `session.eventAt(seq)`,与官方 `dsh-token-meter` 相同),新会话判断从 `composerPhase === "blank"` 改为布尔 `session.blank`;`sessionPersistence` rc.1 不再提供会话枚举(list/listSnapshots 已移除),持久化历史的增量刷新降级为保留已有缓存、只累计 live 会话。客户端注入模块列表同步为新架构模块(见上)。
68
+ - **0.1.4(DSH 0.1.2 适配)**:rc.1 起 live session 不再携带 `.events` 数组(改用 `session.seq` + `session.eventAt(seq)`,与官方 `dsh-token-meter` 相同),新会话判断从 `composerPhase === "blank"` 改为布尔 `session.blank`;`sessionPersistence` stored 会话枚举在 0.1.3-alpha.2 被替换(`listSnapshots`/`readFrom` `list()` + `open()`/`handle.read()`),两条接口见 0.1.6 条目。客户端注入模块列表同步为新架构模块(见上)。
69
+ - **0.1.6(session/event 实时折叠 + stored 会话枚举修复)**:`apply()` 注册官方 `session/event` 监听器,每个 usage 事件即时折叠进缓存,解决 live 会话仅在 hero 屏挂载时才折叠而漏计同一日其他会话用量的问题(表现为当日总量偏小、历史天数丢失);启动时一次性补折叠已存在的 live 会话(如 resumed 会话)。**stored 会话枚举修复**:0.1.3-alpha.2 起 `sessionPersistence` 移除了 `readFrom()` 与 `listSnapshots()`,只保留 `list()` + `open()`/`handle.read()`;旧实现只探测 `list`/`listSnapshots` 却无条件调用 `readFrom`,导致每个 stored 会话抛错并被吞成一条 warn —— 表现为热力图只剩进程内 live 的几天。现在两条接口都支持(`list()` 的 `revision` 同样用于跳过未变更的日志,增量仍按 `seq` 去重与连续性校验),stored 会话可完整补齐历史;两者都不可用时不再误判为"日志被截断",而是保留已折叠天数并告警。token 口径与 `dsh-token-meter` 一致(input + output + cacheRead + cacheWrite,不含 reasoningTokens)。
69
70
 
70
71
  ## License
71
72
 
package/lib/index.js CHANGED
@@ -291,7 +291,7 @@ async function saveCache(ctx, cache) {
291
291
  await mkdir(dirname(path), { recursive: true });
292
292
  const serialized = { version: CACHE_VERSION, sessions: {} };
293
293
  for (const [id, state] of Object.entries(cache.sessions)) serialized.sessions[id] = serializeSession(state);
294
- const tmp = `${path}.tmp`;
294
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
295
295
  await writeFile(tmp, JSON.stringify(serialized), "utf8");
296
296
  await rename(tmp, path);
297
297
  } catch (error) {
@@ -414,23 +414,58 @@ function liveSessionEvents(session, from) {
414
414
  return { count: session.seq, events };
415
415
  }
416
416
 
417
+ /**
418
+ * Read one stored session's events from `fromSeq` onward, across the two
419
+ * `sessionPersistence` generations this plugin declares compatibility with:
420
+ *
421
+ * - 0.1.0-rc.8 … 0.1.2-rc.1 expose `readFrom(id, fromSeq)`;
422
+ * - 0.1.3-alpha.2 and later dropped `readFrom`/`listSnapshots` in favour of
423
+ * `open(id, "read")` + `handle.read()`, which returns the whole log — the
424
+ * caller's `seq`-based delta filter makes the extra prefix harmless.
425
+ *
426
+ * @returns `{ events }` in seq order, or `null` when the backend offers
427
+ * neither read path (callers must not treat that as an empty log).
428
+ */
429
+ async function readSessionEvents(persistence, id, fromSeq) {
430
+ if (typeof persistence.readFrom === "function") {
431
+ const { events } = await persistence.readFrom(id, fromSeq);
432
+ return { events };
433
+ }
434
+ if (typeof persistence.open === "function") {
435
+ const handle = await persistence.open(id, "read");
436
+ try {
437
+ const { events } = await handle.read();
438
+ return { events };
439
+ } finally {
440
+ if (typeof handle.close === "function") await handle.close();
441
+ }
442
+ }
443
+ return null;
444
+ }
445
+
417
446
  /**
418
447
  * Collect per-day usage across live and persisted sessions, incrementally.
419
448
  *
420
449
  * Live sessions: fold only the in-memory events added since the last fold.
421
- * Persisted sessions: skipped when the backend's opaque revision is
422
- * unchanged (`sessionPersistence.listSnapshots`, falling back to always
423
- * reading the delta); when the revision changes, the new events are verified
424
- * to be contiguous with the last folded seq — a gap or an empty delta means
425
- * the log was truncated/rewritten, so the session is refolded from scratch.
426
- * Sessions that vanished are dropped, and a session switching between
450
+ * Persisted sessions: enumerated through `sessionPersistence` and skipped when
451
+ * the backend's opaque revision is unchanged (`listSnapshots` on the 0.1.2
452
+ * train, `list()` on 0.1.3+); when the revision changes, the new events are
453
+ * verified to be contiguous with the last folded seq — a gap or an empty delta
454
+ * means the log was truncated/rewritten, so the session is refolded from
455
+ * scratch. Sessions that vanished are dropped, and a session switching between
427
456
  * live/persisted is refolded from scratch to stay exact.
428
- * On DSH 0.1.2+ (rc.1) sessionPersistence no longer exposes a session
429
- * enumeration (list/listSnapshots are gone), so persisted-only history is
430
- * kept in the cache untouched instead of being refreshed or dropped.
457
+ * A backend that offers neither `readFrom()` nor `open()` (the 0.1.2+ shape
458
+ * this plugin was first adapted to) cannot be read at all: its already-folded
459
+ * days are kept untouched and the session/event listener covers everything
460
+ * appended from now on.
431
461
  */
432
462
  export async function collectUsage(ctx) {
433
463
  return withLock(async () => {
464
+ // Primary: incremental session-event fold. The session/event listener
465
+ // (see apply) folds live sessions in real time regardless of hero-screen
466
+ // mounting; this request-time fold is a sync point that catches anything
467
+ // the listener has not yet reached. Both share the per-session `consumed`
468
+ // cursor, so they never double count.
434
469
  const cache = await loadCache();
435
470
  const live = ctx.get("sessions");
436
471
  const attached = new Set();
@@ -461,7 +496,8 @@ export async function collectUsage(ctx) {
461
496
  );
462
497
  if (canEnumeratePersisted) {
463
498
  // Prefer the backend's opaque per-log revisions (no file I/O in the
464
- // plugin, works for any backend that exposes listSnapshots).
499
+ // plugin): `listSnapshots` is the 0.1.2 spelling, `list()` returns
500
+ // the same snapshots on 0.1.3+.
465
501
  let snapshots = null;
466
502
  if (typeof persistence.listSnapshots === "function") {
467
503
  try {
@@ -470,6 +506,7 @@ export async function collectUsage(ctx) {
470
506
  ctx.logger.warn(`token-heatmap: listSnapshots failed, falling back to list(): ${String(error)}`);
471
507
  }
472
508
  }
509
+ if (snapshots === null && typeof persistence.list === "function") snapshots = await persistence.list();
473
510
  const metas = snapshots !== null ? snapshots.map((entry) => entry.header) : await persistence.list();
474
511
  const revisionOf = new Map();
475
512
  if (snapshots !== null) for (const entry of snapshots) revisionOf.set(entry.header.id, entry.revision);
@@ -478,18 +515,31 @@ export async function collectUsage(ctx) {
478
515
  if (attached.has(meta.id)) continue;
479
516
  const state = cache.sessions[meta.id] ?? createUsageState();
480
517
  const revision = revisionOf.get(meta.id);
481
- const changed = state.kind !== "persisted" || (revision !== void 0 && revision !== state.revision) || revision === void 0;
518
+ // A revision-less backend must be read every pass; with revisions,
519
+ // an unchanged log is skipped entirely.
520
+ const changed = state.kind !== "persisted" || revision === void 0 || revision !== state.revision;
482
521
  if (changed) {
483
522
  try {
484
523
  const wasPersisted = state.kind === "persisted";
485
524
  const fromSeq = wasPersisted ? state.consumed : 0;
486
- const { events } = await persistence.readFrom(meta.id, fromSeq);
525
+ const read = await readSessionEvents(persistence, meta.id, fromSeq);
526
+ if (read === null) {
527
+ // No read path on this backend: keep the folded days as they
528
+ // are rather than reporting an empty log as a rewrite.
529
+ ctx.logger.warn(`token-heatmap: sessionPersistence exposes neither readFrom() nor open() on this DSH; stored session "${meta.id}" cannot be read`);
530
+ cache.sessions[meta.id] = state;
531
+ continue;
532
+ }
533
+ const events = read.events;
487
534
  if (!wasPersisted) {
488
535
  state.days = new Map();
489
536
  state.lastSample = null;
490
537
  state.currentModel = null;
491
538
  state.consumed = 0;
492
539
  }
540
+ // `consumed` is the seq of the last folded event; both read paths
541
+ // yield events carrying their own seq, so the delta filter and the
542
+ // contiguity check below stay index-agnostic.
493
543
  const fresh = wasPersisted ? events.filter((event) => event.seq > (state.consumed ?? 0)) : events;
494
544
  const contiguous = fresh.length === 0 ? state.consumed === 0 : fresh[0].seq === state.consumed + 1;
495
545
  if (!contiguous && state.consumed > 0) {
@@ -498,9 +548,8 @@ export async function collectUsage(ctx) {
498
548
  state.lastSample = null;
499
549
  state.currentModel = null;
500
550
  state.consumed = 0;
501
- const { events: allEvents } = await persistence.readFrom(meta.id, 0);
502
- applyUsageDelta(state, allEvents);
503
- state.consumed = allEvents.length > 0 ? allEvents[allEvents.length - 1].seq : 0;
551
+ applyUsageDelta(state, events);
552
+ state.consumed = events.length > 0 ? events[events.length - 1].seq : 0;
504
553
  } else if (fresh.length > 0) {
505
554
  applyUsageDelta(state, fresh);
506
555
  state.consumed = fresh[fresh.length - 1].seq;
@@ -558,6 +607,70 @@ function apply(ctx) {
558
607
  // Best-effort, fire-and-forget: import the pre-0.1.2 config document into
559
608
  // the namespace and drop the file (see migrateLegacyConfig).
560
609
  migrateLegacyConfig(ctx);
610
+ // Real-time fold: listen to session/event and fold each usage event into
611
+ // the cache immediately, so live session usage is captured regardless of
612
+ // whether the hero screen is mounted (the client polls the usage endpoint
613
+ // only there) and regardless of what sessionPersistence can enumerate —
614
+ // this is the path that keeps counting on a host whose stored logs are
615
+ // unreachable. The request-time fold in collectUsage stays as a sync point;
616
+ // both share the per-session `consumed` cursor so they never double count.
617
+ if (typeof ctx.on === "function") ctx.effect(() => {
618
+ let saveTimer = null;
619
+ let disposed = false;
620
+ const disposer = ctx.on("session/event", (session, event) => {
621
+ loadCache().then((cache) => {
622
+ if (disposed) return;
623
+ const state = cache.sessions[session.id] ?? createUsageState();
624
+ const seq = typeof event.seq === "number" ? event.seq : void 0;
625
+ if (seq !== void 0 && seq < (state.consumed ?? 0)) return;
626
+ applyUsageDelta(state, [event]);
627
+ if (seq !== void 0) state.consumed = Math.max(state.consumed ?? 0, seq + 1);
628
+ state.kind = "live";
629
+ cache.sessions[session.id] = state;
630
+ if (saveTimer === null) {
631
+ saveTimer = setTimeout(() => {
632
+ saveTimer = null;
633
+ saveCache(ctx, cache).catch(() => {});
634
+ }, 2000);
635
+ }
636
+ }).catch(() => {});
637
+ });
638
+ return () => {
639
+ disposed = true;
640
+ if (saveTimer !== null) clearTimeout(saveTimer);
641
+ if (typeof disposer === "function") disposer();
642
+ };
643
+ }, "token-heatmap: session/event fold");
644
+ // Initial fold of live sessions that existed before this plugin loaded
645
+ // (e.g. resumed sessions): fold their in-memory tail from the last cursor
646
+ // so the heatmap has history before the first session/event arrives.
647
+ ctx.effect(() => {
648
+ let disposed = false;
649
+ loadCache().then(async (cache) => {
650
+ if (disposed) return;
651
+ const sessions = typeof ctx.get === "function" ? ctx.get("sessions") : void 0;
652
+ if (sessions === void 0) return;
653
+ for (const session of sessions.list()) {
654
+ if (disposed) return;
655
+ const state = cache.sessions[session.id] ?? createUsageState();
656
+ if (state.kind !== "live") {
657
+ state.days = new Map();
658
+ state.lastSample = null;
659
+ state.currentModel = null;
660
+ state.consumed = 0;
661
+ }
662
+ const { count, events } = liveSessionEvents(session, state.consumed ?? 0);
663
+ if ((state.consumed ?? 0) < count) {
664
+ applyUsageDelta(state, events);
665
+ state.consumed = count;
666
+ }
667
+ state.kind = "live";
668
+ cache.sessions[session.id] = state;
669
+ }
670
+ if (!disposed) await saveCache(ctx, cache);
671
+ }).catch(() => {});
672
+ return () => { disposed = true; };
673
+ }, "token-heatmap: initial live fold");
561
674
  ctx.effect(() => ctx.webServer.register({
562
675
  kind: "exact",
563
676
  path: USAGE_PATH,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kidli1412/dsh-token-heatmap",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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",