@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/README.md +37 -12
- 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 +614 -363
- package/lib/config.js +17 -4
- package/lib/index.js +131 -35
- 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)) {
|
|
@@ -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,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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
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);
|
|
482
564
|
}
|
|
483
|
-
const
|
|
484
|
-
|
|
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 =
|
|
544
|
-
const contiguous = fresh.length === 0
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
state
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
state
|
|
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
|
-
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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.
|
|
4
|
-
"description": "DSH web plugin: GitHub-style daily token-usage heatmap on the new-session screen with
|
|
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": {
|
|
Binary file
|