@itookit/dsht 0.3.3 → 0.3.7

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 (40) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +10 -10
  3. package/README.zh.md +7 -7
  4. package/dist/cli/dsht.d.ts +2 -0
  5. package/dist/cli/dsht.js +125 -0
  6. package/dist/cli/index.js +16 -123
  7. package/dist/controller/controller.d.ts +115 -0
  8. package/dist/controller/controller.js +126 -1
  9. package/dist/controller/perf-measures.d.ts +34 -0
  10. package/dist/controller/perf-measures.js +78 -0
  11. package/dist/cost/controller.d.ts +5 -0
  12. package/dist/cost/controller.js +2 -1
  13. package/dist/cost/scanner.d.ts +4 -2
  14. package/dist/cost/scanner.js +6 -3
  15. package/dist/session/controller.d.ts +157 -1
  16. package/dist/session/controller.js +395 -29
  17. package/dist/session/index.d.ts +2 -0
  18. package/dist/session/index.js +1 -0
  19. package/dist/session/info.d.ts +262 -0
  20. package/dist/session/info.js +326 -0
  21. package/dist/session/navigation.d.ts +62 -8
  22. package/dist/session/navigation.js +72 -13
  23. package/dist/session/transcript.d.ts +37 -1
  24. package/dist/session/transcript.js +73 -0
  25. package/dist/state.d.ts +3 -2
  26. package/dist/state.js +2 -2
  27. package/dist/ui/app.js +230 -178
  28. package/dist/ui/chat/status.js +15 -10
  29. package/dist/ui/dialogs/index.d.ts +9 -8
  30. package/dist/ui/dialogs/index.js +3 -3
  31. package/dist/ui/dialogs/picker.d.ts +21 -3
  32. package/dist/ui/dialogs/picker.js +37 -5
  33. package/dist/ui/input/input.d.ts +18 -3
  34. package/dist/ui/input/input.js +61 -22
  35. package/dist/ui/input/viewport.d.ts +96 -0
  36. package/dist/ui/input/viewport.js +173 -0
  37. package/dsht-m.png +0 -0
  38. package/package.json +3 -3
  39. package/dist/ui/input/history.d.ts +0 -19
  40. package/dist/ui/input/history.js +0 -43
@@ -12,6 +12,8 @@ import { ConnectionController, type ConnectionListener } from './connection.ts';
12
12
  import { MemoryLog } from './memory-log.ts';
13
13
  import { type ControllerStore, type State } from '../state.ts';
14
14
  import type { HistorySearch, RemovalTarget } from '../session/types.ts';
15
+ import type { ComposerState, InteractionState, ModelState, OptionState, PanelState, ReferenceState, ViewState } from '../session/info.ts';
16
+ import type { Reasoning } from '../session/history.ts';
15
17
  /** Application facade over the domain controllers; the UI owns only this object.
16
18
  *
17
19
  * State lives here, connection generations live in `connection`, the selected session and its
@@ -47,6 +49,8 @@ export declare class Controller implements ControllerStore, ConnectionListener {
47
49
  snapshot: () => State;
48
50
  /** Current projection store; replaced at each connection generation. */
49
51
  get telemetry(): Telemetry;
52
+ /** Record of the selected session; the one strong owner lives in `State.session`. */
53
+ get record(): Transcript;
50
54
  /** @returns The current selector generation. */
51
55
  selection(): number;
52
56
  /** Advance the selector generation when the selected workspace or session changes. */
@@ -75,6 +79,10 @@ export declare class Controller implements ControllerStore, ConnectionListener {
75
79
  * diagram cache — and the work the last cost scan re-read, which is the only timer here whose
76
80
  * per-pass work scales with history. With a runtime that exposes `gc`, the sample also reports
77
81
  * the heap after a forced collection, so retained state and uncollected garbage stay distinct.
82
+ *
83
+ * React's development build appends one performance-timeline entry per rendered component and Node
84
+ * never trims them, so the sample counts them and then bounds them; `perfMeasuresCleared` separates
85
+ * entries this sample released from entries the build created since the last one.
78
86
  */
79
87
  private memorySample;
80
88
  /** Collect before reading the heap when the runtime exposes a collection.
@@ -120,6 +128,8 @@ export declare class Controller implements ControllerStore, ConnectionListener {
120
128
  get workingSince(): number | undefined;
121
129
  /** @returns Sessions accounted to the selected workspace, minus archived identities. */
122
130
  get visibleSessions(): ObjectValue[];
131
+ /** @returns Unanswered interactions by session, for the state each list row reports. */
132
+ pendingCounts(): ReadonlyMap<string, number>;
123
133
  /** Load the optional preset roster once per connection. */
124
134
  loadPresetNames(): void;
125
135
  /** @returns Host model routes and adapter-owned reasoning choices. */
@@ -135,10 +145,113 @@ export declare class Controller implements ControllerStore, ConnectionListener {
135
145
  * @returns True when the caller may exit.
136
146
  */
137
147
  interrupt(force?: boolean): Promise<boolean>;
148
+ /** One-line estimate of the selected session's cost, or `?` while the ledger has no entry for it. */
149
+ get sessionCostText(): string;
138
150
  /** Keep history stable while the user reads, searches, or expands it.
139
151
  * @param pinned - Whether the main transcript is being read away from its tail.
140
152
  */
141
153
  pinHistory(pinned: boolean): void;
154
+ /** Recall one step through the selected session's prompt index; never touches the network.
155
+ * @param direction - Negative for older input, positive for newer input.
156
+ * @param current - Composer content before recall began, restored at the newest position.
157
+ * @returns The recalled prompt, or the unsent draft.
158
+ */
159
+ recall(direction: -1 | 1, current: string): string;
160
+ /** Remember a locally submitted command, which never becomes a durable session record.
161
+ * @param value - Submitted command text.
162
+ */
163
+ recordRecall(value: string): void;
164
+ /** Leave recall navigation because the composer was edited or replaced. */
165
+ resetRecall(): void;
166
+ /** Whether recall is parked on the oldest prompt the session retains. */
167
+ get recallAtOldest(): boolean;
168
+ /** How many prompts the selected session retains for recall. */
169
+ get recallLength(): number;
170
+ /** Whether an older prompt is reachable, in the loaded window or on the host. */
171
+ get recallHasOlder(): boolean;
172
+ /** Recover older prompts from the loaded window before spending a page request.
173
+ * @returns Whether any older prompt was recovered.
174
+ */
175
+ refillRecall(): boolean;
176
+ /** Composer draft, caret and parked draft of the selected session. */
177
+ get composer(): ComposerState;
178
+ /** Replace the composer text and caret.
179
+ * @param draft - New text.
180
+ * @param cursor - Caret column; defaults to the end of the text.
181
+ */
182
+ setComposer(draft: string, cursor?: number): void;
183
+ /** Move the caret without changing the text.
184
+ * @param cursor - Caret column.
185
+ */
186
+ setComposerCursor(cursor: number): void;
187
+ /** Move a non-empty draft aside while a dialog owns the keyboard. */
188
+ parkComposer(): void;
189
+ /** Give a parked draft back once no dialog needs the keyboard. */
190
+ restoreComposer(): void;
191
+ /** How the selected session's record is being read right now. */
192
+ get view(): ViewState;
193
+ /** Show a detached history window, releasing the one it replaces.
194
+ * @param window - Record to display, or undefined to return to the live transcript.
195
+ */
196
+ setViewWindow(window?: Transcript): void;
197
+ /** Move the reader's position inside the displayed record.
198
+ * @param scroll - Rows scrolled back from the live end.
199
+ */
200
+ setScroll(scroll: number): void;
201
+ /** Replace the set of expanded reasoning blocks.
202
+ * @param folds - Sequences to expand beyond the default fold.
203
+ */
204
+ setFolds(folds: ReadonlySet<number>): void;
205
+ /** Set the fold mode of the live attempt's completed reasoning.
206
+ * @param reasoning - `row` to fold, `full` to keep the streamed text.
207
+ */
208
+ setLiveReasoning(reasoning: Reasoning): void;
209
+ /** Local answer state for the selected session's pending waterfalls. */
210
+ get interaction(): InteractionState;
211
+ /** Replace the partly collected answers, keyed by waterfall event id.
212
+ * @param answers - Answers collected so far, by event id.
213
+ */
214
+ setAnswers(answers: Record<string, ObjectValue[]>): void;
215
+ /** Replace the pending question's option keyboard state.
216
+ * @param option - Highlighted option, toggled labels and free-text mode; undefined clears it.
217
+ */
218
+ setOption(option?: OptionState): void;
219
+ /** Replace the pending approval's selected row.
220
+ * @param approval - Selected approval row; undefined clears the highlight.
221
+ */
222
+ setApproval(approval?: InteractionState['approval']): void;
223
+ /** Composer-adjacent `@` reference menu state. */
224
+ get reference(): ReferenceState;
225
+ /** Highlight one row of the open reference menu.
226
+ * @param index - Row index into the current matches.
227
+ */
228
+ setReferenceIndex(index: number): void;
229
+ /** Remember the draft that dismissed the reference menu.
230
+ * @param draft - Composer text at dismissal, or undefined to allow the menu again.
231
+ */
232
+ setReferenceDismissed(draft?: string): void;
233
+ /** Panels the selected session has open. */
234
+ get panels(): PanelState;
235
+ /** Show or hide the reasoning panel.
236
+ * @param open - Whether `/think` is open.
237
+ */
238
+ openThoughts(open: boolean): void;
239
+ /** Show or hide the pending-input panel.
240
+ * @param open - Whether `/queue` is open.
241
+ */
242
+ openQueue(open: boolean): void;
243
+ /** Show the model dialog at one step, or close it.
244
+ * @param model - Catalog plus the provider or model being inspected; undefined closes the dialog.
245
+ */
246
+ setModelPanel(model?: ModelState): void;
247
+ /** Show the history or content-search dialog, or close it.
248
+ * @param history - Query, content-search mode and matches; undefined closes the dialog.
249
+ */
250
+ setHistoryPanel(history?: PanelState['history']): void;
251
+ /** Show the host session-search results, or close them.
252
+ * @param search - Query, results and truncation flag; undefined closes the dialog.
253
+ */
254
+ setSearchPanel(search?: PanelState['search']): void;
142
255
  /** Refresh all HTTP-visible sessions without changing the selected conversation.
143
256
  * @param signal - Optional cancellation for an explicit /cost refresh.
144
257
  */
@@ -264,6 +377,8 @@ export declare class Controller implements ControllerStore, ConnectionListener {
264
377
  * @param allowed - Whether the request is approved once.
265
378
  */
266
379
  approve(allowed: boolean): Promise<void>;
380
+ /** Dismiss the whole pending question set without answering it, as the Web close button does. */
381
+ dismissQuestion(): Promise<void>;
267
382
  }
268
383
  export type { HistorySearch, RemovalTarget } from '../session/types.ts';
269
384
  export type { State } from '../state.ts';
@@ -8,8 +8,10 @@ import { markdownCacheStats } from "../session/markdown.js";
8
8
  import { SessionController } from "../session/controller.js";
9
9
  import { CatalogController } from "../catalog/controller.js";
10
10
  import { CostController } from "../cost/controller.js";
11
+ import { costText } from "../cost/ledger.js";
11
12
  import { ConnectionController } from "./connection.js";
12
13
  import { MemoryLog } from "./memory-log.js";
14
+ import { clearReactMeasures, measureCount } from "./perf-measures.js";
13
15
  import { initialState } from "../state.js";
14
16
  /** Application facade over the domain controllers; the UI owns only this object.
15
17
  *
@@ -55,6 +57,10 @@ export class Controller {
55
57
  online: () => this.state.online,
56
58
  signal: () => this.connection.signal(),
57
59
  publish: () => this.update({}),
60
+ // The scan already reads every session's whole history; handing its pages to the session
61
+ // domain lets the prompt cache pick them up, so one open does not pay for a second walk.
62
+ scanPage: (sessionId, records) => this.session.rememberScanPage(sessionId, records),
63
+ scanDone: sessionId => this.session.rememberScanDone(sessionId),
58
64
  });
59
65
  if (memoryLogPath !== undefined)
60
66
  this.memoryLog = new MemoryLog(memoryLogPath, () => this.memorySample());
@@ -68,6 +74,8 @@ export class Controller {
68
74
  snapshot = () => this.state;
69
75
  /** Current projection store; replaced at each connection generation. */
70
76
  get telemetry() { return this.connection.telemetry; }
77
+ /** Record of the selected session; the one strong owner lives in `State.session`. */
78
+ get record() { return this.state.session.record; }
71
79
  /** @returns The current selector generation. */
72
80
  selection() { return this.selector; }
73
81
  /** Advance the selector generation when the selected workspace or session changes. */
@@ -129,18 +137,25 @@ export class Controller {
129
137
  * diagram cache — and the work the last cost scan re-read, which is the only timer here whose
130
138
  * per-pass work scales with history. With a runtime that exposes `gc`, the sample also reports
131
139
  * the heap after a forced collection, so retained state and uncollected garbage stay distinct.
140
+ *
141
+ * React's development build appends one performance-timeline entry per rendered component and Node
142
+ * never trims them, so the sample counts them and then bounds them; `perfMeasuresCleared` separates
143
+ * entries this sample released from entries the build created since the last one.
132
144
  */
133
145
  memorySample() {
134
146
  const memory = process.memoryUsage();
135
- const transcript = this.state.transcript;
147
+ const transcript = this.state.session.record;
136
148
  const ledger = this.costs?.summary();
137
149
  const layout = layoutStats(transcript);
138
150
  const markdown = markdownCacheStats();
151
+ const measuresCleared = clearReactMeasures();
152
+ const measures = measureCount();
139
153
  const gc = this.forcedGc();
140
154
  return {
141
155
  time: new Date().toISOString(),
142
156
  rss: memory.rss, heapTotal: memory.heapTotal, heapUsed: memory.heapUsed,
143
157
  external: memory.external, arrayBuffers: memory.arrayBuffers,
158
+ ...(measures === undefined ? {} : { perfMeasures: measures, perfMeasuresCleared: measuresCleared }),
144
159
  ...(gc === undefined ? {} : { heapUsedAfterGc: gc.used, gcMs: gc.ms }),
145
160
  online: this.state.online, screen: this.state.screen,
146
161
  session: this.state.sessionId ?? null,
@@ -224,6 +239,8 @@ export class Controller {
224
239
  get workingSince() { return this.session.workingSince; }
225
240
  /** @returns Sessions accounted to the selected workspace, minus archived identities. */
226
241
  get visibleSessions() { return this.session.visibleSessions; }
242
+ /** @returns Unanswered interactions by session, for the state each list row reports. */
243
+ pendingCounts() { return this.session.pendingCounts(); }
227
244
  /** Load the optional preset roster once per connection. */
228
245
  loadPresetNames() { this.catalog.loadPresetNames(); }
229
246
  /** @returns Host model routes and adapter-owned reasoning choices. */
@@ -241,10 +258,116 @@ export class Controller {
241
258
  * @returns True when the caller may exit.
242
259
  */
243
260
  interrupt(force = false) { return this.session.interrupt(force); }
261
+ /** One-line estimate of the selected session's cost, or `?` while the ledger has no entry for it. */
262
+ get sessionCostText() {
263
+ const sessionId = this.state.sessionId;
264
+ return this.costs?.hasSession(sessionId) ? costText(this.costs.total(sessionId)) : '?';
265
+ }
244
266
  /** Keep history stable while the user reads, searches, or expands it.
245
267
  * @param pinned - Whether the main transcript is being read away from its tail.
246
268
  */
247
269
  pinHistory(pinned) { this.session.pinHistory(pinned); }
270
+ /** Recall one step through the selected session's prompt index; never touches the network.
271
+ * @param direction - Negative for older input, positive for newer input.
272
+ * @param current - Composer content before recall began, restored at the newest position.
273
+ * @returns The recalled prompt, or the unsent draft.
274
+ */
275
+ recall(direction, current) { return this.session.recall(direction, current); }
276
+ /** Remember a locally submitted command, which never becomes a durable session record.
277
+ * @param value - Submitted command text.
278
+ */
279
+ recordRecall(value) { this.session.recordRecall(value); }
280
+ /** Leave recall navigation because the composer was edited or replaced. */
281
+ resetRecall() { this.session.resetRecall(); }
282
+ /** Whether recall is parked on the oldest prompt the session retains. */
283
+ get recallAtOldest() { return this.session.recallAtOldest; }
284
+ /** How many prompts the selected session retains for recall. */
285
+ get recallLength() { return this.session.recallLength; }
286
+ /** Whether an older prompt is reachable, in the loaded window or on the host. */
287
+ get recallHasOlder() { return this.session.recallHasOlder; }
288
+ /** Recover older prompts from the loaded window before spending a page request.
289
+ * @returns Whether any older prompt was recovered.
290
+ */
291
+ refillRecall() { return this.session.refillRecall(); }
292
+ /** Composer draft, caret and parked draft of the selected session. */
293
+ get composer() { return this.session.composer; }
294
+ /** Replace the composer text and caret.
295
+ * @param draft - New text.
296
+ * @param cursor - Caret column; defaults to the end of the text.
297
+ */
298
+ setComposer(draft, cursor) { this.session.setComposer(draft, cursor); }
299
+ /** Move the caret without changing the text.
300
+ * @param cursor - Caret column.
301
+ */
302
+ setComposerCursor(cursor) { this.session.setComposerCursor(cursor); }
303
+ /** Move a non-empty draft aside while a dialog owns the keyboard. */
304
+ parkComposer() { this.session.parkComposer(); }
305
+ /** Give a parked draft back once no dialog needs the keyboard. */
306
+ restoreComposer() { this.session.restoreComposer(); }
307
+ /** How the selected session's record is being read right now. */
308
+ get view() { return this.session.view; }
309
+ /** Show a detached history window, releasing the one it replaces.
310
+ * @param window - Record to display, or undefined to return to the live transcript.
311
+ */
312
+ setViewWindow(window) { this.session.setViewWindow(window); }
313
+ /** Move the reader's position inside the displayed record.
314
+ * @param scroll - Rows scrolled back from the live end.
315
+ */
316
+ setScroll(scroll) { this.session.setScroll(scroll); }
317
+ /** Replace the set of expanded reasoning blocks.
318
+ * @param folds - Sequences to expand beyond the default fold.
319
+ */
320
+ setFolds(folds) { this.session.setFolds(folds); }
321
+ /** Set the fold mode of the live attempt's completed reasoning.
322
+ * @param reasoning - `row` to fold, `full` to keep the streamed text.
323
+ */
324
+ setLiveReasoning(reasoning) { this.session.setLiveReasoning(reasoning); }
325
+ /** Local answer state for the selected session's pending waterfalls. */
326
+ get interaction() { return this.session.interaction; }
327
+ /** Replace the partly collected answers, keyed by waterfall event id.
328
+ * @param answers - Answers collected so far, by event id.
329
+ */
330
+ setAnswers(answers) { this.session.setAnswers(answers); }
331
+ /** Replace the pending question's option keyboard state.
332
+ * @param option - Highlighted option, toggled labels and free-text mode; undefined clears it.
333
+ */
334
+ setOption(option) { this.session.setOption(option); }
335
+ /** Replace the pending approval's selected row.
336
+ * @param approval - Selected approval row; undefined clears the highlight.
337
+ */
338
+ setApproval(approval) { this.session.setApproval(approval); }
339
+ /** Composer-adjacent `@` reference menu state. */
340
+ get reference() { return this.session.reference; }
341
+ /** Highlight one row of the open reference menu.
342
+ * @param index - Row index into the current matches.
343
+ */
344
+ setReferenceIndex(index) { this.session.setReferenceIndex(index); }
345
+ /** Remember the draft that dismissed the reference menu.
346
+ * @param draft - Composer text at dismissal, or undefined to allow the menu again.
347
+ */
348
+ setReferenceDismissed(draft) { this.session.setReferenceDismissed(draft); }
349
+ /** Panels the selected session has open. */
350
+ get panels() { return this.session.panels; }
351
+ /** Show or hide the reasoning panel.
352
+ * @param open - Whether `/think` is open.
353
+ */
354
+ openThoughts(open) { this.session.openThoughts(open); }
355
+ /** Show or hide the pending-input panel.
356
+ * @param open - Whether `/queue` is open.
357
+ */
358
+ openQueue(open) { this.session.openQueue(open); }
359
+ /** Show the model dialog at one step, or close it.
360
+ * @param model - Catalog plus the provider or model being inspected; undefined closes the dialog.
361
+ */
362
+ setModelPanel(model) { this.session.setModelPanel(model); }
363
+ /** Show the history or content-search dialog, or close it.
364
+ * @param history - Query, content-search mode and matches; undefined closes the dialog.
365
+ */
366
+ setHistoryPanel(history) { this.session.setHistoryPanel(history); }
367
+ /** Show the host session-search results, or close them.
368
+ * @param search - Query, results and truncation flag; undefined closes the dialog.
369
+ */
370
+ setSearchPanel(search) { this.session.setSearchPanel(search); }
248
371
  /** Refresh all HTTP-visible sessions without changing the selected conversation.
249
372
  * @param signal - Optional cancellation for an explicit /cost refresh.
250
373
  */
@@ -369,4 +492,6 @@ export class Controller {
369
492
  * @param allowed - Whether the request is approved once.
370
493
  */
371
494
  async approve(allowed) { await this.session.approve(allowed); }
495
+ /** Dismiss the whole pending question set without answering it, as the Web close button does. */
496
+ async dismissQuestion() { await this.session.dismissQuestion(); }
372
497
  }
@@ -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
+ }
@@ -1,5 +1,6 @@
1
1
  /** Periodic billing scans; the ledger outlives any single connection generation. */
2
2
  import type { Client } from '../transport/client.ts';
3
+ import { type Json } from '../transport/wire.ts';
3
4
  import type { CostLedger } from './ledger.ts';
4
5
  /** Host access a scan needs; supplied by the controller facade. */
5
6
  export interface CostHost {
@@ -11,6 +12,10 @@ export interface CostHost {
11
12
  signal(): AbortSignal;
12
13
  /** Re-publish controller state after the ledger changes. */
13
14
  publish(): void;
15
+ /** Hand one already-read history page to another consumer, which owns what it does with it. */
16
+ scanPage?(sessionId: string, records: readonly Json[]): void;
17
+ /** Report that a session's history was read to its beginning. */
18
+ scanDone?(sessionId: string): void;
14
19
  }
15
20
  /** Owns the background scan: startup, the minute timer, turn-completion refresh, and `/cost`. */
16
21
  export declare class CostController {
@@ -77,7 +77,8 @@ export class CostController {
77
77
  if (!session.running && typeof session.updatedAt === 'number' && this.updates.get(sessionId) === session.updatedAt)
78
78
  continue;
79
79
  try {
80
- const history = await sessionCostHistory(client, session, combined, () => { pages++; });
80
+ const history = await sessionCostHistory(client, session, combined, () => { pages++; }, records => this.host.scanPage?.(sessionId, records));
81
+ this.host.scanDone?.(sessionId);
81
82
  scanned++;
82
83
  events += history.events.length;
83
84
  await ledger.replace(sessionId, history.cursor, history.events);
@@ -1,6 +1,6 @@
1
1
  /** Address and page one session's complete billing history over the host connection. */
2
2
  import { type Client } from '../transport/client.ts';
3
- import { type ObjectValue } from '../transport/wire.ts';
3
+ import { type Json, type ObjectValue } from '../transport/wire.ts';
4
4
  /** Wire addresses for one `session/list` row, in the order the cost scan should try them.
5
5
  *
6
6
  * A subagent child is reachable only under its durable parent, and the list row omits the delivery
@@ -14,9 +14,11 @@ export declare function costAddresses(session: ObjectValue): ObjectValue[];
14
14
  * @param session - One row from the host session list.
15
15
  * @param signal - Cancels paging without cancelling any agent work.
16
16
  * @param onPage - Counts each history request, so a scan can report how much it re-read.
17
+ * @param onRecords - Hands each page's raw records to the caller, which owns any further reading of
18
+ * them; the billing fold itself keeps only the minimal events below.
17
19
  * @returns Opening cursor and the minimal billing events behind it.
18
20
  */
19
- export declare function sessionCostHistory(client: Client, session: ObjectValue, signal: AbortSignal, onPage?: () => void): Promise<{
21
+ export declare function sessionCostHistory(client: Client, session: ObjectValue, signal: AbortSignal, onPage?: () => void, onRecords?: (records: readonly Json[]) => void): Promise<{
20
22
  cursor: number;
21
23
  events: ObjectValue[];
22
24
  }>;
@@ -24,13 +24,15 @@ export function costAddresses(session) {
24
24
  * @param session - One row from the host session list.
25
25
  * @param signal - Cancels paging without cancelling any agent work.
26
26
  * @param onPage - Counts each history request, so a scan can report how much it re-read.
27
+ * @param onRecords - Hands each page's raw records to the caller, which owns any further reading of
28
+ * them; the billing fold itself keeps only the minimal events below.
27
29
  * @returns Opening cursor and the minimal billing events behind it.
28
30
  */
29
- export async function sessionCostHistory(client, session, signal, onPage) {
31
+ export async function sessionCostHistory(client, session, signal, onPage, onRecords) {
30
32
  let lastError;
31
33
  for (const address of costAddresses(session)) {
32
34
  try {
33
- return await readCostHistory(client, address, signal, onPage);
35
+ return await readCostHistory(client, address, signal, onPage, onRecords);
34
36
  }
35
37
  catch (error) {
36
38
  lastError = error;
@@ -42,7 +44,7 @@ export async function sessionCostHistory(client, session, signal, onPage) {
42
44
  throw lastError;
43
45
  }
44
46
  /** Page one addressed session's history into the billing events the ledger folds. */
45
- async function readCostHistory(client, address, signal, onPage) {
47
+ async function readCostHistory(client, address, signal, onPage, onRecords) {
46
48
  onPage?.();
47
49
  const snapshot = await new Promise((resolve, reject) => {
48
50
  let sub;
@@ -77,6 +79,7 @@ async function readCostHistory(client, address, signal, onPage) {
77
79
  while (true) {
78
80
  signal.throwIfAborted();
79
81
  const records = array(page.records);
82
+ onRecords?.(records);
80
83
  events.push(...costRecords(records));
81
84
  if (!page.hasMore)
82
85
  break;