imtoagent 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +234 -0
  2. package/bin/imtoagent +453 -0
  3. package/index.ts +1129 -0
  4. package/modules/agent/claude-adapter.ts +258 -0
  5. package/modules/agent/claude.ts +160 -0
  6. package/modules/agent/codex-adapter.ts +232 -0
  7. package/modules/agent/codex-exec-server.ts +513 -0
  8. package/modules/agent/codex.ts +275 -0
  9. package/modules/agent/opencode-adapter.ts +308 -0
  10. package/modules/agent/opencode.ts +247 -0
  11. package/modules/bot-context.ts +26 -0
  12. package/modules/capabilities.ts +189 -0
  13. package/modules/cli/setup.ts +424 -0
  14. package/modules/core/config.ts +275 -0
  15. package/modules/core/error.ts +124 -0
  16. package/modules/core/index.ts +39 -0
  17. package/modules/core/runtime.ts +282 -0
  18. package/modules/core/session.ts +256 -0
  19. package/modules/core/stats.ts +92 -0
  20. package/modules/core/types.ts +250 -0
  21. package/modules/im/feishu.ts +731 -0
  22. package/modules/im/telegram.ts +639 -0
  23. package/modules/im/wechat.ts +1094 -0
  24. package/modules/im/wecom.ts +603 -0
  25. package/modules/media/feishu-inbound-adapter.ts +108 -0
  26. package/modules/media/index.ts +27 -0
  27. package/modules/media/media-store.ts +273 -0
  28. package/modules/media/resolver.ts +178 -0
  29. package/modules/media/telegram-inbound-adapter.ts +124 -0
  30. package/modules/media/types.ts +76 -0
  31. package/modules/prompt-builder.ts +123 -0
  32. package/modules/proxy/anthropic-proxy.ts +1083 -0
  33. package/modules/proxy/codex-proxy.ts +657 -0
  34. package/modules/rate-limiter.ts +58 -0
  35. package/modules/types.ts +144 -0
  36. package/modules/utils/backend-check.ts +121 -0
  37. package/modules/utils/paths.ts +218 -0
  38. package/package.json +53 -0
  39. package/scripts/postinstall.ts +70 -0
  40. package/templates/config.template.json +57 -0
  41. package/templates/opencode.template.json +28 -0
  42. package/templates/providers.template.json +19 -0
  43. package/templates/soul.template/identity.md +6 -0
  44. package/templates/soul.template/profile.md +11 -0
  45. package/templates/soul.template/rules.md +7 -0
  46. package/templates/soul.template/skills.md +3 -0
  47. package/templates/soul.template/workspace.md +4 -0
@@ -0,0 +1,275 @@
1
+ // Codex Agent 模块
2
+ // 对接 Codex CLI,通过 :18899 Proxy 调用 Provider
3
+
4
+ import { getProxyUsage, resetProxyUsage } from '../proxy/codex-proxy';
5
+ import { calculateCost } from '../proxy/anthropic-proxy';
6
+ import type { AgentContext } from '../types';
7
+ import { getAppServerManager, type AgentEvent } from './codex-exec-server';
8
+ // ================================================================
9
+ // 类型
10
+ // ================================================================
11
+ interface CodexJsonEvent {
12
+ type: string;
13
+ thread_id?: string;
14
+ item?: { type: string; text?: string; name?: string; arguments?: string; output?: string };
15
+ text?: string;
16
+ delta?: string;
17
+ message?: { content?: { type: string; text?: string }[] };
18
+ error?: string;
19
+ }
20
+
21
+ const TOOL_NAMES: Record<string, string> = {
22
+ Bash: '执行命令', Read: '读取文件', Edit: '编辑文件', Write: '写入文件',
23
+ Glob: '搜索文件', Grep: '搜索内容', WebSearch: '搜索网页', WebFetch: '抓取网页',
24
+ NotebookEdit: '编辑 Notebook',
25
+ // Codex 工具名
26
+ command_execution: '执行命令', exec_command: '执行命令', write_stdin: '写入文件', update_plan: '更新计划',
27
+ request_user_input: '请求输入', apply_patch: '应用补丁', view_image: '查看图片',
28
+ spawn_agent: '启动代理', send_input: '发送输入', resume_agent: '恢复代理',
29
+ wait_agent: '等待代理', close_agent: '关闭代理',
30
+ };
31
+
32
+ // ================================================================
33
+ // Codex CLI 调用
34
+ // ================================================================
35
+ function processCodexStream(stdout: string, onTool?: (name: string, args: Record<string, any>) => void): { threadId: string; response: string } {
36
+ let threadId = '', response = '';
37
+ for (const line of stdout.split('\n')) {
38
+ if (!line.trim()) continue;
39
+ try {
40
+ const evt: CodexJsonEvent = JSON.parse(line);
41
+ if (evt.type === 'thread.started' && evt.thread_id) {
42
+ threadId = evt.thread_id;
43
+ } else if (evt.type === 'item.completed') {
44
+ if (evt.item?.type === 'agent_message') {
45
+ response = (response ? response + '\n' : '') + (evt.item.text || '');
46
+ } else if (TOOL_NAMES[evt.item?.type || ''] && onTool) {
47
+ onTool(evt.item.type || 'unknown', { command: (evt.item as any).command || '' });
48
+ }
49
+ } else if (evt.type === 'error' || evt.type === 'thread.error') {
50
+ console.error(`[Codex] event error: ${(evt as any).message || (evt as any).error || JSON.stringify(evt)}`);
51
+ }
52
+ } catch {}
53
+ }
54
+ return { threadId, response };
55
+ }
56
+
57
+ async function spawnCodexExec(
58
+ cwd: string, prompt: string,
59
+ onTool?: (name: string, args: Record<string, any>) => void
60
+ ): Promise<{ threadId: string; response: string }> {
61
+ console.error(`[Codex] spawnExec cwd=${cwd} prompt_len=${prompt.length}`);
62
+ const child = Bun.spawn(['codex', 'exec', '-p', 'imtoagent', '-s', 'danger-full-access',
63
+ '--skip-git-repo-check', '--json', prompt], {
64
+ cwd, stdout: 'pipe', stderr: 'pipe',
65
+ });
66
+
67
+ // 安全读取 stdout/stderr:捕获子进程被 kill 等异常,确保 reject 带 Error 对象
68
+ let stdout = '', stderr = '';
69
+ try {
70
+ [stdout, stderr] = await Promise.all([
71
+ new Response(child.stdout).text().catch((e: any) => { throw new Error(`stdout 读取失败: ${e?.message || e}`); }),
72
+ new Response(child.stderr).text().catch((e: any) => { throw new Error(`stderr 读取失败: ${e?.message || e}`); }),
73
+ ]);
74
+ } catch (ioErr: any) {
75
+ // 子进程可能已被 kill,尝试获取退出码
76
+ try { child.kill('SIGKILL'); } catch {}
77
+ throw new Error(`codex exec I/O 异常: ${ioErr.message}`);
78
+ }
79
+
80
+ const code = await child.exited.catch(() => -1);
81
+ console.error(`[Codex] exec exit=${code} stdout_len=${stdout.length} stderr_len=${stderr.length}`);
82
+ const { threadId, response } = processCodexStream(stdout, onTool);
83
+ if (code !== 0 || !threadId) throw new Error(`codex exec exit ${code}: ${stderr.slice(0, 300)}`);
84
+ return { threadId, response };
85
+ }
86
+
87
+ async function spawnCodexResume(
88
+ cwd: string, threadId: string, prompt: string,
89
+ onTool?: (name: string, args: Record<string, any>) => void
90
+ ): Promise<{ response: string }> {
91
+ console.error(`[Codex] spawnResume cwd=${cwd} threadId=${threadId.slice(-8)} prompt_len=${prompt.length}`);
92
+ const child = Bun.spawn(['codex', 'exec', 'resume', threadId,
93
+ '--dangerously-bypass-approvals-and-sandbox', '-c', 'model_provider=imtoagent', '-c', 'model=gpt-5.5', '--json', '--skip-git-repo-check', prompt], {
94
+ cwd, stdout: 'pipe', stderr: 'pipe',
95
+ });
96
+
97
+ let stdout = '', stderr = '';
98
+ try {
99
+ [stdout, stderr] = await Promise.all([
100
+ new Response(child.stdout).text().catch((e: any) => { throw new Error(`stdout 读取失败: ${e?.message || e}`); }),
101
+ new Response(child.stderr).text().catch((e: any) => { throw new Error(`stderr 读取失败: ${e?.message || e}`); }),
102
+ ]);
103
+ } catch (ioErr: any) {
104
+ try { child.kill('SIGKILL'); } catch {}
105
+ throw new Error(`codex exec resume I/O 异常: ${ioErr.message}`);
106
+ }
107
+
108
+ const code = await child.exited.catch(() => -1);
109
+ console.error(`[Codex] resume exit=${code} stdout_len=${stdout.length} stderr_len=${stderr.length}`);
110
+ if (code !== 0) throw new Error(`codex exec resume exit ${code}: ${stderr.slice(0, 300)}`);
111
+ return { response: processCodexStream(stdout, onTool).response };
112
+ }
113
+
114
+ // ================================================================
115
+ // App-Server 路径(优先使用——流式输出 + 长记忆 + 崩溃不丢上下文)
116
+ // ================================================================
117
+ async function runViaAppServer(
118
+ cwd: string, prompt: string, chatId: string, session: any,
119
+ onTool: (name: string, args: Record<string, any>) => void,
120
+ isFresh: boolean
121
+ ): Promise<{ threadId: string; response: string; usage: { inputTokens: number; outputTokens: number } }> {
122
+ const manager = getAppServerManager();
123
+ const client = await manager.getClient(chatId);
124
+
125
+ // app-server 同进程内线程存活
126
+ // 但进程重启后旧 thread 过期,需要判断代际
127
+ const currentGen = manager.generation;
128
+ const threadExpired = session._appServerGen !== currentGen;
129
+ if (isFresh || !session.codexThreadId || threadExpired) {
130
+ session.codexThreadId = await client.startThread(cwd);
131
+ session._appServerGen = currentGen;
132
+ console.log(`[Codex] app-server 全新线程 thread=${session.codexThreadId.slice(-8)}${threadExpired ? ' (进程重启)' : ''}`);
133
+ }
134
+ // 后续消息直接 turn/start(同线程延续上下文)
135
+
136
+ await client.sendPrompt(session.codexThreadId, prompt, cwd);
137
+
138
+ let response = '';
139
+ let totalUsage = { inputTokens: 0, outputTokens: 0 };
140
+ const startTime = Date.now();
141
+ const MAX_DURATION = 600_000; // 10 分钟总超时
142
+
143
+ for await (const event of client.receiveEvents()) {
144
+ // 超时保护
145
+ if (Date.now() - startTime > MAX_DURATION) {
146
+ console.error('[Codex] app-server 任务超时 (10min)');
147
+ break;
148
+ }
149
+
150
+ switch (event.type) {
151
+ case 'text_delta':
152
+ response += event.textDelta || '';
153
+ break;
154
+ case 'tool_call':
155
+ onTool(event.toolName || 'unknown', event.toolInput || {});
156
+ break;
157
+ case 'turn_result':
158
+ // 累加多轮 token(非终端 turn_result 来自每轮的 turn/completed)
159
+ totalUsage.inputTokens += event.usage?.inputTokens || 0;
160
+ totalUsage.outputTokens += event.usage?.outputTokens || 0;
161
+ break;
162
+ case 'error':
163
+ throw new Error(`app-server 错误: ${event.error}`);
164
+ }
165
+ }
166
+
167
+ return { threadId: session.codexThreadId, response, usage: totalUsage };
168
+ }
169
+
170
+ // ================================================================
171
+ // Codex 模块类
172
+ // ================================================================
173
+ export class CodexAgentModule {
174
+ private ctx: AgentContext;
175
+
176
+ constructor(ctx: AgentContext) {
177
+ this.ctx = ctx;
178
+ }
179
+
180
+ async handleMessage(chatId: string, text: string, session: any) {
181
+ const ctx = this.ctx;
182
+ const cwd = session.cwd || ctx.defaultCwd;
183
+ console.log(`[${ctx.name}] Codex chat=${chatId.slice(-8)} startFresh=${session.startFresh || false}`);
184
+
185
+ const onTool = (name: string, args: Record<string, any>) => {
186
+ const cmd = args.cmd || args.command || '';
187
+ const justification = args.justification || args.description || '';
188
+ const summary = cmd ? cmd.slice(0, 80) : justification.slice(0, 80);
189
+ ctx.addToolLog(chatId, { name, summary });
190
+ };
191
+
192
+ resetProxyUsage();
193
+ try {
194
+ let effectiveText = text;
195
+ if (session.codexMode === 'plan') {
196
+ effectiveText = `[模式: 先计划后执行] 请先制定一个清晰的计划,等我确认后再执行。用户请求: ${text}`;
197
+ }
198
+
199
+ const isFresh = session.startFresh || !session.codexThreadId;
200
+ let response: string;
201
+ let execServerUsage: { inputTokens: number; outputTokens: number } | null = null;
202
+
203
+ session.startFresh = false;
204
+ await ctx.sendProgress(chatId, '💭 思考中...');
205
+
206
+ // 优先尝试 app-server
207
+ console.error(`[${ctx.name}] DEBUG 进入 app-server 分支, isFresh=${isFresh}, threadId=${session.codexThreadId?.slice(-8)}`);
208
+ let useExecFallback = false;
209
+ try {
210
+ const r = await runViaAppServer(cwd, effectiveText, chatId, session, onTool, isFresh);
211
+ response = r.response;
212
+ execServerUsage = r.usage;
213
+ } catch (appErr: any) {
214
+ const errMsg = appErr.message || '';
215
+ console.error(`[${ctx.name}] app-server 失败: ${errMsg}`);
216
+
217
+ // thread not found → app-server 进程内线程丢了,尝试重新创建
218
+ if (errMsg.includes('thread not found') || errMsg.includes('Thread not found')) {
219
+ try {
220
+ session.codexThreadId = undefined;
221
+ const r2 = await runViaAppServer(cwd, effectiveText, chatId, session, onTool, true);
222
+ response = r2.response;
223
+ execServerUsage = r2.usage;
224
+ console.error(`[${ctx.name}] app-server thread 重建成功`);
225
+ } catch {
226
+ useExecFallback = true;
227
+ }
228
+ } else {
229
+ useExecFallback = true;
230
+ }
231
+ }
232
+
233
+ if (useExecFallback) {
234
+ getAppServerManager().removeClient(chatId);
235
+ if (isFresh || !session.codexThreadId) {
236
+ const r = await spawnCodexExec(cwd, effectiveText, onTool);
237
+ session.codexThreadId = r.threadId;
238
+ response = r.response;
239
+ console.log(`[${ctx.name}] 全新会话 thread=${r.threadId.slice(-8)}`);
240
+ } else {
241
+ const r = await spawnCodexResume(cwd, session.codexThreadId, effectiveText, onTool);
242
+ response = r.response;
243
+ }
244
+ }
245
+ ctx.flushToolLog(chatId);
246
+
247
+ // 优先使用 app-server 返回的 usage,否则从 proxy 获取
248
+ const usage = execServerUsage || getProxyUsage();
249
+ if (usage.inputTokens > 0 || usage.outputTokens > 0) {
250
+ const cost = calculateCost(ctx.activeModel, usage.inputTokens, usage.outputTokens);
251
+ ctx.accumulateStats(session, { ...usage, costUSD: cost });
252
+ await ctx.sendProgress(chatId,
253
+ `输入 ${usage.inputTokens.toLocaleString()} Token\n输出 ${usage.outputTokens.toLocaleString()} Token\n费用 $${cost.toFixed(4)}`);
254
+ }
255
+
256
+ if (response) {
257
+ await ctx.sendFormattedReply(chatId, response);
258
+ }
259
+ else await ctx.reply(chatId, '✅ 已完成');
260
+ ctx.persistSession(chatId, session);
261
+ } catch (e: any) {
262
+ console.error(`[${ctx.name}] Codex 错误: ${e.message}`);
263
+ session.codexThreadId = undefined;
264
+ try {
265
+ const r = await spawnCodexExec(cwd, text, onTool);
266
+ session.codexThreadId = r.threadId;
267
+ if (r.response) {
268
+ await ctx.sendFormattedReply(chatId, r.response);
269
+ }
270
+ } catch (e2: any) {
271
+ await ctx.reply(chatId, `❌ 处理失败: ${e2.message}`);
272
+ }
273
+ }
274
+ }
275
+ }
@@ -0,0 +1,308 @@
1
+ // ================================================================
2
+ // OpenCode Agent Adapter — 实现 SDK AgentAdapter 接口
3
+ // ================================================================
4
+ // 职责:对接 opencode serve HTTP API,将 AgentInput 转换为 AgentOutput
5
+ // 多轮循环:OpenCode serve 的 /message 是单轮 API,每次返回可能含
6
+ // tool_call(服务端已执行),需要客户端发 "继续" 推进直到纯文本响应。
7
+ // ================================================================
8
+
9
+ import type { AgentAdapter, AgentInput, AgentOutput } from '../core/types';
10
+ import { buildAttachmentHint } from '../core/types';
11
+ import { buildSystemPrompt } from '../prompt-builder';
12
+ import { getDataDir } from '../utils/paths';
13
+
14
+ // ================================================================
15
+ // OpenCodeAdapter 上下文
16
+ // ================================================================
17
+
18
+ export interface OpenCodeAdapterContext {
19
+ imModule?: { getCapabilities(): any } | null;
20
+ botName: string;
21
+ /** OpenCode Server URL,默认 http://localhost:4096 */
22
+ serverUrl?: string;
23
+ /** 默认模型 */
24
+ defaultModel?: { providerID: string; modelID: string };
25
+ }
26
+
27
+ // ================================================================
28
+ // OpenCode Server HTTP 客户端
29
+ // ================================================================
30
+
31
+ interface OcMessagePart {
32
+ type: string;
33
+ text?: string;
34
+ tool_call?: { name: string; arguments: Record<string, any> };
35
+ tool_result?: { content: string };
36
+ }
37
+
38
+ interface OcMessage {
39
+ info: { id: string; role: string; model?: string };
40
+ parts: OcMessagePart[];
41
+ }
42
+
43
+ async function ocCreateSession(serverUrl: string, title: string): Promise<string> {
44
+ const ac = new AbortController();
45
+ const timer = setTimeout(() => ac.abort(), 300_000);
46
+ const res = await fetch(`${serverUrl}/session`, {
47
+ method: 'POST',
48
+ headers: { 'Content-Type': 'application/json' },
49
+ body: JSON.stringify({ title }),
50
+ signal: ac.signal,
51
+ }).finally(() => clearTimeout(timer));
52
+ if (!res.ok) throw new Error(`oc create session: ${res.status} ${await res.text()}`);
53
+ const data = await res.json();
54
+ return data.id;
55
+ }
56
+
57
+ async function ocSendPrompt(
58
+ serverUrl: string,
59
+ sessionId: string,
60
+ initialText: string,
61
+ system: string,
62
+ defaultModel: { providerID: string; modelID: string },
63
+ onTool?: (name: string, args: Record<string, any>) => void
64
+ ): Promise<{ response: string; toolCalls: Array<{ name: string; summary: string }> }> {
65
+ const MAX_TURNS = 50;
66
+ const TURN_TIMEOUT = 300_000; // 每轮 5 分钟
67
+ const MAX_DURATION = 600_000; // 总超时 10 分钟
68
+ const startTime = Date.now();
69
+
70
+ let promptText = initialText;
71
+ let accumulatedResponse = '';
72
+ let turn = 0;
73
+ const allToolCalls: Array<{ name: string; summary: string }> = [];
74
+
75
+ while (turn < MAX_TURNS) {
76
+ if (Date.now() - startTime > MAX_DURATION) {
77
+ console.error('[OpenCodeAdapter] 任务总超时 (10min)');
78
+ break;
79
+ }
80
+ turn++;
81
+
82
+ // 构建请求体:首轮带 system prompt,后续轮只带纯文本推进
83
+ const body: any = {
84
+ model: defaultModel,
85
+ parts: [{ type: 'text', text: promptText }],
86
+ };
87
+ if (turn === 1 && system) body.system = system;
88
+
89
+ const ac = new AbortController();
90
+ const timer = setTimeout(() => ac.abort(), TURN_TIMEOUT);
91
+ const res = await fetch(`${serverUrl}/session/${sessionId}/message`, {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/json' },
94
+ body: JSON.stringify(body),
95
+ signal: ac.signal,
96
+ }).finally(() => clearTimeout(timer));
97
+ if (!res.ok) throw new Error(`oc send prompt (turn ${turn}): ${res.status} ${await res.text()}`);
98
+
99
+ const data: OcMessage = await res.json();
100
+ let hasToolCall = false;
101
+ let hasText = false;
102
+
103
+ for (const part of data.parts || []) {
104
+ if (part.type === 'text' && part.text) {
105
+ hasText = true;
106
+ accumulatedResponse += (accumulatedResponse ? '\n' : '') + part.text;
107
+ } else if (part.type === 'tool_call' && part.tool_call) {
108
+ hasToolCall = true;
109
+ const name = part.tool_call.name;
110
+ const args = part.tool_call.arguments || {};
111
+ const summary = args.command || args.cmd || args.file_path || args.query
112
+ || JSON.stringify(args).slice(0, 80);
113
+ allToolCalls.push({ name, summary });
114
+ if (onTool) onTool(name, args);
115
+ console.log(`[OpenCodeAdapter] 🔧 turn ${turn}: ${name} ${summary.slice(0, 60)}`);
116
+ }
117
+ }
118
+
119
+ // 有文本回复 → 任务完成(OpenCode 内部已完成多轮 agent loop)
120
+ if (hasText) {
121
+ console.log(`[OpenCodeAdapter] ✅ 完成于 turn ${turn}/${MAX_TURNS}`);
122
+ break;
123
+ }
124
+
125
+ // 无文本且无 tool_call → 空响应,结束
126
+ if (!hasToolCall) {
127
+ console.log(`[OpenCodeAdapter] ⚠️ 空响应,结束于 turn ${turn}/${MAX_TURNS}`);
128
+ break;
129
+ }
130
+
131
+ // 仅有 tool_call 无文本 → OpenCode 无法自行执行,推进下一轮
132
+ promptText = '继续执行,完成剩余任务';
133
+ }
134
+
135
+ if (turn >= MAX_TURNS) {
136
+ console.warn(`[OpenCodeAdapter] ⚠️ 达到最大轮次 ${MAX_TURNS}`);
137
+ }
138
+
139
+ return { response: accumulatedResponse, toolCalls: allToolCalls };
140
+ }
141
+
142
+ async function ocDeleteSession(serverUrl: string, sessionId: string): Promise<void> {
143
+ await fetch(`${serverUrl}/session/${sessionId}`, { method: 'DELETE' }).catch(() => {});
144
+ }
145
+
146
+ // ================================================================
147
+ // OpenCodeAdapter — 实现 AgentAdapter
148
+ // ================================================================
149
+
150
+ export class OpenCodeAdapter implements AgentAdapter {
151
+ readonly name = 'OpenCode';
152
+ private ctx: OpenCodeAdapterContext;
153
+
154
+ constructor(ctx: OpenCodeAdapterContext) {
155
+ this.ctx = ctx;
156
+ }
157
+
158
+ async handleMessage(input: AgentInput): Promise<AgentOutput> {
159
+ const { text, session, systemPrompt: overrideSystemPrompt } = input;
160
+
161
+ const serverUrl = this.ctx.serverUrl || 'http://localhost:4096';
162
+ const defaultModel = this.ctx.defaultModel || { providerID: 'anthropic', modelID: 'claude-sonnet-4-5' };
163
+ const sessionAny = session as any;
164
+
165
+ let effectiveText = text;
166
+
167
+ // 附件信息注入:让 Agent 知道用户发送了附件(图片/文件/语音)及本地路径
168
+ if (input.attachments && input.attachments.length > 0) {
169
+ effectiveText = buildAttachmentHint(input.attachments) + '\n\n---\n\n' + effectiveText;
170
+ }
171
+
172
+ if (session.codexMode === 'plan') {
173
+ effectiveText = `[模式: 先计划后执行] 请先制定一个清晰的计划,等我确认后再执行。用户请求: ${effectiveText}`;
174
+ }
175
+
176
+ // 清理标记
177
+ const shouldClear = session.startFresh;
178
+ session.startFresh = false;
179
+
180
+ // 获取或创建 OpenCode session
181
+ if (shouldClear || !sessionAny.ocSessionId) {
182
+ if (sessionAny.ocSessionId) {
183
+ await ocDeleteSession(serverUrl, sessionAny.ocSessionId);
184
+ console.log(`[OpenCodeAdapter] 已清除 oc session=${sessionAny.ocSessionId.slice(-8)}`);
185
+ }
186
+ sessionAny.ocSessionId = await ocCreateSession(serverUrl, input.chatId);
187
+ session.metadata.ocSessionId = sessionAny.ocSessionId;
188
+ console.log(`[OpenCodeAdapter] 新建 oc session=${sessionAny.ocSessionId.slice(-8)}`);
189
+ }
190
+
191
+ // 构建系统提示词
192
+ const systemPrompt = overrideSystemPrompt || buildSystemPrompt({
193
+ imModule: this.ctx.imModule || null,
194
+ botName: this.ctx.botName,
195
+ });
196
+ console.log(`[OpenCodeAdapter] 📝 system prompt built (${systemPrompt.length} chars)`);
197
+
198
+ // 发送 prompt(多轮循环:自动推进 tool_call → 纯文本响应)
199
+ const { response, toolCalls } = await ocSendPrompt(
200
+ serverUrl,
201
+ sessionAny.ocSessionId,
202
+ effectiveText,
203
+ systemPrompt,
204
+ defaultModel,
205
+ // onTool 回调:适配器层不依赖外部 ctx,直接记日志
206
+ (name, args) => {
207
+ // 工具调用日志由 Runtime 层统一格式化
208
+ }
209
+ );
210
+
211
+ return {
212
+ text: response || '✅ 已完成',
213
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
214
+ };
215
+ }
216
+
217
+ async healthCheck(): Promise<boolean> {
218
+ try {
219
+ const serverUrl = this.ctx.serverUrl || 'http://localhost:4096';
220
+ const res = await fetch(`${serverUrl}/global/health`);
221
+ return res.ok;
222
+ } catch {
223
+ return false;
224
+ }
225
+ }
226
+ }
227
+
228
+ // ================================================================
229
+ // OpenCode Server 生命周期管理(模块级,单例)
230
+ // ================================================================
231
+ // IMtoAgent 作为多 Agent 网关,在适配器层管理后端服务进程
232
+ // 未来 ClaudeAdapter / CodexAdapter 也可按需实现各自的 start/stop
233
+
234
+ let _ocProcess: ReturnType<typeof Bun.spawn> | null = null;
235
+
236
+ const OC_PORT = 4096;
237
+ const OC_URL = `http://127.0.0.1:${OC_PORT}`;
238
+
239
+ /** 启动 OpenCode serve 进程(幂等:已有运行中的服务则复用) */
240
+ export async function startOpenCodeServer(): Promise<void> {
241
+ // 先检查是否已有服务(可能是外部启动的)
242
+ try {
243
+ const res = await fetch(`${OC_URL}/global/health`, { signal: AbortSignal.timeout(3000) });
244
+ if (res.ok) {
245
+ console.log(`[OpenCodeAdapter] 检测到已有服务运行在 ${OC_URL},复用`);
246
+ return;
247
+ }
248
+ } catch {}
249
+
250
+ console.log('[OpenCodeAdapter] 启动 opencode serve...');
251
+ const child = Bun.spawn(
252
+ ['opencode', 'serve', '--port', String(OC_PORT), '--hostname', '127.0.0.1'],
253
+ {
254
+ cwd: getDataDir(),
255
+ env: {
256
+ ...process.env,
257
+ // 环形通信无需真实 key,但 OpenCode 的 Anthropic provider 要求此变量存在
258
+ ANTHROPIC_API_KEY: 'imtoagent-local',
259
+ },
260
+ stdout: 'pipe',
261
+ stderr: 'pipe',
262
+ },
263
+ );
264
+
265
+ // 后台收集日志
266
+ (async () => {
267
+ for await (const line of (child.stdout as any)) {
268
+ console.log(`[OpenCodeAdapter] ${new TextDecoder().decode(line).trim()}`);
269
+ }
270
+ })().catch(() => {});
271
+ (async () => {
272
+ for await (const line of (child.stderr as any)) {
273
+ console.log(`[OpenCodeAdapter:err] ${new TextDecoder().decode(line).trim()}`);
274
+ }
275
+ })().catch(() => {});
276
+
277
+ // 等待健康检查通过(最多 15 秒)
278
+ const start = Date.now();
279
+ const timeout = 15000;
280
+ while (Date.now() - start < timeout) {
281
+ if (child.exitCode !== undefined && child.exitCode !== null) {
282
+ throw new Error(`OpenCode 进程异常退出,exitCode=${child.exitCode}`);
283
+ }
284
+ try {
285
+ const res = await fetch(`${OC_URL}/global/health`, { signal: AbortSignal.timeout(2000) });
286
+ if (res.ok) {
287
+ _ocProcess = child;
288
+ console.log(`[OpenCodeAdapter] 服务启动成功 (PID=${child.pid}, ${OC_URL})`);
289
+ return;
290
+ }
291
+ } catch {}
292
+ await new Promise(r => setTimeout(r, 500));
293
+ }
294
+
295
+ // 超时
296
+ child.kill('SIGTERM');
297
+ throw new Error(`OpenCode 服务启动超时 (${timeout}ms)`);
298
+ }
299
+
300
+ /** 停止 OpenCode serve 进程 */
301
+ export async function stopOpenCodeServer(): Promise<void> {
302
+ if (_ocProcess) {
303
+ console.log('[OpenCodeAdapter] 停止 OpenCode 服务...');
304
+ _ocProcess.kill('SIGTERM');
305
+ await new Promise(r => setTimeout(r, 1000));
306
+ _ocProcess = null;
307
+ }
308
+ }