@ljwei-stak/dsh-model-route-bot 0.66.4 → 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 (271) 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 +18 -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/package.json +1 -1
  178. package/packages/orchestration/src/binding-product.mjs +19 -0
  179. package/packages/orchestration/src/bot-data-actions.mjs +45 -0
  180. package/packages/orchestration/src/bot-data-chart.mjs +109 -0
  181. package/packages/orchestration/src/bot-data-context.mjs +44 -0
  182. package/packages/orchestration/src/bot-data-engine.mjs +809 -0
  183. package/packages/orchestration/src/bot-data-io.mjs +186 -0
  184. package/packages/orchestration/src/bot-data-sql.mjs +195 -0
  185. package/packages/orchestration/src/bot-data-surface.mjs +144 -0
  186. package/packages/orchestration/src/bot-data-tools.mjs +68 -0
  187. package/packages/orchestration/src/bot-data-worker.mjs +228 -0
  188. package/packages/orchestration/src/bot-independent-execution.mjs +195 -0
  189. package/packages/orchestration/src/bot-independent-stream.mjs +74 -0
  190. package/packages/orchestration/src/bot-loop.mjs +152 -19
  191. package/packages/orchestration/src/bot-notifications.mjs +159 -0
  192. package/packages/orchestration/src/bot-policy.mjs +43 -4
  193. package/packages/orchestration/src/bot-profile.mjs +171 -0
  194. package/packages/orchestration/src/bot-routine.mjs +4 -4
  195. package/packages/orchestration/src/bot-runtime-assembly.mjs +255 -8
  196. package/packages/orchestration/src/bot-runtime-switch.mjs +13 -2
  197. package/packages/orchestration/src/bot-serving.mjs +164 -0
  198. package/packages/orchestration/src/bot-session-store.mjs +105 -9
  199. package/packages/orchestration/src/bot-settings.mjs +50 -11
  200. package/packages/orchestration/src/bot-thread-admission.mjs +84 -0
  201. package/packages/orchestration/src/bot-thread-host.mjs +187 -0
  202. package/packages/orchestration/src/bot-threads.mjs +790 -0
  203. package/packages/orchestration/src/data-product-access.mjs +37 -0
  204. package/packages/orchestration/src/data-tool-contract.mjs +291 -0
  205. package/packages/orchestration/src/desktop-http-carrier.mjs +120 -0
  206. package/packages/orchestration/src/desktop-proxy.mjs +311 -0
  207. package/packages/orchestration/src/desktop-sessions.mjs +43 -9
  208. package/packages/orchestration/src/ds-lifecycle.mjs +31 -4
  209. package/packages/orchestration/src/grant-refresh-queue.mjs +17 -0
  210. package/packages/orchestration/src/independent-chat-page.mjs +39 -0
  211. package/packages/orchestration/src/independent-chat-service.mjs +308 -0
  212. package/packages/orchestration/src/independent-context-state.mjs +44 -0
  213. package/packages/orchestration/src/independent-message-wire.mjs +84 -0
  214. package/packages/orchestration/src/independent-tool-catalog.mjs +175 -0
  215. package/packages/orchestration/src/independent-tool-families.mjs +73 -0
  216. package/packages/orchestration/src/independent-tool-host.mjs +344 -0
  217. package/packages/orchestration/src/main-bot-grant.mjs +15 -0
  218. package/packages/orchestration/src/main-model-routes.mjs +208 -0
  219. package/packages/orchestration/src/model-accounting.mjs +99 -35
  220. package/packages/orchestration/src/model-auxiliary-dispatch.mjs +187 -0
  221. package/packages/orchestration/src/model-loop-frame.mjs +179 -0
  222. package/packages/orchestration/src/model-prepared-generation.mjs +175 -0
  223. package/packages/orchestration/src/model-request-bounds.mjs +111 -0
  224. package/packages/orchestration/src/native-activity-contract.mjs +491 -0
  225. package/packages/orchestration/src/native-activity-service.mjs +102 -0
  226. package/packages/orchestration/src/native-activity-store.mjs +249 -0
  227. package/packages/orchestration/src/native-conversation-tools.mjs +78 -0
  228. package/packages/orchestration/src/native-data-tools.mjs +100 -0
  229. package/packages/orchestration/src/native-remote-actions.mjs +742 -0
  230. package/packages/orchestration/src/native-team-tools.mjs +73 -0
  231. package/packages/orchestration/src/pairing-invite.mjs +37 -0
  232. package/packages/orchestration/src/product-composition.mjs +1559 -1001
  233. package/packages/orchestration/src/remote-machines.mjs +82 -14
  234. package/packages/orchestration/src/routine-execution.mjs +5 -3
  235. package/packages/orchestration/src/routine-session.mjs +19 -13
  236. package/packages/orchestration/src/secure-pairing-host.mjs +112 -0
  237. package/packages/orchestration/src/task-journal.mjs +29 -0
  238. package/packages/orchestration/src/task-product.mjs +17 -0
  239. package/packages/orchestration/src/team-assignment-store.mjs +120 -0
  240. package/packages/orchestration/src/team-bridge.mjs +282 -0
  241. package/packages/orchestration/src/team-scheduler.mjs +83 -0
  242. package/packages/orchestration/src/team-sdk-tools.mjs +84 -0
  243. package/packages/orchestration/src/team-tool-contract.mjs +284 -0
  244. package/packages/orchestration/src/ui-host-namespace.mjs +23 -0
  245. package/packages/orchestration/src/web-panel-actions.mjs +103 -0
  246. package/packages/orchestration/src/web-panel-framing.mjs +227 -0
  247. package/packages/orchestration/src/web-panel-store.mjs +206 -0
  248. package/remote-computer-agent/README.md +39 -29
  249. package/remote-computer-agent/build.mjs +3 -0
  250. package/remote-computer-agent/dist/OPENMAUSBOT_LICENSE +202 -0
  251. package/remote-computer-agent/dist/OPENMAUSBOT_NOTICE +10 -0
  252. package/remote-computer-agent/dist/OPENMAUSBOT_SOURCE.json +68 -0
  253. package/remote-computer-agent/dist/cli-stdio-bridge.mjs +215 -25
  254. package/remote-computer-agent/dist/cli-stdio-bridge.mjs.map +4 -4
  255. package/remote-computer-agent/dist/cli.mjs +1688 -168
  256. package/remote-computer-agent/dist/cli.mjs.map +4 -4
  257. package/remote-computer-agent/dist/index.mjs +1579 -122
  258. package/remote-computer-agent/dist/index.mjs.map +4 -4
  259. package/remote-computer-agent/dist/native-peer.ps1 +51 -3
  260. package/remote-computer-agent/package-lock.json +308 -2
  261. package/remote-computer-agent/package.json +2 -1
  262. package/remote-computer-agent/pair-device.ps1 +20 -0
  263. package/remote-computer-agent/setup.ps1 +38 -16
  264. package/remote-computer-agent/src/cli.ts +24 -3
  265. package/remote-computer-agent/src/desktop-session.ts +11 -3
  266. package/remote-computer-agent/src/e2e-server.ts +81 -0
  267. package/remote-computer-agent/src/pairing-page.ts +35 -0
  268. package/remote-computer-agent/src/protocol.ts +25 -25
  269. package/remote-computer-agent/src/secure-pairing.ts +105 -0
  270. package/remote-computer-agent/src/store.ts +20 -5
  271. package/remote-computer-agent/src/transport.ts +39 -15
@@ -13,8 +13,10 @@
13
13
  * - Versioned: a store written by a newer schema is refused instead of guessed at.
14
14
  */
15
15
 
16
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
16
+ import { mkdirSync, readFileSync, renameSync, writeFileSync, openSync, closeSync, fsyncSync, unlinkSync } from 'node:fs'
17
17
  import { dirname, join } from 'node:path'
18
+ import { createHash, randomUUID } from 'node:crypto'
19
+ import { LEGACY_CONTEXT, MAX_CONTEXTS, contextIdOk, contextRequestOk, contextFields, contextView } from './independent-context-state.mjs'
18
20
 
19
21
  export const BOT_SESSION_SCHEMA = 1
20
22
  export const MAX_MESSAGES = 2_000
@@ -28,17 +30,33 @@ const text = value => (typeof value === 'string' ? value : '')
28
30
  const idOk = value => typeof value === 'string' && /^[A-Za-z0-9_.-]{1,128}$/.test(value)
29
31
 
30
32
  function emptySession(botId, now) {
31
- return { schemaVersion: BOT_SESSION_SCHEMA, botId, createdAt: now, updatedAt: now, turns: 0, messages: [], truncated: false }
33
+ return { schemaVersion: BOT_SESSION_SCHEMA, botId, createdAt: now, updatedAt: now, turns: 0, messages: [], truncated: false,
34
+ ...contextFields({ createdAt: 0 }) }
32
35
  }
33
36
 
34
37
  function bounded(message) {
35
38
  const content = text(message.content)
39
+ const model = message.modelSource
40
+ if (model !== undefined && (!model || model.kind !== 'model' ||
41
+ ![model.provider, model.model].every(value => typeof value === 'string' && value.length > 0 && value.length <= 256 && !/[\x00-\x1f]/.test(value)))) fail('INVALID_BOT_MESSAGE_SOURCE')
42
+ if (message.toolCallId !== undefined && (typeof message.toolCallId !== 'string' || !message.toolCallId || message.toolCallId.length > 256)) fail('INVALID_BOT_TOOL_CALL_ID')
43
+ const calls = message.toolCalls
44
+ if (calls !== undefined && (!Array.isArray(calls) || calls.length > 8 || calls.some(call =>
45
+ !call || typeof call.id !== 'string' || !call.id || call.id.length > 256 || typeof call.name !== 'string' || !call.name || call.name.length > 128 ||
46
+ !call.args || typeof call.args !== 'object' || Array.isArray(call.args) || JSON.stringify(call.args).length > MAX_MESSAGE_CHARS) ||
47
+ new Set(calls.map(call => call.id)).size !== calls.length)) fail('INVALID_BOT_TOOL_CALLS')
36
48
  return {
49
+ contextId: message.contextId ?? LEGACY_CONTEXT,
37
50
  role: ROLES.includes(message.role) ? message.role : 'user',
38
51
  content: content.length > MAX_MESSAGE_CHARS ? `${content.slice(0, MAX_MESSAGE_CHARS)}\n[truncated]` : content,
39
52
  at: Number.isSafeInteger(message.at) ? message.at : 0,
40
53
  ...(typeof message.name === 'string' && message.name ? { name: message.name.slice(0, 128) } : {}),
41
54
  ...(message.toolCall ? { toolCall: message.toolCall } : {}),
55
+ ...(typeof message.messageId === 'string' && message.messageId ? { messageId: message.messageId.slice(0, 256) } : {}),
56
+ ...(model ? { modelSource: { kind: 'model', provider: model.provider, model: model.model } } : {}),
57
+ ...(calls ? { toolCalls: structuredClone(calls) } : {}),
58
+ ...(message.toolCallId ? { toolCallId: message.toolCallId } : {}),
59
+ ...(message.isError === true ? { isError: true } : {}),
42
60
  }
43
61
  }
44
62
 
@@ -48,6 +66,7 @@ export function parseSession(value, botId) {
48
66
  if (value.schemaVersion !== BOT_SESSION_SCHEMA) fail(value.schemaVersion > BOT_SESSION_SCHEMA ? 'BOT_SESSION_SCHEMA_NEWER' : 'INVALID_BOT_SESSION')
49
67
  if (value.botId !== botId) fail('BOT_SESSION_MISMATCH')
50
68
  if (!Array.isArray(value.messages)) fail('INVALID_BOT_SESSION')
69
+ if (value.messages.some(message => message.contextId !== undefined && !contextIdOk(message.contextId))) fail('INVALID_BOT_CONTEXT')
51
70
  return {
52
71
  schemaVersion: BOT_SESSION_SCHEMA,
53
72
  botId,
@@ -55,7 +74,9 @@ export function parseSession(value, botId) {
55
74
  updatedAt: Number.isSafeInteger(value.updatedAt) ? value.updatedAt : 0,
56
75
  turns: Number.isSafeInteger(value.turns) && value.turns >= 0 ? value.turns : 0,
57
76
  truncated: value.truncated === true,
58
- messages: value.messages.map(bounded),
77
+ ...contextFields(value),
78
+ messages: value.messages.map((message, index) => bounded({ ...message,
79
+ messageId: message.messageId ?? `legacy-${createHash('sha256').update(JSON.stringify([botId, index, message])).digest('hex')}` })),
59
80
  }
60
81
  }
61
82
 
@@ -79,10 +100,32 @@ export function openBotSessionStore({ dir, now = Date.now, maxMessages = MAX_MES
79
100
  const write = session => {
80
101
  mkdirSync(dirname(fileOf(session.botId)), { recursive: true })
81
102
  const target = fileOf(session.botId)
82
- const temp = `${target}.${process.pid}.tmp`
83
- writeFileSync(temp, JSON.stringify(session))
84
- renameSync(temp, target)
85
- return session
103
+ const temp = `${target}.${process.pid}.${randomUUID()}.tmp`
104
+ let descriptor, renamed = false
105
+ try {
106
+ descriptor = openSync(temp, 'wx', 0o600)
107
+ writeFileSync(descriptor, JSON.stringify(session)); fsyncSync(descriptor)
108
+ closeSync(descriptor); descriptor = undefined
109
+ renameSync(temp, target); renamed = true
110
+ const verified = read(session.botId)
111
+ if (JSON.stringify(verified) !== JSON.stringify(parseSession(session, session.botId))) fail('BOT_SESSION_WRITE_NOT_CONFIRMED')
112
+ return verified
113
+ } catch (error) {
114
+ // Do not overwrite a possibly committed file with an old snapshot.
115
+ error.outcome = renamed ? 'unknown' : 'refused'
116
+ throw error
117
+ } finally { if (descriptor !== undefined) closeSync(descriptor) }
118
+ }
119
+ // Exclusive filesystem admission covers the read-modify-rename in every process.
120
+ // A stale lock is refused; guessing that its owner died could overwrite a live writer.
121
+ const mutate = (botId, change) => {
122
+ const target = fileOf(botId)
123
+ mkdirSync(dirname(target), { recursive: true })
124
+ let lock
125
+ try { lock = openSync(`${target}.lock`, 'wx', 0o600) }
126
+ catch (error) { if (error.code === 'EEXIST') fail('BOT_SESSION_WRITE_BUSY'); throw error }
127
+ try { return change(read(botId)) }
128
+ finally { closeSync(lock); unlinkSync(`${target}.lock`) }
86
129
  }
87
130
 
88
131
  return Object.freeze({
@@ -93,8 +136,12 @@ export function openBotSessionStore({ dir, now = Date.now, maxMessages = MAX_MES
93
136
  /** Append messages and advance the turn counter atomically. */
94
137
  append(botId, messages, { turn = false } = {}) {
95
138
  if (!Array.isArray(messages) || messages.length === 0) fail('INVALID_BOT_SESSION_APPEND')
96
- const session = read(botId)
97
- for (const message of messages) session.messages.push(bounded({ ...message, at: Number.isSafeInteger(message.at) ? message.at : now() }))
139
+ return mutate(botId, session => {
140
+ if (session.contextReset) fail('INDEPENDENT_CONTEXT_PENDING_RECONCILIATION')
141
+ for (const message of messages) session.messages.push(bounded({ ...message,
142
+ contextId: session.contextId,
143
+ ...(!message.messageId ? { messageId: randomUUID() } : {}),
144
+ at: Number.isSafeInteger(message.at) ? message.at : now() }))
98
145
  if (session.messages.length > maxMessages) {
99
146
  session.messages.splice(0, session.messages.length - maxMessages)
100
147
  session.truncated = true
@@ -102,6 +149,55 @@ export function openBotSessionStore({ dir, now = Date.now, maxMessages = MAX_MES
102
149
  if (turn) session.turns += 1
103
150
  session.updatedAt = now()
104
151
  return write(session)
152
+ })
153
+ },
154
+ contextOf: botId => contextView(read(botId)),
155
+ beginContext(botId, { requestKey, contextId, expectedContextId } = {}) {
156
+ if (!contextRequestOk(requestKey) || !contextIdOk(contextId)) fail('INVALID_BOT_CONTEXT_RESET')
157
+ return mutate(botId, session => {
158
+ const existing = session.contexts.find(row => row.requestKey === requestKey)
159
+ if (existing) {
160
+ if (existing.contextId !== contextId) fail('INDEPENDENT_CONTEXT_REQUEST_CONFLICT')
161
+ return session
162
+ }
163
+ if (session.contextReset) fail('INDEPENDENT_CONTEXT_PENDING_RECONCILIATION')
164
+ if (contextId === session.contextId || session.messages.some(row => row.contextId === contextId)) fail('INDEPENDENT_CONTEXT_REQUEST_CONFLICT')
165
+ if (expectedContextId !== undefined && session.contextId !== expectedContextId) fail('INDEPENDENT_CONTEXT_STALE')
166
+ session.contextReset = { requestKey, contextId, previousContextId: session.contextId, createdAt: now() }
167
+ session.updatedAt = now()
168
+ return write(session)
169
+ })
170
+ },
171
+ commitContext(botId, { requestKey, contextId } = {}) {
172
+ return mutate(botId, session => {
173
+ const existing = session.contexts.find(row => row.requestKey === requestKey)
174
+ if (existing) {
175
+ if (existing.contextId !== contextId) fail('INDEPENDENT_CONTEXT_REQUEST_CONFLICT')
176
+ return session
177
+ }
178
+ const intent = session.contextReset
179
+ if (!intent || intent.requestKey !== requestKey || intent.contextId !== contextId) fail('INDEPENDENT_CONTEXT_REQUEST_CONFLICT')
180
+ const sequence = Math.max(...session.contexts.map(row => row.sequence)) + 1
181
+ if (!Number.isSafeInteger(sequence)) fail('INDEPENDENT_CONTEXT_HISTORY_LIMIT')
182
+ session.contexts.push({ ...intent, sequence, createdAt: now() })
183
+ if (session.contexts.length > MAX_CONTEXTS) { session.contexts = session.contexts.slice(-MAX_CONTEXTS); session.contextsTruncated = true }
184
+ session.contextId = contextId
185
+ delete session.contextReset
186
+ session.updatedAt = now()
187
+ return write(session)
188
+ })
189
+ },
190
+ abortContext(botId, { requestKey, contextId } = {}) {
191
+ return mutate(botId, session => {
192
+ if (!session.contextReset || session.contextReset.requestKey !== requestKey || session.contextReset.contextId !== contextId) fail('INDEPENDENT_CONTEXT_REQUEST_CONFLICT')
193
+ delete session.contextReset
194
+ session.updatedAt = now()
195
+ return write(session)
196
+ })
197
+ },
198
+ contextCommitted(botId, requestKey, contextId) {
199
+ const session = read(botId)
200
+ return session.contexts.some(row => row.requestKey === requestKey && row.contextId === contextId)
105
201
  },
106
202
  /** Forget one Bot's transcript (used when a Bot is deleted, never on a transient error). */
107
203
  remove(botId) {
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'
2
2
  import { mkdirSync } from 'node:fs'
3
3
  import { dirname, resolve } from 'node:path'
4
4
  import { OFFICIAL_TOOLS } from '../../../.dsh-plugin/shared/official-tool-registry.mjs'
5
+ import { fundingForMainRoute, materializeMainRouteDefault, normalizeMainModelRoutes, preserveMainModelRoutes } from './main-model-routes.mjs'
5
6
 
6
7
  export const BOT_SETTINGS_SCHEMA = 1
7
8
  export const BOT_EXECUTION_MODES = Object.freeze(['read-only', 'workspace-write', 'cli-native'])
@@ -36,7 +37,7 @@ export function defaultBotSettings() {
36
37
 
37
38
  /** Closed stored schema. No executable paths, installer URLs, credentials or arbitrary roots. */
38
39
  export function normalizeBotSettings(input) {
39
- keys(input, ['schemaVersion', 'enabled', 'model', 'officialTools', 'machineGrants', 'capabilities', 'workspaceRef', 'funding', 'maxConcurrentTasks', 'routing'], 'settings')
40
+ keys(input, ['schemaVersion', 'enabled', 'model', 'officialTools', 'machineGrants', 'capabilities', 'workspaceRef', 'funding', 'maxConcurrentTasks', 'routing', 'mainRoutes'], 'settings')
40
41
  const value = { ...defaultBotSettings(), ...input }
41
42
  if (value.schemaVersion !== 1) fail('BOT_SETTINGS_VERSION_UNSUPPORTED', 'Bot 绑定版本不受支持,请升级宿主。')
42
43
  if (typeof value.enabled !== 'boolean') fail('BOT_SETTINGS_INVALID', 'enabled 必须是布尔值。', { field: 'enabled' })
@@ -82,8 +83,16 @@ export function normalizeBotSettings(input) {
82
83
  if (!['local', 'remote'].includes(value.workspaceRef.kind) || !ref(value.workspaceRef.machineId) || !ref(value.workspaceRef.rootId)) fail('BOT_SETTINGS_INVALID', '工作区必须引用已登记机器和根目录。', { field: 'workspaceRef' })
83
84
  value.workspaceRef = { kind: value.workspaceRef.kind, machineId: value.workspaceRef.machineId, rootId: value.workspaceRef.rootId }
84
85
  }
85
- keys(value.funding, ['mode', 'scopeId', 'pricing', 'request', 'dailyBudgetUsd', 'monthlyBudgetUsd', 'subscription'], 'funding')
86
- const funding = { ...defaultBotSettings().funding, ...value.funding }
86
+ value.funding = normalizeFunding(value.funding)
87
+ if (Object.hasOwn(input, 'mainRoutes')) value.mainRoutes = normalizeMainModelRoutes(input.mainRoutes, { normalizeFunding })
88
+ if (!Number.isSafeInteger(value.maxConcurrentTasks) || value.maxConcurrentTasks < 1 || value.maxConcurrentTasks > 32) fail('BOT_SETTINGS_INVALID', '最大并发任务数必须为 1 至 32。', { field: 'maxConcurrentTasks' })
89
+ json(value)
90
+ return clone(value)
91
+ }
92
+
93
+ function normalizeFunding(input) {
94
+ keys(input, ['mode', 'scopeId', 'pricing', 'request', 'dailyBudgetUsd', 'monthlyBudgetUsd', 'subscription'], 'funding')
95
+ const funding = { ...defaultBotSettings().funding, ...input }
87
96
  if (!['api', 'subscription'].includes(funding.mode) || (funding.scopeId !== null && !ref(funding.scopeId))) fail('BOT_SETTINGS_INVALID', '请选择目录中的 API 或订阅资金范围。', { field: 'funding' })
88
97
  if (funding.pricing !== null) {
89
98
  keys(funding.pricing, ['input', 'output', 'cacheRead', 'cacheWrite'], 'funding.pricing')
@@ -101,17 +110,18 @@ export function normalizeBotSettings(input) {
101
110
  funding.subscription = { dailyCalls: count(funding.subscription.dailyCalls, 'funding.subscription.dailyCalls'), monthlyCalls: count(funding.subscription.monthlyCalls, 'funding.subscription.monthlyCalls') }
102
111
  }
103
112
  if (funding.mode === 'api' && funding.subscription !== null) fail('BOT_SETTINGS_INVALID', 'API 资金不能携带订阅次数;不会隐式切换执行渠道。', { field: 'funding.subscription' })
104
- value.funding = funding
105
- if (!Number.isSafeInteger(value.maxConcurrentTasks) || value.maxConcurrentTasks < 1 || value.maxConcurrentTasks > 32) fail('BOT_SETTINGS_INVALID', '最大并发任务数必须为 1 至 32。', { field: 'maxConcurrentTasks' })
106
- json(value)
107
- return clone(value)
113
+ return funding
108
114
  }
109
115
 
110
116
  function catalogOf(input) {
111
117
  if (!plain(input)) fail('BOT_SETTINGS_CATALOG_UNAVAILABLE', '宿主尚未提供可信模型、机器和资金目录。')
112
118
  const models = list(input.models ?? [], 4096, 'catalog.models').map(item => {
113
119
  if (!token(item.provider) || !token(item.model)) fail('BOT_SETTINGS_CATALOG_INVALID', '宿主模型目录无效。')
120
+ if (item.fundingModes !== undefined && (!Array.isArray(item.fundingModes) || !item.fundingModes.length || item.fundingModes.length > 2
121
+ || item.fundingModes.some(mode => !['api', 'subscription'].includes(mode)) || new Set(item.fundingModes).size !== item.fundingModes.length)) fail('BOT_SETTINGS_CATALOG_INVALID', '宿主模型资金渠道目录无效。')
114
122
  return { provider: item.provider, model: item.model, label: String(item.label ?? item.model).slice(0, 256),
123
+ ...(item.fundingModes === undefined ? {} : { fundingModes: [...item.fundingModes] }),
124
+ ...(item.pricing === undefined ? {} : { pricing: normalizeFunding({ pricing: item.pricing }).pricing }),
115
125
  cliModels: (item.cliModels ?? []).filter(mapping => registry.has(mapping.toolId) && token(mapping.model)).map(mapping => ({ toolId: mapping.toolId, model: mapping.model })) }
116
126
  })
117
127
  unique(models.map(routeRef), 'catalog.models')
@@ -152,6 +162,7 @@ export function validateBotSettings(input, catalogInput) {
152
162
  const denied = (message, field) => fail('BOT_SETTINGS_SCOPE_DENIED', message, { field })
153
163
  const model = settings.model && catalog.models.find(item => routeRef(item) === routeRef(settings.model))
154
164
  if (settings.model && !model) denied('此准确模型已不在宿主目录,请刷新后重新选择。', 'model')
165
+ if (model?.fundingModes && !model.fundingModes.includes(settings.funding.mode)) denied('此模型不支持所选资金渠道,请选择宿主列出的渠道。', 'funding.mode')
155
166
  for (const tool of settings.officialTools) {
156
167
  const entry = catalog.tools.find(item => item.toolId === tool.toolId)
157
168
  if (!entry?.modes.includes(tool.mode)) denied(`${tool.toolId} 尚未获准使用该执行模式。`, 'officialTools')
@@ -187,10 +198,20 @@ function compiledGrant(settings, catalog) {
187
198
  return { tools: [...new Set(tools)].sort(), modelRoutes: [{ ...settings.model }] }
188
199
  }
189
200
 
201
+ export function checkMainRouteFunding(route, funding, catalog) {
202
+ const model = catalog.models.find(item => item.provider === route.provider && item.model === route.model)
203
+ const scope = catalog.fundingScopes.find(item => item.id === funding?.scopeId)
204
+ if (!funding || !model || (model.fundingModes && !model.fundingModes.includes(funding.mode))
205
+ || !scope?.modes.includes(funding.mode) || (scope.models && !scope.models.some(item => item.provider === route.provider && item.model === route.model))) {
206
+ fail('MAIN_ROUTE_FUNDING_UNAVAILABLE', '此路线缺少可用模型、资金范围、渠道或完整限额。', { field: 'mainRoutes', route: routeRef(route) })
207
+ }
208
+ return funding
209
+ }
210
+
190
211
  /** Isolated component tables, safe to colocate with other SQLite components. */
191
- export function openBotSettings({ Database, file, catalogFor = () => ({}), botExists = () => false, onCommitted = () => {}, onSuspended = () => {}, now = Date.now }) {
212
+ export function openBotSettings({ Database, file, catalogFor = () => ({}), botExists = () => false, isMainBot = () => false, onCommitted = () => {}, onSuspended = () => {}, now = Date.now }) {
192
213
  if (typeof Database !== 'function' || typeof file !== 'string' || !file) throw new TypeError('Database constructor and explicit file are required')
193
- for (const [name, callback] of Object.entries({ catalogFor, botExists, onCommitted, onSuspended })) if (typeof callback !== 'function') throw new TypeError(`${name} must be a function`)
214
+ for (const [name, callback] of Object.entries({ catalogFor, botExists, isMainBot, onCommitted, onSuspended })) if (typeof callback !== 'function') throw new TypeError(`${name} must be a function`)
194
215
  if (file !== ':memory:') { file = resolve(file); mkdirSync(dirname(file), { recursive: true }) }
195
216
  const db = new Database(file)
196
217
  try {
@@ -288,8 +309,26 @@ export function openBotSettings({ Database, file, catalogFor = () => ({}), botEx
288
309
  const botId = botIdOf(request.botId)
289
310
  if (!Number.isSafeInteger(request.expectedRevision) || request.expectedRevision < 0) fail('BOT_SETTINGS_INVALID', 'expectedRevision 必须是当前绑定修订号。')
290
311
  if (!sync(botExists(botId), 'botExists')) fail('BOT_SETTINGS_UNKNOWN_BOT', '此 Bot 不在已提交的宿主目录。')
291
- const normalized=normalizeBotSettings(request.settings)
292
- const settings=normalized.enabled?validateBotSettings(normalized,sync(catalogFor(botId),'catalogFor')).settings:normalized
312
+ const previous = load(botId).settings
313
+ const incoming = normalizeBotSettings(request.settings)
314
+ const main = sync(isMainBot(botId), 'isMainBot') === true
315
+ if (!main && Object.hasOwn(incoming, 'mainRoutes') && json(incoming.mainRoutes) !== json(previous.mainRoutes ?? null)) {
316
+ fail('MAIN_ROUTES_ROLE_REQUIRED', '只有已提交的 Main Bot 可以修改多路线配置。')
317
+ }
318
+ let normalized = main ? preserveMainModelRoutes(incoming, previous) : incoming
319
+ if (!main && Object.hasOwn(previous, 'mainRoutes')) normalized.mainRoutes = previous.mainRoutes
320
+ const needsCatalog = normalized.enabled || (main && normalized.mainRoutes)
321
+ const hostCatalog = needsCatalog ? sync(catalogFor(botId), 'catalogFor') : null
322
+ if (main && normalized.mainRoutes) {
323
+ const catalog = catalogOf(hostCatalog)
324
+ for (const route of normalized.mainRoutes.routes) {
325
+ const known = catalog.models.find(item => item.provider === route.provider && item.model === route.model)
326
+ if (route.funding.mode === 'api' && route.funding.pricing === null && known?.pricing) route.funding.pricing = clone(known.pricing)
327
+ if (route.enabled) checkMainRouteFunding(route, fundingForMainRoute(normalized.mainRoutes, route), catalog)
328
+ }
329
+ normalized = materializeMainRouteDefault(normalized, { normalizeFunding })
330
+ }
331
+ const settings = normalized.enabled ? validateBotSettings(normalized, hostCatalog).settings : normalized
293
332
  return commit(botId, request.expectedRevision, settings)
294
333
  },
295
334
  refresh() {
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Which model calls belong to a Bot thread, decided before anything else runs.
3
+ *
4
+ * The runtime's live owner index is a fast answer that goes stale the moment a thread is
5
+ * archived, a Bot is suspended or deleted, or a Bot is moved to the other runtime: an archived
6
+ * thread's Session leaves `chatOwners`, so the ordinary Harness bypass would answer for it as if
7
+ * it were the user's own chat — unguarded, unpoliced and unaccounted. The thread catalog is the
8
+ * record that survives all of that, and its gate is the one the send path already uses.
9
+ *
10
+ * So this guard asks the catalog first, for the exact Bot/thread/session triple, and only then
11
+ * the frozen runtime. Three outcomes, and only three:
12
+ *
13
+ * - `thread`: a committed Bot thread. The gate has just re-confirmed the Bot is committed and
14
+ * healthy, its grant still stands, the thread is open, and the Host still owns the Session.
15
+ * - `unbound`: nothing claims this Session. An ordinary Harness session — the user's own chat,
16
+ * another plugin — stays exactly as unguarded as it was.
17
+ * - a refusal: the Session is claimed but the claim does not stand. Nothing is replayed, and no
18
+ * identity is invented: a doubt about a thread never becomes Main, the owner, or a default
19
+ * Session.
20
+ *
21
+ * A Session the frozen runtime reports as a live thread while the catalog has no record for it
22
+ * is a refusal too. The catalog is the record; the runtime's index is the hint.
23
+ */
24
+
25
+ const fail = (code, message) => {
26
+ const error = new Error(message ?? code)
27
+ error.code = code
28
+ error.outcome = 'refused'
29
+ throw error
30
+ }
31
+
32
+ const safe = work => { try { return work() } catch { return null } }
33
+
34
+ /**
35
+ * @param {{
36
+ * threads?: object | (() => object | null) | null,
37
+ * nativeThreadOf?: ((sessionId: string) => { botId: string, threadId: string } | null | undefined) | null,
38
+ * }} options
39
+ */
40
+ export function createBotThreadAdmission({ threads = null, nativeThreadOf = null } = {}) {
41
+ // The thread host is built after the product runtime, so the resolver is allowed to be lazy:
42
+ // the guard asks for it on every call rather than capturing a half-composed Host.
43
+ const host = () => {
44
+ const value = typeof threads === 'function' ? safe(() => threads()) : threads
45
+ // Either the thread host itself or its catalog: both are the same committed record.
46
+ const catalog = value?.service && typeof value.service === 'object' ? value.service : value
47
+ return catalog && typeof catalog.threadBySession === 'function' && typeof catalog.assertSendable === 'function' ? catalog : null
48
+ }
49
+ const nativeThread = sessionId => (typeof nativeThreadOf === 'function' ? safe(() => nativeThreadOf(sessionId)) : null) ?? null
50
+
51
+ /**
52
+ * @param {string | undefined | null} sessionId
53
+ * @returns {{ kind: 'absent' | 'unbound' } | { kind: 'thread', botId: string, threadId: string, sessionId: string }}
54
+ */
55
+ const admit = sessionId => {
56
+ if (typeof sessionId !== 'string' || sessionId === '') return { kind: 'absent' }
57
+ const service = host()
58
+ const live = nativeThread(sessionId)
59
+ if (!service) {
60
+ // No catalog to ask. A Session the runtime still calls a live Bot thread is refused rather
61
+ // than handed to the ordinary bypass, which would answer for a Bot with no Bot policy.
62
+ if (live) fail('BOT_THREAD_ADMISSION_UNAVAILABLE', '线程会话服务未就绪,宿主无法确认该会话归属,本次请求被拒绝。')
63
+ return { kind: 'unbound' }
64
+ }
65
+ // A catalog that cannot be read is a doubt, not an answer: it may hold the very tombstoned
66
+ // identity this call belongs to, so the failure propagates instead of reading as "unbound".
67
+ const found = service.threadBySession(sessionId) ?? null
68
+ if (found && typeof found.botId === 'string' && typeof found.threadId === 'string') {
69
+ // The exact triple, re-confirmed now: archive, revocation, a deleted Bot, a moved runtime
70
+ // and a Session the Host no longer owns all refuse here.
71
+ const gate = service.assertSendable({ botId: found.botId, threadId: found.threadId })
72
+ // And the Session the gate answered for is the one that was asked about: a record that
73
+ // resolved to some other Session is a refusal, never a silent substitution.
74
+ if (gate?.sessionId !== sessionId) {
75
+ fail('THREAD_SESSION_CONFLICT', '该线程会话与请求的会话不一致,本次请求被拒绝。')
76
+ }
77
+ return { kind: 'thread', botId: found.botId, threadId: found.threadId, sessionId, grantVerified: gate?.grantVerified === true }
78
+ }
79
+ if (live) fail('BOT_THREAD_UNVERIFIED', '该会话是一条 Bot 线程会话,但线程目录没有它的记录,本次请求被拒绝。')
80
+ return { kind: 'unbound' }
81
+ }
82
+
83
+ return Object.freeze({ admit })
84
+ }
@@ -0,0 +1,187 @@
1
+ import { createBotThreads } from './bot-threads.mjs'
2
+
3
+ // The seam between the thread catalog and the real Host. Everything the catalog
4
+ // needs to know about authority is asked here, of services the product already
5
+ // owns: the frozen runtime's thread records, the lifecycle's committed Bots, the
6
+ // principal directory and the capability authority. Nothing is reimplemented,
7
+ // and nothing the Host does not confirm is treated as confirmed.
8
+ //
9
+ // The adapter also reports which Host hooks exist. A Host that has not applied
10
+ // the thread records (an older overlay, or a build without them) says so here,
11
+ // so the composition can surface `blocked` with the missing hook instead of a
12
+ // surface that looks wired and is not.
13
+
14
+ /**
15
+ * @param {{
16
+ * rt: object, // the product runtime (createBotProduct().rt)
17
+ * lifecycle: object, // attachDsLifecycle()
18
+ * authority: object, // capability-broker authority
19
+ * directory: object, // principal directory (principals.mjs)
20
+ * file: string, // where the catalog lives, beside the Host's own state
21
+ * runtimeMode?: (botId: string) => string | null,
22
+ * now?: () => number,
23
+ * }} options
24
+ */
25
+ export function createBotThreadHost({ rt, lifecycle, authority, directory, file, runtimeMode = null, now = Date.now } = {}) {
26
+ if (!rt || typeof rt !== 'object') throw new TypeError('BOT_THREAD_HOST_RUNTIME_REQUIRED')
27
+ if (!lifecycle || typeof lifecycle !== 'object') throw new TypeError('BOT_THREAD_HOST_LIFECYCLE_REQUIRED')
28
+ if (!authority || typeof authority !== 'object') throw new TypeError('BOT_THREAD_HOST_AUTHORITY_REQUIRED')
29
+ if (!directory || typeof directory !== 'object') throw new TypeError('BOT_THREAD_HOST_DIRECTORY_REQUIRED')
30
+ if (typeof file !== 'string' || file === '') throw new TypeError('BOT_THREAD_HOST_FILE_REQUIRED')
31
+
32
+ // Which trusted hooks this Host actually has. `ready` is the honest gate for
33
+ // offering a send at all: without the runtime record and the ownership
34
+ // lookup, a thread Session could be made but never authorized.
35
+ const hooks = Object.freeze({
36
+ createSession: typeof rt.createSession === 'function',
37
+ addThreadSession: typeof rt.addThreadSession === 'function',
38
+ setThreadArchived: typeof rt.setThreadArchived === 'function',
39
+ renameThread: typeof rt.renameThread === 'function',
40
+ threadOf: typeof rt.threadOf === 'function',
41
+ threadsOf: typeof rt.threadsOf === 'function',
42
+ committedBot: typeof lifecycle.committedBot === 'function',
43
+ lifecycleStatus: typeof lifecycle.status === 'function',
44
+ forSession: typeof directory.forSession === 'function',
45
+ forBot: typeof directory.forBot === 'function',
46
+ describe: typeof authority.describe === 'function',
47
+ assertTool: typeof authority.assertTool === 'function',
48
+ })
49
+ const ready = hooks.createSession && hooks.addThreadSession && hooks.committedBot && hooks.forSession && hooks.describe
50
+
51
+ const committedBot = botId => (hooks.committedBot ? lifecycle.committedBot(botId) ?? null : null)
52
+ const lifecycleStatus = () => (hooks.lifecycleStatus ? lifecycle.status() ?? null : null)
53
+
54
+ // The Host's own record of a Bot's threads: identity and archive, read back
55
+ // from committed state. This is what a restart settles an interrupted create
56
+ // against, so it is read from the lifecycle, never from this process.
57
+ const committedThreads = botId => {
58
+ const bot = committedBot(botId)
59
+ if (!Array.isArray(bot?.threads)) return []
60
+ return bot.threads
61
+ .filter(thread => thread && typeof thread.threadId === 'string' && typeof thread.sessionId === 'string')
62
+ .map(thread => ({ threadId: thread.threadId, sessionId: thread.sessionId, archivedAt: thread.archivedAt ?? null }))
63
+ }
64
+
65
+ // The principal directory is the Host's persistent confirmation of who a
66
+ // Session belongs to. It is asked before any policy runs, every time.
67
+ const ownsSession = (botId, sessionId) => {
68
+ if (!hooks.forSession || !hooks.describe) return false
69
+ try { return authority.describe(directory.forSession(sessionId)).botId === botId } catch { return false }
70
+ }
71
+
72
+ // A suspended or disabled Bot has an empty committed grant. The authority
73
+ // says so through the one assertion a send always needs; a doubt is reported
74
+ // as unanswerable rather than assumed.
75
+ const botAuthorized = botId => {
76
+ if (!hooks.forBot || !hooks.assertTool) return null
77
+ try {
78
+ const subject = directory.forBot(botId)
79
+ authority.assertTool(subject, 'model/infer')
80
+ return true
81
+ } catch (error) {
82
+ return error?.code === 'TOOL_DENIED' || error?.code === 'SUBJECT_REVOKED' ? false : null
83
+ }
84
+ }
85
+
86
+ // Makes a thread's Session through the same factory every own chat uses, and
87
+ // records it in the Bot's committed state in one save — the write that makes
88
+ // the lifecycle compile the Session into this Bot's ownership. A refusal the
89
+ // Host names before any Session exists is reported as such, so the catalog
90
+ // does not keep a create intent for an operation that never started.
91
+ const createSession = async ({ botId, threadId, title, dir }) => {
92
+ if (!hooks.createSession) {
93
+ throw Object.assign(new Error('宿主运行时未提供会话工厂。'), { code: 'SESSION_FACTORY_REQUIRED', outcome: 'refused' })
94
+ }
95
+ const readOnly = typeof rt.readOnly === 'function' ? rt.readOnly() : undefined
96
+ if (readOnly !== undefined) throw Object.assign(new Error(readOnly), { code: 'PRODUCT_READ_ONLY', outcome: 'refused' })
97
+ const sessionId = await rt.createSession(title, { dir })
98
+ if (!hooks.addThreadSession) {
99
+ throw Object.assign(new Error('宿主运行时未提供线程会话记录(rt.addThreadSession)。'), { code: 'SESSION_HOST_HOOK_MISSING', outcome: 'refused' })
100
+ }
101
+ await rt.addThreadSession(botId, threadId, sessionId, { name: title })
102
+ return sessionId
103
+ }
104
+
105
+ // Archive is the Host's fact: dropping a thread's authority happens in the
106
+ // committed state, and only then in the catalog.
107
+ const setThreadArchived = async ({ botId, threadId, archivedAt }) => {
108
+ if (!hooks.setThreadArchived) return null
109
+ return rt.setThreadArchived(botId, threadId, archivedAt)
110
+ }
111
+
112
+ // A thread's title is the Host's too: the rename lands in the committed state
113
+ // and answers with the record it wrote, which the catalog checks before it
114
+ // believes the rename happened.
115
+ const renameThread = async ({ botId, threadId, name }) => {
116
+ if (!hooks.renameThread) return null
117
+ return rt.renameThread(botId, threadId, name)
118
+ }
119
+
120
+ const service = createBotThreads({
121
+ file,
122
+ committedBot,
123
+ lifecycleStatus,
124
+ botAuthorized,
125
+ ownsSession,
126
+ committedThreads,
127
+ createSession,
128
+ setThreadArchived,
129
+ renameThread,
130
+ // No public Host session-inspect exists yet: reconcile then reports every
131
+ // unowned Session as 'unbound' rather than 'missing'.
132
+ inspectSession: null,
133
+ runtimeMode: typeof runtimeMode === 'function' ? runtimeMode : null,
134
+ now,
135
+ })
136
+
137
+ // The owner-console handlers. Each takes the RPC payload as a closed field
138
+ // set (the catalog refuses anything else) and returns JSON-safe state; the
139
+ // composition wraps every one of them in ownerOnly.
140
+ const actions = Object.freeze({
141
+ 'threads.readModel': payload => service.readModel(payload ?? {}),
142
+ 'threads.list': payload => service.list(payload?.botId),
143
+ 'threads.create': payload => service.create(payload ?? {}),
144
+ 'threads.rename': payload => service.rename(payload ?? {}),
145
+ 'threads.switch': payload => service.switchThread(payload ?? {}),
146
+ 'threads.open': payload => service.openThread(payload ?? {}),
147
+ 'threads.archive': payload => service.archive(payload ?? {}),
148
+ 'threads.unarchive': payload => service.unarchive(payload ?? {}),
149
+ 'threads.ensure': payload => service.ensureSession(payload ?? {}),
150
+ 'threads.recover': payload => service.recover(payload ?? {}),
151
+ 'threads.reconcile': () => service.reconcile(),
152
+ })
153
+
154
+ /**
155
+ * The gate a real send runs under, before any model or tool call. It answers
156
+ * with the Session the turn must use, or refuses with the reason.
157
+ *
158
+ * `create` defaults to false: dispatching a model request is not a request to
159
+ * make a Session. A caller that means "write in this thread" says so, and
160
+ * only a thread that has never had a Session — no id, no durable create
161
+ * intent, no outcome the Host failed to confirm — is given one, exactly once,
162
+ * through the same single-flight ensure every other path uses. Every other
163
+ * refusal is handed back untouched: an unknown, unbound, revoked, archived,
164
+ * foreign or moved-away thread is never quietly given a fresh identity.
165
+ */
166
+ const sendGate = async ({ botId, threadId, create = false } = {}) => {
167
+ const gate = service.probeSendable({ botId, threadId })
168
+ if (gate.ok) return gate
169
+ if (create !== true) return gate
170
+ // The one path that may make a Session: never created, and never attempted.
171
+ if (gate.code !== 'SESSION_MISSING' || gate.sessionState !== 'none') return gate
172
+ await service.ensureSession({ botId, threadId })
173
+ return service.probeSendable({ botId, threadId })
174
+ }
175
+
176
+ return Object.freeze({
177
+ service,
178
+ actions,
179
+ hooks,
180
+ ready,
181
+ sendGate,
182
+ ownsSession,
183
+ committedThreads,
184
+ botAuthorized,
185
+ close: () => service.close(),
186
+ })
187
+ }