@makerbi/remodex 2.0.1 → 2.3.1
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/package.json +2 -2
- package/src/account-status.js +5 -4
- package/src/bridge.js +1813 -670
- package/src/codex-desktop-refresher.js +35 -7
- package/src/cursor-acp-client.js +242 -0
- package/src/cursor-models.js +134 -0
- package/src/cursor-provider.js +1197 -0
- package/src/desktop-ipc-action-follower.js +2331 -964
- package/src/desktop-ipc-conversation-adapter.js +1132 -0
- package/src/desktop-ipc-conversation-projector.js +1169 -0
- package/src/desktop-ipc-live-owner.js +1790 -0
- package/src/desktop-ipc-owner-transport.js +750 -0
- package/src/desktop-ipc-shared.js +473 -0
- package/src/desktop-ipc-state-patches.js +218 -0
- package/src/opencode-models.js +108 -0
- package/src/opencode-provider.js +1151 -0
- package/src/project-handler.js +50 -7
- package/src/project-registry.js +466 -0
- package/src/push-notification-tracker.js +4 -4
- package/src/rollout-live-mirror.js +930 -218
- package/src/rollout-turn-semantics.js +20 -0
- package/src/runtime-provider-models.js +164 -0
- package/src/runtime-provider-router.js +365 -0
- package/src/scripts/codex-refresh.applescript +26 -15
- package/src/secure-transport.js +204 -9
- package/src/session-jsonl-history.js +429 -39
- package/src/thread-context-handler.js +8 -6
- package/src/thread-runtime-settings-store.js +247 -0
- package/src/voice-audio.js +344 -0
- package/src/voice-handler.js +363 -173
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
// FILE: opencode-provider.js
|
|
2
|
+
// Purpose: Adapts the local OpenCode CLI to Remodex provider-aware thread and turn RPCs.
|
|
3
|
+
// Layer: Bridge runtime provider
|
|
4
|
+
// Exports: createOpenCodeProvider
|
|
5
|
+
// Depends on: child_process, crypto, ./opencode-models
|
|
6
|
+
|
|
7
|
+
const { execFile, spawn } = require("child_process");
|
|
8
|
+
const { randomUUID } = require("crypto");
|
|
9
|
+
const {
|
|
10
|
+
DEFAULT_OPENCODE_MODEL,
|
|
11
|
+
OPENCODE_PROVIDER_ID,
|
|
12
|
+
normalizeOpenCodeModelReference,
|
|
13
|
+
parseOpenCodeModelsOutput,
|
|
14
|
+
} = require("./opencode-models");
|
|
15
|
+
const { normalizeRuntimeProvider } = require("./runtime-provider-models");
|
|
16
|
+
|
|
17
|
+
const OPENCODE_THREAD_PREFIX = "opencode-thread-";
|
|
18
|
+
const OPENCODE_TURN_PREFIX = "opencode-turn-";
|
|
19
|
+
const OPENCODE_EXEC_TIMEOUT_MS = 8_000;
|
|
20
|
+
const OPENCODE_MODEL_CACHE_TTL_MS = 60_000;
|
|
21
|
+
const OPENCODE_MAX_HISTORY_MESSAGES = 200;
|
|
22
|
+
|
|
23
|
+
function createOpenCodeProvider({
|
|
24
|
+
sendApplicationMessage,
|
|
25
|
+
env = process.env,
|
|
26
|
+
execFileImpl = execFile,
|
|
27
|
+
spawnImpl = spawn,
|
|
28
|
+
randomUUIDImpl = randomUUID,
|
|
29
|
+
projectRegistry = null,
|
|
30
|
+
logPrefix = "[remodex]",
|
|
31
|
+
} = {}) {
|
|
32
|
+
return new OpenCodeProvider({
|
|
33
|
+
env,
|
|
34
|
+
execFileImpl,
|
|
35
|
+
logPrefix,
|
|
36
|
+
projectRegistry,
|
|
37
|
+
randomUUIDImpl,
|
|
38
|
+
sendApplicationMessage,
|
|
39
|
+
spawnImpl,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class OpenCodeProvider {
|
|
44
|
+
constructor({
|
|
45
|
+
sendApplicationMessage,
|
|
46
|
+
env,
|
|
47
|
+
execFileImpl,
|
|
48
|
+
spawnImpl,
|
|
49
|
+
randomUUIDImpl,
|
|
50
|
+
projectRegistry,
|
|
51
|
+
logPrefix,
|
|
52
|
+
}) {
|
|
53
|
+
this.id = OPENCODE_PROVIDER_ID;
|
|
54
|
+
this.sendApplicationMessage = sendApplicationMessage;
|
|
55
|
+
this.env = env;
|
|
56
|
+
this.execFile = execFileImpl;
|
|
57
|
+
this.spawn = spawnImpl;
|
|
58
|
+
this.randomUUID = randomUUIDImpl;
|
|
59
|
+
this.projectRegistry = projectRegistry;
|
|
60
|
+
this.logPrefix = logPrefix;
|
|
61
|
+
this.modelCache = null;
|
|
62
|
+
this.threads = new Map();
|
|
63
|
+
this.sessionThreadCache = new Map();
|
|
64
|
+
this.activeTurnsByTurnId = new Map();
|
|
65
|
+
this.activeTurnIdByThreadId = new Map();
|
|
66
|
+
this.finalizedTurns = new Set();
|
|
67
|
+
this.warnedAvailabilityReason = "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
ownsThread(threadId) {
|
|
71
|
+
const normalized = readString(threadId);
|
|
72
|
+
return this.threads.has(normalized)
|
|
73
|
+
|| this.sessionThreadCache.has(normalized)
|
|
74
|
+
|| normalized.startsWith(OPENCODE_THREAD_PREFIX)
|
|
75
|
+
|| normalized.startsWith("ses_");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
canHandleProvider(provider) {
|
|
79
|
+
return normalizeRuntimeProvider(provider) === OPENCODE_PROVIDER_ID;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async listModels() {
|
|
83
|
+
const cached = this.readFreshModelCache();
|
|
84
|
+
if (cached) {
|
|
85
|
+
return cached;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const { stdout } = await this.runOpenCode(["models"], {
|
|
90
|
+
timeout: OPENCODE_EXEC_TIMEOUT_MS,
|
|
91
|
+
});
|
|
92
|
+
const models = parseOpenCodeModelsOutput(stdout);
|
|
93
|
+
this.modelCache = {
|
|
94
|
+
expiresAt: Date.now() + OPENCODE_MODEL_CACHE_TTL_MS,
|
|
95
|
+
value: models,
|
|
96
|
+
};
|
|
97
|
+
return models;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
this.warnUnavailable(error?.message || "OpenCode models are unavailable.");
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async listThreads(params = {}) {
|
|
105
|
+
const limit = boundedPositiveInteger(params.limit, 50);
|
|
106
|
+
const includeArchived = params.includeArchived === true || params.include_archived === true;
|
|
107
|
+
const localThreads = Array.from(this.threads.values())
|
|
108
|
+
.filter((thread) => includeArchived || !thread.archived)
|
|
109
|
+
.map((thread) => publicThread(thread));
|
|
110
|
+
|
|
111
|
+
let sessionThreads = [];
|
|
112
|
+
try {
|
|
113
|
+
const { stdout } = await this.runOpenCode([
|
|
114
|
+
"session",
|
|
115
|
+
"list",
|
|
116
|
+
"--format",
|
|
117
|
+
"json",
|
|
118
|
+
"--max-count",
|
|
119
|
+
String(limit),
|
|
120
|
+
], {
|
|
121
|
+
timeout: OPENCODE_EXEC_TIMEOUT_MS,
|
|
122
|
+
});
|
|
123
|
+
sessionThreads = parseOpenCodeSessionList(stdout).map((session) => this.threadFromSession(session));
|
|
124
|
+
} catch {
|
|
125
|
+
sessionThreads = [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
const data = [...localThreads, ...sessionThreads]
|
|
130
|
+
.filter((thread) => {
|
|
131
|
+
if (!thread?.id || seen.has(thread.id)) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
seen.add(thread.id);
|
|
135
|
+
return true;
|
|
136
|
+
})
|
|
137
|
+
.sort(compareThreadsByUpdatedAt)
|
|
138
|
+
.slice(0, limit);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
data,
|
|
142
|
+
nextCursor: null,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async handleRequest(request) {
|
|
147
|
+
const method = readString(request?.method);
|
|
148
|
+
switch (method) {
|
|
149
|
+
case "thread/start":
|
|
150
|
+
return this.threadStart(request);
|
|
151
|
+
case "thread/resume":
|
|
152
|
+
case "thread/read":
|
|
153
|
+
return this.threadRead(request);
|
|
154
|
+
case "thread/turns/list":
|
|
155
|
+
return this.threadTurnsList(request);
|
|
156
|
+
case "thread/name/set":
|
|
157
|
+
return this.threadNameSet(request);
|
|
158
|
+
case "thread/archive":
|
|
159
|
+
return this.threadArchive(request, true);
|
|
160
|
+
case "thread/unarchive":
|
|
161
|
+
return this.threadArchive(request, false);
|
|
162
|
+
case "turn/start":
|
|
163
|
+
return this.turnStart(request);
|
|
164
|
+
case "turn/interrupt":
|
|
165
|
+
return this.turnInterrupt(request);
|
|
166
|
+
default:
|
|
167
|
+
throw unsupportedMethodError(method);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
handleApplicationResponse() {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
shutdown() {
|
|
176
|
+
for (const active of this.activeTurnsByTurnId.values()) {
|
|
177
|
+
active.stopped = true;
|
|
178
|
+
try {
|
|
179
|
+
active.child.kill("SIGTERM");
|
|
180
|
+
} catch {
|
|
181
|
+
// Ignore shutdown races; the process may already have exited.
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
this.activeTurnsByTurnId.clear();
|
|
185
|
+
this.activeTurnIdByThreadId.clear();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
threadStart(request) {
|
|
189
|
+
const params = request.params || {};
|
|
190
|
+
const now = new Date().toISOString();
|
|
191
|
+
const requestedCwd = readString(params.cwd || params.current_working_directory || params.working_directory);
|
|
192
|
+
const thread = {
|
|
193
|
+
id: `${OPENCODE_THREAD_PREFIX}${this.randomUUID()}`,
|
|
194
|
+
title: readString(params.title) || "OpenCode chat",
|
|
195
|
+
cwd: requestedCwd || process.cwd(),
|
|
196
|
+
model: normalizeOpenCodeModel(params.model),
|
|
197
|
+
createdAt: now,
|
|
198
|
+
updatedAt: now,
|
|
199
|
+
archived: false,
|
|
200
|
+
hasProjectCwd: Boolean(requestedCwd),
|
|
201
|
+
sessionId: "",
|
|
202
|
+
turns: [],
|
|
203
|
+
};
|
|
204
|
+
this.threads.set(thread.id, thread);
|
|
205
|
+
this.rememberThreadProject(thread, "opencode-thread-start");
|
|
206
|
+
return {
|
|
207
|
+
thread: publicThread(thread),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async threadRead(request) {
|
|
212
|
+
const params = request.params || {};
|
|
213
|
+
const threadId = readThreadId(params);
|
|
214
|
+
const thread = await this.resolveThread(threadId).catch((error) => {
|
|
215
|
+
if (error?.errorCode !== "thread_not_found" || !threadId) {
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
return this.adoptThread(threadId, params);
|
|
219
|
+
});
|
|
220
|
+
this.rememberThreadProject(thread, "opencode-thread-read");
|
|
221
|
+
const responseThread = { ...publicThread(thread) };
|
|
222
|
+
if (params.includeTurns === true || params.include_turns === true) {
|
|
223
|
+
responseThread.turns = await this.turnsForThread(threadId, {
|
|
224
|
+
sortDirection: "asc",
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
thread: responseThread,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async threadTurnsList(request) {
|
|
233
|
+
const params = request.params || {};
|
|
234
|
+
const threadId = readThreadId(params);
|
|
235
|
+
const limit = boundedPositiveInteger(params.limit, 50);
|
|
236
|
+
const sortDirection = readString(params.sortDirection || params.sort_direction) || "desc";
|
|
237
|
+
const turns = await this.turnsForThread(threadId, { sortDirection });
|
|
238
|
+
return {
|
|
239
|
+
data: turns.slice(0, limit),
|
|
240
|
+
nextCursor: null,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async turnStart(request) {
|
|
245
|
+
const params = request.params || {};
|
|
246
|
+
const threadId = readThreadId(params);
|
|
247
|
+
const thread = await this.resolveThreadForTurn(threadId, params);
|
|
248
|
+
if (this.activeTurnIdByThreadId.has(thread.id)) {
|
|
249
|
+
throw activeTurnError(thread.id);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const model = normalizeOpenCodeModel(params.model || thread.model);
|
|
253
|
+
const { prompt, inputText } = buildPromptFromTurnInput(params.input);
|
|
254
|
+
if (!prompt) {
|
|
255
|
+
const error = new Error("OpenCode turn/start requires text input.");
|
|
256
|
+
error.errorCode = "opencode_missing_input";
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
thread.model = model;
|
|
261
|
+
thread.updatedAt = new Date().toISOString();
|
|
262
|
+
const turnId = `${OPENCODE_TURN_PREFIX}${this.randomUUID()}`;
|
|
263
|
+
const turn = createStoredTurn({
|
|
264
|
+
inputText,
|
|
265
|
+
model,
|
|
266
|
+
threadId: thread.id,
|
|
267
|
+
turnId,
|
|
268
|
+
});
|
|
269
|
+
thread.turns.push(turn);
|
|
270
|
+
|
|
271
|
+
setImmediate(() => {
|
|
272
|
+
this.runTurn({
|
|
273
|
+
model,
|
|
274
|
+
params,
|
|
275
|
+
prompt,
|
|
276
|
+
thread,
|
|
277
|
+
turn,
|
|
278
|
+
turnId,
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
this.emit("turn/started", {
|
|
283
|
+
threadId: thread.id,
|
|
284
|
+
turnId,
|
|
285
|
+
turn: {
|
|
286
|
+
id: turnId,
|
|
287
|
+
status: "running",
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
turnId,
|
|
293
|
+
turn: {
|
|
294
|
+
id: turnId,
|
|
295
|
+
threadId: thread.id,
|
|
296
|
+
status: "running",
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
runTurn({ model, params, prompt, thread, turn, turnId }) {
|
|
302
|
+
const args = [
|
|
303
|
+
"run",
|
|
304
|
+
"--format",
|
|
305
|
+
"json",
|
|
306
|
+
"--model",
|
|
307
|
+
model,
|
|
308
|
+
"--dir",
|
|
309
|
+
thread.cwd || process.cwd(),
|
|
310
|
+
];
|
|
311
|
+
|
|
312
|
+
if (thread.sessionId) {
|
|
313
|
+
args.push("--session", thread.sessionId);
|
|
314
|
+
}
|
|
315
|
+
if (shouldSkipPermissions(params)) {
|
|
316
|
+
args.push("--dangerously-skip-permissions");
|
|
317
|
+
}
|
|
318
|
+
if (thread.title) {
|
|
319
|
+
args.push("--title", thread.title);
|
|
320
|
+
}
|
|
321
|
+
args.push(prompt);
|
|
322
|
+
|
|
323
|
+
let child;
|
|
324
|
+
try {
|
|
325
|
+
child = this.spawn(resolveOpenCodeCommand(this.env), args, {
|
|
326
|
+
env: this.env,
|
|
327
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
328
|
+
});
|
|
329
|
+
} catch (error) {
|
|
330
|
+
this.completeTurn({
|
|
331
|
+
errorMessage: error?.message || "Failed to start OpenCode.",
|
|
332
|
+
status: "failed",
|
|
333
|
+
thread,
|
|
334
|
+
turn,
|
|
335
|
+
turnId,
|
|
336
|
+
});
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const active = {
|
|
341
|
+
assistantItemId: `opencode-agent-${turnId}`,
|
|
342
|
+
assistantText: "",
|
|
343
|
+
child,
|
|
344
|
+
stderr: "",
|
|
345
|
+
stopped: false,
|
|
346
|
+
textByPartId: new Map(),
|
|
347
|
+
threadId: thread.id,
|
|
348
|
+
turn,
|
|
349
|
+
};
|
|
350
|
+
this.activeTurnsByTurnId.set(turnId, active);
|
|
351
|
+
this.activeTurnIdByThreadId.set(thread.id, turnId);
|
|
352
|
+
|
|
353
|
+
child.stdout?.setEncoding?.("utf8");
|
|
354
|
+
child.stderr?.setEncoding?.("utf8");
|
|
355
|
+
|
|
356
|
+
let stdoutBuffer = "";
|
|
357
|
+
child.stdout?.on("data", (chunk) => {
|
|
358
|
+
stdoutBuffer += String(chunk);
|
|
359
|
+
const lines = stdoutBuffer.split(/\r?\n/);
|
|
360
|
+
stdoutBuffer = lines.pop() || "";
|
|
361
|
+
for (const line of lines) {
|
|
362
|
+
this.handleRunJsonLine({ active, line, thread, turnId });
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
child.stderr?.on("data", (chunk) => {
|
|
367
|
+
active.stderr = truncateTail(`${active.stderr}${chunk}`, 4_000);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
child.on("error", (error) => {
|
|
371
|
+
this.completeTurn({
|
|
372
|
+
errorMessage: error?.message || "OpenCode process failed.",
|
|
373
|
+
status: active.stopped ? "stopped" : "failed",
|
|
374
|
+
thread,
|
|
375
|
+
turn,
|
|
376
|
+
turnId,
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
child.on("close", (code, signal) => {
|
|
381
|
+
if (stdoutBuffer.trim()) {
|
|
382
|
+
this.handleRunJsonLine({ active, line: stdoutBuffer, thread, turnId });
|
|
383
|
+
}
|
|
384
|
+
const stopped = active.stopped || signal === "SIGINT" || signal === "SIGTERM";
|
|
385
|
+
const status = stopped ? "stopped" : (code === 0 ? "completed" : "failed");
|
|
386
|
+
const errorMessage = status === "failed"
|
|
387
|
+
? readString(active.stderr) || `OpenCode exited with code ${code}.`
|
|
388
|
+
: "";
|
|
389
|
+
this.completeTurn({
|
|
390
|
+
errorMessage,
|
|
391
|
+
status,
|
|
392
|
+
thread,
|
|
393
|
+
turn,
|
|
394
|
+
turnId,
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
handleRunJsonLine({ active, line, thread, turnId }) {
|
|
400
|
+
const event = safeParseJSON(line);
|
|
401
|
+
if (!event || typeof event !== "object") {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const sessionId = readString(event.sessionID || event.sessionId || event.part?.sessionID || event.part?.sessionId);
|
|
406
|
+
if (sessionId && thread.sessionId !== sessionId) {
|
|
407
|
+
thread.sessionId = sessionId;
|
|
408
|
+
this.sessionThreadCache.set(sessionId, publicThread(thread));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const type = readString(event.type || event.part?.type).toLowerCase();
|
|
412
|
+
const text = readString(event.part?.text || event.text || event.delta);
|
|
413
|
+
if (!text || isRedactedTextPlaceholder(text)) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (type.includes("reasoning")) {
|
|
418
|
+
this.emit("item/reasoning/textDelta", {
|
|
419
|
+
threadId: thread.id,
|
|
420
|
+
turnId,
|
|
421
|
+
itemId: `opencode-reasoning-${turnId}`,
|
|
422
|
+
delta: text,
|
|
423
|
+
textDelta: text,
|
|
424
|
+
item: {
|
|
425
|
+
id: `opencode-reasoning-${turnId}`,
|
|
426
|
+
type: "reasoning",
|
|
427
|
+
turnId,
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const partId = readString(event.part?.id) || active.assistantItemId;
|
|
434
|
+
const previousText = active.textByPartId.get(partId) || "";
|
|
435
|
+
const delta = computeTextDelta(previousText, text);
|
|
436
|
+
active.textByPartId.set(partId, mergeTextSnapshot(previousText, text));
|
|
437
|
+
if (!delta) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
active.assistantText += delta;
|
|
442
|
+
const assistantItem = active.turn.items.find((item) => item.id === active.assistantItemId);
|
|
443
|
+
if (assistantItem) {
|
|
444
|
+
assistantItem.text = active.assistantText;
|
|
445
|
+
assistantItem.content = textContent(active.assistantText);
|
|
446
|
+
}
|
|
447
|
+
this.emit("item/agentMessage/delta", {
|
|
448
|
+
threadId: thread.id,
|
|
449
|
+
turnId,
|
|
450
|
+
itemId: active.assistantItemId,
|
|
451
|
+
delta,
|
|
452
|
+
textDelta: delta,
|
|
453
|
+
assistantPhase: "final_answer",
|
|
454
|
+
item: {
|
|
455
|
+
id: active.assistantItemId,
|
|
456
|
+
turnId,
|
|
457
|
+
type: "agentMessage",
|
|
458
|
+
phase: "final",
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
completeTurn({ errorMessage = "", status, thread, turn, turnId }) {
|
|
464
|
+
if (this.finalizedTurns.has(turnId)) {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
this.finalizedTurns.add(turnId);
|
|
468
|
+
pruneSet(this.finalizedTurns, 500);
|
|
469
|
+
this.activeTurnsByTurnId.delete(turnId);
|
|
470
|
+
this.activeTurnIdByThreadId.delete(thread.id);
|
|
471
|
+
thread.updatedAt = new Date().toISOString();
|
|
472
|
+
turn.status = status;
|
|
473
|
+
turn.completedAt = thread.updatedAt;
|
|
474
|
+
if (errorMessage) {
|
|
475
|
+
turn.error = { message: errorMessage };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const assistantItem = turn.items.find((item) => item.type === "agentMessage");
|
|
479
|
+
if (assistantItem && assistantItem.text) {
|
|
480
|
+
this.emit("item/completed", {
|
|
481
|
+
threadId: thread.id,
|
|
482
|
+
turnId,
|
|
483
|
+
itemId: assistantItem.id,
|
|
484
|
+
message: assistantItem.text,
|
|
485
|
+
assistantPhase: "final_answer",
|
|
486
|
+
item: {
|
|
487
|
+
id: assistantItem.id,
|
|
488
|
+
turnId,
|
|
489
|
+
type: "agentMessage",
|
|
490
|
+
phase: "final",
|
|
491
|
+
text: assistantItem.text,
|
|
492
|
+
content: assistantItem.content,
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
this.emit("turn/completed", {
|
|
498
|
+
threadId: thread.id,
|
|
499
|
+
turnId,
|
|
500
|
+
model: thread.model,
|
|
501
|
+
status,
|
|
502
|
+
turn: {
|
|
503
|
+
id: turnId,
|
|
504
|
+
status,
|
|
505
|
+
error: errorMessage ? { message: errorMessage } : undefined,
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async turnInterrupt(request) {
|
|
512
|
+
const params = request.params || {};
|
|
513
|
+
const turnId = readString(params.turnId || params.turn_id);
|
|
514
|
+
const threadId = readThreadId(params);
|
|
515
|
+
const resolvedTurnId = turnId || this.activeTurnIdByThreadId.get(threadId) || "";
|
|
516
|
+
const active = this.activeTurnsByTurnId.get(resolvedTurnId);
|
|
517
|
+
if (!active) {
|
|
518
|
+
return {
|
|
519
|
+
success: true,
|
|
520
|
+
interrupted: false,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
active.stopped = true;
|
|
525
|
+
try {
|
|
526
|
+
active.child.kill("SIGINT");
|
|
527
|
+
} catch {
|
|
528
|
+
// The close handler will finalize if the process is still alive.
|
|
529
|
+
}
|
|
530
|
+
return {
|
|
531
|
+
success: true,
|
|
532
|
+
interrupted: true,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async threadNameSet(request) {
|
|
537
|
+
const params = request.params || {};
|
|
538
|
+
const thread = await this.resolveThread(readThreadId(params));
|
|
539
|
+
const name = readString(params.name || params.title);
|
|
540
|
+
if (name) {
|
|
541
|
+
thread.title = name;
|
|
542
|
+
thread.updatedAt = new Date().toISOString();
|
|
543
|
+
}
|
|
544
|
+
const publicValue = publicThread(thread);
|
|
545
|
+
this.emit("thread/name/updated", {
|
|
546
|
+
threadId: publicValue.id,
|
|
547
|
+
thread_id: publicValue.id,
|
|
548
|
+
name: publicValue.name,
|
|
549
|
+
title: publicValue.title,
|
|
550
|
+
});
|
|
551
|
+
return {
|
|
552
|
+
thread: publicValue,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async threadArchive(request, archived) {
|
|
557
|
+
const thread = await this.resolveThread(readThreadId(request.params));
|
|
558
|
+
thread.archived = archived;
|
|
559
|
+
thread.updatedAt = new Date().toISOString();
|
|
560
|
+
return {
|
|
561
|
+
thread: publicThread(thread),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async turnsForThread(threadId, { sortDirection = "desc" } = {}) {
|
|
566
|
+
const thread = await this.resolveThread(threadId);
|
|
567
|
+
let turns = thread.turns || [];
|
|
568
|
+
if (thread.sessionId || thread.id.startsWith("ses_")) {
|
|
569
|
+
turns = await this.exportSessionTurns(thread.sessionId || thread.id, thread);
|
|
570
|
+
thread.turns = turns;
|
|
571
|
+
}
|
|
572
|
+
const normalizedDirection = readString(sortDirection).toLowerCase();
|
|
573
|
+
return normalizedDirection === "asc" ? [...turns] : [...turns].reverse();
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async exportSessionTurns(sessionId, thread) {
|
|
577
|
+
try {
|
|
578
|
+
// `--sanitize` replaces visible chat text with placeholder ids, which the
|
|
579
|
+
// mobile timeline cannot resolve back to readable messages.
|
|
580
|
+
const { stdout } = await this.runOpenCode(["export", sessionId], {
|
|
581
|
+
timeout: 15_000,
|
|
582
|
+
});
|
|
583
|
+
return parseOpenCodeExport(stdout, thread).slice(-OPENCODE_MAX_HISTORY_MESSAGES);
|
|
584
|
+
} catch {
|
|
585
|
+
return thread.turns || [];
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async resolveThread(threadId) {
|
|
590
|
+
const normalized = readString(threadId);
|
|
591
|
+
if (this.threads.has(normalized)) {
|
|
592
|
+
return this.threads.get(normalized);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (this.sessionThreadCache.has(normalized)) {
|
|
596
|
+
const cached = this.sessionThreadCache.get(normalized);
|
|
597
|
+
return this.rememberSessionThread(cached);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (normalized.startsWith("ses_")) {
|
|
601
|
+
return this.rememberSessionThread({
|
|
602
|
+
id: normalized,
|
|
603
|
+
title: "OpenCode chat",
|
|
604
|
+
cwd: process.cwd(),
|
|
605
|
+
model: DEFAULT_OPENCODE_MODEL,
|
|
606
|
+
createdAt: new Date().toISOString(),
|
|
607
|
+
updatedAt: new Date().toISOString(),
|
|
608
|
+
sessionId: normalized,
|
|
609
|
+
hasProjectCwd: false,
|
|
610
|
+
turns: [],
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
throw threadNotFoundError(normalized);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async resolveThreadForTurn(threadId, params = {}) {
|
|
618
|
+
try {
|
|
619
|
+
const thread = await this.resolveThread(threadId);
|
|
620
|
+
this.applyRequestedProjectCwd(thread, params);
|
|
621
|
+
return thread;
|
|
622
|
+
} catch (error) {
|
|
623
|
+
if (!threadId || error?.errorCode !== "thread_not_found") {
|
|
624
|
+
throw error;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return this.adoptThread(threadId, params);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
applyRequestedProjectCwd(thread, params = {}) {
|
|
632
|
+
const requestedCwd = readString(params.cwd || params.current_working_directory || params.working_directory);
|
|
633
|
+
if (!requestedCwd || !thread || (thread.hasProjectCwd && thread.cwd === requestedCwd)) {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
thread.cwd = requestedCwd;
|
|
638
|
+
thread.hasProjectCwd = true;
|
|
639
|
+
this.rememberThreadProject(thread, "opencode-request-cwd");
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
rememberSessionThread(thread) {
|
|
643
|
+
const hasProjectCwd = thread.hasProjectCwd !== false
|
|
644
|
+
&& thread.metadata?.projectCwdSource !== "fallback";
|
|
645
|
+
const stored = {
|
|
646
|
+
id: thread.id,
|
|
647
|
+
title: thread.title || thread.name || "OpenCode chat",
|
|
648
|
+
cwd: thread.cwd || process.cwd(),
|
|
649
|
+
model: normalizeOpenCodeModel(thread.model),
|
|
650
|
+
createdAt: thread.createdAt || new Date().toISOString(),
|
|
651
|
+
updatedAt: thread.updatedAt || new Date().toISOString(),
|
|
652
|
+
archived: false,
|
|
653
|
+
hasProjectCwd,
|
|
654
|
+
sessionId: thread.sessionId || thread.id,
|
|
655
|
+
turns: Array.isArray(thread.turns) ? thread.turns : [],
|
|
656
|
+
};
|
|
657
|
+
this.threads.set(stored.id, stored);
|
|
658
|
+
this.sessionThreadCache.set(stored.sessionId, publicThread(stored));
|
|
659
|
+
this.rememberThreadProject(stored, "opencode-session");
|
|
660
|
+
return stored;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
adoptThread(threadId, params = {}) {
|
|
664
|
+
// Existing Codex-local chats can switch providers mid-thread; adopt the id
|
|
665
|
+
// locally so the OpenCode turn can stream back into the same timeline.
|
|
666
|
+
const now = new Date().toISOString();
|
|
667
|
+
const requestedCwd = readString(params.cwd || params.current_working_directory || params.working_directory);
|
|
668
|
+
const thread = {
|
|
669
|
+
id: threadId,
|
|
670
|
+
title: readString(params.title) || "OpenCode chat",
|
|
671
|
+
cwd: requestedCwd || process.cwd(),
|
|
672
|
+
model: normalizeOpenCodeModel(params.model),
|
|
673
|
+
createdAt: now,
|
|
674
|
+
updatedAt: now,
|
|
675
|
+
archived: false,
|
|
676
|
+
hasProjectCwd: Boolean(requestedCwd),
|
|
677
|
+
sessionId: "",
|
|
678
|
+
turns: [],
|
|
679
|
+
};
|
|
680
|
+
this.threads.set(thread.id, thread);
|
|
681
|
+
this.rememberThreadProject(thread, "opencode-adopt-thread");
|
|
682
|
+
return thread;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
threadFromSession(session) {
|
|
686
|
+
const id = readString(session.id);
|
|
687
|
+
const sessionCwd = readString(session.directory || session.cwd || session.path);
|
|
688
|
+
const thread = {
|
|
689
|
+
id,
|
|
690
|
+
title: readString(session.title || session.name) || "OpenCode chat",
|
|
691
|
+
cwd: sessionCwd || process.cwd(),
|
|
692
|
+
model: normalizeOpenCodeModel(session.model),
|
|
693
|
+
createdAt: normalizeDateString(session.created || session.createdAt || session.created_at),
|
|
694
|
+
updatedAt: normalizeDateString(session.updated || session.updatedAt || session.updated_at),
|
|
695
|
+
archived: false,
|
|
696
|
+
hasProjectCwd: Boolean(sessionCwd),
|
|
697
|
+
sessionId: id,
|
|
698
|
+
turns: [],
|
|
699
|
+
};
|
|
700
|
+
this.sessionThreadCache.set(id, publicThread(thread));
|
|
701
|
+
this.rememberThreadProject(thread, "opencode-session-list");
|
|
702
|
+
return publicThread(thread);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
rememberThreadProject(thread, source) {
|
|
706
|
+
if (!this.projectRegistry || !thread?.hasProjectCwd) {
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
try {
|
|
711
|
+
this.projectRegistry.rememberProjectPath(thread.cwd, {
|
|
712
|
+
source,
|
|
713
|
+
provider: this.id,
|
|
714
|
+
lastSeenAt: thread.updatedAt || thread.createdAt,
|
|
715
|
+
});
|
|
716
|
+
} catch {
|
|
717
|
+
// Project history is a best-effort picker cache, not part of turn execution.
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
readFreshModelCache() {
|
|
722
|
+
if (!this.modelCache || Date.now() > this.modelCache.expiresAt) {
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
return this.modelCache.value;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
runOpenCode(args, options = {}) {
|
|
729
|
+
return execFilePromise(this.execFile, resolveOpenCodeCommand(this.env), args, {
|
|
730
|
+
env: this.env,
|
|
731
|
+
timeout: options.timeout || OPENCODE_EXEC_TIMEOUT_MS,
|
|
732
|
+
maxBuffer: options.maxBuffer || 2 * 1024 * 1024,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
emit(method, params) {
|
|
737
|
+
this.sendApplicationMessage?.(JSON.stringify({
|
|
738
|
+
method,
|
|
739
|
+
params: removeUndefinedValues(params || {}),
|
|
740
|
+
}));
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
warnUnavailable(reason) {
|
|
744
|
+
const normalizedReason = readString(reason);
|
|
745
|
+
if (!normalizedReason || this.warnedAvailabilityReason === normalizedReason) {
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
this.warnedAvailabilityReason = normalizedReason;
|
|
749
|
+
console.warn(`${this.logPrefix} OpenCode unavailable: ${normalizedReason}`);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function resolveOpenCodeCommand(env = process.env) {
|
|
754
|
+
return readString(env.REMODEX_OPENCODE_COMMAND) || "opencode";
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function normalizeOpenCodeModel(value) {
|
|
758
|
+
return normalizeOpenCodeModelReference(value) || DEFAULT_OPENCODE_MODEL;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function execFilePromise(execFileImpl, command, args, options) {
|
|
762
|
+
return new Promise((resolve, reject) => {
|
|
763
|
+
execFileImpl(command, args, options, (error, stdout = "", stderr = "") => {
|
|
764
|
+
if (error) {
|
|
765
|
+
const message = readString(stderr) || error.message || `${command} failed.`;
|
|
766
|
+
const wrapped = new Error(message);
|
|
767
|
+
wrapped.code = error.code;
|
|
768
|
+
reject(wrapped);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
resolve({ stdout: String(stdout), stderr: String(stderr) });
|
|
772
|
+
});
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function parseOpenCodeSessionList(output) {
|
|
777
|
+
const parsed = safeParseJSON(output);
|
|
778
|
+
return Array.isArray(parsed) ? parsed.filter((item) => item && typeof item === "object") : [];
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function parseOpenCodeExport(output, thread) {
|
|
782
|
+
const parsed = safeParseJSON(output);
|
|
783
|
+
const messages = Array.isArray(parsed?.messages) ? parsed.messages.slice(-OPENCODE_MAX_HISTORY_MESSAGES) : [];
|
|
784
|
+
const turns = [];
|
|
785
|
+
let currentTurn = null;
|
|
786
|
+
|
|
787
|
+
for (const message of messages) {
|
|
788
|
+
const role = readString(message?.info?.role || message?.role).toLowerCase();
|
|
789
|
+
const text = textFromExportedMessage(message);
|
|
790
|
+
if (!text) {
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (role === "user" || !currentTurn) {
|
|
795
|
+
currentTurn = {
|
|
796
|
+
id: readString(message?.info?.id) || `${OPENCODE_TURN_PREFIX}${turns.length + 1}`,
|
|
797
|
+
status: "completed",
|
|
798
|
+
createdAt: normalizeDateString(message?.info?.time?.created || message?.created),
|
|
799
|
+
completedAt: normalizeDateString(message?.info?.time?.updated || message?.updated),
|
|
800
|
+
items: [],
|
|
801
|
+
};
|
|
802
|
+
turns.push(currentTurn);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
currentTurn.items.push({
|
|
806
|
+
id: readString(message?.info?.id) || `${currentTurn.id}-${role || "message"}-${currentTurn.items.length}`,
|
|
807
|
+
type: role === "user" ? "userMessage" : "agentMessage",
|
|
808
|
+
role: role === "user" ? "user" : "assistant",
|
|
809
|
+
phase: role === "assistant" ? "final" : undefined,
|
|
810
|
+
text,
|
|
811
|
+
content: textContent(text),
|
|
812
|
+
createdAt: currentTurn.createdAt,
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
if (role === "assistant") {
|
|
816
|
+
currentTurn = null;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (thread?.turns?.length) {
|
|
821
|
+
return mergeStoredAndExportedTurns(thread.turns, turns);
|
|
822
|
+
}
|
|
823
|
+
return turns;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function mergeStoredAndExportedTurns(storedTurns, exportedTurns) {
|
|
827
|
+
const result = [];
|
|
828
|
+
const seenKeys = new Set();
|
|
829
|
+
const storedByFingerprint = new Map();
|
|
830
|
+
for (const turn of storedTurns) {
|
|
831
|
+
const fingerprint = turnFingerprint(turn);
|
|
832
|
+
if (fingerprint && !storedByFingerprint.has(fingerprint)) {
|
|
833
|
+
storedByFingerprint.set(fingerprint, turn);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
for (const exportedTurn of exportedTurns) {
|
|
838
|
+
const fingerprint = turnFingerprint(exportedTurn);
|
|
839
|
+
const turn = (fingerprint && storedByFingerprint.get(fingerprint)) || exportedTurn;
|
|
840
|
+
appendUniqueTurn(result, seenKeys, turn);
|
|
841
|
+
}
|
|
842
|
+
for (const turn of storedTurns) {
|
|
843
|
+
appendUniqueTurn(result, seenKeys, turn);
|
|
844
|
+
}
|
|
845
|
+
return result;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function appendUniqueTurn(result, seenKeys, turn) {
|
|
849
|
+
const keys = turnDeduplicationKeys(turn);
|
|
850
|
+
if (!keys.length || keys.some((key) => seenKeys.has(key))) {
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
for (const key of keys) {
|
|
854
|
+
seenKeys.add(key);
|
|
855
|
+
}
|
|
856
|
+
result.push(turn);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function turnDeduplicationKeys(turn) {
|
|
860
|
+
if (!turn || typeof turn !== "object") {
|
|
861
|
+
return [];
|
|
862
|
+
}
|
|
863
|
+
const keys = [];
|
|
864
|
+
const id = readString(turn.id);
|
|
865
|
+
if (id) {
|
|
866
|
+
keys.push(`id:${id}`);
|
|
867
|
+
}
|
|
868
|
+
const fingerprint = turnFingerprint(turn);
|
|
869
|
+
if (fingerprint) {
|
|
870
|
+
keys.push(`fp:${fingerprint}`);
|
|
871
|
+
}
|
|
872
|
+
return keys;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function turnFingerprint(turn) {
|
|
876
|
+
const userText = firstItemText(turn, "userMessage");
|
|
877
|
+
const assistantText = firstItemText(turn, "agentMessage");
|
|
878
|
+
if (!userText || !assistantText) {
|
|
879
|
+
return "";
|
|
880
|
+
}
|
|
881
|
+
return `${normalizeFingerprintText(userText)}\n---\n${normalizeFingerprintText(assistantText)}`;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function firstItemText(turn, itemType) {
|
|
885
|
+
const items = Array.isArray(turn?.items) ? turn.items : [];
|
|
886
|
+
for (const item of items) {
|
|
887
|
+
if (item?.type !== itemType) {
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
const text = readString(item.text || item.message);
|
|
891
|
+
if (text) {
|
|
892
|
+
return text;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return "";
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function normalizeFingerprintText(value) {
|
|
899
|
+
return readString(value).replace(/\s+/g, " ");
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function textFromExportedMessage(message) {
|
|
903
|
+
const parts = Array.isArray(message?.parts) ? message.parts : [];
|
|
904
|
+
return parts
|
|
905
|
+
.map((part) => {
|
|
906
|
+
if (!part || typeof part !== "object") {
|
|
907
|
+
return "";
|
|
908
|
+
}
|
|
909
|
+
if (part.type === "text" || part.type === "reasoning") {
|
|
910
|
+
const text = readString(part.text);
|
|
911
|
+
return isRedactedTextPlaceholder(text) ? "" : text;
|
|
912
|
+
}
|
|
913
|
+
return "";
|
|
914
|
+
})
|
|
915
|
+
.filter(Boolean)
|
|
916
|
+
.join("\n\n")
|
|
917
|
+
.trim();
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function createStoredTurn({ inputText, model, threadId, turnId }) {
|
|
921
|
+
const now = new Date().toISOString();
|
|
922
|
+
return {
|
|
923
|
+
id: turnId,
|
|
924
|
+
model,
|
|
925
|
+
status: "running",
|
|
926
|
+
createdAt: now,
|
|
927
|
+
items: [
|
|
928
|
+
{
|
|
929
|
+
id: `opencode-user-${turnId}`,
|
|
930
|
+
type: "userMessage",
|
|
931
|
+
role: "user",
|
|
932
|
+
text: inputText,
|
|
933
|
+
content: textContent(inputText),
|
|
934
|
+
createdAt: now,
|
|
935
|
+
},
|
|
936
|
+
{
|
|
937
|
+
id: `opencode-agent-${turnId}`,
|
|
938
|
+
type: "agentMessage",
|
|
939
|
+
role: "assistant",
|
|
940
|
+
phase: "final",
|
|
941
|
+
text: "",
|
|
942
|
+
content: textContent(""),
|
|
943
|
+
createdAt: now,
|
|
944
|
+
},
|
|
945
|
+
],
|
|
946
|
+
metadata: {
|
|
947
|
+
threadId,
|
|
948
|
+
provider: OPENCODE_PROVIDER_ID,
|
|
949
|
+
},
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function publicThread(thread) {
|
|
954
|
+
const hasProjectCwd = thread.hasProjectCwd !== false;
|
|
955
|
+
return {
|
|
956
|
+
id: thread.id,
|
|
957
|
+
title: thread.title,
|
|
958
|
+
name: thread.title,
|
|
959
|
+
cwd: hasProjectCwd ? thread.cwd : null,
|
|
960
|
+
model: normalizeOpenCodeModel(thread.model),
|
|
961
|
+
modelProvider: OPENCODE_PROVIDER_ID,
|
|
962
|
+
provider: OPENCODE_PROVIDER_ID,
|
|
963
|
+
createdAt: thread.createdAt,
|
|
964
|
+
updatedAt: thread.updatedAt,
|
|
965
|
+
metadata: {
|
|
966
|
+
provider: OPENCODE_PROVIDER_ID,
|
|
967
|
+
projectCwdSource: hasProjectCwd ? "explicit" : "fallback",
|
|
968
|
+
},
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function buildPromptFromTurnInput(input) {
|
|
973
|
+
if (typeof input === "string") {
|
|
974
|
+
return {
|
|
975
|
+
inputText: input.trim(),
|
|
976
|
+
prompt: input.trim(),
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
if (!Array.isArray(input)) {
|
|
980
|
+
return {
|
|
981
|
+
inputText: "",
|
|
982
|
+
prompt: "",
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const textParts = [];
|
|
987
|
+
const fallbackParts = [];
|
|
988
|
+
for (const item of input) {
|
|
989
|
+
if (typeof item === "string") {
|
|
990
|
+
appendNonEmpty(textParts, item);
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
if (!item || typeof item !== "object") {
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
const type = readString(item.type).toLowerCase();
|
|
997
|
+
if (type.includes("image")) {
|
|
998
|
+
appendNonEmpty(fallbackParts, imageFallbackText(item));
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
appendNonEmpty(textParts, item.text || item.content || item.message);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const inputText = textParts.join("\n\n").trim() || fallbackParts.join("\n\n").trim();
|
|
1005
|
+
const prompt = [...textParts, ...fallbackParts].join("\n\n").trim();
|
|
1006
|
+
return {
|
|
1007
|
+
inputText,
|
|
1008
|
+
prompt,
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function imageFallbackText(item) {
|
|
1013
|
+
const imagePath = readString(item.path || item.url || item.image_url || item.dataURL || item.data_url);
|
|
1014
|
+
return imagePath ? `[image attached: ${imagePath}]` : "[image attached]";
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function shouldSkipPermissions(params = {}) {
|
|
1018
|
+
const approvalPolicy = readString(params.approvalPolicy || params.approval_policy).toLowerCase();
|
|
1019
|
+
const sandbox = readString(params.sandbox).toLowerCase();
|
|
1020
|
+
const sandboxType = readString(params.sandboxPolicy?.type || params.sandbox_policy?.type).toLowerCase();
|
|
1021
|
+
return approvalPolicy === "never"
|
|
1022
|
+
|| sandbox.includes("danger")
|
|
1023
|
+
|| sandboxType === "dangerfullaccess"
|
|
1024
|
+
|| sandboxType === "danger-full-access";
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function computeTextDelta(previousText, incomingText) {
|
|
1028
|
+
if (!incomingText || incomingText === previousText) {
|
|
1029
|
+
return "";
|
|
1030
|
+
}
|
|
1031
|
+
if (incomingText.startsWith(previousText)) {
|
|
1032
|
+
return incomingText.slice(previousText.length);
|
|
1033
|
+
}
|
|
1034
|
+
return incomingText;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function mergeTextSnapshot(previousText, incomingText) {
|
|
1038
|
+
if (!previousText || incomingText.startsWith(previousText)) {
|
|
1039
|
+
return incomingText;
|
|
1040
|
+
}
|
|
1041
|
+
return previousText + incomingText;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function textContent(text) {
|
|
1045
|
+
return [
|
|
1046
|
+
{
|
|
1047
|
+
type: "text",
|
|
1048
|
+
text: text || "",
|
|
1049
|
+
},
|
|
1050
|
+
];
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function compareThreadsByUpdatedAt(lhs, rhs) {
|
|
1054
|
+
const lhsTime = Date.parse(lhs?.updatedAt || lhs?.updated_at || lhs?.createdAt || lhs?.created_at || 0) || 0;
|
|
1055
|
+
const rhsTime = Date.parse(rhs?.updatedAt || rhs?.updated_at || rhs?.createdAt || rhs?.created_at || 0) || 0;
|
|
1056
|
+
return rhsTime - lhsTime;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function normalizeDateString(value) {
|
|
1060
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1061
|
+
const milliseconds = Math.abs(value) < 10_000_000_000 ? value * 1000 : value;
|
|
1062
|
+
return new Date(milliseconds).toISOString();
|
|
1063
|
+
}
|
|
1064
|
+
const normalized = readString(value);
|
|
1065
|
+
const parsed = Date.parse(normalized);
|
|
1066
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : new Date().toISOString();
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function boundedPositiveInteger(value, fallback) {
|
|
1070
|
+
const numeric = Number(value);
|
|
1071
|
+
if (!Number.isFinite(numeric) || numeric <= 0) {
|
|
1072
|
+
return fallback;
|
|
1073
|
+
}
|
|
1074
|
+
return Math.min(Math.floor(numeric), 200);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function removeUndefinedValues(value) {
|
|
1078
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1079
|
+
return value;
|
|
1080
|
+
}
|
|
1081
|
+
const result = {};
|
|
1082
|
+
for (const [key, child] of Object.entries(value)) {
|
|
1083
|
+
if (child !== undefined) {
|
|
1084
|
+
result[key] = removeUndefinedValues(child);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
return result;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function pruneSet(set, maxSize) {
|
|
1091
|
+
while (set.size > maxSize) {
|
|
1092
|
+
const [first] = set;
|
|
1093
|
+
set.delete(first);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function truncateTail(value, maxChars) {
|
|
1098
|
+
const text = String(value || "");
|
|
1099
|
+
return text.length <= maxChars ? text : text.slice(-maxChars);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function appendNonEmpty(target, value) {
|
|
1103
|
+
const text = readString(value);
|
|
1104
|
+
if (text) {
|
|
1105
|
+
target.push(text);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function readThreadId(params = {}) {
|
|
1110
|
+
return readString(params.threadId || params.thread_id || params.id);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function readString(value) {
|
|
1114
|
+
return typeof value === "string" && value.trim() ? value.trim() : "";
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function safeParseJSON(rawValue) {
|
|
1118
|
+
try {
|
|
1119
|
+
return JSON.parse(String(rawValue || ""));
|
|
1120
|
+
} catch {
|
|
1121
|
+
return null;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function isRedactedTextPlaceholder(value) {
|
|
1126
|
+
return /^\[redacted:text:prt_[A-Za-z0-9_-]+\]$/.test(readString(value));
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function unsupportedMethodError(method) {
|
|
1130
|
+
const error = new Error(`Unsupported OpenCode provider method: ${method || "unknown"}`);
|
|
1131
|
+
error.errorCode = "unsupported_opencode_method";
|
|
1132
|
+
return error;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function threadNotFoundError(threadId) {
|
|
1136
|
+
const error = new Error(`OpenCode thread not found: ${threadId || "unknown"}`);
|
|
1137
|
+
error.errorCode = "thread_not_found";
|
|
1138
|
+
return error;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function activeTurnError(threadId) {
|
|
1142
|
+
const error = new Error(`OpenCode thread already has a running turn: ${threadId}`);
|
|
1143
|
+
error.errorCode = "thread_turn_active";
|
|
1144
|
+
return error;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
module.exports = {
|
|
1148
|
+
createOpenCodeProvider,
|
|
1149
|
+
parseOpenCodeExport,
|
|
1150
|
+
parseOpenCodeSessionList,
|
|
1151
|
+
};
|