@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
@@ -0,0 +1,538 @@
1
+ /**
2
+ * model-selector.ts — `/model` 的分步选择器 (2026-09-26)
3
+ *
4
+ * 流程 (与命令面同一条路, 只是把参数逐步问出来):
5
+ *
6
+ * ```
7
+ * 1 选择供应商 → 2 未配置则输 API key → 3 选择模型 (模糊搜索) → 4 reasoning/temperature
8
+ * → 5 Session 还是 Global → 6 测试连接 → 7 确认切换
9
+ * ```
10
+ *
11
+ * ## 两条硬纪律
12
+ *
13
+ * ① **切换只有一次写盘动作, 且只走统一入口**。本文件不碰配置文件、不碰模型运行时 ——
14
+ * 前面六步全部是"收集意图", 第七步把收集到的东西**一次性**交给
15
+ * `selectModel()` (`src/llm/model-selection.ts`)。理由: P0 之前 CLI 那条路
16
+ * "只改配置文件不重建运行时", 于是出现"切了不生效"; 选择器如果自己写配置就会分叉出第二条路。
17
+ * 可核证据: `src/test/model-selector.test.ts` 有一条源码级门 —— 本文件里出现
18
+ * `updateProvider(` / `setActiveProvider(` / `initMinimax(` / 直接写配置文件 就判红。
19
+ *
20
+ * ② **API key 只在内存里活到第七步**: 不打印、不回显、不落盘到别处 (第 2 步只回显尾 4 位),
21
+ * 由 `selectModel()` 在它自己的写盘路径里落进全局配置。用户在第二~六步取消 → 一个字节都没写。
22
+ *
23
+ * ## 元数据从哪来
24
+ *
25
+ * 列表与能力字段全部由 `src/llm/model-catalog.ts` 给 (那里定义了唯一填充点)。本文件**只做
26
+ * 三态到人话的翻译与排版**, 一个能力值都不产生 —— 拿不到真数据时列表上写的是"未知",
27
+ * 而不是一个看起来像真的常量。
28
+ */
29
+ import { buildProviderSummaries, listModelsFor, formatModelMenuRow, providerGroupSummary, providerTierOf, providerTierCollapsedByDefault, orderProvidersForMenu, PROVIDER_GROUPS, providerRowTone, formatProviderMenuRow, unknownFootnote, searchModelEntries, resolvedApiKeyOf, } from '../llm/model-catalog.js';
30
+ import { selectModel, probeSelection, credentialFingerprintOf, envKeyNamesOf as envKeysOf, } from '../llm/model-selection.js';
31
+ import { admitManualModel } from '../llm/model-discovery.js';
32
+ // 颜色只从 `theme.ts` 来 (唯一事实源): 调子→token 的映射与 `tint()` 都住在那边。
33
+ import { tint } from './theme.js';
34
+ // ============================================================
35
+ // 选择原语 (choose → ask → 放弃)
36
+ // ============================================================
37
+ /**
38
+ * 主屏 (七步那些非选择器屏幕) 一行该用什么调子 —— 分类在**唯一一处**, 组件里不再散落判断。
39
+ * 只认行首的**符号/文字** (✓ ✗ ⚠ ● ○ 步骤 N/7 ──), 不认颜色 —— 所以无色环境下
40
+ * 同一张分类表照样成立, 屏幕只是少了颜色这一层。
41
+ */
42
+ function mainLineTone(line) {
43
+ const s = line.trim();
44
+ if (/^(✗|错误|没有|非法|失败|无法)/.test(s))
45
+ return 'error';
46
+ if (/^(✓|●)/.test(s))
47
+ return 'ok';
48
+ if (/^○/.test(s))
49
+ return 'dim';
50
+ if (/^⚠/.test(s))
51
+ return 'warn';
52
+ if (/^步骤 \d\/7/.test(s))
53
+ return 'accent';
54
+ if (s.includes('special (需专用鉴权') || s.includes('无基址 (需自定义 baseUrl)'))
55
+ return 'warn';
56
+ if (/^──/.test(s))
57
+ return 'muted';
58
+ if (/^(已选|已收到|沿用|作用域|temperature|参数|模型|供应商|提示|用法|分组|目录|当前)/.test(s))
59
+ return 'muted';
60
+ return 'text';
61
+ }
62
+ function makeCtx(io, lines) {
63
+ /** 屏幕那一行上色 (只在 `print` 那一刻; `lines` 里留原文) */
64
+ const paint = (l) => (io.color ? tint(l, mainLineTone(l)) : l);
65
+ const push = (s) => { for (const l of String(s).split('\n')) {
66
+ lines.push(l);
67
+ io.print(paint(l));
68
+ } };
69
+ /**
70
+ * 把候选**逐条印出来再问** (leo 2026-09-27 硬要求)。
71
+ *
72
+ * 为什么单列一个函数: 曾经这里只印一句 `选择 (序号/值, 回车=1)`, 用户**无从知道 1 是谁** ——
73
+ * 序号与选项必须一起出现, 否则提示是空承诺。序号右对齐两位, 分组标题只在切换分组时印一次,
74
+ * 每条的 `label` 自带状态标记 (● 可用 / ○ 未配置凭据 / ← 当前), 不在这一层另编状态。
75
+ */
76
+ const printOptions = (items, title) => {
77
+ push(title);
78
+ let lastGroup;
79
+ items.forEach((c, i) => {
80
+ if (c.group && c.group !== lastGroup) {
81
+ push(` ── ${c.group}`);
82
+ lastGroup = c.group;
83
+ }
84
+ push(` ${String(i + 1).padStart(2)}) ${c.label}${c.hint ? ` — ${c.hint}` : ''}`);
85
+ });
86
+ push(` [${items.length}]`);
87
+ };
88
+ /**
89
+ * 让用户从候选里挑一个; 返回 null = 取消 (或输入流结束)。
90
+ *
91
+ * 非法的输入**要说清为什么**再重问 (序号越界 → 报范围; 字母前缀没命中 → 报实际命中数),
92
+ * 而不是笼统一句"没对上"。**EOF (Ctrl-D)** 与"用户取消"分开: 前者印一行明确的取消理由。
93
+ */
94
+ const pick = async (items, title) => {
95
+ if (!items.length)
96
+ return null;
97
+ // 结构化选择器 (会话内 Ink) 自己渲染选项列表与当前光标 —— 这里不重复印一遍, 否则双份。
98
+ if (io.choose)
99
+ return await io.choose(items, title);
100
+ if (!io.ask)
101
+ return null;
102
+ printOptions(items, title);
103
+ for (let tries = 0; tries < 3; tries++) {
104
+ const ans = await io.ask(CH_PICK);
105
+ if (ans === null) {
106
+ push(EOF_CANCEL);
107
+ return null;
108
+ }
109
+ const raw = ans.trim();
110
+ if (raw === '')
111
+ return items[0].value;
112
+ if (/^\d+$/.test(raw)) {
113
+ const n = Number(raw);
114
+ const hit = items[n - 1];
115
+ if (hit)
116
+ return hit.value;
117
+ push(` ✗ 序号 ${n} 超出范围 (这里只有 1~${items.length} 项) — 重问`);
118
+ continue;
119
+ }
120
+ const exact = items.find((c) => c.value === raw);
121
+ if (exact)
122
+ return exact.value;
123
+ const lo = raw.toLowerCase();
124
+ const pref = items.filter((c) => c.value.toLowerCase().startsWith(lo) || c.label.includes(raw));
125
+ if (pref.length === 1)
126
+ return pref[0].value;
127
+ if (pref.length > 1) {
128
+ push(` ✗ '${raw}' 对上 ${pref.length} 个候选, 请给序号: ${pref.slice(0, 6).map((c) => c.value).join(', ')}${pref.length > 6 ? ' …' : ''} — 重问`);
129
+ continue;
130
+ }
131
+ push(` ✗ 没有候选的值或名字匹配 '${raw}' (可用值见上表) — 重问`);
132
+ }
133
+ push(' ✗ 连试 3 次都没对上 → 取消 (未改动任何配置)');
134
+ return null;
135
+ };
136
+ /** 是非确认 (一律先把两个选项印出来再问) */
137
+ const confirm = async (q, def = true) => {
138
+ const opts = [
139
+ { value: 'yes', label: def ? '确认 (默认)' : '确认' },
140
+ { value: 'no', label: def ? '取消' : '取消 (默认)' },
141
+ ];
142
+ if (io.ask) {
143
+ push(q);
144
+ opts.forEach((c, i) => push(` ${i + 1}) ${c.label}`));
145
+ const ans = await io.ask(`${q} (y/n, 回车=${def ? 'y' : 'n'})`, { default: def ? 'y' : 'n' });
146
+ if (ans === null) {
147
+ push(EOF_CANCEL);
148
+ return false;
149
+ }
150
+ const a = ans.trim().toLowerCase();
151
+ return a === '' ? def : /^(y|yes|1|true|是|好|确认)$/.test(a);
152
+ }
153
+ if (io.choose) {
154
+ const v = await io.choose(opts, q);
155
+ return v === 'yes';
156
+ }
157
+ return false;
158
+ };
159
+ /** 文本输入 (没有该能力 → 返回 null, 由调用方决定退化行为; EOF 也返回 null) */
160
+ const text = async (q, def) => {
161
+ if (!io.ask)
162
+ return null;
163
+ const ans = await io.ask(q, def !== undefined ? { default: def } : undefined);
164
+ if (ans === null)
165
+ return null;
166
+ return ans.trim();
167
+ };
168
+ return { push, pick, confirm, text, printOptions };
169
+ }
170
+ /** 选择提示语 (每一步都用同一句, 方便验收按它数步骤) */
171
+ const CH_PICK = '选择 (序号/值, 回车=1)';
172
+ /** 输入流结束时的统一口径 (必须是"干净取消", 且不许写任何东西) */
173
+ const EOF_CANCEL = '· 输入已结束 (Ctrl-D / EOF) → 取消本次切换, 一个字节都没写';
174
+ // ============================================================
175
+ // 主流程
176
+ // ============================================================
177
+ /**
178
+ * 跑一次分步选择器。**任何一步取消都不写任何东西**; 只有第 7 步会调 `selectModel()`。
179
+ */
180
+ export async function runModelSelector(io, opts = {}) {
181
+ const lines = [];
182
+ const { push, pick, confirm, text } = makeCtx(io, lines);
183
+ const done = (r) => ({ ...r, lines });
184
+ /** 细节只给想看的人: 默认主屏不放 (`--verbose` 或交互里 `d` 才出) */
185
+ const verbose = (s) => { try {
186
+ if (opts.verbose)
187
+ push(s);
188
+ }
189
+ catch { /* 细节打不出不该中断主流程 */ } };
190
+ // ── 1) 供应商 ────────────────────────────────────────────
191
+ // 版面 (2026-09-27 二改, leo: "15 家登记…可以更多吗?" + "不用一下子全部显示, 可以有固定高度或者折叠"):
192
+ // 主屏 = **一行标题 + 候选行 + 一行状态**; 候选**是全部家** (内置 13 + 自定义 + 目录全部),
193
+ // 一屏画不完由全屏选择器用"固定高度视窗 (≤12 行) + 可折叠分组 + 搜索"消化 ——
194
+ // **不是靠把家从候选集里藏掉** (藏了就搜不到、也数不出来)。分组计数/目录新鲜度仍退到 `--verbose`。
195
+ let summaries;
196
+ try {
197
+ summaries = await buildProviderSummaries({ sessionKey: opts.sessionKey });
198
+ }
199
+ catch (e) {
200
+ return done({ ok: false, reachedStep: 'provider', message: `读供应商列表失败: ${String(e?.message || e).slice(0, 160)}` });
201
+ }
202
+ const live = summaries.filter((s) => s.configured);
203
+ // "共 N 家" 从盘上真算 (内置 + 自定义 + 目录全部), 不写死 —— 验收门拿它跟盘上数字对。
204
+ push(`步骤 1/7 供应商 (共 ${summaries.length} 家登记: 内置+自定义+目录全部 · 可用 ${live.length} 家 · 分组可折叠)`);
205
+ verbose(` ${providerGroupSummary(summaries)}`);
206
+ try {
207
+ // 动态 import: 选择器 → provider-catalog 是单向的 (目录层不 import 选择器), 不构成环;
208
+ // 用动态是为了让"目录读盘失败"不影响选择器本身能用。
209
+ const pc = await import('../llm/provider-catalog.js');
210
+ await pc.initializeProviderCatalog();
211
+ verbose(` ${pc.catalogStatusLine()}`);
212
+ verbose(` ${pc.catalogGroupLine()}`);
213
+ verbose(' 看目录: /model catalog · 筛: /model catalog list <筛选词> · 拉最新: /model catalog refresh');
214
+ }
215
+ catch (e) {
216
+ verbose(` ⚠ 目录状态读不出来: ${String(e?.message || e).slice(0, 120)} (目录层的问题, 不影响下面的选择)`);
217
+ }
218
+ if (!summaries.length)
219
+ return done({ ok: false, reachedStep: 'provider', message: '没有任何登记在册的供应商' });
220
+ // 排序 = 分组优先级 (当前 → 可用 → 未配置凭据 → 需专用鉴权 → 无 api 基址);
221
+ // **全部家都在候选数组里** (包括后三组), 只是它们的分组**默认收起** (标题上照写家数)。
222
+ const ordered = orderProvidersForMenu(summaries);
223
+ const providerChoices = ordered.map((s) => {
224
+ const tier = providerTierOf(s);
225
+ return {
226
+ value: s.id,
227
+ label: formatProviderMenuRow(s),
228
+ hint: `${s.name}${s.providerReasoning === 'yes' ? ' · 登记支持 reasoning' : ''}${s.configuredModel ? ` · 配置里 model=${s.configuredModel}` : ''}`,
229
+ // 分组标题只在切换分组时印一次; 家数由选择器写在标题行上 (`── 名字 (N 家) ›`)
230
+ group: PROVIDER_GROUPS[tier],
231
+ // 后三组默认收起: 页面放得下, 而家数照样看得见 (`空格`/`→` 一键展开)
232
+ groupCollapsed: providerTierCollapsedByDefault(tier),
233
+ // 颜色只做**第二通道**: 语义本体是 label 里的符号 (●/○) 与分组标题 (special/无基址 → warn)
234
+ tone: providerRowTone(s),
235
+ };
236
+ });
237
+ const providerId = await pick(providerChoices, '选择供应商 (序号 / 供应商 id):');
238
+ if (!providerId)
239
+ return done({ ok: false, cancelled: true, reachedStep: 'provider', message: '已取消, 未改动任何配置' });
240
+ const summary = summaries.find((s) => s.id === providerId);
241
+ push(`已选供应商: ${providerId} (${summary.name})`);
242
+ // ── 2) 凭证 ──────────────────────────────────────────────
243
+ // 版面: 一行状态 + **四条路** (保持现有 / 替换 / 清除 / 改用环境变量)。有 key 也**不许静默跳过** ——
244
+ // 跳过时必须显式说"沿用现有 key (指纹 fp:xxxx)"。指纹只含长度+哈希, **不含明文**, 也不进日志。
245
+ let pendingKey;
246
+ let credentialAction = 'keep';
247
+ const fp = await credentialFingerprintOf(providerId).catch(() => undefined);
248
+ const keyZh = summary.keyState === 'not_required' ? '免 key'
249
+ : summary.keyState === 'missing' ? '缺 key'
250
+ : summary.keyState === 'env' ? '来自环境变量' : '已配置';
251
+ const envKeys = envKeysOf(providerId);
252
+ push(`步骤 2/7 凭证 — ${providerId}: ${keyZh}${fp ? ` (指纹 ${fp})` : ''}`);
253
+ if (summary.requiresApiKey && io.askHidden) {
254
+ const choices = [];
255
+ if (summary.keyState !== 'missing')
256
+ choices.push({ value: 'keep', label: `保持现有 (${fp || '已配置'})`, hint: '一个字节都不动', tone: 'ok' });
257
+ // 四条路的名字**在两种状态下保持一致** (只是 hint 不同): 用户/脚本答"替换"永远命中同一件事
258
+ choices.push({ value: 'replace', label: '替换 (重新输入, 掩码)', hint: summary.keyState === 'missing' ? '现在输入一个 key' : '覆盖存盘的这一份', tone: 'accent' });
259
+ if (summary.keyState === 'configured')
260
+ choices.push({ value: 'clear', label: '清除存盘的 key', hint: '删掉配置里这一格', tone: 'warn' });
261
+ if (envKeys.length)
262
+ choices.push({ value: 'env', label: `改用环境变量 (${envKeys[0]})`, hint: '不存盘, 从环境读' });
263
+ choices.push({ value: 'cancel', label: '取消 (什么都不改)', tone: 'dim' });
264
+ const act = await pick(choices, `凭证怎么处理 (${choices.length} 选 1):`);
265
+ // 未知答案一律当取消 —— 凭证这条路**只认四个明确动作**, 不猜
266
+ const known = act === 'keep' || act === 'replace' || act === 'clear' || act === 'env';
267
+ if (!act || !known)
268
+ return done({ ok: false, cancelled: true, reachedStep: 'key', message: '已取消, 未改动任何配置' });
269
+ if (act === 'replace') {
270
+ const ans = await io.askHidden(`粘贴 ${providerId} API key (输入掩码显示, 不回显明文)`);
271
+ if (ans === null) {
272
+ push(EOF_CANCEL);
273
+ return done({ ok: false, cancelled: true, reachedStep: 'key', message: '输入已结束 (EOF/Ctrl-D) —— 未改动任何配置' });
274
+ }
275
+ const k = ans.trim();
276
+ if (!k)
277
+ return done({ ok: false, cancelled: true, reachedStep: 'key', message: '没有收到 key, 未改动任何配置' });
278
+ pendingKey = k;
279
+ // 只回显尾 4 位 —— 全文不进任何输出/日志/报告
280
+ push(`已收到 key (****${k.slice(-4)}) — 只在第 7 步落盘, 中途取消则一个字节都不写`);
281
+ }
282
+ else {
283
+ credentialAction = act;
284
+ push(act === 'keep' ? `沿用现有 key (指纹 ${fp || '已配置'})`
285
+ : act === 'clear' ? '将清除存盘的 key (改用环境变量; 没有环境变量则这一步会拦下)'
286
+ : `改用环境变量 (${envKeys[0] || 'env'})`);
287
+ }
288
+ }
289
+ else if (summary.requiresApiKey && summary.keyState === 'missing' && !io.askHidden) {
290
+ // 真缺凭证**且**这个环境收不了 key → 只能明说怎么办 (不再假装能继续)
291
+ return done({
292
+ ok: false,
293
+ reachedStep: 'key',
294
+ failureClass: 'missing_api_key',
295
+ message: `${providerId} 还没有凭证, 且当前环境不能安全地收 key —— 请在系统终端执行 \`bolloon model key ${providerId}\` (或打开 Web 配置页) 后再选。未改动任何配置。`,
296
+ });
297
+ }
298
+ else if (summary.requiresApiKey) {
299
+ push(`沿用现有 key (指纹 ${fp || '已配置'})`);
300
+ }
301
+ // ── 3) 模型 (模糊搜索 + 当前置顶) ─────────────────────────
302
+ let entries;
303
+ try {
304
+ entries = await listModelsFor(providerId, { sessionKey: opts.sessionKey });
305
+ }
306
+ catch (e) {
307
+ return done({ ok: false, reachedStep: 'model', message: `读模型列表失败: ${String(e?.message || e).slice(0, 160)}` });
308
+ }
309
+ if (!entries.length) {
310
+ return done({
311
+ ok: false, reachedStep: 'model',
312
+ message: `${providerId} 没有可用模型 (无内置目录且配置为空) —— 请用 \`/model ${providerId} <model>\` 直接指定, 或先配好目录`,
313
+ });
314
+ }
315
+ push(`步骤 3/7 模型 — ${providerId}, ${entries.length} 个候选`);
316
+ for (const f of unknownFootnote(entries))
317
+ verbose(` ${f}`);
318
+ let candidates = entries;
319
+ let manualModel;
320
+ // 交互式 TUI 自带 `/` 实时筛选 —— 这里再问一句"搜索模型"就是同一件事问两遍 (用户要减到必要);
321
+ // 只有纯文本问答那条路 (io.ask) 才需要先问一次搜索词。
322
+ if (io.ask && !io.filterable && entries.length > 3) {
323
+ const q = await text('搜索模型 (模糊, 留空 = 全部)');
324
+ if (q === null) {
325
+ push(EOF_CANCEL);
326
+ return done({ ok: false, cancelled: true, reachedStep: 'model', message: '输入已结束 (EOF/Ctrl-D) —— 未改动任何配置' });
327
+ }
328
+ if (q) {
329
+ const hit = searchModelEntries(entries, q);
330
+ verbose(`筛选 '${q}' → ${hit.length} 个`);
331
+ candidates = hit;
332
+ }
333
+ }
334
+ if (!candidates.length) {
335
+ // 目录里没有匹配项也**允许手输** (实测: 目录不是可用模型的全集, 手输的名字常常真能用)
336
+ const manual = await text(`清单里没有匹配项。直接输入 ${providerId} 的原始 model ID (留空=取消)`);
337
+ if (manual === null) {
338
+ push(EOF_CANCEL);
339
+ return done({ ok: false, cancelled: true, reachedStep: 'model', message: '输入已结束 (EOF/Ctrl-D) —— 未改动任何配置' });
340
+ }
341
+ if (!manual)
342
+ return done({ ok: false, cancelled: true, reachedStep: 'model', message: '已取消, 未改动任何配置' });
343
+ manualModel = manual;
344
+ // 手输的模型顺手记进发现缓存 (`admitManualModel` 是唯一那处实现) —— 下次搜它就能搜到。
345
+ try {
346
+ const admitted = await admitManualModel(providerId, manual);
347
+ verbose(admitted.ok ? `已记进 ${providerId} 的发现缓存 (手输)` : `· 手工模型没记进缓存: ${admitted.reason}`);
348
+ }
349
+ catch (e) {
350
+ verbose(`· 手工模型没记进缓存: ${e.message}`);
351
+ }
352
+ }
353
+ const modelId = manualModel ?? await pick([
354
+ ...candidates.map((e) => ({
355
+ value: e.id,
356
+ label: formatModelMenuRow(e),
357
+ hint: e.current ? '当前生效' : undefined,
358
+ // 上游目录里没有的名字: 只作**提示**色, 不是拒用 (实测它常真能用)
359
+ tone: (e.upstreamSeen === false ? 'warn' : e.current ? 'accent' : 'plain'),
360
+ })),
361
+ // 清单里没有的模型也能手输 (实测目录不是全集) —— 末行固定放, 与其它步骤的"取消"行同一形态
362
+ ...(io.ask ? [{ value: '__manual__', label: '手输一个 model ID', hint: '目录没列出的名字也能试' }] : []),
363
+ ], `选择模型 (${providerId})`);
364
+ if (!modelId)
365
+ return done({ ok: false, cancelled: true, reachedStep: 'model', message: '已取消, 未改动任何配置' });
366
+ if (modelId === '__manual__') {
367
+ const manual = await text(`输入 ${providerId} 的原始 model ID (留空=取消)`);
368
+ if (manual === null) {
369
+ push(EOF_CANCEL);
370
+ return done({ ok: false, cancelled: true, reachedStep: 'model', message: '输入已结束 (EOF/Ctrl-D) —— 未改动任何配置' });
371
+ }
372
+ if (!manual)
373
+ return done({ ok: false, cancelled: true, reachedStep: 'model', message: '已取消, 未改动任何配置' });
374
+ manualModel = manual;
375
+ try {
376
+ await admitManualModel(providerId, manual);
377
+ }
378
+ catch { /* 缓存记不上不影响切换 */ }
379
+ }
380
+ const chosenModelId = manualModel ?? modelId;
381
+ const entry = candidates.find((e) => e.id === chosenModelId) || entries.find((e) => e.id === chosenModelId);
382
+ // 只回显**这一条真有**的事实; 未知的一律不写 (页脚统一说, 不逐行复读)。
383
+ // 来源一定要标出来 (live / 上游未见 / 手输) —— 用户要能看出"这个模型是从哪来的"。
384
+ const srcTag = manualModel ? ' · 手输 (目录未列出, 探测仍真跑一次)'
385
+ : entry?.upstreamSeen === false ? ' · 上游目录未列出 (探测仍真跑一次)'
386
+ : entry?.upstreamSeen === true ? ` · ${entry.upstreamOrigin === 'live' ? '上游目录' : '上游目录(缓存)'}`
387
+ : '';
388
+ push(`已选模型: ${chosenModelId}${srcTag}`);
389
+ // ── 4) 生成参数 (reasoning / temperature) ────────────────
390
+ const params = {};
391
+ push('步骤 4/7 生成参数');
392
+ // reasoning: 只有"登记为支持"时才提供开关; 能力未知就不给这一项 (不猜)
393
+ if (summary.providerReasoning === 'yes') {
394
+ const r = await pick([
395
+ { value: 'unset', label: '不设 (沿用配置里的值)' },
396
+ { value: 'on', label: '开 (请求推理/思考模式)' },
397
+ { value: 'off', label: '关' },
398
+ ], `${providerId} 登记支持 reasoning — 要不要开? (本机偏好, 不是模型能力数据)`);
399
+ if (r === 'on')
400
+ params.reasoningMode = true;
401
+ else if (r === 'off')
402
+ params.reasoningMode = false;
403
+ }
404
+ else {
405
+ verbose(`reasoning: 该供应商的模型级能力未知 → 不提供开关, 也不写默认值`);
406
+ }
407
+ const temp = await pick([
408
+ { value: 'skip', label: '不设 (沿用配置里的值)' },
409
+ { value: '0', label: 'temperature = 0' },
410
+ { value: '0.3', label: 'temperature = 0.3' },
411
+ { value: '0.7', label: 'temperature = 0.7' },
412
+ { value: '1.0', label: 'temperature = 1.0' },
413
+ { value: '1.5', label: 'temperature = 1.5' },
414
+ ...(io.ask ? [{ value: '__custom__', label: '手工输入 (0~2)' }] : []),
415
+ ], 'temperature (0~2)');
416
+ if (temp === null)
417
+ return done({ ok: false, cancelled: true, reachedStep: 'params', message: '已取消, 未改动任何配置' });
418
+ if (temp === '__custom__') {
419
+ const raw = await text('temperature (0~2)', '0.7');
420
+ if (raw === null) {
421
+ push(EOF_CANCEL);
422
+ return done({ ok: false, cancelled: true, reachedStep: 'params', message: '输入已结束 (EOF/Ctrl-D) —— 未改动任何配置' });
423
+ }
424
+ if (raw === '') {
425
+ push('没输入 → 本项不设');
426
+ }
427
+ else {
428
+ const n = Number(raw);
429
+ if (!Number.isFinite(n) || n < 0 || n > 2) {
430
+ return done({ ok: false, reachedStep: 'params', failureClass: 'invalid_temperature', message: `temperature 只接受 0~2 的数字, 收到 '${raw}' — 未改动任何配置` });
431
+ }
432
+ params.temperature = n;
433
+ }
434
+ }
435
+ else if (temp !== 'skip') {
436
+ params.temperature = Number(temp);
437
+ }
438
+ push(`temperature: ${params.temperature === undefined ? '不设' : params.temperature}`);
439
+ // ── 5) 作用域 ────────────────────────────────────────────
440
+ let scope = 'global';
441
+ push('步骤 5/7 作用域');
442
+ for (let round = 0; round < 2; round++) {
443
+ const s = await pick([
444
+ { value: 'global', label: '全局默认 (影响新会话 + 未绑定模型的任务)' },
445
+ { value: 'session', label: '仅当前会话 (不动全局默认)' },
446
+ ], '这次切换的作用域?');
447
+ if (s === null)
448
+ return done({ ok: false, cancelled: true, reachedStep: 'scope', message: '已取消, 未改动任何配置' });
449
+ scope = s === 'session' ? 'session' : 'global';
450
+ if (scope === 'session' && pendingKey) {
451
+ push('✗ 会话级切换只做路由、不写凭证 —— 要么改成全局默认, 要么先 `bolloon model key ' + providerId + '` 再回来只切路由。');
452
+ continue;
453
+ }
454
+ break;
455
+ }
456
+ if (scope === 'session' && pendingKey) {
457
+ return done({
458
+ ok: false, reachedStep: 'scope', failureClass: 'credential_scope_conflict',
459
+ message: '会话级切换不写凭证 — 未改动任何配置 (请先配全局凭证, 再用 --session 只切路由)',
460
+ });
461
+ }
462
+ push(`作用域: ${scope === 'global' ? '全局默认' : '仅当前会话'}`);
463
+ return await commit(done, push, confirm, verbose, {
464
+ providerId, summary, pendingKey, credentialAction, modelId: chosenModelId, baseUrl: summary.baseUrl, opts, params, scope,
465
+ });
466
+ }
467
+ async function commit(done, push, confirm, verbose, a) {
468
+ const params = a.params || {};
469
+ const scope = a.scope || 'global';
470
+ // ── 6) 测试连接 (对**候选**配置探测; 不写任何东西) ─────────
471
+ // 版面: **失败就直接停**, 一行说清"什么错 + 怎么办" —— 不再问"还要不要继续"
472
+ // (那种问句按 y 也照样被第 7 步拦下, 是假选择)。
473
+ if (a.opts.verify === false) {
474
+ verbose('步骤 6/7 已按 --no-verify 跳过连通探测 (第 6 步预检与第 7 步切换前校验都关)');
475
+ }
476
+ else if (!a.opts.skipProbe) {
477
+ // 预检必须用**和落盘时同一份凭证**: 本次新输入的 key 优先, 否则用该供应商已配置/环境变量里的那一份。
478
+ // (拿不到 key 就不带鉴权头 —— 那确实是"这个供应商现在用不了", 如实报 401, 不假装探测通过。)
479
+ const probeKey = a.pendingKey || await resolvedApiKeyOf(a.providerId).catch(() => undefined);
480
+ const t0 = Date.now();
481
+ const probe = await probeSelection({ provider: a.providerId, model: a.modelId, baseUrl: a.baseUrl, apiKey: probeKey });
482
+ const ms = Date.now() - t0;
483
+ if (!probe.ok) {
484
+ return done({
485
+ ok: false, reachedStep: 'test', failureClass: probe.failureClass,
486
+ message: `连不上/不认识这个模型 (${probe.failureClass || '未知'}): ${probe.detail} — 配置与运行时原样未动。`
487
+ + (probe.failureClass === 'model_not_found' ? ` 换个模型, 或用 \`bolloon model ${a.providerId} <model>\` 直接指定。` : ''),
488
+ });
489
+ }
490
+ push(`步骤 6/7 连通测试通过 (${a.modelId} @ ${a.baseUrl}, ${ms} ms)`
491
+ + (probe.modelAcceptedOutsideCatalog ? ' — 上游目录未列出, 但端点接受' : ''));
492
+ // 第 7 步的"确认"必须先把**要提交的东西**印出来 —— 否则用户是在确认一份看不见的配置。
493
+ // 版面压到最少: 供应商/模型/作用域/参数 (探测结论上一步已说, 不重复; 基址只在 verbose)。
494
+ push('步骤 7/7 确认');
495
+ push(` ${a.providerId} / ${a.modelId} · ${scope === 'global' ? '全局默认' : '仅当前会话'}`);
496
+ if (params.temperature !== undefined || params.reasoningMode !== undefined) {
497
+ push(` 参数: ${params.temperature === undefined ? '' : `temperature=${params.temperature}`}`
498
+ + `${params.reasoningMode === undefined ? '' : `${params.temperature === undefined ? '' : ' · '}reasoning=${params.reasoningMode ? '开' : '关'}`}`);
499
+ }
500
+ verbose(` 基址: ${a.baseUrl}`);
501
+ verbose(` 凭证: ${a.pendingKey ? '本次新输入的 key (掩码输入, 落盘为全局凭证)' : '沿用已配置 / 环境变量里的那一份'}`);
502
+ const proceed = a.opts.assumeYes ? true : await confirm('确认按上面的配置切换?', true);
503
+ if (!proceed)
504
+ return done({ ok: false, cancelled: true, reachedStep: 'test', message: '已取消, 未改动任何配置' });
505
+ }
506
+ else {
507
+ verbose('步骤 6/7 已跳过预检 (skipProbe) —— 第 7 步的切换前校验仍在');
508
+ }
509
+ // ── 7) 唯一写盘点 ───────────────────────────────────────
510
+ const req = {
511
+ provider: a.providerId,
512
+ model: a.modelId,
513
+ baseUrl: a.baseUrl,
514
+ scope,
515
+ ...(a.pendingKey ? { apiKey: a.pendingKey } : {}),
516
+ ...(a.credentialAction !== 'keep' ? { credentialAction: a.credentialAction } : {}),
517
+ ...(params.temperature !== undefined ? { temperature: params.temperature } : {}),
518
+ ...(params.reasoningMode !== undefined ? { reasoningMode: params.reasoningMode } : {}),
519
+ ...(a.opts.sessionKey ? { sessionKey: a.opts.sessionKey } : {}),
520
+ ...(a.opts.verify === false ? { verify: false } : {}),
521
+ };
522
+ verbose('交给统一入口 selectModel() (写配置 + 更新作用域 + 重建运行时 + 更新会话)');
523
+ const r = await selectModel(req);
524
+ // 逐项 ✓/✗ 事实默认**不刷屏** (成功一行, 失败一行); 想看细节走 `--verbose`。
525
+ for (const c of r.checks || [])
526
+ verbose(` ${c}`);
527
+ if (!r.ok) {
528
+ return done({
529
+ ok: false, reachedStep: 'commit', failureClass: r.failureClass,
530
+ message: `${r.message || '切换失败'} — 配置与运行时保持原样`,
531
+ checks: r.checks,
532
+ });
533
+ }
534
+ push(`✓ 已切到 ${r.effective?.provider || a.providerId}/${r.effective?.model || a.modelId}`
535
+ + `${r.effective?.configHash ? ` (hash ${String(r.effective.configHash).slice(0, 12)})` : ''}`
536
+ + ` · ${scope === 'global' ? '全局默认' : '仅当前会话'}`);
537
+ return done({ ok: true, reachedStep: 'done', effective: r.effective, checks: r.checks });
538
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * reply-hygiene.ts — 「对话回复流」卫生: 内部运行日志不许当消息渲染 (2026-09-27)
3
+ *
4
+ * 起因 (leo): 会话里的**回复流**出现了给开发者看的运行日志 ——
5
+ * `🔄 开始 ReAct 循环...` · `🧷 运行已登记 (run=…, 预算 60 步 / 30 分钟)` ·
6
+ * `✅ 处理完成,共 N 次循环` · `🎯 目标仍在进行 (未判完成): …` …
7
+ * 这些行在 Web 面上有自己的去处 (状态栏 / workflow_step), 但 CLI 交互面的 `onStream`
8
+ * **就是对话回复流** —— 在那里它们和用户的结论混在一列消息里, 属于无关信息。
9
+ *
10
+ * 判据的落点 (为什么不在 CLI 里再写一张子串黑名单):
11
+ * 旧的写法是"按内容子串猜"(`content.includes('🔄 循环')`), 于是
12
+ * `🔄 开始 ReAct 循环...` 这种**说得更早**的一句就漏了 —— 猜不完。
13
+ * 现在由**发送点 (emit) 自己声明** `internal: true` (见 `pi-sdk-types.StreamEvent`),
14
+ * 这里只做一件事: 把这个声明翻成"进不进回复流"的唯一判据。
15
+ *
16
+ * 进不进回复流 ≠ 删掉:
17
+ * 被搬走的行**同一个字**要落 `${BOLLOON_HOME:-~/.bolloon}/logs/startup.log`
18
+ * (目的地走已有的 `startupLogPath()`, 不新开第二条落盘路径), 并且 `--verbose` / `BOLLOON_VERBOSE=1`
19
+ * 时原样回到屏上 —— 诊断能力一点不丢。
20
+ */
21
+ import * as fs from 'fs';
22
+ import * as path from 'path';
23
+ import { startupLogPath } from './log-gate.js';
24
+ /** 内部运行日志在日志文件里的行首标记 (可 grep, 且明确区分于启动日志的其它行) */
25
+ export const INTERNAL_RUN_LOG_PREFIX = '[运行] ';
26
+ /**
27
+ * 把一行内部运行日志**落盘** (只落盘, 不管上不上屏)。
28
+ *
29
+ * 目的地由**已有**的 `startupLogPath()` 决定 —— 不新开第二条日志路径, 也不自己拼 HOME;
30
+ * 时间戳格式与启动日志保持一致, 于是 `grep '[运行] '` 就能把"从回复流搬走的那批行"原样捞回来。
31
+ */
32
+ export function appendInternalRunLog(content, tool) {
33
+ try {
34
+ const p = startupLogPath();
35
+ fs.mkdirSync(path.dirname(p), { recursive: true });
36
+ fs.appendFileSync(p, `[${new Date().toISOString()}] ${internalRunLogLine(content, tool)}\n`);
37
+ }
38
+ catch {
39
+ /* 落盘失败绝不影响对话本身 */
40
+ }
41
+ }
42
+ /**
43
+ * 这条 stream 事件是不是**内部运行日志** —— 是则不进对话回复流, 只落日志文件。
44
+ *
45
+ * 只认 emit 侧的显式声明 (`internal: true`), 不按内容猜:
46
+ * 缺省 (未声明) 一律当"用户可见", 保持既有行为不变。
47
+ */
48
+ export function isInternalRunLog(e) {
49
+ return !!e && e.type === 'status' && e.internal === true;
50
+ }
51
+ /** 内部运行日志落盘时的那一行 (原文一字不改, 只加可 grep 的前缀与来源工具) */
52
+ export function internalRunLogLine(content, tool) {
53
+ return `${INTERNAL_RUN_LOG_PREFIX}${tool ? `${tool} · ` : ''}${content}`;
54
+ }