@bolloon/bolloon-agent 0.3.28 → 0.3.30

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.
@@ -1852,6 +1852,147 @@ export function registerBuiltinTools(ctx) {
1852
1852
  }
1853
1853
  }
1854
1854
  });
1855
+ // ============================================================
1856
+ // IPFS / IPNS 通用工具 (2026-08-04) — 查询 + 发布给 agent
1857
+ // 依赖本地 Kubo (自动安装/启动), 复用 publish_did 的 checkKuboSetup
1858
+ // ============================================================
1859
+ ctx.tools.set('ipfs_add', {
1860
+ name: 'ipfs_add',
1861
+ description: '上传文本内容到本地 IPFS (Kubo), 返回 CID. 适合把任意内容/笔记/数据发布到去中心化网络, 之后可用 ipfs_cat 读回、ipns_publish 绑定稳定标识. 自动安装/启动本地 Kubo.',
1862
+ parameters: { content: '要上传的内容 (必填)', name: '可选: 文件名/标签' },
1863
+ execute: async (args) => {
1864
+ try {
1865
+ const content = String(args.content ?? '').trim();
1866
+ if (!content)
1867
+ return { success: false, error: 'content 必填' };
1868
+ await ensureKuboReady();
1869
+ const sdk = await import('@diap/sdk');
1870
+ const ipfs = await sdk.IpfsClient.newWithRemoteNode('http://127.0.0.1:5001', 'http://127.0.0.1:8080');
1871
+ const r = await ipfs.upload(content, args.name ? String(args.name) : 'data');
1872
+ return { success: true, output: `✅ 已上传到 IPFS:\n CID: ${r.cid}\n size: ${r.size} bytes\n 读回: ipfs_cat(cid="${r.cid}")\n 绑定稳定标识: ipns_publish(cid="${r.cid}")` };
1873
+ }
1874
+ catch (e) {
1875
+ return { success: false, error: `ipfs_add 失败: ${String(e.message || e).slice(0, 200)}` };
1876
+ }
1877
+ }
1878
+ });
1879
+ ctx.tools.set('ipfs_cat', {
1880
+ name: 'ipfs_cat',
1881
+ description: '按 CID 从本地 IPFS (Kubo) 读取内容. 参数 cid 可以是 ipfs_add 的返回 CID, 也可以是 ipns_resolve 解析出的 CID.',
1882
+ parameters: { cid: 'IPFS CID (必填)' },
1883
+ execute: async (args) => {
1884
+ try {
1885
+ const cid = String(args.cid || '').trim();
1886
+ if (!cid)
1887
+ return { success: false, error: 'cid 必填' };
1888
+ await ensureKuboReady();
1889
+ const text = await kuboApi(`/api/v0/cat?arg=${encodeURIComponent(cid)}`);
1890
+ const s = String(text ?? '');
1891
+ return { success: true, output: `📄 ${cid} (${s.length} 字符):\n${s.slice(0, 4000)}${s.length > 4000 ? '\n...(截断)' : ''}` };
1892
+ }
1893
+ catch (e) {
1894
+ return { success: false, error: `ipfs_cat 失败: ${String(e.message || e).slice(0, 200)}` };
1895
+ }
1896
+ }
1897
+ });
1898
+ ctx.tools.set('ipfs_ls', {
1899
+ name: 'ipfs_ls',
1900
+ description: '列出 IPFS CID 下的目录内容 (Kubo). 适用于 CID 指向目录 (如 ipfs_add 上传带 name 或 DID 文档目录) 时查看子项.',
1901
+ parameters: { cid: 'IPFS CID (必填)' },
1902
+ execute: async (args) => {
1903
+ try {
1904
+ const cid = String(args.cid || '').trim();
1905
+ if (!cid)
1906
+ return { success: false, error: 'cid 必填' };
1907
+ await ensureKuboReady();
1908
+ const r = await kuboApi(`/api/v0/ls?arg=${encodeURIComponent(cid)}`);
1909
+ const objs = r?.Objects || [];
1910
+ const obj = objs[0];
1911
+ const links = obj?.Links || [];
1912
+ if (links.length === 0 && obj?.Type === 2) {
1913
+ return { success: true, output: `📄 ${cid} 是单个文件 (${obj.Size ?? '?'} bytes), 不是目录` };
1914
+ }
1915
+ const lines = links.map((l) => ` ${l.Type === 1 ? '📁' : '📄'} ${l.Name} ${l.Size} bytes ${l.Hash}`);
1916
+ return { success: true, output: `📂 ${cid} (${links.length} 项):\n${lines.join('\n') || ' (空目录)'}` };
1917
+ }
1918
+ catch (e) {
1919
+ return { success: false, error: `ipfs_ls 失败: ${String(e.message || e).slice(0, 200)}` };
1920
+ }
1921
+ }
1922
+ });
1923
+ ctx.tools.set('ipns_publish', {
1924
+ name: 'ipns_publish',
1925
+ description: '把 IPFS CID 发布为 IPNS name (稳定标识, 内容更新后 name 不变). 默认用 self key (agent 身份), 可指定已有 key. 发布后任何节点可用 ipns_resolve 解析该 name 得到 CID.',
1926
+ parameters: { cid: 'IPFS CID (必填, 通常是 ipfs_add 的返回)', keyName: '可选: Kubo key 名 (默认 self)' },
1927
+ execute: async (args) => {
1928
+ try {
1929
+ const cid = String(args.cid || '').trim();
1930
+ if (!cid)
1931
+ return { success: false, error: 'cid 必填' };
1932
+ await ensureKuboReady();
1933
+ const sdk = await import('@diap/sdk');
1934
+ const ipfs = await sdk.IpfsClient.newWithRemoteNode('http://127.0.0.1:5001', 'http://127.0.0.1:8080');
1935
+ const keyName = String(args.keyName || 'self').trim() || 'self';
1936
+ await ipfs.ensureKeyExists(keyName);
1937
+ const r = await ipfs.publishIpns(cid, keyName, '8760h', '1h');
1938
+ return { success: true, output: `✅ IPNS 已发布:\n name: ${r.name}\n value: ${r.value}\n 解析: ipns_resolve(name="${r.name}")\n 公网访问: https://ipfs.io/ipns/${r.name}` };
1939
+ }
1940
+ catch (e) {
1941
+ return { success: false, error: `ipns_publish 失败: ${String(e.message || e).slice(0, 200)}` };
1942
+ }
1943
+ }
1944
+ });
1945
+ ctx.tools.set('ipns_resolve', {
1946
+ name: 'ipns_resolve',
1947
+ description: '解析 IPNS name 得到 IPFS CID (Kubo). name 通常是 ipns_publish 返回的 name 或 k51... 形式的 IPNS 标识, 也可以是完整 /ipns/<name> 路径. 注意: 首次解析需查 DHT, 最长约 60 秒; 同一 name 重发布后本地缓存可能返回旧 CID, 等待传播后重试.',
1948
+ parameters: { name: 'IPNS name (必填, 如 k51qzi5uqu5d... 或 /ipns/k51...)' },
1949
+ execute: async (args) => {
1950
+ try {
1951
+ const name = String(args.name || '').trim();
1952
+ if (!name)
1953
+ return { success: false, error: 'name 必填' };
1954
+ await ensureKuboReady();
1955
+ const r = await kuboApi(`/api/v0/name/resolve?arg=${encodeURIComponent(name)}`, undefined, 60000);
1956
+ const path = typeof r === 'object' && r !== null ? r.Path : String(r);
1957
+ const cid = String(path).replace(/^\/ipfs\//, '').trim();
1958
+ return { success: true, output: `🔗 ${name} → ${path}\n CID: ${cid}` };
1959
+ }
1960
+ catch (e) {
1961
+ return { success: false, error: `ipns_resolve 失败: ${String(e.message || e).slice(0, 200)}` };
1962
+ }
1963
+ }
1964
+ });
1965
+ }
1966
+ // ─── IPFS/IPNS 通用 helper (2026-08-04) ─────────────────────────────────────
1967
+ // 复用 publish_did 的 checkKuboSetup 自动安装/启动本地 Kubo (darwin-arm64 v0.28.0)
1968
+ async function ensureKuboReady() {
1969
+ const sdk = await import('@diap/sdk');
1970
+ const checkKuboSetup = sdk.checkKuboSetup;
1971
+ if (typeof checkKuboSetup === 'function') {
1972
+ const setup = await checkKuboSetup(true, true);
1973
+ if (!setup?.ready || !setup?.daemonRunning) {
1974
+ throw new Error('本地 Kubo 不可用 (自动安装失败), 无法访问 IPFS');
1975
+ }
1976
+ }
1977
+ }
1978
+ async function kuboApi(pathAndQuery, init, timeoutMs = 30000) {
1979
+ const controller = new AbortController();
1980
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1981
+ try {
1982
+ const url = `http://127.0.0.1:5001${pathAndQuery}`;
1983
+ const resp = await fetch(url, { method: 'POST', signal: controller.signal, ...(init || {}) });
1984
+ if (!resp.ok) {
1985
+ const text = await resp.text();
1986
+ throw new Error(`Kubo API ${pathAndQuery.split('?')[0]} 失败: ${resp.status} ${text.slice(0, 200)}`);
1987
+ }
1988
+ const ct = resp.headers.get('content-type') || '';
1989
+ if (ct.includes('application/json'))
1990
+ return resp.json();
1991
+ return resp.text();
1992
+ }
1993
+ finally {
1994
+ clearTimeout(timer);
1995
+ }
1855
1996
  }
1856
1997
  /**
1857
1998
  * 注册 Wallet + Polymarket + Safe 工具 (基于 constraint-runtime/src/tools/).
@@ -191,3 +191,22 @@ export async function promoteCandidate(name, opts = {}, home = os.homedir()) {
191
191
  }
192
192
  return r;
193
193
  }
194
+ export async function writeRunEndSkillCandidates(steps, source, minOk = 2) {
195
+ const okSteps = (steps || []).filter((s) => s.status === 'ok' && s.name && s.name !== 'system' && s.name !== '?');
196
+ if (okSteps.length < minOk) {
197
+ return { wrote: false, reason: `成功工具不足 (${okSteps.length} < ${minOk})` };
198
+ }
199
+ const toolNames = okSteps.map((s) => s.name).slice(0, 5).join(', ');
200
+ const body = `## 背景\n本轮对话连续成功调用了 ${okSteps.length} 个工具: ${toolNames}.\n\n` +
201
+ `## 流程\n${okSteps.map((s) => `1. 调用 ${s.name}${s.output ? ': ' + String(s.output).slice(0, 120) : ''}`).join('\n')}\n\n` +
202
+ `## 注意事项\n- 工具名以 list_skills / get_operation_logs 的实际注册名为准\n- 沉淀为正式 skill 前请人工确认流程可复用\n`;
203
+ const candName = `auto-${okSteps[0].name}-${Date.now().toString(36)}`;
204
+ const file = await writeSkillCandidate({
205
+ name: candName,
206
+ description: `自动候选: ${okSteps.length} 个工具连续成功 (${toolNames})`,
207
+ body,
208
+ source,
209
+ timestamp: new Date().toISOString(),
210
+ });
211
+ return { wrote: true, file, count: okSteps.length, names: toolNames };
212
+ }
@@ -31,6 +31,9 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
31
31
  const { exit } = useApp();
32
32
  const [thinking, setThinking] = useState(false);
33
33
  const thinkingIdx = useRef(0);
34
+ // 双击 Esc 退出当前进程 (500ms 窗口内第二次按下)
35
+ const lastEscRef = useRef(0);
36
+ const C_WARN_ANSI = '\x1b[38;2;245;158;11m'; // #f59e0b
34
37
  // 全局: 思考动画控制
35
38
  useEffect(() => {
36
39
  globalThis.__inkSetThinking = (v) => setThinking(v);
@@ -55,9 +58,29 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
55
58
  onPrompt(trimmed);
56
59
  }, [onPrompt]);
57
60
  useInput((_input, key) => {
58
- if (key.ctrl && _input === 'c')
61
+ // 退出请求: 通知 startCLI resolve → 走清理 → process.exit (带兜底)
62
+ const requestExit = () => {
63
+ globalThis.__inkRequestExit?.();
59
64
  exit();
60
- // TextInput handles actual input; useInput only for Ctrl+C
65
+ // 兜底: 清理路径挂住时 2s 后强制退出
66
+ setTimeout(() => process.exit(0), 2000);
67
+ };
68
+ if (key.ctrl && _input === 'c') {
69
+ requestExit();
70
+ return;
71
+ }
72
+ // 双击 Esc 退出当前进程: 第一击提示, 500ms 内第二击退出
73
+ if (key.escape) {
74
+ const now = Date.now();
75
+ if (now - lastEscRef.current < 500) {
76
+ requestExit();
77
+ }
78
+ else {
79
+ lastEscRef.current = now;
80
+ inkAppendLine(`${C_WARN_ANSI}⚠ 再按一次 Esc 退出当前进程\x1b[0m`);
81
+ }
82
+ }
83
+ // TextInput handles actual input; useInput only for Ctrl+C / Esc
61
84
  });
62
85
  // 自动更新状态栏 (每秒)
63
86
  useEffect(() => {
@@ -78,7 +101,7 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
78
101
  }, 600);
79
102
  return () => clearInterval(timer);
80
103
  }, [thinking]);
81
- return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsxs(Box, { flexGrow: 1, flexDirection: "column", justifyContent: "flex-start", children: [_jsx(LogoBox, { width: terminalW }), _jsx(Messages, { msgs: msgs }), thinking && (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: [KAOMOJI[thinkingIdx.current], " \u601D\u8003\u4E2D..."] }) }))] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: '─'.repeat(terminalW) }) }), _jsx(Box, { children: _jsx(Text, { children: status }) }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: '─'.repeat(terminalW) }) }), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: "green", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: "\u8F93\u5165\u6D88\u606F..." })] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: '─'.repeat(terminalW) }) })] }));
104
+ return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsxs(Box, { flexGrow: 1, flexDirection: "column", justifyContent: "flex-start", children: [_jsx(LogoBox, { width: terminalW }), _jsx(Messages, { msgs: msgs }), thinking && (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: [KAOMOJI[thinkingIdx.current], " \u601D\u8003\u4E2D..."] }) }))] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: '─'.repeat(terminalW) }) }), _jsx(Box, { children: _jsx(Text, { children: status }) }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: '─'.repeat(terminalW) }) }), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: "green", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: "\u8F93\u5165\u6D88\u606F... Esc \u53CC\u51FB\u9000\u51FA \u00B7 /queue \u6392\u961F \u00B7 !\u7EC8\u7AEF\u547D\u4EE4" })] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: '─'.repeat(terminalW) }) })] }));
82
105
  };
83
106
  // ─── 启动 ────────────────────────────────────────────────────────────────────
84
107
  let _inkInstance = null;
package/dist/index.js CHANGED
@@ -422,17 +422,25 @@ async function startCLI(comm) {
422
422
  return `${C_ACCENT}${cliModelName}\x1b[0m\x1b[90m │\x1b[0m ${cliAgentName} \x1b[90m│\x1b[0m ⏱ ${dur}s\x1b[90m │\x1b[0m ${bar} ${cliContextPct}%`;
423
423
  };
424
424
  startInk((text) => { processInput(text, comm); }, initialStatus, getStatus);
425
- // Wait on a promise that resolves on Ctrl+C
426
- await new Promise(() => { });
425
+ // Wait on a promise that resolves on Ctrl+C / 双击 Esc
426
+ // (ink-app requestExit 调 __inkRequestExit → resolve, 清理后 process.exit)
427
+ let cliExitResolve = () => { };
428
+ const exitPromise = new Promise(resolve => { cliExitResolve = resolve; });
429
+ globalThis.__inkRequestExit = () => { cliExitResolve(); };
430
+ await exitPromise;
431
+ delete globalThis.__inkRequestExit;
427
432
  stopInk();
428
433
  appendLine(`\n${CYAN}👋 再见!${RESET}`);
429
434
  comm.stop();
435
+ process.exit(0);
430
436
  }
431
437
  async function processInput(input, comm) {
432
438
  const trimmed = input.trim();
433
439
  // TUI tool call state (local to this invocation)
434
440
  const tuiToolCalls = [];
435
441
  let tuiToolCounter = 0;
442
+ // run-end 经验整理: 收集本轮连续成功的工具 (≥2 个自动写候选, 颜文字加载)
443
+ const runEndOkSteps = [];
436
444
  // each iteration
437
445
  let lastToolEvent = null;
438
446
  // !command — 直接执行终端命令
@@ -489,6 +497,7 @@ async function processInput(input, comm) {
489
497
  appendLine(` ${C_ACCENT}peers${RESET} 查看 P2P 节点`);
490
498
  appendLine(` ${C_ACCENT}iroh${RESET} 查看 iroh 状态`);
491
499
  appendLine(` ${C_ACCENT}add_friend${RESET} 添加好友`);
500
+ appendLine(` ${C_ACCENT}Esc 双击${RESET} 退出当前进程`);
492
501
  appendLine(` ${C_ACCENT}exit${RESET} 退出`);
493
502
  return;
494
503
  }
@@ -576,6 +585,12 @@ async function processInput(input, comm) {
576
585
  error: e.error,
577
586
  durationMs: p ? Date.now() - p._t : undefined,
578
587
  };
588
+ if (e.type === 'step_done') {
589
+ const t = e.tool ?? p?.tool;
590
+ if (t && t !== 'system' && t !== '?') {
591
+ runEndOkSteps.push({ status: 'ok', name: t, output: e.output });
592
+ }
593
+ }
579
594
  appendLine(renderToolCallListItem(doneItem, tuiToolCalls.length + 1, tuiToolCounter));
580
595
  }
581
596
  }
@@ -584,6 +599,20 @@ async function processInput(input, comm) {
584
599
  appendLine(renderAgentMessage(response));
585
600
  // 停止思考动画
586
601
  inkSetThinking(false);
602
+ // 2026-08-04: run-end 经验整理 — 连续成功工具 ≥2 自动写 skill 候选 (颜文字加载)
603
+ if (runEndOkSteps.length >= 2) {
604
+ appendLine(`${C_DIM}(`・ω・´) 整理本轮经验中... ${runEndOkSteps.length} 个工具调用${RESET}`);
605
+ setImmediate(async () => {
606
+ try {
607
+ const { writeRunEndSkillCandidates } = await import('./agents/skill-writer.js');
608
+ const r = await writeRunEndSkillCandidates(runEndOkSteps, 'cli:interactive');
609
+ if (r.wrote) {
610
+ appendLine(`${C_OK}✨ (◕‿◕) 经验候选已写入: ${r.names}${RESET}`);
611
+ }
612
+ }
613
+ catch { /* 非致命, 静默 */ }
614
+ });
615
+ }
587
616
  // 更新状态栏: 上下文进度
588
617
  try {
589
618
  const msgLen = JSON.stringify(a.messageHistory ?? []).length;
package/dist/llm/pi-ai.js CHANGED
@@ -282,15 +282,34 @@ export class PiAIModel {
282
282
  const _t0 = Date.now();
283
283
  for (let attempt = 0; attempt < 3; attempt++) {
284
284
  const _tFetch = Date.now();
285
- const response = await fetch(`${this.getBaseUrl()}/chat/completions`, {
286
- method: 'POST',
287
- headers: {
288
- 'Content-Type': 'application/json',
289
- 'Authorization': `Bearer ${apiKey}`
290
- },
291
- body: JSON.stringify(requestBody),
292
- signal: this.combinedSignal(signal),
293
- });
285
+ let response;
286
+ try {
287
+ response = await fetch(`${this.getBaseUrl()}/chat/completions`, {
288
+ method: 'POST',
289
+ headers: {
290
+ 'Content-Type': 'application/json',
291
+ 'Authorization': `Bearer ${apiKey}`
292
+ },
293
+ body: JSON.stringify(requestBody),
294
+ signal: this.combinedSignal(signal),
295
+ });
296
+ }
297
+ catch (err) {
298
+ // 2026-08-04: 网络层瞬时错误 (undici "terminated" / ECONNRESET / socket hang up / fetch failed 等)
299
+ // 退避重试最多 2 次 — 之前直接抛给 chat() 变成 "[AI 服务调用失败] terminated" 打断 agent 流程.
300
+ // abort (用户主动 / 120s 超时) 不重试, 原样抛出.
301
+ if (err?.name === 'AbortError' || signal?.aborted)
302
+ throw err;
303
+ const netMsg = String(err?.message || err?.cause?.message || '');
304
+ const isNetworkErr = /terminated|ECONNRESET|socket hang up|fetch failed|network|ETIMEDOUT|ECONNREFUSED|UND_ERR/i.test(netMsg);
305
+ if (attempt < 2 && isNetworkErr) {
306
+ const backoff = 1500 * (attempt + 1);
307
+ console.warn(`[pi-ai] 网络错误 attempt ${attempt + 1}/3: ${netMsg.slice(0, 120)}, 退避 ${backoff}ms 重试`);
308
+ await new Promise(resolve => setTimeout(resolve, backoff));
309
+ continue;
310
+ }
311
+ throw err;
312
+ }
294
313
  const _tResp = Date.now();
295
314
  if (!response.ok) {
296
315
  const errBody = await response.text().catch(() => '(no body)');
@@ -54,6 +54,8 @@ const TOOL_WHITELIST = new Set([
54
54
  'mcp_list_tools', 'mcp_tool',
55
55
  // 2026-08-03: DID 发布到 IPFS+IPNS (自动安装 Kubo)
56
56
  'publish_did',
57
+ // 2026-08-04: IPFS/IPNS 通用查询 + 发布 (pi-sdk-tools.ts)
58
+ 'ipfs_add', 'ipfs_cat', 'ipfs_ls', 'ipns_publish', 'ipns_resolve',
57
59
  // 2026-08-02: 远端 channel 工具 (本地智能体 @ 远程交流)
58
60
  'list_remote_channels', 'send_to_remote_channel',
59
61
  ]);
@@ -200,23 +202,7 @@ export function checkOutput(output) {
200
202
  };
201
203
  }
202
204
  // ============================================================
203
- // Gate 7: 链式调用限制 (单轮最多 5 tool, agent 循环)
204
- // ============================================================
205
- const MAX_TOOL_CALLS_PER_TURN = 5;
206
- export function checkChain(ctx) {
207
- const count = ctx.toolCallCountInTurn ?? 0;
208
- if (count >= MAX_TOOL_CALLS_PER_TURN) {
209
- return {
210
- gate: 'chain',
211
- allowed: false,
212
- reason: `单轮已调 ${count} 个 tool (上限 ${MAX_TOOL_CALLS_PER_TURN})`,
213
- evidence: `当前轮 tool 调用次数: ${count}`,
214
- };
215
- }
216
- return { gate: 'chain', allowed: true };
217
- }
218
- // ============================================================
219
- // Gate 8: 黑名单 (复用 PreToolUse hook 已有 6 条规则, 重新实现于此)
205
+ // Gate 7: 黑名单 (复用 PreToolUse hook 已有 6 条规则, 重新实现于此)
220
206
  // ============================================================
221
207
  const DANGEROUS_CMD_PATTERNS = [
222
208
  { re: /\brm\s+(-[a-z]*f[a-z]*\s+)?-[a-z]*r[a-z]*\s+\//, reason: '禁止递归删除根目录' },
@@ -243,7 +229,7 @@ const TOOL_GATES = [
243
229
  checkChannel,
244
230
  checkRate,
245
231
  checkInject,
246
- checkChain, // 链式限制前置 (chain)
232
+ // 2026-08-04: 移除 checkChain 单轮 5 工具上限会打断 agent 长流程 (MCP 多步测试被反复拦), 用户要求去掉
247
233
  checkBlacklist,
248
234
  // checkOutput 不在 tool.execute 前
249
235
  ];
@@ -3740,24 +3740,17 @@ ${goalDesc}
3740
3740
  // 2026-08-02: run-end skill 候选扫描 (fire-and-forget, 不阻塞 finally)
3741
3741
  // 从本轮 lastSteps 提取连续成功的工具调用模式, 写入 ~/.bolloon/skill-candidates/.
3742
3742
  // agent 之后可调 list_skill_candidates / promote_skill 决定是否转正.
3743
+ // 2026-08-04: 抽公共函数 writeRunEndSkillCandidates (Web server 与 CLI 共用)
3743
3744
  try {
3744
3745
  const steps = runState.lastSteps || [];
3745
3746
  const okTools = steps.filter(s => s.status === 'ok' && s.name && s.name !== 'system');
3746
3747
  if (okTools.length >= 2) {
3747
3748
  setImmediate(async () => {
3748
3749
  try {
3749
- const { writeSkillCandidate } = await import('../agents/skill-writer.js');
3750
- const toolNames = okTools.map(s => s.name).slice(0, 5).join(', ');
3751
- const body = `## 背景\n本轮对话连续成功调用了 ${okTools.length} 个工具: ${toolNames}.\n\n## 流程\n${okTools.map(s => `1. 调用 ${s.name}${s.output ? ': ' + String(s.output).slice(0, 120) : ''}`).join('\n')}\n\n## 注意事项\n- 工具名以 list_skills / get_operation_logs 的实际注册名为准\n- 沉淀为正式 skill 前请人工确认流程可复用\n`;
3752
- const candName = `auto-${okTools[0].name}-${Date.now().toString(36)}`;
3753
- const file = await writeSkillCandidate({
3754
- name: candName,
3755
- description: `自动候选: ${okTools.length} 个工具连续成功 (${toolNames})`,
3756
- body,
3757
- source: `channel:${channelId}`,
3758
- timestamp: new Date().toISOString(),
3759
- });
3760
- console.log(`[skill-candidates] 写入候选 ${file} (${okTools.length} tools)`);
3750
+ const { writeRunEndSkillCandidates } = await import('../agents/skill-writer.js');
3751
+ const r = await writeRunEndSkillCandidates(steps, `channel:${channelId}`);
3752
+ if (r.wrote)
3753
+ console.log(`[skill-candidates] 写入候选 ${r.file} (${r.count} tools)`);
3761
3754
  }
3762
3755
  catch (candErr) {
3763
3756
  console.warn('[skill-candidates] 写入失败 (non-fatal):', candErr?.message?.slice(0, 150));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolloon/bolloon-agent",
3
- "version": "0.3.28",
3
+ "version": "0.3.30",
4
4
  "type": "module",
5
5
  "description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
6
6
  "main": "dist/cli-entry.js",