@itookit/dsht 0.2.0 → 0.2.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.
- package/README.i18n.yaml +2 -2
- package/README.md +48 -27
- package/README.zh.md +48 -27
- package/dist/app.d.ts +16 -1
- package/dist/app.js +450 -73
- package/dist/cli.js +6 -1
- package/dist/client.d.ts +2 -0
- package/dist/client.js +3 -0
- package/dist/controller.d.ts +80 -3
- package/dist/controller.js +350 -75
- package/dist/cost-view.js +3 -1
- package/dist/cost.js +3 -10
- package/dist/history-view.d.ts +8 -0
- package/dist/history-view.js +12 -0
- package/dist/history.d.ts +29 -12
- package/dist/history.js +172 -27
- package/dist/input-history.d.ts +19 -0
- package/dist/input-history.js +43 -0
- package/dist/input.d.ts +3 -1
- package/dist/input.js +3 -1
- package/dist/memory.d.ts +13 -0
- package/dist/memory.js +22 -0
- package/dist/mouse.d.ts +3 -1
- package/dist/mouse.js +15 -4
- package/dist/navigation.d.ts +1 -1
- package/dist/navigation.js +2 -2
- package/dist/status.d.ts +3 -1
- package/dist/status.js +63 -42
- package/dist/telemetry.d.ts +3 -0
- package/dist/telemetry.js +9 -0
- package/dist/theme.d.ts +15 -0
- package/dist/theme.js +15 -0
- package/dist/transcript.d.ts +91 -6
- package/dist/transcript.js +393 -49
- package/dsht.png +0 -0
- package/package.json +4 -2
package/dist/controller.js
CHANGED
|
@@ -6,9 +6,28 @@ import { AuthenticationRequired } from "./auth.js";
|
|
|
6
6
|
import { resolveTarget, sessionLabel } from "./navigation.js";
|
|
7
7
|
import { costRecords } from "./cost.js";
|
|
8
8
|
import { Telemetry } from "./telemetry.js";
|
|
9
|
-
import { Transcript } from "./transcript.js";
|
|
9
|
+
import { Transcript, toolLine } from "./transcript.js";
|
|
10
|
+
import { releaseHistoryLayout } from "./history.js";
|
|
11
|
+
import { DEFAULT_HISTORY_LIMITS } from "./memory.js";
|
|
10
12
|
import { fileReferences } from "./references.js";
|
|
11
13
|
import { array, errorText, object, string } from "./wire.js";
|
|
14
|
+
/** Wire addresses for one `session/list` row, in the order the cost scan should try them.
|
|
15
|
+
*
|
|
16
|
+
* A subagent child is reachable only under its durable parent, and the list row omits the delivery
|
|
17
|
+
* mode, so both modes are offered with the continuable form first.
|
|
18
|
+
* @param session - One row from the host session list.
|
|
19
|
+
* @returns One plain-session address, or both subagent forms when the row is a child.
|
|
20
|
+
*/
|
|
21
|
+
export function costAddresses(session) {
|
|
22
|
+
const sessionId = string(session.sessionId);
|
|
23
|
+
const parentSessionId = typeof session.parentSessionId === 'string' ? session.parentSessionId : '';
|
|
24
|
+
if (session.origin !== 'subagent' || parentSessionId === '')
|
|
25
|
+
return [{ kind: 'session', sessionId }];
|
|
26
|
+
return [
|
|
27
|
+
{ kind: 'subagent', parentSessionId, childSessionId: sessionId, mode: 'continuable' },
|
|
28
|
+
{ kind: 'subagent', parentSessionId, childSessionId: sessionId, mode: 'one-shot' },
|
|
29
|
+
];
|
|
30
|
+
}
|
|
12
31
|
/** Owns reconnects and subscriptions. User commands remain single-attempt operations. */
|
|
13
32
|
export class Controller {
|
|
14
33
|
base;
|
|
@@ -16,9 +35,11 @@ export class Controller {
|
|
|
16
35
|
makeClient;
|
|
17
36
|
authenticate;
|
|
18
37
|
costs;
|
|
38
|
+
historyLimits;
|
|
19
39
|
state = { version: 0, online: false, busy: false, screen: 'workspaces', status: 'Connecting…',
|
|
20
40
|
error: '', workspaces: [], sessions: [], showAllSessions: false, pending: [], transcript: new Transcript() };
|
|
21
41
|
observers = new Set();
|
|
42
|
+
interactions = new Map();
|
|
22
43
|
abort = new AbortController();
|
|
23
44
|
client;
|
|
24
45
|
clientId = '';
|
|
@@ -26,8 +47,10 @@ export class Controller {
|
|
|
26
47
|
runTask;
|
|
27
48
|
generationFailed;
|
|
28
49
|
selection = 0;
|
|
29
|
-
|
|
50
|
+
historyPinned = false;
|
|
51
|
+
telemetry = new Telemetry(new Set(['title', 'modelSelection', 'contextPressure', 'tokenUsage', 'sessionStats', 'agentPreset']));
|
|
30
52
|
observedRunningAt = new Map();
|
|
53
|
+
presetClient;
|
|
31
54
|
catalogRevision = 0;
|
|
32
55
|
catalogTasks = new Set();
|
|
33
56
|
runningUpdates = new Map();
|
|
@@ -54,6 +77,61 @@ export class Controller {
|
|
|
54
77
|
return title !== undefined ? sessionLabel({ sessionId: id, projections: { values: { title } } })
|
|
55
78
|
: row ? sessionLabel(row) : id;
|
|
56
79
|
}
|
|
80
|
+
/** Current agent-preset name, matching the web header's built-in labels and custom metadata. */
|
|
81
|
+
get sessionMode() {
|
|
82
|
+
if (!this.state.sessionId)
|
|
83
|
+
return undefined;
|
|
84
|
+
const id = this.telemetry.view(this.state.sessionId).values.agentPreset;
|
|
85
|
+
if (typeof id !== 'string')
|
|
86
|
+
return undefined;
|
|
87
|
+
const preset = this.state.presets?.find(item => item.id === id);
|
|
88
|
+
const builtIn = new Map([['standard', 'Standard mode'], ['ptc', 'PTC mode'], ['minimal', 'Minimal mode'], ['cordis', 'Creator mode']]);
|
|
89
|
+
return preset?.trust === 'system' && builtIn.has(id) ? builtIn.get(id)
|
|
90
|
+
: typeof preset?.name === 'string' ? preset.name : id;
|
|
91
|
+
}
|
|
92
|
+
/** Load the optional preset roster once per connection, only when a session names a preset. */
|
|
93
|
+
loadPresetNames() {
|
|
94
|
+
const client = this.client;
|
|
95
|
+
if (!client || !this.state.online || this.presetClient === client)
|
|
96
|
+
return;
|
|
97
|
+
this.presetClient = client;
|
|
98
|
+
const task = client.call('agentPresets/list', {}).then(value => {
|
|
99
|
+
if (client === this.client)
|
|
100
|
+
this.update({ presets: array(object(value).presets).map(object), presetError: undefined });
|
|
101
|
+
}).catch(error => {
|
|
102
|
+
if (client === this.client)
|
|
103
|
+
this.update({ presets: [], presetError: errorText(error) });
|
|
104
|
+
});
|
|
105
|
+
this.catalogTasks.add(task);
|
|
106
|
+
void task.finally(() => this.catalogTasks.delete(task));
|
|
107
|
+
}
|
|
108
|
+
/** Fetch current model routes and adapter-owned reasoning choices for the selected session.
|
|
109
|
+
* @returns Host catalog; provider failures remain available to the selector.
|
|
110
|
+
*/
|
|
111
|
+
async modelCatalog() {
|
|
112
|
+
const sessionId = this.sessionId;
|
|
113
|
+
const selection = this.selection;
|
|
114
|
+
const value = object(await this.host.call('session/modelCatalog', {}));
|
|
115
|
+
if (selection !== this.selection || sessionId !== this.state.sessionId)
|
|
116
|
+
throw new Error('Session changed while loading models');
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
/** Select the next request's model; the host also attempts to save its deployment default.
|
|
120
|
+
* @param provider - Host provider route ID.
|
|
121
|
+
* @param model - Exact model ID.
|
|
122
|
+
* @param reasoningEffort - Optional adapter-owned effort ID; omission uses its default.
|
|
123
|
+
*/
|
|
124
|
+
async selectModel(provider, model, reasoningEffort) {
|
|
125
|
+
const sessionId = this.sessionId;
|
|
126
|
+
const selection = this.selection;
|
|
127
|
+
const selected = object(object(await this.host.call('session/selectModel', { request: {
|
|
128
|
+
sessionId, provider, model, ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
|
|
129
|
+
} })).selected);
|
|
130
|
+
if (selection !== this.selection || sessionId !== this.state.sessionId)
|
|
131
|
+
return;
|
|
132
|
+
this.update({ status: `Next request: ${string(selected.provider)} / ${string(selected.model)}${selected.reasoningEffort ? ` · ${string(selected.reasoningEffort)}` : ''}` });
|
|
133
|
+
this.refreshCatalog(this.host);
|
|
134
|
+
}
|
|
57
135
|
/** Epoch start from the retained turn log, or when this client first observed the run. */
|
|
58
136
|
get workingSince() {
|
|
59
137
|
if (!this.running || !this.state.sessionId)
|
|
@@ -92,12 +170,13 @@ export class Controller {
|
|
|
92
170
|
void task.finally(() => { this.interruptTask = undefined; });
|
|
93
171
|
return task;
|
|
94
172
|
}
|
|
95
|
-
constructor(base, token, initialSession, makeClient = () => new Client(base), authenticate = client => client.authenticate(token ?? ''), costs) {
|
|
173
|
+
constructor(base, token, initialSession, makeClient = () => new Client(base), authenticate = client => client.authenticate(token ?? ''), costs, historyLimits = DEFAULT_HISTORY_LIMITS) {
|
|
96
174
|
this.base = base;
|
|
97
175
|
this.initialSession = initialSession;
|
|
98
176
|
this.makeClient = makeClient;
|
|
99
177
|
this.authenticate = authenticate;
|
|
100
178
|
this.costs = costs;
|
|
179
|
+
this.historyLimits = historyLimits;
|
|
101
180
|
}
|
|
102
181
|
/** React-compatible state subscription. */
|
|
103
182
|
subscribe = (listener) => {
|
|
@@ -118,6 +197,28 @@ export class Controller {
|
|
|
118
197
|
await this.interruptTask;
|
|
119
198
|
await Promise.all(this.catalogTasks);
|
|
120
199
|
await this.costTask;
|
|
200
|
+
this.releaseTranscript();
|
|
201
|
+
}
|
|
202
|
+
/** Keep history stable while the user reads, searches, or expands it; trim again at the live tail.
|
|
203
|
+
* @param pinned - Whether the main transcript is actively being read away from its tail.
|
|
204
|
+
*/
|
|
205
|
+
pinHistory(pinned) {
|
|
206
|
+
this.historyPinned = pinned;
|
|
207
|
+
if (!pinned && this.reclaimHistory())
|
|
208
|
+
this.update({});
|
|
209
|
+
}
|
|
210
|
+
reclaimHistory() {
|
|
211
|
+
if (this.historyPinned || !this.state.online)
|
|
212
|
+
return 0;
|
|
213
|
+
const removed = this.state.transcript.trimHistory(this.historyLimits);
|
|
214
|
+
if (removed)
|
|
215
|
+
releaseHistoryLayout(this.state.transcript);
|
|
216
|
+
return removed;
|
|
217
|
+
}
|
|
218
|
+
releaseTranscript() {
|
|
219
|
+
releaseHistoryLayout(this.state.transcript);
|
|
220
|
+
this.state.transcript.dispose();
|
|
221
|
+
this.historyPinned = false;
|
|
121
222
|
}
|
|
122
223
|
/** Stop the selected turn and then close, so quitting does not leave host work running.
|
|
123
224
|
* An idle session stays untouched, and an in-flight cancellation is awaited rather than repeated.
|
|
@@ -172,62 +273,26 @@ export class Controller {
|
|
|
172
273
|
try {
|
|
173
274
|
const sessions = array(object(await client.call('session/list', { _request: {} }, signal)).items).map(object);
|
|
174
275
|
signal.throwIfAborted();
|
|
276
|
+
const failures = [];
|
|
175
277
|
for (const session of sessions) {
|
|
176
278
|
signal.throwIfAborted();
|
|
177
279
|
const sessionId = string(session.sessionId);
|
|
178
280
|
if (!session.running && typeof session.updatedAt === 'number' && this.costUpdates.get(sessionId) === session.updatedAt)
|
|
179
281
|
continue;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
reject(error);
|
|
190
|
-
else
|
|
191
|
-
resolve(frame);
|
|
192
|
-
};
|
|
193
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
194
|
-
try {
|
|
195
|
-
sub = client.subscribe('session/follow', { request: { address: { kind: 'session', sessionId }, maxMessages: 80, assistantStream: true } }, {
|
|
196
|
-
item: value => { const frame = object(value); if (frame.type === 'snapshot')
|
|
197
|
-
finish(undefined, frame); },
|
|
198
|
-
end: error => finish(error ?? new Error('Cost history stream ended')),
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
catch (error) {
|
|
202
|
-
finish(error instanceof Error ? error : new Error(errorText(error)));
|
|
203
|
-
}
|
|
204
|
-
});
|
|
205
|
-
const cut = snapshot.cursor;
|
|
206
|
-
if (typeof cut !== 'number' || !Number.isSafeInteger(cut))
|
|
207
|
-
throw new Error('Invalid cost history cursor');
|
|
208
|
-
let page = snapshot;
|
|
209
|
-
const events = [];
|
|
210
|
-
while (true) {
|
|
282
|
+
try {
|
|
283
|
+
const history = await this.sessionCostHistory(session, signal);
|
|
284
|
+
await ledger.replace(sessionId, history.cursor, history.events);
|
|
285
|
+
if (!session.running && typeof session.updatedAt === 'number')
|
|
286
|
+
this.costUpdates.set(sessionId, session.updatedAt);
|
|
287
|
+
this.update({});
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
// One unreachable or rejected session must not freeze every other session's rates.
|
|
211
291
|
signal.throwIfAborted();
|
|
212
|
-
|
|
213
|
-
events.push(...costRecords(records));
|
|
214
|
-
if (!page.hasMore)
|
|
215
|
-
break;
|
|
216
|
-
const seqs = records.map(r => object(object(r).event).seq);
|
|
217
|
-
if (!seqs.length || seqs.some(n => typeof n !== 'number' || !Number.isSafeInteger(n)))
|
|
218
|
-
throw new Error('Invalid cost history page');
|
|
219
|
-
const beforeSeq = Math.min(...seqs);
|
|
220
|
-
page = object(await client.call('session/page', { request: { address: { kind: 'session', sessionId }, throughSeq: cut, beforeSeq, maxMessages: 80 } }, signal));
|
|
221
|
-
if (page.hasMore && array(page.records).every(r => Number(object(object(r).event).seq) >= beforeSeq))
|
|
222
|
-
throw new Error('Cost history page did not advance');
|
|
292
|
+
failures.push(`${sessionId}: ${errorText(error)}`);
|
|
223
293
|
}
|
|
224
|
-
if (object(snapshot.header).isSeeded === true && !events.some(e => e.type === 'session/end-seed' && object(e.data).inherited === true))
|
|
225
|
-
throw new Error('Cannot attribute inherited session usage');
|
|
226
|
-
await ledger.replace(sessionId, cut, events);
|
|
227
|
-
if (!session.running && typeof session.updatedAt === 'number')
|
|
228
|
-
this.costUpdates.set(sessionId, session.updatedAt);
|
|
229
|
-
this.update({});
|
|
230
294
|
}
|
|
295
|
+
ledger.error = failures.length === 0 ? '' : `${failures.length} of ${sessions.length} sessions failed: ${failures[0]}`;
|
|
231
296
|
ledger.scannedAt = Date.now();
|
|
232
297
|
}
|
|
233
298
|
catch (error) {
|
|
@@ -246,16 +311,132 @@ export class Controller {
|
|
|
246
311
|
this.costTask = undefined;
|
|
247
312
|
}
|
|
248
313
|
}
|
|
314
|
+
/** Read one session's complete cost history, retrying a subagent child with its other delivery mode. */
|
|
315
|
+
async sessionCostHistory(session, signal) {
|
|
316
|
+
let lastError;
|
|
317
|
+
for (const address of costAddresses(session)) {
|
|
318
|
+
try {
|
|
319
|
+
return await this.readCostHistory(address, signal);
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
lastError = error;
|
|
323
|
+
// Only a delivery-mode mismatch justifies the other form; every other failure is final here.
|
|
324
|
+
if (!(error instanceof RemoteError && error.code === 'subagent/unauthorized'))
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
throw lastError;
|
|
329
|
+
}
|
|
330
|
+
/** Page one addressed session's history into the billing events the ledger folds. */
|
|
331
|
+
async readCostHistory(address, signal) {
|
|
332
|
+
const client = this.host;
|
|
333
|
+
const snapshot = await new Promise((resolve, reject) => {
|
|
334
|
+
let sub;
|
|
335
|
+
const timeout = setTimeout(() => finish(new Error('Cost history snapshot timed out')), client.timeoutMs);
|
|
336
|
+
const onAbort = () => finish(new Error('Cost refresh cancelled'));
|
|
337
|
+
const finish = (error, frame) => {
|
|
338
|
+
clearTimeout(timeout);
|
|
339
|
+
signal.removeEventListener('abort', onAbort);
|
|
340
|
+
sub?.cancel();
|
|
341
|
+
if (error)
|
|
342
|
+
reject(error);
|
|
343
|
+
else
|
|
344
|
+
resolve(frame);
|
|
345
|
+
};
|
|
346
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
347
|
+
try {
|
|
348
|
+
sub = client.subscribe('session/follow', { request: { address, maxMessages: 80, assistantStream: true } }, {
|
|
349
|
+
item: value => { const frame = object(value); if (frame.type === 'snapshot')
|
|
350
|
+
finish(undefined, frame); },
|
|
351
|
+
end: error => finish(error ?? new Error('Cost history stream ended')),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
finish(error instanceof Error ? error : new Error(errorText(error)));
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
const cursor = snapshot.cursor;
|
|
359
|
+
if (typeof cursor !== 'number' || !Number.isSafeInteger(cursor))
|
|
360
|
+
throw new Error('Invalid cost history cursor');
|
|
361
|
+
let page = snapshot;
|
|
362
|
+
const events = [];
|
|
363
|
+
while (true) {
|
|
364
|
+
signal.throwIfAborted();
|
|
365
|
+
const records = array(page.records);
|
|
366
|
+
events.push(...costRecords(records));
|
|
367
|
+
if (!page.hasMore)
|
|
368
|
+
break;
|
|
369
|
+
const seqs = records.map(r => object(object(r).event).seq);
|
|
370
|
+
if (!seqs.length || seqs.some(n => typeof n !== 'number' || !Number.isSafeInteger(n)))
|
|
371
|
+
throw new Error('Invalid cost history page');
|
|
372
|
+
const beforeSeq = Math.min(...seqs);
|
|
373
|
+
page = object(await client.call('session/page', { request: { address, throughSeq: cursor, beforeSeq, maxMessages: 80 } }, signal));
|
|
374
|
+
if (page.hasMore && array(page.records).every(r => Number(object(object(r).event).seq) >= beforeSeq))
|
|
375
|
+
throw new Error('Cost history page did not advance');
|
|
376
|
+
}
|
|
377
|
+
if (object(snapshot.header).isSeeded === true && !events.some(e => e.type === 'session/end-seed' && object(e.data).inherited === true))
|
|
378
|
+
throw new Error('Cannot attribute inherited session usage');
|
|
379
|
+
return { cursor, events };
|
|
380
|
+
}
|
|
249
381
|
/** Refresh both lists from the host, then show the requested picker. */
|
|
250
382
|
async showPicker(screen) {
|
|
251
|
-
await this.releasePending();
|
|
252
383
|
const [workspaces, sessions] = await Promise.all([this.host.listWorkspaces(), this.host.listSessions()]);
|
|
253
384
|
this.update({ screen, workspaces, sessions });
|
|
254
385
|
}
|
|
386
|
+
/** Resolve a removal command to one reviewable object without changing the selection.
|
|
387
|
+
* @param kind - Workspace registration removal or session archival.
|
|
388
|
+
* @param query - Exact name, ID, or unambiguous ID prefix.
|
|
389
|
+
* @returns The fixed identity and display details for confirmation.
|
|
390
|
+
*/
|
|
391
|
+
async removalTarget(kind, query) {
|
|
392
|
+
const items = kind === 'workspace' ? await this.host.listWorkspaces() : await this.host.listSessions();
|
|
393
|
+
const row = resolveTarget(items, query, kind === 'workspace' ? 'workspaceId' : 'sessionId', item => kind === 'workspace' ? [string(item.title), string(item.path)] : [sessionLabel(item)]);
|
|
394
|
+
return kind === 'workspace' ? { kind, id: string(row.workspaceId), name: string(row.title), path: string(row.path) }
|
|
395
|
+
: { kind, id: string(row.sessionId), name: sessionLabel(row),
|
|
396
|
+
empty: row.blank === true && row.running === false
|
|
397
|
+
&& !this.runningUpdates.get(string(row.sessionId))
|
|
398
|
+
&& !(this.telemetry.view(string(row.sessionId)).queued ?? 0)
|
|
399
|
+
&& !(this.telemetry.view(string(row.sessionId)).jobs ?? 0)
|
|
400
|
+
&& !(this.state.sessionId === row.sessionId && this.admission) };
|
|
401
|
+
}
|
|
402
|
+
/** Apply a confirmed removal or freshly verified empty-session archival; directories and logs are preserved.
|
|
403
|
+
* @param target - Exact workspace or session identity reviewed by the user.
|
|
404
|
+
*/
|
|
405
|
+
async removeTarget(target) {
|
|
406
|
+
const client = this.host;
|
|
407
|
+
const receipt = object(await client.call(target.kind === 'workspace' ? 'workspace/delete' : 'workspace/archiveSession', {
|
|
408
|
+
request: target.kind === 'workspace' ? { workspaceId: target.id } : { sessionId: target.id },
|
|
409
|
+
}));
|
|
410
|
+
if (client !== this.client)
|
|
411
|
+
return;
|
|
412
|
+
if (target.kind === 'session') {
|
|
413
|
+
client.archivedSessionIds = new Set(array(receipt.archivedSessionIds).map(string));
|
|
414
|
+
if (this.state.sessionId === target.id) {
|
|
415
|
+
this.pickWorkspace(this.state.workspaceId);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
if (receipt.deleted !== true)
|
|
420
|
+
throw new Error('Host did not confirm workspace removal');
|
|
421
|
+
this.update({ workspaces: this.state.workspaces.filter(row => row.workspaceId !== target.id),
|
|
422
|
+
...(this.state.workspaceId === target.id ? { workspaceId: undefined } : {}) });
|
|
423
|
+
}
|
|
424
|
+
this.update({ screen: target.kind === 'workspace' ? 'workspaces' : 'sessions',
|
|
425
|
+
status: target.kind === 'workspace' ? 'Workspace registration removed' : 'Session archived' });
|
|
426
|
+
// A refresh failure must not make a successful mutation look like a rejected deletion.
|
|
427
|
+
try {
|
|
428
|
+
await this.showPicker(target.kind === 'workspace' ? 'workspaces' : 'sessions');
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
this.update({ error: `Removal completed; list refresh failed: ${errorText(error)}` });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
255
434
|
/** Pick a workspace, or use all sessions when the identity is omitted. */
|
|
256
435
|
pickWorkspace(workspaceId) {
|
|
257
|
-
this.follow?.cancel();
|
|
258
436
|
this.selection++;
|
|
437
|
+
this.follow?.cancel();
|
|
438
|
+
this.follow = undefined;
|
|
439
|
+
this.releaseTranscript();
|
|
259
440
|
this.update({ workspaceId, sessionId: undefined, showAllSessions: false, transcript: new Transcript(), screen: 'sessions' });
|
|
260
441
|
}
|
|
261
442
|
/** Open a workspace picker, or resolve a workspace by ID, exact title/path, or unique ID prefix. */
|
|
@@ -267,7 +448,6 @@ export class Controller {
|
|
|
267
448
|
const workspaces = await this.host.listWorkspaces();
|
|
268
449
|
const workspace = resolveTarget(workspaces, query, 'workspaceId', item => [string(item.title), string(item.path)]);
|
|
269
450
|
const sessions = await this.host.listSessions();
|
|
270
|
-
await this.releasePending();
|
|
271
451
|
this.update({ workspaces, sessions });
|
|
272
452
|
this.pickWorkspace(string(workspace.workspaceId));
|
|
273
453
|
}
|
|
@@ -302,9 +482,10 @@ export class Controller {
|
|
|
302
482
|
/** Replace the selected transcript and cancel its preceding follow stream. */
|
|
303
483
|
async selectSession(sessionId) {
|
|
304
484
|
this.stoppingSession = undefined;
|
|
305
|
-
await this.releasePending();
|
|
306
|
-
this.follow?.cancel();
|
|
307
485
|
const selection = ++this.selection;
|
|
486
|
+
this.follow?.cancel();
|
|
487
|
+
this.follow = undefined;
|
|
488
|
+
this.releaseTranscript();
|
|
308
489
|
const transcript = new Transcript();
|
|
309
490
|
const workspace = this.state.workspaces.find(item => array(item.sessionIds).includes(sessionId));
|
|
310
491
|
const workspaceId = workspace ? string(workspace.workspaceId)
|
|
@@ -320,10 +501,11 @@ export class Controller {
|
|
|
320
501
|
return;
|
|
321
502
|
try {
|
|
322
503
|
transcript.accept(value);
|
|
504
|
+
this.reclaimHistory();
|
|
323
505
|
const frame = object(value);
|
|
324
506
|
if (frame.type === 'snapshot')
|
|
325
507
|
this.telemetry.snapshot(sessionId, frame.projections);
|
|
326
|
-
this.update({ transcript, status: this.stoppingSession === sessionId ? this.state.status : transcript.
|
|
508
|
+
this.update({ transcript, status: this.stoppingSession === sessionId ? this.state.status : transcript.hasLiveContent ? 'Responding…' : 'Connected' });
|
|
327
509
|
}
|
|
328
510
|
catch (error) {
|
|
329
511
|
this.generationFailed?.(new Error(errorText(error)));
|
|
@@ -407,18 +589,109 @@ export class Controller {
|
|
|
407
589
|
this.update({ status: 'Cancellation requested' });
|
|
408
590
|
}
|
|
409
591
|
/** Add a page before the retained window using its fixed opening cut. */
|
|
410
|
-
async older(signal) {
|
|
411
|
-
const
|
|
592
|
+
async older(signal, transcript = this.state.transcript) {
|
|
593
|
+
const selection = this.selection;
|
|
594
|
+
if (transcript === this.state.transcript)
|
|
595
|
+
this.historyPinned = true;
|
|
412
596
|
if (!transcript.ready || !transcript.hasMore || transcript.beforeSeq === undefined)
|
|
413
597
|
return;
|
|
414
598
|
const result = await this.host.call('session/page', { request: {
|
|
415
599
|
address: { kind: 'session', sessionId: this.sessionId }, throughSeq: transcript.cursor,
|
|
416
600
|
beforeSeq: transcript.beforeSeq, maxMessages: 80,
|
|
417
601
|
} }, signal);
|
|
418
|
-
if (
|
|
602
|
+
if (selection !== this.selection)
|
|
419
603
|
return;
|
|
420
604
|
transcript.addPage(result);
|
|
421
|
-
this.update({
|
|
605
|
+
this.update({});
|
|
606
|
+
}
|
|
607
|
+
/** Search one page at a time, preserving only the first 200 matches and releasing temporary content.
|
|
608
|
+
* @param query - Literal, case-insensitive text including folded reasoning.
|
|
609
|
+
* @param signal - Cancels HTTP and processing without cancelling the agent.
|
|
610
|
+
* @returns Newest-first bounded summaries and an explicit truncation flag.
|
|
611
|
+
*/
|
|
612
|
+
async searchHistory(query, signal) {
|
|
613
|
+
const source = this.state.transcript;
|
|
614
|
+
if (!source.ready)
|
|
615
|
+
throw new Error('Wait for the session snapshot');
|
|
616
|
+
const sessionId = this.sessionId;
|
|
617
|
+
const selection = this.selection;
|
|
618
|
+
const throughSeq = source.readThrough;
|
|
619
|
+
const needle = query.toLowerCase();
|
|
620
|
+
const result = { items: [], truncated: false };
|
|
621
|
+
const scan = (transcript) => {
|
|
622
|
+
const messages = transcript.messages;
|
|
623
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
624
|
+
signal.throwIfAborted();
|
|
625
|
+
const message = messages[index];
|
|
626
|
+
if (message.role === 'Tool')
|
|
627
|
+
continue;
|
|
628
|
+
const text = message.text;
|
|
629
|
+
const match = text.toLowerCase().indexOf(needle);
|
|
630
|
+
if (match < 0)
|
|
631
|
+
continue;
|
|
632
|
+
if (result.items.length === 200) {
|
|
633
|
+
result.truncated = true;
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
result.items.push({ seq: message.seq, role: message.role, preview: Buffer.from(toolLine(text.slice(Math.max(0, match - 40), match + needle.length + 100), 160)).toString('utf8') });
|
|
637
|
+
}
|
|
638
|
+
return true;
|
|
639
|
+
};
|
|
640
|
+
signal.throwIfAborted();
|
|
641
|
+
if (!scan(source))
|
|
642
|
+
return result;
|
|
643
|
+
let beforeSeq = source.beforeSeq;
|
|
644
|
+
let hasMore = source.hasMore;
|
|
645
|
+
while (hasMore && beforeSeq !== undefined) {
|
|
646
|
+
const page = object(await this.host.call('session/page', { request: {
|
|
647
|
+
address: { kind: 'session', sessionId }, throughSeq, beforeSeq, maxMessages: 80,
|
|
648
|
+
} }, signal));
|
|
649
|
+
signal.throwIfAborted();
|
|
650
|
+
if (selection !== this.selection)
|
|
651
|
+
throw new Error('Session changed while searching history');
|
|
652
|
+
const temporary = new Transcript();
|
|
653
|
+
try {
|
|
654
|
+
temporary.accept({ type: 'snapshot', cursor: throughSeq, assistantStream: { revision: 0 }, records: page.records, hasMore: page.hasMore });
|
|
655
|
+
const next = temporary.beforeSeq;
|
|
656
|
+
if (temporary.hasMore && (next === undefined || next >= beforeSeq))
|
|
657
|
+
throw new Error('Host history page did not advance');
|
|
658
|
+
if (!scan(temporary))
|
|
659
|
+
return result;
|
|
660
|
+
beforeSeq = next;
|
|
661
|
+
hasMore = temporary.hasMore;
|
|
662
|
+
}
|
|
663
|
+
finally {
|
|
664
|
+
temporary.dispose();
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return result;
|
|
668
|
+
}
|
|
669
|
+
/** Load a separate small window ending at a search target; the live transcript keeps following.
|
|
670
|
+
* @param target - Durable message sequence to display.
|
|
671
|
+
* @param signal - Cancels the target-page request.
|
|
672
|
+
* @returns A caller-owned historical window that must be disposed when closed.
|
|
673
|
+
*/
|
|
674
|
+
async historyAt(target, signal) {
|
|
675
|
+
const source = this.state.transcript;
|
|
676
|
+
const selection = this.selection;
|
|
677
|
+
const page = object(await this.host.call('session/page', { request: {
|
|
678
|
+
address: { kind: 'session', sessionId: this.sessionId }, throughSeq: source.readThrough,
|
|
679
|
+
beforeSeq: target + 1, maxMessages: 80,
|
|
680
|
+
} }, signal));
|
|
681
|
+
signal.throwIfAborted();
|
|
682
|
+
if (selection !== this.selection)
|
|
683
|
+
throw new Error('Session changed while opening history');
|
|
684
|
+
const window = new Transcript();
|
|
685
|
+
try {
|
|
686
|
+
window.accept({ type: 'snapshot', cursor: source.readThrough, assistantStream: { revision: 0 }, records: page.records, hasMore: page.hasMore });
|
|
687
|
+
if (!window.messages.some(message => message.seq === target))
|
|
688
|
+
throw new Error('The host did not return the requested message');
|
|
689
|
+
return window;
|
|
690
|
+
}
|
|
691
|
+
catch (error) {
|
|
692
|
+
window.dispose();
|
|
693
|
+
throw error;
|
|
694
|
+
}
|
|
422
695
|
}
|
|
423
696
|
/** Load the prefix required for an explicit history jump; never loop on an unadvancing page.
|
|
424
697
|
* @param target - Visible record sequence, or first for the oldest available history.
|
|
@@ -445,7 +718,8 @@ export class Controller {
|
|
|
445
718
|
if (!pending)
|
|
446
719
|
throw new Error('No pending interaction');
|
|
447
720
|
await this.reply(pending, { kind: 'result', value });
|
|
448
|
-
this.
|
|
721
|
+
this.interactions.delete(string(pending.eventId));
|
|
722
|
+
this.update({});
|
|
449
723
|
}
|
|
450
724
|
/** Restrict an approval command to an approval request. */
|
|
451
725
|
async approve(allowed) {
|
|
@@ -455,11 +729,12 @@ export class Controller {
|
|
|
455
729
|
}
|
|
456
730
|
/** Present only sessions explicitly accounted to the selected workspace. */
|
|
457
731
|
get visibleSessions() {
|
|
732
|
+
const sessions = this.state.sessions.filter(item => !this.client?.archivedSessionIds.has(string(item.sessionId)));
|
|
458
733
|
if (this.state.showAllSessions || !this.state.workspaceId)
|
|
459
|
-
return
|
|
734
|
+
return sessions;
|
|
460
735
|
const workspace = this.state.workspaces.find(item => item.workspaceId === this.state.workspaceId);
|
|
461
736
|
const ids = new Set(array(workspace?.sessionIds ?? []).map(string));
|
|
462
|
-
return
|
|
737
|
+
return sessions.filter(item => ids.has(string(item.sessionId)));
|
|
463
738
|
}
|
|
464
739
|
get host() {
|
|
465
740
|
if (!this.client || !this.state.online)
|
|
@@ -472,18 +747,16 @@ export class Controller {
|
|
|
472
747
|
return this.state.sessionId;
|
|
473
748
|
}
|
|
474
749
|
update(patch) {
|
|
475
|
-
|
|
750
|
+
const next = { ...this.state, ...patch, version: this.state.version + 1 };
|
|
751
|
+
next.pending = next.online && next.screen === 'chat'
|
|
752
|
+
? [...this.interactions.values()].filter(frame => frame.agentId === next.sessionId) : [];
|
|
753
|
+
this.state = next;
|
|
476
754
|
for (const observer of this.observers)
|
|
477
755
|
observer();
|
|
478
756
|
}
|
|
479
757
|
async reply(frame, outcome) {
|
|
480
758
|
await this.host.call('$events/result', { clientId: this.clientId, eventId: string(frame.eventId), outcome });
|
|
481
759
|
}
|
|
482
|
-
async releasePending() {
|
|
483
|
-
for (const pending of this.state.pending)
|
|
484
|
-
await this.reply(pending, { kind: 'next' });
|
|
485
|
-
this.update({ pending: [] });
|
|
486
|
-
}
|
|
487
760
|
refreshCatalog(client) {
|
|
488
761
|
const revision = ++this.catalogRevision;
|
|
489
762
|
const task = client.call('session/modelCatalog', {}).then(value => {
|
|
@@ -506,7 +779,7 @@ export class Controller {
|
|
|
506
779
|
this.runningUpdates.clear();
|
|
507
780
|
this.observedRunningAt.clear();
|
|
508
781
|
this.telemetry = new Telemetry();
|
|
509
|
-
this.update({ controlError: undefined, modelError: undefined, defaultModel: undefined });
|
|
782
|
+
this.update({ controlError: undefined, modelError: undefined, defaultModel: undefined, presets: undefined, presetError: undefined });
|
|
510
783
|
const client = this.makeClient();
|
|
511
784
|
this.client = client;
|
|
512
785
|
try {
|
|
@@ -526,9 +799,9 @@ export class Controller {
|
|
|
526
799
|
resolve();
|
|
527
800
|
}
|
|
528
801
|
else if (frame.type === 'waterfall') {
|
|
529
|
-
if (
|
|
530
|
-
|
|
531
|
-
this.update({
|
|
802
|
+
if (['approval/request', 'user-questions/request'].includes(string(frame.event))) {
|
|
803
|
+
this.interactions.set(string(frame.eventId), frame);
|
|
804
|
+
this.update({});
|
|
532
805
|
}
|
|
533
806
|
else {
|
|
534
807
|
void client.call('$events/result', { clientId: this.clientId,
|
|
@@ -536,7 +809,8 @@ export class Controller {
|
|
|
536
809
|
}
|
|
537
810
|
}
|
|
538
811
|
else if (frame.type === 'cancel') {
|
|
539
|
-
this.
|
|
812
|
+
this.interactions.delete(string(frame.eventId));
|
|
813
|
+
this.update({});
|
|
540
814
|
}
|
|
541
815
|
else if (frame.type === 'emit' && frame.event === 'api-session/status') {
|
|
542
816
|
const args = array(frame.args);
|
|
@@ -630,6 +904,7 @@ export class Controller {
|
|
|
630
904
|
this.generationFailed = undefined;
|
|
631
905
|
this.selection++;
|
|
632
906
|
this.state.transcript.ready = false;
|
|
907
|
+
this.interactions.clear();
|
|
633
908
|
this.update({ online: false, pending: [] });
|
|
634
909
|
await client.close();
|
|
635
910
|
await this.costTask;
|
package/dist/cost-view.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/** Billing estimates with explicit coverage, calendar and unknown-price indicators. */
|
|
3
|
+
import { useTheme } from "./theme.js";
|
|
3
4
|
import { Box, Text } from 'ink';
|
|
4
5
|
import { costText } from "./cost.js";
|
|
5
6
|
import { safeText } from "./wire.js";
|
|
@@ -8,6 +9,7 @@ import { safeText } from "./wire.js";
|
|
|
8
9
|
* @returns Billing panel, including unpriced models and refresh errors.
|
|
9
10
|
*/
|
|
10
11
|
export function CostPanel({ controller }) {
|
|
12
|
+
const theme = useTheme();
|
|
11
13
|
const costs = controller.costs;
|
|
12
14
|
if (!costs)
|
|
13
15
|
return _jsx(Text, { children: "Cost tracking is unavailable" });
|
|
@@ -19,5 +21,5 @@ export function CostPanel({ controller }) {
|
|
|
19
21
|
return _jsxs(Box, { flexDirection: "column", borderStyle: "single", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Cost \u00B7 CNY estimate \u00B7 Asia/Shanghai \u00B7 /cost closes" }), rows.map(([label, total]) => _jsxs(Text, { children: [label, ": ", total ? `${costText(total)} · ${total.unknown} unpriced${total.estimated ? ` · ${total.estimated} estimated` : ''} / ${total.records} requests` : '?'] }, label)), _jsx(Text, { dimColor: true, children: costs.scanning ? 'Refreshing all visible sessions…'
|
|
20
22
|
: costs.coverage === 'partial' ? 'Partial totals · awaiting a complete scan'
|
|
21
23
|
: costs.scannedAt ? `Last refresh: ${new Date(costs.scannedAt).toISOString()}`
|
|
22
|
-
: 'Cached totals from the previous run' }), _jsx(Text, { dimColor: true, children: "Recorded settlement time determines tariff; * means a subtotal is not exact. Provider invoices are authoritative." }), costs.error && _jsxs(Text, { color:
|
|
24
|
+
: 'Cached totals from the previous run' }), _jsx(Text, { dimColor: true, children: "Recorded settlement time determines tariff; * means a subtotal is not exact. Provider invoices are authoritative." }), costs.error && _jsxs(Text, { color: theme.colors.context, children: ["Partial totals: ", safeText(costs.error)] }), costs.missing().slice(0, 6).map(reason => _jsx(Text, { color: theme.colors.context, children: safeText(reason) }, reason))] });
|
|
23
25
|
}
|
package/dist/cost.js
CHANGED
|
@@ -196,7 +196,6 @@ export class CostLedger {
|
|
|
196
196
|
async replace(sessionId, cut, events) {
|
|
197
197
|
if ((this.sessions.get(sessionId)?.cut ?? -2) > cut)
|
|
198
198
|
return;
|
|
199
|
-
const old = new Map(this.sessions.get(sessionId)?.charges.map(c => [c.key, c]));
|
|
200
199
|
const charges = [];
|
|
201
200
|
const inheritedCut = Math.max(-1, ...events.filter(e => e.type === 'session/end-seed' && object(e.data).inherited === true).map(e => Number(e.seq)));
|
|
202
201
|
let route = {};
|
|
@@ -227,15 +226,9 @@ export class CostLedger {
|
|
|
227
226
|
const key = charges[index]?.key ?? String(e.seq);
|
|
228
227
|
if (!usage && charges[index]?.usage)
|
|
229
228
|
continue;
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
charges[index] = previous;
|
|
234
|
-
last = { turn: d.turn, step: d.step, index };
|
|
235
|
-
continue;
|
|
236
|
-
}
|
|
237
|
-
const reusable = previous?.price && previous.provider === provider && previous.model === model ? [previous.price] : this.prices;
|
|
238
|
-
const selected = time === undefined ? lowestPrice(reusable, provider, model) : priceAt(reusable, provider, model, time);
|
|
229
|
+
// Every scan reprices from the current configuration, so correcting prices.json updates
|
|
230
|
+
// stored totals instead of leaving the version an earlier scan happened to apply.
|
|
231
|
+
const selected = time === undefined ? lowestPrice(this.prices, provider, model) : priceAt(this.prices, provider, model, time);
|
|
239
232
|
const estimate = usage && selected ? (usage.input * selected.rates.input + usage.output * selected.rates.output + usage.cacheRead * selected.rates.cacheRead + usage.cacheWrite * selected.rates.cacheWrite) / 1e6 : undefined;
|
|
240
233
|
const amount = estimate !== undefined && Number.isFinite(estimate) ? estimate : undefined;
|
|
241
234
|
charges[index] = { key, time, provider, model, usage, price: selected?.price, amount,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { HistoryRow } from './history.ts';
|
|
2
|
+
/** Render a viewport with role, reasoning, tool and result colors from the selected theme.
|
|
3
|
+
* @param rows - Already wrapped, terminal-safe visible rows.
|
|
4
|
+
* @returns Colored text with unchanged row geometry and plain-text role markers.
|
|
5
|
+
*/
|
|
6
|
+
export declare function HistoryViewport({ rows }: {
|
|
7
|
+
rows: HistoryRow[];
|
|
8
|
+
}): import("react").JSX.Element;
|