@itookit/dsht 0.5.1 → 0.6.0
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.i18n.yaml +2 -2
- package/README.md +10 -4
- package/README.zh.md +12 -6
- package/dist/catalog/controller.d.ts +26 -6
- package/dist/catalog/controller.js +73 -45
- package/dist/catalog/index.d.ts +1 -0
- package/dist/cli/dsht.js +22 -2
- package/dist/cli/startup.js +30 -11
- package/dist/cli/verifier.d.ts +4 -0
- package/dist/cli/verifier.js +28 -5
- package/dist/contracts.d.ts +42 -5
- package/dist/controller/connection-streams.d.ts +22 -0
- package/dist/controller/connection-streams.js +105 -0
- package/dist/controller/connection.d.ts +14 -3
- package/dist/controller/connection.js +40 -69
- package/dist/controller/controller.d.ts +20 -234
- package/dist/controller/controller.js +113 -811
- package/dist/controller/foreground.d.ts +44 -0
- package/dist/controller/foreground.js +79 -0
- package/dist/controller/loop-coordinator.d.ts +48 -0
- package/dist/controller/loop-coordinator.js +647 -0
- package/dist/controller/loop-prompts-schema.d.ts +16 -2
- package/dist/controller/loop-prompts-schema.js +106 -27
- package/dist/controller/loop-prompts.d.ts +17 -2
- package/dist/controller/loop-prompts.generated.js +2 -1
- package/dist/controller/loop-prompts.js +35 -9
- package/dist/controller/loop-protocols.d.ts +3 -1
- package/dist/controller/loop-protocols.js +8 -3
- package/dist/controller/loop-source.d.ts +74 -0
- package/dist/controller/loop-source.js +224 -0
- package/dist/controller/verifier.d.ts +4 -0
- package/dist/cost/controller.d.ts +3 -1
- package/dist/cost/controller.js +26 -7
- package/dist/cost/index.d.ts +1 -1
- package/dist/cost/index.js +1 -1
- package/dist/cost/ledger-files.d.ts +20 -0
- package/dist/cost/ledger-files.js +115 -15
- package/dist/cost/ledger.d.ts +31 -6
- package/dist/cost/ledger.js +74 -22
- package/dist/cost/pricing.d.ts +39 -0
- package/dist/cost/pricing.js +46 -0
- package/dist/cost/scanner.js +1 -0
- package/dist/cost/types.d.ts +9 -3
- package/dist/session/controller.d.ts +23 -35
- package/dist/session/controller.js +113 -363
- package/dist/session/history-reader.d.ts +32 -0
- package/dist/session/history-reader.js +170 -0
- package/dist/session/index.d.ts +1 -1
- package/dist/session/info.d.ts +3 -38
- package/dist/session/info.js +14 -1
- package/dist/session/interactions.d.ts +26 -0
- package/dist/session/interactions.js +75 -0
- package/dist/session/navigator.d.ts +47 -0
- package/dist/session/navigator.js +158 -0
- package/dist/session/prompt-backfill.d.ts +23 -0
- package/dist/session/prompt-backfill.js +88 -0
- package/dist/session/state.d.ts +20 -0
- package/dist/session/state.js +1 -0
- package/dist/session/telemetry.d.ts +15 -6
- package/dist/session/telemetry.js +44 -7
- package/dist/session/transcript.d.ts +5 -1
- package/dist/slash/index.d.ts +1 -1
- package/dist/slash/parse.d.ts +2 -126
- package/dist/slash/registry.d.ts +1 -1
- package/dist/slash/types.d.ts +126 -0
- package/dist/slash/types.js +1 -0
- package/dist/state.d.ts +5 -17
- package/dist/state.js +1 -1
- package/dist/storage/files.d.ts +8 -0
- package/dist/storage/files.js +18 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.js +1 -1
- package/dist/transport/client.d.ts +4 -3
- package/dist/transport/client.js +71 -25
- package/dist/ui/app.js +88 -301
- package/dist/ui/chat/shell-view.d.ts +2 -0
- package/dist/ui/chat/shell-view.js +8 -0
- package/dist/ui/chat/use-history-view.d.ts +69 -0
- package/dist/ui/chat/use-history-view.js +123 -0
- package/dist/ui/dialogs/cost.d.ts +6 -0
- package/dist/ui/dialogs/cost.js +5 -1
- package/dist/ui/dialogs/loop.d.ts +5 -4
- package/dist/ui/dialogs/loop.js +14 -6
- package/dist/ui/dialogs/use-panels.d.ts +53 -0
- package/dist/ui/dialogs/use-panels.js +51 -0
- package/dist/ui/input/use-composer.d.ts +35 -0
- package/dist/ui/input/use-composer.js +109 -0
- package/dist/ui/input/use-deferred-lines.d.ts +16 -0
- package/dist/ui/input/use-deferred-lines.js +54 -0
- package/dist/ui/input/use-history-recall.d.ts +20 -0
- package/dist/ui/input/use-history-recall.js +47 -0
- package/loop.yaml +230 -0
- package/package.json +5 -4
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/** Resolve the records `/loop` runs: the shipped loop.yaml, a user file layered over it, and the
|
|
2
|
+
* table compiled into this build as the last resort.
|
|
3
|
+
*
|
|
4
|
+
* The loop record is configuration, not code: the shipped file travels with the package and is read
|
|
5
|
+
* at startup, so a fix to a rubric reaches an install without a TypeScript build, and `DSHT_LOOP_FILE`
|
|
6
|
+
* or `<config>/loop.yaml` lets an operator add a record or correct one without forking the project.
|
|
7
|
+
*
|
|
8
|
+
* Layering is per record, never per file. A user file that overrides one record leaves every other
|
|
9
|
+
* shipped record following the package, so the next version's fixes still arrive; a whole-file
|
|
10
|
+
* replacement would freeze the shipped records at whatever the operator copied. A record the user does
|
|
11
|
+
* override can never be updated for them — the tool cannot know whether their copy is deliberate — so
|
|
12
|
+
* the shipped record's digest is stamped under the state directory and a change is reported, not
|
|
13
|
+
* applied.
|
|
14
|
+
*
|
|
15
|
+
* `loop-prompts.generated.ts` stays as the fallback: a package whose loop.yaml is missing or corrupt
|
|
16
|
+
* still starts on the records this build was compiled with, which is also what keeps the module
|
|
17
|
+
* self-contained for a test that must not touch the filesystem.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { parse } from 'yaml';
|
|
23
|
+
import { LOOP_PROMPTS } from "./loop-prompts.generated.js";
|
|
24
|
+
import { validateLoopOverlayPrompts, validateLoopPrompts } from "./loop-prompts-schema.js";
|
|
25
|
+
import { ensureDirectory, readText, writePrivateFile } from "../storage/index.js";
|
|
26
|
+
/** Path of the `loop.yaml` shipped beside this module.
|
|
27
|
+
*
|
|
28
|
+
* The path is relative to the module, so it resolves both in the source tree (`src/controller/`) and
|
|
29
|
+
* in the published build (`dist/controller/`), exactly as the version read in `cli/dsht.tsx` does.
|
|
30
|
+
* @returns Absolute path to the shipped record file.
|
|
31
|
+
*/
|
|
32
|
+
export function shippedLoopFile() {
|
|
33
|
+
return fileURLToPath(new URL('../../loop.yaml', import.meta.url));
|
|
34
|
+
}
|
|
35
|
+
/** The overlay file a process reads, if it reads one at all.
|
|
36
|
+
*
|
|
37
|
+
* An explicit file wins over the configuration directory: it is how a script or a second checkout
|
|
38
|
+
* points at another set of records without moving the one the interactive client uses.
|
|
39
|
+
* @param options - Resolved options.
|
|
40
|
+
* @returns Absolute path of the overlay to try.
|
|
41
|
+
*/
|
|
42
|
+
export function loopOverlayFile(options) {
|
|
43
|
+
return options.overlayFile ?? join(options.configDirectory, 'loop.yaml');
|
|
44
|
+
}
|
|
45
|
+
/** Layer one user file over the shipped records.
|
|
46
|
+
*
|
|
47
|
+
* A record is taken whole: replacing one does not merge its fields with the shipped copy, because a
|
|
48
|
+
* record is a single prompt contract and a half-new brief with a half-old round table is a protocol
|
|
49
|
+
* nobody wrote. Records the file does not name are untouched, which is what lets a shipped fix reach
|
|
50
|
+
* an install that customises something else.
|
|
51
|
+
* @param builtin - Shipped records.
|
|
52
|
+
* @param overlay - Parsed user file.
|
|
53
|
+
* @returns The merged table and the names the user file replaced or added.
|
|
54
|
+
*/
|
|
55
|
+
export function mergeLoopSource(builtin, overlay) {
|
|
56
|
+
const overridden = [];
|
|
57
|
+
const added = [];
|
|
58
|
+
for (const name of Object.keys(overlay.protocols ?? {})) {
|
|
59
|
+
(Object.hasOwn(builtin.protocols, name) ? overridden : added).push(name);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
source: {
|
|
63
|
+
version: 1,
|
|
64
|
+
defaults: {
|
|
65
|
+
score: overlay.defaults?.score ?? builtin.defaults.score,
|
|
66
|
+
tries: overlay.defaults?.tries ?? builtin.defaults.tries,
|
|
67
|
+
},
|
|
68
|
+
protocols: { ...builtin.protocols, ...(overlay.protocols ?? {}) },
|
|
69
|
+
},
|
|
70
|
+
overridden,
|
|
71
|
+
added,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** Read the merged records for one process.
|
|
75
|
+
*
|
|
76
|
+
* The shipped file falls back to the compiled-in table with a warning, because a packaging mistake
|
|
77
|
+
* should not stop the client; a user file does not, because an invalid override the operator wrote is
|
|
78
|
+
* a mistake they can fix and a silent fallback would run the shipped records while they believe their
|
|
79
|
+
* own are in force. An absent overlay is ordinary: most installs have none.
|
|
80
|
+
* @param options - Resolution options.
|
|
81
|
+
* @returns The merged records, their sources and the notes to show the operator.
|
|
82
|
+
* @throws when the overlay exists but is not a valid loop file.
|
|
83
|
+
*/
|
|
84
|
+
export async function loadLoopSource(options) {
|
|
85
|
+
const fallback = options.fallback ?? LOOP_PROMPTS;
|
|
86
|
+
const warnings = [];
|
|
87
|
+
const shipped = await readShipped(options.builtinFile ?? shippedLoopFile(), fallback, warnings);
|
|
88
|
+
const overlayPath = loopOverlayFile(options);
|
|
89
|
+
const raw = await readText(overlayPath);
|
|
90
|
+
if (raw === undefined) {
|
|
91
|
+
return {
|
|
92
|
+
source: shipped.source,
|
|
93
|
+
info: {
|
|
94
|
+
...(shipped.file === undefined ? {} : { builtin: shipped.file }),
|
|
95
|
+
overridden: [], added: [], warnings,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const overlay = parseOverlay(overlayPath, raw);
|
|
100
|
+
const merged = mergeLoopSource(shipped.source, overlay);
|
|
101
|
+
const drift = await stampOverrides(options.stateDirectory, shipped.source, merged.overridden, overlayPath);
|
|
102
|
+
return {
|
|
103
|
+
source: merged.source,
|
|
104
|
+
info: {
|
|
105
|
+
...(shipped.file === undefined ? {} : { builtin: shipped.file }),
|
|
106
|
+
file: overlayPath,
|
|
107
|
+
overridden: merged.overridden,
|
|
108
|
+
added: merged.added,
|
|
109
|
+
warnings: [...warnings, ...drift],
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** Read and validate the shipped file, falling back to the compiled-in table.
|
|
114
|
+
* @param path - Shipped file path.
|
|
115
|
+
* @param fallback - Table to use when the file cannot be read or is invalid.
|
|
116
|
+
* @param warnings - Collector for the note that explains a fallback.
|
|
117
|
+
* @returns The shipped records and the file they came from, if any.
|
|
118
|
+
*/
|
|
119
|
+
async function readShipped(path, fallback, warnings) {
|
|
120
|
+
const raw = await readText(path);
|
|
121
|
+
if (raw === undefined) {
|
|
122
|
+
warnings.push(`Cannot read the shipped records at ${path}; using the table compiled into this build.`);
|
|
123
|
+
return { source: fallback };
|
|
124
|
+
}
|
|
125
|
+
let parsed;
|
|
126
|
+
try {
|
|
127
|
+
parsed = parse(raw);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
warnings.push(`Cannot parse the shipped records at ${path}: ${message(error)}. Using the table compiled into this build.`);
|
|
131
|
+
return { source: fallback };
|
|
132
|
+
}
|
|
133
|
+
const errors = validateLoopPrompts(parsed);
|
|
134
|
+
if (errors.length > 0) {
|
|
135
|
+
warnings.push(`The shipped records at ${path} are invalid: ${errors.join('; ')}. Using the table compiled into this build.`);
|
|
136
|
+
return { source: fallback };
|
|
137
|
+
}
|
|
138
|
+
return { source: parsed, file: path };
|
|
139
|
+
}
|
|
140
|
+
/** Parse one overlay file, refusing anything the renderer would misread.
|
|
141
|
+
* @param path - Overlay file path, used in every message.
|
|
142
|
+
* @param raw - File contents.
|
|
143
|
+
* @returns The parsed overlay.
|
|
144
|
+
* @throws when the file is not valid YAML or does not satisfy the overlay schema.
|
|
145
|
+
*/
|
|
146
|
+
function parseOverlay(path, raw) {
|
|
147
|
+
let parsed;
|
|
148
|
+
try {
|
|
149
|
+
parsed = parse(raw);
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
throw new Error(`${path}: ${message(error)}`);
|
|
153
|
+
}
|
|
154
|
+
const errors = validateLoopOverlayPrompts(parsed);
|
|
155
|
+
if (errors.length > 0)
|
|
156
|
+
throw new Error(`${path}:\n- ${errors.join('\n- ')}`);
|
|
157
|
+
return parsed;
|
|
158
|
+
}
|
|
159
|
+
/** Report a shipped record that changed under the operator's override.
|
|
160
|
+
*
|
|
161
|
+
* The stamp is the only durable trace of which shipped copy an override was written against. When a
|
|
162
|
+
* later version ships a different definition of a record this install overrides, that update cannot
|
|
163
|
+
* be applied — the operator's file wins by design — so it is reported once per change instead of
|
|
164
|
+
* being lost silently. The write is best effort: a state directory that cannot be written costs a
|
|
165
|
+
* warning, never a failed start.
|
|
166
|
+
* @param stateDirectory - Directory holding the stamp; omitted disables the check.
|
|
167
|
+
* @param builtin - Shipped records, read before merging.
|
|
168
|
+
* @param overridden - Names the overlay replaced.
|
|
169
|
+
* @param overlayPath - Overlay file the names came from, named in the warning.
|
|
170
|
+
* @returns One warning per shipped record that changed since it was last seen.
|
|
171
|
+
*/
|
|
172
|
+
async function stampOverrides(stateDirectory, builtin, overridden, overlayPath) {
|
|
173
|
+
if (stateDirectory === undefined)
|
|
174
|
+
return [];
|
|
175
|
+
const path = join(stateDirectory, 'loop-overrides.json');
|
|
176
|
+
const warnings = [];
|
|
177
|
+
const current = {};
|
|
178
|
+
let previous = {};
|
|
179
|
+
const raw = await readText(path);
|
|
180
|
+
// No override and no stamp: an install that never customised a shipped record leaves no file behind.
|
|
181
|
+
if (raw === undefined && overridden.length === 0)
|
|
182
|
+
return [];
|
|
183
|
+
if (raw !== undefined) {
|
|
184
|
+
try {
|
|
185
|
+
const parsed = JSON.parse(raw);
|
|
186
|
+
if (parsed.records !== null && typeof parsed.records === 'object')
|
|
187
|
+
previous = parsed.records;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
previous = {};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
for (const name of overridden) {
|
|
194
|
+
const record = builtin.protocols[name];
|
|
195
|
+
// A name the shipped table does not have is an addition, not an override: there is no upstream
|
|
196
|
+
// definition for it to fall behind.
|
|
197
|
+
if (record === undefined)
|
|
198
|
+
continue;
|
|
199
|
+
const digest = digestOf(record);
|
|
200
|
+
current[name] = digest;
|
|
201
|
+
if (previous[name] !== undefined && previous[name] !== digest) {
|
|
202
|
+
warnings.push(`Your ${name} in ${overlayPath} replaces a shipped record that changed in this version; `
|
|
203
|
+
+ `the shipped update is not applied to your copy.`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const stamp = `${JSON.stringify({ version: 1, records: current }, null, 2)}\n`;
|
|
207
|
+
if (stamp !== raw) {
|
|
208
|
+
try {
|
|
209
|
+
await ensureDirectory(stateDirectory);
|
|
210
|
+
await writePrivateFile(path, stamp);
|
|
211
|
+
}
|
|
212
|
+
catch { /* The stamp only enables a warning; failing to write it must not stop the client. */ }
|
|
213
|
+
}
|
|
214
|
+
return warnings;
|
|
215
|
+
}
|
|
216
|
+
/** Content digest of one record, stable across key order because it hashes the serialized record.
|
|
217
|
+
* @param record - Shipped record.
|
|
218
|
+
* @returns Lowercase hex sha256.
|
|
219
|
+
*/
|
|
220
|
+
function digestOf(record) {
|
|
221
|
+
return createHash('sha256').update(JSON.stringify(record)).digest('hex');
|
|
222
|
+
}
|
|
223
|
+
/** Readable text for anything thrown. */
|
|
224
|
+
function message(error) { return error instanceof Error ? error.message : String(error); }
|
|
@@ -123,4 +123,8 @@ export interface VerifierPort {
|
|
|
123
123
|
* @returns The verdict, or a note explaining why there is none.
|
|
124
124
|
*/
|
|
125
125
|
verify(request: VerifierRequest, signal: AbortSignal): Promise<VerifierOutcome>;
|
|
126
|
+
/** Drain adapter-owned cleanup after all run signals have been aborted, before closing the host.
|
|
127
|
+
* Adapters with child processes or remote sessions implement this; pure in-memory judges need not.
|
|
128
|
+
*/
|
|
129
|
+
settle?(): Promise<void>;
|
|
126
130
|
}
|
|
@@ -12,6 +12,8 @@ export interface CostHost {
|
|
|
12
12
|
signal(): AbortSignal;
|
|
13
13
|
/** Re-publish controller state after the ledger changes. */
|
|
14
14
|
publish(): void;
|
|
15
|
+
/** Session the operator is reading; always scanned, so its own total is shown even when it is old. */
|
|
16
|
+
selectedSessionId?(): string | undefined;
|
|
15
17
|
/** Hand one already-read history page to another consumer, which owns what it does with it. */
|
|
16
18
|
scanPage?(sessionId: string, records: readonly Json[]): void;
|
|
17
19
|
/** Report that a session's history was read to its beginning. */
|
|
@@ -35,7 +37,7 @@ export declare class CostController {
|
|
|
35
37
|
* sessions.
|
|
36
38
|
*/
|
|
37
39
|
start(): void;
|
|
38
|
-
/**
|
|
40
|
+
/** Cancel the timer and active scan, then wait for cleanup; keep already folded totals. */
|
|
39
41
|
stop(): Promise<void>;
|
|
40
42
|
/** Refresh after the host reports a turn complete. */
|
|
41
43
|
onTurnIdle(): void;
|
package/dist/cost/controller.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { array, errorText, object, string } from "../transport/wire.js";
|
|
2
|
+
import { costDayStart, costWindowStart } from "./pricing.js";
|
|
2
3
|
import { sessionCostHistory } from "./scanner.js";
|
|
3
4
|
/** Refresh interval for the background cost scan. */
|
|
4
5
|
const REFRESH_INTERVAL_MS = 60_000;
|
|
@@ -30,10 +31,11 @@ export class CostController {
|
|
|
30
31
|
this.timer = setInterval(() => { if (this.host.online())
|
|
31
32
|
void this.refresh().catch(() => undefined); }, REFRESH_INTERVAL_MS);
|
|
32
33
|
}
|
|
33
|
-
/**
|
|
34
|
+
/** Cancel the timer and active scan, then wait for cleanup; keep already folded totals. */
|
|
34
35
|
async stop() {
|
|
35
36
|
clearInterval(this.timer);
|
|
36
37
|
this.timer = undefined;
|
|
38
|
+
this.abort?.abort();
|
|
37
39
|
await this.task;
|
|
38
40
|
}
|
|
39
41
|
/** Refresh after the host reports a turn complete. */
|
|
@@ -45,8 +47,11 @@ export class CostController {
|
|
|
45
47
|
* @param signal - Optional cancellation for an explicit `/cost` refresh.
|
|
46
48
|
*/
|
|
47
49
|
async refresh(signal = this.host.signal()) {
|
|
50
|
+
signal.throwIfAborted();
|
|
51
|
+
this.host.signal().throwIfAborted();
|
|
48
52
|
if (this.task) {
|
|
49
|
-
const
|
|
53
|
+
const scanAbort = this.abort;
|
|
54
|
+
const cancel = () => scanAbort?.abort();
|
|
50
55
|
signal.addEventListener('abort', cancel, { once: true });
|
|
51
56
|
try {
|
|
52
57
|
await this.task;
|
|
@@ -64,24 +69,36 @@ export class CostController {
|
|
|
64
69
|
const ledger = this.ledger;
|
|
65
70
|
ledger.scanning = true;
|
|
66
71
|
ledger.error = '';
|
|
67
|
-
|
|
68
|
-
const task = (async () => {
|
|
72
|
+
const task = Promise.resolve().then(async () => {
|
|
69
73
|
try {
|
|
74
|
+
combined.throwIfAborted();
|
|
70
75
|
const sessions = array(object(await client.call('session/list', { _request: {} }, combined)).items).map(object);
|
|
71
76
|
combined.throwIfAborted();
|
|
72
77
|
const failures = [];
|
|
73
78
|
let scanned = 0, pages = 0, events = 0;
|
|
79
|
+
// Sessions that cannot have spent anything inside the retained window are not worth paging:
|
|
80
|
+
// their whole history would fold into days the ledger does not keep. Billable activity moves
|
|
81
|
+
// `updatedAt`, so anything that happened while this client was away is still read.
|
|
82
|
+
const floor = costDayStart(costWindowStart(Date.now()));
|
|
83
|
+
const selected = this.host.selectedSessionId?.();
|
|
74
84
|
for (const session of sessions) {
|
|
75
85
|
combined.throwIfAborted();
|
|
76
86
|
const sessionId = string(session.sessionId);
|
|
77
|
-
|
|
78
|
-
|
|
87
|
+
// The selected session is exempt from the window, not from the within-generation skip: the
|
|
88
|
+
// panel reports its own total, but an unchanged history is still not read twice a minute.
|
|
89
|
+
if (!session.running && typeof session.updatedAt === 'number') {
|
|
90
|
+
if (sessionId !== selected && session.updatedAt < floor)
|
|
91
|
+
continue;
|
|
92
|
+
if (this.updates.get(sessionId) === session.updatedAt)
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
79
95
|
try {
|
|
80
96
|
const history = await sessionCostHistory(client, session, combined, () => { pages++; }, records => this.host.scanPage?.(sessionId, records));
|
|
81
97
|
this.host.scanDone?.(sessionId);
|
|
82
98
|
scanned++;
|
|
83
99
|
events += history.events.length;
|
|
84
100
|
await ledger.replace(sessionId, history.cursor, history.events);
|
|
101
|
+
combined.throwIfAborted();
|
|
85
102
|
if (!session.running && typeof session.updatedAt === 'number')
|
|
86
103
|
this.updates.set(sessionId, session.updatedAt);
|
|
87
104
|
this.host.publish();
|
|
@@ -103,8 +120,10 @@ export class CostController {
|
|
|
103
120
|
ledger.scanning = false;
|
|
104
121
|
this.host.publish();
|
|
105
122
|
}
|
|
106
|
-
})
|
|
123
|
+
});
|
|
107
124
|
this.task = task;
|
|
125
|
+
// Publish only after reserving the scan: an observer may synchronously join or cancel it.
|
|
126
|
+
this.host.publish();
|
|
108
127
|
try {
|
|
109
128
|
await task;
|
|
110
129
|
}
|
package/dist/cost/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { CostController } from './controller.ts';
|
|
|
3
3
|
export type { CostHost } from './controller.ts';
|
|
4
4
|
export { CostLedger } from './ledger.ts';
|
|
5
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';
|
|
6
|
+
export { candidates, canonicalModel, chargeFor, costDay, costDayStart, costDaysBefore, costMonthStart, costWeekStart, costWindowStart, COST_WINDOW_DAYS, DEFAULT_PRICES, isUncorrectedSeed, priceAt, PRICES_REVISION, PRICING_ENGINE_VERSION, pricesDigest, pricesFrom } from './pricing.ts';
|
|
7
7
|
export { costRecords, foldSamples } from './records.ts';
|
|
8
8
|
export { costAddresses, sessionCostHistory } from './scanner.ts';
|
|
9
9
|
export { MISSING_TIME, MISSING_USAGE, UNSUPPORTED_USAGE } from './types.ts';
|
package/dist/cost/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export { CostController } from "./controller.js";
|
|
3
3
|
export { CostLedger } from "./ledger.js";
|
|
4
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";
|
|
5
|
+
export { candidates, canonicalModel, chargeFor, costDay, costDayStart, costDaysBefore, costMonthStart, costWeekStart, costWindowStart, COST_WINDOW_DAYS, DEFAULT_PRICES, isUncorrectedSeed, priceAt, PRICES_REVISION, PRICING_ENGINE_VERSION, pricesDigest, pricesFrom } from "./pricing.js";
|
|
6
6
|
export { costRecords, foldSamples } from "./records.js";
|
|
7
7
|
export { costAddresses, sessionCostHistory } from "./scanner.js";
|
|
8
8
|
export { MISSING_TIME, MISSING_USAGE, UNSUPPORTED_USAGE } from "./types.js";
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { SavedCost } from './types.ts';
|
|
2
2
|
/** Load every session's stored totals; a file this build cannot read is left for the next scan.
|
|
3
|
+
*
|
|
4
|
+
* A slice is a projection of the host log, so a file this build cannot parse costs a rescan and never
|
|
5
|
+
* stops the client: an unreadable or malformed one is skipped exactly like an older generation.
|
|
3
6
|
* @param directory - Origin-scoped ledger directory, or undefined when persistence is disabled.
|
|
4
7
|
* @returns The newest saved slice per session identity.
|
|
5
8
|
*/
|
|
@@ -14,3 +17,20 @@ export declare function loadLedgers(directory: string | undefined): Promise<Map<
|
|
|
14
17
|
* @returns Whether the directory now holds this slice.
|
|
15
18
|
*/
|
|
16
19
|
export declare function saveLedger(directory: string, saved: SavedCost): Promise<boolean>;
|
|
20
|
+
/** Delete the ledger files a retention window no longer needs.
|
|
21
|
+
*
|
|
22
|
+
* Two rules, because a file this build cannot parse still has to age out: a loaded slice whose newest
|
|
23
|
+
* day is outside the window goes immediately, and any ledger-shaped file whose modification time
|
|
24
|
+
* predates the window goes whatever it holds — which is how a file of an older generation, one this
|
|
25
|
+
* build can no longer read, is collected instead of sitting in the directory forever. A slice is
|
|
26
|
+
* always written after the last day it records, so a file older than the window cannot hold a day
|
|
27
|
+
* inside it: neither rule can delete a day the panel would still have shown.
|
|
28
|
+
*
|
|
29
|
+
* Retention is housekeeping, so every failure here is swallowed: a state directory that is read-only,
|
|
30
|
+
* owned by someone else, or being written by another process costs a stale file, never a start.
|
|
31
|
+
* @param directory - Origin-scoped ledger directory, or undefined when persistence is disabled.
|
|
32
|
+
* @param floorDay - Oldest Beijing day still kept, YYYY-MM-DD.
|
|
33
|
+
* @param loaded - Slices just loaded, so their own days decide before their timestamp does.
|
|
34
|
+
* @returns Session identities whose loaded slice was deleted.
|
|
35
|
+
*/
|
|
36
|
+
export declare function pruneLedgers(directory: string | undefined, floorDay: string, loaded: ReadonlyMap<string, SavedCost>): Promise<string[]>;
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/** Atomic per-session persistence for folded cost totals. */
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { ensureDirectory, listEntries, readText, writePrivateFile } from "../storage/index.js";
|
|
4
|
+
import { ensureDirectory, listEntries, modifiedAt, readText, removeFile, writePrivateFile } from "../storage/index.js";
|
|
5
5
|
/** Current on-disk ledger generation. A file of another generation is ignored: the next scan rebuilds it. */
|
|
6
|
-
const LEDGER_VERSION =
|
|
6
|
+
const LEDGER_VERSION = 4;
|
|
7
7
|
/** The one file that holds a session's totals: the fixed per-session name. */
|
|
8
8
|
function ledgerName(sessionId) {
|
|
9
9
|
return `${createHash('sha256').update(sessionId).digest('hex')}.json`;
|
|
10
10
|
}
|
|
11
11
|
/** Load every session's stored totals; a file this build cannot read is left for the next scan.
|
|
12
|
+
*
|
|
13
|
+
* A slice is a projection of the host log, so a file this build cannot parse costs a rescan and never
|
|
14
|
+
* stops the client: an unreadable or malformed one is skipped exactly like an older generation.
|
|
12
15
|
* @param directory - Origin-scoped ledger directory, or undefined when persistence is disabled.
|
|
13
16
|
* @returns The newest saved slice per session identity.
|
|
14
17
|
*/
|
|
@@ -26,7 +29,6 @@ export async function loadLedgers(directory) {
|
|
|
26
29
|
if (raw === undefined)
|
|
27
30
|
continue;
|
|
28
31
|
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
32
|
if (saved === undefined)
|
|
31
33
|
continue;
|
|
32
34
|
if ((sessions.get(saved.sessionId)?.cut ?? -2) <= saved.cut)
|
|
@@ -54,6 +56,83 @@ export async function saveLedger(directory, saved) {
|
|
|
54
56
|
await writePrivateFile(path, JSON.stringify(saved) + '\n');
|
|
55
57
|
return true;
|
|
56
58
|
}
|
|
59
|
+
/** Delete the ledger files a retention window no longer needs.
|
|
60
|
+
*
|
|
61
|
+
* Two rules, because a file this build cannot parse still has to age out: a loaded slice whose newest
|
|
62
|
+
* day is outside the window goes immediately, and any ledger-shaped file whose modification time
|
|
63
|
+
* predates the window goes whatever it holds — which is how a file of an older generation, one this
|
|
64
|
+
* build can no longer read, is collected instead of sitting in the directory forever. A slice is
|
|
65
|
+
* always written after the last day it records, so a file older than the window cannot hold a day
|
|
66
|
+
* inside it: neither rule can delete a day the panel would still have shown.
|
|
67
|
+
*
|
|
68
|
+
* Retention is housekeeping, so every failure here is swallowed: a state directory that is read-only,
|
|
69
|
+
* owned by someone else, or being written by another process costs a stale file, never a start.
|
|
70
|
+
* @param directory - Origin-scoped ledger directory, or undefined when persistence is disabled.
|
|
71
|
+
* @param floorDay - Oldest Beijing day still kept, YYYY-MM-DD.
|
|
72
|
+
* @param loaded - Slices just loaded, so their own days decide before their timestamp does.
|
|
73
|
+
* @returns Session identities whose loaded slice was deleted.
|
|
74
|
+
*/
|
|
75
|
+
export async function pruneLedgers(directory, floorDay, loaded) {
|
|
76
|
+
if (!directory)
|
|
77
|
+
return [];
|
|
78
|
+
const dropped = [];
|
|
79
|
+
try {
|
|
80
|
+
await ensureDirectory(directory);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return dropped;
|
|
84
|
+
}
|
|
85
|
+
const gone = new Set();
|
|
86
|
+
for (const [sessionId, session] of loaded) {
|
|
87
|
+
// The days are sorted oldest first, so the last one is the newest thing this slice knows. A slice
|
|
88
|
+
// with no days at all carries nothing — no total, no day — and goes too.
|
|
89
|
+
const newest = session.days[session.days.length - 1]?.day;
|
|
90
|
+
if (newest !== undefined && newest >= floorDay)
|
|
91
|
+
continue;
|
|
92
|
+
// A slice that cannot be removed stays loaded: the file is still there, so the totals it holds are
|
|
93
|
+
// still this run's to report.
|
|
94
|
+
if (!await dropLedger(directory, sessionId))
|
|
95
|
+
continue;
|
|
96
|
+
gone.add(sessionId);
|
|
97
|
+
dropped.push(sessionId);
|
|
98
|
+
}
|
|
99
|
+
const floorMs = Date.parse(`${floorDay}T00:00:00+08:00`);
|
|
100
|
+
const kept = new Set([...loaded.keys()].filter(sessionId => !gone.has(sessionId)).map(ledgerName));
|
|
101
|
+
let names;
|
|
102
|
+
try {
|
|
103
|
+
names = await listEntries(directory);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return dropped;
|
|
107
|
+
}
|
|
108
|
+
for (const name of names) {
|
|
109
|
+
if (!/^[0-9a-f]{64}\.json$/.test(name) || kept.has(name))
|
|
110
|
+
continue;
|
|
111
|
+
try {
|
|
112
|
+
// A file removed between listing and stating is simply absent, and needs no deletion.
|
|
113
|
+
const modified = await modifiedAt(join(directory, name));
|
|
114
|
+
if (modified === undefined || modified >= floorMs)
|
|
115
|
+
continue;
|
|
116
|
+
await removeFile(join(directory, name));
|
|
117
|
+
}
|
|
118
|
+
catch { /* best effort: see the contract above */ }
|
|
119
|
+
}
|
|
120
|
+
return dropped;
|
|
121
|
+
}
|
|
122
|
+
/** Remove one slice, reporting whether the directory is now free of it.
|
|
123
|
+
* @param directory - Origin-scoped ledger directory.
|
|
124
|
+
* @param sessionId - Session whose slice should go.
|
|
125
|
+
* @returns True when the file is gone; false when the removal failed and the file remains.
|
|
126
|
+
*/
|
|
127
|
+
async function dropLedger(directory, sessionId) {
|
|
128
|
+
try {
|
|
129
|
+
await removeFile(join(directory, ledgerName(sessionId)));
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
57
136
|
/** Validate one persisted slice; another generation or an unreadable shape is absent. */
|
|
58
137
|
function parseLedger(raw) {
|
|
59
138
|
let value;
|
|
@@ -68,18 +147,39 @@ function parseLedger(raw) {
|
|
|
68
147
|
const record = value;
|
|
69
148
|
if (record.version !== LEDGER_VERSION)
|
|
70
149
|
return undefined;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
150
|
+
try {
|
|
151
|
+
if (typeof record.sessionId !== 'string' || !Number.isSafeInteger(record.cut))
|
|
152
|
+
throw new Error('Invalid cost ledger');
|
|
153
|
+
if (!Number.isSafeInteger(record.engine) || typeof record.catalog !== 'string')
|
|
154
|
+
throw new Error('Invalid cost ledger');
|
|
155
|
+
const total = validTotal(record.total);
|
|
156
|
+
const days = validDays(record.days);
|
|
157
|
+
if (total === undefined || days === undefined)
|
|
158
|
+
throw new Error('Invalid cost ledger');
|
|
159
|
+
if (!Array.isArray(record.unpriced) || record.unpriced.some(reason => typeof reason !== 'string'))
|
|
160
|
+
throw new Error('Invalid cost ledger');
|
|
161
|
+
return { version: LEDGER_VERSION, sessionId: record.sessionId, cut: record.cut, engine: record.engine,
|
|
162
|
+
catalog: record.catalog, total, days, unpriced: record.unpriced };
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Accept every stored day, or none: a partial day list would silently under-report a period.
|
|
169
|
+
* @param value - The `days` field as parsed.
|
|
170
|
+
* @returns The validated days, or undefined when any entry is unusable.
|
|
171
|
+
*/
|
|
172
|
+
function validDays(value) {
|
|
173
|
+
if (!Array.isArray(value))
|
|
174
|
+
return undefined;
|
|
175
|
+
const days = [];
|
|
176
|
+
for (const entry of value) {
|
|
177
|
+
const total = validDay(entry);
|
|
178
|
+
if (total === undefined)
|
|
179
|
+
return undefined;
|
|
180
|
+
days.push(total);
|
|
181
|
+
}
|
|
182
|
+
return days;
|
|
83
183
|
}
|
|
84
184
|
/** Accept one stored subtotal only when the amount is finite and the counts are whole.
|
|
85
185
|
* Costs are fractional, so only the counts are required to be integers. */
|
package/dist/cost/ledger.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
/** CNY estimates folded from host history: per-session totals, re-decided by every scan. */
|
|
1
|
+
/** CNY estimates folded from host history: per-session totals and per-day buckets, re-decided by every scan. */
|
|
2
2
|
import type { ObjectValue } from '../transport/wire.ts';
|
|
3
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. */
|
|
4
|
+
/** Per-origin cache of folded session totals and day buckets; every scan replaces a session at its cut. */
|
|
5
5
|
export declare class CostLedger {
|
|
6
6
|
readonly prices: PriceVersion[];
|
|
7
7
|
readonly directory?: string | undefined;
|
|
@@ -26,8 +26,14 @@ export declare class CostLedger {
|
|
|
26
26
|
* @returns Coverage of the current totals, so callers can mark them without re-deriving the rule.
|
|
27
27
|
*/
|
|
28
28
|
get coverage(): Coverage;
|
|
29
|
-
/** Load the newest cut per session; a stored total is read as it was decided.
|
|
30
|
-
|
|
29
|
+
/** Load the newest cut per session; a stored total is read as it was decided.
|
|
30
|
+
*
|
|
31
|
+
* Slices and dead files that have left the retention window are deleted in the same pass, so the
|
|
32
|
+
* directory cannot grow with every session this client has ever seen. A slice is a projection of the
|
|
33
|
+
* host log, so letting one go costs a rescan of that session, never data.
|
|
34
|
+
* @param now - Clock that names the window, so a caller or a test can pin the boundary.
|
|
35
|
+
*/
|
|
36
|
+
load(now?: number): Promise<void>;
|
|
31
37
|
/** Replace one session using all billing events through the opening snapshot cut.
|
|
32
38
|
*
|
|
33
39
|
* The fold is the projection: every sample is decided again with the table loaded now, so a
|
|
@@ -64,12 +70,31 @@ export declare class CostLedger {
|
|
|
64
70
|
total(sessionId: string): CostTotal;
|
|
65
71
|
/** Every session's requests on one Beijing calendar day.
|
|
66
72
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
73
|
+
* The day is a real bucket rather than only the day a scan ran on, so asking for another day
|
|
74
|
+
* reports that day's spend; the retention window is what limits how far back the answer reaches.
|
|
69
75
|
* @param now - Clock that names the day to report.
|
|
70
76
|
* @returns The day's total across cached sessions.
|
|
71
77
|
*/
|
|
72
78
|
today(now?: number): CostTotal;
|
|
79
|
+
/** Every session's requests in the natural week containing a day, through that day.
|
|
80
|
+
* @param now - Clock that names the day to report.
|
|
81
|
+
* @returns The week-to-date total across cached sessions.
|
|
82
|
+
*/
|
|
83
|
+
week(now?: number): CostTotal;
|
|
84
|
+
/** Every session's requests in the natural month containing a day, through that day.
|
|
85
|
+
* @param now - Clock that names the day to report.
|
|
86
|
+
* @returns The month-to-date total across cached sessions.
|
|
87
|
+
*/
|
|
88
|
+
month(now?: number): CostTotal;
|
|
89
|
+
/** Sum stored day buckets over a Beijing day range, inclusive at both ends.
|
|
90
|
+
*
|
|
91
|
+
* Days outside the retention window are absent rather than zero, so a range wider than the window
|
|
92
|
+
* reports what is still kept — which is why the fold and this sum share one window constant.
|
|
93
|
+
* @param from - First day to include, YYYY-MM-DD.
|
|
94
|
+
* @param to - Last day to include, YYYY-MM-DD.
|
|
95
|
+
* @returns The range's total across cached sessions.
|
|
96
|
+
*/
|
|
97
|
+
private period;
|
|
73
98
|
}
|
|
74
99
|
/** Compact estimates retain an asterisk whenever a subtotal is not exact.
|
|
75
100
|
* @param total - Summary from the ledger.
|