@itookit/dsht 0.2.4 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +54 -25
- package/README.zh.md +54 -25
- package/dist/catalog/controller.d.ts +32 -0
- package/dist/catalog/controller.js +88 -0
- package/dist/catalog/index.d.ts +2 -0
- package/dist/catalog/index.js +2 -0
- package/dist/{cli.js → cli/index.js} +40 -24
- package/dist/controller/connection.d.ts +80 -0
- package/dist/controller/connection.js +190 -0
- package/dist/controller/controller.d.ts +260 -0
- package/dist/controller/controller.js +362 -0
- package/dist/controller/index.d.ts +5 -0
- package/dist/controller/index.js +3 -0
- package/dist/controller/memory-log.d.ts +35 -0
- package/dist/controller/memory-log.js +95 -0
- package/dist/cost/controller.d.ts +34 -0
- package/dist/cost/controller.js +107 -0
- package/dist/cost/index.d.ts +9 -0
- package/dist/cost/index.js +7 -0
- package/dist/cost/ledger-files.d.ts +20 -0
- package/dist/cost/ledger-files.js +128 -0
- package/dist/cost/ledger.d.ts +65 -0
- package/dist/cost/ledger.js +136 -0
- package/dist/cost/pricing.d.ts +48 -0
- package/dist/cost/pricing.js +141 -0
- package/dist/cost/records.d.ts +17 -0
- package/dist/cost/records.js +66 -0
- package/dist/cost/scanner.d.ts +22 -0
- package/dist/cost/scanner.js +95 -0
- package/dist/cost/types.d.ts +69 -0
- package/dist/cost/types.js +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/session/connection-view.d.ts +18 -0
- package/dist/session/connection-view.js +1 -0
- package/dist/{controller.d.ts → session/controller.d.ts} +119 -137
- package/dist/session/controller.js +616 -0
- package/dist/session/export-html.d.ts +9 -0
- package/dist/session/export-html.js +39 -0
- package/dist/session/export.d.ts +9 -0
- package/dist/session/export.js +38 -0
- package/dist/{history.d.ts → session/history.d.ts} +18 -1
- package/dist/session/history.js +338 -0
- package/dist/session/index.d.ts +17 -0
- package/dist/session/index.js +10 -0
- package/dist/session/markdown.d.ts +39 -0
- package/dist/session/markdown.js +255 -0
- package/dist/session/math.d.ts +11 -0
- package/dist/session/math.js +82 -0
- package/dist/{navigation.d.ts → session/navigation.d.ts} +1 -1
- package/dist/{navigation.js → session/navigation.js} +1 -1
- package/dist/{references.js → session/references.js} +1 -1
- package/dist/{telemetry.d.ts → session/telemetry.d.ts} +12 -1
- package/dist/{telemetry.js → session/telemetry.js} +18 -4
- package/dist/{transcript.d.ts → session/transcript.d.ts} +23 -1
- package/dist/{transcript.js → session/transcript.js} +36 -18
- package/dist/session/types.d.ts +18 -0
- package/dist/session/types.js +2 -0
- package/dist/state.d.ts +41 -0
- package/dist/state.js +9 -0
- package/dist/storage/directories.d.ts +14 -0
- package/dist/storage/directories.js +24 -0
- package/dist/storage/files.d.ts +43 -0
- package/dist/storage/files.js +135 -0
- package/dist/storage/index.d.ts +3 -0
- package/dist/storage/index.js +3 -0
- package/dist/transport/auth.js +65 -0
- package/dist/{client.d.ts → transport/client.d.ts} +8 -1
- package/dist/{client.js → transport/client.js} +21 -4
- package/dist/transport/host.d.ts +13 -0
- package/dist/transport/host.js +1 -0
- package/dist/ui/app.d.ts +11 -0
- package/dist/ui/app.js +726 -0
- package/dist/ui/chat/header.d.ts +11 -0
- package/dist/ui/chat/header.js +14 -0
- package/dist/{history-view.d.ts → ui/chat/history-view.d.ts} +1 -1
- package/dist/{history-view.js → ui/chat/history-view.js} +3 -3
- package/dist/ui/chat/status.d.ts +79 -0
- package/dist/ui/chat/status.js +311 -0
- package/dist/ui/chat/viewport.d.ts +17 -0
- package/dist/ui/chat/viewport.js +14 -0
- package/dist/ui/commands/parse.d.ts +96 -0
- package/dist/ui/commands/parse.js +121 -0
- package/dist/ui/commands/registry.d.ts +33 -0
- package/dist/ui/commands/registry.js +72 -0
- package/dist/ui/copy-mode.d.ts +4 -0
- package/dist/ui/copy-mode.js +6 -0
- package/dist/{cost-view.d.ts → ui/dialogs/cost.d.ts} +1 -1
- package/dist/{cost-view.js → ui/dialogs/cost.js} +3 -3
- package/dist/ui/dialogs/index.d.ts +120 -0
- package/dist/ui/dialogs/index.js +113 -0
- package/dist/ui/dialogs/picker.d.ts +18 -0
- package/dist/ui/dialogs/picker.js +38 -0
- package/dist/ui/frozen.d.ts +8 -0
- package/dist/ui/frozen.js +7 -0
- package/dist/ui/input/references.d.ts +12 -0
- package/dist/ui/input/references.js +15 -0
- package/dist/ui/mount.d.ts +6 -0
- package/dist/ui/mount.js +11 -0
- package/dist/{theme.d.ts → ui/theme/index.d.ts} +1 -1
- package/dsht-m.png +0 -0
- package/package.json +20 -14
- package/dist/app.d.ts +0 -21
- package/dist/app.js +0 -746
- package/dist/auth.js +0 -108
- package/dist/controller.js +0 -923
- package/dist/cost.d.ts +0 -119
- package/dist/cost.js +0 -313
- package/dist/history.js +0 -177
- package/dist/status.d.ts +0 -28
- package/dist/status.js +0 -157
- package/dsht.png +0 -0
- /package/dist/{cli.d.ts → cli/index.d.ts} +0 -0
- /package/dist/{memory.d.ts → session/memory.d.ts} +0 -0
- /package/dist/{memory.js → session/memory.js} +0 -0
- /package/dist/{references.d.ts → session/references.d.ts} +0 -0
- /package/dist/{auth.d.ts → transport/auth.d.ts} +0 -0
- /package/dist/{endpoint.d.ts → transport/endpoint.d.ts} +0 -0
- /package/dist/{endpoint.js → transport/endpoint.js} +0 -0
- /package/dist/{wire.d.ts → transport/wire.d.ts} +0 -0
- /package/dist/{wire.js → transport/wire.js} +0 -0
- /package/dist/{input-history.d.ts → ui/input/history.d.ts} +0 -0
- /package/dist/{input-history.js → ui/input/history.js} +0 -0
- /package/dist/{input.d.ts → ui/input/input.d.ts} +0 -0
- /package/dist/{input.js → ui/input/input.js} +0 -0
- /package/dist/{mouse.d.ts → ui/input/mouse.d.ts} +0 -0
- /package/dist/{mouse.js → ui/input/mouse.js} +0 -0
- /package/dist/{theme.js → ui/theme/index.js} +0 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
/** Session domain: selection, the follow stream, history, interaction and navigation. */
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
|
+
import { array, errorText, object, string } from "../transport/wire.js";
|
|
5
|
+
import { saveSessionLog } from "./export.js";
|
|
6
|
+
import { saveTranscriptHtml } from "./export-html.js";
|
|
7
|
+
import { releaseHistoryLayout } from "./history.js";
|
|
8
|
+
import { resolveTarget, sessionLabel } from "./navigation.js";
|
|
9
|
+
import { fileReferences } from "./references.js";
|
|
10
|
+
import { Transcript, toolLine } from "./transcript.js";
|
|
11
|
+
/** Built-in preset identifiers and the labels the web session header shows. */
|
|
12
|
+
const BUILT_IN_MODES = new Map([['standard', 'Standard mode'], ['ptc', 'PTC mode'], ['minimal', 'Minimal mode'], ['cordis', 'Creator mode']]);
|
|
13
|
+
/** Bounded match count for one history search. */
|
|
14
|
+
const SEARCH_MATCH_LIMIT = 200;
|
|
15
|
+
/** Owns the selected session: its follow stream, transcript, history window and interactions. */
|
|
16
|
+
export class SessionController {
|
|
17
|
+
store;
|
|
18
|
+
host;
|
|
19
|
+
connection;
|
|
20
|
+
historyLimits;
|
|
21
|
+
follow;
|
|
22
|
+
interactions = new Map();
|
|
23
|
+
historyPinned = false;
|
|
24
|
+
stoppingSession;
|
|
25
|
+
interruptTask;
|
|
26
|
+
admission;
|
|
27
|
+
constructor(store, host, connection, historyLimits) {
|
|
28
|
+
this.store = store;
|
|
29
|
+
this.host = host;
|
|
30
|
+
this.connection = connection;
|
|
31
|
+
this.historyLimits = historyLimits;
|
|
32
|
+
}
|
|
33
|
+
/** Host running state covers model generation, tools, and waits between assistant attempts. */
|
|
34
|
+
get running() {
|
|
35
|
+
const id = this.store.state.sessionId;
|
|
36
|
+
return id !== undefined && (this.connection.runningFor(id)
|
|
37
|
+
?? this.store.state.sessions.find(row => row.sessionId === id)?.running === true);
|
|
38
|
+
}
|
|
39
|
+
/** Current title projection, falling back to the list title and then the session ID. */
|
|
40
|
+
get sessionName() {
|
|
41
|
+
const id = this.store.state.sessionId;
|
|
42
|
+
if (!id)
|
|
43
|
+
return;
|
|
44
|
+
const title = this.connection.telemetryView().view(id).values.title;
|
|
45
|
+
const row = this.store.state.sessions.find(item => item.sessionId === id);
|
|
46
|
+
return title !== undefined ? sessionLabel({ sessionId: id, projections: { values: { title } } })
|
|
47
|
+
: row ? sessionLabel(row) : id;
|
|
48
|
+
}
|
|
49
|
+
/** Current agent-preset name, matching the web header's built-in labels and custom metadata. */
|
|
50
|
+
get sessionMode() {
|
|
51
|
+
if (!this.store.state.sessionId)
|
|
52
|
+
return undefined;
|
|
53
|
+
const id = this.connection.telemetryView().view(this.store.state.sessionId).values.agentPreset;
|
|
54
|
+
if (typeof id !== 'string')
|
|
55
|
+
return undefined;
|
|
56
|
+
const preset = this.store.state.presets?.find(item => item.id === id);
|
|
57
|
+
return preset?.trust === 'system' && BUILT_IN_MODES.has(id) ? BUILT_IN_MODES.get(id)
|
|
58
|
+
: typeof preset?.name === 'string' ? preset.name : id;
|
|
59
|
+
}
|
|
60
|
+
/** Epoch start from the retained turn log, or when this client first observed the run. */
|
|
61
|
+
get workingSince() {
|
|
62
|
+
if (!this.running || !this.store.state.sessionId)
|
|
63
|
+
return undefined;
|
|
64
|
+
return this.store.state.transcript.activeTurnStartedAt ?? this.connection.observedAt(this.store.state.sessionId);
|
|
65
|
+
}
|
|
66
|
+
/** Present only sessions explicitly accounted to the selected workspace. */
|
|
67
|
+
get visibleSessions() {
|
|
68
|
+
const sessions = this.store.state.sessions.filter(item => !this.host.client()?.archivedSessionIds.has(string(item.sessionId)));
|
|
69
|
+
if (this.store.state.showAllSessions || !this.store.state.workspaceId)
|
|
70
|
+
return sessions;
|
|
71
|
+
const workspace = this.store.state.workspaces.find(item => item.workspaceId === this.store.state.workspaceId);
|
|
72
|
+
const ids = new Set(array(workspace?.sessionIds ?? []).map(string));
|
|
73
|
+
return sessions.filter(item => ids.has(string(item.sessionId)));
|
|
74
|
+
}
|
|
75
|
+
/** Whether a turn, cancellation or prompt admission is still in flight. */
|
|
76
|
+
get active() { return this.interruptTask !== undefined || this.running || this.admission !== undefined; }
|
|
77
|
+
/** Whether reading protects the loaded window, suspending history reclamation. */
|
|
78
|
+
get pinned() { return this.historyPinned; }
|
|
79
|
+
/** Drop generation-scoped state before a new connection generation begins. */
|
|
80
|
+
beginGeneration() { this.stoppingSession = undefined; }
|
|
81
|
+
/** Invalidate in-flight work and drop transient interactions when a generation ends. */
|
|
82
|
+
endGeneration() {
|
|
83
|
+
this.store.bumpSelection();
|
|
84
|
+
this.store.state.transcript.ready = false;
|
|
85
|
+
this.interactions.clear();
|
|
86
|
+
}
|
|
87
|
+
/** Wait for an in-flight cancellation so shutdown leaves nothing running. */
|
|
88
|
+
async settle() { await this.interruptTask; }
|
|
89
|
+
/** Release the selected transcript and its layout caches. */
|
|
90
|
+
release() { this.releaseTranscript(); }
|
|
91
|
+
/** Pending interactions for the selected chat session, derived independently of frame order.
|
|
92
|
+
* @param state - State being published.
|
|
93
|
+
* @returns Retained question and approval frames for that session.
|
|
94
|
+
*/
|
|
95
|
+
pendingFor(state) {
|
|
96
|
+
return [...this.interactions.values()].filter(frame => frame.agentId === state.sessionId);
|
|
97
|
+
}
|
|
98
|
+
/** Retain a recognized host waterfall; unknown events stay with the connection to delegate.
|
|
99
|
+
* @param frame - One decoded waterfall frame.
|
|
100
|
+
* @returns Whether this domain retained the frame for an answer.
|
|
101
|
+
*/
|
|
102
|
+
waterfall(frame) {
|
|
103
|
+
if (!['approval/request', 'user-questions/request'].includes(string(frame.event)))
|
|
104
|
+
return false;
|
|
105
|
+
this.interactions.set(string(frame.eventId), frame);
|
|
106
|
+
this.store.update({});
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
/** Drop a waterfall the host cancelled.
|
|
110
|
+
* @param eventId - Correlation id previously retained.
|
|
111
|
+
*/
|
|
112
|
+
cancelled(eventId) { this.interactions.delete(eventId); this.store.update({}); }
|
|
113
|
+
/** Apply one host running-state notification to the session list and status line.
|
|
114
|
+
* @param sessionId - Session whose state changed.
|
|
115
|
+
* @param running - Whether the host still runs that session.
|
|
116
|
+
*/
|
|
117
|
+
status(sessionId, running) {
|
|
118
|
+
if (!running && this.stoppingSession === sessionId)
|
|
119
|
+
this.stoppingSession = undefined;
|
|
120
|
+
this.store.update({ sessions: this.store.state.sessions.map(row => row.sessionId === sessionId ? { ...row, running } : row),
|
|
121
|
+
...(sessionId === this.store.state.sessionId ? { status: running ? 'Running…' : 'Idle' } : {}) });
|
|
122
|
+
}
|
|
123
|
+
/** Surface a host-reported error for the selected session.
|
|
124
|
+
* @param sessionId - Session the host reported on.
|
|
125
|
+
* @param error - Error payload as delivered by the host.
|
|
126
|
+
*/
|
|
127
|
+
reportError(sessionId, error) {
|
|
128
|
+
if (sessionId === this.store.state.sessionId)
|
|
129
|
+
this.store.update({ status: 'Agent error', error: errorText(error) });
|
|
130
|
+
}
|
|
131
|
+
/** Stop the selected turn, or allow exit only while idle. Repeated keys share one request.
|
|
132
|
+
* @param force - Send an explicit cancellation even when the cached running flag is idle.
|
|
133
|
+
* @returns True when the caller may exit; cancellation failures retain the client.
|
|
134
|
+
*/
|
|
135
|
+
interrupt(force = false) {
|
|
136
|
+
if (this.interruptTask)
|
|
137
|
+
return this.interruptTask;
|
|
138
|
+
if (!force && !this.running && !this.admission && this.store.state.pending.length === 0) {
|
|
139
|
+
return Promise.resolve(!this.store.state.busy);
|
|
140
|
+
}
|
|
141
|
+
const sessionId = this.sessionId;
|
|
142
|
+
this.stoppingSession = sessionId;
|
|
143
|
+
this.store.update({ status: 'Stopping…', error: '' });
|
|
144
|
+
const task = (async () => {
|
|
145
|
+
try {
|
|
146
|
+
// Admission must settle before cancellation can address the newly submitted turn.
|
|
147
|
+
// The prompt caller reports admission failures; an existing turn still needs cancellation.
|
|
148
|
+
await this.admission?.catch(() => undefined);
|
|
149
|
+
await this.host.require().call('session/cancel', { request: { sessionId } });
|
|
150
|
+
if (this.stoppingSession === sessionId && this.store.state.sessionId === sessionId)
|
|
151
|
+
this.store.update({ status: 'Cancellation requested · waiting for host' });
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
this.stoppingSession = undefined;
|
|
155
|
+
this.store.update({ status: 'Cancellation failed', error: errorText(error) });
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
})();
|
|
159
|
+
this.interruptTask = task;
|
|
160
|
+
void task.finally(() => { this.interruptTask = undefined; });
|
|
161
|
+
return task;
|
|
162
|
+
}
|
|
163
|
+
/** Keep history stable while the user reads, searches, or expands it; trim again at the live tail.
|
|
164
|
+
* @param pinned - Whether the main transcript is actively being read away from its tail.
|
|
165
|
+
*/
|
|
166
|
+
pinHistory(pinned) {
|
|
167
|
+
this.historyPinned = pinned;
|
|
168
|
+
if (!pinned && this.reclaimHistory())
|
|
169
|
+
this.store.update({});
|
|
170
|
+
}
|
|
171
|
+
/** Cancel the active turn; pending queue items remain host-owned. */
|
|
172
|
+
async cancelTurn() {
|
|
173
|
+
await this.host.require().call('session/cancel', { request: { sessionId: this.sessionId } });
|
|
174
|
+
this.store.update({ status: 'Cancellation requested' });
|
|
175
|
+
}
|
|
176
|
+
/** Refresh both lists from the host, then show the requested picker.
|
|
177
|
+
* @param screen - Picker to display after the refresh.
|
|
178
|
+
*/
|
|
179
|
+
async showPicker(screen) {
|
|
180
|
+
const client = this.host.require();
|
|
181
|
+
const [workspaces, sessions] = await Promise.all([client.listWorkspaces(), client.listSessions()]);
|
|
182
|
+
this.store.update({ screen, workspaces, sessions });
|
|
183
|
+
}
|
|
184
|
+
/** Resolve a removal command to one reviewable object without changing the selection.
|
|
185
|
+
* @param kind - Workspace registration removal or session archival.
|
|
186
|
+
* @param query - Exact name, ID, or unambiguous ID prefix.
|
|
187
|
+
* @returns The fixed identity and display details for confirmation.
|
|
188
|
+
*/
|
|
189
|
+
async removalTarget(kind, query) {
|
|
190
|
+
const client = this.host.require();
|
|
191
|
+
const items = kind === 'workspace' ? await client.listWorkspaces() : await client.listSessions();
|
|
192
|
+
const row = resolveTarget(items, query, kind === 'workspace' ? 'workspaceId' : 'sessionId', item => kind === 'workspace' ? [string(item.title), string(item.path)] : [sessionLabel(item)]);
|
|
193
|
+
return kind === 'workspace' ? { kind, id: string(row.workspaceId), name: string(row.title), path: string(row.path) }
|
|
194
|
+
: { kind, id: string(row.sessionId), name: sessionLabel(row),
|
|
195
|
+
empty: row.blank === true && row.running === false
|
|
196
|
+
&& !this.connection.runningFor(string(row.sessionId))
|
|
197
|
+
&& !(this.connection.telemetryView().view(string(row.sessionId)).queued ?? 0)
|
|
198
|
+
&& !(this.connection.telemetryView().view(string(row.sessionId)).jobs ?? 0)
|
|
199
|
+
&& !(this.store.state.sessionId === row.sessionId && this.admission) };
|
|
200
|
+
}
|
|
201
|
+
/** Apply a confirmed removal or freshly verified empty-session archival; directories and logs are preserved.
|
|
202
|
+
* @param target - Exact workspace or session identity reviewed by the user.
|
|
203
|
+
*/
|
|
204
|
+
async removeTarget(target) {
|
|
205
|
+
const client = this.host.require();
|
|
206
|
+
const receipt = object(await client.call(target.kind === 'workspace' ? 'workspace/delete' : 'workspace/archiveSession', {
|
|
207
|
+
request: target.kind === 'workspace' ? { workspaceId: target.id } : { sessionId: target.id },
|
|
208
|
+
}));
|
|
209
|
+
if (client !== this.host.client())
|
|
210
|
+
return;
|
|
211
|
+
if (target.kind === 'session') {
|
|
212
|
+
client.archivedSessionIds = new Set(array(receipt.archivedSessionIds).map(string));
|
|
213
|
+
if (this.store.state.sessionId === target.id)
|
|
214
|
+
this.pickWorkspace(this.store.state.workspaceId);
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
if (receipt.deleted !== true)
|
|
218
|
+
throw new Error('Host did not confirm workspace removal');
|
|
219
|
+
this.store.update({ workspaces: this.store.state.workspaces.filter(row => row.workspaceId !== target.id),
|
|
220
|
+
...(this.store.state.workspaceId === target.id ? { workspaceId: undefined } : {}) });
|
|
221
|
+
}
|
|
222
|
+
this.store.update({ screen: target.kind === 'workspace' ? 'workspaces' : 'sessions',
|
|
223
|
+
status: target.kind === 'workspace' ? 'Workspace registration removed' : 'Session archived' });
|
|
224
|
+
// A refresh failure must not make a successful mutation look like a rejected deletion.
|
|
225
|
+
try {
|
|
226
|
+
await this.showPicker(target.kind === 'workspace' ? 'workspaces' : 'sessions');
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
this.store.update({ error: `Removal completed; list refresh failed: ${errorText(error)}` });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Pick a workspace, or use all sessions when the identity is omitted.
|
|
233
|
+
* @param workspaceId - Workspace to select, if any.
|
|
234
|
+
*/
|
|
235
|
+
pickWorkspace(workspaceId) {
|
|
236
|
+
this.store.bumpSelection();
|
|
237
|
+
this.follow?.cancel();
|
|
238
|
+
this.follow = undefined;
|
|
239
|
+
this.releaseTranscript();
|
|
240
|
+
this.store.update({ workspaceId, sessionId: undefined, showAllSessions: false, transcript: new Transcript(), screen: 'sessions' });
|
|
241
|
+
}
|
|
242
|
+
/** Open a workspace picker, or resolve a workspace by ID, exact title/path, or unique ID prefix.
|
|
243
|
+
* @param query - Workspace target, if any.
|
|
244
|
+
*/
|
|
245
|
+
async switchWorkspace(query) {
|
|
246
|
+
if (!query) {
|
|
247
|
+
await this.showPicker('workspaces');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const client = this.host.require();
|
|
251
|
+
const workspaces = await client.listWorkspaces();
|
|
252
|
+
const workspace = resolveTarget(workspaces, query, 'workspaceId', item => [string(item.title), string(item.path)]);
|
|
253
|
+
const sessions = await client.listSessions();
|
|
254
|
+
this.store.update({ workspaces, sessions });
|
|
255
|
+
this.pickWorkspace(string(workspace.workspaceId));
|
|
256
|
+
}
|
|
257
|
+
/** Guide workspace selection, list all sessions with `all`, or resolve an exact session target.
|
|
258
|
+
* @param query - Session target, `all`, or nothing for the guided picker.
|
|
259
|
+
*/
|
|
260
|
+
async switchSession(query) {
|
|
261
|
+
if (!query || query === 'all') {
|
|
262
|
+
await this.showPicker(query === 'all' || this.store.state.workspaceId ? 'sessions' : 'workspaces');
|
|
263
|
+
this.store.update({ showAllSessions: query === 'all' });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const client = this.host.require();
|
|
267
|
+
const [workspaces, sessions] = await Promise.all([client.listWorkspaces(), client.listSessions()]);
|
|
268
|
+
const session = resolveTarget(sessions, query, 'sessionId', item => [sessionLabel(item)]);
|
|
269
|
+
this.store.update({ workspaces, sessions });
|
|
270
|
+
await this.selectSession(string(session.sessionId));
|
|
271
|
+
}
|
|
272
|
+
/** Prompt for a host path without starting a local agent. */
|
|
273
|
+
enterPath() { this.store.update({ screen: 'path' }); }
|
|
274
|
+
/** Register a host directory and move to its session picker.
|
|
275
|
+
* @param path - Absolute directory path on the host.
|
|
276
|
+
*/
|
|
277
|
+
async createWorkspace(path) {
|
|
278
|
+
const result = object(await this.host.require().call('workspace/create', { request: { path } }));
|
|
279
|
+
const workspace = object(result.workspace);
|
|
280
|
+
await this.showPicker('workspaces');
|
|
281
|
+
this.pickWorkspace(string(workspace.workspaceId));
|
|
282
|
+
}
|
|
283
|
+
/** Create a session only after the user explicitly selects New session. */
|
|
284
|
+
async createSession() {
|
|
285
|
+
if (!this.store.state.workspaceId)
|
|
286
|
+
throw new Error('Select a workspace before creating a session');
|
|
287
|
+
const result = object(await this.host.require().call('session/create', { request: { workspaceId: this.store.state.workspaceId } }));
|
|
288
|
+
await this.selectSession(string(result.sessionId));
|
|
289
|
+
}
|
|
290
|
+
/** Replace the selected transcript and cancel its preceding follow stream.
|
|
291
|
+
* @param sessionId - Session to follow.
|
|
292
|
+
*/
|
|
293
|
+
async selectSession(sessionId) {
|
|
294
|
+
this.stoppingSession = undefined;
|
|
295
|
+
this.store.bumpSelection();
|
|
296
|
+
const selection = this.store.selection();
|
|
297
|
+
this.follow?.cancel();
|
|
298
|
+
this.follow = undefined;
|
|
299
|
+
this.releaseTranscript();
|
|
300
|
+
const transcript = new Transcript();
|
|
301
|
+
const workspace = this.store.state.workspaces.find(item => array(item.sessionIds).includes(sessionId));
|
|
302
|
+
const workspaceId = workspace ? string(workspace.workspaceId)
|
|
303
|
+
: this.store.state.sessions.some(item => item.sessionId === sessionId) ? undefined : this.store.state.workspaceId;
|
|
304
|
+
this.connection.observe(sessionId);
|
|
305
|
+
this.store.update({ sessionId, workspaceId, showAllSessions: false, transcript, screen: 'chat', status: 'Loading session…' });
|
|
306
|
+
this.follow = this.host.require().subscribe('session/follow', {
|
|
307
|
+
request: { address: { kind: 'session', sessionId }, maxMessages: 80, assistantStream: true },
|
|
308
|
+
}, {
|
|
309
|
+
item: value => {
|
|
310
|
+
if (selection !== this.store.selection())
|
|
311
|
+
return;
|
|
312
|
+
try {
|
|
313
|
+
transcript.accept(value);
|
|
314
|
+
this.reclaimHistory();
|
|
315
|
+
const frame = object(value);
|
|
316
|
+
if (frame.type === 'snapshot')
|
|
317
|
+
this.connection.telemetryView().snapshot(sessionId, frame.projections);
|
|
318
|
+
this.store.update({ transcript, status: this.stoppingSession === sessionId ? this.store.state.status : transcript.hasLiveContent ? 'Responding…' : 'Connected' });
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
this.connection.fail(new Error(errorText(error)));
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
end: error => {
|
|
325
|
+
if (selection !== this.store.selection())
|
|
326
|
+
return;
|
|
327
|
+
transcript.ready = false;
|
|
328
|
+
this.store.update({ status: 'Session disconnected', error: errorText(error ?? 'Session stream ended') });
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
/** Wait for the selected follow snapshot, failing on disconnect or cancellation.
|
|
333
|
+
* @param signal - Cancels waiting without closing the session.
|
|
334
|
+
*/
|
|
335
|
+
async waitForHistory(signal) {
|
|
336
|
+
const transcript = this.store.state.transcript;
|
|
337
|
+
const deadline = Date.now() + this.host.require().timeoutMs;
|
|
338
|
+
while (!transcript.ready) {
|
|
339
|
+
signal.throwIfAborted();
|
|
340
|
+
if (!this.store.state.online || this.store.state.transcript !== transcript)
|
|
341
|
+
throw new Error('Session changed while loading history');
|
|
342
|
+
if (Date.now() >= deadline)
|
|
343
|
+
throw new Error('Session snapshot timed out');
|
|
344
|
+
await delay(20, undefined, { signal });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/** Search the host's bounded global results, optionally retaining workspace members.
|
|
348
|
+
* @param query - Literal message text.
|
|
349
|
+
* @param workspaceOnly - Restrict returned hits to the selected workspace's session IDs.
|
|
350
|
+
* @param signal - Cancels the HTTP search.
|
|
351
|
+
* @returns Session snippets and the global truncation flag, preserved after filtering.
|
|
352
|
+
*/
|
|
353
|
+
async searchSessions(query, workspaceOnly, signal) {
|
|
354
|
+
const workspace = this.store.state.workspaces.find(item => item.workspaceId === this.store.state.workspaceId);
|
|
355
|
+
if (workspaceOnly && !workspace)
|
|
356
|
+
throw new Error('Select a workspace first');
|
|
357
|
+
const result = object(await this.host.require().call('session/search', { request: { query } }, signal));
|
|
358
|
+
if (!Array.isArray(result.items) || typeof result.hasMore !== 'boolean')
|
|
359
|
+
throw new Error('Invalid session search response');
|
|
360
|
+
const items = result.items.map(value => {
|
|
361
|
+
const item = object(value);
|
|
362
|
+
if (typeof item.sessionId !== 'string' || typeof item.snippet !== 'string')
|
|
363
|
+
throw new Error('Invalid session search item');
|
|
364
|
+
return item;
|
|
365
|
+
});
|
|
366
|
+
const ids = new Set(array(workspace?.sessionIds ?? []));
|
|
367
|
+
return { items: workspaceOnly ? items.filter(item => ids.has(item.sessionId)) : items, hasMore: result.hasMore };
|
|
368
|
+
}
|
|
369
|
+
/** Search paths on the host; does not read or upload file contents.
|
|
370
|
+
* @param query - Path text after @, relative to the selected session cwd.
|
|
371
|
+
* @param signal - Cancels an obsolete composer lookup.
|
|
372
|
+
* @returns Validated candidates in host order.
|
|
373
|
+
*/
|
|
374
|
+
async references(query, signal) {
|
|
375
|
+
return fileReferences(await this.host.require().call('fileReferences/list', { agentId: this.sessionId, query }, signal));
|
|
376
|
+
}
|
|
377
|
+
/** Execute a human command directly, outside the model prompt queue.
|
|
378
|
+
* @param line - Complete slash command, including arguments.
|
|
379
|
+
* @param signal - Cancels the request while the host performs compaction.
|
|
380
|
+
* @returns The host's successful command result text.
|
|
381
|
+
*/
|
|
382
|
+
async command(line, signal) {
|
|
383
|
+
if (!this.store.state.transcript.ready)
|
|
384
|
+
throw new Error('Wait for the session snapshot before running commands');
|
|
385
|
+
const execution = await this.host.require().call('commands/execute', {
|
|
386
|
+
agentId: this.sessionId, line, submittedAttachments: [],
|
|
387
|
+
}, signal, null);
|
|
388
|
+
if (execution === undefined)
|
|
389
|
+
throw new Error(`This host does not provide ${line.split(/\s/, 1)[0]}`);
|
|
390
|
+
const result = object(object(execution).result);
|
|
391
|
+
if ((result.kind !== 'success' && result.kind !== 'error') || (result.text !== undefined && typeof result.text !== 'string')) {
|
|
392
|
+
throw new Error('Invalid command result from host');
|
|
393
|
+
}
|
|
394
|
+
if (result.kind === 'error')
|
|
395
|
+
throw new Error(string(result.text));
|
|
396
|
+
return result.text === undefined ? 'Command completed.' : string(result.text);
|
|
397
|
+
}
|
|
398
|
+
/** Remove one host-owned pending input; an already claimed item reports a host error.
|
|
399
|
+
* @param itemId - Queue occurrence identity from session/control.
|
|
400
|
+
*/
|
|
401
|
+
async removeQueued(itemId) {
|
|
402
|
+
await this.host.require().call('session/updateQueue', { request: { sessionId: this.sessionId, itemId, action: { kind: 'remove' } } });
|
|
403
|
+
}
|
|
404
|
+
/** Export the selected host log to a new local ZIP file.
|
|
405
|
+
* @param path - Optional local destination; existing files are never overwritten.
|
|
406
|
+
* @param signal - Cancels the download and removes an incomplete file.
|
|
407
|
+
* @returns Absolute saved filename.
|
|
408
|
+
*/
|
|
409
|
+
async exportLog(path, signal) {
|
|
410
|
+
return saveSessionLog(this.host.require(), this.sessionId, path, signal);
|
|
411
|
+
}
|
|
412
|
+
/** Save the retained conversation with offline Markdown, diagrams and math.
|
|
413
|
+
* @param path - Optional destination; existing files are never overwritten.
|
|
414
|
+
* @param signal - Cancels the write.
|
|
415
|
+
* @returns Absolute saved filename.
|
|
416
|
+
*/
|
|
417
|
+
async exportHtml(path, signal) {
|
|
418
|
+
return saveTranscriptHtml(this.store.state.transcript, this.sessionId, path, signal);
|
|
419
|
+
}
|
|
420
|
+
/** Admit text once as steering while running, or a new turn while idle; a lost response can leave delivery uncertain.
|
|
421
|
+
* @param text - Composed prompt text.
|
|
422
|
+
*/
|
|
423
|
+
async prompt(text) {
|
|
424
|
+
if (this.store.state.pending.length)
|
|
425
|
+
throw new Error('Answer the pending question or approval first');
|
|
426
|
+
this.stoppingSession = undefined;
|
|
427
|
+
if (!this.store.state.transcript.ready)
|
|
428
|
+
throw new Error('Wait for the session snapshot before sending');
|
|
429
|
+
const admission = this.host.require().call('session/prompt', { request: {
|
|
430
|
+
sessionId: this.sessionId, requestId: randomUUID(), mode: this.running ? 'steer' : 'queue',
|
|
431
|
+
content: [{ type: 'text', text }], clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
432
|
+
} });
|
|
433
|
+
this.admission = admission;
|
|
434
|
+
try {
|
|
435
|
+
await admission;
|
|
436
|
+
}
|
|
437
|
+
finally {
|
|
438
|
+
if (this.admission === admission)
|
|
439
|
+
this.admission = undefined;
|
|
440
|
+
}
|
|
441
|
+
this.store.update({ status: 'Accepted · waiting for host' });
|
|
442
|
+
}
|
|
443
|
+
/** Answer the oldest selected-session interaction, after explicit user action.
|
|
444
|
+
* @param value - Structured answer value or approval outcome.
|
|
445
|
+
*/
|
|
446
|
+
async answer(value) {
|
|
447
|
+
const pending = this.store.state.pending[0];
|
|
448
|
+
if (!pending)
|
|
449
|
+
throw new Error('No pending interaction');
|
|
450
|
+
await this.reply(pending, { kind: 'result', value });
|
|
451
|
+
this.interactions.delete(string(pending.eventId));
|
|
452
|
+
this.store.update({});
|
|
453
|
+
}
|
|
454
|
+
/** Restrict an approval command to an approval request.
|
|
455
|
+
* @param allowed - Whether the request is approved once.
|
|
456
|
+
*/
|
|
457
|
+
async approve(allowed) {
|
|
458
|
+
if (this.store.state.pending[0]?.event !== 'approval/request')
|
|
459
|
+
throw new Error('No pending approval');
|
|
460
|
+
await this.answer(allowed ? 'allowed-once' : 'rejected');
|
|
461
|
+
}
|
|
462
|
+
/** Add a page before the retained window using its fixed opening cut.
|
|
463
|
+
* @param signal - Cancels local paging without interrupting the remote agent.
|
|
464
|
+
* @param transcript - Transcript to extend; defaults to the live one.
|
|
465
|
+
*/
|
|
466
|
+
async older(signal, transcript = this.store.state.transcript) {
|
|
467
|
+
const selection = this.store.selection();
|
|
468
|
+
if (transcript === this.store.state.transcript)
|
|
469
|
+
this.historyPinned = true;
|
|
470
|
+
if (!transcript.ready || !transcript.hasMore || transcript.beforeSeq === undefined)
|
|
471
|
+
return;
|
|
472
|
+
const result = await this.host.require().call('session/page', { request: {
|
|
473
|
+
address: { kind: 'session', sessionId: this.sessionId }, throughSeq: transcript.cursor,
|
|
474
|
+
beforeSeq: transcript.beforeSeq, maxMessages: 80,
|
|
475
|
+
} }, signal);
|
|
476
|
+
if (selection !== this.store.selection())
|
|
477
|
+
return;
|
|
478
|
+
transcript.addPage(result);
|
|
479
|
+
this.store.update({});
|
|
480
|
+
}
|
|
481
|
+
/** Search one page at a time, preserving only the first 200 matches and releasing temporary content.
|
|
482
|
+
* @param query - Literal, case-insensitive text including folded reasoning.
|
|
483
|
+
* @param signal - Cancels HTTP and processing without cancelling the agent.
|
|
484
|
+
* @returns Newest-first bounded summaries and an explicit truncation flag.
|
|
485
|
+
*/
|
|
486
|
+
async searchHistory(query, signal) {
|
|
487
|
+
const source = this.store.state.transcript;
|
|
488
|
+
if (!source.ready)
|
|
489
|
+
throw new Error('Wait for the session snapshot');
|
|
490
|
+
const sessionId = this.sessionId;
|
|
491
|
+
const selection = this.store.selection();
|
|
492
|
+
const throughSeq = source.readThrough;
|
|
493
|
+
const needle = query.toLowerCase();
|
|
494
|
+
const result = { items: [], truncated: false };
|
|
495
|
+
const scan = (transcript) => {
|
|
496
|
+
const messages = transcript.messages;
|
|
497
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
498
|
+
signal.throwIfAborted();
|
|
499
|
+
const message = messages[index];
|
|
500
|
+
if (message.role === 'Tool')
|
|
501
|
+
continue;
|
|
502
|
+
const text = message.text;
|
|
503
|
+
const match = text.toLowerCase().indexOf(needle);
|
|
504
|
+
if (match < 0)
|
|
505
|
+
continue;
|
|
506
|
+
if (result.items.length === SEARCH_MATCH_LIMIT) {
|
|
507
|
+
result.truncated = true;
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
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') });
|
|
511
|
+
}
|
|
512
|
+
return true;
|
|
513
|
+
};
|
|
514
|
+
signal.throwIfAborted();
|
|
515
|
+
if (!scan(source))
|
|
516
|
+
return result;
|
|
517
|
+
let beforeSeq = source.beforeSeq;
|
|
518
|
+
let hasMore = source.hasMore;
|
|
519
|
+
while (hasMore && beforeSeq !== undefined) {
|
|
520
|
+
const page = object(await this.host.require().call('session/page', { request: {
|
|
521
|
+
address: { kind: 'session', sessionId }, throughSeq, beforeSeq, maxMessages: 80,
|
|
522
|
+
} }, signal));
|
|
523
|
+
signal.throwIfAborted();
|
|
524
|
+
if (selection !== this.store.selection())
|
|
525
|
+
throw new Error('Session changed while searching history');
|
|
526
|
+
const temporary = new Transcript();
|
|
527
|
+
try {
|
|
528
|
+
temporary.accept({ type: 'snapshot', cursor: throughSeq, assistantStream: { revision: 0 }, records: page.records, hasMore: page.hasMore });
|
|
529
|
+
const next = temporary.beforeSeq;
|
|
530
|
+
if (temporary.hasMore && (next === undefined || next >= beforeSeq))
|
|
531
|
+
throw new Error('Host history page did not advance');
|
|
532
|
+
if (!scan(temporary))
|
|
533
|
+
return result;
|
|
534
|
+
beforeSeq = next;
|
|
535
|
+
hasMore = temporary.hasMore;
|
|
536
|
+
}
|
|
537
|
+
finally {
|
|
538
|
+
temporary.dispose();
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return result;
|
|
542
|
+
}
|
|
543
|
+
/** Load a separate small window ending at a search target; the live transcript keeps following.
|
|
544
|
+
* @param target - Durable message sequence to display.
|
|
545
|
+
* @param signal - Cancels the target-page request.
|
|
546
|
+
* @returns A caller-owned historical window that must be disposed when closed.
|
|
547
|
+
*/
|
|
548
|
+
async historyAt(target, signal) {
|
|
549
|
+
const source = this.store.state.transcript;
|
|
550
|
+
const selection = this.store.selection();
|
|
551
|
+
const page = object(await this.host.require().call('session/page', { request: {
|
|
552
|
+
address: { kind: 'session', sessionId: this.sessionId }, throughSeq: source.readThrough,
|
|
553
|
+
beforeSeq: target + 1, maxMessages: 80,
|
|
554
|
+
} }, signal));
|
|
555
|
+
signal.throwIfAborted();
|
|
556
|
+
if (selection !== this.store.selection())
|
|
557
|
+
throw new Error('Session changed while opening history');
|
|
558
|
+
const window = new Transcript();
|
|
559
|
+
try {
|
|
560
|
+
window.accept({ type: 'snapshot', cursor: source.readThrough, assistantStream: { revision: 0 }, records: page.records, hasMore: page.hasMore });
|
|
561
|
+
if (!window.messages.some(message => message.seq === target))
|
|
562
|
+
throw new Error('The host did not return the requested message');
|
|
563
|
+
return window;
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
window.dispose();
|
|
567
|
+
throw error;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/** Load the prefix required for an explicit history jump; never loop on an unadvancing page.
|
|
571
|
+
* @param target - Visible record sequence, or first for the oldest available history.
|
|
572
|
+
* @param signal - Cancels local paging without interrupting the remote agent.
|
|
573
|
+
*/
|
|
574
|
+
async historyThrough(target, signal) {
|
|
575
|
+
const transcript = this.store.state.transcript;
|
|
576
|
+
if (!transcript.ready)
|
|
577
|
+
throw new Error('Wait for the session snapshot');
|
|
578
|
+
while (transcript.hasMore && (target === 'first' || transcript.beforeSeq !== undefined && transcript.beforeSeq > target)) {
|
|
579
|
+
signal.throwIfAborted();
|
|
580
|
+
const before = transcript.beforeSeq;
|
|
581
|
+
await this.older(signal);
|
|
582
|
+
if (this.store.state.transcript !== transcript)
|
|
583
|
+
throw new Error('Session changed while loading history');
|
|
584
|
+
if (transcript.hasMore && (before === undefined || transcript.beforeSeq === undefined || transcript.beforeSeq >= before)) {
|
|
585
|
+
throw new Error('Host history page did not advance');
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
/** Reclaim reloadable history unless the user is reading away from the tail.
|
|
590
|
+
* @returns Number of removed records.
|
|
591
|
+
*/
|
|
592
|
+
reclaimHistory() {
|
|
593
|
+
if (this.historyPinned || !this.store.state.online)
|
|
594
|
+
return 0;
|
|
595
|
+
const removed = this.store.state.transcript.trimHistory(this.historyLimits);
|
|
596
|
+
if (removed)
|
|
597
|
+
releaseHistoryLayout(this.store.state.transcript);
|
|
598
|
+
return removed;
|
|
599
|
+
}
|
|
600
|
+
/** Release the selected transcript and its layout caches. */
|
|
601
|
+
releaseTranscript() {
|
|
602
|
+
releaseHistoryLayout(this.store.state.transcript);
|
|
603
|
+
this.store.state.transcript.dispose();
|
|
604
|
+
this.historyPinned = false;
|
|
605
|
+
}
|
|
606
|
+
/** @returns The selected session identity, or a `Select a session first` failure. */
|
|
607
|
+
get sessionId() {
|
|
608
|
+
if (!this.store.state.sessionId)
|
|
609
|
+
throw new Error('Select a session first');
|
|
610
|
+
return this.store.state.sessionId;
|
|
611
|
+
}
|
|
612
|
+
/** Answer one retained waterfall through the connection's event-result endpoint. */
|
|
613
|
+
async reply(frame, outcome) {
|
|
614
|
+
await this.connection.reply(frame, outcome);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Transcript } from './transcript.ts';
|
|
2
|
+
/** Save the currently retained messages and live tail without fetching older history or overwriting files.
|
|
3
|
+
* @param transcript - Selected conversation, captured before writing begins.
|
|
4
|
+
* @param sessionId - Selected session identifier for the document title and default filename.
|
|
5
|
+
* @param destination - Optional local HTML filename.
|
|
6
|
+
* @param signal - Cancels the write and removes incomplete output.
|
|
7
|
+
* @returns Absolute filename of the saved offline document.
|
|
8
|
+
*/
|
|
9
|
+
export declare function saveTranscriptHtml(transcript: Transcript, sessionId: string, destination: string | undefined, signal: AbortSignal): Promise<string>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Offline export of the retained conversation, with inert diagrams and MathJax MathML. */
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { writeExclusiveStream } from "../storage/index.js";
|
|
4
|
+
import { markdownHtml } from "./markdown.js";
|
|
5
|
+
function escape(text) {
|
|
6
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
7
|
+
}
|
|
8
|
+
/** Save the currently retained messages and live tail without fetching older history or overwriting files.
|
|
9
|
+
* @param transcript - Selected conversation, captured before writing begins.
|
|
10
|
+
* @param sessionId - Selected session identifier for the document title and default filename.
|
|
11
|
+
* @param destination - Optional local HTML filename.
|
|
12
|
+
* @param signal - Cancels the write and removes incomplete output.
|
|
13
|
+
* @returns Absolute filename of the saved offline document.
|
|
14
|
+
*/
|
|
15
|
+
export async function saveTranscriptHtml(transcript, sessionId, destination, signal) {
|
|
16
|
+
signal.throwIfAborted();
|
|
17
|
+
const messages = transcript.messagesForWidth(120);
|
|
18
|
+
const live = transcript.liveParts(120);
|
|
19
|
+
const partsHtml = (parts) => parts.map(part => part.kind === 'text' ? markdownHtml(part.text)
|
|
20
|
+
: `<pre>${escape(part.text)}</pre>`).join('\n');
|
|
21
|
+
const body = messages.map(message => `<article><h2>${escape(message.role)}</h2>${partsHtml(message.parts)}</article>`).join('\n')
|
|
22
|
+
+ (live.length ? `<article><h2>Assistant · streaming</h2>${partsHtml(live)}</article>` : '');
|
|
23
|
+
const title = escape(`Session ${sessionId}`);
|
|
24
|
+
const document = `<!doctype html>
|
|
25
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
26
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
|
|
27
|
+
<title>${title}</title><style>
|
|
28
|
+
body{font:16px/1.6 system-ui,sans-serif;max-width:1000px;margin:2em auto;padding:0 1em;color:#222;background:#fff}
|
|
29
|
+
article{border-top:1px solid #ddd;padding:1em 0}pre,code{font-family:ui-monospace,monospace;background:#f5f5f5}
|
|
30
|
+
pre{padding:1em;white-space:pre-wrap;overflow-wrap:anywhere}table{border-collapse:collapse;width:100%;table-layout:fixed}
|
|
31
|
+
td,th{border:1px solid #ccc;padding:.5em;overflow-wrap:anywhere}img{max-width:100%;height:auto}math[display=block]{overflow:auto;padding:1em 0}
|
|
32
|
+
blockquote{border-left:3px solid #ccc;padding-left:1em;margin-left:0}a{overflow-wrap:anywhere}
|
|
33
|
+
</style></head><body><h1>${title}</h1><p>Loaded conversation only; older or evicted messages are not included. Tool rows are summaries.</p>
|
|
34
|
+
${body}</body></html>
|
|
35
|
+
`;
|
|
36
|
+
const path = resolve(destination ?? `session-${sessionId.replace(/[^a-zA-Z0-9_-]/g, '_')}-${Date.now()}.html`);
|
|
37
|
+
await writeExclusiveStream(path, async () => (async function* () { yield Buffer.from(document); })(), signal);
|
|
38
|
+
return path;
|
|
39
|
+
}
|