@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.
- package/README.i18n.yaml +2 -2
- package/README.md +10 -10
- package/README.zh.md +7 -7
- package/dist/cli/dsht.d.ts +2 -0
- package/dist/cli/dsht.js +125 -0
- package/dist/cli/index.js +16 -123
- package/dist/controller/controller.d.ts +115 -0
- package/dist/controller/controller.js +126 -1
- package/dist/controller/perf-measures.d.ts +34 -0
- package/dist/controller/perf-measures.js +78 -0
- package/dist/cost/controller.d.ts +5 -0
- package/dist/cost/controller.js +2 -1
- package/dist/cost/scanner.d.ts +4 -2
- package/dist/cost/scanner.js +6 -3
- package/dist/session/controller.d.ts +157 -1
- package/dist/session/controller.js +395 -29
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +1 -0
- package/dist/session/info.d.ts +262 -0
- package/dist/session/info.js +326 -0
- package/dist/session/navigation.d.ts +62 -8
- package/dist/session/navigation.js +72 -13
- package/dist/session/transcript.d.ts +37 -1
- package/dist/session/transcript.js +73 -0
- package/dist/state.d.ts +3 -2
- package/dist/state.js +2 -2
- package/dist/ui/app.js +230 -178
- package/dist/ui/chat/status.js +15 -10
- package/dist/ui/dialogs/index.d.ts +9 -8
- package/dist/ui/dialogs/index.js +3 -3
- package/dist/ui/dialogs/picker.d.ts +21 -3
- package/dist/ui/dialogs/picker.js +37 -5
- package/dist/ui/input/input.d.ts +18 -3
- package/dist/ui/input/input.js +61 -22
- package/dist/ui/input/viewport.d.ts +96 -0
- package/dist/ui/input/viewport.js +173 -0
- package/dsht-m.png +0 -0
- package/package.json +3 -3
- package/dist/ui/input/history.d.ts +0 -19
- package/dist/ui/input/history.js +0 -43
|
@@ -7,11 +7,14 @@ import { saveTranscriptHtml } from "./export-html.js";
|
|
|
7
7
|
import { releaseHistoryLayout } from "./history.js";
|
|
8
8
|
import { resolveTarget, sessionLabel } from "./navigation.js";
|
|
9
9
|
import { fileReferences } from "./references.js";
|
|
10
|
-
import {
|
|
10
|
+
import { PromptCache } from "./info.js";
|
|
11
|
+
import { recordPrompts, Transcript, toolLine } from "./transcript.js";
|
|
11
12
|
/** Built-in preset identifiers and the labels the web session header shows. */
|
|
12
13
|
const BUILT_IN_MODES = new Map([['standard', 'Standard mode'], ['ptc', 'PTC mode'], ['minimal', 'Minimal mode'], ['cordis', 'Creator mode']]);
|
|
13
14
|
/** Bounded match count for one history search. */
|
|
14
15
|
const SEARCH_MATCH_LIMIT = 200;
|
|
16
|
+
/** Bounded page count for the prompt backfill that runs once per opened session. */
|
|
17
|
+
const PROMPT_BACKFILL_PAGES = 200;
|
|
15
18
|
/** Owns the selected session: its follow stream, transcript, history window and interactions. */
|
|
16
19
|
export class SessionController {
|
|
17
20
|
store;
|
|
@@ -20,7 +23,13 @@ export class SessionController {
|
|
|
20
23
|
historyLimits;
|
|
21
24
|
follow;
|
|
22
25
|
interactions = new Map();
|
|
23
|
-
|
|
26
|
+
/** Cancels the background prompt backfill of the previous selection. */
|
|
27
|
+
promptBackfill;
|
|
28
|
+
/** Prompts of sessions this process has already read, so re-opening one costs no page request. */
|
|
29
|
+
promptCache = new PromptCache();
|
|
30
|
+
/** Prompt index, composer and reading view of the selected session; the instance `State.session` exposes. */
|
|
31
|
+
get info() { return this.store.state.session; }
|
|
32
|
+
get prompts() { return this.info.prompts; }
|
|
24
33
|
stoppingSession;
|
|
25
34
|
interruptTask;
|
|
26
35
|
admission;
|
|
@@ -61,7 +70,7 @@ export class SessionController {
|
|
|
61
70
|
get workingSince() {
|
|
62
71
|
if (!this.running || !this.store.state.sessionId)
|
|
63
72
|
return undefined;
|
|
64
|
-
return this.
|
|
73
|
+
return this.info.record.activeTurnStartedAt ?? this.connection.observedAt(this.store.state.sessionId);
|
|
65
74
|
}
|
|
66
75
|
/** Present only sessions explicitly accounted to the selected workspace. */
|
|
67
76
|
get visibleSessions() {
|
|
@@ -75,13 +84,13 @@ export class SessionController {
|
|
|
75
84
|
/** Whether a turn, cancellation or prompt admission is still in flight. */
|
|
76
85
|
get active() { return this.interruptTask !== undefined || this.running || this.admission !== undefined; }
|
|
77
86
|
/** Whether reading protects the loaded window, suspending history reclamation. */
|
|
78
|
-
get pinned() { return this.
|
|
87
|
+
get pinned() { return this.info.view.pinned; }
|
|
79
88
|
/** Drop generation-scoped state before a new connection generation begins. */
|
|
80
89
|
beginGeneration() { this.stoppingSession = undefined; }
|
|
81
90
|
/** Invalidate in-flight work and drop transient interactions when a generation ends. */
|
|
82
91
|
endGeneration() {
|
|
83
92
|
this.store.bumpSelection();
|
|
84
|
-
this.
|
|
93
|
+
this.info.record.ready = false;
|
|
85
94
|
this.interactions.clear();
|
|
86
95
|
}
|
|
87
96
|
/** Wait for an in-flight cancellation so shutdown leaves nothing running. */
|
|
@@ -95,6 +104,23 @@ export class SessionController {
|
|
|
95
104
|
pendingFor(state) {
|
|
96
105
|
return [...this.interactions.values()].filter(frame => frame.agentId === state.sessionId);
|
|
97
106
|
}
|
|
107
|
+
/** Unanswered interactions by session, so a list can show who is waiting without opening them.
|
|
108
|
+
*
|
|
109
|
+
* The host delivers approval and question waterfalls for every session on one stream, and this
|
|
110
|
+
* client already retains them to answer later, so the counts are a fact of this generation rather
|
|
111
|
+
* than a new subscription. They live only as long as the connection: a reconnect clears the map
|
|
112
|
+
* until the host replays the pending waterfalls.
|
|
113
|
+
* @returns One count per session holding at least one unanswered interaction.
|
|
114
|
+
*/
|
|
115
|
+
pendingCounts() {
|
|
116
|
+
const counts = new Map();
|
|
117
|
+
for (const frame of this.interactions.values()) {
|
|
118
|
+
if (typeof frame.agentId !== 'string')
|
|
119
|
+
continue;
|
|
120
|
+
counts.set(frame.agentId, (counts.get(frame.agentId) ?? 0) + 1);
|
|
121
|
+
}
|
|
122
|
+
return counts;
|
|
123
|
+
}
|
|
98
124
|
/** Retain a recognized host waterfall; unknown events stay with the connection to delegate.
|
|
99
125
|
* @param frame - One decoded waterfall frame.
|
|
100
126
|
* @returns Whether this domain retained the frame for an answer.
|
|
@@ -164,7 +190,7 @@ export class SessionController {
|
|
|
164
190
|
* @param pinned - Whether the main transcript is actively being read away from its tail.
|
|
165
191
|
*/
|
|
166
192
|
pinHistory(pinned) {
|
|
167
|
-
this.
|
|
193
|
+
this.info.view.pinned = pinned;
|
|
168
194
|
if (!pinned && this.reclaimHistory())
|
|
169
195
|
this.store.update({});
|
|
170
196
|
}
|
|
@@ -237,7 +263,7 @@ export class SessionController {
|
|
|
237
263
|
this.follow?.cancel();
|
|
238
264
|
this.follow = undefined;
|
|
239
265
|
this.releaseTranscript();
|
|
240
|
-
this.store.update({ workspaceId, sessionId: undefined, showAllSessions: false,
|
|
266
|
+
this.store.update({ workspaceId, sessionId: undefined, showAllSessions: false, screen: 'sessions' });
|
|
241
267
|
}
|
|
242
268
|
/** Open a workspace picker, or resolve a workspace by ID, exact title/path, or unique ID prefix.
|
|
243
269
|
* @param query - Workspace target, if any.
|
|
@@ -296,13 +322,13 @@ export class SessionController {
|
|
|
296
322
|
const selection = this.store.selection();
|
|
297
323
|
this.follow?.cancel();
|
|
298
324
|
this.follow = undefined;
|
|
299
|
-
this.
|
|
300
|
-
const transcript =
|
|
325
|
+
this.info.reset(sessionId);
|
|
326
|
+
const transcript = this.info.record;
|
|
301
327
|
const workspace = this.store.state.workspaces.find(item => array(item.sessionIds).includes(sessionId));
|
|
302
328
|
const workspaceId = workspace ? string(workspace.workspaceId)
|
|
303
329
|
: this.store.state.sessions.some(item => item.sessionId === sessionId) ? undefined : this.store.state.workspaceId;
|
|
304
330
|
this.connection.observe(sessionId);
|
|
305
|
-
this.store.update({ sessionId, workspaceId, showAllSessions: false,
|
|
331
|
+
this.store.update({ sessionId, workspaceId, showAllSessions: false, screen: 'chat', status: 'Loading session…' });
|
|
306
332
|
this.follow = this.host.require().subscribe('session/follow', {
|
|
307
333
|
request: { address: { kind: 'session', sessionId }, maxMessages: 80, assistantStream: true },
|
|
308
334
|
}, {
|
|
@@ -311,11 +337,12 @@ export class SessionController {
|
|
|
311
337
|
return;
|
|
312
338
|
try {
|
|
313
339
|
transcript.accept(value);
|
|
340
|
+
this.prompts.fold(transcript.promptsSince(this.prompts.through));
|
|
314
341
|
this.reclaimHistory();
|
|
315
342
|
const frame = object(value);
|
|
316
343
|
if (frame.type === 'snapshot')
|
|
317
344
|
this.connection.telemetryView().snapshot(sessionId, frame.projections);
|
|
318
|
-
this.store.update({
|
|
345
|
+
this.store.update({ status: this.stoppingSession === sessionId ? this.store.state.status : transcript.hasLiveContent ? 'Responding…' : 'Connected' });
|
|
319
346
|
}
|
|
320
347
|
catch (error) {
|
|
321
348
|
this.connection.fail(new Error(errorText(error)));
|
|
@@ -328,16 +355,17 @@ export class SessionController {
|
|
|
328
355
|
this.store.update({ status: 'Session disconnected', error: errorText(error ?? 'Session stream ended') });
|
|
329
356
|
},
|
|
330
357
|
});
|
|
358
|
+
this.backfillPrompts(sessionId, selection);
|
|
331
359
|
}
|
|
332
360
|
/** Wait for the selected follow snapshot, failing on disconnect or cancellation.
|
|
333
361
|
* @param signal - Cancels waiting without closing the session.
|
|
334
362
|
*/
|
|
335
363
|
async waitForHistory(signal) {
|
|
336
|
-
const transcript = this.
|
|
364
|
+
const transcript = this.info.record;
|
|
337
365
|
const deadline = Date.now() + this.host.require().timeoutMs;
|
|
338
366
|
while (!transcript.ready) {
|
|
339
367
|
signal.throwIfAborted();
|
|
340
|
-
if (!this.store.state.online || this.
|
|
368
|
+
if (!this.store.state.online || this.info.record !== transcript)
|
|
341
369
|
throw new Error('Session changed while loading history');
|
|
342
370
|
if (Date.now() >= deadline)
|
|
343
371
|
throw new Error('Session snapshot timed out');
|
|
@@ -380,7 +408,7 @@ export class SessionController {
|
|
|
380
408
|
* @returns The host's successful command result text.
|
|
381
409
|
*/
|
|
382
410
|
async command(line, signal) {
|
|
383
|
-
if (!this.
|
|
411
|
+
if (!this.info.record.ready)
|
|
384
412
|
throw new Error('Wait for the session snapshot before running commands');
|
|
385
413
|
const execution = await this.host.require().call('commands/execute', {
|
|
386
414
|
agentId: this.sessionId, line, submittedAttachments: [],
|
|
@@ -393,6 +421,11 @@ export class SessionController {
|
|
|
393
421
|
}
|
|
394
422
|
if (result.kind === 'error')
|
|
395
423
|
throw new Error(string(result.text));
|
|
424
|
+
// Compaction rewrites the host log, so a cached prompt list for this session may describe
|
|
425
|
+
// records that no longer exist. The live index keeps what the reader already sees; the cache is
|
|
426
|
+
// dropped so the next open reads the rewritten history.
|
|
427
|
+
if (line.trim().split(/\s/, 1)[0] === '/compact')
|
|
428
|
+
this.promptCache.drop(this.sessionId);
|
|
396
429
|
return result.text === undefined ? 'Command completed.' : string(result.text);
|
|
397
430
|
}
|
|
398
431
|
/** Remove one host-owned pending input; an already claimed item reports a host error.
|
|
@@ -415,7 +448,7 @@ export class SessionController {
|
|
|
415
448
|
* @returns Absolute saved filename.
|
|
416
449
|
*/
|
|
417
450
|
async exportHtml(path, signal) {
|
|
418
|
-
return saveTranscriptHtml(this.
|
|
451
|
+
return saveTranscriptHtml(this.info.record, this.sessionId, path, signal);
|
|
419
452
|
}
|
|
420
453
|
/** Admit text once as steering while running, or a new turn while idle; a lost response can leave delivery uncertain.
|
|
421
454
|
* @param text - Composed prompt text.
|
|
@@ -424,7 +457,7 @@ export class SessionController {
|
|
|
424
457
|
if (this.store.state.pending.length)
|
|
425
458
|
throw new Error('Answer the pending question or approval first');
|
|
426
459
|
this.stoppingSession = undefined;
|
|
427
|
-
if (!this.
|
|
460
|
+
if (!this.info.record.ready)
|
|
428
461
|
throw new Error('Wait for the session snapshot before sending');
|
|
429
462
|
const admission = this.host.require().call('session/prompt', { request: {
|
|
430
463
|
sessionId: this.sessionId, requestId: randomUUID(), mode: this.running ? 'steer' : 'queue',
|
|
@@ -459,14 +492,34 @@ export class SessionController {
|
|
|
459
492
|
throw new Error('No pending approval');
|
|
460
493
|
await this.answer(allowed ? 'allowed-once' : 'rejected');
|
|
461
494
|
}
|
|
495
|
+
/** Dismiss the whole selected-session question set without answering it.
|
|
496
|
+
*
|
|
497
|
+
* The Web client's close button settles the same waterfall the same way — reject with
|
|
498
|
+
* `ASK_CANCELLED` — so the host records a user cancellation rather than an answer. A question
|
|
499
|
+
* batch is answered as one request, so dismissals also discard partial local answers.
|
|
500
|
+
*/
|
|
501
|
+
async dismissQuestion() {
|
|
502
|
+
const pending = this.store.state.pending[0];
|
|
503
|
+
if (pending?.event !== 'user-questions/request')
|
|
504
|
+
throw new Error('No pending question');
|
|
505
|
+
await this.reply(pending, { kind: 'rejected', error: {
|
|
506
|
+
name: 'UserQuestionError', message: 'the user cancelled ask_user_question', code: 'ASK_CANCELLED',
|
|
507
|
+
} });
|
|
508
|
+
this.interactions.delete(string(pending.eventId));
|
|
509
|
+
this.store.update({});
|
|
510
|
+
}
|
|
462
511
|
/** Add a page before the retained window using its fixed opening cut.
|
|
512
|
+
*
|
|
513
|
+
* Reclamation is deliberately not pinned here. Every caller that needs the fetched page to
|
|
514
|
+
* survive is already covered by the view: scrolling back sets a scroll position, `/think` opens a
|
|
515
|
+
* panel, and a recall page folds its prompts into the index before this resolves. A pin set here
|
|
516
|
+
* had no clearing edge, so after one recall page the history budget silently stopped applying for
|
|
517
|
+
* the rest of the session while every following page kept adding records.
|
|
463
518
|
* @param signal - Cancels local paging without interrupting the remote agent.
|
|
464
519
|
* @param transcript - Transcript to extend; defaults to the live one.
|
|
465
520
|
*/
|
|
466
|
-
async older(signal, transcript = this.
|
|
521
|
+
async older(signal, transcript = this.info.record) {
|
|
467
522
|
const selection = this.store.selection();
|
|
468
|
-
if (transcript === this.store.state.transcript)
|
|
469
|
-
this.historyPinned = true;
|
|
470
523
|
if (!transcript.ready || !transcript.hasMore || transcript.beforeSeq === undefined)
|
|
471
524
|
return;
|
|
472
525
|
const result = await this.host.require().call('session/page', { request: {
|
|
@@ -478,13 +531,326 @@ export class SessionController {
|
|
|
478
531
|
transcript.addPage(result);
|
|
479
532
|
this.store.update({});
|
|
480
533
|
}
|
|
534
|
+
/** Recall one step through the session's prompt index; navigation never touches the network.
|
|
535
|
+
* @param direction - Negative for older input, positive for newer input.
|
|
536
|
+
* @param current - Composer content before recall began, restored at the newest position.
|
|
537
|
+
* @returns The recalled prompt, or the unsent draft.
|
|
538
|
+
*/
|
|
539
|
+
recall(direction, current) { return this.prompts.move(direction, current); }
|
|
540
|
+
/** Composer draft, caret and parked draft, as the selected session holds them. */
|
|
541
|
+
get composer() { return this.info.composer; }
|
|
542
|
+
/** Replace the composer text and caret, publishing only when either actually changed. */
|
|
543
|
+
setComposer(draft, cursor = draft.length) {
|
|
544
|
+
const composer = this.info.composer;
|
|
545
|
+
if (composer.draft === draft && composer.cursor === cursor)
|
|
546
|
+
return;
|
|
547
|
+
composer.draft = draft;
|
|
548
|
+
composer.cursor = cursor;
|
|
549
|
+
this.store.update({});
|
|
550
|
+
}
|
|
551
|
+
/** Move the caret without changing the text. */
|
|
552
|
+
setComposerCursor(cursor) {
|
|
553
|
+
const composer = this.info.composer;
|
|
554
|
+
if (composer.cursor === cursor)
|
|
555
|
+
return;
|
|
556
|
+
composer.cursor = cursor;
|
|
557
|
+
this.store.update({});
|
|
558
|
+
}
|
|
559
|
+
/** Move a non-empty draft aside while a dialog owns the keyboard. */
|
|
560
|
+
parkComposer() {
|
|
561
|
+
const composer = this.info.composer;
|
|
562
|
+
if (composer.draft === '')
|
|
563
|
+
return;
|
|
564
|
+
composer.parked = composer.draft;
|
|
565
|
+
composer.draft = '';
|
|
566
|
+
composer.cursor = 0;
|
|
567
|
+
this.store.update({});
|
|
568
|
+
}
|
|
569
|
+
/** Give a parked draft back once no dialog needs the keyboard. */
|
|
570
|
+
restoreComposer() {
|
|
571
|
+
const composer = this.info.composer;
|
|
572
|
+
if (composer.parked === '')
|
|
573
|
+
return;
|
|
574
|
+
composer.draft = composer.parked;
|
|
575
|
+
composer.cursor = composer.parked.length;
|
|
576
|
+
composer.parked = '';
|
|
577
|
+
this.store.update({});
|
|
578
|
+
}
|
|
579
|
+
/** How the selected session's record is being read right now. */
|
|
580
|
+
get view() { return this.info.view; }
|
|
581
|
+
/** Show a detached history window, releasing the one it replaces.
|
|
582
|
+
* @param window - Record to display, or undefined to return to the live transcript.
|
|
583
|
+
*/
|
|
584
|
+
setViewWindow(window) {
|
|
585
|
+
if (this.info.view.window === window)
|
|
586
|
+
return;
|
|
587
|
+
this.info.closeWindow();
|
|
588
|
+
this.info.view.window = window;
|
|
589
|
+
this.store.update({});
|
|
590
|
+
}
|
|
591
|
+
/** Move the reader's position inside the displayed record.
|
|
592
|
+
* @param scroll - Rows scrolled back from the live end.
|
|
593
|
+
*/
|
|
594
|
+
setScroll(scroll) {
|
|
595
|
+
if (this.info.view.scroll === scroll)
|
|
596
|
+
return;
|
|
597
|
+
this.info.view.scroll = scroll;
|
|
598
|
+
this.store.update({});
|
|
599
|
+
}
|
|
600
|
+
/** Replace the set of expanded reasoning blocks, keyed by message sequence.
|
|
601
|
+
* @param folds - Sequences to expand beyond the default fold.
|
|
602
|
+
*/
|
|
603
|
+
setFolds(folds) {
|
|
604
|
+
if (this.info.view.folds === folds)
|
|
605
|
+
return;
|
|
606
|
+
this.info.view.folds = folds;
|
|
607
|
+
this.store.update({});
|
|
608
|
+
}
|
|
609
|
+
/** Set the fold mode of the live attempt's completed reasoning.
|
|
610
|
+
* @param reasoning - `row` to fold, `full` to keep the streamed text.
|
|
611
|
+
*/
|
|
612
|
+
setLiveReasoning(reasoning) {
|
|
613
|
+
if (this.info.view.liveReasoning === reasoning)
|
|
614
|
+
return;
|
|
615
|
+
this.info.view.liveReasoning = reasoning;
|
|
616
|
+
this.store.update({});
|
|
617
|
+
}
|
|
618
|
+
/** Local answer state for the selected session's pending waterfalls. */
|
|
619
|
+
get interaction() { return this.info.interaction; }
|
|
620
|
+
/** Replace the partly collected answers, keyed by the waterfall event id.
|
|
621
|
+
* @param answers - Answers collected so far, by event id.
|
|
622
|
+
*/
|
|
623
|
+
setAnswers(answers) {
|
|
624
|
+
this.info.interaction.answers = answers;
|
|
625
|
+
this.store.update({});
|
|
626
|
+
}
|
|
627
|
+
/** Replace the pending question's option keyboard state.
|
|
628
|
+
* @param option - Highlighted option, toggled labels and free-text mode; undefined clears it.
|
|
629
|
+
*/
|
|
630
|
+
setOption(option) {
|
|
631
|
+
if (this.info.interaction.option === option)
|
|
632
|
+
return;
|
|
633
|
+
this.info.interaction.option = option;
|
|
634
|
+
this.store.update({});
|
|
635
|
+
}
|
|
636
|
+
/** Replace the pending approval's selected row.
|
|
637
|
+
* @param approval - Selected approval row; undefined clears the highlight.
|
|
638
|
+
*/
|
|
639
|
+
setApproval(approval) {
|
|
640
|
+
if (this.info.interaction.approval === approval)
|
|
641
|
+
return;
|
|
642
|
+
this.info.interaction.approval = approval;
|
|
643
|
+
this.store.update({});
|
|
644
|
+
}
|
|
645
|
+
/** Composer-adjacent `@` reference menu state. */
|
|
646
|
+
get reference() { return this.info.reference; }
|
|
647
|
+
/** Highlight one row of the open reference menu.
|
|
648
|
+
* @param index - Row index into the current matches.
|
|
649
|
+
*/
|
|
650
|
+
setReferenceIndex(index) {
|
|
651
|
+
if (this.info.reference.index === index)
|
|
652
|
+
return;
|
|
653
|
+
this.info.reference.index = index;
|
|
654
|
+
this.store.update({});
|
|
655
|
+
}
|
|
656
|
+
/** Remember the draft that dismissed the reference menu, so it does not reopen while it stands.
|
|
657
|
+
* @param draft - Composer text at dismissal, or undefined to allow the menu again.
|
|
658
|
+
*/
|
|
659
|
+
setReferenceDismissed(draft) {
|
|
660
|
+
if (this.info.reference.dismissed === draft)
|
|
661
|
+
return;
|
|
662
|
+
this.info.reference.dismissed = draft;
|
|
663
|
+
this.store.update({});
|
|
664
|
+
}
|
|
665
|
+
/** Panels the selected session has open. */
|
|
666
|
+
get panels() { return this.info.panels; }
|
|
667
|
+
/** Show or hide the reasoning panel.
|
|
668
|
+
* @param open - Whether `/think` is open.
|
|
669
|
+
*/
|
|
670
|
+
openThoughts(open) {
|
|
671
|
+
if (this.info.panels.thoughts === open)
|
|
672
|
+
return;
|
|
673
|
+
this.info.panels.thoughts = open;
|
|
674
|
+
this.store.update({});
|
|
675
|
+
}
|
|
676
|
+
/** Show or hide the pending-input panel.
|
|
677
|
+
* @param open - Whether `/queue` is open.
|
|
678
|
+
*/
|
|
679
|
+
openQueue(open) {
|
|
680
|
+
if (this.info.panels.queue === open)
|
|
681
|
+
return;
|
|
682
|
+
this.info.panels.queue = open;
|
|
683
|
+
this.store.update({});
|
|
684
|
+
}
|
|
685
|
+
/** Show the model dialog at one step, or close it.
|
|
686
|
+
* @param model - Catalog plus the provider or model being inspected; undefined closes the dialog.
|
|
687
|
+
*/
|
|
688
|
+
setModelPanel(model) {
|
|
689
|
+
if (this.info.panels.model === model)
|
|
690
|
+
return;
|
|
691
|
+
this.info.panels.model = model;
|
|
692
|
+
this.store.update({});
|
|
693
|
+
}
|
|
694
|
+
/** Show the history or content-search dialog, or close it.
|
|
695
|
+
* @param history - Query, content-search mode and matches; undefined closes the dialog.
|
|
696
|
+
*/
|
|
697
|
+
setHistoryPanel(history) {
|
|
698
|
+
if (this.info.panels.history === history)
|
|
699
|
+
return;
|
|
700
|
+
this.info.panels.history = history;
|
|
701
|
+
this.store.update({});
|
|
702
|
+
}
|
|
703
|
+
/** Show the host session-search results, or close them.
|
|
704
|
+
* @param search - Query, results and truncation flag; undefined closes the dialog.
|
|
705
|
+
*/
|
|
706
|
+
setSearchPanel(search) {
|
|
707
|
+
if (this.info.panels.search === search)
|
|
708
|
+
return;
|
|
709
|
+
this.info.panels.search = search;
|
|
710
|
+
this.store.update({});
|
|
711
|
+
}
|
|
712
|
+
/** Remember a locally submitted command, which never becomes a durable session record. */
|
|
713
|
+
recordRecall(value) { this.prompts.record(value); }
|
|
714
|
+
/** Leave recall navigation because the composer was edited or replaced. */
|
|
715
|
+
resetRecall() { this.prompts.resetCursor(); }
|
|
716
|
+
/** Whether recall is parked on the oldest prompt it retains in memory. */
|
|
717
|
+
get recallAtOldest() { return this.prompts.atOldest; }
|
|
718
|
+
/** How many prompts the session retains for recall. */
|
|
719
|
+
get recallLength() { return this.prompts.length; }
|
|
720
|
+
/** Whether an older prompt is reachable at all: shed from the window, or still on the host. */
|
|
721
|
+
get recallHasOlder() {
|
|
722
|
+
const transcript = this.info.record;
|
|
723
|
+
const oldest = this.prompts.oldest;
|
|
724
|
+
const refillable = oldest !== undefined && transcript.beforeSeq !== undefined && oldest > transcript.beforeSeq;
|
|
725
|
+
return refillable || (!this.prompts.exhausted && transcript.hasMore);
|
|
726
|
+
}
|
|
727
|
+
/** Recover older prompts from the loaded window before spending a page request.
|
|
728
|
+
*
|
|
729
|
+
* The transcript window can still hold prompts the index's budgets evicted, so a backward step
|
|
730
|
+
* refills from memory first and only then asks the caller to page. That is what keeps recall
|
|
731
|
+
* complete across submissions, where the previous buffer lost them at the eviction boundary.
|
|
732
|
+
* @returns Whether any older prompt was recovered.
|
|
733
|
+
*/
|
|
734
|
+
refillRecall() {
|
|
735
|
+
const oldest = this.prompts.oldest;
|
|
736
|
+
if (oldest === undefined)
|
|
737
|
+
return false;
|
|
738
|
+
return this.prompts.prepend(this.info.record.promptsBefore(oldest)) > 0;
|
|
739
|
+
}
|
|
740
|
+
/** Seed recall from a complete cached entry, so an open that follows a scan costs no request.
|
|
741
|
+
* @param sessionId - Session being opened.
|
|
742
|
+
* @returns Whether the cache covered this session.
|
|
743
|
+
*/
|
|
744
|
+
adoptCachedPrompts(sessionId) {
|
|
745
|
+
const cached = this.promptCache.get(sessionId);
|
|
746
|
+
if (!cached?.complete)
|
|
747
|
+
return false;
|
|
748
|
+
const oldest = this.prompts.oldest;
|
|
749
|
+
const older = oldest === undefined ? cached.prompts : cached.prompts.filter(prompt => prompt.seq < oldest);
|
|
750
|
+
this.prompts.prepend(older);
|
|
751
|
+
// Same budget as the walk it replaces: `settle` may shed the oldest prefix, and `markComplete`
|
|
752
|
+
// then refuses, so the lazy backward step stays available for whatever was shed.
|
|
753
|
+
this.prompts.settle();
|
|
754
|
+
this.prompts.markComplete();
|
|
755
|
+
this.store.update({});
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
/** Fold one history page the cost scan already read into the prompt cache.
|
|
759
|
+
*
|
|
760
|
+
* The scan reads every session's whole history on connect, so this is where two readers stop
|
|
761
|
+
* paying twice: it hands over pages it already has, and an open that follows reads the cache.
|
|
762
|
+
* @param sessionId - Session the page belongs to.
|
|
763
|
+
* @param records - Raw records of one scanned page.
|
|
764
|
+
*/
|
|
765
|
+
rememberScanPage(sessionId, records) {
|
|
766
|
+
this.promptCache.observe(sessionId, recordPrompts(records), false);
|
|
767
|
+
}
|
|
768
|
+
/** Report that the scanned session's history was read to its beginning.
|
|
769
|
+
* @param sessionId - Session the scan finished.
|
|
770
|
+
*/
|
|
771
|
+
rememberScanDone(sessionId) {
|
|
772
|
+
this.promptCache.observe(sessionId, [], true);
|
|
773
|
+
}
|
|
774
|
+
/** Fold every prompt the host still holds into the recall index, in the background.
|
|
775
|
+
*
|
|
776
|
+
* Session start delivers only the newest window, so without this the arrows could reach older
|
|
777
|
+
* prompts but not show them without paging first. Each page is parsed into a temporary transcript
|
|
778
|
+
* and only its prompts are kept, so the live record, its memory window and the row cache never
|
|
779
|
+
* grow. The walk is bounded and the next selection cancels it; anything past the bound is still
|
|
780
|
+
* reachable through the lazy backward step.
|
|
781
|
+
* @param sessionId - Session being opened.
|
|
782
|
+
* @param selection - Selector generation that must still be current.
|
|
783
|
+
*/
|
|
784
|
+
backfillPrompts(sessionId, selection) {
|
|
785
|
+
this.promptBackfill?.abort();
|
|
786
|
+
const abort = new AbortController();
|
|
787
|
+
this.promptBackfill = abort;
|
|
788
|
+
void (async () => {
|
|
789
|
+
try {
|
|
790
|
+
while (!this.info.record.ready) {
|
|
791
|
+
abort.signal.throwIfAborted();
|
|
792
|
+
await delay(20, undefined, { signal: abort.signal });
|
|
793
|
+
}
|
|
794
|
+
// A cost scan or an earlier open may already have this session's prompts cached.
|
|
795
|
+
if (this.adoptCachedPrompts(sessionId))
|
|
796
|
+
return;
|
|
797
|
+
const throughSeq = this.info.record.readThrough;
|
|
798
|
+
let beforeSeq = this.info.record.beforeSeq;
|
|
799
|
+
let hasMore = this.info.record.hasMore;
|
|
800
|
+
for (let page = 0; hasMore && beforeSeq !== undefined && page < PROMPT_BACKFILL_PAGES; page++) {
|
|
801
|
+
abort.signal.throwIfAborted();
|
|
802
|
+
if (selection !== this.store.selection())
|
|
803
|
+
return;
|
|
804
|
+
const result = object(await this.host.require().call('session/page', { request: {
|
|
805
|
+
address: { kind: 'session', sessionId }, throughSeq, beforeSeq, maxMessages: 80,
|
|
806
|
+
} }, abort.signal));
|
|
807
|
+
abort.signal.throwIfAborted();
|
|
808
|
+
if (selection !== this.store.selection())
|
|
809
|
+
return;
|
|
810
|
+
const temporary = new Transcript();
|
|
811
|
+
try {
|
|
812
|
+
temporary.accept({ type: 'snapshot', cursor: throughSeq, assistantStream: { revision: 0 }, records: result.records, hasMore: result.hasMore });
|
|
813
|
+
const next = temporary.beforeSeq;
|
|
814
|
+
if (temporary.hasMore && (next === undefined || next >= beforeSeq))
|
|
815
|
+
throw new Error('Host history page did not advance');
|
|
816
|
+
this.prompts.prepend(temporary.promptsSince(-1).prompts);
|
|
817
|
+
beforeSeq = next;
|
|
818
|
+
hasMore = temporary.hasMore;
|
|
819
|
+
}
|
|
820
|
+
finally {
|
|
821
|
+
temporary.dispose();
|
|
822
|
+
}
|
|
823
|
+
// A scan that finished while this walk ran already established the same list.
|
|
824
|
+
if (this.adoptCachedPrompts(sessionId))
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
this.prompts.settle();
|
|
828
|
+
// Only a walk that ended because the host said "no more" makes the index exhaustive; one
|
|
829
|
+
// stopped by the page bound leaves the lazy backward step in charge of the rest.
|
|
830
|
+
if (!hasMore)
|
|
831
|
+
this.prompts.markComplete();
|
|
832
|
+
// Cache only what the walk established: advertising a capped or shed list would let a later
|
|
833
|
+
// open skip a fetch it still needs.
|
|
834
|
+
if (!hasMore)
|
|
835
|
+
this.promptCache.put(sessionId, { prompts: this.prompts.durableItems, complete: this.prompts.exhausted });
|
|
836
|
+
this.store.update({});
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
// A cancelled, disconnected or unavailable history leaves the lazy backward step in charge.
|
|
840
|
+
}
|
|
841
|
+
finally {
|
|
842
|
+
if (this.promptBackfill === abort)
|
|
843
|
+
this.promptBackfill = undefined;
|
|
844
|
+
}
|
|
845
|
+
})();
|
|
846
|
+
}
|
|
481
847
|
/** Search one page at a time, preserving only the first 200 matches and releasing temporary content.
|
|
482
848
|
* @param query - Literal, case-insensitive text including folded reasoning.
|
|
483
849
|
* @param signal - Cancels HTTP and processing without cancelling the agent.
|
|
484
850
|
* @returns Newest-first bounded summaries and an explicit truncation flag.
|
|
485
851
|
*/
|
|
486
852
|
async searchHistory(query, signal) {
|
|
487
|
-
const source = this.
|
|
853
|
+
const source = this.info.record;
|
|
488
854
|
if (!source.ready)
|
|
489
855
|
throw new Error('Wait for the session snapshot');
|
|
490
856
|
const sessionId = this.sessionId;
|
|
@@ -546,7 +912,7 @@ export class SessionController {
|
|
|
546
912
|
* @returns A caller-owned historical window that must be disposed when closed.
|
|
547
913
|
*/
|
|
548
914
|
async historyAt(target, signal) {
|
|
549
|
-
const source = this.
|
|
915
|
+
const source = this.info.record;
|
|
550
916
|
const selection = this.store.selection();
|
|
551
917
|
const page = object(await this.host.require().call('session/page', { request: {
|
|
552
918
|
address: { kind: 'session', sessionId: this.sessionId }, throughSeq: source.readThrough,
|
|
@@ -572,14 +938,14 @@ export class SessionController {
|
|
|
572
938
|
* @param signal - Cancels local paging without interrupting the remote agent.
|
|
573
939
|
*/
|
|
574
940
|
async historyThrough(target, signal) {
|
|
575
|
-
const transcript = this.
|
|
941
|
+
const transcript = this.info.record;
|
|
576
942
|
if (!transcript.ready)
|
|
577
943
|
throw new Error('Wait for the session snapshot');
|
|
578
944
|
while (transcript.hasMore && (target === 'first' || transcript.beforeSeq !== undefined && transcript.beforeSeq > target)) {
|
|
579
945
|
signal.throwIfAborted();
|
|
580
946
|
const before = transcript.beforeSeq;
|
|
581
947
|
await this.older(signal);
|
|
582
|
-
if (this.
|
|
948
|
+
if (this.info.record !== transcript)
|
|
583
949
|
throw new Error('Session changed while loading history');
|
|
584
950
|
if (transcript.hasMore && (before === undefined || transcript.beforeSeq === undefined || transcript.beforeSeq >= before)) {
|
|
585
951
|
throw new Error('Host history page did not advance');
|
|
@@ -590,18 +956,18 @@ export class SessionController {
|
|
|
590
956
|
* @returns Number of removed records.
|
|
591
957
|
*/
|
|
592
958
|
reclaimHistory() {
|
|
593
|
-
if (this.
|
|
959
|
+
if (this.info.view.pinned || !this.store.state.online)
|
|
594
960
|
return 0;
|
|
595
|
-
const removed = this.
|
|
961
|
+
const removed = this.info.record.trimHistory(this.historyLimits);
|
|
596
962
|
if (removed)
|
|
597
|
-
releaseHistoryLayout(this.
|
|
963
|
+
releaseHistoryLayout(this.info.record);
|
|
598
964
|
return removed;
|
|
599
965
|
}
|
|
600
966
|
/** Release the selected transcript and its layout caches. */
|
|
601
967
|
releaseTranscript() {
|
|
602
|
-
|
|
603
|
-
this.
|
|
604
|
-
this.
|
|
968
|
+
this.promptBackfill?.abort();
|
|
969
|
+
this.promptBackfill = undefined;
|
|
970
|
+
this.info.reset();
|
|
605
971
|
}
|
|
606
972
|
/** @returns The selected session identity, or a `Select a session first` failure. */
|
|
607
973
|
get sessionId() {
|
package/dist/session/index.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export { Telemetry } from './telemetry.ts';
|
|
|
9
9
|
export type { QueuedInput } from './telemetry.ts';
|
|
10
10
|
export { DEFAULT_HISTORY_LIMITS, historyLimits } from './memory.ts';
|
|
11
11
|
export type { HistoryLimits } from './memory.ts';
|
|
12
|
+
export { DEFAULT_PROMPT_LIMITS, promptText, PromptIndex, SessionInfo } from './info.ts';
|
|
13
|
+
export type { ComposerState, InteractionState, ModelState, OptionState, PanelState, PromptEntry, PromptLimits, PromptRecord, ReferenceState, ViewState } from './info.ts';
|
|
12
14
|
export { navigationCommand, resolveTarget, sessionLabel } from './navigation.ts';
|
|
13
15
|
export { activeReference, fileMention, fileReferences } from './references.ts';
|
|
14
16
|
export type { FileReference } from './references.ts';
|
package/dist/session/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { historyLayout, layoutStats, releaseHistoryLayout } from "./history.js";
|
|
|
5
5
|
export { markdownCacheStats } from "./markdown.js";
|
|
6
6
|
export { Telemetry } from "./telemetry.js";
|
|
7
7
|
export { DEFAULT_HISTORY_LIMITS, historyLimits } from "./memory.js";
|
|
8
|
+
export { DEFAULT_PROMPT_LIMITS, promptText, PromptIndex, SessionInfo } from "./info.js";
|
|
8
9
|
export { navigationCommand, resolveTarget, sessionLabel } from "./navigation.js";
|
|
9
10
|
export { activeReference, fileMention, fileReferences } from "./references.js";
|
|
10
11
|
export { saveSessionLog } from "./export.js";
|