@bolloon/bolloon-agent 0.4.33 → 0.5.1

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 (214) hide show
  1. package/dist/agents/contacts/chain.js +27 -7
  2. package/dist/agents/execution-supervisor.js +580 -40
  3. package/dist/agents/external-events.js +35 -6
  4. package/dist/agents/gateway-group.js +100 -12
  5. package/dist/agents/goal-criteria.js +10 -3
  6. package/dist/agents/goal-flywheel/block-executor.js +346 -0
  7. package/dist/agents/goal-flywheel/continuation-decision.js +592 -0
  8. package/dist/agents/goal-flywheel/goal-change.js +754 -0
  9. package/dist/agents/goal-flywheel/index.js +26 -0
  10. package/dist/agents/goal-flywheel/memory-layers.js +227 -0
  11. package/dist/agents/goal-flywheel/run-closure.js +809 -0
  12. package/dist/agents/goal-flywheel/skill-candidate.js +628 -0
  13. package/dist/agents/goal-flywheel/types.js +329 -0
  14. package/dist/agents/goal-flywheel/wiring/change.js +288 -0
  15. package/dist/agents/goal-flywheel/wiring/closure.js +697 -0
  16. package/dist/agents/goal-flywheel/wiring/continuation.js +432 -0
  17. package/dist/agents/goal-flywheel/wiring/contract.js +270 -0
  18. package/dist/agents/goal-flywheel/wiring/index.js +26 -0
  19. package/dist/agents/goal-flywheel/wiring/monitor.js +254 -0
  20. package/dist/agents/goal-flywheel/wiring/seams.js +592 -0
  21. package/dist/agents/goal-flywheel/work-contract.js +478 -0
  22. package/dist/agents/goal-flywheel/work-monitor.js +488 -0
  23. package/dist/agents/goal-flywheel-wiring.js +1870 -0
  24. package/dist/agents/goal-state-reducer.js +406 -0
  25. package/dist/agents/goal-store.js +35 -1
  26. package/dist/agents/mobile-flywheel-view.js +144 -0
  27. package/dist/agents/mobile-task-actions-verify.js +74 -0
  28. package/dist/agents/mobile-task-actions.js +178 -0
  29. package/dist/agents/mobile-task-views.js +199 -0
  30. package/dist/agents/model-policy.js +651 -0
  31. package/dist/agents/network-pulse.js +304 -18
  32. package/dist/agents/pi-sdk-tools.js +84 -33
  33. package/dist/agents/pi-sdk.js +157 -42
  34. package/dist/agents/run-store.js +71 -5
  35. package/dist/agents/skill-readiness.js +52 -10
  36. package/dist/agents/subagent-manager.js +209 -3
  37. package/dist/agents/task/task-runner.js +17 -4
  38. package/dist/agents/task-group.js +8 -51
  39. package/dist/agents/task-public-text.js +89 -0
  40. package/dist/agents/workflow-pivot-loop.js +19 -12
  41. package/dist/cli/clipboard.js +65 -0
  42. package/dist/cli/commands/index.js +1 -0
  43. package/dist/cli/commands/tasks.js +350 -2
  44. package/dist/cli/identity-command.js +177 -0
  45. package/dist/cli/ink-app.js +145 -17
  46. package/dist/cli/log-gate.js +386 -0
  47. package/dist/cli/mention-data.js +2 -0
  48. package/dist/cli/model-selector.js +538 -0
  49. package/dist/cli/reply-hygiene.js +54 -0
  50. package/dist/cli/setup-wizard.js +757 -69
  51. package/dist/cli/startup-notice.js +160 -0
  52. package/dist/cli/stores.js +22 -1
  53. package/dist/cli/theme.js +49 -0
  54. package/dist/cli/tui-select.js +970 -0
  55. package/dist/cli/update-commands.js +150 -24
  56. package/dist/cli-entry.js +137 -20
  57. package/dist/electron-build/agents/contacts/chain.js +26 -6
  58. package/dist/electron-build/agents/contacts/chain.js.map +1 -1
  59. package/dist/electron-build/agents/execution-supervisor.js +578 -38
  60. package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
  61. package/dist/electron-build/agents/external-events.js +34 -5
  62. package/dist/electron-build/agents/external-events.js.map +1 -1
  63. package/dist/electron-build/agents/goal-criteria.js +10 -3
  64. package/dist/electron-build/agents/goal-criteria.js.map +1 -1
  65. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js +598 -0
  66. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js.map +1 -0
  67. package/dist/electron-build/agents/goal-flywheel/goal-change.js +773 -0
  68. package/dist/electron-build/agents/goal-flywheel/goal-change.js.map +1 -0
  69. package/dist/electron-build/agents/goal-flywheel/memory-layers.js +269 -0
  70. package/dist/electron-build/agents/goal-flywheel/memory-layers.js.map +1 -0
  71. package/dist/electron-build/agents/goal-flywheel/run-closure.js +818 -0
  72. package/dist/electron-build/agents/goal-flywheel/run-closure.js.map +1 -0
  73. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js +642 -0
  74. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js.map +1 -0
  75. package/dist/electron-build/agents/goal-flywheel/types.js +333 -0
  76. package/dist/electron-build/agents/goal-flywheel/types.js.map +1 -0
  77. package/dist/electron-build/agents/goal-flywheel/wiring/change.js +294 -0
  78. package/dist/electron-build/agents/goal-flywheel/wiring/change.js.map +1 -0
  79. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js +742 -0
  80. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js.map +1 -0
  81. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js +444 -0
  82. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js.map +1 -0
  83. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js +279 -0
  84. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js.map +1 -0
  85. package/dist/electron-build/agents/goal-flywheel/wiring/index.js +111 -0
  86. package/dist/electron-build/agents/goal-flywheel/wiring/index.js.map +1 -0
  87. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js +264 -0
  88. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js.map +1 -0
  89. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js +612 -0
  90. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js.map +1 -0
  91. package/dist/electron-build/agents/goal-flywheel/work-contract.js +488 -0
  92. package/dist/electron-build/agents/goal-flywheel/work-contract.js.map +1 -0
  93. package/dist/electron-build/agents/goal-flywheel/work-monitor.js +501 -0
  94. package/dist/electron-build/agents/goal-flywheel/work-monitor.js.map +1 -0
  95. package/dist/electron-build/agents/goal-flywheel-wiring.js +1962 -0
  96. package/dist/electron-build/agents/goal-flywheel-wiring.js.map +1 -0
  97. package/dist/electron-build/agents/goal-state-reducer.js +414 -0
  98. package/dist/electron-build/agents/goal-state-reducer.js.map +1 -0
  99. package/dist/electron-build/agents/goal-store.js +35 -1
  100. package/dist/electron-build/agents/goal-store.js.map +1 -1
  101. package/dist/electron-build/agents/model-policy.js +706 -0
  102. package/dist/electron-build/agents/model-policy.js.map +1 -0
  103. package/dist/electron-build/agents/network-pulse.js +307 -19
  104. package/dist/electron-build/agents/network-pulse.js.map +1 -1
  105. package/dist/electron-build/agents/pi-sdk-tools.js +84 -33
  106. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  107. package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
  108. package/dist/electron-build/agents/pi-sdk.js +156 -41
  109. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  110. package/dist/electron-build/agents/run-store.js +75 -6
  111. package/dist/electron-build/agents/run-store.js.map +1 -1
  112. package/dist/electron-build/agents/skill-readiness.js +52 -9
  113. package/dist/electron-build/agents/skill-readiness.js.map +1 -1
  114. package/dist/electron-build/agents/task/task-runner.js +16 -3
  115. package/dist/electron-build/agents/task/task-runner.js.map +1 -1
  116. package/dist/electron-build/agents/workflow-pivot-loop.js +19 -12
  117. package/dist/electron-build/agents/workflow-pivot-loop.js.map +1 -1
  118. package/dist/electron-build/llm/config-store.js +146 -6
  119. package/dist/electron-build/llm/config-store.js.map +1 -1
  120. package/dist/electron-build/llm/connection-probe.js +764 -0
  121. package/dist/electron-build/llm/connection-probe.js.map +1 -0
  122. package/dist/electron-build/llm/custom-provider-store.js +272 -0
  123. package/dist/electron-build/llm/custom-provider-store.js.map +1 -0
  124. package/dist/electron-build/llm/data/provider-catalog-baked.js +23 -0
  125. package/dist/electron-build/llm/data/provider-catalog-baked.js.map +1 -0
  126. package/dist/electron-build/llm/model-catalog.js +778 -0
  127. package/dist/electron-build/llm/model-catalog.js.map +1 -0
  128. package/dist/electron-build/llm/model-discovery.js +1202 -0
  129. package/dist/electron-build/llm/model-discovery.js.map +1 -0
  130. package/dist/electron-build/llm/model-selection.js +1315 -0
  131. package/dist/electron-build/llm/model-selection.js.map +1 -0
  132. package/dist/electron-build/llm/pi-ai.js +70 -11
  133. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  134. package/dist/electron-build/llm/provider-catalog.js +969 -0
  135. package/dist/electron-build/llm/provider-catalog.js.map +1 -0
  136. package/dist/electron-build/llm/provider-registry.js +950 -0
  137. package/dist/electron-build/llm/provider-registry.js.map +1 -0
  138. package/dist/electron-build/llm/tool-name.js +266 -0
  139. package/dist/electron-build/llm/tool-name.js.map +1 -0
  140. package/dist/electron-build/orbitdb/cid-database.js +83 -17
  141. package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
  142. package/dist/electron-build/orbitdb/ipfs-node.js +31 -2
  143. package/dist/electron-build/orbitdb/ipfs-node.js.map +1 -1
  144. package/dist/electron-build/utils/dual-source-facts.js +236 -0
  145. package/dist/electron-build/utils/dual-source-facts.js.map +1 -0
  146. package/dist/electron-build/utils/dual-source.js +347 -0
  147. package/dist/electron-build/utils/dual-source.js.map +1 -0
  148. package/dist/electron-build/utils/update-manager.js +328 -104
  149. package/dist/electron-build/utils/update-manager.js.map +1 -1
  150. package/dist/electron-build/utils/update-state.js +9 -11
  151. package/dist/electron-build/utils/update-state.js.map +1 -1
  152. package/dist/electron-build/utils/version-identity.js +193 -0
  153. package/dist/electron-build/utils/version-identity.js.map +1 -0
  154. package/dist/electron-build/utils/version-info.js +57 -31
  155. package/dist/electron-build/utils/version-info.js.map +1 -1
  156. package/dist/index.js +385 -108
  157. package/dist/ios/bolloon-web.json +10 -0
  158. package/dist/ios/chain-explorer.js +617 -0
  159. package/dist/ios/explorer.html +163 -0
  160. package/dist/ios/index.html +96 -0
  161. package/dist/ios/mobile-agent.js +2 -2
  162. package/dist/ios/mobile-contacts.js +362 -0
  163. package/dist/ios/mobile-core.js +3102 -310
  164. package/dist/ios/mobile-privacy.js +185 -0
  165. package/dist/ios/mobile-tasks.js +339 -0
  166. package/dist/ios/mobile-update.js +1442 -0
  167. package/dist/ios/mobile.css +19 -0
  168. package/dist/ios/mobile.html +96 -0
  169. package/dist/ios/mobile.js +1013 -41
  170. package/dist/ios/routes-chain-index.js +108 -0
  171. package/dist/ios/routes-contacts.js +420 -0
  172. package/dist/ios/routes-llm-config.js +344 -52
  173. package/dist/ios/routes-mobile-tasks.js +353 -0
  174. package/dist/ios/routes-onchain-trade.js +99 -0
  175. package/dist/ios/routes-x402-info.js +1 -0
  176. package/dist/ios/server.js +921 -0
  177. package/dist/ios/sw.js +1 -1
  178. package/dist/ios/task-frame-server.js +211 -0
  179. package/dist/llm/config-store.js +146 -6
  180. package/dist/llm/connection-probe.js +744 -0
  181. package/dist/llm/custom-provider-store.js +224 -0
  182. package/dist/llm/data/provider-catalog-baked.js +19 -0
  183. package/dist/llm/model-catalog.js +717 -0
  184. package/dist/llm/model-discovery.js +1137 -0
  185. package/dist/llm/model-selection.js +1241 -0
  186. package/dist/llm/pi-ai.js +70 -11
  187. package/dist/llm/provider-catalog.js +898 -0
  188. package/dist/llm/provider-registry.js +917 -0
  189. package/dist/llm/tool-name.js +220 -0
  190. package/dist/orbitdb/cid-database.js +81 -16
  191. package/dist/orbitdb/ipfs-node.js +31 -2
  192. package/dist/setup/onboard.js +34 -8
  193. package/dist/utils/dual-source-facts.js +223 -0
  194. package/dist/utils/dual-source.js +297 -0
  195. package/dist/utils/update-cli.js +16 -21
  196. package/dist/utils/update-health.js +35 -5
  197. package/dist/utils/update-manager.js +325 -96
  198. package/dist/utils/update-state.js +13 -10
  199. package/dist/utils/version-identity.js +176 -0
  200. package/dist/utils/version-info.js +27 -22
  201. package/dist/web/bolloon-web.json +10 -0
  202. package/dist/web/mobile-contacts.js +44 -11
  203. package/dist/web/mobile-core.js +2901 -580
  204. package/dist/web/mobile-tasks.js +339 -0
  205. package/dist/web/mobile-update.js +1442 -0
  206. package/dist/web/mobile.css +19 -0
  207. package/dist/web/mobile.html +64 -0
  208. package/dist/web/mobile.js +838 -35
  209. package/dist/web/routes-llm-config.js +344 -52
  210. package/dist/web/routes-mobile-tasks.js +353 -0
  211. package/dist/web/server.js +163 -4
  212. package/dist/web/sw.js +1 -1
  213. package/package.json +3 -1
  214. package/scripts/build-web.ts +36 -0
@@ -1,5 +1,7 @@
1
1
  import * as fs from 'fs/promises';
2
2
  import * as path from 'path';
3
+ // 2026-09-25 (M3 接缝): 换人必须重签合同的判据只有一份 (纯函数, 零 I/O)
4
+ import { childMatchesContract } from './goal-flywheel/wiring/contract.js';
3
5
  const DEFAULT_STORAGE_PATH = path.join(process.env.HOME || '/tmp', '.bolloon', 'agents');
4
6
  const DEFAULT_TASK_TIMEOUT_MS = 5 * 60 * 1000;
5
7
  const DEFAULT_MAX_CONCURRENT_TASKS = 10;
@@ -160,10 +162,36 @@ export class SubAgentManager {
160
162
  async assignTask(taskId, toAgentId) {
161
163
  const task = this.tasks.get(taskId);
162
164
  if (task) {
165
+ // 2026-09-25 (M3): 挂着工作合同的任务 **不许换人而不换合同**。
166
+ // 合同把 `childAgentId` 钉死了 (回报核验要求 report.childAgentId === contract.childAgentId),
167
+ // 把同一份合同指给另一个子 Agent ⇒ 新子的回报必然对不上合同, 合规判定整个失效。
168
+ // 换人的正确做法是**重新派遣** (父侧 signed 新合同) —— 本方法只维护指派关系, 无权签合同,
169
+ // 所以这里如实拒绝并留痕, 而不是"改了字段就当换成了"。
170
+ if (task.goalId && task.workId) {
171
+ const contract = (task.metadata?.workContract ?? null);
172
+ if (!contract) {
173
+ task.reassignRefused = {
174
+ code: 'contract_unreadable',
175
+ reason: `任务 ${taskId} 挂着 workId=${task.workId} 却读不到合同 → 无法判断新执行者是否与原合同一致, 拒绝换人 (先重签合同)`,
176
+ };
177
+ await this.saveTasks();
178
+ this.notifyTaskListeners(task);
179
+ return;
180
+ }
181
+ const same = childMatchesContract(contract, toAgentId);
182
+ if (!same.ok) {
183
+ task.reassignRefused = { code: 'requires_new_contract', reason: same.reason };
184
+ await this.saveTasks();
185
+ this.notifyTaskListeners(task);
186
+ return;
187
+ }
188
+ task.reassignRefused = null;
189
+ }
163
190
  task.assignedAgentId = toAgentId;
164
191
  task.toAgentId = toAgentId;
165
192
  task.status = 'assigned';
166
193
  task.assignedAt = new Date().toISOString();
194
+ await this.heartbeatContractedTask(task, task.assignedAt);
167
195
  await this.saveTasks();
168
196
  this.notifyTaskListeners(task);
169
197
  }
@@ -171,6 +199,37 @@ export class SubAgentManager {
171
199
  async updateTaskStatus(taskId, status, result, error) {
172
200
  const task = this.tasks.get(taskId);
173
201
  if (task) {
202
+ // 2026-09-25 (M3 接缝): 挂了合同的子任务, 任何状态更新都是"子还活着"的证据 → 记一次心跳。
203
+ // 这是"执行中统一监控"的输入面: 没有心跳, 阻塞巡检只能看见"卡住", 看不见"在动"。
204
+ await this.heartbeatContractedTask(task);
205
+ // 2026-09-25 (飞轮接线 P2): 挂着工作合同的任务, "完成" 必须过回报核验 ——
206
+ // 回一段漂亮话 (没有逐条判据证据) **不算完成**: 任务留在 in_progress, 原因写清楚,
207
+ // 而 Goal 那边的 pendingReports 保持 (父仍然在等这份回报)。
208
+ //
209
+ // 2026-09-25 (M3): 判据从 `task.workId && task.goalId` 收紧成 `task.goalId` ——
210
+ // "派前必须有合同"的**反面**: 目标语境下的任务若**没有**合同 (签发失败 / 旧数据),
211
+ // 它同样不许自称完成 —— 没有合同的回报不核验也不接受 (handleChildReport 的 no_contract)。
212
+ if (status === 'completed' && task.goalId) {
213
+ if (!task.workId) {
214
+ task.status = 'in_progress';
215
+ task.error = '回报不接受为完成: 这条任务在目标语境下**没有工作合同** '
216
+ + '(签合同失败或任务早于合同接线) —— 没有合同就没有判据, 无法核验, 不许当完成记账';
217
+ await this.saveTasks();
218
+ this.notifyTaskListeners(task);
219
+ return;
220
+ }
221
+ const verdict = await this.validateTaskReport(task, result ?? '');
222
+ if (!verdict.accepted) {
223
+ task.status = 'in_progress';
224
+ if (result)
225
+ task.result = result;
226
+ task.error = `回报核验不过, 不接受为完成: ${verdict.reason}`
227
+ + (verdict.missingEvidence.length ? ` [缺证据: ${verdict.missingEvidence.join('; ')}]` : '');
228
+ await this.saveTasks();
229
+ this.notifyTaskListeners(task);
230
+ return;
231
+ }
232
+ }
174
233
  task.status = status;
175
234
  if (result)
176
235
  task.result = result;
@@ -183,6 +242,31 @@ export class SubAgentManager {
183
242
  this.notifyTaskListeners(task);
184
243
  }
185
244
  }
245
+ /**
246
+ * 给挂着合同的任务记一次心跳 (P3 的输入面; 只有真写成盘才算数)。
247
+ *
248
+ * 为什么放在**真实派遣过程**里而不是让子 Agent 自己记得报: 心跳是"任务还活着"的事实,
249
+ * 而子 Agent 是最没有动力报心跳的一方 (它卡住了就不会报)。所以由派遣侧在每一次
250
+ * 真实状态变化 (派出去 / 换人 / 子更新状态) 时落一次 —— 没有这些落点, 阻塞巡检
251
+ * 只能看到"从来没心跳过", 分不清"卡住"与"刚派出"。
252
+ *
253
+ * 失败**不覆盖** `task.error` (那是子 Agent 的事实), 而是记进 `heartbeatError`:
254
+ * 心跳写不进去 = 监控会看到"无进展", 这件事必须留在任务上, 不许静默。
255
+ */
256
+ async heartbeatContractedTask(task, at) {
257
+ if (!task.goalId || !task.workId)
258
+ return;
259
+ const now = at ?? new Date().toISOString();
260
+ try {
261
+ const { recordWorkHeartbeat } = await import('./goal-flywheel-wiring.js');
262
+ await recordWorkHeartbeat(task.goalId, task.workId, now);
263
+ task.lastHeartbeatAt = now;
264
+ task.heartbeatError = null;
265
+ }
266
+ catch (e) {
267
+ task.heartbeatError = `心跳写入失败 (监控会看到"无进展"): ${String(e?.message || e).slice(0, 160)}`;
268
+ }
269
+ }
186
270
  async getTask(taskId) {
187
271
  return this.tasks.get(taskId);
188
272
  }
@@ -300,14 +384,136 @@ export class SubAgentManager {
300
384
  }
301
385
  }
302
386
  }
303
- async delegateTask(fromAgentId, taskDescription, requiredCapabilities, priority = 'normal', input) {
387
+ async delegateTask(fromAgentId, taskDescription, requiredCapabilities, priority = 'normal', input, contractOptions) {
304
388
  const agent = await this.findBestAgentForTask(requiredCapabilities, fromAgentId);
305
389
  if (!agent) {
306
390
  const task = await this.createTask('delegate', taskDescription.substring(0, 50), taskDescription, fromAgentId, undefined, priority, input);
307
- return { task, agent: undefined };
391
+ const workContract = await this.issueWorkContractFor(fromAgentId, task, requiredCapabilities, contractOptions);
392
+ // 派前必须有工作合同 (M3): 目标语境下签不出合同 → **(没有收件人时本来也派不出去)**, 但要如实标注
393
+ if (!workContract && contractOptions?.goalId) {
394
+ this.refuseDispatchWithoutContract(task, contractOptions?.goalId);
395
+ await this.saveTasks();
396
+ return { task, agent: undefined, workContract: undefined };
397
+ }
398
+ if (workContract)
399
+ await this.heartbeatContractedTask(task);
400
+ await this.saveTasks();
401
+ return { task, agent: undefined, workContract };
308
402
  }
309
403
  const task = await this.createTask('delegate', taskDescription.substring(0, 50), taskDescription, fromAgentId, agent.id, priority, input);
310
- return { task, agent };
404
+ // 2026-09-25 (P2): 派遣这件事**本身**就包含签合同 (不是事后再补一份文档);
405
+ // 有没有空闲子 Agent 不影响合同签发 (合同锁的是能力与判据, 不是某个人)。
406
+ //
407
+ // 2026-09-25 (M3): 签不出合同 ⇒ **不派出**。旧形状是"记个 error 照样把任务交出去",
408
+ // 那等于"派前必须有的合同"在失败路径上直接不存在 (子拿到一段任务描述就开始干,
409
+ // 回来只有一段文本, 父连判据都没有)。现在: 任务退回 `pending`、不绑执行者、
410
+ // 返回 `agent: undefined`, 让调用方**没有**可派遣的对象。
411
+ const workContract = await this.issueWorkContractFor(fromAgentId, task, requiredCapabilities, contractOptions);
412
+ if (!workContract && contractOptions?.goalId) {
413
+ this.refuseDispatchWithoutContract(task, contractOptions.goalId);
414
+ await this.saveTasks();
415
+ return { task, agent: undefined, workContract: undefined };
416
+ }
417
+ if (workContract)
418
+ await this.heartbeatContractedTask(task);
419
+ await this.saveTasks();
420
+ return { task, agent, workContract };
421
+ }
422
+ /**
423
+ * 目标语境下没有合同 ⇒ **不派出** (M3 的"派前必须有合同")。
424
+ *
425
+ * 落成事实: 任务退回 `pending` 且**不绑执行者** (没有 `assignedAt`),
426
+ * `contractRefused` 记下原因。于是:
427
+ * · 调用方拿到的 `agent` 是 `undefined` → 没有派遣对象;
428
+ * · 任务不会出现在 `getActiveTasks()` 里 (它没被派出去);
429
+ * · 原因留在任务上, 父能据此重签合同或交人 (不许静默当"没派过")。
430
+ */
431
+ refuseDispatchWithoutContract(task, goalId) {
432
+ const reason = task.error
433
+ ? `派前必须有工作合同: 合同没签成 → 不派出 (${task.error})`
434
+ : '派前必须有工作合同: 合同没签成 → 不派出';
435
+ task.status = 'pending';
436
+ task.assignedAgentId = undefined;
437
+ task.toAgentId = undefined;
438
+ task.assignedAt = undefined;
439
+ // Goal 上下文要留在任务上: ① 它说明这次派遣是**为哪个目标**尝试的;
440
+ // ② 于是完成门 (`updateTaskStatus` 的 `task.goalId` 判据) 也管得到这条没有合同的任务
441
+ // —— 没有合同就不许自称完成, 不留 goalId 的话它就绕过了那道门。
442
+ if (goalId)
443
+ task.goalId = goalId;
444
+ task.contractRefused = { code: 'contract_not_issued', reason };
445
+ }
446
+ /**
447
+ * 给这次派遣签工作合同 (P2)。只有调用方给了 `goalId` 才签 —— 没有长期目标就没有合同上下文,
448
+ * 这时行为与接线前完全一致 (只记任务)。
449
+ */
450
+ async issueWorkContractFor(fromAgentId, task, requiredCapabilities, opts) {
451
+ if (!opts?.goalId)
452
+ return undefined;
453
+ try {
454
+ const { dispatchChildWork } = await import('./goal-flywheel-wiring.js');
455
+ const capability = (requiredCapabilities[0] || 'general').trim();
456
+ const issued = await dispatchChildWork({
457
+ goalId: opts.goalId,
458
+ parentRunId: opts.parentRunId ?? `subagent-task:${task.id}`,
459
+ childAgentId: task.assignedAgentId || task.toAgentId || `(unassigned:${fromAgentId})`,
460
+ capability,
461
+ objective: task.description,
462
+ inputs: { taskId: task.id, input: task.input ?? null },
463
+ allowedTools: opts.allowedTools ?? [],
464
+ budget: opts.budget ?? { maxSteps: null, maxDurationMs: this.config.taskTimeoutMs, maxAmount: null, currency: null },
465
+ deadline: opts.deadline ?? null,
466
+ successCriteria: (opts.successCriteria?.length ? opts.successCriteria : [task.description]).slice(0, 20),
467
+ issuedBy: `subagent-manager:${fromAgentId}`,
468
+ });
469
+ task.goalId = opts.goalId;
470
+ task.workId = issued.workId;
471
+ task.metadata = { ...(task.metadata ?? {}), workContract: issued };
472
+ await this.saveTasks();
473
+ return issued;
474
+ }
475
+ catch (err) {
476
+ // 签合同失败 → 如实记在任务上 (不许悄悄降级成"没合同也能派")
477
+ task.error = `工作合同签发失败: ${String(err?.message || err).slice(0, 200)}`;
478
+ await this.saveTasks();
479
+ return undefined;
480
+ }
481
+ }
482
+ /**
483
+ * 核验挂着合同的子任务回报 (P2)。
484
+ * 报告可以是**结构化 JSON 文本** (推荐: 子按 `bolloon-work-report/1` 回), 也可以是纯文本 ——
485
+ * 纯文本没有逐条证据 → **不接受为完成** (这正是要堵的"回一段漂亮话就算完成")。
486
+ */
487
+ async validateTaskReport(task, result) {
488
+ const { handleChildReport } = await import('./goal-flywheel-wiring.js');
489
+ const contract = (task.metadata?.workContract ?? null);
490
+ const raw = String(result ?? '');
491
+ let report = null;
492
+ try {
493
+ const parsed = JSON.parse(raw);
494
+ if (parsed && typeof parsed === 'object' && typeof parsed.status === 'string') {
495
+ report = parsed;
496
+ }
497
+ }
498
+ catch { /* 不是 JSON → 当纯文本处理 */ }
499
+ if (!report) {
500
+ report = {
501
+ workId: task.workId,
502
+ childAgentId: contract?.childAgentId ?? task.assignedAgentId ?? task.toAgentId ?? '(unknown)',
503
+ status: 'completed',
504
+ summary: raw.slice(0, 400),
505
+ evidence: [],
506
+ artifacts: [],
507
+ checks: [],
508
+ unresolvedItems: [],
509
+ blockReason: null,
510
+ nextRecommendation: '(未提供)',
511
+ durationMs: 0,
512
+ reportedAt: new Date().toISOString(),
513
+ };
514
+ }
515
+ const out = await handleChildReport({ goalId: task.goalId, workId: task.workId, report });
516
+ return { accepted: out.accepted, reason: out.reason, missingEvidence: out.missingEvidence };
311
517
  }
312
518
  async consultAgent(fromAgentId, toAgentId, query, taskId) {
313
519
  return this.sendMessage(fromAgentId, toAgentId, query, 'query', taskId);
@@ -17,13 +17,16 @@ import * as os from 'os';
17
17
  import * as path from 'path';
18
18
  import * as fs from 'fs';
19
19
  import * as crypto from 'crypto';
20
- import { createGoal, attachRun, markCriterion, completeGoalIfEligible, readGoal, setCriteria, } from '../goal-store.js';
20
+ import { createGoal, attachRun, markCriterion, readGoal, setCriteria, } from '../goal-store.js';
21
21
  import { startRun, recordStep, finishRun, readRun } from '../run-store.js';
22
+ import { captureRunModelConfig } from '../../llm/model-selection.js';
22
23
  import { listTransactions, updateTransaction, setTransactionStatus } from '../x402/transaction-store.js';
23
24
  import { bridgeTransactionToRunGoal } from '../x402/goal-run-bridge.js';
24
25
  import { buyInfoAsTransaction } from '../x402/trade.js';
25
26
  import { publishInfo, listInfo } from '../x402/paid-info-store.js';
26
27
  import { planTransactionRecovery } from '../x402/payment-recovery.js';
28
+ // 2026-09-25 (M0 接线冻结): CLI 的 Run 终止也必须过唯一责任链 (规则 ③④)
29
+ import * as wiring from '../goal-flywheel-wiring.js';
27
30
  import { executeContractSkill, validateResourceOutput, verifyInstallFidelity, loadResourceContract } from '../x402/resource-contract.js';
28
31
  import { adviseResource } from './resource-advisor.js';
29
32
  import { resolveTaskBudget, checkPurchaseAllowed, previewPurchaseImpact } from './task-budget.js';
@@ -313,11 +316,20 @@ export async function runTask(opts) {
313
316
  return { ok: false, card, text: renderReportCard(card), stages, budget, outputIssues: [] };
314
317
  }
315
318
  }
316
- const run = await startRun({ surface: 'cli', goal: opts.task, goalId: goal.goalId });
319
+ const run = await startRun({
320
+ surface: 'cli',
321
+ goal: opts.task,
322
+ goalId: goal.goalId,
323
+ // 2026-09-26: 任务执行链同样要记下"这一跑用的是哪一份模型配置"
324
+ modelConfig: await captureRunModelConfig().catch(() => undefined),
325
+ });
317
326
  await attachRun(goal.goalId, run.runId);
318
327
  const fail = async (stage, note, cardOpts, runStatus = 'needs_human') => {
319
328
  mark(stage, note);
320
329
  await finishRun(run.runId, { status: runStatus, summary: note.slice(0, 200) });
330
+ // ★ 2026-09-25 (M0 接线冻结, 规则 ④): CLI 任务的每条失败出口也是 Run 的终止 →
331
+ // 同样过收尾漏斗 (旧写法只 finishRun, 于是这条路径上没有 Memory / Skill 候选 / 权威 continuation)。
332
+ await wiring.closeTaskRun(run.runId, goal.goalId, note, 'cli');
321
333
  const card = buildReportCard({ ...cardOpts, evidenceRef: { goalId: goal.goalId, runId: run.runId, transactionId: cardOpts.evidenceRef?.transactionId }, budgetLines: [...budget.why, ...(cardOpts.budgetLines || [])] });
322
334
  return { ok: false, card, text: renderReportCard(card), goalId: goal.goalId, runId: run.runId, transactionId: card.evidenceRef.transactionId, stages, budget, outputIssues: cardOpts.blocker ? [cardOpts.blocker] : [] };
323
335
  };
@@ -554,8 +566,9 @@ export async function runTask(opts) {
554
566
  summary: evidenceOk ? `任务完成: ${concl.conclusion || '已产出'}` : '未达标 (证据/契约不完整)',
555
567
  evidence: [`bridge: step=${bridge.stepWritten} evidence=${bridge.evidenceWritten} goal=${bridge.goalEvidenceWritten}`],
556
568
  });
557
- if (evidenceOk)
558
- await completeGoalIfEligible(goal.goalId);
569
+ // ★ M0 (规则 ③④): 成功出口也走同一个收尾漏斗 —— 完不完成由飞轮决策 + 完成门判,
570
+ // 不再在这里直接调 completeGoalIfEligible (那是一条绕过 closeRun 的旧收尾路径)。
571
+ await wiring.closeTaskRun(run.runId, goal.goalId, card.conclusion || card.status, 'cli');
559
572
  mark('report', card.status);
560
573
  return {
561
574
  ok: card.status === '已完成',
@@ -40,6 +40,10 @@ import * as os from 'os';
40
40
  import * as path from 'path';
41
41
  import * as crypto from 'crypto';
42
42
  import { parseGroupLink, listGroups, groupSend, groupMessages } from './gateway-group.js';
43
+ // 2026-09-25: 隐私红线规则表抽到 task-public-text.ts (手机端与桌面端**同一份**)
44
+ import { PRIVACY_RULES, NODE_IDENTITY_RULES, scanPublicText, requirePublicText, scanNodeIdentity, mask } from './task-public-text.js';
45
+ // 保持对外 API 不变 (老调用方/测试继续 from './task-group.js' 拿这些)
46
+ export { PRIVACY_RULES, NODE_IDENTITY_RULES, scanPublicText, requirePublicText, scanNodeIdentity, mask };
43
47
  // ── 常量 ────────────────────────────────────────────────────────────────────
44
48
  /** 消息标记 (一行一条; 解析只认这一行) */
45
49
  export const TRAIL_TAG = '[bolloon-task]';
@@ -55,56 +59,9 @@ export const TRAIL_KIND_LABEL = {
55
59
  final: '终审结论',
56
60
  };
57
61
  export const VERDICTS = ['accept', 'reject', 'unknown'];
58
- /**
59
- * 群消息**禁止出现**的形态 (顺序即报告顺序)。宁可多拦 (拒发 + 说清哪条规则), 也不放行一条带标识符的消息。
60
- *
61
- * 实现纪律 (2026-09-23 踩过的坑, 不可回退):
62
- * · **不要用 `\b` 当边界** —— 消息里的 token 会把空白折成 `_`, 而 `_` 也是 \w, 于是 `判据见_0x1111…`
63
- * 这种"粘在 `_`/汉字后面"的标识符 `\b` 判不出来, 会直接漏进群。一律改成对标识符自身字母表的
64
- * lookahead/lookbehind (`(?<![0-9a-fA-FxX])`, `(?![0-9a-fA-F])`), 粘在 `_`/中文/符号后面也照样命中。
65
- * · ipv6 规则要**避开 ISO 时间戳** (`09:50:12.345Z` 只有两个冒号): 只认 3 组以上 `x:y:z:w` 或含 `::` 的压缩形态。
66
- * · 内容哈希 (sha256 64 位裸 hex) **不在**禁列 —— 交付就是要贴哈希 (它不是密钥, 也没有 0x 前缀)。
67
- */
68
- export const PRIVACY_RULES = [
69
- { rule: 'wallet-address', why: '以太坊/EVM 钱包地址', re: /(?<![0-9a-fA-FxX])0x[0-9a-fA-F]{40}(?![0-9a-fA-F])/ },
70
- { rule: 'private-key-hex', why: '32 字节私钥形态 (0x + 64 hex)', re: /(?<![0-9a-fA-FxX])0x[0-9a-fA-F]{64}(?![0-9a-fA-F])/ },
71
- { rule: 'hex-0x-blob', why: '0x 开头的十六进制块 (签名/哈希/密钥都长这样, 群里一律不放原文)', re: /(?<![0-9a-fA-FxX])0x[0-9a-fA-F]{8,}(?![0-9a-fA-F])/ },
72
- { rule: 'did', why: '去中心化身份 DID', re: /did:[a-z0-9]+:[A-Za-z0-9._:%-]{3,}/i },
73
- { rule: 'peer-id', why: 'libp2p peerId (12D3Koo…/Qm…)', re: /12D3Koo[A-Za-z0-9]{8,}|Qm[1-9A-HJ-NP-Za-km-z]{30,}/ },
74
- { rule: 'multiaddr', why: 'libp2p multiaddr 或 store 地址 (/ip4 /tcp /p2p /orbitdb …)', re: /\/(ip4|ip6|dns|dns4|dns6|tcp|udp|ws|wss|quic|p2p|p2p-circuit|ipfs|orbitdb)(?![A-Za-z0-9])/i },
75
- { rule: 'orbitdb-link', why: 'OrbitDB 链接 (群邀请链接本身也不进群消息)', re: /orbitdb:\/\//i },
76
- { rule: 'ipv4', why: 'IPv4 地址', re: /(?<!\d)\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?!\d)/ },
77
- { rule: 'ipv6', why: 'IPv6 地址 (不含 ISO 时间戳)', re: /(?<![0-9A-Za-z])(?:[0-9a-fA-F]{1,4})?::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}(?![0-9A-Za-z])|(?<![0-9A-Za-z:])(?:[0-9a-fA-F]{1,4}:){3,}[0-9a-fA-F]{1,4}(?![0-9A-Za-z])/ },
78
- { rule: 'pem', why: 'PEM 材料 (私钥/证书)', re: /-----BEGIN [A-Z ]*(PRIVATE KEY|CERTIFICATE)/ },
79
- { rule: 'secret-words', why: '私钥/助记词/密钥字样 (正文里提到即停, 人不该把密钥写进群)', re: /(private\s*key|seed\s*phrase|mnemonic|私钥|助记词|密钥材料)/i },
80
- { rule: 'url', why: 'http(s) 端点 (端点会带 host/IP 与用途指纹)', re: /https?:\/\/\S+/i },
81
- { rule: 'email', why: '邮箱地址', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/ },
82
- ];
83
- function mask(s) {
84
- const t = String(s || '');
85
- return t.length <= 4 ? '****' : `${t.slice(0, 4)}…`;
86
- }
87
- /** 扫一遍文本, 返回命中的隐私规则 (空数组 = 可以发) */
88
- export function scanPublicText(text) {
89
- const t = String(text ?? '');
90
- const out = [];
91
- const seen = new Set();
92
- for (const r of PRIVACY_RULES) {
93
- const m = r.re.exec(t);
94
- if (!m)
95
- continue;
96
- if (seen.has(r.rule))
97
- continue;
98
- seen.add(r.rule);
99
- out.push({ rule: r.rule, why: r.why, masked: mask(String(m[0])), chars: String(m[0]).length });
100
- }
101
- return out;
102
- }
103
- /** 发送前唯一闸门: 命中任何规则 → 拒绝发送 (返回 violations 供 CLI 如实报出) */
104
- export function requirePublicText(text) {
105
- const v = scanPublicText(text);
106
- return v.length === 0 ? { ok: true } : { ok: false, violations: v };
107
- }
62
+ // ── 隐私守卫 ────────────────────────────────────────────────────────────────
63
+ // 规则表与扫描函数本身在 `task-public-text.ts` (单一来源, 手机/桌面共用);
64
+ // 本文件只负责**发送前调用它** (见 sendTrailMessage) 与在群管理输出上再过一遍 (scanNodeIdentity)。
108
65
  // ── 发送者标记 (稳定假名, 原始 DID 不入群) ──────────────────────────────────
109
66
  const sha256Hex = (s) => crypto.createHash('sha256').update(s, 'utf8').digest('hex');
110
67
  /** DID → 稳定假名 `agent-<8位>` (同一个人每次都是同一个标记, 但看不出是谁) */
@@ -147,7 +104,7 @@ export async function resolveSenderTag(explicit) {
147
104
  return {
148
105
  ok: false, code: 'INVALID_ARGUMENT',
149
106
  message: '没有本机身份 (~/.bolloon/identity.json) 也没有 --from → 给不出诚实的发送者标记 (拒, 不假造一个)',
150
- detail: { identityFile: '~/.bolloon/identity.json', accepted: ['--from 委托方', 'bolloon setup'] },
107
+ detail: { identityFile: '~/.bolloon/identity.json', accepted: ['--from 委托方', 'bolloon identity init', 'bolloon setup'] },
151
108
  };
152
109
  }
153
110
  /** 与 gateway-group 内部 `groupIdOf` 同款派生 (它没导出; 这里只作地址比不中时的兜底) */
@@ -0,0 +1,89 @@
1
+ /**
2
+ * task-public-text.ts — **公开文本的隐私红线 (单一来源)** (2026-09-25)
3
+ *
4
+ * 为什么要单独一个文件: 同一条红线要同时把住**两个入口** ——
5
+ * · 桌面/CLI: 群消息发送前 (`task-group.ts` → `requirePublicText`)
6
+ * · 手机 WebView: 手机侧发公告/接单/入群前 (mobile-tasks.ts → 同一函数)
7
+ * 两边各抄一份规则 = 迟早漂移 (一边加了规则另一边没加, 于是手机成了绕过红线的后门)。
8
+ * 所以规则表放在这里, 双方都 import 这一份; 本文件**不 import 任何 node: 模块**,
9
+ * 因此既能进 Node, 也能进浏览器 bundle。
10
+ *
11
+ * 群消息**禁止出现**的形态 (顺序即报告顺序)。宁可多拦 (拒发 + 说清哪条规则),
12
+ * 也不放行一条带标识符的消息。
13
+ *
14
+ * 实现纪律 (2026-09-23 踩过的坑, 不可回退):
15
+ * · **不要用 `\b` 当边界** —— 消息里的 token 会把空白折成 `_`, 而 `_` 也是 \w, 于是 `判据见_0x1111…`
16
+ * 这种"粘在 `_`/汉字后面"的标识符 `\b` 判不出来, 会直接漏进群。一律改成对标识符自身字母表的
17
+ * lookahead/lookbehind (`(?<![0-9a-fA-FxX])`, `(?![0-9a-fA-F])`), 粘在 `_`/中文/符号后面也照样命中。
18
+ * · ipv6 规则要**避开 ISO 时间戳** (`09:50:12.345Z` 只有两个冒号): 只认 3 组以上 `x:y:z:w` 或含 `::` 的压缩形态。
19
+ * · 内容哈希 (sha256 64 位裸 hex) **不在**禁列 —— 交付就是要贴哈希 (它不是密钥, 也没有 0x 前缀)。
20
+ */
21
+ export const PRIVACY_RULES = [
22
+ { rule: 'wallet-address', why: '以太坊/EVM 钱包地址', re: /(?<![0-9a-fA-FxX])0x[0-9a-fA-F]{40}(?![0-9a-fA-F])/ },
23
+ { rule: 'private-key-hex', why: '32 字节私钥形态 (0x + 64 hex)', re: /(?<![0-9a-fA-FxX])0x[0-9a-fA-F]{64}(?![0-9a-fA-F])/ },
24
+ { rule: 'hex-0x-blob', why: '0x 开头的十六进制块 (签名/哈希/密钥都长这样, 群里一律不放原文)', re: /(?<![0-9a-fA-FxX])0x[0-9a-fA-F]{8,}(?![0-9a-fA-F])/ },
25
+ { rule: 'did', why: '去中心化身份 DID', re: /did:[a-z0-9]+:[A-Za-z0-9._:%-]{3,}/i },
26
+ { rule: 'peer-id', why: 'libp2p peerId (12D3Koo…/Qm…)', re: /12D3Koo[A-Za-z0-9]{8,}|Qm[1-9A-HJ-NP-Za-km-z]{30,}/ },
27
+ { rule: 'multiaddr', why: 'libp2p multiaddr 或 store 地址 (/ip4 /tcp /p2p /orbitdb …)', re: /\/(ip4|ip6|dns|dns4|dns6|tcp|udp|ws|wss|quic|p2p|p2p-circuit|ipfs|orbitdb)(?![A-Za-z0-9])/i },
28
+ { rule: 'orbitdb-link', why: 'OrbitDB 链接 (群邀请链接本身也不进群消息)', re: /orbitdb:\/\//i },
29
+ { rule: 'ipv4', why: 'IPv4 地址', re: /(?<!\d)\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?!\d)/ },
30
+ { rule: 'ipv6', why: 'IPv6 地址 (不含 ISO 时间戳)', re: /(?<![0-9A-Za-z])(?:[0-9a-fA-F]{1,4})?::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}(?![0-9A-Za-z])|(?<![0-9A-Za-z:])(?:[0-9a-fA-F]{1,4}:){3,}[0-9a-fA-F]{1,4}(?![0-9A-Za-z])/ },
31
+ { rule: 'pem', why: 'PEM 材料 (私钥/证书)', re: /-----BEGIN [A-Z ]*(PRIVATE KEY|CERTIFICATE)/ },
32
+ { rule: 'secret-words', why: '私钥/助记词/密钥字样 (正文里提到即停, 人不该把密钥写进群)', re: /(private\s*key|seed\s*phrase|mnemonic|私钥|助记词|密钥材料)/i },
33
+ { rule: 'url', why: 'http(s) 端点 (端点会带 host/IP 与用途指纹)', re: /https?:\/\/\S+/i },
34
+ { rule: 'email', why: '邮箱地址', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/ },
35
+ ];
36
+ /** 标识符打码 (只留前 4 字符) */
37
+ export function mask(s) {
38
+ const t = String(s || '');
39
+ return t.length <= 4 ? '****' : `${t.slice(0, 4)}…`;
40
+ }
41
+ /** 扫一遍文本, 返回命中的隐私规则 (空数组 = 可以发) */
42
+ export function scanPublicText(text) {
43
+ const t = String(text ?? '');
44
+ const out = [];
45
+ const seen = new Set();
46
+ for (const r of PRIVACY_RULES) {
47
+ const m = r.re.exec(t);
48
+ if (!m)
49
+ continue;
50
+ if (seen.has(r.rule))
51
+ continue;
52
+ seen.add(r.rule);
53
+ out.push({ rule: r.rule, why: r.why, masked: mask(String(m[0])), chars: String(m[0]).length });
54
+ }
55
+ return out;
56
+ }
57
+ /** 发送前唯一闸门: 命中任何规则 → 拒绝发送 (返回 violations 供 CLI/手机如实报出) */
58
+ export function requirePublicText(text) {
59
+ const v = scanPublicText(text);
60
+ return v.length === 0 ? { ok: true } : { ok: false, violations: v };
61
+ }
62
+ /**
63
+ * **节点身份**口径 (群管理命令 `task group create|join|list|link|leave` 的输出用, 2026-09-24):
64
+ * 与群消息同一张规则表, 但**去掉 orbitdb 那一档** ——
65
+ * · `orbitdb-link` 整条去掉: 邀请链接是群唯一能让别人入群的东西, 必须能打印出来;
66
+ * · `multiaddr` 规则里的 `orbitdb` 选项去掉 (只留节点地址 /ip4 /ip6 /dns /tcp /p2p …):
67
+ * 群 store 地址 (`/orbitdb/zdpu…`) 是**群自己的公开标识**, 不是节点地址。
68
+ * 仍然全拦: 原始 DID · 钱包地址 · peerId · 节点 multiaddr · IPv4/IPv6 · 私钥形态 · PEM · URL · 邮箱。
69
+ *
70
+ * 用法: 群管理**输出**再过一遍这道闸 (`scanNodeIdentity`)。命中 = 输出里真有节点身份/联系方式泄漏,
71
+ * 此时**拒输出并如实报**, 不静默脱敏掉再当成没事 (那样人看到的就不是事实了)。
72
+ */
73
+ export const NODE_IDENTITY_RULES = PRIVACY_RULES
74
+ .filter((r) => r.rule !== 'orbitdb-link')
75
+ .map((r) => (r.rule === 'multiaddr'
76
+ ? { ...r, re: /\/(ip4|ip6|dns|dns4|dns6|tcp|udp|ws|wss|quic|p2p|p2p-circuit|ipfs)(?![A-Za-z0-9])/i }
77
+ : r));
78
+ /** 扫节点身份泄漏 (空数组 = 输出干净) */
79
+ export function scanNodeIdentity(text) {
80
+ const t = String(text ?? '');
81
+ const out = [];
82
+ for (const r of NODE_IDENTITY_RULES) {
83
+ const m = r.re.exec(t);
84
+ if (!m)
85
+ continue;
86
+ out.push({ rule: r.rule, why: r.why, masked: mask(String(m[0])), chars: String(m[0]).length });
87
+ }
88
+ return out;
89
+ }
@@ -13,6 +13,8 @@
13
13
  * - Token budget awareness
14
14
  */
15
15
  import { detectRepeatingCalls, toolCallArgsHash } from './loop-review.js';
16
+ // 2026-09-26: 回程派发还原 (LLM 回吐的 API 名 → 注册表真名; 净化在 pi-ai.ts 唯一边界)
17
+ import { resolveApiToolName } from '../llm/tool-name.js';
16
18
  /**
17
19
  * 2026-08-11 (借鉴 Hermes `_canonicalize_tool_call_arguments`): 工具参数规范化 —
18
20
  * LLM 返回的 arguments 常带尾随散文/尾逗号/代码围栏, 直接 JSON.parse 失败会丢整个工具调用.
@@ -234,7 +236,8 @@ export class WorkflowPivotLoop {
234
236
  const fn = tc?.function;
235
237
  if (!fn || !fn.name)
236
238
  continue;
237
- const name = fn.name;
239
+ // 2026-09-26: 回程还原 — 原生 tool_calls 里的名字是净化后的 API 名
240
+ const name = resolveApiToolName(fn.name);
238
241
  if (!this.tools.has(name)) {
239
242
  this.pushContinuationHint(`工具 ${name} 未注册/不存在, 调用已跳过 — 请换用已提供的工具`);
240
243
  continue;
@@ -613,11 +616,12 @@ export class WorkflowPivotLoop {
613
616
  while ((match = toolUseRe.exec(content)) !== null) {
614
617
  try {
615
618
  const obj = JSON.parse(match[1]);
616
- if (obj && obj.name && this.tools.has(obj.name)) {
619
+ const realName = obj && obj.name ? resolveApiToolName(String(obj.name)) : '';
620
+ if (obj && realName && this.tools.has(realName)) {
617
621
  const args = this.normalizeArgs(canonicalizeToolCallArguments(obj.arguments));
618
- pending.push({ name: obj.name, args, description: '', parameters: {} });
622
+ pending.push({ name: realName, args, description: '', parameters: {} });
619
623
  }
620
- else if (obj && obj.name && !this.tools.has(obj.name)) {
624
+ else if (obj && obj.name && !this.tools.has(realName)) {
621
625
  this.pushContinuationHint(`工具 ${obj.name} 未注册/不存在, 调用已跳过 — 请换用已提供的工具`);
622
626
  }
623
627
  }
@@ -634,7 +638,8 @@ export class WorkflowPivotLoop {
634
638
  const invokeRe = /<invoke\s+name="(\w+)"\s*>([\s\S]*?)<\/invoke>/g;
635
639
  let im;
636
640
  while ((im = invokeRe.exec(block)) !== null) {
637
- const name = im[1];
641
+ // 2026-09-26: 回程还原 — LLM 可能回吐的是净化后的 API 名
642
+ const name = resolveApiToolName(im[1]);
638
643
  if (!this.tools.has(name))
639
644
  continue;
640
645
  // 抓 <parameter name="k">v</parameter> 列表
@@ -642,7 +647,7 @@ export class WorkflowPivotLoop {
642
647
  const paramRe = /<parameter\s+name="(\w+)"\s*>([\s\S]*?)<\/parameter>/g;
643
648
  let pm;
644
649
  while ((pm = paramRe.exec(im[2])) !== null) {
645
- args[pm[1]] = pm[2].trim().replace(/^["']|['"]$/g, '');
650
+ args[pm[1]] = pm[2].trim().replace(/^["']|["']$/g, '');
646
651
  }
647
652
  // 避免重复添加
648
653
  if (!pending.some(p => p.name === name)) {
@@ -659,7 +664,8 @@ export class WorkflowPivotLoop {
659
664
  const invokeRe = /<invoke\s+name="(\w+)"\s*>([\s\S]*?)<\/invoke>/g;
660
665
  let im;
661
666
  while ((im = invokeRe.exec(block)) !== null) {
662
- const name = im[1];
667
+ // 2026-09-26: 回程还原 — LLM 可能回吐的是净化后的 API 名
668
+ const name = resolveApiToolName(im[1]);
663
669
  if (!this.tools.has(name))
664
670
  continue;
665
671
  const args = {};
@@ -676,7 +682,7 @@ export class WorkflowPivotLoop {
676
682
  // Pattern 1: Chinese format "调用工具: tool_name(args)"
677
683
  const pattern1 = /调用工具[::]\s*(\w+)\s*\(([^)]*)\)/g;
678
684
  while ((match = pattern1.exec(content)) !== null) {
679
- const name = match[1];
685
+ const name = resolveApiToolName(match[1]);
680
686
  const argsStr = match[2];
681
687
  const args = this.parseArgs(argsStr);
682
688
  if (this.tools.has(name)) {
@@ -686,7 +692,7 @@ export class WorkflowPivotLoop {
686
692
  // Pattern 2: tool_name(args) format
687
693
  const pattern2 = /(\w+)\s*\(\s*([^)]*)\s*\)/g;
688
694
  while ((match = pattern2.exec(content)) !== null) {
689
- const name = match[1];
695
+ const name = resolveApiToolName(match[1]);
690
696
  const argsStr = match[2];
691
697
  // Skip if already matched or doesn't look like a tool call
692
698
  if (pending.some(p => p.name === name))
@@ -703,8 +709,9 @@ export class WorkflowPivotLoop {
703
709
  const parsed = JSON.parse(jsonMatch[0]);
704
710
  if (Array.isArray(parsed.tool_calls)) {
705
711
  for (const tc of parsed.tool_calls) {
706
- if (this.tools.has(tc.name)) {
707
- pending.push({ name: tc.name, args: tc.args || {}, description: '', parameters: {} });
712
+ const realName = resolveApiToolName(String(tc?.name ?? ''));
713
+ if (this.tools.has(realName)) {
714
+ pending.push({ name: realName, args: tc.args || {}, description: '', parameters: {} });
708
715
  }
709
716
  }
710
717
  }
@@ -720,7 +727,7 @@ export class WorkflowPivotLoop {
720
727
  const singleJsonRe = /\{\s*"name"\s*:\s*"(\w+)"\s*,\s*"(?:arguments|input)"\s*:\s*(\{[\s\S]*?\})\s*\}/g;
721
728
  let singleMatch;
722
729
  while ((singleMatch = singleJsonRe.exec(content)) !== null) {
723
- const name = singleMatch[1];
730
+ const name = resolveApiToolName(singleMatch[1]);
724
731
  if (pending.some(p => p.name === name))
725
732
  continue;
726
733
  if (!this.tools.has(name))
@@ -0,0 +1,65 @@
1
+ /**
2
+ * clipboard.ts — 把文本真写进**系统剪贴板** (2026-09-27)
3
+ *
4
+ * ## 为什么需要它
5
+ *
6
+ * 面板是 Ink 画的**一屏自绘帧**: 每帧都是 `eraseLines` + 重画。终端里的鼠标框选靠终端自己维护,
7
+ * 而"每秒重画一帧"会不断打断正在进行的框选 —— 于是"想抄一段回复"变成了撞运气。
8
+ * 真终端字节证据 (`scripts/verify-cli-panel.ts` 抓的那一份):
9
+ * · `ESC[2J ESC[3J ESC[H` (**清屏 + 清回滚缓冲**) 在 30s 内出现 24 次;
10
+ * · 每一次都来自 Ink 的 `renderInteractiveFrame` (`shouldClearTerminalForFrame`: 帧高缩了/上一帧是别的形状)。
11
+ * 这既解释了"选不中", 也解释了"往上滚看不到更早的内容"。
12
+ *
13
+ * 所以复制走**两条腿**:
14
+ * ① 结构上少打断: 版面固定成"整屏高" + 跟随暂停时**完全不重画** (见 `ink-app.tsx`) → 帧不再乱清;
15
+ * ② 一条**不靠鼠标**的显式通路: `/copy` —— 把最近一条/整段回复交给系统剪贴板工具。
16
+ * 能用 `pbcopy` 就不假装"已经复制到剪贴板了"。
17
+ */
18
+ import { spawnSync } from 'child_process';
19
+ /** 剪贴板工具按平台挑 (第一个能跑的胜出); 环境变量 `BOLLOON_CLIPBOARD_CMD` 可显式覆盖 (验收门用) */
20
+ export function clipboardCommand(env = process.env) {
21
+ const override = (env.BOLLOON_CLIPBOARD_CMD ?? '').trim();
22
+ if (override) {
23
+ const parts = override.split(/\s+/).filter(Boolean);
24
+ if (parts.length)
25
+ return { cmd: parts[0], args: parts.slice(1) };
26
+ }
27
+ switch (process.platform) {
28
+ case 'darwin': return { cmd: 'pbcopy', args: [] };
29
+ case 'win32': return { cmd: 'clip', args: [] };
30
+ default:
31
+ if (env.WAYLAND_DISPLAY)
32
+ return { cmd: 'wl-copy', args: [] };
33
+ if (env.DISPLAY)
34
+ return { cmd: 'xclip', args: ['-selection', 'clipboard'] };
35
+ return null;
36
+ }
37
+ }
38
+ /** 剪贴板工具名 (报告/界面里只说工具名, 不打印被复制的内容) */
39
+ export function clipboardToolName(env = process.env) {
40
+ const c = clipboardCommand(env);
41
+ return c ? c.cmd : null;
42
+ }
43
+ /**
44
+ * 真写剪贴板 (同步: 这条命令要立刻给出"成没成", 不能 fire-and-forget)。
45
+ * 返回值只含**长度与工具名** —— 文本内容绝不回显/落日志 (回复里可能有 key/私密内容)。
46
+ */
47
+ export function clipboardWrite(text, env = process.env) {
48
+ const chars = String(text ?? '').length;
49
+ const c = clipboardCommand(env);
50
+ if (!c) {
51
+ return { ok: false, chars, error: '这个平台没有可用的剪贴板命令 (macOS: pbcopy / Windows: clip / Linux: wl-copy 或 xclip)' };
52
+ }
53
+ try {
54
+ const r = spawnSync(c.cmd, c.args, { input: String(text ?? ''), encoding: 'utf8', timeout: 5000 });
55
+ if (r.error)
56
+ return { ok: false, chars, tool: c.cmd, error: `调用 ${c.cmd} 失败: ${r.error.message}` };
57
+ if (r.status !== 0) {
58
+ return { ok: false, chars, tool: c.cmd, error: `${c.cmd} 退出码 ${r.status}${r.stderr ? `: ${String(r.stderr).slice(0, 120)}` : ''}` };
59
+ }
60
+ return { ok: true, chars, tool: c.cmd };
61
+ }
62
+ catch (e) {
63
+ return { ok: false, chars, tool: c.cmd, error: String(e?.message || e).slice(0, 160) };
64
+ }
65
+ }