@kidli1412/dsh-token-heatmap 0.1.5 → 0.4.1

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)) {
@@ -291,7 +304,7 @@ async function saveCache(ctx, cache) {
291
304
  await mkdir(dirname(path), { recursive: true });
292
305
  const serialized = { version: CACHE_VERSION, sessions: {} };
293
306
  for (const [id, state] of Object.entries(cache.sessions)) serialized.sessions[id] = serializeSession(state);
294
- const tmp = `${path}.tmp`;
307
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
295
308
  await writeFile(tmp, JSON.stringify(serialized), "utf8");
296
309
  await rename(tmp, path);
297
310
  } catch (error) {
@@ -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,23 +436,118 @@ 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
+
493
+ /**
494
+ * Read one stored session's events from `fromSeq` onward, across the two
495
+ * `sessionPersistence` generations this plugin declares compatibility with:
496
+ *
497
+ * - 0.1.0-rc.8 … 0.1.2-rc.1 expose `readFrom(id, fromSeq)`;
498
+ * - 0.1.3-alpha.2 and later dropped `readFrom`/`listSnapshots` in favour of
499
+ * `open(id, "read")` + `handle.read()`, which returns the whole log — the
500
+ * caller's `seq`-based delta filter makes the extra prefix harmless.
501
+ *
502
+ * @returns `{ events }` in seq order, or `null` when the backend offers
503
+ * neither read path (callers must not treat that as an empty log).
504
+ */
505
+ async function readSessionEvents(persistence, id, fromSeq) {
506
+ if (typeof persistence.readFrom === "function") {
507
+ const { events } = await persistence.readFrom(id, fromSeq);
508
+ return { events };
509
+ }
510
+ if (typeof persistence.open === "function") {
511
+ const handle = await persistence.open(id, "read");
512
+ try {
513
+ const { events } = await handle.read();
514
+ return { events };
515
+ } finally {
516
+ if (typeof handle.close === "function") await handle.close();
517
+ }
518
+ }
519
+ return null;
520
+ }
521
+
417
522
  /**
418
523
  * Collect per-day usage across live and persisted sessions, incrementally.
419
524
  *
420
525
  * 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
526
+ * Persisted sessions: enumerated through `sessionPersistence` and skipped when
527
+ * the backend's opaque revision is unchanged (`listSnapshots` on the 0.1.2
528
+ * train, `list()` on 0.1.3+); when the revision changes, the new events are
529
+ * verified to be contiguous with the last folded seq — a gap or an empty delta
530
+ * means the log was truncated/rewritten, so the session is refolded from
531
+ * scratch. Sessions that vanished are dropped, and a session switching between
427
532
  * 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.
533
+ * A backend that offers neither `readFrom()` nor `open()` (the 0.1.2+ shape
534
+ * this plugin was first adapted to) cannot be read at all: its already-folded
535
+ * days are kept untouched and the session/event listener covers everything
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.
431
543
  */
432
544
  export async function collectUsage(ctx) {
433
545
  return withLock(async () => {
546
+ // Primary: incremental session-event fold. The session/event listener
547
+ // (see apply) folds live sessions in real time regardless of hero-screen
548
+ // mounting; this request-time fold is a sync point that catches anything
549
+ // the listener has not yet reached. Both share the per-session `consumed`
550
+ // cursor, so they never double count.
434
551
  const cache = await loadCache();
435
552
  const live = ctx.get("sessions");
436
553
  const attached = new Set();
@@ -438,15 +555,16 @@ export async function collectUsage(ctx) {
438
555
  for (const session of live.list()) {
439
556
  attached.add(session.id);
440
557
  const state = cache.sessions[session.id] ?? createUsageState();
441
- if (state.kind !== "live") {
442
- // Live/persisted transition: refold the whole in-memory log.
443
- state.days = new Map();
444
- state.lastSample = null;
445
- state.currentModel = null;
446
- 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);
447
564
  }
448
- const { count, events } = liveSessionEvents(session, state.consumed ?? 0);
449
- if ((state.consumed ?? 0) < count) {
565
+ const from = liveFoldFrom(state.consumed ?? 0, cut);
566
+ const { count, events } = liveSessionEvents(session, from);
567
+ if (from < count) {
450
568
  applyUsageDelta(state, events);
451
569
  state.consumed = count;
452
570
  }
@@ -461,7 +579,8 @@ export async function collectUsage(ctx) {
461
579
  );
462
580
  if (canEnumeratePersisted) {
463
581
  // Prefer the backend's opaque per-log revisions (no file I/O in the
464
- // plugin, works for any backend that exposes listSnapshots).
582
+ // plugin): `listSnapshots` is the 0.1.2 spelling, `list()` returns
583
+ // the same snapshots on 0.1.3+.
465
584
  let snapshots = null;
466
585
  if (typeof persistence.listSnapshots === "function") {
467
586
  try {
@@ -470,6 +589,7 @@ export async function collectUsage(ctx) {
470
589
  ctx.logger.warn(`token-heatmap: listSnapshots failed, falling back to list(): ${String(error)}`);
471
590
  }
472
591
  }
592
+ if (snapshots === null && typeof persistence.list === "function") snapshots = await persistence.list();
473
593
  const metas = snapshots !== null ? snapshots.map((entry) => entry.header) : await persistence.list();
474
594
  const revisionOf = new Map();
475
595
  if (snapshots !== null) for (const entry of snapshots) revisionOf.set(entry.header.id, entry.revision);
@@ -478,33 +598,55 @@ export async function collectUsage(ctx) {
478
598
  if (attached.has(meta.id)) continue;
479
599
  const state = cache.sessions[meta.id] ?? createUsageState();
480
600
  const revision = revisionOf.get(meta.id);
481
- const changed = state.kind !== "persisted" || (revision !== void 0 && revision !== state.revision) || revision === void 0;
601
+ // A revision-less backend must be read every pass; with revisions,
602
+ // an unchanged log is skipped entirely.
603
+ const changed = state.kind !== "persisted" || revision === void 0 || revision !== state.revision;
482
604
  if (changed) {
483
605
  try {
484
606
  const wasPersisted = state.kind === "persisted";
485
607
  const fromSeq = wasPersisted ? state.consumed : 0;
486
- const { events } = await persistence.readFrom(meta.id, fromSeq);
608
+ const read = await readSessionEvents(persistence, meta.id, fromSeq);
609
+ if (read === null) {
610
+ // No read path on this backend: keep the folded days as they
611
+ // are rather than reporting an empty log as a rewrite.
612
+ ctx.logger.warn(`token-heatmap: sessionPersistence exposes neither readFrom() nor open() on this DSH; stored session "${meta.id}" cannot be read`);
613
+ cache.sessions[meta.id] = state;
614
+ continue;
615
+ }
616
+ const events = read.events;
487
617
  if (!wasPersisted) {
488
618
  state.days = new Map();
489
619
  state.lastSample = null;
490
620
  state.currentModel = null;
491
621
  state.consumed = 0;
492
622
  }
493
- const fresh = wasPersisted ? events.filter((event) => event.seq > (state.consumed ?? 0)) : events;
494
- const contiguous = fresh.length === 0 ? state.consumed === 0 : fresh[0].seq === state.consumed + 1;
495
- if (!contiguous && state.consumed > 0) {
496
- // Log truncated or rewritten: refold the whole log.
497
- state.days = new Map();
498
- state.lastSample = null;
499
- state.currentModel = null;
500
- 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;
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);
629
+ // `consumed` is the seq of the last folded event; both read paths
630
+ // yield events carrying their own seq, so the delta filter and the
631
+ // contiguity check below stay index-agnostic.
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;
504
643
  } else if (fresh.length > 0) {
505
644
  applyUsageDelta(state, fresh);
506
645
  state.consumed = fresh[fresh.length - 1].seq;
646
+ } else {
647
+ state.consumed = Math.max(state.consumed ?? 0, cut);
507
648
  }
649
+ state.skipUntil = cut;
508
650
  state.kind = "persisted";
509
651
  if (revision !== void 0) state.revision = revision;
510
652
  } catch (error) {
@@ -558,6 +700,73 @@ function apply(ctx) {
558
700
  // Best-effort, fire-and-forget: import the pre-0.1.2 config document into
559
701
  // the namespace and drop the file (see migrateLegacyConfig).
560
702
  migrateLegacyConfig(ctx);
703
+ // Real-time fold: listen to session/event and fold each usage event into
704
+ // the cache immediately, so live session usage is captured regardless of
705
+ // whether the hero screen is mounted (the client polls the usage endpoint
706
+ // only there) and regardless of what sessionPersistence can enumerate —
707
+ // this is the path that keeps counting on a host whose stored logs are
708
+ // unreachable. The request-time fold in collectUsage stays as a sync point;
709
+ // both share the per-session `consumed` cursor so they never double count.
710
+ if (typeof ctx.on === "function") ctx.effect(() => {
711
+ let saveTimer = null;
712
+ let disposed = false;
713
+ const disposer = ctx.on("session/event", (session, event) => {
714
+ loadCache().then((cache) => {
715
+ if (disposed) return;
716
+ const state = cache.sessions[session.id] ?? createUsageState();
717
+ const cut = forkCutOf(session);
718
+ if ((state.skipUntil ?? 0) !== cut) state.skipUntil = cut;
719
+ const seq = typeof event.seq === "number" ? event.seq : void 0;
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;
725
+ applyUsageDelta(state, [event]);
726
+ if (seq !== void 0) state.consumed = Math.max(state.consumed ?? 0, seq + 1);
727
+ state.kind = "live";
728
+ cache.sessions[session.id] = state;
729
+ if (saveTimer === null) {
730
+ saveTimer = setTimeout(() => {
731
+ saveTimer = null;
732
+ saveCache(ctx, cache).catch(() => {});
733
+ }, 2000);
734
+ }
735
+ }).catch(() => {});
736
+ });
737
+ return () => {
738
+ disposed = true;
739
+ if (saveTimer !== null) clearTimeout(saveTimer);
740
+ if (typeof disposer === "function") disposer();
741
+ };
742
+ }, "token-heatmap: session/event fold");
743
+ // Initial fold of live sessions that existed before this plugin loaded
744
+ // (e.g. resumed sessions): fold their in-memory tail from the last cursor
745
+ // so the heatmap has history before the first session/event arrives.
746
+ ctx.effect(() => {
747
+ let disposed = false;
748
+ loadCache().then(async (cache) => {
749
+ if (disposed) return;
750
+ const sessions = typeof ctx.get === "function" ? ctx.get("sessions") : void 0;
751
+ if (sessions === void 0) return;
752
+ for (const session of sessions.list()) {
753
+ if (disposed) return;
754
+ const state = cache.sessions[session.id] ?? createUsageState();
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) {
760
+ applyUsageDelta(state, events);
761
+ state.consumed = count;
762
+ }
763
+ state.kind = "live";
764
+ cache.sessions[session.id] = state;
765
+ }
766
+ if (!disposed) await saveCache(ctx, cache);
767
+ }).catch(() => {});
768
+ return () => { disposed = true; };
769
+ }, "token-heatmap: initial live fold");
561
770
  ctx.effect(() => ctx.webServer.register({
562
771
  kind: "exact",
563
772
  path: USAGE_PATH,
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.5",
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.1",
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 (⚙ 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": {