@itookit/dsht 0.2.4 → 0.3.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.
Files changed (129) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +54 -25
  3. package/README.zh.md +54 -25
  4. package/dist/catalog/controller.d.ts +32 -0
  5. package/dist/catalog/controller.js +88 -0
  6. package/dist/catalog/index.d.ts +2 -0
  7. package/dist/catalog/index.js +2 -0
  8. package/dist/{cli.js → cli/index.js} +40 -24
  9. package/dist/controller/connection.d.ts +80 -0
  10. package/dist/controller/connection.js +190 -0
  11. package/dist/controller/controller.d.ts +260 -0
  12. package/dist/controller/controller.js +362 -0
  13. package/dist/controller/index.d.ts +5 -0
  14. package/dist/controller/index.js +3 -0
  15. package/dist/controller/memory-log.d.ts +35 -0
  16. package/dist/controller/memory-log.js +95 -0
  17. package/dist/cost/controller.d.ts +34 -0
  18. package/dist/cost/controller.js +107 -0
  19. package/dist/cost/index.d.ts +9 -0
  20. package/dist/cost/index.js +7 -0
  21. package/dist/cost/ledger-files.d.ts +20 -0
  22. package/dist/cost/ledger-files.js +128 -0
  23. package/dist/cost/ledger.d.ts +65 -0
  24. package/dist/cost/ledger.js +136 -0
  25. package/dist/cost/pricing.d.ts +48 -0
  26. package/dist/cost/pricing.js +141 -0
  27. package/dist/cost/records.d.ts +17 -0
  28. package/dist/cost/records.js +66 -0
  29. package/dist/cost/scanner.d.ts +22 -0
  30. package/dist/cost/scanner.js +95 -0
  31. package/dist/cost/types.d.ts +69 -0
  32. package/dist/cost/types.js +3 -0
  33. package/dist/index.d.ts +3 -0
  34. package/dist/index.js +2 -0
  35. package/dist/session/connection-view.d.ts +18 -0
  36. package/dist/session/connection-view.js +1 -0
  37. package/dist/{controller.d.ts → session/controller.d.ts} +119 -137
  38. package/dist/session/controller.js +616 -0
  39. package/dist/session/export-html.d.ts +9 -0
  40. package/dist/session/export-html.js +39 -0
  41. package/dist/session/export.d.ts +9 -0
  42. package/dist/session/export.js +38 -0
  43. package/dist/{history.d.ts → session/history.d.ts} +18 -1
  44. package/dist/session/history.js +338 -0
  45. package/dist/session/index.d.ts +17 -0
  46. package/dist/session/index.js +10 -0
  47. package/dist/session/markdown.d.ts +39 -0
  48. package/dist/session/markdown.js +255 -0
  49. package/dist/session/math.d.ts +11 -0
  50. package/dist/session/math.js +82 -0
  51. package/dist/{navigation.d.ts → session/navigation.d.ts} +1 -1
  52. package/dist/{navigation.js → session/navigation.js} +1 -1
  53. package/dist/{references.js → session/references.js} +1 -1
  54. package/dist/{telemetry.d.ts → session/telemetry.d.ts} +12 -1
  55. package/dist/{telemetry.js → session/telemetry.js} +18 -4
  56. package/dist/{transcript.d.ts → session/transcript.d.ts} +23 -1
  57. package/dist/{transcript.js → session/transcript.js} +36 -18
  58. package/dist/session/types.d.ts +18 -0
  59. package/dist/session/types.js +2 -0
  60. package/dist/state.d.ts +41 -0
  61. package/dist/state.js +9 -0
  62. package/dist/storage/directories.d.ts +14 -0
  63. package/dist/storage/directories.js +24 -0
  64. package/dist/storage/files.d.ts +43 -0
  65. package/dist/storage/files.js +135 -0
  66. package/dist/storage/index.d.ts +3 -0
  67. package/dist/storage/index.js +3 -0
  68. package/dist/transport/auth.js +65 -0
  69. package/dist/{client.d.ts → transport/client.d.ts} +8 -1
  70. package/dist/{client.js → transport/client.js} +21 -4
  71. package/dist/transport/host.d.ts +13 -0
  72. package/dist/transport/host.js +1 -0
  73. package/dist/ui/app.d.ts +11 -0
  74. package/dist/ui/app.js +726 -0
  75. package/dist/ui/chat/header.d.ts +11 -0
  76. package/dist/ui/chat/header.js +14 -0
  77. package/dist/{history-view.d.ts → ui/chat/history-view.d.ts} +1 -1
  78. package/dist/{history-view.js → ui/chat/history-view.js} +3 -3
  79. package/dist/ui/chat/status.d.ts +79 -0
  80. package/dist/ui/chat/status.js +311 -0
  81. package/dist/ui/chat/viewport.d.ts +17 -0
  82. package/dist/ui/chat/viewport.js +14 -0
  83. package/dist/ui/commands/parse.d.ts +96 -0
  84. package/dist/ui/commands/parse.js +121 -0
  85. package/dist/ui/commands/registry.d.ts +33 -0
  86. package/dist/ui/commands/registry.js +72 -0
  87. package/dist/ui/copy-mode.d.ts +4 -0
  88. package/dist/ui/copy-mode.js +6 -0
  89. package/dist/{cost-view.d.ts → ui/dialogs/cost.d.ts} +1 -1
  90. package/dist/{cost-view.js → ui/dialogs/cost.js} +3 -3
  91. package/dist/ui/dialogs/index.d.ts +120 -0
  92. package/dist/ui/dialogs/index.js +113 -0
  93. package/dist/ui/dialogs/picker.d.ts +18 -0
  94. package/dist/ui/dialogs/picker.js +38 -0
  95. package/dist/ui/frozen.d.ts +8 -0
  96. package/dist/ui/frozen.js +7 -0
  97. package/dist/ui/input/references.d.ts +12 -0
  98. package/dist/ui/input/references.js +15 -0
  99. package/dist/ui/mount.d.ts +6 -0
  100. package/dist/ui/mount.js +11 -0
  101. package/dist/{theme.d.ts → ui/theme/index.d.ts} +1 -1
  102. package/dsht-m.png +0 -0
  103. package/package.json +20 -14
  104. package/dist/app.d.ts +0 -21
  105. package/dist/app.js +0 -746
  106. package/dist/auth.js +0 -108
  107. package/dist/controller.js +0 -923
  108. package/dist/cost.d.ts +0 -119
  109. package/dist/cost.js +0 -313
  110. package/dist/history.js +0 -177
  111. package/dist/status.d.ts +0 -28
  112. package/dist/status.js +0 -157
  113. package/dsht.png +0 -0
  114. /package/dist/{cli.d.ts → cli/index.d.ts} +0 -0
  115. /package/dist/{memory.d.ts → session/memory.d.ts} +0 -0
  116. /package/dist/{memory.js → session/memory.js} +0 -0
  117. /package/dist/{references.d.ts → session/references.d.ts} +0 -0
  118. /package/dist/{auth.d.ts → transport/auth.d.ts} +0 -0
  119. /package/dist/{endpoint.d.ts → transport/endpoint.d.ts} +0 -0
  120. /package/dist/{endpoint.js → transport/endpoint.js} +0 -0
  121. /package/dist/{wire.d.ts → transport/wire.d.ts} +0 -0
  122. /package/dist/{wire.js → transport/wire.js} +0 -0
  123. /package/dist/{input-history.d.ts → ui/input/history.d.ts} +0 -0
  124. /package/dist/{input-history.js → ui/input/history.js} +0 -0
  125. /package/dist/{input.d.ts → ui/input/input.d.ts} +0 -0
  126. /package/dist/{input.js → ui/input/input.js} +0 -0
  127. /package/dist/{mouse.d.ts → ui/input/mouse.d.ts} +0 -0
  128. /package/dist/{mouse.js → ui/input/mouse.js} +0 -0
  129. /package/dist/{theme.js → ui/theme/index.js} +0 -0
@@ -0,0 +1,95 @@
1
+ /** Runtime memory samples appended to one bounded local file for diagnosing growth over a long run. */
2
+ import { appendPrivateFile, createPrivateFile, writePrivateFile } from "../storage/index.js";
3
+ import { errorText } from "../transport/wire.js";
4
+ /** Time between automatic samples. */
5
+ const SAMPLE_INTERVAL_MS = 30_000;
6
+ /** Samples kept; reaching the cap rewrites the file with just these lines. */
7
+ const KEEP_SAMPLES = 1000;
8
+ /** First line of the file, so an empty or rewritten log still explains itself. */
9
+ const HEADER = '# dsht memory samples: one JSON object per line, oldest first\n';
10
+ /** Append-only memory log; the file never exceeds twice `KEEP_SAMPLES` lines.
11
+ *
12
+ * The log exists to answer "is retained content growing, or is this just V8's high-water mark",
13
+ * so each sample records the process counters next to the client's retained window and the
14
+ * controller state that decides whether that window is being reclaimed. Samples contain counts
15
+ * and sizes only, never prompt, tool, or session text. A failing log stops itself instead of
16
+ * breaking the client.
17
+ */
18
+ export class MemoryLog {
19
+ path;
20
+ sampleSource;
21
+ timer;
22
+ appended = [];
23
+ writing = false;
24
+ /** Last write failure, when the log stopped itself. */
25
+ error;
26
+ constructor(path, sampleSource) {
27
+ this.path = path;
28
+ this.sampleSource = sampleSource;
29
+ }
30
+ /** Write the header into a new file, then sample once; a missing or unwritable path is reported
31
+ * by that first sample instead of here.
32
+ */
33
+ async begin() {
34
+ if (this.path === undefined)
35
+ return;
36
+ try {
37
+ await createPrivateFile(this.path, HEADER);
38
+ }
39
+ catch { /* the sample below reports it */ }
40
+ await this.sample();
41
+ }
42
+ /** Sample once now and then every `SAMPLE_INTERVAL_MS`; absent when no path was configured. */
43
+ start() {
44
+ if (this.path === undefined || this.timer !== undefined)
45
+ return;
46
+ void this.begin();
47
+ this.timer = setInterval(() => { void this.sample(); }, SAMPLE_INTERVAL_MS);
48
+ }
49
+ /** Stop sampling and rewrite the file with its retained window. */
50
+ async stop() {
51
+ clearInterval(this.timer);
52
+ this.timer = undefined;
53
+ await this.compact();
54
+ }
55
+ /** Append one sample line, skipping overlapping calls.
56
+ *
57
+ * A repeated failure would be noise, so the first one stops the timer and leaves the client
58
+ * running; the log is diagnostics, not a feature the terminal depends on.
59
+ */
60
+ async sample() {
61
+ if (this.path === undefined || this.writing)
62
+ return;
63
+ this.writing = true;
64
+ try {
65
+ const line = `${JSON.stringify(this.sampleSource())}\n`;
66
+ await appendPrivateFile(this.path, line);
67
+ this.appended.push(line);
68
+ this.error = undefined;
69
+ if (this.appended.length >= KEEP_SAMPLES)
70
+ await this.compact();
71
+ }
72
+ catch (error) {
73
+ this.error = errorText(error);
74
+ clearInterval(this.timer);
75
+ this.timer = undefined;
76
+ }
77
+ finally {
78
+ this.writing = false;
79
+ }
80
+ }
81
+ /** Rewrite the file with the header and the newest kept lines, bounding its size. */
82
+ async compact() {
83
+ if (this.path === undefined || this.appended.length === 0)
84
+ return;
85
+ const lines = this.appended.slice(-KEEP_SAMPLES);
86
+ try {
87
+ await writePrivateFile(this.path, HEADER + lines.join(''));
88
+ this.appended = lines;
89
+ }
90
+ catch (error) {
91
+ // Appending continues to the existing file, so a failed rewrite only delays the bound.
92
+ this.error = errorText(error);
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,34 @@
1
+ /** Periodic billing scans; the ledger outlives any single connection generation. */
2
+ import type { Client } from '../transport/client.ts';
3
+ import type { CostLedger } from './ledger.ts';
4
+ /** Host access a scan needs; supplied by the controller facade. */
5
+ export interface CostHost {
6
+ /** Connected client, or undefined while offline. */
7
+ client(): Client | undefined;
8
+ /** Whether the current connection generation is online. */
9
+ online(): boolean;
10
+ /** Client lifetime signal, aborted when the process closes the connection. */
11
+ signal(): AbortSignal;
12
+ /** Re-publish controller state after the ledger changes. */
13
+ publish(): void;
14
+ }
15
+ /** Owns the background scan: startup, the minute timer, turn-completion refresh, and `/cost`. */
16
+ export declare class CostController {
17
+ readonly ledger: CostLedger;
18
+ private readonly host;
19
+ private task;
20
+ private abort;
21
+ private timer;
22
+ private updates;
23
+ constructor(ledger: CostLedger, host: CostHost);
24
+ /** Scan immediately, then once a minute while online. */
25
+ start(): void;
26
+ /** Stop the timer and wait for an in-flight scan; the ledger keeps its cached charges. */
27
+ stop(): Promise<void>;
28
+ /** Refresh after the host reports a turn complete. */
29
+ onTurnIdle(): void;
30
+ /** Refresh all HTTP-visible sessions without changing the selected conversation.
31
+ * @param signal - Optional cancellation for an explicit `/cost` refresh.
32
+ */
33
+ refresh(signal?: AbortSignal): Promise<void>;
34
+ }
@@ -0,0 +1,107 @@
1
+ import { array, errorText, object, string } from "../transport/wire.js";
2
+ import { sessionCostHistory } from "./scanner.js";
3
+ /** Refresh interval for the background cost scan. */
4
+ const REFRESH_INTERVAL_MS = 60_000;
5
+ /** Owns the background scan: startup, the minute timer, turn-completion refresh, and `/cost`. */
6
+ export class CostController {
7
+ ledger;
8
+ host;
9
+ task;
10
+ abort;
11
+ timer;
12
+ updates = new Map();
13
+ constructor(ledger, host) {
14
+ this.ledger = ledger;
15
+ this.host = host;
16
+ }
17
+ /** Scan immediately, then once a minute while online. */
18
+ start() {
19
+ if (this.timer)
20
+ return;
21
+ void this.refresh().catch(() => undefined);
22
+ this.timer = setInterval(() => { if (this.host.online())
23
+ void this.refresh().catch(() => undefined); }, REFRESH_INTERVAL_MS);
24
+ }
25
+ /** Stop the timer and wait for an in-flight scan; the ledger keeps its cached charges. */
26
+ async stop() {
27
+ clearInterval(this.timer);
28
+ this.timer = undefined;
29
+ await this.task;
30
+ }
31
+ /** Refresh after the host reports a turn complete. */
32
+ onTurnIdle() {
33
+ if (this.host.online())
34
+ void this.refresh().catch(() => undefined);
35
+ }
36
+ /** Refresh all HTTP-visible sessions without changing the selected conversation.
37
+ * @param signal - Optional cancellation for an explicit `/cost` refresh.
38
+ */
39
+ async refresh(signal = this.host.signal()) {
40
+ if (this.task) {
41
+ const cancel = () => this.abort?.abort();
42
+ signal.addEventListener('abort', cancel, { once: true });
43
+ try {
44
+ await this.task;
45
+ }
46
+ finally {
47
+ signal.removeEventListener('abort', cancel);
48
+ }
49
+ return;
50
+ }
51
+ const client = this.host.client();
52
+ if (!client)
53
+ throw new Error('Not connected');
54
+ this.abort = new AbortController();
55
+ const combined = AbortSignal.any([signal, this.host.signal(), this.abort.signal]);
56
+ const ledger = this.ledger;
57
+ ledger.scanning = true;
58
+ ledger.error = '';
59
+ this.host.publish();
60
+ const task = (async () => {
61
+ try {
62
+ const sessions = array(object(await client.call('session/list', { _request: {} }, combined)).items).map(object);
63
+ combined.throwIfAborted();
64
+ const failures = [];
65
+ let scanned = 0, pages = 0, events = 0;
66
+ for (const session of sessions) {
67
+ combined.throwIfAborted();
68
+ const sessionId = string(session.sessionId);
69
+ if (!session.running && typeof session.updatedAt === 'number' && this.updates.get(sessionId) === session.updatedAt)
70
+ continue;
71
+ try {
72
+ const history = await sessionCostHistory(client, session, combined, () => { pages++; });
73
+ scanned++;
74
+ events += history.events.length;
75
+ await ledger.replace(sessionId, history.cursor, history.events);
76
+ if (!session.running && typeof session.updatedAt === 'number')
77
+ this.updates.set(sessionId, session.updatedAt);
78
+ this.host.publish();
79
+ }
80
+ catch (error) {
81
+ // One unreachable or rejected session must not freeze every other session's rates.
82
+ combined.throwIfAborted();
83
+ failures.push(`${sessionId}: ${errorText(error)}`);
84
+ }
85
+ }
86
+ ledger.error = failures.length === 0 ? '' : `${failures.length} of ${sessions.length} sessions failed: ${failures[0]}`;
87
+ ledger.lastScan = { sessions: scanned, pages, events };
88
+ ledger.scannedAt = Date.now();
89
+ }
90
+ catch (error) {
91
+ ledger.error = errorText(error);
92
+ }
93
+ finally {
94
+ ledger.scanning = false;
95
+ this.host.publish();
96
+ }
97
+ })();
98
+ this.task = task;
99
+ try {
100
+ await task;
101
+ }
102
+ finally {
103
+ this.task = undefined;
104
+ this.abort = undefined;
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,9 @@
1
+ /** Cost domain: price tables, record folding, the immutable ledger, the scanner and its controller. */
2
+ export { CostController } from './controller.ts';
3
+ export type { CostHost } from './controller.ts';
4
+ export { CostLedger, costText } from './ledger.ts';
5
+ export { chargeFor, costDay, DEFAULT_PRICES, lowestPrice, priceAt, pricesFrom } from './pricing.ts';
6
+ export { costRecords, foldSamples } from './records.ts';
7
+ export { costAddresses, sessionCostHistory } from './scanner.ts';
8
+ export { MISSING_USAGE } from './types.ts';
9
+ export type { Charge, ChargeSample, CostTotal, Coverage, PriceDecision, PriceVersion, Rates, SavedCost, Usage } from './types.ts';
@@ -0,0 +1,7 @@
1
+ /** Cost domain: price tables, record folding, the immutable ledger, the scanner and its controller. */
2
+ export { CostController } from "./controller.js";
3
+ export { CostLedger, costText } from "./ledger.js";
4
+ export { chargeFor, costDay, DEFAULT_PRICES, lowestPrice, priceAt, pricesFrom } from "./pricing.js";
5
+ export { costRecords, foldSamples } from "./records.js";
6
+ export { costAddresses, sessionCostHistory } from "./scanner.js";
7
+ export { MISSING_USAGE } from "./types.js";
@@ -0,0 +1,20 @@
1
+ import type { SavedCost } from './types.ts';
2
+ /** Load every session's newest cut, leaving one fixed file per session.
3
+ *
4
+ * A file of another generation, a file with an unreadable shape, and a cut file superseded by the
5
+ * newer name all describe work the next scan rebuilds, so loading removes them; the newest slice
6
+ * of a superseded name is rewritten under the fixed one first. Files this unit does not own are
7
+ * left where they are.
8
+ * @param directory - Origin-scoped ledger directory, or undefined when persistence is disabled.
9
+ * @returns The newest saved slice per session identity.
10
+ */
11
+ export declare function loadLedgers(directory: string | undefined): Promise<Map<string, SavedCost>>;
12
+ /** Write one session cut unless the directory already holds a newer one.
13
+ *
14
+ * The file is the system of record across processes, so a scan that opened an older snapshot must
15
+ * not replace a cut another scan already persisted.
16
+ * @param directory - Origin-scoped ledger directory.
17
+ * @param saved - Complete slice to persist.
18
+ * @returns Whether the directory now holds this slice.
19
+ */
20
+ export declare function saveLedger(directory: string, saved: SavedCost): Promise<boolean>;
@@ -0,0 +1,128 @@
1
+ /** Atomic per-session persistence for the immutable charge ledger. */
2
+ import { createHash } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ import { ensureDirectory, listEntries, readText, removeFile, writePrivateFile } from "../storage/index.js";
5
+ /** Current on-disk ledger generation. Files of another generation are ignored, not migrated. */
6
+ const LEDGER_VERSION = 2;
7
+ /** Every file this unit owns: the fixed per-session name, or the `<session>-<cut>.json` it replaced. */
8
+ const OWNED_FILE = /^[0-9a-f]{64}(?:-\d+)?\.json$/;
9
+ /** The one file that holds a session's newest cut; the cut itself lives inside the file. */
10
+ function ledgerName(sessionId) {
11
+ return `${createHash('sha256').update(sessionId).digest('hex')}.json`;
12
+ }
13
+ /** Load every session's newest cut, leaving one fixed file per session.
14
+ *
15
+ * A file of another generation, a file with an unreadable shape, and a cut file superseded by the
16
+ * newer name all describe work the next scan rebuilds, so loading removes them; the newest slice
17
+ * of a superseded name is rewritten under the fixed one first. Files this unit does not own are
18
+ * left where they are.
19
+ * @param directory - Origin-scoped ledger directory, or undefined when persistence is disabled.
20
+ * @returns The newest saved slice per session identity.
21
+ */
22
+ export async function loadLedgers(directory) {
23
+ const sessions = new Map();
24
+ if (!directory)
25
+ return sessions;
26
+ await ensureDirectory(directory);
27
+ const superseded = [];
28
+ for (const name of await listEntries(directory)) {
29
+ if (!OWNED_FILE.test(name))
30
+ continue;
31
+ const path = join(directory, name);
32
+ const raw = await readText(path);
33
+ // A file removed between listing and reading is simply absent.
34
+ if (raw === undefined)
35
+ continue;
36
+ const saved = parseLedger(raw);
37
+ if (saved === undefined) {
38
+ await removeFile(path);
39
+ continue;
40
+ }
41
+ if ((sessions.get(saved.sessionId)?.cut ?? -2) <= saved.cut)
42
+ sessions.set(saved.sessionId, saved);
43
+ if (name !== ledgerName(saved.sessionId))
44
+ superseded.push({ path, sessionId: saved.sessionId });
45
+ }
46
+ for (const { path, sessionId } of superseded) {
47
+ await writePrivateFile(join(directory, ledgerName(sessionId)), JSON.stringify(sessions.get(sessionId)) + '\n');
48
+ await removeFile(path);
49
+ }
50
+ return sessions;
51
+ }
52
+ /** Write one session cut unless the directory already holds a newer one.
53
+ *
54
+ * The file is the system of record across processes, so a scan that opened an older snapshot must
55
+ * not replace a cut another scan already persisted.
56
+ * @param directory - Origin-scoped ledger directory.
57
+ * @param saved - Complete slice to persist.
58
+ * @returns Whether the directory now holds this slice.
59
+ */
60
+ export async function saveLedger(directory, saved) {
61
+ const path = join(directory, ledgerName(saved.sessionId));
62
+ const existing = await readText(path);
63
+ if (existing !== undefined && persistedCut(existing) > saved.cut)
64
+ return false;
65
+ await writePrivateFile(path, JSON.stringify(saved) + '\n');
66
+ return true;
67
+ }
68
+ /** The cut already persisted in one file, or -1 when it holds no decision worth keeping.
69
+ * @param raw - File contents read from the ledger directory.
70
+ * @returns The persisted cut.
71
+ */
72
+ function persistedCut(raw) {
73
+ try {
74
+ return parseLedger(raw)?.cut ?? -1;
75
+ }
76
+ // A file of another generation or an unreadable shape carries no decision, so the new cut replaces it.
77
+ catch {
78
+ return -1;
79
+ }
80
+ }
81
+ /** Validate one persisted ledger; another generation or an unreadable shape is absent. */
82
+ function parseLedger(raw) {
83
+ let value;
84
+ try {
85
+ value = JSON.parse(raw);
86
+ }
87
+ catch {
88
+ return undefined;
89
+ }
90
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
91
+ return undefined;
92
+ const record = value;
93
+ if (record.version !== LEDGER_VERSION)
94
+ return undefined;
95
+ if (typeof record.sessionId !== 'string' || !Number.isSafeInteger(record.cut) || !Array.isArray(record.charges)) {
96
+ throw new Error('Invalid cost ledger');
97
+ }
98
+ const charges = record.charges;
99
+ if (!charges.every(validCharge))
100
+ throw new Error('Invalid cost ledger');
101
+ return { version: LEDGER_VERSION, sessionId: record.sessionId, cut: record.cut, charges: charges };
102
+ }
103
+ /** One charge is valid when every recorded field is present with the type the fold writes. */
104
+ function validCharge(value) {
105
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
106
+ return false;
107
+ const c = value;
108
+ if (typeof c.key !== 'string' || typeof c.provider !== 'string' || typeof c.model !== 'string')
109
+ return false;
110
+ if (c.priceId !== undefined && typeof c.priceId !== 'string')
111
+ return false;
112
+ if (c.reason !== undefined && typeof c.reason !== 'string')
113
+ return false;
114
+ if (c.amount !== undefined && (typeof c.amount !== 'number' || !Number.isFinite(c.amount) || c.amount < 0))
115
+ return false;
116
+ if (c.estimated !== undefined && c.estimated !== true)
117
+ return false;
118
+ if (c.time !== undefined && (typeof c.time !== 'number' || !Number.isFinite(c.time) || c.time < 0 || c.time > 8.64e15))
119
+ return false;
120
+ if (c.usage !== undefined) {
121
+ if (typeof c.usage !== 'object' || c.usage === null || Array.isArray(c.usage))
122
+ return false;
123
+ const buckets = Object.values(c.usage);
124
+ if (buckets.length === 0 || buckets.some(n => typeof n !== 'number' || !Number.isSafeInteger(n) || n < 0))
125
+ return false;
126
+ }
127
+ return true;
128
+ }
@@ -0,0 +1,65 @@
1
+ /** Immutable CNY ledger: a charge is priced once and never follows later price configuration. */
2
+ import type { ObjectValue } from '../transport/wire.ts';
3
+ import { type CostTotal, type Coverage, type PriceVersion } from './types.ts';
4
+ /** Per-origin cache of decided request charges; each scan replaces a session at a fixed cut. */
5
+ export declare class CostLedger {
6
+ readonly prices: PriceVersion[];
7
+ readonly directory?: string | undefined;
8
+ private sessions;
9
+ private totals;
10
+ scannedAt?: number;
11
+ scanning: boolean;
12
+ error: string;
13
+ /** Work the last completed scan performed, so a memory sample can attribute its allocation. */
14
+ lastScan?: {
15
+ sessions: number;
16
+ pages: number;
17
+ events: number;
18
+ };
19
+ constructor(prices?: PriceVersion[], directory?: string | undefined);
20
+ /** Cached charges count as complete; only a failed scan or an empty ledger is partial.
21
+ * @returns Coverage of the current totals, so callers can mark them without re-deriving the rule.
22
+ */
23
+ get coverage(): Coverage;
24
+ /** Load the newest complete cut per session; recorded amounts load without re-pricing. */
25
+ load(): Promise<void>;
26
+ /** Replace one session using all billing events through the opening snapshot cut.
27
+ *
28
+ * Sampling is replayed, but a charge that an earlier scan already decided keeps its recorded
29
+ * `priceId` and `amount`, so editing `prices.json` only affects requests decided afterwards.
30
+ * @param sessionId - Host session identity.
31
+ * @param cut - Opening cursor, preventing a stale scan from overwriting a newer scan.
32
+ * @param events - Minimal events returned by `costRecords`, across all history pages.
33
+ */
34
+ replace(sessionId: string, cut: number, events: ObjectValue[]): Promise<void>;
35
+ /** Count the retained ledger so a memory sample can separate it from the transcript window.
36
+ * @returns Sessions and charges currently held, and how many charges carry no amount.
37
+ */
38
+ summary(): {
39
+ sessions: number;
40
+ charges: number;
41
+ unpriced: number;
42
+ };
43
+ /** Whether this session has a complete cached scan.
44
+ * @param sessionId - Selected session identity.
45
+ * @returns True when a complete scan is available.
46
+ */
47
+ hasSession(sessionId?: string): boolean;
48
+ /** Describe unpriced model/usage combinations without exposing conversation content.
49
+ * @returns Unique reasons across cached sessions.
50
+ */
51
+ missing(): string[];
52
+ /** Summarize recorded requests across one session or Beijing calendar days.
53
+ * @param sessionId - Optional session restriction.
54
+ * @param days - Today or today plus the preceding two calendar days.
55
+ * @param now - Clock used for date attribution.
56
+ * @returns Known subtotal, unpriceable count, and estimated count; an estimated record always
57
+ * names an amount, but a dated range only adds the records it can place inside that range.
58
+ */
59
+ total(sessionId?: string, days?: 1 | 3, now?: number): CostTotal;
60
+ }
61
+ /** Compact estimates retain an asterisk whenever a subtotal is not exact.
62
+ * @param total - Summary from the ledger.
63
+ * @returns Yuan amount and incompleteness marker.
64
+ */
65
+ export declare function costText(total: CostTotal): string;
@@ -0,0 +1,136 @@
1
+ import { chargeFor, costDay, DEFAULT_PRICES } from "./pricing.js";
2
+ import { foldSamples } from "./records.js";
3
+ import { loadLedgers, saveLedger } from "./ledger-files.js";
4
+ import { MISSING_USAGE } from "./types.js";
5
+ /** Whether a charge already carries a decision that a later scan must keep. */
6
+ function sealed(charge) {
7
+ return charge.amount !== undefined || (charge.reason !== undefined && charge.reason !== MISSING_USAGE);
8
+ }
9
+ /** Attach the current table's decision, or reuse the decision an earlier scan recorded.
10
+ *
11
+ * A sample without usage has not finished reporting tokens, so it stays open for the next scan;
12
+ * every other decision — priced, unpriced, or estimated — is final.
13
+ */
14
+ function decide(prices, sample, previous) {
15
+ if (previous !== undefined && (sealed(previous) || sample.usage === undefined))
16
+ return previous;
17
+ return { ...sample, ...chargeFor(prices, sample.provider, sample.model, sample.time, sample.usage) };
18
+ }
19
+ /** Per-origin cache of decided request charges; each scan replaces a session at a fixed cut. */
20
+ export class CostLedger {
21
+ prices;
22
+ directory;
23
+ sessions = new Map();
24
+ totals = new Map();
25
+ scannedAt;
26
+ scanning = false;
27
+ error = '';
28
+ /** Work the last completed scan performed, so a memory sample can attribute its allocation. */
29
+ lastScan;
30
+ constructor(prices = DEFAULT_PRICES, directory) {
31
+ this.prices = prices;
32
+ this.directory = directory;
33
+ }
34
+ /** Cached charges count as complete; only a failed scan or an empty ledger is partial.
35
+ * @returns Coverage of the current totals, so callers can mark them without re-deriving the rule.
36
+ */
37
+ get coverage() {
38
+ if (this.scanning)
39
+ return 'scanning';
40
+ if (this.error)
41
+ return 'partial';
42
+ return this.scannedAt !== undefined || this.sessions.size > 0 ? 'complete' : 'partial';
43
+ }
44
+ /** Load the newest complete cut per session; recorded amounts load without re-pricing. */
45
+ async load() {
46
+ this.totals.clear();
47
+ for (const [sessionId, saved] of await loadLedgers(this.directory)) {
48
+ if ((this.sessions.get(sessionId)?.cut ?? -2) <= saved.cut)
49
+ this.sessions.set(sessionId, saved);
50
+ }
51
+ }
52
+ /** Replace one session using all billing events through the opening snapshot cut.
53
+ *
54
+ * Sampling is replayed, but a charge that an earlier scan already decided keeps its recorded
55
+ * `priceId` and `amount`, so editing `prices.json` only affects requests decided afterwards.
56
+ * @param sessionId - Host session identity.
57
+ * @param cut - Opening cursor, preventing a stale scan from overwriting a newer scan.
58
+ * @param events - Minimal events returned by `costRecords`, across all history pages.
59
+ */
60
+ async replace(sessionId, cut, events) {
61
+ const current = this.sessions.get(sessionId);
62
+ if ((current?.cut ?? -2) > cut)
63
+ return;
64
+ const previous = new Map((current?.charges ?? []).map(charge => [charge.key, charge]));
65
+ const charges = foldSamples(events).map(sample => decide(this.prices, sample, previous.get(sample.key)));
66
+ const saved = { version: 2, sessionId, cut, charges };
67
+ // Another process may have persisted a newer cut of this session since it was last read.
68
+ if (this.directory && !await saveLedger(this.directory, saved))
69
+ return;
70
+ this.sessions.set(sessionId, saved);
71
+ this.totals.clear();
72
+ }
73
+ /** Count the retained ledger so a memory sample can separate it from the transcript window.
74
+ * @returns Sessions and charges currently held, and how many charges carry no amount.
75
+ */
76
+ summary() {
77
+ let charges = 0, unpriced = 0;
78
+ for (const session of this.sessions.values()) {
79
+ charges += session.charges.length;
80
+ unpriced += session.charges.filter(charge => charge.amount === undefined).length;
81
+ }
82
+ return { sessions: this.sessions.size, charges, unpriced };
83
+ }
84
+ /** Whether this session has a complete cached scan.
85
+ * @param sessionId - Selected session identity.
86
+ * @returns True when a complete scan is available.
87
+ */
88
+ hasSession(sessionId) { return sessionId !== undefined && this.sessions.has(sessionId); }
89
+ /** Describe unpriced model/usage combinations without exposing conversation content.
90
+ * @returns Unique reasons across cached sessions.
91
+ */
92
+ missing() {
93
+ return [...new Set([...this.sessions.values()].flatMap(s => s.charges.filter(c => c.amount === undefined).map(c => `${c.provider}/${c.model}: ${c.reason}`)))];
94
+ }
95
+ /** Summarize recorded requests across one session or Beijing calendar days.
96
+ * @param sessionId - Optional session restriction.
97
+ * @param days - Today or today plus the preceding two calendar days.
98
+ * @param now - Clock used for date attribution.
99
+ * @returns Known subtotal, unpriceable count, and estimated count; an estimated record always
100
+ * names an amount, but a dated range only adds the records it can place inside that range.
101
+ */
102
+ total(sessionId, days, now = Date.now()) {
103
+ const cacheKey = JSON.stringify([sessionId, days, days ? costDay(now) : '']);
104
+ const cached = this.totals.get(cacheKey);
105
+ if (cached)
106
+ return cached;
107
+ const result = { amount: 0, unknown: 0, estimated: 0, records: 0 };
108
+ const end = costDay(now);
109
+ const start = costDay(now - ((days ?? 1) - 1) * 86400_000);
110
+ for (const session of this.sessions.values()) {
111
+ if (sessionId !== undefined && session.sessionId !== sessionId)
112
+ continue;
113
+ for (const charge of session.charges) {
114
+ if (days && charge.time !== undefined && (costDay(charge.time) < start || costDay(charge.time) > end))
115
+ continue;
116
+ result.records++;
117
+ if (charge.amount === undefined) {
118
+ result.unknown++;
119
+ continue;
120
+ }
121
+ const dated = days === undefined || charge.time !== undefined;
122
+ if (charge.estimated === true || !dated)
123
+ result.estimated++;
124
+ if (dated)
125
+ result.amount += charge.amount;
126
+ }
127
+ }
128
+ this.totals.set(cacheKey, result);
129
+ return result;
130
+ }
131
+ }
132
+ /** Compact estimates retain an asterisk whenever a subtotal is not exact.
133
+ * @param total - Summary from the ledger.
134
+ * @returns Yuan amount and incompleteness marker.
135
+ */
136
+ export function costText(total) { return `~¥${total.amount.toFixed(4)}${total.unknown || total.estimated ? '*' : ''}`; }
@@ -0,0 +1,48 @@
1
+ import { type PriceDecision, type PriceVersion, type Rates, type Usage } from './types.ts';
2
+ export declare const DEFAULT_PRICES: PriceVersion[];
3
+ /** Validate user-maintained price versions, rejecting ambiguous overlapping intervals.
4
+ * @param value - Parsed prices.json array.
5
+ * @returns Price versions with validated rates and schedules.
6
+ */
7
+ export declare function pricesFrom(value: unknown): PriceVersion[];
8
+ /** Return the calendar date used by both daily and three-calendar-day summaries.
9
+ * @param time - Epoch milliseconds.
10
+ * @returns Beijing calendar date, YYYY-MM-DD.
11
+ */
12
+ export declare function costDay(time: number): string;
13
+ /** Select a price by event time, applying half-open local peak windows.
14
+ * @param prices - Validated versions.
15
+ * @param provider - Provider identity from the recorded request.
16
+ * @param model - Recorded model name; official DeepSeek aliases fall back to Pro when containing pro, otherwise Flash.
17
+ * @param time - Recorded settlement timestamp used as a billing-time estimate.
18
+ * @returns Matching price version and per-million-token rates, if known.
19
+ */
20
+ export declare function priceAt(prices: PriceVersion[], provider: string, model: string, time: number): {
21
+ price: PriceVersion;
22
+ rates: Rates;
23
+ } | undefined;
24
+ /** Select a rate without a settlement time, so an unattributable request still enters the total.
25
+ * The cheapest candidate off-peak rate is a floor: it never overstates, and the charge stays
26
+ * marked as estimated.
27
+ * @param prices - Validated versions.
28
+ * @param provider - Provider identity from the recorded request.
29
+ * @param model - Recorded model name.
30
+ * @returns The candidate version with the lowest off-peak input rate and its rates, if any.
31
+ */
32
+ export declare function lowestPrice(prices: PriceVersion[], provider: string, model: string): {
33
+ price: PriceVersion;
34
+ rates: Rates;
35
+ } | undefined;
36
+ /** Decide the amount for one request sample using the table loaded at decision time.
37
+ *
38
+ * The returned decision is recorded once and never revisited: a later `prices.json` change must
39
+ * not move a historical amount. Only a sample with no usable usage (`missing usage`) is left
40
+ * undecided, because its request has not finished reporting tokens yet.
41
+ * @param prices - Validated versions currently loaded.
42
+ * @param provider - Provider identity from the recorded request.
43
+ * @param model - Recorded model name.
44
+ * @param time - Recorded settlement timestamp, when the host logged one.
45
+ * @param usage - Disjoint token buckets, when the host reported valid counts.
46
+ * @returns The selected price identity, the amount, and the reason when no amount exists.
47
+ */
48
+ export declare function chargeFor(prices: PriceVersion[], provider: string, model: string, time: number | undefined, usage: Usage | undefined): PriceDecision;