@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.
- package/README.md +3 -2
- package/lib/index.js +129 -16
- 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
|
|
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`
|
|
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:
|
|
422
|
-
* unchanged (`
|
|
423
|
-
*
|
|
424
|
-
* to be contiguous with the last folded seq — a gap or an empty delta
|
|
425
|
-
* the log was truncated/rewritten, so the session is refolded from
|
|
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
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
502
|
-
|
|
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.
|
|
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",
|