@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.
- package/.dsh-plugin/bot-product.mjs +81 -5
- package/.dsh-plugin/client.js +17490 -1791
- package/README.i18n.yaml +4 -4
- package/README.md +409 -364
- package/README.zh.md +409 -364
- package/apps/integration-client/README.md +31 -7
- package/apps/integration-client/api.mjs +14 -3
- package/apps/integration-client/binding-editor.jsx +308 -109
- package/apps/integration-client/binding-model.mjs +262 -0
- package/apps/integration-client/binding.css +144 -0
- package/apps/integration-client/bot-model-status.mjs +18 -0
- package/apps/integration-client/bot-profile-identity.jsx +197 -0
- package/apps/integration-client/bot-profile.css +57 -0
- package/apps/integration-client/bot-profile.mjs +97 -0
- package/apps/integration-client/bot-settings-dialog.css +18 -0
- package/apps/integration-client/bot-settings-dialog.jsx +111 -0
- package/apps/integration-client/bot-thread-header-bridge.css +20 -0
- package/apps/integration-client/bot-thread-header.jsx +278 -0
- package/apps/integration-client/bot-threads-panel.jsx +378 -0
- package/apps/integration-client/bot-threads.css +169 -0
- package/apps/integration-client/chat-design.mjs +250 -0
- package/apps/integration-client/chat-ui.jsx +175 -0
- package/apps/integration-client/chat.css +477 -0
- package/apps/integration-client/composer-draft-binding.mjs +50 -0
- package/apps/integration-client/composer-draft-bound.jsx +14 -0
- package/apps/integration-client/composer-draft-host.jsx +142 -0
- package/apps/integration-client/composer-draft-session.jsx +287 -0
- package/apps/integration-client/composer-draft-witness.mjs +115 -0
- package/apps/integration-client/composer-draft.css +4 -0
- package/apps/integration-client/composer-draft.mjs +322 -0
- package/apps/integration-client/contact-attention.jsx +54 -0
- package/apps/integration-client/data-panel.jsx +439 -0
- package/apps/integration-client/data.css +69 -0
- package/apps/integration-client/desktop-bridge.mjs +1 -0
- package/apps/integration-client/desktop-http-socket.mjs +49 -0
- package/apps/integration-client/desktop-keyboard.mjs +32 -0
- package/apps/integration-client/desktop-pane.jsx +99 -30
- package/apps/integration-client/desktop-stream.mjs +13 -3
- package/apps/integration-client/desktop-tools.jsx +19 -0
- package/apps/integration-client/desktop-touch.mjs +27 -0
- package/apps/integration-client/desktop.css +41 -0
- package/apps/integration-client/independent-chat-controller.mjs +224 -0
- package/apps/integration-client/independent-chat-entry.jsx +61 -0
- package/apps/integration-client/independent-chat-navigation.mjs +21 -0
- package/apps/integration-client/independent-chat-page.jsx +148 -0
- package/apps/integration-client/independent-chat-rich.jsx +64 -0
- package/apps/integration-client/independent-chat-rich.mjs +30 -0
- package/apps/integration-client/independent-chat.css +73 -0
- package/apps/integration-client/index.jsx +24 -16
- package/apps/integration-client/library-panel.jsx +4 -1
- package/apps/integration-client/licenses/jsqr-1.4.0-LICENSE.txt +202 -0
- package/apps/integration-client/main-route-editor.jsx +94 -0
- package/apps/integration-client/main-route-model.mjs +100 -0
- package/apps/integration-client/model-picker.css +37 -0
- package/apps/integration-client/model-picker.jsx +83 -0
- package/apps/integration-client/native-activity.css +141 -0
- package/apps/integration-client/native-activity.jsx +276 -0
- package/apps/integration-client/native-activity.mjs +384 -0
- package/apps/integration-client/notifications-panel.jsx +186 -0
- package/apps/integration-client/notifications.css +126 -0
- package/apps/integration-client/pairing-qr.jsx +79 -0
- package/apps/integration-client/panels.jsx +126 -115
- package/apps/integration-client/product-slots.jsx +46 -0
- package/apps/integration-client/routine-recorder.mjs +23 -2
- package/apps/integration-client/secure-pairing.jsx +84 -0
- package/apps/integration-client/tasks-panel.jsx +32 -10
- package/apps/integration-client/ui-icons.jsx +17 -0
- package/apps/integration-client/usage-explorer.jsx +43 -0
- package/apps/integration-client/usage-report.mjs +71 -0
- package/apps/integration-client/usage.css +34 -0
- package/apps/integration-client/web-panel-model.mjs +140 -0
- package/apps/integration-client/web-panel-page.jsx +221 -0
- package/apps/integration-client/web-panel-pins-live.jsx +28 -0
- package/apps/integration-client/web-panel.css +139 -0
- package/apps/integration-client/workspace-model.mjs +67 -0
- package/apps/integration-client/workspace-navigation.mjs +56 -0
- package/apps/integration-client/workspace-ui.jsx +173 -0
- package/apps/integration-client/workspace.css +332 -0
- package/modules/bot-runtime/SOURCE.json +176 -50
- package/modules/bot-runtime/index.mjs +169 -13
- package/modules/bot-runtime/product-overlay/OVERLAY.json +128 -2
- package/modules/bot-runtime/product-overlay/base/src/client/header.js +244 -0
- package/modules/bot-runtime/product-overlay/base/src/client/learning-card.js +202 -0
- package/modules/bot-runtime/product-overlay/base/src/client/model-menu.js +371 -0
- package/modules/bot-runtime/product-overlay/base/src/client/secret-card.js +363 -0
- package/modules/bot-runtime/product-overlay/base/src/client/sources.js +68 -0
- package/modules/bot-runtime/product-overlay/base/src/client/turn-error.js +260 -0
- package/modules/bot-runtime/product-overlay/base/src/host/channel.js +647 -0
- package/modules/bot-runtime/product-overlay/base/src/host/memory.js +2050 -0
- package/modules/bot-runtime/product-overlay/base/src/host/models.js +314 -0
- package/modules/bot-runtime/product-overlay/base/src/host/parts.js +1473 -0
- package/modules/bot-runtime/product-overlay/base/src/host/privacy.js +368 -0
- package/modules/bot-runtime/product-overlay/base/src/host/roster.js +776 -0
- package/modules/bot-runtime/product-overlay/base/src/host/store.js +512 -0
- package/modules/bot-runtime/product-overlay/base/src/host/usage.js +653 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/details-drawer.js.patch +48 -2
- package/modules/bot-runtime/product-overlay/patches/src/client/header.js.patch +41 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/index.js.patch +33 -1
- package/modules/bot-runtime/product-overlay/patches/src/client/learning-card.js.patch +20 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/model-menu.js.patch +24 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/secret-card.js.patch +25 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/sources.js.patch +52 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/turn-error.js.patch +23 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/channel.js.patch +46 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/memory.js.patch +11 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/models.js.patch +16 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/parts.js.patch +95 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/privacy.js.patch +11 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/roster.js.patch +145 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/store.js.patch +68 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/usage.js.patch +11 -0
- package/modules/bot-runtime/read-model.mjs +19 -4
- package/modules/bot-runtime/vendor/ds-bot/src/client/details-drawer.js +15 -5
- package/modules/bot-runtime/vendor/ds-bot/src/client/header.js +9 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/index.js +9 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/learning-card.js +2 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/model-menu.js +5 -3
- package/modules/bot-runtime/vendor/ds-bot/src/client/secret-card.js +3 -3
- package/modules/bot-runtime/vendor/ds-bot/src/client/sources.js +25 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/turn-error.js +3 -2
- package/modules/bot-runtime/vendor/ds-bot/src/host/channel.js +14 -2
- package/modules/bot-runtime/vendor/ds-bot/src/host/memory.js +1 -1
- package/modules/bot-runtime/vendor/ds-bot/src/host/models.js +7 -0
- package/modules/bot-runtime/vendor/ds-bot/src/host/parts.js +26 -9
- package/modules/bot-runtime/vendor/ds-bot/src/host/privacy.js +1 -1
- package/modules/bot-runtime/vendor/ds-bot/src/host/roster.js +112 -3
- package/modules/bot-runtime/vendor/ds-bot/src/host/store.js +34 -1
- package/modules/bot-runtime/vendor/ds-bot/src/host/usage.js +1 -1
- package/modules/independent-runtime/index.mjs +152 -62
- package/modules/independent-runtime/remote-tools.mjs +8 -5
- package/modules/openmaus-protocol/NOTICE +10 -0
- package/modules/openmaus-protocol/README.md +15 -0
- package/modules/openmaus-protocol/SOURCE.json +68 -0
- package/modules/openmaus-protocol/dist/protocol.mjs +1203 -0
- package/modules/openmaus-protocol/index.mjs +1 -0
- package/modules/openmaus-protocol/transport.mjs +77 -0
- package/modules/openmaus-protocol/vendor/LICENSE +202 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/README.md +405 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/credentials.ts +128 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/descriptor.ts +84 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/noise.ts +456 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/phone.ts +104 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/session.ts +417 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.json +2565 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.sha256 +1 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/yamux.ts +335 -0
- package/package.json +18 -6
- package/packages/bot-acp/README.md +156 -0
- package/packages/bot-acp/licenses/LICENSE-OpenMausBot +202 -0
- package/packages/bot-acp/licenses/NOTICE.md +27 -0
- package/packages/bot-acp/src/acp-adapter.mjs +795 -0
- package/packages/bot-acp/src/acp-host.mjs +148 -0
- package/packages/bot-acp/src/native-launch.mjs +121 -0
- package/packages/bot-acp/src/permission-policy.mjs +283 -0
- package/packages/bot-acp/src/rpc-codec.mjs +265 -0
- package/packages/bot-acp/src/session-core.mjs +581 -0
- package/packages/bot-acp/src/stdio-rpc.mjs +329 -0
- package/packages/bot-engines/README.md +281 -0
- package/packages/bot-engines/licenses/LICENSE-OpenMausBot +202 -0
- package/packages/bot-engines/licenses/NOTICE.md +32 -0
- package/packages/bot-engines/src/claude-engine.mjs +772 -0
- package/packages/bot-engines/src/claude-llm-adapter.mjs +797 -0
- package/packages/bot-engines/src/claude-llm-turn.mjs +292 -0
- package/packages/bot-engines/src/claude-permission-broker.mjs +130 -0
- package/packages/bot-engines/src/claude-permission-proxy.mjs +87 -0
- package/packages/bot-engines/src/claude-protocol.mjs +177 -0
- package/packages/bot-engines/src/driver-catalog.mjs +334 -0
- package/packages/bot-engines/src/host-loop-request.mjs +95 -0
- package/packages/bot-engines/src/host-registrations.mjs +105 -0
- package/packages/capability-broker/src/cli-bootstrap.mjs +86 -16
- package/packages/capability-broker/src/cli-bridge-response.mjs +80 -0
- package/packages/capability-broker/src/cli-stdio-bridge.mjs +21 -8
- package/packages/cli-runtime/src/codex-session-core.mjs +4 -0
- package/packages/cli-runtime/src/native-cli-output.mjs +57 -0
- package/packages/cli-runtime/src/native-peer.ps1 +51 -3
- package/packages/cli-runtime/src/native-process.mjs +77 -14
- package/packages/model-router-plugin/package.json +1 -1
- package/packages/orchestration/src/binding-product.mjs +19 -0
- package/packages/orchestration/src/bot-data-actions.mjs +45 -0
- package/packages/orchestration/src/bot-data-chart.mjs +109 -0
- package/packages/orchestration/src/bot-data-context.mjs +44 -0
- package/packages/orchestration/src/bot-data-engine.mjs +809 -0
- package/packages/orchestration/src/bot-data-io.mjs +186 -0
- package/packages/orchestration/src/bot-data-sql.mjs +195 -0
- package/packages/orchestration/src/bot-data-surface.mjs +144 -0
- package/packages/orchestration/src/bot-data-tools.mjs +68 -0
- package/packages/orchestration/src/bot-data-worker.mjs +228 -0
- package/packages/orchestration/src/bot-independent-execution.mjs +195 -0
- package/packages/orchestration/src/bot-independent-stream.mjs +74 -0
- package/packages/orchestration/src/bot-loop.mjs +152 -19
- package/packages/orchestration/src/bot-notifications.mjs +159 -0
- package/packages/orchestration/src/bot-policy.mjs +43 -4
- package/packages/orchestration/src/bot-profile.mjs +171 -0
- package/packages/orchestration/src/bot-routine.mjs +4 -4
- package/packages/orchestration/src/bot-runtime-assembly.mjs +255 -8
- package/packages/orchestration/src/bot-runtime-switch.mjs +13 -2
- package/packages/orchestration/src/bot-serving.mjs +164 -0
- package/packages/orchestration/src/bot-session-store.mjs +105 -9
- package/packages/orchestration/src/bot-settings.mjs +50 -11
- package/packages/orchestration/src/bot-thread-admission.mjs +84 -0
- package/packages/orchestration/src/bot-thread-host.mjs +187 -0
- package/packages/orchestration/src/bot-threads.mjs +790 -0
- package/packages/orchestration/src/data-product-access.mjs +37 -0
- package/packages/orchestration/src/data-tool-contract.mjs +291 -0
- package/packages/orchestration/src/desktop-http-carrier.mjs +120 -0
- package/packages/orchestration/src/desktop-proxy.mjs +311 -0
- package/packages/orchestration/src/desktop-sessions.mjs +43 -9
- package/packages/orchestration/src/ds-lifecycle.mjs +31 -4
- package/packages/orchestration/src/grant-refresh-queue.mjs +17 -0
- package/packages/orchestration/src/independent-chat-page.mjs +39 -0
- package/packages/orchestration/src/independent-chat-service.mjs +308 -0
- package/packages/orchestration/src/independent-context-state.mjs +44 -0
- package/packages/orchestration/src/independent-message-wire.mjs +84 -0
- package/packages/orchestration/src/independent-tool-catalog.mjs +175 -0
- package/packages/orchestration/src/independent-tool-families.mjs +73 -0
- package/packages/orchestration/src/independent-tool-host.mjs +344 -0
- package/packages/orchestration/src/main-bot-grant.mjs +15 -0
- package/packages/orchestration/src/main-model-routes.mjs +208 -0
- package/packages/orchestration/src/model-accounting.mjs +99 -35
- package/packages/orchestration/src/model-auxiliary-dispatch.mjs +187 -0
- package/packages/orchestration/src/model-loop-frame.mjs +179 -0
- package/packages/orchestration/src/model-prepared-generation.mjs +175 -0
- package/packages/orchestration/src/model-request-bounds.mjs +111 -0
- package/packages/orchestration/src/native-activity-contract.mjs +491 -0
- package/packages/orchestration/src/native-activity-service.mjs +102 -0
- package/packages/orchestration/src/native-activity-store.mjs +249 -0
- package/packages/orchestration/src/native-conversation-tools.mjs +78 -0
- package/packages/orchestration/src/native-data-tools.mjs +100 -0
- package/packages/orchestration/src/native-remote-actions.mjs +742 -0
- package/packages/orchestration/src/native-team-tools.mjs +73 -0
- package/packages/orchestration/src/pairing-invite.mjs +37 -0
- package/packages/orchestration/src/product-composition.mjs +1559 -1001
- package/packages/orchestration/src/remote-machines.mjs +82 -14
- package/packages/orchestration/src/routine-execution.mjs +5 -3
- package/packages/orchestration/src/routine-session.mjs +19 -13
- package/packages/orchestration/src/secure-pairing-host.mjs +112 -0
- package/packages/orchestration/src/task-journal.mjs +29 -0
- package/packages/orchestration/src/task-product.mjs +17 -0
- package/packages/orchestration/src/team-assignment-store.mjs +120 -0
- package/packages/orchestration/src/team-bridge.mjs +282 -0
- package/packages/orchestration/src/team-scheduler.mjs +83 -0
- package/packages/orchestration/src/team-sdk-tools.mjs +84 -0
- package/packages/orchestration/src/team-tool-contract.mjs +284 -0
- package/packages/orchestration/src/ui-host-namespace.mjs +23 -0
- package/packages/orchestration/src/web-panel-actions.mjs +103 -0
- package/packages/orchestration/src/web-panel-framing.mjs +227 -0
- package/packages/orchestration/src/web-panel-store.mjs +206 -0
- package/remote-computer-agent/README.md +39 -29
- package/remote-computer-agent/build.mjs +3 -0
- package/remote-computer-agent/dist/OPENMAUSBOT_LICENSE +202 -0
- package/remote-computer-agent/dist/OPENMAUSBOT_NOTICE +10 -0
- package/remote-computer-agent/dist/OPENMAUSBOT_SOURCE.json +68 -0
- package/remote-computer-agent/dist/cli-stdio-bridge.mjs +215 -25
- package/remote-computer-agent/dist/cli-stdio-bridge.mjs.map +4 -4
- package/remote-computer-agent/dist/cli.mjs +1688 -168
- package/remote-computer-agent/dist/cli.mjs.map +4 -4
- package/remote-computer-agent/dist/index.mjs +1579 -122
- package/remote-computer-agent/dist/index.mjs.map +4 -4
- package/remote-computer-agent/dist/native-peer.ps1 +51 -3
- package/remote-computer-agent/package-lock.json +308 -2
- package/remote-computer-agent/package.json +2 -1
- package/remote-computer-agent/pair-device.ps1 +20 -0
- package/remote-computer-agent/setup.ps1 +38 -16
- package/remote-computer-agent/src/cli.ts +24 -3
- package/remote-computer-agent/src/desktop-session.ts +11 -3
- package/remote-computer-agent/src/e2e-server.ts +81 -0
- package/remote-computer-agent/src/pairing-page.ts +35 -0
- package/remote-computer-agent/src/protocol.ts +25 -25
- package/remote-computer-agent/src/secure-pairing.ts +105 -0
- package/remote-computer-agent/src/store.ts +20 -5
- package/remote-computer-agent/src/transport.ts +39 -15
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
4
|
+
|
|
5
|
+
// Per-Bot conversation threads: several independent contexts under one committed
|
|
6
|
+
// Bot, each with its own Host Session identity.
|
|
7
|
+
//
|
|
8
|
+
// What this module is: the durable thread catalog — create, name, switch,
|
|
9
|
+
// archive — plus the gate every send must pass before any policy decision. It
|
|
10
|
+
// holds no Agent objects and never drives a turn, so a retired Bot cannot be
|
|
11
|
+
// written through a stale handle here: identity is re-read from the committed
|
|
12
|
+
// lifecycle and re-verified against the Host on every call.
|
|
13
|
+
//
|
|
14
|
+
// Two owners, one fact each. The Host owns identity and authority: a thread
|
|
15
|
+
// Session is created through the product runtime, recorded in the Bot's
|
|
16
|
+
// committed state, compiled into ownership by the lifecycle, and only then is
|
|
17
|
+
// it sendable. This catalog owns the label, the selection and the request
|
|
18
|
+
// keys. Nothing is written twice, so the two can never drift into a state where
|
|
19
|
+
// a catalog entry claims an authority the Host does not confirm.
|
|
20
|
+
//
|
|
21
|
+
// What it is not: a second execution authority. Create/switch/rename/archive
|
|
22
|
+
// touch no model, send no greeting or instruction and start no Task. A Session
|
|
23
|
+
// is made lazily by `ensureSession` through an injected trusted facade, with
|
|
24
|
+
// its working directory taken from the committed Bot record and never from
|
|
25
|
+
// thread or client arguments. Every RPC-shaped entry point takes a closed set
|
|
26
|
+
// of fields: a thread parameter can carry no model, route, folder, credential,
|
|
27
|
+
// endpoint or authorization.
|
|
28
|
+
//
|
|
29
|
+
// State is one small JSON file per Host, written atomically. Concurrent
|
|
30
|
+
// processes are not supported, exactly like the sibling stores
|
|
31
|
+
// (bot-session-store.mjs, bot-routine-store.mjs): the Host composes one.
|
|
32
|
+
|
|
33
|
+
export const BOT_THREAD_SCHEMA = 2
|
|
34
|
+
export const THREAD_STATES = Object.freeze(['open', 'archived'])
|
|
35
|
+
export const SESSION_STATES = Object.freeze(['none', 'pending', 'unbound', 'owned', 'missing', 'unknown'])
|
|
36
|
+
export const MAX_THREADS_PER_BOT = 64
|
|
37
|
+
export const MAX_THREADS = 4096
|
|
38
|
+
export const MAX_NAME = 80
|
|
39
|
+
export const MAX_REQUEST_KEYS = 512
|
|
40
|
+
export const DEFAULT_THREAD_NAME = '新线程'
|
|
41
|
+
|
|
42
|
+
const fail = (code, message) => { const error = new Error(message); error.code = code; throw error }
|
|
43
|
+
const idOk = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value)
|
|
44
|
+
const keyOk = value => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,128}$/.test(value)
|
|
45
|
+
const finite = value => Number.isSafeInteger(value) && value >= 0
|
|
46
|
+
const isArchivedAt = value => value !== undefined && value !== null
|
|
47
|
+
const stateOf = record => (isArchivedAt(record.archivedAt) ? 'archived' : 'open')
|
|
48
|
+
|
|
49
|
+
/** A stored name is a label, never an instruction: bounded, trimmed, defaulted. */
|
|
50
|
+
const nameOf = value => {
|
|
51
|
+
if (value === undefined || value === null) return DEFAULT_THREAD_NAME
|
|
52
|
+
if (typeof value !== 'string') fail('INVALID_THREAD_NAME', '线程名称必须是字符串。')
|
|
53
|
+
return value.trim().slice(0, MAX_NAME) || DEFAULT_THREAD_NAME
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** An RPC-shaped call takes exactly its own fields; anything else is refused. */
|
|
57
|
+
const closed = (value, keys, code = 'INVALID_THREAD_REQUEST') => {
|
|
58
|
+
if (value === undefined || value === null) return {}
|
|
59
|
+
if (typeof value !== 'object' || Array.isArray(value)) fail(code, '请求格式无效。')
|
|
60
|
+
for (const key of Object.keys(value)) if (!keys.includes(key)) fail(code, `不支持的字段:${key}`)
|
|
61
|
+
return value
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseThread(value, botId) {
|
|
65
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('BOT_THREADS_INVALID')
|
|
66
|
+
for (const key of ['threadId', 'name', 'createdAt', 'updatedAt']) if (!(key in value)) fail('BOT_THREADS_INVALID')
|
|
67
|
+
if (!idOk(value.threadId)) fail('BOT_THREADS_INVALID')
|
|
68
|
+
if (typeof value.name !== 'string' || value.name.length > MAX_NAME) fail('BOT_THREADS_INVALID')
|
|
69
|
+
if (!finite(value.createdAt) || !finite(value.updatedAt)) fail('BOT_THREADS_INVALID')
|
|
70
|
+
if (value.archivedAt !== undefined && value.archivedAt !== null && !finite(value.archivedAt)) fail('BOT_THREADS_INVALID')
|
|
71
|
+
if (value.sessionId !== undefined && value.sessionId !== null && !idOk(value.sessionId)) fail('BOT_THREADS_INVALID')
|
|
72
|
+
if (!SESSION_STATES.includes(value.sessionState ?? 'none')) fail('BOT_THREADS_INVALID')
|
|
73
|
+
if (value.requestKey !== undefined && value.requestKey !== null && !keyOk(value.requestKey)) fail('BOT_THREADS_INVALID')
|
|
74
|
+
if (value.operationKey !== undefined && value.operationKey !== null && !keyOk(value.operationKey)) fail('BOT_THREADS_INVALID')
|
|
75
|
+
const sessionState = value.sessionId ? (value.sessionState ?? 'unbound') : 'none'
|
|
76
|
+
return {
|
|
77
|
+
threadId: value.threadId,
|
|
78
|
+
botId,
|
|
79
|
+
name: value.name,
|
|
80
|
+
sessionId: value.sessionId ?? null,
|
|
81
|
+
// A record with no Session reads as 'none' — except for a durable create
|
|
82
|
+
// intent: 'pending' with no Session yet is exactly the marker a restart
|
|
83
|
+
// needs in order to ask the Host what became of the operation.
|
|
84
|
+
sessionState: value.sessionId ? sessionState : (value.sessionState === 'pending' ? 'pending' : 'none'),
|
|
85
|
+
operationKey: value.operationKey ?? null,
|
|
86
|
+
createdAt: value.createdAt,
|
|
87
|
+
updatedAt: value.updatedAt,
|
|
88
|
+
archivedAt: value.archivedAt ?? null,
|
|
89
|
+
requestKey: value.requestKey ?? null,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Validate a saved catalog strictly: a newer or malformed file fails loudly. */
|
|
94
|
+
export function parseBotThreads(value) {
|
|
95
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('BOT_THREADS_INVALID')
|
|
96
|
+
// A file that exists must declare its schema: silently resetting the catalog
|
|
97
|
+
// would lose every thread record, so only a missing file reads as an empty
|
|
98
|
+
// one (see readState).
|
|
99
|
+
if (value.schemaVersion === undefined) fail('BOT_THREADS_INVALID')
|
|
100
|
+
if (value.schemaVersion > BOT_THREAD_SCHEMA) fail('BOT_THREAD_SCHEMA_NEWER', '线程目录由更新版本写入,请先升级宿主。')
|
|
101
|
+
// Schema 1 carried no operation key; it loads as a legacy catalog.
|
|
102
|
+
if (value.schemaVersion !== BOT_THREAD_SCHEMA && value.schemaVersion !== 1) fail('BOT_THREAD_SCHEMA')
|
|
103
|
+
if (!Number.isSafeInteger(value.revision) || value.revision < 0) fail('BOT_THREADS_INVALID')
|
|
104
|
+
if (!value.bots || typeof value.bots !== 'object' || Array.isArray(value.bots)) fail('BOT_THREADS_INVALID')
|
|
105
|
+
const seenThreads = new Set(), seenSessions = new Map(), bots = {}
|
|
106
|
+
for (const [botId, entry] of Object.entries(value.bots)) {
|
|
107
|
+
if (!idOk(botId) || !entry || typeof entry !== 'object' || Array.isArray(entry)) fail('BOT_THREADS_INVALID')
|
|
108
|
+
if (!Array.isArray(entry.threads) || entry.threads.length > MAX_THREADS_PER_BOT) fail('BOT_THREADS_INVALID')
|
|
109
|
+
if (entry.activeThreadId !== undefined && entry.activeThreadId !== null && !idOk(entry.activeThreadId)) fail('BOT_THREADS_INVALID')
|
|
110
|
+
if (entry.requestKeys !== undefined && (!entry.requestKeys || typeof entry.requestKeys !== 'object')) fail('BOT_THREADS_INVALID')
|
|
111
|
+
const threads = entry.threads.map(thread => {
|
|
112
|
+
const record = parseThread(thread, botId)
|
|
113
|
+
// One identity per thread and one thread per Session, across every Bot:
|
|
114
|
+
// a duplicated claim is a corrupted catalog, never a silent merge.
|
|
115
|
+
if (seenThreads.has(record.threadId)) fail('AMBIGUOUS_THREAD_OWNER')
|
|
116
|
+
seenThreads.add(record.threadId)
|
|
117
|
+
if (record.sessionId) {
|
|
118
|
+
if (seenSessions.has(record.sessionId)) fail('AMBIGUOUS_THREAD_SESSION')
|
|
119
|
+
seenSessions.set(record.sessionId, botId)
|
|
120
|
+
}
|
|
121
|
+
return record
|
|
122
|
+
})
|
|
123
|
+
if (seenThreads.size > MAX_THREADS) fail('BOT_THREADS_LIMIT')
|
|
124
|
+
bots[botId] = { activeThreadId: entry.activeThreadId ?? null, threads, requestKeys: { ...(entry.requestKeys ?? {}) } }
|
|
125
|
+
}
|
|
126
|
+
return { schemaVersion: BOT_THREAD_SCHEMA, revision: value.revision, bots }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readState(file) {
|
|
130
|
+
let text
|
|
131
|
+
try { text = readFileSync(file, 'utf8') } catch (error) {
|
|
132
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return { schemaVersion: BOT_THREAD_SCHEMA, revision: 0, bots: {} }
|
|
133
|
+
throw error
|
|
134
|
+
}
|
|
135
|
+
let value
|
|
136
|
+
try { value = JSON.parse(text) } catch { fail('BOT_THREADS_INVALID', '线程目录文件损坏。') }
|
|
137
|
+
return parseBotThreads(value)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The thread catalog for every committed Bot.
|
|
142
|
+
*
|
|
143
|
+
* Injected facades are trusted host code, mirroring the composition seams the
|
|
144
|
+
* product already uses (lifecycle, principal directory, authority, runtime):
|
|
145
|
+
* committedBot(botId) -> saved Bot record | null (lifecycle.committedBot)
|
|
146
|
+
* lifecycleStatus() -> { healthy, closed, … } | null (lifecycle.status)
|
|
147
|
+
* botAuthorized(botId) -> true | false | null (authority probe)
|
|
148
|
+
* ownsSession(botId, sessionId) -> boolean (principal directory)
|
|
149
|
+
* committedThreads(botId) -> [{threadId, sessionId, archivedAt}] (Host's own record)
|
|
150
|
+
* createSession({botId, threadId, title, dir}) -> sessionId (runtime factory + Host record)
|
|
151
|
+
* setThreadArchived({botId, threadId, archivedAt}) -> record | null (Host record)
|
|
152
|
+
* renameThread({botId, threadId, name}) -> record | null (Host thread title)
|
|
153
|
+
* inspectSession(sessionId) -> boolean | null (optional, Host inspect)
|
|
154
|
+
* runtimeMode(botId) -> 'integration' | 'independent' | null
|
|
155
|
+
*/
|
|
156
|
+
export function createBotThreads({
|
|
157
|
+
file,
|
|
158
|
+
committedBot,
|
|
159
|
+
lifecycleStatus = null,
|
|
160
|
+
botAuthorized = null,
|
|
161
|
+
ownsSession = null,
|
|
162
|
+
committedThreads = null,
|
|
163
|
+
createSession = null,
|
|
164
|
+
setThreadArchived = null,
|
|
165
|
+
renameThread = null,
|
|
166
|
+
inspectSession = null,
|
|
167
|
+
runtimeMode = null,
|
|
168
|
+
now = Date.now,
|
|
169
|
+
} = {}) {
|
|
170
|
+
if (typeof file !== 'string' || file === '') throw new TypeError('BOT_THREADS_FILE_REQUIRED')
|
|
171
|
+
if (typeof committedBot !== 'function') throw new TypeError('BOT_THREADS_LIFECYCLE_REQUIRED')
|
|
172
|
+
for (const [name, facade] of [['lifecycleStatus', lifecycleStatus], ['botAuthorized', botAuthorized], ['ownsSession', ownsSession], ['committedThreads', committedThreads], ['createSession', createSession], ['setThreadArchived', setThreadArchived], ['renameThread', renameThread], ['inspectSession', inspectSession], ['runtimeMode', runtimeMode]]) {
|
|
173
|
+
if (facade !== null && typeof facade !== 'function') throw new TypeError(`BOT_THREADS_${name.replace(/[A-Z]/g, letter => `_${letter}`).toUpperCase()}_INVALID`)
|
|
174
|
+
}
|
|
175
|
+
let state = readState(file)
|
|
176
|
+
let closed_ = false
|
|
177
|
+
let storageFailed = false
|
|
178
|
+
let durableState = structuredClone(state)
|
|
179
|
+
const assertCatalog = () => {
|
|
180
|
+
if (closed_) fail('BOT_THREADS_CLOSED', '线程目录已关闭。')
|
|
181
|
+
if (storageFailed) fail('BOT_THREADS_STORAGE_FAILED', '线程目录无法读取,暂不能确认会话归属。请修复存储后重启宿主。')
|
|
182
|
+
}
|
|
183
|
+
// Preserve existing record identities: an admitted Session factory may be
|
|
184
|
+
// awaiting its Host while a different local catalog commit fails.
|
|
185
|
+
const restore = snapshot => {
|
|
186
|
+
state.revision = snapshot.revision
|
|
187
|
+
for (const botId of Object.keys(state.bots)) if (!snapshot.bots[botId]) delete state.bots[botId]
|
|
188
|
+
for (const [botId, saved] of Object.entries(snapshot.bots)) {
|
|
189
|
+
const entry = state.bots[botId] ??= { threads: [], requestKeys: {}, activeThreadId: null }
|
|
190
|
+
const records = new Map(entry.threads.map(row => [row.threadId, row]))
|
|
191
|
+
entry.threads = saved.threads.map(row => {
|
|
192
|
+
const record = records.get(row.threadId) ?? {}
|
|
193
|
+
for (const key of Object.keys(record)) if (!Object.hasOwn(row, key)) delete record[key]
|
|
194
|
+
return Object.assign(record, structuredClone(row))
|
|
195
|
+
})
|
|
196
|
+
entry.requestKeys = { ...saved.requestKeys }
|
|
197
|
+
entry.activeThreadId = saved.activeThreadId
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// One create per thread at a time. Two callers asking for the same thread's
|
|
201
|
+
// Session share one outcome, so a double click can never make two Sessions.
|
|
202
|
+
const flights = new Map()
|
|
203
|
+
|
|
204
|
+
// An atomic replace: the bytes are written beside the file and moved over it,
|
|
205
|
+
// so a reader never sees a half-written catalog. On Windows the move can meet
|
|
206
|
+
// a momentary sharing violation (an indexer or a scanner touching the file it
|
|
207
|
+
// is replacing); the content is already complete at that point, so a short
|
|
208
|
+
// bounded retry finishes the same write instead of failing a thread write the
|
|
209
|
+
// Host had already confirmed. This is a retry of one local commit — never a
|
|
210
|
+
// replay of anything the Host did.
|
|
211
|
+
const replace = (from, to) => {
|
|
212
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
213
|
+
try { renameSync(from, to); return } catch (error) {
|
|
214
|
+
if (attempt >= 4 || (error?.code !== 'EPERM' && error?.code !== 'EBUSY')) throw error
|
|
215
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2 * (attempt + 1))
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const save = () => {
|
|
221
|
+
assertCatalog()
|
|
222
|
+
try {
|
|
223
|
+
state.revision += 1
|
|
224
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
225
|
+
const temp = `${file}.${process.pid}.tmp`
|
|
226
|
+
writeFileSync(temp, JSON.stringify(state))
|
|
227
|
+
replace(temp, file)
|
|
228
|
+
durableState = structuredClone(state)
|
|
229
|
+
} catch (error) {
|
|
230
|
+
restore(durableState)
|
|
231
|
+
try {
|
|
232
|
+
const disk = readState(file)
|
|
233
|
+
if (disk.revision < durableState.revision) throw new Error('CATALOG_ROLLED_BACK')
|
|
234
|
+
restore(disk)
|
|
235
|
+
durableState = structuredClone(state)
|
|
236
|
+
} catch {
|
|
237
|
+
// Never turn an unreadable catalog into an "unbound" Session. Its
|
|
238
|
+
// historical identities may be the only thing preventing a Bot bypass.
|
|
239
|
+
storageFailed = true
|
|
240
|
+
}
|
|
241
|
+
throw error
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const entryOf = botId => state.bots[botId]
|
|
245
|
+
const ensureEntry = botId => {
|
|
246
|
+
if (!state.bots[botId]) state.bots[botId] = { activeThreadId: null, threads: [], requestKeys: {} }
|
|
247
|
+
return state.bots[botId]
|
|
248
|
+
}
|
|
249
|
+
const publicThread = record => ({
|
|
250
|
+
threadId: record.threadId,
|
|
251
|
+
botId: record.botId,
|
|
252
|
+
name: record.name,
|
|
253
|
+
state: stateOf(record),
|
|
254
|
+
archivedAt: record.archivedAt,
|
|
255
|
+
sessionId: record.sessionId,
|
|
256
|
+
// A hint for the interface only: authority is decided by the live Host
|
|
257
|
+
// check in assertSendable, never by this field. A durable create intent
|
|
258
|
+
// shows as 'pending' before the Session exists, and an outcome the Host
|
|
259
|
+
// cannot confirm as 'unknown'; both can outlive a Session-less record.
|
|
260
|
+
sessionState: record.sessionId ? record.sessionState
|
|
261
|
+
: (record.sessionState === 'pending' || record.sessionState === 'unknown' ? record.sessionState : 'none'),
|
|
262
|
+
createdAt: record.createdAt,
|
|
263
|
+
updatedAt: record.updatedAt,
|
|
264
|
+
active: (entryOf(record.botId)?.activeThreadId ?? null) === record.threadId,
|
|
265
|
+
})
|
|
266
|
+
const located = threadId => {
|
|
267
|
+
for (const [botId, entry] of Object.entries(state.bots)) {
|
|
268
|
+
const record = entry.threads.find(thread => thread.threadId === threadId)
|
|
269
|
+
if (record) return { botId, entry, record }
|
|
270
|
+
}
|
|
271
|
+
return null
|
|
272
|
+
}
|
|
273
|
+
const sessionHolder = sessionId => {
|
|
274
|
+
for (const [botId, entry] of Object.entries(state.bots)) {
|
|
275
|
+
const record = entry.threads.find(thread => thread.sessionId === sessionId)
|
|
276
|
+
if (record) return { botId, entry, record }
|
|
277
|
+
}
|
|
278
|
+
return null
|
|
279
|
+
}
|
|
280
|
+
const hostThreads = botId => (typeof committedThreads === 'function' ? committedThreads(botId) : null) ?? []
|
|
281
|
+
|
|
282
|
+
// Fail closed on every host-side doubt: an unhealthy lifecycle has already
|
|
283
|
+
// emptied every grant (suspend()), and an unknown Bot is a deleted or
|
|
284
|
+
// migrated-away one. Neither may accept a thread write.
|
|
285
|
+
const assertHealthy = () => {
|
|
286
|
+
assertCatalog()
|
|
287
|
+
if (typeof lifecycleStatus !== 'function') return
|
|
288
|
+
const status = lifecycleStatus()
|
|
289
|
+
if (status && (status.healthy === false || status.closed === true)) {
|
|
290
|
+
fail('LIFECYCLE_UNHEALTHY', '宿主生命周期未就绪,线程暂不可写入。')
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const assertBot = botId => {
|
|
294
|
+
if (!idOk(botId)) fail('INVALID_BOT_ID', 'Bot 标识无效。')
|
|
295
|
+
const bot = committedBot(botId)
|
|
296
|
+
if (!bot) fail('BOT_NOT_COMMITTED', '该 Bot 不在已提交状态:可能已停用、删除或迁往其他运行时。')
|
|
297
|
+
return bot
|
|
298
|
+
}
|
|
299
|
+
// A suspended or disabled Bot has an empty committed grant: the Host probe
|
|
300
|
+
// says so, and an unanswerable probe is refused rather than assumed. The
|
|
301
|
+
// grant is the only authority a thread turn inherits, so a doubt here must
|
|
302
|
+
// stop the send.
|
|
303
|
+
const assertGrant = botId => {
|
|
304
|
+
if (typeof botAuthorized !== 'function') return { verified: false }
|
|
305
|
+
const verdict = botAuthorized(botId)
|
|
306
|
+
if (verdict === null || verdict === undefined) fail('BOT_GRANT_UNKNOWN', '无法核实该 Bot 的已提交授权,线程暂不可写入。')
|
|
307
|
+
if (verdict === false) fail('BOT_GRANT_EMPTY', '该 Bot 的授权已被撤销或停用,线程暂不可写入。')
|
|
308
|
+
return { verified: true }
|
|
309
|
+
}
|
|
310
|
+
// A Bot is served by exactly one runtime. A Bot moved to the independent
|
|
311
|
+
// loop must not also gain a Host Session here: two writers over one Bot is
|
|
312
|
+
// the failure mode the runtime switch exists to prevent.
|
|
313
|
+
const assertServing = botId => {
|
|
314
|
+
if (typeof runtimeMode !== 'function') return
|
|
315
|
+
const mode = runtimeMode(botId)
|
|
316
|
+
if (mode !== undefined && mode !== null && mode !== 'integration') {
|
|
317
|
+
fail('RUNTIME_MODE_MISMATCH', `运行时已切换:${botId} 当前由 ${mode} 提供服务,集成路径拒绝为其建立线程会话。`)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// The Host's own record of a thread, or null when the Host has none. The
|
|
321
|
+
// Host is the authority for identity: this catalog only mirrors it.
|
|
322
|
+
const hostThread = (botId, threadId) => hostThreads(botId).find(thread => thread.threadId === threadId) ?? null
|
|
323
|
+
// Live ownership, asked of the Host every time. A revoked mapping can never
|
|
324
|
+
// be used just because this catalog still says 'owned'.
|
|
325
|
+
const hostOwns = (botId, sessionId) => typeof ownsSession === 'function' && ownsSession(botId, sessionId) === true
|
|
326
|
+
// Cross-Bot is answered by ownership lookup, never by a caller-supplied pair:
|
|
327
|
+
// a thread id that lives under another Bot is refused by name.
|
|
328
|
+
const resolveThread = (botId, threadId) => {
|
|
329
|
+
if (!idOk(threadId)) fail('INVALID_THREAD_ID', '线程标识无效。')
|
|
330
|
+
const found = located(threadId)
|
|
331
|
+
if (!found) fail('THREAD_NOT_FOUND', '没有这个线程。')
|
|
332
|
+
if (found.botId !== botId) fail('THREAD_FOREIGN_BOT', '该线程属于另一个 Bot,不能在此使用。')
|
|
333
|
+
return found
|
|
334
|
+
}
|
|
335
|
+
const rememberRequestKey = (entry, requestKey, threadId) => {
|
|
336
|
+
if (!requestKey) return
|
|
337
|
+
entry.requestKeys[requestKey] = threadId
|
|
338
|
+
const keys = Object.keys(entry.requestKeys)
|
|
339
|
+
while (keys.length > MAX_REQUEST_KEYS) delete entry.requestKeys[keys.shift()]
|
|
340
|
+
}
|
|
341
|
+
const nextOpenThread = entry => entry.threads
|
|
342
|
+
.filter(thread => stateOf(thread) === 'open')
|
|
343
|
+
.sort((left, right) => right.updatedAt - left.updatedAt)[0] ?? null
|
|
344
|
+
|
|
345
|
+
const list = botId => {
|
|
346
|
+
assertCatalog()
|
|
347
|
+
if (!idOk(botId)) fail('INVALID_BOT_ID', 'Bot 标识无效。')
|
|
348
|
+
const entry = entryOf(botId)
|
|
349
|
+
if (!entry) return { botId, activeThreadId: null, threads: [] }
|
|
350
|
+
const threads = entry.threads.map(publicThread)
|
|
351
|
+
.sort((left, right) => {
|
|
352
|
+
if (left.active !== right.active) return left.active ? -1 : 1
|
|
353
|
+
if (left.state !== right.state) return left.state === 'open' ? -1 : 1
|
|
354
|
+
return right.updatedAt - left.updatedAt
|
|
355
|
+
})
|
|
356
|
+
return { botId, activeThreadId: entry.activeThreadId, threads }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Creating a thread is a local catalog write. It creates no Session, calls
|
|
360
|
+
// no model, sends no greeting and starts no Task; the new thread only becomes
|
|
361
|
+
// the selection, which is why a duplicate requestKey is answered with the
|
|
362
|
+
// thread it already made instead of a second one.
|
|
363
|
+
const create = (input = {}) => {
|
|
364
|
+
const { botId, name, requestKey } = closed(input, ['botId', 'name', 'requestKey'])
|
|
365
|
+
assertHealthy()
|
|
366
|
+
assertBot(botId)
|
|
367
|
+
if (requestKey !== undefined && requestKey !== null && !keyOk(requestKey)) fail('INVALID_REQUEST_KEY', '请求键格式无效。')
|
|
368
|
+
const entry = ensureEntry(botId)
|
|
369
|
+
if (requestKey && entry.requestKeys[requestKey]) {
|
|
370
|
+
const record = entry.threads.find(thread => thread.threadId === entry.requestKeys[requestKey])
|
|
371
|
+
if (record) return { thread: publicThread(record), created: false }
|
|
372
|
+
}
|
|
373
|
+
if (entry.threads.length >= MAX_THREADS_PER_BOT) fail('THREAD_LIMIT', `每个 Bot 最多 ${MAX_THREADS_PER_BOT} 条线程,请先归档。`)
|
|
374
|
+
if (Object.keys(state.bots).reduce((total, key) => total + state.bots[key].threads.length, 0) >= MAX_THREADS) fail('THREAD_LIMIT', '线程总数已达上限。')
|
|
375
|
+
const at = now()
|
|
376
|
+
const record = {
|
|
377
|
+
threadId: randomUUID(),
|
|
378
|
+
botId,
|
|
379
|
+
name: nameOf(name),
|
|
380
|
+
sessionId: null,
|
|
381
|
+
sessionState: 'none',
|
|
382
|
+
operationKey: null,
|
|
383
|
+
createdAt: at,
|
|
384
|
+
updatedAt: at,
|
|
385
|
+
archivedAt: null,
|
|
386
|
+
requestKey: requestKey ?? null,
|
|
387
|
+
}
|
|
388
|
+
entry.threads.push(record)
|
|
389
|
+
rememberRequestKey(entry, requestKey ?? null, record.threadId)
|
|
390
|
+
entry.activeThreadId = record.threadId
|
|
391
|
+
save()
|
|
392
|
+
return { thread: publicThread(record), created: true }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// A thread's title is the Host's fact, exactly like its Session identity: the
|
|
396
|
+
// write lands in the committed state first, and only a reply that confirms
|
|
397
|
+
// the name asked for moves this catalog. A lost reply, or one that confirms a
|
|
398
|
+
// different title, leaves the old name standing — a rename that was never
|
|
399
|
+
// confirmed must never read as a rename that succeeded.
|
|
400
|
+
const rename = async (input = {}) => {
|
|
401
|
+
const { botId, threadId, name } = closed(input, ['botId', 'threadId', 'name'])
|
|
402
|
+
assertHealthy()
|
|
403
|
+
assertBot(botId)
|
|
404
|
+
const { record } = resolveThread(botId, threadId)
|
|
405
|
+
const next = nameOf(name)
|
|
406
|
+
if (typeof renameThread === 'function') {
|
|
407
|
+
let written
|
|
408
|
+
try {
|
|
409
|
+
written = await renameThread({ botId, threadId, name: next })
|
|
410
|
+
} catch (error) {
|
|
411
|
+
// A thread the Host has no record for has no Host title to write: its
|
|
412
|
+
// name travels with the record when the Session is made. Anything else
|
|
413
|
+
// the Host refuses is a refusal, not a rename.
|
|
414
|
+
if (error?.code !== 'BOT_THREAD_UNKNOWN') throw error
|
|
415
|
+
written = null
|
|
416
|
+
}
|
|
417
|
+
if (written !== null && (typeof written.name !== 'string' || written.name !== next)) {
|
|
418
|
+
fail('THREAD_RENAME_UNCONFIRMED', '宿主没有确认这次重命名,线程名称保持不变。')
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
record.name = next
|
|
422
|
+
record.updatedAt = now()
|
|
423
|
+
save()
|
|
424
|
+
return publicThread(record)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Switching only moves the selection: no Session is created or touched, no
|
|
428
|
+
// model is called, no Task starts. An archived thread may be selected — it
|
|
429
|
+
// stays archived until the owner says otherwise — so a Bot always has a
|
|
430
|
+
// well-defined active thread.
|
|
431
|
+
const switchThread = (input = {}) => {
|
|
432
|
+
const { botId, threadId } = closed(input, ['botId', 'threadId'])
|
|
433
|
+
assertHealthy()
|
|
434
|
+
assertBot(botId)
|
|
435
|
+
const { entry } = resolveThread(botId, threadId)
|
|
436
|
+
entry.activeThreadId = threadId
|
|
437
|
+
save()
|
|
438
|
+
return { botId, activeThreadId: threadId }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Archive hides, it never deletes: the Session identity is kept so the thread
|
|
442
|
+
// can be restored with its context intact. The Host records the archive in
|
|
443
|
+
// the same breath, because dropping a thread's authority is the Host's fact
|
|
444
|
+
// to own; only after the Host confirms does this catalog follow.
|
|
445
|
+
const archive = async (input = {}) => {
|
|
446
|
+
const { botId, threadId } = closed(input, ['botId', 'threadId'])
|
|
447
|
+
assertHealthy()
|
|
448
|
+
assertBot(botId)
|
|
449
|
+
const { entry, record } = resolveThread(botId, threadId)
|
|
450
|
+
if (stateOf(record) === 'archived') return { thread: publicThread(record), activeThreadId: entry.activeThreadId, changed: false }
|
|
451
|
+
const at = now()
|
|
452
|
+
if (typeof setThreadArchived === 'function') await setThreadArchived({ botId, threadId, archivedAt: at })
|
|
453
|
+
record.archivedAt = at
|
|
454
|
+
record.updatedAt = at
|
|
455
|
+
if (entry.activeThreadId === threadId) {
|
|
456
|
+
const next = nextOpenThread(entry)
|
|
457
|
+
entry.activeThreadId = next ? next.threadId : null
|
|
458
|
+
}
|
|
459
|
+
save()
|
|
460
|
+
return { thread: publicThread(record), activeThreadId: entry.activeThreadId, changed: true }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const unarchive = async (input = {}) => {
|
|
464
|
+
const { botId, threadId } = closed(input, ['botId', 'threadId'])
|
|
465
|
+
assertHealthy()
|
|
466
|
+
assertBot(botId)
|
|
467
|
+
const { record } = resolveThread(botId, threadId)
|
|
468
|
+
if (stateOf(record) === 'open') return publicThread(record)
|
|
469
|
+
if (typeof setThreadArchived === 'function') await setThreadArchived({ botId, threadId, archivedAt: null })
|
|
470
|
+
record.archivedAt = null
|
|
471
|
+
record.updatedAt = now()
|
|
472
|
+
save()
|
|
473
|
+
return publicThread(record)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const resolve = (input = {}) => {
|
|
477
|
+
const { botId, threadId } = closed(input, ['botId', 'threadId'])
|
|
478
|
+
assertCatalog()
|
|
479
|
+
if (!idOk(botId)) fail('INVALID_BOT_ID', 'Bot 标识无效。')
|
|
480
|
+
return publicThread(resolveThread(botId, threadId).record)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const threadBySession = sessionId => {
|
|
484
|
+
assertCatalog()
|
|
485
|
+
if (!idOk(sessionId)) return null
|
|
486
|
+
const found = sessionHolder(sessionId)
|
|
487
|
+
return found ? { botId: found.botId, threadId: found.record.threadId } : null
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// The pre-policy gate. Everything a send needs is decided here, before any
|
|
491
|
+
// model or tool call: the Bot is committed and healthy, its grant still
|
|
492
|
+
// stands, the thread is open, and the Host itself confirms — right now, not
|
|
493
|
+
// from a cached flag — that this Session belongs to this Bot.
|
|
494
|
+
//
|
|
495
|
+
// It is written once, as a probe that answers with a result, because the
|
|
496
|
+
// shape a caller must branch on is the one that used to be unreachable: a
|
|
497
|
+
// caller that read a field after the throwing form had already refused never
|
|
498
|
+
// learned why. `assertSendable` throws the very same answer, so the two can
|
|
499
|
+
// never drift apart.
|
|
500
|
+
const probeSendable = (input = {}) => {
|
|
501
|
+
const { botId, threadId } = closed(input, ['botId', 'threadId'])
|
|
502
|
+
const refuse = (code, message, sessionState = null) => ({ ok: false, botId, threadId, code, message, sessionState })
|
|
503
|
+
try {
|
|
504
|
+
assertHealthy()
|
|
505
|
+
const bot = assertBot(botId)
|
|
506
|
+
assertServing(botId)
|
|
507
|
+
const { record } = resolveThread(botId, threadId)
|
|
508
|
+
if (stateOf(record) === 'archived') return refuse('THREAD_ARCHIVED', '该线程已归档,恢复后再继续对话。', record.sessionState)
|
|
509
|
+
const grant = assertGrant(botId)
|
|
510
|
+
// An operation whose outcome the Host never confirmed is unknown, whether or
|
|
511
|
+
// not a Session id was ever written down: it is answered before anything
|
|
512
|
+
// else about the Session, and it is never answered by making a new one.
|
|
513
|
+
if (record.sessionState === 'pending' || record.sessionState === 'unknown') {
|
|
514
|
+
return refuse('SESSION_UNKNOWN', '该线程的会话建立结果未知,请先由所有者核对恢复。', record.sessionState)
|
|
515
|
+
}
|
|
516
|
+
if (!record.sessionId) return refuse('SESSION_MISSING', '该线程尚未建立会话,请稍后重试。', record.sessionState)
|
|
517
|
+
if (record.sessionState === 'missing') return refuse('SESSION_MISSING', '该线程的宿主会话已不存在,需要重新建立。', record.sessionState)
|
|
518
|
+
if (!hostOwns(botId, record.sessionId)) {
|
|
519
|
+
// The Host withdrew or never confirmed this mapping: the catalog follows
|
|
520
|
+
// the Host at once, and the send is refused rather than allowed on a
|
|
521
|
+
// revoked identity.
|
|
522
|
+
record.sessionState = record.sessionId ? 'unbound' : 'none'
|
|
523
|
+
save()
|
|
524
|
+
return refuse('SESSION_NOT_OWNED', '宿主未确认该线程会话的归属,线程暂不可写入。', record.sessionState)
|
|
525
|
+
}
|
|
526
|
+
if (record.sessionState !== 'owned') { record.sessionState = 'owned'; save() }
|
|
527
|
+
return { ok: true, botId, threadId, sessionId: record.sessionId, sessionState: record.sessionState, bot, grantVerified: grant.verified }
|
|
528
|
+
} catch (error) {
|
|
529
|
+
return refuse(error?.code ?? 'THREAD_CHECK_FAILED', error?.message ?? String(error))
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const assertSendable = (input = {}) => {
|
|
534
|
+
const probe = probeSendable(input)
|
|
535
|
+
if (probe.ok) return probe
|
|
536
|
+
const error = new Error(probe.message)
|
|
537
|
+
error.code = probe.code
|
|
538
|
+
throw error
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Where a thread's Session comes from, and how an interrupted create is
|
|
542
|
+
// answered. The durable 'pending' marker is written before the factory runs,
|
|
543
|
+
// so a crash between "Session made" and "record saved" is visible on the next
|
|
544
|
+
// call: the Host's own committed record is consulted, an identity it confirms
|
|
545
|
+
// is adopted, and an identity it cannot confirm stays 'unknown' — never
|
|
546
|
+
// silently replayed, because a second Session would orphan the first.
|
|
547
|
+
const resolvePending = (botId, record) => {
|
|
548
|
+
const host = hostThread(botId, record.threadId)
|
|
549
|
+
if (host?.sessionId) {
|
|
550
|
+
const holder = sessionHolder(host.sessionId)
|
|
551
|
+
if (holder && holder.record.threadId !== record.threadId) fail('THREAD_SESSION_CONFLICT', '该会话已绑定到另一条线程。')
|
|
552
|
+
record.sessionId = host.sessionId
|
|
553
|
+
record.operationKey = null
|
|
554
|
+
record.sessionState = hostOwns(botId, host.sessionId) ? 'owned' : 'unbound'
|
|
555
|
+
save()
|
|
556
|
+
return record.sessionState
|
|
557
|
+
}
|
|
558
|
+
record.sessionState = 'unknown'
|
|
559
|
+
save()
|
|
560
|
+
return 'unknown'
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const runEnsure = async (botId, record, { recover }) => {
|
|
564
|
+
assertHealthy()
|
|
565
|
+
assertBot(botId)
|
|
566
|
+
assertServing(botId)
|
|
567
|
+
if (stateOf(record) === 'archived') fail('THREAD_ARCHIVED', '该线程已归档,恢复后再继续对话。')
|
|
568
|
+
assertGrant(botId)
|
|
569
|
+
if (typeof createSession !== 'function') fail('SESSION_FACTORY_REQUIRED', '宿主未提供会话工厂,线程暂无法建立会话。')
|
|
570
|
+
if (typeof ownsSession !== 'function') fail('SESSION_OWNERSHIP_UNVERIFIABLE', '宿主未提供会话归属核实,线程暂无法建立会话。')
|
|
571
|
+
if (record.sessionId && record.sessionState === 'owned' && hostOwns(botId, record.sessionId)) {
|
|
572
|
+
return { sessionId: record.sessionId, state: 'owned', created: false }
|
|
573
|
+
}
|
|
574
|
+
if (record.sessionId && record.sessionState === 'unbound') {
|
|
575
|
+
// A Session the Host has not confirmed is kept, never duplicated: making
|
|
576
|
+
// a second one would orphan the first and hide the missing hook.
|
|
577
|
+
if (hostOwns(botId, record.sessionId)) {
|
|
578
|
+
record.sessionState = 'owned'
|
|
579
|
+
save()
|
|
580
|
+
return { sessionId: record.sessionId, state: 'owned', created: false }
|
|
581
|
+
}
|
|
582
|
+
fail('SESSION_NOT_OWNED', '宿主未确认该线程会话的归属:请先在 Host 记录该线程会话后重试。')
|
|
583
|
+
}
|
|
584
|
+
if (record.sessionState === 'pending') {
|
|
585
|
+
// A previous run got as far as the factory and its outcome was never
|
|
586
|
+
// recorded. The Host's committed record decides, not a guess. The marker
|
|
587
|
+
// stands whether or not a Session id was ever written down.
|
|
588
|
+
const settled = resolvePending(botId, record)
|
|
589
|
+
if (settled === 'owned') return { sessionId: record.sessionId, state: 'owned', created: false }
|
|
590
|
+
if (settled === 'unbound') fail('SESSION_NOT_OWNED', '宿主未确认该线程会话的归属:请先在 Host 记录该线程会话后重试。')
|
|
591
|
+
if (recover !== true) fail('SESSION_UNKNOWN', '该线程上一次建立会话的结果未知:可能已创建但未能记录。请核对后由所有者确认恢复,不要自动重放。')
|
|
592
|
+
}
|
|
593
|
+
if (record.sessionState === 'unknown' && recover !== true) {
|
|
594
|
+
fail('SESSION_UNKNOWN', '该线程上一次建立会话的结果未知:可能已创建但未能记录。请核对后由所有者确认恢复,不要自动重放。')
|
|
595
|
+
}
|
|
596
|
+
if (record.sessionId && record.sessionState === 'missing') {
|
|
597
|
+
// The Host no longer has this Session, so a fresh one is the only answer.
|
|
598
|
+
record.sessionId = null
|
|
599
|
+
record.sessionState = 'none'
|
|
600
|
+
record.operationKey = null
|
|
601
|
+
save()
|
|
602
|
+
}
|
|
603
|
+
const bot = committedBot(botId)
|
|
604
|
+
// The durable intent comes first: whatever happens next, the next call
|
|
605
|
+
// finds this thread mid-operation and resolves it from the Host's record.
|
|
606
|
+
const operationKey = randomUUID()
|
|
607
|
+
record.operationKey = operationKey
|
|
608
|
+
record.sessionState = 'pending'
|
|
609
|
+
save()
|
|
610
|
+
let sessionId
|
|
611
|
+
try {
|
|
612
|
+
sessionId = await createSession({ botId, threadId: record.threadId, title: record.name, dir: typeof bot?.workDir === 'string' ? bot.workDir : undefined, operationKey })
|
|
613
|
+
} catch (error) {
|
|
614
|
+
// A trusted refusal that names no Session (read-only host, unknown Bot)
|
|
615
|
+
// clears the intent: nothing was made. Any other outcome is unknown.
|
|
616
|
+
if (error?.outcome === 'refused') {
|
|
617
|
+
record.sessionState = record.sessionId ? 'unbound' : 'none'
|
|
618
|
+
record.operationKey = null
|
|
619
|
+
save()
|
|
620
|
+
}
|
|
621
|
+
throw error
|
|
622
|
+
}
|
|
623
|
+
if (!idOk(sessionId)) fail('SESSION_INVALID', '宿主返回的会话标识无效。')
|
|
624
|
+
const holder = sessionHolder(sessionId)
|
|
625
|
+
if (holder) fail('THREAD_SESSION_CONFLICT', '该会话已绑定到另一条线程。')
|
|
626
|
+
record.sessionId = sessionId
|
|
627
|
+
record.operationKey = null
|
|
628
|
+
record.sessionState = 'none'
|
|
629
|
+
save()
|
|
630
|
+
if (!hostOwns(botId, sessionId)) {
|
|
631
|
+
record.sessionState = 'unbound'
|
|
632
|
+
save()
|
|
633
|
+
fail('SESSION_NOT_OWNED', '宿主未确认该线程会话的归属:请先在 Host 记录该线程会话后重试。')
|
|
634
|
+
}
|
|
635
|
+
record.sessionState = 'owned'
|
|
636
|
+
save()
|
|
637
|
+
return { sessionId, state: 'owned', created: true }
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const ensureSession = (input = {}) => {
|
|
641
|
+
const { botId, threadId, recover } = closed(input, ['botId', 'threadId', 'recover'])
|
|
642
|
+
if (closed_) fail('BOT_THREADS_CLOSED', '线程目录已关闭。')
|
|
643
|
+
if (!idOk(botId) || !idOk(threadId)) fail('INVALID_THREAD_ID', '线程标识无效。')
|
|
644
|
+
const { record } = resolveThread(botId, threadId)
|
|
645
|
+
const flight = `${botId}\u0000${threadId}`
|
|
646
|
+
const running = flights.get(flight)
|
|
647
|
+
// Single flight: the second caller waits for the first outcome instead of
|
|
648
|
+
// making a Session of its own.
|
|
649
|
+
if (running) return running
|
|
650
|
+
const work = runEnsure(botId, record, { recover: recover === true }).finally(() => flights.delete(flight))
|
|
651
|
+
flights.set(flight, work)
|
|
652
|
+
return work
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// The navigation contract. Opening a thread moves the selection and reports
|
|
656
|
+
// the real Session identity; it delivers nothing. With `create` the caller
|
|
657
|
+
// explicitly asks for the Session to exist (still no model call), which is
|
|
658
|
+
// the only path that makes one.
|
|
659
|
+
const openThread = (input = {}) => {
|
|
660
|
+
const { botId, threadId, create } = closed(input, ['botId', 'threadId', 'create'])
|
|
661
|
+
assertHealthy()
|
|
662
|
+
assertBot(botId)
|
|
663
|
+
assertServing(botId)
|
|
664
|
+
const { entry, record } = resolveThread(botId, threadId)
|
|
665
|
+
entry.activeThreadId = threadId
|
|
666
|
+
save()
|
|
667
|
+
if (create !== true) {
|
|
668
|
+
return { botId, threadId, activeThreadId: threadId, sessionId: record.sessionId, sessionState: record.sessionId ? record.sessionState : 'none', created: false, needsSession: record.sessionId === null }
|
|
669
|
+
}
|
|
670
|
+
return ensureSession({ botId, threadId }).then(result => ({
|
|
671
|
+
botId, threadId, activeThreadId: threadId, sessionId: result.sessionId, sessionState: 'owned', created: result.created, needsSession: false,
|
|
672
|
+
}))
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Re-read the Host's own view after a restart or a grant refresh. A Session
|
|
676
|
+
// the Host no longer has is reported, never dropped: archive hides threads,
|
|
677
|
+
// and only an explicit delete removes a real conversation. A create that was
|
|
678
|
+
// interrupted is settled here — adopted when the Host confirms it, otherwise
|
|
679
|
+
// left 'unknown' for an owner to review.
|
|
680
|
+
const reconcile = () => {
|
|
681
|
+
if (closed_) fail('BOT_THREADS_CLOSED', '线程目录已关闭。')
|
|
682
|
+
const result = { checked: 0, owned: 0, unbound: 0, missing: 0, unknown: 0, adopted: 0, pending: 0 }
|
|
683
|
+
for (const [botId, entry] of Object.entries(state.bots)) {
|
|
684
|
+
for (const record of entry.threads) {
|
|
685
|
+
const host = hostThread(botId, record.threadId)
|
|
686
|
+
if (host === null && record.sessionId === null && record.sessionState === 'none') continue
|
|
687
|
+
result.checked += 1
|
|
688
|
+
if (record.sessionState === 'pending') {
|
|
689
|
+
resolvePending(botId, record)
|
|
690
|
+
result.pending += 1
|
|
691
|
+
}
|
|
692
|
+
if (host !== null && host.sessionId && record.sessionId !== host.sessionId) {
|
|
693
|
+
record.sessionId = host.sessionId
|
|
694
|
+
result.adopted += 1
|
|
695
|
+
}
|
|
696
|
+
// The Host's archive is the authority: a thread it archived is archived.
|
|
697
|
+
if (host !== null && isArchivedAt(host.archivedAt) && !isArchivedAt(record.archivedAt)) {
|
|
698
|
+
record.archivedAt = host.archivedAt
|
|
699
|
+
if (entry.activeThreadId === record.threadId) entry.activeThreadId = nextOpenThread(entry)?.threadId ?? null
|
|
700
|
+
}
|
|
701
|
+
if (!record.sessionId) continue
|
|
702
|
+
if (hostOwns(botId, record.sessionId)) { record.sessionState = 'owned'; result.owned += 1; continue }
|
|
703
|
+
const exists = typeof inspectSession === 'function' ? inspectSession(record.sessionId) : null
|
|
704
|
+
record.sessionState = exists === false ? 'missing' : (record.sessionState === 'unknown' ? 'unknown' : 'unbound')
|
|
705
|
+
result[record.sessionState] += 1
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
if (result.checked > 0) save()
|
|
709
|
+
return result
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// The owner's explicit recovery for a thread whose create outcome is unknown.
|
|
713
|
+
// It adopts the identity the Host confirms, and only when the Host confirms
|
|
714
|
+
// nothing does it make a fresh Session — as a recorded, reviewable act.
|
|
715
|
+
const recover = (input = {}) => {
|
|
716
|
+
const { botId, threadId } = closed(input, ['botId', 'threadId'])
|
|
717
|
+
return ensureSession({ botId, threadId, recover: true })
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// The Bot-delete path calls this after the Host has retired the Bot. It
|
|
721
|
+
// refuses while the Bot is still committed, so a live Bot can never lose its
|
|
722
|
+
// threads to a stray call.
|
|
723
|
+
const purgeBot = botId => {
|
|
724
|
+
assertHealthy()
|
|
725
|
+
if (!idOk(botId)) fail('INVALID_BOT_ID', 'Bot 标识无效。')
|
|
726
|
+
if (committedBot(botId)) fail('BOT_STILL_COMMITTED', '该 Bot 仍在已提交状态,不能清除其线程。')
|
|
727
|
+
if (!state.bots[botId]) return { botId, removed: 0 }
|
|
728
|
+
const removed = state.bots[botId].threads.length
|
|
729
|
+
delete state.bots[botId]
|
|
730
|
+
save()
|
|
731
|
+
return { botId, removed }
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const readModel = (input = {}) => {
|
|
735
|
+
const { botId } = closed(input, ['botId'])
|
|
736
|
+
if (closed_) return { status: 'blocked', reason: '线程目录已关闭。', nextAction: '重启宿主后重试。', items: [], activeThreadId: null }
|
|
737
|
+
if (storageFailed) return { status: 'blocked', reason: '线程目录无法读取,暂不能确认会话归属。', nextAction: '修复存储后重启宿主;不会自动重放会话创建。', items: [], activeThreadId: null }
|
|
738
|
+
if (!idOk(botId)) return { status: 'blocked', reason: 'Bot 标识无效。', nextAction: '从 Bot 列表重新进入。', items: [], activeThreadId: null }
|
|
739
|
+
const status = typeof lifecycleStatus === 'function' ? lifecycleStatus() : null
|
|
740
|
+
if (status && (status.healthy === false || status.closed === true)) {
|
|
741
|
+
return { status: 'blocked', reason: '宿主生命周期未就绪,线程暂时不可用。', nextAction: '修复宿主状态后刷新;线程目录已保留。', items: [], activeThreadId: null }
|
|
742
|
+
}
|
|
743
|
+
const bot = committedBot(botId)
|
|
744
|
+
if (!bot) {
|
|
745
|
+
return { status: 'blocked', reason: '该 Bot 已不在已提交状态。', nextAction: '停用或删除 Bot 后,其线程不再接受写入。', items: [], activeThreadId: null }
|
|
746
|
+
}
|
|
747
|
+
const mode = typeof runtimeMode === 'function' ? runtimeMode(botId) : null
|
|
748
|
+
if (mode !== undefined && mode !== null && mode !== 'integration') {
|
|
749
|
+
return { status: 'blocked', reason: `该 Bot 当前由 ${mode} 运行时提供服务。`, nextAction: '线程会话只在集成运行时可用;移动回集成运行时后恢复。', items: [], activeThreadId: null }
|
|
750
|
+
}
|
|
751
|
+
const view = list(botId)
|
|
752
|
+
return {
|
|
753
|
+
status: 'available',
|
|
754
|
+
reason: '',
|
|
755
|
+
nextAction: '',
|
|
756
|
+
items: view.threads,
|
|
757
|
+
activeThreadId: view.activeThreadId,
|
|
758
|
+
sessionBinding: typeof ownsSession === 'function' ? 'host-confirmed' : 'pending-host-hook',
|
|
759
|
+
archiveAuthority: typeof setThreadArchived === 'function' ? 'host' : 'catalog',
|
|
760
|
+
renameAuthority: typeof renameThread === 'function' ? 'host' : 'catalog',
|
|
761
|
+
identityAuthority: typeof committedThreads === 'function' ? 'host' : 'catalog',
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const close = () => { closed_ = true; for (const flight of flights.values()) flight.catch(() => {}) }
|
|
766
|
+
|
|
767
|
+
return Object.freeze({
|
|
768
|
+
BOT_THREAD_SCHEMA,
|
|
769
|
+
THREAD_STATES,
|
|
770
|
+
SESSION_STATES,
|
|
771
|
+
list,
|
|
772
|
+
create,
|
|
773
|
+
rename,
|
|
774
|
+
switchThread,
|
|
775
|
+
archive,
|
|
776
|
+
unarchive,
|
|
777
|
+
resolve,
|
|
778
|
+
openThread,
|
|
779
|
+
assertSendable,
|
|
780
|
+
probeSendable,
|
|
781
|
+
ensureSession,
|
|
782
|
+
recover,
|
|
783
|
+
reconcile,
|
|
784
|
+
purgeBot,
|
|
785
|
+
readModel,
|
|
786
|
+
threadBySession,
|
|
787
|
+
revision: () => state.revision,
|
|
788
|
+
close,
|
|
789
|
+
})
|
|
790
|
+
}
|