@itookit/dsht 0.3.4 → 0.3.8
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 +4 -3
- package/README.zh.md +4 -3
- package/dist/cli/dsht.js +5 -2
- package/dist/controller/controller.d.ts +113 -1
- package/dist/controller/controller.js +145 -2
- 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 +150 -1
- package/dist/session/controller.js +388 -29
- package/dist/session/history.d.ts +21 -1
- package/dist/session/history.js +15 -0
- 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/transcript.d.ts +37 -1
- package/dist/session/transcript.js +73 -0
- package/dist/shell/controller.d.ts +67 -0
- package/dist/shell/controller.js +126 -0
- package/dist/shell/index.d.ts +5 -0
- package/dist/shell/index.js +3 -0
- package/dist/shell/runner.d.ts +28 -0
- package/dist/shell/runner.js +108 -0
- package/dist/state.d.ts +3 -2
- package/dist/state.js +2 -2
- package/dist/ui/app.js +206 -177
- package/dist/ui/chat/history-view.js +1 -1
- package/dist/ui/chat/shell-view.d.ts +34 -0
- package/dist/ui/chat/shell-view.js +111 -0
- package/dist/ui/chat/status.js +4 -4
- package/dist/ui/commands/parse.d.ts +5 -0
- package/dist/ui/commands/parse.js +9 -0
- package/dist/ui/dialogs/index.d.ts +3 -6
- package/dist/ui/theme/index.d.ts +5 -0
- package/dist/ui/theme/index.js +2 -1
- package/dsht-m.png +0 -0
- package/package.json +1 -1
- package/dist/ui/input/history.d.ts +0 -19
- package/dist/ui/input/history.js +0 -43
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/** Session-owned prompt index: every user prompt of the selected session, plus its recall cursor.
|
|
2
|
+
*
|
|
3
|
+
* The index replaces a plain bounded recall buffer. Each durable entry carries the sequence it came
|
|
4
|
+
* from, so an entry evicted by the budgets stays recoverable: the loaded window refills anything the
|
|
5
|
+
* transcript still holds, and `session/page` refetches anything older than the window. Budgets
|
|
6
|
+
* therefore bound memory without deciding reachability — which is what a base-200 buffer got wrong.
|
|
7
|
+
*
|
|
8
|
+
* Locally submitted slash commands never become durable records, so they are retained too, marked as
|
|
9
|
+
* non-durable. A durable echo of a locally recorded prompt upgrades that entry instead of adding a
|
|
10
|
+
* second copy, keeping the refill boundary (the oldest durable sequence) exact.
|
|
11
|
+
*/
|
|
12
|
+
import { type Reasoning } from './history.ts';
|
|
13
|
+
import { Transcript } from './transcript.ts';
|
|
14
|
+
import type { HistorySearch } from './types.ts';
|
|
15
|
+
import type { ObjectValue } from '../transport/wire.ts';
|
|
16
|
+
/** A durable user prompt as the transcript reports it, before retention. */
|
|
17
|
+
export interface PromptRecord {
|
|
18
|
+
seq: number;
|
|
19
|
+
text: string;
|
|
20
|
+
}
|
|
21
|
+
/** One retained prompt: its text, plus the durable sequence when the host also recorded it. */
|
|
22
|
+
export interface PromptEntry extends PromptRecord {
|
|
23
|
+
durable: boolean;
|
|
24
|
+
}
|
|
25
|
+
/** How many prompts one session may retain, and how much text they may occupy. */
|
|
26
|
+
export interface PromptLimits {
|
|
27
|
+
maxEntries: number;
|
|
28
|
+
maxBytes: number;
|
|
29
|
+
}
|
|
30
|
+
/** Default balance: a long session's whole prompt list, since prompts are far smaller than history. */
|
|
31
|
+
export declare const DEFAULT_PROMPT_LIMITS: PromptLimits;
|
|
32
|
+
/** Flatten one transcript prompt into the single line the composer recalls. */
|
|
33
|
+
export declare function promptText(value: string): string;
|
|
34
|
+
/** The selected session's composer: its draft, the caret in it, and a draft a dialog parked aside. */
|
|
35
|
+
export interface ComposerState {
|
|
36
|
+
draft: string;
|
|
37
|
+
cursor: number;
|
|
38
|
+
parked: string;
|
|
39
|
+
}
|
|
40
|
+
/** How the selected session's record is being read: which window, where, and what is expanded.
|
|
41
|
+
*
|
|
42
|
+
* Everything here changes how the record renders, which is why it is session state rather than a
|
|
43
|
+
* transient panel flag; the record's content stays in `Transcript`. `window` is a strong reference
|
|
44
|
+
* released by `closeWindow`, and the layout cache keyed by it is a weak one.
|
|
45
|
+
*/
|
|
46
|
+
export interface ViewState {
|
|
47
|
+
/** Detached record shown instead of the live transcript while reading jumped-to history. */
|
|
48
|
+
window?: Transcript;
|
|
49
|
+
scroll: number;
|
|
50
|
+
/** Reading protection: reclamation pauses until the reader returns to the live end. */
|
|
51
|
+
pinned: boolean;
|
|
52
|
+
/** Message sequences whose reasoning is expanded beyond the default fold. */
|
|
53
|
+
folds: ReadonlySet<number>;
|
|
54
|
+
/** Fold mode for the live attempt's completed reasoning. */
|
|
55
|
+
liveReasoning: Reasoning;
|
|
56
|
+
}
|
|
57
|
+
/** Composer-adjacent `@` reference menu: the highlighted row and the draft that dismissed it. */
|
|
58
|
+
export interface ReferenceState {
|
|
59
|
+
index: number;
|
|
60
|
+
dismissed?: string;
|
|
61
|
+
}
|
|
62
|
+
/** Model dialog step: the catalog plus the provider or model being inspected. */
|
|
63
|
+
export interface ModelState {
|
|
64
|
+
catalog: ObjectValue;
|
|
65
|
+
provider?: string;
|
|
66
|
+
model?: ObjectValue;
|
|
67
|
+
}
|
|
68
|
+
/** Panels the reader opened for the selected session.
|
|
69
|
+
*
|
|
70
|
+
* Only visibility and query text: every panel's rows come from the record, so closing one loses
|
|
71
|
+
* nothing and a session switch may clear all of it. The row cursor inside a panel is not here — it
|
|
72
|
+
* is focus, held by `Picker` and reset through its `key`.
|
|
73
|
+
*/
|
|
74
|
+
export interface PanelState {
|
|
75
|
+
thoughts: boolean;
|
|
76
|
+
queue: boolean;
|
|
77
|
+
model?: ModelState;
|
|
78
|
+
history?: {
|
|
79
|
+
query: string;
|
|
80
|
+
contentSearch: boolean;
|
|
81
|
+
matches?: HistorySearch;
|
|
82
|
+
};
|
|
83
|
+
search?: {
|
|
84
|
+
query: string;
|
|
85
|
+
items: ObjectValue[];
|
|
86
|
+
hasMore: boolean;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Keyboard state of one pending question's options. */
|
|
90
|
+
export interface OptionState {
|
|
91
|
+
key: string;
|
|
92
|
+
cursor: number;
|
|
93
|
+
selected: string[];
|
|
94
|
+
custom: boolean;
|
|
95
|
+
}
|
|
96
|
+
/** Local answer state for host waterfalls: question answers collected so far, and the keyboard
|
|
97
|
+
* selection of an approval or of a question's options. The waterfall itself stays in
|
|
98
|
+
* `SessionController.interactions`, which is derived per session on every publish.
|
|
99
|
+
*/
|
|
100
|
+
export interface InteractionState {
|
|
101
|
+
answers: Record<string, ObjectValue[]>;
|
|
102
|
+
option?: OptionState;
|
|
103
|
+
approval?: {
|
|
104
|
+
eventId: string;
|
|
105
|
+
index: number;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** Client-owned state of the selected session, reset whenever another session is opened.
|
|
109
|
+
*
|
|
110
|
+
* It holds the record, the prompt index, the composer, the reading view and the local interaction
|
|
111
|
+
* state because all five belong to one session and none of them is owned by the host beyond what the
|
|
112
|
+
* record mirrors; everything derivable from `Telemetry` or `CostLedger` stays out (see the design's
|
|
113
|
+
* §5.7.6). The record is referenced here and nowhere else, so "the selected session" has one entry.
|
|
114
|
+
*/
|
|
115
|
+
export declare class SessionInfo {
|
|
116
|
+
sessionId: string;
|
|
117
|
+
/** The selected session's record. Replaced — never mutated in place — when another session opens. */
|
|
118
|
+
record: Transcript;
|
|
119
|
+
readonly prompts: PromptIndex;
|
|
120
|
+
readonly composer: ComposerState;
|
|
121
|
+
readonly view: ViewState;
|
|
122
|
+
readonly interaction: InteractionState;
|
|
123
|
+
readonly reference: ReferenceState;
|
|
124
|
+
readonly panels: PanelState;
|
|
125
|
+
constructor(sessionId?: string);
|
|
126
|
+
/** Forget everything a previous session owned, keeping this instance identity-stable for `State`. */
|
|
127
|
+
reset(sessionId?: string): void;
|
|
128
|
+
/** Release the detached history window, if the reader has one open. */
|
|
129
|
+
closeWindow(): void;
|
|
130
|
+
}
|
|
131
|
+
/** Seq-ordered prompts with a recall cursor, owned by the selected session.
|
|
132
|
+
*
|
|
133
|
+
* `append` folds only prompts newer than the last fold, so streaming stays O(new records) and never
|
|
134
|
+
* rebuilds a projection just to notice a prompt. `oldest` reports the durable boundary, and
|
|
135
|
+
* `missingBefore` reports what the loaded window can refill before a page request is spent.
|
|
136
|
+
*/
|
|
137
|
+
export declare class PromptIndex {
|
|
138
|
+
private readonly limits;
|
|
139
|
+
private entries;
|
|
140
|
+
private bytes;
|
|
141
|
+
private newestSeq;
|
|
142
|
+
private complete;
|
|
143
|
+
private shed;
|
|
144
|
+
private position;
|
|
145
|
+
private draft;
|
|
146
|
+
constructor(limits?: PromptLimits);
|
|
147
|
+
/** Retained entry count, so a caller can tell an empty index from a parked cursor. */
|
|
148
|
+
get length(): number;
|
|
149
|
+
/** Whether recall is parked on the oldest retained entry. */
|
|
150
|
+
get atOldest(): boolean;
|
|
151
|
+
/** Newest durable sequence already folded, so the next fold scans only what arrived after it. */
|
|
152
|
+
get through(): number;
|
|
153
|
+
/** Oldest durable sequence still retained; the window refills only prompts older than this. */
|
|
154
|
+
get oldest(): number | undefined;
|
|
155
|
+
/** Retained entries in session order, oldest first. */
|
|
156
|
+
get items(): readonly PromptEntry[];
|
|
157
|
+
/** Retained durable prompts only, in session order: what a cache may hand to another index. */
|
|
158
|
+
get durableItems(): PromptRecord[];
|
|
159
|
+
/** Whether a backfill has walked the host history to its beginning for this session. */
|
|
160
|
+
get exhausted(): boolean;
|
|
161
|
+
/** Whether a budget eviction dropped a retained prefix, so the index is no longer contiguous with
|
|
162
|
+
* the session's first prompt and must not claim to be exhaustive. */
|
|
163
|
+
get trimmed(): boolean;
|
|
164
|
+
/** Record that the host holds no prompts older than the ones retained.
|
|
165
|
+
*
|
|
166
|
+
* An index that shed a prefix cannot claim this: the dropped prompts are older than anything the
|
|
167
|
+
* live window holds, so the lazy backward step could not recover them.
|
|
168
|
+
*/
|
|
169
|
+
markComplete(): void;
|
|
170
|
+
/** Forget one session's prompts and cursor. */
|
|
171
|
+
reset(): void;
|
|
172
|
+
/** Fold one transcript scan: its prompts, plus the watermark it covered.
|
|
173
|
+
*
|
|
174
|
+
* The watermark advances even when the scan found no prompt, so a turn of assistant and tool
|
|
175
|
+
* records is scanned once rather than again on every following frame.
|
|
176
|
+
* @param value - Prompts newer than `through`, and the newest sequence the scan covered.
|
|
177
|
+
*/
|
|
178
|
+
fold(value: {
|
|
179
|
+
prompts: readonly PromptRecord[];
|
|
180
|
+
through: number;
|
|
181
|
+
}): void;
|
|
182
|
+
/** Fold prompts whose own sequences are already the watermark; entries beyond the budgets drop the
|
|
183
|
+
* oldest, which stays reloadable.
|
|
184
|
+
* @param values - Prompts in session order, oldest first.
|
|
185
|
+
*/
|
|
186
|
+
append(values: readonly PromptRecord[]): void;
|
|
187
|
+
/** Remember a locally submitted command that never becomes a durable record.
|
|
188
|
+
* @param value - Submitted command text; consecutive repeats coalesce.
|
|
189
|
+
*/
|
|
190
|
+
record(value: string): void;
|
|
191
|
+
/** Add refilled or paged prompts in front; an active cursor shifts so its entry stays selected.
|
|
192
|
+
* @param values - Older durable prompts in session order, oldest first.
|
|
193
|
+
* @returns How many entries were actually retained.
|
|
194
|
+
*/
|
|
195
|
+
prepend(values: readonly PromptRecord[]): number;
|
|
196
|
+
/** Recall older/newer input, restoring the original unsent draft at the end.
|
|
197
|
+
* @param direction - Negative for older input, positive for newer input.
|
|
198
|
+
* @param current - Current composer content before beginning recall.
|
|
199
|
+
* @returns Recalled input, or the original draft when returning to the newest position.
|
|
200
|
+
*/
|
|
201
|
+
move(direction: -1 | 1, current: string): string;
|
|
202
|
+
/** Leave recall navigation when the composer is edited or otherwise replaced. */
|
|
203
|
+
resetCursor(): void;
|
|
204
|
+
/** Trim a bulk backfill down to the budgets.
|
|
205
|
+
*
|
|
206
|
+
* `prepend` deliberately never evicts, because a lazy backward step must not drop what it just
|
|
207
|
+
* recovered. A full-history backfill is different: the excess it added is the oldest prefix, which
|
|
208
|
+
* stays reloadable through the same lazy path, so it can be settled to the budgets once. */
|
|
209
|
+
settle(): void;
|
|
210
|
+
/** Whether one entry fits the per-entry budget the composer can recall. */
|
|
211
|
+
private retainable;
|
|
212
|
+
/** Retain one durable prompt; a durable echo upgrades a local entry instead of duplicating it. */
|
|
213
|
+
private push;
|
|
214
|
+
/** Drop the oldest entries until both budgets hold; a dropped prefix stays reloadable. */
|
|
215
|
+
private trim;
|
|
216
|
+
}
|
|
217
|
+
/** Bytes of prompt text the process keeps across sessions before evicting the least recently used. */
|
|
218
|
+
export declare const DEFAULT_PROMPT_CACHE_BYTES: number;
|
|
219
|
+
/** Process-lifetime cache of one session's folded prompts, so re-opening a session and re-reading
|
|
220
|
+
* its history are not the same cost.
|
|
221
|
+
*
|
|
222
|
+
* Two producers write it: the cost scan, which already reads every page of every session, and the
|
|
223
|
+
* open-time backfill. Only a complete entry lets an open skip the walk; a partial one is written as
|
|
224
|
+
* incomplete and ignored when read, because the part it is missing is the oldest prefix — the part
|
|
225
|
+
* that would still have to be fetched. Bytes are bounded across sessions by least-recently-used
|
|
226
|
+
* eviction, keeping at least the newest session so one huge history is still cached.
|
|
227
|
+
*/
|
|
228
|
+
export declare class PromptCache {
|
|
229
|
+
private readonly maxBytes;
|
|
230
|
+
private entries;
|
|
231
|
+
private bytes;
|
|
232
|
+
constructor(maxBytes?: number);
|
|
233
|
+
/** Store one session's prompts, replacing whatever it held.
|
|
234
|
+
* @param sessionId - Host session identity.
|
|
235
|
+
* @param value - Prompts in session order, and whether the host held no older ones.
|
|
236
|
+
*/
|
|
237
|
+
put(sessionId: string, value: {
|
|
238
|
+
prompts: readonly PromptRecord[];
|
|
239
|
+
complete: boolean;
|
|
240
|
+
}): void;
|
|
241
|
+
/** Fold one scanned page in front of a session's entry.
|
|
242
|
+
*
|
|
243
|
+
* Pages arrive newest first, so each call's prompts are older than everything already cached; the
|
|
244
|
+
* entry stays incomplete until the scan reports it reached the beginning.
|
|
245
|
+
* @param sessionId - Host session identity.
|
|
246
|
+
* @param prompts - One page's prompts, oldest first within the page.
|
|
247
|
+
* @param complete - Whether this call is the scan's final one for that session.
|
|
248
|
+
*/
|
|
249
|
+
observe(sessionId: string, prompts: readonly PromptRecord[], complete: boolean): void;
|
|
250
|
+
/** Read one session's prompts, marking the entry most recently used.
|
|
251
|
+
* @param sessionId - Host session identity.
|
|
252
|
+
* @returns The cached prompts, or undefined when nothing is cached.
|
|
253
|
+
*/
|
|
254
|
+
get(sessionId: string): {
|
|
255
|
+
prompts: PromptRecord[];
|
|
256
|
+
complete: boolean;
|
|
257
|
+
} | undefined;
|
|
258
|
+
/** Forget one session, for example after the host rewrote its history. */
|
|
259
|
+
drop(sessionId: string): void;
|
|
260
|
+
/** Evict least-recently-used sessions until the byte budget holds. */
|
|
261
|
+
private evict;
|
|
262
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/** Session-owned prompt index: every user prompt of the selected session, plus its recall cursor.
|
|
2
|
+
*
|
|
3
|
+
* The index replaces a plain bounded recall buffer. Each durable entry carries the sequence it came
|
|
4
|
+
* from, so an entry evicted by the budgets stays recoverable: the loaded window refills anything the
|
|
5
|
+
* transcript still holds, and `session/page` refetches anything older than the window. Budgets
|
|
6
|
+
* therefore bound memory without deciding reachability — which is what a base-200 buffer got wrong.
|
|
7
|
+
*
|
|
8
|
+
* Locally submitted slash commands never become durable records, so they are retained too, marked as
|
|
9
|
+
* non-durable. A durable echo of a locally recorded prompt upgrades that entry instead of adding a
|
|
10
|
+
* second copy, keeping the refill boundary (the oldest durable sequence) exact.
|
|
11
|
+
*/
|
|
12
|
+
import { releaseHistoryLayout } from "./history.js";
|
|
13
|
+
import { Transcript } from "./transcript.js";
|
|
14
|
+
/** Default balance: a long session's whole prompt list, since prompts are far smaller than history. */
|
|
15
|
+
export const DEFAULT_PROMPT_LIMITS = { maxEntries: 2000, maxBytes: 512 * 1024 };
|
|
16
|
+
/** Longest single prompt worth recalling; a larger one is skipped rather than truncated in place. */
|
|
17
|
+
const MAX_ENTRY_CHARS = 128 * 1024;
|
|
18
|
+
/** Flatten one transcript prompt into the single line the composer recalls. */
|
|
19
|
+
export function promptText(value) { return value.replace(/\r?\n/g, ' ').trim(); }
|
|
20
|
+
/** Client-owned state of the selected session, reset whenever another session is opened.
|
|
21
|
+
*
|
|
22
|
+
* It holds the record, the prompt index, the composer, the reading view and the local interaction
|
|
23
|
+
* state because all five belong to one session and none of them is owned by the host beyond what the
|
|
24
|
+
* record mirrors; everything derivable from `Telemetry` or `CostLedger` stays out (see the design's
|
|
25
|
+
* §5.7.6). The record is referenced here and nowhere else, so "the selected session" has one entry.
|
|
26
|
+
*/
|
|
27
|
+
export class SessionInfo {
|
|
28
|
+
sessionId;
|
|
29
|
+
/** The selected session's record. Replaced — never mutated in place — when another session opens. */
|
|
30
|
+
record = new Transcript();
|
|
31
|
+
prompts = new PromptIndex();
|
|
32
|
+
composer = { draft: '', cursor: 0, parked: '' };
|
|
33
|
+
view = { scroll: 0, pinned: false, folds: new Set(), liveReasoning: 'row' };
|
|
34
|
+
interaction = { answers: {} };
|
|
35
|
+
reference = { index: 0 };
|
|
36
|
+
panels = { thoughts: false, queue: false };
|
|
37
|
+
constructor(sessionId = '') {
|
|
38
|
+
this.sessionId = sessionId;
|
|
39
|
+
}
|
|
40
|
+
/** Forget everything a previous session owned, keeping this instance identity-stable for `State`. */
|
|
41
|
+
reset(sessionId = '') {
|
|
42
|
+
this.sessionId = sessionId;
|
|
43
|
+
this.closeWindow();
|
|
44
|
+
releaseHistoryLayout(this.record);
|
|
45
|
+
this.record.dispose();
|
|
46
|
+
this.record = new Transcript();
|
|
47
|
+
this.prompts.reset();
|
|
48
|
+
this.composer.draft = '';
|
|
49
|
+
this.composer.cursor = 0;
|
|
50
|
+
this.composer.parked = '';
|
|
51
|
+
this.view.scroll = 0;
|
|
52
|
+
this.view.pinned = false;
|
|
53
|
+
this.view.folds = new Set();
|
|
54
|
+
this.view.liveReasoning = 'row';
|
|
55
|
+
this.interaction.answers = {};
|
|
56
|
+
this.interaction.option = undefined;
|
|
57
|
+
this.interaction.approval = undefined;
|
|
58
|
+
this.reference.index = 0;
|
|
59
|
+
this.reference.dismissed = undefined;
|
|
60
|
+
this.panels.thoughts = false;
|
|
61
|
+
this.panels.queue = false;
|
|
62
|
+
this.panels.model = undefined;
|
|
63
|
+
this.panels.history = undefined;
|
|
64
|
+
this.panels.search = undefined;
|
|
65
|
+
}
|
|
66
|
+
/** Release the detached history window, if the reader has one open. */
|
|
67
|
+
closeWindow() {
|
|
68
|
+
const window = this.view.window;
|
|
69
|
+
if (!window)
|
|
70
|
+
return;
|
|
71
|
+
releaseHistoryLayout(window);
|
|
72
|
+
window.dispose();
|
|
73
|
+
this.view.window = undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Seq-ordered prompts with a recall cursor, owned by the selected session.
|
|
77
|
+
*
|
|
78
|
+
* `append` folds only prompts newer than the last fold, so streaming stays O(new records) and never
|
|
79
|
+
* rebuilds a projection just to notice a prompt. `oldest` reports the durable boundary, and
|
|
80
|
+
* `missingBefore` reports what the loaded window can refill before a page request is spent.
|
|
81
|
+
*/
|
|
82
|
+
export class PromptIndex {
|
|
83
|
+
limits;
|
|
84
|
+
entries = [];
|
|
85
|
+
bytes = 0;
|
|
86
|
+
newestSeq = -1;
|
|
87
|
+
complete = false;
|
|
88
|
+
shed = false;
|
|
89
|
+
position;
|
|
90
|
+
draft = '';
|
|
91
|
+
constructor(limits = DEFAULT_PROMPT_LIMITS) {
|
|
92
|
+
this.limits = limits;
|
|
93
|
+
}
|
|
94
|
+
/** Retained entry count, so a caller can tell an empty index from a parked cursor. */
|
|
95
|
+
get length() { return this.entries.length; }
|
|
96
|
+
/** Whether recall is parked on the oldest retained entry. */
|
|
97
|
+
get atOldest() { return this.position === 0; }
|
|
98
|
+
/** Newest durable sequence already folded, so the next fold scans only what arrived after it. */
|
|
99
|
+
get through() { return this.newestSeq; }
|
|
100
|
+
/** Oldest durable sequence still retained; the window refills only prompts older than this. */
|
|
101
|
+
get oldest() {
|
|
102
|
+
for (const entry of this.entries)
|
|
103
|
+
if (entry.durable)
|
|
104
|
+
return entry.seq;
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
/** Retained entries in session order, oldest first. */
|
|
108
|
+
get items() { return this.entries; }
|
|
109
|
+
/** Retained durable prompts only, in session order: what a cache may hand to another index. */
|
|
110
|
+
get durableItems() {
|
|
111
|
+
return this.entries.filter(entry => entry.durable).map(entry => ({ seq: entry.seq, text: entry.text }));
|
|
112
|
+
}
|
|
113
|
+
/** Whether a backfill has walked the host history to its beginning for this session. */
|
|
114
|
+
get exhausted() { return this.complete; }
|
|
115
|
+
/** Whether a budget eviction dropped a retained prefix, so the index is no longer contiguous with
|
|
116
|
+
* the session's first prompt and must not claim to be exhaustive. */
|
|
117
|
+
get trimmed() { return this.shed; }
|
|
118
|
+
/** Record that the host holds no prompts older than the ones retained.
|
|
119
|
+
*
|
|
120
|
+
* An index that shed a prefix cannot claim this: the dropped prompts are older than anything the
|
|
121
|
+
* live window holds, so the lazy backward step could not recover them.
|
|
122
|
+
*/
|
|
123
|
+
markComplete() { if (!this.shed)
|
|
124
|
+
this.complete = true; }
|
|
125
|
+
/** Forget one session's prompts and cursor. */
|
|
126
|
+
reset() {
|
|
127
|
+
this.entries = [];
|
|
128
|
+
this.bytes = 0;
|
|
129
|
+
this.newestSeq = -1;
|
|
130
|
+
this.complete = false;
|
|
131
|
+
this.shed = false;
|
|
132
|
+
this.position = undefined;
|
|
133
|
+
this.draft = '';
|
|
134
|
+
}
|
|
135
|
+
/** Fold one transcript scan: its prompts, plus the watermark it covered.
|
|
136
|
+
*
|
|
137
|
+
* The watermark advances even when the scan found no prompt, so a turn of assistant and tool
|
|
138
|
+
* records is scanned once rather than again on every following frame.
|
|
139
|
+
* @param value - Prompts newer than `through`, and the newest sequence the scan covered.
|
|
140
|
+
*/
|
|
141
|
+
fold(value) {
|
|
142
|
+
for (const prompt of value.prompts)
|
|
143
|
+
this.push(prompt);
|
|
144
|
+
this.newestSeq = Math.max(this.newestSeq, value.through, value.prompts.reduce((max, prompt) => Math.max(max, prompt.seq), -1));
|
|
145
|
+
}
|
|
146
|
+
/** Fold prompts whose own sequences are already the watermark; entries beyond the budgets drop the
|
|
147
|
+
* oldest, which stays reloadable.
|
|
148
|
+
* @param values - Prompts in session order, oldest first.
|
|
149
|
+
*/
|
|
150
|
+
append(values) {
|
|
151
|
+
this.fold({ prompts: values, through: values.reduce((max, value) => Math.max(max, value.seq), -1) });
|
|
152
|
+
}
|
|
153
|
+
/** Remember a locally submitted command that never becomes a durable record.
|
|
154
|
+
* @param value - Submitted command text; consecutive repeats coalesce.
|
|
155
|
+
*/
|
|
156
|
+
record(value) {
|
|
157
|
+
const entry = { seq: this.newestSeq, text: promptText(value), durable: false };
|
|
158
|
+
if (!this.retainable(entry) || this.entries.at(-1)?.text === entry.text)
|
|
159
|
+
return;
|
|
160
|
+
this.entries.push(entry);
|
|
161
|
+
this.bytes += entry.text.length * 2;
|
|
162
|
+
this.trim();
|
|
163
|
+
}
|
|
164
|
+
/** Add refilled or paged prompts in front; an active cursor shifts so its entry stays selected.
|
|
165
|
+
* @param values - Older durable prompts in session order, oldest first.
|
|
166
|
+
* @returns How many entries were actually retained.
|
|
167
|
+
*/
|
|
168
|
+
prepend(values) {
|
|
169
|
+
const older = values.map(value => ({ seq: value.seq, text: promptText(value.text), durable: true }))
|
|
170
|
+
.filter(value => this.retainable(value));
|
|
171
|
+
if (!older.length)
|
|
172
|
+
return 0;
|
|
173
|
+
this.entries = [...older, ...this.entries];
|
|
174
|
+
this.bytes += older.reduce((sum, value) => sum + value.text.length * 2, 0);
|
|
175
|
+
if (this.position !== undefined)
|
|
176
|
+
this.position += older.length;
|
|
177
|
+
return older.length;
|
|
178
|
+
}
|
|
179
|
+
/** Recall older/newer input, restoring the original unsent draft at the end.
|
|
180
|
+
* @param direction - Negative for older input, positive for newer input.
|
|
181
|
+
* @param current - Current composer content before beginning recall.
|
|
182
|
+
* @returns Recalled input, or the original draft when returning to the newest position.
|
|
183
|
+
*/
|
|
184
|
+
move(direction, current) {
|
|
185
|
+
if (!this.entries.length)
|
|
186
|
+
return current;
|
|
187
|
+
if (this.position === undefined) {
|
|
188
|
+
if (direction > 0)
|
|
189
|
+
return current;
|
|
190
|
+
this.position = this.entries.length;
|
|
191
|
+
this.draft = current;
|
|
192
|
+
}
|
|
193
|
+
this.position = Math.max(0, Math.min(this.entries.length, this.position + direction));
|
|
194
|
+
if (this.position === this.entries.length) {
|
|
195
|
+
const draft = this.draft;
|
|
196
|
+
this.resetCursor();
|
|
197
|
+
return draft;
|
|
198
|
+
}
|
|
199
|
+
return this.entries[this.position].text;
|
|
200
|
+
}
|
|
201
|
+
/** Leave recall navigation when the composer is edited or otherwise replaced. */
|
|
202
|
+
resetCursor() { this.position = undefined; this.draft = ''; }
|
|
203
|
+
/** Trim a bulk backfill down to the budgets.
|
|
204
|
+
*
|
|
205
|
+
* `prepend` deliberately never evicts, because a lazy backward step must not drop what it just
|
|
206
|
+
* recovered. A full-history backfill is different: the excess it added is the oldest prefix, which
|
|
207
|
+
* stays reloadable through the same lazy path, so it can be settled to the budgets once. */
|
|
208
|
+
settle() { this.trim(); }
|
|
209
|
+
/** Whether one entry fits the per-entry budget the composer can recall. */
|
|
210
|
+
retainable(value) {
|
|
211
|
+
return value.text !== '' && value.text.length <= MAX_ENTRY_CHARS;
|
|
212
|
+
}
|
|
213
|
+
/** Retain one durable prompt; a durable echo upgrades a local entry instead of duplicating it. */
|
|
214
|
+
push(value) {
|
|
215
|
+
const entry = { seq: value.seq, text: promptText(value.text), durable: true };
|
|
216
|
+
if (!this.retainable(entry))
|
|
217
|
+
return;
|
|
218
|
+
const last = this.entries.at(-1);
|
|
219
|
+
if (last && last.text === entry.text) {
|
|
220
|
+
last.seq = entry.seq;
|
|
221
|
+
last.durable = true;
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
this.entries.push(entry);
|
|
225
|
+
this.bytes += entry.text.length * 2;
|
|
226
|
+
this.trim();
|
|
227
|
+
}
|
|
228
|
+
/** Drop the oldest entries until both budgets hold; a dropped prefix stays reloadable. */
|
|
229
|
+
trim() {
|
|
230
|
+
while (this.entries.length > this.limits.maxEntries || this.bytes > this.limits.maxBytes) {
|
|
231
|
+
this.bytes -= this.entries.shift().text.length * 2;
|
|
232
|
+
this.shed = true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** Bytes of prompt text the process keeps across sessions before evicting the least recently used. */
|
|
237
|
+
export const DEFAULT_PROMPT_CACHE_BYTES = 4 * 1024 * 1024;
|
|
238
|
+
/** Process-lifetime cache of one session's folded prompts, so re-opening a session and re-reading
|
|
239
|
+
* its history are not the same cost.
|
|
240
|
+
*
|
|
241
|
+
* Two producers write it: the cost scan, which already reads every page of every session, and the
|
|
242
|
+
* open-time backfill. Only a complete entry lets an open skip the walk; a partial one is written as
|
|
243
|
+
* incomplete and ignored when read, because the part it is missing is the oldest prefix — the part
|
|
244
|
+
* that would still have to be fetched. Bytes are bounded across sessions by least-recently-used
|
|
245
|
+
* eviction, keeping at least the newest session so one huge history is still cached.
|
|
246
|
+
*/
|
|
247
|
+
export class PromptCache {
|
|
248
|
+
maxBytes;
|
|
249
|
+
entries = new Map();
|
|
250
|
+
bytes = 0;
|
|
251
|
+
constructor(maxBytes = DEFAULT_PROMPT_CACHE_BYTES) {
|
|
252
|
+
this.maxBytes = maxBytes;
|
|
253
|
+
}
|
|
254
|
+
/** Store one session's prompts, replacing whatever it held.
|
|
255
|
+
* @param sessionId - Host session identity.
|
|
256
|
+
* @param value - Prompts in session order, and whether the host held no older ones.
|
|
257
|
+
*/
|
|
258
|
+
put(sessionId, value) {
|
|
259
|
+
const all = value.prompts.filter(prompt => prompt.text !== '')
|
|
260
|
+
.map(prompt => ({ seq: prompt.seq, text: prompt.text }));
|
|
261
|
+
let prompts = all, complete = value.complete;
|
|
262
|
+
let bytes = all.reduce((sum, prompt) => sum + prompt.text.length * 2, 0);
|
|
263
|
+
// One session can hold more prompt text than the whole budget. Keep its newest prompts that fit
|
|
264
|
+
// and record the entry as incomplete, so a later open still fetches the older part instead of
|
|
265
|
+
// trusting a list with a hole at the front.
|
|
266
|
+
if (bytes > this.maxBytes) {
|
|
267
|
+
let start = all.length, kept = 0;
|
|
268
|
+
for (let index = all.length - 1; index >= 0; index--) {
|
|
269
|
+
const size = all[index].text.length * 2;
|
|
270
|
+
if (kept + size > this.maxBytes)
|
|
271
|
+
break;
|
|
272
|
+
kept += size;
|
|
273
|
+
start = index;
|
|
274
|
+
}
|
|
275
|
+
prompts = all.slice(start);
|
|
276
|
+
bytes = kept;
|
|
277
|
+
complete = false;
|
|
278
|
+
}
|
|
279
|
+
this.drop(sessionId);
|
|
280
|
+
const entry = { prompts, complete, bytes };
|
|
281
|
+
this.entries.set(sessionId, entry);
|
|
282
|
+
this.bytes += entry.bytes;
|
|
283
|
+
this.evict();
|
|
284
|
+
}
|
|
285
|
+
/** Fold one scanned page in front of a session's entry.
|
|
286
|
+
*
|
|
287
|
+
* Pages arrive newest first, so each call's prompts are older than everything already cached; the
|
|
288
|
+
* entry stays incomplete until the scan reports it reached the beginning.
|
|
289
|
+
* @param sessionId - Host session identity.
|
|
290
|
+
* @param prompts - One page's prompts, oldest first within the page.
|
|
291
|
+
* @param complete - Whether this call is the scan's final one for that session.
|
|
292
|
+
*/
|
|
293
|
+
observe(sessionId, prompts, complete) {
|
|
294
|
+
const current = this.entries.get(sessionId)?.prompts ?? [];
|
|
295
|
+
this.put(sessionId, { prompts: [...prompts, ...current], complete });
|
|
296
|
+
}
|
|
297
|
+
/** Read one session's prompts, marking the entry most recently used.
|
|
298
|
+
* @param sessionId - Host session identity.
|
|
299
|
+
* @returns The cached prompts, or undefined when nothing is cached.
|
|
300
|
+
*/
|
|
301
|
+
get(sessionId) {
|
|
302
|
+
const entry = this.entries.get(sessionId);
|
|
303
|
+
if (!entry)
|
|
304
|
+
return undefined;
|
|
305
|
+
this.entries.delete(sessionId);
|
|
306
|
+
this.entries.set(sessionId, entry);
|
|
307
|
+
return { prompts: entry.prompts, complete: entry.complete };
|
|
308
|
+
}
|
|
309
|
+
/** Forget one session, for example after the host rewrote its history. */
|
|
310
|
+
drop(sessionId) {
|
|
311
|
+
const entry = this.entries.get(sessionId);
|
|
312
|
+
if (!entry)
|
|
313
|
+
return;
|
|
314
|
+
this.bytes -= entry.bytes;
|
|
315
|
+
this.entries.delete(sessionId);
|
|
316
|
+
}
|
|
317
|
+
/** Evict least-recently-used sessions until the byte budget holds. */
|
|
318
|
+
evict() {
|
|
319
|
+
for (const [sessionId, entry] of this.entries) {
|
|
320
|
+
if (this.bytes <= this.maxBytes || this.entries.size === 1)
|
|
321
|
+
return;
|
|
322
|
+
this.bytes -= entry.bytes;
|
|
323
|
+
this.entries.delete(sessionId);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { HistoryLimits } from './memory.ts';
|
|
2
|
-
import {
|
|
2
|
+
import type { PromptRecord } from './info.ts';
|
|
3
|
+
import { type Json, type ObjectValue } from '../transport/wire.ts';
|
|
3
4
|
interface ToolSummary {
|
|
4
5
|
name: string;
|
|
5
6
|
operation?: string;
|
|
@@ -19,6 +20,21 @@ export declare function toolLine(text: string, width: number): string;
|
|
|
19
20
|
* @returns Blocks joined by newlines.
|
|
20
21
|
*/
|
|
21
22
|
export declare function contentText(content: Json | undefined, tools?: ReadonlyMap<string, ToolSummary>, width?: number, reasoning?: 'row' | 'full'): string;
|
|
23
|
+
/** One durable `user/message` event as a recallable prompt.
|
|
24
|
+
*
|
|
25
|
+
* Injected context and every other record type return undefined. This is the single place the rule
|
|
26
|
+
* lives, so a prompt parsed straight from a wire record (the cost scan's pages) is identical to one
|
|
27
|
+
* the transcript folded into its own window.
|
|
28
|
+
* @param seq - Durable sequence of the record.
|
|
29
|
+
* @param event - Decoded wire event, when the record carried one.
|
|
30
|
+
* @returns The prompt text, or undefined when the record is not a user prompt.
|
|
31
|
+
*/
|
|
32
|
+
export declare function eventPrompt(seq: number, event: ObjectValue | undefined): PromptRecord | undefined;
|
|
33
|
+
/** User prompts in one raw history page, oldest first.
|
|
34
|
+
* @param records - One HTTP history page's records.
|
|
35
|
+
* @returns Prompts with their durable sequences, in page order.
|
|
36
|
+
*/
|
|
37
|
+
export declare function recordPrompts(records: unknown): PromptRecord[];
|
|
22
38
|
/** Semantic content stays separate from terminal rows, styles, and fold state. */
|
|
23
39
|
export interface MessagePart {
|
|
24
40
|
kind: 'text' | 'reasoning' | 'tool' | 'success' | 'error';
|
|
@@ -144,6 +160,26 @@ export declare class Transcript {
|
|
|
144
160
|
get thoughts(): ThoughtEntry[];
|
|
145
161
|
/** Latest loaded user prompt summary, sharing the durable thought index. */
|
|
146
162
|
get latestPrompt(): string;
|
|
163
|
+
/** User prompts newer than `afterSeq`, oldest first, with the newest sequence scanned.
|
|
164
|
+
*
|
|
165
|
+
* Recall folds its tail this way instead of projecting messages, so a stream frame never rebuilds
|
|
166
|
+
* the row layout at a second width just to notice a new prompt. The watermark is returned even when
|
|
167
|
+
* the scanned records contributed no prompt, because an assistant-only turn must not make the next
|
|
168
|
+
* frame rescan it.
|
|
169
|
+
* @param afterSeq - Newest sequence already folded into the caller's index.
|
|
170
|
+
* @returns Prompts with an increasing sequence, and the watermark the caller should adopt.
|
|
171
|
+
*/
|
|
172
|
+
promptsSince(afterSeq: number): {
|
|
173
|
+
prompts: PromptRecord[];
|
|
174
|
+
through: number;
|
|
175
|
+
};
|
|
176
|
+
/** User prompts strictly older than `beforeSeq`, oldest first, from the retained window.
|
|
177
|
+
* @param beforeSeq - Sequence the caller's index has already reached.
|
|
178
|
+
* @returns Loaded prompts that can refill an evicted prefix without a page request.
|
|
179
|
+
*/
|
|
180
|
+
promptsBefore(beforeSeq: number): PromptRecord[];
|
|
181
|
+
/** One `user/message` record as a prompt; injected context and other records return undefined. */
|
|
182
|
+
private userPrompt;
|
|
147
183
|
/** Number of semantic records and unfinished legacy chunks held by the client. */
|
|
148
184
|
get retainedRecordCount(): number;
|
|
149
185
|
/** Earliest loaded record, used with the opening cursor for backward paging. */
|