antechamber 0.1.0 → 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/CHANGELOG.md +51 -0
- package/README.md +17 -14
- package/bin/opencode-antechamber +10 -1
- package/bin/opencode-pi +11 -1
- package/dist/approvals.d.ts +4 -1
- package/dist/approvals.js +51 -33
- package/dist/catalog.d.ts +1 -1
- package/dist/catalog.js +1 -1
- package/dist/commands/context.d.ts +0 -1
- package/dist/commands/context.js +1 -1
- package/dist/commands/doctor.d.ts +3 -2
- package/dist/commands/serve.d.ts +2 -1
- package/dist/commands/setup.d.ts +2 -3
- package/dist/commands/setup.js +16 -4
- package/dist/http.d.ts +8 -1
- package/dist/http.js +42 -19
- package/dist/pi-event-translation.d.ts +21 -3
- package/dist/pi-event-translation.js +317 -65
- package/dist/pi-rpc.d.ts +26 -2
- package/dist/pi-rpc.js +102 -29
- package/dist/prompt-parts.d.ts +40 -0
- package/dist/prompt-parts.js +157 -0
- package/dist/route-services.d.ts +45 -2
- package/dist/route-services.js +70 -3
- package/dist/routes-v1.js +110 -65
- package/dist/routes-v2.js +376 -73
- package/dist/server.d.ts +9 -1
- package/dist/server.js +109 -9
- package/dist/session-commands.d.ts +20 -15
- package/dist/session-commands.js +61 -11
- package/dist/session-diff.d.ts +1 -1
- package/dist/session-diff.js +10 -2
- package/dist/session-history.js +113 -42
- package/dist/session-hosts.js +2 -2
- package/dist/session-store.d.ts +8 -4
- package/dist/session-store.js +183 -33
- package/dist/session-tree.d.ts +9 -2
- package/dist/session-tree.js +51 -63
- package/dist/sessions.d.ts +34 -6
- package/dist/sessions.js +340 -146
- package/dist/sse.d.ts +16 -6
- package/dist/sse.js +77 -21
- package/dist/subagents.d.ts +5 -0
- package/dist/subagents.js +86 -69
- package/dist/tool-map.d.ts +3 -2
- package/dist/types.d.ts +164 -1
- package/dist/v2.d.ts +18 -4
- package/dist/v2.js +141 -50
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/omo-dag/README.md +2 -0
- package/extensions/omo-dag/docs/DESIGN.md +10 -1
- package/extensions/omo-dag/panel/index.html +1 -1
- package/extensions/omo-dag/panel/main.js +60 -60
- package/extensions/omo-dag/service/main.js +72 -8
- package/package.json +1 -1
package/dist/sessions.js
CHANGED
|
@@ -2,24 +2,29 @@
|
|
|
2
2
|
* Session registry: one OpenCode session maps to one `pi --mode rpc` process.
|
|
3
3
|
* Composes the live event bridge and restored-session history reconstruction.
|
|
4
4
|
*/
|
|
5
|
-
import { id, PiEventTranslator } from "./pi-event-translation.js";
|
|
5
|
+
import { id, PiEventTranslator, syncSessionCost } from "./pi-event-translation.js";
|
|
6
6
|
import { parsePiSessionFile, parsePiSessionText } from "./session-history.js";
|
|
7
|
+
import { CONTEXT_MESSAGE_TYPE, nativePromptInput, v2PromptAttachments } from "./prompt-parts.js";
|
|
7
8
|
import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
|
8
9
|
import { join } from "node:path";
|
|
9
|
-
import { PiRpcClient, RPC_COMMAND_TIMEOUT_MS } from "./pi-rpc.js";
|
|
10
|
+
import { COMPACT_RPC_TIMEOUT_MS, PiRpcClient, RPC_COMMAND_TIMEOUT_MS, } from "./pi-rpc.js";
|
|
10
11
|
import { stopSessionHosts } from "./session-hosts.js";
|
|
11
12
|
import { setThinkingLevel } from "./thinking-levels.js";
|
|
12
13
|
import { SessionLifecycle } from "./lifecycle.js";
|
|
13
14
|
import { ApprovalBridge, extensionUiRequestFrom } from "./approvals.js";
|
|
14
|
-
import { getAgentName } from "./agent-name.js";
|
|
15
|
-
import {
|
|
15
|
+
import { getAgentName, getExposedAgentNames } from "./agent-name.js";
|
|
16
|
+
import { RequestBodyError } from "./http.js";
|
|
17
|
+
import { bindMessageEntries, durableShellTree, forkConversation, getEntries, navigate, readSessionTree, revertConversation, SessionTreeError, unrevertConversation, } from "./session-tree.js";
|
|
16
18
|
import { SubagentBridge } from "./subagents.js";
|
|
17
19
|
import { sessionDiff } from "./session-diff.js";
|
|
18
|
-
import { announceSessionNotice } from "./session-commands.js";
|
|
19
|
-
import { ocIdFor, piSessionDir, piSessionsRootMtime, scanAllPiSessions, scanPiSessions,
|
|
20
|
+
import { announceSessionNotice, slashTitlePrompt } from "./session-commands.js";
|
|
21
|
+
import { ocIdFor, piSessionDir, piSessionsRootMtime, scanAllPiSessions, scanPiSessions, SessionStore, readSessionTitle, } from "./session-store.js";
|
|
20
22
|
import { ADAPTER_VERSION } from "./version.js";
|
|
21
23
|
export { ADAPTER_VERSION } from "./version.js";
|
|
22
24
|
const SESSION_SCAN_CACHE_TTL_MS = 5_000;
|
|
25
|
+
const PI_FILE_RESOLVE_ATTEMPTS = 10;
|
|
26
|
+
const PI_FILE_RESOLVE_RETRY_MS = 200;
|
|
27
|
+
const SESSION_TITLE_MAX_CHARS = 60;
|
|
23
28
|
export class SessionManager {
|
|
24
29
|
emit;
|
|
25
30
|
defaultDirectory;
|
|
@@ -57,10 +62,14 @@ export class SessionManager {
|
|
|
57
62
|
persistMeta: (s) => this.persistMeta(s),
|
|
58
63
|
});
|
|
59
64
|
}
|
|
60
|
-
async create(directory, title, parentID) {
|
|
61
|
-
|
|
65
|
+
async create(directory, title, parentID, options = {}) {
|
|
66
|
+
if (options.agent !== undefined && !getExposedAgentNames().includes(options.agent))
|
|
67
|
+
throw new RequestBodyError(400, `unknown agent: ${options.agent}`);
|
|
68
|
+
return this.lifecycle.run(() => this.createLive(directory, title, parentID, options));
|
|
62
69
|
}
|
|
63
|
-
async createLive(directory, title, parentID) {
|
|
70
|
+
async createLive(directory, title, parentID, options = {}) {
|
|
71
|
+
if (options.id !== undefined && (options.id === "" || this.get(options.id) !== undefined))
|
|
72
|
+
throw new RequestBodyError(400, "session id must be nonempty and unused");
|
|
64
73
|
await this.lifecycle.makeRoom();
|
|
65
74
|
const pi = new PiRpcClient({ cwd: directory, clock: this.lifecycle.clock });
|
|
66
75
|
const state = await pi.command({ type: "get_state" }).catch(async (error) => {
|
|
@@ -78,12 +87,18 @@ export class SessionManager {
|
|
|
78
87
|
if (piSessionId !== undefined)
|
|
79
88
|
this.createdSessions.set(piSessionId, directory);
|
|
80
89
|
const info = {
|
|
81
|
-
id: piSessionId !== undefined ? ocIdFor(piSessionId) : id("ses"),
|
|
90
|
+
id: options.id ?? (piSessionId !== undefined ? ocIdFor(piSessionId) : id("ses")),
|
|
82
91
|
projectID: directory,
|
|
83
92
|
directory,
|
|
84
93
|
title: title?.trim() || "New session",
|
|
85
94
|
version: ADAPTER_VERSION,
|
|
86
95
|
time: { created: now, updated: now },
|
|
96
|
+
agent: options.agent ?? getAgentName(),
|
|
97
|
+
slug: piSessionId ?? id("session"),
|
|
98
|
+
path: "",
|
|
99
|
+
summary: { additions: 0, deletions: 0, files: 0 },
|
|
100
|
+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
101
|
+
...(options.metadata !== undefined ? { metadata: options.metadata } : {}),
|
|
87
102
|
...(parentID !== undefined ? { parentID } : {}),
|
|
88
103
|
};
|
|
89
104
|
const managed = {
|
|
@@ -101,44 +116,73 @@ export class SessionManager {
|
|
|
101
116
|
...(piSessionId !== undefined ? { piSessionId } : {}),
|
|
102
117
|
...(state.data.thinkingLevel !== undefined ? { thinkingLevel: state.data.thinkingLevel } : {}),
|
|
103
118
|
};
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
119
|
+
let published = false;
|
|
120
|
+
try {
|
|
121
|
+
managed.approvals = this.approvalsFor(managed);
|
|
122
|
+
this.sessions.set(info.id, managed);
|
|
123
|
+
published = true;
|
|
124
|
+
this.subagents.wire(managed);
|
|
125
|
+
this.lifecycle.touch(managed);
|
|
126
|
+
this.wirePi(managed, pi);
|
|
127
|
+
if (options.model !== undefined)
|
|
128
|
+
await this.setModel(info.id, options.model);
|
|
129
|
+
this.emit(directory, { type: "session.created", properties: { info } });
|
|
130
|
+
// Announce pi's real model *after* session.created, so the client already
|
|
131
|
+
// knows the session when the switch marker lands in its transcript.
|
|
132
|
+
if (options.model === undefined)
|
|
133
|
+
this.applyPiModel(managed, state.data.model?.provider, state.data.model?.id);
|
|
134
|
+
// Bind pi's session file so the session survives adapter restarts. pi
|
|
135
|
+
// reports the path directly; fall back to a background scan for older pi
|
|
136
|
+
// versions, and re-bind after the first prompt (pi persists lazily).
|
|
137
|
+
if (typeof state.data.sessionFile === "string" && state.data.sessionFile !== "") {
|
|
138
|
+
managed.piPath = state.data.sessionFile;
|
|
139
|
+
this.persistMeta(managed);
|
|
140
|
+
}
|
|
141
|
+
else if (piSessionId !== undefined) {
|
|
142
|
+
void this.resolvePiFile(directory, piSessionId)
|
|
143
|
+
.then((piPath) => {
|
|
144
|
+
if (this.sessions.get(info.id) !== managed || piPath === undefined || managed.piPath !== undefined)
|
|
145
|
+
return;
|
|
124
146
|
managed.piPath = piPath;
|
|
125
147
|
this.persistMeta(managed);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (
|
|
133
|
-
|
|
148
|
+
})
|
|
149
|
+
.catch((error) => {
|
|
150
|
+
if (this.sessions.get(info.id) === managed)
|
|
151
|
+
this.reportPersistenceError(managed, error);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (managed.titleSource === "user") {
|
|
155
|
+
const renamed = await pi.command({ type: "set_session_name", name: info.title });
|
|
156
|
+
if (!renamed.success)
|
|
157
|
+
throw new Error(renamed.error ?? "session rename rejected");
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
this.applyNativeTitle(managed, state.data.sessionName);
|
|
161
|
+
}
|
|
162
|
+
const reconciled = await pi.command({ type: "get_state" });
|
|
163
|
+
if (reconciled.success)
|
|
164
|
+
this.applyNativeTitle(managed, reconciled.data?.sessionName);
|
|
165
|
+
return info;
|
|
134
166
|
}
|
|
135
|
-
|
|
136
|
-
|
|
167
|
+
catch (error) {
|
|
168
|
+
if (published)
|
|
169
|
+
await this.rollbackCreate(managed, piSessionId);
|
|
170
|
+
else
|
|
171
|
+
await pi.dispose();
|
|
172
|
+
throw error;
|
|
137
173
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
174
|
+
}
|
|
175
|
+
async rollbackCreate(managed, piSessionId) {
|
|
176
|
+
this.sessions.delete(managed.info.id);
|
|
177
|
+
if (piSessionId !== undefined)
|
|
178
|
+
this.createdSessions.delete(piSessionId);
|
|
179
|
+
managed.unwirePi?.();
|
|
180
|
+
const pi = managed.pi;
|
|
181
|
+
managed.pi = undefined;
|
|
182
|
+
if (pi !== undefined)
|
|
183
|
+
await pi.dispose();
|
|
184
|
+
this.store.remove(managed.info.id);
|
|
185
|
+
this.emit(managed.info.directory, { type: "session.deleted", properties: { info: managed.info } });
|
|
142
186
|
}
|
|
143
187
|
applyNativeTitle(s, value) {
|
|
144
188
|
if (s.titleSource === "user" || typeof value !== "string")
|
|
@@ -186,12 +230,15 @@ export class SessionManager {
|
|
|
186
230
|
const onExit = (code) => {
|
|
187
231
|
if (s.pi !== pi)
|
|
188
232
|
return;
|
|
189
|
-
|
|
233
|
+
this.events.finishInterruptedRun(s, {
|
|
234
|
+
name: "UnknownError",
|
|
235
|
+
data: { message: `pi process exited (code=${code})` },
|
|
236
|
+
});
|
|
237
|
+
delete s.interruptRequested;
|
|
190
238
|
this.subagents.stopParent(s.info.id);
|
|
191
239
|
void this.lifecycle
|
|
192
240
|
.dormant(s)
|
|
193
241
|
.catch((error) => console.error("[lifecycle] exit cleanup failed:", error));
|
|
194
|
-
this.events.setStatus(s, { type: "idle" });
|
|
195
242
|
this.emit(s.info.directory, {
|
|
196
243
|
type: "session.error",
|
|
197
244
|
properties: {
|
|
@@ -199,6 +246,7 @@ export class SessionManager {
|
|
|
199
246
|
error: { name: "UnknownError", data: { message: `pi process exited (code=${code})` } },
|
|
200
247
|
},
|
|
201
248
|
});
|
|
249
|
+
this.events.setStatus(s, { type: "idle" });
|
|
202
250
|
};
|
|
203
251
|
pi.onEvent(onEvent);
|
|
204
252
|
pi.onExtensionUI(onExtensionUI);
|
|
@@ -214,7 +262,7 @@ export class SessionManager {
|
|
|
214
262
|
async resolvePiFile(directory, piSessionId) {
|
|
215
263
|
const dir = piSessionDir(directory);
|
|
216
264
|
const suffix = `_${piSessionId}.jsonl`;
|
|
217
|
-
for (let attempt = 0; attempt <
|
|
265
|
+
for (let attempt = 0; attempt < PI_FILE_RESOLVE_ATTEMPTS; attempt++) {
|
|
218
266
|
try {
|
|
219
267
|
for (const name of readdirSync(dir)) {
|
|
220
268
|
if (name.endsWith(suffix))
|
|
@@ -224,7 +272,7 @@ export class SessionManager {
|
|
|
224
272
|
catch {
|
|
225
273
|
/* session dir may not exist until pi writes the first entry */
|
|
226
274
|
}
|
|
227
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
275
|
+
await new Promise((resolve) => setTimeout(resolve, PI_FILE_RESOLVE_RETRY_MS));
|
|
228
276
|
}
|
|
229
277
|
return undefined;
|
|
230
278
|
}
|
|
@@ -277,8 +325,9 @@ export class SessionManager {
|
|
|
277
325
|
try {
|
|
278
326
|
unlinkSync(piPath);
|
|
279
327
|
}
|
|
280
|
-
catch {
|
|
281
|
-
|
|
328
|
+
catch (error) {
|
|
329
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
|
|
330
|
+
throw error;
|
|
282
331
|
}
|
|
283
332
|
}
|
|
284
333
|
this.store.remove(sessionId);
|
|
@@ -290,14 +339,26 @@ export class SessionManager {
|
|
|
290
339
|
* (SDK v2 session.update); `title` is only touched when actually sent,
|
|
291
340
|
* otherwise an archive-only PATCH would blank the title.
|
|
292
341
|
*/
|
|
293
|
-
update(sessionId, patch) {
|
|
342
|
+
async update(sessionId, patch) {
|
|
294
343
|
const s = this.get(sessionId);
|
|
295
344
|
if (!s)
|
|
296
345
|
return undefined;
|
|
346
|
+
if (patch.agent !== undefined && !getExposedAgentNames().includes(patch.agent))
|
|
347
|
+
throw new RequestBodyError(400, `unknown agent: ${patch.agent}`);
|
|
297
348
|
if (typeof patch.title === "string" && patch.title.trim() !== "") {
|
|
298
|
-
|
|
349
|
+
const title = patch.title.trim().replace(/\s+/g, " ");
|
|
350
|
+
if (s.pi !== undefined) {
|
|
351
|
+
const renamed = await s.pi.command({ type: "set_session_name", name: title });
|
|
352
|
+
if (!renamed.success)
|
|
353
|
+
throw new Error(renamed.error ?? "session rename rejected");
|
|
354
|
+
}
|
|
355
|
+
s.info.title = title;
|
|
299
356
|
s.titleSource = "user";
|
|
300
357
|
}
|
|
358
|
+
if (patch.metadata !== undefined)
|
|
359
|
+
s.info.metadata = patch.metadata;
|
|
360
|
+
if (patch.agent !== undefined)
|
|
361
|
+
s.info.agent = patch.agent;
|
|
301
362
|
if (patch.archived !== undefined) {
|
|
302
363
|
if (patch.archived > 0)
|
|
303
364
|
s.info.time.archived = patch.archived;
|
|
@@ -306,9 +367,6 @@ export class SessionManager {
|
|
|
306
367
|
}
|
|
307
368
|
s.info.time.updated = Date.now();
|
|
308
369
|
this.persistMeta(s);
|
|
309
|
-
if (typeof patch.title === "string" && patch.title !== "" && s.pi !== undefined) {
|
|
310
|
-
void s.pi.command({ type: "set_session_name", name: s.info.title }).catch(() => undefined);
|
|
311
|
-
}
|
|
312
370
|
this.emit(s.info.directory, { type: "session.updated", properties: { info: s.info } });
|
|
313
371
|
return s.info;
|
|
314
372
|
}
|
|
@@ -324,11 +382,7 @@ export class SessionManager {
|
|
|
324
382
|
properties: { sessionID: s.info.id, todos: s.todoBridge.todos },
|
|
325
383
|
});
|
|
326
384
|
}
|
|
327
|
-
|
|
328
|
-
if (!notice)
|
|
329
|
-
return messages;
|
|
330
|
-
const boundary = messages.findIndex((message) => message.info.id === s.tree.revert?.messageID);
|
|
331
|
-
return [...messages.slice(0, boundary), notice, ...messages.slice(boundary)];
|
|
385
|
+
return messages;
|
|
332
386
|
}
|
|
333
387
|
/** Append to canonical history, before the hidden redo branch while reverted. */
|
|
334
388
|
appendMessage(sessionId, message) {
|
|
@@ -353,6 +407,25 @@ export class SessionManager {
|
|
|
353
407
|
this.operations.delete(s.info.id);
|
|
354
408
|
}
|
|
355
409
|
}
|
|
410
|
+
/** Cancellation releases only the caller; the native action keeps its queue lease. */
|
|
411
|
+
async waitForOperation(pending, signal) {
|
|
412
|
+
if (!signal)
|
|
413
|
+
return pending;
|
|
414
|
+
let onAbort;
|
|
415
|
+
const aborted = new Promise((_, reject) => {
|
|
416
|
+
onAbort = () => reject(signal.reason);
|
|
417
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
418
|
+
if (signal.aborted)
|
|
419
|
+
onAbort();
|
|
420
|
+
});
|
|
421
|
+
try {
|
|
422
|
+
return await Promise.race([pending, aborted]);
|
|
423
|
+
}
|
|
424
|
+
finally {
|
|
425
|
+
if (onAbort)
|
|
426
|
+
signal.removeEventListener("abort", onAbort);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
356
429
|
async syncTree(s, treeOperation = false) {
|
|
357
430
|
const pi = s.pi;
|
|
358
431
|
if (!pi)
|
|
@@ -409,7 +482,8 @@ export class SessionManager {
|
|
|
409
482
|
});
|
|
410
483
|
}
|
|
411
484
|
async fork(sessionId, messageID) {
|
|
412
|
-
|
|
485
|
+
// Admission must see either the parent or child owner throughout the transfer.
|
|
486
|
+
return this.treeSession(sessionId, (s, pi) => this.lifecycle.run(async () => {
|
|
413
487
|
const tree = await this.syncTree(s, true);
|
|
414
488
|
const state = await forkConversation(s, pi, tree, messageID);
|
|
415
489
|
this.createdSessions.set(state.sessionId, s.info.directory);
|
|
@@ -427,6 +501,12 @@ export class SessionManager {
|
|
|
427
501
|
child.piSessionId = state.sessionId;
|
|
428
502
|
delete child.restored;
|
|
429
503
|
child.model = s.model;
|
|
504
|
+
child.info.parentID = s.info.id;
|
|
505
|
+
if (messageID !== undefined)
|
|
506
|
+
child.info.parentMessageID = messageID;
|
|
507
|
+
child.info.agent = s.info.agent ?? getAgentName();
|
|
508
|
+
if (s.info.metadata !== undefined)
|
|
509
|
+
child.info.metadata = structuredClone(s.info.metadata);
|
|
430
510
|
const childTree = await getEntries(pi);
|
|
431
511
|
child.tree.leafId = childTree.leafId;
|
|
432
512
|
this.ensureMessages(child);
|
|
@@ -435,50 +515,39 @@ export class SessionManager {
|
|
|
435
515
|
this.persistMeta(child);
|
|
436
516
|
this.emit(child.info.directory, { type: "session.created", properties: { info: child.info } });
|
|
437
517
|
return child.info;
|
|
438
|
-
});
|
|
518
|
+
}));
|
|
439
519
|
}
|
|
440
520
|
async revert(sessionId, messageID) {
|
|
441
521
|
return this.treeSession(sessionId, async (s, pi) => {
|
|
442
522
|
const tree = await this.syncTree(s, true);
|
|
443
|
-
|
|
523
|
+
// OpenChamber renders its own reverted-message dock from session.revert.
|
|
444
524
|
if (!(await revertConversation(s, pi, tree, messageID)))
|
|
445
525
|
return s.info;
|
|
446
|
-
this.removeRevertNotice(s, previousNotice);
|
|
447
526
|
this.persistMeta(s);
|
|
448
527
|
this.emit(s.info.directory, { type: "session.updated", properties: { info: s.info } });
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
this.events.emitPart(s, part);
|
|
454
|
-
}
|
|
528
|
+
this.emit(s.info.directory, {
|
|
529
|
+
type: "session.revert.staged",
|
|
530
|
+
properties: { sessionID: s.info.id, revert: { messageID } },
|
|
531
|
+
});
|
|
455
532
|
return s.info;
|
|
456
533
|
});
|
|
457
534
|
}
|
|
458
535
|
async unrevert(sessionId) {
|
|
459
536
|
return this.treeSession(sessionId, async (s, pi) => {
|
|
460
537
|
await this.syncTree(s, true);
|
|
461
|
-
const previousNotice = revertNotice(s);
|
|
462
538
|
if (!(await unrevertConversation(s, pi)))
|
|
463
539
|
return s.info;
|
|
464
|
-
this.removeRevertNotice(s, previousNotice);
|
|
465
540
|
this.persistMeta(s);
|
|
466
541
|
this.emit(s.info.directory, { type: "session.updated", properties: { info: s.info } });
|
|
542
|
+
this.emit(s.info.directory, { type: "session.revert.cleared", properties: { sessionID: s.info.id } });
|
|
467
543
|
return s.info;
|
|
468
544
|
});
|
|
469
545
|
}
|
|
470
|
-
removeRevertNotice(s, notice) {
|
|
471
|
-
if (notice)
|
|
472
|
-
this.emit(s.info.directory, {
|
|
473
|
-
type: "message.removed",
|
|
474
|
-
properties: { sessionID: s.info.id, messageID: notice.info.id },
|
|
475
|
-
});
|
|
476
|
-
}
|
|
477
546
|
async reloadMessages(sessionId, signal) {
|
|
478
547
|
const s = this.get(sessionId);
|
|
479
548
|
if (!s)
|
|
480
549
|
return undefined;
|
|
481
|
-
return this.operation(s, () => this.reloadMessagesLive(s, signal));
|
|
550
|
+
return this.waitForOperation(this.operation(s, () => this.reloadMessagesLive(s, signal)), signal);
|
|
482
551
|
}
|
|
483
552
|
/** Called only within the conversation operation queue, including RPC refreshes. */
|
|
484
553
|
async reloadMessagesLive(s, signal) {
|
|
@@ -489,13 +558,13 @@ export class SessionManager {
|
|
|
489
558
|
const pi = s.pi;
|
|
490
559
|
if (!pi)
|
|
491
560
|
throw new SessionTreeError("Session cannot be resumed");
|
|
492
|
-
const tree = await getEntries(pi
|
|
561
|
+
const tree = await getEntries(pi);
|
|
493
562
|
signal?.throwIfAborted();
|
|
494
563
|
bindMessageEntries(s, tree);
|
|
495
564
|
// Compact/bash and extension slash commands can append without agent_end.
|
|
496
565
|
// Binding follows new descendants, never a stale ancestor of the selection.
|
|
497
566
|
if (s.tree.revert && s.tree.leafId !== undefined && tree.leafId !== s.tree.leafId)
|
|
498
|
-
await navigate(pi, s.tree.leafId
|
|
567
|
+
await navigate(pi, s.tree.leafId);
|
|
499
568
|
signal?.throwIfAborted();
|
|
500
569
|
const previous = s.messages ?? [];
|
|
501
570
|
const messages = this.refreshMessageCache(s);
|
|
@@ -513,7 +582,7 @@ export class SessionManager {
|
|
|
513
582
|
for (const part of message.parts)
|
|
514
583
|
this.events.emitPart(s, part);
|
|
515
584
|
}
|
|
516
|
-
this.update(sessionId, {});
|
|
585
|
+
await this.update(sessionId, {});
|
|
517
586
|
return messages;
|
|
518
587
|
}
|
|
519
588
|
statuses() {
|
|
@@ -529,11 +598,45 @@ export class SessionManager {
|
|
|
529
598
|
this.ensureMessages(s);
|
|
530
599
|
return s.todoBridge?.todos ?? [];
|
|
531
600
|
}
|
|
532
|
-
async
|
|
601
|
+
async commitRevert(sessionId) {
|
|
602
|
+
await this.treeSession(sessionId, async (s, pi) => {
|
|
603
|
+
await this.commitRevertLive(s, pi);
|
|
604
|
+
return s.info;
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
async commitRevertLive(s, pi) {
|
|
608
|
+
const revert = s.tree.revert;
|
|
609
|
+
if (revert === undefined)
|
|
610
|
+
return;
|
|
611
|
+
const boundary = s.messages?.findIndex((message) => message.info.id === revert.messageID) ?? -1;
|
|
612
|
+
if (boundary >= 0) {
|
|
613
|
+
for (const message of s.messages?.splice(boundary) ?? []) {
|
|
614
|
+
this.emit(s.info.directory, {
|
|
615
|
+
type: "message.removed",
|
|
616
|
+
properties: { sessionID: s.info.id, messageID: message.info.id },
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
delete s.tree.revert;
|
|
621
|
+
delete s.info.revert;
|
|
622
|
+
bindMessageEntries(s, await getEntries(pi));
|
|
623
|
+
this.persistMeta(s);
|
|
624
|
+
this.emit(s.info.directory, { type: "session.updated", properties: { info: s.info } });
|
|
625
|
+
this.emit(s.info.directory, {
|
|
626
|
+
type: "session.revert.committed",
|
|
627
|
+
properties: { sessionID: s.info.id, to: revert.messageID },
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
async diff(sessionId, options = {}) {
|
|
533
631
|
const s = this.get(sessionId);
|
|
534
632
|
if (s === undefined)
|
|
535
633
|
throw new Error(`unknown session: ${sessionId}`);
|
|
536
|
-
|
|
634
|
+
const messages = this.ensureMessages(s);
|
|
635
|
+
const start = options.from === undefined ? 0 : messages.findIndex((message) => message.info.id === options.from);
|
|
636
|
+
const end = options.to === undefined ? messages.length : messages.findIndex((message) => message.info.id === options.to);
|
|
637
|
+
if (start < 0 || end < 0 || start > end)
|
|
638
|
+
throw new RequestBodyError(400, "invalid diff message range");
|
|
639
|
+
return sessionDiff(s.info.directory, messages.slice(start, end), options.context);
|
|
537
640
|
}
|
|
538
641
|
emitDiff(s) {
|
|
539
642
|
if (!this.dirtyDiffs.delete(s.info.id))
|
|
@@ -564,9 +667,17 @@ export class SessionManager {
|
|
|
564
667
|
if (!s)
|
|
565
668
|
return false;
|
|
566
669
|
if (s.pi !== undefined) {
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
670
|
+
if (s.status.type !== "idle")
|
|
671
|
+
s.interruptRequested = true;
|
|
672
|
+
try {
|
|
673
|
+
const response = await s.pi.command({ type: "abort" });
|
|
674
|
+
if (!response.success)
|
|
675
|
+
throw new Error(response.error ?? "pi abort failed");
|
|
676
|
+
}
|
|
677
|
+
catch (error) {
|
|
678
|
+
delete s.interruptRequested;
|
|
679
|
+
throw error;
|
|
680
|
+
}
|
|
570
681
|
}
|
|
571
682
|
s.cancelShell?.();
|
|
572
683
|
s.approvals?.cancelAll();
|
|
@@ -620,27 +731,14 @@ export class SessionManager {
|
|
|
620
731
|
if (pi === undefined)
|
|
621
732
|
throw new Error(`session cannot be resumed: ${sessionId}`);
|
|
622
733
|
this.lifecycle.touch(s);
|
|
623
|
-
const
|
|
734
|
+
const timeout = command.type === "compact" ? COMPACT_RPC_TIMEOUT_MS : RPC_COMMAND_TIMEOUT_MS;
|
|
735
|
+
signal?.throwIfAborted();
|
|
736
|
+
const response = await pi.command(command, timeout, undefined, onDispatch);
|
|
624
737
|
if (!signal?.aborted && response.success && (command.type === "compact" || command.type === "bash"))
|
|
625
738
|
await this.reloadMessagesLive(s, signal);
|
|
626
739
|
return response;
|
|
627
740
|
});
|
|
628
|
-
|
|
629
|
-
return pending;
|
|
630
|
-
// Cancel the caller's wait without releasing the conversation queue. The
|
|
631
|
-
// queued action checks the signal before dispatch when its predecessor ends.
|
|
632
|
-
let onAbort;
|
|
633
|
-
const aborted = new Promise((_, reject) => {
|
|
634
|
-
onAbort = () => reject(signal.reason);
|
|
635
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
636
|
-
});
|
|
637
|
-
try {
|
|
638
|
-
return await Promise.race([pending, aborted]);
|
|
639
|
-
}
|
|
640
|
-
finally {
|
|
641
|
-
if (onAbort)
|
|
642
|
-
signal.removeEventListener("abort", onAbort);
|
|
643
|
-
}
|
|
741
|
+
return this.waitForOperation(pending, signal);
|
|
644
742
|
}
|
|
645
743
|
/**
|
|
646
744
|
* POST /api/session/{id}/model — OpenChamber awaits this before every prompt
|
|
@@ -712,6 +810,7 @@ export class SessionManager {
|
|
|
712
810
|
return false;
|
|
713
811
|
const model = { providerID: provider, modelID: modelId };
|
|
714
812
|
s.model = model;
|
|
813
|
+
s.info.model = { id: modelId, providerID: provider, variant: s.thinkingLevel ?? "default" };
|
|
715
814
|
this.persistMeta(s);
|
|
716
815
|
this.emit(s.info.directory, {
|
|
717
816
|
type: "model.changed",
|
|
@@ -719,13 +818,13 @@ export class SessionManager {
|
|
|
719
818
|
});
|
|
720
819
|
return true;
|
|
721
820
|
}
|
|
722
|
-
async prompt(sessionId, text, model, noReply = false, messageID, delivery = "steer") {
|
|
821
|
+
async prompt(sessionId, text, model, noReply = false, messageID, delivery = "steer", parts, metadata) {
|
|
723
822
|
const s = this.get(sessionId);
|
|
724
823
|
if (!s)
|
|
725
824
|
throw new Error(`unknown session: ${sessionId}`);
|
|
726
|
-
return this.operation(s, () => this.promptLive(s, text, model, noReply, messageID, delivery));
|
|
825
|
+
return this.operation(s, () => this.promptLive(s, text, model, noReply, messageID, delivery, parts, metadata));
|
|
727
826
|
}
|
|
728
|
-
async
|
|
827
|
+
async preparePrompt(s, model) {
|
|
729
828
|
const sessionId = s.info.id;
|
|
730
829
|
if (s.pi === undefined)
|
|
731
830
|
await this.revive(s);
|
|
@@ -734,42 +833,29 @@ export class SessionManager {
|
|
|
734
833
|
throw new Error(`session cannot be resumed: ${sessionId}`);
|
|
735
834
|
this.lifecycle.touch(s);
|
|
736
835
|
this.ensureMessages(s);
|
|
737
|
-
|
|
738
|
-
const boundary = s.messages?.findIndex((message) => message.info.id === s.tree.revert?.messageID) ?? -1;
|
|
739
|
-
if (boundary >= 0) {
|
|
740
|
-
for (const message of s.messages?.splice(boundary) ?? []) {
|
|
741
|
-
this.emit(s.info.directory, {
|
|
742
|
-
type: "message.removed",
|
|
743
|
-
properties: { sessionID: s.info.id, messageID: message.info.id },
|
|
744
|
-
});
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
this.removeRevertNotice(s, revertNotice(s));
|
|
748
|
-
delete s.tree.revert;
|
|
749
|
-
delete s.info.revert;
|
|
750
|
-
bindMessageEntries(s, await getEntries(pi));
|
|
751
|
-
this.persistMeta(s);
|
|
752
|
-
this.emit(s.info.directory, { type: "session.updated", properties: { info: s.info } });
|
|
753
|
-
}
|
|
754
|
-
// Same funnel as POST /api/session/{id}/model. Failing loudly matters: the
|
|
755
|
-
// previous code swallowed pi's refusal and then reported the *requested*
|
|
756
|
-
// model as though the switch had happened.
|
|
836
|
+
await this.commitRevertLive(s, pi);
|
|
757
837
|
if (model !== undefined) {
|
|
758
838
|
try {
|
|
759
839
|
// This prompt already owns the operation queue; do not enqueue recursively.
|
|
760
840
|
await this.setModelLive(s, model);
|
|
761
841
|
}
|
|
762
|
-
catch (
|
|
842
|
+
catch (error) {
|
|
763
843
|
this.emit(s.info.directory, {
|
|
764
844
|
type: "session.error",
|
|
765
845
|
properties: {
|
|
766
846
|
sessionID: s.info.id,
|
|
767
|
-
error: {
|
|
847
|
+
error: {
|
|
848
|
+
name: "UnknownError",
|
|
849
|
+
data: { message: error instanceof Error ? error.message : String(error) },
|
|
850
|
+
},
|
|
768
851
|
},
|
|
769
852
|
});
|
|
770
|
-
throw
|
|
853
|
+
throw error;
|
|
771
854
|
}
|
|
772
855
|
}
|
|
856
|
+
return pi;
|
|
857
|
+
}
|
|
858
|
+
publishPrompt(s, text, model, messageID, inputParts, metadata, context = false) {
|
|
773
859
|
// OpenChamber sends a client-generated messageID with its optimistic
|
|
774
860
|
// insert; the server MUST reuse it so the echoed message.part.updated /
|
|
775
861
|
// message.updated events reconcile the optimistic entry in place instead
|
|
@@ -781,12 +867,23 @@ export class SessionManager {
|
|
|
781
867
|
time: { created: Date.now() },
|
|
782
868
|
agent: getAgentName(),
|
|
783
869
|
model: s.model ?? model ?? { providerID: "unknown", modelID: "unknown" },
|
|
870
|
+
...(context ? { synthetic: true } : {}),
|
|
871
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
784
872
|
};
|
|
785
|
-
|
|
873
|
+
if (metadata !== undefined)
|
|
874
|
+
(s.tree.promptMetadata ??= {})[userMsg.id] = metadata;
|
|
875
|
+
const parts = (inputParts ?? [{ type: "text", text }]).map((part) => ({
|
|
876
|
+
...part,
|
|
877
|
+
id: part.id ?? id("prt"),
|
|
878
|
+
sessionID: s.info.id,
|
|
879
|
+
messageID: userMsg.id,
|
|
880
|
+
}));
|
|
881
|
+
if (inputParts?.some((part) => part.type !== "text"))
|
|
882
|
+
(s.tree.promptParts ??= {})[userMsg.id] = [...inputParts];
|
|
786
883
|
s.messages?.push({ info: userMsg, parts });
|
|
787
884
|
s.info.time.updated = Date.now();
|
|
788
|
-
if (s.info.title === "New session" && s.titleSource !== "user" && s.titleSource !== "native") {
|
|
789
|
-
s.info.title = text.trim().replace(/\s+/g, " ").slice(0,
|
|
885
|
+
if (!context && s.info.title === "New session" && s.titleSource !== "user" && s.titleSource !== "native") {
|
|
886
|
+
s.info.title = text.trim().replace(/\s+/g, " ").slice(0, SESSION_TITLE_MAX_CHARS) || "New session";
|
|
790
887
|
s.titleSource = "fallback";
|
|
791
888
|
}
|
|
792
889
|
this.persistMeta(s);
|
|
@@ -795,19 +892,74 @@ export class SessionManager {
|
|
|
795
892
|
// Echo the user text part: the reducer replaces the client's sessionID-less
|
|
796
893
|
// optimistic part (same type, new id) in place — without this echo a page
|
|
797
894
|
// fetch merges the optimistic part back in and the text shows twice.
|
|
798
|
-
for (const
|
|
799
|
-
this.events.emitPart(s,
|
|
800
|
-
|
|
801
|
-
|
|
895
|
+
for (const part of parts)
|
|
896
|
+
this.events.emitPart(s, part);
|
|
897
|
+
return userMsg;
|
|
898
|
+
}
|
|
899
|
+
async promptLive(s, text, model, noReply = false, messageID, delivery = "steer", parts, metadata) {
|
|
900
|
+
const pi = await this.preparePrompt(s, model);
|
|
901
|
+
const native = nativePromptInput(text, parts ?? []);
|
|
902
|
+
const userMsg = this.publishPrompt(s, text, model, messageID, parts, metadata, noReply);
|
|
903
|
+
if (noReply) {
|
|
904
|
+
await this.admitContext(s, pi, userMsg, native.message);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
if (!(await this.dispatchPrompt(s, pi, userMsg, text, delivery, native, parts ?? [])))
|
|
908
|
+
return;
|
|
909
|
+
await this.bindPromptState(s);
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* No-reply context (v2 synthetic, v1 noReply) joins the native conversation without a turn:
|
|
913
|
+
* OmO appends it now when idle, or after the running turn, so the next prompt carries it.
|
|
914
|
+
*/
|
|
915
|
+
async admitContext(s, pi, userMsg, text) {
|
|
916
|
+
const res = await pi.command({
|
|
917
|
+
type: "send_custom_message",
|
|
918
|
+
customType: CONTEXT_MESSAGE_TYPE,
|
|
919
|
+
content: [{ type: "text", text }],
|
|
920
|
+
display: true,
|
|
921
|
+
details: { messageID: userMsg.id },
|
|
922
|
+
triggerTurn: false,
|
|
923
|
+
});
|
|
924
|
+
if (!res.success) {
|
|
925
|
+
if (s.tree.promptParts)
|
|
926
|
+
delete s.tree.promptParts[userMsg.id];
|
|
927
|
+
if (s.tree.promptMetadata)
|
|
928
|
+
delete s.tree.promptMetadata[userMsg.id];
|
|
929
|
+
s.messages = s.messages?.filter((message) => message.info.id !== userMsg.id);
|
|
930
|
+
this.emit(s.info.directory, {
|
|
931
|
+
type: "message.removed",
|
|
932
|
+
properties: { sessionID: s.info.id, messageID: userMsg.id },
|
|
933
|
+
});
|
|
934
|
+
throw new Error(res.error ?? "context admission rejected");
|
|
935
|
+
}
|
|
936
|
+
// Bind the appended entry and persist the new leaf before announcing delivery, or a restart
|
|
937
|
+
// restores the old branch without the context.
|
|
938
|
+
await this.bindPromptState(s);
|
|
939
|
+
this.emit(s.info.directory, {
|
|
940
|
+
type: "session.inbox.enqueued",
|
|
941
|
+
properties: {
|
|
942
|
+
sessionID: s.info.id,
|
|
943
|
+
inboxID: userMsg.id,
|
|
944
|
+
item: { type: "synthetic", payload: { text, ...(userMsg.metadata ? { metadata: userMsg.metadata } : {}) } },
|
|
945
|
+
},
|
|
946
|
+
});
|
|
947
|
+
this.events.deliverInput(s, userMsg.id);
|
|
948
|
+
}
|
|
949
|
+
async dispatchPrompt(s, pi, userMsg, text, delivery, native, parts) {
|
|
802
950
|
const wasIdle = s.status.type === "idle";
|
|
803
951
|
if (wasIdle) {
|
|
804
952
|
s.toolByCallId = new Map();
|
|
805
953
|
this.events.setStatus(s, { type: "busy" });
|
|
806
954
|
}
|
|
807
|
-
const input = { text, delivery, accepted: false, delivered: false };
|
|
955
|
+
const input = { text: native.message, delivery, accepted: false, delivered: false };
|
|
808
956
|
(s.inputs ??= new Map()).set(userMsg.id, input);
|
|
809
957
|
const rejectInput = () => {
|
|
810
958
|
s.inputs?.delete(userMsg.id);
|
|
959
|
+
if (s.tree.promptParts)
|
|
960
|
+
delete s.tree.promptParts[userMsg.id];
|
|
961
|
+
if (s.tree.promptMetadata)
|
|
962
|
+
delete s.tree.promptMetadata[userMsg.id];
|
|
811
963
|
s.messages = s.messages?.filter((message) => message.info.id !== userMsg.id);
|
|
812
964
|
this.emit(s.info.directory, {
|
|
813
965
|
type: "message.removed",
|
|
@@ -826,9 +978,10 @@ export class SessionManager {
|
|
|
826
978
|
const res = await pi
|
|
827
979
|
.command({
|
|
828
980
|
type: "prompt",
|
|
829
|
-
|
|
981
|
+
...native,
|
|
830
982
|
streamingBehavior: delivery === "queue" ? "followUp" : "steer",
|
|
831
983
|
clientMessageId: userMsg.id,
|
|
984
|
+
...slashTitlePrompt(text),
|
|
832
985
|
})
|
|
833
986
|
.catch((error) => {
|
|
834
987
|
rejectInput();
|
|
@@ -846,12 +999,24 @@ export class SessionManager {
|
|
|
846
999
|
error: { name: "UnknownError", data: { message: res.error ?? "prompt rejected" } },
|
|
847
1000
|
},
|
|
848
1001
|
});
|
|
849
|
-
return;
|
|
1002
|
+
return false;
|
|
850
1003
|
}
|
|
851
1004
|
input.accepted = true;
|
|
852
1005
|
this.emit(s.info.directory, {
|
|
853
1006
|
type: "session.inbox.enqueued",
|
|
854
|
-
properties: {
|
|
1007
|
+
properties: {
|
|
1008
|
+
sessionID: s.info.id,
|
|
1009
|
+
inboxID: userMsg.id,
|
|
1010
|
+
item: {
|
|
1011
|
+
type: "user",
|
|
1012
|
+
delivery,
|
|
1013
|
+
payload: {
|
|
1014
|
+
text,
|
|
1015
|
+
...v2PromptAttachments(parts),
|
|
1016
|
+
...(userMsg.metadata ? { metadata: userMsg.metadata } : {}),
|
|
1017
|
+
},
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
855
1020
|
});
|
|
856
1021
|
if (input.delivered)
|
|
857
1022
|
this.events.deliverInput(s, userMsg.id);
|
|
@@ -860,9 +1025,14 @@ export class SessionManager {
|
|
|
860
1025
|
typeof res.data === "object" &&
|
|
861
1026
|
"disposition" in res.data &&
|
|
862
1027
|
res.data.disposition === "handled") {
|
|
1028
|
+
if (!input.delivered)
|
|
1029
|
+
this.events.deliverInput(s, userMsg.id);
|
|
863
1030
|
if (wasIdle)
|
|
864
1031
|
this.events.setStatus(s, { type: "idle" });
|
|
865
1032
|
}
|
|
1033
|
+
return true;
|
|
1034
|
+
}
|
|
1035
|
+
async bindPromptState(s) {
|
|
866
1036
|
// First message makes pi write the session file — bind it now so the
|
|
867
1037
|
// session (and its current title) survives an adapter restart.
|
|
868
1038
|
if (s.piPath === undefined && s.piSessionId !== undefined) {
|
|
@@ -969,6 +1139,8 @@ export class SessionManager {
|
|
|
969
1139
|
this.subagents.discover(s, s.messages);
|
|
970
1140
|
if (admissions)
|
|
971
1141
|
this.persistMeta(s);
|
|
1142
|
+
if (syncSessionCost(s))
|
|
1143
|
+
this.emit(s.info.directory, { type: "session.updated", properties: { info: s.info } });
|
|
972
1144
|
}
|
|
973
1145
|
return s.messages;
|
|
974
1146
|
}
|
|
@@ -1039,7 +1211,7 @@ export class SessionManager {
|
|
|
1039
1211
|
}
|
|
1040
1212
|
}
|
|
1041
1213
|
adopt(sc) {
|
|
1042
|
-
const ocId = ocIdFor(sc.piSessionId);
|
|
1214
|
+
const ocId = this.store.idFor(sc.piSessionId) ?? ocIdFor(sc.piSessionId);
|
|
1043
1215
|
const reg = this.store.get(ocId);
|
|
1044
1216
|
const titleSource = reg?.titleSource ??
|
|
1045
1217
|
(reg?.title && reg.title !== "New session" && reg.title !== sc.fallbackTitle ? "user" : "fallback");
|
|
@@ -1049,6 +1221,19 @@ export class SessionManager {
|
|
|
1049
1221
|
projectID: sc.directory,
|
|
1050
1222
|
directory: sc.directory,
|
|
1051
1223
|
title: useRegistry ? (reg?.title ?? sc.title) : sc.title,
|
|
1224
|
+
agent: reg?.agent ?? getAgentName(),
|
|
1225
|
+
slug: sc.piSessionId,
|
|
1226
|
+
path: "",
|
|
1227
|
+
summary: { additions: 0, deletions: 0, files: 0 },
|
|
1228
|
+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
1229
|
+
...(reg?.metadata !== undefined ? { metadata: reg.metadata } : {}),
|
|
1230
|
+
...(reg?.parentID !== undefined ? { parentID: reg.parentID } : {}),
|
|
1231
|
+
...(reg?.parentMessageID !== undefined ? { parentMessageID: reg.parentMessageID } : {}),
|
|
1232
|
+
...(reg?.model !== undefined
|
|
1233
|
+
? {
|
|
1234
|
+
model: { id: reg.model.modelID, providerID: reg.model.providerID, variant: reg.model.variant ?? "default" },
|
|
1235
|
+
}
|
|
1236
|
+
: {}),
|
|
1052
1237
|
...(reg?.tree?.revert ? { revert: { messageID: reg.tree.revert.messageID } } : {}),
|
|
1053
1238
|
version: ADAPTER_VERSION,
|
|
1054
1239
|
time: {
|
|
@@ -1106,6 +1291,11 @@ export class SessionManager {
|
|
|
1106
1291
|
}
|
|
1107
1292
|
/** Registry write for adapter-owned metadata (title override, pi path). */
|
|
1108
1293
|
persistMeta(s) {
|
|
1294
|
+
for (const message of s.messages ?? []) {
|
|
1295
|
+
if (s.localMessageIds?.has(message.info.id))
|
|
1296
|
+
continue;
|
|
1297
|
+
(s.tree.messageParts ??= {})[message.info.id] = message.parts.map((part) => part.id);
|
|
1298
|
+
}
|
|
1109
1299
|
const piPath = s.piPath ?? s.restored?.piPath;
|
|
1110
1300
|
const piSessionId = s.piSessionId ?? s.restored?.piSessionId;
|
|
1111
1301
|
if (piPath === undefined || piSessionId === undefined)
|
|
@@ -1120,6 +1310,10 @@ export class SessionManager {
|
|
|
1120
1310
|
...(s.info.title !== "New session" ? { title: s.info.title } : {}),
|
|
1121
1311
|
...(s.titleSource !== undefined ? { titleSource: s.titleSource } : {}),
|
|
1122
1312
|
...(s.info.time.archived !== undefined ? { archived: s.info.time.archived } : {}),
|
|
1313
|
+
...(s.info.metadata !== undefined ? { metadata: s.info.metadata } : {}),
|
|
1314
|
+
...(s.info.agent !== undefined ? { agent: s.info.agent } : {}),
|
|
1315
|
+
...(s.info.parentID !== undefined ? { parentID: s.info.parentID } : {}),
|
|
1316
|
+
...(s.info.parentMessageID !== undefined ? { parentMessageID: s.info.parentMessageID } : {}),
|
|
1123
1317
|
...(s.model !== undefined ? { model: { providerID: s.model.providerID, modelID: s.model.modelID } } : {}),
|
|
1124
1318
|
created: s.info.time.created,
|
|
1125
1319
|
updated: s.info.time.updated,
|
|
@@ -1127,11 +1321,11 @@ export class SessionManager {
|
|
|
1127
1321
|
}
|
|
1128
1322
|
/** Async event callbacks cannot reject an HTTP operation; report save failures through SSE. */
|
|
1129
1323
|
reportPersistenceError(s, error) {
|
|
1130
|
-
|
|
1131
|
-
|
|
1324
|
+
const name = error instanceof Error ? error.name : "UnknownError";
|
|
1325
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1132
1326
|
this.emit(s.info.directory, {
|
|
1133
1327
|
type: "session.error",
|
|
1134
|
-
properties: { sessionID: s.info.id, error: { name
|
|
1328
|
+
properties: { sessionID: s.info.id, error: { name, data: { message } } },
|
|
1135
1329
|
});
|
|
1136
1330
|
}
|
|
1137
1331
|
/** Keep the existing event entry point for RPC wiring and test harnesses. */
|