@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/README.md +38 -13
- package/docs//345/215/241/347/211/207/350/256/276/347/275/256/351/235/242/346/235/277.jpg +0 -0
- package/docs//345/271/264/350/247/206/345/233/276.jpg +0 -0
- package/docs//346/234/210/350/247/206/345/233/276.jpg +0 -0
- package/docs//351/242/204/350/247/210-/346/226/260/347/211/210/344/274/232/350/257/235/351/241/265.jpg +0 -0
- package/lib/client.js +505 -370
- package/lib/config.js +17 -4
- package/lib/index.js +249 -40
- package/lib/usage.js +6 -2
- package/package.json +2 -2
- package/docs//347/203/255/345/212/233/345/233/276.jpg +0 -0
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({
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
state
|
|
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
|
|
449
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
4
|
-
"description": "DSH web plugin: GitHub-style daily token-usage heatmap on the new-session screen with
|
|
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": {
|
|
Binary file
|