@kidli1412/dsh-token-heatmap 0.1.6 → 0.4.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/lib/config.js CHANGED
@@ -2,8 +2,12 @@
2
2
  * dsh-token-heatmap — config model.
3
3
  *
4
4
  * Pure validation/coercion for the plugin's user-facing settings:
5
- * enabled — master switch for the hero-screen heatmap card (default true)
6
5
  * colorScheme — cell palette name (default "green")
6
+ * defaultView — view the card opens with: "year" | "month" (default "year")
7
+ *
8
+ * The 0.1.x `enabled` master switch was dropped in 0.2.0 (the card is always
9
+ * rendered on the hero screen); a stored `enabled` key is ignored and never
10
+ * rewritten.
7
11
  *
8
12
  * The server half persists the raw JSON document under
9
13
  * `<DSH_HOME>/storages/token-heatmap-config.json` and serves it over the
@@ -16,16 +20,21 @@
16
20
  * server still running older code; the client renders any unknown scheme with
17
21
  * its green fallback. The server bounds only the SHAPE (a short non-blank
18
22
  * string) so a foreign or hand-edited document still degrades to sane values.
23
+ * `defaultView` IS bounded to the known modes: an unknown mode has no
24
+ * renderer to fall back to.
19
25
  *
20
26
  * @module dsh-token-heatmap/config
21
27
  */
22
28
 
23
29
  /** Default configuration (also what a corrupt or absent document resolves to). */
24
- export const DEFAULT_CONFIG = Object.freeze({ enabled: true, colorScheme: "green" });
30
+ export const DEFAULT_CONFIG = Object.freeze({ colorScheme: "green", defaultView: "year" });
25
31
 
26
32
  /** Canonical color schemes, in display order (informational; not enforced). */
27
33
  export const COLOR_SCHEMES = Object.freeze(["green", "blue", "orange", "red", "purple", "teal"]);
28
34
 
35
+ /** Canonical view modes, in display order. */
36
+ export const VIEW_MODES = Object.freeze(["year", "month"]);
37
+
29
38
  /** Upper bound on a stored scheme name; anything longer is treated as junk. */
30
39
  const SCHEME_MAX_LENGTH = 32;
31
40
 
@@ -38,17 +47,21 @@ function isPlainObject(value) {
38
47
  * Validate and coerce one raw config document into the canonical shape.
39
48
  * Unknown fields are dropped; missing or invalid fields fall back to the
40
49
  * default. `colorScheme` is preserved verbatim (any short non-blank string),
41
- * so a newer client's scheme survives an older server between restarts.
50
+ * so a newer client's scheme survives an older server between restarts;
51
+ * `defaultView` is coerced to a known mode instead, and the retired `enabled`
52
+ * key is ignored.
42
53
  * @param raw - parsed JSON document, or undefined for an absent file.
43
54
  * @returns the canonical config.
44
55
  */
45
56
  export function parseConfig(raw) {
46
57
  const config = { ...DEFAULT_CONFIG };
47
58
  if (!isPlainObject(raw)) return config;
48
- if (typeof raw.enabled === "boolean") config.enabled = raw.enabled;
49
59
  if (typeof raw.colorScheme === "string") {
50
60
  const scheme = raw.colorScheme.trim();
51
61
  if (scheme.length > 0 && scheme.length <= SCHEME_MAX_LENGTH) config.colorScheme = scheme;
52
62
  }
63
+ if (typeof raw.defaultView === "string" && VIEW_MODES.includes(raw.defaultView.trim())) {
64
+ config.defaultView = raw.defaultView.trim();
65
+ }
53
66
  return config;
54
67
  }
package/lib/index.js CHANGED
@@ -36,7 +36,7 @@ import { homedir } from "node:os";
36
36
  import { join, dirname } from "node:path";
37
37
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
38
38
  import { applyUsageDelta, createUsageState, mergeInto, renderUsage, zeroBuckets } from "./usage.js";
39
- import { DEFAULT_CONFIG, parseConfig } from "./config.js";
39
+ import { DEFAULT_CONFIG, VIEW_MODES, parseConfig } from "./config.js";
40
40
  import z from "@deepseek-ai/schemastery";
41
41
 
42
42
  /** Stable Cordis plugin name. */
@@ -66,17 +66,26 @@ const SETTINGS_NAMESPACE = "token-heatmap";
66
66
  * validates against. Scheme membership is deliberately NOT enforced (a newer
67
67
  * client may know a palette the server does not — the client falls back to
68
68
  * green); only the same shape bounds parseConfig applies: a short, non-blank
69
- * string.
69
+ * string. `defaultView` is bounded to the rendered view modes (year/month).
70
+ *
71
+ * The 0.1.x `enabled` master switch is gone: the card is always rendered on
72
+ * the hero screen, so the field is neither accepted nor served (an old
73
+ * settings.yaml keeps its dead `enabled` key, and nothing reads it).
70
74
  */
71
75
  const TokenHeatmapSettingsSchema = z.object({
72
- enabled: z.boolean().default(true),
73
- colorScheme: z.string().min(1).max(32).default("green")
76
+ colorScheme: z.string().min(1).max(32).default("green"),
77
+ defaultView: z.union(VIEW_MODES.map((mode) => z.const(mode))).default("year")
74
78
  });
75
79
  //#endregion
76
80
 
77
81
  const USAGE_PATH = "/api/token-heatmap/usage";
78
82
  const CONFIG_PATH = "/api/token-heatmap/config";
79
- const CACHE_VERSION = 1;
83
+ /**
84
+ * Cache format version. 2 folds every session from its fork cut (see
85
+ * `forkCutOf`): a version-1 cache may hold a forked child's inherited prefix —
86
+ * the parent's tokens, already folded under the parent — so it is rebuilt.
87
+ */
88
+ const CACHE_VERSION = 2;
80
89
 
81
90
  /** Write a JSON response. */
82
91
  function json(res, status, value) {
@@ -198,6 +207,7 @@ function serializeSession(state) {
198
207
  return {
199
208
  kind: state.kind ?? "persisted",
200
209
  consumed: state.consumed ?? 0,
210
+ ...(state.skipUntil === void 0 || state.skipUntil === 0 ? {} : { skipUntil: state.skipUntil }),
201
211
  ...(state.revision === void 0 ? {} : { revision: state.revision }),
202
212
  days,
203
213
  lastSample: state.lastSample === null ? null : {
@@ -216,6 +226,9 @@ function parseSession(raw) {
216
226
  if (raw === null || typeof raw !== "object") return state;
217
227
  state.kind = typeof raw.kind === "string" ? raw.kind : "persisted";
218
228
  state.consumed = Number.isSafeInteger(raw.consumed) ? raw.consumed : 0;
229
+ // Missing in a hand-edited or legacy entry: 0 means "no fork cut known",
230
+ // which only re-folds the inherited prefix once, on the next pass.
231
+ state.skipUntil = Number.isSafeInteger(raw.skipUntil) && raw.skipUntil > 0 ? raw.skipUntil : 0;
219
232
  if (typeof raw.revision === "string") state.revision = raw.revision;
220
233
  if (raw.days !== null && typeof raw.days === "object") {
221
234
  for (const [date, entry] of Object.entries(raw.days)) {
@@ -346,8 +359,10 @@ async function migrateLegacyConfig(ctx) {
346
359
  const userExists = descriptor !== void 0 && descriptor.user !== void 0;
347
360
  if (!userExists) {
348
361
  const patch = {};
349
- if (legacy.enabled !== DEFAULT_CONFIG.enabled) patch.enabled = legacy.enabled;
362
+ // The legacy document's `enabled` switch died with 0.2.0: import only
363
+ // what the card still owns.
350
364
  if (legacy.colorScheme !== DEFAULT_CONFIG.colorScheme) patch.colorScheme = legacy.colorScheme;
365
+ if (legacy.defaultView !== DEFAULT_CONFIG.defaultView) patch.defaultView = legacy.defaultView;
351
366
  if (Object.keys(patch).length > 0) await ctx.settings.update(SETTINGS_NAMESPACE, patch);
352
367
  }
353
368
  await rm(path, { force: true });
@@ -356,10 +371,17 @@ async function migrateLegacyConfig(ctx) {
356
371
  }
357
372
  }
358
373
 
359
- /** Serve the resolved settings section (schema defaults + user layer). */
374
+ /**
375
+ * Serve the resolved settings section (schema defaults + user layer).
376
+ * `enabled` stays in the payload as a constant true: the legacy loopback
377
+ * endpoint is a back-compat API, and a pre-0.2.0 client that reads it must not
378
+ * lose the card over a switch this version no longer has.
379
+ */
360
380
  function serveConfig(ctx) {
361
381
  const section = ctx.settings.get(SETTINGS_NAMESPACE);
362
- return { enabled: section?.enabled !== false, colorScheme: typeof section?.colorScheme === "string" && section.colorScheme.length > 0 ? section.colorScheme : DEFAULT_CONFIG.colorScheme };
382
+ const scheme = typeof section?.colorScheme === "string" && section.colorScheme.length > 0 ? section.colorScheme : DEFAULT_CONFIG.colorScheme;
383
+ const view = typeof section?.defaultView === "string" && VIEW_MODES.includes(section.defaultView) ? section.defaultView : DEFAULT_CONFIG.defaultView;
384
+ return { enabled: true, colorScheme: scheme, defaultView: view };
363
385
  }
364
386
 
365
387
  async function handleConfig(ctx, req, res) {
@@ -414,6 +436,60 @@ function liveSessionEvents(session, from) {
414
436
  return { count: session.seq, events };
415
437
  }
416
438
 
439
+ /**
440
+ * Fork-inherited prefix length of a live session: the number of leading
441
+ * events copied from its parent at fork time. Those events carry the PARENT's
442
+ * usage and are folded when the parent is folded, so a seeded child must skip
443
+ * them or the same tokens are counted twice (measured 2026-09-14: 13.15e8
444
+ * instead of 8.34e8).
445
+ *
446
+ * `header.isSeeded` marks fork lineage and `inheritedEventCount` is the exact
447
+ * cut (both are official Session state on 0.1.2+, the cut is what
448
+ * `Session.ownEvents()` starts from). `isSeeded` alone is NOT enough: a
449
+ * resumed session also carries a constructor seed, but that seed is its OWN
450
+ * history and must be folded in full.
451
+ * @returns the inherited prefix length, or 0 when the session is not a fork.
452
+ */
453
+ function forkCutOf(session) {
454
+ if (session === null || typeof session !== "object") return 0;
455
+ if (session.header?.isSeeded !== true) return 0;
456
+ const inherited = session.inheritedEventCount;
457
+ return typeof inherited === "number" && Number.isSafeInteger(inherited) && inherited > 0 ? inherited : 0;
458
+ }
459
+
460
+ /**
461
+ * Fork-inherited prefix length read from a STORED event log. DSH projects the
462
+ * in-process `inheritedEventCount` into the log as the LAST `session/end-seed`
463
+ * event carrying `{ inherited: true }` (an UNMARKED `session/end-seed` is a
464
+ * compaction boundary, not a fork cut), so that event's seq + 1 is the cut.
465
+ * This mirrors `dsh-session-format-v2-to-v3`, which derives the restored cut
466
+ * from that same marker.
467
+ * @returns the inherited prefix length, or 0 when the log carries no fork cut.
468
+ */
469
+ function forkCutOfEvents(events) {
470
+ let cut = 0;
471
+ for (const event of events) {
472
+ if (event?.type === "session/end-seed" && event.data?.inherited === true && typeof event.seq === "number") {
473
+ cut = Math.max(cut, event.seq + 1);
474
+ }
475
+ }
476
+ return cut;
477
+ }
478
+
479
+ /** First seq a session's fold may read: its fork cut, never past its cursor. */
480
+ function liveFoldFrom(consumed, cut) {
481
+ return Math.max(consumed, cut);
482
+ }
483
+
484
+ /** Drop a session's folded days so the next fold starts at its fork cut. */
485
+ function resetFold(state, cut) {
486
+ state.days = new Map();
487
+ state.lastSample = null;
488
+ state.currentModel = null;
489
+ state.consumed = 0;
490
+ state.skipUntil = cut;
491
+ }
492
+
417
493
  /**
418
494
  * Read one stored session's events from `fromSeq` onward, across the two
419
495
  * `sessionPersistence` generations this plugin declares compatibility with:
@@ -458,6 +534,12 @@ async function readSessionEvents(persistence, id, fromSeq) {
458
534
  * this plugin was first adapted to) cannot be read at all: its already-folded
459
535
  * days are kept untouched and the session/event listener covers everything
460
536
  * appended from now on.
537
+ *
538
+ * FORKED sessions (`header.isSeeded`) start their log with a copy of the
539
+ * parent's events. That prefix belongs to the parent and is folded there, so
540
+ * both the live and the stored path fold from the session's fork cut
541
+ * (`skipUntil`) instead of seq 0; discovering or moving a cut refolds the
542
+ * session from it.
461
543
  */
462
544
  export async function collectUsage(ctx) {
463
545
  return withLock(async () => {
@@ -473,15 +555,16 @@ export async function collectUsage(ctx) {
473
555
  for (const session of live.list()) {
474
556
  attached.add(session.id);
475
557
  const state = cache.sessions[session.id] ?? createUsageState();
476
- if (state.kind !== "live") {
477
- // Live/persisted transition: refold the whole in-memory log.
478
- state.days = new Map();
479
- state.lastSample = null;
480
- state.currentModel = null;
481
- state.consumed = 0;
558
+ const cut = forkCutOf(session);
559
+ if (state.kind !== "live" || (state.skipUntil ?? 0) !== cut) {
560
+ // Live/persisted transition, or the fork cut was (re)discovered:
561
+ // refold from the cut. The inherited prefix stays folded under
562
+ // the parent, so starting at the cut avoids double counting it.
563
+ resetFold(state, cut);
482
564
  }
483
- const { count, events } = liveSessionEvents(session, state.consumed ?? 0);
484
- if ((state.consumed ?? 0) < count) {
565
+ const from = liveFoldFrom(state.consumed ?? 0, cut);
566
+ const { count, events } = liveSessionEvents(session, from);
567
+ if (from < count) {
485
568
  applyUsageDelta(state, events);
486
569
  state.consumed = count;
487
570
  }
@@ -537,23 +620,33 @@ export async function collectUsage(ctx) {
537
620
  state.currentModel = null;
538
621
  state.consumed = 0;
539
622
  }
623
+ // Fork-inherited prefix: discovered from the log on the first
624
+ // read, reused from the cache afterwards (both read paths yield
625
+ // the whole log, so the marker is always in view).
626
+ const cut = wasPersisted ? (state.skipUntil ?? 0) : forkCutOfEvents(events);
627
+ const start = Math.max(fromSeq, cut);
628
+ if (!wasPersisted || start !== fromSeq) resetFold(state, cut);
540
629
  // `consumed` is the seq of the last folded event; both read paths
541
630
  // yield events carrying their own seq, so the delta filter and the
542
631
  // contiguity check below stay index-agnostic.
543
- const fresh = wasPersisted ? events.filter((event) => event.seq > (state.consumed ?? 0)) : events;
544
- const contiguous = fresh.length === 0 ? state.consumed === 0 : fresh[0].seq === state.consumed + 1;
545
- if (!contiguous && state.consumed > 0) {
546
- // Log truncated or rewritten: refold the whole log.
547
- state.days = new Map();
548
- state.lastSample = null;
549
- state.currentModel = null;
550
- state.consumed = 0;
551
- applyUsageDelta(state, events);
552
- state.consumed = events.length > 0 ? events[events.length - 1].seq : 0;
632
+ const fresh = events.filter((event) => typeof event.seq === "number" && event.seq >= Math.max(state.consumed ?? 0, cut));
633
+ const contiguous = fresh.length === 0
634
+ ? (state.consumed ?? 0) <= cut
635
+ : fresh[0].seq === (state.consumed ?? 0) + 1 || fresh[0].seq === cut;
636
+ if (!contiguous && (state.consumed ?? 0) > cut) {
637
+ // Log truncated or rewritten: refold the whole log from the cut.
638
+ resetFold(state, cut);
639
+ const { events: allEvents } = await readSessionEvents(persistence, meta.id, cut) ?? { events: [] };
640
+ const owned = allEvents.filter((event) => typeof event.seq === "number" && event.seq >= cut);
641
+ applyUsageDelta(state, owned);
642
+ state.consumed = owned.length > 0 ? owned[owned.length - 1].seq : cut;
553
643
  } else if (fresh.length > 0) {
554
644
  applyUsageDelta(state, fresh);
555
645
  state.consumed = fresh[fresh.length - 1].seq;
646
+ } else {
647
+ state.consumed = Math.max(state.consumed ?? 0, cut);
556
648
  }
649
+ state.skipUntil = cut;
557
650
  state.kind = "persisted";
558
651
  if (revision !== void 0) state.revision = revision;
559
652
  } catch (error) {
@@ -621,8 +714,14 @@ function apply(ctx) {
621
714
  loadCache().then((cache) => {
622
715
  if (disposed) return;
623
716
  const state = cache.sessions[session.id] ?? createUsageState();
717
+ const cut = forkCutOf(session);
718
+ if ((state.skipUntil ?? 0) !== cut) state.skipUntil = cut;
624
719
  const seq = typeof event.seq === "number" ? event.seq : void 0;
625
- if (seq !== void 0 && seq < (state.consumed ?? 0)) return;
720
+ // Constructor seeds (a fork's inherited prefix, or a resume's own
721
+ // stored log) are never published here, so a live event at or after
722
+ // the cut is the only thing this feed carries; the guard keeps a
723
+ // stray replay of the parent's prefix from re-counting it.
724
+ if (seq !== void 0 && seq < liveFoldFrom(state.consumed ?? 0, cut)) return;
626
725
  applyUsageDelta(state, [event]);
627
726
  if (seq !== void 0) state.consumed = Math.max(state.consumed ?? 0, seq + 1);
628
727
  state.kind = "live";
@@ -653,14 +752,11 @@ function apply(ctx) {
653
752
  for (const session of sessions.list()) {
654
753
  if (disposed) return;
655
754
  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) {
755
+ const cut = forkCutOf(session);
756
+ if (state.kind !== "live" || (state.skipUntil ?? 0) !== cut) resetFold(state, cut);
757
+ const from = liveFoldFrom(state.consumed ?? 0, cut);
758
+ const { count, events } = liveSessionEvents(session, from);
759
+ if (from < count) {
664
760
  applyUsageDelta(state, events);
665
761
  state.consumed = count;
666
762
  }
package/lib/usage.js CHANGED
@@ -131,14 +131,18 @@ function entryOf(byDay, day) {
131
131
  * One session's incremental fold state. `days` holds the already-folded
132
132
  * per-day entries; `lastSample`/`currentModel` let a later event slice keep
133
133
  * the replace-last-sample semantics and model attribution across fold
134
- * boundaries without replaying the whole log.
134
+ * boundaries without replaying the whole log. `skipUntil` is the session's
135
+ * fork-inherited prefix length — the events before it are the fork PARENT's
136
+ * usage, folded when the parent is folded, so folding starts there and the
137
+ * same tokens are never counted twice. 0 for a session that is not a fork.
135
138
  */
136
139
  export function createUsageState() {
137
140
  return {
138
141
  days: new Map(),
139
142
  lastSample: null,
140
143
  currentModel: null,
141
- consumed: 0
144
+ consumed: 0,
145
+ skipUntil: 0
142
146
  };
143
147
  }
144
148
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kidli1412/dsh-token-heatmap",
3
- "version": "0.1.6",
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.",
3
+ "version": "0.4.2",
4
+ "description": "DSH web plugin: GitHub-style daily token-usage heatmap on the new-session screen with switchable year/month views and six color schemes. The card configures itself through a floating ⚙ panel (palette + default view) — no DSH settings card — and shows today / this-month / all-time totals.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {