@iamsamyiok/agents-chat 3.18.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/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @iamsamyiok/agents-chat (npm 包版)
2
+
3
+ 多智能体群聊工具,通过 npm 全局安装后可使用 `agents-chat` 命令启动。
4
+
5
+ > 包名说明:npm 相似名策略禁止发布裸名 `agents-chat`(已存在相似包 `agentschat`),
6
+ > 因此使用官方建议的 scoped 包名 `@iamsamyiok/agents-chat`,安装后的命令仍是 `agents-chat`。
7
+
8
+ ## 安装
9
+
10
+ ```bash
11
+ npm install -g @iamsamyiok/agents-chat
12
+ ```
13
+
14
+ ## 前置要求
15
+
16
+ 本机需已安装任一 AI 执行内核:
17
+
18
+ - **OpenCode** (推荐): `npm install -g opencode-ai`
19
+ - **Claude Code**: `npm install -g @anthropic-ai/claude-code`
20
+ - **Codex CLI**: `npm install -g @openai/codex`
21
+ - **pi**: `npm install -g @earendil-works/pi-coding-agent`
22
+
23
+ ## 快速开始
24
+
25
+ ```bash
26
+ # 启动服务(后台运行,自动打开浏览器)
27
+ agents-chat
28
+
29
+ # 查看状态
30
+ agents-chat status
31
+
32
+ # 停止服务
33
+ agents-chat stop
34
+ ```
35
+
36
+ 默认访问 http://localhost:3456
37
+
38
+ ## 配置
39
+
40
+ 编辑 `~/.agents-chat/.env` 文件进行配置。
41
+
42
+ 详见原始项目文档。
@@ -0,0 +1,487 @@
1
+ // Agent 运行器:多内核适配(OpenCode / Claude Code / Codex / pi 任选其一)
2
+ // - 统一执行协议:非交互 CLI + prompt 经 stdin 传入 + stdout NDJSON 事件流
3
+ // - 各内核只实现三件事:命令行参数构造、事件行解析、检测方式
4
+ // - 内核选择:配置页下拉框(config.kernel,'auto' 为按检测顺序自动)
5
+ // - 未检测到任何内核时显式报错,绝不静默回退演示模式
6
+ // - 仅当 .env 明确设置 AGENTS_CHAT_MOCK=1 才进入演示模式(输出带演示标识)
7
+ // - 每次调用独立进程、全新会话:任务之间上下文不互通
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { execSync, spawn } = require('child_process');
11
+
12
+ const ROOT = path.join(__dirname, '..', '..');
13
+ const DATA_DIR = process.env.AGENTS_CHAT_DATA || path.join(ROOT, '.data');
14
+ const MOCK_SCRIPT = path.join(__dirname, '..', 'mock', 'mock-agent.js');
15
+
16
+ // 模型标识必须形如 provider/model,且只含安全字符(会进入命令行参数)
17
+ const MODEL_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
18
+
19
+ // ---------- 内核注册表 ----------
20
+ // 新增内核只需:在此登记 + 实现 buildKernelArgs/parseKernelEvent 两个分支
21
+ const KERNEL_DEFS = [
22
+ {
23
+ id: 'opencode', label: 'OpenCode', cmd: 'opencode',
24
+ install: 'npm install -g opencode-ai',
25
+ auth: 'opencode auth login(配置模型与密钥)',
26
+ note: '多 Provider,与本项目默认行为一致'
27
+ },
28
+ {
29
+ id: 'claude', label: 'Claude Code', cmd: 'claude',
30
+ install: 'npm install -g @anthropic-ai/claude-code',
31
+ auth: 'claude 首次运行按提示登录(Anthropic 订阅或 ANTHROPIC_API_KEY)',
32
+ note: 'Anthropic 系模型,headless 文档最完善'
33
+ },
34
+ {
35
+ id: 'codex', label: 'Codex CLI', cmd: 'codex',
36
+ install: 'npm install -g @openai/codex',
37
+ auth: 'codex login(ChatGPT 订阅或 OPENAI_API_KEY)',
38
+ note: 'OpenAI 系模型,自带沙箱隔离'
39
+ },
40
+ {
41
+ id: 'pi', label: 'pi', cmd: 'pi',
42
+ install: 'npm install -g @earendil-works/pi-coding-agent',
43
+ auth: 'pi 内运行 /login(各 Provider 密钥写入 ~/.pi/agent/auth.json)',
44
+ note: '多 Provider 极简内核,MIT 开源'
45
+ }
46
+ ];
47
+
48
+ // ---------- CLI 检测(通用 where/which,带 10s 缓存) ----------
49
+ // Windows 下 npm 安装的 CLI 是 .cmd 垫片,Node 18.20+ 禁止直接 spawn,须走 shell
50
+ // 显式路径优先级:AGENTS_CHAT_<ID>_CMD(如 AGENTS_CHAT_OPENCODE_CMD)> PATH 查找
51
+ let detectCache = { ts: 0, map: null };
52
+
53
+ function findCli(def) {
54
+ const custom = process.env[`AGENTS_CHAT_${def.id.toUpperCase()}_CMD`];
55
+ if (custom && fs.existsSync(custom)) {
56
+ return { cmd: custom, shell: /\.(cmd|bat)$/i.test(custom) };
57
+ }
58
+ try {
59
+ const finder = process.platform === 'win32' ? `where ${def.cmd}` : `which ${def.cmd}`;
60
+ const out = execSync(finder, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
61
+ const all = String(out).split(/\r?\n/).map(s => s.trim()).filter(Boolean).filter(p => fs.existsSync(p));
62
+ if (all.length) {
63
+ // Windows 上 where 可能先返回无扩展名的 bash 垫片(存在但无法被 spawn 直接执行,报 ENOENT),
64
+ // 须优先选 .exe/.cmd/.bat 等 Windows 可执行垫片
65
+ const winExe = all.find(p => /\.(exe|cmd|bat|com)$/i.test(p));
66
+ const first = process.platform === 'win32' && winExe ? winExe : all[0];
67
+ return { cmd: first, shell: /\.(cmd|bat)$/i.test(first) };
68
+ }
69
+ } catch { /* 未安装 */ }
70
+ return null;
71
+ }
72
+
73
+ function detectKernels() {
74
+ if (detectCache.map && Date.now() - detectCache.ts < 10000) return detectCache.map;
75
+ const map = {};
76
+ for (const def of KERNEL_DEFS) {
77
+ const found = findCli(def);
78
+ map[def.id] = { id: def.id, label: def.label, ok: !!found, cmd: found ? found.cmd : '', shell: !!(found && found.shell) };
79
+ }
80
+ detectCache = { ts: Date.now(), map };
81
+ return map;
82
+ }
83
+
84
+ // 解析当前生效内核:
85
+ // - demo:.env 显式 AGENTS_CHAT_MOCK=1
86
+ // - <内核id>:配置指定的内核(须已安装)或自动按注册表顺序选第一个已安装的
87
+ // - missing:一个都没装 / 选中的内核未安装(missingKernel 指明缺哪个)
88
+ const storeRef = require('./store');
89
+ function resolveRunner() {
90
+ if (process.env.AGENTS_CHAT_MOCK === '1') {
91
+ return { kind: 'demo', configured: false };
92
+ }
93
+ const detected = detectKernels();
94
+ let pref = '';
95
+ try { pref = String(storeRef.getConfig().kernel || '').trim(); } catch { /* ignore */ }
96
+ if (pref && pref !== 'auto') {
97
+ const def = KERNEL_DEFS.find(k => k.id === pref);
98
+ const d = def && detected[pref];
99
+ if (def && d && d.ok) {
100
+ return { kind: pref, kernel: def, cmd: d.cmd, shell: d.shell, configured: true };
101
+ }
102
+ return { kind: 'missing', configured: false, missingKernel: def || null };
103
+ }
104
+ for (const def of KERNEL_DEFS) {
105
+ const d = detected[def.id];
106
+ if (d && d.ok) return { kind: def.id, kernel: def, cmd: d.cmd, shell: d.shell, configured: true };
107
+ }
108
+ return { kind: 'missing', configured: false, missingKernel: null };
109
+ }
110
+
111
+ function missingHint(runner) {
112
+ if (runner && runner.missingKernel) {
113
+ const k = runner.missingKernel;
114
+ return [
115
+ `未检测到已选择的执行内核 ${k.label}。请先安装并确认命令行可用:`,
116
+ ` ${k.install}`,
117
+ ` ${k.auth}`,
118
+ `安装后重启本程序;也可在 .env 中用 AGENTS_CHAT_${k.id.toUpperCase()}_CMD 指定完整路径。`
119
+ ].join('\n');
120
+ }
121
+ return [
122
+ '未检测到任何可用的智能体内核。请至少安装其中一个:',
123
+ ...KERNEL_DEFS.map(k => ` ${k.label}: ${k.install}(${k.auth})`),
124
+ '安装后重启本程序;也可在 .env 中用 AGENTS_CHAT_<内核>_CMD 指定完整路径。'
125
+ ].join('\n');
126
+ }
127
+
128
+ // ---------- 各内核:命令行参数构造 ----------
129
+ function buildKernelArgs(kernelId, agent) {
130
+ const model = agent.model || process.env.AGENTS_CHAT_MODEL || '';
131
+
132
+ if (kernelId === 'opencode') {
133
+ const args = ['run', '--format', 'json'];
134
+ if (model && MODEL_RE.test(model)) args.push('-m', model);
135
+ // 非交互模式下 opencode 对权限请求默认自动拒绝,会导致无法真实干活;
136
+ // 因此默认加 --auto,.env 可用 AGENTS_CHAT_AUTO_APPROVE=0 关闭
137
+ if (process.env.AGENTS_CHAT_AUTO_APPROVE !== '0') args.push('--auto');
138
+ return args;
139
+ }
140
+
141
+ if (kernelId === 'claude') {
142
+ // headless print 模式;stream-json 事件流需要 --verbose 才包含 assistant 块
143
+ const args = ['-p', '--output-format', 'stream-json', '--verbose', '--dangerously-skip-permissions'];
144
+ // claude 只认纯模型名(如 sonnet / claude-sonnet-4-5),provider/model 取后半
145
+ if (model) args.push('--model', model.split('/').pop());
146
+ return args;
147
+ }
148
+
149
+ if (kernelId === 'codex') {
150
+ // exec 非交互 + JSONL 事件流;末尾 "-" 表示 prompt 从 stdin 读
151
+ const args = ['exec', '--json', '--skip-git-repo-check', '--sandbox', 'workspace-write'];
152
+ if (model) args.push('-m', model.split('/').pop());
153
+ args.push('-');
154
+ return args;
155
+ }
156
+
157
+ if (kernelId === 'pi') {
158
+ // --mode json 输出 JSONL 事件流;--no-session 不落会话文件(每次全新会话)
159
+ // 位置参数只放一句引导语,完整任务经 stdin 传入(避免命令行长度/转义问题)
160
+ const args = ['--mode', 'json', '--no-session', '请完成标准输入(stdin)中给出的任务,任务说明以 stdin 内容为准。'];
161
+ if (model && MODEL_RE.test(model)) args.push('-m', model);
162
+ return args;
163
+ }
164
+
165
+ return null;
166
+ }
167
+
168
+ // ---------- NDJSON 事件 → 文本流 ----------
169
+ // 从工具入参提取操作对象摘要(路径/命令/网址等),让用户知道工具到底动了什么
170
+ function toolTarget(part) {
171
+ const state = (part && part.state) || {};
172
+ const input = state.input || part.input;
173
+ if (!input || typeof input !== 'object') return '';
174
+ const keys = ['filePath', 'path', 'file_path', 'command', 'cmd', 'url', 'pattern', 'query', 'name', 'content'];
175
+ for (const k of keys) {
176
+ const v = input[k];
177
+ if (typeof v === 'string' && v.trim()) return `(${v.slice(0, 60)})`;
178
+ }
179
+ const first = Object.values(input).find(v => typeof v === 'string' && v.trim());
180
+ return first ? `(${first.slice(0, 60)})` : '';
181
+ }
182
+
183
+ function describeTool(part) {
184
+ const name = part && part.tool ? part.tool : 'tool';
185
+ const state = (part && part.state) || {};
186
+ if (state.status === 'error') {
187
+ return `[工具] ${name} 失败:${String(state.error || '').slice(0, 300)}`;
188
+ }
189
+ return `[工具] ${name}${toolTarget(part)} 执行完成`;
190
+ }
191
+
192
+ // ---------- 各内核:单行事件解析(统一映射为 onChunk/errEvents) ----------
193
+ function parseKernelEvent(kernelId, ev, onChunk, errEvents) {
194
+ // ---- OpenCode:text / tool_use / error ----
195
+ if (kernelId === 'opencode') {
196
+ if (ev.type === 'text' && ev.part && typeof ev.part.text === 'string') {
197
+ if (ev.part.text.trim()) onChunk({ content: ev.part.text, done: false });
198
+ return;
199
+ }
200
+ if (ev.type === 'tool_use' && ev.part) {
201
+ onChunk({ content: `${describeTool(ev.part)}\n`, done: false });
202
+ return;
203
+ }
204
+ if (ev.type === 'error') {
205
+ const e = ev.error;
206
+ let msg;
207
+ if (e && e.data && e.data.message) msg = String(e.data.message);
208
+ else if (e && e.name) msg = String(e.name);
209
+ else msg = JSON.stringify(e).slice(0, 500);
210
+ if (msg) errEvents.push(msg);
211
+ }
212
+ return; // step_start / step_finish / reasoning 等事件对聊天界面是噪音,忽略
213
+ }
214
+
215
+ // ---- Claude Code:assistant 内容块 + result 终态 ----
216
+ if (kernelId === 'claude') {
217
+ if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) {
218
+ for (const b of ev.message.content) {
219
+ if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) {
220
+ onChunk({ content: b.text, done: false });
221
+ } else if (b.type === 'tool_use' && b.name) {
222
+ onChunk({ content: `${describeTool({ tool: b.name, state: { input: b.input } })}\n`, done: false });
223
+ }
224
+ }
225
+ return;
226
+ }
227
+ if (ev.type === 'result') {
228
+ if (ev.is_error || (ev.subtype && String(ev.subtype).startsWith('error'))) {
229
+ errEvents.push(String(ev.result || ev.subtype || 'claude 执行出错').slice(0, 1000));
230
+ }
231
+ // subtype=success 时 result 为最终文本,assistant 事件已发过,跳过避免重复
232
+ }
233
+ return; // system/init、user(工具结果回灌)等忽略
234
+ }
235
+
236
+ // ---- Codex:item.completed 携带成品条目 ----
237
+ if (kernelId === 'codex') {
238
+ if (ev.type === 'item.completed' && ev.item) {
239
+ const it = ev.item;
240
+ if (it.type === 'agent_message' && typeof it.text === 'string' && it.text.trim()) {
241
+ onChunk({ content: it.text, done: false });
242
+ } else if (it.type === 'command_execution') {
243
+ onChunk({ content: `${describeTool({ tool: 'bash', state: { input: { command: it.command } } })}\n`, done: false });
244
+ } else if (it.type === 'file_change') {
245
+ const paths = (it.changes || []).map(c => c.path || '').filter(Boolean).join('、');
246
+ onChunk({ content: `${describeTool({ tool: 'edit', state: { input: { path: paths } } })}\n`, done: false });
247
+ } else if (it.type === 'mcp_tool_call') {
248
+ onChunk({ content: `${describeTool({ tool: it.tool || 'mcp', state: { input: it.arguments || it.input } })}\n`, done: false });
249
+ }
250
+ return; // item.started / item.updated 是过程噪音,reasoning / todo_list 忽略
251
+ }
252
+ if (ev.type === 'error' && ev.message) {
253
+ errEvents.push(String(ev.message).slice(0, 1000));
254
+ } else if (ev.type === 'turn.failed' && ev.error) {
255
+ errEvents.push(String(ev.error.message || ev.error).slice(0, 1000));
256
+ }
257
+ return;
258
+ }
259
+
260
+ // ---- pi:message_end 携带最终 assistant 消息(content 块数组) ----
261
+ if (kernelId === 'pi') {
262
+ if (ev.type === 'message_end' && ev.message && ev.message.role === 'assistant') {
263
+ const blocks = Array.isArray(ev.message.content) ? ev.message.content : [];
264
+ for (const b of blocks) {
265
+ if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) {
266
+ onChunk({ content: b.text, done: false });
267
+ } else if (b.type === 'toolCall' && b.name) {
268
+ onChunk({ content: `${describeTool({ tool: b.name, state: { input: b.arguments } })}\n`, done: false });
269
+ }
270
+ }
271
+ if (ev.message.stopReason === 'error') {
272
+ errEvents.push(String(ev.message.errorMessage || 'pi 执行出错').slice(0, 1000));
273
+ }
274
+ }
275
+ return; // session 头 / agent_* / turn_* / tool_execution_* 事件忽略
276
+ }
277
+ }
278
+
279
+ // 单行 NDJSON 事件解析入口
280
+ function handleEventLine(line, kernelId, onChunk, errEvents) {
281
+ let ev;
282
+ try {
283
+ ev = JSON.parse(line);
284
+ } catch {
285
+ return; // 非 JSON 行(崩溃输出等)忽略,退出码会兜底
286
+ }
287
+ if (!ev || !ev.type) return;
288
+ parseKernelEvent(kernelId, ev, onChunk, errEvents);
289
+ }
290
+
291
+ // ---------- 运行中的子进程登记(手动停止 / 退出清理用) ----------
292
+ const activeChildren = new Map(); // child -> scope('chat' | 'tasks')
293
+ function registerChild(child, scope) {
294
+ if (!child) return;
295
+ activeChildren.set(child, scope || 'chat');
296
+ child.on('close', () => activeChildren.delete(child));
297
+ }
298
+
299
+ // 停止某作用域的全部子进程:标记后强杀进程树,close 回调会给出友好提示
300
+ function stopScope(scope) {
301
+ let n = 0;
302
+ for (const [child, s] of activeChildren) {
303
+ if (s !== scope) continue;
304
+ child._stopped = true;
305
+ killTree(child);
306
+ n++;
307
+ }
308
+ return n;
309
+ }
310
+
311
+ function stopAllChildren() {
312
+ let n = 0;
313
+ for (const [child] of activeChildren) { child._stopped = true; killTree(child); n++; }
314
+ return n;
315
+ }
316
+
317
+ // 运行一次 agent,流式回调 onChunk({content, done, error});scope 用于停止定位
318
+ function runAgent(agent, prompt, onChunk, scope) {
319
+ const runner = resolveRunner();
320
+
321
+ if (runner.kind === 'missing') {
322
+ onChunk({ content: '', done: true, error: missingHint(runner) });
323
+ return null;
324
+ }
325
+
326
+ if (runner.kind === 'demo') {
327
+ const env = {
328
+ ...process.env,
329
+ MOCK_BEHAVIOR: agent.behavior || 'echo',
330
+ MOCK_AGENT_ID: agent.id
331
+ };
332
+ return spawnMock([MOCK_SCRIPT, prompt], agent, env, onChunk, scope);
333
+ }
334
+
335
+ const args = buildKernelArgs(runner.kind, agent);
336
+ if (!args) {
337
+ onChunk({ content: '', done: true, error: `内核 ${runner.kind} 暂不支持` });
338
+ return null;
339
+ }
340
+
341
+ // 人设注入:各内核均不支持/不宜命令行传 system prompt,统一拼进 prompt 开头
342
+ const fullPrompt = agent.systemPrompt
343
+ ? `${agent.systemPrompt}\n\n${prompt}`
344
+ : prompt;
345
+
346
+ return spawnKernel(runner, args, fullPrompt, agent, onChunk, scope);
347
+ }
348
+
349
+ // 智能体工作目录(全局统一):
350
+ // - 环境变量 AGENTS_CHAT_CWD 优先(卡牌 workspace 等单次执行场景注入)
351
+ // - 配置了有效 globalCwd → 所有智能体共用该目录(文件资料集中在一处)
352
+ // - 否则默认 <数据目录>/workspace/ 共享目录
353
+ function resolveCwd() {
354
+ const envCwd = String(process.env.AGENTS_CHAT_CWD || '').trim();
355
+ if (envCwd) {
356
+ try { if (fs.existsSync(envCwd) && fs.statSync(envCwd).isDirectory()) return envCwd; } catch { /* ignore */ }
357
+ }
358
+ let g = '';
359
+ try { g = String(storeRef.getConfig().globalCwd || '').trim(); } catch { /* ignore */ }
360
+ if (g) {
361
+ try { if (fs.existsSync(g) && fs.statSync(g).isDirectory()) return g; } catch { /* ignore */ }
362
+ }
363
+ const dir = path.join(DATA_DIR, 'workspace');
364
+ try { fs.mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
365
+ return dir;
366
+ }
367
+
368
+ function spawnKernel(runner, args, prompt, agent, onChunk, scope) {
369
+ const label = runner.kernel ? runner.kernel.label : runner.kind;
370
+ let child;
371
+ try {
372
+ child = spawn(runner.cmd, args, {
373
+ cwd: resolveCwd(),
374
+ stdio: ['pipe', 'pipe', 'pipe'],
375
+ shell: runner.shell,
376
+ windowsHide: true
377
+ });
378
+ } catch (error) {
379
+ onChunk({ content: '', done: true, error: `启动 ${label} 失败:${error.message}` });
380
+ return null;
381
+ }
382
+ registerChild(child, scope);
383
+
384
+ // prompt 全文经 stdin 传入:任意字符都安全
385
+ child.stdin.on('error', () => { /* stdin 已关闭则忽略 */ });
386
+ child.stdin.write(prompt);
387
+ child.stdin.end();
388
+
389
+ let stdoutBuf = '';
390
+ let stderrBuf = '';
391
+ let errEvents = [];
392
+ let killed = false;
393
+
394
+ // 超时保护:默认 10 分钟,超时强杀整个进程树
395
+ const timeoutMs = Number(process.env.AGENTS_CHAT_TIMEOUT_MS) > 0
396
+ ? Number(process.env.AGENTS_CHAT_TIMEOUT_MS)
397
+ : 600000;
398
+ const timer = setTimeout(() => {
399
+ killed = true;
400
+ killTree(child);
401
+ }, timeoutMs);
402
+
403
+ child.stdout.on('data', (data) => {
404
+ stdoutBuf += data.toString();
405
+ let idx;
406
+ while ((idx = stdoutBuf.indexOf('\n')) >= 0) {
407
+ const line = stdoutBuf.slice(0, idx).trim();
408
+ stdoutBuf = stdoutBuf.slice(idx + 1);
409
+ if (!line) continue;
410
+ handleEventLine(line, runner.kind, onChunk, errEvents);
411
+ }
412
+ });
413
+
414
+ child.stderr.on('data', (data) => { stderrBuf += data.toString(); });
415
+
416
+ child.on('error', (error) => {
417
+ clearTimeout(timer);
418
+ onChunk({ content: '', done: true, error: `调用 ${label} 失败:${error.message}` });
419
+ });
420
+
421
+ child.on('close', (code) => {
422
+ clearTimeout(timer);
423
+ // 处理没有换行结尾的残留行
424
+ if (stdoutBuf.trim()) handleEventLine(stdoutBuf.trim(), runner.kind, onChunk, errEvents);
425
+ let error;
426
+ if (child._stopped) {
427
+ error = '已手动停止';
428
+ } else if (killed) {
429
+ error = `执行超时(超过 ${Math.round(timeoutMs / 1000)} 秒已强制终止)。可在 .env 调大 AGENTS_CHAT_TIMEOUT_MS`;
430
+ } else if (errEvents.length > 0) {
431
+ error = errEvents.join('\n').slice(0, 2000);
432
+ } else if (code !== 0) {
433
+ error = (stderrBuf.trim() || `${label} 进程异常退出(退出码 ${code})`).slice(0, 2000);
434
+ }
435
+ onChunk({ content: '', done: true, error });
436
+ });
437
+
438
+ return child;
439
+ }
440
+
441
+ function killTree(child) {
442
+ if (!child || child.exitCode !== null) return;
443
+ try {
444
+ if (process.platform === 'win32') {
445
+ execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'ignore', timeout: 10000 });
446
+ } else {
447
+ child.kill('SIGKILL');
448
+ }
449
+ } catch { /* 进程可能已退出 */ }
450
+ }
451
+
452
+ // ---------- 演示模式 ----------
453
+ function spawnMock(args, agent, env, onChunk, scope) {
454
+ let child;
455
+ try {
456
+ child = spawn(process.execPath, args, {
457
+ cwd: resolveCwd(),
458
+ stdio: ['ignore', 'pipe', 'pipe'],
459
+ env
460
+ });
461
+ } catch (error) {
462
+ onChunk({ content: '', done: true, error: String(error) });
463
+ return null;
464
+ }
465
+ registerChild(child, scope);
466
+
467
+ let stderrBuf = '';
468
+ child.stdout.on('data', (data) => {
469
+ onChunk({ content: data.toString(), done: false });
470
+ });
471
+ child.stderr.on('data', (data) => { stderrBuf += data.toString(); });
472
+ child.on('error', (error) => {
473
+ onChunk({ content: '', done: true, error: error.message });
474
+ });
475
+ child.on('close', (code) => {
476
+ onChunk({
477
+ content: '',
478
+ done: true,
479
+ error: child._stopped
480
+ ? '已手动停止'
481
+ : (code !== 0 && stderrBuf.trim() ? stderrBuf.trim().slice(0, 2000) : undefined)
482
+ });
483
+ });
484
+ return child;
485
+ }
486
+
487
+ module.exports = { runAgent, resolveRunner, detectKernels, KERNEL_DEFS, missingHint, describeTool, stopScope, stopAllChildren, resolveCwd, registerChild };
@@ -0,0 +1,107 @@
1
+ // 辅助小模型客户端:管家记忆整理 / 上下文压缩(任意 OpenAI 兼容接口)
2
+ // 设计原则——绝不抛异常、绝不阻塞主流程:
3
+ // - 未配置/网络失败/超时/响应异常 → 一律返回 {ok:false},调用方走确定性回退
4
+ // - 超时控制(AbortController)+ 失败重试 1 次(网络错误/429/5xx)
5
+ // - Qwen3 等思考型模型兼容:content 为空时回退 reasoning_content,并剥离 <think> 标签
6
+ const AUX_BASE = () => String(process.env.AGENTS_CHAT_AUX_BASE_URL || '').trim().replace(/\/+$/, '');
7
+ const AUX_MODEL = () => String(process.env.AGENTS_CHAT_AUX_MODEL || '').trim();
8
+ const AUX_KEY = () => String(process.env.AGENTS_CHAT_AUX_API_KEY || '').trim();
9
+
10
+ const PLACEHOLDER_RE = /^your-api-key|^changeme$/i;
11
+
12
+ function auxReady() {
13
+ const base = AUX_BASE(), model = AUX_MODEL(), key = AUX_KEY();
14
+ return !!(base && model && key && !PLACEHOLDER_RE.test(key));
15
+ }
16
+
17
+ // 剥离思考型模型的 <think>…</think> 段与首尾空白;未闭合的 <think>(流截断,答案未产出)丢弃其后全部内容
18
+ function stripThinking(text) {
19
+ return String(text || '')
20
+ .replace(/<think>[\s\S]*?<\/think>/gi, '')
21
+ .replace(/^[\s]*<\/?think>[\s]*$/gim, '')
22
+ .replace(/<think>[\s\S]*$/i, '')
23
+ .trim();
24
+ }
25
+
26
+ // 从 OpenAI 兼容响应中取文本(content 空 → 回退 reasoning_content)
27
+ function pickContent(data) {
28
+ const msg = data && data.choices && data.choices[0] && data.choices[0].message;
29
+ if (!msg) return '';
30
+ const c = typeof msg.content === 'string' ? msg.content
31
+ : (Array.isArray(msg.content) && msg.content.map(b => (b && typeof b.text === 'string') ? b.text : '').join('')) || '';
32
+ if (c && c.trim()) return c;
33
+ if (typeof msg.reasoning_content === 'string' && msg.reasoning_content.trim()) return msg.reasoning_content;
34
+ return '';
35
+ }
36
+
37
+ async function auxChatOnce(messages, opts, timeoutMs) {
38
+ const ctrl = new AbortController();
39
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
40
+ try {
41
+ const resp = await fetch(`${AUX_BASE()}/chat/completions`, {
42
+ method: 'POST',
43
+ signal: ctrl.signal,
44
+ headers: {
45
+ 'Content-Type': 'application/json',
46
+ 'Authorization': `Bearer ${AUX_KEY()}`
47
+ },
48
+ body: JSON.stringify({
49
+ model: AUX_MODEL(),
50
+ messages,
51
+ temperature: opts.temperature !== undefined ? opts.temperature : 0.3,
52
+ max_tokens: opts.maxTokens || 600,
53
+ stream: false
54
+ })
55
+ });
56
+ if (!resp.ok) {
57
+ const err = new Error(`HTTP ${resp.status}`);
58
+ err.status = resp.status;
59
+ throw err;
60
+ }
61
+ const data = await resp.json();
62
+ const text = stripThinking(pickContent(data));
63
+ if (!text) return { ok: false, error: '辅助模型返回空内容' };
64
+ return { ok: true, text };
65
+ } finally {
66
+ clearTimeout(timer);
67
+ }
68
+ }
69
+
70
+ // 统一入口:messages=[{role,content}],返回 {ok, text, error}
71
+ // 可重试:网络错误/超时/429/5xx 重试 1 次;4xx(配置错)不重试
72
+ async function auxChat(messages, opts) {
73
+ const o = opts || {};
74
+ if (!auxReady()) return { ok: false, error: '辅助模型未配置' };
75
+ const timeoutMs = o.timeoutMs || 45000;
76
+ let last = null;
77
+ for (let attempt = 0; attempt < 2; attempt++) {
78
+ try {
79
+ return await auxChatOnce(messages, o, timeoutMs);
80
+ } catch (e) {
81
+ last = e;
82
+ const status = e && e.status;
83
+ const retryable = !status || status === 429 || status >= 500;
84
+ if (!retryable) break;
85
+ }
86
+ }
87
+ const msg = last && last.name === 'AbortError' ? `辅助模型超时(${Math.round(timeoutMs / 1000)}s)` : `辅助模型调用失败:${(last && last.message) || last}`;
88
+ return { ok: false, error: msg };
89
+ }
90
+
91
+ // 便捷:单轮指令 → 纯文本
92
+ async function auxTask(instruction, o) {
93
+ return auxChat([{ role: 'user', content: instruction }], o);
94
+ }
95
+
96
+ // 便捷:要求输出 JSON(截取首个 JSON 对象,容错代码块包裹/前后缀文本)
97
+ function parseAuxJSON(text) {
98
+ const raw = String(text || '');
99
+ const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
100
+ const body = fenced ? fenced[1] : raw;
101
+ const start = body.indexOf('{');
102
+ const end = body.lastIndexOf('}');
103
+ if (start < 0 || end <= start) return null;
104
+ try { return JSON.parse(body.slice(start, end + 1)); } catch { return null; }
105
+ }
106
+
107
+ module.exports = { auxReady, auxChat, auxTask, parseAuxJSON, stripThinking };