@bolloon/bolloon-agent 0.5.4 → 0.5.5

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.
Files changed (126) hide show
  1. package/dist/agents/agent-identity-store.js +41 -5
  2. package/dist/agents/background-notices.js +124 -0
  3. package/dist/agents/channel-identity.js +54 -0
  4. package/dist/agents/code-write-gate.js +47 -0
  5. package/dist/agents/deny-pipeline.js +11 -2
  6. package/dist/agents/egress-sanitize.js +69 -0
  7. package/dist/agents/error-classifier.js +67 -5
  8. package/dist/agents/experience-review.js +226 -0
  9. package/dist/agents/gateway-group.js +153 -18
  10. package/dist/agents/iteration-budget.js +64 -0
  11. package/dist/agents/lesson-to-skill.js +211 -0
  12. package/dist/agents/managed-services.js +64 -0
  13. package/dist/agents/parse-tool-call.js +38 -32
  14. package/dist/agents/pi-sdk-session-manager.js +72 -3
  15. package/dist/agents/pi-sdk-tools.js +201 -32
  16. package/dist/agents/pi-sdk.js +463 -21
  17. package/dist/agents/plan-store.js +36 -0
  18. package/dist/agents/process-runner.js +64 -2
  19. package/dist/agents/shell-guard.js +109 -13
  20. package/dist/agents/skill-health.js +90 -0
  21. package/dist/agents/skill-ledger.js +129 -0
  22. package/dist/agents/tool-loop-guard.js +303 -0
  23. package/dist/agents/tool-path-args.js +53 -0
  24. package/dist/agents/tool-result-gate.js +50 -0
  25. package/dist/agents/tool-subset.js +109 -0
  26. package/dist/agents/tool-telemetry.js +97 -0
  27. package/dist/bootstrap/context-collector.js +37 -7
  28. package/dist/bootstrap/lifecycle-hooks.js +12 -3
  29. package/dist/bootstrap/persona-init.js +174 -0
  30. package/dist/bootstrap/persona-loader.js +5 -1
  31. package/dist/cli/ink-app.js +153 -6
  32. package/dist/cli/input-paste.js +218 -0
  33. package/dist/cli/loading-tui.js +3 -1
  34. package/dist/cli/log-gate.js +7 -0
  35. package/dist/cli/mention-data.js +30 -0
  36. package/dist/cli/reasoning-view.js +103 -0
  37. package/dist/cli/startup-notice.js +25 -0
  38. package/dist/cli/status-segments.js +87 -2
  39. package/dist/cli/trace-line.js +54 -0
  40. package/dist/context-compaction/auto-compact.js +37 -2
  41. package/dist/electron-build/agents/agent-identity-store.js +43 -5
  42. package/dist/electron-build/agents/agent-identity-store.js.map +1 -1
  43. package/dist/electron-build/agents/background-notices.js +138 -0
  44. package/dist/electron-build/agents/background-notices.js.map +1 -0
  45. package/dist/electron-build/agents/channel-identity.js +64 -0
  46. package/dist/electron-build/agents/channel-identity.js.map +1 -0
  47. package/dist/electron-build/agents/code-write-gate.js +55 -0
  48. package/dist/electron-build/agents/code-write-gate.js.map +1 -0
  49. package/dist/electron-build/agents/deny-pipeline.js +11 -2
  50. package/dist/electron-build/agents/deny-pipeline.js.map +1 -1
  51. package/dist/electron-build/agents/egress-sanitize.js +76 -0
  52. package/dist/electron-build/agents/egress-sanitize.js.map +1 -0
  53. package/dist/electron-build/agents/error-classifier.js +68 -5
  54. package/dist/electron-build/agents/error-classifier.js.map +1 -1
  55. package/dist/electron-build/agents/experience-review.js +243 -0
  56. package/dist/electron-build/agents/experience-review.js.map +1 -0
  57. package/dist/electron-build/agents/iteration-budget.js +73 -0
  58. package/dist/electron-build/agents/iteration-budget.js.map +1 -0
  59. package/dist/electron-build/agents/lesson-to-skill.js +228 -0
  60. package/dist/electron-build/agents/lesson-to-skill.js.map +1 -0
  61. package/dist/electron-build/agents/managed-services.js +69 -0
  62. package/dist/electron-build/agents/managed-services.js.map +1 -0
  63. package/dist/electron-build/agents/parse-tool-call.js +38 -32
  64. package/dist/electron-build/agents/parse-tool-call.js.map +1 -1
  65. package/dist/electron-build/agents/pi-sdk-session-manager.js +75 -3
  66. package/dist/electron-build/agents/pi-sdk-session-manager.js.map +1 -1
  67. package/dist/electron-build/agents/pi-sdk-tools.js +200 -31
  68. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  69. package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
  70. package/dist/electron-build/agents/pi-sdk.js +464 -21
  71. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  72. package/dist/electron-build/agents/plan-store.js +37 -0
  73. package/dist/electron-build/agents/plan-store.js.map +1 -1
  74. package/dist/electron-build/agents/process-runner.js +71 -2
  75. package/dist/electron-build/agents/process-runner.js.map +1 -1
  76. package/dist/electron-build/agents/shell-guard.js +110 -14
  77. package/dist/electron-build/agents/shell-guard.js.map +1 -1
  78. package/dist/electron-build/agents/skill-health.js +103 -0
  79. package/dist/electron-build/agents/skill-health.js.map +1 -0
  80. package/dist/electron-build/agents/skill-ledger.js +146 -0
  81. package/dist/electron-build/agents/skill-ledger.js.map +1 -0
  82. package/dist/electron-build/agents/tool-loop-guard.js +319 -0
  83. package/dist/electron-build/agents/tool-loop-guard.js.map +1 -0
  84. package/dist/electron-build/agents/tool-path-args.js +63 -0
  85. package/dist/electron-build/agents/tool-path-args.js.map +1 -0
  86. package/dist/electron-build/agents/tool-result-gate.js +59 -0
  87. package/dist/electron-build/agents/tool-result-gate.js.map +1 -0
  88. package/dist/electron-build/agents/tool-subset.js +116 -0
  89. package/dist/electron-build/agents/tool-subset.js.map +1 -0
  90. package/dist/electron-build/agents/tool-telemetry.js +108 -0
  91. package/dist/electron-build/agents/tool-telemetry.js.map +1 -0
  92. package/dist/electron-build/bootstrap/context-collector.js +38 -7
  93. package/dist/electron-build/bootstrap/context-collector.js.map +1 -1
  94. package/dist/electron-build/bootstrap/lifecycle-hooks.js +12 -3
  95. package/dist/electron-build/bootstrap/lifecycle-hooks.js.map +1 -1
  96. package/dist/electron-build/bootstrap/persona-init.js +216 -0
  97. package/dist/electron-build/bootstrap/persona-init.js.map +1 -0
  98. package/dist/electron-build/bootstrap/persona-loader.js +5 -1
  99. package/dist/electron-build/bootstrap/persona-loader.js.map +1 -1
  100. package/dist/electron-build/context-compaction/auto-compact.js +38 -2
  101. package/dist/electron-build/context-compaction/auto-compact.js.map +1 -1
  102. package/dist/electron-build/external-engines/delegate.js +22 -0
  103. package/dist/electron-build/external-engines/delegate.js.map +1 -1
  104. package/dist/electron-build/orbitdb/cid-database.js +55 -0
  105. package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
  106. package/dist/electron-build/orbitdb/ipfs-node.js +36 -11
  107. package/dist/electron-build/orbitdb/ipfs-node.js.map +1 -1
  108. package/dist/electron-build/web/server-storage.js +26 -5
  109. package/dist/electron-build/web/server-storage.js.map +1 -1
  110. package/dist/external-engines/delegate.js +21 -0
  111. package/dist/index.js +503 -77
  112. package/dist/orbitdb/cid-database.js +55 -0
  113. package/dist/orbitdb/event-index.js +370 -0
  114. package/dist/orbitdb/event-query.js +131 -0
  115. package/dist/orbitdb/event-shell.js +510 -0
  116. package/dist/orbitdb/group-access.js +381 -0
  117. package/dist/orbitdb/group-shards.js +140 -0
  118. package/dist/orbitdb/ipfs-node.js +36 -11
  119. package/dist/web/bolloon-web.json +3 -3
  120. package/dist/web/client.js +3 -2
  121. package/dist/web/mobile-core.js +7762 -6289
  122. package/dist/web/routes-mobile-tasks.js +2 -1
  123. package/dist/web/server-storage.js +26 -5
  124. package/dist/web/server.js +2 -0
  125. package/dist/web/sw.js +1 -1
  126. package/package.json +2 -1
@@ -0,0 +1,211 @@
1
+ /**
2
+ * 教训 ⇒ 技能 (2026-10-01, 用户: 「skills 教训学习, bolloon 有吗
3
+ * 查下来的事实: bolloon **三块都有** —— 回合后自审(`experience-review.ts` ✓) · 技能读写(`skill-loader`/`skill-writer` ✓) ·
4
+ * 技能库(`~/.bolloon/skills/`, 实测 1301 个 ✓) —— **但中间没有线** ✗: 复盘产出的教训只落到
5
+ * `~/.bolloon/experience/<类>.md` ✗, **从不写回技能** ✗ ⇒ 教训与技能库是两座孤岛。
6
+ * 抄来的三条纪律(来自运行时那套的回合后自审流程 ✓, 用 bolloon 自己的话写):
7
+ * ① **先找已有的**(umbrella)再考虑新建 —— 1301 个技能已够碎, 再自动新建只会更碎 ✗;
8
+ * ② **读后写**: 要改某个 SKILL.md 必须先把它读进来(读不到就**不写**, 绝不盲改 ✗);
9
+ * ③ **匹配不够强就不写技能**, 只留经验条目(宁缺勿碎 ✓)。
10
+ * 纯函数为主 ⇒ 可测。
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import os from 'node:os';
15
+ /** 匹配强度门槛: 重叠词数 ≥ 这个值才算"就是讲这件事的技能" */
16
+ export const LESSON_MATCH_MIN_SCORE = 3;
17
+ const STOP = new Set(['the', 'and', 'for', 'with', 'use', 'when', 'skill', '技能', '的', '和', '与', '在', '是', '要', '不', '有']);
18
+ /** 粗略分词: 英文按词、中文按 2 字滑窗 —— 够用且便宜(不引分词库 ✓) */
19
+ export function tokens(text) {
20
+ const s = String(text ?? '').toLowerCase();
21
+ const out = new Set();
22
+ for (const w of s.match(/[a-z0-9_-]{2,}/g) || [])
23
+ if (!STOP.has(w))
24
+ out.add(w);
25
+ const cjk = s.replace(/[^\u4e00-\u9fa5]/g, ' ');
26
+ // 2026-10-01 校准: 中文用 **3 字**窗口 —— 2 字窗口在 1300 个技能里误命中率太高(实测 "提交" 撞无关技能 ✗)
27
+ for (const run of cjk.split(/\s+/))
28
+ for (let i = 0; i + 3 <= run.length; i++)
29
+ out.add(run.slice(i, i + 3));
30
+ return [...out];
31
+ }
32
+ export function scoreLessonAgainstSkill(lesson, skill) {
33
+ const lt = new Set(tokens(`${lesson.title} ${lesson.body} ${lesson.klass || ''}`));
34
+ const st = new Set(tokens(`${skill.name} ${skill.description || ''}`));
35
+ let hit = 0;
36
+ for (const t of st)
37
+ if (lt.has(t))
38
+ hit++;
39
+ // 名字里直接出现也算强信号
40
+ if (skill.name && String(lesson.title).toLowerCase().includes(skill.name.toLowerCase()))
41
+ hit += 2;
42
+ return hit;
43
+ }
44
+ /** 决定这条教训该进技能还是只留经验 (③ 匹配不够强就不写技能) */
45
+ export function decideLessonSink(lesson, skills) {
46
+ let best;
47
+ let bestScore = 0;
48
+ for (const s of skills) {
49
+ if (!s.file)
50
+ continue; // 没路径的(跨库加载)不参与改写
51
+ const sc = scoreLessonAgainstSkill(lesson, s);
52
+ if (sc > bestScore) {
53
+ bestScore = sc;
54
+ best = s;
55
+ }
56
+ }
57
+ const suggestions = skills
58
+ .map((s) => ({ name: s.name, score: scoreLessonAgainstSkill(lesson, s) }))
59
+ .filter((x) => x.score > 0).sort((a, b) => b.score - a.score).slice(0, 3);
60
+ // 实测校准(2026-10-01): 拿今天 4 条真实教训去撞用户真实的 1300 个技能, 最高分只有 2, 且 top 命中是**瞎的**
61
+ // ⇒ 说明关键词匹配**不足以可靠地自动改技能** ✗ ⇒ 规矩: 只有**强命中**(同名/描述高度重合, 分数够高)才自动写技能 ✓,
62
+ // 否则只留经验 + 附候选, 由人/智能体决定 ✓(宁缺勿碎 ✓)。
63
+ if (best && bestScore >= LESSON_MATCH_MIN_SCORE) {
64
+ return { kind: 'skill', skill: best, score: bestScore, suggestions, reason: `强命中已有技能 ${best.name} (score=${bestScore})` };
65
+ }
66
+ return {
67
+ kind: 'experience-only', suggestions,
68
+ reason: `没有足够强的匹配 (最高 score=${bestScore} < ${LESSON_MATCH_MIN_SCORE}) ⇒ 只留经验, 不自动改写技能(实测自动匹配会瞎撞 ⇒ 宁缺勿碎 ✓)`,
69
+ };
70
+ }
71
+ export const LESSON_SECTION = '## 教训 (自动沉淀)';
72
+ /**
73
+ * 把一条教训**并进** SKILL.md 正文: 已有同名条目 ⇒ **更新那一条**(不重复堆 ✓); 否则追加。
74
+ */
75
+ export function mergeLessonIntoBody(body, lesson) {
76
+ const src = String(body ?? '');
77
+ const line = `- **${lesson.title.trim()}**: ${lesson.body.trim().replace(/\s+/g, ' ')}`;
78
+ const esc = lesson.title.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
79
+ const re = new RegExp(`^- \\*\\*${esc}\\*\\*:[^\\n]*$`, 'm');
80
+ if (re.test(src))
81
+ return { body: src.replace(re, line), updated: true };
82
+ if (src.includes(LESSON_SECTION))
83
+ return { body: `${src.replace(/\s+$/, '')}\n${line}\n`, updated: false };
84
+ return { body: `${src.replace(/\s+$/, '')}\n\n${LESSON_SECTION}\n${line}\n`, updated: false };
85
+ }
86
+ /**
87
+ * 落盘: **读后写**(① 读不到就不写 ✓) + 同标题更新(不堆 ✓)。返回实际动作, 便于日志与测试。
88
+ */
89
+ export function applyLessonToSkill(skill, lesson) {
90
+ const file = skill?.file;
91
+ if (!file)
92
+ return { patched: false, reason: '技能没有文件路径(只读库), 不改写' };
93
+ if (!fs.existsSync(file))
94
+ return { patched: false, reason: `找不到 ${file} (读后写: 读不到就不写)` };
95
+ try {
96
+ const body = fs.readFileSync(file, 'utf-8');
97
+ if (!body.trim())
98
+ return { patched: false, reason: '文件是空的, 不盲改' };
99
+ const merged = mergeLessonIntoBody(body, lesson);
100
+ fs.writeFileSync(file, merged.body, 'utf-8');
101
+ return { patched: true, file, updated: merged.updated };
102
+ }
103
+ catch (e) {
104
+ return { patched: false, reason: `写入失败: ${String(e?.message || e).slice(0, 80)}` };
105
+ }
106
+ }
107
+ /** 从一个技能目录收 name/description/file (只读 SKILL.md 头部, 便宜 ✓) */
108
+ export function skillsFromDirs(dirs) {
109
+ const out = [];
110
+ for (const dir of dirs) {
111
+ try {
112
+ if (!fs.existsSync(dir))
113
+ continue;
114
+ for (const name of fs.readdirSync(dir)) {
115
+ const file = path.join(dir, name, 'SKILL.md');
116
+ try {
117
+ if (!fs.existsSync(file))
118
+ continue;
119
+ const head = fs.readFileSync(file, 'utf-8').slice(0, 1200);
120
+ const desc = /description:\s*(.+)/.exec(head);
121
+ out.push({ name, description: desc ? desc[1].trim().replace(/^[>\s]+/, '').slice(0, 200) : '', file });
122
+ }
123
+ catch { /* 单个技能读失败不影响其它 */ }
124
+ }
125
+ }
126
+ catch { /* 目录读失败跳过 */ }
127
+ }
128
+ return out;
129
+ }
130
+ /**
131
+ * 把"这条教训可以沉淀进哪些技能"记到日志文件给人/智能体看
132
+ * (实测关键词匹配不足以自动改技能 ⇒ **不替人决定** ✓; 但候选要留痕 ⇒ 写 `~/.bolloon/logs/lesson-suggestions.jsonl` ✓)。
133
+ */
134
+ export function logLessonSuggestions(lesson, decision, home = os.homedir()) {
135
+ try {
136
+ const dir = path.join(home, '.bolloon', 'logs');
137
+ fs.mkdirSync(dir, { recursive: true });
138
+ const file = path.join(dir, 'lesson-suggestions.jsonl');
139
+ try {
140
+ if (fs.existsSync(file) && fs.readFileSync(file, 'utf-8').split('\n').length > 300)
141
+ fs.writeFileSync(file, '', 'utf-8');
142
+ }
143
+ catch { /* 忽略 */ }
144
+ fs.appendFileSync(file, JSON.stringify({
145
+ ts: new Date().toISOString(), title: lesson.title, kind: decision.kind,
146
+ reason: decision.reason, suggestions: decision.suggestions,
147
+ }) + '\n', 'utf-8');
148
+ }
149
+ catch { /* 观测失败绝不影响复盘 */ }
150
+ }
151
+ // ─────────────────────────────────────────────────────────────────────────────
152
+ // 2026-10-01 (用户: 「教训…改为都进去进入技能和判断力库」):
153
+ // 原先我给"弱命中"只记候选、不写技能 ✗ —— 用户要求**都进技能库** ✓。
154
+ // 但实测(真教训撞真 1300 技能, 最高分 2 且 top 命中是瞎的)说明**瞎匹配会写错地方** ✗ ⇒ 折中:
155
+ // · 强命中 ⇒ 写进**那个**技能 ✓;
156
+ // · 弱命中 ⇒ 写进一个统一的沉淀技能 `lessons-learned`(按类分节 ✓ + 记下候选技能供人挑拣 ✓)
157
+ // ⇒ **每条教训都进技能库** ✓, 且不污染无关技能 ✓, 也便于人工管理(一个文件看全 ✓)。
158
+ // ─────────────────────────────────────────────────────────────────────────────
159
+ export const SINK_SKILL_NAME = 'lessons-learned';
160
+ /** 沉淀技能的目录 (用户级技能库, 与 skill-writer 同一处) */
161
+ export function sinkSkillDir(home = os.homedir()) {
162
+ return path.join(home, '.bolloon', 'skills', SINK_SKILL_NAME);
163
+ }
164
+ /**
165
+ * 确保沉淀技能存在 (**读后写**: 存在就不动它 ✓; 不存在才创建 ✓)。
166
+ * 返回 SKILL.md 路径。
167
+ */
168
+ export function ensureSinkSkill(home = os.homedir()) {
169
+ const dir = sinkSkillDir(home);
170
+ const file = path.join(dir, 'SKILL.md');
171
+ if (fs.existsSync(file))
172
+ return { file, created: false };
173
+ fs.mkdirSync(dir, { recursive: true });
174
+ fs.writeFileSync(file, [
175
+ '---',
176
+ `name: ${SINK_SKILL_NAME}`,
177
+ 'description: 用于**沉淀学到的教训**: 自动把"可复用的规矩/踩过的坑"按类收在这里; 每类一节, 同标题更新不重复堆。',
178
+ '---',
179
+ '',
180
+ `# ${SINK_SKILL_NAME}`,
181
+ '',
182
+ '自动沉淀的教训(每条: **规矩** + 为什么)。同标题 ⇒ 更新那一条, 不重复堆。',
183
+ '若某条教训明显属于某个专门技能(见条目里"候选"), 人工把它挪过去更合适。',
184
+ '',
185
+ ].join('\n'), 'utf-8');
186
+ return { file, created: true };
187
+ }
188
+ /**
189
+ * 一条教训的**唯一出口**: 强命中 ⇒ 写那个技能; 否则 ⇒ 写沉淀技能。**保证每条都进技能库** ✓。
190
+ */
191
+ export function routeLessonToSkill(lesson, skills, home = os.homedir()) {
192
+ const d = decideLessonSink(lesson, skills);
193
+ if (d.kind === 'skill' && d.skill?.file) {
194
+ const r = applyLessonToSkill(d.skill, lesson);
195
+ if (r.patched) {
196
+ return { skill: { name: d.skill.name, file: r.file, patched: true, created: false, updated: r.updated }, reason: d.reason, candidates: d.suggestions };
197
+ }
198
+ // 命中了但写不进去(文件读不到/空) ⇒ 退到沉淀技能, 保证"都进去" ✓
199
+ }
200
+ const sink = ensureSinkSkill(home);
201
+ const body = fs.readFileSync(sink.file, 'utf-8');
202
+ const withCandidates = d.suggestions.length
203
+ ? { ...lesson, body: `${lesson.body} (候选技能: ${d.suggestions.map((s) => `${s.name}(${s.score})`).join(', ')})` }
204
+ : lesson;
205
+ const r = applyLessonToSkill({ name: SINK_SKILL_NAME, file: sink.file }, withCandidates);
206
+ return {
207
+ skill: { name: SINK_SKILL_NAME, file: sink.file, patched: r.patched, created: sink.created, updated: r.updated },
208
+ reason: `${d.reason} ⇒ 收进沉淀技能 ${SINK_SKILL_NAME}`,
209
+ candidates: d.suggestions,
210
+ };
211
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * 常驻服务注册表 (2026-10-01, 用户: 「process 也要可以管理群聊和去中心化交流进程」)。
3
+ *
4
+ * 背景: `process` 工具原来只管 `terminal background=true` 起的 **shell 进程** ✗,
5
+ * 而"群聊/去中心化交流"是**常驻服务**(社交心跳 · P2P 网络 · OrbitDB 群聊存储 · 文档接收) ⇒ 看不见也管不着 ✗。
6
+ * 做法: 一个极小的注册表 —— 每个服务提供 `status()` ✓ 和**可选**的 `start()/stop()` ✓。
7
+ * **如实分级**: 只有真的实现了启停的服务才允许 start/stop ✓; 其余标 `controllable:false`,
8
+ * 控制请求会被明确拒绝并给出理由(绝不假装停掉 ✗)。
9
+ */
10
+ export class ManagedServices {
11
+ services = new Map();
12
+ register(svc) {
13
+ if (!svc?.name)
14
+ return;
15
+ this.services.set(svc.name, svc);
16
+ }
17
+ list() {
18
+ const rows = [];
19
+ for (const s of this.services.values()) {
20
+ let status = '(状态不可用)';
21
+ try {
22
+ status = String(s.status() ?? '');
23
+ }
24
+ catch (e) {
25
+ status = `(取状态失败: ${String(e?.message || e).slice(0, 60)})`;
26
+ }
27
+ rows.push({ name: s.name, description: s.description, status, controllable: !!(s.start || s.stop) });
28
+ }
29
+ return rows.sort((a, b) => a.name.localeCompare(b.name));
30
+ }
31
+ /** 渲染成给模型的一行行文本 */
32
+ describe() {
33
+ const rows = this.list();
34
+ if (!rows.length)
35
+ return '(没有注册常驻服务)';
36
+ return rows.map((r) => `- ${r.name} [${r.controllable ? '可启停' : '仅状态'}] ${r.status} — ${r.description}`).join('\n');
37
+ }
38
+ /**
39
+ * 控制: op = 'start' | 'stop'. **只对实现了对应动作的服务放行** ✓。
40
+ */
41
+ async control(name, op) {
42
+ const s = this.services.get(String(name || '').trim());
43
+ if (!s)
44
+ return { ok: false, output: `没有这个服务: ${name}(用 process 的 action:'services' 看有哪些)` };
45
+ const fn = op === 'start' ? s.start : s.stop;
46
+ if (!fn) {
47
+ return { ok: false, output: `${s.name} 不支持 ${op}(只提供状态) —— 它是随进程存在的常驻组件, 没有独立启停 ✓` };
48
+ }
49
+ try {
50
+ await fn();
51
+ let now = '';
52
+ try {
53
+ now = String(s.status() ?? '');
54
+ }
55
+ catch { /* 忽略 */ }
56
+ return { ok: true, output: `${s.name} 已${op === 'start' ? '启动' : '停止'} ✓ 现状: ${now}` };
57
+ }
58
+ catch (e) {
59
+ return { ok: false, output: `${s.name} ${op} 失败: ${String(e?.message || e).slice(0, 120)}` };
60
+ }
61
+ }
62
+ }
63
+ /** 进程级单例 (工具上下文里传的就是它) */
64
+ export const managedServices = new ManagedServices();
@@ -284,6 +284,8 @@ function resolve(ctx, name) {
284
284
  return ctx.resolveAlias(name);
285
285
  return defaultResolveAlias(name, ctx.tools);
286
286
  }
287
+ /** 诊断开关 (2026-10-01): 默认关 —— 这条 diag 在热路径上且上屏, 默认开会刷屏 */
288
+ const PARSE_DIAG_ON = process.env.BOLLOON_PARSE_DIAG === '1' || process.env.BOLLOON_VERBOSE === '1';
287
289
  export function parseToolCall(content, ctx) {
288
290
  if (!content)
289
291
  return null;
@@ -293,38 +295,42 @@ export function parseToolCall(content, ctx) {
293
295
  // 怀疑 parseToolCall 没拿到正确的 name/args. 这里只打日志不改任何逻辑.
294
296
  // 日志形态: [parseToolCall diag] ok=true/false name=... argKeys=... rawHead=...
295
297
  // 用 console.warn 一行 JSON, 方便 grep + 排时间线.
296
- try {
297
- const result = (function _diagProbe() {
298
- // 用与正文完全相同的解析路径,但提前跑一次,记录是否命中
299
- // 走完下面所有分支再覆盖回原值即可
300
- return null;
301
- })();
302
- void result;
303
- const probe = (function _doParse() {
304
- // 内联一份最便宜的 "能不能解出 name" 探测 — 不重复跑全部分支
305
- const m1 = strippedContent.match(/<invoke\s+name=["']([\w]+)["']/);
306
- if (m1) {
307
- return { name: m1[1], args: { __probe_invoketag: '1' } };
308
- }
309
- const m2 = strippedContent.match(/<function_calls>[\s\S]*?<invoke\s+name=["']([\w]+)["']/);
310
- if (m2) {
311
- return { name: m2[1], args: { __probe_function_calls: '1' } };
312
- }
313
- const m3 = strippedContent.match(/\{[\s\S]*?"name"\s*:\s*["']([\w]+)["']/);
314
- if (m3) {
315
- return { name: m3[1], args: { __probe_json_name: '1' } };
316
- }
317
- return null;
318
- })();
319
- console.warn('[parseToolCall diag] rawLen=' + content.length +
320
- ' strippedLen=' + strippedContent.length +
321
- ' probeName=' + (probe?.name ?? 'null') +
322
- ' rawHead=' + JSON.stringify(content.slice(0, 500)));
323
- }
324
- catch (diagErr) {
325
- // 诊断日志绝不能影响主路径 — 吞掉任何 diag 自身抛错
326
- console.warn('[parseToolCall diag] diag-self-failed:', String(diagErr));
327
- }
298
+ if (PARSE_DIAG_ON)
299
+ try {
300
+ const result = (function _diagProbe() {
301
+ // 用与正文完全相同的解析路径,但提前跑一次,记录是否命中
302
+ // 走完下面所有分支再覆盖回原值即可
303
+ return null;
304
+ })();
305
+ void result;
306
+ const probe = (function _doParse() {
307
+ // 内联一份最便宜的 "能不能解出 name" 探测 — 不重复跑全部分支
308
+ const m1 = strippedContent.match(/<invoke\s+name=["']([\w]+)["']/);
309
+ if (m1) {
310
+ return { name: m1[1], args: { __probe_invoketag: '1' } };
311
+ }
312
+ const m2 = strippedContent.match(/<function_calls>[\s\S]*?<invoke\s+name=["']([\w]+)["']/);
313
+ if (m2) {
314
+ return { name: m2[1], args: { __probe_function_calls: '1' } };
315
+ }
316
+ const m3 = strippedContent.match(/\{[\s\S]*?"name"\s*:\s*["']([\w]+)["']/);
317
+ if (m3) {
318
+ return { name: m3[1], args: { __probe_json_name: '1' } };
319
+ }
320
+ return null;
321
+ })();
322
+ // 2026-10-01: 这条诊断默认**不打** —— 它在 parseToolCall 热路径上且上屏,
323
+ // 用户实测每轮都刷 `[parseToolCall diag] rawLen=… rawHead=…` (仓规: 内部运行日志不进回复流)。
324
+ // 要排障显式开: BOLLOON_PARSE_DIAG=1 (或 BOLLOON_VERBOSE=1 / --verbose)。
325
+ console.warn('[parseToolCall diag] rawLen=' + content.length +
326
+ ' strippedLen=' + strippedContent.length +
327
+ ' probeName=' + (probe?.name ?? 'null') +
328
+ ' rawHead=' + JSON.stringify(content.slice(0, 500)));
329
+ }
330
+ catch (diagErr) {
331
+ // 诊断日志绝不能影响主路径 — 吞掉任何 diag 自身抛错
332
+ console.warn('[parseToolCall diag] diag-self-failed:', String(diagErr));
333
+ }
328
334
  // === 1. JSON function-call (OpenAI / Anthropic / Minimax-style) ===
329
335
  const jsonPatterns = [
330
336
  // markdown json code block + OpenAI 块, 同时匹配 arguments/input 字段
@@ -4,6 +4,62 @@ import { getGlobalSharedContext } from '../social/global-shared-context.js';
4
4
  import { Session, saveSession, loadSession } from '@bolloon/constraint-runtime';
5
5
  import { SHARED_SESSION_PATH } from '../web/server-types.js';
6
6
  const PERSONA_PATH = path.join(process.env.HOME || '/tmp', '.bolloon', 'persona.json');
7
+ /** agentId 的安全化 (与 persona-loader 同口径) */
8
+ function safePersonaScope(id) {
9
+ return String(id || '').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
10
+ }
11
+ /** 某个 agent 自己的 persona.json 路径 (persona/<agentId>/persona.json) */
12
+ export function personaPathFor(scopeId) {
13
+ const root = process.env.HOME || '/tmp';
14
+ if (!scopeId)
15
+ return PERSONA_PATH;
16
+ return path.join(root, '.bolloon', 'persona', safePersonaScope(scopeId), 'persona.json');
17
+ }
18
+ /** 该 agent 名下有没有身份文档目录 (persona/<agentId>/*.md) */
19
+ export async function hasAgentIdentityDocs(scopeId) {
20
+ if (!scopeId)
21
+ return false;
22
+ const dir = path.dirname(personaPathFor(scopeId));
23
+ try {
24
+ const files = await fs.readdir(dir);
25
+ return files.some((f) => f.endsWith('.md'));
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ /**
32
+ * persona 到底该从哪来 (2026-10-01, 用户报「所有回复都是一个智能体人格」)。
33
+ *
34
+ * 真因: 原先每个 session 都读**全局** `~/.bolloon/persona.json` (你那份是 2026-08-10 的老
35
+ * `set persona` 留下的 `{name: 小宝}`) ⇒ 不管切到哪个 agent, 身份名都是「小宝」✗,
36
+ * 把按 agent 的 6 份身份文档盖住了。
37
+ *
38
+ * 现在的口径 (三条, 优先级从上到下):
39
+ * 1) 该 agent 自己的 `persona/<agentId>/persona.json` ⇒ 用它 (想给某个 agent 单独设 persona 走这里);
40
+ * 2) 该 agent 有身份文档目录 (persona/<agentId>/*.md) ⇒ **返回 null** —— 身份由那些文档承担,
41
+ * 不再套用全局那份 (这正是用户的情况);
42
+ * 3) 没有 scope, 或该 agent 什么文档都没有 ⇒ 回落全局 persona.json (老的默认 agent 路径保持兼容)。
43
+ */
44
+ export async function resolvePersonaSource(scopeId) {
45
+ if (scopeId) {
46
+ const scoped = personaPathFor(scopeId);
47
+ try {
48
+ await fs.access(scoped);
49
+ return { kind: 'scoped', path: scoped };
50
+ }
51
+ catch { /* 没有该 agent 自己的 persona.json */ }
52
+ if (await hasAgentIdentityDocs(scopeId))
53
+ return { kind: 'docs', path: scoped };
54
+ }
55
+ try {
56
+ await fs.access(PERSONA_PATH);
57
+ return { kind: 'global', path: PERSONA_PATH };
58
+ }
59
+ catch {
60
+ return { kind: 'none', path: PERSONA_PATH };
61
+ }
62
+ }
7
63
  /**
8
64
  * PiSessionManager — 负责:
9
65
  * - 加载 / 持久化 persona
@@ -26,10 +82,13 @@ export class PiSessionManager {
26
82
  cwd;
27
83
  sharedContext;
28
84
  agentId;
29
- constructor(agentId, cwd) {
85
+ /** 2026-10-01: persona 归属的 agent (channel 的 agentId); 空 = 老行为 (全局 persona.json) */
86
+ personaScopeId;
87
+ constructor(agentId, cwd, personaScopeId) {
30
88
  this.cwd = cwd;
31
89
  this.sessionDir = path.join(cwd, '.port_sessions');
32
90
  this.agentId = agentId;
91
+ this.personaScopeId = personaScopeId ? String(personaScopeId) : undefined;
33
92
  const sessionId = `pi-session-${Date.now()}`;
34
93
  this.session = new Session(sessionId);
35
94
  this.state = {
@@ -117,9 +176,17 @@ export class PiSessionManager {
117
176
  });
118
177
  this.initialized = true;
119
178
  }
179
+ /** 让调用方在拿到 channel 的 agentId 后再定 persona 归属 (幂等) */
180
+ setPersonaScope(scopeId) {
181
+ this.personaScopeId = scopeId ? String(scopeId) : undefined;
182
+ }
120
183
  async loadPersona() {
121
184
  try {
122
- const data = await fs.readFile(PERSONA_PATH, 'utf-8');
185
+ const src = await resolvePersonaSource(this.personaScopeId);
186
+ // kind='docs' ⇒ 该 agent 有身份文档, 身份不由 persona.json 承担 ⇒ 返回 null (不套全局那份)
187
+ if (src.kind === 'docs' || src.kind === 'none')
188
+ return null;
189
+ const data = await fs.readFile(src.path, 'utf-8');
123
190
  return JSON.parse(data);
124
191
  }
125
192
  catch {
@@ -144,7 +211,9 @@ export class PiSessionManager {
144
211
  await fs.writeFile(this.channelsPath, JSON.stringify(channelsArray, null, 2));
145
212
  }
146
213
  async savePersona(persona) {
147
- await fs.writeFile(PERSONA_PATH, JSON.stringify(persona, null, 2));
214
+ // 2026-10-01: 按 agent 写回 —— 有 scope 就写 persona/<agentId>/persona.json,
215
+ // 否则写全局 (不再让某个 agent 的 set_persona 把全局那份改掉)
216
+ await fs.writeFile(personaPathFor(this.personaScopeId), JSON.stringify(persona, null, 2));
148
217
  this.persona = persona;
149
218
  }
150
219
  getPersona() {