@wu529778790/open-im 1.10.3-beta.0 → 1.10.3-beta.2

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.
@@ -13,6 +13,7 @@ import { existsSync, readFileSync } from 'fs';
13
13
  import { homedir } from 'os';
14
14
  import { join } from 'path';
15
15
  import { createLogger } from '../logger.js';
16
+ import { toReplyPlainText } from '../shared/utils.js';
16
17
  const log = createLogger('ClaudeSDK');
17
18
  function loadUserPluginSettings() {
18
19
  try {
@@ -380,7 +381,7 @@ export class ClaudeSDKAdapter {
380
381
  callbacks.onError(errMsg);
381
382
  return;
382
383
  }
383
- const resultText = m.result ?? '';
384
+ const resultText = toReplyPlainText(m.result ?? '');
384
385
  const result = {
385
386
  success,
386
387
  result: resultText,
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "
3
3
  import { dirname, extname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { APP_HOME } from "./constants.js";
6
+ import { resolveNodeExecutable } from "./node-exec.js";
6
7
  import { isRunning } from "./service-control.js";
7
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
9
  const PID_FILE = join(APP_HOME, "open-im.pid");
@@ -12,15 +13,16 @@ function logError(prefix, err) {
12
13
  process.stderr.write(`[manager-control] ${prefix} ${msg}\n`);
13
14
  }
14
15
  function getManagerEntry() {
16
+ const node = resolveNodeExecutable();
15
17
  const extension = extname(fileURLToPath(import.meta.url));
16
18
  if (extension === ".ts") {
17
19
  return {
18
- command: process.execPath,
20
+ command: node,
19
21
  args: ["--import", "tsx", join(__dirname, "manager.ts")],
20
22
  };
21
23
  }
22
24
  return {
23
- command: process.execPath,
25
+ command: node,
24
26
  args: [join(__dirname, "manager.js")],
25
27
  };
26
28
  }
@@ -95,12 +97,31 @@ export async function startManagerProcess(cwd) {
95
97
  env: process.env,
96
98
  windowsHide: process.platform === "win32",
97
99
  });
98
- child.on("error", (err) => {
100
+ try {
101
+ await new Promise((resolve, reject) => {
102
+ const onSpawn = () => {
103
+ child.off("error", onError);
104
+ resolve();
105
+ };
106
+ const onError = (err) => {
107
+ child.off("spawn", onSpawn);
108
+ reject(err);
109
+ };
110
+ child.once("spawn", onSpawn);
111
+ child.once("error", onError);
112
+ });
113
+ }
114
+ catch (err) {
99
115
  logError("Manager process spawn failed:", err);
100
- });
116
+ const hint = " 若 Node 安装路径已失效,请设置环境变量 OPEN_IM_NODE 指向有效的 node 可执行文件(Windows 一般为 node.exe)。";
117
+ throw new Error(`Failed to start manager process: ${err instanceof Error ? err.message : String(err)}${hint}`);
118
+ }
101
119
  child.unref();
102
- if (!child.pid) {
103
- throw new Error("Failed to start manager process.");
120
+ child.on("error", (err) => {
121
+ logError("Manager process error after spawn:", err);
122
+ });
123
+ if (child.pid === undefined || child.pid === null) {
124
+ throw new Error("Failed to start manager process: no PID after spawn.");
104
125
  }
105
126
  writeManagerPid(child.pid);
106
127
  const deadline = Date.now() + 8000;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * 用于 spawn 子进程的 Node 可执行文件路径。
3
+ * Windows 上偶发 process.execPath 指向已删除/移动的安装(如 D: 盘路径失效),导致 ENOENT;
4
+ * 此时回退到 PATH 中的 `node`,或通过 OPEN_IM_NODE / NODE_EXE 显式指定。
5
+ */
6
+ export declare function resolveNodeExecutable(): string;
@@ -0,0 +1,16 @@
1
+ import { existsSync } from "node:fs";
2
+ /**
3
+ * 用于 spawn 子进程的 Node 可执行文件路径。
4
+ * Windows 上偶发 process.execPath 指向已删除/移动的安装(如 D: 盘路径失效),导致 ENOENT;
5
+ * 此时回退到 PATH 中的 `node`,或通过 OPEN_IM_NODE / NODE_EXE 显式指定。
6
+ */
7
+ export function resolveNodeExecutable() {
8
+ const fromEnv = (process.env.OPEN_IM_NODE ?? process.env.NODE_EXE)?.trim();
9
+ if (fromEnv && existsSync(fromEnv)) {
10
+ return fromEnv;
11
+ }
12
+ if (process.execPath && existsSync(process.execPath)) {
13
+ return process.execPath;
14
+ }
15
+ return "node";
16
+ }
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { dirname, extname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { APP_HOME, SHUTDOWN_PORT } from "./constants.js";
6
+ import { resolveNodeExecutable } from "./node-exec.js";
6
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
8
  const PID_FILE = join(APP_HOME, "open-im-worker.pid");
8
9
  const PORT_FILE = join(APP_HOME, "open-im.port");
@@ -16,15 +17,16 @@ function removePortFile() {
16
17
  }
17
18
  }
18
19
  function getServiceEntry() {
20
+ const node = resolveNodeExecutable();
19
21
  const extension = extname(fileURLToPath(import.meta.url));
20
22
  if (extension === ".ts") {
21
23
  return {
22
- command: process.execPath,
24
+ command: node,
23
25
  args: ["--import", "tsx", join(__dirname, "index.ts")],
24
26
  };
25
27
  }
26
28
  return {
27
- command: process.execPath,
29
+ command: node,
28
30
  args: [join(__dirname, "index.js")],
29
31
  };
30
32
  }
@@ -2,7 +2,7 @@
2
2
  * 共享 AI 任务执行层,支持多 ToolAdapter。
3
3
  */
4
4
  import { resolvePlatformAiCommand } from '../config.js';
5
- import { formatToolStats, formatToolCallNotification, getContextWarning, getAIToolDisplayName, } from './utils.js';
5
+ import { formatToolStats, formatToolCallNotification, getContextWarning, getAIToolDisplayName, toReplyPlainText, } from './utils.js';
6
6
  import { createLogger, emitStructuredEvent } from '../logger.js';
7
7
  import { hashUserId } from '../telemetry/hash-user.js';
8
8
  import { sanitize } from '../sanitize.js';
@@ -171,10 +171,11 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
171
171
  pendingUpdate = null;
172
172
  }
173
173
  const note = buildCompletionNote(result, sessionManager, ctx);
174
- const output = result.accumulated ||
174
+ const raw = result.accumulated ||
175
175
  result.result ||
176
176
  taskState.latestContent ||
177
- '(无输出)';
177
+ '';
178
+ const output = raw ? toReplyPlainText(raw) : '(无输出)';
178
179
  if (!result.accumulated && !result.result && taskState.latestContent) {
179
180
  log.warn(`Empty AI output from adapter but had streamed content (${taskState.latestContent.length} chars), using latestContent. platform=${ctx.platform}, taskKey=${ctx.taskKey}`);
180
181
  }
@@ -287,7 +288,11 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
287
288
  durationMs: 0,
288
289
  errorSnippet: sanitize(String(err).slice(0, 400)),
289
290
  });
290
- platformAdapter.sendError(`内部错误:${err instanceof Error ? err.message : String(err)}`).catch(() => { });
291
+ platformAdapter
292
+ .sendError(`内部错误:${err instanceof Error ? err.message : String(err)}`)
293
+ .catch(() => {
294
+ /* ignore */
295
+ });
291
296
  resolve();
292
297
  }
293
298
  return;
@@ -13,6 +13,11 @@ export declare function handleEnqueueResult(enqueueResult: EnqueueResult, sendTe
13
13
  queueFull?: string;
14
14
  queued?: string;
15
15
  }): Promise<void>;
16
+ /**
17
+ * 将 AI 适配器可能返回的非字符串(如第三方 API 把 result 解析成对象)转为可展示的文本,
18
+ * 避免 IM 侧出现 "[object Object]"。
19
+ */
20
+ export declare function toReplyPlainText(value: unknown): string;
16
21
  /** 转义路径供 Markdown 显示,防止 xxx.yyy.com 被解析为链接 */
17
22
  export declare function escapePathForMarkdown(path: string): string;
18
23
  /** AI 工具显示名称映射(aiCommand -> 用户友好名称) */
@@ -16,6 +16,25 @@ export async function handleEnqueueResult(enqueueResult, sendTextReply, messages
16
16
  await sendTextReply(messages?.queued ?? DEFAULT_QUEUED_MESSAGE);
17
17
  }
18
18
  }
19
+ /**
20
+ * 将 AI 适配器可能返回的非字符串(如第三方 API 把 result 解析成对象)转为可展示的文本,
21
+ * 避免 IM 侧出现 "[object Object]"。
22
+ */
23
+ export function toReplyPlainText(value) {
24
+ if (value == null)
25
+ return '';
26
+ if (typeof value === 'string')
27
+ return value;
28
+ if (typeof value === 'object') {
29
+ try {
30
+ return JSON.stringify(value, null, 2);
31
+ }
32
+ catch {
33
+ return String(value);
34
+ }
35
+ }
36
+ return String(value);
37
+ }
19
38
  /** 转义路径供 Markdown 显示,防止 xxx.yyy.com 被解析为链接 */
20
39
  export function escapePathForMarkdown(path) {
21
40
  return `\`${path.replace(/`/g, '\\`')}\``;
@@ -3,6 +3,9 @@ export declare function initTelemetryUpload(opts: {
3
3
  url?: string;
4
4
  token?: string;
5
5
  }): void;
6
- /** 单行 NDJSON(已含 \\n)。 */
6
+ /**
7
+ * 单行 NDJSON(已含 \\n)。
8
+ * 满 BATCH_MAX_LINES 立即上传;否则自「当前积压周期」起至少间隔 MIN_PARTIAL_FLUSH_INTERVAL_MS 再上传。
9
+ */
7
10
  export declare function enqueueTelemetryLine(line: string): void;
8
11
  export declare function shutdownTelemetryUpload(): Promise<void>;
@@ -1,5 +1,12 @@
1
- const BATCH_MAX_LINES = 50;
2
- const FLUSH_INTERVAL_MS = 4000;
1
+ /**
2
+ * 遥测 NDJSON 上传:
3
+ * - 单次 POST 最多 BATCH_MAX_LINES 条(控制 body 大小);
4
+ * - 事件稀疏时按 MIN_PARTIAL_FLUSH_INTERVAL_MS 合并上报,降低时间维度上的请求频率;
5
+ * - 积压达到 BATCH_MAX_LINES 时仍立即上传(突发流量)。
6
+ */
7
+ const BATCH_MAX_LINES = 100;
8
+ /** 稀疏流量:队列未满批时,最早在「首条入队」后经过该间隔才上传(避免短间隔反复 POST) */
9
+ const MIN_PARTIAL_FLUSH_INTERVAL_MS = 60_000;
3
10
  const MAX_QUEUE = 8000;
4
11
  const MAX_BACKOFF_MS = 120_000;
5
12
  const INITIAL_BACKOFF_MS = 1000;
@@ -23,13 +30,15 @@ function clearBackoffTimer() {
23
30
  backoffTimer = null;
24
31
  }
25
32
  }
26
- function scheduleIdleFlush() {
27
- if (!uploadEnabled || !endpoint || idleTimer || flushing)
33
+ function schedulePartialFlush() {
34
+ if (!uploadEnabled || !endpoint || idleTimer || flushing || backoffTimer)
28
35
  return;
29
36
  idleTimer = setTimeout(() => {
30
37
  idleTimer = null;
31
- void flushPipeline();
32
- }, FLUSH_INTERVAL_MS);
38
+ void flushPipeline().catch(() => {
39
+ /* 静默;退避重试由 flushPipeline/backoff 处理 */
40
+ });
41
+ }, MIN_PARTIAL_FLUSH_INTERVAL_MS);
33
42
  }
34
43
  async function postBatch(lines) {
35
44
  if (!endpoint || lines.length === 0)
@@ -41,8 +50,19 @@ async function postBatch(lines) {
41
50
  };
42
51
  if (bearer)
43
52
  headers.authorization = `Bearer ${bearer}`;
44
- const res = await fetch(endpoint, { method: 'POST', headers, body });
45
- return res.ok;
53
+ try {
54
+ const res = await fetch(endpoint, { method: 'POST', headers, body });
55
+ try {
56
+ await res.text();
57
+ }
58
+ catch {
59
+ /* ignore body read errors */
60
+ }
61
+ return res.ok;
62
+ }
63
+ catch {
64
+ return false;
65
+ }
46
66
  }
47
67
  async function flushPipeline() {
48
68
  if (!uploadEnabled || !endpoint || flushing)
@@ -72,11 +92,11 @@ async function flushPipeline() {
72
92
  }
73
93
  }
74
94
  }
95
+ catch {
96
+ /* 静默:上传失败不得向外抛,避免 unhandledRejection */
97
+ }
75
98
  finally {
76
99
  flushing = false;
77
- if (uploadEnabled && endpoint && queue.length > 0 && !backoffTimer) {
78
- scheduleIdleFlush();
79
- }
80
100
  }
81
101
  }
82
102
  function backoffThenRetry() {
@@ -85,7 +105,7 @@ function backoffThenRetry() {
85
105
  backoffTimer = setTimeout(() => {
86
106
  backoffTimer = null;
87
107
  backoffMs = Math.min(MAX_BACKOFF_MS, backoffMs * 2);
88
- void flushPipeline().finally(resolve);
108
+ void flushPipeline().catch(() => { }).finally(resolve);
89
109
  }, backoffMs);
90
110
  });
91
111
  }
@@ -100,20 +120,27 @@ export function initTelemetryUpload(opts) {
100
120
  queue = [];
101
121
  }
102
122
  }
103
- /** 单行 NDJSON(已含 \\n)。 */
123
+ /**
124
+ * 单行 NDJSON(已含 \\n)。
125
+ * 满 BATCH_MAX_LINES 立即上传;否则自「当前积压周期」起至少间隔 MIN_PARTIAL_FLUSH_INTERVAL_MS 再上传。
126
+ */
104
127
  export function enqueueTelemetryLine(line) {
105
128
  if (!uploadEnabled || !endpoint)
106
129
  return;
107
130
  if (queue.length >= MAX_QUEUE) {
108
131
  queue.splice(0, Math.floor(MAX_QUEUE / 4));
109
132
  }
133
+ const wasEmpty = queue.length === 0;
110
134
  queue.push(line);
111
135
  if (queue.length >= BATCH_MAX_LINES) {
112
136
  clearIdleTimer();
113
- void flushPipeline();
137
+ void flushPipeline().catch(() => {
138
+ /* 静默 */
139
+ });
140
+ return;
114
141
  }
115
- else if (!idleTimer && !flushing && !backoffTimer) {
116
- scheduleIdleFlush();
142
+ if (wasEmpty && !idleTimer && !flushing && !backoffTimer) {
143
+ schedulePartialFlush();
117
144
  }
118
145
  }
119
146
  export async function shutdownTelemetryUpload() {
@@ -144,10 +171,11 @@ export async function shutdownTelemetryUpload() {
144
171
  };
145
172
  if (br)
146
173
  headers.authorization = `Bearer ${br}`;
147
- await fetch(ep, { method: 'POST', headers, body });
174
+ const res = await fetch(ep, { method: 'POST', headers, body });
175
+ await res.text().catch(() => { });
148
176
  }
149
177
  catch {
150
- /* best effort */
178
+ /* best effort,静默 */
151
179
  }
152
180
  }
153
181
  }
@@ -2,6 +2,7 @@
2
2
  * WorkBuddy Message Sender - Send responses to WeChat KF
3
3
  */
4
4
  import { createLogger } from '../logger.js';
5
+ import { toReplyPlainText } from '../shared/utils.js';
5
6
  import { getCentrifugeClient } from './client.js';
6
7
  const log = createLogger('WorkBuddySender');
7
8
  /**
@@ -17,7 +18,7 @@ export async function sendTextReply(_client, chatId, text, msgId) {
17
18
  await client.sendPromptResponse({
18
19
  session_id: chatId,
19
20
  prompt_id: msgId,
20
- content: [{ type: 'text', text }],
21
+ content: [{ type: 'text', text: toReplyPlainText(text) }],
21
22
  stop_reason: 'end_turn',
22
23
  });
23
24
  }
@@ -47,5 +48,5 @@ export function sendStreamingChunk(_client, chatId, text, msgId) {
47
48
  log.warn('WorkBuddy client not available, cannot send chunk');
48
49
  return;
49
50
  }
50
- client.sendMessageChunk(chatId, msgId, { type: 'text', text });
51
+ client.sendMessageChunk(chatId, msgId, { type: 'text', text: toReplyPlainText(text) });
51
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.10.3-beta.0",
3
+ "version": "1.10.3-beta.2",
4
4
  "description": "Multi-platform IM bridge for AI CLI tools (Claude, Codex, CodeBuddy)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",