@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
@@ -103,7 +103,34 @@ function writeEvents(list, h) {
103
103
  /* 统计失败绝不影响主路径 */
104
104
  }
105
105
  }
106
- // ── 匿名化 ──────────────────────────────────────────────────────────────────
106
+ export function tallySignatureAudit(h, opts = {}) {
107
+ const now = opts.now ?? Date.now();
108
+ const windowMs = opts.windowMs ?? PULSE_LIMITS.windowMs;
109
+ const file = path.join(home(h), '.bolloon', 'wallet-signatures.jsonl');
110
+ if (!fs.existsSync(file))
111
+ return { available: false, count: 0, window_ms: windowMs };
112
+ let count = 0;
113
+ try {
114
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
115
+ if (!line.trim())
116
+ continue;
117
+ let row = null;
118
+ try {
119
+ row = JSON.parse(line);
120
+ }
121
+ catch {
122
+ continue;
123
+ } // 坏行跳过, 不让一行坏数据毁掉整个计数
124
+ const at = Number(row?.at);
125
+ if (Number.isFinite(at) && at >= now - windowMs)
126
+ count += 1;
127
+ }
128
+ }
129
+ catch {
130
+ return { available: false, count: 0, window_ms: windowMs };
131
+ }
132
+ return { available: true, count, window_ms: windowMs };
133
+ }
107
134
  /** 节点摘要: sha256(值) 前 16 位。不可逆, 不暴露原值。 */
108
135
  export function nodeDigest(value) {
109
136
  return crypto.createHash('sha256').update(`bolloon-pulse|${String(value)}`).digest('hex').slice(0, 16);
@@ -728,6 +755,8 @@ export function buildChainIdScope(rows) {
728
755
  * (页面不许出现「0 个任务」与「N 行任务」并存而**不解释**)
729
756
  * ⑥ 0x 长 hex 越界 (2026-09-23): 只许出现在 tx_hash/contract/explorer_* 四个白名单键下 ——
730
757
  * EOA 地址跑到别的键 (或白名单键形状不对) 就拒绝导出
758
+ * ⑧ ★ **同一概念不许并排两个数** (2026-09-24, 见 `totalsScopeIssues`): 顶部计数与表格行数/
759
+ * 同源计数矛盾, 或者某个数没有来源却不标「未接入」→ 拒绝导出 (这道门在 UI 侧还有一份镜像)
731
760
  */
732
761
  export function snapshotConsistencyIssues(snap) {
733
762
  const issues = [];
@@ -756,8 +785,137 @@ export function snapshotConsistencyIssues(snap) {
756
785
  }
757
786
  issues.push(...auditPublicHexLeaks(snap)); // ⑥ 0x 长 hex 越界 → 拒绝导出 (不静默放行)
758
787
  issues.push(...openTasksIssues(snap)); // ⑦ 待接单任务的字段白名单与「只含未认领」不变式
788
+ issues.push(...totalsScopeIssues(snap)); // ⑧ ★ 同一概念不得并排两个数 (顶部 vs 表格)
759
789
  return issues;
760
790
  }
791
+ /** 顶部逐字段口径的键 (与 `TotalsFieldKey` 逐字一致; 少一个 = 那个数没有来源说明) */
792
+ export const TOTALS_FIELD_KEYS = [
793
+ 'nodes', 'agents', 'active_agents', 'seen_last_24h',
794
+ 'tasks', 'tasks_completed', 'tasks_verified', 'tasks_settled', 'signatures',
795
+ ];
796
+ const TOTALS_FIELD_SOURCES = ['pulse-events', 'chain-index', 'signature-audit', 'none'];
797
+ const TOTALS_FIELD_WINDOWS = ['window-24h', 'full', 'unknown'];
798
+ /** 「未接入」类措辞 (无源时必须出现, 否则等于没说清为什么没有数) */
799
+ const UNAVAILABLE_WORDS = /未接入|not connected|无可用源|no available source/;
800
+ /**
801
+ * ★ 新不变量门 (2026-09-24 leo:「数量怎么对不上」): **同一概念不许并排两个数**。
802
+ *
803
+ * 检查的是「页面会把它们放在同一屏里」的那几个数 —— 它们的口径必须各自挂得住:
804
+ * ① 逐字段口径 `totals_scope.fields` 九个键必须齐 (source/window/short/label 双语都在) ——
805
+ * 缺了就只能靠 notes 辩解, 而那正是当初「顶部 0 个任务 vs 表格 25 行」能上线的路径;
806
+ * ② 值 `null` ⇔ 口径标 `none` + 明说「未接入」; **有源却不给数** 或 **无源却拿 0 冒充** 都算矛盾;
807
+ * ③ 同一概念对账 (表里有 5 个任务而顶部写 0 任务 → 判红):
808
+ * · 链上索引可用时, 顶部 tasks/tasks_completed/tasks_settled **必须逐字等于** activity_totals
809
+ * 的同名字段 (同源即恒等, 任何解释都救不了);
810
+ * · 表格行数/同源计数非零而顶部为 0 或缺值 → 必须有口径说明 (note 里写清) 才放行;
811
+ * · 顶部说有 N 个任务而表格一行都没有 (反向矛盾) → 同样判红。
812
+ * `status === 'unavailable'` 的整份快照不发布任何计数 (页面整块显示「快照读不到」), 故只查形状。
813
+ */
814
+ export function totalsScopeIssues(snap) {
815
+ const out = [];
816
+ const t = snap?.totals || {};
817
+ const ts = snap?.totals_scope;
818
+ const at = snap?.activity_totals || {};
819
+ const rows = Array.isArray(snap?.confirmed_activity) ? snap.confirmed_activity : [];
820
+ const unavailableSnap = String(snap?.status || '') === 'unavailable';
821
+ if (!ts || typeof ts !== 'object')
822
+ return ['totals_scope 缺失 (顶部计数的口径必须随快照一起给)'];
823
+ if (!ts.fields || typeof ts.fields !== 'object') {
824
+ return ['totals_scope.fields 缺失 (顶部每个数都要能就地说明来自哪 —— 不许只靠 notes 辩解)'];
825
+ }
826
+ // ① 形状: 九个键齐 + 每个键的 source/window/short/label 都合法
827
+ for (const k of TOTALS_FIELD_KEYS) {
828
+ const f = ts.fields[k];
829
+ if (!f || typeof f !== 'object') {
830
+ out.push(`totals_scope.fields.${k} 缺失`);
831
+ continue;
832
+ }
833
+ if (!TOTALS_FIELD_SOURCES.includes(f.source))
834
+ out.push(`totals_scope.fields.${k}.source=${JSON.stringify(f.source)} 不在白名单`);
835
+ if (!TOTALS_FIELD_WINDOWS.includes(f.window))
836
+ out.push(`totals_scope.fields.${k}.window=${JSON.stringify(f.window)} 不在白名单`);
837
+ if (!f.label?.zh || !f.label?.en)
838
+ out.push(`totals_scope.fields.${k}.label 必须双语都给`);
839
+ if (!f.short?.zh || !f.short?.en)
840
+ out.push(`totals_scope.fields.${k}.short 必须双语都给 (页面要就地贴在数字旁)`);
841
+ }
842
+ if (out.length)
843
+ return out; // 形状都不对就不往下算数
844
+ if (!unavailableSnap) {
845
+ // ② 值 ⇔ 口径 (null = 未接入; 有源必须有数; 无源不许给 0)
846
+ for (const k of TOTALS_FIELD_KEYS) {
847
+ const f = ts.fields[k];
848
+ const v = t[k];
849
+ const isNull = v === null || v === undefined;
850
+ if (isNull) {
851
+ if (f.source !== 'none') {
852
+ out.push(`totals.${k} 没有值 (未接入) 却标了源 ${f.source} —— 有源就必须给真值, 没源就标 none`);
853
+ }
854
+ if (!UNAVAILABLE_WORDS.test(`${f.short?.zh}${f.short?.en}${f.label?.zh}${f.label?.en}`)) {
855
+ out.push(`totals.${k} 没有值却没有一句「未接入」说明 (读者会把空白读成 0)`);
856
+ }
857
+ }
858
+ else {
859
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) {
860
+ out.push(`totals.${k}=${JSON.stringify(v)} 不是非负有限数`);
861
+ }
862
+ else if (f.source === 'none') {
863
+ out.push(`totals.${k}=${v} 却标「无源」 —— 不许拿裸 0 冒充「没有接入」(那会被读成"没发生过")`);
864
+ }
865
+ }
866
+ }
867
+ // ③ 同一概念对账 (顶部计数 vs 表格行数/同源计数)
868
+ const notes = Array.isArray(snap.notes) ? snap.notes.join(' ') : '';
869
+ const pairs = [
870
+ ['tasks', t.tasks, at.tasks],
871
+ ['tasks_completed', t.tasks_completed, at.tasks_completed],
872
+ ['tasks_settled', t.tasks_settled, at.tasks_settled],
873
+ ];
874
+ const tableRows = Number(at.rows) || rows.length;
875
+ const sameSourceChain = String(at.source) === 'chain-index';
876
+ // ⑨ 声明与能力不符: 某字段自称「链上索引」口径, 而这一份快照的链上索引根本不可用 ——
877
+ // 那就是替一个不存在的源背书 (页面会写「链上索引·全量」, 而索引这一份没读出来)。
878
+ // 注: 本块必须放在 pairs / sameSourceChain 之后 (先声明后使用, 否则 TDZ 崩)。
879
+ if (!sameSourceChain) {
880
+ for (const [k] of pairs) {
881
+ const f = ts.fields[k];
882
+ if (f.source === 'chain-index') {
883
+ out.push(`totals_scope.fields.${k}.source='chain-index' 而这一份快照的链上索引不可用 ` +
884
+ `(activity_totals.source=${JSON.stringify(at.source)}) —— 声明与能力不符`);
885
+ }
886
+ }
887
+ }
888
+ for (const [k, top, src] of pairs) {
889
+ const n = Number(src) || 0;
890
+ const topN = (top === null || top === undefined) ? null : Number(top);
891
+ const topScope = ts.fields[k];
892
+ if (sameSourceChain && topScope.source === 'chain-index') {
893
+ // 同源: 必须逐字相等, 不需要任何"解释"
894
+ if (topN !== n) {
895
+ out.push(`同一概念两个数: 顶部 totals.${k}=${topN === null ? '未接入' : topN} ≠ 链上索引同源计数 activity_totals.${k}=${n}` +
896
+ ` (表格 ${tableRows} 行) —— 同源即恒等, 页面并排出现两个数就是矛盾`);
897
+ }
898
+ continue;
899
+ }
900
+ if (n > 0 && (topN === null || topN === 0)) {
901
+ const explained = notes.includes('口径') && notes.includes(String(tableRows)) && notes.includes('脉冲事件');
902
+ if (!explained) {
903
+ out.push(`顶部 totals.${k}=${topN === null ? '未接入' : 0} 与表格里的 ${n} 个同类 (${tableRows} 行) 矛盾, 且没有任何口径说明`);
904
+ }
905
+ }
906
+ if (topN !== null && n === 0 && tableRows === 0 && topN > 0) {
907
+ out.push(`顶部 totals.${k}=${topN} 而表格一行都没有 (${tableRows} 行) —— 反向矛盾: 数不出行却报了数`);
908
+ }
909
+ }
910
+ }
911
+ // ④ 明说"数字不一样"时必须有解释 (老规矩保留; 不许只靠一句 label)
912
+ if (ts.differs_from_activity === true) {
913
+ const notes = Array.isArray(snap.notes) ? snap.notes.join(' ') : '';
914
+ if (!notes.includes('口径'))
915
+ out.push('totals_scope.differs_from_activity=true 但 notes 里没有任何口径说明');
916
+ }
917
+ return out;
918
+ }
761
919
  /** open_tasks 行的字段白名单 (与 `OpenTaskRow` 逐字一致; 多一个键 = 可能把正文/身份带出去了) */
762
920
  export const OPEN_TASK_KEYS = ['capability', 'budget', 'currency', 'network', 'deadline', 'claimed', 'announcementId'];
763
921
  /**
@@ -798,26 +956,85 @@ export function openTasksIssues(snap) {
798
956
  });
799
957
  return out;
800
958
  }
959
+ // ── 顶部计数的逐字段口径 (2026-09-24) ────────────────────────────────────────
960
+ // 页面把 8~9 个数字并排放在**一行**里, 一行一个总口径是解释不了它们的 —— 每个数必须自带来源。
961
+ const WINDOW_HOURS = Math.round(PULSE_LIMITS.windowMs / 3600000);
962
+ /** 「本节点 N h 脉冲事件」口径 (节点/智能体类计数, 以及链上索引不可用时的降级口径) */
963
+ function pulseField(what) {
964
+ return {
965
+ source: 'pulse-events',
966
+ window: 'window-24h',
967
+ short: { zh: `${WINDOW_HOURS}h 脉冲`, en: `${WINDOW_HOURS}h pulse` },
968
+ label: {
969
+ zh: `本节点 ${WINDOW_HOURS}h 观察窗口内收到的脉冲事件 · ${what.zh}`,
970
+ en: `pulse events received by this node within the ${WINDOW_HOURS}h window · ${what.en}`,
971
+ },
972
+ };
973
+ }
974
+ /** 「链上索引 · 全量」口径 (与下方活动表**同源**: 同源即恒等, 页面不许并排两个数) */
975
+ function chainField(what) {
976
+ return {
977
+ source: 'chain-index',
978
+ window: 'full',
979
+ short: { zh: '链上索引·全量', en: 'chain index · whole' },
980
+ label: {
981
+ zh: `链上索引全量 (与下方活动表同源) · ${what.zh}`,
982
+ en: `chain index, whole (same source as the activity table below) · ${what.en}`,
983
+ },
984
+ };
985
+ }
986
+ /** 「本机签名审计账」口径 (真源: `<home>/.bolloon/wallet-signatures.jsonl` 窗口内条数) */
987
+ function signatureAuditField() {
988
+ return {
989
+ source: 'signature-audit',
990
+ window: 'window-24h',
991
+ short: { zh: `${WINDOW_HOURS}h 签名审计`, en: `${WINDOW_HOURS}h signature audit` },
992
+ label: {
993
+ zh: `本机签名审计账 (wallet-signatures.jsonl) ${WINDOW_HOURS}h 内条数 —— 只数条数, 不含签名内容`,
994
+ en: `rows in this node's signature audit log (wallet-signatures.jsonl) within ${WINDOW_HOURS}h — count only, no content`,
995
+ },
996
+ };
997
+ }
998
+ /** 「没有可用源」= 未接入 (值必须是 null; 页面必须写「未接入」, 不许拿 0 冒充「没发生过」) */
999
+ function noneField(why) {
1000
+ return {
1001
+ source: 'none',
1002
+ window: 'unknown',
1003
+ short: { zh: '未接入', en: 'not connected' },
1004
+ label: { zh: `没有可用源 → 报「未接入」而不是 0 · ${why.zh}`, en: `no available source → reported as "not connected", not 0 · ${why.en}` },
1005
+ unavailable: true,
1006
+ };
1007
+ }
801
1008
  export function computeSnapshot(events, opts) {
802
1009
  const now = opts.now;
803
1010
  const fresh_until = now + PULSE_LIMITS.snapshotTtlMs;
804
1011
  if (opts.unavailable) {
805
1012
  const emptyRows = [];
806
1013
  const gates = normalizeActivityGates(DEFAULT_CONFIRMATIONS);
1014
+ // 观察层不可用 = **没有数** (不是 0): 计数一律 null/0 占位由页面整块隐藏, 口径逐字段标「未接入」。
1015
+ const downField = noneField({ zh: '观察层不可用', en: 'observation layer unavailable' });
807
1016
  return {
808
1017
  status: 'unavailable',
809
1018
  generated_at: now,
810
1019
  fresh_until,
811
1020
  scope: 'observed',
812
1021
  scope_label: { zh: '当前节点观察到', en: 'Observed by this node' },
813
- totals: { nodes: 0, agents: 0, active_agents: 0, seen_last_24h: 0, tasks: 0, tasks_completed: 0, tasks_verified: 0, signatures: 0 },
1022
+ totals: {
1023
+ nodes: 0, agents: 0, active_agents: 0, seen_last_24h: 0,
1024
+ tasks: null, tasks_completed: null, tasks_verified: null, signatures: null, tasks_settled: null,
1025
+ },
814
1026
  totals_scope: {
815
1027
  source: 'pulse-events', window_ms: PULSE_LIMITS.windowMs,
816
1028
  label: {
817
- zh: `只统计本节点 ${PULSE_LIMITS.windowMs / 3600000}h 观察窗口内收到的脉冲事件 (本节点自己上报的)`,
818
- en: `Only pulse events received by this node within the ${PULSE_LIMITS.windowMs / 3600000}h observation window (reported by this node itself)`,
1029
+ zh: `本节点 ${WINDOW_HOURS}h 观察窗口的脉冲事件层不可用 → 没有数 (不是 0)`,
1030
+ en: `This node's ${WINDOW_HOURS}h pulse layer is unavailable → no counts (not zero)`,
819
1031
  },
820
1032
  differs_from_activity: false,
1033
+ fields: {
1034
+ nodes: downField, agents: downField, active_agents: downField, seen_last_24h: downField,
1035
+ tasks: downField, tasks_completed: downField, tasks_verified: downField,
1036
+ tasks_settled: downField, signatures: downField,
1037
+ },
821
1038
  },
822
1039
  capabilities: [],
823
1040
  recent_activity: [],
@@ -859,10 +1076,13 @@ export function computeSnapshot(events, opts) {
859
1076
  capabilities.push({ key: 'other', count: otherCount.size });
860
1077
  capabilities.sort((a, b) => b.count - a.count || (a.key < b.key ? -1 : 1));
861
1078
  }
862
- // 经济计数: 按**不同任务**去重 (同一任务重复事件不虚增), 且只给聚合数不给内容
1079
+ // 经济计数 (**脉冲口径**) —— 注意: 顶部「任务/已完成/已结算」的**权威源是链上索引**
1080
+ // (见下面 chainAuthoritative 那一段), 脉冲口径只在索引不可用时才顶上去。
1081
+ // 按**不同任务**去重 (同一任务重复事件不虚增), 且只给聚合数不给内容
863
1082
  const tasks = new Set();
864
1083
  const tasksCompleted = new Set();
865
1084
  const tasksVerified = new Set();
1085
+ const tasksSettledPulse = new Set();
866
1086
  for (const e of window) {
867
1087
  const t = e.taskProof;
868
1088
  if (!t)
@@ -871,6 +1091,8 @@ export function computeSnapshot(events, opts) {
871
1091
  tasks.add(t);
872
1092
  if (e.type === 'task_completed' || e.type === 'trade_settled')
873
1093
  tasksCompleted.add(t);
1094
+ if (e.type === 'trade_settled')
1095
+ tasksSettledPulse.add(t);
874
1096
  if (e.type === 'trade_verified')
875
1097
  tasksVerified.add(t);
876
1098
  }
@@ -895,15 +1117,62 @@ export function computeSnapshot(events, opts) {
895
1117
  // 同源计数 + 链归属: **输入就是上表那批行**, 所以不可能与行数/链 id 打架
896
1118
  const activity_totals = summarizeActivityRows(activity.rows, { source: activity.source, gates: activity.gates });
897
1119
  const chain_id_scope = buildChainIdScope(activity.rows);
1120
+ // ★ 2026-09-24 (leo: 「数量怎么对不上, 尤其是后面的任务和钱包」):
1121
+ // 顶部任务类计数的**权威源改成链上索引** —— 它与下面那张表**同源**, 同源即恒等,
1122
+ // 从根上消灭「顶部 0 个任务 / 表格 15 行任务」这种并排矛盾 (不再靠 notes 辩解)。
1123
+ // 链上索引不可用 (降级 pulse-events) 时才退回旧的脉冲口径, 并在 fields 里如实标出降级。
1124
+ const chainAuthoritative = activity.source === 'chain-index';
1125
+ const totalsTasks = chainAuthoritative ? activity_totals.tasks : tasks.size;
1126
+ const totalsCompleted = chainAuthoritative ? activity_totals.tasks_completed : tasksCompleted.size;
1127
+ const totalsSettled = chainAuthoritative ? activity_totals.tasks_settled : tasksSettledPulse.size;
1128
+ // 「验真」在链上索引里**没有**对应事件 → 链上口径下这个数没有源 (null + 未接入), 不拿结算数冒充已验证
1129
+ const totalsVerified = chainAuthoritative ? null : tasksVerified.size;
1130
+ // 钱包签名: 真源 = 本机签名审计账 (窗口内条数, 只计数); 没有审计账 → 退回脉冲事件上报数;
1131
+ // 两个都没有 → null (未接入)。**绝不**在没有源的时候报 0 (那是「没发生过」, 是另一句话)。
1132
+ const sigTally = opts.signatureAudit ?? null;
1133
+ const auditWired = !!sigTally && sigTally.available === true;
1134
+ const totalsSignatures = auditWired
1135
+ ? Number(sigTally.count)
1136
+ : (signatureKeys.size > 0 ? signatureKeys.size : null);
1137
+ const fields = {
1138
+ nodes: pulseField({ zh: '不同节点数', en: 'distinct nodes' }),
1139
+ agents: pulseField({ zh: '不同 Agent 数', en: 'distinct agents' }),
1140
+ active_agents: pulseField({ zh: '活跃 Agent 数', en: 'active agents' }),
1141
+ seen_last_24h: pulseField({ zh: '24h 内出现过的不同 Agent', en: 'distinct agents seen within 24h' }),
1142
+ tasks: chainAuthoritative
1143
+ ? chainField({ zh: '有链上活动的不同任务', en: 'distinct tasks with on-chain activity' })
1144
+ : pulseField({ zh: '观察到发起的任务', en: 'tasks observed as created' }),
1145
+ tasks_completed: chainAuthoritative
1146
+ ? chainField({ zh: '交付完成 (ProofSubmittedV2) 的不同任务', en: 'distinct tasks with ProofSubmittedV2' })
1147
+ : pulseField({ zh: '交付完成的任务', en: 'tasks observed as completed' }),
1148
+ tasks_settled: chainAuthoritative
1149
+ ? chainField({ zh: '托管结算 (Released/Refunded/Expired/Disputed) 的不同任务', en: 'distinct tasks with an on-chain escrow settlement' })
1150
+ : pulseField({ zh: '链上口径结算的任务', en: 'tasks with on-chain settlement' }),
1151
+ tasks_verified: chainAuthoritative
1152
+ ? noneField({ zh: '链上索引没有「验真」事件', en: 'the chain index has no verification event' })
1153
+ : pulseField({ zh: '真验真 (trade_verified) 的任务', en: 'tasks truly verified (trade_verified)' }),
1154
+ signatures: auditWired
1155
+ ? signatureAuditField()
1156
+ : (signatureKeys.size > 0
1157
+ ? pulseField({ zh: '上报过的钱包签名 (按来源+时刻去重)', en: 'reported wallet signatures (deduped by source+time)' })
1158
+ : noneField({ zh: '本节点既没有签名审计账, 也没有签名脉冲事件', en: 'this node has neither a signature audit log nor signature pulse events' })),
1159
+ };
898
1160
  const totals_scope = {
899
- source: 'pulse-events',
1161
+ source: chainAuthoritative ? 'mixed' : 'pulse-events',
900
1162
  window_ms: PULSE_LIMITS.windowMs,
901
1163
  label: {
902
- zh: `只统计本节点 ${PULSE_LIMITS.windowMs / 3600000}h 观察窗口内收到的脉冲事件 (本节点自己上报的)`,
903
- en: `Only pulse events received by this node within the ${PULSE_LIMITS.windowMs / 3600000}h observation window (reported by this node itself)`,
1164
+ zh: chainAuthoritative
1165
+ ? `节点/智能体 = 本节点 ${WINDOW_HOURS}h 脉冲事件; 任务/已完成/已结算 = 链上索引全量 (与下表同源); 钱包签名 = 本机签名审计`
1166
+ : `只统计本节点 ${WINDOW_HOURS}h 观察窗口内收到的脉冲事件 (本节点自己上报的)`,
1167
+ en: chainAuthoritative
1168
+ ? `nodes/agents = this node's ${WINDOW_HOURS}h pulse events; tasks/completed/settled = chain index, whole (same source as the table below); wallet signatures = this node's signature audit log`
1169
+ : `Only pulse events received by this node within the ${WINDOW_HOURS}h observation window (reported by this node itself)`,
904
1170
  },
905
1171
  differs_from_activity: activity_totals.rows > 0 &&
906
- (tasks.size !== activity_totals.tasks || tasksCompleted.size !== activity_totals.tasks_completed),
1172
+ (totalsTasks !== activity_totals.tasks ||
1173
+ totalsCompleted !== activity_totals.tasks_completed ||
1174
+ totalsSettled !== activity_totals.tasks_settled),
1175
+ fields,
907
1176
  };
908
1177
  if (activity.source === 'chain-index') {
909
1178
  notes.push(`confirmed_activity 来自链上索引 (chain-index): ${activity_totals.rows} 行 · ${activity_totals.tasks} 个不同任务 · ` +
@@ -916,10 +1185,21 @@ export function computeSnapshot(events, opts) {
916
1185
  }
917
1186
  // 两套计数口径不同时**必须**解释 —— 页面不许出现「0 个任务」与「N 行任务」并存而不解释
918
1187
  if (totals_scope.differs_from_activity) {
919
- notes.push(`口径不同, 不是数据丢失: totals.tasks/tasks_completed/tasks_verified/signatures 只数本节点 ` +
920
- `${PULSE_LIMITS.windowMs / 3600000}h 窗口内的脉冲事件 (本快照 tasks=${tasks.size} · tasks_completed=${tasksCompleted.size} · ` +
921
- `tasks_verified=${tasksVerified.size} · signatures=${signatureKeys.size}); 上表 ${activity_totals.rows} 行来自链上索引 ` +
922
- `(全量, 不是 ${PULSE_LIMITS.windowMs / 3600000}h 窗口) —— 同源计数见 activity_totals`);
1188
+ notes.push(`口径不同, 不是数据丢失: 链上索引不可用 → 顶部任务类计数**降级为脉冲口径** (本快照 tasks=${tasks.size} · ` +
1189
+ `tasks_completed=${tasksCompleted.size} · tasks_verified=${tasksVerified.size} · tasks_settled=${tasksSettledPulse.size} · ` +
1190
+ `signatures=${signatureKeys.size}); 上表 ${activity_totals.rows} 行来自脉冲事件 (同源计数见 activity_totals, ` +
1191
+ `不是链上索引全量) —— 每个数的来源见 totals_scope.fields`);
1192
+ }
1193
+ if (chainAuthoritative) {
1194
+ notes.push(`顶部「任务/已完成/已结算」= 链上索引的**同源计数** (= activity_totals, 与下表同源, 同源即恒等): ` +
1195
+ `tasks=${totalsTasks} · tasks_completed=${totalsCompleted} · tasks_settled=${totalsSettled}; ` +
1196
+ `「节点/智能体」= 本节点 ${WINDOW_HOURS}h 脉冲事件; 「已验证」在链上索引里没有对应事件 → ` +
1197
+ `该口径无对应事件源, 不下发该字段 (不是 0); 「钱包签名」= ${auditWired
1198
+ ? `本机签名审计账 ${totalsSignatures} 条 (${WINDOW_HOURS}h 窗口, 只计数)`
1199
+ : (totalsSignatures === null
1200
+ ? '无可用源 (本节点既没有签名审计账, 也没有签名脉冲事件), 不下发该字段'
1201
+ : `脉冲事件上报 ${totalsSignatures} 条`)} — ` +
1202
+ `每个数的来源见 totals_scope.fields`);
923
1203
  }
924
1204
  if (activity_totals.rows > 0)
925
1205
  notes.push(chain_id_scope.note.zh);
@@ -936,10 +1216,12 @@ export function computeSnapshot(events, opts) {
936
1216
  agents: agents.size,
937
1217
  active_agents: active.size,
938
1218
  seen_last_24h: agents.size, // 24h 窗口内的不同 Agent
939
- tasks: tasks.size, // 观察到发起的任务数 (聚合, 无内容)
940
- tasks_completed: tasksCompleted.size,
941
- tasks_verified: tasksVerified.size,
942
- signatures: signatureKeys.size, // 真实签名次数 (只计数)
1219
+ // ★ 任务类计数 = 链上索引同源值 (与下表恒等); 索引不可用才退回脉冲口径 (见 fields.tasks)
1220
+ tasks: totalsTasks,
1221
+ tasks_completed: totalsCompleted,
1222
+ tasks_verified: totalsVerified, // 链上口径下 = null (未接入), 不冒充 0
1223
+ signatures: totalsSignatures, // 真源 = 本机签名审计账; 无源 = null (未接入)
1224
+ tasks_settled: totalsSettled, // 新增字段一律排最后 (老 8 个字段顺序逐字不变)
943
1225
  },
944
1226
  totals_scope,
945
1227
  capabilities,
@@ -966,6 +1248,7 @@ export async function getNetworkPulse(opts = {}) {
966
1248
  const shapeOk = Array.isArray(cached?.confirmed_activity)
967
1249
  && typeof cached?.confirmed_activity_source === 'string'
968
1250
  && !!cached?.totals_scope
1251
+ && !!cached?.totals_scope?.fields // 老缓存没有逐字段口径 → 过期形状, 重算
969
1252
  && !!cached?.activity_totals
970
1253
  && !!cached?.chain_id_scope
971
1254
  && Array.isArray(cached?.open_tasks); // 老缓存没有待接单任务 → 过期形状, 重算
@@ -983,8 +1266,11 @@ export async function getNetworkPulse(opts = {}) {
983
1266
  }
984
1267
  // 活动行优先取 P5 链上索引 (真链上事实); 索引不可用 → 退回脉冲事件, 并在快照里标出来源
985
1268
  const confirmedActivity = await resolveConfirmedActivity({ home: opts.home, events, now });
1269
+ // 钱包签名的**真源**: 本机签名审计账 (窗口内条数)。没有账 → 注入 available:false → 快照标「未接入」
1270
+ // (不拿 0 冒充「没发生过」: 本机明明签过而脉冲事件丢了的那次真事故就是这么来的)
1271
+ const signatureAudit = tallySignatureAudit(opts.home, { now, windowMs: PULSE_LIMITS.windowMs });
986
1272
  // 待接单任务: 只读本机公告板目录 (未认领且未过期), 投影成白名单 7 字段 —— 与脉冲事件层无关
987
- const snap = computeSnapshot(events, { now, confirmedActivity, openTasks: readOpenTasks(opts.home, now) });
1273
+ const snap = computeSnapshot(events, { now, confirmedActivity, openTasks: readOpenTasks(opts.home, now), signatureAudit });
988
1274
  try {
989
1275
  fs.mkdirSync(pulseDir(opts.home), { recursive: true });
990
1276
  fs.writeFileSync(snapshotFile(opts.home), JSON.stringify(snap), 'utf8');
@@ -3185,9 +3185,9 @@ export function registerBuiltinTools(ctx) {
3185
3185
  });
3186
3186
  ctx.tools.set('bolloon_config_set', {
3187
3187
  name: 'bolloon_config_set',
3188
- description: '修改 Bolloon 自身配置 (~/.bolloon/bolloon-config.json). 可切换激活供应商 (provider) 或改某供应商的 model/baseUrl/temperature/enabled. 修改立即生效 (下次 LLM 调用使用新配置). 例: provider=deepseek; provider=minimax, model=MiniMax-M3; deepseek.temperature=0.3',
3188
+ description: '修改 Bolloon 自身配置 (~/.bolloon/bolloon-config.json). 可切换激活供应商 (provider) 或改某供应商的 model/baseUrl/temperature/enabled/maxTokens. 切换类改动走**统一入口** (先校验探测, 过了才落盘并重建模型运行时), 所以调用后返回的"当前生效"就是真的生效的那一份. 例: provider=deepseek; provider=minimax, model=MiniMax-M3; deepseek.temperature=0.3',
3189
3189
  parameters: {
3190
- provider: '可选: 切换激活供应商 (如 deepseek / minimax / openai / anthropic / ollama)',
3190
+ provider: '可选: 切换激活供应商 (内置的 13 家 + 已注册的自定义供应商)',
3191
3191
  'provider.key': '可选: 修改指定供应商字段, 格式 <供应商>.<字段>=<值>, 如 minimax.model=MiniMax-M3',
3192
3192
  model: '可选: 同时设置激活供应商的 model',
3193
3193
  temperature: '可选: 同时设置激活供应商的 temperature (0-2)',
@@ -3195,64 +3195,115 @@ export function registerBuiltinTools(ctx) {
3195
3195
  execute: async (args) => {
3196
3196
  try {
3197
3197
  const { llmConfigStore } = await import('../llm/config-store.js');
3198
+ const { selectModel, formatEffectiveModel, listRegisteredProviderIds } = await import('../llm/model-selection.js');
3198
3199
  await llmConfigStore.initialize();
3199
3200
  const changes = [];
3200
- // 1. 切换激活供应商
3201
- if (args.provider) {
3202
- const name = String(args.provider).trim().toLowerCase();
3203
- const known = ['openai', 'anthropic', 'ollama', 'openrouter', 'gemini', 'minimax', 'deepseek', 'kimi', 'glm', 'qwen', 'mimo', 'grok', 'local'];
3204
- if (!known.includes(name))
3205
- return { success: false, error: `未知供应商: ${name}. 可用: ${known.join(', ')}` };
3206
- await llmConfigStore.setActiveProvider(name);
3207
- changes.push(`activeProvider=${name}`);
3208
- }
3209
- // 2. 修改指定供应商字段 (provider.key=value)
3201
+ // 切换类改动累积起来**一次性**交给统一入口 (校验+探测+落盘+重建运行时都在它里面)。
3202
+ // `provider.field=` 里 model/baseUrl 是"选择自己的字段": 只在这家**正在用**时才算切换
3203
+ // (改一家没在用的供应商的地址/模型 = 配置编辑, 不该顺手把它切成激活)。
3204
+ const switchReq = {};
3205
+ const activeBefore = await llmConfigStore.getActiveProvider().catch(() => '');
3206
+ const optIn = (provider, field, val) => {
3207
+ if (!switchReq.provider)
3208
+ switchReq.provider = provider;
3209
+ if (switchReq.provider !== provider)
3210
+ return false;
3211
+ if (field === 'model')
3212
+ switchReq.model = val;
3213
+ else if (field === 'baseUrl')
3214
+ switchReq.baseUrl = val;
3215
+ else
3216
+ return false;
3217
+ return true;
3218
+ };
3219
+ const switchProvider = args.provider ? String(args.provider).trim().toLowerCase() : '';
3220
+ if (switchProvider) {
3221
+ switchReq.provider = switchProvider;
3222
+ if (args.model)
3223
+ switchReq.model = String(args.model);
3224
+ if (args.temperature !== undefined) {
3225
+ const t = Number(args.temperature);
3226
+ if (Number.isNaN(t))
3227
+ return { success: false, error: `temperature=${args.temperature} 不是数字` };
3228
+ switchReq.temperature = t;
3229
+ }
3230
+ }
3231
+ // 1. 非选择的字段 (temperature/maxTokens/enabled/apiKey) 与"没在用那家"的字段 —— 配置编辑
3210
3232
  for (const [k, v] of Object.entries(args)) {
3211
3233
  if (k === 'provider' || k === 'model' || k === 'temperature')
3212
3234
  continue;
3213
3235
  if (!k.includes('.'))
3214
3236
  continue;
3215
3237
  const [prov, field] = k.split('.');
3238
+ const p = prov.trim().toLowerCase();
3216
3239
  const val = String(v);
3240
+ const isSelectionField = field === 'model' || field === 'baseUrl';
3241
+ if (isSelectionField && (switchProvider ? switchProvider === p : activeBefore === p)) {
3242
+ optIn(p, field, val); // 改的是"正在用的那一份" → 走入口 (顺带重建运行时)
3243
+ continue;
3244
+ }
3217
3245
  if (field === 'temperature' || field === 'maxTokens') {
3218
3246
  const num = Number(val);
3219
3247
  if (Number.isNaN(num))
3220
3248
  return { success: false, error: `${k}=${val} 不是数字` };
3221
- await llmConfigStore.updateProvider(prov, { [field]: num });
3249
+ await llmConfigStore.updateProvider(p, { [field]: num });
3222
3250
  }
3223
3251
  else if (field === 'enabled') {
3224
- await llmConfigStore.updateProvider(prov, { enabled: val === 'true' || val === '1' });
3252
+ await llmConfigStore.updateProvider(p, { enabled: val === 'true' || val === '1' });
3225
3253
  }
3226
3254
  else if (field === 'apiKey') {
3227
- await llmConfigStore.updateProvider(prov, { apiKey: val });
3255
+ await llmConfigStore.updateProvider(p, { apiKey: val });
3228
3256
  }
3229
3257
  else {
3230
- await llmConfigStore.updateProvider(prov, { [field]: val });
3258
+ await llmConfigStore.updateProvider(p, { [field]: val });
3231
3259
  }
3232
- changes.push(`${prov}.${field}=${field === 'apiKey' ? '***' : val}`);
3260
+ changes.push(`${p}.${field}=${field === 'apiKey' ? '***' : val}`);
3233
3261
  }
3234
- // 3. 激活供应商的 model / temperature
3235
- if (args.model || args.temperature) {
3236
- const active = await llmConfigStore.getActiveProvider();
3237
- const patch = {};
3262
+ // 2. 选择的字段 (model/baseUrl) 单独给 activate 供应商时, 也走入口
3263
+ if (!switchProvider && (args.model || args.temperature !== undefined)) {
3264
+ switchReq.provider = activeBefore;
3238
3265
  if (args.model)
3239
- patch.model = String(args.model);
3240
- if (args.temperature) {
3266
+ switchReq.model = String(args.model);
3267
+ if (args.temperature !== undefined) {
3241
3268
  const t = Number(args.temperature);
3242
3269
  if (Number.isNaN(t))
3243
3270
  return { success: false, error: `temperature=${args.temperature} 不是数字` };
3244
- patch.temperature = t;
3271
+ switchReq.temperature = t;
3245
3272
  }
3246
- await llmConfigStore.updateProvider(active, patch);
3247
- if (args.model)
3248
- changes.push(`${active}.model=${args.model}`);
3249
- if (args.temperature)
3250
- changes.push(`${active}.temperature=${args.temperature}`);
3251
3273
  }
3252
- if (changes.length === 0)
3253
- return { success: false, error: '没有要修改的配置项. 例: provider=deepseek 或 minimax.model=MiniMax-M3' };
3254
- const cfg = await llmConfigStore.getConfig();
3255
- return { success: true, output: `✅ 配置已更新: ${changes.join(', ')}\n 当前激活: ${cfg.activeProvider}` };
3274
+ // 3. 一次性提交给统一入口 —— 它是唯一做"校验+探测+落盘+重建运行时"的地方
3275
+ if (switchReq.provider) {
3276
+ const r = await selectModel({ ...switchReq, scope: 'global' });
3277
+ if (!r.ok) {
3278
+ // 失败时入口保证"配置与运行时都没变" —— 如实说, 不假装成功
3279
+ return {
3280
+ success: false,
3281
+ error: `切换 ${switchReq.provider} 未生效 [${r.failureClass || '未知'}]: ${r.message}` +
3282
+ (r.previous ? `\n 仍在用: ${formatEffectiveModel(r.previous)}` : ''),
3283
+ };
3284
+ }
3285
+ const bits = [`activeProvider=${r.effective.provider}`];
3286
+ if (switchReq.model)
3287
+ bits.push(`model=${r.effective.model}`);
3288
+ if (switchReq.baseUrl)
3289
+ bits.push(`baseUrl=${r.effective.baseUrl}`);
3290
+ if (switchReq.temperature !== undefined)
3291
+ bits.push(`temperature=${switchReq.temperature}`);
3292
+ changes.push(...bits);
3293
+ }
3294
+ if (changes.length === 0) {
3295
+ return {
3296
+ success: false,
3297
+ error: `没有要修改的配置项. 例: provider=deepseek 或 minimax.model=MiniMax-M3. 可用供应商: ${listRegisteredProviderIds().join(', ')}`,
3298
+ };
3299
+ }
3300
+ // 结尾一律回**真正生效**的那一份 (入口写完就是它; 只有配置编辑时也从入口读, 免得各读各的)
3301
+ const { effectiveModelConfig } = await import('../llm/model-selection.js');
3302
+ const eff = await effectiveModelConfig({});
3303
+ return {
3304
+ success: true,
3305
+ output: `✅ 配置已更新: ${changes.join(', ')}\n 当前生效: ${eff ? formatEffectiveModel(eff) : '读不出来'}`,
3306
+ };
3256
3307
  }
3257
3308
  catch (e) {
3258
3309
  return { success: false, error: `bolloon_config_set 失败: ${String(e.message || e).slice(0, 200)}` };