jukto-cli 0.1.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/README.md +109 -0
- package/dist/ai/codex.d.ts +149 -0
- package/dist/ai/codex.js +2122 -0
- package/dist/ai/index.d.ts +57 -0
- package/dist/ai/index.js +119 -0
- package/dist/ai/interface.d.ts +93 -0
- package/dist/ai/interface.js +3 -0
- package/dist/ai/opencode.d.ts +72 -0
- package/dist/ai/opencode.js +883 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3356 -0
- package/dist/transport/protocol.d.ts +77 -0
- package/dist/transport/protocol.js +79 -0
- package/dist/transport/v2.d.ts +47 -0
- package/dist/transport/v2.js +347 -0
- package/package.json +43 -0
package/dist/ai/codex.js
ADDED
|
@@ -0,0 +1,2122 @@
|
|
|
1
|
+
// Codex AI provider — spawns `codex app-server` and speaks JSON-RPC 2.0
|
|
2
|
+
// over stdin/stdout. Maps Codex's thread/turn model onto Jukto's AIProvider
|
|
3
|
+
// contract using the same thread/list + thread/read flow used by Remodex.
|
|
4
|
+
import * as crypto from "crypto";
|
|
5
|
+
import * as path from "path";
|
|
6
|
+
import { spawn } from "child_process";
|
|
7
|
+
import { createInterface } from "readline";
|
|
8
|
+
const THREAD_LIST_SOURCE_KINDS = ["cli", "vscode", "appServer", "exec", "unknown"];
|
|
9
|
+
const CODEX_AGENTS = [
|
|
10
|
+
{
|
|
11
|
+
name: "Build",
|
|
12
|
+
mode: "default",
|
|
13
|
+
description: "Default Codex collaboration mode.",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: "plan",
|
|
17
|
+
mode: "plan",
|
|
18
|
+
description: "Plan-first Codex collaboration mode.",
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
const DEBUG_MODE = process.env.JUKTO_DEBUG === "1" || process.env.JUKTO_DEBUG_AI === "1";
|
|
22
|
+
function joinStreamingText(previousText, nextChunk) {
|
|
23
|
+
if (!previousText) {
|
|
24
|
+
return nextChunk;
|
|
25
|
+
}
|
|
26
|
+
if (nextChunk.startsWith(previousText)) {
|
|
27
|
+
return nextChunk;
|
|
28
|
+
}
|
|
29
|
+
if (previousText.endsWith(nextChunk)) {
|
|
30
|
+
return previousText;
|
|
31
|
+
}
|
|
32
|
+
const maxOverlap = Math.min(previousText.length, nextChunk.length);
|
|
33
|
+
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
|
|
34
|
+
if (previousText.slice(-overlap) === nextChunk.slice(0, overlap)) {
|
|
35
|
+
return previousText + nextChunk.slice(overlap);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return previousText + nextChunk;
|
|
39
|
+
}
|
|
40
|
+
export class CodexProvider {
|
|
41
|
+
proc = null;
|
|
42
|
+
shuttingDown = false;
|
|
43
|
+
emitter = null;
|
|
44
|
+
defaultModelContextWindow = null;
|
|
45
|
+
nextId = 1;
|
|
46
|
+
pending = new Map();
|
|
47
|
+
sessions = new Map();
|
|
48
|
+
deletedThreadIds = new Set();
|
|
49
|
+
resumedThreadIds = new Set();
|
|
50
|
+
pendingPermissionRequestIds = new Map();
|
|
51
|
+
pendingQuestionRequestIds = new Map();
|
|
52
|
+
assistantMessageIdByTurnId = new Map();
|
|
53
|
+
partTextById = new Map();
|
|
54
|
+
debugLog(message, fields) {
|
|
55
|
+
if (!DEBUG_MODE)
|
|
56
|
+
return;
|
|
57
|
+
const suffix = fields ? ` ${JSON.stringify(fields)}` : "";
|
|
58
|
+
console.log(`[codex] ${message}${suffix}`);
|
|
59
|
+
}
|
|
60
|
+
debugHistory(message, fields) {
|
|
61
|
+
if (!DEBUG_MODE)
|
|
62
|
+
return;
|
|
63
|
+
console.log(`[codex-history] ${message} ${JSON.stringify(fields)}`);
|
|
64
|
+
}
|
|
65
|
+
async init() {
|
|
66
|
+
if (DEBUG_MODE)
|
|
67
|
+
console.log("Starting Codex app-server...");
|
|
68
|
+
const windowsSpawnOptions = process.platform === "win32"
|
|
69
|
+
? { shell: true }
|
|
70
|
+
: {};
|
|
71
|
+
this.proc = spawn("codex", ["app-server"], {
|
|
72
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
73
|
+
env: process.env,
|
|
74
|
+
...windowsSpawnOptions,
|
|
75
|
+
});
|
|
76
|
+
const rl = createInterface({ input: this.proc.stdout });
|
|
77
|
+
rl.on("line", (line) => this.handleLine(line));
|
|
78
|
+
this.proc.on("error", (err) => {
|
|
79
|
+
console.error("[codex] Failed to start codex process:", err.message);
|
|
80
|
+
this.emitter?.({ type: "sse_dead", properties: { error: err.message } });
|
|
81
|
+
});
|
|
82
|
+
this.proc.on("exit", (code) => {
|
|
83
|
+
if (!this.shuttingDown) {
|
|
84
|
+
const msg = `codex app-server exited with code ${code}`;
|
|
85
|
+
console.error(`[codex] ${msg}`);
|
|
86
|
+
this.emitter?.({ type: "sse_dead", properties: { error: msg } });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
await this.call("initialize", {
|
|
90
|
+
clientInfo: { name: "jukto", version: "1.0" },
|
|
91
|
+
capabilities: {
|
|
92
|
+
experimentalApi: true,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
await this.refreshConfigDefaults().catch(() => {
|
|
96
|
+
// Config defaults are best-effort metadata only.
|
|
97
|
+
});
|
|
98
|
+
if (DEBUG_MODE)
|
|
99
|
+
console.log("Codex ready.\n");
|
|
100
|
+
}
|
|
101
|
+
async destroy() {
|
|
102
|
+
this.shuttingDown = true;
|
|
103
|
+
this.proc?.stdin?.end();
|
|
104
|
+
this.proc?.kill();
|
|
105
|
+
this.proc = null;
|
|
106
|
+
}
|
|
107
|
+
subscribe(emitter) {
|
|
108
|
+
this.emitter = emitter;
|
|
109
|
+
return () => {
|
|
110
|
+
this.emitter = null;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
async createSession(title) {
|
|
114
|
+
const result = await this.call("thread/start", {
|
|
115
|
+
cwd: process.cwd(),
|
|
116
|
+
persistExtendedHistory: true,
|
|
117
|
+
});
|
|
118
|
+
const threadObject = this.extractThreadObject(result);
|
|
119
|
+
const threadId = this.extractThreadId(threadObject);
|
|
120
|
+
if (!threadId) {
|
|
121
|
+
throw new Error("thread/start response missing threadId");
|
|
122
|
+
}
|
|
123
|
+
const threadTitle = this.extractThreadTitle(threadObject) || title || "Conversation";
|
|
124
|
+
const session = this.upsertSession({
|
|
125
|
+
id: threadId,
|
|
126
|
+
title: threadTitle,
|
|
127
|
+
createdAt: this.extractCreatedAt(threadObject) ?? Date.now(),
|
|
128
|
+
updatedAt: this.extractUpdatedAt(threadObject) ?? Date.now(),
|
|
129
|
+
archived: false,
|
|
130
|
+
cwd: this.extractThreadCwd(threadObject) ?? process.cwd(),
|
|
131
|
+
});
|
|
132
|
+
this.resumedThreadIds.add(threadId);
|
|
133
|
+
return { session: this.toSessionInfo(session) };
|
|
134
|
+
}
|
|
135
|
+
async listSessions() {
|
|
136
|
+
const activeThreads = await this.fetchServerThreads();
|
|
137
|
+
let archivedThreads = [];
|
|
138
|
+
try {
|
|
139
|
+
archivedThreads = await this.fetchServerThreadsByArchiveState(true);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// Some Codex runtimes may not support archived thread listing.
|
|
143
|
+
}
|
|
144
|
+
this.reconcileSessionsWithServer(activeThreads, archivedThreads);
|
|
145
|
+
const sessions = Array.from(this.sessions.values())
|
|
146
|
+
.filter((session) => !session.archived)
|
|
147
|
+
.filter((session) => !this.deletedThreadIds.has(session.id))
|
|
148
|
+
.filter((session) => this.belongsToCurrentRoot(session))
|
|
149
|
+
.sort((a, b) => a.updatedAt - b.updatedAt)
|
|
150
|
+
.map((session) => this.toSessionInfo(session));
|
|
151
|
+
return { sessions };
|
|
152
|
+
}
|
|
153
|
+
async getSession(id) {
|
|
154
|
+
const session = this.sessions.get(id);
|
|
155
|
+
if (session) {
|
|
156
|
+
return { session: this.toSessionInfo(session) };
|
|
157
|
+
}
|
|
158
|
+
await this.listSessions();
|
|
159
|
+
const next = this.sessions.get(id);
|
|
160
|
+
if (!next) {
|
|
161
|
+
throw Object.assign(new Error(`Session ${id} not found`), { code: "ENOENT" });
|
|
162
|
+
}
|
|
163
|
+
return { session: this.toSessionInfo(next) };
|
|
164
|
+
}
|
|
165
|
+
async deleteSession(id) {
|
|
166
|
+
const session = this.sessions.get(id);
|
|
167
|
+
this.deletedThreadIds.add(id);
|
|
168
|
+
this.sessions.delete(id);
|
|
169
|
+
this.resumedThreadIds.delete(id);
|
|
170
|
+
try {
|
|
171
|
+
const params = { threadId: id };
|
|
172
|
+
if (session?.cwd) {
|
|
173
|
+
params.cwd = session.cwd;
|
|
174
|
+
}
|
|
175
|
+
await this.call("thread/archive", params);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// Match Remodex behavior: delete is optimistic locally, archive is best effort.
|
|
179
|
+
}
|
|
180
|
+
return { deleted: true };
|
|
181
|
+
}
|
|
182
|
+
async renameSession(id, title) {
|
|
183
|
+
const trimmed = title.trim();
|
|
184
|
+
if (!trimmed) {
|
|
185
|
+
throw new Error("Session title cannot be empty");
|
|
186
|
+
}
|
|
187
|
+
const methodsToTry = [
|
|
188
|
+
{ method: "thread/name/set", params: { threadId: id, name: trimmed } },
|
|
189
|
+
{ method: "thread/name/set", params: { threadId: id, title: trimmed } },
|
|
190
|
+
{ method: "thread/name/update", params: { threadId: id, name: trimmed } },
|
|
191
|
+
{ method: "thread/name/update", params: { threadId: id, title: trimmed } },
|
|
192
|
+
{ method: "thread/metadata/update", params: { threadId: id, title: trimmed } },
|
|
193
|
+
{ method: "thread/update", params: { threadId: id, title: trimmed } },
|
|
194
|
+
{ method: "thread/rename", params: { threadId: id, title: trimmed } },
|
|
195
|
+
];
|
|
196
|
+
let renamed = false;
|
|
197
|
+
let lastError = null;
|
|
198
|
+
for (const entry of methodsToTry) {
|
|
199
|
+
try {
|
|
200
|
+
await this.call(entry.method, entry.params);
|
|
201
|
+
renamed = true;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
lastError = err;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (!renamed && lastError) {
|
|
209
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
210
|
+
}
|
|
211
|
+
const existing = this.sessions.get(id) ?? this.ensureLocalSession(id);
|
|
212
|
+
const session = this.upsertSession({
|
|
213
|
+
id,
|
|
214
|
+
title: trimmed,
|
|
215
|
+
createdAt: existing.createdAt,
|
|
216
|
+
updatedAt: Date.now(),
|
|
217
|
+
archived: existing.archived,
|
|
218
|
+
cwd: existing.cwd,
|
|
219
|
+
}, true);
|
|
220
|
+
return { session: this.toSessionInfo(session) };
|
|
221
|
+
}
|
|
222
|
+
async getMessages(sessionId) {
|
|
223
|
+
const session = this.ensureLocalSession(sessionId);
|
|
224
|
+
this.debugLog("getMessages start", {
|
|
225
|
+
sessionId,
|
|
226
|
+
existingMessageCount: session.messages.length,
|
|
227
|
+
existingPartCount: session.messages.reduce((sum, message) => sum + (message.parts?.length || 0), 0),
|
|
228
|
+
});
|
|
229
|
+
const result = await this.call("thread/read", {
|
|
230
|
+
threadId: session.id,
|
|
231
|
+
includeTurns: true,
|
|
232
|
+
});
|
|
233
|
+
const threadObject = this.extractThreadObject(result);
|
|
234
|
+
if (!threadObject) {
|
|
235
|
+
return { messages: session.messages };
|
|
236
|
+
}
|
|
237
|
+
this.logThreadReadSummary(sessionId, threadObject);
|
|
238
|
+
const historyMessages = this.decodeMessagesFromThreadRead(sessionId, threadObject);
|
|
239
|
+
this.debugLog("getMessages decoded thread", {
|
|
240
|
+
sessionId,
|
|
241
|
+
turnCount: this.readArray(threadObject.turns).length,
|
|
242
|
+
decodedMessageCount: historyMessages.length,
|
|
243
|
+
decodedPartCount: historyMessages.reduce((sum, message) => sum + (message.parts?.length || 0), 0),
|
|
244
|
+
decodedRoles: historyMessages.map((message) => message.role),
|
|
245
|
+
decodedPartTypes: historyMessages.flatMap((message) => (message.parts || []).map((part) => String(this.asRecord(part).type ?? "unknown"))),
|
|
246
|
+
});
|
|
247
|
+
if (historyMessages.length > 0) {
|
|
248
|
+
session.messages = historyMessages;
|
|
249
|
+
}
|
|
250
|
+
this.upsertSession({
|
|
251
|
+
id: sessionId,
|
|
252
|
+
title: this.extractThreadTitle(threadObject),
|
|
253
|
+
createdAt: this.extractCreatedAt(threadObject) ?? session.createdAt,
|
|
254
|
+
updatedAt: this.extractUpdatedAt(threadObject) ?? session.updatedAt,
|
|
255
|
+
archived: false,
|
|
256
|
+
cwd: this.extractThreadCwd(threadObject) ?? session.cwd,
|
|
257
|
+
});
|
|
258
|
+
if (this.defaultModelContextWindow && this.defaultModelContextWindow > 0) {
|
|
259
|
+
this.emitter?.({
|
|
260
|
+
type: "session.usage",
|
|
261
|
+
properties: {
|
|
262
|
+
sessionID: sessionId,
|
|
263
|
+
tokenUsage: {
|
|
264
|
+
modelContextWindow: this.defaultModelContextWindow,
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return { messages: session.messages };
|
|
270
|
+
}
|
|
271
|
+
async prompt(sessionId, text, model, agent, files = [], codexOptions) {
|
|
272
|
+
const session = this.ensureLocalSession(sessionId);
|
|
273
|
+
session.updatedAt = Date.now();
|
|
274
|
+
(async () => {
|
|
275
|
+
try {
|
|
276
|
+
const modelId = await this.resolveModelId(model);
|
|
277
|
+
const modelInfo = await this.fetchModelById(modelId);
|
|
278
|
+
const effortLevels = modelInfo?.supportedReasoningEfforts.map((effort) => effort.reasoningEffort) ?? [];
|
|
279
|
+
const defaultEffort = modelInfo?.defaultReasoningEffort ?? effortLevels[0] ?? "medium";
|
|
280
|
+
const requestedEffort = codexOptions?.reasoningEffort;
|
|
281
|
+
const reasoningEffort = requestedEffort && effortLevels.includes(requestedEffort)
|
|
282
|
+
? requestedEffort
|
|
283
|
+
: defaultEffort;
|
|
284
|
+
const requestedSpeedTier = codexOptions?.speed && codexOptions.speed !== "default"
|
|
285
|
+
? codexOptions.speed
|
|
286
|
+
: undefined;
|
|
287
|
+
const serviceTier = requestedSpeedTier && modelInfo?.additionalSpeedTiers.includes(requestedSpeedTier)
|
|
288
|
+
? requestedSpeedTier
|
|
289
|
+
: undefined;
|
|
290
|
+
const collaborationMode = this.buildCollaborationMode(agent, modelId, reasoningEffort);
|
|
291
|
+
// Freshly created sessions already have a live backend thread from
|
|
292
|
+
// thread/start. Forcing thread/resume here can attach stale state to a
|
|
293
|
+
// brand-new session and trigger rollout lookup failures on turn/start.
|
|
294
|
+
await this.ensureThreadResumed(session.id, false, codexOptions, collaborationMode);
|
|
295
|
+
let imageUrlKey = "url";
|
|
296
|
+
while (true) {
|
|
297
|
+
try {
|
|
298
|
+
await this.call("turn/start", {
|
|
299
|
+
threadId: session.id,
|
|
300
|
+
input: this.makeTurnInputPayload(text, files, imageUrlKey),
|
|
301
|
+
...(modelId ? { model: modelId } : {}),
|
|
302
|
+
...(reasoningEffort ? { effort: reasoningEffort } : {}),
|
|
303
|
+
...(serviceTier ? { serviceTier } : {}),
|
|
304
|
+
...(collaborationMode ? { collaborationMode } : {}),
|
|
305
|
+
});
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
if (imageUrlKey === "url"
|
|
310
|
+
&& files.length > 0
|
|
311
|
+
&& this.shouldRetryTurnStartWithImageURLField(err)) {
|
|
312
|
+
imageUrlKey = "image_url";
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
throw err;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
catch (err) {
|
|
320
|
+
const message = err.message;
|
|
321
|
+
console.error("[codex] turn/start error:", message);
|
|
322
|
+
this.emitter?.({ type: "prompt_error", properties: { sessionId, error: message } });
|
|
323
|
+
}
|
|
324
|
+
})();
|
|
325
|
+
return { ack: true };
|
|
326
|
+
}
|
|
327
|
+
async abort(sessionId) {
|
|
328
|
+
const session = this.ensureLocalSession(sessionId);
|
|
329
|
+
const turnId = session.activeTurnId ?? await this.resolveInFlightTurnId(session.id);
|
|
330
|
+
if (!turnId) {
|
|
331
|
+
throw new Error(`Session ${sessionId} has no active interruptible Codex turn`);
|
|
332
|
+
}
|
|
333
|
+
session.activeTurnId = turnId;
|
|
334
|
+
await this.call("turn/interrupt", { threadId: session.id, turnId });
|
|
335
|
+
return {};
|
|
336
|
+
}
|
|
337
|
+
async agents() {
|
|
338
|
+
return { agents: [...CODEX_AGENTS] };
|
|
339
|
+
}
|
|
340
|
+
async providers() {
|
|
341
|
+
const items = await this.fetchModels();
|
|
342
|
+
const models = Object.fromEntries(items.map((item) => [
|
|
343
|
+
item.model,
|
|
344
|
+
{
|
|
345
|
+
id: item.model,
|
|
346
|
+
name: item.displayName || item.model,
|
|
347
|
+
provider: "codex",
|
|
348
|
+
description: item.description,
|
|
349
|
+
defaultReasoningEffort: item.defaultReasoningEffort,
|
|
350
|
+
supportedReasoningEfforts: item.supportedReasoningEfforts,
|
|
351
|
+
additionalSpeedTiers: item.additionalSpeedTiers,
|
|
352
|
+
},
|
|
353
|
+
]));
|
|
354
|
+
const defaultModel = items.find((item) => item.isDefault)?.model;
|
|
355
|
+
return {
|
|
356
|
+
providers: items.length > 0
|
|
357
|
+
? [{ id: "codex", name: "Codex", models }]
|
|
358
|
+
: [],
|
|
359
|
+
default: defaultModel ? { codex: defaultModel } : {},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
async setAuth(providerId, key) {
|
|
363
|
+
throw new Error("Codex auth configuration is not supported by Jukto yet");
|
|
364
|
+
}
|
|
365
|
+
async command(sessionId, command, args) {
|
|
366
|
+
throw new Error("Codex command execution is not supported by Jukto yet");
|
|
367
|
+
}
|
|
368
|
+
async revert(sessionId, messageId) {
|
|
369
|
+
throw new Error("Codex revert is not supported by Jukto yet");
|
|
370
|
+
}
|
|
371
|
+
async unrevert(sessionId) {
|
|
372
|
+
throw new Error("Codex unrevert is not supported by Jukto yet");
|
|
373
|
+
}
|
|
374
|
+
async share(sessionId) {
|
|
375
|
+
return { share: { url: null } };
|
|
376
|
+
}
|
|
377
|
+
async permissionReply(sessionId, permissionId, response) {
|
|
378
|
+
const pending = this.pendingPermissionRequestIds.get(permissionId);
|
|
379
|
+
if (!pending) {
|
|
380
|
+
throw new Error(`Codex permission request ${permissionId} is no longer pending`);
|
|
381
|
+
}
|
|
382
|
+
const decision = response === "reject"
|
|
383
|
+
? "decline"
|
|
384
|
+
: response === "always"
|
|
385
|
+
? "acceptForSession"
|
|
386
|
+
: "accept";
|
|
387
|
+
const isCommandApproval = this.isCommandApprovalMethod(pending.method);
|
|
388
|
+
const result = isCommandApproval
|
|
389
|
+
? { decision }
|
|
390
|
+
: decision;
|
|
391
|
+
this.send({ jsonrpc: "2.0", id: pending.requestId, result });
|
|
392
|
+
this.pendingPermissionRequestIds.delete(permissionId);
|
|
393
|
+
this.emitter?.({
|
|
394
|
+
type: "permission.replied",
|
|
395
|
+
properties: { sessionID: sessionId, permissionId, response },
|
|
396
|
+
});
|
|
397
|
+
return {};
|
|
398
|
+
}
|
|
399
|
+
async questionReply(sessionId, questionId, answers) {
|
|
400
|
+
const pending = this.pendingQuestionRequestIds.get(questionId);
|
|
401
|
+
if (!pending) {
|
|
402
|
+
this.emitter?.({
|
|
403
|
+
type: "question.replied",
|
|
404
|
+
properties: { sessionID: sessionId, requestID: questionId, answers, skipped: true },
|
|
405
|
+
});
|
|
406
|
+
return {};
|
|
407
|
+
}
|
|
408
|
+
const responseAnswers = {};
|
|
409
|
+
pending.questionIds.forEach((id, index) => {
|
|
410
|
+
responseAnswers[id] = {
|
|
411
|
+
answers: Array.isArray(answers[index]) ? answers[index].filter((value) => typeof value === "string") : [],
|
|
412
|
+
};
|
|
413
|
+
});
|
|
414
|
+
this.send({
|
|
415
|
+
jsonrpc: "2.0",
|
|
416
|
+
id: pending.requestId,
|
|
417
|
+
result: { answers: responseAnswers },
|
|
418
|
+
});
|
|
419
|
+
this.pendingQuestionRequestIds.delete(questionId);
|
|
420
|
+
this.emitter?.({
|
|
421
|
+
type: "question.replied",
|
|
422
|
+
properties: { sessionID: sessionId, requestID: questionId, answers },
|
|
423
|
+
});
|
|
424
|
+
return {};
|
|
425
|
+
}
|
|
426
|
+
async questionReject(sessionId, questionId) {
|
|
427
|
+
const pending = this.pendingQuestionRequestIds.get(questionId);
|
|
428
|
+
if (!pending) {
|
|
429
|
+
this.emitter?.({
|
|
430
|
+
type: "question.rejected",
|
|
431
|
+
properties: { sessionID: sessionId, requestID: questionId, skipped: true },
|
|
432
|
+
});
|
|
433
|
+
return {};
|
|
434
|
+
}
|
|
435
|
+
this.send({
|
|
436
|
+
jsonrpc: "2.0",
|
|
437
|
+
id: pending.requestId,
|
|
438
|
+
result: { answers: {} },
|
|
439
|
+
});
|
|
440
|
+
this.pendingQuestionRequestIds.delete(questionId);
|
|
441
|
+
this.emitter?.({
|
|
442
|
+
type: "question.rejected",
|
|
443
|
+
properties: { sessionID: sessionId, requestID: questionId },
|
|
444
|
+
});
|
|
445
|
+
return {};
|
|
446
|
+
}
|
|
447
|
+
send(req) {
|
|
448
|
+
if (!this.proc?.stdin?.writable)
|
|
449
|
+
return;
|
|
450
|
+
this.proc.stdin.write(JSON.stringify(req) + "\n");
|
|
451
|
+
}
|
|
452
|
+
call(method, params) {
|
|
453
|
+
return new Promise((resolve, reject) => {
|
|
454
|
+
const id = this.nextId++;
|
|
455
|
+
const key = String(id);
|
|
456
|
+
this.pending.set(key, { resolve, reject });
|
|
457
|
+
this.send({ jsonrpc: "2.0", id, method, params });
|
|
458
|
+
setTimeout(() => {
|
|
459
|
+
if (this.pending.has(key)) {
|
|
460
|
+
this.pending.delete(key);
|
|
461
|
+
reject(new Error(`Codex RPC timeout: ${method} (id=${id})`));
|
|
462
|
+
}
|
|
463
|
+
}, 30_000);
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
handleLine(line) {
|
|
467
|
+
let msg;
|
|
468
|
+
try {
|
|
469
|
+
msg = JSON.parse(line);
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if ("method" in msg && typeof msg.method === "string") {
|
|
475
|
+
const inbound = msg;
|
|
476
|
+
const params = this.asRecord(inbound.params);
|
|
477
|
+
if (inbound.id != null) {
|
|
478
|
+
this.handleServerRequest(inbound.method, inbound.id, params);
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
this.handleNotification(inbound.method, params);
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if ("id" in msg) {
|
|
486
|
+
const resp = msg;
|
|
487
|
+
const pending = this.pending.get(String(resp.id));
|
|
488
|
+
if (!pending)
|
|
489
|
+
return;
|
|
490
|
+
this.pending.delete(String(resp.id));
|
|
491
|
+
if (resp.error) {
|
|
492
|
+
pending.reject(new Error(resp.error.message));
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
pending.resolve(resp.result ?? null);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
handleServerRequest(method, requestId, params) {
|
|
500
|
+
const session = this.resolveSessionFromPayload(params);
|
|
501
|
+
if (method === "item/tool/requestUserInput") {
|
|
502
|
+
const questionRequestId = String(requestId);
|
|
503
|
+
const callId = this.extractItemId(params) ?? undefined;
|
|
504
|
+
const messageId = session ? this.ensureAssistantMessage(session, this.extractTurnId(params) ?? `session:${session.id}`) : undefined;
|
|
505
|
+
const questions = this.extractStructuredUserInputQuestions(params);
|
|
506
|
+
this.pendingQuestionRequestIds.set(questionRequestId, {
|
|
507
|
+
sessionId: session?.id ?? "",
|
|
508
|
+
requestId,
|
|
509
|
+
messageId,
|
|
510
|
+
callId,
|
|
511
|
+
questionIds: questions.map((question) => this.readString(this.asRecord(question).id)).filter((value) => Boolean(value)),
|
|
512
|
+
});
|
|
513
|
+
this.emitter?.({
|
|
514
|
+
type: "question.asked",
|
|
515
|
+
properties: {
|
|
516
|
+
id: questionRequestId,
|
|
517
|
+
sessionID: session?.id,
|
|
518
|
+
questions,
|
|
519
|
+
tool: {
|
|
520
|
+
...(messageId ? { messageID: messageId } : {}),
|
|
521
|
+
...(callId ? { callID: callId } : {}),
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (method === "item/commandExecution/requestApproval"
|
|
528
|
+
|| method === "item/fileChange/requestApproval"
|
|
529
|
+
|| method.endsWith("requestApproval")) {
|
|
530
|
+
const permissionId = String(requestId);
|
|
531
|
+
const callId = this.extractItemId(params) ?? undefined;
|
|
532
|
+
const messageId = session ? this.ensureAssistantMessage(session, this.extractTurnId(params) ?? `session:${session.id}`) : undefined;
|
|
533
|
+
this.pendingPermissionRequestIds.set(permissionId, {
|
|
534
|
+
sessionId: session?.id ?? "",
|
|
535
|
+
requestId,
|
|
536
|
+
method,
|
|
537
|
+
messageId,
|
|
538
|
+
callId,
|
|
539
|
+
});
|
|
540
|
+
this.emitter?.({
|
|
541
|
+
type: "permission.updated",
|
|
542
|
+
properties: {
|
|
543
|
+
id: permissionId,
|
|
544
|
+
sessionID: session?.id,
|
|
545
|
+
messageID: messageId,
|
|
546
|
+
callID: callId,
|
|
547
|
+
type: method,
|
|
548
|
+
title: this.readString(params.reason) ?? this.readString(params.command) ?? method,
|
|
549
|
+
metadata: params,
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
this.send({
|
|
555
|
+
jsonrpc: "2.0",
|
|
556
|
+
id: requestId,
|
|
557
|
+
error: { code: -32601, message: `Unsupported request method: ${method}` },
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
handleNotification(method, params) {
|
|
561
|
+
this.ingestThreadMetadata(params);
|
|
562
|
+
const session = this.resolveSessionFromPayload(params);
|
|
563
|
+
switch (method) {
|
|
564
|
+
case "thread/started":
|
|
565
|
+
case "thread/name/updated":
|
|
566
|
+
if (session) {
|
|
567
|
+
this.upsertSession({
|
|
568
|
+
id: session.id,
|
|
569
|
+
title: this.extractThreadTitle(params),
|
|
570
|
+
createdAt: this.extractCreatedAt(params) ?? session.createdAt,
|
|
571
|
+
updatedAt: this.extractUpdatedAt(params) ?? Date.now(),
|
|
572
|
+
archived: false,
|
|
573
|
+
}, true);
|
|
574
|
+
}
|
|
575
|
+
return;
|
|
576
|
+
case "thread/status/changed":
|
|
577
|
+
if (session) {
|
|
578
|
+
this.upsertSession({
|
|
579
|
+
id: session.id,
|
|
580
|
+
title: this.extractThreadTitle(params),
|
|
581
|
+
createdAt: this.extractCreatedAt(params) ?? session.createdAt,
|
|
582
|
+
updatedAt: this.extractUpdatedAt(params) ?? Date.now(),
|
|
583
|
+
archived: session.archived,
|
|
584
|
+
}, true);
|
|
585
|
+
this.emitter?.({
|
|
586
|
+
type: "session.status",
|
|
587
|
+
properties: { sessionID: session.id, status: params.status ?? params },
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
return;
|
|
591
|
+
case "turn/started":
|
|
592
|
+
if (session) {
|
|
593
|
+
const turnId = this.extractTurnId(params);
|
|
594
|
+
if (turnId)
|
|
595
|
+
session.activeTurnId = turnId;
|
|
596
|
+
session.updatedAt = Date.now();
|
|
597
|
+
this.emitter?.({
|
|
598
|
+
type: "session.status",
|
|
599
|
+
properties: { sessionID: session.id, status: { type: "running" } },
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
return;
|
|
603
|
+
case "thread/tokenUsage/updated":
|
|
604
|
+
if (session) {
|
|
605
|
+
this.emitter?.({
|
|
606
|
+
type: "session.usage",
|
|
607
|
+
properties: {
|
|
608
|
+
sessionID: session.id,
|
|
609
|
+
tokenUsage: params.tokenUsage ?? null,
|
|
610
|
+
},
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
return;
|
|
614
|
+
case "turn/completed":
|
|
615
|
+
case "turn/failed":
|
|
616
|
+
if (session) {
|
|
617
|
+
session.activeTurnId = undefined;
|
|
618
|
+
session.updatedAt = Date.now();
|
|
619
|
+
this.finishAssistantTurn(session, params, method === "turn/failed");
|
|
620
|
+
this.refreshSessionMetadata(session.id).catch(() => {
|
|
621
|
+
// Best-effort metadata refresh for title/preview updates after a turn ends.
|
|
622
|
+
});
|
|
623
|
+
this.emitter?.({ type: "session.idle", properties: { sessionID: session.id } });
|
|
624
|
+
if (method === "turn/failed") {
|
|
625
|
+
const error = this.readString(this.asRecord(params.error).message) ?? "Turn failed";
|
|
626
|
+
this.emitter?.({ type: "session.error", properties: { sessionID: session.id, error } });
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return;
|
|
630
|
+
case "error":
|
|
631
|
+
case "codex/event/error":
|
|
632
|
+
if (session) {
|
|
633
|
+
const error = this.readString(this.asRecord(params.error).message)
|
|
634
|
+
?? this.readString(params.message)
|
|
635
|
+
?? "Codex error";
|
|
636
|
+
this.emitter?.({ type: "session.error", properties: { sessionID: session.id, error } });
|
|
637
|
+
}
|
|
638
|
+
return;
|
|
639
|
+
case "codex/event/user_message":
|
|
640
|
+
this.emitMirroredUserMessage(session, params);
|
|
641
|
+
return;
|
|
642
|
+
case "item/started":
|
|
643
|
+
case "codex/event/item_started":
|
|
644
|
+
this.handleItemStarted(session, params);
|
|
645
|
+
return;
|
|
646
|
+
case "item/agentMessage/delta":
|
|
647
|
+
case "codex/event/agent_message_content_delta":
|
|
648
|
+
case "codex/event/agent_message_delta":
|
|
649
|
+
this.emitTextPart(session, params, "text", false);
|
|
650
|
+
return;
|
|
651
|
+
case "item/reasoning/summaryTextDelta":
|
|
652
|
+
case "item/reasoning/summaryPartAdded":
|
|
653
|
+
case "item/reasoning/textDelta":
|
|
654
|
+
this.emitTextPart(session, params, "reasoning", false);
|
|
655
|
+
return;
|
|
656
|
+
case "item/fileChange/outputDelta":
|
|
657
|
+
case "item/toolCall/outputDelta":
|
|
658
|
+
case "item/toolCall/output_delta":
|
|
659
|
+
case "item/tool_call/outputDelta":
|
|
660
|
+
case "item/tool_call/output_delta":
|
|
661
|
+
case "item/commandExecution/outputDelta":
|
|
662
|
+
case "item/command_execution/outputDelta":
|
|
663
|
+
case "codex/event/exec_command_output_delta":
|
|
664
|
+
case "codex/event/read":
|
|
665
|
+
case "codex/event/search":
|
|
666
|
+
case "codex/event/list_files":
|
|
667
|
+
this.emitStructuredToolPart(session, method, params, false);
|
|
668
|
+
return;
|
|
669
|
+
case "turn/diff/updated":
|
|
670
|
+
case "codex/event/turn_diff_updated":
|
|
671
|
+
case "codex/event/turn_diff":
|
|
672
|
+
this.emitStructuredToolPart(session, method, params, true);
|
|
673
|
+
return;
|
|
674
|
+
case "item/completed":
|
|
675
|
+
case "codex/event/item_completed":
|
|
676
|
+
case "codex/event/agent_message":
|
|
677
|
+
case "codex/event/exec_command_end":
|
|
678
|
+
case "codex/event/patch_apply_end":
|
|
679
|
+
if (this.handleStructuredItemCompleted(session, params)) {
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
this.emitTextPart(session, params, "text", true);
|
|
683
|
+
return;
|
|
684
|
+
case "serverRequest/resolved": {
|
|
685
|
+
const permissionId = this.readString(params.requestId) ?? this.readString(params.requestID);
|
|
686
|
+
if (permissionId && this.pendingPermissionRequestIds.has(permissionId)) {
|
|
687
|
+
this.pendingPermissionRequestIds.delete(permissionId);
|
|
688
|
+
this.emitter?.({ type: "permission.replied", properties: { permissionId } });
|
|
689
|
+
}
|
|
690
|
+
if (permissionId && this.pendingQuestionRequestIds.has(permissionId)) {
|
|
691
|
+
this.pendingQuestionRequestIds.delete(permissionId);
|
|
692
|
+
}
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
default:
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
isCommandApprovalMethod(method) {
|
|
700
|
+
return method === "item/commandExecution/requestApproval"
|
|
701
|
+
|| method === "item/command_execution/request_approval";
|
|
702
|
+
}
|
|
703
|
+
emitMirroredUserMessage(session, params) {
|
|
704
|
+
if (!session)
|
|
705
|
+
return;
|
|
706
|
+
const text = this.readString(params.message) ?? this.readString(params.text);
|
|
707
|
+
if (!text)
|
|
708
|
+
return;
|
|
709
|
+
const turnId = this.extractTurnId(params) ?? `mirrored:${crypto.randomUUID()}`;
|
|
710
|
+
const messageId = `user:${turnId}`;
|
|
711
|
+
if (!session.messages.find((message) => message.id === messageId)) {
|
|
712
|
+
session.messages.push({
|
|
713
|
+
id: messageId,
|
|
714
|
+
role: "user",
|
|
715
|
+
parts: [{ id: `${messageId}:text`, type: "text", text }],
|
|
716
|
+
time: Date.now(),
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
this.emitter?.({
|
|
720
|
+
type: "message.updated",
|
|
721
|
+
properties: { info: { sessionID: session.id, id: messageId, role: "user" } },
|
|
722
|
+
});
|
|
723
|
+
this.emitter?.({
|
|
724
|
+
type: "message.part.updated",
|
|
725
|
+
properties: {
|
|
726
|
+
part: { id: `${messageId}:text`, sessionID: session.id, messageID: messageId, type: "text", text },
|
|
727
|
+
message: { sessionID: session.id, id: messageId, role: "user" },
|
|
728
|
+
},
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
handleItemStarted(session, params) {
|
|
732
|
+
if (!session)
|
|
733
|
+
return;
|
|
734
|
+
const item = this.extractIncomingItem(params);
|
|
735
|
+
const itemType = this.normalizedItemType(this.readString(item.type) ?? "");
|
|
736
|
+
if (itemType === "reasoning") {
|
|
737
|
+
this.emitTextPart(session, params, "reasoning", false);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (itemType === "toolcall"
|
|
741
|
+
|| itemType === "commandexecution"
|
|
742
|
+
|| itemType === "filechange"
|
|
743
|
+
|| itemType === "diff"
|
|
744
|
+
|| itemType === "plan"
|
|
745
|
+
|| itemType === "contextcompaction"
|
|
746
|
+
|| itemType === "enteredreviewmode") {
|
|
747
|
+
this.emitStructuredToolPart(session, itemType, params, false);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
if (this.isAssistantMessageItem(itemType, this.readString(item.role))) {
|
|
751
|
+
this.ensureAssistantMessage(session, this.extractTurnId(params) ?? `session:${session.id}`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
handleStructuredItemCompleted(session, params) {
|
|
755
|
+
if (!session)
|
|
756
|
+
return false;
|
|
757
|
+
const item = this.extractIncomingItem(params);
|
|
758
|
+
const itemType = this.normalizedItemType(this.readString(item.type) ?? "");
|
|
759
|
+
if (itemType === "toolcall"
|
|
760
|
+
|| itemType === "commandexecution"
|
|
761
|
+
|| itemType === "filechange"
|
|
762
|
+
|| itemType === "diff"
|
|
763
|
+
|| itemType === "plan"
|
|
764
|
+
|| itemType === "contextcompaction"
|
|
765
|
+
|| itemType === "enteredreviewmode") {
|
|
766
|
+
this.emitStructuredToolPart(session, itemType, params, true);
|
|
767
|
+
return true;
|
|
768
|
+
}
|
|
769
|
+
return false;
|
|
770
|
+
}
|
|
771
|
+
emitTextPart(session, params, partType, preferWholeText) {
|
|
772
|
+
if (!session)
|
|
773
|
+
return;
|
|
774
|
+
const turnId = this.extractTurnId(params) ?? session.activeTurnId ?? `session:${session.id}`;
|
|
775
|
+
const messageId = this.ensureAssistantMessage(session, turnId);
|
|
776
|
+
const partKey = `${messageId}:${partType}:${this.extractItemId(params) ?? "main"}`;
|
|
777
|
+
const nextChunk = this.extractTextPayload(params);
|
|
778
|
+
if (!nextChunk)
|
|
779
|
+
return;
|
|
780
|
+
const previousText = this.partTextById.get(partKey) ?? "";
|
|
781
|
+
const nextText = preferWholeText
|
|
782
|
+
? nextChunk
|
|
783
|
+
: joinStreamingText(previousText, nextChunk);
|
|
784
|
+
this.partTextById.set(partKey, nextText);
|
|
785
|
+
session.updatedAt = Date.now();
|
|
786
|
+
this.upsertLocalMessagePart(session, messageId, {
|
|
787
|
+
id: partKey,
|
|
788
|
+
sessionID: session.id,
|
|
789
|
+
messageID: messageId,
|
|
790
|
+
type: partType,
|
|
791
|
+
text: nextText,
|
|
792
|
+
});
|
|
793
|
+
this.emitMessagePartEvent(session.id, messageId, "assistant", {
|
|
794
|
+
id: partKey,
|
|
795
|
+
sessionID: session.id,
|
|
796
|
+
messageID: messageId,
|
|
797
|
+
type: partType,
|
|
798
|
+
text: nextText,
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
emitStructuredToolPart(session, method, params, completed) {
|
|
802
|
+
if (!session)
|
|
803
|
+
return;
|
|
804
|
+
const turnId = this.extractTurnId(params) ?? session.activeTurnId ?? `session:${session.id}`;
|
|
805
|
+
const messageId = this.ensureAssistantMessage(session, turnId);
|
|
806
|
+
const item = this.extractIncomingItem(params);
|
|
807
|
+
const normalizedType = this.normalizeStructuredType(this.readString(item.type) ?? method);
|
|
808
|
+
const itemId = this.extractItemId(params) ?? this.readString(item.id) ?? normalizedType ?? "tool";
|
|
809
|
+
const fileChangeLike = this.isFileChangeStructuredItem(normalizedType, item, params);
|
|
810
|
+
const emittedPartType = normalizedType === "plan"
|
|
811
|
+
? "plan"
|
|
812
|
+
: (fileChangeLike ? "file-change" : "tool");
|
|
813
|
+
const partId = `${messageId}:${emittedPartType}:${itemId}`;
|
|
814
|
+
const nextText = this.extractStructuredOutput(params, item, normalizedType);
|
|
815
|
+
const prevOutput = this.partTextById.get(partId) ?? "";
|
|
816
|
+
const output = completed
|
|
817
|
+
? nextText ?? prevOutput
|
|
818
|
+
: `${prevOutput}${nextText ?? ""}`;
|
|
819
|
+
if (output) {
|
|
820
|
+
this.partTextById.set(partId, output);
|
|
821
|
+
}
|
|
822
|
+
const state = completed ? "completed" : "running";
|
|
823
|
+
const name = this.describeToolPart(normalizedType, method, item);
|
|
824
|
+
const input = this.extractToolInput(item, params);
|
|
825
|
+
const outputValue = output || this.describeCompletedItemOutput(item, params, normalizedType) || undefined;
|
|
826
|
+
const patch = emittedPartType === "file-change"
|
|
827
|
+
? this.extractCanonicalPatch(params, item)
|
|
828
|
+
: undefined;
|
|
829
|
+
const part = {
|
|
830
|
+
id: partId,
|
|
831
|
+
sessionID: session.id,
|
|
832
|
+
messageID: messageId,
|
|
833
|
+
type: emittedPartType,
|
|
834
|
+
...(emittedPartType === "plan"
|
|
835
|
+
? { text: outputValue ?? (emittedPartType === "plan" ? "Planning..." : "") }
|
|
836
|
+
: { name, toolName: name, input, output: outputValue, state, ...(patch ? { patch } : {}) }),
|
|
837
|
+
};
|
|
838
|
+
this.upsertLocalMessagePart(session, messageId, part);
|
|
839
|
+
this.emitMessagePartEvent(session.id, messageId, "assistant", part);
|
|
840
|
+
}
|
|
841
|
+
finishAssistantTurn(session, params, failed) {
|
|
842
|
+
const turnId = this.extractTurnId(params);
|
|
843
|
+
if (!turnId)
|
|
844
|
+
return;
|
|
845
|
+
const messageId = this.assistantMessageIdByTurnId.get(turnId);
|
|
846
|
+
if (!messageId)
|
|
847
|
+
return;
|
|
848
|
+
const finishPartId = `${messageId}:finish`;
|
|
849
|
+
const part = {
|
|
850
|
+
id: finishPartId,
|
|
851
|
+
sessionID: session.id,
|
|
852
|
+
messageID: messageId,
|
|
853
|
+
type: "step-finish",
|
|
854
|
+
title: failed ? "Failed" : "Completed",
|
|
855
|
+
time: { end: Date.now() },
|
|
856
|
+
};
|
|
857
|
+
this.upsertLocalMessagePart(session, messageId, part);
|
|
858
|
+
this.emitMessagePartEvent(session.id, messageId, "assistant", part);
|
|
859
|
+
}
|
|
860
|
+
emitMessagePartEvent(sessionId, messageId, role, part) {
|
|
861
|
+
this.emitter?.({
|
|
862
|
+
type: "message.updated",
|
|
863
|
+
properties: { info: { sessionID: sessionId, id: messageId, role } },
|
|
864
|
+
});
|
|
865
|
+
this.emitter?.({
|
|
866
|
+
type: "message.part.updated",
|
|
867
|
+
properties: {
|
|
868
|
+
part,
|
|
869
|
+
message: { sessionID: sessionId, id: messageId, role },
|
|
870
|
+
},
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
ensureAssistantMessage(session, turnId) {
|
|
874
|
+
const existing = this.assistantMessageIdByTurnId.get(turnId);
|
|
875
|
+
if (existing)
|
|
876
|
+
return existing;
|
|
877
|
+
const messageId = crypto.randomUUID();
|
|
878
|
+
this.assistantMessageIdByTurnId.set(turnId, messageId);
|
|
879
|
+
session.messages.push({
|
|
880
|
+
id: messageId,
|
|
881
|
+
role: "assistant",
|
|
882
|
+
parts: [{
|
|
883
|
+
id: `${messageId}:start`,
|
|
884
|
+
type: "step-start",
|
|
885
|
+
title: "Working",
|
|
886
|
+
time: { start: Date.now() },
|
|
887
|
+
}],
|
|
888
|
+
time: Date.now(),
|
|
889
|
+
});
|
|
890
|
+
return messageId;
|
|
891
|
+
}
|
|
892
|
+
upsertLocalMessagePart(session, messageId, part) {
|
|
893
|
+
const message = session.messages.find((entry) => entry.id === messageId);
|
|
894
|
+
if (!message)
|
|
895
|
+
return;
|
|
896
|
+
const parts = Array.isArray(message.parts) ? [...message.parts] : [];
|
|
897
|
+
const idx = parts.findIndex((entry) => this.asRecord(entry).id === part.id);
|
|
898
|
+
if (idx >= 0) {
|
|
899
|
+
parts[idx] = part;
|
|
900
|
+
}
|
|
901
|
+
else {
|
|
902
|
+
parts.push(part);
|
|
903
|
+
}
|
|
904
|
+
message.parts = parts;
|
|
905
|
+
message.time = Date.now();
|
|
906
|
+
}
|
|
907
|
+
async fetchServerThreads() {
|
|
908
|
+
return this.fetchServerThreadsByArchiveState(false);
|
|
909
|
+
}
|
|
910
|
+
async refreshConfigDefaults() {
|
|
911
|
+
const result = await this.call("config/read", undefined);
|
|
912
|
+
const payload = this.asRecord(result);
|
|
913
|
+
const config = this.asRecord(payload.config ?? result);
|
|
914
|
+
const raw = config.model_context_window;
|
|
915
|
+
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
|
|
916
|
+
this.defaultModelContextWindow = raw;
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (typeof raw === "string" && raw.trim()) {
|
|
920
|
+
const parsed = Number(raw);
|
|
921
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
922
|
+
this.defaultModelContextWindow = parsed;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
async refreshSessionMetadata(sessionId) {
|
|
927
|
+
const session = this.sessions.get(sessionId);
|
|
928
|
+
const result = await this.call("thread/read", {
|
|
929
|
+
threadId: sessionId,
|
|
930
|
+
includeTurns: false,
|
|
931
|
+
});
|
|
932
|
+
const threadObject = this.extractThreadObject(result);
|
|
933
|
+
if (!threadObject || Object.keys(threadObject).length === 0) {
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
this.upsertSession({
|
|
937
|
+
id: sessionId,
|
|
938
|
+
title: this.extractThreadTitle(threadObject),
|
|
939
|
+
createdAt: this.extractCreatedAt(threadObject) ?? session?.createdAt ?? Date.now(),
|
|
940
|
+
updatedAt: this.extractUpdatedAt(threadObject) ?? session?.updatedAt ?? Date.now(),
|
|
941
|
+
archived: false,
|
|
942
|
+
cwd: this.extractThreadCwd(threadObject) ?? session?.cwd,
|
|
943
|
+
}, true);
|
|
944
|
+
}
|
|
945
|
+
async fetchServerThreadsByArchiveState(archived) {
|
|
946
|
+
const threads = [];
|
|
947
|
+
let nextCursor = null;
|
|
948
|
+
let hasRequestedFirstPage = false;
|
|
949
|
+
do {
|
|
950
|
+
const result = await this.call("thread/list", {
|
|
951
|
+
sourceKinds: THREAD_LIST_SOURCE_KINDS,
|
|
952
|
+
archived,
|
|
953
|
+
cursor: nextCursor,
|
|
954
|
+
});
|
|
955
|
+
const payload = this.asRecord(result);
|
|
956
|
+
const page = Array.isArray(payload.data)
|
|
957
|
+
? payload.data
|
|
958
|
+
: Array.isArray(payload.items)
|
|
959
|
+
? payload.items
|
|
960
|
+
: Array.isArray(payload.threads)
|
|
961
|
+
? payload.threads
|
|
962
|
+
: [];
|
|
963
|
+
for (const entry of page) {
|
|
964
|
+
const parsed = this.parseThreadListEntry(entry, archived);
|
|
965
|
+
if (parsed)
|
|
966
|
+
threads.push(parsed);
|
|
967
|
+
}
|
|
968
|
+
nextCursor = payload.nextCursor ?? payload.next_cursor ?? null;
|
|
969
|
+
hasRequestedFirstPage = true;
|
|
970
|
+
} while (hasRequestedFirstPage && this.hasNextCursor(nextCursor));
|
|
971
|
+
return threads;
|
|
972
|
+
}
|
|
973
|
+
async fetchModels() {
|
|
974
|
+
const result = await this.call("model/list", {
|
|
975
|
+
cursor: null,
|
|
976
|
+
limit: 50,
|
|
977
|
+
includeHidden: false,
|
|
978
|
+
});
|
|
979
|
+
const payload = this.asRecord(result);
|
|
980
|
+
const items = Array.isArray(payload.items)
|
|
981
|
+
? payload.items
|
|
982
|
+
: Array.isArray(payload.data)
|
|
983
|
+
? payload.data
|
|
984
|
+
: Array.isArray(payload.models)
|
|
985
|
+
? payload.models
|
|
986
|
+
: [];
|
|
987
|
+
return items
|
|
988
|
+
.map((value) => {
|
|
989
|
+
const obj = this.asRecord(value);
|
|
990
|
+
const model = this.readString(obj.model) ?? this.readString(obj.id);
|
|
991
|
+
if (!model)
|
|
992
|
+
return undefined;
|
|
993
|
+
const displayName = this.readString(obj.displayName)
|
|
994
|
+
?? this.readString(obj.display_name)
|
|
995
|
+
?? model;
|
|
996
|
+
const supportedReasoningEfforts = Array.isArray(obj.supportedReasoningEfforts)
|
|
997
|
+
? obj.supportedReasoningEfforts
|
|
998
|
+
.map((effort) => {
|
|
999
|
+
const effortObj = this.asRecord(effort);
|
|
1000
|
+
const reasoningEffort = this.readString(effortObj.reasoningEffort)
|
|
1001
|
+
?? this.readString(effortObj.reasoning_effort);
|
|
1002
|
+
if (!reasoningEffort)
|
|
1003
|
+
return undefined;
|
|
1004
|
+
const description = this.readString(effortObj.description);
|
|
1005
|
+
return {
|
|
1006
|
+
reasoningEffort,
|
|
1007
|
+
...(description ? { description } : {}),
|
|
1008
|
+
};
|
|
1009
|
+
})
|
|
1010
|
+
.filter((effort) => Boolean(effort))
|
|
1011
|
+
: [];
|
|
1012
|
+
const defaultReasoningEffort = this.readString(obj.defaultReasoningEffort)
|
|
1013
|
+
?? this.readString(obj.default_reasoning_effort);
|
|
1014
|
+
const additionalSpeedTiers = Array.isArray(obj.additionalSpeedTiers)
|
|
1015
|
+
? obj.additionalSpeedTiers.filter((tier) => typeof tier === "string" && tier.length > 0)
|
|
1016
|
+
: [];
|
|
1017
|
+
return {
|
|
1018
|
+
id: this.readString(obj.id) ?? model,
|
|
1019
|
+
model,
|
|
1020
|
+
displayName,
|
|
1021
|
+
description: this.readString(obj.description) ?? "",
|
|
1022
|
+
isDefault: Boolean(obj.isDefault ?? obj.is_default),
|
|
1023
|
+
...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
|
|
1024
|
+
supportedReasoningEfforts,
|
|
1025
|
+
additionalSpeedTiers,
|
|
1026
|
+
};
|
|
1027
|
+
})
|
|
1028
|
+
.filter((value) => Boolean(value));
|
|
1029
|
+
}
|
|
1030
|
+
async fetchModelById(modelId) {
|
|
1031
|
+
const items = await this.fetchModels();
|
|
1032
|
+
if (!modelId) {
|
|
1033
|
+
return items.find((item) => item.isDefault) ?? items[0];
|
|
1034
|
+
}
|
|
1035
|
+
return items.find((item) => item.model === modelId || item.id === modelId)
|
|
1036
|
+
?? items.find((item) => item.isDefault)
|
|
1037
|
+
?? items[0];
|
|
1038
|
+
}
|
|
1039
|
+
async resolveModelId(model) {
|
|
1040
|
+
if (model) {
|
|
1041
|
+
return model.providerID === "codex" ? model.modelID : `${model.providerID}/${model.modelID}`;
|
|
1042
|
+
}
|
|
1043
|
+
const items = await this.fetchModels();
|
|
1044
|
+
return items.find((item) => item.isDefault)?.model ?? items[0]?.model;
|
|
1045
|
+
}
|
|
1046
|
+
buildCollaborationMode(agent, modelId, reasoningEffort) {
|
|
1047
|
+
const normalizedAgent = (agent ?? "").trim().toLowerCase();
|
|
1048
|
+
const mode = normalizedAgent === "build" ? "default" : normalizedAgent;
|
|
1049
|
+
if (mode !== "default" && mode !== "plan") {
|
|
1050
|
+
return undefined;
|
|
1051
|
+
}
|
|
1052
|
+
if (!modelId) {
|
|
1053
|
+
return undefined;
|
|
1054
|
+
}
|
|
1055
|
+
return {
|
|
1056
|
+
mode,
|
|
1057
|
+
settings: {
|
|
1058
|
+
model: modelId,
|
|
1059
|
+
reasoning_effort: reasoningEffort,
|
|
1060
|
+
},
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
parseThreadListEntry(value, archived = false) {
|
|
1064
|
+
const obj = this.asRecord(value);
|
|
1065
|
+
const id = this.extractThreadId(obj);
|
|
1066
|
+
if (!id)
|
|
1067
|
+
return undefined;
|
|
1068
|
+
return {
|
|
1069
|
+
id,
|
|
1070
|
+
title: this.extractThreadTitle(obj),
|
|
1071
|
+
createdAt: this.extractCreatedAt(obj),
|
|
1072
|
+
updatedAt: this.extractUpdatedAt(obj),
|
|
1073
|
+
archived,
|
|
1074
|
+
cwd: this.extractThreadCwd(obj),
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
hasNextCursor(value) {
|
|
1078
|
+
if (value == null)
|
|
1079
|
+
return false;
|
|
1080
|
+
if (typeof value === "string")
|
|
1081
|
+
return value.trim().length > 0;
|
|
1082
|
+
return true;
|
|
1083
|
+
}
|
|
1084
|
+
ingestThreadMetadata(payload) {
|
|
1085
|
+
const threadId = this.extractThreadId(payload);
|
|
1086
|
+
if (!threadId)
|
|
1087
|
+
return;
|
|
1088
|
+
const title = this.extractThreadTitleFromUnknown(payload);
|
|
1089
|
+
this.upsertSession({
|
|
1090
|
+
id: threadId,
|
|
1091
|
+
title,
|
|
1092
|
+
createdAt: this.extractCreatedAt(payload) ?? Date.now(),
|
|
1093
|
+
updatedAt: this.extractUpdatedAt(payload) ?? Date.now(),
|
|
1094
|
+
cwd: this.extractThreadCwd(payload),
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
reconcileSessionsWithServer(activeThreads, archivedThreads = []) {
|
|
1098
|
+
const localSessions = this.sessions;
|
|
1099
|
+
const merged = new Map();
|
|
1100
|
+
for (const thread of activeThreads) {
|
|
1101
|
+
if (this.deletedThreadIds.has(thread.id))
|
|
1102
|
+
continue;
|
|
1103
|
+
const session = this.mergeSession(localSessions.get(thread.id), { ...thread, archived: false });
|
|
1104
|
+
merged.set(thread.id, session);
|
|
1105
|
+
}
|
|
1106
|
+
for (const thread of archivedThreads) {
|
|
1107
|
+
if (this.deletedThreadIds.has(thread.id))
|
|
1108
|
+
continue;
|
|
1109
|
+
if (merged.has(thread.id))
|
|
1110
|
+
continue;
|
|
1111
|
+
const session = this.mergeSession(localSessions.get(thread.id), { ...thread, archived: true });
|
|
1112
|
+
merged.set(thread.id, session);
|
|
1113
|
+
}
|
|
1114
|
+
for (const [id, session] of localSessions.entries()) {
|
|
1115
|
+
if (!merged.has(id)) {
|
|
1116
|
+
merged.set(id, session);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
this.sessions = merged;
|
|
1120
|
+
}
|
|
1121
|
+
mergeSession(existing, input) {
|
|
1122
|
+
if (!existing) {
|
|
1123
|
+
return {
|
|
1124
|
+
id: input.id,
|
|
1125
|
+
title: input.title ?? "Conversation",
|
|
1126
|
+
createdAt: input.createdAt ?? Date.now(),
|
|
1127
|
+
updatedAt: input.updatedAt ?? Date.now(),
|
|
1128
|
+
archived: input.archived ?? false,
|
|
1129
|
+
cwd: input.cwd,
|
|
1130
|
+
messages: [],
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
existing.title = input.title ?? existing.title;
|
|
1134
|
+
existing.createdAt = input.createdAt ?? existing.createdAt;
|
|
1135
|
+
existing.updatedAt = input.updatedAt != null
|
|
1136
|
+
? Math.max(existing.updatedAt, input.updatedAt)
|
|
1137
|
+
: existing.updatedAt;
|
|
1138
|
+
existing.archived = input.archived ?? existing.archived ?? false;
|
|
1139
|
+
existing.cwd = input.cwd ?? existing.cwd;
|
|
1140
|
+
return existing;
|
|
1141
|
+
}
|
|
1142
|
+
upsertSession(input, emitUpdated = false) {
|
|
1143
|
+
const existing = this.sessions.get(input.id);
|
|
1144
|
+
const before = existing
|
|
1145
|
+
? {
|
|
1146
|
+
title: existing.title,
|
|
1147
|
+
createdAt: existing.createdAt,
|
|
1148
|
+
updatedAt: existing.updatedAt,
|
|
1149
|
+
archived: existing.archived ?? false,
|
|
1150
|
+
cwd: existing.cwd,
|
|
1151
|
+
}
|
|
1152
|
+
: null;
|
|
1153
|
+
const session = this.mergeSession(existing, input);
|
|
1154
|
+
this.sessions.set(session.id, session);
|
|
1155
|
+
const changed = !before
|
|
1156
|
+
|| before.title !== session.title
|
|
1157
|
+
|| before.createdAt !== session.createdAt
|
|
1158
|
+
|| before.updatedAt !== session.updatedAt
|
|
1159
|
+
|| before.archived !== (session.archived ?? false)
|
|
1160
|
+
|| before.cwd !== session.cwd;
|
|
1161
|
+
if (emitUpdated && changed) {
|
|
1162
|
+
this.emitter?.({ type: "session.updated", properties: { info: this.toSessionInfo(session) } });
|
|
1163
|
+
}
|
|
1164
|
+
return session;
|
|
1165
|
+
}
|
|
1166
|
+
ensureLocalSession(sessionId) {
|
|
1167
|
+
const existing = this.sessions.get(sessionId);
|
|
1168
|
+
if (existing)
|
|
1169
|
+
return existing;
|
|
1170
|
+
return this.upsertSession({ id: sessionId, title: "Conversation", createdAt: Date.now(), updatedAt: Date.now() });
|
|
1171
|
+
}
|
|
1172
|
+
async ensureThreadResumed(threadId, force = false, codexOptions, collaborationMode) {
|
|
1173
|
+
if (!threadId || this.resumedThreadIds.has(threadId)) {
|
|
1174
|
+
if (!force)
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
const session = this.sessions.get(threadId);
|
|
1178
|
+
const params = {
|
|
1179
|
+
threadId,
|
|
1180
|
+
persistExtendedHistory: true,
|
|
1181
|
+
};
|
|
1182
|
+
if (session?.cwd) {
|
|
1183
|
+
params.cwd = session.cwd;
|
|
1184
|
+
}
|
|
1185
|
+
const permissionMode = codexOptions?.permissionMode ?? "default";
|
|
1186
|
+
if (permissionMode === "full-access") {
|
|
1187
|
+
params.approvalPolicy = "never";
|
|
1188
|
+
params.sandbox = "danger-full-access";
|
|
1189
|
+
}
|
|
1190
|
+
if (collaborationMode) {
|
|
1191
|
+
params.collaborationMode = collaborationMode;
|
|
1192
|
+
}
|
|
1193
|
+
const result = await this.call("thread/resume", params);
|
|
1194
|
+
const payload = this.asRecord(result);
|
|
1195
|
+
const threadValue = payload.thread;
|
|
1196
|
+
const threadObject = threadValue ? this.asRecord(threadValue) : this.extractThreadObject(result);
|
|
1197
|
+
if (threadObject && Object.keys(threadObject).length > 0) {
|
|
1198
|
+
const nextSession = this.upsertSession({
|
|
1199
|
+
id: threadId,
|
|
1200
|
+
title: this.extractThreadTitle(threadObject),
|
|
1201
|
+
createdAt: this.extractCreatedAt(threadObject) ?? session?.createdAt ?? Date.now(),
|
|
1202
|
+
updatedAt: this.extractUpdatedAt(threadObject) ?? session?.updatedAt ?? Date.now(),
|
|
1203
|
+
archived: false,
|
|
1204
|
+
cwd: this.extractThreadCwd(threadObject) ?? session?.cwd,
|
|
1205
|
+
}, true);
|
|
1206
|
+
const historyMessages = this.decodeMessagesFromThreadRead(threadId, threadObject);
|
|
1207
|
+
if (historyMessages.length > 0 && (!nextSession.activeTurnId || nextSession.messages.length === 0 || force)) {
|
|
1208
|
+
nextSession.messages = historyMessages;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
this.resumedThreadIds.add(threadId);
|
|
1212
|
+
}
|
|
1213
|
+
resolveSessionFromPayload(payload) {
|
|
1214
|
+
const threadId = this.extractThreadId(payload);
|
|
1215
|
+
if (!threadId)
|
|
1216
|
+
return undefined;
|
|
1217
|
+
return this.ensureLocalSession(threadId);
|
|
1218
|
+
}
|
|
1219
|
+
async resolveInFlightTurnId(threadId) {
|
|
1220
|
+
const result = await this.call("thread/read", { threadId, includeTurns: true });
|
|
1221
|
+
const thread = this.extractThreadObject(result);
|
|
1222
|
+
const turns = this.readArray(thread.turns);
|
|
1223
|
+
for (const turn of turns.slice().reverse()) {
|
|
1224
|
+
const turnObj = this.asRecord(turn);
|
|
1225
|
+
const status = this.normalizedItemType(this.readString(turnObj.status) ?? this.readString(this.asRecord(turnObj.status).type) ?? "");
|
|
1226
|
+
if (status.includes("running")
|
|
1227
|
+
|| status.includes("active")
|
|
1228
|
+
|| status.includes("processing")
|
|
1229
|
+
|| status.includes("started")
|
|
1230
|
+
|| status.includes("pending")) {
|
|
1231
|
+
return this.readString(turnObj.id);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
return undefined;
|
|
1235
|
+
}
|
|
1236
|
+
decodeMessagesFromThreadRead(threadId, threadObject) {
|
|
1237
|
+
const turns = this.readArray(threadObject.turns);
|
|
1238
|
+
const messages = [];
|
|
1239
|
+
let orderOffset = 0;
|
|
1240
|
+
for (const turn of turns) {
|
|
1241
|
+
const turnObject = this.asRecord(turn);
|
|
1242
|
+
const turnId = this.readString(turnObject.id);
|
|
1243
|
+
const turnTime = this.extractUpdatedAt(turnObject) ?? this.extractCreatedAt(turnObject) ?? Date.now();
|
|
1244
|
+
const items = this.readArray(turnObject.items);
|
|
1245
|
+
const assistantParts = [];
|
|
1246
|
+
let assistantMessageId;
|
|
1247
|
+
let assistantTimestamp = turnTime;
|
|
1248
|
+
const turnItemTypes = [];
|
|
1249
|
+
for (const item of items) {
|
|
1250
|
+
const itemObject = this.asRecord(item);
|
|
1251
|
+
const type = this.normalizedItemType(this.readString(itemObject.type) ?? "");
|
|
1252
|
+
turnItemTypes.push(type || "unknown");
|
|
1253
|
+
const itemId = this.readString(itemObject.id) ?? crypto.randomUUID();
|
|
1254
|
+
const timestamp = this.extractUpdatedAt(itemObject) ?? this.extractCreatedAt(itemObject) ?? (turnTime + orderOffset++);
|
|
1255
|
+
if (type === "usermessage") {
|
|
1256
|
+
const parts = this.decodeUserMessageParts(itemObject, threadId, itemId);
|
|
1257
|
+
if (parts.length === 0)
|
|
1258
|
+
continue;
|
|
1259
|
+
messages.push({
|
|
1260
|
+
id: itemId,
|
|
1261
|
+
role: "user",
|
|
1262
|
+
parts,
|
|
1263
|
+
time: timestamp,
|
|
1264
|
+
});
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
if (type === "agentmessage" || type === "assistantmessage" || (type === "message" && !this.isUserRole(itemObject))) {
|
|
1268
|
+
const text = this.decodeItemText(itemObject);
|
|
1269
|
+
if (!text)
|
|
1270
|
+
continue;
|
|
1271
|
+
assistantMessageId = assistantMessageId ?? itemId;
|
|
1272
|
+
assistantTimestamp = Math.max(assistantTimestamp, timestamp);
|
|
1273
|
+
assistantParts.push({
|
|
1274
|
+
id: `${assistantMessageId}:text:${itemId}`,
|
|
1275
|
+
type: "text",
|
|
1276
|
+
text,
|
|
1277
|
+
sessionID: threadId,
|
|
1278
|
+
messageID: assistantMessageId,
|
|
1279
|
+
});
|
|
1280
|
+
continue;
|
|
1281
|
+
}
|
|
1282
|
+
if (type === "reasoning") {
|
|
1283
|
+
const text = this.decodeReasoningItemText(itemObject);
|
|
1284
|
+
assistantMessageId = assistantMessageId ?? (turnId ? `assistant:${turnId}` : itemId);
|
|
1285
|
+
assistantTimestamp = Math.max(assistantTimestamp, timestamp);
|
|
1286
|
+
assistantParts.push({
|
|
1287
|
+
id: `${assistantMessageId}:reasoning:${itemId}`,
|
|
1288
|
+
type: "reasoning",
|
|
1289
|
+
text,
|
|
1290
|
+
sessionID: threadId,
|
|
1291
|
+
messageID: assistantMessageId,
|
|
1292
|
+
});
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
if (type === "plan") {
|
|
1296
|
+
const text = this.decodePlanItemText(itemObject);
|
|
1297
|
+
assistantMessageId = assistantMessageId ?? (turnId ? `assistant:${turnId}` : itemId);
|
|
1298
|
+
assistantTimestamp = Math.max(assistantTimestamp, timestamp);
|
|
1299
|
+
assistantParts.push({
|
|
1300
|
+
id: `${assistantMessageId}:plan:${itemId}`,
|
|
1301
|
+
type: "plan",
|
|
1302
|
+
text,
|
|
1303
|
+
sessionID: threadId,
|
|
1304
|
+
messageID: assistantMessageId,
|
|
1305
|
+
});
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
if (type === "commandexecution"
|
|
1309
|
+
|| type === "enteredreviewmode"
|
|
1310
|
+
|| type === "exitedreviewmode"
|
|
1311
|
+
|| type === "contextcompaction"
|
|
1312
|
+
|| type === "mcptoolcall"
|
|
1313
|
+
|| type === "dynamictoolcall"
|
|
1314
|
+
|| type === "collabtoolcall"
|
|
1315
|
+
|| type === "collabagenttoolcall"
|
|
1316
|
+
|| type === "websearch"
|
|
1317
|
+
|| type === "imageview") {
|
|
1318
|
+
assistantMessageId = assistantMessageId ?? (turnId ? `assistant:${turnId}` : itemId);
|
|
1319
|
+
assistantTimestamp = Math.max(assistantTimestamp, timestamp);
|
|
1320
|
+
assistantParts.push(this.decodeStoredToolLikePart(type, itemObject, threadId, assistantMessageId, itemId));
|
|
1321
|
+
continue;
|
|
1322
|
+
}
|
|
1323
|
+
if (type === "filechange" || type === "toolcall" || type === "diff") {
|
|
1324
|
+
assistantMessageId = assistantMessageId ?? (turnId ? `assistant:${turnId}` : itemId);
|
|
1325
|
+
assistantTimestamp = Math.max(assistantTimestamp, timestamp);
|
|
1326
|
+
assistantParts.push(this.decodeStoredToolLikePart(type, itemObject, threadId, assistantMessageId, itemId));
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
if (assistantParts.length > 0) {
|
|
1330
|
+
const resolvedAssistantMessageId = assistantMessageId ?? (turnId ? `assistant:${turnId}` : crypto.randomUUID());
|
|
1331
|
+
messages.push({
|
|
1332
|
+
id: resolvedAssistantMessageId,
|
|
1333
|
+
role: "assistant",
|
|
1334
|
+
parts: assistantParts.map((part) => ({
|
|
1335
|
+
...part,
|
|
1336
|
+
sessionID: threadId,
|
|
1337
|
+
messageID: resolvedAssistantMessageId,
|
|
1338
|
+
})),
|
|
1339
|
+
time: assistantTimestamp,
|
|
1340
|
+
});
|
|
1341
|
+
if (turnId) {
|
|
1342
|
+
this.assistantMessageIdByTurnId.set(turnId, resolvedAssistantMessageId);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
this.debugLog("decoded stored turn", {
|
|
1346
|
+
threadId,
|
|
1347
|
+
turnId: turnId ?? null,
|
|
1348
|
+
itemCount: items.length,
|
|
1349
|
+
itemTypes: turnItemTypes,
|
|
1350
|
+
emittedUserMessages: messages.filter((message) => message.role === "user").length,
|
|
1351
|
+
emittedAssistantParts: assistantParts.length,
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
return messages;
|
|
1355
|
+
}
|
|
1356
|
+
logThreadReadSummary(sessionId, threadObject) {
|
|
1357
|
+
const turns = this.readArray(threadObject.turns);
|
|
1358
|
+
const turnSummaries = turns.map((turn, index) => {
|
|
1359
|
+
const turnObject = this.asRecord(turn);
|
|
1360
|
+
const items = this.readArray(turnObject.items);
|
|
1361
|
+
return {
|
|
1362
|
+
index,
|
|
1363
|
+
turnId: this.readString(turnObject.id) ?? null,
|
|
1364
|
+
status: this.readString(turnObject.status) ?? this.readString(this.asRecord(turnObject.status).type) ?? null,
|
|
1365
|
+
itemCount: items.length,
|
|
1366
|
+
itemTypes: items.map((item) => this.normalizedItemType(this.readString(this.asRecord(item).type) ?? "") || "unknown"),
|
|
1367
|
+
itemSummaries: items.map((item) => {
|
|
1368
|
+
const itemObject = this.asRecord(item);
|
|
1369
|
+
const type = this.normalizedItemType(this.readString(itemObject.type) ?? "") || "unknown";
|
|
1370
|
+
return {
|
|
1371
|
+
id: this.readString(itemObject.id) ?? null,
|
|
1372
|
+
type,
|
|
1373
|
+
keys: Object.keys(itemObject).sort(),
|
|
1374
|
+
textPreview: this.firstString(itemObject, ["text", "summary", "message", "query", "path", "tool", "command"])?.slice(0, 120) ?? null,
|
|
1375
|
+
hasAggregatedOutput: typeof itemObject.aggregatedOutput === "string" && itemObject.aggregatedOutput.length > 0,
|
|
1376
|
+
aggregatedOutputLength: typeof itemObject.aggregatedOutput === "string" ? itemObject.aggregatedOutput.length : 0,
|
|
1377
|
+
changesCount: Array.isArray(itemObject.changes) ? itemObject.changes.length : 0,
|
|
1378
|
+
hasResult: itemObject.result != null,
|
|
1379
|
+
hasContentItems: Array.isArray(itemObject.contentItems) && itemObject.contentItems.length > 0,
|
|
1380
|
+
status: this.readString(itemObject.status) ?? null,
|
|
1381
|
+
};
|
|
1382
|
+
}),
|
|
1383
|
+
};
|
|
1384
|
+
});
|
|
1385
|
+
this.debugHistory("thread/read summary", {
|
|
1386
|
+
sessionId,
|
|
1387
|
+
threadId: this.readString(threadObject.id) ?? null,
|
|
1388
|
+
turnCount: turns.length,
|
|
1389
|
+
turnSummaries,
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
decodeStoredToolLikePart(type, itemObject, threadId, messageId, itemId) {
|
|
1393
|
+
if (type === "filechange" || type === "diff" || this.isFileChangeStructuredItem(type, itemObject)) {
|
|
1394
|
+
const output = this.decodeFileLikeItemText(itemObject) ?? this.describeCompletedItemOutput(itemObject, itemObject, type) ?? "File changes";
|
|
1395
|
+
const patch = this.extractCanonicalPatch(itemObject, itemObject);
|
|
1396
|
+
return {
|
|
1397
|
+
id: `${messageId}:file-change:${itemId}`,
|
|
1398
|
+
type: "file-change",
|
|
1399
|
+
name: this.describeToolPart(type, type, itemObject),
|
|
1400
|
+
toolName: this.describeToolPart(type, type, itemObject),
|
|
1401
|
+
output,
|
|
1402
|
+
state: "completed",
|
|
1403
|
+
...(patch ? { patch } : {}),
|
|
1404
|
+
sessionID: threadId,
|
|
1405
|
+
messageID: messageId,
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
const name = this.describeStoredToolName(type, itemObject);
|
|
1409
|
+
const input = this.extractStoredToolInput(type, itemObject);
|
|
1410
|
+
const output = this.extractStoredToolOutput(type, itemObject);
|
|
1411
|
+
return {
|
|
1412
|
+
id: `${messageId}:tool:${itemId}`,
|
|
1413
|
+
type: "tool",
|
|
1414
|
+
name,
|
|
1415
|
+
toolName: name,
|
|
1416
|
+
...(input !== undefined ? { input } : {}),
|
|
1417
|
+
...(output !== undefined ? { output } : {}),
|
|
1418
|
+
state: "completed",
|
|
1419
|
+
sessionID: threadId,
|
|
1420
|
+
messageID: messageId,
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
describeStoredToolName(type, itemObject) {
|
|
1424
|
+
if (type === "commandexecution") {
|
|
1425
|
+
return "command";
|
|
1426
|
+
}
|
|
1427
|
+
if (type === "websearch") {
|
|
1428
|
+
return "web-search";
|
|
1429
|
+
}
|
|
1430
|
+
if (type === "imageview") {
|
|
1431
|
+
return "image-view";
|
|
1432
|
+
}
|
|
1433
|
+
if (type === "collabtoolcall" || type === "collabagenttoolcall") {
|
|
1434
|
+
return "agent";
|
|
1435
|
+
}
|
|
1436
|
+
if (type === "enteredreviewmode") {
|
|
1437
|
+
return "review";
|
|
1438
|
+
}
|
|
1439
|
+
if (type === "exitedreviewmode") {
|
|
1440
|
+
return "review";
|
|
1441
|
+
}
|
|
1442
|
+
if (type === "contextcompaction") {
|
|
1443
|
+
return "context";
|
|
1444
|
+
}
|
|
1445
|
+
return this.firstString(itemObject, [
|
|
1446
|
+
"name",
|
|
1447
|
+
"toolName",
|
|
1448
|
+
"tool_name",
|
|
1449
|
+
"title",
|
|
1450
|
+
"serverToolName",
|
|
1451
|
+
"server_tool_name",
|
|
1452
|
+
"kind",
|
|
1453
|
+
]) ?? type;
|
|
1454
|
+
}
|
|
1455
|
+
extractStoredToolInput(type, itemObject) {
|
|
1456
|
+
if (type === "commandexecution") {
|
|
1457
|
+
return this.extractCommandExecutionInput(itemObject, itemObject);
|
|
1458
|
+
}
|
|
1459
|
+
return itemObject.input
|
|
1460
|
+
?? itemObject.arguments
|
|
1461
|
+
?? itemObject.args
|
|
1462
|
+
?? itemObject.query
|
|
1463
|
+
?? itemObject.path
|
|
1464
|
+
?? itemObject.url
|
|
1465
|
+
?? itemObject.command
|
|
1466
|
+
?? itemObject.pattern
|
|
1467
|
+
?? undefined;
|
|
1468
|
+
}
|
|
1469
|
+
extractStoredToolOutput(type, itemObject) {
|
|
1470
|
+
if (type === "commandexecution") {
|
|
1471
|
+
return this.decodeStoredCommandExecutionOutput(itemObject);
|
|
1472
|
+
}
|
|
1473
|
+
if (type === "enteredreviewmode") {
|
|
1474
|
+
return `Reviewing ${this.readString(itemObject.review) ?? "changes"}...`;
|
|
1475
|
+
}
|
|
1476
|
+
if (type === "exitedreviewmode") {
|
|
1477
|
+
return this.firstString(itemObject, ["summary", "text", "message"]) ?? "Exited review mode";
|
|
1478
|
+
}
|
|
1479
|
+
if (type === "contextcompaction") {
|
|
1480
|
+
return "Context compacted";
|
|
1481
|
+
}
|
|
1482
|
+
if (type === "imageview") {
|
|
1483
|
+
const path = this.firstString(itemObject, ["path", "file", "filePath", "file_path", "url"]);
|
|
1484
|
+
return path ? `Viewed image ${path}` : "Viewed image";
|
|
1485
|
+
}
|
|
1486
|
+
const direct = this.firstString(itemObject, [
|
|
1487
|
+
"aggregatedOutput",
|
|
1488
|
+
"output",
|
|
1489
|
+
"outputText",
|
|
1490
|
+
"output_text",
|
|
1491
|
+
"text",
|
|
1492
|
+
"message",
|
|
1493
|
+
"summary",
|
|
1494
|
+
"result",
|
|
1495
|
+
"content",
|
|
1496
|
+
]);
|
|
1497
|
+
if (direct?.trim()) {
|
|
1498
|
+
return direct.trim();
|
|
1499
|
+
}
|
|
1500
|
+
const flattened = this.flattenTextValue(itemObject.output ?? itemObject.result ?? itemObject.content).trim();
|
|
1501
|
+
return flattened || undefined;
|
|
1502
|
+
}
|
|
1503
|
+
decodeStoredCommandExecutionOutput(itemObject) {
|
|
1504
|
+
const aggregatedOutput = this.firstString(itemObject, [
|
|
1505
|
+
"aggregatedOutput",
|
|
1506
|
+
"aggregated_output",
|
|
1507
|
+
"output",
|
|
1508
|
+
"outputText",
|
|
1509
|
+
"output_text",
|
|
1510
|
+
"stdout",
|
|
1511
|
+
"stderr",
|
|
1512
|
+
"text",
|
|
1513
|
+
"message",
|
|
1514
|
+
"summary",
|
|
1515
|
+
]);
|
|
1516
|
+
if (aggregatedOutput?.trim()) {
|
|
1517
|
+
return aggregatedOutput.trim();
|
|
1518
|
+
}
|
|
1519
|
+
return this.decodeCommandExecutionItemText(itemObject, "commandexecution");
|
|
1520
|
+
}
|
|
1521
|
+
decodeUserMessageParts(itemObject, threadId, itemId) {
|
|
1522
|
+
const parts = [];
|
|
1523
|
+
const content = this.readArray(itemObject.content);
|
|
1524
|
+
let fileIndex = 0;
|
|
1525
|
+
for (const entry of content) {
|
|
1526
|
+
const obj = this.asRecord(entry);
|
|
1527
|
+
const type = this.normalizedItemType(this.readString(obj.type) ?? "");
|
|
1528
|
+
if (type === "image" || type === "localimage") {
|
|
1529
|
+
const url = this.readString(obj.url) ?? this.readString(obj.image_url) ?? this.readString(obj.imageUrl);
|
|
1530
|
+
if (!url)
|
|
1531
|
+
continue;
|
|
1532
|
+
parts.push({
|
|
1533
|
+
id: `${itemId}:file:${fileIndex++}`,
|
|
1534
|
+
type: "file",
|
|
1535
|
+
mime: this.inferImageMimeFromDataUrl(url),
|
|
1536
|
+
filename: this.readString(obj.filename) ?? this.readString(obj.name) ?? undefined,
|
|
1537
|
+
url,
|
|
1538
|
+
sessionID: threadId,
|
|
1539
|
+
messageID: itemId,
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
const text = this.decodeItemText(itemObject);
|
|
1544
|
+
if (text) {
|
|
1545
|
+
parts.push({
|
|
1546
|
+
id: `${itemId}:text`,
|
|
1547
|
+
type: "text",
|
|
1548
|
+
text,
|
|
1549
|
+
sessionID: threadId,
|
|
1550
|
+
messageID: itemId,
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
return parts;
|
|
1554
|
+
}
|
|
1555
|
+
decodeItemText(itemObject) {
|
|
1556
|
+
const content = this.readArray(itemObject.content);
|
|
1557
|
+
const parts = [];
|
|
1558
|
+
for (const entry of content) {
|
|
1559
|
+
const obj = this.asRecord(entry);
|
|
1560
|
+
const type = this.normalizedItemType(this.readString(obj.type) ?? "");
|
|
1561
|
+
if (type === "text" || type === "inputtext" || type === "outputtext" || type === "message") {
|
|
1562
|
+
const text = this.readString(obj.text) ?? this.readString(this.asRecord(obj.data).text);
|
|
1563
|
+
if (text)
|
|
1564
|
+
parts.push(text);
|
|
1565
|
+
}
|
|
1566
|
+
else if (type === "skill") {
|
|
1567
|
+
const skill = this.readString(obj.id) ?? this.readString(obj.name);
|
|
1568
|
+
if (skill)
|
|
1569
|
+
parts.push(`$${skill}`);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
const joined = parts.join("\n").trim();
|
|
1573
|
+
return joined || this.readString(itemObject.text) || this.readString(itemObject.message) || "";
|
|
1574
|
+
}
|
|
1575
|
+
makeTurnInputPayload(text, files, imageUrlKey) {
|
|
1576
|
+
const items = [];
|
|
1577
|
+
for (const file of files) {
|
|
1578
|
+
const url = typeof file.url === "string" ? file.url.trim() : "";
|
|
1579
|
+
if (!url)
|
|
1580
|
+
continue;
|
|
1581
|
+
items.push({
|
|
1582
|
+
type: "image",
|
|
1583
|
+
[imageUrlKey]: url,
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
const trimmedText = text.trim();
|
|
1587
|
+
if (trimmedText) {
|
|
1588
|
+
items.push({ type: "text", text: trimmedText });
|
|
1589
|
+
}
|
|
1590
|
+
return items;
|
|
1591
|
+
}
|
|
1592
|
+
shouldRetryTurnStartWithImageURLField(error) {
|
|
1593
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
1594
|
+
if (!message.includes("image_url")) {
|
|
1595
|
+
return false;
|
|
1596
|
+
}
|
|
1597
|
+
return (message.includes("missing")
|
|
1598
|
+
|| message.includes("unknown field")
|
|
1599
|
+
|| message.includes("expected")
|
|
1600
|
+
|| message.includes("invalid"));
|
|
1601
|
+
}
|
|
1602
|
+
inferImageMimeFromDataUrl(url) {
|
|
1603
|
+
const match = /^data:([^;,]+)[;,]/i.exec(url);
|
|
1604
|
+
if (match?.[1]) {
|
|
1605
|
+
return match[1];
|
|
1606
|
+
}
|
|
1607
|
+
return "image/jpeg";
|
|
1608
|
+
}
|
|
1609
|
+
decodeReasoningItemText(itemObject) {
|
|
1610
|
+
const summary = this.flattenTextValue(itemObject.summary).trim();
|
|
1611
|
+
const content = this.flattenTextValue(itemObject.content).trim();
|
|
1612
|
+
return [summary, content].filter(Boolean).join("\n\n") || "Thinking...";
|
|
1613
|
+
}
|
|
1614
|
+
decodePlanItemText(itemObject) {
|
|
1615
|
+
return this.decodeItemText(itemObject) || this.flattenTextValue(itemObject.summary).trim() || "Planning...";
|
|
1616
|
+
}
|
|
1617
|
+
decodeCommandExecutionItemText(itemObject, type) {
|
|
1618
|
+
if (type === "enteredreviewmode") {
|
|
1619
|
+
return `Reviewing ${this.readString(itemObject.review) ?? "changes"}...`;
|
|
1620
|
+
}
|
|
1621
|
+
if (type === "contextcompaction") {
|
|
1622
|
+
return "Context compacted";
|
|
1623
|
+
}
|
|
1624
|
+
const status = this.readString(this.asRecord(itemObject.status).type) ?? this.readString(itemObject.status) ?? "completed";
|
|
1625
|
+
const command = this.firstString(itemObject, ["command", "cmd", "raw_command", "rawCommand", "input", "invocation"]) ?? "command";
|
|
1626
|
+
return `${this.normalizedCommandPhase(status)} ${this.shortCommand(command)}`;
|
|
1627
|
+
}
|
|
1628
|
+
decodeFileLikeItemText(itemObject) {
|
|
1629
|
+
const status = this.normalizedFileChangeStatus(itemObject, true);
|
|
1630
|
+
const changesBody = this.renderFileChangeEntriesBody(itemObject.changes);
|
|
1631
|
+
if (changesBody) {
|
|
1632
|
+
return `Status: ${status}\n\n${changesBody}`;
|
|
1633
|
+
}
|
|
1634
|
+
const diff = this.firstString(itemObject, ["diff", "unified_diff", "unifiedDiff", "patch"]);
|
|
1635
|
+
if (diff?.trim()) {
|
|
1636
|
+
return this.renderUnifiedDiffBody(diff, status);
|
|
1637
|
+
}
|
|
1638
|
+
const direct = this.firstString(itemObject, [
|
|
1639
|
+
"text",
|
|
1640
|
+
"message",
|
|
1641
|
+
"summary",
|
|
1642
|
+
"stdout",
|
|
1643
|
+
"stderr",
|
|
1644
|
+
"output_text",
|
|
1645
|
+
"outputText",
|
|
1646
|
+
]);
|
|
1647
|
+
if (direct?.trim()) {
|
|
1648
|
+
return `Status: ${status}\n\n${direct.trim()}`;
|
|
1649
|
+
}
|
|
1650
|
+
return undefined;
|
|
1651
|
+
}
|
|
1652
|
+
normalizedCommandPhase(rawStatus) {
|
|
1653
|
+
const normalized = rawStatus.trim().toLowerCase();
|
|
1654
|
+
if (normalized.includes("fail") || normalized.includes("error"))
|
|
1655
|
+
return "failed";
|
|
1656
|
+
if (normalized.includes("cancel") || normalized.includes("abort") || normalized.includes("interrupt"))
|
|
1657
|
+
return "stopped";
|
|
1658
|
+
if (normalized.includes("complete") || normalized.includes("success") || normalized.includes("done"))
|
|
1659
|
+
return "completed";
|
|
1660
|
+
return "running";
|
|
1661
|
+
}
|
|
1662
|
+
shortCommand(rawCommand, maxLength = 92) {
|
|
1663
|
+
const normalized = rawCommand.trim().replace(/\s+/g, " ");
|
|
1664
|
+
if (!normalized)
|
|
1665
|
+
return "command";
|
|
1666
|
+
if (normalized.length <= maxLength)
|
|
1667
|
+
return normalized;
|
|
1668
|
+
return `${normalized.slice(0, maxLength - 1)}...`;
|
|
1669
|
+
}
|
|
1670
|
+
flattenTextValue(value) {
|
|
1671
|
+
if (typeof value === "string")
|
|
1672
|
+
return value;
|
|
1673
|
+
if (Array.isArray(value)) {
|
|
1674
|
+
return value.map((entry) => this.flattenTextValue(entry)).filter(Boolean).join("\n");
|
|
1675
|
+
}
|
|
1676
|
+
if (value && typeof value === "object") {
|
|
1677
|
+
const obj = value;
|
|
1678
|
+
return this.readString(obj.text) ?? this.readString(obj.message) ?? this.flattenTextValue(obj.content);
|
|
1679
|
+
}
|
|
1680
|
+
return "";
|
|
1681
|
+
}
|
|
1682
|
+
toSessionInfo(session) {
|
|
1683
|
+
return {
|
|
1684
|
+
id: session.id,
|
|
1685
|
+
title: session.title,
|
|
1686
|
+
time: {
|
|
1687
|
+
created: session.createdAt,
|
|
1688
|
+
updated: session.updatedAt,
|
|
1689
|
+
},
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
extractThreadObject(payload) {
|
|
1693
|
+
const obj = this.asRecord(payload);
|
|
1694
|
+
const nested = this.asRecord(obj.thread);
|
|
1695
|
+
return Object.keys(nested).length > 0 ? nested : obj;
|
|
1696
|
+
}
|
|
1697
|
+
extractThreadTitleFromUnknown(payload) {
|
|
1698
|
+
return this.extractThreadTitle(this.extractThreadObject(payload));
|
|
1699
|
+
}
|
|
1700
|
+
extractThreadTitle(payload) {
|
|
1701
|
+
const thread = this.asRecord(payload.thread);
|
|
1702
|
+
const explicitName = this.readString(thread.name) ?? this.readString(payload.name);
|
|
1703
|
+
if (explicitName)
|
|
1704
|
+
return explicitName;
|
|
1705
|
+
const explicitTitle = this.readString(thread.title) ?? this.readString(payload.title);
|
|
1706
|
+
if (explicitTitle)
|
|
1707
|
+
return explicitTitle;
|
|
1708
|
+
const preview = this.readString(thread.preview) ?? this.readString(payload.preview);
|
|
1709
|
+
if (!preview)
|
|
1710
|
+
return undefined;
|
|
1711
|
+
return preview.charAt(0).toUpperCase() + preview.slice(1);
|
|
1712
|
+
}
|
|
1713
|
+
extractTurnId(payload) {
|
|
1714
|
+
return (this.readString(payload.turnId)
|
|
1715
|
+
?? this.readString(payload.turn_id)
|
|
1716
|
+
?? this.readString(this.asRecord(payload.turn).id)
|
|
1717
|
+
?? this.readString(this.asRecord(payload.turn).turnId)
|
|
1718
|
+
?? this.readString(this.asRecord(payload.event).id)
|
|
1719
|
+
?? this.readString(this.asRecord(this.asRecord(payload.event).turn).id)
|
|
1720
|
+
?? ((payload.msg != null || payload.event != null) ? this.readString(payload.id) : undefined));
|
|
1721
|
+
}
|
|
1722
|
+
extractItemId(payload) {
|
|
1723
|
+
return (this.readString(payload.itemId)
|
|
1724
|
+
?? this.readString(payload.item_id)
|
|
1725
|
+
?? this.readString(payload.call_id)
|
|
1726
|
+
?? this.readString(payload.callId)
|
|
1727
|
+
?? this.readString(payload.id)
|
|
1728
|
+
?? this.readString(payload.messageId)
|
|
1729
|
+
?? this.readString(payload.message_id)
|
|
1730
|
+
?? this.readString(this.asRecord(payload.item).id)
|
|
1731
|
+
?? this.readString(this.asRecord(payload.item).itemId)
|
|
1732
|
+
?? this.readString(this.asRecord(payload.item).call_id)
|
|
1733
|
+
?? this.readString(this.asRecord(payload.item).callId)
|
|
1734
|
+
?? this.readString(this.asRecord(payload.item).messageId)
|
|
1735
|
+
?? this.readString(this.asRecord(payload.event).itemId)
|
|
1736
|
+
?? this.readString(this.asRecord(payload.event).item_id)
|
|
1737
|
+
?? this.readString(this.asRecord(payload.event).call_id)
|
|
1738
|
+
?? this.readString(this.asRecord(payload.event).callId)
|
|
1739
|
+
?? this.readString(this.asRecord(payload.event).id)
|
|
1740
|
+
?? this.readString(this.asRecord(this.asRecord(payload.event).item).id));
|
|
1741
|
+
}
|
|
1742
|
+
extractTextPayload(payload) {
|
|
1743
|
+
return (this.readRawString(payload.delta)
|
|
1744
|
+
?? this.readRawString(payload.text)
|
|
1745
|
+
?? this.readRawString(payload.message)
|
|
1746
|
+
?? this.readRawString(this.asRecord(payload.item).text)
|
|
1747
|
+
?? this.readRawString(this.asRecord(payload.item).delta)
|
|
1748
|
+
?? this.readRawString(this.asRecord(payload.item).message)
|
|
1749
|
+
?? this.readRawString(this.asRecord(payload.event).text)
|
|
1750
|
+
?? this.readRawString(this.asRecord(payload.event).delta)
|
|
1751
|
+
?? this.readRawString(this.asRecord(payload.event).message));
|
|
1752
|
+
}
|
|
1753
|
+
extractStructuredUserInputQuestions(payload) {
|
|
1754
|
+
const rawQuestions = this.readArray(payload.questions);
|
|
1755
|
+
const questions = [];
|
|
1756
|
+
for (const value of rawQuestions) {
|
|
1757
|
+
const question = this.asRecord(value);
|
|
1758
|
+
const options = this.readArray(question.options)
|
|
1759
|
+
.map((option) => {
|
|
1760
|
+
const optionObject = this.asRecord(option);
|
|
1761
|
+
const label = this.readString(optionObject.label);
|
|
1762
|
+
const description = this.readString(optionObject.description);
|
|
1763
|
+
if (!label)
|
|
1764
|
+
return undefined;
|
|
1765
|
+
return {
|
|
1766
|
+
label,
|
|
1767
|
+
...(description ? { description } : {}),
|
|
1768
|
+
};
|
|
1769
|
+
})
|
|
1770
|
+
.filter((value) => Boolean(value));
|
|
1771
|
+
const id = this.readString(question.id);
|
|
1772
|
+
const header = this.readString(question.header);
|
|
1773
|
+
const prompt = this.readString(question.question);
|
|
1774
|
+
if (!id || !header || !prompt) {
|
|
1775
|
+
continue;
|
|
1776
|
+
}
|
|
1777
|
+
questions.push({
|
|
1778
|
+
id,
|
|
1779
|
+
header,
|
|
1780
|
+
question: prompt,
|
|
1781
|
+
...(options.length > 0 ? { options } : {}),
|
|
1782
|
+
...(typeof question.isOther === "boolean" ? { isOther: question.isOther } : {}),
|
|
1783
|
+
...(typeof question.isSecret === "boolean" ? { isSecret: question.isSecret } : {}),
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
return questions;
|
|
1787
|
+
}
|
|
1788
|
+
extractThreadId(payload) {
|
|
1789
|
+
if (!payload || typeof payload !== "object")
|
|
1790
|
+
return undefined;
|
|
1791
|
+
const obj = payload;
|
|
1792
|
+
const direct = this.readString(obj.threadId) ?? this.readString(obj.thread_id) ?? this.readString(obj.id);
|
|
1793
|
+
if (direct)
|
|
1794
|
+
return direct;
|
|
1795
|
+
const thread = this.asRecord(obj.thread);
|
|
1796
|
+
return this.readString(thread.id) ?? this.readString(thread.threadId) ?? this.readString(thread.thread_id);
|
|
1797
|
+
}
|
|
1798
|
+
extractThreadCwd(payload) {
|
|
1799
|
+
const obj = this.extractThreadObject(payload);
|
|
1800
|
+
const cwd = this.firstString(obj, [
|
|
1801
|
+
"cwd",
|
|
1802
|
+
"projectPath",
|
|
1803
|
+
"project_path",
|
|
1804
|
+
"gitWorkingDirectory",
|
|
1805
|
+
"git_working_directory",
|
|
1806
|
+
"workingDirectory",
|
|
1807
|
+
"working_directory",
|
|
1808
|
+
]);
|
|
1809
|
+
return cwd ? this.normalizeDirectoryPath(cwd) : undefined;
|
|
1810
|
+
}
|
|
1811
|
+
extractCreatedAt(payload) {
|
|
1812
|
+
const obj = this.extractThreadObject(payload);
|
|
1813
|
+
return this.readTimestamp(obj.createdAt) ?? this.readTimestamp(obj.created_at);
|
|
1814
|
+
}
|
|
1815
|
+
extractUpdatedAt(payload) {
|
|
1816
|
+
const obj = this.extractThreadObject(payload);
|
|
1817
|
+
return this.readTimestamp(obj.updatedAt)
|
|
1818
|
+
?? this.readTimestamp(obj.updated_at)
|
|
1819
|
+
?? this.readTimestamp(obj.time);
|
|
1820
|
+
}
|
|
1821
|
+
readTimestamp(value) {
|
|
1822
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1823
|
+
return value > 10_000_000_000 ? value : value * 1000;
|
|
1824
|
+
}
|
|
1825
|
+
if (typeof value === "string" && value.trim()) {
|
|
1826
|
+
const asNumber = Number(value);
|
|
1827
|
+
if (Number.isFinite(asNumber)) {
|
|
1828
|
+
return asNumber > 10_000_000_000 ? asNumber : asNumber * 1000;
|
|
1829
|
+
}
|
|
1830
|
+
const parsed = Date.parse(value);
|
|
1831
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
1832
|
+
}
|
|
1833
|
+
if (value && typeof value === "object") {
|
|
1834
|
+
const obj = value;
|
|
1835
|
+
return this.readTimestamp(obj.updated) ?? this.readTimestamp(obj.created) ?? this.readTimestamp(obj.start) ?? this.readTimestamp(obj.end);
|
|
1836
|
+
}
|
|
1837
|
+
return undefined;
|
|
1838
|
+
}
|
|
1839
|
+
firstString(obj, keys) {
|
|
1840
|
+
for (const key of keys) {
|
|
1841
|
+
const value = this.readString(obj[key]);
|
|
1842
|
+
if (value)
|
|
1843
|
+
return value;
|
|
1844
|
+
}
|
|
1845
|
+
return undefined;
|
|
1846
|
+
}
|
|
1847
|
+
firstStringFromSources(sources, keys) {
|
|
1848
|
+
for (const source of sources) {
|
|
1849
|
+
const value = this.firstString(source, keys);
|
|
1850
|
+
if (value)
|
|
1851
|
+
return value;
|
|
1852
|
+
}
|
|
1853
|
+
return undefined;
|
|
1854
|
+
}
|
|
1855
|
+
readArray(value) {
|
|
1856
|
+
return Array.isArray(value) ? value : [];
|
|
1857
|
+
}
|
|
1858
|
+
readRawString(value) {
|
|
1859
|
+
return typeof value === "string" ? value : undefined;
|
|
1860
|
+
}
|
|
1861
|
+
readString(value) {
|
|
1862
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
1863
|
+
}
|
|
1864
|
+
asRecord(value) {
|
|
1865
|
+
return value && typeof value === "object" ? value : {};
|
|
1866
|
+
}
|
|
1867
|
+
normalizedItemType(rawType) {
|
|
1868
|
+
return rawType.replace(/[_-]/g, "").toLowerCase();
|
|
1869
|
+
}
|
|
1870
|
+
isUserRole(itemObject) {
|
|
1871
|
+
return (this.readString(itemObject.role) ?? "").toLowerCase().includes("user");
|
|
1872
|
+
}
|
|
1873
|
+
isAssistantMessageItem(itemType, role) {
|
|
1874
|
+
const normalizedRole = (role ?? "").toLowerCase();
|
|
1875
|
+
return itemType === "agentmessage"
|
|
1876
|
+
|| itemType === "assistantmessage"
|
|
1877
|
+
|| itemType === "exitedreviewmode"
|
|
1878
|
+
|| (itemType === "message" && !normalizedRole.includes("user"));
|
|
1879
|
+
}
|
|
1880
|
+
extractIncomingItem(params) {
|
|
1881
|
+
const direct = this.asRecord(params.item);
|
|
1882
|
+
if (Object.keys(direct).length > 0)
|
|
1883
|
+
return direct;
|
|
1884
|
+
const eventItem = this.asRecord(this.asRecord(params.event).item);
|
|
1885
|
+
if (Object.keys(eventItem).length > 0)
|
|
1886
|
+
return eventItem;
|
|
1887
|
+
return this.asRecord(params.event);
|
|
1888
|
+
}
|
|
1889
|
+
describeToolPart(normalizedType, method, item) {
|
|
1890
|
+
if (normalizedType === "commandexecution") {
|
|
1891
|
+
return "command";
|
|
1892
|
+
}
|
|
1893
|
+
if (normalizedType === "filechange" || normalizedType === "diff") {
|
|
1894
|
+
return "file-change";
|
|
1895
|
+
}
|
|
1896
|
+
if (normalizedType === "plan") {
|
|
1897
|
+
return "plan";
|
|
1898
|
+
}
|
|
1899
|
+
if (normalizedType === "enteredreviewmode") {
|
|
1900
|
+
return "review";
|
|
1901
|
+
}
|
|
1902
|
+
if (normalizedType === "contextcompaction") {
|
|
1903
|
+
return "context";
|
|
1904
|
+
}
|
|
1905
|
+
return this.readString(item.name) ?? method.replace(/^.*\//, "");
|
|
1906
|
+
}
|
|
1907
|
+
normalizeStructuredType(rawType) {
|
|
1908
|
+
const normalized = this.normalizedItemType(rawType);
|
|
1909
|
+
if (normalized.includes("turndiff") || normalized === "diff")
|
|
1910
|
+
return "diff";
|
|
1911
|
+
if (normalized.includes("filechange"))
|
|
1912
|
+
return "filechange";
|
|
1913
|
+
if (normalized.includes("toolcall"))
|
|
1914
|
+
return "toolcall";
|
|
1915
|
+
if (normalized.includes("commandexecution"))
|
|
1916
|
+
return "commandexecution";
|
|
1917
|
+
return normalized;
|
|
1918
|
+
}
|
|
1919
|
+
isFileChangeStructuredItem(normalizedType, item, params) {
|
|
1920
|
+
if (normalizedType === "filechange" || normalizedType === "diff") {
|
|
1921
|
+
return true;
|
|
1922
|
+
}
|
|
1923
|
+
if (normalizedType !== "toolcall") {
|
|
1924
|
+
return false;
|
|
1925
|
+
}
|
|
1926
|
+
const event = this.asRecord(params?.event);
|
|
1927
|
+
const nestedItem = this.asRecord(event.item);
|
|
1928
|
+
const sources = [item, params ?? {}, event, nestedItem];
|
|
1929
|
+
for (const source of sources) {
|
|
1930
|
+
if (this.firstString(source, ["diff", "unified_diff", "unifiedDiff", "patch"])) {
|
|
1931
|
+
return true;
|
|
1932
|
+
}
|
|
1933
|
+
if (this.readArray(source.changes).length > 0) {
|
|
1934
|
+
return true;
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
const toolName = this.firstString(item, ["name", "toolName", "tool_name", "title"])?.toLowerCase() ?? "";
|
|
1938
|
+
return toolName.includes("patch") || toolName.includes("edit") || toolName.includes("write");
|
|
1939
|
+
}
|
|
1940
|
+
extractStructuredOutput(params, item, normalizedType) {
|
|
1941
|
+
if (normalizedType === "filechange" || normalizedType === "toolcall" || normalizedType === "diff") {
|
|
1942
|
+
return this.extractDiffLikePayload(params, item, normalizedType) ?? this.extractTextPayload(params);
|
|
1943
|
+
}
|
|
1944
|
+
return this.extractTextPayload(params);
|
|
1945
|
+
}
|
|
1946
|
+
extractDiffLikePayload(params, item, normalizedType) {
|
|
1947
|
+
const event = this.asRecord(params.event);
|
|
1948
|
+
const nestedItem = this.asRecord(event.item);
|
|
1949
|
+
const sources = [params, item, event, nestedItem];
|
|
1950
|
+
const status = this.normalizedFileChangeStatus(item, false);
|
|
1951
|
+
const changesBody = this.renderFileChangeEntriesBody(item.changes ?? params.changes ?? event.changes ?? nestedItem.changes);
|
|
1952
|
+
if (changesBody) {
|
|
1953
|
+
return `Status: ${status}\n\n${changesBody}`;
|
|
1954
|
+
}
|
|
1955
|
+
for (const source of sources) {
|
|
1956
|
+
const diff = this.firstString(source, ["diff", "unified_diff", "unifiedDiff", "patch"]);
|
|
1957
|
+
if (diff) {
|
|
1958
|
+
return this.renderUnifiedDiffBody(diff, status);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
for (const source of sources) {
|
|
1962
|
+
const direct = this.firstString(source, ["text", "message", "summary", "output", "output_text", "outputText"]);
|
|
1963
|
+
if (direct) {
|
|
1964
|
+
if (normalizedType === "toolcall" && !this.isFileChangeStructuredItem("toolcall", item, params)) {
|
|
1965
|
+
return direct;
|
|
1966
|
+
}
|
|
1967
|
+
return `Status: ${status}\n\n${direct}`;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
return undefined;
|
|
1971
|
+
}
|
|
1972
|
+
renderFileChangeEntriesBody(rawChanges) {
|
|
1973
|
+
const changes = this.readArray(rawChanges);
|
|
1974
|
+
if (changes.length === 0)
|
|
1975
|
+
return undefined;
|
|
1976
|
+
const rendered = changes
|
|
1977
|
+
.map((change) => {
|
|
1978
|
+
const obj = this.asRecord(change);
|
|
1979
|
+
const path = this.firstString(obj, [
|
|
1980
|
+
"path",
|
|
1981
|
+
"file",
|
|
1982
|
+
"file_path",
|
|
1983
|
+
"filePath",
|
|
1984
|
+
"relative_path",
|
|
1985
|
+
"relativePath",
|
|
1986
|
+
"new_path",
|
|
1987
|
+
"newPath",
|
|
1988
|
+
"to",
|
|
1989
|
+
"target",
|
|
1990
|
+
"name",
|
|
1991
|
+
"old_path",
|
|
1992
|
+
"oldPath",
|
|
1993
|
+
"from",
|
|
1994
|
+
]);
|
|
1995
|
+
const normalizedPath = this.normalizeDisplayPath(path) ?? "file";
|
|
1996
|
+
const kind = this.firstString(obj, ["kind", "type", "action", "status"]) ?? "change";
|
|
1997
|
+
const diff = this.firstString(obj, ["diff", "unified_diff", "unifiedDiff", "patch"]) ?? "";
|
|
1998
|
+
return diff
|
|
1999
|
+
? `Path: ${normalizedPath}\nKind: ${kind}\n\n\`\`\`diff\n${diff}\n\`\`\``
|
|
2000
|
+
: `Path: ${normalizedPath}\nKind: ${kind}`;
|
|
2001
|
+
})
|
|
2002
|
+
.filter(Boolean);
|
|
2003
|
+
return rendered.length > 0 ? rendered.join("\n\n---\n\n") : undefined;
|
|
2004
|
+
}
|
|
2005
|
+
renderUnifiedDiffBody(diff, status) {
|
|
2006
|
+
return `Status: ${status}\n\n\`\`\`diff\n${diff.trim()}\n\`\`\``;
|
|
2007
|
+
}
|
|
2008
|
+
extractCanonicalPatch(params, item) {
|
|
2009
|
+
const event = this.asRecord(params.event);
|
|
2010
|
+
const nestedItem = this.asRecord(event.item);
|
|
2011
|
+
const sources = [item, params, event, nestedItem];
|
|
2012
|
+
for (const source of sources) {
|
|
2013
|
+
const diff = this.firstString(source, ["diff", "unified_diff", "unifiedDiff", "patch"]);
|
|
2014
|
+
if (diff?.trim()) {
|
|
2015
|
+
return diff.trim();
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
const changes = this.readArray(item.changes ?? params.changes ?? event.changes ?? nestedItem.changes);
|
|
2019
|
+
if (changes.length === 0)
|
|
2020
|
+
return undefined;
|
|
2021
|
+
const patch = changes
|
|
2022
|
+
.map((change) => {
|
|
2023
|
+
const obj = this.asRecord(change);
|
|
2024
|
+
return this.firstString(obj, ["diff", "unified_diff", "unifiedDiff", "patch"]) ?? "";
|
|
2025
|
+
})
|
|
2026
|
+
.filter((value) => value.trim().length > 0)
|
|
2027
|
+
.join("\n");
|
|
2028
|
+
return patch.trim() || undefined;
|
|
2029
|
+
}
|
|
2030
|
+
normalizeDisplayPath(rawPath) {
|
|
2031
|
+
if (!rawPath)
|
|
2032
|
+
return undefined;
|
|
2033
|
+
const trimmed = rawPath.trim();
|
|
2034
|
+
if (!trimmed)
|
|
2035
|
+
return undefined;
|
|
2036
|
+
if (!path.isAbsolute(trimmed))
|
|
2037
|
+
return trimmed;
|
|
2038
|
+
const relative = path.relative(process.cwd(), trimmed);
|
|
2039
|
+
if (!relative || relative.startsWith("..")) {
|
|
2040
|
+
return trimmed;
|
|
2041
|
+
}
|
|
2042
|
+
return relative;
|
|
2043
|
+
}
|
|
2044
|
+
normalizeDirectoryPath(rawPath) {
|
|
2045
|
+
if (!rawPath)
|
|
2046
|
+
return undefined;
|
|
2047
|
+
const trimmed = rawPath.trim();
|
|
2048
|
+
if (!trimmed)
|
|
2049
|
+
return undefined;
|
|
2050
|
+
return path.resolve(trimmed);
|
|
2051
|
+
}
|
|
2052
|
+
belongsToCurrentRoot(session) {
|
|
2053
|
+
const sessionCwd = this.normalizeDirectoryPath(session.cwd);
|
|
2054
|
+
const currentRoot = this.normalizeDirectoryPath(process.cwd());
|
|
2055
|
+
if (!sessionCwd || !currentRoot) {
|
|
2056
|
+
return false;
|
|
2057
|
+
}
|
|
2058
|
+
return sessionCwd === currentRoot;
|
|
2059
|
+
}
|
|
2060
|
+
normalizedFileChangeStatus(itemObject, isCompleted) {
|
|
2061
|
+
const status = this.readString(itemObject.status)
|
|
2062
|
+
?? this.readString(this.asRecord(itemObject.status).type)
|
|
2063
|
+
?? this.readString(itemObject.state)
|
|
2064
|
+
?? this.readString(this.asRecord(itemObject.state).type);
|
|
2065
|
+
if (status)
|
|
2066
|
+
return status;
|
|
2067
|
+
return isCompleted ? "completed" : "inProgress";
|
|
2068
|
+
}
|
|
2069
|
+
extractToolInput(item, params) {
|
|
2070
|
+
const normalizedType = this.normalizeStructuredType(this.readString(item.type) ?? "");
|
|
2071
|
+
if (normalizedType === "commandexecution") {
|
|
2072
|
+
return this.extractCommandExecutionInput(item, params);
|
|
2073
|
+
}
|
|
2074
|
+
return item.input ?? item.command ?? item.path ?? item.args ?? params.command ?? params.path ?? undefined;
|
|
2075
|
+
}
|
|
2076
|
+
extractCommandExecutionInput(item, params) {
|
|
2077
|
+
const event = this.asRecord(params?.event);
|
|
2078
|
+
const nestedItem = this.asRecord(event.item);
|
|
2079
|
+
const sources = [item, params ?? {}, event, nestedItem];
|
|
2080
|
+
const command = this.firstStringFromSources(sources, [
|
|
2081
|
+
"command",
|
|
2082
|
+
"cmd",
|
|
2083
|
+
"raw_command",
|
|
2084
|
+
"rawCommand",
|
|
2085
|
+
"invocation",
|
|
2086
|
+
"input",
|
|
2087
|
+
"fullCommand",
|
|
2088
|
+
"full_command",
|
|
2089
|
+
]);
|
|
2090
|
+
const cwd = this.firstStringFromSources(sources, [
|
|
2091
|
+
"cwd",
|
|
2092
|
+
"workdir",
|
|
2093
|
+
"workingDirectory",
|
|
2094
|
+
"working_directory",
|
|
2095
|
+
]);
|
|
2096
|
+
if (command && cwd)
|
|
2097
|
+
return { command, cwd };
|
|
2098
|
+
if (command)
|
|
2099
|
+
return { command };
|
|
2100
|
+
if (cwd)
|
|
2101
|
+
return { cwd };
|
|
2102
|
+
return undefined;
|
|
2103
|
+
}
|
|
2104
|
+
describeCompletedItemOutput(item, params, normalizedType) {
|
|
2105
|
+
if (normalizedType === "commandexecution") {
|
|
2106
|
+
return this.firstString(item, ["stdout", "stderr", "text", "message", "summary"]);
|
|
2107
|
+
}
|
|
2108
|
+
if (normalizedType === "plan") {
|
|
2109
|
+
return this.decodePlanItemText(item);
|
|
2110
|
+
}
|
|
2111
|
+
if (normalizedType === "filechange" || normalizedType === "toolcall" || normalizedType === "diff") {
|
|
2112
|
+
return this.extractDiffLikePayload(params, item, normalizedType) ?? this.decodeFileLikeItemText(item);
|
|
2113
|
+
}
|
|
2114
|
+
if (normalizedType === "enteredreviewmode") {
|
|
2115
|
+
return `Reviewing ${this.readString(item.review) ?? "changes"}...`;
|
|
2116
|
+
}
|
|
2117
|
+
if (normalizedType === "contextcompaction") {
|
|
2118
|
+
return "Context compacted";
|
|
2119
|
+
}
|
|
2120
|
+
return this.firstString(item, ["text", "message", "summary", "output", "output_text", "outputText"]);
|
|
2121
|
+
}
|
|
2122
|
+
}
|