@ljwei-stak/dsh-model-route-bot 0.66.3 → 0.67.0

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 (272) hide show
  1. package/.dsh-plugin/bot-product.mjs +81 -5
  2. package/.dsh-plugin/client.js +17490 -1791
  3. package/README.i18n.yaml +4 -4
  4. package/README.md +409 -364
  5. package/README.zh.md +409 -364
  6. package/apps/integration-client/README.md +31 -7
  7. package/apps/integration-client/api.mjs +14 -3
  8. package/apps/integration-client/binding-editor.jsx +308 -109
  9. package/apps/integration-client/binding-model.mjs +262 -0
  10. package/apps/integration-client/binding.css +144 -0
  11. package/apps/integration-client/bot-model-status.mjs +18 -0
  12. package/apps/integration-client/bot-profile-identity.jsx +197 -0
  13. package/apps/integration-client/bot-profile.css +57 -0
  14. package/apps/integration-client/bot-profile.mjs +97 -0
  15. package/apps/integration-client/bot-settings-dialog.css +18 -0
  16. package/apps/integration-client/bot-settings-dialog.jsx +111 -0
  17. package/apps/integration-client/bot-thread-header-bridge.css +20 -0
  18. package/apps/integration-client/bot-thread-header.jsx +278 -0
  19. package/apps/integration-client/bot-threads-panel.jsx +378 -0
  20. package/apps/integration-client/bot-threads.css +169 -0
  21. package/apps/integration-client/chat-design.mjs +250 -0
  22. package/apps/integration-client/chat-ui.jsx +175 -0
  23. package/apps/integration-client/chat.css +477 -0
  24. package/apps/integration-client/composer-draft-binding.mjs +50 -0
  25. package/apps/integration-client/composer-draft-bound.jsx +14 -0
  26. package/apps/integration-client/composer-draft-host.jsx +142 -0
  27. package/apps/integration-client/composer-draft-session.jsx +287 -0
  28. package/apps/integration-client/composer-draft-witness.mjs +115 -0
  29. package/apps/integration-client/composer-draft.css +4 -0
  30. package/apps/integration-client/composer-draft.mjs +322 -0
  31. package/apps/integration-client/contact-attention.jsx +54 -0
  32. package/apps/integration-client/data-panel.jsx +439 -0
  33. package/apps/integration-client/data.css +69 -0
  34. package/apps/integration-client/desktop-bridge.mjs +1 -0
  35. package/apps/integration-client/desktop-http-socket.mjs +49 -0
  36. package/apps/integration-client/desktop-keyboard.mjs +32 -0
  37. package/apps/integration-client/desktop-pane.jsx +99 -30
  38. package/apps/integration-client/desktop-stream.mjs +13 -3
  39. package/apps/integration-client/desktop-tools.jsx +19 -0
  40. package/apps/integration-client/desktop-touch.mjs +27 -0
  41. package/apps/integration-client/desktop.css +41 -0
  42. package/apps/integration-client/independent-chat-controller.mjs +224 -0
  43. package/apps/integration-client/independent-chat-entry.jsx +61 -0
  44. package/apps/integration-client/independent-chat-navigation.mjs +21 -0
  45. package/apps/integration-client/independent-chat-page.jsx +148 -0
  46. package/apps/integration-client/independent-chat-rich.jsx +64 -0
  47. package/apps/integration-client/independent-chat-rich.mjs +30 -0
  48. package/apps/integration-client/independent-chat.css +73 -0
  49. package/apps/integration-client/index.jsx +24 -16
  50. package/apps/integration-client/library-panel.jsx +4 -1
  51. package/apps/integration-client/licenses/jsqr-1.4.0-LICENSE.txt +202 -0
  52. package/apps/integration-client/main-route-editor.jsx +94 -0
  53. package/apps/integration-client/main-route-model.mjs +100 -0
  54. package/apps/integration-client/model-picker.css +37 -0
  55. package/apps/integration-client/model-picker.jsx +83 -0
  56. package/apps/integration-client/native-activity.css +141 -0
  57. package/apps/integration-client/native-activity.jsx +276 -0
  58. package/apps/integration-client/native-activity.mjs +384 -0
  59. package/apps/integration-client/notifications-panel.jsx +186 -0
  60. package/apps/integration-client/notifications.css +126 -0
  61. package/apps/integration-client/pairing-qr.jsx +79 -0
  62. package/apps/integration-client/panels.jsx +126 -115
  63. package/apps/integration-client/product-slots.jsx +46 -0
  64. package/apps/integration-client/routine-recorder.mjs +23 -2
  65. package/apps/integration-client/secure-pairing.jsx +84 -0
  66. package/apps/integration-client/tasks-panel.jsx +32 -10
  67. package/apps/integration-client/ui-icons.jsx +17 -0
  68. package/apps/integration-client/usage-explorer.jsx +43 -0
  69. package/apps/integration-client/usage-report.mjs +71 -0
  70. package/apps/integration-client/usage.css +34 -0
  71. package/apps/integration-client/web-panel-model.mjs +140 -0
  72. package/apps/integration-client/web-panel-page.jsx +221 -0
  73. package/apps/integration-client/web-panel-pins-live.jsx +28 -0
  74. package/apps/integration-client/web-panel.css +139 -0
  75. package/apps/integration-client/workspace-model.mjs +67 -0
  76. package/apps/integration-client/workspace-navigation.mjs +56 -0
  77. package/apps/integration-client/workspace-ui.jsx +173 -0
  78. package/apps/integration-client/workspace.css +332 -0
  79. package/modules/bot-runtime/SOURCE.json +176 -50
  80. package/modules/bot-runtime/index.mjs +169 -13
  81. package/modules/bot-runtime/product-overlay/OVERLAY.json +128 -2
  82. package/modules/bot-runtime/product-overlay/base/src/client/header.js +244 -0
  83. package/modules/bot-runtime/product-overlay/base/src/client/learning-card.js +202 -0
  84. package/modules/bot-runtime/product-overlay/base/src/client/model-menu.js +371 -0
  85. package/modules/bot-runtime/product-overlay/base/src/client/secret-card.js +363 -0
  86. package/modules/bot-runtime/product-overlay/base/src/client/sources.js +68 -0
  87. package/modules/bot-runtime/product-overlay/base/src/client/turn-error.js +260 -0
  88. package/modules/bot-runtime/product-overlay/base/src/host/channel.js +647 -0
  89. package/modules/bot-runtime/product-overlay/base/src/host/memory.js +2050 -0
  90. package/modules/bot-runtime/product-overlay/base/src/host/models.js +314 -0
  91. package/modules/bot-runtime/product-overlay/base/src/host/parts.js +1473 -0
  92. package/modules/bot-runtime/product-overlay/base/src/host/privacy.js +368 -0
  93. package/modules/bot-runtime/product-overlay/base/src/host/roster.js +776 -0
  94. package/modules/bot-runtime/product-overlay/base/src/host/store.js +512 -0
  95. package/modules/bot-runtime/product-overlay/base/src/host/usage.js +653 -0
  96. package/modules/bot-runtime/product-overlay/patches/src/client/details-drawer.js.patch +48 -2
  97. package/modules/bot-runtime/product-overlay/patches/src/client/header.js.patch +41 -0
  98. package/modules/bot-runtime/product-overlay/patches/src/client/index.js.patch +33 -1
  99. package/modules/bot-runtime/product-overlay/patches/src/client/learning-card.js.patch +20 -0
  100. package/modules/bot-runtime/product-overlay/patches/src/client/model-menu.js.patch +24 -0
  101. package/modules/bot-runtime/product-overlay/patches/src/client/secret-card.js.patch +25 -0
  102. package/modules/bot-runtime/product-overlay/patches/src/client/sources.js.patch +52 -0
  103. package/modules/bot-runtime/product-overlay/patches/src/client/turn-error.js.patch +23 -0
  104. package/modules/bot-runtime/product-overlay/patches/src/host/channel.js.patch +46 -0
  105. package/modules/bot-runtime/product-overlay/patches/src/host/memory.js.patch +11 -0
  106. package/modules/bot-runtime/product-overlay/patches/src/host/models.js.patch +16 -0
  107. package/modules/bot-runtime/product-overlay/patches/src/host/parts.js.patch +95 -0
  108. package/modules/bot-runtime/product-overlay/patches/src/host/privacy.js.patch +11 -0
  109. package/modules/bot-runtime/product-overlay/patches/src/host/roster.js.patch +145 -0
  110. package/modules/bot-runtime/product-overlay/patches/src/host/store.js.patch +68 -0
  111. package/modules/bot-runtime/product-overlay/patches/src/host/usage.js.patch +11 -0
  112. package/modules/bot-runtime/read-model.mjs +19 -4
  113. package/modules/bot-runtime/vendor/ds-bot/src/client/details-drawer.js +15 -5
  114. package/modules/bot-runtime/vendor/ds-bot/src/client/header.js +9 -2
  115. package/modules/bot-runtime/vendor/ds-bot/src/client/index.js +9 -2
  116. package/modules/bot-runtime/vendor/ds-bot/src/client/learning-card.js +2 -2
  117. package/modules/bot-runtime/vendor/ds-bot/src/client/model-menu.js +5 -3
  118. package/modules/bot-runtime/vendor/ds-bot/src/client/secret-card.js +3 -3
  119. package/modules/bot-runtime/vendor/ds-bot/src/client/sources.js +25 -2
  120. package/modules/bot-runtime/vendor/ds-bot/src/client/turn-error.js +3 -2
  121. package/modules/bot-runtime/vendor/ds-bot/src/host/channel.js +14 -2
  122. package/modules/bot-runtime/vendor/ds-bot/src/host/memory.js +1 -1
  123. package/modules/bot-runtime/vendor/ds-bot/src/host/models.js +7 -0
  124. package/modules/bot-runtime/vendor/ds-bot/src/host/parts.js +26 -9
  125. package/modules/bot-runtime/vendor/ds-bot/src/host/privacy.js +1 -1
  126. package/modules/bot-runtime/vendor/ds-bot/src/host/roster.js +112 -3
  127. package/modules/bot-runtime/vendor/ds-bot/src/host/store.js +34 -1
  128. package/modules/bot-runtime/vendor/ds-bot/src/host/usage.js +1 -1
  129. package/modules/independent-runtime/index.mjs +152 -62
  130. package/modules/independent-runtime/remote-tools.mjs +8 -5
  131. package/modules/openmaus-protocol/NOTICE +10 -0
  132. package/modules/openmaus-protocol/README.md +15 -0
  133. package/modules/openmaus-protocol/SOURCE.json +68 -0
  134. package/modules/openmaus-protocol/dist/protocol.mjs +1203 -0
  135. package/modules/openmaus-protocol/index.mjs +1 -0
  136. package/modules/openmaus-protocol/transport.mjs +77 -0
  137. package/modules/openmaus-protocol/vendor/LICENSE +202 -0
  138. package/modules/openmaus-protocol/vendor/companion/src/e2e/README.md +405 -0
  139. package/modules/openmaus-protocol/vendor/companion/src/e2e/credentials.ts +128 -0
  140. package/modules/openmaus-protocol/vendor/companion/src/e2e/descriptor.ts +84 -0
  141. package/modules/openmaus-protocol/vendor/companion/src/e2e/noise.ts +456 -0
  142. package/modules/openmaus-protocol/vendor/companion/src/e2e/phone.ts +104 -0
  143. package/modules/openmaus-protocol/vendor/companion/src/e2e/session.ts +417 -0
  144. package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.json +2565 -0
  145. package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.sha256 +1 -0
  146. package/modules/openmaus-protocol/vendor/companion/src/e2e/yamux.ts +335 -0
  147. package/package.json +20 -6
  148. package/packages/bot-acp/README.md +156 -0
  149. package/packages/bot-acp/licenses/LICENSE-OpenMausBot +202 -0
  150. package/packages/bot-acp/licenses/NOTICE.md +27 -0
  151. package/packages/bot-acp/src/acp-adapter.mjs +795 -0
  152. package/packages/bot-acp/src/acp-host.mjs +148 -0
  153. package/packages/bot-acp/src/native-launch.mjs +121 -0
  154. package/packages/bot-acp/src/permission-policy.mjs +283 -0
  155. package/packages/bot-acp/src/rpc-codec.mjs +265 -0
  156. package/packages/bot-acp/src/session-core.mjs +581 -0
  157. package/packages/bot-acp/src/stdio-rpc.mjs +329 -0
  158. package/packages/bot-engines/README.md +281 -0
  159. package/packages/bot-engines/licenses/LICENSE-OpenMausBot +202 -0
  160. package/packages/bot-engines/licenses/NOTICE.md +32 -0
  161. package/packages/bot-engines/src/claude-engine.mjs +772 -0
  162. package/packages/bot-engines/src/claude-llm-adapter.mjs +797 -0
  163. package/packages/bot-engines/src/claude-llm-turn.mjs +292 -0
  164. package/packages/bot-engines/src/claude-permission-broker.mjs +130 -0
  165. package/packages/bot-engines/src/claude-permission-proxy.mjs +87 -0
  166. package/packages/bot-engines/src/claude-protocol.mjs +177 -0
  167. package/packages/bot-engines/src/driver-catalog.mjs +334 -0
  168. package/packages/bot-engines/src/host-loop-request.mjs +95 -0
  169. package/packages/bot-engines/src/host-registrations.mjs +105 -0
  170. package/packages/capability-broker/src/cli-bootstrap.mjs +86 -16
  171. package/packages/capability-broker/src/cli-bridge-response.mjs +80 -0
  172. package/packages/capability-broker/src/cli-stdio-bridge.mjs +21 -8
  173. package/packages/cli-runtime/src/codex-session-core.mjs +4 -0
  174. package/packages/cli-runtime/src/native-cli-output.mjs +57 -0
  175. package/packages/cli-runtime/src/native-peer.ps1 +51 -3
  176. package/packages/cli-runtime/src/native-process.mjs +77 -14
  177. package/packages/model-router-plugin/README.md +3 -3
  178. package/packages/model-router-plugin/package.json +1 -1
  179. package/packages/orchestration/src/binding-product.mjs +19 -0
  180. package/packages/orchestration/src/bot-data-actions.mjs +45 -0
  181. package/packages/orchestration/src/bot-data-chart.mjs +109 -0
  182. package/packages/orchestration/src/bot-data-context.mjs +44 -0
  183. package/packages/orchestration/src/bot-data-engine.mjs +809 -0
  184. package/packages/orchestration/src/bot-data-io.mjs +186 -0
  185. package/packages/orchestration/src/bot-data-sql.mjs +195 -0
  186. package/packages/orchestration/src/bot-data-surface.mjs +144 -0
  187. package/packages/orchestration/src/bot-data-tools.mjs +68 -0
  188. package/packages/orchestration/src/bot-data-worker.mjs +228 -0
  189. package/packages/orchestration/src/bot-independent-execution.mjs +195 -0
  190. package/packages/orchestration/src/bot-independent-stream.mjs +74 -0
  191. package/packages/orchestration/src/bot-loop.mjs +152 -19
  192. package/packages/orchestration/src/bot-notifications.mjs +159 -0
  193. package/packages/orchestration/src/bot-policy.mjs +43 -4
  194. package/packages/orchestration/src/bot-profile.mjs +171 -0
  195. package/packages/orchestration/src/bot-routine.mjs +4 -4
  196. package/packages/orchestration/src/bot-runtime-assembly.mjs +255 -8
  197. package/packages/orchestration/src/bot-runtime-switch.mjs +13 -2
  198. package/packages/orchestration/src/bot-serving.mjs +164 -0
  199. package/packages/orchestration/src/bot-session-store.mjs +105 -9
  200. package/packages/orchestration/src/bot-settings.mjs +50 -11
  201. package/packages/orchestration/src/bot-thread-admission.mjs +84 -0
  202. package/packages/orchestration/src/bot-thread-host.mjs +187 -0
  203. package/packages/orchestration/src/bot-threads.mjs +790 -0
  204. package/packages/orchestration/src/data-product-access.mjs +37 -0
  205. package/packages/orchestration/src/data-tool-contract.mjs +291 -0
  206. package/packages/orchestration/src/desktop-http-carrier.mjs +120 -0
  207. package/packages/orchestration/src/desktop-proxy.mjs +311 -0
  208. package/packages/orchestration/src/desktop-sessions.mjs +43 -9
  209. package/packages/orchestration/src/ds-lifecycle.mjs +31 -4
  210. package/packages/orchestration/src/grant-refresh-queue.mjs +17 -0
  211. package/packages/orchestration/src/independent-chat-page.mjs +39 -0
  212. package/packages/orchestration/src/independent-chat-service.mjs +308 -0
  213. package/packages/orchestration/src/independent-context-state.mjs +44 -0
  214. package/packages/orchestration/src/independent-message-wire.mjs +84 -0
  215. package/packages/orchestration/src/independent-tool-catalog.mjs +175 -0
  216. package/packages/orchestration/src/independent-tool-families.mjs +73 -0
  217. package/packages/orchestration/src/independent-tool-host.mjs +344 -0
  218. package/packages/orchestration/src/main-bot-grant.mjs +15 -0
  219. package/packages/orchestration/src/main-model-routes.mjs +208 -0
  220. package/packages/orchestration/src/model-accounting.mjs +99 -35
  221. package/packages/orchestration/src/model-auxiliary-dispatch.mjs +187 -0
  222. package/packages/orchestration/src/model-loop-frame.mjs +179 -0
  223. package/packages/orchestration/src/model-prepared-generation.mjs +175 -0
  224. package/packages/orchestration/src/model-request-bounds.mjs +111 -0
  225. package/packages/orchestration/src/native-activity-contract.mjs +491 -0
  226. package/packages/orchestration/src/native-activity-service.mjs +102 -0
  227. package/packages/orchestration/src/native-activity-store.mjs +249 -0
  228. package/packages/orchestration/src/native-conversation-tools.mjs +78 -0
  229. package/packages/orchestration/src/native-data-tools.mjs +100 -0
  230. package/packages/orchestration/src/native-remote-actions.mjs +742 -0
  231. package/packages/orchestration/src/native-team-tools.mjs +73 -0
  232. package/packages/orchestration/src/pairing-invite.mjs +37 -0
  233. package/packages/orchestration/src/product-composition.mjs +1559 -1001
  234. package/packages/orchestration/src/remote-machines.mjs +82 -14
  235. package/packages/orchestration/src/routine-execution.mjs +5 -3
  236. package/packages/orchestration/src/routine-session.mjs +19 -13
  237. package/packages/orchestration/src/secure-pairing-host.mjs +112 -0
  238. package/packages/orchestration/src/task-journal.mjs +29 -0
  239. package/packages/orchestration/src/task-product.mjs +17 -0
  240. package/packages/orchestration/src/team-assignment-store.mjs +120 -0
  241. package/packages/orchestration/src/team-bridge.mjs +282 -0
  242. package/packages/orchestration/src/team-scheduler.mjs +83 -0
  243. package/packages/orchestration/src/team-sdk-tools.mjs +84 -0
  244. package/packages/orchestration/src/team-tool-contract.mjs +284 -0
  245. package/packages/orchestration/src/ui-host-namespace.mjs +23 -0
  246. package/packages/orchestration/src/web-panel-actions.mjs +103 -0
  247. package/packages/orchestration/src/web-panel-framing.mjs +227 -0
  248. package/packages/orchestration/src/web-panel-store.mjs +206 -0
  249. package/remote-computer-agent/README.md +39 -29
  250. package/remote-computer-agent/build.mjs +3 -0
  251. package/remote-computer-agent/dist/OPENMAUSBOT_LICENSE +202 -0
  252. package/remote-computer-agent/dist/OPENMAUSBOT_NOTICE +10 -0
  253. package/remote-computer-agent/dist/OPENMAUSBOT_SOURCE.json +68 -0
  254. package/remote-computer-agent/dist/cli-stdio-bridge.mjs +215 -25
  255. package/remote-computer-agent/dist/cli-stdio-bridge.mjs.map +4 -4
  256. package/remote-computer-agent/dist/cli.mjs +1688 -168
  257. package/remote-computer-agent/dist/cli.mjs.map +4 -4
  258. package/remote-computer-agent/dist/index.mjs +1579 -122
  259. package/remote-computer-agent/dist/index.mjs.map +4 -4
  260. package/remote-computer-agent/dist/native-peer.ps1 +51 -3
  261. package/remote-computer-agent/package-lock.json +308 -2
  262. package/remote-computer-agent/package.json +2 -1
  263. package/remote-computer-agent/pair-device.ps1 +20 -0
  264. package/remote-computer-agent/setup.ps1 +38 -16
  265. package/remote-computer-agent/src/cli.ts +24 -3
  266. package/remote-computer-agent/src/desktop-session.ts +11 -3
  267. package/remote-computer-agent/src/e2e-server.ts +81 -0
  268. package/remote-computer-agent/src/pairing-page.ts +35 -0
  269. package/remote-computer-agent/src/protocol.ts +25 -25
  270. package/remote-computer-agent/src/secure-pairing.ts +105 -0
  271. package/remote-computer-agent/src/store.ts +20 -5
  272. package/remote-computer-agent/src/transport.ts +39 -15
@@ -0,0 +1,73 @@
1
+ /** Add Host-selected families to the API loop without inventing a Harness Agent.
2
+ * Native CLI routes retain their private, typed MCP doors. Families are resolved
3
+ * again per catalog/call, and never supply model or funding authority.
4
+ */
5
+ const fail = (code, message = code) => { throw Object.assign(new Error(message), { code }) }
6
+
7
+ export function extendIndependentToolHost({ base, familiesFor, validateCall = () => {} } = {}) {
8
+ if (typeof base?.forBot !== 'function' || typeof familiesFor !== 'function') throw new TypeError('INDEPENDENT_FAMILIES_HOST_REQUIRED')
9
+ let closed = false
10
+ const lifetime = new AbortController()
11
+ const alive = () => { if (closed) fail('TOOL_HOST_CLOSED', '宿主工具服务已关闭,本次调用被拒绝。') }
12
+ return Object.freeze({ ...base,
13
+ forBot(botId, options = {}) {
14
+ const view = base.forBot(botId, options)
15
+ // The native adapter has its own schema/parser/renderers and must never
16
+ // convert a CLI-observed call into an SDK executable call.
17
+ if (options.nativeRoute) return view
18
+ const offered = context => {
19
+ alive()
20
+ const entries = familiesFor(botId, context) ?? []
21
+ if (!Array.isArray(entries)) fail('INDEPENDENT_FAMILIES_INVALID')
22
+ const names = new Set(view.schemas().map(entry => entry.name))
23
+ for (const entry of entries) {
24
+ const name = entry.handler?.definition?.name
25
+ if (typeof name !== 'string' || !entry.handler.definition.inputSchema
26
+ || typeof entry.handler.parse !== 'function' || typeof entry.handler.execute !== 'function'
27
+ || names.has(name)) fail('INDEPENDENT_FAMILIES_INVALID')
28
+ names.add(name)
29
+ }
30
+ return entries
31
+ }
32
+ return Object.freeze({ ...view,
33
+ schemas: () => [...view.schemas(), ...offered().map(({ handler }) => ({ name: handler.definition.name,
34
+ description: handler.definition.description, parameters: handler.definition.inputSchema }))],
35
+ isReadonly: name => {
36
+ const entry = offered().find(row => row.handler.definition.name === name)
37
+ return entry ? entry.readonly === true : view.isReadonly(name)
38
+ },
39
+ async call(name, args = {}, context = {}) {
40
+ alive()
41
+ if (context.botId !== undefined && context.botId !== botId) fail('SUBJECT_MISMATCH')
42
+ await validateCall(botId, context)
43
+ alive(); context.signal?.throwIfAborted()
44
+ const entry = offered(context).find(row => row.handler.definition.name === name)
45
+ if (!entry) {
46
+ const value = await view.call(name, args, context)
47
+ alive(); context.signal?.throwIfAborted()
48
+ await validateCall(botId, context)
49
+ return value
50
+ }
51
+ if (typeof context.turnId !== 'string' || !context.turnId || typeof context.callId !== 'string' || !context.callId || !context.signal) fail('INDEPENDENT_TOOL_TURN_REQUIRED')
52
+ const signal = AbortSignal.any([lifetime.signal, context.signal])
53
+ signal.throwIfAborted()
54
+ const parsed = entry.handler.parse(args)
55
+ await entry.validate?.()
56
+ const value = await entry.handler.execute(parsed, { context: { turnId: context.turnId, callId: context.callId,
57
+ ...(typeof context.conversationId === 'string' ? { conversationId: context.conversationId } : {}) }, signal })
58
+ alive(); signal.throwIfAborted()
59
+ await entry.validate?.()
60
+ await validateCall(botId, context)
61
+ alive(); signal.throwIfAborted()
62
+ return value
63
+ },
64
+ })
65
+ },
66
+ close() {
67
+ if (closed) return
68
+ closed = true
69
+ lifetime.abort(new Error('TOOL_HOST_CLOSED'))
70
+ base.close?.()
71
+ },
72
+ })
73
+ }
@@ -0,0 +1,344 @@
1
+ /**
2
+ * The Host's own tool service for one independent Bot.
3
+ *
4
+ * The independent loop has a transcript and a cancel signal; it has no machine, no task journal and
5
+ * no authority. This module is the seam the Host composes once and publishes, and it is the only
6
+ * way an independent turn reaches the outside world:
7
+ *
8
+ * ```
9
+ * ctx.provide('botIndependentToolHost', createIndependentToolHost({
10
+ * directory, machines: () => machines, taskService: () => taskService,
11
+ * backends: () => taskBackends ? taskBackends.catalog().map(entry => entry.id) : [], now,
12
+ * }))
13
+ * ```
14
+ *
15
+ * `forBot(botId)` answers with a live view of that Bot: `schemas()` is re-decided from committed
16
+ * grants on every call, `isReadonly()` says whether a call may run beside another, and `call()`
17
+ * re-decides everything again before a single byte leaves the process.
18
+ *
19
+ * Four rules are load-bearing:
20
+ *
21
+ * 1. **Identity is Host-owned.** The machine, the root, the task id, the attempt id, the operation
22
+ * id and the request key are derived from the Bot's committed binding and the turn identity the
23
+ * loop hands over. A reply may name one of its own bound machines or roots, and may reference an
24
+ * operation or task the Host already issued; it can never mint one.
25
+ * 2. **A write is a durable Task or it does not happen.** `bot_fs_write` and `bot_command` go
26
+ * through the existing task service — submit, attempt, operation, journal — with a request key
27
+ * derived from the turn and the call, so a repeated call re-reads the same task instead of
28
+ * dispatching a second one.
29
+ * 3. **An unknown outcome is reconciled, never replayed.** When the backend cannot prove what
30
+ * happened the call throws with the task identity attached; nothing here submits a replacement.
31
+ * 4. **The cancel signal reaches the machine and the task wait.** A cancel that arrives before
32
+ * dispatch produces zero machine calls; one that arrives during leaves the remote operation its
33
+ * own identity and its own stop evidence.
34
+ *
35
+ * Disposal is part of the contract: `close()` aborts this service's own waits and drops its
36
+ * registries, and refuses further calls. It never claims a remote operation stopped — the durable
37
+ * task keeps its identity and the Host reconciles it. A Host that published this service should call
38
+ * `close()` from its own dispose effect, beside closing the task service and the machine directory.
39
+ */
40
+
41
+ import { createHash, randomUUID } from 'node:crypto'
42
+ import { catalogFor, independentToolEntry } from './independent-tool-catalog.mjs'
43
+
44
+ const fail = (code, message, extra) => { throw Object.assign(new Error(message ?? code), { code, ...(extra ?? {}) }) }
45
+ const ref = value => typeof value === 'string' && /^[-A-Za-z0-9_.]{1,128}$/.test(value)
46
+ const key = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value)
47
+ const plain = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
48
+ const cancelled = () => Object.assign(new Error('这一轮已被取消。'), { code: 'BOT_LOOP_CANCELLED' })
49
+ const closedError = taskId => Object.assign(new Error('宿主工具服务已关闭,本次等待结束;远端任务保持自己的身份,由宿主对账。'), { code: 'TOOL_HOST_CLOSED', taskId })
50
+
51
+ /** Task states that will not change without a Host decision. */
52
+ const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'blocked', 'skipped', 'needs-reconciliation', 'waiting-approval'])
53
+ /** Task states the loop may keep waiting on. */
54
+ const PENDING = new Set(['ready', 'admitting', 'running', 'cancelling', 'waiting-budget'])
55
+
56
+ /** How many issued operation/task ids one Bot may reference. Older ones simply stop being citable. */
57
+ const ISSUED_LIMIT = 512
58
+ /**
59
+ * A stable, Host-owned reference from a turn identity and one call id. Both come from the loop, not
60
+ * from the model, and neither is ever echoed back into the request as authority.
61
+ */
62
+ const reference = (turnId, callId) => createHash('sha256').update(`${String(turnId ?? '')}/${String(callId ?? '')}`).digest('hex').slice(0, 32)
63
+
64
+ /**
65
+ * @param {{
66
+ * directory: { forBot: (botId: string) => unknown },
67
+ * authority?: { assertTool: (subject: unknown, name: string) => void } | null,
68
+ * machines: object | (() => object | null),
69
+ * taskService?: object | (() => object | null) | null,
70
+ * backends?: string[] | (() => string[]),
71
+ * supportsToolsFor?: (botId: string) => boolean,
72
+ * now?: () => number,
73
+ * pollMs?: number,
74
+ * waitMs?: number,
75
+ * issuedLimit?: number,
76
+ * }} options
77
+ */
78
+ export function createIndependentToolHost({ directory, authority = null, machines, taskService = null, backends = [], supportsToolsFor = () => true, now = Date.now, pollMs = 50, waitMs = 120_000, issuedLimit = ISSUED_LIMIT } = {}) {
79
+ if (!directory || typeof directory.forBot !== 'function') throw new TypeError('INDEPENDENT_TOOL_HOST_REQUIRES_DIRECTORY')
80
+ if (machines === undefined || machines === null) throw new TypeError('INDEPENDENT_TOOL_HOST_REQUIRES_MACHINES')
81
+ if (typeof supportsToolsFor !== 'function') throw new TypeError('INDEPENDENT_TOOL_HOST_REQUIRES_MODEL_CAPABILITIES')
82
+ if (!Number.isSafeInteger(pollMs) || pollMs < 5 || pollMs > 1000) throw new TypeError('INVALID_TOOL_HOST_POLL')
83
+ if (!Number.isSafeInteger(waitMs) || waitMs < 1000 || waitMs > 3_600_000) throw new TypeError('INVALID_TOOL_HOST_WAIT')
84
+ if (!Number.isSafeInteger(issuedLimit) || issuedLimit < 1 || issuedLimit > 100_000) throw new TypeError('INVALID_TOOL_HOST_ISSUED_LIMIT')
85
+ const machineStore = () => {
86
+ const value = typeof machines === 'function' ? machines() : machines
87
+ if (!value || typeof value.call !== 'function') fail('MACHINE_DIRECTORY_UNAVAILABLE')
88
+ return value
89
+ }
90
+ const tasks = () => {
91
+ const value = typeof taskService === 'function' ? taskService() : taskService
92
+ return value && typeof value.submit === 'function' ? value : null
93
+ }
94
+ const backendIds = () => {
95
+ const value = typeof backends === 'function' ? backends() : backends
96
+ const ids = Array.isArray(value) ? value.filter(id => typeof id === 'string') : []
97
+ return tasks() ? [...ids, 'task-service'] : ids
98
+ }
99
+ // Operations and tasks this Host issued, per Bot. A reply may only reference one of these; it
100
+ // cannot ask about an operation it never received. The registry is bounded, and it is the only
101
+ // state this service keeps.
102
+ const issued = new Map()
103
+ const waits = new Set()
104
+ let closed = false
105
+ const remember = (botId, id) => {
106
+ const set = issued.get(botId) ?? new Set()
107
+ set.add(id)
108
+ while (set.size > issuedLimit) set.delete(set.values().next().value)
109
+ issued.set(botId, set)
110
+ }
111
+ const known = (botId, id) => Boolean(issued.get(botId)?.has(id))
112
+ const alive = () => { if (closed) fail('TOOL_HOST_CLOSED', '宿主工具服务已关闭,本次调用被拒绝。') }
113
+ /** Every wait this service is sitting in, so a close can end them without a stop claim. */
114
+ const track = waiter => { waits.add(waiter); return () => waits.delete(waiter) }
115
+
116
+ /** Live committed bindings for one Bot, straight from the machine directory. */
117
+ const scopesFor = botId => {
118
+ const store = machineStore()
119
+ const rows = typeof store.scopes === 'function' ? store.scopes(botId) : []
120
+ return rows.filter(scope => scope && ref(scope.machineId) && Array.isArray(scope.tools) && Array.isArray(scope.roots))
121
+ }
122
+
123
+ /**
124
+ * Whether this Bot's authority still holds a capability.
125
+ *
126
+ * The catalog is not allowed to offer a tool the authority would refuse, so a grant that was
127
+ * withdrawn takes the tool out of the request instead of leaving the model to discover the refusal
128
+ * one call at a time. A Host that publishes no authority keeps the machine binding as its only
129
+ * gate, and the call itself is still re-checked there.
130
+ */
131
+ const grantedFor = botId => capability => {
132
+ if (!authority || typeof authority.assertTool !== 'function') return true
133
+ try { authority.assertTool(directory.forBot(botId), capability); return true } catch { return false }
134
+ }
135
+
136
+ /**
137
+ * The machine and root this call runs on, re-decided now.
138
+ *
139
+ * A reply may name one of its own live bindings and one of that binding's roots; anything else is
140
+ * refused before the machine directory is touched.
141
+ */
142
+ const targetFor = (botId, entry, args) => {
143
+ const scopes = scopesFor(botId)
144
+ const wanted = args.machineId
145
+ if (wanted !== undefined && (typeof wanted !== 'string' || !scopes.some(scope => scope.machineId === wanted))) fail('MACHINE_NOT_BOUND')
146
+ const scope = wanted ? scopes.find(item => item.machineId === wanted) : scopes[0]
147
+ if (!scope) fail('MACHINE_NOT_BOUND')
148
+ if (!scope.tools.includes(entry.machineTool)) fail('MACHINE_BOT_DENIED', `该 Bot 未获授 ${entry.machineTool}。`)
149
+ if (entry.effect === 'workspace-write' && scope.mode !== 'workspace-write') fail('MACHINE_BOT_DENIED', '该授权是只读的。')
150
+ const rootWanted = args.rootId
151
+ if (rootWanted !== undefined && (typeof rootWanted !== 'string' || !scope.roots.includes(rootWanted))) fail('MACHINE_ROOT_NOT_BOUND')
152
+ const rootId = rootWanted ?? scope.roots[0]
153
+ if (typeof rootId !== 'string' || !rootId) fail('MACHINE_ROOT_NOT_BOUND')
154
+ return { machineId: scope.machineId, rootId }
155
+ }
156
+
157
+ /** The business arguments: identity keys are stripped before they can reach the machine. */
158
+ const businessOf = args => {
159
+ const calls = { ...args }
160
+ for (const name of ['machineId', 'rootId', 'botId', 'taskId', 'attemptId', 'operationId', 'endpoint', 'secretRef', 'grantToken', 'executable', 'env']) delete calls[name]
161
+ return calls
162
+ }
163
+
164
+ const waitForTask = async (botId, taskId, signal) => {
165
+ const service = tasks()
166
+ const deadline = now() + waitMs
167
+ for (;;) {
168
+ alive()
169
+ signal?.throwIfAborted()
170
+ const task = service.get(directory.forBot(botId), { taskId })
171
+ if (!task) fail('TASK_NOT_FOUND', '任务记录不存在。', { taskId })
172
+ if (TERMINAL.has(task.state)) return task
173
+ if (!PENDING.has(task.state)) fail('TASK_STATE_UNREADABLE', `任务状态无法读取:${task.state}`, { taskId, state: task.state })
174
+ if (now() >= deadline) fail('TASK_WAIT_TIMEOUT', '等待任务结束时超时。', { taskId, state: task.state })
175
+ await new Promise((resolve, reject) => {
176
+ let untrack = () => {}
177
+ const stop = why => { untrack(); clearTimeout(timer); signal?.removeEventListener('abort', onAbort); reject(why) }
178
+ const onAbort = () => stop(cancelled())
179
+ // A close ends this wait locally; it says nothing about whether the remote task stopped.
180
+ untrack = track(() => stop(closedError(taskId)))
181
+ const timer = setTimeout(() => { untrack(); resolve() }, pollMs)
182
+ if (signal?.aborted) return stop(cancelled())
183
+ signal?.addEventListener('abort', onAbort, { once: true })
184
+ })
185
+ }
186
+ }
187
+ const runMachineTool = (botId, entry, args, context) => {
188
+ const subject = directory.forBot(botId)
189
+ const target = targetFor(botId, entry, args)
190
+ const taskId = `bot-${reference(context.turnId, botId)}`
191
+ const attemptId = `attempt-${reference(context.callId, entry.name)}`
192
+ const calls = businessOf(args)
193
+ // The machine layer addresses its own workspace by root, and it requires the two spellings to
194
+ // agree; the root is the one the Host just chose, never one the reply named.
195
+ calls.rootId = target.rootId
196
+ // An observation carries its own operation id as Host metadata, and the machine layer requires
197
+ // the two spellings to agree.
198
+ const observation = entry.machineTool === 'operation_status' || entry.machineTool === 'operation_cancel'
199
+ if (observation) calls.operationId = args.operationId
200
+ return machineStore().call(subject, {
201
+ machineId: target.machineId, rootId: target.rootId, taskId, attemptId, tool: entry.machineTool, args: calls,
202
+ ...(observation ? { operationId: args.operationId } : {}),
203
+ }, { ...(context.signal ? { signal: context.signal } : {}) })
204
+ }
205
+
206
+ const runTaskTool = async (botId, entry, args, context) => {
207
+ const service = tasks()
208
+ const subject = directory.forBot(botId)
209
+ const target = targetFor(botId, entry, args)
210
+ // The request key is the Host's own correlation of this turn and this call: a repeated call
211
+ // reads the same task instead of dispatching a second one.
212
+ const requestKey = `${botId}-${reference(context.turnId, context.callId)}`
213
+ const input = { machineId: target.machineId, rootId: target.rootId, ...businessOf(args) }
214
+ let submitted
215
+ try {
216
+ submitted = service.submit(subject, { requestKey, backend: entry.backend, input })
217
+ } catch (error) {
218
+ if (error?.code === 'TASK_BACKEND_UNAVAILABLE') fail('TASK_BACKEND_UNAVAILABLE', '宿主未注册该持久任务后端。', { capability: entry.name })
219
+ throw error
220
+ }
221
+ const taskId = submitted?.taskId
222
+ if (!key(taskId)) fail('TASK_SUBMIT_UNREADABLE', '任务提交没有返回可读身份。')
223
+ remember(botId, taskId)
224
+ const cancel = () => { try { service.cancel(subject, { taskId }) } catch { /* the journal retains its current outcome */ } }
225
+ context.signal?.addEventListener('abort', cancel, { once: true })
226
+ let task
227
+ try {
228
+ if (context.signal?.aborted) cancel()
229
+ task = await waitForTask(botId, taskId, context.signal)
230
+ } finally { context.signal?.removeEventListener('abort', cancel) }
231
+ if (task.state !== 'succeeded') {
232
+ // Unknown stays unknown: the task keeps its identity and its evidence, and the caller is told
233
+ // which task to reconcile. Nothing here submits a replacement.
234
+ const code = task.state === 'needs-reconciliation' ? 'TASK_OUTCOME_UNKNOWN' : `TASK_${String(task.state).toUpperCase()}`
235
+ fail(code, `持久任务未成功完成:${task.state}`, { taskId, attemptId: task.attemptId, operationId: task.operationId, state: task.state })
236
+ }
237
+ return { taskId, attemptId: task.attemptId, operationId: task.operationId, result: task.result ?? null }
238
+ }
239
+
240
+ const observeTool = (botId, entry, args, context) => {
241
+ if (entry.machineTool === 'operation_status' || entry.machineTool === 'operation_cancel') {
242
+ const operationId = args.operationId
243
+ if (!ref(operationId)) fail('INVALID_TOOL_ARGS', 'operationId 缺失或格式不正确。')
244
+ // Only an operation this Host issued for this Bot may be observed or stopped.
245
+ if (!known(botId, operationId)) fail('OPERATION_NOT_ISSUED', '该 operationId 不是宿主发给本 Bot 的。')
246
+ }
247
+ if (entry.machineTool === 'artifact_stat' || entry.machineTool === 'artifact_read') {
248
+ if (!/^[a-f0-9]{64}$/.test(String(args.artifactId ?? ''))) fail('INVALID_TOOL_ARGS', 'artifactId 缺失或格式不正确。')
249
+ }
250
+ const result = runMachineTool(botId, entry, args, context)
251
+ if (typeof result?.operationId === 'string') remember(botId, result.operationId)
252
+ return result
253
+ }
254
+
255
+ const readTask = async (botId, args, context) => {
256
+ const service = tasks()
257
+ const taskId = args.taskId
258
+ if (!key(taskId)) fail('INVALID_TOOL_ARGS', 'taskId 缺失或格式不正确。')
259
+ if (!known(botId, taskId)) fail('TASK_NOT_ISSUED', '该 taskId 不是宿主发给本 Bot 的。')
260
+ context.signal?.throwIfAborted()
261
+ return service.get(directory.forBot(botId), { taskId })
262
+ }
263
+
264
+ const cancelTask = async (botId, args, context) => {
265
+ const service = tasks()
266
+ const taskId = args.taskId
267
+ if (!key(taskId)) fail('INVALID_TOOL_ARGS', 'taskId 缺失或格式不正确。')
268
+ if (!known(botId, taskId)) fail('TASK_NOT_ISSUED', '该 taskId 不是宿主发给本 Bot 的。')
269
+ const outcome = service.cancel(directory.forBot(botId), { taskId })
270
+ // A cancel is a request, not a confirmation: the attempt keeps its own identity and the Host
271
+ // reconciles it. The loop reports the request, never a stop proof it does not have.
272
+ return { taskId, cancelRequested: true, state: outcome?.state ?? null }
273
+ }
274
+
275
+ return Object.freeze({
276
+ /**
277
+ * One Bot's live tool surface.
278
+ *
279
+ * The returned object is cheap and stateless: every method re-reads committed grants, so a
280
+ * cached loop can never freeze an older grant into a turn.
281
+ */
282
+ forBot(botId, { nativeRoute = null } = {}) {
283
+ if (typeof botId !== 'string' || !botId) throw new TypeError('INDEPENDENT_TOOL_HOST_REQUIRES_BOT')
284
+ const supported = () => supportsToolsFor(botId, nativeRoute) === true
285
+ const view = Object.freeze({
286
+ botId,
287
+ /** The schemas this Bot may see right now. */
288
+ schemas() {
289
+ if(!supported())return []
290
+ return catalogFor({ scopes: scopesFor(botId), backends: backendIds(), granted: grantedFor(botId) }).tools
291
+ },
292
+ /** What this Host cannot run for this Bot, and why. */
293
+ unavailable() {
294
+ const catalog=catalogFor({ scopes: scopesFor(botId), backends: backendIds(), granted: grantedFor(botId) })
295
+ return supported()?catalog.unavailable:[...catalog.unavailable,...catalog.tools.map(tool=>({name:tool.name,code:'MODEL_TOOLS_UNSUPPORTED'}))]
296
+ },
297
+ /** Which machines this Bot's own live bindings cover. */
298
+ machines() {
299
+ return catalogFor({ scopes: scopesFor(botId), backends: backendIds(), granted: grantedFor(botId) }).machines
300
+ },
301
+ isReadonly(name) {
302
+ const entry = independentToolEntry(name)
303
+ return Boolean(entry) && entry.effect === 'read'
304
+ },
305
+ async call(name, args = {}, context = {}) {
306
+ alive()
307
+ if(!supported())fail('MODEL_TOOLS_UNSUPPORTED','当前对话引擎不支持工具调用;Bot 的工具权限仍保留。')
308
+ const entry = independentToolEntry(name)
309
+ // Offered-or-refused is decided here, not at schema time: a grant that changed between
310
+ // the request and the call stops the call.
311
+ if (!entry) fail('TOOL_NOT_GRANTED', `未获授的工具:${String(name)}`)
312
+ if (!plain(args)) fail('INVALID_TOOL_ARGS', '工具参数必须是一个普通对象。')
313
+ const granted = view.schemas().some(tool => tool.name === name)
314
+ if (!granted) fail('TOOL_NOT_GRANTED', `该 Bot 当前未获授 ${name}。`)
315
+ const turnId = context.turnId ?? randomUUID()
316
+ const callId = context.callId ?? randomUUID()
317
+ const scoped = { ...context, turnId, callId }
318
+ // Reading or stopping one of this Bot's own tasks is not a machine effect at all.
319
+ if (entry.backend === 'task-service') return entry.taskAction === 'cancel' ? cancelTask(botId, args, scoped) : readTask(botId, args, scoped)
320
+ if (entry.backend) return runTaskTool(botId, entry, args, scoped)
321
+ if (entry.machineTool === 'operation_status' || entry.machineTool === 'operation_cancel'
322
+ || entry.machineTool === 'artifact_stat' || entry.machineTool === 'artifact_read') return observeTool(botId, entry, args, scoped)
323
+ return runMachineTool(botId, entry, args, scoped)
324
+ },
325
+ })
326
+ return view
327
+ },
328
+
329
+ /**
330
+ * Dispose this service: end its own waits, drop its registries and refuse further calls.
331
+ *
332
+ * This is not a stop of anything remote. A task still in flight keeps its identity and its
333
+ * evidence in the journal, and the Host reconciles it; nothing here claims it stopped, and
334
+ * nothing here submits a replacement.
335
+ */
336
+ close() {
337
+ if (closed) return
338
+ closed = true
339
+ for (const stop of [...waits]) stop()
340
+ waits.clear()
341
+ issued.clear()
342
+ },
343
+ })
344
+ }
@@ -0,0 +1,15 @@
1
+ /** Main's full-control role comes from committed Host state. Worker checkbox
2
+ * grants do not narrow it. Services still own actual availability and accounting.
3
+ */
4
+ export function compileMainBotGrant({ base, catalog, routes, toolNames = [], teamToolNames = [] }) {
5
+ return {
6
+ tools: [...new Set([
7
+ ...(base.tools ?? []), 'model/infer', 'task/execute', 'task/submit', 'task/read', 'task/cancel', 'task/retry', 'task/reconcile',
8
+ ...(catalog.capabilities ?? []).flatMap(item => item.tools ?? []),
9
+ ...(catalog.tools ?? []).flatMap(item => item.grantTools ?? []),
10
+ ...toolNames.map(name => `tool/${name}`), ...teamToolNames.map(name => `tool/${name}`),
11
+ 'tool/bot_dispatch', 'tool/bot_handoff_task',
12
+ ])].sort(),
13
+ modelRoutes: routes.map(route => ({ provider: route.provider, model: route.model })),
14
+ }
15
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Main Bot per-exact-route execution/funding profiles.
3
+ *
4
+ * A pure, I/O-free helper that lives beside the closed `bot_settings` schema.
5
+ * It stores an optional `settings.mainRoutes` collection (schema version 1) in
6
+ * the same `bot_settings_records` revision, coexisting with the existing
7
+ * default-model binding. It never builds a second funding, price or receipt
8
+ * ledger, never opens SQLite and never invents a catalog: every amount, token
9
+ * bound, mode and cross-channel rule is delegated to the Host's own legacy
10
+ * funding normalizer, which the parent injects so the old rules are not copied
11
+ * into a second place.
12
+ *
13
+ * Roles, accounts, credentials, endpoints and paths are never read here. The
14
+ * Host commits the Main role, the exact model existence, the funding scope and
15
+ * the price facts; this helper only normalizes the declared shape, assembles a
16
+ * complete legacy funding record from a profile plus the unified global limits,
17
+ * and refuses anything it cannot prove.
18
+ */
19
+
20
+ const MAIN_ROUTES_SCHEMA = 1
21
+ const MAX_ROUTES = 64
22
+ const MAX_BYTES = 64 * 1024
23
+
24
+ // Legacy shapes, mirrored so this module stays free of a bot-settings import
25
+ // (and therefore free of the circular dependency the closed schema would add).
26
+ const plain = value => value && typeof value === 'object' && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value))
27
+ const ref = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value)
28
+ const token = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/@+\-]{0,255}$/.test(value)
29
+ const clone = value => structuredClone(value)
30
+ const fail = (code, message, details = {}) => { throw Object.assign(new Error(message), { code, details }) }
31
+ const keys = (value, allowed, path) => {
32
+ if (!plain(value) || Object.keys(value).some(key => !allowed.includes(key))) fail('BOT_SETTINGS_INVALID', `${path} 含有未知字段或格式无效。`, { field: path })
33
+ }
34
+ const list = (value, limit, path) => {
35
+ if (!Array.isArray(value) || value.length > limit) fail('BOT_SETTINGS_INVALID', `${path} 列表格式无效或超过 ${limit} 项。`, { field: path })
36
+ return value
37
+ }
38
+ // A binary tuple key, never a joined string: a model may itself contain "/", so
39
+ // `provider/model` would let `a/b`+`c` collide with `a`+`b/c`.
40
+ const routeKey = (provider, model) => `${provider}\u0000${model}`
41
+
42
+ const requireNormalizer = options => {
43
+ const normalizeFunding = options?.normalizeFunding
44
+ if (typeof normalizeFunding !== 'function') throw new TypeError('normalizeMainModelRoutes/materializeMainRouteDefault require an injected legacy funding normalizer { normalizeFunding }')
45
+ return normalizeFunding
46
+ }
47
+
48
+ const normalizeApiLimit = (value, normalizeFunding) => {
49
+ if (value === null || value === undefined) return null
50
+ keys(value, ['dailyBudgetUsd', 'monthlyBudgetUsd'], 'mainRoutes.limits.api')
51
+ // Reuse the legacy amount bound and field checks instead of restating them.
52
+ const funding = normalizeFunding({ mode: 'api', scopeId: null, pricing: null, request: null, dailyBudgetUsd: value.dailyBudgetUsd, monthlyBudgetUsd: value.monthlyBudgetUsd, subscription: null })
53
+ // The group as a whole is either configured (both numbers, 0 included) or left
54
+ // null for "not configured"; a null inside a present group is never stored.
55
+ if (funding.dailyBudgetUsd === null || funding.monthlyBudgetUsd === null) fail('BOT_SETTINGS_INVALID', 'API 每日与每月预算必须同时填写;整组留空表示未配置,0 表示禁止付费。', { field: 'mainRoutes.limits.api' })
56
+ return { dailyBudgetUsd: funding.dailyBudgetUsd, monthlyBudgetUsd: funding.monthlyBudgetUsd }
57
+ }
58
+
59
+ const normalizeSubscriptionLimit = (value, normalizeFunding) => {
60
+ if (value === null || value === undefined) return null
61
+ keys(value, ['dailyCalls', 'monthlyCalls'], 'mainRoutes.limits.subscription')
62
+ // Reuse the legacy integer-call bound and field checks.
63
+ const funding = normalizeFunding({ mode: 'subscription', scopeId: null, pricing: null, request: null, dailyBudgetUsd: null, monthlyBudgetUsd: null, subscription: { dailyCalls: value.dailyCalls, monthlyCalls: value.monthlyCalls } })
64
+ return { dailyCalls: funding.subscription.dailyCalls, monthlyCalls: funding.subscription.monthlyCalls }
65
+ }
66
+
67
+ const normalizeRouteFunding = (value, normalizeFunding) => {
68
+ keys(value, ['mode', 'scopeId', 'pricing', 'request'], 'mainRoutes.routes.funding')
69
+ const mode = value.mode
70
+ if (mode !== 'api' && mode !== 'subscription') fail('BOT_SETTINGS_INVALID', '路线资金渠道必须是 api 或 subscription。', { field: 'mainRoutes.routes.funding.mode' })
71
+ // A subscription profile never carries API price or request bounds, and an API
72
+ // profile never carries a subscription group (an extra key is already refused
73
+ // by the closed field list). These are refused, not silently dropped.
74
+ if (mode === 'subscription' && ((value.pricing ?? null) !== null || (value.request ?? null) !== null)) fail('BOT_SETTINGS_INVALID', '订阅路线不能夹带 API 价格或请求上界;不会隐式切换渠道。', { field: 'mainRoutes.routes.funding' })
75
+ const funding = normalizeFunding(mode === 'api'
76
+ ? { mode, scopeId: value.scopeId ?? null, pricing: value.pricing ?? null, request: value.request ?? null, dailyBudgetUsd: null, monthlyBudgetUsd: null, subscription: null }
77
+ : { mode, scopeId: value.scopeId ?? null, pricing: null, request: null, dailyBudgetUsd: null, monthlyBudgetUsd: null, subscription: null })
78
+ return { mode: funding.mode, scopeId: funding.scopeId, pricing: funding.pricing, request: funding.request }
79
+ }
80
+
81
+ const normalizeRoute = (value, normalizeFunding) => {
82
+ keys(value, ['provider', 'model', 'enabled', 'funding'], 'mainRoutes.routes')
83
+ if (!token(value.provider) || !token(value.model)) fail('BOT_SETTINGS_INVALID', '路线需要准确的 provider/model。', { field: 'mainRoutes.routes' })
84
+ if (typeof value.enabled !== 'boolean') fail('BOT_SETTINGS_INVALID', 'enabled 必须是布尔值。', { field: 'mainRoutes.routes.enabled' })
85
+ return { provider: value.provider, model: value.model, enabled: value.enabled, funding: normalizeRouteFunding(value.funding, normalizeFunding) }
86
+ }
87
+
88
+ /**
89
+ * Normalize an optional `settings.mainRoutes` value. Returns `null` for the
90
+ * absent/null case (old records keep their shape). Otherwise returns the closed
91
+ * `{ schemaVersion, limits, routes }` collection: at most 64 routes, stably
92
+ * sorted by exact provider/model, no duplicate tuple, total JSON <= 64 KiB.
93
+ * Every amount, token bound, mode and cross-channel rule is checked by the
94
+ * injected legacy normalizer.
95
+ */
96
+ export function normalizeMainModelRoutes(value, options = {}) {
97
+ const normalizeFunding = requireNormalizer(options)
98
+ if (value === null || value === undefined) return null
99
+ keys(value, ['schemaVersion', 'limits', 'routes'], 'mainRoutes')
100
+ if (value.schemaVersion !== MAIN_ROUTES_SCHEMA) fail('BOT_SETTINGS_VERSION_UNSUPPORTED', '多路线配置版本不受支持,请升级宿主。', { field: 'mainRoutes.schemaVersion' })
101
+ keys(value.limits, ['api', 'subscription'], 'mainRoutes.limits')
102
+ const limits = {
103
+ api: normalizeApiLimit(value.limits.api, normalizeFunding),
104
+ subscription: normalizeSubscriptionLimit(value.limits.subscription, normalizeFunding),
105
+ }
106
+ const routes = list(value.routes, MAX_ROUTES, 'mainRoutes.routes').map(route => normalizeRoute(route, normalizeFunding))
107
+ const seen = new Set()
108
+ for (const route of routes) {
109
+ const key = routeKey(route.provider, route.model)
110
+ if (seen.has(key)) fail('BOT_SETTINGS_DUPLICATE', `路线 ${route.provider}/${route.model} 重复。`, { field: 'mainRoutes.routes' })
111
+ seen.add(key)
112
+ }
113
+ routes.sort((a, b) => a.provider.localeCompare(b.provider) || a.model.localeCompare(b.model))
114
+ const collection = { schemaVersion: MAIN_ROUTES_SCHEMA, limits, routes }
115
+ if (Buffer.byteLength(JSON.stringify(collection)) > MAX_BYTES) fail('BOT_SETTINGS_LIMIT', '多路线配置不能超过 64 KiB。', { field: 'mainRoutes' })
116
+ return collection
117
+ }
118
+
119
+ /**
120
+ * The complete legacy funding record for one exact, enabled route, or `null`.
121
+ *
122
+ * `null` means the route is absent, disabled, or not ready: the required global
123
+ * limit group, the API price/request bounds, or the funding scope is missing.
124
+ * The result is a deep copy, so a caller cannot mutate the normalized profile.
125
+ *
126
+ * Every API route draws on the same global `limits.api` budget and every
127
+ * subscription route on the same global `limits.subscription` quota; no per-route
128
+ * counter is created, so the Host's aggregate ceilings keep applying across all
129
+ * routes. This helper does not run the ledger, so it cannot claim the aggregate
130
+ * was actually enforced.
131
+ */
132
+ export function fundingForMainRoute(collection, target) {
133
+ if (!plain(collection) || !Array.isArray(collection.routes)) return null
134
+ const provider = target?.provider, model = target?.model
135
+ if (typeof provider !== 'string' || typeof model !== 'string') return null
136
+ const route = collection.routes.find(item => item.provider === provider && item.model === model)
137
+ if (!route || route.enabled !== true || !plain(route.funding)) return null
138
+ const scopeId = route.funding.scopeId
139
+ if (scopeId === null) return null
140
+ if (route.funding.mode === 'api') {
141
+ if (collection.limits.api === null || route.funding.pricing === null || route.funding.request === null) return null
142
+ return clone({ mode: 'api', scopeId, pricing: route.funding.pricing, request: route.funding.request,
143
+ dailyBudgetUsd: collection.limits.api.dailyBudgetUsd, monthlyBudgetUsd: collection.limits.api.monthlyBudgetUsd, subscription: null })
144
+ }
145
+ if (route.funding.mode !== 'subscription' || collection.limits.subscription === null) return null
146
+ return clone({ mode: 'subscription', scopeId, pricing: null, request: null, dailyBudgetUsd: null, monthlyBudgetUsd: null,
147
+ subscription: { dailyCalls: collection.limits.subscription.dailyCalls, monthlyCalls: collection.limits.subscription.monthlyCalls } })
148
+ }
149
+
150
+ /**
151
+ * Derive `settings.funding` from the default exact model's route plus the
152
+ * unified global limits, returning a clone.
153
+ *
154
+ * The default is the committed `settings.model`; a route is never silently
155
+ * chosen as "the first one". When `mainRoutes` is absent/null the clone is
156
+ * value-identical to the input (the legacy single-route case is untouched).
157
+ * When `mainRoutes` is present and the default route is ready, `settings.funding`
158
+ * is generated from that route plus the unified limits whether or not the Bot is
159
+ * enabled. When the default route is not ready the call is refused only if the
160
+ * settings are enabled; a disabled settings keeps its unfinished draft and never
161
+ * receives a fabricated available funding.
162
+ */
163
+ export function materializeMainRouteDefault(settings, options = {}) {
164
+ if (!plain(settings)) throw new TypeError('materializeMainRouteDefault requires a plain settings object')
165
+ const next = clone(settings)
166
+ if (next.mainRoutes === null || next.mainRoutes === undefined) return next
167
+ const collection = normalizeMainModelRoutes(next.mainRoutes, options)
168
+ const model = plain(next.model) ? next.model : null
169
+ const funding = model ? fundingForMainRoute(collection, { provider: model.provider, model: model.model }) : null
170
+ if (funding !== null) next.funding = funding
171
+ else if (next.enabled === true) fail('MAIN_ROUTE_DEFAULT_UNAVAILABLE', '启用多路线前,默认模型必须对应一条已启用且已就绪的路线;不会自动选择第一条路线。', { field: 'mainRoutes' })
172
+ return next
173
+ }
174
+
175
+ const routeState = settings => {
176
+ if (!plain(settings) || !Object.hasOwn(settings, 'mainRoutes') || settings.mainRoutes === undefined) return 'absent'
177
+ return settings.mainRoutes === null ? 'exit' : 'present'
178
+ }
179
+ // Key-order-independent deep serialization, so a re-serialized legacy funding
180
+ // still compares equal.
181
+ const canonical = value => {
182
+ if (value === null || typeof value !== 'object') return JSON.stringify(value === undefined ? null : value) ?? 'null'
183
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
184
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`
185
+ }
186
+ const identityUnchanged = (next, previous) => canonical(next.model) === canonical(previous.model) && canonical(next.funding) === canonical(previous.funding)
187
+
188
+ /**
189
+ * Protect a stored multi-route collection from an older client that omits the
190
+ * field it does not know about.
191
+ *
192
+ * An explicit `mainRoutes` value (object or null) on `next` is always honored:
193
+ * `null` exits multi-route, an object is the new declaration. Only a missing
194
+ * key triggers protection: if `previous` carried a collection and neither the
195
+ * default model nor the funding changed, the stored collection is re-attached;
196
+ * if either changed the client must re-read, so the call is refused. A previous
197
+ * that had no collection leaves `next` untouched.
198
+ */
199
+ export function preserveMainModelRoutes(next, previous) {
200
+ if (!plain(next)) throw new TypeError('preserveMainModelRoutes requires a plain next settings object')
201
+ const previousSafe = plain(previous) ? previous : {}
202
+ if (routeState(next) !== 'absent') return clone(next)
203
+ if (routeState(previousSafe) === 'present') {
204
+ if (identityUnchanged(next, previousSafe)) return { ...clone(next), mainRoutes: clone(previousSafe.mainRoutes) }
205
+ fail('MAIN_ROUTES_RELOAD_REQUIRED', '默认模型或资金已更改,请重新读取多路线配置后再保存。', { field: 'mainRoutes' })
206
+ }
207
+ return clone(next)
208
+ }