@itookit/dsht 0.3.2 → 0.3.4

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 (55) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +21 -20
  3. package/README.zh.md +21 -20
  4. package/dist/cli/dsht.d.ts +2 -0
  5. package/dist/cli/dsht.js +125 -0
  6. package/dist/cli/index.js +16 -126
  7. package/dist/controller/controller.d.ts +18 -1
  8. package/dist/controller/controller.js +24 -2
  9. package/dist/controller/perf-measures.d.ts +34 -0
  10. package/dist/controller/perf-measures.js +78 -0
  11. package/dist/cost/config.d.ts +17 -0
  12. package/dist/cost/config.js +68 -0
  13. package/dist/cost/controller.d.ts +9 -2
  14. package/dist/cost/controller.js +10 -2
  15. package/dist/cost/index.d.ts +5 -4
  16. package/dist/cost/index.js +4 -3
  17. package/dist/cost/ledger-files.d.ts +4 -8
  18. package/dist/cost/ledger-files.js +46 -71
  19. package/dist/cost/ledger.d.ts +33 -20
  20. package/dist/cost/ledger.js +78 -68
  21. package/dist/cost/pricing.d.ts +55 -17
  22. package/dist/cost/pricing.js +126 -44
  23. package/dist/cost/records.js +18 -5
  24. package/dist/cost/types.d.ts +34 -18
  25. package/dist/cost/types.js +4 -0
  26. package/dist/session/controller.d.ts +16 -0
  27. package/dist/session/controller.js +33 -0
  28. package/dist/session/navigation.d.ts +80 -0
  29. package/dist/session/navigation.js +107 -0
  30. package/dist/session/transcript.d.ts +48 -6
  31. package/dist/session/transcript.js +117 -14
  32. package/dist/storage/files.d.ts +8 -0
  33. package/dist/storage/files.js +17 -0
  34. package/dist/storage/heap-snapshot.d.ts +19 -0
  35. package/dist/storage/heap-snapshot.js +29 -0
  36. package/dist/storage/index.d.ts +2 -1
  37. package/dist/storage/index.js +2 -1
  38. package/dist/ui/app.js +116 -10
  39. package/dist/ui/chat/history-view.d.ts +5 -1
  40. package/dist/ui/chat/history-view.js +9 -4
  41. package/dist/ui/chat/status.d.ts +16 -4
  42. package/dist/ui/chat/status.js +112 -32
  43. package/dist/ui/commands/parse.d.ts +3 -0
  44. package/dist/ui/commands/parse.js +5 -0
  45. package/dist/ui/commands/registry.js +1 -0
  46. package/dist/ui/dialogs/cost.js +3 -3
  47. package/dist/ui/dialogs/index.d.ts +6 -2
  48. package/dist/ui/dialogs/index.js +3 -3
  49. package/dist/ui/dialogs/picker.d.ts +21 -3
  50. package/dist/ui/dialogs/picker.js +37 -5
  51. package/dist/ui/input/input.d.ts +18 -3
  52. package/dist/ui/input/input.js +61 -22
  53. package/dist/ui/input/viewport.d.ts +96 -0
  54. package/dist/ui/input/viewport.js +173 -0
  55. package/package.json +3 -3
package/dist/cli/index.js CHANGED
@@ -1,128 +1,18 @@
1
1
  #!/usr/bin/env node
2
- /** Standalone executable entry; connects to an existing host and never launches Harness. */
3
- import { createHash } from 'node:crypto';
4
- import { homedir } from 'node:os';
5
- import { join } from 'node:path';
6
- import { CostLedger, DEFAULT_PRICES, pricesFrom } from "../cost/index.js";
7
- import { parseArgs } from 'node:util';
8
- import { mount } from "../ui/mount.js";
9
- import { createPrivateFile, ensureDirectory, readText } from "../storage/index.js";
10
- import { sessionLabel } from "../session/navigation.js";
11
- import { CookieStore, login } from "../transport/auth.js";
12
- import { Client } from "../transport/client.js";
13
- import { historyLimits } from "../session/memory.js";
14
- import { Controller } from "../controller/controller.js";
15
- import { endpoint } from "../transport/endpoint.js";
16
- import { errorText, safeText, string } from "../transport/wire.js";
17
- const HELP = `Usage: dsht [options] [list workspaces|list sessions]
18
-
19
- With no command, choose a workspace and session interactively.
20
-
21
- --url <url> Host URL, or the dsh web URL with ?token= (DSH_URL)
22
- --workspace <id> Filter list sessions by workspace
23
- --session <id> Open a session directly
24
- --auth-dir <path> Private cookie directory (or DSHT_AUTH_DIR)
25
- --history-records <n> Soft history record limit (default 2000)
26
- --history-mb <n> Soft history payload budget in MiB (default 16)
27
- --memory-log <path> Append runtime memory samples; a failing log stops itself
28
- --no-memory-log Disable the runtime memory log (default: enabled)
29
- --json Print machine-readable list output
30
- --help Show this help
31
-
32
- The default host is http://127.0.0.1:3080.
33
- First login: export DSH_TOKEN, or export DSH_URL as the URL printed by dsh web.
34
- Cookies are saved per server origin and reused on later starts. Tokens are never saved.
35
- /cost shows session, today and three-day CNY estimates.
36
- DSHT_CONFIG_DIR overrides the prices.json directory; DSHT_STATE_DIR overrides usage storage.
37
- The memory log defaults to <state>/memory.log; DSHT_MEMORY_LOG sets another path or 'off'.
38
- Examples:
39
- npx @itookit/dsht
40
- dsht list workspaces --json
41
- dsht list sessions --workspace <id> --json
42
- `;
43
- async function main() {
44
- const { values, positionals } = parseArgs({ allowPositionals: true, options: {
45
- url: { type: 'string', default: process.env.DSH_URL ?? 'http://127.0.0.1:3080' },
46
- 'history-records': { type: 'string' }, 'history-mb': { type: 'string' },
47
- workspace: { type: 'string' }, session: { type: 'string' }, 'auth-dir': { type: 'string' }, json: { type: 'boolean' }, help: { type: 'boolean' },
48
- 'memory-log': { type: 'string' }, 'no-memory-log': { type: 'boolean' },
49
- } });
50
- if (values.help) {
51
- process.stdout.write(HELP);
52
- return;
53
- }
54
- const list = positionals[0] === 'list' && ['workspaces', 'sessions'].includes(positionals[1] ?? '') && positionals.length === 2;
55
- if (positionals.length && !list)
56
- throw new Error('Unknown command. Use --help.');
57
- if (!list && (values.json || values.workspace))
58
- throw new Error('--json and --workspace apply to list commands');
59
- if (list && values.session)
60
- throw new Error('--session applies to interactive mode');
61
- const limits = historyLimits(values['history-records'], values['history-mb']);
62
- const { url, token } = endpoint(values.url, process.env.DSH_TOKEN);
63
- const store = new CookieStore(values['auth-dir']);
64
- if (list) {
65
- const client = new Client(url);
66
- try {
67
- await login(client, token, store);
68
- if (positionals[1] === 'workspaces' || values.workspace)
69
- await client.connect();
70
- const items = positionals[1] === 'workspaces' ? await client.listWorkspaces() : await client.listSessions(values.workspace);
71
- if (values.json)
72
- process.stdout.write(`${JSON.stringify({ items }, null, 2)}\n`);
73
- else {
74
- const lines = items.map(item => positionals[1] === 'workspaces'
75
- ? `${string(item.workspaceId)}\t${string(item.title)}\t${string(item.path)}`
76
- : `${string(item.sessionId)}\t${sessionLabel(item)}\t${item.running ? 'running' : 'idle'}`);
77
- process.stdout.write(`${safeText(lines.join('\n'))}${lines.length ? '\n' : ''}`);
78
- }
79
- }
80
- finally {
81
- await client.close();
82
- }
83
- return;
84
- }
85
- if (!process.stdin.isTTY || !process.stdout.isTTY)
86
- throw new Error('Interactive mode requires a terminal. Use list workspaces or list sessions for scripts.');
87
- const config = process.env.DSHT_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'dsht');
88
- await ensureDirectory(config);
89
- const pricePath = join(config, 'prices.json');
90
- await createPrivateFile(pricePath, JSON.stringify(DEFAULT_PRICES, null, 2) + '\n');
91
- const raw = await readText(pricePath);
92
- if (raw === undefined)
93
- throw new Error(`Price configuration disappeared: ${pricePath}`);
94
- const prices = pricesFrom(JSON.parse(raw));
95
- const stateRoot = process.env.DSHT_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'dsht');
96
- const costDirectory = join(stateRoot, 'cost', createHash('sha256').update(new URL(url).origin).digest('hex'));
97
- const costs = new CostLedger(prices, costDirectory);
98
- await costs.load();
99
- const controller = new Controller(url, token, values.session, undefined, client => login(client, token, store), costs, limits, memoryLogPath(stateRoot, values['memory-log'], values['no-memory-log']));
100
- const app = mount(controller);
101
- const terminate = () => app.unmount();
102
- process.once('SIGTERM', terminate);
103
- controller.start();
104
- try {
105
- await app.waitUntilExit();
106
- }
107
- finally {
108
- process.off('SIGTERM', terminate);
109
- await controller.shutdown();
110
- }
111
- }
112
- /** Resolve the runtime memory log path: an explicit flag wins, then the environment, then the default.
113
- * @param stateRoot - Application state root used for the default path.
114
- * @param requested - `--memory-log` value, when given.
115
- * @param disabled - `--no-memory-log` flag.
116
- * @returns Absolute log path, or undefined when the log is disabled.
2
+ /**
3
+ * Executable entry. It must set the React build before anything imports Ink.
4
+ *
5
+ * A static `import` is evaluated before this file's body runs, so importing the CLI
6
+ * directly would let `react-reconciler` resolve to its development build and emit a
7
+ * `performance.measure()` entry for every render. Node's performance timeline holds
8
+ * those entries for the life of the process and never trims them, which grew the heap
9
+ * by roughly 1.2 KB per render until the process was restarted. The dynamic import
10
+ * below is therefore load-bearing: `NODE_ENV` has to be set first.
11
+ *
12
+ * Set `DSHT_REACT_DEV=1` to keep the development build for React warnings and
13
+ * DevTools performance tracks.
117
14
  */
118
- function memoryLogPath(stateRoot, requested, disabled) {
119
- if (requested !== undefined && requested.trim() === '')
120
- throw new Error('--memory-log requires a path');
121
- if (disabled)
122
- return undefined;
123
- const chosen = (requested ?? process.env.DSHT_MEMORY_LOG)?.trim();
124
- if (chosen === undefined || chosen === '')
125
- return join(stateRoot, 'memory.log');
126
- return chosen === 'off' ? undefined : chosen;
127
- }
128
- main().catch(error => { process.stderr.write(`${errorText(error)}\n`); process.exitCode = 1; });
15
+ if ((process.env.DSHT_REACT_DEV ?? '') === '')
16
+ process.env.NODE_ENV ??= 'production';
17
+ await import('./dsht.js');
18
+ export {};
@@ -23,6 +23,8 @@ export declare class Controller implements ControllerStore, ConnectionListener {
23
23
  readonly costs?: CostLedger | undefined;
24
24
  readonly historyLimits: HistoryLimits;
25
25
  readonly memoryLogPath?: string | undefined;
26
+ /** Directory this client runs in, offered as a workspace when the host has not registered it. */
27
+ readonly localDirectory: string;
26
28
  state: State;
27
29
  /** Physical connection, retry loop and projection store. */
28
30
  readonly connection: ConnectionController;
@@ -36,7 +38,9 @@ export declare class Controller implements ControllerStore, ConnectionListener {
36
38
  readonly memoryLog: MemoryLog | undefined;
37
39
  private readonly observers;
38
40
  private selector;
39
- constructor(base: string, token: string | undefined, initialSession?: string | undefined, makeClient?: () => Client, authenticate?: (client: Client) => Promise<void>, costs?: CostLedger | undefined, historyLimits?: HistoryLimits, memoryLogPath?: string | undefined);
41
+ constructor(base: string, token: string | undefined, initialSession?: string | undefined, makeClient?: () => Client, authenticate?: (client: Client) => Promise<void>, costs?: CostLedger | undefined, historyLimits?: HistoryLimits, memoryLogPath?: string | undefined,
42
+ /** Directory this client runs in, offered as a workspace when the host has not registered it. */
43
+ localDirectory?: string);
40
44
  /** React-compatible state subscription. */
41
45
  subscribe: (listener: () => void) => (() => void);
42
46
  /** Snapshot identity changes only when the controller publishes. */
@@ -71,6 +75,10 @@ export declare class Controller implements ControllerStore, ConnectionListener {
71
75
  * diagram cache — and the work the last cost scan re-read, which is the only timer here whose
72
76
  * per-pass work scales with history. With a runtime that exposes `gc`, the sample also reports
73
77
  * the heap after a forced collection, so retained state and uncollected garbage stay distinct.
78
+ *
79
+ * React's development build appends one performance-timeline entry per rendered component and Node
80
+ * never trims them, so the sample counts them and then bounds them; `perfMeasuresCleared` separates
81
+ * entries this sample released from entries the build created since the last one.
74
82
  */
75
83
  private memorySample;
76
84
  /** Collect before reading the heap when the runtime exposes a collection.
@@ -116,6 +124,8 @@ export declare class Controller implements ControllerStore, ConnectionListener {
116
124
  get workingSince(): number | undefined;
117
125
  /** @returns Sessions accounted to the selected workspace, minus archived identities. */
118
126
  get visibleSessions(): ObjectValue[];
127
+ /** @returns Unanswered interactions by session, for the state each list row reports. */
128
+ pendingCounts(): ReadonlyMap<string, number>;
119
129
  /** Load the optional preset roster once per connection. */
120
130
  loadPresetNames(): void;
121
131
  /** @returns Host model routes and adapter-owned reasoning choices. */
@@ -219,6 +229,11 @@ export declare class Controller implements ControllerStore, ConnectionListener {
219
229
  * @returns Absolute saved filename.
220
230
  */
221
231
  exportHtml(path: string | undefined, signal: AbortSignal): Promise<string>;
232
+ /** Write a V8 heap snapshot into this client's working directory; the write pauses the client.
233
+ * @param tag - Sampling-point label naming the file, such as `after-stress`.
234
+ * @returns Absolute path of the written snapshot.
235
+ */
236
+ heapSnapshot(tag?: string): string;
222
237
  /** Admit text once as steering while running, or a new turn while idle.
223
238
  * @param text - Composed prompt text.
224
239
  */
@@ -255,6 +270,8 @@ export declare class Controller implements ControllerStore, ConnectionListener {
255
270
  * @param allowed - Whether the request is approved once.
256
271
  */
257
272
  approve(allowed: boolean): Promise<void>;
273
+ /** Dismiss the whole pending question set without answering it, as the Web close button does. */
274
+ dismissQuestion(): Promise<void>;
258
275
  }
259
276
  export type { HistorySearch, RemovalTarget } from '../session/types.ts';
260
277
  export type { State } from '../state.ts';
@@ -1,5 +1,6 @@
1
1
  /** Application facade: composes the connection, session, catalog and cost domains. */
2
2
  import { Client } from "../transport/client.js";
3
+ import { writeHeapSnapshot } from "../storage/index.js";
3
4
  import { errorText } from "../transport/wire.js";
4
5
  import { DEFAULT_HISTORY_LIMITS } from "../session/memory.js";
5
6
  import { layoutStats } from "../session/history.js";
@@ -9,6 +10,7 @@ import { CatalogController } from "../catalog/controller.js";
9
10
  import { CostController } from "../cost/controller.js";
10
11
  import { ConnectionController } from "./connection.js";
11
12
  import { MemoryLog } from "./memory-log.js";
13
+ import { clearReactMeasures, measureCount } from "./perf-measures.js";
12
14
  import { initialState } from "../state.js";
13
15
  /** Application facade over the domain controllers; the UI owns only this object.
14
16
  *
@@ -21,6 +23,7 @@ export class Controller {
21
23
  costs;
22
24
  historyLimits;
23
25
  memoryLogPath;
26
+ localDirectory;
24
27
  state = initialState();
25
28
  /** Physical connection, retry loop and projection store. */
26
29
  connection;
@@ -34,12 +37,15 @@ export class Controller {
34
37
  memoryLog;
35
38
  observers = new Set();
36
39
  selector = 0;
37
- constructor(base, token, initialSession, makeClient = () => new Client(base), authenticate = client => client.authenticate(token ?? ''), costs, historyLimits = DEFAULT_HISTORY_LIMITS, memoryLogPath) {
40
+ constructor(base, token, initialSession, makeClient = () => new Client(base), authenticate = client => client.authenticate(token ?? ''), costs, historyLimits = DEFAULT_HISTORY_LIMITS, memoryLogPath,
41
+ /** Directory this client runs in, offered as a workspace when the host has not registered it. */
42
+ localDirectory = process.cwd()) {
38
43
  this.base = base;
39
44
  this.initialSession = initialSession;
40
45
  this.costs = costs;
41
46
  this.historyLimits = historyLimits;
42
47
  this.memoryLogPath = memoryLogPath;
48
+ this.localDirectory = localDirectory;
43
49
  const options = { base, token, initialSession, makeClient, authenticate };
44
50
  this.connection = new ConnectionController(this, options, this);
45
51
  this.session = new SessionController(this, this.connection, this.connection, historyLimits);
@@ -124,6 +130,10 @@ export class Controller {
124
130
  * diagram cache — and the work the last cost scan re-read, which is the only timer here whose
125
131
  * per-pass work scales with history. With a runtime that exposes `gc`, the sample also reports
126
132
  * the heap after a forced collection, so retained state and uncollected garbage stay distinct.
133
+ *
134
+ * React's development build appends one performance-timeline entry per rendered component and Node
135
+ * never trims them, so the sample counts them and then bounds them; `perfMeasuresCleared` separates
136
+ * entries this sample released from entries the build created since the last one.
127
137
  */
128
138
  memorySample() {
129
139
  const memory = process.memoryUsage();
@@ -131,11 +141,14 @@ export class Controller {
131
141
  const ledger = this.costs?.summary();
132
142
  const layout = layoutStats(transcript);
133
143
  const markdown = markdownCacheStats();
144
+ const measuresCleared = clearReactMeasures();
145
+ const measures = measureCount();
134
146
  const gc = this.forcedGc();
135
147
  return {
136
148
  time: new Date().toISOString(),
137
149
  rss: memory.rss, heapTotal: memory.heapTotal, heapUsed: memory.heapUsed,
138
150
  external: memory.external, arrayBuffers: memory.arrayBuffers,
151
+ ...(measures === undefined ? {} : { perfMeasures: measures, perfMeasuresCleared: measuresCleared }),
139
152
  ...(gc === undefined ? {} : { heapUsedAfterGc: gc.used, gcMs: gc.ms }),
140
153
  online: this.state.online, screen: this.state.screen,
141
154
  session: this.state.sessionId ?? null,
@@ -150,7 +163,7 @@ export class Controller {
150
163
  scanning: this.costs?.scanning ?? false,
151
164
  ...(this.costs?.lastScan === undefined ? {} : { scanSessions: this.costs.lastScan.sessions,
152
165
  scanPages: this.costs.lastScan.pages, scanEvents: this.costs.lastScan.events }),
153
- ...(ledger === undefined ? {} : { ledgerSessions: ledger.sessions, ledgerCharges: ledger.charges, ledgerUnpriced: ledger.unpriced }),
166
+ ...(ledger === undefined ? {} : { ledgerSessions: ledger.sessions, ledgerRecords: ledger.records, ledgerUnpriced: ledger.unpriced }),
154
167
  };
155
168
  }
156
169
  /** Collect before reading the heap when the runtime exposes a collection.
@@ -219,6 +232,8 @@ export class Controller {
219
232
  get workingSince() { return this.session.workingSince; }
220
233
  /** @returns Sessions accounted to the selected workspace, minus archived identities. */
221
234
  get visibleSessions() { return this.session.visibleSessions; }
235
+ /** @returns Unanswered interactions by session, for the state each list row reports. */
236
+ pendingCounts() { return this.session.pendingCounts(); }
222
237
  /** Load the optional preset roster once per connection. */
223
238
  loadPresetNames() { this.catalog.loadPresetNames(); }
224
239
  /** @returns Host model routes and adapter-owned reasoning choices. */
@@ -323,6 +338,11 @@ export class Controller {
323
338
  * @returns Absolute saved filename.
324
339
  */
325
340
  async exportHtml(path, signal) { return this.session.exportHtml(path, signal); }
341
+ /** Write a V8 heap snapshot into this client's working directory; the write pauses the client.
342
+ * @param tag - Sampling-point label naming the file, such as `after-stress`.
343
+ * @returns Absolute path of the written snapshot.
344
+ */
345
+ heapSnapshot(tag) { return writeHeapSnapshot(process.cwd(), tag); }
326
346
  /** Admit text once as steering while running, or a new turn while idle.
327
347
  * @param text - Composed prompt text.
328
348
  */
@@ -359,4 +379,6 @@ export class Controller {
359
379
  * @param allowed - Whether the request is approved once.
360
380
  */
361
381
  async approve(allowed) { await this.session.approve(allowed); }
382
+ /** Dismiss the whole pending question set without answering it, as the Web close button does. */
383
+ async dismissQuestion() { await this.session.dismissQuestion(); }
362
384
  }
@@ -0,0 +1,34 @@
1
+ /** Drop the render measurements React's development build appends to the global performance timeline.
2
+ *
3
+ * `react-reconciler` emits one `performance.measure()` entry per rendered component when it resolves
4
+ * to its development build, and Node keeps every entry for the life of the process because
5
+ * `performance.clearMeasures()` is the only way to release them. Each entry also carries a
6
+ * `detail.devtools.properties` payload describing the component's props, which is where the memory
7
+ * actually goes: roughly 1.2 KB per render once the duplicated property names are counted.
8
+ *
9
+ * The production build emits no measurements at all, so `src/cli/index.ts` selects it and this
10
+ * module is a safety net for `DSHT_REACT_DEV=1`, where the development build is deliberate.
11
+ */
12
+ /** Measure names the current timeline holds that React created.
13
+ *
14
+ * A name only counts when it is exactly one of `REACT_NAMES` or carries React's zero-width prefix,
15
+ * so entries an application or a library created under its own name are never selected.
16
+ * @param performance - Timeline to read.
17
+ * @returns Names React created, without duplicates.
18
+ */
19
+ export declare function reactMeasureNames(performance: Performance): string[];
20
+ /** Remove React's render measurements, and only those, from the global performance timeline.
21
+ *
22
+ * Intended to run on the memory-sampling interval: the development build keeps appending, so one
23
+ * pass only bounds the total instead of ending it. A failure is not worth propagating, because this
24
+ * is a bounded diagnostic and a measurement library that rejects input should not stop the client.
25
+ * @returns How many distinct measure names were cleared.
26
+ */
27
+ export declare function clearReactMeasures(): number;
28
+ /** How many measure entries the timeline currently holds.
29
+ *
30
+ * Recorded next to the heap counters so a memory log shows whether retained growth tracks the
31
+ * render count. Entries cleared by `clearReactMeasures` leave the count at zero.
32
+ * @returns Entry count, or undefined on a runtime without a readable timeline.
33
+ */
34
+ export declare function measureCount(): number | undefined;
@@ -0,0 +1,78 @@
1
+ /** Drop the render measurements React's development build appends to the global performance timeline.
2
+ *
3
+ * `react-reconciler` emits one `performance.measure()` entry per rendered component when it resolves
4
+ * to its development build, and Node keeps every entry for the life of the process because
5
+ * `performance.clearMeasures()` is the only way to release them. Each entry also carries a
6
+ * `detail.devtools.properties` payload describing the component's props, which is where the memory
7
+ * actually goes: roughly 1.2 KB per render once the duplicated property names are counted.
8
+ *
9
+ * The production build emits no measurements at all, so `src/cli/index.ts` selects it and this
10
+ * module is a safety net for `DSHT_REACT_DEV=1`, where the development build is deliberate.
11
+ */
12
+ /** Zero-width space React prefixes to a component's own measure name; see `ReactFiberPerformanceTrack` in react-reconciler. */
13
+ const COMPONENT_PREFIX = '\u200b';
14
+ /** Measure names React passes as an explicit update trigger, or as the label for an errored or recovered boundary. */
15
+ const REACT_NAMES = [
16
+ 'Update', 'Cascading Update', 'Update Blocked', 'Update Suspended', 'Mount', 'Unmount',
17
+ 'Reconnect', 'Disconnect', 'Recovered', 'Errored',
18
+ ];
19
+ /** Constructors `performance` uses for invalid input, which a measurement library cannot survive. */
20
+ const PROGRAMMING_ERRORS = ['TypeError', 'RangeError', 'SyntaxError'];
21
+ /** The global performance timeline, when the runtime exposes one.
22
+ * @returns The timeline, or undefined on a runtime without `performance` or `getEntriesByType`.
23
+ */
24
+ function timeline() {
25
+ const candidate = globalThis.performance;
26
+ return typeof candidate?.getEntriesByType === 'function' ? candidate : undefined;
27
+ }
28
+ /** Measure names the current timeline holds that React created.
29
+ *
30
+ * A name only counts when it is exactly one of `REACT_NAMES` or carries React's zero-width prefix,
31
+ * so entries an application or a library created under its own name are never selected.
32
+ * @param performance - Timeline to read.
33
+ * @returns Names React created, without duplicates.
34
+ */
35
+ export function reactMeasureNames(performance) {
36
+ const names = new Set();
37
+ for (const entry of performance.getEntriesByType('measure')) {
38
+ const name = entry.name;
39
+ if (name.startsWith(COMPONENT_PREFIX))
40
+ names.add(name);
41
+ else if (REACT_NAMES.includes(name))
42
+ names.add(name);
43
+ }
44
+ return [...names];
45
+ }
46
+ /** Remove React's render measurements, and only those, from the global performance timeline.
47
+ *
48
+ * Intended to run on the memory-sampling interval: the development build keeps appending, so one
49
+ * pass only bounds the total instead of ending it. A failure is not worth propagating, because this
50
+ * is a bounded diagnostic and a measurement library that rejects input should not stop the client.
51
+ * @returns How many distinct measure names were cleared.
52
+ */
53
+ export function clearReactMeasures() {
54
+ const performance = timeline();
55
+ if (performance === undefined)
56
+ return 0;
57
+ const names = reactMeasureNames(performance);
58
+ for (const name of names) {
59
+ try {
60
+ performance.clearMeasures(name);
61
+ }
62
+ catch (error) {
63
+ if (!PROGRAMMING_ERRORS.includes(error?.name ?? ''))
64
+ throw error;
65
+ }
66
+ }
67
+ return names.length;
68
+ }
69
+ /** How many measure entries the timeline currently holds.
70
+ *
71
+ * Recorded next to the heap counters so a memory log shows whether retained growth tracks the
72
+ * render count. Entries cleared by `clearReactMeasures` leave the count at zero.
73
+ * @returns Entry count, or undefined on a runtime without a readable timeline.
74
+ */
75
+ export function measureCount() {
76
+ const performance = timeline();
77
+ return performance?.getEntriesByType('measure').length;
78
+ }
@@ -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
+ }
@@ -21,9 +21,16 @@ export declare class CostController {
21
21
  private timer;
22
22
  private updates;
23
23
  constructor(ledger: CostLedger, host: CostHost);
24
- /** Scan immediately, then once a minute while online. */
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
+ */
25
32
  start(): void;
26
- /** Stop the timer and wait for an in-flight scan; the ledger keeps its cached charges. */
33
+ /** Stop the timer and wait for an in-flight scan; the ledger keeps its folded totals. */
27
34
  stop(): Promise<void>;
28
35
  /** Refresh after the host reports a turn complete. */
29
36
  onTurnIdle(): void;
@@ -14,15 +14,23 @@ export class CostController {
14
14
  this.ledger = ledger;
15
15
  this.host = host;
16
16
  }
17
- /** Scan immediately, then once a minute while online. */
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
+ */
18
25
  start() {
19
26
  if (this.timer)
20
27
  return;
28
+ this.updates.clear();
21
29
  void this.refresh().catch(() => undefined);
22
30
  this.timer = setInterval(() => { if (this.host.online())
23
31
  void this.refresh().catch(() => undefined); }, REFRESH_INTERVAL_MS);
24
32
  }
25
- /** Stop the timer and wait for an in-flight scan; the ledger keeps its cached charges. */
33
+ /** Stop the timer and wait for an in-flight scan; the ledger keeps its folded totals. */
26
34
  async stop() {
27
35
  clearInterval(this.timer);
28
36
  this.timer = undefined;
@@ -1,9 +1,10 @@
1
- /** Cost domain: price tables, record folding, the immutable ledger, the scanner and its controller. */
1
+ /** Cost domain: price tables, record folding, the folded ledger, the scanner and its controller. */
2
2
  export { CostController } from './controller.ts';
3
3
  export type { CostHost } from './controller.ts';
4
4
  export { CostLedger, costText } from './ledger.ts';
5
- export { chargeFor, costDay, DEFAULT_PRICES, lowestPrice, priceAt, pricesFrom } from './pricing.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';
6
7
  export { costRecords, foldSamples } from './records.ts';
7
8
  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';
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';
@@ -1,7 +1,8 @@
1
- /** Cost domain: price tables, record folding, the immutable ledger, the scanner and its controller. */
1
+ /** Cost domain: price tables, record folding, the folded ledger, the scanner and its controller. */
2
2
  export { CostController } from "./controller.js";
3
3
  export { CostLedger, costText } from "./ledger.js";
4
- export { chargeFor, costDay, DEFAULT_PRICES, lowestPrice, priceAt, pricesFrom } from "./pricing.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";
5
6
  export { costRecords, foldSamples } from "./records.js";
6
7
  export { costAddresses, sessionCostHistory } from "./scanner.js";
7
- export { MISSING_USAGE } from "./types.js";
8
+ export { MISSING_TIME, MISSING_USAGE, UNSUPPORTED_USAGE } from "./types.js";
@@ -1,18 +1,14 @@
1
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.
2
+ /** Load every session's stored totals; a file this build cannot read is left for the next scan.
8
3
  * @param directory - Origin-scoped ledger directory, or undefined when persistence is disabled.
9
4
  * @returns The newest saved slice per session identity.
10
5
  */
11
6
  export declare function loadLedgers(directory: string | undefined): Promise<Map<string, SavedCost>>;
12
- /** Write one session cut unless the directory already holds a newer one.
7
+ /** Write one session's totals unless the directory already holds a newer scan.
13
8
  *
14
9
  * 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.
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.
16
12
  * @param directory - Origin-scoped ledger directory.
17
13
  * @param saved - Complete slice to persist.
18
14
  * @returns Whether the directory now holds this slice.