@itookit/dsht 0.3.0 → 0.3.3

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 (134) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +39 -23
  3. package/README.zh.md +40 -24
  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} +42 -29
  9. package/dist/controller/connection.d.ts +80 -0
  10. package/dist/controller/connection.js +190 -0
  11. package/dist/controller/controller.d.ts +269 -0
  12. package/dist/controller/controller.js +372 -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/config.d.ts +17 -0
  18. package/dist/cost/config.js +68 -0
  19. package/dist/cost/controller.d.ts +41 -0
  20. package/dist/cost/controller.js +115 -0
  21. package/dist/cost/index.d.ts +10 -0
  22. package/dist/cost/index.js +8 -0
  23. package/dist/cost/ledger-files.d.ts +16 -0
  24. package/dist/cost/ledger-files.js +103 -0
  25. package/dist/cost/ledger.d.ts +78 -0
  26. package/dist/cost/ledger.js +146 -0
  27. package/dist/cost/pricing.d.ts +86 -0
  28. package/dist/cost/pricing.js +223 -0
  29. package/dist/cost/records.d.ts +17 -0
  30. package/dist/cost/records.js +79 -0
  31. package/dist/cost/scanner.d.ts +22 -0
  32. package/dist/cost/scanner.js +95 -0
  33. package/dist/cost/types.d.ts +85 -0
  34. package/dist/cost/types.js +7 -0
  35. package/dist/index.d.ts +3 -0
  36. package/dist/index.js +2 -0
  37. package/dist/session/connection-view.d.ts +18 -0
  38. package/dist/session/connection-view.js +1 -0
  39. package/dist/{controller.d.ts → session/controller.d.ts} +102 -136
  40. package/dist/session/controller.js +616 -0
  41. package/dist/session/export-html.d.ts +9 -0
  42. package/dist/session/export-html.js +39 -0
  43. package/dist/{export.d.ts → session/export.d.ts} +1 -1
  44. package/dist/{export.js → session/export.js} +6 -20
  45. package/dist/{history.d.ts → session/history.d.ts} +17 -0
  46. package/dist/{history.js → session/history.js} +163 -2
  47. package/dist/session/index.d.ts +17 -0
  48. package/dist/session/index.js +10 -0
  49. package/dist/session/markdown.d.ts +39 -0
  50. package/dist/session/markdown.js +255 -0
  51. package/dist/session/math.d.ts +11 -0
  52. package/dist/session/math.js +82 -0
  53. package/dist/session/navigation.d.ts +37 -0
  54. package/dist/session/navigation.js +84 -0
  55. package/dist/{references.js → session/references.js} +1 -1
  56. package/dist/{telemetry.d.ts → session/telemetry.d.ts} +1 -1
  57. package/dist/{telemetry.js → session/telemetry.js} +1 -1
  58. package/dist/{transcript.d.ts → session/transcript.d.ts} +65 -1
  59. package/dist/{transcript.js → session/transcript.js} +141 -20
  60. package/dist/session/types.d.ts +18 -0
  61. package/dist/session/types.js +2 -0
  62. package/dist/state.d.ts +41 -0
  63. package/dist/state.js +9 -0
  64. package/dist/storage/directories.d.ts +14 -0
  65. package/dist/storage/directories.js +24 -0
  66. package/dist/storage/files.d.ts +51 -0
  67. package/dist/storage/files.js +152 -0
  68. package/dist/storage/heap-snapshot.d.ts +19 -0
  69. package/dist/storage/heap-snapshot.js +29 -0
  70. package/dist/storage/index.d.ts +4 -0
  71. package/dist/storage/index.js +4 -0
  72. package/dist/transport/auth.js +65 -0
  73. package/dist/transport/host.d.ts +13 -0
  74. package/dist/transport/host.js +1 -0
  75. package/dist/ui/app.d.ts +11 -0
  76. package/dist/ui/app.js +790 -0
  77. package/dist/ui/chat/header.d.ts +11 -0
  78. package/dist/ui/chat/header.js +14 -0
  79. package/dist/ui/chat/history-view.d.ts +12 -0
  80. package/dist/ui/chat/history-view.js +17 -0
  81. package/dist/ui/chat/status.d.ts +91 -0
  82. package/dist/ui/chat/status.js +386 -0
  83. package/dist/ui/chat/viewport.d.ts +17 -0
  84. package/dist/ui/chat/viewport.js +14 -0
  85. package/dist/ui/commands/parse.d.ts +99 -0
  86. package/dist/ui/commands/parse.js +126 -0
  87. package/dist/ui/commands/registry.d.ts +33 -0
  88. package/dist/ui/commands/registry.js +73 -0
  89. package/dist/ui/copy-mode.d.ts +4 -0
  90. package/dist/ui/copy-mode.js +6 -0
  91. package/dist/{cost-view.d.ts → ui/dialogs/cost.d.ts} +1 -1
  92. package/dist/{cost-view.js → ui/dialogs/cost.js} +6 -6
  93. package/dist/ui/dialogs/index.d.ts +120 -0
  94. package/dist/ui/dialogs/index.js +113 -0
  95. package/dist/ui/dialogs/picker.d.ts +18 -0
  96. package/dist/ui/dialogs/picker.js +38 -0
  97. package/dist/ui/frozen.d.ts +8 -0
  98. package/dist/ui/frozen.js +7 -0
  99. package/dist/ui/input/references.d.ts +12 -0
  100. package/dist/ui/input/references.js +15 -0
  101. package/dist/ui/mount.d.ts +6 -0
  102. package/dist/ui/mount.js +11 -0
  103. package/dist/{theme.d.ts → ui/theme/index.d.ts} +1 -1
  104. package/package.json +19 -13
  105. package/dist/app.d.ts +0 -21
  106. package/dist/app.js +0 -805
  107. package/dist/auth.js +0 -108
  108. package/dist/controller.js +0 -961
  109. package/dist/cost.d.ts +0 -119
  110. package/dist/cost.js +0 -313
  111. package/dist/history-view.d.ts +0 -8
  112. package/dist/history-view.js +0 -12
  113. package/dist/navigation.d.ts +0 -11
  114. package/dist/navigation.js +0 -36
  115. package/dist/status.d.ts +0 -28
  116. package/dist/status.js +0 -157
  117. /package/dist/{cli.d.ts → cli/index.d.ts} +0 -0
  118. /package/dist/{memory.d.ts → session/memory.d.ts} +0 -0
  119. /package/dist/{memory.js → session/memory.js} +0 -0
  120. /package/dist/{references.d.ts → session/references.d.ts} +0 -0
  121. /package/dist/{auth.d.ts → transport/auth.d.ts} +0 -0
  122. /package/dist/{client.d.ts → transport/client.d.ts} +0 -0
  123. /package/dist/{client.js → transport/client.js} +0 -0
  124. /package/dist/{endpoint.d.ts → transport/endpoint.d.ts} +0 -0
  125. /package/dist/{endpoint.js → transport/endpoint.js} +0 -0
  126. /package/dist/{wire.d.ts → transport/wire.d.ts} +0 -0
  127. /package/dist/{wire.js → transport/wire.js} +0 -0
  128. /package/dist/{input-history.d.ts → ui/input/history.d.ts} +0 -0
  129. /package/dist/{input-history.js → ui/input/history.js} +0 -0
  130. /package/dist/{input.d.ts → ui/input/input.d.ts} +0 -0
  131. /package/dist/{input.js → ui/input/input.js} +0 -0
  132. /package/dist/{mouse.d.ts → ui/input/mouse.d.ts} +0 -0
  133. /package/dist/{mouse.js → ui/input/mouse.js} +0 -0
  134. /package/dist/{theme.js → ui/theme/index.js} +0 -0
@@ -0,0 +1,35 @@
1
+ import { type ObjectValue } from '../transport/wire.ts';
2
+ /** Append-only memory log; the file never exceeds twice `KEEP_SAMPLES` lines.
3
+ *
4
+ * The log exists to answer "is retained content growing, or is this just V8's high-water mark",
5
+ * so each sample records the process counters next to the client's retained window and the
6
+ * controller state that decides whether that window is being reclaimed. Samples contain counts
7
+ * and sizes only, never prompt, tool, or session text. A failing log stops itself instead of
8
+ * breaking the client.
9
+ */
10
+ export declare class MemoryLog {
11
+ readonly path: string | undefined;
12
+ private readonly sampleSource;
13
+ private timer;
14
+ private appended;
15
+ private writing;
16
+ /** Last write failure, when the log stopped itself. */
17
+ error: string | undefined;
18
+ constructor(path: string | undefined, sampleSource: () => ObjectValue);
19
+ /** Write the header into a new file, then sample once; a missing or unwritable path is reported
20
+ * by that first sample instead of here.
21
+ */
22
+ private begin;
23
+ /** Sample once now and then every `SAMPLE_INTERVAL_MS`; absent when no path was configured. */
24
+ start(): void;
25
+ /** Stop sampling and rewrite the file with its retained window. */
26
+ stop(): Promise<void>;
27
+ /** Append one sample line, skipping overlapping calls.
28
+ *
29
+ * A repeated failure would be noise, so the first one stops the timer and leaves the client
30
+ * running; the log is diagnostics, not a feature the terminal depends on.
31
+ */
32
+ sample(): Promise<void>;
33
+ /** Rewrite the file with the header and the newest kept lines, bounding its size. */
34
+ private compact;
35
+ }
@@ -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,17 @@
1
+ import type { PriceVersion } from './types.ts';
2
+ /** Load the price table, seeding the file on first use and refreshing a copy the user never edited.
3
+ *
4
+ * `prices.json` overrides the shipped table, so a correction to the shipped rates would otherwise
5
+ * never reach an install that already had the file — which is how a superseded Flash table kept
6
+ * charging 1.5x for input after it was fixed. The stamp records the seeded bytes: while the file
7
+ * still hashes to them the tool owns it and follows the shipped table, and the first edit makes the
8
+ * file authoritative, so no rate the user chose is ever overwritten.
9
+ * @param directory - Configuration directory holding `prices.json`.
10
+ * @param shipped - Table this build ships, seeded on first use and followed while the file is untouched.
11
+ * @returns The validated table, and whether it came from a file the user maintains.
12
+ * @throws when the file exists but is not a valid table.
13
+ */
14
+ export declare function loadPrices(directory: string, shipped?: PriceVersion[]): Promise<{
15
+ prices: PriceVersion[];
16
+ custom: boolean;
17
+ }>;
@@ -0,0 +1,68 @@
1
+ /** Seed, migrate and load the price configuration that overrides the shipped table. */
2
+ import { createHash } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ import { createPrivateFile, readText, writePrivateFile } from "../storage/index.js";
5
+ import { DEFAULT_PRICES, PRICES_REVISION, isUncorrectedSeed, pricesFrom } from "./pricing.js";
6
+ /** Load the price table, seeding the file on first use and refreshing a copy the user never edited.
7
+ *
8
+ * `prices.json` overrides the shipped table, so a correction to the shipped rates would otherwise
9
+ * never reach an install that already had the file — which is how a superseded Flash table kept
10
+ * charging 1.5x for input after it was fixed. The stamp records the seeded bytes: while the file
11
+ * still hashes to them the tool owns it and follows the shipped table, and the first edit makes the
12
+ * file authoritative, so no rate the user chose is ever overwritten.
13
+ * @param directory - Configuration directory holding `prices.json`.
14
+ * @param shipped - Table this build ships, seeded on first use and followed while the file is untouched.
15
+ * @returns The validated table, and whether it came from a file the user maintains.
16
+ * @throws when the file exists but is not a valid table.
17
+ */
18
+ export async function loadPrices(directory, shipped = DEFAULT_PRICES) {
19
+ const path = join(directory, 'prices.json');
20
+ const stampPath = join(directory, 'prices.seed.json');
21
+ const seeded = `${JSON.stringify(shipped, null, 2)}\n`;
22
+ const raw = await readText(path);
23
+ if (raw === undefined) {
24
+ await createPrivateFile(path, seeded);
25
+ await writePrivateFile(stampPath, stamp(seeded));
26
+ return { prices: shipped, custom: false };
27
+ }
28
+ const prices = pricesFrom(JSON.parse(raw));
29
+ const recorded = await readStamp(stampPath);
30
+ // A file without a stamp predates the record; only the superseded shipped seed is recognized there,
31
+ // because anything else may be a table the user wrote by hand.
32
+ const owned = recorded === undefined ? isUncorrectedSeed(prices) : recorded === hash(raw);
33
+ if (!owned)
34
+ return { prices, custom: true };
35
+ // Write only what a corrected table actually changed, so an unchanged file needs no write at all
36
+ // and a read-only configuration directory still starts.
37
+ if (raw !== seeded)
38
+ await writePrivateFile(path, seeded);
39
+ if (recorded !== hash(seeded))
40
+ await writePrivateFile(stampPath, stamp(seeded));
41
+ return { prices: shipped, custom: false };
42
+ }
43
+ /** Sha256 of one file's contents.
44
+ * @param contents - Exact file text.
45
+ * @returns Lowercase hex digest.
46
+ */
47
+ function hash(contents) { return createHash('sha256').update(contents).digest('hex'); }
48
+ /** Serialize the stamp written beside a seeded table.
49
+ * @param contents - Exact seeded text.
50
+ * @returns Stamp file contents.
51
+ */
52
+ function stamp(contents) { return `${JSON.stringify({ revision: PRICES_REVISION, hash: hash(contents) }, null, 2)}\n`; }
53
+ /** Read the seed stamp, tolerating a stamp written by another revision.
54
+ * @param path - Stamp file path.
55
+ * @returns The digest recorded for the seeded file, or undefined when no stamp applies.
56
+ */
57
+ async function readStamp(path) {
58
+ const raw = await readText(path);
59
+ if (raw === undefined)
60
+ return;
61
+ try {
62
+ const parsed = JSON.parse(raw);
63
+ return typeof parsed.hash === 'string' ? parsed.hash : undefined;
64
+ }
65
+ catch {
66
+ return undefined;
67
+ }
68
+ }
@@ -0,0 +1,41 @@
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
+ *
26
+ * Every generation starts with no skip bookkeeping, because the host keeps running while this
27
+ * client is not connected: an update time this client already recorded cannot prove that nothing
28
+ * happened during the gap, so the first scan of the generation reads every session's history
29
+ * again. Within that generation the recorded times keep the minute timer from re-reading idle
30
+ * sessions.
31
+ */
32
+ start(): void;
33
+ /** Stop the timer and wait for an in-flight scan; the ledger keeps its folded totals. */
34
+ stop(): Promise<void>;
35
+ /** Refresh after the host reports a turn complete. */
36
+ onTurnIdle(): void;
37
+ /** Refresh all HTTP-visible sessions without changing the selected conversation.
38
+ * @param signal - Optional cancellation for an explicit `/cost` refresh.
39
+ */
40
+ refresh(signal?: AbortSignal): Promise<void>;
41
+ }
@@ -0,0 +1,115 @@
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
+ *
19
+ * Every generation starts with no skip bookkeeping, because the host keeps running while this
20
+ * client is not connected: an update time this client already recorded cannot prove that nothing
21
+ * happened during the gap, so the first scan of the generation reads every session's history
22
+ * again. Within that generation the recorded times keep the minute timer from re-reading idle
23
+ * sessions.
24
+ */
25
+ start() {
26
+ if (this.timer)
27
+ return;
28
+ this.updates.clear();
29
+ void this.refresh().catch(() => undefined);
30
+ this.timer = setInterval(() => { if (this.host.online())
31
+ void this.refresh().catch(() => undefined); }, REFRESH_INTERVAL_MS);
32
+ }
33
+ /** Stop the timer and wait for an in-flight scan; the ledger keeps its folded totals. */
34
+ async stop() {
35
+ clearInterval(this.timer);
36
+ this.timer = undefined;
37
+ await this.task;
38
+ }
39
+ /** Refresh after the host reports a turn complete. */
40
+ onTurnIdle() {
41
+ if (this.host.online())
42
+ void this.refresh().catch(() => undefined);
43
+ }
44
+ /** Refresh all HTTP-visible sessions without changing the selected conversation.
45
+ * @param signal - Optional cancellation for an explicit `/cost` refresh.
46
+ */
47
+ async refresh(signal = this.host.signal()) {
48
+ if (this.task) {
49
+ const cancel = () => this.abort?.abort();
50
+ signal.addEventListener('abort', cancel, { once: true });
51
+ try {
52
+ await this.task;
53
+ }
54
+ finally {
55
+ signal.removeEventListener('abort', cancel);
56
+ }
57
+ return;
58
+ }
59
+ const client = this.host.client();
60
+ if (!client)
61
+ throw new Error('Not connected');
62
+ this.abort = new AbortController();
63
+ const combined = AbortSignal.any([signal, this.host.signal(), this.abort.signal]);
64
+ const ledger = this.ledger;
65
+ ledger.scanning = true;
66
+ ledger.error = '';
67
+ this.host.publish();
68
+ const task = (async () => {
69
+ try {
70
+ const sessions = array(object(await client.call('session/list', { _request: {} }, combined)).items).map(object);
71
+ combined.throwIfAborted();
72
+ const failures = [];
73
+ let scanned = 0, pages = 0, events = 0;
74
+ for (const session of sessions) {
75
+ combined.throwIfAborted();
76
+ const sessionId = string(session.sessionId);
77
+ if (!session.running && typeof session.updatedAt === 'number' && this.updates.get(sessionId) === session.updatedAt)
78
+ continue;
79
+ try {
80
+ const history = await sessionCostHistory(client, session, combined, () => { pages++; });
81
+ scanned++;
82
+ events += history.events.length;
83
+ await ledger.replace(sessionId, history.cursor, history.events);
84
+ if (!session.running && typeof session.updatedAt === 'number')
85
+ this.updates.set(sessionId, session.updatedAt);
86
+ this.host.publish();
87
+ }
88
+ catch (error) {
89
+ // One unreachable or rejected session must not freeze every other session's rates.
90
+ combined.throwIfAborted();
91
+ failures.push(`${sessionId}: ${errorText(error)}`);
92
+ }
93
+ }
94
+ ledger.error = failures.length === 0 ? '' : `${failures.length} of ${sessions.length} sessions failed: ${failures[0]}`;
95
+ ledger.lastScan = { sessions: scanned, pages, events };
96
+ ledger.scannedAt = Date.now();
97
+ }
98
+ catch (error) {
99
+ ledger.error = errorText(error);
100
+ }
101
+ finally {
102
+ ledger.scanning = false;
103
+ this.host.publish();
104
+ }
105
+ })();
106
+ this.task = task;
107
+ try {
108
+ await task;
109
+ }
110
+ finally {
111
+ this.task = undefined;
112
+ this.abort = undefined;
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,10 @@
1
+ /** Cost domain: price tables, record folding, the folded 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 { loadPrices } from './config.ts';
6
+ export { candidates, canonicalModel, chargeFor, costDay, DEFAULT_PRICES, isUncorrectedSeed, priceAt, PRICES_REVISION, PRICING_ENGINE_VERSION, pricesDigest, pricesFrom } from './pricing.ts';
7
+ export { costRecords, foldSamples } from './records.ts';
8
+ export { costAddresses, sessionCostHistory } from './scanner.ts';
9
+ export { MISSING_TIME, MISSING_USAGE, UNSUPPORTED_USAGE } from './types.ts';
10
+ export type { ChargeSample, CostTotal, Coverage, DayTotal, PriceDecision, PriceVersion, Rates, SavedCost, Usage } from './types.ts';
@@ -0,0 +1,8 @@
1
+ /** Cost domain: price tables, record folding, the folded ledger, the scanner and its controller. */
2
+ export { CostController } from "./controller.js";
3
+ export { CostLedger, costText } from "./ledger.js";
4
+ export { loadPrices } from "./config.js";
5
+ export { candidates, canonicalModel, chargeFor, costDay, DEFAULT_PRICES, isUncorrectedSeed, priceAt, PRICES_REVISION, PRICING_ENGINE_VERSION, pricesDigest, pricesFrom } from "./pricing.js";
6
+ export { costRecords, foldSamples } from "./records.js";
7
+ export { costAddresses, sessionCostHistory } from "./scanner.js";
8
+ export { MISSING_TIME, MISSING_USAGE, UNSUPPORTED_USAGE } from "./types.js";
@@ -0,0 +1,16 @@
1
+ import type { SavedCost } from './types.ts';
2
+ /** Load every session's stored totals; a file this build cannot read is left for the next scan.
3
+ * @param directory - Origin-scoped ledger directory, or undefined when persistence is disabled.
4
+ * @returns The newest saved slice per session identity.
5
+ */
6
+ export declare function loadLedgers(directory: string | undefined): Promise<Map<string, SavedCost>>;
7
+ /** Write one session's totals unless the directory already holds a newer scan.
8
+ *
9
+ * The file is the system of record across processes, so a scan that opened an older snapshot must
10
+ * not replace a cut another scan already persisted, and a process holding older decision rules must
11
+ * not seal its totals over newer ones.
12
+ * @param directory - Origin-scoped ledger directory.
13
+ * @param saved - Complete slice to persist.
14
+ * @returns Whether the directory now holds this slice.
15
+ */
16
+ export declare function saveLedger(directory: string, saved: SavedCost): Promise<boolean>;
@@ -0,0 +1,103 @@
1
+ /** Atomic per-session persistence for folded cost totals. */
2
+ import { createHash } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ import { ensureDirectory, listEntries, readText, writePrivateFile } from "../storage/index.js";
5
+ /** Current on-disk ledger generation. A file of another generation is ignored: the next scan rebuilds it. */
6
+ const LEDGER_VERSION = 3;
7
+ /** The one file that holds a session's totals: the fixed per-session name. */
8
+ function ledgerName(sessionId) {
9
+ return `${createHash('sha256').update(sessionId).digest('hex')}.json`;
10
+ }
11
+ /** Load every session's stored totals; a file this build cannot read is left for the next scan.
12
+ * @param directory - Origin-scoped ledger directory, or undefined when persistence is disabled.
13
+ * @returns The newest saved slice per session identity.
14
+ */
15
+ export async function loadLedgers(directory) {
16
+ const sessions = new Map();
17
+ if (!directory)
18
+ return sessions;
19
+ await ensureDirectory(directory);
20
+ for (const name of await listEntries(directory)) {
21
+ if (!/^[0-9a-f]{64}\.json$/.test(name))
22
+ continue;
23
+ const path = join(directory, name);
24
+ const raw = await readText(path);
25
+ // A file removed between listing and reading is simply absent.
26
+ if (raw === undefined)
27
+ continue;
28
+ const saved = parseLedger(raw);
29
+ // A slice is a projection of the host log: one this build cannot use costs a rescan, not data.
30
+ if (saved === undefined)
31
+ continue;
32
+ if ((sessions.get(saved.sessionId)?.cut ?? -2) <= saved.cut)
33
+ sessions.set(saved.sessionId, saved);
34
+ }
35
+ return sessions;
36
+ }
37
+ /** Write one session's totals unless the directory already holds a newer scan.
38
+ *
39
+ * The file is the system of record across processes, so a scan that opened an older snapshot must
40
+ * not replace a cut another scan already persisted, and a process holding older decision rules must
41
+ * not seal its totals over newer ones.
42
+ * @param directory - Origin-scoped ledger directory.
43
+ * @param saved - Complete slice to persist.
44
+ * @returns Whether the directory now holds this slice.
45
+ */
46
+ export async function saveLedger(directory, saved) {
47
+ const path = join(directory, ledgerName(saved.sessionId));
48
+ const existing = await readText(path);
49
+ if (existing !== undefined) {
50
+ const previous = parseLedger(existing);
51
+ if (previous !== undefined && (previous.cut > saved.cut || previous.engine > saved.engine))
52
+ return false;
53
+ }
54
+ await writePrivateFile(path, JSON.stringify(saved) + '\n');
55
+ return true;
56
+ }
57
+ /** Validate one persisted slice; another generation or an unreadable shape is absent. */
58
+ function parseLedger(raw) {
59
+ let value;
60
+ try {
61
+ value = JSON.parse(raw);
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
67
+ return undefined;
68
+ const record = value;
69
+ if (record.version !== LEDGER_VERSION)
70
+ return undefined;
71
+ if (typeof record.sessionId !== 'string' || !Number.isSafeInteger(record.cut))
72
+ throw new Error('Invalid cost ledger');
73
+ if (!Number.isSafeInteger(record.engine) || typeof record.catalog !== 'string')
74
+ throw new Error('Invalid cost ledger');
75
+ const total = validTotal(record.total);
76
+ const day = validDay(record.day);
77
+ if (total === undefined || day === undefined)
78
+ throw new Error('Invalid cost ledger');
79
+ if (!Array.isArray(record.unpriced) || record.unpriced.some(reason => typeof reason !== 'string'))
80
+ throw new Error('Invalid cost ledger');
81
+ return { version: LEDGER_VERSION, sessionId: record.sessionId, cut: record.cut, engine: record.engine,
82
+ catalog: record.catalog, total, day, unpriced: record.unpriced };
83
+ }
84
+ /** Accept one stored subtotal only when the amount is finite and the counts are whole.
85
+ * Costs are fractional, so only the counts are required to be integers. */
86
+ function validTotal(value) {
87
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
88
+ return undefined;
89
+ const total = value;
90
+ const amount = total.amount;
91
+ if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0)
92
+ return undefined;
93
+ const counts = [total.unknown, total.records];
94
+ if (!counts.every(count => typeof count === 'number' && Number.isSafeInteger(count) && count >= 0))
95
+ return undefined;
96
+ return { amount, unknown: total.unknown, records: total.records };
97
+ }
98
+ /** Accept one stored day bucket only when it also names the Beijing day it covers. */
99
+ function validDay(value) {
100
+ const total = validTotal(value);
101
+ const day = value?.day;
102
+ return total === undefined || typeof day !== 'string' || day === '' ? undefined : { day, ...total };
103
+ }
@@ -0,0 +1,78 @@
1
+ /** CNY estimates folded from host history: per-session totals, re-decided by every scan. */
2
+ import type { ObjectValue } from '../transport/wire.ts';
3
+ import { type CostTotal, type Coverage, type PriceVersion } from './types.ts';
4
+ /** Per-origin cache of folded session totals; every scan replaces a session at its cut. */
5
+ export declare class CostLedger {
6
+ readonly prices: PriceVersion[];
7
+ readonly directory?: string | undefined;
8
+ /** Whether the table came from a file the user maintains, rather than the shipped one. */
9
+ readonly customPrices: boolean;
10
+ private sessions;
11
+ private totals;
12
+ private readonly catalog;
13
+ scannedAt?: number;
14
+ scanning: boolean;
15
+ error: string;
16
+ /** Work the last completed scan performed, so a memory sample can attribute its allocation. */
17
+ lastScan?: {
18
+ sessions: number;
19
+ pages: number;
20
+ events: number;
21
+ };
22
+ constructor(prices?: PriceVersion[], directory?: string | undefined,
23
+ /** Whether the table came from a file the user maintains, rather than the shipped one. */
24
+ customPrices?: boolean);
25
+ /** Cached totals count as complete; only a failed scan or an empty ledger is partial.
26
+ * @returns Coverage of the current totals, so callers can mark them without re-deriving the rule.
27
+ */
28
+ get coverage(): Coverage;
29
+ /** Load the newest cut per session; a stored total is read as it was decided. */
30
+ load(): Promise<void>;
31
+ /** Replace one session using all billing events through the opening snapshot cut.
32
+ *
33
+ * The fold is the projection: every sample is decided again with the table loaded now, so a
34
+ * corrected table reaches history on the next scan and a request no table covered yet is priced
35
+ * as soon as one does. Only the totals are kept, and the cut and engine keep an older scan from
36
+ * replacing a newer one.
37
+ * @param sessionId - Host session identity.
38
+ * @param cut - Opening cursor, preventing a stale scan from overwriting a newer scan.
39
+ * @param events - Minimal events returned by `costRecords`, across all history pages.
40
+ * @param now - Clock that names the calendar day the day bucket covers.
41
+ */
42
+ replace(sessionId: string, cut: number, events: ObjectValue[], now?: number): Promise<void>;
43
+ /** Count the retained ledger so a memory sample can separate it from the transcript window.
44
+ * @returns Sessions, requests and unpriced requests currently held.
45
+ */
46
+ summary(): {
47
+ sessions: number;
48
+ records: number;
49
+ unpriced: number;
50
+ };
51
+ /** Whether this session has a complete cached scan.
52
+ * @param sessionId - Selected session identity, which may be unset before one is picked.
53
+ * @returns True when a complete scan is available, narrowing the identity to a string.
54
+ */
55
+ hasSession(sessionId: string | undefined): sessionId is string;
56
+ /** Describe unpriced model/usage combinations without exposing conversation content.
57
+ * @returns Unique reasons across cached sessions.
58
+ */
59
+ missing(): string[];
60
+ /** One session's stored totals.
61
+ * @param sessionId - Session identity to report.
62
+ * @returns The total a scan folded for it, or zeros when no scan has covered it.
63
+ */
64
+ total(sessionId: string): CostTotal;
65
+ /** Every session's requests on one Beijing calendar day.
66
+ *
67
+ * A slice keeps only the day its last scan ran on, so another day reads as nothing spent today
68
+ * rather than as the last day that was scanned.
69
+ * @param now - Clock that names the day to report.
70
+ * @returns The day's total across cached sessions.
71
+ */
72
+ today(now?: number): CostTotal;
73
+ }
74
+ /** Compact estimates retain an asterisk whenever a subtotal is not exact.
75
+ * @param total - Summary from the ledger.
76
+ * @returns Yuan amount and incompleteness marker.
77
+ */
78
+ export declare function costText(total: CostTotal): string;