@supanadit/morse-web 0.2.0
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/LICENSE +21 -0
- package/README.md +95 -0
- package/dist/cli.mjs +539 -0
- package/dist/cli.mjs.map +7 -0
- package/dist/server.mjs +3910 -0
- package/dist/server.mjs.map +7 -0
- package/dist/ui/3rdpartylicenses.txt +591 -0
- package/dist/ui/favicon.ico +0 -0
- package/dist/ui/favicon.svg +9 -0
- package/dist/ui/index.html +44 -0
- package/dist/ui/main-6YN6VQNO.js +88 -0
- package/dist/ui/styles-WKFZG3MN.css +1 -0
- package/dist/ui/webview.manifest.json +7 -0
- package/package.json +56 -0
package/dist/server.mjs
ADDED
|
@@ -0,0 +1,3910 @@
|
|
|
1
|
+
// Generated by packages/morse-web/build.mjs — do not edit.
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
5
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
6
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--)
|
|
7
|
+
if (decorator = decorators[i])
|
|
8
|
+
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
9
|
+
if (kind && result) __defProp(target, key, result);
|
|
10
|
+
return result;
|
|
11
|
+
};
|
|
12
|
+
var __decorateParam = (index, decorator) => (target, key) => decorator(target, key, index);
|
|
13
|
+
|
|
14
|
+
// ../server/src/main.ts
|
|
15
|
+
import "reflect-metadata";
|
|
16
|
+
import { Logger as Logger2 } from "@nestjs/common";
|
|
17
|
+
import { NestFactory } from "@nestjs/core";
|
|
18
|
+
import { WsAdapter } from "@nestjs/platform-ws";
|
|
19
|
+
|
|
20
|
+
// ../protocol/dist/wire.js
|
|
21
|
+
var CLIENT_MESSAGE_TYPES = [
|
|
22
|
+
"client/ready",
|
|
23
|
+
"chat/prompt",
|
|
24
|
+
"chat/edit",
|
|
25
|
+
"chat/fork",
|
|
26
|
+
"chat/abort",
|
|
27
|
+
"session/new",
|
|
28
|
+
"session/load",
|
|
29
|
+
"session/activate",
|
|
30
|
+
"session/close",
|
|
31
|
+
"session/delete",
|
|
32
|
+
"session/compact",
|
|
33
|
+
"history/load",
|
|
34
|
+
"session/list",
|
|
35
|
+
"project/list",
|
|
36
|
+
"project/open",
|
|
37
|
+
"model/set",
|
|
38
|
+
"thinking/set",
|
|
39
|
+
"interaction/respond",
|
|
40
|
+
"host/command"
|
|
41
|
+
];
|
|
42
|
+
function encodeWireMessage(message) {
|
|
43
|
+
return JSON.stringify(message);
|
|
44
|
+
}
|
|
45
|
+
function isClientToHostMessage(value) {
|
|
46
|
+
return hasKnownType(value, CLIENT_MESSAGE_TYPES);
|
|
47
|
+
}
|
|
48
|
+
function parseClientMessage(raw) {
|
|
49
|
+
const value = decode(raw);
|
|
50
|
+
return isClientToHostMessage(value) ? value : void 0;
|
|
51
|
+
}
|
|
52
|
+
function decode(raw) {
|
|
53
|
+
if (typeof raw === "string") {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(raw);
|
|
56
|
+
} catch {
|
|
57
|
+
return void 0;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (raw instanceof Uint8Array) {
|
|
61
|
+
return decode(new TextDecoder().decode(raw));
|
|
62
|
+
}
|
|
63
|
+
if (raw instanceof ArrayBuffer) {
|
|
64
|
+
return decode(new TextDecoder().decode(new Uint8Array(raw)));
|
|
65
|
+
}
|
|
66
|
+
return raw;
|
|
67
|
+
}
|
|
68
|
+
function hasKnownType(value, allowed) {
|
|
69
|
+
if (typeof value !== "object" || value === null) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const type = value.type;
|
|
73
|
+
return typeof type === "string" && allowed.includes(type);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ../protocol/dist/version.js
|
|
77
|
+
var PROTOCOL_VERSION = 15;
|
|
78
|
+
var DEFAULT_WS_PATH = "/ws";
|
|
79
|
+
|
|
80
|
+
// ../protocol/dist/frontend-manifest.js
|
|
81
|
+
function isFrontendManifest(value) {
|
|
82
|
+
if (typeof value !== "object" || value === null) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
const candidate = value;
|
|
86
|
+
return typeof candidate.name === "string" && typeof candidate.version === "string" && typeof candidate.protocolVersion === "number" && typeof candidate.entry === "string";
|
|
87
|
+
}
|
|
88
|
+
function parseFrontendManifest(raw) {
|
|
89
|
+
if (!raw) {
|
|
90
|
+
return void 0;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(raw);
|
|
94
|
+
return isFrontendManifest(parsed) ? parsed : void 0;
|
|
95
|
+
} catch {
|
|
96
|
+
return void 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ../server/src/app.module.ts
|
|
101
|
+
import { Module } from "@nestjs/common";
|
|
102
|
+
|
|
103
|
+
// ../core/dist/domain.js
|
|
104
|
+
var THINKING_LEVELS = [
|
|
105
|
+
"off",
|
|
106
|
+
"minimal",
|
|
107
|
+
"low",
|
|
108
|
+
"medium",
|
|
109
|
+
"high",
|
|
110
|
+
"xhigh",
|
|
111
|
+
"max"
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
// ../core/dist/errors.js
|
|
115
|
+
var MorseError = class extends Error {
|
|
116
|
+
code;
|
|
117
|
+
constructor(message, code, options) {
|
|
118
|
+
super(message, options);
|
|
119
|
+
this.code = code;
|
|
120
|
+
this.name = new.target.name;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
var AgentUnavailableError = class extends MorseError {
|
|
124
|
+
constructor(message, options) {
|
|
125
|
+
super(message, "agent-unavailable", options);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
var AgentProtocolError = class extends MorseError {
|
|
129
|
+
constructor(message, options) {
|
|
130
|
+
super(message, "agent-protocol", options);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
var UnsupportedByHostError = class extends MorseError {
|
|
134
|
+
constructor(message) {
|
|
135
|
+
super(message, "unsupported-by-host");
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// ../core/dist/session/service.js
|
|
140
|
+
var DEFAULT_HOT_LIMIT = 4;
|
|
141
|
+
var SessionRegistry = class {
|
|
142
|
+
deps;
|
|
143
|
+
hot = /* @__PURE__ */ new Map();
|
|
144
|
+
/** Least recently used first. */
|
|
145
|
+
recency = [];
|
|
146
|
+
listeners = /* @__PURE__ */ new Set();
|
|
147
|
+
hotLimit;
|
|
148
|
+
activeKey;
|
|
149
|
+
syntheticKeys = 0;
|
|
150
|
+
/** Cached snapshot of a never-recording spawn (see `draftDefaults`). */
|
|
151
|
+
draftCache;
|
|
152
|
+
draftProbe;
|
|
153
|
+
constructor(deps) {
|
|
154
|
+
this.deps = deps;
|
|
155
|
+
this.hotLimit = Math.max(1, deps.hotLimit ?? DEFAULT_HOT_LIMIT);
|
|
156
|
+
}
|
|
157
|
+
get defaultWorkspace() {
|
|
158
|
+
return this.deps.defaultWorkspace;
|
|
159
|
+
}
|
|
160
|
+
subscribe(listener) {
|
|
161
|
+
this.listeners.add(listener);
|
|
162
|
+
return () => {
|
|
163
|
+
this.listeners.delete(listener);
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
activeKeyOf() {
|
|
167
|
+
return this.activeKey;
|
|
168
|
+
}
|
|
169
|
+
active() {
|
|
170
|
+
return this.activeKey === void 0 ? void 0 : this.hot.get(this.activeKey)?.gateway;
|
|
171
|
+
}
|
|
172
|
+
requireActive() {
|
|
173
|
+
const gateway = this.active();
|
|
174
|
+
if (!gateway) {
|
|
175
|
+
throw new AgentUnavailableError("No active Morse session. Open a session first.");
|
|
176
|
+
}
|
|
177
|
+
return gateway;
|
|
178
|
+
}
|
|
179
|
+
stateOf(key) {
|
|
180
|
+
return this.hot.get(key)?.state;
|
|
181
|
+
}
|
|
182
|
+
hotKeys() {
|
|
183
|
+
return [...this.recency];
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* What the backend offers before any session exists: the model catalog,
|
|
187
|
+
* thinking levels and commands of an untouched spawn. Probed once per
|
|
188
|
+
* registry — never per client — from a gateway that never records itself
|
|
189
|
+
* (pi `--no-session`), so the empty state can ship a real model picker
|
|
190
|
+
* without creating a session.
|
|
191
|
+
*/
|
|
192
|
+
async draftDefaults() {
|
|
193
|
+
if (this.draftCache) {
|
|
194
|
+
return this.draftCache;
|
|
195
|
+
}
|
|
196
|
+
this.draftProbe ??= this.probeDraft();
|
|
197
|
+
return this.draftProbe;
|
|
198
|
+
}
|
|
199
|
+
/** Opens a new (or resumes an existing) session and makes it active. */
|
|
200
|
+
async open(options = {}) {
|
|
201
|
+
const workspace = options.workspace ?? this.deps.defaultWorkspace;
|
|
202
|
+
const gateway = await this.deps.factory.create({
|
|
203
|
+
workspace,
|
|
204
|
+
sessionId: options.sessionId
|
|
205
|
+
});
|
|
206
|
+
const state = await gateway.state();
|
|
207
|
+
const key = state.sessionId ?? this.nextSyntheticKey(workspace);
|
|
208
|
+
const alreadyHot = this.hot.get(key);
|
|
209
|
+
if (alreadyHot) {
|
|
210
|
+
await gateway.dispose().catch((error) => {
|
|
211
|
+
this.deps.logger.warn("Failed to dispose a duplicate agent gateway", error);
|
|
212
|
+
});
|
|
213
|
+
this.touch(key);
|
|
214
|
+
this.activeKey = key;
|
|
215
|
+
return { key, state: alreadyHot.state, reused: true };
|
|
216
|
+
}
|
|
217
|
+
this.hot.set(key, {
|
|
218
|
+
key,
|
|
219
|
+
gateway,
|
|
220
|
+
workspace,
|
|
221
|
+
detach: gateway.subscribe((event) => {
|
|
222
|
+
this.emit({ sessionKey: key, event: this.absorb(key, event) });
|
|
223
|
+
}),
|
|
224
|
+
state
|
|
225
|
+
});
|
|
226
|
+
this.touch(key);
|
|
227
|
+
this.activeKey = key;
|
|
228
|
+
await this.evictBeyondLimit();
|
|
229
|
+
return { key, state, reused: false };
|
|
230
|
+
}
|
|
231
|
+
/** Switches to a hot session, or opens it when it is not running anymore. */
|
|
232
|
+
async activate(key, workspace) {
|
|
233
|
+
const existing = this.hot.get(key);
|
|
234
|
+
if (existing) {
|
|
235
|
+
this.touch(key);
|
|
236
|
+
this.activeKey = key;
|
|
237
|
+
return { key, state: existing.state, reused: true };
|
|
238
|
+
}
|
|
239
|
+
return this.open({ workspace, sessionId: key });
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Forks the active session before a past user message. pi's fork writes a
|
|
243
|
+
* *new* session file while reusing the same subprocess, so the hot entry is
|
|
244
|
+
* re-keyed to the new session id instead of respawning the agent. The
|
|
245
|
+
* controller truncates its transcript to match, then sends the edited prompt.
|
|
246
|
+
*/
|
|
247
|
+
async forkActive(entryId) {
|
|
248
|
+
const previousKey = this.activeKey;
|
|
249
|
+
const hot = previousKey === void 0 ? void 0 : this.hot.get(previousKey);
|
|
250
|
+
if (previousKey === void 0 || !hot) {
|
|
251
|
+
throw new AgentUnavailableError("No active Morse session. Open a session first.");
|
|
252
|
+
}
|
|
253
|
+
const result = await hot.gateway.fork(entryId);
|
|
254
|
+
const state = await hot.gateway.state();
|
|
255
|
+
if (result.cancelled) {
|
|
256
|
+
return { key: previousKey, previousKey, state, text: result.text, cancelled: true };
|
|
257
|
+
}
|
|
258
|
+
const key = state.sessionId ?? this.nextSyntheticKey(hot.workspace);
|
|
259
|
+
if (key === previousKey) {
|
|
260
|
+
return { key, previousKey, state, text: result.text, cancelled: false };
|
|
261
|
+
}
|
|
262
|
+
hot.detach();
|
|
263
|
+
this.hot.delete(previousKey);
|
|
264
|
+
this.hot.set(key, {
|
|
265
|
+
key,
|
|
266
|
+
gateway: hot.gateway,
|
|
267
|
+
workspace: hot.workspace,
|
|
268
|
+
state,
|
|
269
|
+
detach: hot.gateway.subscribe((event) => {
|
|
270
|
+
this.emit({ sessionKey: key, event: this.absorb(key, event) });
|
|
271
|
+
})
|
|
272
|
+
});
|
|
273
|
+
const index = this.recency.indexOf(previousKey);
|
|
274
|
+
if (index >= 0) {
|
|
275
|
+
this.recency[index] = key;
|
|
276
|
+
} else {
|
|
277
|
+
this.recency.push(key);
|
|
278
|
+
}
|
|
279
|
+
this.activeKey = key;
|
|
280
|
+
return { key, previousKey, state, text: result.text, cancelled: false };
|
|
281
|
+
}
|
|
282
|
+
async close(key) {
|
|
283
|
+
const session = this.hot.get(key);
|
|
284
|
+
this.forget(key);
|
|
285
|
+
if (!session) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
session.detach();
|
|
289
|
+
await session.gateway.dispose().catch((error) => {
|
|
290
|
+
this.deps.logger.warn("Failed to dispose an agent gateway", error);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Deletes a session for good: its hot process first (it may hold the file
|
|
295
|
+
* open), then the stored conversation through the catalog. Storage rules stay
|
|
296
|
+
* in the adapter; this use case owns only the order and the refusal when the
|
|
297
|
+
* host cannot delete at all.
|
|
298
|
+
*/
|
|
299
|
+
async remove(key) {
|
|
300
|
+
await this.close(key);
|
|
301
|
+
const remove = this.deps.catalog.remove;
|
|
302
|
+
if (!remove) {
|
|
303
|
+
throw new UnsupportedByHostError("This host cannot delete sessions.");
|
|
304
|
+
}
|
|
305
|
+
await remove.call(this.deps.catalog, key);
|
|
306
|
+
}
|
|
307
|
+
listSessions() {
|
|
308
|
+
return this.deps.catalog.list();
|
|
309
|
+
}
|
|
310
|
+
/** Projects are the directories pi has session buckets for. */
|
|
311
|
+
async listProjects(sessions) {
|
|
312
|
+
const all = sessions ?? await this.listSessions();
|
|
313
|
+
const projects = /* @__PURE__ */ new Map();
|
|
314
|
+
for (const session of all) {
|
|
315
|
+
if (session.cwd.length === 0) {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const existing = projects.get(session.cwd);
|
|
319
|
+
if (existing) {
|
|
320
|
+
existing.sessionCount += 1;
|
|
321
|
+
existing.lastUsedAt = Math.max(existing.lastUsedAt, session.updatedAt);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
projects.set(session.cwd, {
|
|
325
|
+
path: session.cwd,
|
|
326
|
+
name: baseName(session.cwd),
|
|
327
|
+
sessionCount: 1,
|
|
328
|
+
lastUsedAt: session.updatedAt
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
const defaultPath = this.deps.defaultWorkspace.cwd;
|
|
332
|
+
if (!projects.has(defaultPath)) {
|
|
333
|
+
projects.set(defaultPath, {
|
|
334
|
+
path: defaultPath,
|
|
335
|
+
name: this.deps.defaultWorkspace.name,
|
|
336
|
+
sessionCount: 0,
|
|
337
|
+
lastUsedAt: 0
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
return [...projects.values()].sort((left, right) => right.lastUsedAt - left.lastUsedAt);
|
|
341
|
+
}
|
|
342
|
+
async dispose() {
|
|
343
|
+
const sessions = [...this.hot.values()];
|
|
344
|
+
this.hot.clear();
|
|
345
|
+
this.recency.length = 0;
|
|
346
|
+
this.listeners.clear();
|
|
347
|
+
this.activeKey = void 0;
|
|
348
|
+
this.draftCache = void 0;
|
|
349
|
+
this.draftProbe = void 0;
|
|
350
|
+
await Promise.all(sessions.map((session) => {
|
|
351
|
+
session.detach();
|
|
352
|
+
return session.gateway.dispose().catch((error) => {
|
|
353
|
+
this.deps.logger.warn("Failed to dispose an agent gateway", error);
|
|
354
|
+
});
|
|
355
|
+
}));
|
|
356
|
+
}
|
|
357
|
+
absorb(key, event) {
|
|
358
|
+
const session = this.hot.get(key);
|
|
359
|
+
if (session && (event.type === "agent/ready" || event.type === "agent/state")) {
|
|
360
|
+
session.state = event.state;
|
|
361
|
+
}
|
|
362
|
+
return event;
|
|
363
|
+
}
|
|
364
|
+
emit(event) {
|
|
365
|
+
for (const listener of [...this.listeners]) {
|
|
366
|
+
try {
|
|
367
|
+
listener(event);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
this.deps.logger.error("Agent event listener failed", error);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
touch(key) {
|
|
374
|
+
const index = this.recency.indexOf(key);
|
|
375
|
+
if (index >= 0) {
|
|
376
|
+
this.recency.splice(index, 1);
|
|
377
|
+
}
|
|
378
|
+
this.recency.push(key);
|
|
379
|
+
}
|
|
380
|
+
forget(key) {
|
|
381
|
+
const index = this.recency.indexOf(key);
|
|
382
|
+
if (index >= 0) {
|
|
383
|
+
this.recency.splice(index, 1);
|
|
384
|
+
}
|
|
385
|
+
this.hot.delete(key);
|
|
386
|
+
if (this.activeKey === key) {
|
|
387
|
+
this.activeKey = void 0;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Keeps at most `hotLimit` processes alive, retiring the coldest one that is
|
|
392
|
+
* not working. A running session is never killed: when every candidate is
|
|
393
|
+
* mid-run the registry goes over the limit instead, because dropping a
|
|
394
|
+
* parallel conversation is worse than a few extra processes.
|
|
395
|
+
*/
|
|
396
|
+
async evictBeyondLimit() {
|
|
397
|
+
while (this.recency.length > this.hotLimit) {
|
|
398
|
+
const coldest = this.recency.find((key) => key !== this.activeKey && !this.isWorking(key));
|
|
399
|
+
if (coldest === void 0) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
this.deps.logger.info(`Retiring idle session ${coldest} (hot limit ${this.hotLimit})`);
|
|
403
|
+
await this.close(coldest);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/** A session the agent is answering in is off limits for the LRU. */
|
|
407
|
+
isWorking(key) {
|
|
408
|
+
return this.hot.get(key)?.state.streaming === true;
|
|
409
|
+
}
|
|
410
|
+
nextSyntheticKey(workspace) {
|
|
411
|
+
this.syntheticKeys += 1;
|
|
412
|
+
return `${workspace.cwd}#ephemeral-${this.syntheticKeys}`;
|
|
413
|
+
}
|
|
414
|
+
/** Asks the factory for a never-recording gateway and snapshots its state. */
|
|
415
|
+
async probeDraft() {
|
|
416
|
+
const factory = this.deps.factory.probeDefaults;
|
|
417
|
+
if (!factory) {
|
|
418
|
+
return void 0;
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
const gateway = await factory.call(this.deps.factory, { workspace: this.deps.defaultWorkspace });
|
|
422
|
+
const state = await gateway.state();
|
|
423
|
+
await gateway.dispose();
|
|
424
|
+
return state;
|
|
425
|
+
} catch (error) {
|
|
426
|
+
this.deps.logger.warn("Could not probe the draft model catalog", error);
|
|
427
|
+
return void 0;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
function baseName(path) {
|
|
432
|
+
const parts = path.split(/[\\/]/).filter((part) => part.length > 0);
|
|
433
|
+
return parts.at(-1) ?? path;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ../core/dist/chat/service.js
|
|
437
|
+
var ChatService = class {
|
|
438
|
+
deps;
|
|
439
|
+
constructor(deps) {
|
|
440
|
+
this.deps = deps;
|
|
441
|
+
}
|
|
442
|
+
async prompt(text, mode = "new", images, pins) {
|
|
443
|
+
const trimmed = text.trim();
|
|
444
|
+
const hasAttachments = images !== void 0 && images.length > 0 || pins !== void 0 && pins.length > 0;
|
|
445
|
+
if (trimmed.length === 0 && !hasAttachments) {
|
|
446
|
+
this.notice("warn", "Nothing to send \u2014 the prompt is empty.");
|
|
447
|
+
return { accepted: false, reason: "empty-prompt" };
|
|
448
|
+
}
|
|
449
|
+
let agent;
|
|
450
|
+
try {
|
|
451
|
+
agent = this.deps.agent.requireActive();
|
|
452
|
+
} catch (error) {
|
|
453
|
+
this.deps.logger.error("Prompt rejected: no active agent session", error);
|
|
454
|
+
return { accepted: false, reason: "no-session" };
|
|
455
|
+
}
|
|
456
|
+
let effectiveMode = mode;
|
|
457
|
+
const state = await agent.state();
|
|
458
|
+
if (state.streaming && effectiveMode === "new") {
|
|
459
|
+
effectiveMode = "followUp";
|
|
460
|
+
this.notice("info", "The agent is still working \u2014 queued as a follow-up.");
|
|
461
|
+
}
|
|
462
|
+
const disposition = await agent.prompt(trimmed, effectiveMode, images, pins);
|
|
463
|
+
return { accepted: true, disposition };
|
|
464
|
+
}
|
|
465
|
+
async abort() {
|
|
466
|
+
await this.deps.agent.requireActive().abort();
|
|
467
|
+
}
|
|
468
|
+
async setModel(model) {
|
|
469
|
+
await this.deps.agent.requireActive().setModel(model);
|
|
470
|
+
}
|
|
471
|
+
async setThinkingLevel(level) {
|
|
472
|
+
await this.deps.agent.requireActive().setThinkingLevel(level);
|
|
473
|
+
}
|
|
474
|
+
/** Shrinks the conversation context (`/compact` in the TUI). */
|
|
475
|
+
async compact(customInstructions) {
|
|
476
|
+
await this.deps.agent.requireActive().compact(customInstructions);
|
|
477
|
+
}
|
|
478
|
+
async state() {
|
|
479
|
+
return this.deps.agent.requireActive().state();
|
|
480
|
+
}
|
|
481
|
+
notice(level, text) {
|
|
482
|
+
this.deps.onNotice?.(level, text);
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
// ../adapter-pi-rpc/dist/internal/rpc-types.js
|
|
487
|
+
function asRecord(value) {
|
|
488
|
+
return typeof value === "object" && value !== null ? value : void 0;
|
|
489
|
+
}
|
|
490
|
+
function asString(value) {
|
|
491
|
+
return typeof value === "string" ? value : void 0;
|
|
492
|
+
}
|
|
493
|
+
function asNumber(value) {
|
|
494
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
495
|
+
}
|
|
496
|
+
function asArray(value) {
|
|
497
|
+
return Array.isArray(value) ? value : void 0;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ../adapter-pi-rpc/dist/event-mapping.js
|
|
501
|
+
function mapSessionEvent(record, now) {
|
|
502
|
+
switch (record.type) {
|
|
503
|
+
case "agent_start":
|
|
504
|
+
return { events: [{ type: "agent/run-start", at: now() }], state: { streaming: true } };
|
|
505
|
+
case "message_update":
|
|
506
|
+
return mapMessageUpdate(record, now);
|
|
507
|
+
case "message_end":
|
|
508
|
+
return mapMessageEnd(record, now);
|
|
509
|
+
case "tool_execution_start":
|
|
510
|
+
return mapToolStart(record, now);
|
|
511
|
+
case "tool_execution_update":
|
|
512
|
+
return mapToolUpdate(record, now);
|
|
513
|
+
case "tool_execution_end":
|
|
514
|
+
return mapToolEnd(record, now);
|
|
515
|
+
case "compaction_start":
|
|
516
|
+
return { events: [notice("info", "Compacting the conversation context\u2026", now)] };
|
|
517
|
+
case "compaction_end":
|
|
518
|
+
return mapCompactionEnd(record, now);
|
|
519
|
+
case "auto_retry_start": {
|
|
520
|
+
const attempt = numberField(record, "attempt");
|
|
521
|
+
const max = numberField(record, "maxAttempts");
|
|
522
|
+
const reason = stringField(record, "errorMessage");
|
|
523
|
+
const label = attempt && max ? ` (attempt ${attempt}/${max})` : "";
|
|
524
|
+
return { events: [notice("warn", `Retrying${label}: ${reason ?? "provider error"}`, now)] };
|
|
525
|
+
}
|
|
526
|
+
case "auto_retry_end":
|
|
527
|
+
return record["success"] === true ? { events: [] } : {
|
|
528
|
+
events: [
|
|
529
|
+
notice("error", stringField(record, "finalError") ?? "Retries exhausted.", now)
|
|
530
|
+
]
|
|
531
|
+
};
|
|
532
|
+
case "extension_error":
|
|
533
|
+
return {
|
|
534
|
+
events: [
|
|
535
|
+
notice("error", `pi extension failed: ${stringField(record, "error") ?? "unknown error"}`, now)
|
|
536
|
+
]
|
|
537
|
+
};
|
|
538
|
+
default:
|
|
539
|
+
return { events: [] };
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
function toEntryHistory(entries) {
|
|
543
|
+
const history = [];
|
|
544
|
+
const toolEntries = /* @__PURE__ */ new Map();
|
|
545
|
+
for (const raw of entries) {
|
|
546
|
+
const entry = asRecord(raw);
|
|
547
|
+
if (!entry) {
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
switch (asString(entry["type"])) {
|
|
551
|
+
case "message": {
|
|
552
|
+
const message = asRecord(entry["message"]);
|
|
553
|
+
if (message) {
|
|
554
|
+
collectMessage(message, history, toolEntries);
|
|
555
|
+
}
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
case "compaction": {
|
|
559
|
+
const at = entryTimestamp(entry["timestamp"]);
|
|
560
|
+
history.push({
|
|
561
|
+
role: "compaction",
|
|
562
|
+
summary: asString(entry["summary"]) ?? "",
|
|
563
|
+
tokensBefore: asNumber(entry["tokensBefore"]),
|
|
564
|
+
...at !== void 0 ? { at } : {}
|
|
565
|
+
});
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
case "branch_summary": {
|
|
569
|
+
const summary = asString(entry["summary"]);
|
|
570
|
+
if (!summary) {
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
const at = entryTimestamp(entry["timestamp"]);
|
|
574
|
+
history.push({
|
|
575
|
+
role: "user",
|
|
576
|
+
text: `*The conversation briefly explored another branch and returned with this summary:*
|
|
577
|
+
|
|
578
|
+
${summary}`,
|
|
579
|
+
...at !== void 0 ? { at } : {}
|
|
580
|
+
});
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
default:
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return history;
|
|
588
|
+
}
|
|
589
|
+
function activePathEntries(data) {
|
|
590
|
+
const records = (data?.entries ?? []).map(asRecord).filter(isDefined);
|
|
591
|
+
const leafId = asString(data?.leafId);
|
|
592
|
+
if (!leafId) {
|
|
593
|
+
return records;
|
|
594
|
+
}
|
|
595
|
+
const byId = new Map(records.map((record) => [asString(record["id"]), record]).filter((pair) => pair[0] !== void 0));
|
|
596
|
+
if (!byId.has(leafId)) {
|
|
597
|
+
return records;
|
|
598
|
+
}
|
|
599
|
+
const keep = /* @__PURE__ */ new Set([leafId]);
|
|
600
|
+
let cursor = leafId;
|
|
601
|
+
while (cursor !== null) {
|
|
602
|
+
const parent = byId.get(cursor);
|
|
603
|
+
const parentId = parent ? asString(parent["parentId"]) ?? null : null;
|
|
604
|
+
if (parentId === null || keep.has(parentId)) {
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
keep.add(parentId);
|
|
608
|
+
cursor = parentId;
|
|
609
|
+
}
|
|
610
|
+
return records.filter((record) => keep.has(asString(record["id"]) ?? ""));
|
|
611
|
+
}
|
|
612
|
+
function collectMessage(message, entries, toolEntries) {
|
|
613
|
+
const role = asString(message["role"]);
|
|
614
|
+
if (role === "toolResult") {
|
|
615
|
+
const toolCallId = asString(message["toolCallId"]);
|
|
616
|
+
const entry = toolCallId ? toolEntries.get(toolCallId) : void 0;
|
|
617
|
+
if (entry) {
|
|
618
|
+
const output = contentToText(message["content"]);
|
|
619
|
+
if (output.length > 0) {
|
|
620
|
+
entry.output = output;
|
|
621
|
+
}
|
|
622
|
+
entry.status = message["isError"] === true ? "error" : "ok";
|
|
623
|
+
}
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (role !== "user" && role !== "assistant") {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
let text = contentToText(message["content"]);
|
|
630
|
+
const thinking = contentToThinking(message["content"]);
|
|
631
|
+
let pins;
|
|
632
|
+
const images = role === "user" ? contentToImages(message["content"]) : [];
|
|
633
|
+
if (role === "user") {
|
|
634
|
+
const split = splitTrailingMentions(text);
|
|
635
|
+
text = split.text;
|
|
636
|
+
pins = split.pins.length > 0 ? split.pins : void 0;
|
|
637
|
+
}
|
|
638
|
+
if (text.length > 0 || thinking.length > 0 || pins !== void 0 && pins.length > 0 || images.length > 0) {
|
|
639
|
+
entries.push({
|
|
640
|
+
role,
|
|
641
|
+
text,
|
|
642
|
+
thinking: thinking.length > 0 ? thinking : void 0,
|
|
643
|
+
...images.length > 0 ? { images } : {},
|
|
644
|
+
...pins !== void 0 ? { pins } : {},
|
|
645
|
+
model: asString(message["model"]),
|
|
646
|
+
at: asNumber(message["timestamp"])
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
if (role === "assistant") {
|
|
650
|
+
for (const call of toolCallsOf(message["content"])) {
|
|
651
|
+
const entry = {
|
|
652
|
+
role: "tool",
|
|
653
|
+
toolCallId: call.id,
|
|
654
|
+
name: call.name,
|
|
655
|
+
title: describeToolCall(call.name, call.arguments),
|
|
656
|
+
input: safeStringify(call.arguments),
|
|
657
|
+
status: "ok",
|
|
658
|
+
at: asNumber(message["timestamp"])
|
|
659
|
+
};
|
|
660
|
+
toolEntries.set(call.id, entry);
|
|
661
|
+
entries.push(entry);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
function toolCallsOf(content) {
|
|
666
|
+
const blocks = asArray(content);
|
|
667
|
+
if (!blocks) {
|
|
668
|
+
return [];
|
|
669
|
+
}
|
|
670
|
+
const calls = [];
|
|
671
|
+
for (const block of blocks) {
|
|
672
|
+
const record = asRecord(block);
|
|
673
|
+
if (!record || asString(record["type"]) !== "toolCall") {
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
const name = asString(record["name"]);
|
|
677
|
+
if (!name) {
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
calls.push({
|
|
681
|
+
id: asString(record["id"]) ?? `call-${calls.length}`,
|
|
682
|
+
name,
|
|
683
|
+
arguments: asRecord(record["arguments"])
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
return calls;
|
|
687
|
+
}
|
|
688
|
+
function mapMessageUpdate(record, now) {
|
|
689
|
+
const assistant = asRecord(record["assistantMessageEvent"]);
|
|
690
|
+
if (!assistant) {
|
|
691
|
+
return { events: [] };
|
|
692
|
+
}
|
|
693
|
+
if (assistant.type === "text_delta" && assistant.delta) {
|
|
694
|
+
return {
|
|
695
|
+
events: [{ type: "agent/delta", at: now(), channel: "text", delta: assistant.delta }]
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
if (assistant.type === "thinking_delta" && assistant.delta) {
|
|
699
|
+
return {
|
|
700
|
+
events: [{ type: "agent/delta", at: now(), channel: "thinking", delta: assistant.delta }]
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
return { events: [] };
|
|
704
|
+
}
|
|
705
|
+
function mapMessageEnd(record, now) {
|
|
706
|
+
const message = asRecord(record["message"]);
|
|
707
|
+
if (!message || message.role !== "assistant") {
|
|
708
|
+
return { events: [] };
|
|
709
|
+
}
|
|
710
|
+
const events = [];
|
|
711
|
+
const state = {};
|
|
712
|
+
const usage = toUsage(message.usage);
|
|
713
|
+
if (usage) {
|
|
714
|
+
state.lastUsage = usage;
|
|
715
|
+
}
|
|
716
|
+
const text = contentToText(message.content);
|
|
717
|
+
const thinking = contentToThinking(message.content);
|
|
718
|
+
if (text.length > 0 || thinking.length > 0) {
|
|
719
|
+
events.push({ type: "agent/message", at: now(), text, thinking });
|
|
720
|
+
}
|
|
721
|
+
if (message.stopReason === "aborted") {
|
|
722
|
+
state.streaming = false;
|
|
723
|
+
events.push({ type: "agent/run-end", at: now(), reason: "aborted" });
|
|
724
|
+
} else if (message.stopReason === "error") {
|
|
725
|
+
events.push(notice("error", "The model request failed.", now));
|
|
726
|
+
}
|
|
727
|
+
return { events, state };
|
|
728
|
+
}
|
|
729
|
+
function mapToolStart(record, now) {
|
|
730
|
+
const toolCallId = stringField(record, "toolCallId") ?? `tool-${now()}`;
|
|
731
|
+
const toolName = stringField(record, "toolName") ?? "tool";
|
|
732
|
+
const args = asRecord(record["args"]);
|
|
733
|
+
return {
|
|
734
|
+
events: [
|
|
735
|
+
{
|
|
736
|
+
type: "agent/tool-start",
|
|
737
|
+
at: now(),
|
|
738
|
+
toolCallId,
|
|
739
|
+
name: toolName,
|
|
740
|
+
title: describeToolCall(toolName, args),
|
|
741
|
+
input: safeStringify(record["args"])
|
|
742
|
+
}
|
|
743
|
+
]
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
function mapToolUpdate(record, now) {
|
|
747
|
+
const toolCallId = stringField(record, "toolCallId");
|
|
748
|
+
if (!toolCallId) {
|
|
749
|
+
return { events: [] };
|
|
750
|
+
}
|
|
751
|
+
return {
|
|
752
|
+
events: [
|
|
753
|
+
{
|
|
754
|
+
type: "agent/tool-update",
|
|
755
|
+
at: now(),
|
|
756
|
+
toolCallId,
|
|
757
|
+
output: resultToText(record["partialResult"]),
|
|
758
|
+
status: "running"
|
|
759
|
+
}
|
|
760
|
+
]
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
function mapToolEnd(record, now) {
|
|
764
|
+
const toolCallId = stringField(record, "toolCallId");
|
|
765
|
+
if (!toolCallId) {
|
|
766
|
+
return { events: [] };
|
|
767
|
+
}
|
|
768
|
+
return {
|
|
769
|
+
events: [
|
|
770
|
+
{
|
|
771
|
+
type: "agent/tool-end",
|
|
772
|
+
at: now(),
|
|
773
|
+
toolCallId,
|
|
774
|
+
status: record["isError"] === true ? "error" : "ok",
|
|
775
|
+
output: resultToText(record["result"])
|
|
776
|
+
}
|
|
777
|
+
]
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
function mapCompactionEnd(record, now) {
|
|
781
|
+
if (record["aborted"] === true) {
|
|
782
|
+
return { events: [notice("warn", "Compaction was aborted.", now)] };
|
|
783
|
+
}
|
|
784
|
+
const error = stringField(record, "errorMessage");
|
|
785
|
+
if (error) {
|
|
786
|
+
return { events: [notice("error", `Compaction failed: ${error}`, now)] };
|
|
787
|
+
}
|
|
788
|
+
const result = asRecord(record["result"]);
|
|
789
|
+
return {
|
|
790
|
+
events: [
|
|
791
|
+
{
|
|
792
|
+
type: "agent/compaction",
|
|
793
|
+
at: now(),
|
|
794
|
+
summary: asString(result?.["summary"]) ?? "",
|
|
795
|
+
tokensBefore: asNumber(result?.["tokensBefore"])
|
|
796
|
+
}
|
|
797
|
+
]
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
function notice(level, text, now) {
|
|
801
|
+
return { type: "agent/notice", at: now(), level, text };
|
|
802
|
+
}
|
|
803
|
+
function stringField(record, key) {
|
|
804
|
+
return asString(record[key]);
|
|
805
|
+
}
|
|
806
|
+
function numberField(record, key) {
|
|
807
|
+
const value = record[key];
|
|
808
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
809
|
+
}
|
|
810
|
+
function toUsage(usage) {
|
|
811
|
+
if (!usage) {
|
|
812
|
+
return void 0;
|
|
813
|
+
}
|
|
814
|
+
const input = usage.input ?? 0;
|
|
815
|
+
const output = usage.output ?? 0;
|
|
816
|
+
const cacheRead = usage.cacheRead ?? 0;
|
|
817
|
+
const cacheWrite = usage.cacheWrite ?? 0;
|
|
818
|
+
const reasoning = usage.reasoning ?? 0;
|
|
819
|
+
return {
|
|
820
|
+
inputTokens: input,
|
|
821
|
+
outputTokens: output,
|
|
822
|
+
totalTokens: usage.totalTokens ?? usage.total ?? input + output,
|
|
823
|
+
...cacheRead > 0 ? { cacheReadTokens: cacheRead } : {},
|
|
824
|
+
...cacheWrite > 0 ? { cacheWriteTokens: cacheWrite } : {},
|
|
825
|
+
...reasoning > 0 ? { reasoningTokens: reasoning } : {}
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
function toSessionStats(stats) {
|
|
829
|
+
if (!stats) {
|
|
830
|
+
return {};
|
|
831
|
+
}
|
|
832
|
+
const patch = {};
|
|
833
|
+
const usage = toUsage(stats.tokens);
|
|
834
|
+
if (usage) {
|
|
835
|
+
patch.usage = usage;
|
|
836
|
+
}
|
|
837
|
+
if (typeof stats.cost === "number" && Number.isFinite(stats.cost)) {
|
|
838
|
+
patch.costUsd = stats.cost;
|
|
839
|
+
}
|
|
840
|
+
const context = stats.contextUsage;
|
|
841
|
+
if (context && typeof context.contextWindow === "number") {
|
|
842
|
+
patch.contextUsage = {
|
|
843
|
+
tokens: typeof context.tokens === "number" ? context.tokens : null,
|
|
844
|
+
contextWindow: context.contextWindow,
|
|
845
|
+
percent: typeof context.percent === "number" ? context.percent : null
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
if (typeof stats.totalMessages === "number") {
|
|
849
|
+
patch.counts = {
|
|
850
|
+
userMessages: stats.userMessages ?? 0,
|
|
851
|
+
assistantMessages: stats.assistantMessages ?? 0,
|
|
852
|
+
toolCalls: stats.toolCalls ?? 0,
|
|
853
|
+
toolResults: stats.toolResults ?? 0,
|
|
854
|
+
totalMessages: stats.totalMessages
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
return patch;
|
|
858
|
+
}
|
|
859
|
+
function contentToText(content) {
|
|
860
|
+
if (typeof content === "string") {
|
|
861
|
+
return content;
|
|
862
|
+
}
|
|
863
|
+
const blocks = asArray(content);
|
|
864
|
+
if (!blocks) {
|
|
865
|
+
return "";
|
|
866
|
+
}
|
|
867
|
+
const parts = [];
|
|
868
|
+
for (const block of blocks) {
|
|
869
|
+
const record = asRecord(block);
|
|
870
|
+
if (!record || record["type"] !== "text") {
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
parts.push(asString(record["text"]) ?? "");
|
|
874
|
+
}
|
|
875
|
+
return parts.join("");
|
|
876
|
+
}
|
|
877
|
+
function contentToThinking(content) {
|
|
878
|
+
const blocks = asArray(content);
|
|
879
|
+
if (!blocks) {
|
|
880
|
+
return "";
|
|
881
|
+
}
|
|
882
|
+
const parts = [];
|
|
883
|
+
for (const block of blocks) {
|
|
884
|
+
const record = asRecord(block);
|
|
885
|
+
if (!record) {
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
const type = asString(record["type"]);
|
|
889
|
+
if (type !== "thinking" && type !== "reasoning") {
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
parts.push(asString(record["thinking"]) ?? asString(record["text"]) ?? "");
|
|
893
|
+
}
|
|
894
|
+
return parts.join("");
|
|
895
|
+
}
|
|
896
|
+
function contentToImages(content) {
|
|
897
|
+
const blocks = asArray(content);
|
|
898
|
+
if (!blocks) {
|
|
899
|
+
return [];
|
|
900
|
+
}
|
|
901
|
+
const images = [];
|
|
902
|
+
for (const block of blocks) {
|
|
903
|
+
const record = asRecord(block);
|
|
904
|
+
if (!record || asString(record["type"]) !== "image") {
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
const data = asString(record["data"]);
|
|
908
|
+
if (!data) {
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
images.push({ data, mimeType: asString(record["mimeType"]) ?? "image/png" });
|
|
912
|
+
}
|
|
913
|
+
return images;
|
|
914
|
+
}
|
|
915
|
+
var PIN_MENTION = /^@([^\s]+?)(?::(\d+)-(\d+))?$/;
|
|
916
|
+
function splitTrailingMentions(text) {
|
|
917
|
+
const lines = text.split("\n");
|
|
918
|
+
let cursor = lines.length;
|
|
919
|
+
const pins = [];
|
|
920
|
+
while (cursor > 0) {
|
|
921
|
+
const match = PIN_MENTION.exec(lines[cursor - 1] ?? "");
|
|
922
|
+
const record = match ? {
|
|
923
|
+
path: match[1] ?? "",
|
|
924
|
+
...match[2] !== void 0 ? { startLine: Number(match[2]) } : {},
|
|
925
|
+
...match[3] !== void 0 ? { endLine: Number(match[3]) } : {}
|
|
926
|
+
} : void 0;
|
|
927
|
+
if (!record || record.path.length === 0) {
|
|
928
|
+
break;
|
|
929
|
+
}
|
|
930
|
+
pins.unshift(record);
|
|
931
|
+
cursor -= 1;
|
|
932
|
+
}
|
|
933
|
+
if (cursor === lines.length) {
|
|
934
|
+
return { text, pins: [] };
|
|
935
|
+
}
|
|
936
|
+
let prose = lines.slice(0, cursor).join("\n");
|
|
937
|
+
while (prose.endsWith("\n")) {
|
|
938
|
+
prose = prose.slice(0, -1);
|
|
939
|
+
}
|
|
940
|
+
return { text: prose, pins };
|
|
941
|
+
}
|
|
942
|
+
function resultToText(result) {
|
|
943
|
+
if (result === void 0 || result === null) {
|
|
944
|
+
return void 0;
|
|
945
|
+
}
|
|
946
|
+
if (typeof result === "string") {
|
|
947
|
+
return result;
|
|
948
|
+
}
|
|
949
|
+
const record = asRecord(result);
|
|
950
|
+
if (!record) {
|
|
951
|
+
return safeStringify(result);
|
|
952
|
+
}
|
|
953
|
+
const text = contentToText(record["content"]);
|
|
954
|
+
return text.length > 0 ? text : safeStringify(record);
|
|
955
|
+
}
|
|
956
|
+
function describeToolCall(toolName, args) {
|
|
957
|
+
if (!args) {
|
|
958
|
+
return toolName;
|
|
959
|
+
}
|
|
960
|
+
const command = asString(args["command"]) ?? asString(args["cmd"]);
|
|
961
|
+
if (toolName === "bash" && command) {
|
|
962
|
+
return `bash: ${truncate(command, 100)}`;
|
|
963
|
+
}
|
|
964
|
+
const path = asString(args["path"]) ?? asString(args["file_path"]) ?? asString(args["filePath"]) ?? asString(args["file"]) ?? asString(args["target"]);
|
|
965
|
+
if (path) {
|
|
966
|
+
return `${toolName}: ${truncate(path, 100)}`;
|
|
967
|
+
}
|
|
968
|
+
const pattern = asString(args["pattern"]) ?? asString(args["query"]);
|
|
969
|
+
if (pattern) {
|
|
970
|
+
return `${toolName}: ${truncate(pattern, 100)}`;
|
|
971
|
+
}
|
|
972
|
+
return toolName;
|
|
973
|
+
}
|
|
974
|
+
function safeStringify(value) {
|
|
975
|
+
if (value === void 0) {
|
|
976
|
+
return void 0;
|
|
977
|
+
}
|
|
978
|
+
try {
|
|
979
|
+
return typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
980
|
+
} catch {
|
|
981
|
+
return String(value);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
function truncate(value, max) {
|
|
985
|
+
const single = value.replace(/\s+/g, " ").trim();
|
|
986
|
+
return single.length > max ? `${single.slice(0, max)}\u2026` : single;
|
|
987
|
+
}
|
|
988
|
+
function entryTimestamp(value) {
|
|
989
|
+
const ms = asNumber(value);
|
|
990
|
+
if (ms !== void 0) {
|
|
991
|
+
return ms;
|
|
992
|
+
}
|
|
993
|
+
const iso = asString(value);
|
|
994
|
+
if (iso) {
|
|
995
|
+
const parsed = Date.parse(iso);
|
|
996
|
+
if (Number.isFinite(parsed)) {
|
|
997
|
+
return parsed;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
return void 0;
|
|
1001
|
+
}
|
|
1002
|
+
function isDefined(value) {
|
|
1003
|
+
return value !== void 0;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// ../adapter-pi-rpc/dist/internal/rpc-client.js
|
|
1007
|
+
import { spawn } from "node:child_process";
|
|
1008
|
+
|
|
1009
|
+
// ../adapter-pi-rpc/dist/internal/jsonl-framer.js
|
|
1010
|
+
import { StringDecoder } from "node:string_decoder";
|
|
1011
|
+
var JsonlFramer = class {
|
|
1012
|
+
decoder = new StringDecoder("utf8");
|
|
1013
|
+
buffer = "";
|
|
1014
|
+
push(chunk) {
|
|
1015
|
+
this.buffer += typeof chunk === "string" ? chunk : this.decoder.write(chunk);
|
|
1016
|
+
const records = [];
|
|
1017
|
+
let newline = this.buffer.indexOf("\n");
|
|
1018
|
+
while (newline !== -1) {
|
|
1019
|
+
const line = stripCarriageReturn(this.buffer.slice(0, newline));
|
|
1020
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
1021
|
+
if (line.trim().length > 0) {
|
|
1022
|
+
records.push(line);
|
|
1023
|
+
}
|
|
1024
|
+
newline = this.buffer.indexOf("\n");
|
|
1025
|
+
}
|
|
1026
|
+
return records;
|
|
1027
|
+
}
|
|
1028
|
+
/** Returns a trailing record that never got its LF, if any. */
|
|
1029
|
+
flush() {
|
|
1030
|
+
const rest = stripCarriageReturn(this.buffer + this.decoder.end());
|
|
1031
|
+
this.buffer = "";
|
|
1032
|
+
return rest.trim().length > 0 ? rest : void 0;
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
function stripCarriageReturn(line) {
|
|
1036
|
+
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// ../adapter-pi-rpc/dist/internal/rpc-client.js
|
|
1040
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
1041
|
+
var SHUTDOWN_GRACE_MS = 2e3;
|
|
1042
|
+
var PiRpcClient = class {
|
|
1043
|
+
options;
|
|
1044
|
+
child;
|
|
1045
|
+
framer = new JsonlFramer();
|
|
1046
|
+
nextId = 1;
|
|
1047
|
+
pending = /* @__PURE__ */ new Map();
|
|
1048
|
+
failed;
|
|
1049
|
+
constructor(options) {
|
|
1050
|
+
this.options = options;
|
|
1051
|
+
}
|
|
1052
|
+
get running() {
|
|
1053
|
+
return this.child !== void 0;
|
|
1054
|
+
}
|
|
1055
|
+
start() {
|
|
1056
|
+
if (this.child) {
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
this.failed = void 0;
|
|
1060
|
+
const child = spawn(this.options.command, this.options.args, {
|
|
1061
|
+
cwd: this.options.cwd,
|
|
1062
|
+
env: this.options.env ?? process.env,
|
|
1063
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1064
|
+
windowsHide: true
|
|
1065
|
+
});
|
|
1066
|
+
this.child = child;
|
|
1067
|
+
child.stdout.on("data", (chunk) => this.onStdout(chunk));
|
|
1068
|
+
child.stderr.on("data", (chunk) => {
|
|
1069
|
+
this.options.onStderr?.(chunk.toString("utf8"));
|
|
1070
|
+
});
|
|
1071
|
+
child.on("error", (error) => {
|
|
1072
|
+
this.failAll(new AgentUnavailableError(`Failed to start the pi agent (${this.options.command}): ${error.message}`, { cause: error }));
|
|
1073
|
+
});
|
|
1074
|
+
child.on("exit", (code, signal) => {
|
|
1075
|
+
this.child = void 0;
|
|
1076
|
+
this.failAll(new AgentUnavailableError(`The pi agent exited (code=${code === null ? "null" : code}, signal=${signal ?? "null"}).`));
|
|
1077
|
+
this.options.onExit?.({ code, signal });
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
async request(command, timeoutMs = this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS) {
|
|
1081
|
+
if (!this.child) {
|
|
1082
|
+
throw this.failed ?? new AgentUnavailableError("The pi agent is not running.");
|
|
1083
|
+
}
|
|
1084
|
+
const id = `morse-${this.nextId++}`;
|
|
1085
|
+
return new Promise((resolve4, reject) => {
|
|
1086
|
+
const timer = setTimeout(() => {
|
|
1087
|
+
this.pending.delete(id);
|
|
1088
|
+
reject(new AgentProtocolError(`pi did not answer "${command.type}" within ${timeoutMs} ms.`));
|
|
1089
|
+
}, timeoutMs);
|
|
1090
|
+
this.pending.set(id, {
|
|
1091
|
+
command: command.type,
|
|
1092
|
+
resolve: (data) => resolve4(data),
|
|
1093
|
+
reject,
|
|
1094
|
+
timer
|
|
1095
|
+
});
|
|
1096
|
+
this.write({ id, ...command }, (error) => {
|
|
1097
|
+
this.settle(id, error);
|
|
1098
|
+
});
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
/** Answers an `extension_ui_request`; never expects a matching response. */
|
|
1102
|
+
respond(response) {
|
|
1103
|
+
this.write(response);
|
|
1104
|
+
}
|
|
1105
|
+
async dispose() {
|
|
1106
|
+
const child = this.child;
|
|
1107
|
+
if (!child) {
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
let exited = false;
|
|
1111
|
+
await new Promise((resolve4) => {
|
|
1112
|
+
const finish = () => {
|
|
1113
|
+
exited = true;
|
|
1114
|
+
resolve4();
|
|
1115
|
+
};
|
|
1116
|
+
child.once("exit", finish);
|
|
1117
|
+
child.stdin.end();
|
|
1118
|
+
setTimeout(resolve4, SHUTDOWN_GRACE_MS).unref?.();
|
|
1119
|
+
});
|
|
1120
|
+
if (!exited) {
|
|
1121
|
+
child.kill();
|
|
1122
|
+
}
|
|
1123
|
+
this.child = void 0;
|
|
1124
|
+
}
|
|
1125
|
+
onStdout(chunk) {
|
|
1126
|
+
for (const line of this.framer.push(chunk)) {
|
|
1127
|
+
let parsed;
|
|
1128
|
+
try {
|
|
1129
|
+
parsed = JSON.parse(line);
|
|
1130
|
+
} catch {
|
|
1131
|
+
this.options.onStderr?.(`[morse] unreadable pi record: ${line}
|
|
1132
|
+
`);
|
|
1133
|
+
continue;
|
|
1134
|
+
}
|
|
1135
|
+
this.handleRecord(parsed);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
handleRecord(parsed) {
|
|
1139
|
+
const record = parsed;
|
|
1140
|
+
if (record !== null && typeof record === "object" && record["type"] === "response") {
|
|
1141
|
+
this.handleResponse(record);
|
|
1142
|
+
}
|
|
1143
|
+
this.options.onRecord(record);
|
|
1144
|
+
}
|
|
1145
|
+
handleResponse(record) {
|
|
1146
|
+
const id = typeof record["id"] === "string" ? record["id"] : void 0;
|
|
1147
|
+
if (!id) {
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
const pending = this.pending.get(id);
|
|
1151
|
+
if (!pending) {
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
clearTimeout(pending.timer);
|
|
1155
|
+
this.pending.delete(id);
|
|
1156
|
+
if (record["success"] === true) {
|
|
1157
|
+
pending.resolve(record["data"]);
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
const error = typeof record["error"] === "string" ? record["error"] : "unknown error";
|
|
1161
|
+
pending.reject(new AgentProtocolError(`pi rejected "${pending.command}": ${error}`));
|
|
1162
|
+
}
|
|
1163
|
+
write(payload, onError) {
|
|
1164
|
+
const child = this.child;
|
|
1165
|
+
if (!child) {
|
|
1166
|
+
onError?.(new AgentUnavailableError("The pi agent is not running."));
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
const frame = `${JSON.stringify(payload)}
|
|
1170
|
+
`;
|
|
1171
|
+
child.stdin.write(frame, "utf8", (error) => {
|
|
1172
|
+
if (error) {
|
|
1173
|
+
onError?.(error);
|
|
1174
|
+
}
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
settle(id, error) {
|
|
1178
|
+
const pending = this.pending.get(id);
|
|
1179
|
+
if (!pending) {
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
clearTimeout(pending.timer);
|
|
1183
|
+
this.pending.delete(id);
|
|
1184
|
+
pending.reject(error);
|
|
1185
|
+
}
|
|
1186
|
+
failAll(error) {
|
|
1187
|
+
this.failed = error;
|
|
1188
|
+
for (const [id, pending] of [...this.pending]) {
|
|
1189
|
+
clearTimeout(pending.timer);
|
|
1190
|
+
this.pending.delete(id);
|
|
1191
|
+
pending.reject(error);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
};
|
|
1195
|
+
|
|
1196
|
+
// ../adapter-pi-rpc/dist/pi-rpc-agent.js
|
|
1197
|
+
var COMPACT_REQUEST_TIMEOUT_MS = 6e5;
|
|
1198
|
+
var PiRpcAgent = class {
|
|
1199
|
+
options;
|
|
1200
|
+
client;
|
|
1201
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1202
|
+
now;
|
|
1203
|
+
sessionState;
|
|
1204
|
+
ready = false;
|
|
1205
|
+
disposed = false;
|
|
1206
|
+
/** Full mapped history, fetched once and paged locally (see `history`). */
|
|
1207
|
+
historyCache;
|
|
1208
|
+
constructor(options) {
|
|
1209
|
+
this.options = options;
|
|
1210
|
+
this.now = options.now ?? (() => Date.now());
|
|
1211
|
+
this.sessionState = {
|
|
1212
|
+
workspace: options.workspace,
|
|
1213
|
+
thinkingLevel: "off",
|
|
1214
|
+
availableModels: [],
|
|
1215
|
+
availableThinkingLevels: [],
|
|
1216
|
+
availableCommands: [],
|
|
1217
|
+
streaming: false
|
|
1218
|
+
};
|
|
1219
|
+
this.client = new PiRpcClient({
|
|
1220
|
+
command: options.spawn.command,
|
|
1221
|
+
args: options.spawn.args,
|
|
1222
|
+
cwd: options.workspace.cwd,
|
|
1223
|
+
env: options.env,
|
|
1224
|
+
requestTimeoutMs: options.requestTimeoutMs,
|
|
1225
|
+
onRecord: (record) => {
|
|
1226
|
+
this.onRecord(record);
|
|
1227
|
+
},
|
|
1228
|
+
onStderr: (text) => {
|
|
1229
|
+
const trimmed = text.trim();
|
|
1230
|
+
if (trimmed.length > 0) {
|
|
1231
|
+
this.options.logger.debug(`pi: ${trimmed}`);
|
|
1232
|
+
}
|
|
1233
|
+
},
|
|
1234
|
+
onExit: (info) => {
|
|
1235
|
+
this.ready = false;
|
|
1236
|
+
if (!this.disposed) {
|
|
1237
|
+
this.emit({
|
|
1238
|
+
type: "agent/fatal",
|
|
1239
|
+
at: this.now(),
|
|
1240
|
+
message: `The pi agent stopped (code=${info.code === null ? "null" : info.code}).`
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
/** Spawns the subprocess and reads its initial state. Throws when pi is missing. */
|
|
1247
|
+
async initialize() {
|
|
1248
|
+
this.client.start();
|
|
1249
|
+
await this.refreshState();
|
|
1250
|
+
return this.snapshotState();
|
|
1251
|
+
}
|
|
1252
|
+
subscribe(listener) {
|
|
1253
|
+
this.listeners.add(listener);
|
|
1254
|
+
return () => {
|
|
1255
|
+
this.listeners.delete(listener);
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
async state() {
|
|
1259
|
+
if (!this.ready) {
|
|
1260
|
+
await this.refreshState();
|
|
1261
|
+
}
|
|
1262
|
+
return this.snapshotState();
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Resumed sessions keep their messages on disk; `get_messages` is how a client
|
|
1266
|
+
* shows what was said before this connection existed.
|
|
1267
|
+
*
|
|
1268
|
+
* pi's `get_messages` has no cursor, so the full mapping is fetched once and
|
|
1269
|
+
* cached; pages are then sliced locally from the end. The cache is dropped on
|
|
1270
|
+
* the next prompt, so a re-seed never serves stale history.
|
|
1271
|
+
*/
|
|
1272
|
+
async history(options = {}) {
|
|
1273
|
+
const entries = await this.historyEntries();
|
|
1274
|
+
const total = entries.length;
|
|
1275
|
+
const limit = options.limit !== void 0 && options.limit > 0 ? options.limit : total;
|
|
1276
|
+
const requested = options.before === void 0 ? total : Number(options.before);
|
|
1277
|
+
const end = Number.isFinite(requested) ? Math.max(0, Math.min(total, Math.trunc(requested))) : total;
|
|
1278
|
+
const start = Math.max(0, end - limit);
|
|
1279
|
+
return {
|
|
1280
|
+
entries: entries.slice(start, end),
|
|
1281
|
+
hasOlder: start > 0,
|
|
1282
|
+
...start > 0 ? { before: String(start) } : {}
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
async historyEntries() {
|
|
1286
|
+
if (this.historyCache !== void 0) {
|
|
1287
|
+
return this.historyCache;
|
|
1288
|
+
}
|
|
1289
|
+
const data = await this.client.request({
|
|
1290
|
+
type: "get_entries"
|
|
1291
|
+
});
|
|
1292
|
+
this.historyCache = toEntryHistory(activePathEntries(data));
|
|
1293
|
+
return this.historyCache;
|
|
1294
|
+
}
|
|
1295
|
+
async prompt(text, mode, images, pins) {
|
|
1296
|
+
const attachment = images && images.length > 0 ? toPiImages(images) : void 0;
|
|
1297
|
+
const imageArgs = attachment ? { images: attachment } : {};
|
|
1298
|
+
const message = appendPinMentions(text, pins);
|
|
1299
|
+
this.historyCache = void 0;
|
|
1300
|
+
const command = mode === "steer" ? { type: "steer", message, ...imageArgs } : mode === "followUp" ? { type: "follow_up", message, ...imageArgs } : { type: "prompt", message, ...imageArgs };
|
|
1301
|
+
const data = await this.client.request(command);
|
|
1302
|
+
const disposition = data?.disposition;
|
|
1303
|
+
if (disposition === "queued" || disposition === "handled") {
|
|
1304
|
+
return disposition;
|
|
1305
|
+
}
|
|
1306
|
+
return "started";
|
|
1307
|
+
}
|
|
1308
|
+
async abort() {
|
|
1309
|
+
await this.client.request({ type: "abort" });
|
|
1310
|
+
}
|
|
1311
|
+
/** Past user messages pi can fork from (its `get_fork_messages`). */
|
|
1312
|
+
async forkMessages() {
|
|
1313
|
+
const data = await this.client.request({
|
|
1314
|
+
type: "get_fork_messages"
|
|
1315
|
+
});
|
|
1316
|
+
const messages = Array.isArray(data?.messages) ? data.messages : [];
|
|
1317
|
+
const result = [];
|
|
1318
|
+
for (const raw of messages) {
|
|
1319
|
+
const record = asRecord(raw);
|
|
1320
|
+
const entryId = record ? asString(record["entryId"]) : void 0;
|
|
1321
|
+
if (!record || !entryId) {
|
|
1322
|
+
continue;
|
|
1323
|
+
}
|
|
1324
|
+
result.push({ entryId, text: asString(record["text"]) ?? "" });
|
|
1325
|
+
}
|
|
1326
|
+
return result;
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Forks the conversation before a user entry. pi writes a new session file and
|
|
1330
|
+
* rebinds in place, so the process stays alive but `sessionFile` changes —
|
|
1331
|
+
* the cached state is refreshed before returning.
|
|
1332
|
+
*/
|
|
1333
|
+
async fork(entryId) {
|
|
1334
|
+
const data = await this.client.request({
|
|
1335
|
+
type: "fork",
|
|
1336
|
+
entryId
|
|
1337
|
+
});
|
|
1338
|
+
this.historyCache = void 0;
|
|
1339
|
+
if (data?.cancelled === true) {
|
|
1340
|
+
return { text: data.text ?? "", cancelled: true };
|
|
1341
|
+
}
|
|
1342
|
+
await this.refreshState();
|
|
1343
|
+
return { text: data?.text ?? "", cancelled: false };
|
|
1344
|
+
}
|
|
1345
|
+
async setModel(model) {
|
|
1346
|
+
const data = await this.client.request({
|
|
1347
|
+
type: "set_model",
|
|
1348
|
+
provider: model.provider,
|
|
1349
|
+
modelId: model.id
|
|
1350
|
+
});
|
|
1351
|
+
this.patchState({ model: data ? toModelRef(data) : model });
|
|
1352
|
+
}
|
|
1353
|
+
async setThinkingLevel(level) {
|
|
1354
|
+
await this.client.request({ type: "set_thinking_level", level });
|
|
1355
|
+
this.patchState({ thinkingLevel: level });
|
|
1356
|
+
}
|
|
1357
|
+
async compact(customInstructions) {
|
|
1358
|
+
await this.client.request(customInstructions === void 0 ? { type: "compact" } : { type: "compact", customInstructions }, COMPACT_REQUEST_TIMEOUT_MS);
|
|
1359
|
+
await this.refreshStats();
|
|
1360
|
+
}
|
|
1361
|
+
respondToInteraction(response) {
|
|
1362
|
+
this.client.respond({
|
|
1363
|
+
type: "extension_ui_response",
|
|
1364
|
+
id: response.requestId,
|
|
1365
|
+
value: response.value,
|
|
1366
|
+
confirmed: response.confirmed,
|
|
1367
|
+
cancelled: response.cancelled
|
|
1368
|
+
});
|
|
1369
|
+
return Promise.resolve();
|
|
1370
|
+
}
|
|
1371
|
+
async dispose() {
|
|
1372
|
+
this.disposed = true;
|
|
1373
|
+
this.listeners.clear();
|
|
1374
|
+
await this.client.dispose();
|
|
1375
|
+
}
|
|
1376
|
+
async refreshState() {
|
|
1377
|
+
const state = await this.client.request({ type: "get_state" });
|
|
1378
|
+
const models = await this.client.request({ type: "get_available_models" }).catch(() => void 0);
|
|
1379
|
+
const levels = await this.client.request({ type: "get_available_thinking_levels" }).catch(() => void 0);
|
|
1380
|
+
const commands = await this.client.request({ type: "get_commands" }).catch(() => void 0);
|
|
1381
|
+
const availableModels = (models?.models ?? []).map(toModelRef);
|
|
1382
|
+
const availableThinkingLevels = dedupeThinkingLevels((levels?.levels ?? []).map(toThinkingLevel).filter(isDefined2));
|
|
1383
|
+
const thinkingLevel = toThinkingLevel(state.thinkingLevel);
|
|
1384
|
+
this.sessionState = {
|
|
1385
|
+
...this.sessionState,
|
|
1386
|
+
sessionId: state.sessionFile ?? state.sessionId,
|
|
1387
|
+
sessionTitle: state.sessionName,
|
|
1388
|
+
model: state.model ? toModelRef(state.model) : this.sessionState.model,
|
|
1389
|
+
thinkingLevel: thinkingLevel ?? this.sessionState.thinkingLevel,
|
|
1390
|
+
availableModels,
|
|
1391
|
+
availableThinkingLevels: availableThinkingLevels.length > 0 ? availableThinkingLevels : [...THINKING_LEVELS],
|
|
1392
|
+
availableCommands: toCommands(commands?.commands),
|
|
1393
|
+
streaming: state.isStreaming === true
|
|
1394
|
+
};
|
|
1395
|
+
this.ready = true;
|
|
1396
|
+
await this.refreshStats();
|
|
1397
|
+
this.emit({ type: "agent/ready", at: this.now(), state: this.snapshotState() });
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Pulls the cumulative token/cost/context totals pi keeps for the session, so
|
|
1401
|
+
* the footer can show the same numbers the TUI does. Best-effort: an older pi
|
|
1402
|
+
* without `get_session_stats` simply leaves the previous values in place.
|
|
1403
|
+
*/
|
|
1404
|
+
async refreshStats() {
|
|
1405
|
+
const stats = await this.client.request({ type: "get_session_stats" }).catch(() => void 0);
|
|
1406
|
+
const patch = toSessionStats(stats);
|
|
1407
|
+
if (Object.keys(patch).length === 0) {
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
this.sessionState = { ...this.sessionState, ...patch };
|
|
1411
|
+
this.emit({ type: "agent/state", at: this.now(), state: this.snapshotState() });
|
|
1412
|
+
}
|
|
1413
|
+
onRecord(record) {
|
|
1414
|
+
if (record["type"] === "extension_ui_request") {
|
|
1415
|
+
this.onUiRequest(record);
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
switch (record["type"]) {
|
|
1419
|
+
case "agent_settled":
|
|
1420
|
+
this.patchState({ streaming: false });
|
|
1421
|
+
this.emit({ type: "agent/run-end", at: this.now(), reason: "settled" });
|
|
1422
|
+
void this.refreshStats();
|
|
1423
|
+
return;
|
|
1424
|
+
case "thinking_level_changed": {
|
|
1425
|
+
const level = toThinkingLevel(asString(record["level"]));
|
|
1426
|
+
if (level) {
|
|
1427
|
+
this.patchState({ thinkingLevel: level });
|
|
1428
|
+
}
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
case "session_info_changed":
|
|
1432
|
+
this.patchState({ sessionTitle: asString(record["name"]) });
|
|
1433
|
+
return;
|
|
1434
|
+
case "queue_update":
|
|
1435
|
+
return;
|
|
1436
|
+
default:
|
|
1437
|
+
break;
|
|
1438
|
+
}
|
|
1439
|
+
const mapped = mapSessionEvent(record, this.now);
|
|
1440
|
+
if (mapped.state) {
|
|
1441
|
+
this.patchState(mapped.state);
|
|
1442
|
+
}
|
|
1443
|
+
for (const event of mapped.events) {
|
|
1444
|
+
this.emit(event);
|
|
1445
|
+
}
|
|
1446
|
+
if (record["type"] === "compaction_end") {
|
|
1447
|
+
void this.refreshStats();
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
onUiRequest(request) {
|
|
1451
|
+
const method = request.method;
|
|
1452
|
+
if (method === "notify") {
|
|
1453
|
+
this.emit({
|
|
1454
|
+
type: "agent/notice",
|
|
1455
|
+
at: this.now(),
|
|
1456
|
+
level: toNoticeLevel(request.notifyType),
|
|
1457
|
+
text: request.message ?? ""
|
|
1458
|
+
});
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
if (method !== "select" && method !== "confirm" && method !== "input" && method !== "editor") {
|
|
1462
|
+
this.options.logger.debug(`pi requested unsupported UI method "${method}"; ignoring.`);
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
this.emit({
|
|
1466
|
+
type: "agent/interaction",
|
|
1467
|
+
at: this.now(),
|
|
1468
|
+
request: toInteractionRequest(method, request)
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
patchState(patch) {
|
|
1472
|
+
this.sessionState = { ...this.sessionState, ...patch };
|
|
1473
|
+
this.emit({ type: "agent/state", at: this.now(), state: this.snapshotState() });
|
|
1474
|
+
}
|
|
1475
|
+
snapshotState() {
|
|
1476
|
+
return {
|
|
1477
|
+
...this.sessionState,
|
|
1478
|
+
workspace: { ...this.sessionState.workspace },
|
|
1479
|
+
availableModels: [...this.sessionState.availableModels],
|
|
1480
|
+
availableThinkingLevels: [...this.sessionState.availableThinkingLevels],
|
|
1481
|
+
availableCommands: [...this.sessionState.availableCommands]
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
emit(event) {
|
|
1485
|
+
for (const listener of [...this.listeners]) {
|
|
1486
|
+
try {
|
|
1487
|
+
listener(event);
|
|
1488
|
+
} catch (error) {
|
|
1489
|
+
this.options.logger.error("Agent event listener failed", error);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
};
|
|
1494
|
+
function toInteractionRequest(kind, request) {
|
|
1495
|
+
const title = request.title ?? kind;
|
|
1496
|
+
switch (kind) {
|
|
1497
|
+
case "select":
|
|
1498
|
+
return {
|
|
1499
|
+
requestId: request.id,
|
|
1500
|
+
kind: "select",
|
|
1501
|
+
title,
|
|
1502
|
+
options: (request.options ?? []).map((value) => ({ value, label: value }))
|
|
1503
|
+
};
|
|
1504
|
+
case "confirm":
|
|
1505
|
+
return { requestId: request.id, kind: "confirm", title, message: request.message ?? "" };
|
|
1506
|
+
case "input":
|
|
1507
|
+
return {
|
|
1508
|
+
requestId: request.id,
|
|
1509
|
+
kind: "input",
|
|
1510
|
+
title,
|
|
1511
|
+
placeholder: request.placeholder
|
|
1512
|
+
};
|
|
1513
|
+
case "editor":
|
|
1514
|
+
return { requestId: request.id, kind: "editor", title, value: request.prefill };
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
function toModelRef(model) {
|
|
1518
|
+
return {
|
|
1519
|
+
provider: model.provider,
|
|
1520
|
+
id: model.id,
|
|
1521
|
+
name: model.name ?? model.id,
|
|
1522
|
+
contextWindow: model.contextWindow,
|
|
1523
|
+
maxTokens: model.maxTokens
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
function toThinkingLevel(value) {
|
|
1527
|
+
if (!value) {
|
|
1528
|
+
return void 0;
|
|
1529
|
+
}
|
|
1530
|
+
return THINKING_LEVELS.find((level) => level === value);
|
|
1531
|
+
}
|
|
1532
|
+
function dedupeThinkingLevels(levels) {
|
|
1533
|
+
return [...new Set(levels)];
|
|
1534
|
+
}
|
|
1535
|
+
function toCommandSource(value) {
|
|
1536
|
+
return value === "prompt" || value === "skill" ? value : "extension";
|
|
1537
|
+
}
|
|
1538
|
+
function toCommands(items) {
|
|
1539
|
+
return (items ?? []).filter((item) => {
|
|
1540
|
+
return typeof item.name === "string" && item.name.trim().length > 0;
|
|
1541
|
+
}).map((item) => ({
|
|
1542
|
+
name: item.name,
|
|
1543
|
+
description: item.description,
|
|
1544
|
+
source: toCommandSource(item.source)
|
|
1545
|
+
}));
|
|
1546
|
+
}
|
|
1547
|
+
function toNoticeLevel(value) {
|
|
1548
|
+
if (value === "error") {
|
|
1549
|
+
return "error";
|
|
1550
|
+
}
|
|
1551
|
+
if (value === "warning" || value === "warn") {
|
|
1552
|
+
return "warn";
|
|
1553
|
+
}
|
|
1554
|
+
return "info";
|
|
1555
|
+
}
|
|
1556
|
+
function isDefined2(value) {
|
|
1557
|
+
return value !== void 0;
|
|
1558
|
+
}
|
|
1559
|
+
function toPiImages(images) {
|
|
1560
|
+
return images.map((image) => ({ type: "image", data: image.data, mimeType: image.mimeType }));
|
|
1561
|
+
}
|
|
1562
|
+
function appendPinMentions(text, pins) {
|
|
1563
|
+
if (!pins || pins.length === 0) {
|
|
1564
|
+
return text;
|
|
1565
|
+
}
|
|
1566
|
+
const mentions = pins.map((pin) => {
|
|
1567
|
+
if (pin.startLine === void 0) {
|
|
1568
|
+
return `@${pin.path}`;
|
|
1569
|
+
}
|
|
1570
|
+
const end = pin.endLine ?? pin.startLine;
|
|
1571
|
+
return `@${pin.path}:${pin.startLine}-${end}`;
|
|
1572
|
+
}).join("\n");
|
|
1573
|
+
const prose = text.trim();
|
|
1574
|
+
return prose.length > 0 ? `${prose}
|
|
1575
|
+
|
|
1576
|
+
${mentions}` : mentions;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// ../adapter-pi-rpc/dist/internal/resolve-pi.js
|
|
1580
|
+
import { accessSync, constants } from "node:fs";
|
|
1581
|
+
import { homedir } from "node:os";
|
|
1582
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
1583
|
+
function buildRpcArgs(options) {
|
|
1584
|
+
const args = ["--mode", "rpc"];
|
|
1585
|
+
if (options.noSession) {
|
|
1586
|
+
args.push("--no-session");
|
|
1587
|
+
}
|
|
1588
|
+
if (options.sessionDir) {
|
|
1589
|
+
args.push("--session-dir", options.sessionDir);
|
|
1590
|
+
}
|
|
1591
|
+
if (options.sessionPath) {
|
|
1592
|
+
args.push("--session", options.sessionPath);
|
|
1593
|
+
}
|
|
1594
|
+
args.push(...options.extraArgs ?? []);
|
|
1595
|
+
return args;
|
|
1596
|
+
}
|
|
1597
|
+
function resolvePi(options = {}) {
|
|
1598
|
+
const env = options.env ?? process.env;
|
|
1599
|
+
const args = buildRpcArgs(options);
|
|
1600
|
+
const configured = options.piPath ?? env.MORSE_PI_PATH;
|
|
1601
|
+
if (configured) {
|
|
1602
|
+
return { command: configured, args, source: "configured" };
|
|
1603
|
+
}
|
|
1604
|
+
const entry = options.nodeEntryPath ?? env.MORSE_PI_ENTRY;
|
|
1605
|
+
if (entry && isExecutableEntry(entry)) {
|
|
1606
|
+
return { command: process.execPath, args: [entry, ...args], source: "node-entry" };
|
|
1607
|
+
}
|
|
1608
|
+
const onPath = findOnPath("pi", env);
|
|
1609
|
+
if (onPath) {
|
|
1610
|
+
return { command: onPath, args, source: "path" };
|
|
1611
|
+
}
|
|
1612
|
+
throw new AgentUnavailableError([
|
|
1613
|
+
"The pi coding agent was not found.",
|
|
1614
|
+
"Install it (`npm install -g @earendil-works/pi-coding-agent`) or point Morse at it:",
|
|
1615
|
+
'- VS Code: setting "morse.pi.path"',
|
|
1616
|
+
"- NestJS host: env MORSE_PI_PATH (or MORSE_PI_ENTRY for a bundled rpc-entry.js)"
|
|
1617
|
+
].join("\n"));
|
|
1618
|
+
}
|
|
1619
|
+
function findOnPath(binary, env = process.env) {
|
|
1620
|
+
const pathValue = env.PATH ?? env.Path ?? "";
|
|
1621
|
+
if (pathValue.length === 0) {
|
|
1622
|
+
return void 0;
|
|
1623
|
+
}
|
|
1624
|
+
const extensions = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").filter((value) => value.length > 0) : [""];
|
|
1625
|
+
for (const directory of pathValue.split(delimiter)) {
|
|
1626
|
+
if (directory.length === 0) {
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
for (const extension of extensions) {
|
|
1630
|
+
const candidate = join(directory, `${binary}${extension}`);
|
|
1631
|
+
if (isExecutableEntry(candidate)) {
|
|
1632
|
+
return candidate;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
return void 0;
|
|
1637
|
+
}
|
|
1638
|
+
function resolveSessionDir(configured, env = process.env) {
|
|
1639
|
+
const fromEnv = env.PI_CODING_AGENT_SESSION_DIR;
|
|
1640
|
+
const value = configured ?? (fromEnv && fromEnv.length > 0 ? fromEnv : void 0);
|
|
1641
|
+
const base = value ?? join(homedir(), ".pi", "agent", "sessions");
|
|
1642
|
+
return isAbsolute(base) ? base : join(process.cwd(), base);
|
|
1643
|
+
}
|
|
1644
|
+
function isExecutableEntry(path) {
|
|
1645
|
+
try {
|
|
1646
|
+
accessSync(path, constants.R_OK);
|
|
1647
|
+
return true;
|
|
1648
|
+
} catch {
|
|
1649
|
+
return false;
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// ../adapter-pi-rpc/dist/pi-rpc-agent-factory.js
|
|
1654
|
+
var PiRpcAgentFactory = class {
|
|
1655
|
+
options;
|
|
1656
|
+
logger;
|
|
1657
|
+
constructor(options, logger) {
|
|
1658
|
+
this.options = options;
|
|
1659
|
+
this.logger = logger;
|
|
1660
|
+
}
|
|
1661
|
+
async create(input) {
|
|
1662
|
+
const resolveOptions = {
|
|
1663
|
+
piPath: this.options.piPath,
|
|
1664
|
+
nodeEntryPath: this.options.nodeEntryPath,
|
|
1665
|
+
sessionDir: this.options.sessionDir,
|
|
1666
|
+
noSession: this.options.noSession,
|
|
1667
|
+
// The catalog hands back session file paths as ids, which is what
|
|
1668
|
+
// `pi --session <path>` expects.
|
|
1669
|
+
sessionPath: input.sessionId,
|
|
1670
|
+
extraArgs: this.options.extraArgs,
|
|
1671
|
+
env: this.options.env
|
|
1672
|
+
};
|
|
1673
|
+
return this.spawnAgent(resolveOptions, input.workspace, input.sessionId);
|
|
1674
|
+
}
|
|
1675
|
+
/**
|
|
1676
|
+
* A gateway that never records: pi is forced onto `--no-session` and no
|
|
1677
|
+
* session path is passed, so this spawn cannot touch the session list on
|
|
1678
|
+
* disk. Its only job is to answer state once — model catalog, thinking
|
|
1679
|
+
* levels, commands — for the draft (empty panel) state.
|
|
1680
|
+
*/
|
|
1681
|
+
async probeDefaults(input) {
|
|
1682
|
+
const resolveOptions = {
|
|
1683
|
+
piPath: this.options.piPath,
|
|
1684
|
+
nodeEntryPath: this.options.nodeEntryPath,
|
|
1685
|
+
sessionDir: this.options.sessionDir,
|
|
1686
|
+
noSession: true,
|
|
1687
|
+
extraArgs: this.options.extraArgs,
|
|
1688
|
+
env: this.options.env
|
|
1689
|
+
};
|
|
1690
|
+
return this.spawnAgent(resolveOptions, input.workspace);
|
|
1691
|
+
}
|
|
1692
|
+
async spawnAgent(resolveOptions, workspace, sessionPath) {
|
|
1693
|
+
const spawn2 = resolvePi(resolveOptions);
|
|
1694
|
+
this.logger.info(`Starting pi agent (${spawn2.source}): ${spawn2.command} ${spawn2.args.join(" ")}`);
|
|
1695
|
+
const agent = new PiRpcAgent({
|
|
1696
|
+
spawn: spawn2,
|
|
1697
|
+
workspace,
|
|
1698
|
+
logger: this.logger,
|
|
1699
|
+
sessionPath,
|
|
1700
|
+
env: this.options.env,
|
|
1701
|
+
requestTimeoutMs: this.options.requestTimeoutMs
|
|
1702
|
+
});
|
|
1703
|
+
await agent.initialize();
|
|
1704
|
+
return agent;
|
|
1705
|
+
}
|
|
1706
|
+
};
|
|
1707
|
+
|
|
1708
|
+
// ../adapter-pi-rpc/dist/pi-rpc-session-catalog.js
|
|
1709
|
+
import { readdir, readFile, stat, unlink } from "node:fs/promises";
|
|
1710
|
+
import { basename, join as join2, resolve, sep } from "node:path";
|
|
1711
|
+
var PiRpcSessionCatalog = class {
|
|
1712
|
+
directory;
|
|
1713
|
+
maxSessions;
|
|
1714
|
+
removeFile;
|
|
1715
|
+
constructor(options = {}) {
|
|
1716
|
+
this.directory = resolveSessionDir(options.sessionDir, options.env);
|
|
1717
|
+
this.maxSessions = options.maxSessions ?? 50;
|
|
1718
|
+
this.removeFile = options.removeFile ?? unlink;
|
|
1719
|
+
}
|
|
1720
|
+
get sessionDirectory() {
|
|
1721
|
+
return this.directory;
|
|
1722
|
+
}
|
|
1723
|
+
async list() {
|
|
1724
|
+
const files = await this.collectSessionFiles();
|
|
1725
|
+
const summaries = [];
|
|
1726
|
+
for (const file of files.slice(0, this.maxSessions)) {
|
|
1727
|
+
const summary = await readSessionSummary(file.path, file.updatedAt);
|
|
1728
|
+
if (summary) {
|
|
1729
|
+
summaries.push(summary);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
return summaries;
|
|
1733
|
+
}
|
|
1734
|
+
/**
|
|
1735
|
+
* Deletes one stored session. The id is a session file path, so the guard is
|
|
1736
|
+
* the point: only a `.jsonl` inside this catalog's session directory is ever
|
|
1737
|
+
* removed. A stale client or a hostile payload cannot turn a session id into
|
|
1738
|
+
* a delete of an arbitrary file.
|
|
1739
|
+
*/
|
|
1740
|
+
async remove(id) {
|
|
1741
|
+
const path = resolve(id);
|
|
1742
|
+
const directory = resolve(this.directory);
|
|
1743
|
+
if (!path.startsWith(`${directory}${sep}`) || !path.endsWith(".jsonl")) {
|
|
1744
|
+
throw new Error(`Refusing to delete a session outside ${directory}`);
|
|
1745
|
+
}
|
|
1746
|
+
await this.removeFile(path);
|
|
1747
|
+
}
|
|
1748
|
+
async collectSessionFiles() {
|
|
1749
|
+
const buckets = await safeReadDir(this.directory);
|
|
1750
|
+
const files = [];
|
|
1751
|
+
for (const bucket of buckets) {
|
|
1752
|
+
if (!bucket.isDirectory()) {
|
|
1753
|
+
continue;
|
|
1754
|
+
}
|
|
1755
|
+
const bucketPath = join2(this.directory, bucket.name);
|
|
1756
|
+
for (const entry of await safeReadDir(bucketPath)) {
|
|
1757
|
+
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
|
|
1758
|
+
continue;
|
|
1759
|
+
}
|
|
1760
|
+
const path = join2(bucketPath, entry.name);
|
|
1761
|
+
const info = await stat(path).catch(() => void 0);
|
|
1762
|
+
files.push({ path, updatedAt: info?.mtimeMs ?? 0 });
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
files.sort((left, right) => right.updatedAt - left.updatedAt);
|
|
1766
|
+
return files;
|
|
1767
|
+
}
|
|
1768
|
+
};
|
|
1769
|
+
async function readSessionSummary(path, updatedAt) {
|
|
1770
|
+
const raw = await readFile(path, "utf8").catch(() => void 0);
|
|
1771
|
+
if (raw === void 0) {
|
|
1772
|
+
return void 0;
|
|
1773
|
+
}
|
|
1774
|
+
let id = basename(path, ".jsonl");
|
|
1775
|
+
let cwd = "";
|
|
1776
|
+
let name;
|
|
1777
|
+
let firstUserText;
|
|
1778
|
+
let messageCount = 0;
|
|
1779
|
+
for (const line of raw.split("\n")) {
|
|
1780
|
+
if (!line.startsWith("{")) {
|
|
1781
|
+
continue;
|
|
1782
|
+
}
|
|
1783
|
+
let entry;
|
|
1784
|
+
try {
|
|
1785
|
+
entry = JSON.parse(line);
|
|
1786
|
+
} catch {
|
|
1787
|
+
continue;
|
|
1788
|
+
}
|
|
1789
|
+
const type = entry["type"];
|
|
1790
|
+
if (type === "session" && cwd.length === 0) {
|
|
1791
|
+
id = asString(entry["id"]) ?? id;
|
|
1792
|
+
cwd = asString(entry["cwd"]) ?? "";
|
|
1793
|
+
continue;
|
|
1794
|
+
}
|
|
1795
|
+
if (type === "message") {
|
|
1796
|
+
messageCount += 1;
|
|
1797
|
+
if (!firstUserText) {
|
|
1798
|
+
const message = asRecord(entry["message"]);
|
|
1799
|
+
if (message?.["role"] === "user") {
|
|
1800
|
+
firstUserText = textOf(message["content"]);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
continue;
|
|
1804
|
+
}
|
|
1805
|
+
if ((type === "session_info" || type === "session_name") && !name) {
|
|
1806
|
+
name = asString(entry["name"]);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
return {
|
|
1810
|
+
id: path,
|
|
1811
|
+
title: name ?? titleFromText(firstUserText) ?? "Untitled session",
|
|
1812
|
+
cwd,
|
|
1813
|
+
updatedAt,
|
|
1814
|
+
messageCount
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
1817
|
+
function textOf(content) {
|
|
1818
|
+
const direct = asString(content);
|
|
1819
|
+
if (direct !== void 0) {
|
|
1820
|
+
return direct.length > 0 ? direct : void 0;
|
|
1821
|
+
}
|
|
1822
|
+
const blocks = Array.isArray(content) ? content : void 0;
|
|
1823
|
+
if (!blocks) {
|
|
1824
|
+
return void 0;
|
|
1825
|
+
}
|
|
1826
|
+
for (const block of blocks) {
|
|
1827
|
+
const record = asRecord(block);
|
|
1828
|
+
if (record?.["type"] !== "text") {
|
|
1829
|
+
continue;
|
|
1830
|
+
}
|
|
1831
|
+
const text = asString(record["text"]);
|
|
1832
|
+
if (text !== void 0 && text.trim().length > 0) {
|
|
1833
|
+
return text;
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
return void 0;
|
|
1837
|
+
}
|
|
1838
|
+
function titleFromText(text) {
|
|
1839
|
+
if (!text) {
|
|
1840
|
+
return void 0;
|
|
1841
|
+
}
|
|
1842
|
+
const single = text.replace(/\s+/g, " ").trim();
|
|
1843
|
+
if (single.length === 0) {
|
|
1844
|
+
return void 0;
|
|
1845
|
+
}
|
|
1846
|
+
return single.length > 72 ? `${single.slice(0, 72)}\u2026` : single;
|
|
1847
|
+
}
|
|
1848
|
+
async function safeReadDir(path) {
|
|
1849
|
+
try {
|
|
1850
|
+
return await readdir(path, { withFileTypes: true });
|
|
1851
|
+
} catch {
|
|
1852
|
+
return [];
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// ../adapter-pi-rpc/dist/create-pi-rpc-adapter.js
|
|
1857
|
+
function createPiRpcAdapter(config, logger) {
|
|
1858
|
+
const factory = new PiRpcAgentFactory(config, logger);
|
|
1859
|
+
const catalog = new PiRpcSessionCatalog({
|
|
1860
|
+
sessionDir: config.sessionDir,
|
|
1861
|
+
maxSessions: config.maxSessions,
|
|
1862
|
+
env: config.env,
|
|
1863
|
+
removeFile: config.removeSessionFile
|
|
1864
|
+
});
|
|
1865
|
+
return {
|
|
1866
|
+
factory,
|
|
1867
|
+
catalog,
|
|
1868
|
+
describe: () => resolvePi({
|
|
1869
|
+
piPath: config.piPath,
|
|
1870
|
+
nodeEntryPath: config.nodeEntryPath,
|
|
1871
|
+
sessionDir: config.sessionDir,
|
|
1872
|
+
noSession: config.noSession,
|
|
1873
|
+
extraArgs: config.extraArgs,
|
|
1874
|
+
env: config.env
|
|
1875
|
+
})
|
|
1876
|
+
};
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// ../host-runtime/dist/transcript-projector.js
|
|
1880
|
+
var TranscriptProjector = class {
|
|
1881
|
+
options;
|
|
1882
|
+
items = [];
|
|
1883
|
+
toolIndexes = /* @__PURE__ */ new Map();
|
|
1884
|
+
assistantIndex = -1;
|
|
1885
|
+
counter = 0;
|
|
1886
|
+
now;
|
|
1887
|
+
idPrefix;
|
|
1888
|
+
constructor(options) {
|
|
1889
|
+
this.options = options;
|
|
1890
|
+
this.now = options.now ?? (() => Date.now());
|
|
1891
|
+
this.idPrefix = options.idPrefix ?? "morse";
|
|
1892
|
+
}
|
|
1893
|
+
snapshot() {
|
|
1894
|
+
return this.items.map((item) => ({ ...item }));
|
|
1895
|
+
}
|
|
1896
|
+
reset() {
|
|
1897
|
+
this.items.length = 0;
|
|
1898
|
+
this.toolIndexes.clear();
|
|
1899
|
+
this.assistantIndex = -1;
|
|
1900
|
+
this.options.emit({ type: "transcript/replace", payload: { items: [] } });
|
|
1901
|
+
}
|
|
1902
|
+
userPrompt(text, images, pins) {
|
|
1903
|
+
const item = {
|
|
1904
|
+
kind: "user",
|
|
1905
|
+
id: this.nextId("user"),
|
|
1906
|
+
at: this.now(),
|
|
1907
|
+
text,
|
|
1908
|
+
...images && images.length > 0 ? { images } : {},
|
|
1909
|
+
...pins && pins.length > 0 ? { pins } : {}
|
|
1910
|
+
};
|
|
1911
|
+
this.assistantIndex = -1;
|
|
1912
|
+
this.appendItem(item);
|
|
1913
|
+
}
|
|
1914
|
+
/** Appends an item that already happened (a seeded history entry). */
|
|
1915
|
+
append(item) {
|
|
1916
|
+
this.items.push(item);
|
|
1917
|
+
this.options.emit({ type: "transcript/append", payload: { ...item } });
|
|
1918
|
+
}
|
|
1919
|
+
/**
|
|
1920
|
+
* Replaces the whole transcript in one message. Used when a fork re-parents
|
|
1921
|
+
* the session: the branch after the edited message no longer exists, so the
|
|
1922
|
+
* client must drop those rows instead of appending to them.
|
|
1923
|
+
*/
|
|
1924
|
+
replace(items) {
|
|
1925
|
+
this.items.length = 0;
|
|
1926
|
+
this.items.push(...items);
|
|
1927
|
+
this.toolIndexes.clear();
|
|
1928
|
+
this.assistantIndex = -1;
|
|
1929
|
+
this.options.emit({ type: "transcript/replace", payload: { items: this.snapshot() } });
|
|
1930
|
+
}
|
|
1931
|
+
/**
|
|
1932
|
+
* Inserts an older history page in front of everything already shown. The
|
|
1933
|
+
* recorded indexes point into `items`, so they shift by the page size.
|
|
1934
|
+
*/
|
|
1935
|
+
prepend(items) {
|
|
1936
|
+
if (items.length === 0) {
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
this.items.unshift(...items);
|
|
1940
|
+
if (this.assistantIndex !== -1) {
|
|
1941
|
+
this.assistantIndex += items.length;
|
|
1942
|
+
}
|
|
1943
|
+
for (const [toolCallId, index] of this.toolIndexes) {
|
|
1944
|
+
this.toolIndexes.set(toolCallId, index + items.length);
|
|
1945
|
+
}
|
|
1946
|
+
this.options.emit({ type: "transcript/prepend", payload: { items } });
|
|
1947
|
+
}
|
|
1948
|
+
notice(level, text) {
|
|
1949
|
+
this.appendItem({ kind: "notice", id: this.nextId("notice"), at: this.now(), level, text });
|
|
1950
|
+
}
|
|
1951
|
+
/** The live compaction boundary: a marker row for where the context reset. */
|
|
1952
|
+
appendMarker(event) {
|
|
1953
|
+
const { summary, tokensBefore } = event;
|
|
1954
|
+
this.appendItem({
|
|
1955
|
+
kind: "compaction",
|
|
1956
|
+
id: this.nextId("compaction"),
|
|
1957
|
+
at: this.now(),
|
|
1958
|
+
...summary.length > 0 ? { summary } : {},
|
|
1959
|
+
...tokensBefore !== void 0 ? { tokensBefore } : {}
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
error(message, detail) {
|
|
1963
|
+
this.appendItem({
|
|
1964
|
+
kind: "notice",
|
|
1965
|
+
id: this.nextId("error"),
|
|
1966
|
+
at: this.now(),
|
|
1967
|
+
level: "error",
|
|
1968
|
+
text: detail ? `${message}
|
|
1969
|
+
${detail}` : message
|
|
1970
|
+
});
|
|
1971
|
+
}
|
|
1972
|
+
apply(event) {
|
|
1973
|
+
switch (event.type) {
|
|
1974
|
+
case "agent/run-start":
|
|
1975
|
+
this.assistantIndex = -1;
|
|
1976
|
+
break;
|
|
1977
|
+
case "agent/delta":
|
|
1978
|
+
this.applyDelta(event.channel, event.delta);
|
|
1979
|
+
break;
|
|
1980
|
+
case "agent/message":
|
|
1981
|
+
this.applyMessage(event.text, event.thinking);
|
|
1982
|
+
break;
|
|
1983
|
+
case "agent/tool-start":
|
|
1984
|
+
this.applyToolStart(event.toolCallId, event.name, event.title, event.input);
|
|
1985
|
+
break;
|
|
1986
|
+
case "agent/tool-update":
|
|
1987
|
+
this.applyToolUpdate(event.toolCallId, event.output, event.status);
|
|
1988
|
+
break;
|
|
1989
|
+
case "agent/tool-end":
|
|
1990
|
+
this.applyToolEnd(event.toolCallId, event.status, event.output, event.durationMs);
|
|
1991
|
+
break;
|
|
1992
|
+
case "agent/run-end":
|
|
1993
|
+
this.finishAssistant(event.reason === "error" ? void 0 : event.reason);
|
|
1994
|
+
break;
|
|
1995
|
+
case "agent/notice":
|
|
1996
|
+
this.notice(event.level, event.text);
|
|
1997
|
+
break;
|
|
1998
|
+
case "agent/compaction":
|
|
1999
|
+
this.appendMarker(event);
|
|
2000
|
+
break;
|
|
2001
|
+
case "agent/fatal":
|
|
2002
|
+
this.error(event.message, event.detail);
|
|
2003
|
+
break;
|
|
2004
|
+
default:
|
|
2005
|
+
break;
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
applyDelta(channel, delta) {
|
|
2009
|
+
const item = this.ensureAssistant();
|
|
2010
|
+
this.options.emit({
|
|
2011
|
+
type: "transcript/delta",
|
|
2012
|
+
payload: channel === "text" ? { id: item.id, text: delta } : { id: item.id, thinking: delta }
|
|
2013
|
+
});
|
|
2014
|
+
item[channel] += delta;
|
|
2015
|
+
item.streaming = true;
|
|
2016
|
+
}
|
|
2017
|
+
applyMessage(text, thinking) {
|
|
2018
|
+
const item = this.ensureAssistant();
|
|
2019
|
+
item.text = text;
|
|
2020
|
+
item.thinking = thinking;
|
|
2021
|
+
item.streaming = false;
|
|
2022
|
+
this.assistantIndex = -1;
|
|
2023
|
+
this.options.emit({ type: "transcript/update", payload: { ...item } });
|
|
2024
|
+
}
|
|
2025
|
+
finishAssistant(reason) {
|
|
2026
|
+
if (this.assistantIndex === -1) {
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
const item = this.items[this.assistantIndex];
|
|
2030
|
+
if (item && item.kind === "assistant") {
|
|
2031
|
+
item.streaming = false;
|
|
2032
|
+
if (reason === "aborted" && item.text.length === 0) {
|
|
2033
|
+
item.text = "_Run aborted._";
|
|
2034
|
+
}
|
|
2035
|
+
this.options.emit({ type: "transcript/update", payload: { ...item } });
|
|
2036
|
+
}
|
|
2037
|
+
this.assistantIndex = -1;
|
|
2038
|
+
}
|
|
2039
|
+
applyToolStart(toolCallId, name, title, input) {
|
|
2040
|
+
const item = {
|
|
2041
|
+
kind: "tool",
|
|
2042
|
+
id: this.nextId("tool"),
|
|
2043
|
+
at: this.now(),
|
|
2044
|
+
name,
|
|
2045
|
+
title,
|
|
2046
|
+
status: "running",
|
|
2047
|
+
input
|
|
2048
|
+
};
|
|
2049
|
+
this.toolIndexes.set(toolCallId, this.items.length);
|
|
2050
|
+
this.appendItem(item);
|
|
2051
|
+
}
|
|
2052
|
+
applyToolUpdate(toolCallId, output, status) {
|
|
2053
|
+
const item = this.toolItem(toolCallId);
|
|
2054
|
+
if (!item) {
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
if (output !== void 0) {
|
|
2058
|
+
item.output = output;
|
|
2059
|
+
}
|
|
2060
|
+
if (status !== void 0) {
|
|
2061
|
+
item.status = status;
|
|
2062
|
+
}
|
|
2063
|
+
this.updateItem(item);
|
|
2064
|
+
}
|
|
2065
|
+
applyToolEnd(toolCallId, status, output, durationMs) {
|
|
2066
|
+
const item = this.toolItem(toolCallId);
|
|
2067
|
+
if (!item) {
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
item.status = status;
|
|
2071
|
+
if (output !== void 0) {
|
|
2072
|
+
item.output = output;
|
|
2073
|
+
}
|
|
2074
|
+
if (durationMs !== void 0) {
|
|
2075
|
+
item.durationMs = durationMs;
|
|
2076
|
+
}
|
|
2077
|
+
this.updateItem(item);
|
|
2078
|
+
this.toolIndexes.delete(toolCallId);
|
|
2079
|
+
}
|
|
2080
|
+
ensureAssistant() {
|
|
2081
|
+
const existing = this.assistantIndex >= 0 ? this.items[this.assistantIndex] : void 0;
|
|
2082
|
+
if (existing && existing.kind === "assistant") {
|
|
2083
|
+
return existing;
|
|
2084
|
+
}
|
|
2085
|
+
const item = {
|
|
2086
|
+
kind: "assistant",
|
|
2087
|
+
id: this.nextId("assistant"),
|
|
2088
|
+
at: this.now(),
|
|
2089
|
+
text: "",
|
|
2090
|
+
thinking: "",
|
|
2091
|
+
streaming: true
|
|
2092
|
+
};
|
|
2093
|
+
this.assistantIndex = this.items.length;
|
|
2094
|
+
this.appendItem(item);
|
|
2095
|
+
return item;
|
|
2096
|
+
}
|
|
2097
|
+
toolItem(toolCallId) {
|
|
2098
|
+
const index = this.toolIndexes.get(toolCallId);
|
|
2099
|
+
if (index === void 0) {
|
|
2100
|
+
return void 0;
|
|
2101
|
+
}
|
|
2102
|
+
const item = this.items[index];
|
|
2103
|
+
return item && item.kind === "tool" ? item : void 0;
|
|
2104
|
+
}
|
|
2105
|
+
appendItem(item) {
|
|
2106
|
+
this.items.push(item);
|
|
2107
|
+
this.options.emit({ type: "transcript/append", payload: { ...item } });
|
|
2108
|
+
}
|
|
2109
|
+
updateItem(item) {
|
|
2110
|
+
this.options.emit({ type: "transcript/update", payload: { ...item } });
|
|
2111
|
+
}
|
|
2112
|
+
nextId(kind) {
|
|
2113
|
+
this.counter += 1;
|
|
2114
|
+
return `${this.idPrefix}-${kind}-${this.counter}`;
|
|
2115
|
+
}
|
|
2116
|
+
};
|
|
2117
|
+
|
|
2118
|
+
// ../host-runtime/dist/transcript-store.js
|
|
2119
|
+
var SessionTranscriptStore = class {
|
|
2120
|
+
projectors = /* @__PURE__ */ new Map();
|
|
2121
|
+
cursors = /* @__PURE__ */ new Map();
|
|
2122
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2123
|
+
subscribe(listener) {
|
|
2124
|
+
this.listeners.add(listener);
|
|
2125
|
+
return () => {
|
|
2126
|
+
this.listeners.delete(listener);
|
|
2127
|
+
};
|
|
2128
|
+
}
|
|
2129
|
+
items(sessionKey) {
|
|
2130
|
+
return this.projectors.get(sessionKey)?.snapshot() ?? [];
|
|
2131
|
+
}
|
|
2132
|
+
/**
|
|
2133
|
+
* Paging state lives with the transcript, not the connection: a refresh
|
|
2134
|
+
* reattaches to the same warm session and must keep offering older pages.
|
|
2135
|
+
*/
|
|
2136
|
+
historyCursor(sessionKey) {
|
|
2137
|
+
return this.cursors.get(sessionKey) ?? { hasOlder: false };
|
|
2138
|
+
}
|
|
2139
|
+
setHistoryCursor(sessionKey, cursor) {
|
|
2140
|
+
this.cursors.set(sessionKey, cursor);
|
|
2141
|
+
}
|
|
2142
|
+
apply(sessionKey, event) {
|
|
2143
|
+
this.projectorFor(sessionKey).apply(event);
|
|
2144
|
+
}
|
|
2145
|
+
/** Replays a resumed session's history into an empty transcript. */
|
|
2146
|
+
seed(sessionKey, items) {
|
|
2147
|
+
const projector = this.projectorFor(sessionKey);
|
|
2148
|
+
for (const item of items) {
|
|
2149
|
+
projector.append(item);
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
/** Inserts an older history page in front of a session's transcript. */
|
|
2153
|
+
prepend(sessionKey, items) {
|
|
2154
|
+
this.projectorFor(sessionKey).prepend(items);
|
|
2155
|
+
}
|
|
2156
|
+
/**
|
|
2157
|
+
* Moves a transcript to a new session key after the agent forked the session
|
|
2158
|
+
* before a past message. The caller passes the rows that survive the fork;
|
|
2159
|
+
* the branch after the edited message is dropped with a single replace, so no
|
|
2160
|
+
* stale rows linger under the new session.
|
|
2161
|
+
*/
|
|
2162
|
+
move(fromKey, toKey, items) {
|
|
2163
|
+
if (fromKey === toKey) {
|
|
2164
|
+
this.projectorFor(toKey).replace(items);
|
|
2165
|
+
return;
|
|
2166
|
+
}
|
|
2167
|
+
this.projectors.delete(fromKey);
|
|
2168
|
+
this.cursors.delete(fromKey);
|
|
2169
|
+
this.cursors.set(toKey, { hasOlder: false });
|
|
2170
|
+
this.projectorFor(toKey).replace(items);
|
|
2171
|
+
}
|
|
2172
|
+
userPrompt(sessionKey, text, images, pins) {
|
|
2173
|
+
this.projectorFor(sessionKey).userPrompt(text, images, pins);
|
|
2174
|
+
}
|
|
2175
|
+
notice(sessionKey, level, text) {
|
|
2176
|
+
this.projectorFor(sessionKey).notice(level, text);
|
|
2177
|
+
}
|
|
2178
|
+
error(sessionKey, message, detail) {
|
|
2179
|
+
this.projectorFor(sessionKey).error(message, detail);
|
|
2180
|
+
}
|
|
2181
|
+
/** Drops a transcript (used when a session is closed or restarted). */
|
|
2182
|
+
reset(sessionKey) {
|
|
2183
|
+
this.cursors.delete(sessionKey);
|
|
2184
|
+
const projector = this.projectors.get(sessionKey);
|
|
2185
|
+
if (!projector) {
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
projector.reset();
|
|
2189
|
+
}
|
|
2190
|
+
clear(sessionKey) {
|
|
2191
|
+
this.cursors.delete(sessionKey);
|
|
2192
|
+
this.projectors.delete(sessionKey);
|
|
2193
|
+
}
|
|
2194
|
+
dispose() {
|
|
2195
|
+
this.projectors.clear();
|
|
2196
|
+
this.cursors.clear();
|
|
2197
|
+
this.listeners.clear();
|
|
2198
|
+
}
|
|
2199
|
+
projectorFor(sessionKey) {
|
|
2200
|
+
let projector = this.projectors.get(sessionKey);
|
|
2201
|
+
if (!projector) {
|
|
2202
|
+
projector = new TranscriptProjector({
|
|
2203
|
+
emit: (message) => {
|
|
2204
|
+
this.emit({
|
|
2205
|
+
sessionKey,
|
|
2206
|
+
message,
|
|
2207
|
+
items: projector?.snapshot() ?? []
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
2210
|
+
});
|
|
2211
|
+
this.projectors.set(sessionKey, projector);
|
|
2212
|
+
}
|
|
2213
|
+
return projector;
|
|
2214
|
+
}
|
|
2215
|
+
emit(update) {
|
|
2216
|
+
for (const listener of [...this.listeners]) {
|
|
2217
|
+
listener(update);
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
};
|
|
2221
|
+
|
|
2222
|
+
// ../host-runtime/dist/view-state.js
|
|
2223
|
+
function emptySessionViewState(workspace, meta) {
|
|
2224
|
+
return {
|
|
2225
|
+
workspace: { cwd: workspace.cwd, name: workspace.name },
|
|
2226
|
+
thinkingLevel: "off",
|
|
2227
|
+
availableModels: [],
|
|
2228
|
+
availableThinkingLevels: [],
|
|
2229
|
+
availableCommands: [],
|
|
2230
|
+
streaming: false,
|
|
2231
|
+
busy: meta.busy,
|
|
2232
|
+
agentReady: meta.agentReady,
|
|
2233
|
+
agentStarting: meta.agentStarting,
|
|
2234
|
+
agentError: meta.agentError,
|
|
2235
|
+
hasOlderHistory: meta.hasOlderHistory,
|
|
2236
|
+
loadingOlderHistory: meta.loadingOlderHistory
|
|
2237
|
+
};
|
|
2238
|
+
}
|
|
2239
|
+
function draftSessionViewState(workspace, meta, catalog, pendingModel, pendingThinking) {
|
|
2240
|
+
const empty = emptySessionViewState(workspace, meta);
|
|
2241
|
+
if (!catalog) {
|
|
2242
|
+
return empty;
|
|
2243
|
+
}
|
|
2244
|
+
return {
|
|
2245
|
+
...empty,
|
|
2246
|
+
model: toModelOption(pendingModel ?? catalog.model),
|
|
2247
|
+
thinkingLevel: pendingThinking ?? catalog.thinkingLevel ?? "off",
|
|
2248
|
+
availableModels: catalog.availableModels.map(toModelOption).filter(isModelOption),
|
|
2249
|
+
availableThinkingLevels: catalog.availableThinkingLevels.map(toThinkingLevel2),
|
|
2250
|
+
availableCommands: catalog.availableCommands.map(toCommandOption)
|
|
2251
|
+
};
|
|
2252
|
+
}
|
|
2253
|
+
function toSessionViewState(state, meta) {
|
|
2254
|
+
return {
|
|
2255
|
+
sessionId: state.sessionId,
|
|
2256
|
+
sessionTitle: state.sessionTitle,
|
|
2257
|
+
workspace: { cwd: state.workspace.cwd, name: state.workspace.name },
|
|
2258
|
+
model: toModelOption(state.model),
|
|
2259
|
+
thinkingLevel: state.thinkingLevel,
|
|
2260
|
+
availableModels: state.availableModels.map(toModelOption).filter(isModelOption),
|
|
2261
|
+
availableThinkingLevels: state.availableThinkingLevels.map(toThinkingLevel2),
|
|
2262
|
+
availableCommands: state.availableCommands.map(toCommandOption),
|
|
2263
|
+
streaming: state.streaming,
|
|
2264
|
+
busy: meta.busy,
|
|
2265
|
+
usage: toUsage2(state.usage),
|
|
2266
|
+
lastUsage: toUsage2(state.lastUsage),
|
|
2267
|
+
costUsd: state.costUsd,
|
|
2268
|
+
contextUsage: state.contextUsage ? { ...state.contextUsage } : void 0,
|
|
2269
|
+
counts: state.counts ? { ...state.counts } : void 0,
|
|
2270
|
+
agentReady: meta.agentReady,
|
|
2271
|
+
agentStarting: meta.agentStarting,
|
|
2272
|
+
agentError: meta.agentError,
|
|
2273
|
+
hasOlderHistory: meta.hasOlderHistory,
|
|
2274
|
+
loadingOlderHistory: meta.loadingOlderHistory
|
|
2275
|
+
};
|
|
2276
|
+
}
|
|
2277
|
+
function toModelOption(model) {
|
|
2278
|
+
if (!model) {
|
|
2279
|
+
return void 0;
|
|
2280
|
+
}
|
|
2281
|
+
return {
|
|
2282
|
+
provider: model.provider,
|
|
2283
|
+
id: model.id,
|
|
2284
|
+
name: model.name,
|
|
2285
|
+
contextWindow: model.contextWindow,
|
|
2286
|
+
maxTokens: model.maxTokens
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
2289
|
+
function isModelOption(value) {
|
|
2290
|
+
return value !== void 0;
|
|
2291
|
+
}
|
|
2292
|
+
function toCommandOption(command) {
|
|
2293
|
+
return {
|
|
2294
|
+
name: command.name,
|
|
2295
|
+
description: command.description,
|
|
2296
|
+
source: command.source
|
|
2297
|
+
};
|
|
2298
|
+
}
|
|
2299
|
+
function toThinkingLevel2(level) {
|
|
2300
|
+
return level;
|
|
2301
|
+
}
|
|
2302
|
+
function toUsage2(usage) {
|
|
2303
|
+
return usage ? { ...usage } : void 0;
|
|
2304
|
+
}
|
|
2305
|
+
function toInteractionRequest2(request) {
|
|
2306
|
+
switch (request.kind) {
|
|
2307
|
+
case "select":
|
|
2308
|
+
return {
|
|
2309
|
+
requestId: request.requestId,
|
|
2310
|
+
kind: "select",
|
|
2311
|
+
title: request.title,
|
|
2312
|
+
message: request.message,
|
|
2313
|
+
options: request.options.map((option) => ({ ...option }))
|
|
2314
|
+
};
|
|
2315
|
+
case "confirm":
|
|
2316
|
+
return {
|
|
2317
|
+
requestId: request.requestId,
|
|
2318
|
+
kind: "confirm",
|
|
2319
|
+
title: request.title,
|
|
2320
|
+
message: request.message,
|
|
2321
|
+
danger: request.danger
|
|
2322
|
+
};
|
|
2323
|
+
case "input":
|
|
2324
|
+
return {
|
|
2325
|
+
requestId: request.requestId,
|
|
2326
|
+
kind: "input",
|
|
2327
|
+
title: request.title,
|
|
2328
|
+
placeholder: request.placeholder,
|
|
2329
|
+
value: request.value
|
|
2330
|
+
};
|
|
2331
|
+
case "editor":
|
|
2332
|
+
return {
|
|
2333
|
+
requestId: request.requestId,
|
|
2334
|
+
kind: "editor",
|
|
2335
|
+
title: request.title,
|
|
2336
|
+
value: request.value,
|
|
2337
|
+
language: request.language
|
|
2338
|
+
};
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
function noticeMessage(level, text, at) {
|
|
2342
|
+
return { type: "notice", payload: { level, text, at } };
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
// ../host-runtime/dist/session-controller.js
|
|
2346
|
+
var HISTORY_PAGE_SIZE = 50;
|
|
2347
|
+
var HostSessionController = class {
|
|
2348
|
+
options;
|
|
2349
|
+
summaries = /* @__PURE__ */ new Map();
|
|
2350
|
+
detachRegistry;
|
|
2351
|
+
detachTranscripts;
|
|
2352
|
+
activeKey;
|
|
2353
|
+
/** True while the panel shows the intentional no-session draft state. */
|
|
2354
|
+
isDraft = false;
|
|
2355
|
+
/** Project a draft opens its first session in (browser "New session here"). */
|
|
2356
|
+
draftWorkspace;
|
|
2357
|
+
/** Catalog of a never-recording probe — fills the draft pickers. */
|
|
2358
|
+
draftCatalog;
|
|
2359
|
+
/** Picks made in the draft, applied to the session the first prompt opens. */
|
|
2360
|
+
draftModel;
|
|
2361
|
+
draftThinking;
|
|
2362
|
+
agentReady = false;
|
|
2363
|
+
agentStarting = false;
|
|
2364
|
+
agentError;
|
|
2365
|
+
busy = false;
|
|
2366
|
+
disposed = false;
|
|
2367
|
+
hasOpenedOnce = false;
|
|
2368
|
+
hostReadyEmitted = false;
|
|
2369
|
+
pendingInteractions = /* @__PURE__ */ new Set();
|
|
2370
|
+
/** Cursor of the oldest history page loaded for the active session. */
|
|
2371
|
+
historyBefore;
|
|
2372
|
+
hasOlderHistory = false;
|
|
2373
|
+
loadingOlderHistory = false;
|
|
2374
|
+
/** Discriminates history item ids across pages (see `historyItems`). */
|
|
2375
|
+
historyPageSeq = 0;
|
|
2376
|
+
constructor(options) {
|
|
2377
|
+
this.options = options;
|
|
2378
|
+
}
|
|
2379
|
+
async start() {
|
|
2380
|
+
this.detachRegistry = this.options.services.registry.subscribe((tagged) => {
|
|
2381
|
+
void this.onTaggedEvent(tagged.sessionKey, tagged.event);
|
|
2382
|
+
});
|
|
2383
|
+
this.detachTranscripts = this.options.transcripts.subscribe((update) => {
|
|
2384
|
+
if (update.sessionKey === this.activeKey) {
|
|
2385
|
+
this.options.emit(update.message);
|
|
2386
|
+
}
|
|
2387
|
+
});
|
|
2388
|
+
if (this.options.autoOpen !== false) {
|
|
2389
|
+
this.agentStarting = true;
|
|
2390
|
+
}
|
|
2391
|
+
this.emitReady();
|
|
2392
|
+
if (this.options.autoOpen !== false) {
|
|
2393
|
+
await this.openDefaultSession();
|
|
2394
|
+
} else {
|
|
2395
|
+
const existingKey = this.options.services.registry.activeKeyOf();
|
|
2396
|
+
if (existingKey === void 0) {
|
|
2397
|
+
this.isDraft = true;
|
|
2398
|
+
void this.warmDraft();
|
|
2399
|
+
} else {
|
|
2400
|
+
await this.runSessionChange(() => this.options.services.registry.activate(existingKey));
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
void this.publishLists();
|
|
2404
|
+
}
|
|
2405
|
+
/**
|
|
2406
|
+
* Points this controller at a new client. Hosts that reattach a still-warm
|
|
2407
|
+
* session to a reconnecting client use this so the same agent process keeps
|
|
2408
|
+
* serving without being respawned.
|
|
2409
|
+
*/
|
|
2410
|
+
setEmitter(emit) {
|
|
2411
|
+
this.options.emit = emit;
|
|
2412
|
+
}
|
|
2413
|
+
async dispose() {
|
|
2414
|
+
if (this.disposed) {
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2417
|
+
this.disposed = true;
|
|
2418
|
+
this.detachRegistry?.();
|
|
2419
|
+
this.detachRegistry = void 0;
|
|
2420
|
+
this.detachTranscripts?.();
|
|
2421
|
+
this.detachTranscripts = void 0;
|
|
2422
|
+
if (this.options.ownsRegistry === true) {
|
|
2423
|
+
await this.options.services.registry.dispose();
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
async handleClientMessage(message) {
|
|
2427
|
+
if (this.disposed) {
|
|
2428
|
+
return;
|
|
2429
|
+
}
|
|
2430
|
+
switch (message.type) {
|
|
2431
|
+
case "client/ready":
|
|
2432
|
+
if (message.payload.protocolVersion !== PROTOCOL_VERSION) {
|
|
2433
|
+
this.emitWireError("Frontend and host speak different protocol versions.", `frontend=${message.payload.protocolVersion} host=${PROTOCOL_VERSION}`);
|
|
2434
|
+
return;
|
|
2435
|
+
}
|
|
2436
|
+
if (message.payload.frontend) {
|
|
2437
|
+
this.options.logger.info(`Frontend ${message.payload.frontend.name}@${message.payload.frontend.version} connected`);
|
|
2438
|
+
}
|
|
2439
|
+
if (!this.hostReadyEmitted) {
|
|
2440
|
+
return;
|
|
2441
|
+
}
|
|
2442
|
+
this.emitReady();
|
|
2443
|
+
void this.publishLists();
|
|
2444
|
+
return;
|
|
2445
|
+
case "chat/prompt":
|
|
2446
|
+
await this.prompt(message.payload.text, message.payload.mode, message.payload.images, message.payload.pins);
|
|
2447
|
+
return;
|
|
2448
|
+
case "chat/edit":
|
|
2449
|
+
await this.editPrompt(message.payload.itemId, message.payload.text);
|
|
2450
|
+
return;
|
|
2451
|
+
case "chat/fork":
|
|
2452
|
+
await this.forkPrompt(message.payload.itemId);
|
|
2453
|
+
return;
|
|
2454
|
+
case "chat/abort":
|
|
2455
|
+
await this.guard(() => this.options.services.chat.abort());
|
|
2456
|
+
return;
|
|
2457
|
+
case "session/new":
|
|
2458
|
+
await this.enterDraft(message.payload.cwd ? workspaceOf(message.payload.cwd) : void 0);
|
|
2459
|
+
return;
|
|
2460
|
+
case "session/activate":
|
|
2461
|
+
case "session/load":
|
|
2462
|
+
await this.activateSession(message.payload.sessionId, message.payload.cwd);
|
|
2463
|
+
return;
|
|
2464
|
+
case "session/close":
|
|
2465
|
+
await this.closeSession(message.payload.sessionId);
|
|
2466
|
+
return;
|
|
2467
|
+
case "session/delete":
|
|
2468
|
+
await this.deleteSession(message.payload.sessionId);
|
|
2469
|
+
return;
|
|
2470
|
+
case "session/compact":
|
|
2471
|
+
this.setBusy(true);
|
|
2472
|
+
try {
|
|
2473
|
+
await this.guard(() => this.options.services.chat.compact(message.payload.instructions));
|
|
2474
|
+
} finally {
|
|
2475
|
+
this.setBusy(false);
|
|
2476
|
+
}
|
|
2477
|
+
return;
|
|
2478
|
+
case "history/load":
|
|
2479
|
+
await this.loadOlderHistory();
|
|
2480
|
+
return;
|
|
2481
|
+
case "session/list":
|
|
2482
|
+
await this.publishSessions();
|
|
2483
|
+
return;
|
|
2484
|
+
case "project/list":
|
|
2485
|
+
await this.publishProjects();
|
|
2486
|
+
return;
|
|
2487
|
+
case "project/open":
|
|
2488
|
+
await this.enterDraft(workspaceOf(message.payload.path));
|
|
2489
|
+
return;
|
|
2490
|
+
case "model/set":
|
|
2491
|
+
await this.setModel(message.payload.provider, message.payload.id);
|
|
2492
|
+
return;
|
|
2493
|
+
case "thinking/set":
|
|
2494
|
+
await this.setThinkingLevel(message.payload.level);
|
|
2495
|
+
return;
|
|
2496
|
+
case "interaction/respond":
|
|
2497
|
+
await this.respondToInteraction(message.payload);
|
|
2498
|
+
return;
|
|
2499
|
+
case "host/command":
|
|
2500
|
+
await this.runHostCommand(message.payload.command, message.payload.args, message.payload.requestId);
|
|
2501
|
+
return;
|
|
2502
|
+
default:
|
|
2503
|
+
return;
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
async openDefaultSession() {
|
|
2507
|
+
const existingKey = this.options.services.registry.activeKeyOf();
|
|
2508
|
+
if (existingKey) {
|
|
2509
|
+
await this.runSessionChange(() => this.options.services.registry.activate(existingKey));
|
|
2510
|
+
return;
|
|
2511
|
+
}
|
|
2512
|
+
await this.runSessionChange(() => this.options.services.registry.open());
|
|
2513
|
+
}
|
|
2514
|
+
/**
|
|
2515
|
+
* The empty panel is an intentional draft: no agent process, no session
|
|
2516
|
+
* file, no navigator entry — "New session" becomes real only once the first
|
|
2517
|
+
* prompt runs. A pending project and pending picks (model, thinking) wait
|
|
2518
|
+
* here until that first prompt opens the session they belong to.
|
|
2519
|
+
*/
|
|
2520
|
+
async enterDraft(workspace) {
|
|
2521
|
+
if (workspace && !this.canOpen(workspace.cwd)) {
|
|
2522
|
+
this.fail(`Morse is not allowed to run an agent in ${workspace.cwd}.`, "Add it to MORSE_PROJECTS first.");
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
this.activeKey = void 0;
|
|
2526
|
+
this.isDraft = true;
|
|
2527
|
+
this.draftWorkspace = workspace;
|
|
2528
|
+
this.resetViewState();
|
|
2529
|
+
this.options.emit({ type: "transcript/replace", payload: { items: [] } });
|
|
2530
|
+
this.emitState();
|
|
2531
|
+
await this.warmDraft();
|
|
2532
|
+
}
|
|
2533
|
+
/** Clears everything the previous session had left on the view state. */
|
|
2534
|
+
resetViewState() {
|
|
2535
|
+
this.historyBefore = void 0;
|
|
2536
|
+
this.hasOlderHistory = false;
|
|
2537
|
+
this.loadingOlderHistory = false;
|
|
2538
|
+
this.agentReady = false;
|
|
2539
|
+
this.agentStarting = false;
|
|
2540
|
+
this.agentError = void 0;
|
|
2541
|
+
}
|
|
2542
|
+
/** Fills the draft pickers from the registry's session-less probe. */
|
|
2543
|
+
async warmDraft() {
|
|
2544
|
+
this.draftCatalog = await this.options.services.registry.draftDefaults();
|
|
2545
|
+
if (this.activeKey === void 0 && !this.disposed) {
|
|
2546
|
+
this.emitState();
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
async activateSession(sessionId, cwd) {
|
|
2550
|
+
const known = this.summaries.get(sessionId);
|
|
2551
|
+
const path = cwd ?? known?.cwd;
|
|
2552
|
+
if (path && !this.canOpen(path)) {
|
|
2553
|
+
this.fail(`Morse is not allowed to run an agent in ${path}.`, "Add it to MORSE_PROJECTS first.");
|
|
2554
|
+
return;
|
|
2555
|
+
}
|
|
2556
|
+
this.isDraft = false;
|
|
2557
|
+
this.draftWorkspace = void 0;
|
|
2558
|
+
this.draftModel = void 0;
|
|
2559
|
+
this.draftThinking = void 0;
|
|
2560
|
+
await this.runSessionChange(() => this.options.services.registry.activate(sessionId, path ? workspaceOf(path) : void 0));
|
|
2561
|
+
}
|
|
2562
|
+
async closeSession(sessionId) {
|
|
2563
|
+
await this.guard(() => this.options.services.registry.close(sessionId));
|
|
2564
|
+
this.options.transcripts.clear(sessionId);
|
|
2565
|
+
await this.fallBackAfterSessionGone(sessionId);
|
|
2566
|
+
}
|
|
2567
|
+
/**
|
|
2568
|
+
* Deletes a session for good: the registry disposes its process and the
|
|
2569
|
+
* catalog drops the stored conversation. Which directories an agent may
|
|
2570
|
+
* touch is the host's rule (`ProjectPolicy`), so one use case is safe behind
|
|
2571
|
+
* a server allow-list and inside a VS Code window — the wiring differs, the
|
|
2572
|
+
* decision does not.
|
|
2573
|
+
*/
|
|
2574
|
+
async deleteSession(sessionId) {
|
|
2575
|
+
const path = this.summaries.get(sessionId)?.cwd;
|
|
2576
|
+
if (path !== void 0 && path.length > 0 && !this.canOpen(path)) {
|
|
2577
|
+
this.fail(`Morse is not allowed to delete sessions in ${path}.`, "Add it to MORSE_PROJECTS first.");
|
|
2578
|
+
return;
|
|
2579
|
+
}
|
|
2580
|
+
const result = await this.guard(() => this.options.services.registry.remove(sessionId));
|
|
2581
|
+
if (!result.ok) {
|
|
2582
|
+
return;
|
|
2583
|
+
}
|
|
2584
|
+
this.options.transcripts.clear(sessionId);
|
|
2585
|
+
await this.fallBackAfterSessionGone(sessionId);
|
|
2586
|
+
this.options.emit(noticeMessage("info", "Session deleted.", Date.now()));
|
|
2587
|
+
}
|
|
2588
|
+
/** The panel must stay usable once the session it showed is gone. */
|
|
2589
|
+
async fallBackAfterSessionGone(sessionId) {
|
|
2590
|
+
if (this.activeKey !== sessionId) {
|
|
2591
|
+
this.publishActivity();
|
|
2592
|
+
await this.publishSessions();
|
|
2593
|
+
return;
|
|
2594
|
+
}
|
|
2595
|
+
this.activeKey = void 0;
|
|
2596
|
+
this.historyBefore = void 0;
|
|
2597
|
+
this.hasOlderHistory = false;
|
|
2598
|
+
this.loadingOlderHistory = false;
|
|
2599
|
+
this.agentReady = false;
|
|
2600
|
+
this.agentError = void 0;
|
|
2601
|
+
const next = this.options.services.registry.hotKeys().at(-1);
|
|
2602
|
+
if (next) {
|
|
2603
|
+
await this.runSessionChange(() => this.options.services.registry.activate(next));
|
|
2604
|
+
} else {
|
|
2605
|
+
await this.enterDraft();
|
|
2606
|
+
}
|
|
2607
|
+
await this.publishSessions();
|
|
2608
|
+
}
|
|
2609
|
+
/** Opens/activates a session while reporting progress and failures. */
|
|
2610
|
+
async runSessionChange(change) {
|
|
2611
|
+
const announce = this.hasOpenedOnce;
|
|
2612
|
+
if (announce) {
|
|
2613
|
+
this.setBusy(true);
|
|
2614
|
+
}
|
|
2615
|
+
this.agentStarting = true;
|
|
2616
|
+
if (announce) {
|
|
2617
|
+
this.emitState();
|
|
2618
|
+
}
|
|
2619
|
+
try {
|
|
2620
|
+
const opened = await change();
|
|
2621
|
+
this.adopt(opened);
|
|
2622
|
+
} catch (error) {
|
|
2623
|
+
this.agentReady = false;
|
|
2624
|
+
this.agentError = describeError(error);
|
|
2625
|
+
this.options.logger.warn("Could not start the Morse agent", error);
|
|
2626
|
+
this.fail(`Could not start the Morse agent: ${this.agentError}`, this.options.agentHint);
|
|
2627
|
+
} finally {
|
|
2628
|
+
this.agentStarting = false;
|
|
2629
|
+
this.hasOpenedOnce = true;
|
|
2630
|
+
if (announce) {
|
|
2631
|
+
this.setBusy(false);
|
|
2632
|
+
} else {
|
|
2633
|
+
this.emitState();
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
adopt(opened) {
|
|
2638
|
+
this.activeKey = opened.key;
|
|
2639
|
+
this.isDraft = false;
|
|
2640
|
+
this.draftWorkspace = void 0;
|
|
2641
|
+
if (!opened.reused) {
|
|
2642
|
+
this.options.transcripts.reset(opened.key);
|
|
2643
|
+
}
|
|
2644
|
+
this.historyBefore = void 0;
|
|
2645
|
+
this.hasOlderHistory = false;
|
|
2646
|
+
this.loadingOlderHistory = false;
|
|
2647
|
+
this.agentReady = true;
|
|
2648
|
+
this.agentError = void 0;
|
|
2649
|
+
this.emitTranscript(opened.key);
|
|
2650
|
+
this.emitState();
|
|
2651
|
+
void this.publishSessions();
|
|
2652
|
+
void this.seedHistory(opened.key);
|
|
2653
|
+
}
|
|
2654
|
+
/**
|
|
2655
|
+
* A resumed session has its messages on disk but streams nothing on attach, so
|
|
2656
|
+
* the transcript would look empty. Fill it from the *newest* page of history:
|
|
2657
|
+
* a session with thousands of messages must open without rendering them all,
|
|
2658
|
+
* and older pages are fetched on demand (see `loadOlderHistory`).
|
|
2659
|
+
*/
|
|
2660
|
+
async seedHistory(key) {
|
|
2661
|
+
if (this.options.transcripts.items(key).length > 0) {
|
|
2662
|
+
const cursor = this.options.transcripts.historyCursor(key);
|
|
2663
|
+
this.historyBefore = cursor.before;
|
|
2664
|
+
this.hasOlderHistory = cursor.hasOlder;
|
|
2665
|
+
this.emitState();
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2668
|
+
if (this.activeKey !== key) {
|
|
2669
|
+
return;
|
|
2670
|
+
}
|
|
2671
|
+
const gateway = this.options.services.registry.active();
|
|
2672
|
+
if (!gateway) {
|
|
2673
|
+
return;
|
|
2674
|
+
}
|
|
2675
|
+
this.loadingOlderHistory = true;
|
|
2676
|
+
this.emitState();
|
|
2677
|
+
const result = await this.guard(() => gateway.history({ limit: HISTORY_PAGE_SIZE }));
|
|
2678
|
+
this.loadingOlderHistory = false;
|
|
2679
|
+
if (!result.ok || this.options.transcripts.items(key).length > 0 || this.activeKey !== key) {
|
|
2680
|
+
this.emitState();
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2683
|
+
this.historyBefore = result.value.before;
|
|
2684
|
+
this.hasOlderHistory = result.value.hasOlder;
|
|
2685
|
+
this.options.transcripts.setHistoryCursor(key, {
|
|
2686
|
+
before: result.value.before,
|
|
2687
|
+
hasOlder: result.value.hasOlder
|
|
2688
|
+
});
|
|
2689
|
+
this.options.transcripts.seed(key, historyItems(key, result.value.entries, this.historyPageSeq++));
|
|
2690
|
+
this.emitState();
|
|
2691
|
+
}
|
|
2692
|
+
/** Prepends the previous page of history when the reader reaches the top. */
|
|
2693
|
+
async loadOlderHistory() {
|
|
2694
|
+
if (!this.hasOlderHistory || this.loadingOlderHistory) {
|
|
2695
|
+
return;
|
|
2696
|
+
}
|
|
2697
|
+
const key = this.activeKey;
|
|
2698
|
+
const gateway = this.options.services.registry.active();
|
|
2699
|
+
if (key === void 0 || !gateway) {
|
|
2700
|
+
return;
|
|
2701
|
+
}
|
|
2702
|
+
this.loadingOlderHistory = true;
|
|
2703
|
+
this.emitState();
|
|
2704
|
+
const result = await this.guard(() => gateway.history({ limit: HISTORY_PAGE_SIZE, before: this.historyBefore }));
|
|
2705
|
+
this.loadingOlderHistory = false;
|
|
2706
|
+
if (!result.ok || this.activeKey !== key) {
|
|
2707
|
+
this.emitState();
|
|
2708
|
+
return;
|
|
2709
|
+
}
|
|
2710
|
+
this.historyBefore = result.value.before;
|
|
2711
|
+
this.hasOlderHistory = result.value.hasOlder;
|
|
2712
|
+
this.options.transcripts.setHistoryCursor(key, {
|
|
2713
|
+
before: result.value.before,
|
|
2714
|
+
hasOlder: result.value.hasOlder
|
|
2715
|
+
});
|
|
2716
|
+
this.options.transcripts.prepend(key, historyItems(key, result.value.entries, this.historyPageSeq++));
|
|
2717
|
+
this.emitState();
|
|
2718
|
+
}
|
|
2719
|
+
canOpen(path) {
|
|
2720
|
+
const scope = this.options.scope ?? { kind: "global" };
|
|
2721
|
+
if (scope.kind === "workspace" && !insideRoots(path, scope.roots)) {
|
|
2722
|
+
return false;
|
|
2723
|
+
}
|
|
2724
|
+
return this.options.policy?.canOpen(path) ?? true;
|
|
2725
|
+
}
|
|
2726
|
+
withinScope(path) {
|
|
2727
|
+
const scope = this.options.scope ?? { kind: "global" };
|
|
2728
|
+
return scope.kind === "global" || insideRoots(path, scope.roots);
|
|
2729
|
+
}
|
|
2730
|
+
async prompt(text, mode, images, pins) {
|
|
2731
|
+
if (!this.agentReady) {
|
|
2732
|
+
if (this.isDraft) {
|
|
2733
|
+
await this.runSessionChange(() => this.options.services.registry.open({ workspace: this.draftWorkspace }));
|
|
2734
|
+
this.draftWorkspace = void 0;
|
|
2735
|
+
} else {
|
|
2736
|
+
await this.openDefaultSession();
|
|
2737
|
+
}
|
|
2738
|
+
if (!this.agentReady) {
|
|
2739
|
+
return;
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
this.isDraft = false;
|
|
2743
|
+
const pendingModel = this.draftModel;
|
|
2744
|
+
const pendingThinking = this.draftThinking;
|
|
2745
|
+
this.draftModel = void 0;
|
|
2746
|
+
this.draftThinking = void 0;
|
|
2747
|
+
if (pendingModel) {
|
|
2748
|
+
await this.guard(() => this.options.services.chat.setModel({
|
|
2749
|
+
provider: pendingModel.provider,
|
|
2750
|
+
id: pendingModel.id,
|
|
2751
|
+
name: pendingModel.name
|
|
2752
|
+
}));
|
|
2753
|
+
}
|
|
2754
|
+
if (pendingThinking) {
|
|
2755
|
+
await this.guard(() => this.options.services.chat.setThinkingLevel(pendingThinking));
|
|
2756
|
+
}
|
|
2757
|
+
const result = await this.guard(() => this.options.services.chat.prompt(text, mode ?? "new", images, pins));
|
|
2758
|
+
if (!result.ok || !result.value.accepted) {
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2761
|
+
const key = this.activeKey;
|
|
2762
|
+
if (key) {
|
|
2763
|
+
this.options.transcripts.userPrompt(key, text, images, pins);
|
|
2764
|
+
void this.publishSessions();
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
/**
|
|
2768
|
+
* Edits a past user message: fork the conversation before it (which discards
|
|
2769
|
+
* that turn and everything after), truncate the transcript to match, then send
|
|
2770
|
+
* the edited text as a fresh prompt. Attachments on the original message ride
|
|
2771
|
+
* along, so an image or pin is not silently dropped by the edit.
|
|
2772
|
+
*/
|
|
2773
|
+
async editPrompt(itemId, text) {
|
|
2774
|
+
const target = await this.forkBefore(itemId, "edit");
|
|
2775
|
+
if (!target) {
|
|
2776
|
+
return;
|
|
2777
|
+
}
|
|
2778
|
+
await this.prompt(text, "new", target.images, target.pins);
|
|
2779
|
+
}
|
|
2780
|
+
/**
|
|
2781
|
+
* Branches the conversation before a past user message without sending
|
|
2782
|
+
* anything. pi's fork writes a new session file while the old branch stays
|
|
2783
|
+
* resumable; the transcript re-homes onto the new, shorter branch and the
|
|
2784
|
+
* forked prompt goes back to the composer, so the user continues from the
|
|
2785
|
+
* fork point instead of retyping it.
|
|
2786
|
+
*/
|
|
2787
|
+
async forkPrompt(itemId) {
|
|
2788
|
+
const target = await this.forkBefore(itemId, "fork");
|
|
2789
|
+
if (!target) {
|
|
2790
|
+
return;
|
|
2791
|
+
}
|
|
2792
|
+
this.options.emit({
|
|
2793
|
+
type: "composer/seed",
|
|
2794
|
+
payload: {
|
|
2795
|
+
text: target.text,
|
|
2796
|
+
...target.images ? { images: target.images } : {},
|
|
2797
|
+
...target.pins ? { pins: target.pins } : {}
|
|
2798
|
+
}
|
|
2799
|
+
});
|
|
2800
|
+
void this.publishSessions();
|
|
2801
|
+
}
|
|
2802
|
+
/**
|
|
2803
|
+
* Forks the active session before a past user message and re-homes the
|
|
2804
|
+
* transcript onto the new branch. Shared by edit (which then sends) and fork
|
|
2805
|
+
* (which seeds the composer). Returns the forked message, or `undefined` when
|
|
2806
|
+
* the fork was refused — a notice has already been emitted in that case.
|
|
2807
|
+
*/
|
|
2808
|
+
async forkBefore(itemId, action) {
|
|
2809
|
+
const gerund = action === "edit" ? "editing" : "forking";
|
|
2810
|
+
const past = action === "edit" ? "edited" : "forked";
|
|
2811
|
+
if (this.isDraft) {
|
|
2812
|
+
this.options.emit(noticeMessage("warn", `Nothing to ${action} yet \u2014 this session has no messages.`, Date.now()));
|
|
2813
|
+
return void 0;
|
|
2814
|
+
}
|
|
2815
|
+
if (!this.agentReady) {
|
|
2816
|
+
await this.openDefaultSession();
|
|
2817
|
+
if (!this.agentReady) {
|
|
2818
|
+
return void 0;
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
const key = this.activeKey;
|
|
2822
|
+
const gateway = this.options.services.registry.active();
|
|
2823
|
+
if (key === void 0 || !gateway) {
|
|
2824
|
+
this.options.emit(noticeMessage("warn", `No session to ${action}.`, Date.now()));
|
|
2825
|
+
return void 0;
|
|
2826
|
+
}
|
|
2827
|
+
const items = this.options.transcripts.items(key);
|
|
2828
|
+
const target = items.find((item) => item.id === itemId);
|
|
2829
|
+
if (!target || target.kind !== "user") {
|
|
2830
|
+
this.options.emit(noticeMessage("warn", `That message is no longer available to ${action}.`, Date.now()));
|
|
2831
|
+
return void 0;
|
|
2832
|
+
}
|
|
2833
|
+
if (this.options.services.registry.stateOf(key)?.streaming === true) {
|
|
2834
|
+
this.options.emit(noticeMessage("warn", `Wait for the agent to finish before ${gerund} a message.`, Date.now()));
|
|
2835
|
+
return void 0;
|
|
2836
|
+
}
|
|
2837
|
+
const messages = await this.guard(() => gateway.forkMessages());
|
|
2838
|
+
if (!messages.ok) {
|
|
2839
|
+
return void 0;
|
|
2840
|
+
}
|
|
2841
|
+
const entryId = entryIdForEdit(items, itemId, messages.value);
|
|
2842
|
+
if (entryId === void 0) {
|
|
2843
|
+
this.options.emit(noticeMessage("warn", `This message cannot be ${past} \u2014 the agent has no fork point for it.`, Date.now()));
|
|
2844
|
+
return void 0;
|
|
2845
|
+
}
|
|
2846
|
+
const forked = await this.guard(() => this.options.services.registry.forkActive(entryId));
|
|
2847
|
+
if (!forked.ok) {
|
|
2848
|
+
return void 0;
|
|
2849
|
+
}
|
|
2850
|
+
if (forked.value.cancelled) {
|
|
2851
|
+
this.options.emit(noticeMessage("warn", "The fork was cancelled.", Date.now()));
|
|
2852
|
+
return void 0;
|
|
2853
|
+
}
|
|
2854
|
+
const index = items.findIndex((item) => item.id === itemId);
|
|
2855
|
+
const kept = items.slice(0, index);
|
|
2856
|
+
this.activeKey = forked.value.key;
|
|
2857
|
+
this.options.transcripts.move(key, forked.value.key, kept);
|
|
2858
|
+
this.historyBefore = void 0;
|
|
2859
|
+
this.hasOlderHistory = false;
|
|
2860
|
+
this.loadingOlderHistory = false;
|
|
2861
|
+
this.emitState();
|
|
2862
|
+
return target;
|
|
2863
|
+
}
|
|
2864
|
+
async setModel(provider, id) {
|
|
2865
|
+
if (this.activeKey === void 0) {
|
|
2866
|
+
const match = this.draftCatalog?.availableModels.find((model) => model.provider === provider && model.id === id);
|
|
2867
|
+
this.draftModel = match ?? { provider, id, name: id };
|
|
2868
|
+
this.emitState();
|
|
2869
|
+
return;
|
|
2870
|
+
}
|
|
2871
|
+
const result = await this.guard(() => this.options.services.chat.setModel({ provider, id, name: id }));
|
|
2872
|
+
if (result.ok) {
|
|
2873
|
+
this.emitState();
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
async setThinkingLevel(level) {
|
|
2877
|
+
if (this.activeKey === void 0) {
|
|
2878
|
+
this.draftThinking = level;
|
|
2879
|
+
this.emitState();
|
|
2880
|
+
return;
|
|
2881
|
+
}
|
|
2882
|
+
const result = await this.guard(() => this.options.services.chat.setThinkingLevel(level));
|
|
2883
|
+
if (result.ok) {
|
|
2884
|
+
this.emitState();
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
async respondToInteraction(response) {
|
|
2888
|
+
this.pendingInteractions.delete(response.requestId);
|
|
2889
|
+
this.options.emit({ type: "interaction/dismiss", payload: { requestId: response.requestId } });
|
|
2890
|
+
const gateway = this.options.services.registry.active();
|
|
2891
|
+
if (!gateway) {
|
|
2892
|
+
return;
|
|
2893
|
+
}
|
|
2894
|
+
await this.guard(() => gateway.respondToInteraction(response));
|
|
2895
|
+
}
|
|
2896
|
+
async runHostCommand(command, args, requestId) {
|
|
2897
|
+
const handler = this.options.onHostCommand;
|
|
2898
|
+
if (!handler) {
|
|
2899
|
+
this.options.emit(noticeMessage("warn", `This host does not support "${command}".`, Date.now()));
|
|
2900
|
+
this.answerHostCommand(requestId, false, void 0, `This host does not support "${command}".`);
|
|
2901
|
+
return;
|
|
2902
|
+
}
|
|
2903
|
+
const result = await this.guard(() => Promise.resolve(handler(command, args, { cwd: this.currentState().workspace.cwd })));
|
|
2904
|
+
if (!requestId) {
|
|
2905
|
+
return;
|
|
2906
|
+
}
|
|
2907
|
+
if (result.ok) {
|
|
2908
|
+
this.answerHostCommand(requestId, true, result.value);
|
|
2909
|
+
return;
|
|
2910
|
+
}
|
|
2911
|
+
this.answerHostCommand(requestId, false, void 0, `"${command}" failed`);
|
|
2912
|
+
}
|
|
2913
|
+
/** Replies to a host command the frontend asked a value for. */
|
|
2914
|
+
answerHostCommand(requestId, ok, data, error) {
|
|
2915
|
+
if (!requestId) {
|
|
2916
|
+
return;
|
|
2917
|
+
}
|
|
2918
|
+
this.options.emit({ type: "host/command/result", payload: { requestId, ok, data, error } });
|
|
2919
|
+
}
|
|
2920
|
+
async onTaggedEvent(sessionKey, event) {
|
|
2921
|
+
if (sessionKey !== this.activeKey) {
|
|
2922
|
+
if (event.type !== "agent/ready" && event.type !== "agent/state") {
|
|
2923
|
+
this.options.transcripts.apply(sessionKey, event);
|
|
2924
|
+
if (event.type === "agent/run-end") {
|
|
2925
|
+
void this.publishSessions();
|
|
2926
|
+
}
|
|
2927
|
+
} else {
|
|
2928
|
+
this.publishActivity();
|
|
2929
|
+
}
|
|
2930
|
+
return;
|
|
2931
|
+
}
|
|
2932
|
+
switch (event.type) {
|
|
2933
|
+
case "agent/ready":
|
|
2934
|
+
this.agentReady = true;
|
|
2935
|
+
this.agentError = void 0;
|
|
2936
|
+
this.emitState();
|
|
2937
|
+
return;
|
|
2938
|
+
case "agent/state":
|
|
2939
|
+
this.emitState();
|
|
2940
|
+
return;
|
|
2941
|
+
case "agent/run-end":
|
|
2942
|
+
this.options.transcripts.apply(sessionKey, event);
|
|
2943
|
+
void this.publishSessions();
|
|
2944
|
+
return;
|
|
2945
|
+
case "agent/interaction":
|
|
2946
|
+
await this.handleInteraction(event.request);
|
|
2947
|
+
return;
|
|
2948
|
+
case "agent/fatal":
|
|
2949
|
+
this.agentReady = false;
|
|
2950
|
+
this.agentError = event.message;
|
|
2951
|
+
this.options.transcripts.error(sessionKey, event.message, event.detail);
|
|
2952
|
+
this.emitState();
|
|
2953
|
+
return;
|
|
2954
|
+
default:
|
|
2955
|
+
this.options.transcripts.apply(sessionKey, event);
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
async handleInteraction(request) {
|
|
2959
|
+
const dialogs = this.options.dialogs;
|
|
2960
|
+
if (!dialogs) {
|
|
2961
|
+
this.pendingInteractions.add(request.requestId);
|
|
2962
|
+
this.options.emit({ type: "interaction/request", payload: toInteractionRequest2(request) });
|
|
2963
|
+
return;
|
|
2964
|
+
}
|
|
2965
|
+
const response = await this.askNatively(dialogs, request);
|
|
2966
|
+
const gateway = this.options.services.registry.active();
|
|
2967
|
+
if (!gateway) {
|
|
2968
|
+
return;
|
|
2969
|
+
}
|
|
2970
|
+
await this.guard(() => gateway.respondToInteraction(response));
|
|
2971
|
+
}
|
|
2972
|
+
async askNatively(dialogs, request) {
|
|
2973
|
+
switch (request.kind) {
|
|
2974
|
+
case "select": {
|
|
2975
|
+
const value = await dialogs.select(request);
|
|
2976
|
+
return value === void 0 ? { requestId: request.requestId, cancelled: true } : { requestId: request.requestId, value };
|
|
2977
|
+
}
|
|
2978
|
+
case "confirm":
|
|
2979
|
+
return { requestId: request.requestId, confirmed: await dialogs.confirm(request) };
|
|
2980
|
+
case "input": {
|
|
2981
|
+
const value = await dialogs.input(request);
|
|
2982
|
+
return value === void 0 ? { requestId: request.requestId, cancelled: true } : { requestId: request.requestId, value };
|
|
2983
|
+
}
|
|
2984
|
+
case "editor": {
|
|
2985
|
+
const value = await dialogs.editor(request);
|
|
2986
|
+
return value === void 0 ? { requestId: request.requestId, cancelled: true } : { requestId: request.requestId, value };
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
async publishLists() {
|
|
2991
|
+
await this.publishSessions();
|
|
2992
|
+
}
|
|
2993
|
+
/**
|
|
2994
|
+
* Projects are derived from sessions, so the caller can hand in the list it
|
|
2995
|
+
* just built instead of making the registry read the catalog again. That also
|
|
2996
|
+
* lets a brand-new project show up before pi has persisted its first session.
|
|
2997
|
+
*/
|
|
2998
|
+
async publishProjects(sessions) {
|
|
2999
|
+
const result = await this.guard(() => this.options.services.registry.listProjects(sessions));
|
|
3000
|
+
if (result.ok) {
|
|
3001
|
+
const projects = result.value.filter((project) => this.withinScope(project.path));
|
|
3002
|
+
this.options.emit({ type: "project/list", payload: { projects } });
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
async publishSessions() {
|
|
3006
|
+
const result = await this.guard(() => this.options.services.registry.listSessions());
|
|
3007
|
+
if (result.ok) {
|
|
3008
|
+
const sessions = result.value.filter((session) => this.withinScope(session.cwd));
|
|
3009
|
+
const known = new Set(sessions.map((session) => session.id));
|
|
3010
|
+
for (const key of this.options.services.registry.hotKeys()) {
|
|
3011
|
+
if (known.has(key)) {
|
|
3012
|
+
continue;
|
|
3013
|
+
}
|
|
3014
|
+
const state = this.options.services.registry.stateOf(key);
|
|
3015
|
+
const cwd = state?.workspace.cwd ?? this.options.services.registry.defaultWorkspace.cwd;
|
|
3016
|
+
if (!this.withinScope(cwd)) {
|
|
3017
|
+
continue;
|
|
3018
|
+
}
|
|
3019
|
+
sessions.push({
|
|
3020
|
+
id: key,
|
|
3021
|
+
title: state?.sessionTitle ?? this.derivedTitle(key) ?? "New session",
|
|
3022
|
+
cwd,
|
|
3023
|
+
updatedAt: Date.now(),
|
|
3024
|
+
messageCount: 0
|
|
3025
|
+
});
|
|
3026
|
+
}
|
|
3027
|
+
this.summaries = new Map(sessions.map((session) => [session.id, session]));
|
|
3028
|
+
this.options.emit({ type: "session/list", payload: { sessions } });
|
|
3029
|
+
await this.publishProjects(sessions);
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
/**
|
|
3033
|
+
* Title for a session pi has not persisted yet. pi names a session only when
|
|
3034
|
+
* something calls `set_session_name`, and until the file exists the catalog
|
|
3035
|
+
* cannot derive a title from the file either — so derive it from the first
|
|
3036
|
+
* user message here, the same way the catalog does. A session must not stay
|
|
3037
|
+
* "New session" once the user has said something.
|
|
3038
|
+
*/
|
|
3039
|
+
derivedTitle(key) {
|
|
3040
|
+
for (const item of this.options.transcripts.items(key)) {
|
|
3041
|
+
if (item.kind !== "user" || item.text.trim().length === 0) {
|
|
3042
|
+
continue;
|
|
3043
|
+
}
|
|
3044
|
+
const single = item.text.replace(/\s+/g, " ").trim();
|
|
3045
|
+
return single.length > 72 ? `${single.slice(0, 72)}\u2026` : single;
|
|
3046
|
+
}
|
|
3047
|
+
return void 0;
|
|
3048
|
+
}
|
|
3049
|
+
emitReady() {
|
|
3050
|
+
this.hostReadyEmitted = true;
|
|
3051
|
+
this.options.emit({
|
|
3052
|
+
type: "transcript/replace",
|
|
3053
|
+
payload: { items: this.activeKey ? this.options.transcripts.items(this.activeKey) : [] }
|
|
3054
|
+
});
|
|
3055
|
+
this.options.emit({
|
|
3056
|
+
type: "host/ready",
|
|
3057
|
+
payload: {
|
|
3058
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
3059
|
+
capabilities: this.options.capabilities,
|
|
3060
|
+
state: this.currentState(),
|
|
3061
|
+
frontend: this.options.frontend
|
|
3062
|
+
}
|
|
3063
|
+
});
|
|
3064
|
+
this.publishActivity();
|
|
3065
|
+
}
|
|
3066
|
+
emitTranscript(key) {
|
|
3067
|
+
this.options.emit({
|
|
3068
|
+
type: "transcript/replace",
|
|
3069
|
+
payload: { items: this.options.transcripts.items(key) }
|
|
3070
|
+
});
|
|
3071
|
+
}
|
|
3072
|
+
emitState() {
|
|
3073
|
+
this.options.emit({ type: "session/state", payload: this.currentState() });
|
|
3074
|
+
this.publishActivity();
|
|
3075
|
+
}
|
|
3076
|
+
/**
|
|
3077
|
+
* Reports every live session (hot process), not only the selected one. The
|
|
3078
|
+
* navigator marks the ones the agent is actively working in, so two parallel
|
|
3079
|
+
* conversations both read as running instead of one silently taking over.
|
|
3080
|
+
*/
|
|
3081
|
+
publishActivity() {
|
|
3082
|
+
const sessions = this.options.services.registry.hotKeys().map((key) => {
|
|
3083
|
+
const state = this.options.services.registry.stateOf(key);
|
|
3084
|
+
const isActive = key === this.activeKey;
|
|
3085
|
+
return {
|
|
3086
|
+
sessionKey: key,
|
|
3087
|
+
streaming: state?.streaming ?? false,
|
|
3088
|
+
busy: isActive ? this.busy : false,
|
|
3089
|
+
agentReady: isActive ? this.agentReady : state !== void 0,
|
|
3090
|
+
agentStarting: isActive ? this.agentStarting : false,
|
|
3091
|
+
...isActive && this.agentError !== void 0 ? { agentError: this.agentError } : {}
|
|
3092
|
+
};
|
|
3093
|
+
});
|
|
3094
|
+
this.options.emit({ type: "session/activity", payload: { sessions } });
|
|
3095
|
+
}
|
|
3096
|
+
currentState() {
|
|
3097
|
+
const meta = {
|
|
3098
|
+
agentReady: this.agentReady,
|
|
3099
|
+
agentStarting: this.agentStarting,
|
|
3100
|
+
agentError: this.agentError,
|
|
3101
|
+
busy: this.busy,
|
|
3102
|
+
hasOlderHistory: this.hasOlderHistory,
|
|
3103
|
+
loadingOlderHistory: this.loadingOlderHistory
|
|
3104
|
+
};
|
|
3105
|
+
const state = this.activeKey === void 0 ? void 0 : this.options.services.registry.stateOf(this.activeKey);
|
|
3106
|
+
if (state) {
|
|
3107
|
+
return toSessionViewState(state, meta);
|
|
3108
|
+
}
|
|
3109
|
+
return draftSessionViewState(this.draftWorkspace ?? this.options.services.registry.defaultWorkspace, meta, this.draftCatalog, this.draftModel, this.draftThinking);
|
|
3110
|
+
}
|
|
3111
|
+
setBusy(busy) {
|
|
3112
|
+
if (this.busy === busy) {
|
|
3113
|
+
return;
|
|
3114
|
+
}
|
|
3115
|
+
this.busy = busy;
|
|
3116
|
+
this.emitState();
|
|
3117
|
+
}
|
|
3118
|
+
fail(message, detail) {
|
|
3119
|
+
const key = this.activeKey;
|
|
3120
|
+
if (key !== void 0) {
|
|
3121
|
+
this.options.transcripts.error(key, message, detail);
|
|
3122
|
+
return;
|
|
3123
|
+
}
|
|
3124
|
+
this.options.emit(noticeMessage("error", detail ? `${message}
|
|
3125
|
+
${detail}` : message, Date.now()));
|
|
3126
|
+
}
|
|
3127
|
+
/** Transient error for the client (banner), never a transcript item. */
|
|
3128
|
+
emitWireError(message, detail) {
|
|
3129
|
+
this.options.emit({ type: "error", payload: { message, detail, at: Date.now() } });
|
|
3130
|
+
this.options.emit(noticeMessage("error", detail ? `${message}
|
|
3131
|
+
${detail}` : message, Date.now()));
|
|
3132
|
+
}
|
|
3133
|
+
async guard(action) {
|
|
3134
|
+
try {
|
|
3135
|
+
return { ok: true, value: await action() };
|
|
3136
|
+
} catch (error) {
|
|
3137
|
+
const message = describeError(error);
|
|
3138
|
+
this.options.logger.error(`Morse command failed: ${message}`, error);
|
|
3139
|
+
this.fail(message);
|
|
3140
|
+
return { ok: false };
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
};
|
|
3144
|
+
function workspaceOf(path) {
|
|
3145
|
+
const parts = path.split(/[\\/]/).filter((part) => part.length > 0);
|
|
3146
|
+
return { cwd: path, name: parts.at(-1) ?? path };
|
|
3147
|
+
}
|
|
3148
|
+
function insideRoots(path, roots) {
|
|
3149
|
+
return roots.some((root) => path === root || path.startsWith(`${root}/`) || path.startsWith(`${root}\\`));
|
|
3150
|
+
}
|
|
3151
|
+
function historyItems(key, entries, page) {
|
|
3152
|
+
return entries.map((entry, index) => {
|
|
3153
|
+
const id = `${key}#history-${page}-${index}`;
|
|
3154
|
+
const at = entry.at ?? 0;
|
|
3155
|
+
if (entry.role === "user") {
|
|
3156
|
+
return {
|
|
3157
|
+
kind: "user",
|
|
3158
|
+
id,
|
|
3159
|
+
at,
|
|
3160
|
+
text: entry.text,
|
|
3161
|
+
...entry.images ? { images: entry.images } : {},
|
|
3162
|
+
...entry.pins ? { pins: entry.pins } : {}
|
|
3163
|
+
};
|
|
3164
|
+
}
|
|
3165
|
+
if (entry.role === "tool") {
|
|
3166
|
+
return {
|
|
3167
|
+
kind: "tool",
|
|
3168
|
+
id,
|
|
3169
|
+
at,
|
|
3170
|
+
name: entry.name,
|
|
3171
|
+
title: entry.title,
|
|
3172
|
+
status: entry.status,
|
|
3173
|
+
input: entry.input,
|
|
3174
|
+
output: entry.output
|
|
3175
|
+
};
|
|
3176
|
+
}
|
|
3177
|
+
if (entry.role === "compaction") {
|
|
3178
|
+
return {
|
|
3179
|
+
kind: "compaction",
|
|
3180
|
+
id,
|
|
3181
|
+
at,
|
|
3182
|
+
summary: entry.summary,
|
|
3183
|
+
...entry.tokensBefore !== void 0 ? { tokensBefore: entry.tokensBefore } : {}
|
|
3184
|
+
};
|
|
3185
|
+
}
|
|
3186
|
+
return {
|
|
3187
|
+
kind: "assistant",
|
|
3188
|
+
id,
|
|
3189
|
+
at,
|
|
3190
|
+
text: entry.text,
|
|
3191
|
+
thinking: entry.thinking ?? "",
|
|
3192
|
+
streaming: false,
|
|
3193
|
+
model: entry.model
|
|
3194
|
+
};
|
|
3195
|
+
});
|
|
3196
|
+
}
|
|
3197
|
+
function entryIdForEdit(items, itemId, forkMessages) {
|
|
3198
|
+
const forkable = items.filter((item) => item.kind === "user" && (item.text.trim().length > 0 || (item.pins?.length ?? 0) > 0));
|
|
3199
|
+
const index = forkable.findIndex((item) => item.id === itemId);
|
|
3200
|
+
if (index === -1) {
|
|
3201
|
+
return void 0;
|
|
3202
|
+
}
|
|
3203
|
+
const position = forkMessages.length - 1 - (forkable.length - 1 - index);
|
|
3204
|
+
return position >= 0 ? forkMessages[position]?.entryId : void 0;
|
|
3205
|
+
}
|
|
3206
|
+
function describeError(error) {
|
|
3207
|
+
if (error instanceof Error) {
|
|
3208
|
+
return error.message;
|
|
3209
|
+
}
|
|
3210
|
+
return typeof error === "string" ? error : JSON.stringify(error);
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
// ../server/src/internal/http/health.controller.ts
|
|
3214
|
+
import { Controller, Get } from "@nestjs/common";
|
|
3215
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
3216
|
+
import { join as join3 } from "node:path";
|
|
3217
|
+
import { Inject } from "@nestjs/common";
|
|
3218
|
+
|
|
3219
|
+
// ../server/src/app/tokens.ts
|
|
3220
|
+
var MORSE_CONFIG = "MORSE_CONFIG";
|
|
3221
|
+
var MORSE_LOGGER = "MORSE_LOGGER";
|
|
3222
|
+
var MORSE_PI_ADAPTER = "MORSE_PI_ADAPTER";
|
|
3223
|
+
var MORSE_SESSION_REGISTRY = "MORSE_SESSION_REGISTRY";
|
|
3224
|
+
var MORSE_TRANSCRIPT_STORE = "MORSE_TRANSCRIPT_STORE";
|
|
3225
|
+
var MORSE_PROJECT_POLICY = "MORSE_PROJECT_POLICY";
|
|
3226
|
+
|
|
3227
|
+
// ../server/src/internal/http/health.controller.ts
|
|
3228
|
+
var HealthController = class {
|
|
3229
|
+
constructor(config) {
|
|
3230
|
+
this.config = config;
|
|
3231
|
+
}
|
|
3232
|
+
health() {
|
|
3233
|
+
return {
|
|
3234
|
+
status: "ok",
|
|
3235
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
3236
|
+
workspace: this.config.workspace.cwd,
|
|
3237
|
+
frontend: this.config.uiDir
|
|
3238
|
+
};
|
|
3239
|
+
}
|
|
3240
|
+
async manifest() {
|
|
3241
|
+
const raw = await readFile2(join3(this.config.uiDir, "webview.manifest.json"), "utf8").catch(
|
|
3242
|
+
() => void 0
|
|
3243
|
+
);
|
|
3244
|
+
return parseFrontendManifest(raw) ?? { name: "unknown", protocolVersion: PROTOCOL_VERSION };
|
|
3245
|
+
}
|
|
3246
|
+
};
|
|
3247
|
+
__decorateClass([
|
|
3248
|
+
Get("health")
|
|
3249
|
+
], HealthController.prototype, "health", 1);
|
|
3250
|
+
__decorateClass([
|
|
3251
|
+
Get("manifest")
|
|
3252
|
+
], HealthController.prototype, "manifest", 1);
|
|
3253
|
+
HealthController = __decorateClass([
|
|
3254
|
+
Controller("api"),
|
|
3255
|
+
__decorateParam(0, Inject(MORSE_CONFIG))
|
|
3256
|
+
], HealthController);
|
|
3257
|
+
|
|
3258
|
+
// ../server/src/internal/logging/nest-logger.ts
|
|
3259
|
+
import { Logger } from "@nestjs/common";
|
|
3260
|
+
var NestMorseLogger = class {
|
|
3261
|
+
constructor() {
|
|
3262
|
+
this.logger = new Logger("morse");
|
|
3263
|
+
}
|
|
3264
|
+
debug(message, detail) {
|
|
3265
|
+
this.logger.debug(withDetail(message, detail));
|
|
3266
|
+
}
|
|
3267
|
+
info(message, detail) {
|
|
3268
|
+
this.logger.log(withDetail(message, detail));
|
|
3269
|
+
}
|
|
3270
|
+
warn(message, detail) {
|
|
3271
|
+
this.logger.warn(withDetail(message, detail));
|
|
3272
|
+
}
|
|
3273
|
+
error(message, detail) {
|
|
3274
|
+
this.logger.error(withDetail(message, detail));
|
|
3275
|
+
}
|
|
3276
|
+
};
|
|
3277
|
+
function withDetail(message, detail) {
|
|
3278
|
+
if (detail === void 0) {
|
|
3279
|
+
return message;
|
|
3280
|
+
}
|
|
3281
|
+
if (detail instanceof Error) {
|
|
3282
|
+
return `${message}: ${detail.message}`;
|
|
3283
|
+
}
|
|
3284
|
+
try {
|
|
3285
|
+
return `${message} ${JSON.stringify(detail)}`;
|
|
3286
|
+
} catch {
|
|
3287
|
+
return `${message} ${String(detail)}`;
|
|
3288
|
+
}
|
|
3289
|
+
}
|
|
3290
|
+
|
|
3291
|
+
// ../server/src/internal/projects/project-policy.ts
|
|
3292
|
+
import { isAbsolute as isAbsolute2, resolve as resolve2, sep as sep2 } from "node:path";
|
|
3293
|
+
var ServerProjectPolicy = class {
|
|
3294
|
+
constructor(roots) {
|
|
3295
|
+
this.roots = roots.filter((root) => root.trim().length > 0).map((root) => resolve2(root));
|
|
3296
|
+
}
|
|
3297
|
+
get allowList() {
|
|
3298
|
+
return [...this.roots];
|
|
3299
|
+
}
|
|
3300
|
+
canOpen(path) {
|
|
3301
|
+
if (!isAbsolute2(path)) {
|
|
3302
|
+
return false;
|
|
3303
|
+
}
|
|
3304
|
+
if (this.roots.length === 0) {
|
|
3305
|
+
return true;
|
|
3306
|
+
}
|
|
3307
|
+
const target = resolve2(path);
|
|
3308
|
+
return this.roots.some(
|
|
3309
|
+
(root) => target === root || target.startsWith(`${root}${sep2}`)
|
|
3310
|
+
);
|
|
3311
|
+
}
|
|
3312
|
+
};
|
|
3313
|
+
|
|
3314
|
+
// ../server/src/internal/registry.ts
|
|
3315
|
+
import { Inject as Inject2, Injectable } from "@nestjs/common";
|
|
3316
|
+
function createSessionRegistry(adapter, config, logger) {
|
|
3317
|
+
return new SessionRegistry({
|
|
3318
|
+
factory: adapter.factory,
|
|
3319
|
+
catalog: adapter.catalog,
|
|
3320
|
+
defaultWorkspace: config.workspace,
|
|
3321
|
+
hotLimit: config.hotSessions,
|
|
3322
|
+
logger
|
|
3323
|
+
});
|
|
3324
|
+
}
|
|
3325
|
+
var MorseRegistryLifecycle = class {
|
|
3326
|
+
constructor(registry, transcripts) {
|
|
3327
|
+
this.registry = registry;
|
|
3328
|
+
this.transcripts = transcripts;
|
|
3329
|
+
}
|
|
3330
|
+
async onModuleDestroy() {
|
|
3331
|
+
await this.registry.dispose();
|
|
3332
|
+
this.transcripts.dispose();
|
|
3333
|
+
}
|
|
3334
|
+
};
|
|
3335
|
+
MorseRegistryLifecycle = __decorateClass([
|
|
3336
|
+
Injectable(),
|
|
3337
|
+
__decorateParam(0, Inject2(MORSE_SESSION_REGISTRY)),
|
|
3338
|
+
__decorateParam(1, Inject2(MORSE_TRANSCRIPT_STORE))
|
|
3339
|
+
], MorseRegistryLifecycle);
|
|
3340
|
+
|
|
3341
|
+
// ../server/src/internal/ws/morse.gateway.ts
|
|
3342
|
+
import {
|
|
3343
|
+
WebSocketGateway
|
|
3344
|
+
} from "@nestjs/websockets";
|
|
3345
|
+
import { Inject as Inject4 } from "@nestjs/common";
|
|
3346
|
+
|
|
3347
|
+
// ../server/src/internal/ws/session-factory.service.ts
|
|
3348
|
+
import { Inject as Inject3, Injectable as Injectable2 } from "@nestjs/common";
|
|
3349
|
+
|
|
3350
|
+
// ../server/src/internal/uploads/upload-store.ts
|
|
3351
|
+
import { existsSync } from "node:fs";
|
|
3352
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3353
|
+
import { basename as basename2, isAbsolute as isAbsolute3, join as join4, relative, sep as sep3 } from "node:path";
|
|
3354
|
+
var MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
3355
|
+
var DEFAULT_UPLOAD_DIR = ".morse/uploads";
|
|
3356
|
+
var UploadError = class extends Error {
|
|
3357
|
+
};
|
|
3358
|
+
async function saveUpload(cwd, request, uploadDir = DEFAULT_UPLOAD_DIR) {
|
|
3359
|
+
const name = safeFileName(request.name);
|
|
3360
|
+
const data = request.data;
|
|
3361
|
+
if (typeof data !== "string" || data.length === 0) {
|
|
3362
|
+
throw new UploadError("That upload carried no data.");
|
|
3363
|
+
}
|
|
3364
|
+
const bytes = Buffer.from(data, "base64");
|
|
3365
|
+
if (bytes.length === 0) {
|
|
3366
|
+
throw new UploadError("That upload carried no data.");
|
|
3367
|
+
}
|
|
3368
|
+
if (bytes.length > MAX_UPLOAD_BYTES) {
|
|
3369
|
+
throw new UploadError(
|
|
3370
|
+
`That file is ${formatMb(bytes.length)} MB; the limit is ${formatMb(MAX_UPLOAD_BYTES)} MB.`
|
|
3371
|
+
);
|
|
3372
|
+
}
|
|
3373
|
+
const directory = isAbsolute3(uploadDir) ? uploadDir : join4(cwd, uploadDir);
|
|
3374
|
+
await mkdir(directory, { recursive: true });
|
|
3375
|
+
const target = uniquePath(directory, name);
|
|
3376
|
+
await writeFile(target, bytes);
|
|
3377
|
+
return { path: mentionPath(cwd, target), name: basename2(target), bytes: bytes.length };
|
|
3378
|
+
}
|
|
3379
|
+
function safeFileName(raw) {
|
|
3380
|
+
const value = typeof raw === "string" ? raw : "";
|
|
3381
|
+
const base = basename2(value.replace(/\\/g, "/")).trim();
|
|
3382
|
+
const cleaned = base.replace(/[\u0000-\u001f<>:"|?*]/g, "_").replace(/^\.+/, "").trim();
|
|
3383
|
+
if (cleaned.length === 0) {
|
|
3384
|
+
throw new UploadError("That upload had no usable file name.");
|
|
3385
|
+
}
|
|
3386
|
+
return cleaned.slice(0, 120);
|
|
3387
|
+
}
|
|
3388
|
+
function uniquePath(directory, name) {
|
|
3389
|
+
const dot = name.lastIndexOf(".");
|
|
3390
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
3391
|
+
const extension = dot > 0 ? name.slice(dot) : "";
|
|
3392
|
+
let candidate = join4(directory, name);
|
|
3393
|
+
let suffix = 1;
|
|
3394
|
+
while (existsSync(candidate)) {
|
|
3395
|
+
candidate = join4(directory, `${stem}-${suffix}${extension}`);
|
|
3396
|
+
suffix += 1;
|
|
3397
|
+
}
|
|
3398
|
+
return candidate;
|
|
3399
|
+
}
|
|
3400
|
+
function mentionPath(cwd, target) {
|
|
3401
|
+
const rel = relative(cwd, target);
|
|
3402
|
+
const posix = rel.split(sep3).join("/");
|
|
3403
|
+
return posix.length === 0 || posix.startsWith("..") || isAbsolute3(rel) ? target : posix;
|
|
3404
|
+
}
|
|
3405
|
+
function formatMb(bytes) {
|
|
3406
|
+
return (bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, "");
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3409
|
+
// ../server/src/internal/workspace/directory-browser.ts
|
|
3410
|
+
import { readdir as readdir2, stat as stat2 } from "node:fs/promises";
|
|
3411
|
+
import { homedir as homedir2 } from "node:os";
|
|
3412
|
+
import { basename as basename3, dirname, isAbsolute as isAbsolute4, join as join5, resolve as resolve3 } from "node:path";
|
|
3413
|
+
var SKIPPED_DIRECTORIES = /* @__PURE__ */ new Set([
|
|
3414
|
+
"node_modules",
|
|
3415
|
+
"dist",
|
|
3416
|
+
"out",
|
|
3417
|
+
"build",
|
|
3418
|
+
"coverage",
|
|
3419
|
+
"target",
|
|
3420
|
+
"vendor",
|
|
3421
|
+
"venv",
|
|
3422
|
+
"__pycache__",
|
|
3423
|
+
".angular",
|
|
3424
|
+
".next",
|
|
3425
|
+
".nuxt",
|
|
3426
|
+
".turbo",
|
|
3427
|
+
".cache"
|
|
3428
|
+
]);
|
|
3429
|
+
async function browseDirectory(path, options) {
|
|
3430
|
+
const requested = path?.trim();
|
|
3431
|
+
const start = resolve3(requested && requested.length > 0 ? requested : options.defaultPath);
|
|
3432
|
+
if (requested && !isAbsolute4(requested)) {
|
|
3433
|
+
throw new Error(`A folder path must be absolute: ${requested}`);
|
|
3434
|
+
}
|
|
3435
|
+
const entries = await readdir2(start, { withFileTypes: true });
|
|
3436
|
+
const directories = entries.filter((entry) => entry.isDirectory()).filter((entry) => !shouldSkip(entry.name)).map((entry) => ({ name: entry.name, path: join5(start, entry.name) })).sort((left, right) => left.name.localeCompare(right.name));
|
|
3437
|
+
const parent = dirname(start);
|
|
3438
|
+
return {
|
|
3439
|
+
path: start,
|
|
3440
|
+
parent: parent === start ? void 0 : parent,
|
|
3441
|
+
directories,
|
|
3442
|
+
isGitRepo: await isGitRepo(start),
|
|
3443
|
+
roots: shortcutRoots(options)
|
|
3444
|
+
};
|
|
3445
|
+
}
|
|
3446
|
+
function shouldSkip(name) {
|
|
3447
|
+
return name.startsWith(".") || SKIPPED_DIRECTORIES.has(name);
|
|
3448
|
+
}
|
|
3449
|
+
async function isGitRepo(path) {
|
|
3450
|
+
try {
|
|
3451
|
+
await stat2(join5(path, ".git"));
|
|
3452
|
+
return true;
|
|
3453
|
+
} catch {
|
|
3454
|
+
return false;
|
|
3455
|
+
}
|
|
3456
|
+
}
|
|
3457
|
+
function shortcutRoots(options) {
|
|
3458
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3459
|
+
const roots = [];
|
|
3460
|
+
for (const candidate of [...options.roots ?? [], homedir2(), options.defaultPath]) {
|
|
3461
|
+
if (!candidate || !isAbsolute4(candidate)) {
|
|
3462
|
+
continue;
|
|
3463
|
+
}
|
|
3464
|
+
const path = resolve3(candidate);
|
|
3465
|
+
if (seen.has(path)) {
|
|
3466
|
+
continue;
|
|
3467
|
+
}
|
|
3468
|
+
seen.add(path);
|
|
3469
|
+
roots.push({ name: basename3(path) || path, path });
|
|
3470
|
+
}
|
|
3471
|
+
return roots;
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3474
|
+
// ../server/src/internal/workspace/workspace-index.ts
|
|
3475
|
+
import { execFile } from "node:child_process";
|
|
3476
|
+
import { readdir as readdir3 } from "node:fs/promises";
|
|
3477
|
+
import { join as join6, relative as relative2 } from "node:path";
|
|
3478
|
+
var SKIPPED_DIRECTORIES2 = /* @__PURE__ */ new Set([
|
|
3479
|
+
"node_modules",
|
|
3480
|
+
"dist",
|
|
3481
|
+
"out",
|
|
3482
|
+
"build",
|
|
3483
|
+
"coverage",
|
|
3484
|
+
"target",
|
|
3485
|
+
"vendor",
|
|
3486
|
+
"venv",
|
|
3487
|
+
"__pycache__",
|
|
3488
|
+
".angular",
|
|
3489
|
+
".next",
|
|
3490
|
+
".nuxt",
|
|
3491
|
+
".turbo",
|
|
3492
|
+
".cache"
|
|
3493
|
+
]);
|
|
3494
|
+
var MAX_INDEXED_FILES = 5e3;
|
|
3495
|
+
var INDEX_TTL_MS = 6e4;
|
|
3496
|
+
var indexes = /* @__PURE__ */ new Map();
|
|
3497
|
+
var inFlight = /* @__PURE__ */ new Map();
|
|
3498
|
+
async function workspaceFiles(cwd, logger) {
|
|
3499
|
+
const { files, source } = await cachedIndex(cwd);
|
|
3500
|
+
logger.info(`File picker: ${files.length} entries via ${source} in ${cwd}`);
|
|
3501
|
+
return files;
|
|
3502
|
+
}
|
|
3503
|
+
async function cachedIndex(cwd) {
|
|
3504
|
+
const now = Date.now();
|
|
3505
|
+
const cached = indexes.get(cwd);
|
|
3506
|
+
if (cached && now - cached.at < INDEX_TTL_MS) {
|
|
3507
|
+
return cached;
|
|
3508
|
+
}
|
|
3509
|
+
const pending = inFlight.get(cwd);
|
|
3510
|
+
if (pending) {
|
|
3511
|
+
return pending;
|
|
3512
|
+
}
|
|
3513
|
+
const task = indexWorkspace(cwd).then((index) => {
|
|
3514
|
+
const entry = { ...index, at: Date.now() };
|
|
3515
|
+
indexes.set(cwd, entry);
|
|
3516
|
+
return entry;
|
|
3517
|
+
}).finally(() => {
|
|
3518
|
+
inFlight.delete(cwd);
|
|
3519
|
+
});
|
|
3520
|
+
inFlight.set(cwd, task);
|
|
3521
|
+
return task;
|
|
3522
|
+
}
|
|
3523
|
+
async function indexWorkspace(cwd) {
|
|
3524
|
+
const fromGit = await gitFileList(cwd);
|
|
3525
|
+
if (fromGit) {
|
|
3526
|
+
return { files: indexEntries(fromGit), source: "git" };
|
|
3527
|
+
}
|
|
3528
|
+
const fromWalk = [];
|
|
3529
|
+
await walk(cwd, cwd, fromWalk);
|
|
3530
|
+
return { files: indexEntries(fromWalk), source: "walk" };
|
|
3531
|
+
}
|
|
3532
|
+
function indexEntries(paths) {
|
|
3533
|
+
const entries = new Set(dedupe(paths).slice(0, MAX_INDEXED_FILES));
|
|
3534
|
+
for (const path of [...entries]) {
|
|
3535
|
+
const segments = path.split("/");
|
|
3536
|
+
for (let depth = 1; depth < segments.length; depth += 1) {
|
|
3537
|
+
entries.add(`${segments.slice(0, depth).join("/")}/`);
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
return [...entries].sort();
|
|
3541
|
+
}
|
|
3542
|
+
function dedupe(paths) {
|
|
3543
|
+
return [...new Set(paths)];
|
|
3544
|
+
}
|
|
3545
|
+
async function gitFileList(cwd) {
|
|
3546
|
+
try {
|
|
3547
|
+
const [prefix, listed] = await Promise.all([
|
|
3548
|
+
runGit(["rev-parse", "--show-prefix"], cwd),
|
|
3549
|
+
runGit(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--full-name"], cwd)
|
|
3550
|
+
]);
|
|
3551
|
+
if (listed === void 0) {
|
|
3552
|
+
return void 0;
|
|
3553
|
+
}
|
|
3554
|
+
const root = prefix?.trim() ?? "";
|
|
3555
|
+
return listed.split("\0").filter((path) => path.length > 0).filter((path) => root.length === 0 || path.startsWith(root)).map((path) => path.slice(root.length)).filter((path) => path.length > 0 && !isInSkippedDirectory(path));
|
|
3556
|
+
} catch {
|
|
3557
|
+
return void 0;
|
|
3558
|
+
}
|
|
3559
|
+
}
|
|
3560
|
+
function runGit(args, cwd) {
|
|
3561
|
+
return new Promise((resolve4) => {
|
|
3562
|
+
execFile(
|
|
3563
|
+
"git",
|
|
3564
|
+
args,
|
|
3565
|
+
{ cwd, timeout: 5e3, maxBuffer: 8 * 1024 * 1024, windowsHide: true },
|
|
3566
|
+
(error, stdout) => resolve4(error ? void 0 : stdout)
|
|
3567
|
+
);
|
|
3568
|
+
});
|
|
3569
|
+
}
|
|
3570
|
+
function isInSkippedDirectory(path) {
|
|
3571
|
+
const folders = path.split("/").slice(0, -1);
|
|
3572
|
+
return folders.some((folder) => SKIPPED_DIRECTORIES2.has(folder) || folder.startsWith("."));
|
|
3573
|
+
}
|
|
3574
|
+
async function walk(root, directory, files) {
|
|
3575
|
+
if (files.length >= MAX_INDEXED_FILES) {
|
|
3576
|
+
return;
|
|
3577
|
+
}
|
|
3578
|
+
let entries;
|
|
3579
|
+
try {
|
|
3580
|
+
entries = await readdir3(directory, { withFileTypes: true });
|
|
3581
|
+
} catch {
|
|
3582
|
+
return;
|
|
3583
|
+
}
|
|
3584
|
+
for (const entry of entries) {
|
|
3585
|
+
if (files.length >= MAX_INDEXED_FILES) {
|
|
3586
|
+
return;
|
|
3587
|
+
}
|
|
3588
|
+
const full = join6(directory, entry.name);
|
|
3589
|
+
if (entry.isDirectory()) {
|
|
3590
|
+
if (SKIPPED_DIRECTORIES2.has(entry.name) || entry.name.startsWith(".")) {
|
|
3591
|
+
continue;
|
|
3592
|
+
}
|
|
3593
|
+
await walk(root, full, files);
|
|
3594
|
+
continue;
|
|
3595
|
+
}
|
|
3596
|
+
if (entry.isFile() || entry.isSymbolicLink()) {
|
|
3597
|
+
files.push(toPosix(relative2(root, full)));
|
|
3598
|
+
}
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
function toPosix(path) {
|
|
3602
|
+
return path.split("\\").join("/");
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3605
|
+
// ../server/src/internal/ws/session-factory.service.ts
|
|
3606
|
+
var MorseSessionFactory = class {
|
|
3607
|
+
constructor(registry, transcripts, policy, logger, config) {
|
|
3608
|
+
this.registry = registry;
|
|
3609
|
+
this.transcripts = transcripts;
|
|
3610
|
+
this.policy = policy;
|
|
3611
|
+
this.logger = logger;
|
|
3612
|
+
this.config = config;
|
|
3613
|
+
}
|
|
3614
|
+
capabilities() {
|
|
3615
|
+
return {
|
|
3616
|
+
hostKind: "server",
|
|
3617
|
+
// The browser host is global: every project pi knows about.
|
|
3618
|
+
scope: "global",
|
|
3619
|
+
// A browser has no local editor to read, so there is nothing to pin.
|
|
3620
|
+
editorContext: false,
|
|
3621
|
+
// No QuickPick/InputBox here, so the frontend renders interactions.
|
|
3622
|
+
nativeDialogs: false,
|
|
3623
|
+
insertIntoEditor: false,
|
|
3624
|
+
revealFile: false,
|
|
3625
|
+
// The server can walk the active session's directory, so the composer's
|
|
3626
|
+
// `@` picker works here too — the same list VS Code offers for a workspace.
|
|
3627
|
+
filePicker: true,
|
|
3628
|
+
// A browser cannot hand a dragged file's path to pi, so the host takes the
|
|
3629
|
+
// bytes and writes them next to the session; the frontend then `@mentions`
|
|
3630
|
+
// the path it gets back.
|
|
3631
|
+
fileUpload: true,
|
|
3632
|
+
// The browser host has no workspace folder, so "New session" has to ask
|
|
3633
|
+
// which directory the agent should run in. The frontend opens its folder
|
|
3634
|
+
// modal; VS Code has a folder already and leaves this off.
|
|
3635
|
+
directoryPicker: true,
|
|
3636
|
+
// pi's fork re-parents the conversation, so a past prompt can be edited.
|
|
3637
|
+
editMessage: true,
|
|
3638
|
+
// The same fork can branch a new session and hand the prompt back instead.
|
|
3639
|
+
forkMessage: true
|
|
3640
|
+
};
|
|
3641
|
+
}
|
|
3642
|
+
createFor(emit) {
|
|
3643
|
+
const services = {
|
|
3644
|
+
registry: this.registry,
|
|
3645
|
+
chat: new ChatService({ agent: this.registry, logger: this.logger })
|
|
3646
|
+
};
|
|
3647
|
+
return new HostSessionController({
|
|
3648
|
+
services,
|
|
3649
|
+
capabilities: this.capabilities(),
|
|
3650
|
+
emit,
|
|
3651
|
+
logger: this.logger,
|
|
3652
|
+
transcripts: this.transcripts,
|
|
3653
|
+
policy: this.policy,
|
|
3654
|
+
scope: { kind: "global" },
|
|
3655
|
+
// The registry outlives this connection on purpose.
|
|
3656
|
+
ownsRegistry: false,
|
|
3657
|
+
// A page load must not create a session; the first prompt opens one.
|
|
3658
|
+
autoOpen: false,
|
|
3659
|
+
onHostCommand: (command, args, context) => this.runHostCommand(command, args, context),
|
|
3660
|
+
agentHint: "Install the pi CLI and make sure it is on PATH, or start the server with MORSE_PI_PATH set."
|
|
3661
|
+
});
|
|
3662
|
+
}
|
|
3663
|
+
/**
|
|
3664
|
+
* The host commands this host answers: browsing the filesystem for a project
|
|
3665
|
+
* folder, listing the active session's files for the composer's `@mention`
|
|
3666
|
+
* picker, and writing a browser upload down next to the session.
|
|
3667
|
+
*
|
|
3668
|
+
* `listDirectories` is read-only and may look anywhere the server can read;
|
|
3669
|
+
* the commands that touch a session are scoped to its cwd (which
|
|
3670
|
+
* `ProjectPolicy` already approved when the session was opened), never to a
|
|
3671
|
+
* path the client names.
|
|
3672
|
+
*/
|
|
3673
|
+
async runHostCommand(command, args, context) {
|
|
3674
|
+
switch (command) {
|
|
3675
|
+
case "listDirectories": {
|
|
3676
|
+
const requested = typeof args?.path === "string" ? args.path : void 0;
|
|
3677
|
+
const listing = await browseDirectory(requested, {
|
|
3678
|
+
roots: this.policy.allowList,
|
|
3679
|
+
defaultPath: this.registry.defaultWorkspace.cwd
|
|
3680
|
+
});
|
|
3681
|
+
return { ...listing, canOpen: this.policy.canOpen(listing.path) };
|
|
3682
|
+
}
|
|
3683
|
+
case "listFiles": {
|
|
3684
|
+
const cwd = this.requireWritableCwd(context);
|
|
3685
|
+
const files = await workspaceFiles(cwd, this.logger);
|
|
3686
|
+
return { files };
|
|
3687
|
+
}
|
|
3688
|
+
case "uploadFile": {
|
|
3689
|
+
const cwd = this.requireWritableCwd(context);
|
|
3690
|
+
const saved = await saveUpload(cwd, args ?? {}, this.config.uploadDir);
|
|
3691
|
+
this.logger.info(`Upload stored: ${saved.path} (${saved.bytes} bytes)`);
|
|
3692
|
+
return saved;
|
|
3693
|
+
}
|
|
3694
|
+
default:
|
|
3695
|
+
throw new UnsupportedByHostError(`This host does not support "${command}".`);
|
|
3696
|
+
}
|
|
3697
|
+
}
|
|
3698
|
+
/**
|
|
3699
|
+
* The directory the command should touch, after `ProjectPolicy` approved it.
|
|
3700
|
+
* The controller hands in what the client is viewing; the registry's active
|
|
3701
|
+
* session is only a fallback for a host command issued without one.
|
|
3702
|
+
*/
|
|
3703
|
+
requireWritableCwd(context) {
|
|
3704
|
+
const cwd = context?.cwd ?? this.activeCwd();
|
|
3705
|
+
if (!cwd) {
|
|
3706
|
+
throw new UnsupportedByHostError("No active Morse session yet.");
|
|
3707
|
+
}
|
|
3708
|
+
if (!this.policy.canOpen(cwd)) {
|
|
3709
|
+
throw new UnsupportedByHostError(`Morse is not allowed to work in ${cwd}.`);
|
|
3710
|
+
}
|
|
3711
|
+
return cwd;
|
|
3712
|
+
}
|
|
3713
|
+
/** The cwd of the session this connection is showing. */
|
|
3714
|
+
activeCwd() {
|
|
3715
|
+
const key = this.registry.activeKeyOf();
|
|
3716
|
+
const workspace = key === void 0 ? void 0 : this.registry.stateOf(key)?.workspace;
|
|
3717
|
+
return workspace?.cwd ?? this.registry.defaultWorkspace.cwd;
|
|
3718
|
+
}
|
|
3719
|
+
};
|
|
3720
|
+
MorseSessionFactory = __decorateClass([
|
|
3721
|
+
Injectable2(),
|
|
3722
|
+
__decorateParam(0, Inject3(MORSE_SESSION_REGISTRY)),
|
|
3723
|
+
__decorateParam(1, Inject3(MORSE_TRANSCRIPT_STORE)),
|
|
3724
|
+
__decorateParam(2, Inject3(MORSE_PROJECT_POLICY)),
|
|
3725
|
+
__decorateParam(3, Inject3(MORSE_LOGGER)),
|
|
3726
|
+
__decorateParam(4, Inject3(MORSE_CONFIG))
|
|
3727
|
+
], MorseSessionFactory);
|
|
3728
|
+
|
|
3729
|
+
// ../server/src/internal/ws/morse.gateway.ts
|
|
3730
|
+
var MorseGateway = class {
|
|
3731
|
+
constructor(sessions, logger) {
|
|
3732
|
+
this.sessions = sessions;
|
|
3733
|
+
this.logger = logger;
|
|
3734
|
+
this.clients = /* @__PURE__ */ new Map();
|
|
3735
|
+
}
|
|
3736
|
+
handleConnection(client) {
|
|
3737
|
+
const emit = (message) => {
|
|
3738
|
+
if (client.readyState === 1) {
|
|
3739
|
+
client.send(encodeWireMessage(message));
|
|
3740
|
+
}
|
|
3741
|
+
};
|
|
3742
|
+
const controller = this.sessions.createFor(emit);
|
|
3743
|
+
this.clients.set(client, controller);
|
|
3744
|
+
client.on("message", (data) => {
|
|
3745
|
+
void this.onMessage(controller, data);
|
|
3746
|
+
});
|
|
3747
|
+
client.on("error", (error) => {
|
|
3748
|
+
this.logger.warn("WebSocket error", error);
|
|
3749
|
+
});
|
|
3750
|
+
void controller.start().catch((error) => {
|
|
3751
|
+
this.logger.error("Failed to start a Morse session for a new client", error);
|
|
3752
|
+
});
|
|
3753
|
+
}
|
|
3754
|
+
handleDisconnect(client) {
|
|
3755
|
+
const controller = this.clients.get(client);
|
|
3756
|
+
this.clients.delete(client);
|
|
3757
|
+
void controller?.dispose().catch((error) => {
|
|
3758
|
+
this.logger.warn("Failed to dispose a Morse session controller", error);
|
|
3759
|
+
});
|
|
3760
|
+
}
|
|
3761
|
+
async onMessage(controller, data) {
|
|
3762
|
+
const message = parseClientMessage(decode(data));
|
|
3763
|
+
if (!message) {
|
|
3764
|
+
this.logger.warn("Ignoring an unrecognised client message");
|
|
3765
|
+
return;
|
|
3766
|
+
}
|
|
3767
|
+
await controller.handleClientMessage(message);
|
|
3768
|
+
}
|
|
3769
|
+
};
|
|
3770
|
+
MorseGateway = __decorateClass([
|
|
3771
|
+
WebSocketGateway({ path: DEFAULT_WS_PATH }),
|
|
3772
|
+
__decorateParam(0, Inject4(MorseSessionFactory)),
|
|
3773
|
+
__decorateParam(1, Inject4(MORSE_LOGGER))
|
|
3774
|
+
], MorseGateway);
|
|
3775
|
+
|
|
3776
|
+
// ../server/src/app/config.ts
|
|
3777
|
+
import { basename as basename4, dirname as dirname2, isAbsolute as isAbsolute5, join as join7 } from "node:path";
|
|
3778
|
+
import { existsSync as existsSync2, readFileSync } from "node:fs";
|
|
3779
|
+
import { fileURLToPath } from "node:url";
|
|
3780
|
+
function loadConfig(env = process.env) {
|
|
3781
|
+
const workspacePath = resolveWorkspacePath(env);
|
|
3782
|
+
const configuredUiDir = env.MORSE_UI_DIR;
|
|
3783
|
+
const moduleDir = dirname2(fileURLToPath(import.meta.url));
|
|
3784
|
+
const bundledUiDir = join7(moduleDir, "ui");
|
|
3785
|
+
const monorepoUiDir = join7(moduleDir, "..", "..", "..", "ui-angular", "dist");
|
|
3786
|
+
const defaultUiDir = existsSync2(join7(bundledUiDir, "index.html")) ? bundledUiDir : monorepoUiDir;
|
|
3787
|
+
const defaultHost = "127.0.0.1";
|
|
3788
|
+
return {
|
|
3789
|
+
host: env.MORSE_HOST ?? defaultHost,
|
|
3790
|
+
port: parsePositiveInt(env.MORSE_PORT, 4399),
|
|
3791
|
+
uiDir: configuredUiDir ? isAbsolute5(configuredUiDir) ? configuredUiDir : join7(process.cwd(), configuredUiDir) : defaultUiDir,
|
|
3792
|
+
workspace: { cwd: workspacePath, name: basename4(workspacePath) || workspacePath },
|
|
3793
|
+
piPath: env.MORSE_PI_PATH,
|
|
3794
|
+
nodeEntryPath: env.MORSE_PI_ENTRY,
|
|
3795
|
+
sessionDir: env.MORSE_SESSION_DIR,
|
|
3796
|
+
noSession: env.MORSE_NO_SESSION === "1",
|
|
3797
|
+
requestTimeoutMs: parsePositiveInt(env.MORSE_REQUEST_TIMEOUT_MS, 3e4),
|
|
3798
|
+
hotSessions: parsePositiveInt(env.MORSE_HOT_SESSIONS, 4),
|
|
3799
|
+
projects: parseList(env.MORSE_PROJECTS),
|
|
3800
|
+
uploadDir: env.MORSE_UPLOAD_DIR?.trim() || DEFAULT_UPLOAD_DIR
|
|
3801
|
+
};
|
|
3802
|
+
}
|
|
3803
|
+
function parsePositiveInt(value, fallback) {
|
|
3804
|
+
if (!value) {
|
|
3805
|
+
return fallback;
|
|
3806
|
+
}
|
|
3807
|
+
const parsed = Number.parseInt(value, 10);
|
|
3808
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
|
3809
|
+
}
|
|
3810
|
+
function parseList(value) {
|
|
3811
|
+
if (!value) {
|
|
3812
|
+
return [];
|
|
3813
|
+
}
|
|
3814
|
+
return value.split(/[:,]/).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
3815
|
+
}
|
|
3816
|
+
function resolveWorkspacePath(env) {
|
|
3817
|
+
const explicit = env.MORSE_WORKSPACE;
|
|
3818
|
+
if (explicit && explicit.trim().length > 0) {
|
|
3819
|
+
return explicit;
|
|
3820
|
+
}
|
|
3821
|
+
const start = process.cwd();
|
|
3822
|
+
let directory = start;
|
|
3823
|
+
for (; ; ) {
|
|
3824
|
+
const manifest = join7(directory, "package.json");
|
|
3825
|
+
if (existsSync2(manifest) && declaresWorkspaces(manifest)) {
|
|
3826
|
+
return directory;
|
|
3827
|
+
}
|
|
3828
|
+
const parent = dirname2(directory);
|
|
3829
|
+
if (parent === directory) {
|
|
3830
|
+
return start;
|
|
3831
|
+
}
|
|
3832
|
+
directory = parent;
|
|
3833
|
+
}
|
|
3834
|
+
}
|
|
3835
|
+
function declaresWorkspaces(manifestPath) {
|
|
3836
|
+
try {
|
|
3837
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
3838
|
+
return manifest.workspaces !== void 0;
|
|
3839
|
+
} catch {
|
|
3840
|
+
return false;
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
|
|
3844
|
+
// ../server/src/app.module.ts
|
|
3845
|
+
var AppModule = class {
|
|
3846
|
+
};
|
|
3847
|
+
AppModule = __decorateClass([
|
|
3848
|
+
Module({
|
|
3849
|
+
controllers: [HealthController],
|
|
3850
|
+
providers: [
|
|
3851
|
+
{
|
|
3852
|
+
provide: MORSE_CONFIG,
|
|
3853
|
+
useFactory: () => loadConfig()
|
|
3854
|
+
},
|
|
3855
|
+
{
|
|
3856
|
+
provide: MORSE_LOGGER,
|
|
3857
|
+
useFactory: () => new NestMorseLogger()
|
|
3858
|
+
},
|
|
3859
|
+
{
|
|
3860
|
+
provide: MORSE_PI_ADAPTER,
|
|
3861
|
+
useFactory: (config, logger) => createPiRpcAdapter(
|
|
3862
|
+
{
|
|
3863
|
+
piPath: config.piPath,
|
|
3864
|
+
nodeEntryPath: config.nodeEntryPath,
|
|
3865
|
+
sessionDir: config.sessionDir,
|
|
3866
|
+
noSession: config.noSession,
|
|
3867
|
+
requestTimeoutMs: config.requestTimeoutMs
|
|
3868
|
+
},
|
|
3869
|
+
logger
|
|
3870
|
+
),
|
|
3871
|
+
inject: [MORSE_CONFIG, MORSE_LOGGER]
|
|
3872
|
+
},
|
|
3873
|
+
{
|
|
3874
|
+
provide: MORSE_SESSION_REGISTRY,
|
|
3875
|
+
useFactory: (adapter, config, logger) => createSessionRegistry(adapter, config, logger),
|
|
3876
|
+
inject: [MORSE_PI_ADAPTER, MORSE_CONFIG, MORSE_LOGGER]
|
|
3877
|
+
},
|
|
3878
|
+
{
|
|
3879
|
+
provide: MORSE_TRANSCRIPT_STORE,
|
|
3880
|
+
useFactory: () => new SessionTranscriptStore()
|
|
3881
|
+
},
|
|
3882
|
+
{
|
|
3883
|
+
provide: MORSE_PROJECT_POLICY,
|
|
3884
|
+
useFactory: (config) => new ServerProjectPolicy(config.projects),
|
|
3885
|
+
inject: [MORSE_CONFIG]
|
|
3886
|
+
},
|
|
3887
|
+
MorseRegistryLifecycle,
|
|
3888
|
+
MorseSessionFactory,
|
|
3889
|
+
MorseGateway
|
|
3890
|
+
]
|
|
3891
|
+
})
|
|
3892
|
+
], AppModule);
|
|
3893
|
+
|
|
3894
|
+
// ../server/src/main.ts
|
|
3895
|
+
async function bootstrap() {
|
|
3896
|
+
const app = await NestFactory.create(AppModule);
|
|
3897
|
+
const config = app.get(MORSE_CONFIG);
|
|
3898
|
+
app.useWebSocketAdapter(new WsAdapter(app));
|
|
3899
|
+
app.useStaticAssets(config.uiDir);
|
|
3900
|
+
app.enableCors({ origin: true });
|
|
3901
|
+
app.enableShutdownHooks();
|
|
3902
|
+
await app.listen(config.port, config.host);
|
|
3903
|
+
const logger = new Logger2("morse");
|
|
3904
|
+
logger.log(`Morse server listening on http://${config.host}:${config.port}`);
|
|
3905
|
+
logger.log(`WebSocket endpoint: ws://${config.host}:${config.port}${DEFAULT_WS_PATH}`);
|
|
3906
|
+
logger.log(`Frontend served from ${config.uiDir}`);
|
|
3907
|
+
logger.log(`Workspace: ${config.workspace.cwd} (override with MORSE_WORKSPACE)`);
|
|
3908
|
+
}
|
|
3909
|
+
void bootstrap();
|
|
3910
|
+
//# sourceMappingURL=server.mjs.map
|