@bolloon/bolloon-agent 0.2.6 → 0.2.8

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.
@@ -44,6 +44,16 @@ const localManifest = {
44
44
  };
45
45
  export function setLocalManifest(m) {
46
46
  Object.assign(localManifest, m, { publishedAt: Date.now() });
47
+ // 2026-07-05: 显式重置 v2 数组字段 — Partial merge 不会清空未提供的 key,
48
+ // 但调用者通常期望"整片替换", 所以这里强制对齐 (避免测试间泄漏).
49
+ if (!('groups' in m))
50
+ localManifest.groups = [];
51
+ if (!('functions' in m))
52
+ localManifest.functions = [];
53
+ if (!('exportments' in m))
54
+ localManifest.exportments = [];
55
+ if (!('sciences' in m))
56
+ localManifest.sciences = [];
47
57
  return localManifest;
48
58
  }
49
59
  export function getLocalManifest() {
@@ -58,6 +68,48 @@ export function addLocalAgent(agent) {
58
68
  localManifest.publishedAt = Date.now();
59
69
  return localManifest;
60
70
  }
71
+ // ============== 2026-07-05: 4 类资源 setter (groups/function/exportment/science) ==============
72
+ // 默认 localManifest 不带这些字段; 一旦调用就 patch 进去. 不强制覆盖旧值.
73
+ export function addLocalGroup(g) {
74
+ localManifest.groups = localManifest.groups || [];
75
+ const idx = localManifest.groups.findIndex((x) => x.id === g.id);
76
+ if (idx >= 0)
77
+ localManifest.groups[idx] = { ...localManifest.groups[idx], ...g };
78
+ else
79
+ localManifest.groups.push(g);
80
+ localManifest.publishedAt = Date.now();
81
+ return localManifest;
82
+ }
83
+ export function addLocalFunction(f) {
84
+ localManifest.functions = localManifest.functions || [];
85
+ const idx = localManifest.functions.findIndex((x) => x.capability === f.capability);
86
+ if (idx >= 0)
87
+ localManifest.functions[idx] = { ...localManifest.functions[idx], ...f };
88
+ else
89
+ localManifest.functions.push(f);
90
+ localManifest.publishedAt = Date.now();
91
+ return localManifest;
92
+ }
93
+ export function addLocalExportment(e) {
94
+ localManifest.exportments = localManifest.exportments || [];
95
+ const idx = localManifest.exportments.findIndex((x) => x.name === e.name);
96
+ if (idx >= 0)
97
+ localManifest.exportments[idx] = { ...localManifest.exportments[idx], ...e };
98
+ else
99
+ localManifest.exportments.push(e);
100
+ localManifest.publishedAt = Date.now();
101
+ return localManifest;
102
+ }
103
+ export function addLocalScience(s) {
104
+ localManifest.sciences = localManifest.sciences || [];
105
+ const idx = localManifest.sciences.findIndex((x) => x.id === s.id);
106
+ if (idx >= 0)
107
+ localManifest.sciences[idx] = { ...localManifest.sciences[idx], ...s };
108
+ else
109
+ localManifest.sciences.push(s);
110
+ localManifest.publishedAt = Date.now();
111
+ return localManifest;
112
+ }
61
113
  // ============== 远端 manifest 缓存 ==============
62
114
  const remoteManifests = new Map(); // key = ownerPublicKey
63
115
  export function cacheRemoteManifest(m) {
@@ -26,65 +26,137 @@ export function segmentChatReply(reply, ctx) {
26
26
  return [];
27
27
  const segments = [];
28
28
  let remaining = reply;
29
- // === 1. <think>...</think> (内容保留, wrap think segment) ===
29
+ // === 1. 启发式: 开头"让我.../First I'll.../I should..." 句子进 think ===
30
+ // LLM 偶尔不写 <think> 标签但直接出思考. 启发式: 文本首句以这些模式开头 → think
31
+ // 必须在 step 1 (显式 <think> 切分) 之前 — 因为显式切分 push 完会反序
32
+ if (!remaining.startsWith('<think>')) {
33
+ remaining = extractLeadingThinking(remaining, segments);
34
+ }
35
+ // === 2. 切 <think>...</think> (内容保留, wrap 成 think segment) ===
30
36
  remaining = extractAndPush('think', remaining, segments, /<think>([\s\S]*?)<\/think>/g);
31
- // === 2. 切 <environment_details>...</environment_details> ===
37
+ // === 3. 切 <environment_details>...</environment_details> ===
32
38
  remaining = extractAndPush('env_details', remaining, segments, /<environment_details>([\s\S]*?)<\/environment_details>/g);
33
- // === 3. 切 <final gen>...</final gen> 或单 marker ===
34
- // final 标记后面是最终答案. 整个切出, 只留 marker 之前的内容作为 text.
39
+ // === 4. 切 tool_call 标记 (核心: 完全去掉, 不让前端看到) ===
40
+ // 2026-07-01 修: 这步必须在 final 之前 final 标记之前的 tool_call
41
+ // "LLM 在 final 之前还在调工具", 这部分 content 应进 text (中间过程), 不应进 final.
42
+ remaining = stripToolCallMarkers(remaining, segments, ctx);
43
+ // === 5. 过滤 LLM 填充词 ("好了"/"完成"/"可以" 等单句 text 不上屏) ===
44
+ if (remaining.trim()) {
45
+ remaining = filterFillerText(remaining);
46
+ }
47
+ // === 6. final 之前的 text 段 (中间对话) push — 必须在 final push 之前 ===
48
+ // 注意: 这时 remaining 还含 <final gen> 标记 + 标记后内容. 切 final 前先把 <final gen> 之后内容清掉
49
+ // 实际: 切 final 后再切 text 更清楚
35
50
  const finalIdx = remaining.indexOf('<final gen>');
51
+ let beforeFinalText = remaining;
52
+ if (finalIdx >= 0) {
53
+ beforeFinalText = remaining.substring(0, finalIdx);
54
+ }
55
+ const textContent = beforeFinalText.trim();
56
+ if (textContent) {
57
+ segments.push({ type: 'text', content: textContent });
58
+ }
59
+ // === 7. 切 <final gen>...</final gen> (永远最后 push, 渲染时在最后) ===
36
60
  if (finalIdx >= 0) {
37
61
  const afterFinal = remaining.substring(finalIdx + '<final gen>'.length).trim();
38
62
  if (afterFinal) {
39
63
  segments.push({ type: 'final', content: afterFinal });
40
64
  }
41
- remaining = remaining.substring(0, finalIdx).trim();
42
- }
43
- // === 4. 切 tool_call 标记 (核心: 完全去掉, 不让前端看到) ===
44
- // parse-tool-call 知道怎么定位. 我们做类似但收集 segment.
45
- remaining = stripToolCallMarkers(remaining, segments, ctx);
46
- // === 5. 剩余当 text ===
47
- const textContent = remaining.trim();
48
- if (textContent) {
49
- segments.push({ type: 'text', content: textContent });
50
65
  }
51
66
  return segments;
52
67
  }
53
- /** 提取正则匹配的 group(1) 内容, 包装为 segment; 原文位置去掉 */
68
+ /**
69
+ * 修 extractAndPush 的 reverse 删除 bug (2026-07-01)
70
+ * 之前: 用 mutate 后的 next 算切片, idx 错位 → 多 match 时删除错
71
+ * 现在: 用 lastIndex 累加在原文上标记删除区, 最后一次性 slice
72
+ */
54
73
  function extractAndPush(type, text, out, re) {
55
- // 关键是保留顺序. 用 exec 循环 + lastIndex 累加
56
74
  const matches = [];
57
- let m;
58
- // 复制正则避免 lastIndex stateful 污染
59
75
  const localRe = new RegExp(re.source, re.flags);
76
+ let m;
60
77
  while ((m = localRe.exec(text)) !== null) {
78
+ const content = m[1] ? m[1].trim() : '';
61
79
  matches.push({
62
- idx: m.index,
63
- len: m[0].length,
64
- content: m[1].trim(),
80
+ start: m.index,
81
+ end: m.index + m[0].length,
82
+ content,
65
83
  });
66
84
  if (m[0].length === 0)
67
- localRe.lastIndex++; // 防无限 loop
85
+ localRe.lastIndex++;
68
86
  }
69
87
  if (matches.length === 0)
70
88
  return text;
71
- // 反向遍历删除, 同时按原顺序 push
72
- let next = text;
73
- for (let i = matches.length - 1; i >= 0; i--) {
74
- const { idx, len, content } = matches[i];
75
- if (!content)
76
- continue;
77
- next = next.substring(0, idx) + next.substring(idx + len);
78
- // 但 push 是正向, 倒着算 position
79
- }
80
- // 重新正向 push
89
+ // push 正向 (保顺序)
81
90
  for (const m of matches) {
82
91
  if (m.content)
83
92
  out.push({ type, content: m.content });
84
93
  }
94
+ // 删除: 在原文上基于 start/end 切片, 不在 mutate 后算 idx
95
+ let next = '';
96
+ let cursor = 0;
97
+ for (const m of matches) {
98
+ next += text.substring(cursor, m.start);
99
+ cursor = m.end;
100
+ }
101
+ next += text.substring(cursor);
85
102
  return next;
86
103
  }
87
- /** 去掉 tool_call 标记 (XML / JSON / Sentinel 各种形式), 留下纯 text. 识别到的 wrap 成 tool_call segment. */
104
+ /**
105
+ * 启发式: 提取开头的"思考"句子 (2026-07-01 新增)
106
+ * 模式: 文本开头遇到这些起手式, 整句 (到下一个 \n 或 . 或 句号) 切到 think 段
107
+ * 例子:
108
+ * "让我先想想用户的需求" + 正文 → think(让我先想想用户的需求) + 正文
109
+ * "First I'll check the project structure" → think(...)
110
+ * 不命中 → 整段保留, 走 text
111
+ */
112
+ function extractLeadingThinking(text, out) {
113
+ // 模式 1: 显式/隐式思考句首 (一句到第一个 \n 或 \.\?\! 结束)
114
+ // "让我..." / "我先..." / "先来..." / "接下来..." / "First, ..." / "I'll ..."
115
+ // 整句进 think, 后续 content 进 text
116
+ // 模式 2: 整行都是思考 (一行 \n 间隔)
117
+ const firstLineEnd = text.indexOf('\n');
118
+ const firstLine = firstLineEnd === -1 ? text : text.substring(0, firstLineEnd);
119
+ const rest = firstLineEnd === -1 ? '' : text.substring(firstLineEnd + 1);
120
+ // 中文/英文思考起手词
121
+ const thinkingStartRe = /^(让我|我先|我应该|先来|先|接下来|好的[,,]?\s*我|让我先|先看看|让我看看|思考|考虑)/;
122
+ const enThinkingStartRe = /^(Let me|I'll|I will|First,|Next,|Now,|So,|Alright[,.]\s+(?:let me|I'll|I will))/i;
123
+ if (firstLine.trim().length === 0) {
124
+ return text; // 空开头不动
125
+ }
126
+ // 单行启发式: 整行 < 120 字符 + 起始 match → 整行进 think
127
+ if (firstLine.length <= 120 && (thinkingStartRe.test(firstLine) || enThinkingStartRe.test(firstLine))) {
128
+ out.push({ type: 'think', content: firstLine.trim() });
129
+ return rest;
130
+ }
131
+ // 多行启发式: 第一段 (到第一个空行) 是思考
132
+ if (firstLine.length <= 80 && (thinkingStartRe.test(firstLine) || enThinkingStartRe.test(firstLine))) {
133
+ out.push({ type: 'think', content: firstLine.trim() });
134
+ return rest;
135
+ }
136
+ return text;
137
+ }
138
+ /**
139
+ * 过滤 LLM 填充词 (2026-07-01 新增)
140
+ * 单独的"好了"/"完成"/"可以"/"答完了"等单句不显示在气泡
141
+ * 配合 segmentChatReply 步骤 6
142
+ */
143
+ function filterFillerText(text) {
144
+ // 整行 = filler 词 → 整行丢
145
+ const lines = text.split('\n');
146
+ const filtered = [];
147
+ for (const line of lines) {
148
+ const t = line.trim();
149
+ if (!t)
150
+ continue;
151
+ // 匹配: 整行 = 填充词 (可带标点)
152
+ if (/^(好|好了|好的|完成|完成了|任务完成|可以|可以了|答完了|说完了|就这样|完了|done|ok|OK|Okay|okay|alright|fine|let me check|我来)\.?$/i.test(t)) {
153
+ continue;
154
+ }
155
+ filtered.push(line);
156
+ }
157
+ return filtered.join('\n').trim();
158
+ }
159
+ /** 提取正则匹配的 group(1) 内容, 包装为 segment; 原文位置去掉 */
88
160
  function stripToolCallMarkers(text, out, ctx) {
89
161
  // 1. 解析 [TOOL_CALL]...[/TOOL_CALL]
90
162
  let result = text.replace(/\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/g, (m) => {
@@ -149,6 +221,42 @@ function stripToolCallMarkers(text, out, ctx) {
149
221
  });
150
222
  // 7. [Function calling]...[/Function calling] 旧 bolloon
151
223
  result = result.replace(/\[Function[^\]]*\]\s*/g, '');
224
+ // 8. (2026-07-01) 未闭合的 tool_call 起始标签 (LLM 偶输出截断)
225
+ // 例子: "<tool_call><invoke name="shell_exec"><command>ls</command></invoke>" 缺少 </tool_call>
226
+ // 上面 1-7 步的正则都要求完整闭合对, 不闭合会留残文.
227
+ // 修法: 扫到孤立起始标签 (后面没有匹配的闭合) → 整段删到 \n\n 或 end
228
+ result = stripUnclosedToolCallTags(result);
229
+ return result;
230
+ }
231
+ /**
232
+ * 删未闭合的 tool_call 起始标签 + 后面到段结束 (2026-07-01)
233
+ * 例子: "<tool_call><invoke name="shell_exec"><command>ls</command></invoke>"
234
+ * → 检查每个起始标签 (<tool_call>, <invoke, <function_calls, [TOOL_CALL])
235
+ * → 如果没找到对应闭合, 删整段
236
+ */
237
+ function stripUnclosedToolCallTags(text) {
238
+ const startTags = [
239
+ { tag: '<tool_call>', closeTag: '</tool_call>', openRe: /<tool_call>/g },
240
+ { tag: '<invoke ', closeTag: '</invoke>', openRe: /<invoke\s+name=["']([\w]+)["']>/g },
241
+ { tag: '<function_calls>', closeTag: '</function_calls>', openRe: /<function_calls>/g },
242
+ { tag: '[TOOL_CALL]', closeTag: '[/TOOL_CALL]', openRe: /\[TOOL_CALL\]/g },
243
+ ];
244
+ let result = text;
245
+ for (const { tag, closeTag, openRe } of startTags) {
246
+ let m;
247
+ while ((m = openRe.exec(result)) !== null) {
248
+ // 检查 startIdx 之后是否还有 closeTag
249
+ const tail = result.substring(m.index);
250
+ const closeIdx = tail.indexOf(closeTag);
251
+ if (closeIdx === -1) {
252
+ // 未闭合 — 删起始标签 + 之后所有内容 (到 \n\n 或 end)
253
+ const nextBlank = result.indexOf('\n\n', m.index);
254
+ const endIdx = nextBlank === -1 ? result.length : nextBlank;
255
+ result = result.substring(0, m.index) + result.substring(endIdx);
256
+ break; // 重新开始本 tag 扫描
257
+ }
258
+ }
259
+ }
152
260
  return result;
153
261
  }
154
262
  /** 从 <invoke>...</invoke> inner XML 抽 <command> / <args> / <parameter> 简易 args */
@@ -0,0 +1,210 @@
1
+ /**
2
+ * peer-manifest-loader.ts — 智能体相互了解的懒加载触发器
3
+ *
4
+ * 设计目的 (2026-07-05):
5
+ * 远程 P2P 节点的 manifest 不默认进 LLM prompt, 只在需要时拉取.
6
+ * 3 个触发点:
7
+ * ① @-mention 远端 channel → 立即拉对方 manifest + 对应 agent 详细描述
8
+ * ② 本地 agent 连续失败 → 兜底拉对方 manifest (对方可能有解)
9
+ * ③ 关键词触发 → 用户说 "持续协助" / "cooperate" 时加载
10
+ *
11
+ * 加载结果拼成 prompt 附加块 (≤2000 字符), 拼到 system prompt 尾部.
12
+ * 不入持久化 prompt, 不污染主对话.
13
+ */
14
+ import * as fs from 'fs/promises';
15
+ import * as peerFs from '../network/peer-fs.js';
16
+ // ============== 主入口 ==============
17
+ /**
18
+ * 加载对方的 manifest + (可选) 详细 agent 描述, 拼成 prompt 块.
19
+ * 行为:
20
+ * 1) 先看本地 ~/.bolloon/peers/<pk>/capability-index.md 缓存 — 命中且新 (≤1h) → 直接用
21
+ * 2) 否则调 RPC 拉 manifest → 落盘 → 读 capability-index.md
22
+ * 3) 如果 triggerValue 是 agent id 或 capability 名, 再发 RPC 拉详细描述
23
+ */
24
+ export async function loadPeerManifest(ctx, opts) {
25
+ const t0 = Date.now();
26
+ const pk = ctx.remotePublicKey;
27
+ if (!pk || pk === opts.p2p.getPublicKey())
28
+ return null; // 自己
29
+ let rpcTriggered = false;
30
+ let idx = await peerFs.readPeerIndex(pk);
31
+ let capabilityIndex = await peerFs.readCapabilityIndex(pk);
32
+ // 缓存判断: capability-index 存在 + 索引在 TTL 内 → 跳过 RPC
33
+ const ttl = opts.cacheTtlMs ?? 60 * 60 * 1000;
34
+ if (!idx || !capabilityIndex || (idx.updatedAt && (Date.now() - new Date(idx.updatedAt).getTime()) > ttl)) {
35
+ // 缓存过期或不完整, 发 RPC 拉新
36
+ const since = idx?.manifestTs || 0;
37
+ const req = JSON.stringify({
38
+ v: 3, op: 'agent.manifest.exchange',
39
+ payload: { since, fromPublicKey: opts.p2p.getPublicKey() }
40
+ });
41
+ const r = await opts.p2p.sendToWithWait(pk, req, 3000);
42
+ if (r === 'SENT') {
43
+ rpcTriggered = true;
44
+ // 等异步处理落盘 — 这里 sleep 短时间, 不阻塞主线程太久
45
+ await new Promise(r => setTimeout(r, 500));
46
+ idx = await peerFs.readPeerIndex(pk);
47
+ capabilityIndex = await peerFs.readCapabilityIndex(pk);
48
+ }
49
+ }
50
+ if (!idx) {
51
+ return {
52
+ publicKey: pk,
53
+ capabilityIndex: '',
54
+ promptBlock: `[远端 peer ${pk.slice(0, 12)}…] 暂无 manifest 缓存 (对方未响应或本地首次连接).`,
55
+ agentDescriptions: [],
56
+ rpcTriggered,
57
+ durationMs: Date.now() - t0,
58
+ };
59
+ }
60
+ // 按 triggerValue 决定要不要拉详细 agent 描述
61
+ const agentDescriptions = [];
62
+ const lowerVal = ctx.triggerValue.toLowerCase();
63
+ // 1) 如果 triggerValue 看起来是 agent id (有特殊前缀/格式) → 直接拉
64
+ // 2) 如果是 capability 名 (编程/翻译/...) → 在 idx 里找匹配 capability 的 agent → 拉
65
+ const candidates = [];
66
+ if (ctx.triggerValue.match(/^(agent_|agt_|bot_|assist_|task_)/)) {
67
+ candidates.push(ctx.triggerValue);
68
+ }
69
+ else {
70
+ for (const a of idx.agents) {
71
+ if (a.capabilities.some(c => c.toLowerCase().includes(lowerVal))) {
72
+ candidates.push(a.id);
73
+ }
74
+ }
75
+ }
76
+ // 拉详细描述 (限 3 个, 避免 prompt 爆炸)
77
+ for (const agentId of candidates.slice(0, 3)) {
78
+ const localFile = peerFs.getPeerAgentMdPath(pk, agentId);
79
+ let body = '';
80
+ try {
81
+ body = await fs.readFile(localFile, 'utf-8');
82
+ }
83
+ catch { }
84
+ if (!body) {
85
+ // 本地没缓存, 拉 RPC
86
+ const req = JSON.stringify({
87
+ v: 3, op: 'agent.resource.get',
88
+ payload: { agentId, fromPublicKey: opts.p2p.getPublicKey() }
89
+ });
90
+ const r = await opts.p2p.sendToWithWait(pk, req, 3000);
91
+ if (r === 'SENT') {
92
+ rpcTriggered = true;
93
+ // 异步处理, 等 300ms
94
+ await new Promise(r => setTimeout(r, 300));
95
+ try {
96
+ body = await fs.readFile(localFile, 'utf-8');
97
+ }
98
+ catch { }
99
+ }
100
+ }
101
+ if (body)
102
+ agentDescriptions.push({ agentId, body: body.slice(0, 1500) });
103
+ }
104
+ // 拼 prompt 附加块
105
+ const block = buildPromptBlock(ctx, capabilityIndex || '(无索引)', agentDescriptions, idx);
106
+ return {
107
+ publicKey: pk,
108
+ capabilityIndex: capabilityIndex || '',
109
+ promptBlock: block,
110
+ agentDescriptions,
111
+ rpcTriggered,
112
+ durationMs: Date.now() - t0,
113
+ };
114
+ }
115
+ /**
116
+ * 把加载结果拼成可注入 system prompt 的文本块 (≤2000 字)
117
+ */
118
+ function buildPromptBlock(ctx, capabilityIndex, agentDescriptions, idx) {
119
+ const lines = [];
120
+ lines.push(`[远端 peer 临时上下文] ${ctx.reason} → ${ctx.triggerValue}`);
121
+ lines.push(`远端节点: ${idx.ownerName || idx.publicKey.slice(0, 12)}… (${idx.agents.length} agents)`);
122
+ lines.push('');
123
+ lines.push('## 对方能力索引');
124
+ lines.push(capabilityIndex);
125
+ lines.push('');
126
+ if (agentDescriptions.length > 0) {
127
+ lines.push('## 触发相关的 agent 详细描述');
128
+ for (const a of agentDescriptions) {
129
+ lines.push(`### ${a.agentId}`);
130
+ lines.push(a.body);
131
+ lines.push('');
132
+ }
133
+ }
134
+ const text = lines.join('\n').trim();
135
+ return text.length > 2000 ? text.slice(0, 1997) + '…' : text;
136
+ }
137
+ /**
138
+ * 检测 /message 处理后是否需要加载远端 peer manifest.
139
+ * 输入:
140
+ * - text (用户原始输入)
141
+ * - channelId (当前 channel)
142
+ * - remoteChannels (远端 channel 列表 [{ id, name, _ownerPublicKey }])
143
+ * - consecutiveFailures (连续 LLM 失败次数, 可选)
144
+ */
145
+ export function detectLoadTrigger(opts) {
146
+ // 触发 ①: @-mention 远端 channel
147
+ const mentionRe = /@([一-龥A-Za-z0-9_\-]{1,30})/g;
148
+ for (const m of opts.text.matchAll(mentionRe)) {
149
+ const name = m[1];
150
+ const rc = opts.remoteChannels.find(c => c.name === name);
151
+ if (rc && rc._ownerPublicKey) {
152
+ return {
153
+ shouldLoad: true,
154
+ reason: 'mention-remote',
155
+ remotePublicKey: rc._ownerPublicKey,
156
+ triggerValue: name,
157
+ };
158
+ }
159
+ }
160
+ // 触发 ③: 关键词
161
+ const text = opts.text.toLowerCase();
162
+ const keywords = ['持续协助', '一起合作', 'cooperate', '协作', '对方能干', 'remote help', 'find peer'];
163
+ for (const k of keywords) {
164
+ if (text.includes(k.toLowerCase())) {
165
+ // 关键词触发 — 用第一个 known remote channel 的 owner
166
+ if (opts.remoteChannels.length > 0 && opts.remoteChannels[0]._ownerPublicKey) {
167
+ return {
168
+ shouldLoad: true,
169
+ reason: 'cooperate-keyword',
170
+ remotePublicKey: opts.remoteChannels[0]._ownerPublicKey,
171
+ triggerValue: k,
172
+ };
173
+ }
174
+ }
175
+ }
176
+ // 触发 ②: 连续失败 (3+ 次)
177
+ const fails = opts.consecutiveFailures || 0;
178
+ if (fails >= 3) {
179
+ // 用最近一次 @ 的远端 channel; 没有则取 remoteChannels[0]
180
+ const lastMention = [...opts.text.matchAll(mentionRe)].pop();
181
+ let rc = opts.remoteChannels[0];
182
+ if (lastMention) {
183
+ const found = opts.remoteChannels.find(c => c.name === lastMention[1]);
184
+ if (found)
185
+ rc = found;
186
+ }
187
+ if (rc && rc._ownerPublicKey) {
188
+ return {
189
+ shouldLoad: true,
190
+ reason: 'consecutive-failure',
191
+ remotePublicKey: rc._ownerPublicKey,
192
+ triggerValue: rc.name,
193
+ };
194
+ }
195
+ }
196
+ return { shouldLoad: false };
197
+ }
198
+ // ============== 失败计数器 (内存) ==============
199
+ const failureCounters = new Map();
200
+ export function recordFailure(channelId) {
201
+ const cur = (failureCounters.get(channelId) || 0) + 1;
202
+ failureCounters.set(channelId, cur);
203
+ return cur;
204
+ }
205
+ export function clearFailure(channelId) {
206
+ failureCounters.delete(channelId);
207
+ }
208
+ export function getFailures(channelId) {
209
+ return failureCounters.get(channelId) || 0;
210
+ }
@@ -19,7 +19,7 @@ import { getBranchPrefix, getCooldownMs, checkWritePath } from './shell-guard.js
19
19
  import { DiscoveredAgentsManager, createSocialHeartbeat } from '../social/heartbeat.js';
20
20
  import { getGlobalSharedContext } from '../social/global-shared-context.js';
21
21
  import { Session, SkillRegistry, saveSession, loadSession } from '@bolloon/constraint-runtime';
22
- import { loadSkillsFromPaths, defaultSkillPaths, describeSkill } from './skill-loader.js';
22
+ import { loadSkillsFromPaths, defaultSkillPaths } from './skill-loader.js';
23
23
  // Judgment 注入门 (P0): 在主对话 LLM 调起前自动拼入 Top 3 判断力
24
24
  // 失败静默, 不阻塞主对话
25
25
  import { injectJudgmentGate, recordJudgmentUsage } from '../pi-ecosystem-judgment/injection-gate.js';
@@ -411,6 +411,8 @@ class PiAgentSession {
411
411
  promptStartTime = 0;
412
412
  /** 当前 channel id (由 getAgentForChannel / prompt 4 参注入, 供 hook / log 使用) */
413
413
  currentChannelId = '';
414
+ /** 2026-07-04: 当前 agentId (server.ts 通过 createAgentSession 选项注入), 供 onSessionStart 加载 persona docs */
415
+ currentAgentId = '';
414
416
  /** M2.2 (2026-06-17): 当前轮的用户请求 intent, runReActLoop 拼 systemPrompt 时会读这个 */
415
417
  currentIntent = 'chitchat';
416
418
  currentIntentHint = '';
@@ -454,6 +456,8 @@ class PiAgentSession {
454
456
  this.peerId = config.peerId || 'local';
455
457
  this.identity = config.identityDoc || this.createDefaultIdentity();
456
458
  this.minimaxAvailable = this.checkMinimax();
459
+ // 2026-07-04: 透传 agentId (server.ts 通过 createAgentSession 选项注入)
460
+ this.currentAgentId = config.agentId || '';
457
461
  // 2026-06-30: 持久化层可注入 — 测试传 tmpDir, 业务用默认 ~/.bolloon/sessions/cache/
458
462
  this._sessionStore = config.sessionStore ?? defaultSessionStore;
459
463
  this.constraintLayer = new ConstraintLayer();
@@ -597,23 +601,17 @@ class PiAgentSession {
597
601
  * 2. ~/.bolloon/skills/ 全局用户级
598
602
  * 3. <cwd>/.bolloon/skills/ 项目级
599
603
  * 4. ~/.boll/skills/ 全局 (兼容 bollharness 旧用户)
600
- * 5. <bolloon-repo>/src/bollharness/.boll/skills/ bolloon 仓库内置 skill
601
- * (bolloon 项目本身用 pi-sdk 写核心, 19 个 skill 视为项目级 builtin)
604
+ *
605
+ * 2026-07-04: 移除 18bollharness builtin skill (findBolloonBuiltinSkillsPath).
606
+ * 历史遗留: 写 pi-sdk 时为方便演示, 把 bolloon 项目里的 19 个 skill 强制注入到 system prompt.
607
+ * 问题: system prompt 涨到 22K chars, LLM (minimax M3) 在 pivot loop 里反复 think 不输出
608
+ * `<final gen>`, session 落盘拿不到最终回答.
609
+ * 现在: 只让用户放 .bolloon/skills/SKILL.md 才生效, 干净且 project-owned.
602
610
  *
603
611
  * 静默忽略不存在的目录.
604
612
  */
605
613
  loadSkills(paths) {
606
- let resolved;
607
- if (paths && paths.length > 0) {
608
- resolved = paths;
609
- }
610
- else {
611
- resolved = [
612
- ...defaultSkillPaths(os.homedir(), this.cwd),
613
- // bolloon 仓库内置 skill (相对本 npm 包的位置)
614
- this.findBolloonBuiltinSkillsPath(),
615
- ].filter((p) => Boolean(p));
616
- }
614
+ const resolved = (paths && paths.length > 0) ? paths : defaultSkillPaths(os.homedir(), this.cwd);
617
615
  loadSkillsFromPaths(resolved)
618
616
  .then((skills) => {
619
617
  for (const s of skills) {
@@ -622,36 +620,17 @@ class PiAgentSession {
622
620
  }
623
621
  this.skillRegistry.register(s);
624
622
  }
625
- console.log(`[loadSkills] 已加载 ${skills.length} 个 skill: ${skills.map(describeSkill).join(' | ')}`);
623
+ console.log(`[loadSkills] 已加载 ${skills.length} 个 skill from ${resolved.join(', ')}`);
624
+ if (skills.length > 0) {
625
+ for (const s of skills) {
626
+ console.log(` - ${s.name}: ${s.description.substring(0, 100)}${s.description.length > 100 ? '...' : ''}`);
627
+ }
628
+ }
626
629
  })
627
630
  .catch((err) => {
628
631
  console.error('[loadSkills] 加载失败:', err);
629
632
  });
630
633
  }
631
- /**
632
- * 定位 bolloon 仓库内置的 bollharness skill 目录.
633
- * 向上回溯 cwd, 找第一个包含 src/bollharness/.boll/skills 的祖先.
634
- * 找不到时返回 null (例如把 bolloon-agent 作为外部依赖安装时).
635
- */
636
- findBolloonBuiltinSkillsPath() {
637
- let dir = this.cwd;
638
- for (let i = 0; i < 6; i++) {
639
- const candidate = path.join(dir, 'src', 'bollharness', '.boll', 'skills');
640
- try {
641
- if (fsSync.existsSync(candidate) && fsSync.statSync(candidate).isDirectory()) {
642
- return candidate;
643
- }
644
- }
645
- catch {
646
- // 忽略 stat 异常, 继续向上
647
- }
648
- const parent = path.dirname(dir);
649
- if (parent === dir)
650
- break;
651
- dir = parent;
652
- }
653
- return null;
654
- }
655
634
  async initHarness() {
656
635
  try {
657
636
  const { createBollharnessIntegration } = await import('../bollharness-integration/index.js');
@@ -1913,9 +1892,13 @@ class PiAgentSession {
1913
1892
  }
1914
1893
  // Bootstrap SessionStart: 收集项目 Context, 拼到 systemAddition 头部
1915
1894
  // (失败静默, 5s 限流防止循环)
1895
+ // 2026-07-04: 透传 agentId 让 onSessionStart 加载 persona 文档
1916
1896
  let bootstrapAddition = '';
1917
1897
  try {
1918
- const ss = await onSessionStart({ channelId: this.currentChannelId || undefined });
1898
+ const ss = await onSessionStart({
1899
+ channelId: this.currentChannelId || undefined,
1900
+ agentId: this.currentAgentId || undefined,
1901
+ });
1919
1902
  bootstrapAddition = ss.systemAddition || '';
1920
1903
  }
1921
1904
  catch (err) {
@@ -2144,7 +2127,7 @@ ${this.getToolDefinitions()}
2144
2127
  // promptWithPivotLoop 路径 0 step events — UI 显示 timeline 但永远是空
2145
2128
  // 2026-06-17: 透传 signal 让 abort 工作 — loop.execute() 当前不接 signal 参数,
2146
2129
  // 所以 abort 行为通过 this.currentSignal 共享给 loop 内部读 (后续 M3.2 接 task plan 时一起加)
2147
- const result = await loop.execute(input, llm, systemPrompt, this.currentOnStream ?? undefined);
2130
+ const result = await loop.execute(input, llm, systemPrompt, this.currentOnStream ?? undefined, this.currentSignal ?? undefined);
2148
2131
  this.messageHistory.push({ role: 'user', content: input });
2149
2132
  if (result.response) {
2150
2133
  this.messageHistory.push({ role: 'assistant', content: result.response });
@@ -28,12 +28,26 @@ export class SessionStore {
28
28
  get dir() {
29
29
  return this.cacheDir;
30
30
  }
31
- /** 单文件路径 — 暴露出来方便测试和外部读取. */
31
+ /** 单文件路径 — 暴露出来方便测试和外部读取.
32
+ *
33
+ * 2026-07-04 fix: Windows 文件名禁止 `:` (NTFS). web server 用 `channelId:currentSessionId`
34
+ * 拼 sessionKey (server.ts:1759 之类), 会含 `:`. 在 Linux/macOS 上能用, Windows 上
35
+ * fs.writeFile 抛 EINVAL. 修法: filename 层 escape `:` → `__`, key 保持不变
36
+ * (load/save/listKeys/deleteKey 全部透明).
37
+ */
32
38
  pathFor(key) {
33
39
  if (!key || key.includes('/') || key.includes('..')) {
34
40
  throw new Error(`SessionStore: invalid key ${JSON.stringify(key)}`);
35
41
  }
36
- return path.join(this.cacheDir, `${key}.json`);
42
+ return path.join(this.cacheDir, `${SessionStore.filenameEscape(key)}.json`);
43
+ }
44
+ /** 把 session key 转换成跨平台安全的 filename (escape Windows 非法字符). */
45
+ static filenameEscape(key) {
46
+ return key.replace(/:/g, '__');
47
+ }
48
+ /** listKeys 反向: 把 filename 还原成 session key. */
49
+ static filenameUnescape(filenameKey) {
50
+ return filenameKey.replace(/__/g, ':');
37
51
  }
38
52
  /**
39
53
  * 写 history to disk.
@@ -116,7 +130,7 @@ export class SessionStore {
116
130
  const files = await fs.readdir(this.cacheDir);
117
131
  return files
118
132
  .filter(f => f.endsWith('.json') && !f.endsWith('.tmp'))
119
- .map(f => f.slice(0, -'.json'.length))
133
+ .map(f => SessionStore.filenameUnescape(f.slice(0, -'.json'.length)))
120
134
  .sort();
121
135
  }
122
136
  catch {
@@ -13,7 +13,7 @@
13
13
  * "commandAllowlist": ["git", "npm", "tsc", "vitest", "cat", "ls", "..."],
14
14
  * "commandDenylist": ["rm", "mv", "chmod", "sudo", "su", "curl", "wget"],
15
15
  * "pathAllowlist": [
16
- * "src/web/client.js",
16
+ * "src/web/client.ts",
17
17
  * "src/agents/workflow-engine.ts",
18
18
  * "*.md",
19
19
  * "docs/**"
@@ -58,7 +58,7 @@ const FALLBACK_COMMAND_ALLOWLIST = new Set([
58
58
  ]);
59
59
  const FALLBACK_PATH_ALLOWLIST = [
60
60
  // 自由区: AI 可以改
61
- 'src/web/client.js',
61
+ 'src/web/client.ts',
62
62
  'src/web/style.css',
63
63
  'src/agents/workflow-engine.ts',
64
64
  'src/agents/workflow-pivot-loop.ts',