dsh-codex-handoff 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +272 -2
- package/cordis.patch.yml +5 -0
- package/icon.svg +5 -0
- package/lib/client.js +972 -0
- package/lib/index.js +1049 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +48 -3
package/lib/index.js
ADDED
|
@@ -0,0 +1,1049 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GPT / Codex 对话桥 —— Host 半边。
|
|
3
|
+
*
|
|
4
|
+
* 职责:
|
|
5
|
+
* 1. 调本机 codex CLI(`codex exec`),新建会话或 `codex exec resume <thread>` 续接;
|
|
6
|
+
* 2. 把每一轮的 prompt / 回复 / 元数据写进 <workspace>/.bridge,并在 conversations.json 里建索引;
|
|
7
|
+
* 3. 通过 /api/codex-bridge/* 给 Client 半边提供只读数据;
|
|
8
|
+
* 4. 注册 `codex_gpt` 工具,让 DSH agent 能直接把 GPT 当子代理调用(同一个 conversationId 多次调用即为多轮)。
|
|
9
|
+
*
|
|
10
|
+
* 会话默认持久化(不加 --ephemeral),因此这些对话会出现在 Codex 软件的会话库里,可继续在 Codex 内复核。
|
|
11
|
+
*/
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
16
|
+
|
|
17
|
+
const ROUTE_PREFIX = "/api/codex-bridge";
|
|
18
|
+
const DEFAULT_WORKSPACE =
|
|
19
|
+
process.env.DSH_CODEX_BRIDGE_WORKSPACE || process.cwd();
|
|
20
|
+
const DEFAULT_MODEL = process.env.DSH_CODEX_BRIDGE_MODEL || "";
|
|
21
|
+
const DEFAULT_REASONING = process.env.DSH_CODEX_BRIDGE_REASONING || "";
|
|
22
|
+
const TIMEOUT_SEC = Number(process.env.DSH_CODEX_BRIDGE_TIMEOUT_SEC || 900);
|
|
23
|
+
/** true = 不加载 Codex 用户配置(skills / MCP / 记忆 / 人设指令都看不到)。默认沿用用户配置。 */
|
|
24
|
+
const ISOLATED = process.env.DSH_CODEX_BRIDGE_ISOLATE === "1";
|
|
25
|
+
|
|
26
|
+
const UTF8 = "utf8";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 选一个工作区:显式环境变量 → 已经带 .bridge 的 workspace → 注册表里的第一个 workspace
|
|
30
|
+
* → workingDirectory 默认值 → 进程 cwd。懒计算,跟随用户当前工作区。
|
|
31
|
+
*/
|
|
32
|
+
function resolveWorkspace(ctx) {
|
|
33
|
+
const env = process.env.DSH_CODEX_BRIDGE_WORKSPACE;
|
|
34
|
+
if (env) return env;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const registry = ctx.get("workspaceRegistry");
|
|
38
|
+
const list = registry?.list?.() ?? [];
|
|
39
|
+
const withBridge = list.find((w) => w?.path && fs.existsSync(path.join(w.path, ".bridge")));
|
|
40
|
+
if (withBridge) return withBridge.path;
|
|
41
|
+
if (list[0]?.path) return list[0].path;
|
|
42
|
+
} catch {
|
|
43
|
+
/* 服务未就绪 */
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const wd = ctx.get("workingDirectory");
|
|
48
|
+
if (wd?.defaultDirectory) return wd.defaultDirectory;
|
|
49
|
+
} catch {
|
|
50
|
+
/* 没有该服务 */
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return process.env.DSH_CODEX_BRIDGE_WORKSPACE || process.cwd() || DEFAULT_WORKSPACE;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function bridgeRoot(workspace) {
|
|
57
|
+
return path.join(workspace, ".bridge");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ensureDirs(root) {
|
|
61
|
+
for (const dir of ["queue", "outbox", "inbox", "logs"]) {
|
|
62
|
+
fs.mkdirSync(path.join(root, dir), { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function stamp() {
|
|
67
|
+
const d = new Date();
|
|
68
|
+
const p = (n, w = 2) => String(n).padStart(w, "0");
|
|
69
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function newId(prefix) {
|
|
73
|
+
return `${prefix}-${stamp()}-${Math.random().toString(16).slice(2, 7)}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Codex 可执行文件:环境变量 → PATH → 新版 App 安装目录。 */
|
|
77
|
+
function resolveCodex() {
|
|
78
|
+
const explicit = process.env.DSH_CODEX_BRIDGE_CODEX || process.env.CODEX_BIN;
|
|
79
|
+
if (explicit && fs.existsSync(explicit)) return explicit;
|
|
80
|
+
|
|
81
|
+
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
|
82
|
+
const binDir = path.join(localAppData, "OpenAI", "Codex", "bin");
|
|
83
|
+
try {
|
|
84
|
+
const candidates = fs
|
|
85
|
+
.readdirSync(binDir, { withFileTypes: true })
|
|
86
|
+
.filter((e) => e.isDirectory())
|
|
87
|
+
.map((e) => ({
|
|
88
|
+
dir: path.join(binDir, e.name),
|
|
89
|
+
mtime: fs.statSync(path.join(binDir, e.name)).mtimeMs,
|
|
90
|
+
}))
|
|
91
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
92
|
+
for (const c of candidates) {
|
|
93
|
+
const exe = path.join(c.dir, process.platform === "win32" ? "codex.exe" : "codex");
|
|
94
|
+
if (fs.existsSync(exe)) return exe;
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
/* 目录不存在就落到 PATH 上 */
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const name of process.platform === "win32" ? ["codex.exe", "codex"] : ["codex"]) {
|
|
101
|
+
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
|
102
|
+
if (!dir) continue;
|
|
103
|
+
const candidate = path.join(dir, name);
|
|
104
|
+
try {
|
|
105
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
106
|
+
} catch {
|
|
107
|
+
/* 权限问题忽略 */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return "";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── 存储 ─────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
function indexPath(root) {
|
|
117
|
+
return path.join(root, "conversations.json");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readIndex(root) {
|
|
121
|
+
const p = indexPath(root);
|
|
122
|
+
if (!fs.existsSync(p)) return { version: 1, conversations: [] };
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(fs.readFileSync(p, UTF8));
|
|
125
|
+
if (!Array.isArray(parsed?.conversations)) return { version: 1, conversations: [] };
|
|
126
|
+
return parsed;
|
|
127
|
+
} catch {
|
|
128
|
+
return { version: 1, conversations: [] };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function writeIndex(root, index) {
|
|
133
|
+
const p = indexPath(root);
|
|
134
|
+
const tmp = `${p}.tmp`;
|
|
135
|
+
fs.writeFileSync(tmp, JSON.stringify(index, null, 2), UTF8);
|
|
136
|
+
fs.renameSync(tmp, p);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readJsonSafe(p) {
|
|
140
|
+
try {
|
|
141
|
+
return JSON.parse(fs.readFileSync(p, UTF8));
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function readTextSafe(p) {
|
|
148
|
+
try {
|
|
149
|
+
return fs.readFileSync(p, UTF8);
|
|
150
|
+
} catch {
|
|
151
|
+
return "";
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 老格式兼容:conversations.json 出现之前,记录只有 outbox/<id>.prompt.md 和 inbox/<id>.reply.md。
|
|
157
|
+
* 这里把没有进索引的那些也扫出来,并尽量从 inbox/<id>.json 或 logs/<id>.stdout.jsonl 里
|
|
158
|
+
* 捞出当时的 Codex thread_id(旧记录虽然是 ephemeral,事件流里仍记着它)。
|
|
159
|
+
*/
|
|
160
|
+
function scanLegacyTurns(root, knownTurnIds) {
|
|
161
|
+
const outboxDir = path.join(root, "outbox");
|
|
162
|
+
const inboxDir = path.join(root, "inbox");
|
|
163
|
+
const logsDir = path.join(root, "logs");
|
|
164
|
+
|
|
165
|
+
let names = [];
|
|
166
|
+
try {
|
|
167
|
+
names = fs.readdirSync(outboxDir).filter((n) => n.endsWith(".prompt.md"));
|
|
168
|
+
} catch {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const turns = [];
|
|
173
|
+
for (const name of names) {
|
|
174
|
+
const id = name.replace(/\.prompt\.md$/, "");
|
|
175
|
+
if (knownTurnIds.has(id)) continue;
|
|
176
|
+
|
|
177
|
+
const prompt = readTextSafe(path.join(outboxDir, name));
|
|
178
|
+
const reply = readTextSafe(path.join(inboxDir, `${id}.reply.md`));
|
|
179
|
+
if (!prompt.trim() && !reply.trim()) continue;
|
|
180
|
+
|
|
181
|
+
const meta = readJsonSafe(path.join(inboxDir, `${id}.json`)) || {};
|
|
182
|
+
let threadId = String(meta.thread_id || meta.threadId || meta.resumeThreadId || "");
|
|
183
|
+
if (!threadId) {
|
|
184
|
+
const log = readTextSafe(path.join(logsDir, `${id}.stdout.jsonl`));
|
|
185
|
+
const hit = log.match(/"thread_id"\s*:\s*"([0-9a-fA-F-]{8,})"/);
|
|
186
|
+
if (hit) threadId = hit[1];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let at = 0;
|
|
190
|
+
for (const candidate of [path.join(inboxDir, `${id}.reply.md`), path.join(outboxDir, name)]) {
|
|
191
|
+
try {
|
|
192
|
+
at = fs.statSync(candidate).mtimeMs;
|
|
193
|
+
break;
|
|
194
|
+
} catch {
|
|
195
|
+
/* 换下一个候选 */
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
turns.push({
|
|
200
|
+
id,
|
|
201
|
+
prompt,
|
|
202
|
+
reply,
|
|
203
|
+
meta,
|
|
204
|
+
threadId,
|
|
205
|
+
at,
|
|
206
|
+
model: String(meta.model || ""),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
turns.sort((a, b) => a.at - b.at);
|
|
210
|
+
return turns;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Codex 会话库回读 ─────────────────────────────────────────────────
|
|
214
|
+
//
|
|
215
|
+
// Codex 把每条会话的完整时间线写在 $CODEX_HOME/sessions/YYYY/MM/DD/rollout-*-<threadId>.jsonl。
|
|
216
|
+
// 用户在 Codex 软件里继续同一会话后,新轮次只存在于那里;回读它,侧栏才能看到「两边都算」的完整对话。
|
|
217
|
+
|
|
218
|
+
const rolloutIndexCache = { at: 0, files: [] };
|
|
219
|
+
|
|
220
|
+
function codexHomeDir() {
|
|
221
|
+
return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function listRolloutFiles() {
|
|
225
|
+
if (Date.now() - rolloutIndexCache.at < 30000 && rolloutIndexCache.files.length > 0) {
|
|
226
|
+
return rolloutIndexCache.files;
|
|
227
|
+
}
|
|
228
|
+
const files = [];
|
|
229
|
+
const walk = (dir, depth) => {
|
|
230
|
+
if (depth > 4) return;
|
|
231
|
+
let entries;
|
|
232
|
+
try {
|
|
233
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
234
|
+
} catch {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
for (const entry of entries) {
|
|
238
|
+
const full = path.join(dir, entry.name);
|
|
239
|
+
if (entry.isDirectory()) walk(full, depth + 1);
|
|
240
|
+
else if (entry.name.endsWith(".jsonl")) files.push(full);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
walk(path.join(codexHomeDir(), "sessions"), 0);
|
|
244
|
+
rolloutIndexCache.at = Date.now();
|
|
245
|
+
rolloutIndexCache.files = files;
|
|
246
|
+
return files;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function findRolloutForThread(threadId) {
|
|
250
|
+
if (!threadId) return "";
|
|
251
|
+
const wanted = String(threadId).toLowerCase();
|
|
252
|
+
return listRolloutFiles().find((file) => file.toLowerCase().includes(wanted)) || "";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** 从 rollout 里抽 user/assistant 原文,跳过环境上下文与 developer 注入。 */
|
|
256
|
+
function readRolloutTimeline(file, maxMessages = 60) {
|
|
257
|
+
let raw;
|
|
258
|
+
try {
|
|
259
|
+
raw = fs.readFileSync(file, UTF8);
|
|
260
|
+
} catch {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
const messages = [];
|
|
264
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
265
|
+
if (!line.trim()) continue;
|
|
266
|
+
let event;
|
|
267
|
+
try {
|
|
268
|
+
event = JSON.parse(line);
|
|
269
|
+
} catch {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (event?.type !== "response_item") continue;
|
|
273
|
+
const payload = event.payload;
|
|
274
|
+
if (payload?.type !== "message") continue;
|
|
275
|
+
const role = payload.role;
|
|
276
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
277
|
+
let text = "";
|
|
278
|
+
for (const part of payload.content || []) {
|
|
279
|
+
if (typeof part?.text === "string") text += part.text;
|
|
280
|
+
}
|
|
281
|
+
if (!text.trim()) continue;
|
|
282
|
+
if (role === "user" && text.trimStart().startsWith("<environment_context>")) continue;
|
|
283
|
+
messages.push({ role, text, at: event.timestamp || "" });
|
|
284
|
+
}
|
|
285
|
+
return messages.slice(-maxMessages);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
/** 模型目录(`codex debug models` 解析,10 分钟缓存)。 */
|
|
291
|
+
const modelsCache = { at: 0, codex: "", models: [] };
|
|
292
|
+
|
|
293
|
+
function listModels(codex) {
|
|
294
|
+
if (!codex) return modelsCache.models;
|
|
295
|
+
if (Date.now() - modelsCache.at < 600000 && modelsCache.codex === codex && modelsCache.models.length > 0) {
|
|
296
|
+
return modelsCache.models;
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
const raw = execFileSync(codex, ["debug", "models"], {
|
|
300
|
+
encoding: "utf8",
|
|
301
|
+
windowsHide: true,
|
|
302
|
+
timeout: 15000,
|
|
303
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
304
|
+
});
|
|
305
|
+
const parsed = JSON.parse(raw);
|
|
306
|
+
const models = (Array.isArray(parsed?.models) ? parsed.models : []).map((model) => ({
|
|
307
|
+
slug: String(model.slug || ""),
|
|
308
|
+
displayName: String(model.display_name || model.slug || ""),
|
|
309
|
+
defaultReasoning: String(model.default_reasoning_level || ""),
|
|
310
|
+
reasoningLevels: (Array.isArray(model.supported_reasoning_levels) ? model.supported_reasoning_levels : [])
|
|
311
|
+
.map((level) => String(level?.effort || level?.level || ""))
|
|
312
|
+
.filter(Boolean),
|
|
313
|
+
})).filter((model) => model.slug);
|
|
314
|
+
modelsCache.at = Date.now();
|
|
315
|
+
modelsCache.codex = codex;
|
|
316
|
+
modelsCache.models = models;
|
|
317
|
+
return models;
|
|
318
|
+
} catch (error) {
|
|
319
|
+
console.error(`[codex-bridge] 读取模型目录失败: ${String(error)}`);
|
|
320
|
+
return modelsCache.models;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* 从 rollout 的 `turn_context` 读这一轮**实际**使用的模型与推理强度。
|
|
326
|
+
* 桥记录的 model 只是「请求时传了什么」,不传就是空的;实际值只有 Codex 自己知道。
|
|
327
|
+
*/
|
|
328
|
+
function readActualModel(threadId) {
|
|
329
|
+
const file = findRolloutForThread(threadId);
|
|
330
|
+
if (!file) return null;
|
|
331
|
+
let raw;
|
|
332
|
+
try {
|
|
333
|
+
raw = fs.readFileSync(file, UTF8);
|
|
334
|
+
} catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
let last = null;
|
|
338
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
339
|
+
if (!line.trim()) continue;
|
|
340
|
+
let event;
|
|
341
|
+
try {
|
|
342
|
+
event = JSON.parse(line);
|
|
343
|
+
} catch {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (event?.type !== "turn_context" || !event.payload) continue;
|
|
347
|
+
const payload = event.payload;
|
|
348
|
+
const effort =
|
|
349
|
+
payload.effort ||
|
|
350
|
+
payload.reasoning_effort ||
|
|
351
|
+
payload.collaboration_mode?.settings?.reasoning_effort ||
|
|
352
|
+
"";
|
|
353
|
+
if (payload.model || effort) {
|
|
354
|
+
last = { model: String(payload.model || ""), effort: String(effort || "") };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return last;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** 把 rollout 时间线与桥自己记录的回复对照,标出「只在 Codex 侧出现过」的那些轮次。 */function buildCodexTimeline(threadId, knownReplies) {
|
|
361
|
+
const file = findRolloutForThread(threadId);
|
|
362
|
+
if (!file) return { file: "", messages: [] };
|
|
363
|
+
const messages = readRolloutTimeline(file).map((message) => {
|
|
364
|
+
const text = String(message.text || "").trim();
|
|
365
|
+
return {
|
|
366
|
+
role: message.role,
|
|
367
|
+
text: message.text,
|
|
368
|
+
at: message.at,
|
|
369
|
+
// 只有回复能确定身份;用户输入会被桥包装过,不做匹配。
|
|
370
|
+
external: message.role === "assistant" && knownReplies.size > 0 && !knownReplies.has(text),
|
|
371
|
+
};
|
|
372
|
+
});
|
|
373
|
+
return { file, messages };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function buildArgv({ codex, workspace, threadId, model, reasoning, replyPath, persist }) {
|
|
377
|
+
// 注意:`codex exec resume` 的选项集比顶层 `codex exec` 小——它接受
|
|
378
|
+
// --skip-git-repo-check / --json / -o / -m / -c,但不接受 -C、-s 和 --color。
|
|
379
|
+
// resume 的 cwd 由 spawn 的 cwd 决定,沙箱沿用原会话。
|
|
380
|
+
const argv = [codex, "exec"];
|
|
381
|
+
if (threadId) {
|
|
382
|
+
argv.push("resume", threadId, "-", "--skip-git-repo-check", "--json", "-o", replyPath);
|
|
383
|
+
} else {
|
|
384
|
+
argv.push(
|
|
385
|
+
"-C",
|
|
386
|
+
workspace,
|
|
387
|
+
"-s",
|
|
388
|
+
"read-only",
|
|
389
|
+
"--skip-git-repo-check",
|
|
390
|
+
"--color",
|
|
391
|
+
"never",
|
|
392
|
+
"--json",
|
|
393
|
+
"-o",
|
|
394
|
+
replyPath,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
if (!persist) argv.push("--ephemeral");
|
|
398
|
+
if (ISOLATED) argv.push("--ignore-user-config");
|
|
399
|
+
if (model) argv.push("-m", model);
|
|
400
|
+
if (reasoning) argv.push("-c", `model_reasoning_effort="${reasoning}"`);
|
|
401
|
+
return argv;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* 跑一次 codex exec。返回 { threadId, reply, usage, exitCode, durationMs, stdout, stderr }。
|
|
406
|
+
* @param {object} opts
|
|
407
|
+
*/
|
|
408
|
+
function runCodex(opts) {
|
|
409
|
+
const {
|
|
410
|
+
codex,
|
|
411
|
+
workspace,
|
|
412
|
+
threadId = "",
|
|
413
|
+
prompt,
|
|
414
|
+
model = "",
|
|
415
|
+
reasoning = "",
|
|
416
|
+
replyPath,
|
|
417
|
+
persist = true,
|
|
418
|
+
timeoutSec = TIMEOUT_SEC,
|
|
419
|
+
signal,
|
|
420
|
+
} = opts;
|
|
421
|
+
|
|
422
|
+
const argv = buildArgv({ codex, workspace, threadId, model, reasoning, replyPath, persist });
|
|
423
|
+
const started = Date.now();
|
|
424
|
+
|
|
425
|
+
return new Promise((resolve, reject) => {
|
|
426
|
+
let child;
|
|
427
|
+
try {
|
|
428
|
+
child = spawn(argv[0], argv.slice(1), {
|
|
429
|
+
cwd: workspace,
|
|
430
|
+
env: process.env,
|
|
431
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
432
|
+
windowsHide: true,
|
|
433
|
+
});
|
|
434
|
+
} catch (error) {
|
|
435
|
+
reject(error);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const out = [];
|
|
440
|
+
const err = [];
|
|
441
|
+
let settled = false;
|
|
442
|
+
let timer = null;
|
|
443
|
+
|
|
444
|
+
const onAbort = () => {
|
|
445
|
+
try {
|
|
446
|
+
child.kill();
|
|
447
|
+
} catch {
|
|
448
|
+
/* 已退出 */
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
if (signal) {
|
|
453
|
+
if (signal.aborted) onAbort();
|
|
454
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
timer = setTimeout(() => {
|
|
458
|
+
try {
|
|
459
|
+
child.kill();
|
|
460
|
+
} catch {
|
|
461
|
+
/* ignore */
|
|
462
|
+
}
|
|
463
|
+
err.push(`\n[bridge] 超时 ${timeoutSec}s,已终止。\n`);
|
|
464
|
+
}, timeoutSec * 1000);
|
|
465
|
+
|
|
466
|
+
child.stdout.on("data", (buf) => out.push(buf));
|
|
467
|
+
child.stderr.on("data", (buf) => err.push(buf));
|
|
468
|
+
|
|
469
|
+
child.on("error", (error) => {
|
|
470
|
+
if (settled) return;
|
|
471
|
+
settled = true;
|
|
472
|
+
clearTimeout(timer);
|
|
473
|
+
reject(error);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
child.on("close", (exitCode) => {
|
|
477
|
+
if (settled) return;
|
|
478
|
+
settled = true;
|
|
479
|
+
clearTimeout(timer);
|
|
480
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
481
|
+
|
|
482
|
+
const stdout = Buffer.concat(out).toString("utf8");
|
|
483
|
+
const stderr = Buffer.concat(err).toString("utf8");
|
|
484
|
+
|
|
485
|
+
let resolvedThreadId = threadId;
|
|
486
|
+
let usage = null;
|
|
487
|
+
let lastMessage = "";
|
|
488
|
+
let failure = "";
|
|
489
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
490
|
+
const trimmed = line.trim();
|
|
491
|
+
if (!trimmed.startsWith("{")) continue;
|
|
492
|
+
let event;
|
|
493
|
+
try {
|
|
494
|
+
event = JSON.parse(trimmed);
|
|
495
|
+
} catch {
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (event.type === "thread.started" && event.thread_id) resolvedThreadId = event.thread_id;
|
|
499
|
+
if (event.type === "turn.completed" && event.usage) usage = event.usage;
|
|
500
|
+
if (
|
|
501
|
+
event.type === "item.completed" &&
|
|
502
|
+
event.item?.type === "agent_message" &&
|
|
503
|
+
typeof event.item.text === "string"
|
|
504
|
+
) {
|
|
505
|
+
lastMessage = event.item.text;
|
|
506
|
+
}
|
|
507
|
+
// turn.failed 才是致命错误;顶层 error 事件可能只是重连提示,item 里的 error 多是特性告警。
|
|
508
|
+
if (event.type === "turn.failed") {
|
|
509
|
+
const raw = String(event.error?.message || "");
|
|
510
|
+
let message = raw;
|
|
511
|
+
try {
|
|
512
|
+
const parsed = JSON.parse(raw);
|
|
513
|
+
message = String(parsed?.error?.message || parsed?.message || raw);
|
|
514
|
+
} catch {
|
|
515
|
+
/* raw 本来就是纯文本 */
|
|
516
|
+
}
|
|
517
|
+
failure = message || "codex turn failed";
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
let reply = readTextSafe(replyPath);
|
|
522
|
+
if (!reply.trim()) reply = lastMessage;
|
|
523
|
+
|
|
524
|
+
resolve({
|
|
525
|
+
threadId: resolvedThreadId,
|
|
526
|
+
reply,
|
|
527
|
+
usage,
|
|
528
|
+
exitCode,
|
|
529
|
+
failure,
|
|
530
|
+
durationMs: Date.now() - started,
|
|
531
|
+
stdout,
|
|
532
|
+
stderr,
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
child.stdin.on("error", () => {
|
|
537
|
+
/* EPIPE:进程提前退出,close 会处理 */
|
|
538
|
+
});
|
|
539
|
+
child.stdin.write(prompt, UTF8);
|
|
540
|
+
child.stdin.end();
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ── 会话操作 ─────────────────────────────────────────────────────────
|
|
545
|
+
|
|
546
|
+
function findConversation(index, id) {
|
|
547
|
+
return index.conversations.find((c) => c.id === id);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function summarize(text, max = 72) {
|
|
551
|
+
const oneLine = String(text || "").replace(/\s+/g, " ").trim();
|
|
552
|
+
return oneLine.length > max ? `${oneLine.slice(0, max)}…` : oneLine || "(未命名对话)";
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* 老记录的 prompt 带着桥模板头(`# Bridge message …` / `## Role` / `## Rules`),
|
|
557
|
+
* 直接截前 72 字只会显示模板。`## Request` 之后才是用户真正问的话。
|
|
558
|
+
*/
|
|
559
|
+
function deriveTitle(prompt) {
|
|
560
|
+
const text = String(prompt || "");
|
|
561
|
+
const marker = "## Request";
|
|
562
|
+
const at = text.indexOf(marker);
|
|
563
|
+
const body = at >= 0 ? text.slice(at + marker.length) : text;
|
|
564
|
+
const cleaned = body
|
|
565
|
+
.replace(/^[\s#>-]+/, "")
|
|
566
|
+
.split(/\n##\s/)[0]
|
|
567
|
+
.trim();
|
|
568
|
+
return summarize(cleaned);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function turnFiles(root, turnId) {
|
|
572
|
+
return {
|
|
573
|
+
prompt: path.join(root, "outbox", `${turnId}.prompt.md`),
|
|
574
|
+
reply: path.join(root, "inbox", `${turnId}.reply.md`),
|
|
575
|
+
meta: path.join(root, "inbox", `${turnId}.json`),
|
|
576
|
+
stdout: path.join(root, "logs", `${turnId}.stdout.jsonl`),
|
|
577
|
+
stderr: path.join(root, "logs", `${turnId}.stderr.log`),
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* 发一轮对话。conversationId 为空则新建会话;否则续接该会话的 Codex thread。
|
|
583
|
+
*/
|
|
584
|
+
async function sendTurn(ctx, { workspace, conversationId, message, model, reasoning, threadId: explicitThread, signal }) {
|
|
585
|
+
const root = bridgeRoot(workspace);
|
|
586
|
+
ensureDirs(root);
|
|
587
|
+
const codex = resolveCodex();
|
|
588
|
+
if (!codex) throw new Error("找不到 codex 可执行文件;设置 DSH_CODEX_BRIDGE_CODEX 或把 codex 加进 PATH。");
|
|
589
|
+
if (!message || !message.trim()) throw new Error("message 不能为空。");
|
|
590
|
+
|
|
591
|
+
const index = readIndex(root);
|
|
592
|
+
let conversation = conversationId ? findConversation(index, conversationId) : undefined;
|
|
593
|
+
if (conversationId && !conversation) throw new Error(`找不到对话 ${conversationId}`);
|
|
594
|
+
|
|
595
|
+
const turnId = newId("m");
|
|
596
|
+
const files = turnFiles(root, turnId);
|
|
597
|
+
fs.writeFileSync(files.prompt, message, UTF8);
|
|
598
|
+
|
|
599
|
+
const effectiveModel = model || conversation?.model || DEFAULT_MODEL;
|
|
600
|
+
const effectiveReasoning = reasoning || conversation?.reasoning || DEFAULT_REASONING;
|
|
601
|
+
const effectiveThread = conversation?.threadId || explicitThread || "";
|
|
602
|
+
|
|
603
|
+
fs.writeFileSync(
|
|
604
|
+
files.meta,
|
|
605
|
+
JSON.stringify(
|
|
606
|
+
{
|
|
607
|
+
id: turnId,
|
|
608
|
+
conversationId: conversation?.id ?? "",
|
|
609
|
+
state: "running",
|
|
610
|
+
created_at: new Date().toISOString(),
|
|
611
|
+
model: effectiveModel,
|
|
612
|
+
reasoning: effectiveReasoning,
|
|
613
|
+
resumeThreadId: effectiveThread,
|
|
614
|
+
},
|
|
615
|
+
null,
|
|
616
|
+
2,
|
|
617
|
+
),
|
|
618
|
+
UTF8,
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
let result;
|
|
622
|
+
try {
|
|
623
|
+
result = await runCodex({
|
|
624
|
+
codex,
|
|
625
|
+
workspace,
|
|
626
|
+
threadId: effectiveThread,
|
|
627
|
+
prompt: message,
|
|
628
|
+
model: effectiveModel,
|
|
629
|
+
reasoning: effectiveReasoning,
|
|
630
|
+
replyPath: files.reply,
|
|
631
|
+
persist: true,
|
|
632
|
+
signal,
|
|
633
|
+
});
|
|
634
|
+
} catch (error) {
|
|
635
|
+
fs.writeFileSync(
|
|
636
|
+
files.meta,
|
|
637
|
+
JSON.stringify(
|
|
638
|
+
{
|
|
639
|
+
id: turnId,
|
|
640
|
+
conversationId: conversation?.id ?? "",
|
|
641
|
+
state: "failed",
|
|
642
|
+
created_at: new Date().toISOString(),
|
|
643
|
+
error: String(error?.message ?? error),
|
|
644
|
+
},
|
|
645
|
+
null,
|
|
646
|
+
2,
|
|
647
|
+
),
|
|
648
|
+
UTF8,
|
|
649
|
+
);
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
fs.writeFileSync(files.stdout, result.stdout, UTF8);
|
|
654
|
+
fs.writeFileSync(files.stderr, result.stderr, UTF8);
|
|
655
|
+
|
|
656
|
+
// 空回复等于这一轮失败(模型不支持该参数、额度不足、网络中断等),
|
|
657
|
+
// 把原因写下来并在索引里留痕,避免侧栏出现「看起来成功但是空的」记录。
|
|
658
|
+
const failed = !result.reply.trim();
|
|
659
|
+
const failureReason = result.failure || `codex 退出码 ${result.exitCode},没有产生回复`;
|
|
660
|
+
if (failed) fs.writeFileSync(files.reply, `(无回复) ${failureReason}`, UTF8);
|
|
661
|
+
|
|
662
|
+
// Codex 实际用的模型只有它自己的 rollout 里才有;请求参数没传时这里是唯一来源。
|
|
663
|
+
const actual = readActualModel(result.threadId || conversation?.threadId || "");
|
|
664
|
+
const actualModel = actual?.model || "";
|
|
665
|
+
const actualEffort = actual?.effort || "";
|
|
666
|
+
|
|
667
|
+
if (!conversation) {
|
|
668
|
+
conversation = {
|
|
669
|
+
id: newId("c"),
|
|
670
|
+
title: summarize(message),
|
|
671
|
+
threadId: result.threadId || "",
|
|
672
|
+
model: effectiveModel,
|
|
673
|
+
reasoning: effectiveReasoning,
|
|
674
|
+
createdAt: Date.now(),
|
|
675
|
+
updatedAt: Date.now(),
|
|
676
|
+
turns: [],
|
|
677
|
+
};
|
|
678
|
+
index.conversations.unshift(conversation);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (result.threadId) conversation.threadId = result.threadId;
|
|
682
|
+
// 会话级记忆记的是「最近一次实际用的」,这样下次续接能沿用最后一次的选择。
|
|
683
|
+
if (actualModel) conversation.model = actualModel;
|
|
684
|
+
if (actualEffort) conversation.reasoning = actualEffort;
|
|
685
|
+
conversation.updatedAt = Date.now();
|
|
686
|
+
conversation.turns.push({
|
|
687
|
+
id: turnId,
|
|
688
|
+
at: Date.now(),
|
|
689
|
+
promptFile: path.relative(root, files.prompt),
|
|
690
|
+
replyFile: path.relative(root, files.reply),
|
|
691
|
+
metaFile: path.relative(root, files.meta),
|
|
692
|
+
exitCode: result.exitCode,
|
|
693
|
+
durationMs: result.durationMs,
|
|
694
|
+
failed,
|
|
695
|
+
});
|
|
696
|
+
writeIndex(root, index);
|
|
697
|
+
|
|
698
|
+
const meta = {
|
|
699
|
+
id: turnId,
|
|
700
|
+
conversationId: conversation.id,
|
|
701
|
+
state: failed ? "failed" : "done",
|
|
702
|
+
created_at: new Date().toISOString(),
|
|
703
|
+
model: effectiveModel,
|
|
704
|
+
reasoning: effectiveReasoning,
|
|
705
|
+
actualModel,
|
|
706
|
+
actualEffort,
|
|
707
|
+
threadId: conversation.threadId,
|
|
708
|
+
exit_code: result.exitCode,
|
|
709
|
+
duration_s: Math.round(result.durationMs / 100) / 10,
|
|
710
|
+
usage: result.usage,
|
|
711
|
+
error: failed ? failureReason : undefined,
|
|
712
|
+
};
|
|
713
|
+
fs.writeFileSync(files.meta, JSON.stringify(meta, null, 2), UTF8);
|
|
714
|
+
|
|
715
|
+
if (failed) throw new Error(`Codex 没有产生回复:${failureReason}`);
|
|
716
|
+
|
|
717
|
+
return { conversation, turn: { id: turnId, ...meta }, reply: result.reply };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ── HTTP 路由(给 Client 半边读) ────────────────────────────────────
|
|
721
|
+
|
|
722
|
+
/** 路由改用 Node 原生 handler(resource 路由只放行 GET/HEAD,收不了 POST)。 */
|
|
723
|
+
|
|
724
|
+
function listPayload(workspace) {
|
|
725
|
+
const root = bridgeRoot(workspace);
|
|
726
|
+
const index = readIndex(root);
|
|
727
|
+
|
|
728
|
+
const knownTurnIds = new Set();
|
|
729
|
+
for (const c of index.conversations) {
|
|
730
|
+
for (const turn of c.turns || []) knownTurnIds.add(turn.id);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const conversations = index.conversations
|
|
734
|
+
.slice()
|
|
735
|
+
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
|
736
|
+
.map((c) => ({
|
|
737
|
+
id: c.id,
|
|
738
|
+
title: c.title,
|
|
739
|
+
threadId: c.threadId || "",
|
|
740
|
+
model: c.model || "",
|
|
741
|
+
reasoning: c.reasoning || "",
|
|
742
|
+
createdAt: c.createdAt || 0,
|
|
743
|
+
updatedAt: c.updatedAt || 0,
|
|
744
|
+
turnCount: (c.turns || []).length,
|
|
745
|
+
lastTurnAt: (c.turns || []).at?.(-1)?.at ?? c.updatedAt ?? 0,
|
|
746
|
+
legacy: false,
|
|
747
|
+
}));
|
|
748
|
+
|
|
749
|
+
// 老格式记录(索引里没有的那些)各自作为一段单轮会话列出。
|
|
750
|
+
for (const turn of scanLegacyTurns(root, knownTurnIds)) {
|
|
751
|
+
conversations.push({
|
|
752
|
+
id: turn.id,
|
|
753
|
+
title: deriveTitle(turn.prompt),
|
|
754
|
+
threadId: turn.threadId,
|
|
755
|
+
model: turn.model,
|
|
756
|
+
reasoning: String(turn.meta?.reasoning || ""),
|
|
757
|
+
createdAt: turn.at,
|
|
758
|
+
updatedAt: turn.at,
|
|
759
|
+
turnCount: 1,
|
|
760
|
+
lastTurnAt: turn.at,
|
|
761
|
+
legacy: true,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
conversations.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
766
|
+
return {
|
|
767
|
+
workspace,
|
|
768
|
+
bridgeRoot: root,
|
|
769
|
+
codex: resolveCodex(),
|
|
770
|
+
defaults: {
|
|
771
|
+
model: DEFAULT_MODEL,
|
|
772
|
+
reasoning: DEFAULT_REASONING,
|
|
773
|
+
isolated: ISOLATED,
|
|
774
|
+
timeoutSec: TIMEOUT_SEC,
|
|
775
|
+
},
|
|
776
|
+
conversations,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function readPayload(workspace, conversationId) {
|
|
781
|
+
const root = bridgeRoot(workspace);
|
|
782
|
+
const index = readIndex(root);
|
|
783
|
+
const conversation = findConversation(index, conversationId);
|
|
784
|
+
|
|
785
|
+
if (!conversation) {
|
|
786
|
+
// 老格式:直接按 id 找那一轮的 prompt / reply。
|
|
787
|
+
const turn = scanLegacyTurns(root, new Set()).find((t) => t.id === conversationId);
|
|
788
|
+
if (!turn) return null;
|
|
789
|
+
const knownReplies = new Set([String(turn.reply || "").trim()].filter(Boolean));
|
|
790
|
+
return {
|
|
791
|
+
id: turn.id,
|
|
792
|
+
title: deriveTitle(turn.prompt),
|
|
793
|
+
threadId: turn.threadId,
|
|
794
|
+
model: turn.model,
|
|
795
|
+
createdAt: turn.at,
|
|
796
|
+
updatedAt: turn.at,
|
|
797
|
+
legacy: true,
|
|
798
|
+
turns: [
|
|
799
|
+
{
|
|
800
|
+
id: turn.id,
|
|
801
|
+
at: turn.at,
|
|
802
|
+
exitCode: turn.meta?.exit_code ?? null,
|
|
803
|
+
durationMs: (turn.meta?.duration_s ?? 0) * 1000,
|
|
804
|
+
prompt: turn.prompt,
|
|
805
|
+
reply: turn.reply,
|
|
806
|
+
meta: turn.meta,
|
|
807
|
+
},
|
|
808
|
+
],
|
|
809
|
+
codexTimeline: buildCodexTimeline(turn.threadId, knownReplies),
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const turns = (conversation.turns || []).map((turn) => ({
|
|
814
|
+
id: turn.id,
|
|
815
|
+
at: turn.at,
|
|
816
|
+
exitCode: turn.exitCode,
|
|
817
|
+
durationMs: turn.durationMs,
|
|
818
|
+
prompt: readTextSafe(path.join(root, turn.promptFile)),
|
|
819
|
+
reply: readTextSafe(path.join(root, turn.replyFile)),
|
|
820
|
+
meta: readJsonSafe(path.join(root, turn.metaFile)),
|
|
821
|
+
}));
|
|
822
|
+
const knownReplies = new Set(
|
|
823
|
+
turns.map((turn) => String(turn.reply || "").trim()).filter(Boolean),
|
|
824
|
+
);
|
|
825
|
+
return {
|
|
826
|
+
id: conversation.id,
|
|
827
|
+
title: conversation.title,
|
|
828
|
+
threadId: conversation.threadId || "",
|
|
829
|
+
model: conversation.model || "",
|
|
830
|
+
createdAt: conversation.createdAt || 0,
|
|
831
|
+
updatedAt: conversation.updatedAt || 0,
|
|
832
|
+
legacy: false,
|
|
833
|
+
turns,
|
|
834
|
+
codexTimeline: buildCodexTimeline(conversation.threadId, knownReplies),
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function makeHandler(ctx, resolveWorkspace) {
|
|
839
|
+
return async (req, res) => {
|
|
840
|
+
const send = (status, value) => {
|
|
841
|
+
try {
|
|
842
|
+
const body = JSON.stringify(value);
|
|
843
|
+
res.writeHead(status, {
|
|
844
|
+
"content-type": "application/json; charset=utf-8",
|
|
845
|
+
"cache-control": "no-store",
|
|
846
|
+
"content-length": Buffer.byteLength(body),
|
|
847
|
+
});
|
|
848
|
+
res.end(body);
|
|
849
|
+
} catch (error) {
|
|
850
|
+
try {
|
|
851
|
+
res.destroy();
|
|
852
|
+
} catch {
|
|
853
|
+
/* 连接已断 */
|
|
854
|
+
}
|
|
855
|
+
console.error(`[codex-bridge] 响应失败: ${String(error)}`);
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
let url;
|
|
860
|
+
try {
|
|
861
|
+
url = new URL(req.url || "/", "http://localhost");
|
|
862
|
+
} catch {
|
|
863
|
+
send(400, { error: "bad url" });
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
const workspace = resolveWorkspace();
|
|
867
|
+
const rest = (url.pathname || "").slice(ROUTE_PREFIX.length).replace(/^\/+/, "");
|
|
868
|
+
|
|
869
|
+
try {
|
|
870
|
+
// POST /api/codex-bridge/send —— 发一轮真实对话(会消耗 Codex 额度)。
|
|
871
|
+
// 注意:resource 路由只能 GET/HEAD,所以这里用 handler 收 POST。
|
|
872
|
+
if (req.method === "POST") {
|
|
873
|
+
if (rest !== "send") {
|
|
874
|
+
send(404, { error: "not found" });
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
const chunks = [];
|
|
878
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
879
|
+
let body = {};
|
|
880
|
+
try {
|
|
881
|
+
body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}") || {};
|
|
882
|
+
} catch {
|
|
883
|
+
body = {};
|
|
884
|
+
}
|
|
885
|
+
const result = await sendTurn(undefined, {
|
|
886
|
+
workspace,
|
|
887
|
+
conversationId: String(body?.conversationId || ""),
|
|
888
|
+
threadId: String(body?.threadId || ""),
|
|
889
|
+
message: String(body?.message || ""),
|
|
890
|
+
model: String(body?.model || ""),
|
|
891
|
+
reasoning: String(body?.reasoning || ""),
|
|
892
|
+
});
|
|
893
|
+
send(200, {
|
|
894
|
+
conversationId: result.conversation.id,
|
|
895
|
+
threadId: result.conversation.threadId || "",
|
|
896
|
+
reply: result.reply,
|
|
897
|
+
});
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
902
|
+
send(405, { error: "method not allowed" });
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (rest === "" || rest === "list") {
|
|
907
|
+
send(200, listPayload(workspace));
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (rest === "models") {
|
|
911
|
+
send(200, { models: listModels(resolveCodex()) });
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
if (rest === "status") {
|
|
915
|
+
send(200, {
|
|
916
|
+
workspace,
|
|
917
|
+
bridgeRoot: bridgeRoot(workspace),
|
|
918
|
+
codex: resolveCodex(),
|
|
919
|
+
model: DEFAULT_MODEL,
|
|
920
|
+
reasoning: DEFAULT_REASONING,
|
|
921
|
+
isolated: ISOLATED,
|
|
922
|
+
timeoutSec: TIMEOUT_SEC,
|
|
923
|
+
});
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
if (rest === "read") {
|
|
927
|
+
const id = url.searchParams.get("id") || "";
|
|
928
|
+
const payload = readPayload(workspace, id);
|
|
929
|
+
if (!payload) {
|
|
930
|
+
send(404, { error: `找不到对话 ${id}` });
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
send(200, payload);
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
send(404, { error: "not found" });
|
|
937
|
+
} catch (error) {
|
|
938
|
+
send(500, { error: String(error?.message ?? error) });
|
|
939
|
+
}
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// ── 插件入口 ─────────────────────────────────────────────────────────
|
|
944
|
+
|
|
945
|
+
/** 由 apply 填充,供工具执行使用。 */
|
|
946
|
+
let runtime = null;
|
|
947
|
+
|
|
948
|
+
export function apply(ctx) {
|
|
949
|
+
const state = {
|
|
950
|
+
getWorkspace: () => resolveWorkspace(ctx),
|
|
951
|
+
running: new Map(),
|
|
952
|
+
};
|
|
953
|
+
runtime = state;
|
|
954
|
+
|
|
955
|
+
ctx.effect(() => {
|
|
956
|
+
const root = bridgeRoot(state.getWorkspace());
|
|
957
|
+
try {
|
|
958
|
+
ensureDirs(root);
|
|
959
|
+
} catch (error) {
|
|
960
|
+
console.error(`[codex-bridge] 无法创建 ${root}: ${String(error)}`);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const webServer = ctx.get("webServer");
|
|
964
|
+
if (webServer) {
|
|
965
|
+
const dispose = webServer.register({
|
|
966
|
+
kind: "prefix",
|
|
967
|
+
path: ROUTE_PREFIX,
|
|
968
|
+
handler: makeHandler(ctx, () => state.getWorkspace()),
|
|
969
|
+
});
|
|
970
|
+
return () => dispose();
|
|
971
|
+
}
|
|
972
|
+
return () => {};
|
|
973
|
+
}, "codex-bridge: http route");
|
|
974
|
+
|
|
975
|
+
// ── 工具:把 GPT 当子代理调用 ──────────────────────────────────────
|
|
976
|
+
ctx.effect(() => {
|
|
977
|
+
const tools = ctx.get("tools");
|
|
978
|
+
if (!tools) return () => {};
|
|
979
|
+
|
|
980
|
+
const dispose = tools.register({
|
|
981
|
+
name: "codex_gpt",
|
|
982
|
+
description:
|
|
983
|
+
"把任务交给本机 Codex(GPT)执行,并返回它的回复。同一个 conversationId 再次调用即为多轮续接(同一个 Codex 会话)," +
|
|
984
|
+
"适合与 GPT 做内部协作与复核。省略 conversationId 时新开一个会话。会话默认落进 Codex 会话库,可在 Codex 软件内继续查看。",
|
|
985
|
+
parameters: {
|
|
986
|
+
type: "object",
|
|
987
|
+
properties: {
|
|
988
|
+
message: { type: "string", description: "要发给 GPT 的内容。" },
|
|
989
|
+
conversationId: {
|
|
990
|
+
type: "string",
|
|
991
|
+
description: "续接已有会话的 id;省略则新建。id 来自上一次调用的返回,或侧栏里看到的记录 id。",
|
|
992
|
+
},
|
|
993
|
+
threadId: {
|
|
994
|
+
type: "string",
|
|
995
|
+
description:
|
|
996
|
+
"可选:直接续接一个 Codex thread(例如侧栏里旧记录上显示的 #短码对应的完整 id)。与 conversationId 二选一。",
|
|
997
|
+
},
|
|
998
|
+
model: {
|
|
999
|
+
type: "string",
|
|
1000
|
+
description:
|
|
1001
|
+
"可选,模型 slug,例如 gpt-6.1-sol。不传则用 Codex 配置里的默认模型;可用值来自 codex debug models。",
|
|
1002
|
+
},
|
|
1003
|
+
reasoning: {
|
|
1004
|
+
type: "string",
|
|
1005
|
+
description:
|
|
1006
|
+
"可选,推理深度。不同模型支持的值不同(gpt-6-sol 支持 none/low/medium/high/xhigh/max),不传则用 Codex 默认。",
|
|
1007
|
+
},
|
|
1008
|
+
},
|
|
1009
|
+
required: ["message"],
|
|
1010
|
+
},
|
|
1011
|
+
output: {
|
|
1012
|
+
schema: {
|
|
1013
|
+
type: "object",
|
|
1014
|
+
properties: {
|
|
1015
|
+
conversationId: { type: "string" },
|
|
1016
|
+
threadId: { type: "string" },
|
|
1017
|
+
reply: { type: "string" },
|
|
1018
|
+
},
|
|
1019
|
+
required: ["conversationId", "reply"],
|
|
1020
|
+
},
|
|
1021
|
+
render(_args, value) {
|
|
1022
|
+
return [{ type: "text", text: String(value?.reply ?? "") }];
|
|
1023
|
+
},
|
|
1024
|
+
},
|
|
1025
|
+
async execute(args, exec) {
|
|
1026
|
+
const input = args && typeof args === "object" ? args : {};
|
|
1027
|
+
const result = await sendTurn(ctx, {
|
|
1028
|
+
workspace: state.getWorkspace(),
|
|
1029
|
+
conversationId: String(input.conversationId || ""),
|
|
1030
|
+
threadId: String(input.threadId || ""),
|
|
1031
|
+
message: String(input.message || ""),
|
|
1032
|
+
model: String(input.model || ""),
|
|
1033
|
+
reasoning: String(input.reasoning || ""),
|
|
1034
|
+
signal: exec?.signal,
|
|
1035
|
+
});
|
|
1036
|
+
return {
|
|
1037
|
+
conversationId: result.conversation.id,
|
|
1038
|
+
threadId: result.conversation.threadId || "",
|
|
1039
|
+
reply: result.reply,
|
|
1040
|
+
};
|
|
1041
|
+
},
|
|
1042
|
+
});
|
|
1043
|
+
return () => dispose();
|
|
1044
|
+
}, "codex-bridge: codex_gpt tool");
|
|
1045
|
+
|
|
1046
|
+
return { workspace: state.getWorkspace() };
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
export default { apply };
|