@itookit/dsht 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +30 -15
  3. package/README.zh.md +30 -15
  4. package/dist/catalog/controller.d.ts +32 -0
  5. package/dist/catalog/controller.js +88 -0
  6. package/dist/catalog/index.d.ts +2 -0
  7. package/dist/catalog/index.js +2 -0
  8. package/dist/{cli.js → cli/index.js} +40 -24
  9. package/dist/controller/connection.d.ts +80 -0
  10. package/dist/controller/connection.js +190 -0
  11. package/dist/controller/controller.d.ts +260 -0
  12. package/dist/controller/controller.js +362 -0
  13. package/dist/controller/index.d.ts +5 -0
  14. package/dist/controller/index.js +3 -0
  15. package/dist/controller/memory-log.d.ts +35 -0
  16. package/dist/controller/memory-log.js +95 -0
  17. package/dist/cost/controller.d.ts +34 -0
  18. package/dist/cost/controller.js +107 -0
  19. package/dist/cost/index.d.ts +9 -0
  20. package/dist/cost/index.js +7 -0
  21. package/dist/cost/ledger-files.d.ts +20 -0
  22. package/dist/cost/ledger-files.js +128 -0
  23. package/dist/cost/ledger.d.ts +65 -0
  24. package/dist/cost/ledger.js +136 -0
  25. package/dist/cost/pricing.d.ts +48 -0
  26. package/dist/cost/pricing.js +141 -0
  27. package/dist/cost/records.d.ts +17 -0
  28. package/dist/cost/records.js +66 -0
  29. package/dist/cost/scanner.d.ts +22 -0
  30. package/dist/cost/scanner.js +95 -0
  31. package/dist/cost/types.d.ts +69 -0
  32. package/dist/cost/types.js +3 -0
  33. package/dist/index.d.ts +3 -0
  34. package/dist/index.js +2 -0
  35. package/dist/session/connection-view.d.ts +18 -0
  36. package/dist/session/connection-view.js +1 -0
  37. package/dist/{controller.d.ts → session/controller.d.ts} +102 -136
  38. package/dist/session/controller.js +616 -0
  39. package/dist/session/export-html.d.ts +9 -0
  40. package/dist/session/export-html.js +39 -0
  41. package/dist/{export.d.ts → session/export.d.ts} +1 -1
  42. package/dist/{export.js → session/export.js} +6 -20
  43. package/dist/{history.d.ts → session/history.d.ts} +17 -0
  44. package/dist/{history.js → session/history.js} +163 -2
  45. package/dist/session/index.d.ts +17 -0
  46. package/dist/session/index.js +10 -0
  47. package/dist/session/markdown.d.ts +39 -0
  48. package/dist/session/markdown.js +255 -0
  49. package/dist/session/math.d.ts +11 -0
  50. package/dist/session/math.js +82 -0
  51. package/dist/{navigation.d.ts → session/navigation.d.ts} +1 -1
  52. package/dist/{navigation.js → session/navigation.js} +1 -1
  53. package/dist/{references.js → session/references.js} +1 -1
  54. package/dist/{telemetry.d.ts → session/telemetry.d.ts} +1 -1
  55. package/dist/{telemetry.js → session/telemetry.js} +1 -1
  56. package/dist/{transcript.d.ts → session/transcript.d.ts} +23 -1
  57. package/dist/{transcript.js → session/transcript.js} +36 -18
  58. package/dist/session/types.d.ts +18 -0
  59. package/dist/session/types.js +2 -0
  60. package/dist/state.d.ts +41 -0
  61. package/dist/state.js +9 -0
  62. package/dist/storage/directories.d.ts +14 -0
  63. package/dist/storage/directories.js +24 -0
  64. package/dist/storage/files.d.ts +43 -0
  65. package/dist/storage/files.js +135 -0
  66. package/dist/storage/index.d.ts +3 -0
  67. package/dist/storage/index.js +3 -0
  68. package/dist/transport/auth.js +65 -0
  69. package/dist/transport/host.d.ts +13 -0
  70. package/dist/transport/host.js +1 -0
  71. package/dist/ui/app.d.ts +11 -0
  72. package/dist/ui/app.js +726 -0
  73. package/dist/ui/chat/header.d.ts +11 -0
  74. package/dist/ui/chat/header.js +14 -0
  75. package/dist/{history-view.d.ts → ui/chat/history-view.d.ts} +1 -1
  76. package/dist/{history-view.js → ui/chat/history-view.js} +3 -3
  77. package/dist/ui/chat/status.d.ts +79 -0
  78. package/dist/ui/chat/status.js +311 -0
  79. package/dist/ui/chat/viewport.d.ts +17 -0
  80. package/dist/ui/chat/viewport.js +14 -0
  81. package/dist/ui/commands/parse.d.ts +96 -0
  82. package/dist/ui/commands/parse.js +121 -0
  83. package/dist/ui/commands/registry.d.ts +33 -0
  84. package/dist/ui/commands/registry.js +72 -0
  85. package/dist/ui/copy-mode.d.ts +4 -0
  86. package/dist/ui/copy-mode.js +6 -0
  87. package/dist/{cost-view.d.ts → ui/dialogs/cost.d.ts} +1 -1
  88. package/dist/{cost-view.js → ui/dialogs/cost.js} +3 -3
  89. package/dist/ui/dialogs/index.d.ts +120 -0
  90. package/dist/ui/dialogs/index.js +113 -0
  91. package/dist/ui/dialogs/picker.d.ts +18 -0
  92. package/dist/ui/dialogs/picker.js +38 -0
  93. package/dist/ui/frozen.d.ts +8 -0
  94. package/dist/ui/frozen.js +7 -0
  95. package/dist/ui/input/references.d.ts +12 -0
  96. package/dist/ui/input/references.js +15 -0
  97. package/dist/ui/mount.d.ts +6 -0
  98. package/dist/ui/mount.js +11 -0
  99. package/dist/{theme.d.ts → ui/theme/index.d.ts} +1 -1
  100. package/package.json +19 -13
  101. package/dist/app.d.ts +0 -21
  102. package/dist/app.js +0 -805
  103. package/dist/auth.js +0 -108
  104. package/dist/controller.js +0 -961
  105. package/dist/cost.d.ts +0 -119
  106. package/dist/cost.js +0 -313
  107. package/dist/status.d.ts +0 -28
  108. package/dist/status.js +0 -157
  109. /package/dist/{cli.d.ts → cli/index.d.ts} +0 -0
  110. /package/dist/{memory.d.ts → session/memory.d.ts} +0 -0
  111. /package/dist/{memory.js → session/memory.js} +0 -0
  112. /package/dist/{references.d.ts → session/references.d.ts} +0 -0
  113. /package/dist/{auth.d.ts → transport/auth.d.ts} +0 -0
  114. /package/dist/{client.d.ts → transport/client.d.ts} +0 -0
  115. /package/dist/{client.js → transport/client.js} +0 -0
  116. /package/dist/{endpoint.d.ts → transport/endpoint.d.ts} +0 -0
  117. /package/dist/{endpoint.js → transport/endpoint.js} +0 -0
  118. /package/dist/{wire.d.ts → transport/wire.d.ts} +0 -0
  119. /package/dist/{wire.js → transport/wire.js} +0 -0
  120. /package/dist/{input-history.d.ts → ui/input/history.d.ts} +0 -0
  121. /package/dist/{input-history.js → ui/input/history.js} +0 -0
  122. /package/dist/{input.d.ts → ui/input/input.d.ts} +0 -0
  123. /package/dist/{input.js → ui/input/input.js} +0 -0
  124. /package/dist/{mouse.d.ts → ui/input/mouse.d.ts} +0 -0
  125. /package/dist/{mouse.js → ui/input/mouse.js} +0 -0
  126. /package/dist/{theme.js → ui/theme/index.js} +0 -0
@@ -0,0 +1,362 @@
1
+ /** Application facade: composes the connection, session, catalog and cost domains. */
2
+ import { Client } from "../transport/client.js";
3
+ import { errorText } from "../transport/wire.js";
4
+ import { DEFAULT_HISTORY_LIMITS } from "../session/memory.js";
5
+ import { layoutStats } from "../session/history.js";
6
+ import { markdownCacheStats } from "../session/markdown.js";
7
+ import { SessionController } from "../session/controller.js";
8
+ import { CatalogController } from "../catalog/controller.js";
9
+ import { CostController } from "../cost/controller.js";
10
+ import { ConnectionController } from "./connection.js";
11
+ import { MemoryLog } from "./memory-log.js";
12
+ import { initialState } from "../state.js";
13
+ /** Application facade over the domain controllers; the UI owns only this object.
14
+ *
15
+ * State lives here, connection generations live in `connection`, the selected session and its
16
+ * history live in `session`, model metadata lives in `catalog`, and billing lives in `cost`.
17
+ */
18
+ export class Controller {
19
+ base;
20
+ initialSession;
21
+ costs;
22
+ historyLimits;
23
+ memoryLogPath;
24
+ state = initialState();
25
+ /** Physical connection, retry loop and projection store. */
26
+ connection;
27
+ /** Selected session, follow stream, history and interactions. */
28
+ session;
29
+ /** Model routes and agent-preset metadata. */
30
+ catalog;
31
+ /** Background billing scan; present only when a ledger was supplied. */
32
+ cost;
33
+ /** Bounded runtime memory samples; present only when a log path was supplied. */
34
+ memoryLog;
35
+ observers = new Set();
36
+ selector = 0;
37
+ constructor(base, token, initialSession, makeClient = () => new Client(base), authenticate = client => client.authenticate(token ?? ''), costs, historyLimits = DEFAULT_HISTORY_LIMITS, memoryLogPath) {
38
+ this.base = base;
39
+ this.initialSession = initialSession;
40
+ this.costs = costs;
41
+ this.historyLimits = historyLimits;
42
+ this.memoryLogPath = memoryLogPath;
43
+ const options = { base, token, initialSession, makeClient, authenticate };
44
+ this.connection = new ConnectionController(this, options, this);
45
+ this.session = new SessionController(this, this.connection, this.connection, historyLimits);
46
+ this.catalog = new CatalogController(this, this.connection);
47
+ if (costs)
48
+ this.cost = new CostController(costs, {
49
+ client: () => this.connection.client(),
50
+ online: () => this.state.online,
51
+ signal: () => this.connection.signal(),
52
+ publish: () => this.update({}),
53
+ });
54
+ if (memoryLogPath !== undefined)
55
+ this.memoryLog = new MemoryLog(memoryLogPath, () => this.memorySample());
56
+ }
57
+ /** React-compatible state subscription. */
58
+ subscribe = (listener) => {
59
+ this.observers.add(listener);
60
+ return () => this.observers.delete(listener);
61
+ };
62
+ /** Snapshot identity changes only when the controller publishes. */
63
+ snapshot = () => this.state;
64
+ /** Current projection store; replaced at each connection generation. */
65
+ get telemetry() { return this.connection.telemetry; }
66
+ /** @returns The current selector generation. */
67
+ selection() { return this.selector; }
68
+ /** Advance the selector generation when the selected workspace or session changes. */
69
+ bumpSelection() { this.selector++; }
70
+ /** Publish a state patch, re-deriving the visible pending interactions.
71
+ * @param patch - Fields to replace on the current state.
72
+ */
73
+ update(patch) {
74
+ const next = { ...this.state, ...patch, version: this.state.version + 1 };
75
+ next.pending = next.online && next.screen === 'chat' && this.session
76
+ ? this.session.pendingFor(next) : [];
77
+ this.state = next;
78
+ for (const observer of this.observers)
79
+ observer();
80
+ }
81
+ /** Start one retry loop, with a fresh snapshot generation after every disconnect. */
82
+ start() { this.connection.start(); this.memoryLog?.start(); }
83
+ /** Cancel retries and HTTP, close the socket, and release session and catalog work. */
84
+ async stop() {
85
+ await this.connection.stop();
86
+ await this.session.settle();
87
+ await this.catalog.settle();
88
+ await this.cost?.stop();
89
+ await this.memoryLog?.stop();
90
+ this.session.release();
91
+ }
92
+ /** Stop the selected turn and then close, so quitting does not leave host work running.
93
+ * An idle session stays untouched, and an in-flight cancellation is awaited rather than repeated.
94
+ */
95
+ async shutdown() {
96
+ if (this.session.active)
97
+ await this.session.interrupt(true);
98
+ await this.stop();
99
+ }
100
+ /** Run a UI operation and expose errors without destroying the current input.
101
+ * @param operation - Operation to run while the client is busy.
102
+ * @returns Whether the operation completed.
103
+ */
104
+ async perform(operation) {
105
+ if (this.state.busy || !this.state.online)
106
+ return false;
107
+ this.update({ busy: true, error: '' });
108
+ try {
109
+ await operation();
110
+ return true;
111
+ }
112
+ catch (error) {
113
+ this.update({ error: errorText(error) });
114
+ return false;
115
+ }
116
+ finally {
117
+ this.update({ busy: false });
118
+ }
119
+ }
120
+ /** Read the counters one memory sample records; content never leaves as text.
121
+ *
122
+ * The retained transcript and the ledger are small in practice, so a sample also reads the two
123
+ * structures that grow with rendered content — the layout row cache and the bounded math and
124
+ * diagram cache — and the work the last cost scan re-read, which is the only timer here whose
125
+ * per-pass work scales with history. With a runtime that exposes `gc`, the sample also reports
126
+ * the heap after a forced collection, so retained state and uncollected garbage stay distinct.
127
+ */
128
+ memorySample() {
129
+ const memory = process.memoryUsage();
130
+ const transcript = this.state.transcript;
131
+ const ledger = this.costs?.summary();
132
+ const layout = layoutStats(transcript);
133
+ const markdown = markdownCacheStats();
134
+ const gc = this.forcedGc();
135
+ return {
136
+ time: new Date().toISOString(),
137
+ rss: memory.rss, heapTotal: memory.heapTotal, heapUsed: memory.heapUsed,
138
+ external: memory.external, arrayBuffers: memory.arrayBuffers,
139
+ ...(gc === undefined ? {} : { heapUsedAfterGc: gc.used, gcMs: gc.ms }),
140
+ online: this.state.online, screen: this.state.screen,
141
+ session: this.state.sessionId ?? null,
142
+ pinned: this.session.pinned,
143
+ records: transcript.retainedRecordCount, retainedBytes: transcript.retainedBytes,
144
+ beforeSeq: transcript.beforeSeq ?? null, hasMore: transcript.hasMore,
145
+ live: transcript.hasLiveContent, liveChars: transcript.liveText.length, pending: this.state.pending.length,
146
+ thoughts: transcript.thoughts.length,
147
+ ...(layout === undefined ? {} : { layoutRows: layout.rows, layoutCacheBytes: layout.cacheBytes, layoutSpans: layout.spans,
148
+ layoutSpanChars: layout.spanChars, layoutLiveWraps: layout.liveWraps, layoutLiveMarkdown: layout.liveMarkdown }),
149
+ markdownEntries: markdown.entries, markdownChars: markdown.chars, markdownHits: markdown.hits, markdownMisses: markdown.misses,
150
+ scanning: this.costs?.scanning ?? false,
151
+ ...(this.costs?.lastScan === undefined ? {} : { scanSessions: this.costs.lastScan.sessions,
152
+ scanPages: this.costs.lastScan.pages, scanEvents: this.costs.lastScan.events }),
153
+ ...(ledger === undefined ? {} : { ledgerSessions: ledger.sessions, ledgerCharges: ledger.charges, ledgerUnpriced: ledger.unpriced }),
154
+ };
155
+ }
156
+ /** Collect before reading the heap when the runtime exposes a collection.
157
+ * @returns Heap in use after the collection and how long it took, or undefined without `global.gc`.
158
+ */
159
+ forcedGc() {
160
+ const collect = globalThis.gc;
161
+ if (typeof collect !== 'function')
162
+ return undefined;
163
+ const start = Date.now();
164
+ collect();
165
+ return { used: process.memoryUsage().heapUsed, ms: Date.now() - start };
166
+ }
167
+ /** A new generation starts; drop generation-scoped domain state. */
168
+ begin() {
169
+ this.session.beginGeneration();
170
+ this.catalog.reset();
171
+ this.update({ controlError: undefined });
172
+ }
173
+ /** The event stream is ready and the control baseline is applied. */
174
+ async ready() {
175
+ this.catalog.refresh();
176
+ this.update({ online: true, status: 'Connected', error: '', pending: [] });
177
+ const screen = this.state.screen;
178
+ await this.session.showPicker(screen === 'sessions' ? 'sessions' : 'workspaces');
179
+ const sessionId = this.state.sessionId ?? this.initialSession;
180
+ if (sessionId && (screen === 'chat' || this.initialSession && !this.state.sessionId))
181
+ await this.session.selectSession(sessionId);
182
+ this.cost?.start();
183
+ }
184
+ /** The generation ended; invalidate session work and stop the scan. */
185
+ async ended() {
186
+ this.session.endGeneration();
187
+ await this.cost?.stop();
188
+ }
189
+ /** Deliver a host waterfall to the session domain.
190
+ * @param frame - One decoded waterfall frame.
191
+ * @returns Whether the session domain retained it.
192
+ */
193
+ waterfall(frame) { return this.session.waterfall(frame); }
194
+ /** Drop a waterfall the host cancelled.
195
+ * @param eventId - Correlation id previously retained.
196
+ */
197
+ cancelled(eventId) { this.session.cancelled(eventId); }
198
+ /** Apply one host running-state notification.
199
+ * @param sessionId - Session whose state changed.
200
+ * @param running - Whether the host still runs that session.
201
+ */
202
+ status(sessionId, running) { this.session.status(sessionId, running); }
203
+ /** Surface a host-reported session error.
204
+ * @param sessionId - Session the host reported on.
205
+ * @param error - Error payload as delivered by the host.
206
+ */
207
+ error(sessionId, error) { this.session.reportError(sessionId, error); }
208
+ /** Reload the model catalog after a host settings, credential or adapter change. */
209
+ invalidated() { this.catalog.refresh(); }
210
+ /** Refresh billing after a turn finished. */
211
+ idle() { this.cost?.onTurnIdle(); }
212
+ /** @returns Host running state of the selected session. */
213
+ get running() { return this.session.running; }
214
+ /** @returns Current session title, falling back to the list title and then the ID. */
215
+ get sessionName() { return this.session.sessionName; }
216
+ /** @returns Current agent-preset label. */
217
+ get sessionMode() { return this.session.sessionMode; }
218
+ /** @returns Epoch start of the active turn, when known. */
219
+ get workingSince() { return this.session.workingSince; }
220
+ /** @returns Sessions accounted to the selected workspace, minus archived identities. */
221
+ get visibleSessions() { return this.session.visibleSessions; }
222
+ /** Load the optional preset roster once per connection. */
223
+ loadPresetNames() { this.catalog.loadPresetNames(); }
224
+ /** @returns Host model routes and adapter-owned reasoning choices. */
225
+ async modelCatalog() { return this.catalog.modelCatalog(); }
226
+ /** Select the next request's model.
227
+ * @param provider - Host provider route ID.
228
+ * @param model - Exact model ID.
229
+ * @param reasoningEffort - Optional adapter-owned effort ID.
230
+ */
231
+ async selectModel(provider, model, reasoningEffort) {
232
+ await this.catalog.selectModel(provider, model, reasoningEffort);
233
+ }
234
+ /** Stop the selected turn, or allow exit only while idle.
235
+ * @param force - Send an explicit cancellation even when the cached running flag is idle.
236
+ * @returns True when the caller may exit.
237
+ */
238
+ interrupt(force = false) { return this.session.interrupt(force); }
239
+ /** Keep history stable while the user reads, searches, or expands it.
240
+ * @param pinned - Whether the main transcript is being read away from its tail.
241
+ */
242
+ pinHistory(pinned) { this.session.pinHistory(pinned); }
243
+ /** Refresh all HTTP-visible sessions without changing the selected conversation.
244
+ * @param signal - Optional cancellation for an explicit /cost refresh.
245
+ */
246
+ async refreshCosts(signal = this.connection.signal()) { await this.cost?.refresh(signal); }
247
+ /** Refresh both lists from the host, then show the requested picker.
248
+ * @param screen - Picker to display after the refresh.
249
+ */
250
+ async showPicker(screen) { await this.session.showPicker(screen); }
251
+ /** Resolve a removal command to one reviewable object.
252
+ * @param kind - Workspace registration removal or session archival.
253
+ * @param query - Exact name, ID, or unambiguous ID prefix.
254
+ * @returns The fixed identity and display details for confirmation.
255
+ */
256
+ async removalTarget(kind, query) { return this.session.removalTarget(kind, query); }
257
+ /** Apply a confirmed removal or verified empty-session archival.
258
+ * @param target - Exact workspace or session identity reviewed by the user.
259
+ */
260
+ async removeTarget(target) { await this.session.removeTarget(target); }
261
+ /** Pick a workspace, or use all sessions when the identity is omitted.
262
+ * @param workspaceId - Workspace to select, if any.
263
+ */
264
+ pickWorkspace(workspaceId) { this.session.pickWorkspace(workspaceId); }
265
+ /** Open a workspace picker, or resolve a workspace target.
266
+ * @param query - Workspace target, if any.
267
+ */
268
+ async switchWorkspace(query) { await this.session.switchWorkspace(query); }
269
+ /** Guide session selection, list all sessions with `all`, or resolve a target.
270
+ * @param query - Session target, `all`, or nothing for the guided picker.
271
+ */
272
+ async switchSession(query) { await this.session.switchSession(query); }
273
+ /** Prompt for a host path without starting a local agent. */
274
+ enterPath() { this.session.enterPath(); }
275
+ /** Register a host directory and move to its session picker.
276
+ * @param path - Absolute directory path on the host.
277
+ */
278
+ async createWorkspace(path) { await this.session.createWorkspace(path); }
279
+ /** Create a session in the selected workspace. */
280
+ async createSession() { await this.session.createSession(); }
281
+ /** Replace the selected transcript and follow the session.
282
+ * @param sessionId - Session to follow.
283
+ */
284
+ async selectSession(sessionId) { await this.session.selectSession(sessionId); }
285
+ /** Wait for the selected follow snapshot.
286
+ * @param signal - Cancels waiting without closing the session.
287
+ */
288
+ async waitForHistory(signal) { await this.session.waitForHistory(signal); }
289
+ /** Search host session results.
290
+ * @param query - Literal message text.
291
+ * @param workspaceOnly - Restrict hits to the selected workspace.
292
+ * @param signal - Cancels the HTTP search.
293
+ * @returns Session snippets and the global truncation flag.
294
+ */
295
+ async searchSessions(query, workspaceOnly, signal) {
296
+ return this.session.searchSessions(query, workspaceOnly, signal);
297
+ }
298
+ /** Search host paths for the composer's `@` completion.
299
+ * @param query - Path text after @.
300
+ * @param signal - Cancels an obsolete lookup.
301
+ * @returns Validated candidates in host order.
302
+ */
303
+ async references(query, signal) { return this.session.references(query, signal); }
304
+ /** Execute a human command directly, outside the model prompt queue.
305
+ * @param line - Complete slash command, including arguments.
306
+ * @param signal - Cancels the request while the host performs compaction.
307
+ * @returns The host's successful command result text.
308
+ */
309
+ async command(line, signal) { return this.session.command(line, signal); }
310
+ /** Remove one host-owned pending input.
311
+ * @param itemId - Queue occurrence identity from session/control.
312
+ */
313
+ async removeQueued(itemId) { await this.session.removeQueued(itemId); }
314
+ /** Export the selected host log to a new local ZIP file.
315
+ * @param path - Optional local destination; existing files are never overwritten.
316
+ * @param signal - Cancels the download and removes an incomplete file.
317
+ * @returns Absolute saved filename.
318
+ */
319
+ async exportLog(path, signal) { return this.session.exportLog(path, signal); }
320
+ /** Save loaded Markdown, diagrams and math as offline HTML.
321
+ * @param path - Optional filename; existing files are not replaced.
322
+ * @param signal - Cancels the write.
323
+ * @returns Absolute saved filename.
324
+ */
325
+ async exportHtml(path, signal) { return this.session.exportHtml(path, signal); }
326
+ /** Admit text once as steering while running, or a new turn while idle.
327
+ * @param text - Composed prompt text.
328
+ */
329
+ async prompt(text) { await this.session.prompt(text); }
330
+ /** Cancel the active turn; pending queue items remain host-owned. */
331
+ async cancelTurn() { await this.session.cancelTurn(); }
332
+ /** Add a page before the retained window.
333
+ * @param signal - Cancels local paging without interrupting the remote agent.
334
+ * @param transcript - Transcript to extend; defaults to the live one.
335
+ */
336
+ async older(signal, transcript) { await this.session.older(signal, transcript); }
337
+ /** Search the loaded history page by page.
338
+ * @param query - Literal, case-insensitive text including folded reasoning.
339
+ * @param signal - Cancels HTTP and processing without cancelling the agent.
340
+ * @returns Newest-first bounded summaries and an explicit truncation flag.
341
+ */
342
+ async searchHistory(query, signal) { return this.session.searchHistory(query, signal); }
343
+ /** Load a separate small window ending at a search target.
344
+ * @param target - Durable message sequence to display.
345
+ * @param signal - Cancels the target-page request.
346
+ * @returns A caller-owned historical window that must be disposed when closed.
347
+ */
348
+ async historyAt(target, signal) { return this.session.historyAt(target, signal); }
349
+ /** Load the prefix required for an explicit history jump.
350
+ * @param target - Visible record sequence, or first for the oldest available history.
351
+ * @param signal - Cancels local paging without interrupting the remote agent.
352
+ */
353
+ async historyThrough(target, signal) { await this.session.historyThrough(target, signal); }
354
+ /** Answer the oldest selected-session interaction, after explicit user action.
355
+ * @param value - Structured answer value or approval outcome.
356
+ */
357
+ async answer(value) { await this.session.answer(value); }
358
+ /** Approve or reject the pending approval request.
359
+ * @param allowed - Whether the request is approved once.
360
+ */
361
+ async approve(allowed) { await this.session.approve(allowed); }
362
+ }
@@ -0,0 +1,5 @@
1
+ /** Controller domain: the application facade and the connection it owns. */
2
+ export { Controller } from './controller.ts';
3
+ export type { HistorySearch, RemovalTarget, State } from './controller.ts';
4
+ export { ConnectionController } from './connection.ts';
5
+ export type { ConnectionListener, ConnectionOptions } from './connection.ts';
@@ -0,0 +1,3 @@
1
+ /** Controller domain: the application facade and the connection it owns. */
2
+ export { Controller } from "./controller.js";
3
+ export { ConnectionController } from "./connection.js";
@@ -0,0 +1,35 @@
1
+ import { type ObjectValue } from '../transport/wire.ts';
2
+ /** Append-only memory log; the file never exceeds twice `KEEP_SAMPLES` lines.
3
+ *
4
+ * The log exists to answer "is retained content growing, or is this just V8's high-water mark",
5
+ * so each sample records the process counters next to the client's retained window and the
6
+ * controller state that decides whether that window is being reclaimed. Samples contain counts
7
+ * and sizes only, never prompt, tool, or session text. A failing log stops itself instead of
8
+ * breaking the client.
9
+ */
10
+ export declare class MemoryLog {
11
+ readonly path: string | undefined;
12
+ private readonly sampleSource;
13
+ private timer;
14
+ private appended;
15
+ private writing;
16
+ /** Last write failure, when the log stopped itself. */
17
+ error: string | undefined;
18
+ constructor(path: string | undefined, sampleSource: () => ObjectValue);
19
+ /** Write the header into a new file, then sample once; a missing or unwritable path is reported
20
+ * by that first sample instead of here.
21
+ */
22
+ private begin;
23
+ /** Sample once now and then every `SAMPLE_INTERVAL_MS`; absent when no path was configured. */
24
+ start(): void;
25
+ /** Stop sampling and rewrite the file with its retained window. */
26
+ stop(): Promise<void>;
27
+ /** Append one sample line, skipping overlapping calls.
28
+ *
29
+ * A repeated failure would be noise, so the first one stops the timer and leaves the client
30
+ * running; the log is diagnostics, not a feature the terminal depends on.
31
+ */
32
+ sample(): Promise<void>;
33
+ /** Rewrite the file with the header and the newest kept lines, bounding its size. */
34
+ private compact;
35
+ }
@@ -0,0 +1,95 @@
1
+ /** Runtime memory samples appended to one bounded local file for diagnosing growth over a long run. */
2
+ import { appendPrivateFile, createPrivateFile, writePrivateFile } from "../storage/index.js";
3
+ import { errorText } from "../transport/wire.js";
4
+ /** Time between automatic samples. */
5
+ const SAMPLE_INTERVAL_MS = 30_000;
6
+ /** Samples kept; reaching the cap rewrites the file with just these lines. */
7
+ const KEEP_SAMPLES = 1000;
8
+ /** First line of the file, so an empty or rewritten log still explains itself. */
9
+ const HEADER = '# dsht memory samples: one JSON object per line, oldest first\n';
10
+ /** Append-only memory log; the file never exceeds twice `KEEP_SAMPLES` lines.
11
+ *
12
+ * The log exists to answer "is retained content growing, or is this just V8's high-water mark",
13
+ * so each sample records the process counters next to the client's retained window and the
14
+ * controller state that decides whether that window is being reclaimed. Samples contain counts
15
+ * and sizes only, never prompt, tool, or session text. A failing log stops itself instead of
16
+ * breaking the client.
17
+ */
18
+ export class MemoryLog {
19
+ path;
20
+ sampleSource;
21
+ timer;
22
+ appended = [];
23
+ writing = false;
24
+ /** Last write failure, when the log stopped itself. */
25
+ error;
26
+ constructor(path, sampleSource) {
27
+ this.path = path;
28
+ this.sampleSource = sampleSource;
29
+ }
30
+ /** Write the header into a new file, then sample once; a missing or unwritable path is reported
31
+ * by that first sample instead of here.
32
+ */
33
+ async begin() {
34
+ if (this.path === undefined)
35
+ return;
36
+ try {
37
+ await createPrivateFile(this.path, HEADER);
38
+ }
39
+ catch { /* the sample below reports it */ }
40
+ await this.sample();
41
+ }
42
+ /** Sample once now and then every `SAMPLE_INTERVAL_MS`; absent when no path was configured. */
43
+ start() {
44
+ if (this.path === undefined || this.timer !== undefined)
45
+ return;
46
+ void this.begin();
47
+ this.timer = setInterval(() => { void this.sample(); }, SAMPLE_INTERVAL_MS);
48
+ }
49
+ /** Stop sampling and rewrite the file with its retained window. */
50
+ async stop() {
51
+ clearInterval(this.timer);
52
+ this.timer = undefined;
53
+ await this.compact();
54
+ }
55
+ /** Append one sample line, skipping overlapping calls.
56
+ *
57
+ * A repeated failure would be noise, so the first one stops the timer and leaves the client
58
+ * running; the log is diagnostics, not a feature the terminal depends on.
59
+ */
60
+ async sample() {
61
+ if (this.path === undefined || this.writing)
62
+ return;
63
+ this.writing = true;
64
+ try {
65
+ const line = `${JSON.stringify(this.sampleSource())}\n`;
66
+ await appendPrivateFile(this.path, line);
67
+ this.appended.push(line);
68
+ this.error = undefined;
69
+ if (this.appended.length >= KEEP_SAMPLES)
70
+ await this.compact();
71
+ }
72
+ catch (error) {
73
+ this.error = errorText(error);
74
+ clearInterval(this.timer);
75
+ this.timer = undefined;
76
+ }
77
+ finally {
78
+ this.writing = false;
79
+ }
80
+ }
81
+ /** Rewrite the file with the header and the newest kept lines, bounding its size. */
82
+ async compact() {
83
+ if (this.path === undefined || this.appended.length === 0)
84
+ return;
85
+ const lines = this.appended.slice(-KEEP_SAMPLES);
86
+ try {
87
+ await writePrivateFile(this.path, HEADER + lines.join(''));
88
+ this.appended = lines;
89
+ }
90
+ catch (error) {
91
+ // Appending continues to the existing file, so a failed rewrite only delays the bound.
92
+ this.error = errorText(error);
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,34 @@
1
+ /** Periodic billing scans; the ledger outlives any single connection generation. */
2
+ import type { Client } from '../transport/client.ts';
3
+ import type { CostLedger } from './ledger.ts';
4
+ /** Host access a scan needs; supplied by the controller facade. */
5
+ export interface CostHost {
6
+ /** Connected client, or undefined while offline. */
7
+ client(): Client | undefined;
8
+ /** Whether the current connection generation is online. */
9
+ online(): boolean;
10
+ /** Client lifetime signal, aborted when the process closes the connection. */
11
+ signal(): AbortSignal;
12
+ /** Re-publish controller state after the ledger changes. */
13
+ publish(): void;
14
+ }
15
+ /** Owns the background scan: startup, the minute timer, turn-completion refresh, and `/cost`. */
16
+ export declare class CostController {
17
+ readonly ledger: CostLedger;
18
+ private readonly host;
19
+ private task;
20
+ private abort;
21
+ private timer;
22
+ private updates;
23
+ constructor(ledger: CostLedger, host: CostHost);
24
+ /** Scan immediately, then once a minute while online. */
25
+ start(): void;
26
+ /** Stop the timer and wait for an in-flight scan; the ledger keeps its cached charges. */
27
+ stop(): Promise<void>;
28
+ /** Refresh after the host reports a turn complete. */
29
+ onTurnIdle(): void;
30
+ /** Refresh all HTTP-visible sessions without changing the selected conversation.
31
+ * @param signal - Optional cancellation for an explicit `/cost` refresh.
32
+ */
33
+ refresh(signal?: AbortSignal): Promise<void>;
34
+ }
@@ -0,0 +1,107 @@
1
+ import { array, errorText, object, string } from "../transport/wire.js";
2
+ import { sessionCostHistory } from "./scanner.js";
3
+ /** Refresh interval for the background cost scan. */
4
+ const REFRESH_INTERVAL_MS = 60_000;
5
+ /** Owns the background scan: startup, the minute timer, turn-completion refresh, and `/cost`. */
6
+ export class CostController {
7
+ ledger;
8
+ host;
9
+ task;
10
+ abort;
11
+ timer;
12
+ updates = new Map();
13
+ constructor(ledger, host) {
14
+ this.ledger = ledger;
15
+ this.host = host;
16
+ }
17
+ /** Scan immediately, then once a minute while online. */
18
+ start() {
19
+ if (this.timer)
20
+ return;
21
+ void this.refresh().catch(() => undefined);
22
+ this.timer = setInterval(() => { if (this.host.online())
23
+ void this.refresh().catch(() => undefined); }, REFRESH_INTERVAL_MS);
24
+ }
25
+ /** Stop the timer and wait for an in-flight scan; the ledger keeps its cached charges. */
26
+ async stop() {
27
+ clearInterval(this.timer);
28
+ this.timer = undefined;
29
+ await this.task;
30
+ }
31
+ /** Refresh after the host reports a turn complete. */
32
+ onTurnIdle() {
33
+ if (this.host.online())
34
+ void this.refresh().catch(() => undefined);
35
+ }
36
+ /** Refresh all HTTP-visible sessions without changing the selected conversation.
37
+ * @param signal - Optional cancellation for an explicit `/cost` refresh.
38
+ */
39
+ async refresh(signal = this.host.signal()) {
40
+ if (this.task) {
41
+ const cancel = () => this.abort?.abort();
42
+ signal.addEventListener('abort', cancel, { once: true });
43
+ try {
44
+ await this.task;
45
+ }
46
+ finally {
47
+ signal.removeEventListener('abort', cancel);
48
+ }
49
+ return;
50
+ }
51
+ const client = this.host.client();
52
+ if (!client)
53
+ throw new Error('Not connected');
54
+ this.abort = new AbortController();
55
+ const combined = AbortSignal.any([signal, this.host.signal(), this.abort.signal]);
56
+ const ledger = this.ledger;
57
+ ledger.scanning = true;
58
+ ledger.error = '';
59
+ this.host.publish();
60
+ const task = (async () => {
61
+ try {
62
+ const sessions = array(object(await client.call('session/list', { _request: {} }, combined)).items).map(object);
63
+ combined.throwIfAborted();
64
+ const failures = [];
65
+ let scanned = 0, pages = 0, events = 0;
66
+ for (const session of sessions) {
67
+ combined.throwIfAborted();
68
+ const sessionId = string(session.sessionId);
69
+ if (!session.running && typeof session.updatedAt === 'number' && this.updates.get(sessionId) === session.updatedAt)
70
+ continue;
71
+ try {
72
+ const history = await sessionCostHistory(client, session, combined, () => { pages++; });
73
+ scanned++;
74
+ events += history.events.length;
75
+ await ledger.replace(sessionId, history.cursor, history.events);
76
+ if (!session.running && typeof session.updatedAt === 'number')
77
+ this.updates.set(sessionId, session.updatedAt);
78
+ this.host.publish();
79
+ }
80
+ catch (error) {
81
+ // One unreachable or rejected session must not freeze every other session's rates.
82
+ combined.throwIfAborted();
83
+ failures.push(`${sessionId}: ${errorText(error)}`);
84
+ }
85
+ }
86
+ ledger.error = failures.length === 0 ? '' : `${failures.length} of ${sessions.length} sessions failed: ${failures[0]}`;
87
+ ledger.lastScan = { sessions: scanned, pages, events };
88
+ ledger.scannedAt = Date.now();
89
+ }
90
+ catch (error) {
91
+ ledger.error = errorText(error);
92
+ }
93
+ finally {
94
+ ledger.scanning = false;
95
+ this.host.publish();
96
+ }
97
+ })();
98
+ this.task = task;
99
+ try {
100
+ await task;
101
+ }
102
+ finally {
103
+ this.task = undefined;
104
+ this.abort = undefined;
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,9 @@
1
+ /** Cost domain: price tables, record folding, the immutable ledger, the scanner and its controller. */
2
+ export { CostController } from './controller.ts';
3
+ export type { CostHost } from './controller.ts';
4
+ export { CostLedger, costText } from './ledger.ts';
5
+ export { chargeFor, costDay, DEFAULT_PRICES, lowestPrice, priceAt, pricesFrom } from './pricing.ts';
6
+ export { costRecords, foldSamples } from './records.ts';
7
+ export { costAddresses, sessionCostHistory } from './scanner.ts';
8
+ export { MISSING_USAGE } from './types.ts';
9
+ export type { Charge, ChargeSample, CostTotal, Coverage, PriceDecision, PriceVersion, Rates, SavedCost, Usage } from './types.ts';
@@ -0,0 +1,7 @@
1
+ /** Cost domain: price tables, record folding, the immutable ledger, the scanner and its controller. */
2
+ export { CostController } from "./controller.js";
3
+ export { CostLedger, costText } from "./ledger.js";
4
+ export { chargeFor, costDay, DEFAULT_PRICES, lowestPrice, priceAt, pricesFrom } from "./pricing.js";
5
+ export { costRecords, foldSamples } from "./records.js";
6
+ export { costAddresses, sessionCostHistory } from "./scanner.js";
7
+ export { MISSING_USAGE } from "./types.js";