@wu529778790/open-im 1.11.8-beta.2 → 1.11.8-beta.4

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.
@@ -1,10 +1,5 @@
1
- /**
2
- * OpenCode Adapter — run tasks through OpenCode CLI (`opencode run`)
3
- */
4
1
  import type { RunCallbacks, RunHandle, RunOptions, ToolAdapter } from './tool-adapter.interface.js';
5
2
  export declare class OpenCodeAdapter implements ToolAdapter {
6
- private cliPath;
7
3
  readonly toolId = "opencode";
8
- constructor(cliPath: string);
9
4
  run(prompt: string, sessionId: string | undefined, workDir: string, callbacks: RunCallbacks, options?: RunOptions): RunHandle;
10
5
  }
@@ -1,15 +1,8 @@
1
- /**
2
- * OpenCode Adapter — run tasks through OpenCode CLI (`opencode run`)
3
- */
4
- import { runOpenCode } from '../opencode/cli-runner.js';
1
+ import { runOpenCodeSdk } from '../opencode/sdk-runner.js';
5
2
  export class OpenCodeAdapter {
6
- cliPath;
7
3
  toolId = 'opencode';
8
- constructor(cliPath) {
9
- this.cliPath = cliPath;
10
- }
11
4
  run(prompt, sessionId, workDir, callbacks, options) {
12
- return runOpenCode(this.cliPath, prompt, sessionId, workDir, {
5
+ const handle = runOpenCodeSdk(prompt, sessionId, workDir, {
13
6
  onText: callbacks.onText,
14
7
  onThinking: callbacks.onThinking,
15
8
  onToolUse: callbacks.onToolUse,
@@ -41,5 +34,10 @@ export class OpenCodeAdapter {
41
34
  skipPermissions: options?.skipPermissions,
42
35
  model: options?.model,
43
36
  });
37
+ return {
38
+ abort: () => {
39
+ handle.then(h => h.abort());
40
+ },
41
+ };
44
42
  }
45
43
  }
@@ -3,6 +3,7 @@ import { ClaudeSDKAdapter } from './claude-sdk-adapter.js';
3
3
  import { CodexAdapter } from './codex-adapter.js';
4
4
  import { CodeBuddyAdapter } from './codebuddy-adapter.js';
5
5
  import { OpenCodeAdapter } from './opencode-adapter.js';
6
+ import { startOpencode, stopOpencode, isOpencodeRunning } from '../opencode/sdk-manager.js';
6
7
  import { createLogger } from '../logger.js';
7
8
  import { destroyAllLiveChildren } from '../shared/process-kill.js';
8
9
  const log = createLogger('Registry');
@@ -15,7 +16,7 @@ const ADAPTER_FACTORIES = {
15
16
  claude: () => new ClaudeSDKAdapter(),
16
17
  codex: (c) => new CodexAdapter(c.codexCliPath),
17
18
  codebuddy: (c) => new CodeBuddyAdapter(c.codebuddyCliPath),
18
- opencode: (c) => new OpenCodeAdapter(c.opencodeCliPath),
19
+ opencode: () => new OpenCodeAdapter(),
19
20
  };
20
21
  export function initAdapters(config) {
21
22
  adapters.clear();
@@ -27,6 +28,12 @@ export function initAdapters(config) {
27
28
  }
28
29
  log.info(`${aiCommand} adapter enabled`);
29
30
  adapters.set(aiCommand, factory(config));
31
+ // SDK 工具需要懒启动 server,这里先触发预热
32
+ if (aiCommand === 'opencode' && !isOpencodeRunning()) {
33
+ startOpencode().catch((err) => {
34
+ log.warn(`OpenCode SDK server prewarm failed (will retry on first use): ${err}`);
35
+ });
36
+ }
30
37
  }
31
38
  }
32
39
  export function getAdapter(aiCommand) {
@@ -34,6 +41,8 @@ export function getAdapter(aiCommand) {
34
41
  }
35
42
  export function cleanupAdapters() {
36
43
  ClaudeSDKAdapter.destroy();
44
+ // 关闭 opencode SDK server
45
+ stopOpencode();
37
46
  // 强制终止仍在运行的 CLI 子进程(Codex/CodeBuddy),避免僵尸 / 孤儿
38
47
  destroyAllLiveChildren();
39
48
  adapters.clear();
@@ -122,6 +122,17 @@ function startPolling() {
122
122
  const extracted = extractTextContent(msg);
123
123
  if (!extracted)
124
124
  continue;
125
+ // Skip echoed bot messages (iLink API echoes bot's own messages as USER type)
126
+ // 1) Messages sent by this bot have client_id starting with "open-im:"
127
+ if (msg.client_id?.startsWith('open-im:')) {
128
+ log.debug(`Skipping echoed bot message: client_id=${msg.client_id}`);
129
+ continue;
130
+ }
131
+ // 2) Bot lifecycle notifications and tool call notifications echoed back
132
+ if (isBotNotificationEcho(extracted)) {
133
+ log.debug(`Skipping bot notification echo: content="${extracted.substring(0, 80)}"`);
134
+ continue;
135
+ }
125
136
  const chatId = msg.from_user_id ?? '';
126
137
  const msgId = String(msg.message_id ?? msg.seq ?? '');
127
138
  if (!chatId) {
@@ -313,6 +324,23 @@ async function extractImages(msg) {
313
324
  }
314
325
  return paths;
315
326
  }
327
+ /**
328
+ * Check if extracted text is a bot notification being echoed back by the iLink API.
329
+ * Bot notifications have distinctive emoji prefixes that are unlikely in real user messages.
330
+ */
331
+ function isBotNotificationEcho(text) {
332
+ const firstLine = text.split('\n')[0]?.trim() ?? '';
333
+ // Lifecycle/AI info notifications
334
+ if (firstLine.startsWith('🤖 AI:'))
335
+ return true;
336
+ // Service status notifications
337
+ if (firstLine.startsWith('✅ ') || firstLine.startsWith('🔄 ') || firstLine.startsWith('ℹ️ '))
338
+ return true;
339
+ // Tool call notifications (sent by streamUpdate in event-handler)
340
+ if (firstLine.startsWith('⚙️ '))
341
+ return true;
342
+ return false;
343
+ }
316
344
  /**
317
345
  * Extract text content from an iLink message's item_list.
318
346
  * Returns the first text item found, or a placeholder for media types.
@@ -109,7 +109,7 @@ export declare const PAGE_TEXTS: {
109
109
  readonly claudeCli: "Claude CLI path";
110
110
  readonly codexCli: "Codex CLI path";
111
111
  readonly codebuddyCli: "CodeBuddy CLI path";
112
- readonly opencodeCli: "OpenCode CLI path";
112
+ readonly opencodeSdkInfo: "OpenCode uses the built-in SDK integration. No CLI path needed.";
113
113
  readonly codexProxy: "Codex proxy";
114
114
  readonly codexApiKey: "OPENAI_API_KEY";
115
115
  readonly codexApiKeyTip: "Set the OpenAI API key used by Codex. You can also edit the auth file below.";
@@ -320,7 +320,7 @@ export declare const PAGE_TEXTS: {
320
320
  readonly claudeCli: "Claude CLI 路径";
321
321
  readonly codexCli: "Codex CLI 路径";
322
322
  readonly codebuddyCli: "CodeBuddy CLI 路径";
323
- readonly opencodeCli: "OpenCode CLI 路径";
323
+ readonly opencodeSdkInfo: "OpenCode 使用 SDK 集成,无需配置 CLI 路径。";
324
324
  readonly codexProxy: "Codex 代理";
325
325
  readonly codexApiKey: "OPENAI_API_KEY";
326
326
  readonly codexApiKeyTip: "设置 Codex 使用的 OpenAI API Key。也可以在下方编辑 auth 文件。";
@@ -109,7 +109,7 @@ export const PAGE_TEXTS = {
109
109
  claudeCli: "Claude CLI path",
110
110
  codexCli: "Codex CLI path",
111
111
  codebuddyCli: "CodeBuddy CLI path",
112
- opencodeCli: "OpenCode CLI path",
112
+ opencodeSdkInfo: "OpenCode uses the built-in SDK integration. No CLI path needed.",
113
113
  codexProxy: "Codex proxy",
114
114
  codexApiKey: "OPENAI_API_KEY",
115
115
  codexApiKeyTip: "Set the OpenAI API key used by Codex. You can also edit the auth file below.",
@@ -321,7 +321,7 @@ export const PAGE_TEXTS = {
321
321
  claudeCli: "Claude CLI \u8def\u5f84",
322
322
  codexCli: "Codex CLI \u8def\u5f84",
323
323
  codebuddyCli: "CodeBuddy CLI \u8def\u5f84",
324
- opencodeCli: "OpenCode CLI \u8def\u5f84",
324
+ opencodeSdkInfo: "OpenCode \u4f7f\u7528 SDK \u96c6\u6210\uff0c\u65e0\u9700\u914d\u7f6e CLI \u8def\u5f84\u3002",
325
325
  codexProxy: "Codex \u4ee3\u7406",
326
326
  codexApiKey: "OPENAI_API_KEY",
327
327
  codexApiKeyTip: "\u8bbe\u7f6e Codex \u4f7f\u7528\u7684 OpenAI API Key\u3002\u4e5f\u53ef\u4ee5\u5728\u4e0b\u65b9\u7f16\u8f91 auth \u6587\u4ef6\u3002",
@@ -0,0 +1,11 @@
1
+ import type { OpencodeClient } from '@opencode-ai/sdk/v2/client';
2
+ export declare function startOpencode(options?: {
3
+ hostname?: string;
4
+ port?: number;
5
+ signal?: AbortSignal;
6
+ timeout?: number;
7
+ }): Promise<OpencodeClient>;
8
+ export declare function stopOpencode(): void;
9
+ export declare function getOpencodeClient(): OpencodeClient;
10
+ export declare function isOpencodeRunning(): boolean;
11
+ export declare function connectToOpencode(baseUrl?: string): Promise<OpencodeClient>;
@@ -0,0 +1,46 @@
1
+ import { createOpencode, createOpencodeClient } from '@opencode-ai/sdk/v2';
2
+ import { createLogger } from '../logger.js';
3
+ const log = createLogger('OpenCodeSDK');
4
+ let instance = null;
5
+ export async function startOpencode(options) {
6
+ if (instance) {
7
+ log.info('OpenCode SDK already running');
8
+ return instance.client;
9
+ }
10
+ log.info('Starting OpenCode SDK server...');
11
+ const result = await createOpencode({
12
+ hostname: options?.hostname ?? '127.0.0.1',
13
+ port: options?.port ?? 0,
14
+ signal: options?.signal,
15
+ timeout: options?.timeout ?? 15000,
16
+ });
17
+ log.info(`OpenCode server started at ${result.server.url}`);
18
+ instance = result;
19
+ return instance.client;
20
+ }
21
+ export function stopOpencode() {
22
+ if (instance) {
23
+ log.info('Stopping OpenCode SDK server...');
24
+ instance.server.close();
25
+ instance = null;
26
+ log.info('OpenCode SDK server stopped');
27
+ }
28
+ }
29
+ export function getOpencodeClient() {
30
+ if (!instance) {
31
+ throw new Error('OpenCode SDK not started');
32
+ }
33
+ return instance.client;
34
+ }
35
+ export function isOpencodeRunning() {
36
+ return instance !== null;
37
+ }
38
+ export async function connectToOpencode(baseUrl) {
39
+ if (instance)
40
+ return instance.client;
41
+ const url = baseUrl ?? 'http://127.0.0.1:4096';
42
+ log.info(`Connecting to existing OpenCode server at ${url}`);
43
+ const client = createOpencodeClient({ baseUrl: url });
44
+ instance = { client, server: { url, close() { } } };
45
+ return client;
46
+ }
@@ -0,0 +1,27 @@
1
+ export interface OpenCodeSdkRunCallbacks {
2
+ onText: (accumulated: string) => void;
3
+ onThinking?: (accumulated: string) => void;
4
+ onToolUse?: (toolName: string, toolInput?: Record<string, unknown>) => void;
5
+ onComplete: (result: {
6
+ success: boolean;
7
+ result: string;
8
+ accumulated: string;
9
+ cost: number;
10
+ durationMs: number;
11
+ model?: string;
12
+ numTurns: number;
13
+ toolStats: Record<string, number>;
14
+ }) => void;
15
+ onError: (error: string) => void;
16
+ onSessionId?: (sessionId: string) => void;
17
+ onSessionInvalid?: () => void;
18
+ }
19
+ export interface OpenCodeSdkRunOptions {
20
+ skipPermissions?: boolean;
21
+ model?: string;
22
+ }
23
+ export interface OpenCodeSdkRunHandle {
24
+ abort: () => void;
25
+ }
26
+ export declare function runOpenCodeSdk(prompt: string, sessionId: string | undefined, workDir: string, callbacks: OpenCodeSdkRunCallbacks, options?: OpenCodeSdkRunOptions): Promise<OpenCodeSdkRunHandle>;
27
+ export declare function clearSessionCache(workDir?: string): void;
@@ -0,0 +1,201 @@
1
+ import { startOpencode } from './sdk-manager.js';
2
+ import { createLogger } from '../logger.js';
3
+ const log = createLogger('OpenCodeSDKRunner');
4
+ const sessionCache = new Map();
5
+ function parseModel(modelStr) {
6
+ const slashIdx = modelStr.indexOf('/');
7
+ if (slashIdx > 0) {
8
+ return { providerID: modelStr.slice(0, slashIdx), modelID: modelStr.slice(slashIdx + 1) };
9
+ }
10
+ return { providerID: 'anthropic', modelID: modelStr };
11
+ }
12
+ export async function runOpenCodeSdk(prompt, sessionId, workDir, callbacks, options) {
13
+ const client = await startOpencode();
14
+ const abortController = new AbortController();
15
+ const startTime = Date.now();
16
+ let currentSessionId = sessionId;
17
+ if (!currentSessionId) {
18
+ currentSessionId = sessionCache.get(workDir);
19
+ }
20
+ if (!currentSessionId) {
21
+ try {
22
+ const projectName = workDir.split('/').filter(Boolean).pop() || 'project';
23
+ const result = await client.session.create({
24
+ directory: workDir,
25
+ title: `open-im: ${projectName}`,
26
+ });
27
+ currentSessionId = result.data?.id;
28
+ if (!currentSessionId) {
29
+ throw new Error('Session creation returned no ID');
30
+ }
31
+ sessionCache.set(workDir, currentSessionId);
32
+ callbacks.onSessionId?.(currentSessionId);
33
+ log.info(`Created session ${currentSessionId} for ${workDir}`);
34
+ }
35
+ catch (err) {
36
+ const msg = (err instanceof Error ? err.message : String(err));
37
+ log.error('Failed to create session:', msg);
38
+ callbacks.onError(msg);
39
+ return { abort: () => { } };
40
+ }
41
+ }
42
+ else {
43
+ log.info(`Using ${sessionId ? 'provided' : 'cached'} session ${currentSessionId}`);
44
+ }
45
+ let accumulatedText = '';
46
+ let accumulatedThinking = '';
47
+ const toolStats = {};
48
+ let runSettled = false;
49
+ let sseConnected = false;
50
+ // 用于等待 SSE 连接就绪后再发 prompt,避免竞态
51
+ const sseReadyResolve = () => { sseConnected = true; };
52
+ const subscribeEvents = async () => {
53
+ try {
54
+ const sse = await client.global.event({ signal: abortController.signal });
55
+ // SSE 连接已建立,通知主流程可以开始 prompt
56
+ sseReadyResolve();
57
+ log.debug('SSE stream connected');
58
+ for await (const raw of sse.stream) {
59
+ const ev = raw;
60
+ const payload = ev?.payload;
61
+ if (!payload?.properties)
62
+ continue;
63
+ if (payload.properties.sessionID !== currentSessionId)
64
+ continue;
65
+ switch (payload.type) {
66
+ case 'session.next.text.delta': {
67
+ const delta = payload.properties.delta;
68
+ if (delta) {
69
+ accumulatedText += delta;
70
+ callbacks.onText(accumulatedText);
71
+ }
72
+ break;
73
+ }
74
+ case 'session.next.text.ended': {
75
+ // text.ended 携带完整文本,作为 SSE 流的兜底
76
+ const fullText = payload.properties.text;
77
+ if (fullText && !accumulatedText) {
78
+ accumulatedText = fullText;
79
+ callbacks.onText(accumulatedText);
80
+ log.debug(`SSE text.ended fallback: got ${fullText.length} chars`);
81
+ }
82
+ break;
83
+ }
84
+ case 'session.next.reasoning.delta': {
85
+ const delta = payload.properties.delta;
86
+ if (delta) {
87
+ accumulatedThinking += delta;
88
+ callbacks.onThinking?.(accumulatedThinking);
89
+ }
90
+ break;
91
+ }
92
+ case 'session.next.reasoning.ended': {
93
+ // reasoning.ended 携带完整推理文本
94
+ const fullReasoning = payload.properties.text;
95
+ if (fullReasoning && !accumulatedThinking) {
96
+ accumulatedThinking = fullReasoning;
97
+ callbacks.onThinking?.(accumulatedThinking);
98
+ }
99
+ break;
100
+ }
101
+ case 'session.next.tool.called': {
102
+ const tool = payload.properties.tool;
103
+ if (tool) {
104
+ toolStats[tool] = (toolStats[tool] || 0) + 1;
105
+ callbacks.onToolUse?.(tool, payload.properties.input);
106
+ }
107
+ break;
108
+ }
109
+ default:
110
+ log.debug(`SSE unhandled event: ${payload.type}`);
111
+ break;
112
+ }
113
+ }
114
+ }
115
+ catch (err) {
116
+ if (!abortController.signal.aborted) {
117
+ log.debug('SSE stream ended:', err?.message);
118
+ }
119
+ }
120
+ };
121
+ const background = subscribeEvents();
122
+ // 等待 SSE 连接就绪(最多 3 秒),避免 prompt 在 SSE 未连接时就开始
123
+ // 如果 3 秒内 SSE 未连接,仍然继续执行(SSE 可能后续连接并补收事件)
124
+ const sseReady = new Promise((resolve) => {
125
+ const check = setInterval(() => {
126
+ if (sseConnected) {
127
+ clearInterval(check);
128
+ resolve();
129
+ }
130
+ }, 100);
131
+ // 3 秒超时:SSE 可能需要重试连接,不等太久
132
+ setTimeout(() => {
133
+ clearInterval(check);
134
+ if (!sseConnected)
135
+ log.warn('SSE not connected within 3s, proceeding with prompt anyway');
136
+ resolve();
137
+ }, 3000);
138
+ });
139
+ await sseReady;
140
+ try {
141
+ const result = await client.session.prompt({
142
+ sessionID: currentSessionId,
143
+ directory: workDir,
144
+ parts: [{ type: 'text', text: prompt }],
145
+ ...(options?.model ? { model: parseModel(options.model) } : {}),
146
+ });
147
+ abortController.abort();
148
+ await background.catch(() => { });
149
+ runSettled = true;
150
+ const data = result.data;
151
+ const parts = data?.parts ?? [];
152
+ const finalText = parts
153
+ .filter((p) => p.type === 'text' && typeof p.text === 'string')
154
+ .map(p => p.text)
155
+ .join('\n');
156
+ const cost = data?.info?.cost ?? 0;
157
+ log.info(`SDK prompt completed: accumulatedText=${accumulatedText.length}, finalText=${finalText.length}, parts=${parts.length}, cost=${cost}`);
158
+ if (!accumulatedText && !finalText) {
159
+ log.warn(`SDK prompt returned empty output — data keys: ${data ? Object.keys(data).join(',') : 'null'}, parts types: ${parts.map(p => p.type).join(',')}`);
160
+ }
161
+ callbacks.onComplete({
162
+ success: true,
163
+ result: finalText,
164
+ accumulated: accumulatedText || finalText,
165
+ cost,
166
+ durationMs: Date.now() - startTime,
167
+ numTurns: 1,
168
+ toolStats,
169
+ });
170
+ }
171
+ catch (err) {
172
+ abortController.abort();
173
+ await background.catch(() => { });
174
+ if (runSettled)
175
+ return { abort: () => { } };
176
+ const msg = (err instanceof Error ? err.message : String(err));
177
+ log.error('OpenCode SDK error:', msg);
178
+ if (/session\s*(not found|expired|corrupt)|no\s*session/i.test(msg)) {
179
+ sessionCache.delete(workDir);
180
+ callbacks.onSessionInvalid?.();
181
+ }
182
+ runSettled = true;
183
+ callbacks.onError(msg);
184
+ }
185
+ return {
186
+ abort: () => {
187
+ if (!abortController.signal.aborted) {
188
+ abortController.abort();
189
+ client.session.abort({ sessionID: currentSessionId }).catch(() => { });
190
+ }
191
+ },
192
+ };
193
+ }
194
+ export function clearSessionCache(workDir) {
195
+ if (workDir) {
196
+ sessionCache.delete(workDir);
197
+ }
198
+ else {
199
+ sessionCache.clear();
200
+ }
201
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.11.8-beta.2",
3
+ "version": "1.11.8-beta.4",
4
4
  "description": "Your AI coding assistant, in every chat app. Multi-platform IM bridge for Claude Code, Codex, and CodeBuddy.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -55,6 +55,7 @@
55
55
  "@anthropic-ai/sdk": "^0.104.2",
56
56
  "@aws-sdk/client-s3": "^3.1035.0",
57
57
  "@larksuiteoapi/node-sdk": "^1.59.0",
58
+ "@opencode-ai/sdk": "^1.17.9",
58
59
  "@sentry/node": "^10.58.0",
59
60
  "centrifuge": "^5.5.3",
60
61
  "dingtalk-stream": "^2.1.4",