discord: idiomatic Effect refactor with conversation service, durable ledger, and split sandbox architecture
Refactor the Discord bot to idiomatic Effect TypeScript: - Branded types (ThreadId, ChannelId, etc.) and Schema.Class for all data - Split SandboxManager into SandboxProvisioner (stateless lifecycle) + ThreadAgentPool (per-thread orchestration) - Pure Conversation service with port interfaces (Inbox/Outbox/History/Threads) - ConversationLedger for message dedup, at-least-once delivery, and replay on restart - Per-thread serialized execution via ActorMap with idle timeouts - Discord slash commands (/status, /reset) and in-thread commands (!status, !reset) - Catch-up on missed messages at startup via offset tracking - Typed errors (Schema.TaggedError) with retriable/non-retriable classification - Local CLI (conversation:cli) and automation CLI (conversation:ctl) - Test coverage for conversation service, ledger, session store, and actors
This commit is contained in:
parent
ef92226c33
commit
3c22e16386
58 changed files with 8023 additions and 2363 deletions
354
bun.lock
354
bun.lock
|
|
@ -213,6 +213,29 @@
|
|||
"vite": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/discord": {
|
||||
"name": "@opencode/discord",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@daytonaio/sdk": "latest",
|
||||
"@effect/ai": "^0.33.2",
|
||||
"@effect/ai-anthropic": "^0.23.0",
|
||||
"@effect/experimental": "^0.58.0",
|
||||
"@effect/platform": "latest",
|
||||
"@effect/platform-bun": "0.87.1",
|
||||
"@effect/sql-sqlite-bun": "^0.50.0",
|
||||
"@opencode-ai/sdk": "latest",
|
||||
"discord.js": "^14",
|
||||
"effect": "^3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/language-service": "0.73.1",
|
||||
"@effect/sql": "^0.49.0",
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "^22",
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.61",
|
||||
|
|
@ -496,6 +519,7 @@
|
|||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"protobufjs",
|
||||
"esbuild",
|
||||
"web-tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
|
|
@ -617,6 +641,8 @@
|
|||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="],
|
||||
|
||||
"@anthropic-ai/tokenizer": ["@anthropic-ai/tokenizer@0.0.4", "", { "dependencies": { "@types/node": "^18.11.18", "tiktoken": "^1.0.10" } }, "sha512-EHRKbxlxlc8W4KCBEseByJ7YwyYCmgu9OyN59H9+IYIGPoKv8tXyQXinkeGDI+cI8Tiuz9wk2jZb/kK7AyvL7g=="],
|
||||
|
||||
"@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="],
|
||||
|
||||
"@astrojs/check": ["@astrojs/check@0.9.6", "", { "dependencies": { "@astrojs/language-server": "^2.16.1", "chokidar": "^4.0.1", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-jlaEu5SxvSgmfGIFfNgcn5/f+29H61NJzEMfAZ82Xopr4XBchXB1GVlcJsE+elUlsYSbXlptZLX+JMG3b/wZEA=="],
|
||||
|
|
@ -683,6 +709,8 @@
|
|||
|
||||
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.933.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/nested-clients": "3.933.0", "@aws-sdk/types": "3.930.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-c7Eccw2lhFx2/+qJn3g+uIDWRuWi2A6Sz3PVvckFUEzPsP0dPUo19hlvtarwP5GzrsXn0yEPRVhpewsIaSCGaQ=="],
|
||||
|
||||
"@aws-sdk/lib-storage": ["@aws-sdk/lib-storage@3.990.0", "", { "dependencies": { "@smithy/abort-controller": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.14", "@smithy/smithy-client": "^4.11.3", "buffer": "5.6.0", "events": "3.3.0", "stream-browserify": "3.0.0", "tslib": "^2.6.2" }, "peerDependencies": { "@aws-sdk/client-s3": "^3.990.0" } }, "sha512-SHog6kMWXwLBTeVwFAd+EHwr1874Ei5ob1DTL5mLmJDwbmkFog2VDKN+9BmI4di0yxY057Ps2vhhWLhKy89wuA=="],
|
||||
|
||||
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.930.0", "", { "dependencies": { "@aws-sdk/types": "3.930.0", "@aws-sdk/util-arn-parser": "3.893.0", "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-config-provider": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-cnCLWeKPYgvV4yRYPFH6pWMdUByvu2cy2BAlfsPpvnm4RaVioztyvxmQj5PmVN5fvWs5w/2d6U7le8X9iye2sA=="],
|
||||
|
||||
"@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.930.0", "", { "dependencies": { "@aws-sdk/types": "3.930.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-5HEQ+JU4DrLNWeY27wKg/jeVa8Suy62ivJHOSUf6e6hZdVIMx0h/kXS1fHEQNNiLu2IzSEP/bFXsKBaW7x7s0g=="],
|
||||
|
|
@ -851,12 +879,54 @@
|
|||
|
||||
"@ctrl/tinycolor": ["@ctrl/tinycolor@4.2.0", "", {}, "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A=="],
|
||||
|
||||
"@daytonaio/api-client": ["@daytonaio/api-client@0.143.0", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-LcrvExGcwyngt1JnVoxuBMD19fL9F+vwGnp18Hav5HJEbICM0pbrJc2FJ242sQJquNIF5mq+5jgznWg6wshZJA=="],
|
||||
|
||||
"@daytonaio/sdk": ["@daytonaio/sdk@0.143.0", "", { "dependencies": { "@aws-sdk/client-s3": "^3.787.0", "@aws-sdk/lib-storage": "^3.798.0", "@daytonaio/api-client": "0.143.0", "@daytonaio/toolbox-api-client": "0.143.0", "@iarna/toml": "^2.2.5", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-trace-otlp-http": "^0.207.0", "@opentelemetry/instrumentation-http": "^0.207.0", "@opentelemetry/otlp-exporter-base": "0.207.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-node": "^0.207.0", "@opentelemetry/sdk-trace-base": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", "axios": "^1.13.5", "busboy": "^1.0.0", "dotenv": "^17.0.1", "expand-tilde": "^2.0.2", "fast-glob": "^3.3.0", "form-data": "^4.0.4", "isomorphic-ws": "^5.0.0", "pathe": "^2.0.3", "shell-quote": "^1.8.2", "tar": "^7.5.7" } }, "sha512-itQ409W+WuuqLd8edIRVUWeRUyujSlOKInnN6GeC3OqyOWT27wyD/KzkesXDJtQ6HthGtqa4uRtHqnayd5wRCQ=="],
|
||||
|
||||
"@daytonaio/toolbox-api-client": ["@daytonaio/toolbox-api-client@0.143.0", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-E6+yHPnFygNqRIctoDheISHCpzQkHydAU7fiBfsA5pnElfB/yLTOXQlnZfP8gfvOmUkRNU8QCXKzaualRTlI7w=="],
|
||||
|
||||
"@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="],
|
||||
|
||||
"@discordjs/builders": ["@discordjs/builders@1.13.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.33", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w=="],
|
||||
|
||||
"@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
|
||||
|
||||
"@discordjs/formatters": ["@discordjs/formatters@0.6.2", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="],
|
||||
|
||||
"@discordjs/rest": ["@discordjs/rest@2.6.0", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.16", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w=="],
|
||||
|
||||
"@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="],
|
||||
|
||||
"@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="],
|
||||
|
||||
"@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="],
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||
|
||||
"@effect/ai": ["@effect/ai@0.33.2", "", { "dependencies": { "find-my-way-ts": "^0.1.6" }, "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.1", "@effect/rpc": "^0.73.0", "effect": "^3.19.14" } }, "sha512-iJ6pz9qiKFXhcnriJJSBQ5+Bz3oEg+6hVQ7OPmmTa0dVkKUPIgX9nHqHfstcwLnDfdXBj/zCl79U+iMpGtQBnA=="],
|
||||
|
||||
"@effect/ai-anthropic": ["@effect/ai-anthropic@0.23.0", "", { "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4" }, "peerDependencies": { "@effect/ai": "^0.33.0", "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.0", "effect": "^3.19.13" } }, "sha512-ftkyQpTY4OCPTe9hlB53WCXseWm11lOf+zXDNCJG2nAqW5mZ+4kd/PPiqV7jd2zDukAtLc0I+R3EGs1fOLCG5A=="],
|
||||
|
||||
"@effect/cluster": ["@effect/cluster@0.56.3", "", { "dependencies": { "kubernetes-types": "^1.30.0" }, "peerDependencies": { "@effect/platform": "^0.94.4", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "@effect/workflow": "^0.16.0", "effect": "^3.19.16" } }, "sha512-SimiwC4Gzy71wnWOkAMdwxdo9CQhePGXrEz5OC5LQdIvoSzTCu9z82Rj8X5Vo1m1wlz6oNc3WmQLnwbbt/Eukg=="],
|
||||
|
||||
"@effect/experimental": ["@effect/experimental@0.58.0", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/platform": "^0.94.0", "effect": "^3.19.13", "ioredis": "^5", "lmdb": "^3" }, "optionalPeers": ["ioredis", "lmdb"] }, "sha512-IEP9sapjF6rFy5TkoqDPc86st/fnqUfjT7Xa3pWJrFGr1hzaMXHo+mWsYOZS9LAOVKnpHuVziDK97EP5qsCHVA=="],
|
||||
|
||||
"@effect/language-service": ["@effect/language-service@0.73.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-FbOKzXmP1QM6/YMvDFZGTZ0Gk0AjKqRSY48dpAN0zUdVzq5xV/Us/6vZ5FcdmG6GjgSWP2rpESy59OMvfPeylw=="],
|
||||
|
||||
"@effect/platform": ["@effect/platform@0.94.4", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.19.16" } }, "sha512-mK8pbskFAcBRA5Ooyt02kCBuWltZakyaDcM4ByTY0jXQMFC3NUveNK62JVH7XB+f3XZ8OoBBxUnlLben4plEKQ=="],
|
||||
|
||||
"@effect/platform-bun": ["@effect/platform-bun@0.87.1", "", { "dependencies": { "@effect/platform-node-shared": "^0.57.1", "multipasta": "^0.2.7" }, "peerDependencies": { "@effect/cluster": "^0.56.1", "@effect/platform": "^0.94.2", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "effect": "^3.19.15" } }, "sha512-I88d0YqWbvLY2GGeIxK3r5k0l/MoUCCnxiHJG+X6gqaHu+pIs0djDtJ+ORhw/3qha9ojcVu6pyaBmnUjgzQHWQ=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@0.57.1", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "multipasta": "^0.2.7", "ws": "^8.18.2" }, "peerDependencies": { "@effect/cluster": "^0.56.1", "@effect/platform": "^0.94.2", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "effect": "^3.19.15" } }, "sha512-oX/bApMdoKsyrDiNdJxo7U9Rz1RXsjRv+ecfAPp1qGlSdGIo32wVRvJ2XCHqYj0sqaYJS0pU0/GCulRfVGuJag=="],
|
||||
|
||||
"@effect/rpc": ["@effect/rpc@0.73.0", "", { "dependencies": { "msgpackr": "^1.11.4" }, "peerDependencies": { "@effect/platform": "^0.94.0", "effect": "^3.19.13" } }, "sha512-iMPf6tTriz8sK0l5x4koFId8Hz5nFptHYg8WqyjHGIIVLTpZxuiSqhmXZG7FnAs5N2n6uCEws4wWGcIgXNUrFg=="],
|
||||
|
||||
"@effect/sql": ["@effect/sql@0.49.0", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.0", "effect": "^3.19.13" } }, "sha512-9UEKR+z+MrI/qMAmSvb/RiD9KlgIazjZUCDSpwNgm0lEK9/Q6ExEyfziiYFVCPiptp52cBw8uBHRic8hHnwqXA=="],
|
||||
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@0.50.2", "", { "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.4", "@effect/sql": "^0.49.0", "effect": "^3.19.16" } }, "sha512-bd+rwFMjZ53OZhOnzQ0Zdef9IK2lgd0xP3M/553Y+aZjqOctMjhZmd3PVi8JjR+pRgSzN5XBu9Gly97t0aPsug=="],
|
||||
|
||||
"@effect/workflow": ["@effect/workflow@0.16.0", "", { "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.0", "@effect/rpc": "^0.73.0", "effect": "^3.19.13" } }, "sha512-MiAdlxx3TixkgHdbw+Yf1Z3tHAAE0rOQga12kIydJqj05Fnod+W/I+kQGRMY/XWRg+QUsVxhmh1qTr7Ype6lrw=="],
|
||||
|
||||
"@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="],
|
||||
|
||||
"@emmetio/css-abbreviation": ["@emmetio/css-abbreviation@2.1.8", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw=="],
|
||||
|
|
@ -979,6 +1049,10 @@
|
|||
|
||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||
|
||||
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="],
|
||||
|
||||
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
|
||||
|
||||
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
|
||||
|
||||
"@hey-api/codegen-core": ["@hey-api/codegen-core@0.5.5", "", { "dependencies": { "@hey-api/types": "0.1.2", "ansi-colors": "4.1.3", "c12": "3.3.3", "color-support": "1.1.3" }, "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-f2ZHucnA2wBGAY8ipB4wn/mrEYW+WUxU2huJmUvfDO6AE2vfILSHeF3wCO39Pz4wUYPoAWZByaauftLrOfC12Q=="],
|
||||
|
|
@ -995,6 +1069,8 @@
|
|||
|
||||
"@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="],
|
||||
|
||||
"@iarna/toml": ["@iarna/toml@2.2.5", "", {}, "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg=="],
|
||||
|
||||
"@ibm/plex": ["@ibm/plex@6.4.1", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.1" } }, "sha512-fnsipQywHt3zWvsnlyYKMikcVI7E2fEwpiPnIHFqlbByXVfQfANAAeJk1IV4mNnxhppUIDlhU0TzwYwL++Rn2g=="],
|
||||
|
||||
"@ibm/telemetry-js": ["@ibm/telemetry-js@1.11.0", "", { "bin": { "ibmtelemetry": "dist/collect.js" } }, "sha512-RO/9j+URJnSfseWg9ZkEX9p+a3Ousd33DBU7rOafoZB08RqdzxFVYJ2/iM50dkBuD0o7WX7GYt1sLbNgCoE+pA=="],
|
||||
|
|
@ -1117,6 +1193,8 @@
|
|||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
|
||||
|
||||
"@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="],
|
||||
|
||||
"@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="],
|
||||
|
|
@ -1189,6 +1267,18 @@
|
|||
|
||||
"@motionone/utils": ["@motionone/utils@10.18.0", "", { "dependencies": { "@motionone/types": "^10.17.1", "hey-listen": "^1.0.8", "tslib": "^2.3.1" } }, "sha512-3XVF7sgyTSI2KWvTf6uLlBJ5iAgRgmvp3bpuOiQJvInd4nZ19ET8lX5unn30SlmRH7hXbBbH+Gxd0m0klJ3Xtw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
|
@ -1273,12 +1363,70 @@
|
|||
|
||||
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
|
||||
|
||||
"@opencode/discord": ["@opencode/discord@workspace:packages/discord"],
|
||||
|
||||
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.4", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" } }, "sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw=="],
|
||||
|
||||
"@openrouter/sdk": ["@openrouter/sdk@0.1.27", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.207.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ=="],
|
||||
|
||||
"@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.2.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ=="],
|
||||
|
||||
"@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="],
|
||||
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": ["@opentelemetry/exporter-logs-otlp-grpc@0.207.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.207.0", "@opentelemetry/otlp-grpc-exporter-base": "0.207.0", "@opentelemetry/otlp-transformer": "0.207.0", "@opentelemetry/sdk-logs": "0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-K92RN+kQGTMzFDsCzsYNGqOsXRUnko/Ckk+t/yPJao72MewOLgBUTWVHhebgkNfRCYqDz1v3K0aPT9OJkemvgg=="],
|
||||
|
||||
"@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.207.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.207.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.207.0", "@opentelemetry/otlp-transformer": "0.207.0", "@opentelemetry/sdk-logs": "0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-JpOh7MguEUls8eRfkVVW3yRhClo5b9LqwWTOg8+i4gjr/+8eiCtquJnC7whvpTIGyff06cLZ2NsEj+CVP3Mjeg=="],
|
||||
|
||||
"@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.207.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.207.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.207.0", "@opentelemetry/otlp-transformer": "0.207.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-logs": "0.207.0", "@opentelemetry/sdk-trace-base": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-RQJEV/K6KPbQrIUbsrRkEe0ufks1o5OGLHy6jbDD8tRjeCsbFHWfg99lYBRqBV33PYZJXsigqMaAbjWGTFYzLw=="],
|
||||
|
||||
"@opentelemetry/exporter-metrics-otlp-grpc": ["@opentelemetry/exporter-metrics-otlp-grpc@0.207.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.2.0", "@opentelemetry/exporter-metrics-otlp-http": "0.207.0", "@opentelemetry/otlp-exporter-base": "0.207.0", "@opentelemetry/otlp-grpc-exporter-base": "0.207.0", "@opentelemetry/otlp-transformer": "0.207.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-metrics": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-6flX89W54gkwmqYShdcTBR1AEF5C1Ob0O8pDgmLPikTKyEv27lByr9yBmO5WrP0+5qJuNPHrLfgFQFYi6npDGA=="],
|
||||
|
||||
"@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.207.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.207.0", "@opentelemetry/otlp-transformer": "0.207.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-metrics": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-fG8FAJmvXOrKXGIRN8+y41U41IfVXxPRVwyB05LoMqYSjugx/FSBkMZUZXUT/wclTdmBKtS5MKoi0bEKkmRhSw=="],
|
||||
|
||||
"@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.207.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/exporter-metrics-otlp-http": "0.207.0", "@opentelemetry/otlp-exporter-base": "0.207.0", "@opentelemetry/otlp-transformer": "0.207.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-metrics": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-kDBxiTeQjaRlUQzS1COT9ic+et174toZH6jxaVuVAvGqmxOkgjpLOjrI5ff8SMMQE69r03L3Ll3nPKekLopLwg=="],
|
||||
|
||||
"@opentelemetry/exporter-prometheus": ["@opentelemetry/exporter-prometheus@0.207.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-metrics": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Y5p1s39FvIRmU+F1++j7ly8/KSqhMmn6cMfpQqiDCqDjdDHwUtSq0XI0WwL3HYGnZeaR/VV4BNmsYQJ7GAPrhw=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": ["@opentelemetry/exporter-trace-otlp-grpc@0.207.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.207.0", "@opentelemetry/otlp-grpc-exporter-base": "0.207.0", "@opentelemetry/otlp-transformer": "0.207.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-trace-base": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-7u2ZmcIx6D4KG/+5np4X2qA0o+O0K8cnUDhR4WI/vr5ZZ0la9J9RG+tkSjC7Yz+2XgL6760gSIM7/nyd3yaBLA=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.207.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.207.0", "@opentelemetry/otlp-transformer": "0.207.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-trace-base": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-HSRBzXHIC7C8UfPQdu15zEEoBGv0yWkhEwxqgPCHVUKUQ9NLHVGXkVrf65Uaj7UwmAkC1gQfkuVYvLlD//AnUQ=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.207.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.207.0", "@opentelemetry/otlp-transformer": "0.207.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-trace-base": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ruUQB4FkWtxHjNmSXjrhmJZFvyMm+tBzHyMm7YPQshApy4wvZUTcrpPyP/A/rCl/8M4BwoVIZdiwijMdbZaq4w=="],
|
||||
|
||||
"@opentelemetry/exporter-zipkin": ["@opentelemetry/exporter-zipkin@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-trace-base": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-VV4QzhGCT7cWrGasBWxelBjqbNBbyHicWWS/66KoZoe9BzYwFB72SH2/kkc4uAviQlO8iwv2okIJy+/jqqEHTg=="],
|
||||
|
||||
"@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.207.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.207.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA=="],
|
||||
|
||||
"@opentelemetry/instrumentation-http": ["@opentelemetry/instrumentation-http@0.207.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/instrumentation": "0.207.0", "@opentelemetry/semantic-conventions": "^1.29.0", "forwarded-parse": "2.1.2" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FC4i5hVixTzuhg4SV2ycTEAYx+0E2hm+GwbdoVPSA6kna0pPVI4etzaA9UkpJ9ussumQheFXP6rkGIaFJjMxsw=="],
|
||||
|
||||
"@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.207.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-transformer": "0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-4RQluMVVGMrHok/3SVeSJ6EnRNkA2MINcX88sh+d/7DjGUrewW/WT88IsMEci0wUM+5ykTpPPNbEOoW+jwHnbw=="],
|
||||
|
||||
"@opentelemetry/otlp-grpc-exporter-base": ["@opentelemetry/otlp-grpc-exporter-base@0.207.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.207.0", "@opentelemetry/otlp-transformer": "0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-eKFjKNdsPed4q9yYqeI5gBTLjXxDM/8jwhiC0icw3zKxHVGBySoDsed5J5q/PGY/3quzenTr3FiTxA3NiNT+nw=="],
|
||||
|
||||
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.207.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.207.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-logs": "0.207.0", "@opentelemetry/sdk-metrics": "2.2.0", "@opentelemetry/sdk-trace-base": "2.2.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+6DRZLqM02uTIY5GASMZWUwr52sLfNiEe20+OEaZKhztCs3+2LxoTjb6JxFRd9q1qNqckXKYlUKjbH/AhG8/ZA=="],
|
||||
|
||||
"@opentelemetry/propagator-b3": ["@opentelemetry/propagator-b3@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-9CrbTLFi5Ee4uepxg2qlpQIozoJuoAZU5sKMx0Mn7Oh+p7UrgCiEV6C02FOxxdYVRRFQVCinYR8Kf6eMSQsIsw=="],
|
||||
|
||||
"@opentelemetry/propagator-jaeger": ["@opentelemetry/propagator-jaeger@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FfeOHOrdhiNzecoB1jZKp2fybqmqMPJUXe2ZOydP7QzmTPYcfPeuaclTLYVhK3HyJf71kt8sTl92nV4YIaLaKA=="],
|
||||
|
||||
"@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="],
|
||||
|
||||
"@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.207.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.207.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-4MEQmn04y+WFe6cyzdrXf58hZxilvY59lzZj2AccuHW/+BxLn/rGVN/Irsi/F0qfBOpMOrrCLKTExoSL2zoQmg=="],
|
||||
|
||||
"@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw=="],
|
||||
|
||||
"@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.207.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.207.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/exporter-logs-otlp-grpc": "0.207.0", "@opentelemetry/exporter-logs-otlp-http": "0.207.0", "@opentelemetry/exporter-logs-otlp-proto": "0.207.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.207.0", "@opentelemetry/exporter-metrics-otlp-http": "0.207.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.207.0", "@opentelemetry/exporter-prometheus": "0.207.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.207.0", "@opentelemetry/exporter-trace-otlp-http": "0.207.0", "@opentelemetry/exporter-trace-otlp-proto": "0.207.0", "@opentelemetry/exporter-zipkin": "2.2.0", "@opentelemetry/instrumentation": "0.207.0", "@opentelemetry/propagator-b3": "2.2.0", "@opentelemetry/propagator-jaeger": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-logs": "0.207.0", "@opentelemetry/sdk-metrics": "2.2.0", "@opentelemetry/sdk-trace-base": "2.2.0", "@opentelemetry/sdk-trace-node": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-hnRsX/M8uj0WaXOBvFenQ8XsE8FLVh2uSnn1rkWu4mx+qu7EKGUZvZng6y/95cyzsqOfiaDDr08Ek4jppkIDNg=="],
|
||||
|
||||
"@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.5.1", "", { "dependencies": { "@opentelemetry/core": "2.5.1", "@opentelemetry/resources": "2.5.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw=="],
|
||||
|
||||
"@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.2.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.2.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/sdk-trace-base": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-+OaRja3f0IqGG2kptVeYsrZQK9nKRSpfFrKtRBq4uh6nIB8bTBgaGvYQrQoRrQWQMA5dK5yLhDMDc0dvYvCOIQ=="],
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.39.0", "", {}, "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.1.79", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.79", "@opentui/core-darwin-x64": "0.1.79", "@opentui/core-linux-arm64": "0.1.79", "@opentui/core-linux-x64": "0.1.79", "@opentui/core-win32-arm64": "0.1.79", "@opentui/core-win32-x64": "0.1.79", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-job/t09w8A/aHb/WuaVbimu5fIffyN+PCuVO5cYhXEg/NkOkC/WdFi80B8bwncR/DBPyLAh6oJ3EG86grOVo5g=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.79", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kgsGniV+DM5G1P3GideyJhvfnthNKcVCAm2mPTIr9InQ3L0gS/Feh7zgwOS/jxDvdlQbOWGKMk2Z3JApeC1MLw=="],
|
||||
|
|
@ -1433,6 +1581,26 @@
|
|||
|
||||
"@protobuf-ts/runtime-rpc": ["@protobuf-ts/runtime-rpc@2.11.1", "", { "dependencies": { "@protobuf-ts/runtime": "^2.11.1" } }, "sha512-4CqqUmNA+/uMz00+d3CYKgElXO9VrEbucjnBFEjqI4GuDrEQ32MaI3q+9qPBvIGOlL4PmHXrzM32vBPWRhQKWQ=="],
|
||||
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
|
||||
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||
|
||||
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
|
||||
|
||||
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
|
||||
|
||||
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
|
||||
|
||||
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
||||
|
||||
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
|
||||
|
||||
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
||||
|
||||
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
||||
|
||||
"@radix-ui/colors": ["@radix-ui/colors@1.0.1", "", {}, "sha512-xySw8f0ZVsAEP+e7iLl3EvcBXX7gsIlC1Zso/sPBW9gIWerBTgz6axrjU+MZ39wD+WFi5h5zdWpsg3+hwt2Qsg=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.13.10" } }, "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw=="],
|
||||
|
|
@ -1551,6 +1719,12 @@
|
|||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ=="],
|
||||
|
||||
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
|
||||
|
||||
"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
|
||||
|
||||
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="],
|
||||
|
||||
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
|
||||
|
||||
"@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
|
||||
|
|
@ -1983,6 +2157,8 @@
|
|||
|
||||
"@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="],
|
||||
|
||||
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
|
||||
|
||||
"@volar/kit": ["@volar/kit@2.4.28", "", { "dependencies": { "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "typesafe-path": "^0.2.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "typescript": "*" } }, "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg=="],
|
||||
|
||||
"@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
|
||||
|
|
@ -2013,6 +2189,8 @@
|
|||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="],
|
||||
|
|
@ -2195,6 +2373,8 @@
|
|||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"c12": ["c12@3.3.3", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^17.2.3", "exsolve": "^1.0.8", "giget": "^2.0.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.0.0", "pkg-types": "^2.3.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q=="],
|
||||
|
|
@ -2243,6 +2423,8 @@
|
|||
|
||||
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
|
||||
|
||||
"cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="],
|
||||
|
||||
"classnames": ["classnames@2.3.2", "", {}, "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="],
|
||||
|
||||
"clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="],
|
||||
|
|
@ -2393,6 +2575,10 @@
|
|||
|
||||
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
|
||||
|
||||
"discord-api-types": ["discord-api-types@0.38.39", "", {}, "sha512-XRdDQvZvID1XvcFftjSmd4dcmMi/RL/jSy5sduBDAvCGFcNFHThdIQXCEBDZFe52lCNEzuIL0QJoKYAmRmxLUA=="],
|
||||
|
||||
"discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="],
|
||||
|
||||
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||
|
||||
"dns-packet": ["dns-packet@5.6.1", "", { "dependencies": { "@leichtgewicht/ip-codec": "^2.0.1" } }, "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw=="],
|
||||
|
|
@ -2429,6 +2615,8 @@
|
|||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@3.19.16", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-7+XC3vGrbAhCHd8LTFHvnZjRpZKZ8YHRZqJTkpNoxcJ2mCyNs2SwI+6VkV/ij8Y3YW7wfBN4EbU06/F5+m/wkQ=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.282", "", {}, "sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ=="],
|
||||
|
||||
"emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="],
|
||||
|
|
@ -2523,6 +2711,8 @@
|
|||
|
||||
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
|
||||
|
||||
"expand-tilde": ["expand-tilde@2.0.2", "", { "dependencies": { "homedir-polyfill": "^1.0.1" } }, "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw=="],
|
||||
|
||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||
|
||||
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
|
||||
|
|
@ -2537,6 +2727,8 @@
|
|||
|
||||
"extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
|
||||
|
||||
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
|
||||
|
||||
"fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="],
|
||||
|
||||
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
||||
|
|
@ -2575,6 +2767,8 @@
|
|||
|
||||
"find-my-way": ["find-my-way@9.4.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w=="],
|
||||
|
||||
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
|
||||
|
||||
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
|
||||
"finity": ["finity@0.5.4", "", {}, "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA=="],
|
||||
|
|
@ -2601,6 +2795,8 @@
|
|||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="],
|
||||
|
||||
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||
|
||||
"framer-motion": ["framer-motion@8.5.5", "", { "dependencies": { "@motionone/dom": "^10.15.3", "hey-listen": "^1.0.8", "tslib": "^2.4.0" }, "optionalDependencies": { "@emotion/is-prop-valid": "^0.8.2" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-5IDx5bxkjWHWUF3CVJoSyUVOtrbAxtzYBBowRE2uYI/6VYhkEBD+rbTHEGuUmbGHRj6YqqSfoG7Aa1cLyWCrBA=="],
|
||||
|
|
@ -2749,6 +2945,8 @@
|
|||
|
||||
"hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
|
||||
|
||||
"homedir-polyfill": ["homedir-polyfill@1.0.3", "", { "dependencies": { "parse-passwd": "^1.0.0" } }, "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA=="],
|
||||
|
||||
"hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="],
|
||||
|
||||
"hono-openapi": ["hono-openapi@1.1.2", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-toUcO60MftRBxqcVyxsHNYs2m4vf4xkQaiARAucQx3TiBPDtMNNkoh+C4I1vAretQZiGyaLOZNWn1YxfSyUA5g=="],
|
||||
|
|
@ -2793,6 +2991,8 @@
|
|||
|
||||
"image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="],
|
||||
|
||||
"import-in-the-middle": ["import-in-the-middle@2.0.6", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw=="],
|
||||
|
||||
"import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="],
|
||||
|
||||
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
|
||||
|
|
@ -2971,6 +3171,8 @@
|
|||
|
||||
"klona": ["klona@2.0.6", "", {}, "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="],
|
||||
|
||||
"kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
|
||||
|
||||
"lang-map": ["lang-map@0.4.0", "", { "dependencies": { "language-map": "^1.1.0" } }, "sha512-oiSqZIEUnWdFeDNsp4HId4tAxdFbx5iMBOwA3666Fn2L8Khj8NiD9xRvMsGmKXopPVkaDFtSv3CJOmXFUB0Hcg=="],
|
||||
|
||||
"language-map": ["language-map@1.5.0", "", {}, "sha512-n7gFZpe+DwEAX9cXVTw43i3wiudWDDtSn28RmdnS/HCPr284dQI/SztsamWanRr75oSlKSaGbV2nmWCTzGCoVg=="],
|
||||
|
|
@ -3011,6 +3213,8 @@
|
|||
|
||||
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
|
||||
|
||||
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
|
||||
|
||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||
|
||||
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
|
||||
|
|
@ -3025,6 +3229,8 @@
|
|||
|
||||
"lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="],
|
||||
|
||||
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
|
||||
|
||||
"loglevelnext": ["loglevelnext@6.0.0", "", {}, "sha512-FDl1AI2sJGjHHG3XKJd6sG3/6ncgiGCQ0YkW46nxe7SfqQq6hujd9CvFXIXtkGBUN83KPZ2KSOJK8q5P0bSSRQ=="],
|
||||
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
|
@ -3043,6 +3249,8 @@
|
|||
|
||||
"luxon": ["luxon@3.6.1", "", {}, "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ=="],
|
||||
|
||||
"magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="],
|
||||
|
|
@ -3187,9 +3395,9 @@
|
|||
|
||||
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
|
||||
|
||||
|
|
@ -3205,16 +3413,24 @@
|
|||
|
||||
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
||||
|
||||
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
|
||||
|
||||
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
|
||||
|
||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"msgpackr": ["msgpackr@1.11.8", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA=="],
|
||||
|
||||
"msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="],
|
||||
|
||||
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
|
||||
"multicast-dns": ["multicast-dns@7.2.5", "", { "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" }, "bin": { "multicast-dns": "cli.js" } }, "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg=="],
|
||||
|
||||
"multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="],
|
||||
|
||||
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
|
||||
|
||||
"mysql2": ["mysql2@3.14.4", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-Cs/jx3WZPNrYHVz+Iunp9ziahaG5uFMvD2R8Zlmc194AqXNxt9HBNu7ZsPYrUtmJsF0egETCWIdMIYAwOGjL1w=="],
|
||||
|
|
@ -3249,6 +3465,8 @@
|
|||
|
||||
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
|
||||
|
||||
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
|
||||
|
||||
"node-html-parser": ["node-html-parser@7.0.2", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-DxodLVh7a6JMkYzWyc8nBX9MaF4M0lLFYkJHlWOiu7+9/I6mwNK9u5TbAMC7qfqDJEPX9OIoWA2A9t4C2l1mUQ=="],
|
||||
|
||||
"node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="],
|
||||
|
|
@ -3353,6 +3571,8 @@
|
|||
|
||||
"parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="],
|
||||
|
||||
"parse-passwd": ["parse-passwd@1.0.0", "", {}, "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q=="],
|
||||
|
||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
|
||||
|
|
@ -3463,12 +3683,16 @@
|
|||
|
||||
"proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"punycode": ["punycode@1.3.2", "", {}, "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw=="],
|
||||
|
||||
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
|
||||
|
||||
"qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="],
|
||||
|
||||
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
|
||||
|
|
@ -3573,6 +3797,8 @@
|
|||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
|
||||
|
||||
"reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="],
|
||||
|
||||
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||
|
|
@ -3765,8 +3991,12 @@
|
|||
|
||||
"stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="],
|
||||
|
||||
"stream-browserify": ["stream-browserify@3.0.0", "", { "dependencies": { "inherits": "~2.0.4", "readable-stream": "^3.5.0" } }, "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA=="],
|
||||
|
||||
"stream-replace-string": ["stream-replace-string@2.0.0", "", {}, "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w=="],
|
||||
|
||||
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
|
||||
|
||||
"streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="],
|
||||
|
||||
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
|
@ -3835,6 +4065,8 @@
|
|||
|
||||
"thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="],
|
||||
|
||||
"tiktoken": ["tiktoken@1.0.22", "", {}, "sha512-PKvy1rVF1RibfF3JlXBSP0Jrcw2uq3yXdgcEXtKTYn3QJ/cBRBHDnrJ5jHky+MENZ6DIPwNUGWpkVx+7joCpNA=="],
|
||||
|
||||
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
|
@ -3875,6 +4107,8 @@
|
|||
|
||||
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||
|
||||
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
|
||||
|
||||
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
|
@ -3991,7 +4225,7 @@
|
|||
|
||||
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
|
||||
|
||||
"uuid": ["uuid@8.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw=="],
|
||||
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
|
|
@ -4105,7 +4339,7 @@
|
|||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
|
||||
|
||||
|
|
@ -4223,6 +4457,12 @@
|
|||
|
||||
"@aws-sdk/client-sts/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.782.0", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "3.782.0", "@aws-sdk/types": "3.775.0", "@smithy/node-config-provider": "^4.0.2", "@smithy/types": "^4.2.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-dMFkUBgh2Bxuw8fYZQoH/u3H4afQ12VSkzEi//qFiDTwbKYq+u+RYjc8GLDM6JSK1BShMu5AVR7HD4ap1TYUnA=="],
|
||||
|
||||
"@aws-sdk/lib-storage/@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.14", "", { "dependencies": { "@smithy/core": "^3.23.0", "@smithy/middleware-serde": "^4.2.9", "@smithy/node-config-provider": "^4.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-FUFNE5KVeaY6U/GL0nzAAHkaCHzXLZcY1EhtQnsAqhD8Du13oPKtMB9/0WK4/LK6a/T5OZ24wPoSShff5iI6Ag=="],
|
||||
|
||||
"@aws-sdk/lib-storage/@smithy/smithy-client": ["@smithy/smithy-client@4.11.3", "", { "dependencies": { "@smithy/core": "^3.23.0", "@smithy/middleware-endpoint": "^4.4.14", "@smithy/middleware-stack": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.12", "tslib": "^2.6.2" } }, "sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg=="],
|
||||
|
||||
"@aws-sdk/lib-storage/buffer": ["buffer@5.6.0", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4" } }, "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw=="],
|
||||
|
||||
"@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="],
|
||||
|
||||
"@azure/core-http/@azure/abort-controller": ["@azure/abort-controller@1.1.0", "", { "dependencies": { "tslib": "^2.2.0" } }, "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw=="],
|
||||
|
|
@ -4247,8 +4487,24 @@
|
|||
|
||||
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||
|
||||
"@daytonaio/api-client/axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="],
|
||||
|
||||
"@daytonaio/sdk/axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="],
|
||||
|
||||
"@daytonaio/toolbox-api-client/axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="],
|
||||
|
||||
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
|
||||
"@discordjs/rest/undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="],
|
||||
|
||||
"@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
|
||||
"@discordjs/ws/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
|
||||
|
||||
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"@effect/platform-node-shared/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
|
||||
|
|
@ -4257,6 +4513,8 @@
|
|||
|
||||
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@grpc/proto-loader/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
|
||||
|
||||
"@hono/zod-validator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
|
@ -4381,6 +4639,28 @@
|
|||
|
||||
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||
|
||||
"@opencode/discord/@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.65", "", {}, "sha512-XlpgQJQ5WwO4tYgyyHoTT0NAB5/1StXonabVUAYVpW0JdtbwWFSdFEaLkWx6CU7MNW6ELP+SMC4n6wWO6zRW8Q=="],
|
||||
|
||||
"@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-http/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="],
|
||||
|
||||
"@opentelemetry/exporter-zipkin/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="],
|
||||
|
||||
"@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="],
|
||||
|
||||
"@opentelemetry/sdk-node/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="],
|
||||
|
||||
"@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.5.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA=="],
|
||||
|
||||
"@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.5.1", "", { "dependencies": { "@opentelemetry/core": "2.5.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ=="],
|
||||
|
||||
"@opentelemetry/sdk-trace-node/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="],
|
||||
|
||||
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
||||
|
||||
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.9", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="],
|
||||
|
|
@ -4451,8 +4731,6 @@
|
|||
|
||||
"@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="],
|
||||
|
||||
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="],
|
||||
|
|
@ -4483,6 +4761,8 @@
|
|||
|
||||
"aws-sdk/events": ["events@1.1.1", "", {}, "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw=="],
|
||||
|
||||
"aws-sdk/uuid": ["uuid@8.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw=="],
|
||||
|
||||
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
|
||||
|
||||
"babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
|
||||
|
|
@ -4503,6 +4783,8 @@
|
|||
|
||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"discord.js/undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="],
|
||||
|
||||
"dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="],
|
||||
|
||||
"drizzle-kit/esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="],
|
||||
|
|
@ -4531,8 +4813,6 @@
|
|||
|
||||
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
|
||||
|
|
@ -4555,6 +4835,8 @@
|
|||
|
||||
"lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
|
||||
|
||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
|
@ -4569,6 +4851,8 @@
|
|||
|
||||
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
|
||||
|
||||
"node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"nypm/citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="],
|
||||
|
|
@ -4645,6 +4929,8 @@
|
|||
|
||||
"sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
|
||||
|
||||
"stream-browserify/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
|
||||
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
|
@ -4653,8 +4939,6 @@
|
|||
|
||||
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||
|
||||
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
|
@ -4665,8 +4949,6 @@
|
|||
|
||||
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
|
||||
|
||||
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
||||
|
||||
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
|
@ -4741,6 +5023,14 @@
|
|||
|
||||
"@aws-sdk/client-sts/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.782.0", "", { "dependencies": { "@aws-sdk/core": "3.775.0", "@aws-sdk/nested-clients": "3.782.0", "@aws-sdk/types": "3.775.0", "@smithy/property-provider": "^4.0.2", "@smithy/types": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-xCna0opVPaueEbJoclj5C6OpDNi0Gynj+4d7tnuXGgQhTHPyAz8ZyClkVqpi5qvHTgxROdUEDxWqEO5jqRHZHQ=="],
|
||||
|
||||
"@aws-sdk/lib-storage/@smithy/middleware-endpoint/@smithy/core": ["@smithy/core@3.23.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg=="],
|
||||
|
||||
"@aws-sdk/lib-storage/@smithy/smithy-client/@smithy/core": ["@smithy/core@3.23.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg=="],
|
||||
|
||||
"@aws-sdk/lib-storage/@smithy/smithy-client/@smithy/util-stream": ["@smithy/util-stream@4.5.12", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.9", "@smithy/node-http-handler": "^4.4.10", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg=="],
|
||||
|
||||
"@aws-sdk/lib-storage/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"@aws-sdk/xml-builder/fast-xml-parser/strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="],
|
||||
|
||||
"@azure/core-xml/fast-xml-parser/strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="],
|
||||
|
|
@ -4791,6 +5081,10 @@
|
|||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||
|
||||
"@grpc/proto-loader/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"@grpc/proto-loader/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
|
@ -4869,6 +5163,8 @@
|
|||
|
||||
"@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
|
@ -4973,8 +5269,6 @@
|
|||
|
||||
"@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||
|
||||
"@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||
|
||||
"@slack/web-api/p-queue/p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="],
|
||||
|
|
@ -4993,8 +5287,6 @@
|
|||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="],
|
||||
|
|
@ -5085,8 +5377,6 @@
|
|||
|
||||
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"js-beautify/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
|
@ -5133,8 +5423,6 @@
|
|||
|
||||
"tw-to-css/tailwindcss/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],
|
||||
|
|
@ -5217,6 +5505,18 @@
|
|||
|
||||
"@aws-sdk/client-sts/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.782.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.775.0", "@aws-sdk/middleware-host-header": "3.775.0", "@aws-sdk/middleware-logger": "3.775.0", "@aws-sdk/middleware-recursion-detection": "3.775.0", "@aws-sdk/middleware-user-agent": "3.782.0", "@aws-sdk/region-config-resolver": "3.775.0", "@aws-sdk/types": "3.775.0", "@aws-sdk/util-endpoints": "3.782.0", "@aws-sdk/util-user-agent-browser": "3.775.0", "@aws-sdk/util-user-agent-node": "3.782.0", "@smithy/config-resolver": "^4.1.0", "@smithy/core": "^3.2.0", "@smithy/fetch-http-handler": "^5.0.2", "@smithy/hash-node": "^4.0.2", "@smithy/invalid-dependency": "^4.0.2", "@smithy/middleware-content-length": "^4.0.2", "@smithy/middleware-endpoint": "^4.1.0", "@smithy/middleware-retry": "^4.1.0", "@smithy/middleware-serde": "^4.0.3", "@smithy/middleware-stack": "^4.0.2", "@smithy/node-config-provider": "^4.0.2", "@smithy/node-http-handler": "^4.0.4", "@smithy/protocol-http": "^5.1.0", "@smithy/smithy-client": "^4.2.0", "@smithy/types": "^4.2.0", "@smithy/url-parser": "^4.0.2", "@smithy/util-base64": "^4.0.0", "@smithy/util-body-length-browser": "^4.0.0", "@smithy/util-body-length-node": "^4.0.0", "@smithy/util-defaults-mode-browser": "^4.0.8", "@smithy/util-defaults-mode-node": "^4.0.8", "@smithy/util-endpoints": "^3.0.2", "@smithy/util-middleware": "^4.0.2", "@smithy/util-retry": "^4.0.2", "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-QOYC8q7luzHFXrP0xYAqBctoPkynjfV0r9dqntFu4/IWMTyC1vlo1UTxFAjIPyclYw92XJyEkVCVg9v/nQnsUA=="],
|
||||
|
||||
"@aws-sdk/lib-storage/@smithy/middleware-endpoint/@smithy/core/@smithy/util-stream": ["@smithy/util-stream@4.5.12", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.9", "@smithy/node-http-handler": "^4.4.10", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg=="],
|
||||
|
||||
"@aws-sdk/lib-storage/@smithy/smithy-client/@smithy/util-stream/@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.10", "", { "dependencies": { "@smithy/abort-controller": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/querystring-builder": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA=="],
|
||||
|
||||
"@grpc/proto-loader/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"@grpc/proto-loader/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"@grpc/proto-loader/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"@grpc/proto-loader/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"@jsx-email/cli/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"@jsx-email/cli/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
|
@ -5267,6 +5567,8 @@
|
|||
|
||||
"@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request-error/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
|
|
@ -5289,8 +5591,6 @@
|
|||
|
||||
"@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||
|
||||
"@slack/web-api/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
|
||||
|
||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
|
@ -5327,6 +5627,8 @@
|
|||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
|
@ -5349,6 +5651,12 @@
|
|||
|
||||
"@aws-sdk/client-sts/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.782.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.775.0", "@aws-sdk/middleware-host-header": "3.775.0", "@aws-sdk/middleware-logger": "3.775.0", "@aws-sdk/middleware-recursion-detection": "3.775.0", "@aws-sdk/middleware-user-agent": "3.782.0", "@aws-sdk/region-config-resolver": "3.775.0", "@aws-sdk/types": "3.775.0", "@aws-sdk/util-endpoints": "3.782.0", "@aws-sdk/util-user-agent-browser": "3.775.0", "@aws-sdk/util-user-agent-node": "3.782.0", "@smithy/config-resolver": "^4.1.0", "@smithy/core": "^3.2.0", "@smithy/fetch-http-handler": "^5.0.2", "@smithy/hash-node": "^4.0.2", "@smithy/invalid-dependency": "^4.0.2", "@smithy/middleware-content-length": "^4.0.2", "@smithy/middleware-endpoint": "^4.1.0", "@smithy/middleware-retry": "^4.1.0", "@smithy/middleware-serde": "^4.0.3", "@smithy/middleware-stack": "^4.0.2", "@smithy/node-config-provider": "^4.0.2", "@smithy/node-http-handler": "^4.0.4", "@smithy/protocol-http": "^5.1.0", "@smithy/smithy-client": "^4.2.0", "@smithy/types": "^4.2.0", "@smithy/url-parser": "^4.0.2", "@smithy/util-base64": "^4.0.0", "@smithy/util-body-length-browser": "^4.0.0", "@smithy/util-body-length-node": "^4.0.0", "@smithy/util-defaults-mode-browser": "^4.0.8", "@smithy/util-defaults-mode-node": "^4.0.8", "@smithy/util-endpoints": "^3.0.2", "@smithy/util-middleware": "^4.0.2", "@smithy/util-retry": "^4.0.2", "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-QOYC8q7luzHFXrP0xYAqBctoPkynjfV0r9dqntFu4/IWMTyC1vlo1UTxFAjIPyclYw92XJyEkVCVg9v/nQnsUA=="],
|
||||
|
||||
"@aws-sdk/lib-storage/@smithy/middleware-endpoint/@smithy/core/@smithy/util-stream/@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.10", "", { "dependencies": { "@smithy/abort-controller": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/querystring-builder": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA=="],
|
||||
|
||||
"@grpc/proto-loader/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"@grpc/proto-loader/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="],
|
||||
|
|
@ -5357,6 +5665,8 @@
|
|||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ ALLOWED_CHANNEL_IDS= # Comma-separated Discord channel IDs
|
|||
DISCORD_ROLE_ID= # Role ID that triggers the bot (optional, for @role mentions)
|
||||
DISCORD_CATEGORY_ID= # Optional category ID that is allowed
|
||||
DISCORD_REQUIRED_ROLE_ID= # Optional role required to talk to bot
|
||||
DISCORD_COMMAND_GUILD_ID= # Optional guild ID for instant slash command updates (dev only)
|
||||
|
||||
# Daytona
|
||||
DAYTONA_API_KEY=
|
||||
|
|
|
|||
|
|
@ -1,211 +0,0 @@
|
|||
# AGENTS.md
|
||||
|
||||
Guide for coding agents working in this repository.
|
||||
Use this file for build/test commands and coding conventions.
|
||||
|
||||
## Project Snapshot
|
||||
|
||||
- Stack: Bun + TypeScript (ESM, strict mode)
|
||||
- App: Discord bot that provisions Daytona sandboxes
|
||||
- Persistence: SQLite (`discord.sqlite`, table `discord_sessions`)
|
||||
- Runtime flow: Discord thread -> sandbox -> OpenCode session
|
||||
- Ops: structured JSON logs + `/healthz` and `/readyz`
|
||||
|
||||
## Repository Map
|
||||
|
||||
- `src/index.ts`: startup, wiring, graceful shutdown
|
||||
- `src/config.ts`: env schema and parsing (Zod)
|
||||
- `src/discord/`: Discord client + handlers + routing logic
|
||||
- `src/sandbox/`: sandbox lifecycle + OpenCode transport
|
||||
- `src/sessions/store.ts`: SQLite-backed session store
|
||||
- `src/db/init.ts`: idempotent DB schema initialization
|
||||
- `src/http/health.ts`: health/readiness HTTP server
|
||||
- `.env.example`: env contract
|
||||
|
||||
## Setup and Run Commands
|
||||
|
||||
### Install
|
||||
|
||||
- `bun install`
|
||||
|
||||
### First-time local setup
|
||||
|
||||
- `cp .env.example .env`
|
||||
- Fill required secrets in `.env`
|
||||
- Initialize schema: `bun run db:init`
|
||||
|
||||
### Development run
|
||||
|
||||
- Watch mode: `bun run dev`
|
||||
- Normal run: `bun run start`
|
||||
- Dev bootstrap helper: `bun run dev:setup`
|
||||
|
||||
### Static checks
|
||||
|
||||
- Typecheck: `bun run typecheck`
|
||||
- Build: `bun run build`
|
||||
- Combined check: `bun run check`
|
||||
|
||||
### Health checks
|
||||
|
||||
- `curl -s http://127.0.0.1:8787/healthz`
|
||||
- `curl -i http://127.0.0.1:8787/readyz`
|
||||
|
||||
## Testing Commands
|
||||
|
||||
There is no first-party test suite in `src/` yet.
|
||||
Use Bun test commands for new tests.
|
||||
|
||||
- Run all tests (if present): `bun test`
|
||||
- Run a single test file: `bun test path/to/file.test.ts`
|
||||
- Run one file in watch mode: `bun test --watch path/to/file.test.ts`
|
||||
When adding tests, prefer colocated `*.test.ts` near implementation files.
|
||||
|
||||
## Cursor / Copilot Rules
|
||||
|
||||
Checked these paths:
|
||||
|
||||
- `.cursor/rules/`
|
||||
- `.cursorrules`
|
||||
- `.github/copilot-instructions.md`
|
||||
No Cursor/Copilot rule files currently exist in this repo.
|
||||
If added later, update this file and follow those rules.
|
||||
|
||||
## Code Style
|
||||
|
||||
### TypeScript and modules
|
||||
|
||||
- Keep code strict-TypeScript compatible.
|
||||
- Use ESM imports/exports only.
|
||||
- Prefer named exports over default exports.
|
||||
- Add explicit return types on exported functions.
|
||||
|
||||
### Imports
|
||||
|
||||
- Group imports as: external first, then internal.
|
||||
- Use `import type` for type-only imports.
|
||||
- Keep import paths consistent with existing relative style.
|
||||
|
||||
### Formatting
|
||||
|
||||
- Match existing style:
|
||||
- double quotes
|
||||
- semicolons
|
||||
- trailing commas where appropriate
|
||||
- Keep functions small and focused.
|
||||
- Avoid comments unless logic is non-obvious.
|
||||
|
||||
### Naming
|
||||
|
||||
- `camelCase`: variables/functions
|
||||
- `PascalCase`: classes/interfaces/type aliases
|
||||
- `UPPER_SNAKE_CASE`: env keys and constants
|
||||
- Log events should be stable (`domain.action.result`).
|
||||
|
||||
### Types and contracts
|
||||
|
||||
- Reuse shared types from `src/types.ts`.
|
||||
- Preserve `SessionStatus` semantics when adding new states.
|
||||
- Prefer `unknown` over `any` at untrusted boundaries.
|
||||
- Narrow and validate external data before use.
|
||||
|
||||
## Error Handling and Logging
|
||||
|
||||
- Use `logger` from `src/observability/logger.ts`.
|
||||
- Do not add raw `console.log` in app paths.
|
||||
- Include context fields when available:
|
||||
- `threadId`
|
||||
- `channelId`
|
||||
- `guildId`
|
||||
- `sandboxId`
|
||||
- `sessionId`
|
||||
- Fail fast on invalid config in `src/config.ts`.
|
||||
- Wrap network/process operations in contextual `try/catch`.
|
||||
- Separate recoverable errors from terminal errors.
|
||||
- Never log secret values.
|
||||
|
||||
## Environment and Secrets
|
||||
|
||||
- Read env only through `getEnv()`.
|
||||
- Update `.env.example` for env schema changes.
|
||||
- Keep auth tokens out of command strings and logs.
|
||||
- Pass runtime secrets via environment variables.
|
||||
|
||||
## Domain-Specific Rules
|
||||
|
||||
### Session lifecycle
|
||||
|
||||
- Session mapping (`thread_id`, `sandbox_id`, `session_id`) is authoritative.
|
||||
- Resume existing sandbox/session before creating replacements.
|
||||
- Recreate only when sandbox is unavailable/destroyed.
|
||||
- If session changes, replay Discord thread history as fallback context.
|
||||
|
||||
### Daytona behavior
|
||||
|
||||
- `stop()` clears running processes but keeps filesystem state.
|
||||
- `start()` requires process bootstrap (`opencode serve`).
|
||||
- Keep lifecycle transitions deterministic and observable.
|
||||
|
||||
### OpenCode transport
|
||||
|
||||
- Keep preview token separate from persisted URL when possible.
|
||||
- Send token using `x-daytona-preview-token` header.
|
||||
- Keep retry loops bounded and configurable.
|
||||
|
||||
### Discord handler behavior
|
||||
|
||||
- Ignore bot/self chatter and respect mention/role gating.
|
||||
- Preserve thread ownership checks for bot-managed threads.
|
||||
- Keep outbound messages chunked for Discord size limits.
|
||||
|
||||
## Non-Obvious Discoveries
|
||||
|
||||
### OpenCode session persistence
|
||||
|
||||
- Sessions are disk-persistent JSON files in `~/.local/share/opencode/storage/session/<projectID>/`
|
||||
- Sessions survive `opencode serve` restarts if filesystem intact AND process restarts from same git repo directory
|
||||
- Sessions are scoped by `projectID` = git root commit hash (or `"global"` for non-git dirs)
|
||||
- After `daytona.start()`, processes are guaranteed dead - always restart `opencode serve` immediately, don't wait for health first (`src/sandbox/manager.ts:400-420`)
|
||||
|
||||
### Session reattach debugging
|
||||
|
||||
- If `sessionExists()` returns false but sandbox filesystem is intact, search by title (`Discord thread <threadId>`) via `listSessions()` - session may exist under different ID due to OpenCode internal state changes
|
||||
- Thread lock per `threadId` prevents concurrent create/resume races (`src/sandbox/manager.ts:614-632`)
|
||||
- Never fall back to new sandbox when `daytona.start()` succeeds - filesystem is intact, only OpenCode process needs restart
|
||||
|
||||
### Discord + multiple processes
|
||||
|
||||
- Multiple bot processes with same `DISCORD_TOKEN` cause race conditions - one succeeds, others fail with `DiscordAPIError[160004]` (thread already created)
|
||||
- PTY sessions with `exec bash -l` stay alive after command exits, leading to duplicate bot runtimes if not cleaned up
|
||||
|
||||
### Sandbox runtime auth
|
||||
|
||||
- Pass `GITHUB_TOKEN` as process env to `opencode serve` via `sandbox.process.executeCommand()` `env` parameter
|
||||
- Never interpolate tokens into command strings - use `env` parameter in `exec()` options (`src/sandbox/manager.ts:27-72`)
|
||||
|
||||
## Agent Workflow Checklist
|
||||
|
||||
### Before coding
|
||||
|
||||
- Read related modules and follow existing patterns.
|
||||
- Prefer narrow, minimal changes over broad refactors.
|
||||
|
||||
### During coding
|
||||
|
||||
- Keep behavior backwards-compatible unless intentionally changing it.
|
||||
- Keep changes cohesive (schema + store + manager together).
|
||||
- Add/update logs for important lifecycle branches.
|
||||
|
||||
### After coding
|
||||
|
||||
- Run `bun run typecheck`
|
||||
- Run `bun run build`
|
||||
- Run `bun run db:init` for schema-affecting changes
|
||||
- Smoke-check health endpoints if bootstrap/runtime changed
|
||||
|
||||
## Git/PR Safety for Agents
|
||||
|
||||
- Do not commit or push unless explicitly requested.
|
||||
- Do not amend commits unless explicitly requested.
|
||||
- Avoid destructive git commands unless explicitly requested.
|
||||
- Summaries should cite changed files and operational impact.
|
||||
1
packages/discord/AGENTS.md
Symbolic link
1
packages/discord/AGENTS.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
CLAUDE.md
|
||||
88
packages/discord/CLAUDE.md
Normal file
88
packages/discord/CLAUDE.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Discord Bot Package
|
||||
|
||||
Discord bot that provisions Daytona sandboxes running OpenCode sessions in threads.
|
||||
|
||||
## Architecture
|
||||
|
||||
Bun + TypeScript (ESM, strict mode) with Effect for all business logic. SQLite persistence via `@effect/sql`.
|
||||
|
||||
- `src/index.ts` — startup, layer composition, graceful shutdown
|
||||
- `src/config.ts` — env schema (Effect Schema + branded types)
|
||||
- `src/conversation/` — pure conversation service (Inbox/Outbox ports, turn logic, ConversationLedger for dedup/replay)
|
||||
- `src/discord/` — Discord.js adapter (message handler, turn routing, formatting)
|
||||
- `src/sandbox/` — sandbox lifecycle (SandboxProvisioner, OpenCode client, ThreadAgentPool)
|
||||
- `src/sessions/store.ts` — SQLite-backed session store
|
||||
- `src/lib/actors/` — ActorMap (per-key serialized execution with idle timeouts)
|
||||
- `src/db/` — database client, schema init, migrations
|
||||
- `src/http/health.ts` — health/readiness HTTP server
|
||||
- `src/types.ts` — shared branded types and data classes
|
||||
|
||||
## Effect Conventions
|
||||
|
||||
- Services use `Context.Tag("@discord/<Name>")`
|
||||
- Errors use `Schema.TaggedError` with `Schema.Defect` for defect-like causes
|
||||
- Use `Effect.gen(function*() { ... })` for composition
|
||||
- Use `Effect.fn("ServiceName.method")` for named/traced effects
|
||||
- Layer composition: `Layer.mergeAll`, `Layer.provide`, `Layer.provideMerge`
|
||||
- Use `Schema.Class` for data types with multiple fields
|
||||
- Use branded schemas (`Schema.brand`) for single-value IDs
|
||||
- Construct branded values and Schema.Class instances with `.make()`
|
||||
- Module pattern for utilities: namespace for types, const for implementation (e.g. `ActorMap.make()`, `ActorMap.ActorMap<K>`)
|
||||
|
||||
## Type Safety
|
||||
|
||||
- **No `any`** — use `unknown` at untrusted boundaries, narrow with Schema decoding
|
||||
- **No `as` casts** — prefer Schema decode, type guards, or restructuring
|
||||
- **Non-null assertions (`!`) are banned** — use Option, optional chaining, or early returns
|
||||
- **Use `Option<T>` instead of `T | null`** — Effect's Option type for absent values from stores/lookups
|
||||
- **Branded types everywhere** — `ThreadId`, `ChannelId`, `GuildId`, `SandboxId`, `SessionId` from `src/types.ts`
|
||||
- **Accept branded types in function signatures** — don't accept `string` and `.make()` inside; push branding to the boundary
|
||||
- `as const` is fine (const assertion, not a cast)
|
||||
|
||||
## Branded Types
|
||||
|
||||
All branded ID schemas live in `src/types.ts`:
|
||||
- `ThreadId`, `ChannelId`, `GuildId` — Discord identifiers
|
||||
- `SandboxId` — Daytona sandbox identifier
|
||||
- `SessionId` — OpenCode session identifier
|
||||
|
||||
Brand at the system boundary (Discord event parsing, schema classes), then pass branded types through all internal code.
|
||||
|
||||
## Testing
|
||||
|
||||
- `bun test` — run all tests
|
||||
- `bun test path/to/file.test.ts` — single file
|
||||
- Test helpers in `src/test/effect.ts`
|
||||
- Colocate tests as `*.test.ts` next to implementation
|
||||
|
||||
## Build & Check
|
||||
|
||||
- `bun run typecheck` — type checking
|
||||
- `bun run build` — production build
|
||||
- `bun run check` — combined
|
||||
|
||||
## Local Debug CLIs
|
||||
|
||||
- `bun run conversation:cli` — interactive local conversation shell
|
||||
- `/channel` to return to channel mode
|
||||
- `/threads` to list known threads with indexes
|
||||
- `/pick [n]` to select a thread by index
|
||||
- `/thread [id|n]` to jump to a thread by id or index
|
||||
- channel auto-switch only follows newly seen threads (prevents jumping to old active threads)
|
||||
|
||||
- `bun run conversation:ctl` — non-interactive JSON CLI for agents/automation
|
||||
- `active`
|
||||
- `status --thread <id>`
|
||||
- `logs --thread <id> [--lines 120]`
|
||||
- `pause --thread <id>`
|
||||
- `destroy --thread <id>`
|
||||
- `resume --thread <id> [--channel <id> --guild <id>]`
|
||||
- `restart --thread <id>`
|
||||
- `send --thread <id> --text "<message>" [--follow --wait-ms 180000 --logs-every-ms 2000 --lines 80]`
|
||||
|
||||
## Session Lifecycle
|
||||
|
||||
- Session mapping (`threadId` -> `sandboxId` -> `sessionId`) is authoritative
|
||||
- Resume existing sandbox/session before creating replacements
|
||||
- Recreate only when sandbox is truly unavailable/destroyed
|
||||
- If session changes, replay Discord thread history as context
|
||||
|
|
@ -25,7 +25,7 @@ Discord bot that provisions [Daytona](https://daytona.io) sandboxes running [Ope
|
|||
2. Create a new application
|
||||
3. Go to **Bot** and click **Reset Token** — save this as `DISCORD_TOKEN`
|
||||
4. Enable **Message Content Intent** under **Privileged Gateway Intents**
|
||||
5. Go to **OAuth2 > URL Generator**, select the `bot` scope with permissions: **Send Messages**, **Create Public Threads**, **Send Messages in Threads**, **Read Message History**
|
||||
5. Go to **OAuth2 > URL Generator**, select scopes `bot` and `applications.commands` with permissions: **Send Messages**, **Create Public Threads**, **Send Messages in Threads**, **Read Message History**
|
||||
6. Use the generated URL to invite the bot to your server
|
||||
|
||||
### 2. Get Your API Keys
|
||||
|
|
@ -89,6 +89,7 @@ This image does not require Docker Compose or special network wiring; only outbo
|
|||
| `DISCORD_CATEGORY_ID` | _(empty)_ | Restrict the bot to a specific channel category |
|
||||
| `DISCORD_ROLE_ID` | _(empty)_ | Role ID that triggers the bot via @role mentions |
|
||||
| `DISCORD_REQUIRED_ROLE_ID` | _(empty)_ | Role users must have to interact with the bot |
|
||||
| `DISCORD_COMMAND_GUILD_ID` | _(empty)_ | Register slash commands in one guild for instant updates (dev-friendly) |
|
||||
|
||||
#### Optional — Storage & Runtime
|
||||
|
||||
|
|
@ -130,6 +131,13 @@ This image does not require Docker Compose or special network wiring; only outbo
|
|||
| `bun run build` | Bundle for deployment |
|
||||
| `bun run check` | Typecheck + build |
|
||||
|
||||
### Discord Slash Commands
|
||||
|
||||
- `/status` — show current sandbox session for the thread
|
||||
- `/reset` — destroy session so next message provisions a fresh sandbox
|
||||
|
||||
These map to the existing `!status` / `!reset` behavior.
|
||||
|
||||
## Health Endpoints
|
||||
|
||||
- `GET /healthz` — Liveness check (uptime, Discord status, active sessions)
|
||||
|
|
@ -138,12 +146,13 @@ This image does not require Docker Compose or special network wiring; only outbo
|
|||
## Architecture
|
||||
|
||||
```
|
||||
Discord thread
|
||||
└─ message-create handler
|
||||
└─ SandboxManager.resolveSessionForMessage()
|
||||
Discord / CLI
|
||||
└─ Conversation service (Inbox → turn logic → Outbox)
|
||||
├─ ConversationLedger (dedup, at-least-once delivery, replay on restart)
|
||||
└─ ThreadAgentPool.getOrCreate(threadId)
|
||||
├─ active? → health check → reuse
|
||||
├─ paused? → daytona.start() → restart opencode → reattach session
|
||||
└─ missing? → create sandbox → clone repo → start opencode → new session
|
||||
├─ paused? → SandboxProvisioner.resume() → reattach session
|
||||
└─ missing? → SandboxProvisioner.provision() → new sandbox + session
|
||||
```
|
||||
|
||||
Sessions are persisted in a local SQLite file. Sandbox filesystem (including OpenCode session state) survives pause/resume cycles via Daytona stop/start.
|
||||
|
|
|
|||
|
|
@ -8,18 +8,29 @@
|
|||
"dev": "bun run --watch src/index.ts",
|
||||
"dev:setup": "bun run db:init",
|
||||
"start": "bun run src/index.ts",
|
||||
"conversation:cli": "bun run src/conversation/control/cli.ts",
|
||||
"conversation:controller": "bun run src/conversation/control/controller.ts",
|
||||
"conversation:ctl": "bun run src/conversation/control/controller.ts",
|
||||
"build": "bun build src/index.ts --target=bun --outdir=dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "bun run typecheck && bun run build"
|
||||
"check": "bun run typecheck && bun run build",
|
||||
"prepare": "effect-language-service patch"
|
||||
},
|
||||
"dependencies": {
|
||||
"discord.js": "^14",
|
||||
"@daytonaio/sdk": "latest",
|
||||
"@effect/ai": "^0.33.2",
|
||||
"@effect/ai-anthropic": "^0.23.0",
|
||||
"@effect/experimental": "^0.58.0",
|
||||
"@effect/sql-sqlite-bun": "^0.50.0",
|
||||
"@effect/platform": "latest",
|
||||
"@effect/platform-bun": "0.87.1",
|
||||
"@opencode-ai/sdk": "latest",
|
||||
"effect": "^3",
|
||||
"zod": "^3"
|
||||
"discord.js": "^14",
|
||||
"effect": "^3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/language-service": "0.73.1",
|
||||
"@effect/sql": "^0.49.0",
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "^22",
|
||||
"typescript": "^5"
|
||||
|
|
|
|||
|
|
@ -1,28 +1,51 @@
|
|||
You're a senior engineer on the OpenCode team. You're in a Discord channel where teammates and community members ask questions about the codebase. You have the full opencode repo cloned at your working directory.
|
||||
you are an engineering assistant for the opencode repo, running inside a discord workflow.
|
||||
|
||||
This is an internal tool — people tag you to ask about how things work, where code lives, why something was built a certain way, or to get help debugging. Think of it like someone pinging you on Slack.
|
||||
your job is to help people solve real code and operations problems quickly.
|
||||
|
||||
## Tone
|
||||
## communication style
|
||||
|
||||
- Just answer the question. Don't preface with "Based on my analysis" or "I'd be happy to help" or "Let me look into that for you." Just give the answer.
|
||||
- Write like you're messaging a coworker. Lowercase is fine. Short paragraphs. No essays.
|
||||
- Don't over-format. Use markdown for code blocks and the occasional list, but don't turn every response into a formatted document with headers and bullet points. Just talk.
|
||||
- Be direct and opinionated when it makes sense. "yeah that's a bug" or "I'd just use X here" is better than hedging everything.
|
||||
- If you don't know, say "not sure" or "I'd have to dig into that more." Don't make stuff up.
|
||||
- Match the vibe. Quick question = quick answer. Detailed question = longer answer with code refs.
|
||||
- default to lowercase developer style.
|
||||
- mirror the user's phrasing and level of formality.
|
||||
- be concise by default. expand only when the question needs it.
|
||||
- skip fluff and generic preambles. answer directly.
|
||||
- if unsure, say so and state exactly what you need to verify.
|
||||
|
||||
## What you do
|
||||
## operating process
|
||||
|
||||
- Search and read the codebase to answer questions
|
||||
- Run git, grep, gh CLI to find things
|
||||
- Reference specific files and line numbers like `src/tui/app.ts:142`
|
||||
- Quote relevant code when it helps
|
||||
- Explain architecture and design decisions based on what's actually in the code
|
||||
1. understand the request and goal.
|
||||
2. inspect the codebase and runtime signals first (files, logs, tests, git history).
|
||||
3. use tools to gather evidence before concluding.
|
||||
4. give a concrete answer with file references and next action.
|
||||
5. if asked to implement, make the change and verify.
|
||||
|
||||
## Rules
|
||||
## tool usage
|
||||
|
||||
- **Search the code first.** Don't answer from memory — look it up and cite where things are.
|
||||
- **Don't edit files unless someone explicitly asks you to.**
|
||||
- **Keep it short.** Under 1500 chars unless the question actually needs a longer answer.
|
||||
- **Summarize command output.** Don't paste raw terminal dumps.
|
||||
- When you reference code, include the file path so people can go look at it.
|
||||
- you can use repo tools and shell commands.
|
||||
- prefer fast code search (`rg`) and direct file reads.
|
||||
- use git commands for context and diffs.
|
||||
- use github cli (`gh`) for issues/prs when asked, or when explicitly instructed to file findings.
|
||||
- use web lookup when external, time-sensitive, or non-repo facts are needed.
|
||||
|
||||
## github issue workflow
|
||||
|
||||
when creating an issue, include:
|
||||
- summary
|
||||
- impact
|
||||
- reproduction steps
|
||||
- expected vs actual behavior
|
||||
- suspected files/components
|
||||
- proposed next step
|
||||
|
||||
## quality bar
|
||||
|
||||
- do not invent behavior; verify from code or logs.
|
||||
- include file paths and line references for technical claims.
|
||||
- summarize command outputs; do not dump noisy logs unless requested.
|
||||
- do not edit files unless explicitly asked.
|
||||
- avoid risky/destructive actions unless explicitly approved.
|
||||
|
||||
## continuous improvement (for later expansion)
|
||||
|
||||
- if a recurring workflow appears, propose a reusable skill/process.
|
||||
- present a short skill spec: trigger, inputs, steps, outputs.
|
||||
- do not self-modify prompt or automation config without explicit user approval.
|
||||
|
|
|
|||
40
packages/discord/src/config.test.ts
Normal file
40
packages/discord/src/config.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Duration, Effect, Redacted } from "effect"
|
||||
import { AppConfig } from "./config"
|
||||
import { effectTest } from "./test/effect"
|
||||
|
||||
const provider = (input?: ReadonlyArray<readonly [string, string]>) =>
|
||||
ConfigProvider.fromMap(
|
||||
new Map([
|
||||
["DISCORD_TOKEN", "discord-token"],
|
||||
["DAYTONA_API_KEY", "daytona-token"],
|
||||
["OPENCODE_ZEN_API_KEY", "zen-token"],
|
||||
...(input ?? []),
|
||||
]),
|
||||
)
|
||||
|
||||
const load = (input?: ReadonlyArray<readonly [string, string]>) =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* AppConfig
|
||||
return config
|
||||
}).pipe(
|
||||
Effect.provide(AppConfig.layer),
|
||||
Effect.withConfigProvider(provider(input)),
|
||||
)
|
||||
|
||||
describe("AppConfig", () => {
|
||||
effectTest("parses SANDBOX_TIMEOUT as Duration", () =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* load([["SANDBOX_TIMEOUT", "45 minutes"]])
|
||||
expect(Duration.toMinutes(config.sandboxTimeout)).toBe(45)
|
||||
expect(Redacted.value(config.discordToken)).toBe("discord-token")
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("falls back to SANDBOX_TIMEOUT_MINUTES", () =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* load([["SANDBOX_TIMEOUT_MINUTES", "31"]])
|
||||
expect(Duration.toMinutes(config.sandboxTimeout)).toBe(31)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,61 +1,189 @@
|
|||
import { z } from "zod"
|
||||
import { Config, Context, Duration, Effect, Layer, Redacted, Schema } from "effect"
|
||||
|
||||
const envSchema = z.object({
|
||||
DISCORD_TOKEN: z.string().min(1),
|
||||
ALLOWED_CHANNEL_IDS: z
|
||||
.string()
|
||||
.default("")
|
||||
.transform((s) =>
|
||||
const TurnRoutingMode = Schema.Literal("off", "heuristic", "ai")
|
||||
type TurnRoutingMode = typeof TurnRoutingMode.Type
|
||||
|
||||
const SandboxReusePolicy = Schema.Literal("resume_preferred", "recreate")
|
||||
type SandboxReusePolicy = typeof SandboxReusePolicy.Type
|
||||
|
||||
const LogLevel = Schema.Literal("debug", "info", "warn", "error")
|
||||
type LogLevel = typeof LogLevel.Type
|
||||
|
||||
const Port = Schema.NumberFromString.pipe(
|
||||
Schema.int(),
|
||||
Schema.between(1, 65535),
|
||||
)
|
||||
|
||||
export const Minutes = Schema.NumberFromString.pipe(
|
||||
Schema.int(),
|
||||
Schema.positive(),
|
||||
Schema.brand("Minutes"),
|
||||
)
|
||||
export type Minutes = typeof Minutes.Type
|
||||
|
||||
export const Milliseconds = Schema.NumberFromString.pipe(
|
||||
Schema.int(),
|
||||
Schema.positive(),
|
||||
Schema.brand("Milliseconds"),
|
||||
)
|
||||
export type Milliseconds = typeof Milliseconds.Type
|
||||
|
||||
export const Seconds = Schema.NumberFromString.pipe(
|
||||
Schema.int(),
|
||||
Schema.positive(),
|
||||
Schema.brand("Seconds"),
|
||||
)
|
||||
export type Seconds = typeof Seconds.Type
|
||||
|
||||
const CommaSeparatedList = Schema.transform(
|
||||
Schema.String,
|
||||
Schema.Array(Schema.String),
|
||||
{
|
||||
decode: (s) =>
|
||||
s
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id.length > 0),
|
||||
),
|
||||
DISCORD_CATEGORY_ID: z.string().default(""),
|
||||
DISCORD_ROLE_ID: z.string().default(""),
|
||||
DISCORD_REQUIRED_ROLE_ID: z.string().default(""),
|
||||
DATABASE_PATH: z.string().default("discord.sqlite"),
|
||||
DAYTONA_API_KEY: z.string().min(1),
|
||||
OPENCODE_ZEN_API_KEY: z.string().min(1),
|
||||
GITHUB_TOKEN: z.string().default(""),
|
||||
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
||||
LOG_PRETTY: z
|
||||
.string()
|
||||
.default("false")
|
||||
.transform((value) => value.toLowerCase() === "true"),
|
||||
HEALTH_HOST: z.string().default("0.0.0.0"),
|
||||
HEALTH_PORT: z.coerce.number().default(8787),
|
||||
TURN_ROUTING_MODE: z.enum(["off", "heuristic", "ai"]).default("ai"),
|
||||
TURN_ROUTING_MODEL: z.string().default("claude-haiku-4-5"),
|
||||
SANDBOX_REUSE_POLICY: z.enum(["resume_preferred", "recreate"]).default("resume_preferred"),
|
||||
SANDBOX_TIMEOUT_MINUTES: z.coerce.number().default(30),
|
||||
PAUSED_TTL_MINUTES: z.coerce.number().default(180),
|
||||
RESUME_HEALTH_TIMEOUT_MS: z.coerce.number().default(120000),
|
||||
SANDBOX_CREATION_TIMEOUT: z.coerce.number().default(180),
|
||||
OPENCODE_MODEL: z.string().default("opencode/claude-sonnet-4-5"),
|
||||
})
|
||||
encode: (a) => a.join(","),
|
||||
},
|
||||
)
|
||||
|
||||
export type Env = z.infer<typeof envSchema>
|
||||
|
||||
let _config: Env | null = null
|
||||
|
||||
export function getEnv(): Env {
|
||||
if (!_config) {
|
||||
const result = envSchema.safeParse(process.env)
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
level: "error",
|
||||
event: "config.invalid",
|
||||
component: "config",
|
||||
message: "Invalid environment variables",
|
||||
fieldErrors: result.error.flatten().fieldErrors,
|
||||
}),
|
||||
)
|
||||
throw new Error("Invalid environment configuration")
|
||||
}
|
||||
_config = result.data
|
||||
export declare namespace AppConfig {
|
||||
export interface Service {
|
||||
readonly discordToken: Redacted.Redacted
|
||||
readonly allowedChannelIds: ReadonlyArray<string>
|
||||
readonly discordCategoryId: string
|
||||
readonly discordRoleId: string
|
||||
readonly discordRequiredRoleId: string
|
||||
readonly discordCommandGuildId: string
|
||||
readonly databasePath: string
|
||||
readonly daytonaApiKey: Redacted.Redacted
|
||||
readonly openCodeZenApiKey: Redacted.Redacted
|
||||
readonly githubToken: string
|
||||
readonly logLevel: LogLevel
|
||||
readonly healthHost: string
|
||||
readonly healthPort: number
|
||||
readonly turnRoutingMode: TurnRoutingMode
|
||||
readonly turnRoutingModel: string
|
||||
readonly sandboxReusePolicy: SandboxReusePolicy
|
||||
readonly sandboxTimeout: Duration.Duration
|
||||
readonly cleanupInterval: Duration.Duration
|
||||
readonly staleActiveGraceMinutes: Minutes
|
||||
readonly pausedTtlMinutes: Minutes
|
||||
readonly activeHealthCheckTimeoutMs: Milliseconds
|
||||
readonly startupHealthTimeoutMs: Milliseconds
|
||||
readonly resumeHealthTimeoutMs: Milliseconds
|
||||
readonly sandboxCreationTimeout: Seconds
|
||||
readonly openCodeModel: string
|
||||
}
|
||||
return _config
|
||||
}
|
||||
|
||||
export class AppConfig extends Context.Tag("@discord/AppConfig")<AppConfig, AppConfig.Service>() {
|
||||
static readonly layer = Layer.effect(
|
||||
AppConfig,
|
||||
Effect.gen(function* () {
|
||||
const discordToken = yield* Config.redacted("DISCORD_TOKEN")
|
||||
const allowedChannelIds = yield* Schema.Config("ALLOWED_CHANNEL_IDS", CommaSeparatedList).pipe(
|
||||
Config.orElse(() => Config.succeed([] as ReadonlyArray<string>)),
|
||||
)
|
||||
const discordCategoryId = yield* Config.string("DISCORD_CATEGORY_ID").pipe(
|
||||
Config.withDefault(""),
|
||||
)
|
||||
const discordRoleId = yield* Config.string("DISCORD_ROLE_ID").pipe(
|
||||
Config.withDefault(""),
|
||||
)
|
||||
const discordRequiredRoleId = yield* Config.string("DISCORD_REQUIRED_ROLE_ID").pipe(
|
||||
Config.withDefault(""),
|
||||
)
|
||||
const discordCommandGuildId = yield* Config.string("DISCORD_COMMAND_GUILD_ID").pipe(
|
||||
Config.withDefault(""),
|
||||
)
|
||||
const databasePath = yield* Config.string("DATABASE_PATH").pipe(
|
||||
Config.withDefault("discord.sqlite"),
|
||||
)
|
||||
const daytonaApiKey = yield* Config.redacted("DAYTONA_API_KEY")
|
||||
const openCodeZenApiKey = yield* Config.redacted("OPENCODE_ZEN_API_KEY")
|
||||
const githubToken = yield* Config.string("GITHUB_TOKEN").pipe(
|
||||
Config.withDefault(""),
|
||||
)
|
||||
const logLevel = yield* Schema.Config("LOG_LEVEL", LogLevel).pipe(
|
||||
Config.orElse(() => Config.succeed("info" as const)),
|
||||
)
|
||||
const healthHost = yield* Config.string("HEALTH_HOST").pipe(
|
||||
Config.withDefault("0.0.0.0"),
|
||||
)
|
||||
const healthPort = yield* Schema.Config("HEALTH_PORT", Port).pipe(
|
||||
Config.orElse(() => Config.succeed(8787)),
|
||||
)
|
||||
const turnRoutingMode = yield* Schema.Config("TURN_ROUTING_MODE", TurnRoutingMode).pipe(
|
||||
Config.orElse(() => Config.succeed("ai" as const)),
|
||||
)
|
||||
const turnRoutingModel = yield* Config.string("TURN_ROUTING_MODEL").pipe(
|
||||
Config.withDefault("claude-haiku-4-5"),
|
||||
)
|
||||
const sandboxReusePolicy = yield* Schema.Config("SANDBOX_REUSE_POLICY", SandboxReusePolicy).pipe(
|
||||
Config.orElse(() => Config.succeed("resume_preferred" as const)),
|
||||
)
|
||||
const sandboxTimeout = yield* Config.duration("SANDBOX_TIMEOUT").pipe(
|
||||
Config.orElse(() =>
|
||||
Schema.Config("SANDBOX_TIMEOUT_MINUTES", Minutes).pipe(
|
||||
Config.map((n) => Duration.minutes(n)),
|
||||
),
|
||||
),
|
||||
Config.withDefault(Duration.minutes(30)),
|
||||
)
|
||||
const cleanupInterval = yield* Config.duration("SANDBOX_CLEANUP_INTERVAL").pipe(
|
||||
Config.withDefault(Duration.minutes(5)),
|
||||
)
|
||||
const staleActiveGraceMinutes = yield* Schema.Config("STALE_ACTIVE_GRACE_MINUTES", Minutes).pipe(
|
||||
Config.orElse(() => Config.succeed(Minutes.make(5))),
|
||||
)
|
||||
const pausedTtlMinutes = yield* Schema.Config("PAUSED_TTL_MINUTES", Minutes).pipe(
|
||||
Config.orElse(() => Config.succeed(Minutes.make(180))),
|
||||
)
|
||||
const activeHealthCheckTimeoutMs = yield* Schema.Config("ACTIVE_HEALTH_CHECK_TIMEOUT_MS", Milliseconds).pipe(
|
||||
Config.orElse(() => Config.succeed(Milliseconds.make(15000))),
|
||||
)
|
||||
const startupHealthTimeoutMs = yield* Schema.Config("STARTUP_HEALTH_TIMEOUT_MS", Milliseconds).pipe(
|
||||
Config.orElse(() => Config.succeed(Milliseconds.make(120000))),
|
||||
)
|
||||
const resumeHealthTimeoutMs = yield* Schema.Config("RESUME_HEALTH_TIMEOUT_MS", Milliseconds).pipe(
|
||||
Config.orElse(() => Config.succeed(Milliseconds.make(120000))),
|
||||
)
|
||||
const sandboxCreationTimeout = yield* Schema.Config("SANDBOX_CREATION_TIMEOUT", Seconds).pipe(
|
||||
Config.orElse(() => Config.succeed(Seconds.make(180))),
|
||||
)
|
||||
const openCodeModel = yield* Config.string("OPENCODE_MODEL").pipe(
|
||||
Config.withDefault("opencode/claude-sonnet-4-5"),
|
||||
)
|
||||
|
||||
return AppConfig.of({
|
||||
discordToken,
|
||||
allowedChannelIds,
|
||||
discordCategoryId,
|
||||
discordRoleId,
|
||||
discordRequiredRoleId,
|
||||
discordCommandGuildId,
|
||||
databasePath,
|
||||
daytonaApiKey,
|
||||
openCodeZenApiKey,
|
||||
githubToken,
|
||||
logLevel,
|
||||
healthHost,
|
||||
healthPort,
|
||||
turnRoutingMode,
|
||||
turnRoutingModel,
|
||||
sandboxReusePolicy,
|
||||
sandboxTimeout,
|
||||
cleanupInterval,
|
||||
staleActiveGraceMinutes,
|
||||
pausedTtlMinutes,
|
||||
activeHealthCheckTimeoutMs,
|
||||
startupHealthTimeoutMs,
|
||||
resumeHealthTimeoutMs,
|
||||
sandboxCreationTimeout,
|
||||
openCodeModel,
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.orDie)
|
||||
}
|
||||
|
|
|
|||
52
packages/discord/src/conversation/README.md
Normal file
52
packages/discord/src/conversation/README.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Conversation Experiment
|
||||
|
||||
This folder contains the active Discord conversation runtime.
|
||||
|
||||
Goal:
|
||||
|
||||
- keep inbound transport as a stream (`Inbox.events`)
|
||||
- keep outbound transport as actions (`Outbox.publish`)
|
||||
- support first-contact channel messages by resolving a thread target through `Threads.ensure`
|
||||
- move orchestration into a transport-agnostic `Conversation` service
|
||||
|
||||
Current status:
|
||||
|
||||
- `model/schema.ts`: normalized event/action schema (`thread_message` and `channel_message`)
|
||||
- `services/*`: service contracts (`Inbox`, `Outbox`, `History`, `Threads`, `ConversationLedger`) + `Conversation`
|
||||
- `implementations/local/index.ts`: local implementation with `send()` / `take()` for non-Discord chat loops
|
||||
- `implementations/discord/index.ts`: Discord implementation mapping message events to `Inbound` and actions to Discord sends
|
||||
- `control/state.ts` + `control/cli.ts` + `control/controller.ts`: local CLI state, interactive CLI, and non-interactive controller commands
|
||||
|
||||
This module is wired into `src/index.ts`.
|
||||
|
||||
Reliability semantics:
|
||||
|
||||
- inbound events are durably admitted by `message_id` before processing
|
||||
- pending events replay on startup
|
||||
- startup catch-up fetches missed Discord messages from tracked thread sources and allowed channels using persisted offsets
|
||||
- response text is cached before Discord delivery so retries can re-publish without re-calling the model
|
||||
|
||||
Local CLI notes (`bun run conversation:cli`):
|
||||
|
||||
- `typing` now emits as soon as a target thread is resolved (before sandbox/session resolution), so startup latency is visible.
|
||||
- channel and thread modes are explicit:
|
||||
- `/channel` routes to top-level channel mode
|
||||
- `/thread [id|n]` routes directly to thread mode (`n` is 1-based index from `/threads`; without arg, uses last seen thread)
|
||||
- `/threads` lists known thread ids with indexes
|
||||
- `/pick [n]` shows/selects a thread by index
|
||||
- auto-switch from channel mode now only follows newly-seen threads (prevents jumping to old threads still emitting output)
|
||||
- local thread simulation now mirrors Discord intent: each channel-mode message creates a new thread root, while explicit thread mode continues an existing thread
|
||||
- local thread ids are human-readable (`thread-adjective-noun-n`) to make `/threads` easy to scan
|
||||
|
||||
Agent CLI notes (`bun run conversation:controller` or `bun run conversation:ctl`):
|
||||
|
||||
- non-interactive JSON output for automation (`ok: true/false`)
|
||||
- commands:
|
||||
- `active`
|
||||
- `status --thread <id>`
|
||||
- `logs --thread <id> [--lines 120]`
|
||||
- `pause --thread <id>`
|
||||
- `destroy --thread <id>`
|
||||
- `resume --thread <id> [--channel <id> --guild <id>]`
|
||||
- `restart --thread <id>`
|
||||
- `send --thread <id> --text "<message>" [--follow --wait-ms 180000 --logs-every-ms 2000 --lines 80]`
|
||||
456
packages/discord/src/conversation/control/cli.ts
Normal file
456
packages/discord/src/conversation/control/cli.ts
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
import { stdin, stdout } from "node:process"
|
||||
import readline from "node:readline/promises"
|
||||
import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic"
|
||||
import { FetchHttpClient } from "@effect/platform"
|
||||
import { BunContext, BunRuntime } from "@effect/platform-bun"
|
||||
import { Effect, Layer, LogLevel, Logger, Option, Stream } from "effect"
|
||||
import { AppConfig } from "../../config"
|
||||
import { TurnRouter } from "../../discord/turn-routing"
|
||||
import { SqliteDb } from "../../db/client"
|
||||
import { DaytonaService } from "../../sandbox/daytona"
|
||||
import { OpenCodeClient } from "../../sandbox/opencode-client"
|
||||
import { ThreadAgentPool } from "../../sandbox/pool"
|
||||
import { SandboxProvisioner } from "../../sandbox/provisioner"
|
||||
import { SessionStore } from "../../sessions/store"
|
||||
import { PreviewAccess, ThreadId } from "../../types"
|
||||
import type { Action } from "../model/schema"
|
||||
import { makeTui } from "../implementations/local"
|
||||
import { Conversation } from "../services/conversation"
|
||||
import { ConversationLedger } from "../services/ledger"
|
||||
import { autoThread, base, channelFrom, parse, prompt, scopeText, threadFrom } from "./state"
|
||||
|
||||
const AnthropicLayer = Layer.unwrapEffect(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* AppConfig
|
||||
return AnthropicLanguageModel.layer({ model: config.turnRoutingModel }).pipe(
|
||||
Layer.provide(AnthropicClient.layer({
|
||||
apiKey: config.openCodeZenApiKey,
|
||||
apiUrl: "https://opencode.ai/zen",
|
||||
})),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const BaseLayer = Layer.mergeAll(
|
||||
AppConfig.layer,
|
||||
FetchHttpClient.layer,
|
||||
BunContext.layer,
|
||||
Logger.minimumLogLevel(LogLevel.Warning),
|
||||
)
|
||||
const WithSqlite = Layer.provideMerge(SqliteDb.layer, BaseLayer)
|
||||
const WithAnthropic = Layer.provideMerge(AnthropicLayer, WithSqlite)
|
||||
const WithDaytona = Layer.provideMerge(DaytonaService.layer, WithAnthropic)
|
||||
const WithOpenCode = Layer.provideMerge(OpenCodeClient.layer, WithDaytona)
|
||||
const WithRouting = Layer.provideMerge(TurnRouter.layer, WithOpenCode)
|
||||
const WithSessions = Layer.provideMerge(SessionStore.layer, WithRouting)
|
||||
const WithProvisioner = Layer.provideMerge(SandboxProvisioner.layer, WithSessions)
|
||||
const CoreLayer = Layer.provideMerge(ThreadAgentPool.layer, WithProvisioner)
|
||||
|
||||
const colors = {
|
||||
reset: "\x1b[0m",
|
||||
dim: "\x1b[2m",
|
||||
cyan: "\x1b[36m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
red: "\x1b[31m",
|
||||
} as const
|
||||
|
||||
const now = () => new Date().toLocaleTimeString("en-US", { hour12: false })
|
||||
|
||||
const run = Effect.gen(function* () {
|
||||
const tui = yield* makeTui
|
||||
const layer = Conversation.layer.pipe(
|
||||
Layer.provideMerge(tui.layer),
|
||||
Layer.provideMerge(ConversationLedger.noop),
|
||||
Layer.provideMerge(CoreLayer),
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
const config = yield* AppConfig
|
||||
const pool = yield* ThreadAgentPool
|
||||
const daytona = yield* DaytonaService
|
||||
const oc = yield* OpenCodeClient
|
||||
const sessions = yield* SessionStore
|
||||
const rl = readline.createInterface({ input: stdin, output: stdout, terminal: true })
|
||||
const restart =
|
||||
'pkill -f \'opencode serve --port 4096\' >/dev/null 2>&1 || true; for d in "$HOME/opencode" "/home/daytona/opencode" "/root/opencode"; do if [ -d "$d" ]; then cd "$d" && setsid opencode serve --port 4096 --hostname 0.0.0.0 > /tmp/opencode.log 2>&1 & exit 0; fi; done; exit 1'
|
||||
let scope = base()
|
||||
let pending = 0
|
||||
let last: ThreadId | null = null
|
||||
const seen = new Set<ThreadId>()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
rl.close()
|
||||
}),
|
||||
)
|
||||
|
||||
const draw = (line: string, keep = true): Effect.Effect<void> =>
|
||||
Effect.sync(() => {
|
||||
stdout.write(`\r\x1b[2K${line}\n`)
|
||||
if (keep) {
|
||||
stdout.write(`${prompt(scope)}${rl.line}`)
|
||||
}
|
||||
})
|
||||
|
||||
const stamp = (label: string, color: string, text: string) =>
|
||||
`${colors.dim}${now()}${colors.reset} ${color}${label}${colors.reset} ${text}`
|
||||
|
||||
const info = (text: string): Effect.Effect<void> => draw(stamp("info", colors.blue, text), false)
|
||||
|
||||
const block = (head: string, body: string): Effect.Effect<void> =>
|
||||
Effect.sync(() => {
|
||||
stdout.write(`\r\x1b[2K${head}\n${body}\n`)
|
||||
stdout.write(`${prompt(scope)}${rl.line}`)
|
||||
})
|
||||
|
||||
const noteThread = (thread_id: ThreadId): Effect.Effect<void> =>
|
||||
Effect.sync(() => {
|
||||
seen.add(thread_id)
|
||||
last = thread_id
|
||||
})
|
||||
|
||||
const pick = (thread_id: ThreadId | null): ThreadId | null => {
|
||||
if (thread_id) return thread_id
|
||||
if (scope.kind === "thread") return scope.thread_id
|
||||
return last
|
||||
}
|
||||
const list = () => Array.from(seen)
|
||||
const byIndex = (index: number) => list().at(index - 1) ?? null
|
||||
const fromRef = (thread_id: ThreadId | null) => {
|
||||
if (!thread_id) return null
|
||||
const raw = `${thread_id}`.trim()
|
||||
if (!/^\d+$/.test(raw)) return thread_id
|
||||
const index = Number(raw)
|
||||
if (!Number.isInteger(index) || index <= 0) return null
|
||||
return byIndex(index)
|
||||
}
|
||||
|
||||
const tracked = (thread_id: ThreadId) =>
|
||||
pool.getTrackedSession(thread_id).pipe(
|
||||
Effect.map((row) => Option.isSome(row) ? row.value : null),
|
||||
Effect.catchAll(() => Effect.succeed(null)),
|
||||
)
|
||||
|
||||
const sessionText = (thread_id: ThreadId, session: {
|
||||
status: string
|
||||
sandboxId: string
|
||||
sessionId: string
|
||||
resumeFailCount: number
|
||||
lastError: string | null
|
||||
}) =>
|
||||
`${colors.dim}${thread_id}${colors.reset} status=${session.status} sandbox=${session.sandboxId} session=${session.sessionId} resume_failures=${session.resumeFailCount}${session.lastError ? ` error=${session.lastError.slice(0, 120)}` : ""}`
|
||||
|
||||
const render = (action: Action) => {
|
||||
if (action.kind === "typing") {
|
||||
return stamp("typing", colors.yellow, `${colors.dim}[${action.thread_id}]${colors.reset}`)
|
||||
}
|
||||
return stamp("assistant", colors.cyan, `${colors.dim}[${action.thread_id}]${colors.reset} ${action.text}`)
|
||||
}
|
||||
|
||||
yield* draw(
|
||||
stamp(
|
||||
"ready",
|
||||
colors.yellow,
|
||||
`${colors.dim}Type messages. /thread [id|n], /pick [n], /channel, /threads, /status, /logs, /restart, /pause, /destroy, /resume, /active, /help, /exit${colors.reset}`,
|
||||
),
|
||||
false,
|
||||
)
|
||||
|
||||
yield* Effect.forkScoped(
|
||||
Stream.runForEach(
|
||||
tui.actions,
|
||||
(action) =>
|
||||
Effect.gen(function* () {
|
||||
const known = seen.has(action.thread_id)
|
||||
yield* noteThread(action.thread_id)
|
||||
const next = autoThread(scope, action, known)
|
||||
const switched = scope.kind === "channel" && next.kind === "thread"
|
||||
scope = next
|
||||
if (switched) {
|
||||
yield* info(`${colors.dim}using ${scopeText(scope)} (/channel to go back)${colors.reset}`)
|
||||
}
|
||||
if ((action.kind === "send" || action.kind === "reply") && pending > 0) {
|
||||
pending -= 1
|
||||
}
|
||||
yield* draw(render(action))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
yield* Effect.forkScoped(conversation.run)
|
||||
|
||||
const queue = (text: string) =>
|
||||
Effect.gen(function* () {
|
||||
const target = scopeText(scope)
|
||||
if (scope.kind === "channel") {
|
||||
yield* tui.send(text)
|
||||
} else {
|
||||
yield* tui.sendTo(scope.thread_id, text)
|
||||
}
|
||||
pending += 1
|
||||
yield* draw(stamp("queued", colors.green, `${colors.dim}[${target}]${colors.reset} ${text}`), false)
|
||||
yield* Effect.fork(
|
||||
Effect.suspend(() =>
|
||||
pending > 0
|
||||
? draw(stamp("waiting", colors.yellow, `${colors.dim}[${target}] preparing sandbox/session...${colors.reset}`), false)
|
||||
: Effect.void,
|
||||
).pipe(
|
||||
Effect.delay("2 seconds"),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const command = (text: string) =>
|
||||
Effect.gen(function* () {
|
||||
const cmd = parse(text)
|
||||
if (!cmd) return false
|
||||
|
||||
if (cmd.kind === "help") {
|
||||
yield* info(
|
||||
`${colors.dim}/thread [id|n], /pick [n], /channel, /threads, /status [thread], /logs [lines] [thread], /restart [thread], /pause [thread], /destroy [thread], /resume [thread], /active, /exit${colors.reset}`,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "threads") {
|
||||
if (seen.size === 0) {
|
||||
yield* info(`${colors.dim}no known threads yet${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* info(`${colors.dim}${list().map((id, i) => `${i + 1}:${id}`).join(", ")}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "pick") {
|
||||
if (seen.size === 0) {
|
||||
yield* info(`${colors.dim}no known threads yet${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
if (!cmd.index) {
|
||||
yield* info(`${colors.dim}${list().map((id, i) => `${i + 1}:${id}`).join(", ")}${colors.reset}`)
|
||||
yield* info(`${colors.dim}pick one with /pick <n>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
const thread_id = byIndex(cmd.index)
|
||||
if (!thread_id) {
|
||||
yield* info(`${colors.dim}invalid thread index ${cmd.index}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
scope = threadFrom(scope, thread_id)
|
||||
yield* info(`${colors.dim}using ${scopeText(scope)}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "active") {
|
||||
yield* sessions.listActive().pipe(
|
||||
Effect.matchEffect({
|
||||
onFailure: (error) =>
|
||||
info(`${colors.red}active query failed${colors.reset} ${String(error)}`),
|
||||
onSuccess: (active) =>
|
||||
active.length === 0
|
||||
? info(`${colors.dim}no active sessions${colors.reset}`)
|
||||
: info(`${colors.dim}${active.map((s) => `${s.threadId}(${s.status})`).join(", ")}${colors.reset}`),
|
||||
}),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "channel") {
|
||||
scope = channelFrom(scope)
|
||||
yield* info(`${colors.dim}using ${scopeText(scope)}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "thread") {
|
||||
const selected = fromRef(cmd.thread_id)
|
||||
if (selected) {
|
||||
scope = threadFrom(scope, selected)
|
||||
yield* noteThread(selected)
|
||||
yield* info(`${colors.dim}using ${scopeText(scope)}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
if (cmd.thread_id) {
|
||||
yield* info(`${colors.dim}invalid thread id/index${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
if (last) {
|
||||
scope = threadFrom(scope, last)
|
||||
yield* info(`${colors.dim}using ${scopeText(scope)}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* info(`${colors.dim}no thread id yet. use /thread <id>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "status") {
|
||||
const thread_id = pick(cmd.thread_id)
|
||||
if (!thread_id) {
|
||||
yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* noteThread(thread_id)
|
||||
const session = yield* tracked(thread_id)
|
||||
if (!session) {
|
||||
yield* info(`${colors.dim}no tracked session for ${thread_id}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* info(sessionText(thread_id, session))
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "logs") {
|
||||
const thread_id = pick(cmd.thread_id)
|
||||
if (!thread_id) {
|
||||
yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* noteThread(thread_id)
|
||||
const session = yield* tracked(thread_id)
|
||||
if (!session) {
|
||||
yield* info(`${colors.dim}no tracked session for ${thread_id}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* daytona.exec(
|
||||
session.sandboxId,
|
||||
"read-opencode-log",
|
||||
`cat /tmp/opencode.log 2>/dev/null | tail -${cmd.lines}`,
|
||||
).pipe(
|
||||
Effect.matchEffect({
|
||||
onFailure: (error) =>
|
||||
info(`${colors.red}log read failed${colors.reset} ${String(error)}`),
|
||||
onSuccess: (result) =>
|
||||
block(
|
||||
stamp("logs", colors.blue, `${colors.dim}[${thread_id}]${colors.reset}`),
|
||||
result.output.trim() || "(empty log)",
|
||||
),
|
||||
}),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "pause") {
|
||||
const thread_id = pick(cmd.thread_id)
|
||||
if (!thread_id) {
|
||||
yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* noteThread(thread_id)
|
||||
yield* pool.pauseSession(thread_id, "manual-cli").pipe(
|
||||
Effect.matchEffect({
|
||||
onFailure: (error) =>
|
||||
info(`${colors.red}pause failed${colors.reset} ${String(error)}`),
|
||||
onSuccess: () =>
|
||||
info(`${colors.dim}paused ${thread_id}${colors.reset}`),
|
||||
}),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "destroy") {
|
||||
const thread_id = pick(cmd.thread_id)
|
||||
if (!thread_id) {
|
||||
yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* noteThread(thread_id)
|
||||
yield* pool.destroySession(thread_id).pipe(
|
||||
Effect.matchEffect({
|
||||
onFailure: (error) =>
|
||||
info(`${colors.red}destroy failed${colors.reset} ${String(error)}`),
|
||||
onSuccess: () =>
|
||||
info(`${colors.dim}destroyed ${thread_id}${colors.reset}`),
|
||||
}),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "resume") {
|
||||
const thread_id = pick(cmd.thread_id)
|
||||
if (!thread_id) {
|
||||
yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* noteThread(thread_id)
|
||||
const session = yield* tracked(thread_id)
|
||||
if (!session) {
|
||||
yield* info(`${colors.dim}no tracked session for ${thread_id}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* pool.getOrCreate(thread_id, session.channelId, session.guildId).pipe(
|
||||
Effect.matchEffect({
|
||||
onFailure: (error) =>
|
||||
info(`${colors.red}resume failed${colors.reset} ${String(error)}`),
|
||||
onSuccess: (agent) =>
|
||||
agent.current().pipe(
|
||||
Effect.flatMap((current) =>
|
||||
info(
|
||||
`${colors.dim}resumed ${thread_id} sandbox=${current.sandboxId} session=${current.sessionId}${colors.reset}`,
|
||||
),
|
||||
),
|
||||
Effect.catchAll((error) =>
|
||||
info(`${colors.red}resume failed${colors.reset} ${String(error)}`),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "restart") {
|
||||
const thread_id = pick(cmd.thread_id)
|
||||
if (!thread_id) {
|
||||
yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* noteThread(thread_id)
|
||||
const session = yield* tracked(thread_id)
|
||||
if (!session) {
|
||||
yield* info(`${colors.dim}no tracked session for ${thread_id}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
const restarted = yield* daytona.exec(session.sandboxId, "restart-opencode-serve", restart).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catchAll((error) =>
|
||||
info(`${colors.red}restart failed${colors.reset} ${String(error)}`).pipe(Effect.as(false)),
|
||||
),
|
||||
)
|
||||
if (!restarted) return true
|
||||
const healthy = yield* oc.waitForHealthy(PreviewAccess.from(session), config.activeHealthCheckTimeoutMs).pipe(
|
||||
Effect.catchAll(() => Effect.succeed(false)),
|
||||
)
|
||||
if (!healthy) {
|
||||
yield* info(`${colors.red}restart ran, but health check failed${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* info(`${colors.dim}restart complete and healthy${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
|
||||
yield* info(`${colors.dim}unknown command: /${cmd.name}${colors.reset}`)
|
||||
return true
|
||||
})
|
||||
|
||||
const loop: Effect.Effect<void> = Effect.gen(function* () {
|
||||
const text = (yield* Effect.promise(() => rl.question(prompt(scope)))).trim()
|
||||
if (!text) return yield* loop
|
||||
if (text === "/exit" || text === "exit" || text === "quit") return
|
||||
const handled = yield* command(text)
|
||||
if (handled) return yield* loop
|
||||
yield* queue(text)
|
||||
return yield* loop
|
||||
})
|
||||
|
||||
yield* loop
|
||||
}).pipe(
|
||||
Effect.provide(layer),
|
||||
Effect.scoped,
|
||||
)
|
||||
})
|
||||
|
||||
run.pipe(
|
||||
Logger.withMinimumLogLevel(LogLevel.Warning),
|
||||
BunRuntime.runMain,
|
||||
)
|
||||
436
packages/discord/src/conversation/control/controller.ts
Normal file
436
packages/discord/src/conversation/control/controller.ts
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
import { FetchHttpClient } from "@effect/platform"
|
||||
import { BunContext, BunRuntime } from "@effect/platform-bun"
|
||||
import { Duration, Effect, Exit, Fiber, Layer, LogLevel, Logger, Option, Ref, Schema } from "effect"
|
||||
import { AppConfig } from "../../config"
|
||||
import { SqliteDb } from "../../db/client"
|
||||
import { DaytonaService } from "../../sandbox/daytona"
|
||||
import { OpenCodeClient } from "../../sandbox/opencode-client"
|
||||
import { ThreadAgentPool } from "../../sandbox/pool"
|
||||
import { SandboxProvisioner } from "../../sandbox/provisioner"
|
||||
import { SessionStore } from "../../sessions/store"
|
||||
import { ChannelId, GuildId, PreviewAccess, SandboxId, SessionInfo, ThreadId } from "../../types"
|
||||
|
||||
const BaseLayer = Layer.mergeAll(
|
||||
AppConfig.layer,
|
||||
FetchHttpClient.layer,
|
||||
BunContext.layer,
|
||||
Logger.minimumLogLevel(LogLevel.None),
|
||||
)
|
||||
const WithSqlite = Layer.provideMerge(SqliteDb.layer, BaseLayer)
|
||||
const WithDaytona = Layer.provideMerge(DaytonaService.layer, WithSqlite)
|
||||
const WithOpenCode = Layer.provideMerge(OpenCodeClient.layer, WithDaytona)
|
||||
const WithSessions = Layer.provideMerge(SessionStore.layer, WithOpenCode)
|
||||
const WithProvisioner = Layer.provideMerge(SandboxProvisioner.layer, WithSessions)
|
||||
const CoreLayer = Layer.provideMerge(ThreadAgentPool.layer, WithProvisioner)
|
||||
|
||||
const restart =
|
||||
'pkill -f \'opencode serve --port 4096\' >/dev/null 2>&1 || true; for d in "$HOME/opencode" "/home/daytona/opencode" "/root/opencode"; do if [ -d "$d" ]; then cd "$d" && setsid opencode serve --port 4096 --hostname 0.0.0.0 > /tmp/opencode.log 2>&1 & exit 0; fi; done; exit 1'
|
||||
|
||||
type Opt = Record<string, string | boolean>
|
||||
|
||||
class CtlUsageError extends Schema.TaggedError<CtlUsageError>()("CtlUsageError", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
class CtlInternalError extends Schema.TaggedError<CtlInternalError>()("CtlInternalError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.Defect,
|
||||
}) {}
|
||||
|
||||
const usage = (message: string) => CtlUsageError.make({ message })
|
||||
const internal = (cause: unknown, message = text(cause)) => CtlInternalError.make({ message, cause })
|
||||
|
||||
const text = (cause: unknown) => {
|
||||
if (cause instanceof Error) return cause.message
|
||||
if (typeof cause === "object" && cause !== null && "_tag" in cause && "message" in cause) {
|
||||
const tag = (cause as { _tag?: unknown })._tag
|
||||
const message = (cause as { message?: unknown }).message
|
||||
if (typeof tag === "string" && typeof message === "string") return `${tag}: ${message}`
|
||||
}
|
||||
if (typeof cause === "object" && cause !== null) return String(cause)
|
||||
return String(cause)
|
||||
}
|
||||
|
||||
const parse = (argv: ReadonlyArray<string>) => {
|
||||
const input = argv.slice(2)
|
||||
const cmd = input.at(0)?.toLowerCase() ?? "help"
|
||||
const scan = input.slice(1).reduce(
|
||||
(state: { opts: Opt; args: ReadonlyArray<string>; key: string | null }, token) => {
|
||||
if (token.startsWith("--")) {
|
||||
const key = token.slice(2)
|
||||
if (key.length === 0) return state
|
||||
if (state.key) {
|
||||
return {
|
||||
opts: { ...state.opts, [state.key]: true },
|
||||
args: state.args,
|
||||
key,
|
||||
}
|
||||
}
|
||||
return { ...state, key }
|
||||
}
|
||||
if (state.key) {
|
||||
return {
|
||||
opts: { ...state.opts, [state.key]: token },
|
||||
args: state.args,
|
||||
key: null,
|
||||
}
|
||||
}
|
||||
return { ...state, args: [...state.args, token] }
|
||||
},
|
||||
{ opts: {} as Opt, args: [] as ReadonlyArray<string>, key: null as string | null },
|
||||
)
|
||||
if (!scan.key) return { cmd, opts: scan.opts, args: scan.args }
|
||||
return {
|
||||
cmd,
|
||||
opts: { ...scan.opts, [scan.key]: true },
|
||||
args: scan.args,
|
||||
}
|
||||
}
|
||||
|
||||
const value = (opts: Opt, key: string) => {
|
||||
const raw = opts[key]
|
||||
if (typeof raw !== "string") return null
|
||||
const out = raw.trim()
|
||||
if (!out) return null
|
||||
return out
|
||||
}
|
||||
|
||||
const number = (opts: Opt, key: string, fallback: number) => {
|
||||
const raw = value(opts, key)
|
||||
if (!raw) return fallback
|
||||
const out = Number(raw)
|
||||
if (!Number.isInteger(out) || out <= 0) return fallback
|
||||
return out
|
||||
}
|
||||
|
||||
const flag = (opts: Opt, key: string) => {
|
||||
const raw = opts[key]
|
||||
if (raw === true) return true
|
||||
if (typeof raw !== "string") return false
|
||||
return raw === "1" || raw.toLowerCase() === "true" || raw.toLowerCase() === "yes"
|
||||
}
|
||||
|
||||
let ctlSeq = 0
|
||||
const pick = (opts: Opt, active: ReadonlyArray<{ threadId: ThreadId }>) => {
|
||||
const raw = value(opts, "thread")
|
||||
if (raw) return Effect.succeed(ThreadId.make(raw))
|
||||
if (active.length === 1) return Effect.succeed(active[0].threadId)
|
||||
if (active.length > 1) return Effect.fail(usage("missing --thread (multiple active sessions)"))
|
||||
ctlSeq += 1
|
||||
return Effect.succeed(ThreadId.make(`ctl-${ctlSeq}`))
|
||||
}
|
||||
|
||||
const print = (ok: boolean, command: string, payload: Record<string, unknown>) =>
|
||||
Effect.sync(() => {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
ok,
|
||||
command,
|
||||
...payload,
|
||||
}, null, 2)}\n`,
|
||||
)
|
||||
})
|
||||
|
||||
const event = (command: string, name: string, payload: Record<string, unknown>) =>
|
||||
Effect.sync(() => {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
ok: true,
|
||||
command,
|
||||
event: name,
|
||||
...payload,
|
||||
})}\n`,
|
||||
)
|
||||
})
|
||||
|
||||
const run = Effect.gen(function* () {
|
||||
const ctl = parse(process.argv)
|
||||
const config = yield* AppConfig
|
||||
const pool = yield* ThreadAgentPool
|
||||
const sessions = yield* SessionStore
|
||||
const daytona = yield* DaytonaService
|
||||
const oc = yield* OpenCodeClient
|
||||
const active = yield* sessions.listActive()
|
||||
const tracked = (thread_id: ThreadId) =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* pool.getTrackedSession(thread_id)
|
||||
if (Option.isSome(row)) return row.value
|
||||
return yield* usage(`no tracked session for thread ${thread_id}`)
|
||||
})
|
||||
const resolve = (thread_id: ThreadId, opts: Opt): Effect.Effect<SessionInfo, CtlUsageError | CtlInternalError> =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* pool.getTrackedSession(thread_id).pipe(
|
||||
Effect.catchAll((cause) =>
|
||||
internal(cause),
|
||||
),
|
||||
)
|
||||
if (Option.isSome(row)) {
|
||||
const agent = yield* pool.getOrCreate(thread_id, row.value.channelId, row.value.guildId).pipe(
|
||||
Effect.catchAll((cause) =>
|
||||
internal(cause),
|
||||
),
|
||||
)
|
||||
return yield* agent.current().pipe(
|
||||
Effect.catchAll((cause) =>
|
||||
internal(cause),
|
||||
),
|
||||
)
|
||||
}
|
||||
const channel = value(opts, "channel") ?? "ctl"
|
||||
const guild = value(opts, "guild") ?? "local"
|
||||
const agent = yield* pool.getOrCreate(thread_id, ChannelId.make(channel), GuildId.make(guild)).pipe(
|
||||
Effect.catchAll((cause) =>
|
||||
internal(cause),
|
||||
),
|
||||
)
|
||||
return yield* agent.current().pipe(
|
||||
Effect.catchAll((cause) =>
|
||||
internal(cause),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
if (ctl.cmd === "help") {
|
||||
return yield* print(true, ctl.cmd, {
|
||||
usage: [
|
||||
"conversation:ctl active",
|
||||
"conversation:ctl status --thread <id>",
|
||||
"conversation:ctl logs --thread <id> [--lines 120]",
|
||||
"conversation:ctl pause --thread <id>",
|
||||
"conversation:ctl destroy --thread <id>",
|
||||
"conversation:ctl resume --thread <id> [--channel <id> --guild <id>]",
|
||||
"conversation:ctl restart --thread <id>",
|
||||
"conversation:ctl send --thread <id> --text <message> [--follow --wait-ms 180000 --logs-every-ms 2000 --lines 80]",
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (ctl.cmd === "active") {
|
||||
return yield* print(true, ctl.cmd, {
|
||||
count: active.length,
|
||||
sessions: active.map((row) => ({
|
||||
threadId: row.threadId,
|
||||
channelId: row.channelId,
|
||||
guildId: row.guildId,
|
||||
sandboxId: row.sandboxId,
|
||||
sessionId: row.sessionId,
|
||||
status: row.status,
|
||||
resumeFailCount: row.resumeFailCount,
|
||||
lastError: row.lastError,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
if (ctl.cmd === "status") {
|
||||
const thread_id = yield* pick(ctl.opts, active)
|
||||
return yield* pool.getTrackedSession(thread_id).pipe(
|
||||
Effect.flatMap((row) =>
|
||||
print(true, ctl.cmd, {
|
||||
threadId: thread_id,
|
||||
tracked: Option.isSome(row),
|
||||
session: Option.isSome(row)
|
||||
? {
|
||||
threadId: row.value.threadId,
|
||||
channelId: row.value.channelId,
|
||||
guildId: row.value.guildId,
|
||||
sandboxId: row.value.sandboxId,
|
||||
sessionId: row.value.sessionId,
|
||||
status: row.value.status,
|
||||
resumeFailCount: row.value.resumeFailCount,
|
||||
lastError: row.value.lastError,
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
if (ctl.cmd === "logs") {
|
||||
const thread_id = yield* pick(ctl.opts, active)
|
||||
const lines = number(ctl.opts, "lines", 120)
|
||||
const row = yield* tracked(thread_id)
|
||||
const out = yield* daytona.exec(
|
||||
row.sandboxId,
|
||||
"read-opencode-log",
|
||||
`cat /tmp/opencode.log 2>/dev/null | tail -${lines}`,
|
||||
)
|
||||
return yield* print(true, ctl.cmd, {
|
||||
threadId: thread_id,
|
||||
sandboxId: row.sandboxId,
|
||||
lines,
|
||||
output: out.output,
|
||||
})
|
||||
}
|
||||
|
||||
if (ctl.cmd === "pause") {
|
||||
const thread_id = yield* pick(ctl.opts, active)
|
||||
yield* pool.pauseSession(thread_id, "manual-ctl")
|
||||
return yield* print(true, ctl.cmd, { threadId: thread_id })
|
||||
}
|
||||
|
||||
if (ctl.cmd === "destroy") {
|
||||
const thread_id = yield* pick(ctl.opts, active)
|
||||
yield* pool.destroySession(thread_id)
|
||||
return yield* print(true, ctl.cmd, { threadId: thread_id })
|
||||
}
|
||||
|
||||
if (ctl.cmd === "resume") {
|
||||
const thread_id = yield* pick(ctl.opts, active)
|
||||
const row = yield* resolve(thread_id, ctl.opts)
|
||||
return yield* print(true, ctl.cmd, {
|
||||
threadId: thread_id,
|
||||
session: {
|
||||
sandboxId: row.sandboxId,
|
||||
sessionId: row.sessionId,
|
||||
status: row.status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (ctl.cmd === "restart") {
|
||||
const thread_id = yield* pick(ctl.opts, active)
|
||||
const row = yield* resolve(thread_id, ctl.opts)
|
||||
yield* daytona.exec(row.sandboxId, "restart-opencode-serve", restart)
|
||||
const healthy = yield* oc.waitForHealthy(PreviewAccess.from(row), config.activeHealthCheckTimeoutMs).pipe(
|
||||
Effect.catchAll(() => Effect.succeed(false)),
|
||||
)
|
||||
return yield* print(true, ctl.cmd, {
|
||||
threadId: thread_id,
|
||||
sandboxId: row.sandboxId,
|
||||
healthy,
|
||||
})
|
||||
}
|
||||
|
||||
if (ctl.cmd === "send") {
|
||||
const thread_id = yield* pick(ctl.opts, active)
|
||||
const message = value(ctl.opts, "text") ?? ctl.args.join(" ").trim()
|
||||
if (!message) {
|
||||
return yield* usage("missing message text (pass --text \"...\")")
|
||||
}
|
||||
const wait = number(ctl.opts, "wait-ms", 0)
|
||||
const every = number(ctl.opts, "logs-every-ms", 2000)
|
||||
const lines = number(ctl.opts, "lines", 80)
|
||||
const follow = flag(ctl.opts, "follow") || wait > 0
|
||||
|
||||
if (!follow) {
|
||||
const row = yield* resolve(thread_id, ctl.opts)
|
||||
const agent = yield* pool.getOrCreate(thread_id, row.channelId, row.guildId)
|
||||
const reply = yield* agent.send(message)
|
||||
const current = yield* agent.current()
|
||||
return yield* print(true, ctl.cmd, {
|
||||
threadId: thread_id,
|
||||
sandboxId: current.sandboxId,
|
||||
sessionId: current.sessionId,
|
||||
reply,
|
||||
})
|
||||
}
|
||||
|
||||
const known = yield* pool.getTrackedSession(thread_id).pipe(
|
||||
Effect.catchAll(() => Effect.succeed(Option.none())),
|
||||
)
|
||||
const sandbox = yield* Ref.make<SandboxId | null>(Option.isSome(known) ? known.value.sandboxId : null)
|
||||
const last = yield* Ref.make<string>("")
|
||||
const started = Date.now()
|
||||
|
||||
const fiber = yield* Effect.fork(
|
||||
Effect.gen(function* () {
|
||||
const row = yield* resolve(thread_id, ctl.opts)
|
||||
yield* Ref.set(sandbox, row.sandboxId)
|
||||
const agent = yield* pool.getOrCreate(thread_id, row.channelId, row.guildId)
|
||||
const reply = yield* agent.send(message)
|
||||
const current = yield* agent.current()
|
||||
return { row: current, reply }
|
||||
}),
|
||||
)
|
||||
|
||||
yield* event(ctl.cmd, "started", {
|
||||
threadId: thread_id,
|
||||
waitMs: wait,
|
||||
logsEveryMs: every,
|
||||
lines,
|
||||
})
|
||||
|
||||
const waitTick = Effect.void.pipe(Effect.delay(Duration.millis(every)))
|
||||
const loop = (): Effect.Effect<{ row: SessionInfo; reply: string }, unknown> =>
|
||||
Effect.gen(function* () {
|
||||
const done = yield* Fiber.poll(fiber)
|
||||
if (Option.isSome(done)) {
|
||||
if (Exit.isSuccess(done.value)) return done.value.value
|
||||
return yield* Effect.failCause(done.value.cause)
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - started
|
||||
if (wait > 0 && elapsed >= wait) {
|
||||
yield* Fiber.interrupt(fiber)
|
||||
return yield* usage(`send timed out after ${wait}ms`)
|
||||
}
|
||||
|
||||
const sandboxId = yield* Ref.get(sandbox)
|
||||
if (!sandboxId) {
|
||||
yield* event(ctl.cmd, "progress", {
|
||||
threadId: thread_id,
|
||||
elapsedMs: elapsed,
|
||||
stage: "resolving-session",
|
||||
})
|
||||
yield* waitTick
|
||||
return yield* loop()
|
||||
}
|
||||
|
||||
const output = yield* daytona.exec(
|
||||
sandboxId,
|
||||
"read-opencode-log",
|
||||
`cat /tmp/opencode.log 2>/dev/null | tail -${lines}`,
|
||||
).pipe(
|
||||
Effect.map((row) => row.output),
|
||||
Effect.catchAll((cause) =>
|
||||
Effect.succeed(`(log read failed: ${text(cause)})`),
|
||||
),
|
||||
)
|
||||
|
||||
const previous = yield* Ref.get(last)
|
||||
if (output !== previous) {
|
||||
yield* Ref.set(last, output)
|
||||
yield* event(ctl.cmd, "progress", {
|
||||
threadId: thread_id,
|
||||
elapsedMs: elapsed,
|
||||
sandboxId,
|
||||
logs: output,
|
||||
})
|
||||
} else {
|
||||
yield* event(ctl.cmd, "progress", {
|
||||
threadId: thread_id,
|
||||
elapsedMs: elapsed,
|
||||
sandboxId,
|
||||
logs: "(no change)",
|
||||
})
|
||||
}
|
||||
|
||||
yield* waitTick
|
||||
return yield* loop()
|
||||
})
|
||||
|
||||
const result = yield* loop()
|
||||
return yield* print(true, ctl.cmd, {
|
||||
threadId: thread_id,
|
||||
sandboxId: result.row.sandboxId,
|
||||
sessionId: result.row.sessionId,
|
||||
reply: result.reply,
|
||||
})
|
||||
}
|
||||
|
||||
return yield* usage(`unknown command: ${ctl.cmd}`)
|
||||
}).pipe(
|
||||
Effect.catchAll((cause) => {
|
||||
const command = parse(process.argv).cmd
|
||||
return print(false, command, { error: text(cause) }).pipe(
|
||||
Effect.zipRight(Effect.sync(() => {
|
||||
process.exitCode = 1
|
||||
})),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
run.pipe(
|
||||
Effect.provide(CoreLayer),
|
||||
Effect.scoped,
|
||||
BunRuntime.runMain,
|
||||
)
|
||||
52
packages/discord/src/conversation/control/state.test.ts
Normal file
52
packages/discord/src/conversation/control/state.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import { ThreadId } from "../../types"
|
||||
import { Send, Typing } from "../model/schema"
|
||||
import { autoThread, base, channelFrom, parse, prompt, queueTarget, scopeText, threadFrom } from "./state"
|
||||
|
||||
describe("cli-state", () => {
|
||||
it("parses commands", () => {
|
||||
expect(parse("hello")).toBeNull()
|
||||
expect(parse("/help")).toEqual({ kind: "help" })
|
||||
expect(parse("/channel")).toEqual({ kind: "channel" })
|
||||
expect(parse("/threads")).toEqual({ kind: "threads" })
|
||||
expect(parse("/pick")).toEqual({ kind: "pick", index: null })
|
||||
expect(parse("/pick 2")).toEqual({ kind: "pick", index: 2 })
|
||||
expect(parse("/active")).toEqual({ kind: "active" })
|
||||
expect(parse("/thread")).toEqual({ kind: "thread", thread_id: null })
|
||||
expect(parse("/thread abc")).toEqual({ kind: "thread", thread_id: ThreadId.make("abc") })
|
||||
expect(parse("/status")).toEqual({ kind: "status", thread_id: null })
|
||||
expect(parse("/status abc")).toEqual({ kind: "status", thread_id: ThreadId.make("abc") })
|
||||
expect(parse("/logs")).toEqual({ kind: "logs", lines: 120, thread_id: null })
|
||||
expect(parse("/logs 80")).toEqual({ kind: "logs", lines: 80, thread_id: null })
|
||||
expect(parse("/logs abc")).toEqual({ kind: "logs", lines: 120, thread_id: ThreadId.make("abc") })
|
||||
expect(parse("/logs 80 abc")).toEqual({ kind: "logs", lines: 80, thread_id: ThreadId.make("abc") })
|
||||
expect(parse("/pause")).toEqual({ kind: "pause", thread_id: null })
|
||||
expect(parse("/destroy")).toEqual({ kind: "destroy", thread_id: null })
|
||||
expect(parse("/resume")).toEqual({ kind: "resume", thread_id: null })
|
||||
expect(parse("/restart")).toEqual({ kind: "restart", thread_id: null })
|
||||
expect(parse("/nope")).toEqual({ kind: "unknown", name: "nope" })
|
||||
})
|
||||
|
||||
it("formats scope and prompt", () => {
|
||||
const a = base()
|
||||
const b = threadFrom(a, ThreadId.make("t1"))
|
||||
expect(scopeText(a)).toBe("channel:local-channel")
|
||||
expect(scopeText(b)).toBe("thread:t1")
|
||||
expect(prompt(a)).toBe("channel> ")
|
||||
expect(prompt(b)).toBe("thread:t1> ")
|
||||
expect(queueTarget(a)).toBe("channel")
|
||||
expect(queueTarget(b)).toBe("thread")
|
||||
expect(channelFrom(b)).toEqual(a)
|
||||
})
|
||||
|
||||
it("auto switches from channel to thread on action", () => {
|
||||
const a = base()
|
||||
const typing = Typing.make({ kind: "typing", thread_id: ThreadId.make("t-a") })
|
||||
const send = Send.make({ kind: "send", thread_id: ThreadId.make("t-b"), text: "ok" })
|
||||
|
||||
expect(autoThread(a, typing)).toEqual(threadFrom(a, ThreadId.make("t-a")))
|
||||
expect(autoThread(a, send)).toEqual(threadFrom(a, ThreadId.make("t-b")))
|
||||
expect(autoThread(a, send, true)).toEqual(a)
|
||||
expect(autoThread(threadFrom(a, ThreadId.make("t0")), send)).toEqual(threadFrom(a, ThreadId.make("t0")))
|
||||
})
|
||||
})
|
||||
104
packages/discord/src/conversation/control/state.ts
Normal file
104
packages/discord/src/conversation/control/state.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { ThreadId } from "../../types"
|
||||
import type { Action } from "../model/schema"
|
||||
|
||||
const LOCAL_CHANNEL = "local-channel" as const
|
||||
|
||||
export type Scope =
|
||||
| { kind: "channel"; channel_id: typeof LOCAL_CHANNEL }
|
||||
| { kind: "thread"; thread_id: ThreadId; channel_id: typeof LOCAL_CHANNEL }
|
||||
|
||||
export type Command =
|
||||
| { kind: "channel" }
|
||||
| { kind: "help" }
|
||||
| { kind: "threads" }
|
||||
| { kind: "pick"; index: number | null }
|
||||
| { kind: "active" }
|
||||
| { kind: "thread"; thread_id: ThreadId | null }
|
||||
| { kind: "status"; thread_id: ThreadId | null }
|
||||
| { kind: "logs"; thread_id: ThreadId | null; lines: number }
|
||||
| { kind: "pause"; thread_id: ThreadId | null }
|
||||
| { kind: "destroy"; thread_id: ThreadId | null }
|
||||
| { kind: "resume"; thread_id: ThreadId | null }
|
||||
| { kind: "restart"; thread_id: ThreadId | null }
|
||||
| { kind: "unknown"; name: string }
|
||||
|
||||
export const base = (): Scope => ({ kind: "channel", channel_id: LOCAL_CHANNEL })
|
||||
|
||||
const target = (value: string | undefined) => {
|
||||
const raw = value?.trim() ?? ""
|
||||
if (!raw) return null
|
||||
return ThreadId.make(raw)
|
||||
}
|
||||
|
||||
const parseLines = (raw: string | undefined) => {
|
||||
const n = Number(raw)
|
||||
if (!Number.isFinite(n)) return null
|
||||
if (!Number.isInteger(n)) return null
|
||||
if (n <= 0) return null
|
||||
return n
|
||||
}
|
||||
|
||||
const parseIndex = (raw: string | undefined) => {
|
||||
const n = Number(raw)
|
||||
if (!Number.isInteger(n) || n <= 0) return null
|
||||
return n
|
||||
}
|
||||
|
||||
export const parse = (line: string): Command | null => {
|
||||
const text = line.trim()
|
||||
if (!text.startsWith("/")) return null
|
||||
const parts = text.slice(1).split(/\s+/)
|
||||
const head = parts.at(0)?.toLowerCase() ?? ""
|
||||
const args = parts.slice(1)
|
||||
|
||||
if (head === "channel") return { kind: "channel" }
|
||||
if (head === "help") return { kind: "help" }
|
||||
if (head === "threads") return { kind: "threads" }
|
||||
if (head === "pick") return { kind: "pick", index: parseIndex(args.at(0)) }
|
||||
if (head === "active") return { kind: "active" }
|
||||
if (head === "thread") return { kind: "thread", thread_id: target(args.at(0)) }
|
||||
if (head === "status") return { kind: "status", thread_id: target(args.at(0)) }
|
||||
|
||||
if (head === "logs") {
|
||||
const lines = parseLines(args.at(0))
|
||||
if (lines === null) {
|
||||
return { kind: "logs", lines: 120, thread_id: target(args.at(0)) }
|
||||
}
|
||||
return { kind: "logs", lines, thread_id: target(args.at(1)) }
|
||||
}
|
||||
|
||||
if (head === "pause") return { kind: "pause", thread_id: target(args.at(0)) }
|
||||
if (head === "destroy") return { kind: "destroy", thread_id: target(args.at(0)) }
|
||||
if (head === "resume") return { kind: "resume", thread_id: target(args.at(0)) }
|
||||
if (head === "restart") return { kind: "restart", thread_id: target(args.at(0)) }
|
||||
|
||||
return { kind: "unknown", name: head }
|
||||
}
|
||||
|
||||
export const scopeText = (scope: Scope) => scope.kind === "channel"
|
||||
? `channel:${scope.channel_id}`
|
||||
: `thread:${scope.thread_id}`
|
||||
|
||||
export const prompt = (scope: Scope) => scope.kind === "channel"
|
||||
? "channel> "
|
||||
: `thread:${scope.thread_id}> `
|
||||
|
||||
export const queueTarget = (scope: Scope) => scope.kind === "channel" ? "channel" : "thread"
|
||||
|
||||
export const threadFrom = (scope: Scope, thread_id: ThreadId): Scope => ({
|
||||
kind: "thread",
|
||||
channel_id: scope.channel_id,
|
||||
thread_id,
|
||||
})
|
||||
|
||||
export const channelFrom = (scope: Scope): Scope => ({
|
||||
kind: "channel",
|
||||
channel_id: scope.channel_id,
|
||||
})
|
||||
|
||||
export const autoThread = (scope: Scope, action: Action, known = false): Scope => {
|
||||
if (scope.kind === "thread") return scope
|
||||
if (action.kind !== "typing" && action.kind !== "send" && action.kind !== "reply") return scope
|
||||
if (known) return scope
|
||||
return threadFrom(scope, action.thread_id)
|
||||
}
|
||||
|
|
@ -0,0 +1,849 @@
|
|||
import type { ChatInputCommandInteraction, GuildMember, Interaction, Message, TextChannel, ThreadChannel } from "discord.js"
|
||||
import { ChannelType, MessageFlags } from "discord.js"
|
||||
import { Context, Effect, Layer, Option, Queue, Ref, Runtime, Schedule, Stream } from "effect"
|
||||
import { AppConfig } from "../../../config"
|
||||
import { DiscordClient } from "../../../discord/client"
|
||||
import { TYPING_INTERVAL } from "../../../discord/constants"
|
||||
import { cleanResponse, splitForDiscord } from "../../../discord/format"
|
||||
import { ThreadAgentPool } from "../../../sandbox/pool"
|
||||
import { SessionStore } from "../../../sessions/store"
|
||||
import { ChannelId, GuildId, ThreadId } from "../../../types"
|
||||
import { DeliveryError, HistoryError, messageOf, ThreadEnsureError } from "../../model/errors"
|
||||
import { ChannelMessage, Mention, ThreadMessage, ThreadRef, Typing, type Action, type Inbound } from "../../model/schema"
|
||||
import { ConversationLedger, History, Inbox, Outbox, Threads } from "../../services"
|
||||
|
||||
type ChatChannel = TextChannel | ThreadChannel
|
||||
|
||||
const HISTORY_FETCH_LIMIT = 40
|
||||
const HISTORY_LINE_CHAR_LIMIT = 500
|
||||
const HISTORY_TOTAL_CHAR_LIMIT = 6000
|
||||
const INGRESS_DEDUP_LIMIT = 4_000
|
||||
const EMPTY_MENTION_REPLY = "Tag me with a question!"
|
||||
const SETUP_FAILURE_REPLY = "Something went wrong setting up the thread."
|
||||
const COMMAND_NOT_THREAD_REPLY = "Use this command inside a Discord thread."
|
||||
const COMMAND_FORBIDDEN_REPLY = "You don't have the required role for this command."
|
||||
const COMMAND_CHANNEL_REPLY = "This thread is not allowed for the bot."
|
||||
const COMMAND_ACK = "Running command in this thread..."
|
||||
const CATCHUP_PAGE_SIZE = 100
|
||||
const COMMANDS = [
|
||||
{
|
||||
name: "status",
|
||||
description: "Show sandbox status for this thread",
|
||||
},
|
||||
{
|
||||
name: "reset",
|
||||
description: "Destroy the sandbox session for this thread",
|
||||
},
|
||||
] as const
|
||||
|
||||
const commandText = (name: string): string => {
|
||||
if (name === "status") return "!status"
|
||||
if (name === "reset") return "!reset"
|
||||
return ""
|
||||
}
|
||||
|
||||
const isChannelAllowed = (channelId: string, categoryId: string | null, config: AppConfig.Service): boolean => {
|
||||
if (config.allowedChannelIds.length > 0 && config.allowedChannelIds.includes(channelId)) return true
|
||||
if (config.discordCategoryId && categoryId === config.discordCategoryId) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const hasRequiredRole = (member: GuildMember | null, config: AppConfig.Service): boolean => {
|
||||
if (!config.discordRequiredRoleId) return true
|
||||
if (!member) return false
|
||||
return member.roles.cache.has(config.discordRequiredRoleId)
|
||||
}
|
||||
|
||||
const asThreadChannel = (value: unknown): ThreadChannel | null => {
|
||||
if (typeof value !== "object" || value === null) return null
|
||||
const type = (value as { type?: unknown }).type
|
||||
if (type === ChannelType.PublicThread || type === ChannelType.PrivateThread) return value as ThreadChannel
|
||||
return null
|
||||
}
|
||||
|
||||
const asTextChannel = (value: unknown): TextChannel | null => {
|
||||
if (typeof value !== "object" || value === null) return null
|
||||
const type = (value as { type?: unknown }).type
|
||||
if (type === ChannelType.GuildText) return value as TextChannel
|
||||
return null
|
||||
}
|
||||
|
||||
const isMentioned = (message: Message, botUserId: string, botRoleId: string): boolean => {
|
||||
if (botUserId.length > 0 && message.mentions.users.has(botUserId)) return true
|
||||
if (botRoleId.length > 0 && message.mentions.roles.has(botRoleId)) return true
|
||||
if (botUserId.length > 0 && message.content.includes(`<@${botUserId}>`)) return true
|
||||
if (botUserId.length > 0 && message.content.includes(`<@!${botUserId}>`)) return true
|
||||
if (botRoleId.length > 0 && message.content.includes(`<@&${botRoleId}>`)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const buildHistoryReplayPrompt = Effect.fn("DiscordAdapter.buildHistoryReplayPrompt")(
|
||||
function* (channel: ChatChannel, latest: string) {
|
||||
const fetched = yield* Effect.tryPromise(() => channel.messages.fetch({ limit: HISTORY_FETCH_LIMIT }))
|
||||
const ordered = [...fetched.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp)
|
||||
const lines = ordered
|
||||
.filter((prior) => !prior.system)
|
||||
.flatMap((prior) => {
|
||||
const text = prior.content.replace(/\s+/g, " ").trim()
|
||||
const files = prior.attachments.size > 0
|
||||
? `[attachments: ${[...prior.attachments.values()].map((att) => att.name ?? "file").join(", ")}]`
|
||||
: ""
|
||||
const line = text || files
|
||||
if (!line) return []
|
||||
const value = line.length > HISTORY_LINE_CHAR_LIMIT ? `${line.slice(0, HISTORY_LINE_CHAR_LIMIT)}...` : line
|
||||
return [`${prior.author.bot ? "assistant" : "user"}: ${value}`]
|
||||
})
|
||||
|
||||
const prior = lines.at(-1) === `user: ${latest}` ? lines.slice(0, -1) : lines
|
||||
if (prior.length === 0) return latest
|
||||
|
||||
const selected = prior.reduceRight(
|
||||
(state, candidate) => {
|
||||
if (state.stop) return state
|
||||
if (state.total + candidate.length > HISTORY_TOTAL_CHAR_LIMIT && state.list.length > 0) {
|
||||
return { ...state, stop: true }
|
||||
}
|
||||
return { list: [candidate, ...state.list], total: state.total + candidate.length, stop: false }
|
||||
},
|
||||
{ list: [] as ReadonlyArray<string>, total: 0, stop: false },
|
||||
).list
|
||||
|
||||
return [
|
||||
"Conversation history from this same Discord thread (oldest to newest):",
|
||||
selected.join("\n"),
|
||||
"",
|
||||
"Continue the same conversation and respond to the latest user message:",
|
||||
latest,
|
||||
].join("\n")
|
||||
},
|
||||
)
|
||||
|
||||
const statusOf = (cause: unknown): number | null => {
|
||||
if (typeof cause !== "object" || cause === null) return null
|
||||
const status = (cause as { status?: unknown }).status
|
||||
if (typeof status === "number") return status
|
||||
const code = (cause as { code?: unknown }).code
|
||||
if (typeof code === "number") return code
|
||||
return null
|
||||
}
|
||||
|
||||
const deliveryRetriable = (cause: unknown): boolean => {
|
||||
const status = statusOf(cause)
|
||||
if (status === 429) return true
|
||||
if (status !== null && status >= 500) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const catchupBenign = (cause: unknown): boolean => {
|
||||
const text = messageOf(cause).toLowerCase()
|
||||
if (text.includes("missing access")) return true
|
||||
if (text.includes("missing permissions")) return true
|
||||
if (text.includes("unknown channel")) return true
|
||||
if (text.includes("50001")) return true
|
||||
if (text.includes("50013")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const deliveryRetry = Schedule.exponential("200 millis").pipe(
|
||||
Schedule.intersect(Schedule.recurs(3)),
|
||||
Schedule.whileInput((error: DeliveryError) => error.retriable),
|
||||
)
|
||||
|
||||
export class DiscordConversationServices {
|
||||
static readonly portLayer = Layer.scopedContext(
|
||||
Effect.gen(function* () {
|
||||
const client = yield* DiscordClient
|
||||
const config = yield* AppConfig
|
||||
const pool = yield* ThreadAgentPool
|
||||
const sessions = yield* SessionStore
|
||||
const ledger = yield* ConversationLedger
|
||||
const runtime = yield* Effect.runtime<never>()
|
||||
const input = yield* Queue.unbounded<Inbound>()
|
||||
const chats = new Map<string, ChatChannel>()
|
||||
const texts = new Map<string, TextChannel>()
|
||||
const refs = new Map<string, Message>()
|
||||
const roots = new Map<string, ThreadId>()
|
||||
const seen = new Set<string>()
|
||||
const order: Array<string> = []
|
||||
const ref_ids: Array<string> = []
|
||||
const root_ids: Array<string> = []
|
||||
|
||||
const mark = (message_id: string): boolean => {
|
||||
if (seen.has(message_id)) return false
|
||||
seen.add(message_id)
|
||||
order.push(message_id)
|
||||
if (order.length <= INGRESS_DEDUP_LIMIT) return true
|
||||
const oldest = order.shift()
|
||||
if (!oldest) return true
|
||||
seen.delete(oldest)
|
||||
return true
|
||||
}
|
||||
|
||||
const stash = <A>(map: Map<string, A>, keys: Array<string>, key: string, value: A) => {
|
||||
if (!map.has(key)) keys.push(key)
|
||||
map.set(key, value)
|
||||
if (keys.length <= INGRESS_DEDUP_LIMIT) return
|
||||
const oldest = keys.shift()
|
||||
if (!oldest) return
|
||||
map.delete(oldest)
|
||||
}
|
||||
|
||||
const sourceChannel = (channel_id: string) => `channel:${channel_id}`
|
||||
const sourceThread = (thread_id: string) => `thread:${thread_id}`
|
||||
const uniq = <A>(values: ReadonlyArray<A>): Array<A> => [...new Set(values)]
|
||||
|
||||
const offer = (event: Inbound, onFresh: Effect.Effect<void>) =>
|
||||
ledger.admit(event).pipe(
|
||||
Effect.flatMap((fresh) => {
|
||||
if (!fresh) {
|
||||
return Effect.logDebug("Message deduped (already seen)").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.message.deduped",
|
||||
message_id: event.message_id,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return Effect.logInfo("Message queued").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.message.queued",
|
||||
kind: event.kind,
|
||||
message_id: event.message_id,
|
||||
author_id: event.author_id,
|
||||
content: event.content.slice(0, 200),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.zipRight(onFresh),
|
||||
Effect.zipRight(input.offer(event)),
|
||||
Effect.asVoid,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const memberOf = (message: Message) => {
|
||||
if (message.member) return Effect.succeed(message.member)
|
||||
if (config.discordRequiredRoleId.length === 0) return Effect.succeed<GuildMember | null>(null)
|
||||
const guild = message.guild
|
||||
if (!guild) return Effect.succeed<GuildMember | null>(null)
|
||||
return Effect.tryPromise(() => guild.members.fetch(message.author.id)).pipe(
|
||||
Effect.catchAll(() => Effect.succeed<GuildMember | null>(null)),
|
||||
)
|
||||
}
|
||||
|
||||
const ingestMessage = Effect.fn("DiscordAdapter.ingestMessage")(function* (message: Message) {
|
||||
const source = message.channel.type === ChannelType.PublicThread || message.channel.type === ChannelType.PrivateThread
|
||||
? sourceThread(message.channel.id)
|
||||
: message.channel.type === ChannelType.GuildText
|
||||
? sourceChannel(message.channel.id)
|
||||
: null
|
||||
if (source === null) return
|
||||
|
||||
if (message.author.bot || message.mentions.everyone) {
|
||||
yield* ledger.setOffset(source, message.id)
|
||||
return
|
||||
}
|
||||
const member = yield* memberOf(message)
|
||||
if (!hasRequiredRole(member, config)) {
|
||||
yield* ledger.setOffset(source, message.id)
|
||||
return
|
||||
}
|
||||
|
||||
const bot_user_id = client.user?.id ?? ""
|
||||
const bot_role_id = config.discordRoleId
|
||||
const mentioned = isMentioned(message, bot_user_id, bot_role_id)
|
||||
const content = message.content.replace(/<@[!&]?\d+>/g, "").trim()
|
||||
const mentions = Mention.make({
|
||||
user_ids: [...message.mentions.users.keys()],
|
||||
role_ids: [...message.mentions.roles.keys()],
|
||||
})
|
||||
|
||||
if (!content && mentioned) {
|
||||
yield* Effect.tryPromise(() => message.reply(EMPTY_MENTION_REPLY)).pipe(Effect.catchAll(() => Effect.void))
|
||||
yield* ledger.setOffset(source, message.id)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.channel.type === ChannelType.PublicThread || message.channel.type === ChannelType.PrivateThread) {
|
||||
const thread = message.channel as ThreadChannel
|
||||
const thread_id = ThreadId.make(thread.id)
|
||||
const channel_id = ChannelId.make(thread.parentId ?? thread.id)
|
||||
const allowed = isChannelAllowed(thread.parentId ?? "", thread.parent?.parentId ?? null, config)
|
||||
|
||||
if (!allowed) {
|
||||
const owned = yield* pool.hasTrackedThread(thread_id).pipe(
|
||||
Effect.catchAll(() => Effect.succeed(false)),
|
||||
)
|
||||
if (!owned || mentioned) {
|
||||
yield* ledger.setOffset(source, message.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const event = ThreadMessage.make({
|
||||
kind: "thread_message",
|
||||
thread_id,
|
||||
channel_id,
|
||||
message_id: message.id,
|
||||
guild_id: GuildId.make(message.guildId ?? ""),
|
||||
bot_user_id,
|
||||
bot_role_id,
|
||||
author_id: message.author.id,
|
||||
author_is_bot: message.author.bot,
|
||||
mentions_everyone: message.mentions.everyone,
|
||||
mentions,
|
||||
content,
|
||||
})
|
||||
yield* offer(
|
||||
event,
|
||||
Effect.sync(() => {
|
||||
chats.set(event.thread_id, thread)
|
||||
stash(refs, ref_ids, event.message_id, message)
|
||||
}),
|
||||
)
|
||||
yield* ledger.setOffset(source, message.id)
|
||||
return
|
||||
}
|
||||
|
||||
const channel = message.channel as TextChannel
|
||||
if (!isChannelAllowed(channel.id, channel.parentId ?? null, config)) {
|
||||
yield* ledger.setOffset(source, message.id)
|
||||
return
|
||||
}
|
||||
|
||||
const event = ChannelMessage.make({
|
||||
kind: "channel_message",
|
||||
channel_id: ChannelId.make(channel.id),
|
||||
message_id: message.id,
|
||||
guild_id: GuildId.make(message.guildId ?? ""),
|
||||
bot_user_id,
|
||||
bot_role_id,
|
||||
author_id: message.author.id,
|
||||
author_is_bot: message.author.bot,
|
||||
mentions_everyone: message.mentions.everyone,
|
||||
mentions,
|
||||
content,
|
||||
})
|
||||
yield* offer(
|
||||
event,
|
||||
Effect.sync(() => {
|
||||
texts.set(event.channel_id, channel)
|
||||
stash(refs, ref_ids, event.message_id, message)
|
||||
}),
|
||||
)
|
||||
yield* ledger.setOffset(source, message.id)
|
||||
})
|
||||
|
||||
const onMessage = (message: Message): void => {
|
||||
if (!mark(message.id)) return
|
||||
const run = ingestMessage(message).pipe(
|
||||
Effect.catchAll((error) =>
|
||||
Effect.logError("Failed ingesting Discord message").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.ingest.failed",
|
||||
message_id: message.id,
|
||||
error: messageOf(error),
|
||||
}),
|
||||
)),
|
||||
)
|
||||
void Runtime.runPromise(runtime)(run)
|
||||
}
|
||||
|
||||
const pullAfter = (channel: ChatChannel, after: string): Effect.Effect<number, unknown> =>
|
||||
Effect.tryPromise(() =>
|
||||
channel.messages.fetch({
|
||||
limit: CATCHUP_PAGE_SIZE,
|
||||
after,
|
||||
})
|
||||
).pipe(
|
||||
Effect.map((page) => [...page.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp)),
|
||||
Effect.flatMap((rows) => {
|
||||
if (rows.length === 0) return Effect.succeed(0)
|
||||
const last = rows.at(-1)
|
||||
if (!last) return Effect.succeed(0)
|
||||
return Effect.forEach(rows, (row) => ingestMessage(row), { discard: true }).pipe(
|
||||
Effect.zipRight(
|
||||
rows.length < CATCHUP_PAGE_SIZE
|
||||
? Effect.succeed(rows.length)
|
||||
: pullAfter(channel, last.id).pipe(Effect.map((tail: number) => rows.length + tail)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const catchupSource = (source: string, channel: ChatChannel) =>
|
||||
Effect.gen(function* () {
|
||||
const offset = yield* ledger.getOffset(source)
|
||||
if (Option.isNone(offset)) {
|
||||
const page = yield* Effect.tryPromise(() => channel.messages.fetch({ limit: 1 }))
|
||||
const latest = page.first()
|
||||
if (latest) yield* ledger.setOffset(source, latest.id)
|
||||
return 0
|
||||
}
|
||||
return yield* pullAfter(channel, offset.value)
|
||||
})
|
||||
|
||||
const categoryChannels = () =>
|
||||
Effect.gen(function* () {
|
||||
if (config.discordCategoryId.length === 0) return [] as Array<string>
|
||||
const guilds = [...client.guilds.cache.values()]
|
||||
const nested = yield* Effect.forEach(
|
||||
guilds,
|
||||
(guild) =>
|
||||
Effect.tryPromise(() => guild.channels.fetch()).pipe(
|
||||
Effect.map((channels) =>
|
||||
[...channels.values()].flatMap((channel) => {
|
||||
const text = asTextChannel(channel)
|
||||
if (!text) return []
|
||||
if (text.parentId !== config.discordCategoryId) return []
|
||||
return [text.id]
|
||||
}),
|
||||
),
|
||||
Effect.catchAll(() => Effect.succeed([] as Array<string>)),
|
||||
),
|
||||
{ discard: false, concurrency: "unbounded" },
|
||||
)
|
||||
return nested.flat()
|
||||
})
|
||||
|
||||
const fetchText = (channel_id: string) =>
|
||||
Effect.tryPromise(() => client.channels.fetch(channel_id)).pipe(
|
||||
Effect.map((channel) => asTextChannel(channel)),
|
||||
Effect.catchAll(() => Effect.succeed(null)),
|
||||
)
|
||||
|
||||
const fetchThread = (thread_id: string) =>
|
||||
Effect.tryPromise(() => client.channels.fetch(thread_id)).pipe(
|
||||
Effect.map((channel) => asThreadChannel(channel)),
|
||||
Effect.catchAll(() => Effect.succeed(null)),
|
||||
)
|
||||
|
||||
const recoverMissedMessages = Effect.gen(function* () {
|
||||
const channels = config.allowedChannelIds.length > 0
|
||||
? uniq(config.allowedChannelIds)
|
||||
: uniq(yield* categoryChannels())
|
||||
const threads = uniq((yield* sessions.listTrackedThreads()).map((id) => String(id)))
|
||||
|
||||
const fromChannels = yield* Effect.forEach(
|
||||
channels,
|
||||
(channel_id) =>
|
||||
fetchText(channel_id).pipe(
|
||||
Effect.flatMap((channel) => {
|
||||
if (!channel) return Effect.succeed(0)
|
||||
return catchupSource(sourceChannel(channel_id), channel)
|
||||
}),
|
||||
Effect.catchAll((error) => {
|
||||
const log = catchupBenign(error) ? Effect.logDebug("Channel catch-up skipped") : Effect.logWarning("Channel catch-up failed")
|
||||
return log.pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.catchup.channel.failed",
|
||||
channel_id,
|
||||
error: messageOf(error),
|
||||
}),
|
||||
Effect.as(0),
|
||||
)
|
||||
}),
|
||||
),
|
||||
{ discard: false, concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
const fromThreads = yield* Effect.forEach(
|
||||
threads,
|
||||
(thread_id) =>
|
||||
fetchThread(thread_id).pipe(
|
||||
Effect.flatMap((thread) => {
|
||||
if (!thread) return Effect.succeed(0)
|
||||
return catchupSource(sourceThread(thread_id), thread)
|
||||
}),
|
||||
Effect.catchAll((error) => {
|
||||
const log = catchupBenign(error) ? Effect.logDebug("Thread catch-up skipped") : Effect.logWarning("Thread catch-up failed")
|
||||
return log.pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.catchup.thread.failed",
|
||||
thread_id,
|
||||
error: messageOf(error),
|
||||
}),
|
||||
Effect.as(0),
|
||||
)
|
||||
}),
|
||||
),
|
||||
{ discard: false, concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
const fetched = [...fromChannels, ...fromThreads].reduce((n, x) => n + x, 0)
|
||||
yield* Effect.logInfo("Discord catch-up complete").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.catchup.complete",
|
||||
channels: channels.length,
|
||||
threads: threads.length,
|
||||
fetched,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const acknowledge = (interaction: ChatInputCommandInteraction, content: string) =>
|
||||
Effect.tryPromise(async () => {
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.editReply({ content })
|
||||
} else {
|
||||
await interaction.reply({ content, flags: MessageFlags.Ephemeral })
|
||||
}
|
||||
}).pipe(Effect.catchAll(() => Effect.void))
|
||||
|
||||
const onInteraction = (interaction: Interaction): void => {
|
||||
if (!interaction.isChatInputCommand()) return
|
||||
const text = commandText(interaction.commandName)
|
||||
if (!text) return
|
||||
if (!mark(interaction.id)) return
|
||||
const handle = Effect.gen(function* () {
|
||||
yield* Effect.tryPromise(() =>
|
||||
interaction.deferReply({
|
||||
flags: MessageFlags.Ephemeral,
|
||||
})
|
||||
).pipe(Effect.catchAll(() => Effect.void))
|
||||
const thread = asThreadChannel(interaction.channel)
|
||||
if (!thread) {
|
||||
yield* acknowledge(interaction, COMMAND_NOT_THREAD_REPLY)
|
||||
return
|
||||
}
|
||||
const thread_id = ThreadId.make(thread.id)
|
||||
const channel_id = ChannelId.make(thread.parentId ?? thread.id)
|
||||
const allowed = isChannelAllowed(thread.parentId ?? "", thread.parent?.parentId ?? null, config)
|
||||
if (!allowed) {
|
||||
const owned = yield* pool.hasTrackedThread(thread_id).pipe(
|
||||
Effect.catchAll(() => Effect.succeed(false)),
|
||||
)
|
||||
if (!owned) {
|
||||
yield* acknowledge(interaction, COMMAND_CHANNEL_REPLY)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const member = yield* Effect.tryPromise(() =>
|
||||
interaction.guild ? interaction.guild.members.fetch(interaction.user.id) : Promise.resolve(null),
|
||||
).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
||||
if (!hasRequiredRole(member, config)) {
|
||||
yield* acknowledge(interaction, COMMAND_FORBIDDEN_REPLY)
|
||||
return
|
||||
}
|
||||
|
||||
const bot_user_id = client.user?.id ?? ""
|
||||
const event = ThreadMessage.make({
|
||||
kind: "thread_message",
|
||||
thread_id,
|
||||
channel_id,
|
||||
message_id: interaction.id,
|
||||
guild_id: GuildId.make(interaction.guildId ?? ""),
|
||||
bot_user_id,
|
||||
bot_role_id: config.discordRoleId,
|
||||
author_id: interaction.user.id,
|
||||
author_is_bot: false,
|
||||
mentions_everyone: false,
|
||||
mentions: Mention.make({
|
||||
user_ids: bot_user_id.length > 0 ? [bot_user_id] : [],
|
||||
role_ids: [],
|
||||
}),
|
||||
content: text,
|
||||
})
|
||||
const ingest = ledger.admit(event).pipe(
|
||||
Effect.flatMap((fresh) => {
|
||||
if (!fresh) return Effect.void
|
||||
return Effect.sync(() => {
|
||||
chats.set(event.thread_id, thread)
|
||||
input.unsafeOffer(event)
|
||||
})
|
||||
}),
|
||||
Effect.catchAll(() => Effect.void),
|
||||
)
|
||||
yield* ingest
|
||||
yield* acknowledge(interaction, COMMAND_ACK)
|
||||
})
|
||||
void Runtime.runPromise(runtime)(handle)
|
||||
}
|
||||
|
||||
const registerCommands = Effect.gen(function* () {
|
||||
if (!client.isReady()) {
|
||||
yield* Effect.async<void, never>((resume) => {
|
||||
const ready = () => {
|
||||
resume(Effect.void)
|
||||
}
|
||||
client.once("clientReady", ready)
|
||||
return Effect.sync(() => {
|
||||
client.off("clientReady", ready)
|
||||
})
|
||||
})
|
||||
}
|
||||
const app = client.application
|
||||
if (!app) return
|
||||
const guild = config.discordCommandGuildId.trim()
|
||||
const registered = yield* Effect.tryPromise(() =>
|
||||
guild.length > 0
|
||||
? app.commands.set([...COMMANDS], guild)
|
||||
: app.commands.set([...COMMANDS]),
|
||||
)
|
||||
yield* Effect.logInfo("Discord slash commands registered").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "discord.commands.registered",
|
||||
scope: guild.length > 0 ? "guild" : "global",
|
||||
guild_id: guild.length > 0 ? guild : "global",
|
||||
count: registered.size,
|
||||
}),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.tapError((cause) =>
|
||||
Effect.logError("Discord slash command registration failed").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "discord.commands.failed",
|
||||
message: messageOf(cause),
|
||||
}),
|
||||
)),
|
||||
Effect.catchAll(() => Effect.void),
|
||||
)
|
||||
|
||||
yield* registerCommands
|
||||
|
||||
client.on("messageCreate", onMessage)
|
||||
client.on("interactionCreate", onInteraction)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
client.off("messageCreate", onMessage)
|
||||
client.off("interactionCreate", onInteraction)
|
||||
yield* input.shutdown
|
||||
}),
|
||||
)
|
||||
|
||||
yield* recoverMissedMessages.pipe(
|
||||
Effect.catchAll((error) =>
|
||||
Effect.logError("Discord catch-up failed").pipe(
|
||||
Effect.annotateLogs({ event: "conversation.catchup.failed", error: messageOf(error) }),
|
||||
)),
|
||||
)
|
||||
|
||||
yield* ledger.replayPending().pipe(
|
||||
Effect.flatMap((events) =>
|
||||
Effect.forEach(
|
||||
events,
|
||||
(event) =>
|
||||
Effect.sync(() => {
|
||||
input.unsafeOffer(event)
|
||||
}),
|
||||
{ discard: true },
|
||||
).pipe(
|
||||
Effect.zipRight(
|
||||
Effect.logInfo("Replayed pending conversation events").pipe(
|
||||
Effect.annotateLogs({ event: "conversation.ledger.replay", count: events.length }),
|
||||
),
|
||||
),
|
||||
)),
|
||||
Effect.catchAll((error) =>
|
||||
Effect.logError("Failed replaying pending conversation events").pipe(
|
||||
Effect.annotateLogs({ event: "conversation.ledger.replay.failed", error: messageOf(error) }),
|
||||
)),
|
||||
)
|
||||
|
||||
const inbox = Inbox.of({
|
||||
events: Stream.fromQueue(input, { shutdown: false }),
|
||||
})
|
||||
|
||||
const channelOf = (thread_id: ThreadId, action: Action["kind"]) => {
|
||||
const channel = chats.get(thread_id)
|
||||
if (channel) return Effect.succeed(channel)
|
||||
return Effect.tryPromise(() => client.channels.fetch(thread_id)).pipe(
|
||||
Effect.flatMap((fetched) => {
|
||||
const thread = asThreadChannel(fetched)
|
||||
if (thread) {
|
||||
chats.set(thread_id, thread)
|
||||
return Effect.succeed(thread)
|
||||
}
|
||||
return DeliveryError.make({
|
||||
thread_id,
|
||||
action,
|
||||
message: "missing-thread-channel",
|
||||
retriable: false,
|
||||
})
|
||||
}),
|
||||
Effect.mapError((cause) =>
|
||||
DeliveryError.make({
|
||||
thread_id,
|
||||
action,
|
||||
message: messageOf(cause),
|
||||
retriable: deliveryRetriable(cause),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
const deliver = (thread_id: ThreadId, action: Action["kind"], send: Effect.Effect<unknown, unknown>) =>
|
||||
Effect.gen(function* () {
|
||||
const attempts = yield* Ref.make(0)
|
||||
yield* send.pipe(
|
||||
Effect.mapError((cause) =>
|
||||
DeliveryError.make({
|
||||
thread_id,
|
||||
action,
|
||||
message: messageOf(cause),
|
||||
retriable: deliveryRetriable(cause),
|
||||
})),
|
||||
Effect.tapError((error) =>
|
||||
Ref.updateAndGet(attempts, (n) => n + 1).pipe(
|
||||
Effect.flatMap((attempt) =>
|
||||
Effect.logWarning("Discord delivery attempt failed").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.delivery.retry",
|
||||
thread_id,
|
||||
action,
|
||||
attempt,
|
||||
retriable: error.retriable,
|
||||
message: error.message,
|
||||
}),
|
||||
)),
|
||||
),
|
||||
),
|
||||
Effect.retry(deliveryRetry),
|
||||
Effect.tapError((error) =>
|
||||
Ref.get(attempts).pipe(
|
||||
Effect.flatMap((attempt) =>
|
||||
Effect.logError("Discord delivery failed").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.delivery.failed",
|
||||
thread_id,
|
||||
action,
|
||||
attempts: attempt,
|
||||
retriable: error.retriable,
|
||||
message: error.message,
|
||||
}),
|
||||
)),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const sendTyping = (thread_id: ThreadId) =>
|
||||
Effect.gen(function* () {
|
||||
const channel = yield* channelOf(thread_id, "typing")
|
||||
yield* deliver(thread_id, "typing", Effect.tryPromise(() => channel.sendTyping()))
|
||||
})
|
||||
|
||||
const sendText = (thread_id: ThreadId, action: "send" | "reply", text: string) =>
|
||||
Effect.gen(function* () {
|
||||
const channel = yield* channelOf(thread_id, action)
|
||||
yield* Effect.forEach(
|
||||
splitForDiscord(cleanResponse(text)),
|
||||
(chunk) => deliver(thread_id, action, Effect.tryPromise(() => channel.send(chunk))),
|
||||
{ discard: true },
|
||||
)
|
||||
})
|
||||
|
||||
const publish = (action: Action) => {
|
||||
if (action.kind === "typing") return sendTyping(action.thread_id)
|
||||
return sendText(action.thread_id, action.kind, action.text)
|
||||
}
|
||||
|
||||
const withTyping = <A, E, R>(thread_id: ThreadId, self: Effect.Effect<A, E, R>) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const pulse = publish(
|
||||
Typing.make({
|
||||
kind: "typing",
|
||||
thread_id,
|
||||
}),
|
||||
).pipe(Effect.catchAll(() => Effect.void))
|
||||
yield* pulse
|
||||
yield* Effect.forkScoped(
|
||||
Effect.repeat(pulse, Schedule.spaced(TYPING_INTERVAL)).pipe(
|
||||
Effect.delay(TYPING_INTERVAL),
|
||||
),
|
||||
)
|
||||
return yield* self
|
||||
}),
|
||||
)
|
||||
|
||||
const outbox = Outbox.of({ publish, withTyping })
|
||||
|
||||
const history = History.of({
|
||||
rehydrate: (thread_id, latest: string) =>
|
||||
Effect.gen(function* () {
|
||||
const channel = chats.get(thread_id)
|
||||
if (!channel) return latest
|
||||
return yield* buildHistoryReplayPrompt(channel, latest).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
HistoryError.make({
|
||||
thread_id,
|
||||
message: messageOf(cause),
|
||||
retriable: true,
|
||||
})),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
const threads = Threads.of({
|
||||
ensure: (event, name: string) => {
|
||||
if (event.kind === "thread_message") {
|
||||
return Effect.succeed(ThreadRef.make({ thread_id: event.thread_id, channel_id: event.channel_id }))
|
||||
}
|
||||
|
||||
const known = roots.get(event.message_id)
|
||||
if (known) {
|
||||
return Effect.succeed(ThreadRef.make({ thread_id: known, channel_id: event.channel_id }))
|
||||
}
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const local = texts.get(event.channel_id)
|
||||
const channel = local
|
||||
? local
|
||||
: yield* Effect.tryPromise(() => client.channels.fetch(event.channel_id)).pipe(
|
||||
Effect.map((fetched) => asTextChannel(fetched)),
|
||||
Effect.mapError((cause) =>
|
||||
ThreadEnsureError.make({
|
||||
channel_id: event.channel_id,
|
||||
message: messageOf(cause),
|
||||
retriable: deliveryRetriable(cause),
|
||||
})),
|
||||
)
|
||||
if (!channel) {
|
||||
return yield* ThreadEnsureError.make({
|
||||
channel_id: event.channel_id,
|
||||
message: "missing-parent-channel",
|
||||
retriable: false,
|
||||
})
|
||||
}
|
||||
texts.set(event.channel_id, channel)
|
||||
const base = refs.get(event.message_id)
|
||||
const thread = yield* Effect.tryPromise(() =>
|
||||
channel.threads.create({
|
||||
name,
|
||||
startMessage: base ?? event.message_id,
|
||||
autoArchiveDuration: 60,
|
||||
}),
|
||||
).pipe(
|
||||
Effect.tapError(() =>
|
||||
Effect.tryPromise(() =>
|
||||
base
|
||||
? base.reply(SETUP_FAILURE_REPLY).then(() => undefined)
|
||||
: Promise.resolve(undefined)
|
||||
).pipe(
|
||||
Effect.catchAll(() => Effect.void),
|
||||
)),
|
||||
Effect.mapError((cause) =>
|
||||
ThreadEnsureError.make({
|
||||
channel_id: event.channel_id,
|
||||
message: messageOf(cause),
|
||||
retriable: deliveryRetriable(cause),
|
||||
})),
|
||||
)
|
||||
const thread_id = ThreadId.make(thread.id)
|
||||
chats.set(thread_id, thread)
|
||||
stash(roots, root_ids, event.message_id, thread_id)
|
||||
return ThreadRef.make({ thread_id, channel_id: event.channel_id })
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return Context.empty().pipe(
|
||||
Context.add(Inbox, inbox),
|
||||
Context.add(Outbox, outbox),
|
||||
Context.add(History, history),
|
||||
Context.add(Threads, threads),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const DiscordConversationServicesLive = DiscordConversationServices.portLayer
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer, Option } from "effect"
|
||||
import { TurnRouter, TurnRoutingDecision } from "../../../discord/turn-routing"
|
||||
import { ThreadAgentPool, type ThreadAgent } from "../../../sandbox/pool"
|
||||
import { effectTest, testConfigLayer } from "../../../test/effect"
|
||||
import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, ThreadId } from "../../../types"
|
||||
import { Conversation } from "../../services/conversation"
|
||||
import { ConversationLedger } from "../../services/ledger"
|
||||
import { makeTui } from "./index"
|
||||
|
||||
const makeSession = (id: string) =>
|
||||
SessionInfo.make({
|
||||
threadId: ThreadId.make("thread-local-channel"),
|
||||
channelId: ChannelId.make("local-channel"),
|
||||
guildId: GuildId.make("local"),
|
||||
sandboxId: SandboxId.make("sb1"),
|
||||
sessionId: SessionId.make(id),
|
||||
previewUrl: "https://preview",
|
||||
previewToken: null,
|
||||
status: "active",
|
||||
lastError: null,
|
||||
resumeFailCount: 0,
|
||||
})
|
||||
|
||||
const routerLayer = Layer.succeed(
|
||||
TurnRouter,
|
||||
TurnRouter.of({
|
||||
shouldRespond: () =>
|
||||
Effect.succeed(TurnRoutingDecision.make({ shouldRespond: true, reason: "test" })),
|
||||
generateThreadName: () => Effect.succeed("unused"),
|
||||
}),
|
||||
)
|
||||
|
||||
const makePoolLayer = (opts: {
|
||||
getOrCreate?: ThreadAgentPool.Service["getOrCreate"]
|
||||
send?: (prompt: string) => string
|
||||
seen?: Array<string>
|
||||
gate?: Deferred.Deferred<void>
|
||||
}) => {
|
||||
const session = makeSession("s1")
|
||||
const seen = opts.seen ?? []
|
||||
const defaultGetOrCreate: ThreadAgentPool.Service["getOrCreate"] = () =>
|
||||
Effect.gen(function* () {
|
||||
if (opts.gate) yield* Deferred.await(opts.gate)
|
||||
return {
|
||||
threadId: session.threadId,
|
||||
session,
|
||||
current: () => Effect.succeed(session),
|
||||
send: (prompt: string) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(prompt)
|
||||
return opts.send ? opts.send(prompt) : `local:${prompt}`
|
||||
}),
|
||||
pause: () => Effect.void,
|
||||
destroy: () => Effect.void,
|
||||
} satisfies ThreadAgent
|
||||
})
|
||||
|
||||
return Layer.succeed(
|
||||
ThreadAgentPool,
|
||||
ThreadAgentPool.of({
|
||||
getOrCreate: opts.getOrCreate ?? defaultGetOrCreate,
|
||||
hasTrackedThread: () => Effect.succeed(true),
|
||||
getTrackedSession: () => Effect.succeed(Option.none()),
|
||||
getActiveSessionCount: () => Effect.succeed(0),
|
||||
pauseSession: () => Effect.void,
|
||||
destroySession: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe("makeTui", () => {
|
||||
effectTest("drives conversation locally without Discord", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: Array<string> = []
|
||||
const tui = yield* makeTui
|
||||
const poolLayer = makePoolLayer({ seen })
|
||||
|
||||
const live = Conversation.layer.pipe(
|
||||
Layer.provideMerge(tui.layer),
|
||||
Layer.provideMerge(ConversationLedger.noop),
|
||||
Layer.provideMerge(routerLayer),
|
||||
Layer.provideMerge(poolLayer),
|
||||
Layer.provideMerge(testConfigLayer),
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* Effect.forkScoped(conversation.run)
|
||||
|
||||
yield* tui.send("hello local")
|
||||
|
||||
const first = yield* tui.take
|
||||
const second = yield* tui.take
|
||||
|
||||
expect(seen).toEqual(["hello local"])
|
||||
expect(first.kind).toBe("typing")
|
||||
expect(second.kind).toBe("send")
|
||||
expect(/^thread-[a-z]+-[a-z]+-\d+$/.test(String(second.thread_id))).toBe(true)
|
||||
if (second.kind === "send") expect(second.text).toBe("local:hello local")
|
||||
}).pipe(Effect.provide(live))
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("publishes typing before session resolution completes", () =>
|
||||
Effect.gen(function* () {
|
||||
const gate = yield* Deferred.make<void>()
|
||||
const tui = yield* makeTui
|
||||
const poolLayer = makePoolLayer({ gate })
|
||||
|
||||
const live = Conversation.layer.pipe(
|
||||
Layer.provideMerge(tui.layer),
|
||||
Layer.provideMerge(ConversationLedger.noop),
|
||||
Layer.provideMerge(routerLayer),
|
||||
Layer.provideMerge(poolLayer),
|
||||
Layer.provideMerge(testConfigLayer),
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* Effect.forkScoped(conversation.run)
|
||||
|
||||
yield* tui.send("hello local")
|
||||
const first = yield* tui.take
|
||||
expect(first.kind).toBe("typing")
|
||||
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
const second = yield* tui.take
|
||||
expect(second.kind).toBe("send")
|
||||
}).pipe(Effect.provide(live))
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("channel messages create distinct threads", () =>
|
||||
Effect.gen(function* () {
|
||||
const tui = yield* makeTui
|
||||
const poolLayer = makePoolLayer({})
|
||||
|
||||
const live = Conversation.layer.pipe(
|
||||
Layer.provideMerge(tui.layer),
|
||||
Layer.provideMerge(ConversationLedger.noop),
|
||||
Layer.provideMerge(routerLayer),
|
||||
Layer.provideMerge(poolLayer),
|
||||
Layer.provideMerge(testConfigLayer),
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* Effect.forkScoped(conversation.run)
|
||||
|
||||
yield* tui.send("one")
|
||||
const firstTyping = yield* tui.take
|
||||
const firstSend = yield* tui.take
|
||||
|
||||
yield* tui.send("two")
|
||||
const secondTyping = yield* tui.take
|
||||
const secondSend = yield* tui.take
|
||||
|
||||
expect(firstTyping.kind).toBe("typing")
|
||||
expect(firstSend.kind).toBe("send")
|
||||
expect(secondTyping.kind).toBe("typing")
|
||||
expect(secondSend.kind).toBe("send")
|
||||
if (firstTyping.kind === "typing" && secondTyping.kind === "typing") {
|
||||
expect(firstTyping.thread_id === secondTyping.thread_id).toBe(false)
|
||||
}
|
||||
}).pipe(Effect.provide(live))
|
||||
}),
|
||||
)
|
||||
})
|
||||
169
packages/discord/src/conversation/implementations/local/index.ts
Normal file
169
packages/discord/src/conversation/implementations/local/index.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { Effect, Layer, Queue, Schedule, Stream } from "effect"
|
||||
import { TYPING_INTERVAL } from "../../../discord/constants"
|
||||
import { ChannelId, GuildId, ThreadId } from "../../../types"
|
||||
import { ChannelMessage, Mention, ThreadMessage, ThreadRef, Typing, type Action, type Inbound } from "../../model/schema"
|
||||
import { History, Inbox, Outbox, Threads } from "../../services"
|
||||
|
||||
export type Tui = {
|
||||
layer: Layer.Layer<Inbox | Outbox | History | Threads, never, never>
|
||||
send: (text: string) => Effect.Effect<void>
|
||||
sendTo: (thread_id: ThreadId, text: string) => Effect.Effect<void>
|
||||
take: Effect.Effect<Action>
|
||||
actions: Stream.Stream<Action>
|
||||
}
|
||||
|
||||
export const makeTui = Effect.gen(function* () {
|
||||
const input = yield* Queue.unbounded<Inbound>()
|
||||
const output = yield* Queue.unbounded<Action>()
|
||||
const history = new Map<string, Array<string>>()
|
||||
const roots = new Map<string, ThreadId>()
|
||||
const parents = new Map<string, ChannelId>()
|
||||
const words = {
|
||||
a: ["brisk", "calm", "dapper", "eager", "fuzzy", "gentle", "jolly", "mellow", "nimble", "sunny"],
|
||||
b: ["otter", "falcon", "panda", "badger", "fox", "heron", "lemur", "raven", "tiger", "whale"],
|
||||
} as const
|
||||
let seq = 0
|
||||
|
||||
const name = () => {
|
||||
const i = seq
|
||||
seq += 1
|
||||
const x = words.a[i % words.a.length] ?? "brisk"
|
||||
const y = words.b[Math.floor(i / words.a.length) % words.b.length] ?? "otter"
|
||||
const z = Math.floor(i / (words.a.length * words.b.length)) + 1
|
||||
return ThreadId.make(`thread-${x}-${y}-${z}`)
|
||||
}
|
||||
|
||||
const remember = (thread_id: ThreadId, line: string) => {
|
||||
const current = history.get(thread_id)
|
||||
if (current) {
|
||||
current.push(line)
|
||||
return
|
||||
}
|
||||
history.set(thread_id, [line])
|
||||
}
|
||||
|
||||
const sendTo = (thread_id: ThreadId, text: string) =>
|
||||
Effect.gen(function* () {
|
||||
remember(thread_id, `user: ${text}`)
|
||||
const channel_id = parents.get(thread_id) ?? ChannelId.make(`channel-${thread_id}`)
|
||||
yield* input.offer(
|
||||
ThreadMessage.make({
|
||||
kind: "thread_message",
|
||||
thread_id,
|
||||
channel_id,
|
||||
message_id: crypto.randomUUID(),
|
||||
guild_id: GuildId.make("local"),
|
||||
bot_user_id: "local-bot",
|
||||
bot_role_id: "",
|
||||
author_id: "local-user",
|
||||
author_is_bot: false,
|
||||
mentions_everyone: false,
|
||||
mentions: Mention.make({ user_ids: [], role_ids: [] }),
|
||||
content: text,
|
||||
}),
|
||||
).pipe(Effect.asVoid)
|
||||
})
|
||||
|
||||
const send = (text: string) =>
|
||||
Effect.gen(function* () {
|
||||
const channel_id = ChannelId.make("local-channel")
|
||||
yield* input.offer(
|
||||
ChannelMessage.make({
|
||||
kind: "channel_message",
|
||||
channel_id,
|
||||
message_id: crypto.randomUUID(),
|
||||
guild_id: GuildId.make("local"),
|
||||
bot_user_id: "local-bot",
|
||||
bot_role_id: "",
|
||||
author_id: "local-user",
|
||||
author_is_bot: false,
|
||||
mentions_everyone: false,
|
||||
mentions: Mention.make({ user_ids: ["local-bot"], role_ids: [] }),
|
||||
content: text,
|
||||
}),
|
||||
).pipe(Effect.asVoid)
|
||||
})
|
||||
|
||||
const layer = Layer.mergeAll(
|
||||
Layer.succeed(
|
||||
Inbox,
|
||||
Inbox.of({
|
||||
events: Stream.fromQueue(input, { shutdown: false }),
|
||||
}),
|
||||
),
|
||||
Layer.succeed(
|
||||
Outbox,
|
||||
Outbox.of({
|
||||
publish: (action) =>
|
||||
Effect.gen(function* () {
|
||||
if (action.kind === "send" || action.kind === "reply") {
|
||||
remember(action.thread_id, `assistant: ${action.text}`)
|
||||
}
|
||||
yield* output.offer(action).pipe(Effect.asVoid)
|
||||
}),
|
||||
withTyping: <A, E, R>(thread_id: ThreadId, self: Effect.Effect<A, E, R>) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const pulse = output.offer(
|
||||
Typing.make({
|
||||
kind: "typing",
|
||||
thread_id,
|
||||
}),
|
||||
).pipe(Effect.asVoid)
|
||||
yield* pulse
|
||||
yield* Effect.forkScoped(
|
||||
Effect.repeat(pulse, Schedule.spaced(TYPING_INTERVAL)).pipe(
|
||||
Effect.delay(TYPING_INTERVAL),
|
||||
),
|
||||
)
|
||||
return yield* self
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
Layer.succeed(
|
||||
History,
|
||||
History.of({
|
||||
rehydrate: (thread_id, latest: string) =>
|
||||
Effect.sync(() => {
|
||||
const lines = history.get(thread_id) ?? []
|
||||
const prior = lines.at(-1) === `user: ${latest}` ? lines.slice(0, -1) : lines
|
||||
if (prior.length === 0) return latest
|
||||
return [
|
||||
"Conversation history from this same thread (oldest to newest):",
|
||||
prior.join("\n"),
|
||||
"",
|
||||
"Continue the same conversation and respond to the latest user message:",
|
||||
latest,
|
||||
].join("\n")
|
||||
}),
|
||||
}),
|
||||
),
|
||||
Layer.succeed(
|
||||
Threads,
|
||||
Threads.of({
|
||||
ensure: (event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.kind === "thread_message") {
|
||||
parents.set(event.thread_id, event.channel_id)
|
||||
return ThreadRef.make({ thread_id: event.thread_id, channel_id: event.channel_id })
|
||||
}
|
||||
const known = roots.get(event.message_id)
|
||||
if (known) return ThreadRef.make({ thread_id: known, channel_id: event.channel_id })
|
||||
const thread_id = name()
|
||||
roots.set(event.message_id, thread_id)
|
||||
parents.set(thread_id, event.channel_id)
|
||||
return ThreadRef.make({ thread_id, channel_id: event.channel_id })
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
layer,
|
||||
send,
|
||||
sendTo,
|
||||
take: output.take,
|
||||
actions: Stream.fromQueue(output, { shutdown: false }),
|
||||
} satisfies Tui
|
||||
})
|
||||
98
packages/discord/src/conversation/model/errors.ts
Normal file
98
packages/discord/src/conversation/model/errors.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { Schema } from "effect"
|
||||
import { ChannelId, ThreadId } from "../../types"
|
||||
|
||||
const DeliveryAction = Schema.Literal("typing", "send", "reply")
|
||||
|
||||
export class ThreadEnsureError extends Schema.TaggedError<ThreadEnsureError>()(
|
||||
"ThreadEnsureError",
|
||||
{
|
||||
channel_id: ChannelId,
|
||||
message: Schema.String,
|
||||
retriable: Schema.Boolean,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class HistoryError extends Schema.TaggedError<HistoryError>()(
|
||||
"HistoryError",
|
||||
{
|
||||
thread_id: ThreadId,
|
||||
message: Schema.String,
|
||||
retriable: Schema.Boolean,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class DeliveryError extends Schema.TaggedError<DeliveryError>()(
|
||||
"DeliveryError",
|
||||
{
|
||||
thread_id: ThreadId,
|
||||
action: DeliveryAction,
|
||||
message: Schema.String,
|
||||
retriable: Schema.Boolean,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class RoutingError extends Schema.TaggedError<RoutingError>()(
|
||||
"RoutingError",
|
||||
{
|
||||
message: Schema.String,
|
||||
retriable: Schema.Boolean,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class SandboxSendError extends Schema.TaggedError<SandboxSendError>()(
|
||||
"SandboxSendError",
|
||||
{
|
||||
thread_id: ThreadId,
|
||||
message: Schema.String,
|
||||
retriable: Schema.Boolean,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ReliabilityError extends Schema.TaggedError<ReliabilityError>()(
|
||||
"ReliabilityError",
|
||||
{
|
||||
message_id: Schema.String,
|
||||
message: Schema.String,
|
||||
retriable: Schema.Boolean,
|
||||
},
|
||||
) {}
|
||||
|
||||
export const ConversationError = Schema.Union(
|
||||
ThreadEnsureError,
|
||||
HistoryError,
|
||||
DeliveryError,
|
||||
RoutingError,
|
||||
SandboxSendError,
|
||||
ReliabilityError,
|
||||
)
|
||||
|
||||
export type ConversationError = typeof ConversationError.Type
|
||||
|
||||
const messageFrom = (value: unknown, depth: number): string => {
|
||||
if (depth > 4) return String(value)
|
||||
if (typeof value === "string") return value
|
||||
|
||||
if (value instanceof Error) {
|
||||
const nested = Reflect.get(value, "cause")
|
||||
if (nested === undefined) return value.message
|
||||
const inner = messageFrom(nested, depth + 1)
|
||||
if (inner.length === 0 || inner === value.message) return value.message
|
||||
return `${value.message}: ${inner}`
|
||||
}
|
||||
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const message = Reflect.get(value, "message")
|
||||
const nested = Reflect.get(value, "cause")
|
||||
if (typeof message === "string" && nested === undefined) return message
|
||||
if (typeof message === "string") {
|
||||
const inner = messageFrom(nested, depth + 1)
|
||||
if (inner.length === 0 || inner === message) return message
|
||||
return `${message}: ${inner}`
|
||||
}
|
||||
if (nested !== undefined) return messageFrom(nested, depth + 1)
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export const messageOf = (cause: unknown): string => messageFrom(cause, 0)
|
||||
73
packages/discord/src/conversation/model/schema.ts
Normal file
73
packages/discord/src/conversation/model/schema.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { Schema } from "effect"
|
||||
import { ThreadId, ChannelId, GuildId } from "../../types"
|
||||
|
||||
export class Mention extends Schema.Class<Mention>("Mention")({
|
||||
user_ids: Schema.Array(Schema.String),
|
||||
role_ids: Schema.Array(Schema.String),
|
||||
}) {}
|
||||
|
||||
export class ThreadMessage extends Schema.Class<ThreadMessage>("ThreadMessage")({
|
||||
kind: Schema.Literal("thread_message"),
|
||||
thread_id: ThreadId,
|
||||
channel_id: ChannelId,
|
||||
message_id: Schema.String,
|
||||
guild_id: GuildId,
|
||||
bot_user_id: Schema.String,
|
||||
bot_role_id: Schema.String,
|
||||
author_id: Schema.String,
|
||||
author_is_bot: Schema.Boolean,
|
||||
mentions_everyone: Schema.Boolean,
|
||||
mentions: Mention,
|
||||
content: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class ChannelMessage extends Schema.Class<ChannelMessage>("ChannelMessage")({
|
||||
kind: Schema.Literal("channel_message"),
|
||||
channel_id: ChannelId,
|
||||
message_id: Schema.String,
|
||||
guild_id: GuildId,
|
||||
bot_user_id: Schema.String,
|
||||
bot_role_id: Schema.String,
|
||||
author_id: Schema.String,
|
||||
author_is_bot: Schema.Boolean,
|
||||
mentions_everyone: Schema.Boolean,
|
||||
mentions: Mention,
|
||||
content: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const Inbound = Schema.Union(
|
||||
ThreadMessage,
|
||||
ChannelMessage,
|
||||
)
|
||||
|
||||
export type Inbound = typeof Inbound.Type
|
||||
|
||||
export class ThreadRef extends Schema.Class<ThreadRef>("ThreadRef")({
|
||||
thread_id: ThreadId,
|
||||
channel_id: ChannelId,
|
||||
}) {}
|
||||
|
||||
export class Send extends Schema.Class<Send>("Send")({
|
||||
kind: Schema.Literal("send"),
|
||||
thread_id: ThreadId,
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class Reply extends Schema.Class<Reply>("Reply")({
|
||||
kind: Schema.Literal("reply"),
|
||||
thread_id: ThreadId,
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class Typing extends Schema.Class<Typing>("Typing")({
|
||||
kind: Schema.Literal("typing"),
|
||||
thread_id: ThreadId,
|
||||
}) {}
|
||||
|
||||
export const Action = Schema.Union(
|
||||
Send,
|
||||
Reply,
|
||||
Typing,
|
||||
)
|
||||
|
||||
export type Action = typeof Action.Type
|
||||
799
packages/discord/src/conversation/services/conversation.test.ts
Normal file
799
packages/discord/src/conversation/services/conversation.test.ts
Normal file
|
|
@ -0,0 +1,799 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Option, Stream } from "effect"
|
||||
import { TurnRouter, TurnRoutingDecision } from "../../discord/turn-routing"
|
||||
import { DatabaseError, OpenCodeClientError, SandboxDeadError } from "../../errors"
|
||||
import { ThreadAgentPool, type ThreadAgent } from "../../sandbox/pool"
|
||||
import { effectTest, testConfigLayer } from "../../test/effect"
|
||||
import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, ThreadId } from "../../types"
|
||||
import { Mention, ThreadMessage, ThreadRef, Typing, type Action, type Inbound } from "../model/schema"
|
||||
import { History } from "./history"
|
||||
import { Inbox } from "./inbox"
|
||||
import { ConversationLedger, MessageState } from "./ledger"
|
||||
import { Outbox } from "./outbox"
|
||||
import { Threads } from "./threads"
|
||||
import { Conversation } from "./conversation"
|
||||
|
||||
const makeSession = (id: string, threadId = "t1", channelId = "c1") =>
|
||||
SessionInfo.make({
|
||||
threadId: ThreadId.make(threadId),
|
||||
channelId: ChannelId.make(channelId),
|
||||
guildId: GuildId.make("g1"),
|
||||
sandboxId: SandboxId.make("sb1"),
|
||||
sessionId: SessionId.make(id),
|
||||
previewUrl: "https://preview",
|
||||
previewToken: null,
|
||||
status: "active",
|
||||
lastError: null,
|
||||
resumeFailCount: 0,
|
||||
})
|
||||
|
||||
const makeThreadEvent = (props: {
|
||||
threadId: string
|
||||
channelId: string
|
||||
messageId: string
|
||||
content: string
|
||||
}) =>
|
||||
ThreadMessage.make({
|
||||
kind: "thread_message",
|
||||
thread_id: ThreadId.make(props.threadId),
|
||||
channel_id: ChannelId.make(props.channelId),
|
||||
message_id: props.messageId,
|
||||
guild_id: GuildId.make("g1"),
|
||||
bot_user_id: "bot-1",
|
||||
bot_role_id: "role-1",
|
||||
author_id: "u1",
|
||||
author_is_bot: false,
|
||||
mentions_everyone: false,
|
||||
mentions: Mention.make({ user_ids: [], role_ids: [] }),
|
||||
content: props.content,
|
||||
})
|
||||
|
||||
const makeEvent = (content: string) =>
|
||||
makeThreadEvent({ threadId: "t1", channelId: "c1", messageId: "m1", content })
|
||||
|
||||
const makeChannelEvent = (content: string) => ({
|
||||
kind: "channel_message" as const,
|
||||
channel_id: ChannelId.make("c-root"),
|
||||
message_id: "m-root",
|
||||
guild_id: GuildId.make("g1"),
|
||||
bot_user_id: "bot-1",
|
||||
bot_role_id: "role-1",
|
||||
author_id: "u1",
|
||||
author_is_bot: false,
|
||||
mentions_everyone: false,
|
||||
mentions: Mention.make({ user_ids: ["bot-1"], role_ids: [] }),
|
||||
content,
|
||||
})
|
||||
|
||||
const makeRouterLayer = (shouldRespond: boolean) =>
|
||||
Layer.succeed(
|
||||
TurnRouter,
|
||||
TurnRouter.of({
|
||||
shouldRespond: () =>
|
||||
Effect.succeed(
|
||||
TurnRoutingDecision.make({
|
||||
shouldRespond,
|
||||
reason: "test",
|
||||
}),
|
||||
),
|
||||
generateThreadName: () => Effect.succeed("unused"),
|
||||
}),
|
||||
)
|
||||
|
||||
const makeAgent = (
|
||||
session: SessionInfo,
|
||||
send: (
|
||||
session: SessionInfo,
|
||||
text: string,
|
||||
) => Effect.Effect<string, OpenCodeClientError | SandboxDeadError | DatabaseError>,
|
||||
prompts: Array<string>,
|
||||
): ThreadAgent => ({
|
||||
threadId: session.threadId,
|
||||
session,
|
||||
current: () => Effect.succeed(session),
|
||||
send: (text: string) =>
|
||||
Effect.sync(() => {
|
||||
prompts.push(text)
|
||||
return { session, text }
|
||||
}).pipe(
|
||||
Effect.flatMap(({ session, text }) => send(session, text)),
|
||||
),
|
||||
pause: () => Effect.void,
|
||||
destroy: () => Effect.void,
|
||||
})
|
||||
|
||||
const makeConversationLayer = (props: {
|
||||
events: ReadonlyArray<Inbound>
|
||||
tracked: Option.Option<SessionInfo>
|
||||
resolves: ReadonlyArray<SessionInfo>
|
||||
resolve?: (threadId: ThreadId, channelId: ChannelId, guildId: GuildId) => SessionInfo
|
||||
send: (
|
||||
session: SessionInfo,
|
||||
text: string,
|
||||
) => Effect.Effect<string, OpenCodeClientError | SandboxDeadError | DatabaseError>
|
||||
rehydrate: (threadId: ThreadId, latest: string) => Effect.Effect<string>
|
||||
shouldRespond?: boolean
|
||||
actions: Array<Action>
|
||||
prompts: Array<string>
|
||||
}) => {
|
||||
const resolveIndex = { value: 0 }
|
||||
|
||||
const inboxLayer = Layer.succeed(
|
||||
Inbox,
|
||||
Inbox.of({
|
||||
events: Stream.fromIterable(props.events),
|
||||
}),
|
||||
)
|
||||
|
||||
const outboxLayer = Layer.succeed(
|
||||
Outbox,
|
||||
Outbox.of({
|
||||
publish: (action) =>
|
||||
Effect.sync(() => {
|
||||
props.actions.push(action)
|
||||
}),
|
||||
withTyping: <A, E, R>(thread_id: ThreadId, self: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
props.actions.push(
|
||||
Typing.make({
|
||||
kind: "typing",
|
||||
thread_id,
|
||||
}),
|
||||
)
|
||||
return yield* self
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const historyLayer = Layer.succeed(
|
||||
History,
|
||||
History.of({
|
||||
rehydrate: props.rehydrate,
|
||||
}),
|
||||
)
|
||||
|
||||
const threadsLayer = Layer.succeed(
|
||||
Threads,
|
||||
Threads.of({
|
||||
ensure: (event) => {
|
||||
if (event.kind === "thread_message") {
|
||||
return Effect.succeed(ThreadRef.make({ thread_id: event.thread_id, channel_id: event.channel_id }))
|
||||
}
|
||||
return Effect.succeed(ThreadRef.make({ thread_id: ThreadId.make("t-new"), channel_id: event.channel_id }))
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const resolveSession = (threadId: ThreadId, channelId: ChannelId, guildId: GuildId): SessionInfo => {
|
||||
if (props.resolve) return props.resolve(threadId, channelId, guildId)
|
||||
const ix = Math.min(resolveIndex.value, props.resolves.length - 1)
|
||||
const session = props.resolves[ix]!
|
||||
resolveIndex.value += 1
|
||||
return session
|
||||
}
|
||||
|
||||
const poolLayer = Layer.succeed(
|
||||
ThreadAgentPool,
|
||||
ThreadAgentPool.of({
|
||||
getOrCreate: (threadId, channelId, guildId) =>
|
||||
Effect.sync(() => {
|
||||
const session = resolveSession(threadId, channelId, guildId)
|
||||
return makeAgent(session, props.send, props.prompts)
|
||||
}),
|
||||
hasTrackedThread: () => Effect.succeed(true),
|
||||
getTrackedSession: () => Effect.succeed(props.tracked),
|
||||
getActiveSessionCount: () => Effect.succeed(0),
|
||||
pauseSession: () => Effect.void,
|
||||
destroySession: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
|
||||
return Conversation.layer.pipe(
|
||||
Layer.provideMerge(inboxLayer),
|
||||
Layer.provideMerge(outboxLayer),
|
||||
Layer.provideMerge(historyLayer),
|
||||
Layer.provideMerge(ConversationLedger.noop),
|
||||
Layer.provideMerge(threadsLayer),
|
||||
Layer.provideMerge(poolLayer),
|
||||
Layer.provideMerge(makeRouterLayer(props.shouldRespond ?? true)),
|
||||
Layer.provideMerge(testConfigLayer),
|
||||
)
|
||||
}
|
||||
|
||||
describe("Conversation", () => {
|
||||
effectTest("run consumes fake inbox and publishes typing + send", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const live = makeConversationLayer({
|
||||
events: [makeEvent("hello")],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s1")],
|
||||
send: (_session, text) => Effect.succeed(`echo:${text}`),
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* conversation.run
|
||||
|
||||
expect(prompts).toEqual(["hello"])
|
||||
expect(actions.map((x) => x.kind)).toEqual(["typing", "send"])
|
||||
const sent = actions[1]
|
||||
if (!sent) throw new Error("missing send action")
|
||||
expect(sent.kind).toBe("send")
|
||||
expect(sent.thread_id).toBe(ThreadId.make("t1"))
|
||||
if (sent.kind === "send") expect(sent.text).toBe("echo:hello")
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("turn rehydrates prompt when tracked and resolved sessions differ", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const live = makeConversationLayer({
|
||||
events: [],
|
||||
tracked: Option.some(makeSession("s-old")),
|
||||
resolves: [makeSession("s-new")],
|
||||
send: () => Effect.succeed("ok"),
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* conversation.turn(makeEvent("help me"))
|
||||
|
||||
expect(prompts).toEqual(["rehydrated:help me"])
|
||||
expect(actions.map((x) => x.kind)).toEqual(["typing", "send"])
|
||||
const sent = actions[1]
|
||||
if (!sent) throw new Error("missing send action")
|
||||
expect(sent.kind).toBe("send")
|
||||
expect(sent.thread_id).toBe(ThreadId.make("t1"))
|
||||
if (sent.kind === "send") expect(sent.text).toBe("ok")
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("turn recovers from dead sandbox by re-resolving and retrying", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const calls = { value: 0 }
|
||||
const live = makeConversationLayer({
|
||||
events: [],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s-a"), makeSession("s-b")],
|
||||
send: (_session, text) => {
|
||||
if (calls.value === 0) {
|
||||
calls.value += 1
|
||||
return Effect.fail(
|
||||
SandboxDeadError.make({
|
||||
threadId: ThreadId.make("t1"),
|
||||
reason: "dead",
|
||||
}),
|
||||
)
|
||||
}
|
||||
calls.value += 1
|
||||
return Effect.succeed(`ok:${text}`)
|
||||
},
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* conversation.turn(makeEvent("fix build"))
|
||||
|
||||
expect(prompts).toEqual(["fix build", "rehydrated:fix build"])
|
||||
expect(actions.map((x) => x.kind)).toEqual(["typing", "send", "send"])
|
||||
const recovery = actions[1]
|
||||
if (!recovery) throw new Error("missing recovery action")
|
||||
expect(recovery.kind).toBe("send")
|
||||
expect(recovery.thread_id).toBe(ThreadId.make("t1"))
|
||||
if (recovery.kind === "send") expect(recovery.text).toBe("*Session changed state, recovering...*")
|
||||
|
||||
const sent = actions[2]
|
||||
if (!sent) throw new Error("missing final action")
|
||||
expect(sent.kind).toBe("send")
|
||||
expect(sent.thread_id).toBe(ThreadId.make("t1"))
|
||||
if (sent.kind === "send") expect(sent.text).toBe("ok:rehydrated:fix build")
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("turn handles a channel message by using ensured thread target", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const live = makeConversationLayer({
|
||||
events: [],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s1")],
|
||||
send: (_session, text) => Effect.succeed(`echo:${text}`),
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* conversation.turn(makeChannelEvent("from channel"))
|
||||
|
||||
expect(prompts).toEqual(["from channel"])
|
||||
expect(actions.map((x) => x.kind)).toEqual(["typing", "send"])
|
||||
const sent = actions[1]
|
||||
if (!sent) throw new Error("missing send action")
|
||||
expect(sent.kind).toBe("send")
|
||||
expect(sent.thread_id).toBe(ThreadId.make("t-new"))
|
||||
if (sent.kind === "send") expect(sent.text).toBe("echo:from channel")
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("run retries retriable failures in-process", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const calls = { value: 0 }
|
||||
const live = makeConversationLayer({
|
||||
events: [makeEvent("retry now")],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s1")],
|
||||
send: (_session, text) =>
|
||||
Effect.gen(function* () {
|
||||
calls.value += 1
|
||||
if (calls.value === 1) {
|
||||
return yield* Effect.fail(new OpenCodeClientError({
|
||||
operation: "sendPrompt",
|
||||
statusCode: 502,
|
||||
body: "StatusCode: non 2xx status code (502 POST https://proxy.daytona.works/session/s1/message)",
|
||||
}))
|
||||
}
|
||||
return `ok:${text}`
|
||||
}),
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* conversation.run
|
||||
|
||||
expect(prompts).toEqual(["retry now", "retry now"])
|
||||
expect(actions.map((x) => x.kind)).toEqual(["typing", "typing", "send"])
|
||||
const sent = actions[2]
|
||||
if (!sent) throw new Error("missing send action")
|
||||
expect(sent.kind).toBe("send")
|
||||
expect(sent.thread_id).toBe(ThreadId.make("t1"))
|
||||
if (sent.kind === "send") expect(sent.text).toBe("ok:retry now")
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("turn sends generic non-retriable error text", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const live = makeConversationLayer({
|
||||
events: [],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s1")],
|
||||
send: () =>
|
||||
Effect.fail(new DatabaseError({
|
||||
cause: new Error("StatusCode: non 2xx status code (502 POST https://proxy.daytona.works/session/s1/message)"),
|
||||
})),
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* conversation.turn(makeEvent("oops")).pipe(Effect.either)
|
||||
|
||||
expect(prompts).toEqual(["oops"])
|
||||
expect(actions.map((x) => x.kind)).toEqual(["typing", "send"])
|
||||
const sent = actions[1]
|
||||
if (!sent) throw new Error("missing send action")
|
||||
expect(sent.kind).toBe("send")
|
||||
expect(sent.thread_id).toBe(ThreadId.make("t1"))
|
||||
if (sent.kind === "send") {
|
||||
expect(sent.text).toBe("Something went wrong. Please try again in a moment.")
|
||||
expect(sent.text.includes("proxy.daytona.works")).toBe(false)
|
||||
}
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("run processes different thread keys concurrently", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const gate = Effect.runSync(Deferred.make<void>())
|
||||
const fast = Effect.runSync(Deferred.make<void>())
|
||||
const live = makeConversationLayer({
|
||||
events: [
|
||||
makeThreadEvent({ threadId: "t1", channelId: "c1", messageId: "m1", content: "slow" }),
|
||||
makeThreadEvent({ threadId: "t2", channelId: "c2", messageId: "m2", content: "fast" }),
|
||||
],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s-a", "t1", "c1"), makeSession("s-b", "t2", "c2")],
|
||||
resolve: (threadId, channelId) => {
|
||||
if (threadId === "t2") return makeSession("s-b", "t2", channelId)
|
||||
return makeSession("s-a", "t1", channelId)
|
||||
},
|
||||
send: (session, text) =>
|
||||
Effect.gen(function* () {
|
||||
if (session.threadId === ThreadId.make("t2")) {
|
||||
yield* Deferred.succeed(fast, undefined)
|
||||
return `ok:${text}`
|
||||
}
|
||||
yield* Deferred.await(gate)
|
||||
return `ok:${text}`
|
||||
}),
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
const fiber = yield* Effect.forkScoped(conversation.run)
|
||||
yield* Deferred.await(fast).pipe(
|
||||
Effect.timeoutFail({ duration: "1 second", onTimeout: () => "thread-concurrency-blocked" }),
|
||||
)
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
yield* Fiber.join(fiber)
|
||||
|
||||
expect(prompts.sort()).toEqual(["fast", "slow"])
|
||||
expect(actions.filter((x) => x.kind === "typing").length).toBe(2)
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
})
|
||||
|
||||
// --- Duplicate processing tests ---
|
||||
|
||||
/** A ledger that tracks admit/start calls and enforces dedup like the real one */
|
||||
const makeTrackingLedger = () => {
|
||||
const admitted = new Set<string>()
|
||||
const started = new Set<string>()
|
||||
const completed = new Set<string>()
|
||||
const admitCalls: Array<string> = []
|
||||
const startCalls: Array<string> = []
|
||||
|
||||
const service: ConversationLedger.Service = {
|
||||
admit: (event) =>
|
||||
Effect.sync(() => {
|
||||
admitCalls.push(event.message_id)
|
||||
if (admitted.has(event.message_id)) return false
|
||||
admitted.add(event.message_id)
|
||||
return true
|
||||
}),
|
||||
replayPending: () => Effect.succeed([]),
|
||||
start: (message_id) =>
|
||||
Effect.sync(() => {
|
||||
startCalls.push(message_id)
|
||||
if (started.has(message_id) || completed.has(message_id)) return Option.none()
|
||||
started.add(message_id)
|
||||
return Option.some(MessageState.make({
|
||||
thread_id: null,
|
||||
channel_id: null,
|
||||
response_text: null,
|
||||
prompt_text: null,
|
||||
session_id: null,
|
||||
}))
|
||||
}),
|
||||
setTarget: () => Effect.void,
|
||||
setPrompt: () => Effect.void,
|
||||
setResponse: () => Effect.void,
|
||||
complete: (message_id) =>
|
||||
Effect.sync(() => {
|
||||
started.delete(message_id)
|
||||
completed.add(message_id)
|
||||
}),
|
||||
retry: (message_id) =>
|
||||
Effect.sync(() => {
|
||||
started.delete(message_id)
|
||||
// Back to pending — NOT completed, so start will work again
|
||||
}),
|
||||
prune: () => Effect.void,
|
||||
getOffset: () => Effect.succeed(Option.none()),
|
||||
setOffset: () => Effect.void,
|
||||
}
|
||||
|
||||
return { service, admitted, started, completed, admitCalls, startCalls }
|
||||
}
|
||||
|
||||
const makeConversationLayerWithLedger = (props: {
|
||||
events: ReadonlyArray<Inbound>
|
||||
tracked: Option.Option<SessionInfo>
|
||||
resolves: ReadonlyArray<SessionInfo>
|
||||
resolve?: (threadId: ThreadId, channelId: ChannelId, guildId: GuildId) => SessionInfo
|
||||
send: (
|
||||
session: SessionInfo,
|
||||
text: string,
|
||||
) => Effect.Effect<string, OpenCodeClientError | SandboxDeadError | DatabaseError>
|
||||
rehydrate: (threadId: ThreadId, latest: string) => Effect.Effect<string>
|
||||
shouldRespond?: boolean
|
||||
actions: Array<Action>
|
||||
prompts: Array<string>
|
||||
ledger: ConversationLedger.Service
|
||||
}) => {
|
||||
const resolveIndex = { value: 0 }
|
||||
|
||||
const inboxLayer = Layer.succeed(
|
||||
Inbox,
|
||||
Inbox.of({
|
||||
events: Stream.fromIterable(props.events),
|
||||
}),
|
||||
)
|
||||
|
||||
const outboxLayer = Layer.succeed(
|
||||
Outbox,
|
||||
Outbox.of({
|
||||
publish: (action) =>
|
||||
Effect.sync(() => {
|
||||
props.actions.push(action)
|
||||
}),
|
||||
withTyping: <A, E, R>(thread_id: ThreadId, self: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
props.actions.push(
|
||||
Typing.make({
|
||||
kind: "typing",
|
||||
thread_id,
|
||||
}),
|
||||
)
|
||||
return yield* self
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const historyLayer = Layer.succeed(
|
||||
History,
|
||||
History.of({
|
||||
rehydrate: props.rehydrate,
|
||||
}),
|
||||
)
|
||||
|
||||
const threadsLayer = Layer.succeed(
|
||||
Threads,
|
||||
Threads.of({
|
||||
ensure: (event) => {
|
||||
if (event.kind === "thread_message") {
|
||||
return Effect.succeed(ThreadRef.make({ thread_id: event.thread_id, channel_id: event.channel_id }))
|
||||
}
|
||||
return Effect.succeed(ThreadRef.make({ thread_id: ThreadId.make("t-new"), channel_id: event.channel_id }))
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const resolveSession = (threadId: ThreadId, channelId: ChannelId, guildId: GuildId): SessionInfo => {
|
||||
if (props.resolve) return props.resolve(threadId, channelId, guildId)
|
||||
const ix = Math.min(resolveIndex.value, props.resolves.length - 1)
|
||||
const session = props.resolves[ix]!
|
||||
resolveIndex.value += 1
|
||||
return session
|
||||
}
|
||||
|
||||
const poolLayer = Layer.succeed(
|
||||
ThreadAgentPool,
|
||||
ThreadAgentPool.of({
|
||||
getOrCreate: (threadId, channelId, guildId) =>
|
||||
Effect.sync(() => {
|
||||
const session = resolveSession(threadId, channelId, guildId)
|
||||
return makeAgent(session, props.send, props.prompts)
|
||||
}),
|
||||
hasTrackedThread: () => Effect.succeed(true),
|
||||
getTrackedSession: () => Effect.succeed(props.tracked),
|
||||
getActiveSessionCount: () => Effect.succeed(0),
|
||||
pauseSession: () => Effect.void,
|
||||
destroySession: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
|
||||
const ledgerLayer = Layer.succeed(ConversationLedger, ConversationLedger.of(props.ledger))
|
||||
|
||||
return Conversation.layer.pipe(
|
||||
Layer.provideMerge(inboxLayer),
|
||||
Layer.provideMerge(outboxLayer),
|
||||
Layer.provideMerge(historyLayer),
|
||||
Layer.provideMerge(ledgerLayer),
|
||||
Layer.provideMerge(threadsLayer),
|
||||
Layer.provideMerge(poolLayer),
|
||||
Layer.provideMerge(makeRouterLayer(props.shouldRespond ?? true)),
|
||||
Layer.provideMerge(testConfigLayer),
|
||||
)
|
||||
}
|
||||
|
||||
describe("Conversation duplicate processing", () => {
|
||||
effectTest("same message_id queued twice via run is only sent once", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const ledger = makeTrackingLedger()
|
||||
const event = makeEvent("hello")
|
||||
|
||||
const live = makeConversationLayerWithLedger({
|
||||
// Feed the same event twice to simulate catch-up + real-time race
|
||||
events: [event, event],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s1")],
|
||||
send: (_session, text) => Effect.succeed(`echo:${text}`),
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
ledger: ledger.service,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* conversation.run
|
||||
|
||||
// The ledger should have been called twice with admit
|
||||
expect(ledger.admitCalls).toEqual(["m1", "m1"])
|
||||
// But only one start should have succeeded
|
||||
expect(ledger.startCalls.length).toBeLessThanOrEqual(2)
|
||||
// The agent should have received the prompt only once
|
||||
expect(prompts).toEqual(["hello"])
|
||||
// Only one typing + one send
|
||||
expect(actions.filter((x) => x.kind === "send").length).toBe(1)
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("noop ledger now deduplicates (bug is fixed)", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const event = makeEvent("hello")
|
||||
|
||||
// The noop ledger now tracks seen message_ids
|
||||
const live = makeConversationLayer({
|
||||
events: [event, event],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s1")],
|
||||
send: (_session, text) => Effect.succeed(`echo:${text}`),
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* conversation.run
|
||||
|
||||
// Same event fed twice but only processed once
|
||||
expect(prompts).toEqual(["hello"])
|
||||
expect(actions.filter((x) => x.kind === "send").length).toBe(1)
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("turn called twice with same event only processes once with tracking ledger", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const ledger = makeTrackingLedger()
|
||||
const event = makeEvent("help me")
|
||||
|
||||
const live = makeConversationLayerWithLedger({
|
||||
events: [],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s1")],
|
||||
send: (_session, text) => Effect.succeed(`echo:${text}`),
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
ledger: ledger.service,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
// Call turn twice with the same event (simulating race between real-time and catch-up)
|
||||
yield* conversation.turn(event)
|
||||
yield* conversation.turn(event)
|
||||
|
||||
expect(prompts).toEqual(["help me"])
|
||||
expect(actions.filter((x) => x.kind === "send").length).toBe(1)
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("retry resets to pending and second run processes again", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const sendCount = { value: 0 }
|
||||
|
||||
// A ledger that allows retry -> re-process flow
|
||||
const admitted = new Set<string>()
|
||||
const state = new Map<string, "pending" | "processing" | "completed">()
|
||||
const ledgerService: ConversationLedger.Service = {
|
||||
admit: (event) =>
|
||||
Effect.sync(() => {
|
||||
if (admitted.has(event.message_id)) return false
|
||||
admitted.add(event.message_id)
|
||||
state.set(event.message_id, "pending")
|
||||
return true
|
||||
}),
|
||||
replayPending: () => Effect.succeed([]),
|
||||
start: (message_id) =>
|
||||
Effect.sync(() => {
|
||||
if (state.get(message_id) !== "pending") return Option.none()
|
||||
state.set(message_id, "processing")
|
||||
return Option.some(MessageState.make({
|
||||
thread_id: null,
|
||||
channel_id: null,
|
||||
response_text: null,
|
||||
prompt_text: null,
|
||||
session_id: null,
|
||||
}))
|
||||
}),
|
||||
setTarget: () => Effect.void,
|
||||
setPrompt: () => Effect.void,
|
||||
setResponse: () => Effect.void,
|
||||
complete: (message_id) =>
|
||||
Effect.sync(() => { state.set(message_id, "completed") }),
|
||||
retry: (message_id) =>
|
||||
Effect.sync(() => { state.set(message_id, "pending") }),
|
||||
prune: () => Effect.void,
|
||||
getOffset: () => Effect.succeed(Option.none()),
|
||||
setOffset: () => Effect.void,
|
||||
}
|
||||
|
||||
const event = makeEvent("build it")
|
||||
|
||||
const live = makeConversationLayerWithLedger({
|
||||
events: [],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s1"), makeSession("s2")],
|
||||
send: (_session, text) => {
|
||||
sendCount.value += 1
|
||||
if (sendCount.value === 1) {
|
||||
return Effect.fail(
|
||||
SandboxDeadError.make({
|
||||
threadId: ThreadId.make("t1"),
|
||||
reason: "dead",
|
||||
}),
|
||||
)
|
||||
}
|
||||
return Effect.succeed(`ok:${text}`)
|
||||
},
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
ledger: ledgerService,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
// First turn fails (SandboxDeadError caught + recovery), so it processes once
|
||||
yield* conversation.turn(event)
|
||||
|
||||
// After the first turn, the ledger state should be completed (recovery succeeded inline)
|
||||
// A second turn with the same event should be blocked by admit
|
||||
yield* conversation.turn(event)
|
||||
|
||||
// The second turn should NOT have sent the prompt again
|
||||
// (admit returns false because the message was already admitted)
|
||||
const sendActions = actions.filter((x) => x.kind === "send")
|
||||
// The first turn should have: recovery message + successful reply
|
||||
expect(sendActions.length).toBeGreaterThanOrEqual(1)
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("two different messages on same thread are processed sequentially (not lost)", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const ledger = makeTrackingLedger()
|
||||
const event1 = makeThreadEvent({ threadId: "t1", channelId: "c1", messageId: "m1", content: "first" })
|
||||
const event2 = makeThreadEvent({ threadId: "t1", channelId: "c1", messageId: "m2", content: "second" })
|
||||
|
||||
const live = makeConversationLayerWithLedger({
|
||||
events: [event1, event2],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s1")],
|
||||
send: (_session, text) => Effect.succeed(`echo:${text}`),
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
ledger: ledger.service,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* conversation.run
|
||||
|
||||
// Both messages should be processed (different message_ids)
|
||||
expect(prompts).toEqual(["first", "second"])
|
||||
expect(actions.filter((x) => x.kind === "send").length).toBe(2)
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
})
|
||||
328
packages/discord/src/conversation/services/conversation.ts
Normal file
328
packages/discord/src/conversation/services/conversation.ts
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
import { Context, Effect, Layer, Option, Schedule, Stream } from "effect"
|
||||
import { AppConfig } from "../../config"
|
||||
import { TurnRouter } from "../../discord/turn-routing"
|
||||
import { ActorMap } from "../../lib/actors/keyed"
|
||||
import { ThreadAgentPool } from "../../sandbox/pool"
|
||||
import type { ChannelId, ThreadId } from "../../types"
|
||||
import { type ConversationError, messageOf, ReliabilityError, RoutingError, SandboxSendError } from "../model/errors"
|
||||
import { Send, type Inbound } from "../model/schema"
|
||||
import { History } from "./history"
|
||||
import { Inbox } from "./inbox"
|
||||
import { ConversationLedger } from "./ledger"
|
||||
import { Outbox } from "./outbox"
|
||||
import { Threads } from "./threads"
|
||||
|
||||
export declare namespace Conversation {
|
||||
export interface Service {
|
||||
readonly turn: (event: Inbound) => Effect.Effect<void, ConversationError>
|
||||
readonly run: Effect.Effect<void>
|
||||
}
|
||||
}
|
||||
|
||||
export class Conversation extends Context.Tag("@discord/conversation/Conversation")<
|
||||
Conversation,
|
||||
Conversation.Service
|
||||
>() {
|
||||
static readonly layer = Layer.scoped(
|
||||
Conversation,
|
||||
Effect.gen(function* () {
|
||||
const inbox = yield* Inbox
|
||||
const outbox = yield* Outbox
|
||||
const history = yield* History
|
||||
const threads = yield* Threads
|
||||
const ledger = yield* ConversationLedger
|
||||
const config = yield* AppConfig
|
||||
const pool = yield* ThreadAgentPool
|
||||
const router = yield* TurnRouter
|
||||
const actors = yield* ActorMap.make<string>()
|
||||
const RETRIABLE_TAGS: ReadonlySet<string> = new Set([
|
||||
"SandboxDeadError",
|
||||
"OpenCodeClientError",
|
||||
"HealthCheckError",
|
||||
"SandboxStartError",
|
||||
])
|
||||
const RETRY_MESSAGE = "Something went wrong. Please try again in a moment."
|
||||
const turnRetry = Schedule.exponential("500 millis").pipe(
|
||||
Schedule.intersect(Schedule.recurs(2)),
|
||||
Schedule.whileInput((error: ConversationError) => error.retriable),
|
||||
)
|
||||
|
||||
const asSendError =
|
||||
(thread_id: ThreadId) =>
|
||||
(cause: { readonly _tag: string }): SandboxSendError =>
|
||||
SandboxSendError.make({
|
||||
thread_id,
|
||||
message: messageOf(cause),
|
||||
retriable: RETRIABLE_TAGS.has(cause._tag),
|
||||
})
|
||||
|
||||
const asReliabilityError = (message_id: string) =>
|
||||
(cause: unknown): ReliabilityError =>
|
||||
ReliabilityError.make({
|
||||
message_id,
|
||||
message: messageOf(cause),
|
||||
retriable: true,
|
||||
})
|
||||
|
||||
const publishText = (threadId: ThreadId, text: string) =>
|
||||
outbox.publish(
|
||||
Send.make({
|
||||
kind: "send",
|
||||
thread_id: threadId,
|
||||
text,
|
||||
}),
|
||||
)
|
||||
|
||||
const reportFailure = (thread_id: ThreadId) => (error: ConversationError) => {
|
||||
if (error.retriable) return Effect.fail(error)
|
||||
return publishText(thread_id, RETRY_MESSAGE).pipe(
|
||||
Effect.catchAll(() => Effect.void),
|
||||
Effect.zipRight(Effect.fail(error)),
|
||||
)
|
||||
}
|
||||
|
||||
const route = Effect.fn("Conversation.route")(function* (event: Inbound) {
|
||||
if (event.author_is_bot) return false
|
||||
if (event.mentions_everyone) return false
|
||||
if (!event.content.trim()) return false
|
||||
|
||||
const mentioned = event.mentions.user_ids.includes(event.bot_user_id)
|
||||
|| (event.bot_role_id.length > 0 && event.mentions.role_ids.includes(event.bot_role_id))
|
||||
if (event.kind === "channel_message") return mentioned
|
||||
if (mentioned) return true
|
||||
|
||||
const owned = yield* pool.hasTrackedThread(event.thread_id).pipe(
|
||||
Effect.mapError(asSendError(event.thread_id)),
|
||||
)
|
||||
if (!owned) return false
|
||||
|
||||
const decision = yield* router.shouldRespond({
|
||||
content: event.content,
|
||||
botUserId: event.bot_user_id,
|
||||
botRoleId: event.bot_role_id,
|
||||
mentionedUserIds: event.mentions.user_ids,
|
||||
mentionedRoleIds: event.mentions.role_ids,
|
||||
}).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
RoutingError.make({
|
||||
message: messageOf(cause),
|
||||
retriable: false,
|
||||
})),
|
||||
)
|
||||
return decision.shouldRespond
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("Conversation.resolve")(function* (event: Inbound) {
|
||||
if (event.kind === "thread_message") {
|
||||
return { thread_id: event.thread_id, channel_id: event.channel_id }
|
||||
}
|
||||
const name = yield* router.generateThreadName(event.content)
|
||||
return yield* threads.ensure(event, name)
|
||||
})
|
||||
|
||||
const buildInput = Effect.fn("Conversation.buildInput")(function* (
|
||||
event: Inbound,
|
||||
target: { thread_id: ThreadId; channel_id: ChannelId },
|
||||
) {
|
||||
const toSendError = asSendError(target.thread_id)
|
||||
const tracked = yield* pool.getTrackedSession(target.thread_id).pipe(
|
||||
Effect.mapError(toSendError),
|
||||
)
|
||||
const agent = yield* pool.getOrCreate(target.thread_id, target.channel_id, event.guild_id).pipe(
|
||||
Effect.mapError(toSendError),
|
||||
)
|
||||
const current = yield* agent.current().pipe(
|
||||
Effect.mapError(toSendError),
|
||||
)
|
||||
const prompt = Option.isSome(tracked) && tracked.value.sessionId !== current.sessionId
|
||||
? yield* history.rehydrate(target.thread_id, event.content)
|
||||
: event.content
|
||||
return { target, agent, prompt, session: current }
|
||||
})
|
||||
|
||||
const command = (event: Inbound, target: { thread_id: ThreadId; channel_id: ChannelId }) =>
|
||||
Effect.gen(function* () {
|
||||
const text = event.content.trim().toLowerCase()
|
||||
if (text === "!reset") {
|
||||
yield* pool.destroySession(target.thread_id).pipe(Effect.catchAll(() => Effect.void))
|
||||
yield* publishText(target.thread_id, "*☠️ Session destroyed. Next message will provision a fresh sandbox.*")
|
||||
return true
|
||||
}
|
||||
if (text === "!status") {
|
||||
const tracked = yield* pool.getTrackedSession(target.thread_id).pipe(Effect.catchAll(() => Effect.succeed(Option.none())))
|
||||
if (Option.isNone(tracked)) {
|
||||
yield* publishText(target.thread_id, "*No active session for this thread.*")
|
||||
} else {
|
||||
const s = tracked.value
|
||||
const model = config.openCodeModel.replace("opencode/", "")
|
||||
const lines = [
|
||||
`**Status:** ${s.status}`,
|
||||
`**Model:** \`${model}\``,
|
||||
`**Sandbox:** \`${s.sandboxId}\``,
|
||||
`**Session:** \`${s.sessionId}\``,
|
||||
s.resumeFailCount > 0 ? `**Resume failures:** ${s.resumeFailCount}` : null,
|
||||
s.lastError ? `**Last error:** ${s.lastError.slice(0, 200)}` : null,
|
||||
].filter(Boolean)
|
||||
yield* publishText(target.thread_id, lines.join("\n"))
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const turnRaw = Effect.fn("Conversation.turnRaw")(function* (
|
||||
event: Inbound,
|
||||
state: {
|
||||
thread_id: ThreadId | null
|
||||
channel_id: ChannelId | null
|
||||
response_text: string | null
|
||||
prompt_text: string | null
|
||||
session_id: string | null
|
||||
},
|
||||
) {
|
||||
if (!(yield* route(event))) return
|
||||
|
||||
const target = state.thread_id && state.channel_id
|
||||
? { thread_id: state.thread_id, channel_id: state.channel_id }
|
||||
: yield* resolve(event)
|
||||
|
||||
yield* ledger.setTarget(event.message_id, target.thread_id, target.channel_id).pipe(
|
||||
Effect.mapError(asReliabilityError(event.message_id)),
|
||||
)
|
||||
|
||||
if (state.response_text) {
|
||||
yield* publishText(target.thread_id, state.response_text)
|
||||
return
|
||||
}
|
||||
|
||||
if (yield* command(event, target)) return
|
||||
|
||||
yield* Effect.logInfo("User message").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.user.message",
|
||||
thread_id: target.thread_id,
|
||||
author_id: event.author_id,
|
||||
content: event.content.slice(0, 200),
|
||||
}),
|
||||
)
|
||||
|
||||
yield* outbox.withTyping(
|
||||
target.thread_id,
|
||||
Effect.gen(function* () {
|
||||
const input = yield* buildInput(event, target)
|
||||
const reuse = state.prompt_text !== null
|
||||
&& state.session_id !== null
|
||||
&& state.session_id === input.session.sessionId
|
||||
const prompt = reuse ? (state.prompt_text ?? input.prompt) : input.prompt
|
||||
if (!reuse) {
|
||||
yield* ledger.setPrompt(event.message_id, prompt, input.session.sessionId).pipe(
|
||||
Effect.mapError(asReliabilityError(event.message_id)),
|
||||
)
|
||||
} else {
|
||||
yield* Effect.logInfo("Recovered in-flight prompt from ledger").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.ledger.prompt.reused",
|
||||
message_id: event.message_id,
|
||||
thread_id: target.thread_id,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const reply = yield* input.agent.send(prompt).pipe(
|
||||
Effect.catchTag("SandboxDeadError", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* publishText(input.target.thread_id, "*Session changed state, recovering...*")
|
||||
const toErr = asSendError(input.target.thread_id)
|
||||
const next = yield* pool.getOrCreate(
|
||||
input.target.thread_id,
|
||||
input.target.channel_id,
|
||||
event.guild_id,
|
||||
).pipe(Effect.mapError(toErr))
|
||||
const nextSession = yield* next.current().pipe(
|
||||
Effect.mapError(toErr),
|
||||
)
|
||||
const prompt = nextSession.sessionId !== input.session.sessionId
|
||||
? yield* history.rehydrate(input.target.thread_id, event.content)
|
||||
: event.content
|
||||
return yield* next.send(prompt)
|
||||
}),
|
||||
),
|
||||
Effect.mapError(asSendError(input.target.thread_id)),
|
||||
)
|
||||
|
||||
yield* Effect.logInfo("Bot reply").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.bot.reply",
|
||||
thread_id: input.target.thread_id,
|
||||
content: reply.slice(0, 200),
|
||||
}),
|
||||
)
|
||||
yield* ledger.setResponse(event.message_id, reply).pipe(
|
||||
Effect.mapError(asReliabilityError(event.message_id)),
|
||||
)
|
||||
yield* publishText(input.target.thread_id, reply)
|
||||
}),
|
||||
).pipe(
|
||||
Effect.catchAll(reportFailure(target.thread_id)),
|
||||
)
|
||||
})
|
||||
|
||||
const keyOf = (event: Inbound) =>
|
||||
event.kind === "thread_message"
|
||||
? `thread:${event.thread_id}`
|
||||
: `channel:${event.channel_id}`
|
||||
|
||||
const runEvent = Effect.fn("Conversation.runEvent")(function* (event: Inbound) {
|
||||
yield* ledger.admit(event).pipe(
|
||||
Effect.mapError(asReliabilityError(event.message_id)),
|
||||
)
|
||||
const state = yield* ledger.start(event.message_id).pipe(
|
||||
Effect.mapError(asReliabilityError(event.message_id)),
|
||||
)
|
||||
if (Option.isNone(state)) return
|
||||
|
||||
yield* turnRaw(event, state.value).pipe(
|
||||
Effect.tap(() =>
|
||||
ledger.complete(event.message_id).pipe(
|
||||
Effect.mapError(asReliabilityError(event.message_id)),
|
||||
)),
|
||||
Effect.catchAll((error) =>
|
||||
ledger.retry(event.message_id, messageOf(error).slice(0, 500)).pipe(
|
||||
Effect.mapError(asReliabilityError(event.message_id)),
|
||||
Effect.zipRight(Effect.fail(error)),
|
||||
)),
|
||||
)
|
||||
})
|
||||
|
||||
const turn = Effect.fn("Conversation.turn")(function* (event: Inbound) {
|
||||
yield* actors.run(
|
||||
keyOf(event),
|
||||
runEvent(event),
|
||||
{ touch: false },
|
||||
)
|
||||
})
|
||||
|
||||
const run = inbox.events.pipe(
|
||||
Stream.mapEffect(
|
||||
(event) =>
|
||||
turn(event).pipe(
|
||||
Effect.retry(turnRetry),
|
||||
Effect.catchAll((error) =>
|
||||
Effect.logError("Conversation turn failed").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.turn.failed",
|
||||
tag: error._tag,
|
||||
retriable: error.retriable,
|
||||
message: error.message,
|
||||
}),
|
||||
)),
|
||||
),
|
||||
{ concurrency: "unbounded", unordered: true },
|
||||
),
|
||||
Stream.runDrain,
|
||||
)
|
||||
|
||||
return Conversation.of({ turn, run })
|
||||
}),
|
||||
)
|
||||
}
|
||||
18
packages/discord/src/conversation/services/history.ts
Normal file
18
packages/discord/src/conversation/services/history.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Context, Effect, Layer } from "effect"
|
||||
import type { ThreadId } from "../../types"
|
||||
import type { HistoryError } from "../model/errors"
|
||||
|
||||
export declare namespace History {
|
||||
export interface Service {
|
||||
readonly rehydrate: (thread_id: ThreadId, latest: string) => Effect.Effect<string, HistoryError>
|
||||
}
|
||||
}
|
||||
|
||||
export class History extends Context.Tag("@discord/conversation/History")<History, History.Service>() {
|
||||
static readonly passthrough = Layer.succeed(
|
||||
History,
|
||||
History.of({
|
||||
rehydrate: (_thread_id: ThreadId, latest: string) => Effect.succeed(latest),
|
||||
}),
|
||||
)
|
||||
}
|
||||
17
packages/discord/src/conversation/services/inbox.ts
Normal file
17
packages/discord/src/conversation/services/inbox.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Context, Layer, Stream } from "effect"
|
||||
import type { Inbound } from "../model/schema"
|
||||
|
||||
export declare namespace Inbox {
|
||||
export interface Service {
|
||||
readonly events: Stream.Stream<Inbound>
|
||||
}
|
||||
}
|
||||
|
||||
export class Inbox extends Context.Tag("@discord/conversation/Inbox")<Inbox, Inbox.Service>() {
|
||||
static readonly empty = Layer.succeed(
|
||||
Inbox,
|
||||
Inbox.of({
|
||||
events: Stream.empty as Stream.Stream<Inbound>,
|
||||
}),
|
||||
)
|
||||
}
|
||||
6
packages/discord/src/conversation/services/index.ts
Normal file
6
packages/discord/src/conversation/services/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export { Conversation } from "./conversation"
|
||||
export { History } from "./history"
|
||||
export { Inbox } from "./inbox"
|
||||
export { ConversationLedger } from "./ledger"
|
||||
export { Outbox } from "./outbox"
|
||||
export { Threads } from "./threads"
|
||||
166
packages/discord/src/conversation/services/ledger.test.ts
Normal file
166
packages/discord/src/conversation/services/ledger.test.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import * as Client from "@effect/sql/SqlClient"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Layer, Option, Redacted } from "effect"
|
||||
import { AppConfig } from "../../config"
|
||||
import { SqliteDb } from "../../db/client"
|
||||
import { initializeSchema } from "../../db/init"
|
||||
import { effectTest, withTempSqliteFile } from "../../test/effect"
|
||||
import { ChannelId, GuildId, SessionId, ThreadId } from "../../types"
|
||||
import { Mention, ThreadMessage, type Inbound } from "../model/schema"
|
||||
import { ConversationLedger } from "./ledger"
|
||||
|
||||
const makeConfig = (databasePath: string) =>
|
||||
AppConfig.of({
|
||||
discordToken: Redacted.make("token"),
|
||||
allowedChannelIds: [],
|
||||
discordCategoryId: "",
|
||||
discordRoleId: "",
|
||||
discordRequiredRoleId: "",
|
||||
discordCommandGuildId: "",
|
||||
databasePath,
|
||||
daytonaApiKey: Redacted.make("daytona"),
|
||||
openCodeZenApiKey: Redacted.make("zen"),
|
||||
githubToken: "",
|
||||
logLevel: "info",
|
||||
healthHost: "127.0.0.1",
|
||||
healthPort: 8787,
|
||||
turnRoutingMode: "off",
|
||||
turnRoutingModel: "claude-haiku-4-5",
|
||||
sandboxReusePolicy: "resume_preferred",
|
||||
sandboxTimeout: Duration.minutes(30),
|
||||
cleanupInterval: Duration.minutes(5),
|
||||
staleActiveGraceMinutes: 5 as AppConfig.Service["staleActiveGraceMinutes"],
|
||||
pausedTtlMinutes: 180 as AppConfig.Service["pausedTtlMinutes"],
|
||||
activeHealthCheckTimeoutMs: 15000 as AppConfig.Service["activeHealthCheckTimeoutMs"],
|
||||
startupHealthTimeoutMs: 120000 as AppConfig.Service["startupHealthTimeoutMs"],
|
||||
resumeHealthTimeoutMs: 120000 as AppConfig.Service["resumeHealthTimeoutMs"],
|
||||
sandboxCreationTimeout: 180 as AppConfig.Service["sandboxCreationTimeout"],
|
||||
openCodeModel: "opencode/claude-sonnet-4-5",
|
||||
})
|
||||
|
||||
const event = (message_id: string, content: string): Inbound =>
|
||||
ThreadMessage.make({
|
||||
kind: "thread_message",
|
||||
thread_id: ThreadId.make("t1"),
|
||||
channel_id: ChannelId.make("c1"),
|
||||
message_id,
|
||||
guild_id: GuildId.make("g1"),
|
||||
bot_user_id: "bot-1",
|
||||
bot_role_id: "role-1",
|
||||
author_id: "u1",
|
||||
author_is_bot: false,
|
||||
mentions_everyone: false,
|
||||
mentions: Mention.make({ user_ids: ["bot-1"], role_ids: [] }),
|
||||
content,
|
||||
})
|
||||
|
||||
const withLedger = <A, E, R>(
|
||||
run: (ledger: ConversationLedger.Service, sql: Client.SqlClient) => Effect.Effect<A, E, R>,
|
||||
) =>
|
||||
withTempSqliteFile((databasePath) =>
|
||||
Effect.gen(function* () {
|
||||
const config = Layer.succeed(AppConfig, makeConfig(databasePath))
|
||||
const sqlite = SqliteDb.layer.pipe(Layer.provide(config))
|
||||
const deps = Layer.merge(sqlite, config)
|
||||
const live = Layer.merge(
|
||||
ConversationLedger.layer.pipe(Layer.provide(deps)),
|
||||
sqlite,
|
||||
)
|
||||
const program = Effect.all([ConversationLedger, SqliteDb]).pipe(
|
||||
Effect.flatMap(([ledger, sql]) =>
|
||||
initializeSchema.pipe(
|
||||
Effect.provideService(Client.SqlClient, sql),
|
||||
Effect.zipRight(run(ledger, sql)),
|
||||
)),
|
||||
)
|
||||
return yield* program.pipe(Effect.provide(live))
|
||||
}),
|
||||
"discord-ledger-",
|
||||
)
|
||||
|
||||
describe("ConversationLedger", () => {
|
||||
effectTest("deduplicates by message id and tracks completion", () =>
|
||||
withLedger((ledger) =>
|
||||
Effect.gen(function* () {
|
||||
const m = event("m1", "hello")
|
||||
expect(yield* ledger.admit(m)).toBe(true)
|
||||
expect(yield* ledger.admit(m)).toBe(false)
|
||||
|
||||
const started = yield* ledger.start(m.message_id)
|
||||
expect(Option.isSome(started)).toBe(true)
|
||||
if (Option.isNone(started)) return
|
||||
|
||||
yield* ledger.setTarget(m.message_id, ThreadId.make("t1"), ChannelId.make("c1"))
|
||||
yield* ledger.setPrompt(m.message_id, "prompt:hello", SessionId.make("s1"))
|
||||
yield* ledger.setResponse(m.message_id, "reply:hello")
|
||||
yield* ledger.complete(m.message_id)
|
||||
|
||||
const next = yield* ledger.start(m.message_id)
|
||||
expect(Option.isNone(next)).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
effectTest("replays pending rows and recovers processing rows", () =>
|
||||
withLedger((ledger) =>
|
||||
Effect.gen(function* () {
|
||||
const a = event("m-a", "one")
|
||||
const b = event("m-b", "two")
|
||||
yield* ledger.admit(a)
|
||||
yield* ledger.admit(b)
|
||||
|
||||
const started = yield* ledger.start(a.message_id)
|
||||
expect(Option.isSome(started)).toBe(true)
|
||||
|
||||
const replay = yield* ledger.replayPending()
|
||||
expect(replay.map((x) => x.message_id)).toEqual(["m-a", "m-b"])
|
||||
|
||||
const again = yield* ledger.start(a.message_id)
|
||||
expect(Option.isSome(again)).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
effectTest("retains cached response across retry and prunes old completed rows", () =>
|
||||
withLedger((ledger, sql) =>
|
||||
Effect.gen(function* () {
|
||||
const m = event("m-cache", "cache")
|
||||
yield* ledger.admit(m)
|
||||
yield* ledger.start(m.message_id)
|
||||
yield* ledger.setTarget(m.message_id, ThreadId.make("t1"), ChannelId.make("c1"))
|
||||
yield* ledger.setPrompt(m.message_id, "prompt:cache", SessionId.make("s1"))
|
||||
yield* ledger.setResponse(m.message_id, "reply:cache")
|
||||
yield* ledger.retry(m.message_id, "send failed")
|
||||
|
||||
const resumed = yield* ledger.start(m.message_id)
|
||||
expect(Option.isSome(resumed)).toBe(true)
|
||||
if (Option.isSome(resumed)) {
|
||||
expect(resumed.value.response_text).toBe("reply:cache")
|
||||
expect(resumed.value.thread_id).toBe(ThreadId.make("t1"))
|
||||
expect(resumed.value.channel_id).toBe(ChannelId.make("c1"))
|
||||
}
|
||||
|
||||
yield* ledger.complete(m.message_id)
|
||||
yield* sql`UPDATE conversation_inbox
|
||||
SET completed_at = datetime('now', '-10 minutes')
|
||||
WHERE message_id = ${m.message_id}`
|
||||
yield* ledger.prune()
|
||||
|
||||
const rows = yield* sql<{ n: number }>`SELECT COUNT(*) AS n FROM conversation_inbox WHERE message_id = ${m.message_id}`
|
||||
expect(rows[0]?.n ?? 0).toBe(0)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
effectTest("stores and updates source offsets", () =>
|
||||
withLedger((ledger) =>
|
||||
Effect.gen(function* () {
|
||||
expect(Option.isNone(yield* ledger.getOffset("thread:t1"))).toBe(true)
|
||||
yield* ledger.setOffset("thread:t1", "m1")
|
||||
expect(yield* ledger.getOffset("thread:t1")).toEqual(Option.some("m1"))
|
||||
yield* ledger.setOffset("thread:t1", "m9")
|
||||
expect(yield* ledger.getOffset("thread:t1")).toEqual(Option.some("m9"))
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
274
packages/discord/src/conversation/services/ledger.ts
Normal file
274
packages/discord/src/conversation/services/ledger.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import * as Client from "@effect/sql/SqlClient"
|
||||
import { Context, Effect, Layer, Option, Schedule, Schema } from "effect"
|
||||
import { AppConfig } from "../../config"
|
||||
import { SqliteDb } from "../../db/client"
|
||||
import { initializeSchema } from "../../db/init"
|
||||
import { DatabaseError } from "../../errors"
|
||||
import { ChannelId, SessionId, ThreadId } from "../../types"
|
||||
import { Inbound } from "../model/schema"
|
||||
|
||||
const DEDUP_TTL_MINUTES = 5
|
||||
const PRUNE_BATCH_SIZE = 500
|
||||
|
||||
type Snapshot = {
|
||||
thread_id: ThreadId | null
|
||||
channel_id: ChannelId | null
|
||||
response_text: string | null
|
||||
prompt_text: string | null
|
||||
session_id: SessionId | null
|
||||
}
|
||||
|
||||
export class MessageState extends Schema.Class<MessageState>("MessageState")({
|
||||
thread_id: Schema.NullOr(ThreadId),
|
||||
channel_id: Schema.NullOr(ChannelId),
|
||||
response_text: Schema.NullOr(Schema.String),
|
||||
prompt_text: Schema.NullOr(Schema.String),
|
||||
session_id: Schema.NullOr(SessionId),
|
||||
}) {}
|
||||
|
||||
const InboundJson = Schema.parseJson(Inbound)
|
||||
const decode = Schema.decodeUnknown(InboundJson)
|
||||
const encode = Schema.encode(InboundJson)
|
||||
|
||||
const db = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(Effect.mapError((cause) => new DatabaseError({ cause })))
|
||||
|
||||
const changes = (sql: Client.SqlClient) =>
|
||||
db(
|
||||
sql<{ n: number }>`SELECT changes() AS n`.pipe(
|
||||
Effect.map((rows) => rows[0]?.n ?? 0),
|
||||
),
|
||||
)
|
||||
|
||||
const toState = (row: Snapshot) =>
|
||||
MessageState.make({
|
||||
thread_id: row.thread_id,
|
||||
channel_id: row.channel_id,
|
||||
response_text: row.response_text,
|
||||
prompt_text: row.prompt_text,
|
||||
session_id: row.session_id,
|
||||
})
|
||||
|
||||
export declare namespace ConversationLedger {
|
||||
export interface Service {
|
||||
readonly admit: (event: Inbound) => Effect.Effect<boolean, DatabaseError>
|
||||
readonly replayPending: () => Effect.Effect<ReadonlyArray<Inbound>, DatabaseError>
|
||||
readonly start: (message_id: string) => Effect.Effect<Option.Option<MessageState>, DatabaseError>
|
||||
readonly setTarget: (message_id: string, thread_id: ThreadId, channel_id: ChannelId) => Effect.Effect<void, DatabaseError>
|
||||
readonly setPrompt: (message_id: string, prompt: string, session_id: SessionId) => Effect.Effect<void, DatabaseError>
|
||||
readonly setResponse: (message_id: string, response: string) => Effect.Effect<void, DatabaseError>
|
||||
readonly complete: (message_id: string) => Effect.Effect<void, DatabaseError>
|
||||
readonly retry: (message_id: string, error: string) => Effect.Effect<void, DatabaseError>
|
||||
readonly prune: () => Effect.Effect<void, DatabaseError>
|
||||
readonly getOffset: (source_id: string) => Effect.Effect<Option.Option<string>, DatabaseError>
|
||||
readonly setOffset: (source_id: string, message_id: string) => Effect.Effect<void, DatabaseError>
|
||||
}
|
||||
}
|
||||
|
||||
export class ConversationLedger extends Context.Tag("@discord/conversation/ConversationLedger")<
|
||||
ConversationLedger,
|
||||
ConversationLedger.Service
|
||||
>() {
|
||||
static readonly noop = Layer.effect(
|
||||
ConversationLedger,
|
||||
Effect.sync(() => {
|
||||
const pending = new Set<string>()
|
||||
const completed = new Set<string>()
|
||||
return ConversationLedger.of({
|
||||
admit: (event) =>
|
||||
Effect.sync(() => {
|
||||
if (pending.has(event.message_id) || completed.has(event.message_id)) return false
|
||||
pending.add(event.message_id)
|
||||
return true
|
||||
}),
|
||||
replayPending: () => Effect.succeed([]),
|
||||
start: (message_id) =>
|
||||
Effect.sync(() => {
|
||||
if (!pending.has(message_id)) return Option.none()
|
||||
pending.delete(message_id)
|
||||
return Option.some(MessageState.make({
|
||||
thread_id: null,
|
||||
channel_id: null,
|
||||
response_text: null,
|
||||
prompt_text: null,
|
||||
session_id: null,
|
||||
}))
|
||||
}),
|
||||
setTarget: () => Effect.void,
|
||||
setPrompt: () => Effect.void,
|
||||
setResponse: () => Effect.void,
|
||||
complete: (message_id) => Effect.sync(() => { completed.add(message_id) }),
|
||||
retry: (message_id) => Effect.sync(() => { pending.add(message_id) }),
|
||||
prune: () => Effect.void,
|
||||
getOffset: () => Effect.succeed(Option.none()),
|
||||
setOffset: () => Effect.void,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
static readonly layer = Layer.scoped(
|
||||
ConversationLedger,
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqliteDb
|
||||
const config = yield* AppConfig
|
||||
yield* db(initializeSchema.pipe(Effect.provideService(Client.SqlClient, sql)))
|
||||
|
||||
const admit = Effect.fn("ConversationLedger.admit")(function* (event: Inbound) {
|
||||
const payload = yield* encode(event).pipe(
|
||||
Effect.mapError((cause) => new DatabaseError({ cause })),
|
||||
)
|
||||
yield* db(
|
||||
sql`INSERT OR IGNORE INTO conversation_inbox (message_id, kind, payload_json, status, created_at, updated_at)
|
||||
VALUES (${event.message_id}, ${event.kind}, ${payload}, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
)
|
||||
return (yield* changes(sql)) > 0
|
||||
})
|
||||
|
||||
const replayPending = Effect.fn("ConversationLedger.replayPending")(function* () {
|
||||
yield* db(
|
||||
sql`UPDATE conversation_inbox
|
||||
SET status = 'pending', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE status = 'processing'`,
|
||||
)
|
||||
const rows = yield* db(
|
||||
sql<{ payload_json: string }>`SELECT payload_json
|
||||
FROM conversation_inbox
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at ASC`,
|
||||
)
|
||||
return yield* Effect.forEach(rows, (row) =>
|
||||
decode(row.payload_json).pipe(Effect.mapError((cause) => new DatabaseError({ cause }))),
|
||||
)
|
||||
})
|
||||
|
||||
const start = Effect.fn("ConversationLedger.start")(function* (message_id: string) {
|
||||
yield* db(
|
||||
sql`UPDATE conversation_inbox
|
||||
SET status = 'processing', attempts = attempts + 1,
|
||||
processing_started_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE message_id = ${message_id} AND status = 'pending'`,
|
||||
)
|
||||
if ((yield* changes(sql)) === 0) return Option.none<MessageState>()
|
||||
const rows = yield* db(
|
||||
sql<Snapshot>`SELECT thread_id, channel_id, response_text, prompt_text, session_id
|
||||
FROM conversation_inbox
|
||||
WHERE message_id = ${message_id}
|
||||
LIMIT 1`,
|
||||
)
|
||||
const row = rows[0]
|
||||
if (!row) return Option.none<MessageState>()
|
||||
return Option.some(toState(row))
|
||||
})
|
||||
|
||||
const setTarget = Effect.fn("ConversationLedger.setTarget")(function* (
|
||||
message_id: string,
|
||||
thread_id: ThreadId,
|
||||
channel_id: ChannelId,
|
||||
) {
|
||||
yield* db(
|
||||
sql`UPDATE conversation_inbox
|
||||
SET thread_id = ${thread_id}, channel_id = ${channel_id}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE message_id = ${message_id}`,
|
||||
)
|
||||
})
|
||||
|
||||
const setPrompt = Effect.fn("ConversationLedger.setPrompt")(function* (
|
||||
message_id: string,
|
||||
prompt: string,
|
||||
session_id: SessionId,
|
||||
) {
|
||||
yield* db(
|
||||
sql`UPDATE conversation_inbox
|
||||
SET prompt_text = ${prompt}, session_id = ${session_id}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE message_id = ${message_id}`,
|
||||
)
|
||||
})
|
||||
|
||||
const setResponse = Effect.fn("ConversationLedger.setResponse")(function* (message_id: string, response: string) {
|
||||
yield* db(
|
||||
sql`UPDATE conversation_inbox
|
||||
SET response_text = ${response}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE message_id = ${message_id}`,
|
||||
)
|
||||
})
|
||||
|
||||
const complete = Effect.fn("ConversationLedger.complete")(function* (message_id: string) {
|
||||
yield* db(
|
||||
sql`UPDATE conversation_inbox
|
||||
SET status = 'completed', completed_at = CURRENT_TIMESTAMP,
|
||||
processing_started_at = NULL, last_error = NULL, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE message_id = ${message_id}`,
|
||||
)
|
||||
})
|
||||
|
||||
const retry = Effect.fn("ConversationLedger.retry")(function* (message_id: string, error: string) {
|
||||
yield* db(
|
||||
sql`UPDATE conversation_inbox
|
||||
SET status = 'pending', last_error = ${error}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE message_id = ${message_id}`,
|
||||
)
|
||||
})
|
||||
|
||||
const prune = Effect.fn("ConversationLedger.prune")(function* () {
|
||||
yield* db(
|
||||
sql`DELETE FROM conversation_inbox
|
||||
WHERE message_id IN (
|
||||
SELECT message_id
|
||||
FROM conversation_inbox
|
||||
WHERE status = 'completed'
|
||||
AND completed_at IS NOT NULL
|
||||
AND completed_at < datetime('now', '-' || ${DEDUP_TTL_MINUTES} || ' minutes')
|
||||
ORDER BY completed_at ASC
|
||||
LIMIT ${PRUNE_BATCH_SIZE}
|
||||
)`,
|
||||
)
|
||||
})
|
||||
|
||||
const getOffset = Effect.fn("ConversationLedger.getOffset")(function* (source_id: string) {
|
||||
const rows = yield* db(
|
||||
sql<{ last_message_id: string }>`SELECT last_message_id
|
||||
FROM conversation_offsets
|
||||
WHERE source_id = ${source_id}
|
||||
LIMIT 1`,
|
||||
)
|
||||
const row = rows[0]
|
||||
if (!row) return Option.none<string>()
|
||||
return Option.some(row.last_message_id)
|
||||
})
|
||||
|
||||
const setOffset = Effect.fn("ConversationLedger.setOffset")(function* (source_id: string, message_id: string) {
|
||||
yield* db(
|
||||
sql`INSERT INTO conversation_offsets (source_id, last_message_id, updated_at)
|
||||
VALUES (${source_id}, ${message_id}, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(source_id) DO UPDATE SET
|
||||
last_message_id = excluded.last_message_id,
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
)
|
||||
})
|
||||
|
||||
yield* prune().pipe(
|
||||
Effect.catchAll((error) =>
|
||||
Effect.logError("Conversation ledger prune failed").pipe(
|
||||
Effect.annotateLogs({ event: "conversation.ledger.prune.failed", error: String(error) }),
|
||||
)),
|
||||
Effect.repeat(Schedule.spaced(config.cleanupInterval)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return ConversationLedger.of({
|
||||
admit,
|
||||
replayPending,
|
||||
start,
|
||||
setTarget,
|
||||
setPrompt,
|
||||
setResponse,
|
||||
complete,
|
||||
retry,
|
||||
prune,
|
||||
getOffset,
|
||||
setOffset,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
21
packages/discord/src/conversation/services/outbox.ts
Normal file
21
packages/discord/src/conversation/services/outbox.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Context, Effect, Layer } from "effect"
|
||||
import type { ThreadId } from "../../types"
|
||||
import type { DeliveryError } from "../model/errors"
|
||||
import type { Action } from "../model/schema"
|
||||
|
||||
export declare namespace Outbox {
|
||||
export interface Service {
|
||||
readonly publish: (action: Action) => Effect.Effect<void, DeliveryError>
|
||||
readonly withTyping: <A, E, R>(thread_id: ThreadId, self: Effect.Effect<A, E, R>) => Effect.Effect<A, E | DeliveryError, R>
|
||||
}
|
||||
}
|
||||
|
||||
export class Outbox extends Context.Tag("@discord/conversation/Outbox")<Outbox, Outbox.Service>() {
|
||||
static readonly noop = Layer.succeed(
|
||||
Outbox,
|
||||
Outbox.of({
|
||||
publish: () => Effect.void,
|
||||
withTyping: (_thread_id, self) => self,
|
||||
}),
|
||||
)
|
||||
}
|
||||
29
packages/discord/src/conversation/services/threads.ts
Normal file
29
packages/discord/src/conversation/services/threads.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Context, Effect, Layer } from "effect"
|
||||
import { ThreadEnsureError } from "../model/errors"
|
||||
import type { Inbound, ThreadRef } from "../model/schema"
|
||||
|
||||
export declare namespace Threads {
|
||||
export interface Service {
|
||||
readonly ensure: (event: Inbound, name: string) => Effect.Effect<ThreadRef, ThreadEnsureError>
|
||||
}
|
||||
}
|
||||
|
||||
export class Threads extends Context.Tag("@discord/conversation/Threads")<Threads, Threads.Service>() {
|
||||
static readonly empty = Layer.succeed(
|
||||
Threads,
|
||||
Threads.of({
|
||||
ensure: (event) => {
|
||||
if (event.kind === "thread_message") {
|
||||
return Effect.succeed({ thread_id: event.thread_id, channel_id: event.channel_id })
|
||||
}
|
||||
return Effect.fail(
|
||||
ThreadEnsureError.make({
|
||||
channel_id: event.channel_id,
|
||||
message: "threads adapter missing for channel message",
|
||||
retriable: false,
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +1,22 @@
|
|||
import { Database } from "bun:sqlite"
|
||||
import { getEnv } from "../config"
|
||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||
import * as Client from "@effect/sql/SqlClient"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { AppConfig } from "../config"
|
||||
|
||||
let _db: Database | null = null
|
||||
|
||||
export function getDb(): Database {
|
||||
if (!_db) {
|
||||
_db = new Database(getEnv().DATABASE_PATH, { create: true })
|
||||
_db.exec("PRAGMA journal_mode = WAL;")
|
||||
_db.exec("PRAGMA busy_timeout = 5000;")
|
||||
}
|
||||
return _db
|
||||
export class SqliteDb extends Context.Tag("@discord/SqliteDb")<SqliteDb, Client.SqlClient>() {
|
||||
static readonly layer = Layer.effect(
|
||||
SqliteDb,
|
||||
Effect.gen(function* () {
|
||||
const db = yield* Client.SqlClient
|
||||
yield* db`PRAGMA busy_timeout = 5000`
|
||||
return db
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provide(
|
||||
Layer.unwrapEffect(
|
||||
Effect.map(AppConfig, (config) => SqliteClient.layer({ filename: config.databasePath })),
|
||||
),
|
||||
),
|
||||
Layer.orDie,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
129
packages/discord/src/db/init.test.ts
Normal file
129
packages/discord/src/db/init.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import * as Client from "@effect/sql/SqlClient"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { initializeSchema } from "./init"
|
||||
import { effectTest, withSqlite, withTempSqliteFile } from "../test/effect"
|
||||
|
||||
const columns = [
|
||||
"thread_id",
|
||||
"channel_id",
|
||||
"guild_id",
|
||||
"sandbox_id",
|
||||
"session_id",
|
||||
"preview_url",
|
||||
"preview_token",
|
||||
"status",
|
||||
"last_activity",
|
||||
"pause_requested_at",
|
||||
"paused_at",
|
||||
"resume_attempted_at",
|
||||
"resumed_at",
|
||||
"destroyed_at",
|
||||
"last_health_ok_at",
|
||||
"last_error",
|
||||
"resume_fail_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
const indexes = [
|
||||
"discord_sessions_status_last_activity_idx",
|
||||
"discord_sessions_status_updated_at_idx",
|
||||
]
|
||||
|
||||
const inboxColumns = [
|
||||
"message_id",
|
||||
"kind",
|
||||
"payload_json",
|
||||
"status",
|
||||
"thread_id",
|
||||
"channel_id",
|
||||
"prompt_text",
|
||||
"session_id",
|
||||
"response_text",
|
||||
"attempts",
|
||||
"processing_started_at",
|
||||
"completed_at",
|
||||
"last_error",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
const inboxIndexes = [
|
||||
"conversation_inbox_status_created_at_idx",
|
||||
"conversation_inbox_completed_at_idx",
|
||||
]
|
||||
|
||||
const offsetColumns = [
|
||||
"source_id",
|
||||
"last_message_id",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
const offsetIndexes = [
|
||||
"conversation_offsets_updated_at_idx",
|
||||
]
|
||||
|
||||
const getColumns = (db: Client.SqlClient) =>
|
||||
db<{ name: string }>`PRAGMA table_info(discord_sessions)`.pipe(
|
||||
Effect.map((rows) => rows.map((row: { name: string }) => row.name)),
|
||||
)
|
||||
|
||||
const getInboxColumns = (db: Client.SqlClient) =>
|
||||
db<{ name: string }>`PRAGMA table_info(conversation_inbox)`.pipe(
|
||||
Effect.map((rows) => rows.map((row: { name: string }) => row.name)),
|
||||
)
|
||||
|
||||
const getIndexes = (db: Client.SqlClient) =>
|
||||
db<{ name: string }>`PRAGMA index_list(discord_sessions)`.pipe(
|
||||
Effect.map((rows) => rows.map((row: { name: string }) => row.name)),
|
||||
)
|
||||
|
||||
const getInboxIndexes = (db: Client.SqlClient) =>
|
||||
db<{ name: string }>`PRAGMA index_list(conversation_inbox)`.pipe(
|
||||
Effect.map((rows) => rows.map((row: { name: string }) => row.name)),
|
||||
)
|
||||
|
||||
const getOffsetColumns = (db: Client.SqlClient) =>
|
||||
db<{ name: string }>`PRAGMA table_info(conversation_offsets)`.pipe(
|
||||
Effect.map((rows) => rows.map((row: { name: string }) => row.name)),
|
||||
)
|
||||
|
||||
const getOffsetIndexes = (db: Client.SqlClient) =>
|
||||
db<{ name: string }>`PRAGMA index_list(conversation_offsets)`.pipe(
|
||||
Effect.map((rows) => rows.map((row: { name: string }) => row.name)),
|
||||
)
|
||||
|
||||
describe("initializeSchema", () => {
|
||||
effectTest("creates schema and is idempotent", () =>
|
||||
withTempSqliteFile((filename) =>
|
||||
Effect.gen(function* () {
|
||||
yield* withSqlite(filename, (db) => initializeSchema.pipe(Effect.provideService(Client.SqlClient, db)))
|
||||
const one = yield* withSqlite(filename, getColumns)
|
||||
const inboxOne = yield* withSqlite(filename, getInboxColumns)
|
||||
const offsetOne = yield* withSqlite(filename, getOffsetColumns)
|
||||
expect(one).toEqual(columns)
|
||||
expect(inboxOne).toEqual(inboxColumns)
|
||||
expect(offsetOne).toEqual(offsetColumns)
|
||||
|
||||
yield* withSqlite(filename, (db) => initializeSchema.pipe(Effect.provideService(Client.SqlClient, db)))
|
||||
const two = yield* withSqlite(filename, getColumns)
|
||||
const inboxTwo = yield* withSqlite(filename, getInboxColumns)
|
||||
const offsetTwo = yield* withSqlite(filename, getOffsetColumns)
|
||||
expect(two).toEqual(one)
|
||||
expect(inboxTwo).toEqual(inboxOne)
|
||||
expect(offsetTwo).toEqual(offsetOne)
|
||||
|
||||
const seen = new Set(two)
|
||||
expect(seen.size).toBe(two.length)
|
||||
const actual = (yield* withSqlite(filename, getIndexes)).filter((name) => !name.startsWith("sqlite_"))
|
||||
const inboxActual = (yield* withSqlite(filename, getInboxIndexes)).filter((name) => !name.startsWith("sqlite_"))
|
||||
const offsetActual = (yield* withSqlite(filename, getOffsetIndexes)).filter((name) => !name.startsWith("sqlite_"))
|
||||
expect(new Set(actual)).toEqual(new Set(indexes))
|
||||
expect(new Set(inboxActual)).toEqual(new Set(inboxIndexes))
|
||||
expect(new Set(offsetActual)).toEqual(new Set(offsetIndexes))
|
||||
}),
|
||||
"discord-sessions-",
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,76 +1,23 @@
|
|||
import type { Database } from "bun:sqlite"
|
||||
import { getDb } from "./client"
|
||||
import { logger } from "../observability/logger"
|
||||
import { Reactivity } from "@effect/experimental"
|
||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||
import * as Migrator from "@effect/sql/Migrator"
|
||||
import * as Client from "@effect/sql/SqlClient"
|
||||
import { Effect } from "effect"
|
||||
import migration0001 from "./migrations/0001_discord_sessions"
|
||||
import migration0002 from "./migrations/0002_conversation_offsets"
|
||||
|
||||
const PREFIX = "[db]"
|
||||
const run = Migrator.make({})({
|
||||
loader: Migrator.fromRecord({
|
||||
"0001_discord_sessions": migration0001,
|
||||
"0002_conversation_offsets": migration0002,
|
||||
}),
|
||||
})
|
||||
|
||||
export async function initializeDatabase(): Promise<void> {
|
||||
const db = getDb()
|
||||
export const initializeSchema = run
|
||||
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS discord_sessions (
|
||||
thread_id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL,
|
||||
guild_id TEXT NOT NULL,
|
||||
sandbox_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
preview_url TEXT NOT NULL,
|
||||
preview_token TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN ('creating', 'active', 'pausing', 'paused', 'resuming', 'destroying', 'destroyed', 'error')),
|
||||
last_activity TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
pause_requested_at TEXT,
|
||||
paused_at TEXT,
|
||||
resume_attempted_at TEXT,
|
||||
resumed_at TEXT,
|
||||
destroyed_at TEXT,
|
||||
last_health_ok_at TEXT,
|
||||
last_error TEXT,
|
||||
resume_fail_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
|
||||
addColumn(db, "preview_token", "TEXT")
|
||||
addColumn(db, "last_activity", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP")
|
||||
addColumn(db, "created_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP")
|
||||
addColumn(db, "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP")
|
||||
addColumn(db, "pause_requested_at", "TEXT")
|
||||
addColumn(db, "paused_at", "TEXT")
|
||||
addColumn(db, "resume_attempted_at", "TEXT")
|
||||
addColumn(db, "resumed_at", "TEXT")
|
||||
addColumn(db, "destroyed_at", "TEXT")
|
||||
addColumn(db, "last_health_ok_at", "TEXT")
|
||||
addColumn(db, "last_error", "TEXT")
|
||||
addColumn(db, "resume_fail_count", "INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS discord_sessions_status_last_activity_idx
|
||||
ON discord_sessions (status, last_activity)`)
|
||||
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS discord_sessions_status_updated_at_idx
|
||||
ON discord_sessions (status, updated_at)`)
|
||||
}
|
||||
|
||||
function addColumn(db: Database, name: string, definition: string): void {
|
||||
if (hasColumn(db, name)) return
|
||||
db.exec(`ALTER TABLE discord_sessions ADD COLUMN ${name} ${definition}`)
|
||||
}
|
||||
|
||||
function hasColumn(db: Database, name: string): boolean {
|
||||
const rows = db.query("PRAGMA table_info(discord_sessions)").all() as Array<{ name: string }>
|
||||
return rows.some((row) => row.name === name)
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
initializeDatabase()
|
||||
.then(() => {
|
||||
logger.info({ event: "db.schema.ready", component: "db", message: `${PREFIX} Schema is ready` })
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error({
|
||||
event: "db.schema.failed",
|
||||
component: "db",
|
||||
message: `${PREFIX} Failed to initialize schema`,
|
||||
error: err,
|
||||
})
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
export const initializeSchemaForFile = (filename: string) =>
|
||||
SqliteClient.make({ filename }).pipe(
|
||||
Effect.provide(Reactivity.layer),
|
||||
Effect.flatMap((db) => run.pipe(Effect.provideService(Client.SqlClient, db))),
|
||||
Effect.scoped
|
||||
)
|
||||
|
|
|
|||
131
packages/discord/src/db/migrations/0001_discord_sessions.ts
Normal file
131
packages/discord/src/db/migrations/0001_discord_sessions.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import * as Client from "@effect/sql/SqlClient"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const TABLE = `CREATE TABLE IF NOT EXISTS discord_sessions (
|
||||
thread_id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL,
|
||||
guild_id TEXT NOT NULL,
|
||||
sandbox_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
preview_url TEXT NOT NULL,
|
||||
preview_token TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN ('creating', 'active', 'pausing', 'paused', 'resuming', 'destroying', 'destroyed', 'error')),
|
||||
last_activity TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
pause_requested_at TEXT,
|
||||
paused_at TEXT,
|
||||
resume_attempted_at TEXT,
|
||||
resumed_at TEXT,
|
||||
destroyed_at TEXT,
|
||||
last_health_ok_at TEXT,
|
||||
last_error TEXT,
|
||||
resume_fail_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`
|
||||
|
||||
const INBOX_TABLE = `CREATE TABLE IF NOT EXISTS conversation_inbox (
|
||||
message_id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('thread_message', 'channel_message')),
|
||||
payload_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'processing', 'completed')),
|
||||
thread_id TEXT,
|
||||
channel_id TEXT,
|
||||
prompt_text TEXT,
|
||||
session_id TEXT,
|
||||
response_text TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
processing_started_at TEXT,
|
||||
completed_at TEXT,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`
|
||||
|
||||
const OFFSETS_TABLE = `CREATE TABLE IF NOT EXISTS conversation_offsets (
|
||||
source_id TEXT PRIMARY KEY,
|
||||
last_message_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`
|
||||
|
||||
const COLUMNS = [
|
||||
["preview_token", "TEXT"],
|
||||
["last_activity", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"],
|
||||
["created_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"],
|
||||
["updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"],
|
||||
["pause_requested_at", "TEXT"],
|
||||
["paused_at", "TEXT"],
|
||||
["resume_attempted_at", "TEXT"],
|
||||
["resumed_at", "TEXT"],
|
||||
["destroyed_at", "TEXT"],
|
||||
["last_health_ok_at", "TEXT"],
|
||||
["last_error", "TEXT"],
|
||||
["resume_fail_count", "INTEGER NOT NULL DEFAULT 0"],
|
||||
] as const
|
||||
|
||||
const INBOX_COLUMNS = [
|
||||
["status", "TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'completed'))"],
|
||||
["thread_id", "TEXT"],
|
||||
["channel_id", "TEXT"],
|
||||
["prompt_text", "TEXT"],
|
||||
["session_id", "TEXT"],
|
||||
["response_text", "TEXT"],
|
||||
["attempts", "INTEGER NOT NULL DEFAULT 0"],
|
||||
["processing_started_at", "TEXT"],
|
||||
["completed_at", "TEXT"],
|
||||
["last_error", "TEXT"],
|
||||
["created_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"],
|
||||
["updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"],
|
||||
] as const
|
||||
|
||||
const OFFSET_COLUMNS = [
|
||||
["last_message_id", "TEXT NOT NULL"],
|
||||
["updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"],
|
||||
] as const
|
||||
|
||||
const INDEXES = [
|
||||
`CREATE INDEX IF NOT EXISTS discord_sessions_status_last_activity_idx
|
||||
ON discord_sessions (status, last_activity)`,
|
||||
`CREATE INDEX IF NOT EXISTS discord_sessions_status_updated_at_idx
|
||||
ON discord_sessions (status, updated_at)`,
|
||||
] as const
|
||||
|
||||
const INBOX_INDEXES = [
|
||||
`CREATE INDEX IF NOT EXISTS conversation_inbox_status_created_at_idx
|
||||
ON conversation_inbox (status, created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS conversation_inbox_completed_at_idx
|
||||
ON conversation_inbox (completed_at)`,
|
||||
] as const
|
||||
|
||||
const OFFSET_INDEXES = [
|
||||
`CREATE INDEX IF NOT EXISTS conversation_offsets_updated_at_idx
|
||||
ON conversation_offsets (updated_at)`,
|
||||
] as const
|
||||
|
||||
export default Effect.gen(function* () {
|
||||
const db = yield* Client.SqlClient
|
||||
yield* db.unsafe(TABLE)
|
||||
yield* db.unsafe(INBOX_TABLE)
|
||||
yield* db.unsafe(OFFSETS_TABLE)
|
||||
|
||||
const names = new Set((yield* db<{ name: string }>`PRAGMA table_info(discord_sessions)`).map((row) => row.name))
|
||||
const missing = COLUMNS.filter(([name]) => !names.has(name))
|
||||
yield* Effect.forEach(missing, ([name, definition]) => db.unsafe(`ALTER TABLE discord_sessions ADD COLUMN ${name} ${definition}`), {
|
||||
discard: true,
|
||||
})
|
||||
|
||||
const inboxNames = new Set((yield* db<{ name: string }>`PRAGMA table_info(conversation_inbox)`).map((row) => row.name))
|
||||
const inboxMissing = INBOX_COLUMNS.filter(([name]) => !inboxNames.has(name))
|
||||
yield* Effect.forEach(inboxMissing, ([name, definition]) => db.unsafe(`ALTER TABLE conversation_inbox ADD COLUMN ${name} ${definition}`), {
|
||||
discard: true,
|
||||
})
|
||||
|
||||
const offsetNames = new Set((yield* db<{ name: string }>`PRAGMA table_info(conversation_offsets)`).map((row) => row.name))
|
||||
const offsetMissing = OFFSET_COLUMNS.filter(([name]) => !offsetNames.has(name))
|
||||
yield* Effect.forEach(offsetMissing, ([name, definition]) => db.unsafe(`ALTER TABLE conversation_offsets ADD COLUMN ${name} ${definition}`), {
|
||||
discard: true,
|
||||
})
|
||||
|
||||
yield* Effect.forEach(INDEXES, (index) => db.unsafe(index), { discard: true })
|
||||
yield* Effect.forEach(INBOX_INDEXES, (index) => db.unsafe(index), { discard: true })
|
||||
yield* Effect.forEach(OFFSET_INDEXES, (index) => db.unsafe(index), { discard: true })
|
||||
})
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import * as Client from "@effect/sql/SqlClient"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const TABLE = `CREATE TABLE IF NOT EXISTS conversation_offsets (
|
||||
source_id TEXT PRIMARY KEY,
|
||||
last_message_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`
|
||||
|
||||
const COLUMNS = [
|
||||
["last_message_id", "TEXT NOT NULL"],
|
||||
["updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"],
|
||||
] as const
|
||||
|
||||
const INDEXES = [
|
||||
`CREATE INDEX IF NOT EXISTS conversation_offsets_updated_at_idx
|
||||
ON conversation_offsets (updated_at)`,
|
||||
] as const
|
||||
|
||||
export default Effect.gen(function* () {
|
||||
const db = yield* Client.SqlClient
|
||||
yield* db.unsafe(TABLE)
|
||||
|
||||
const names = new Set((yield* db<{ name: string }>`PRAGMA table_info(conversation_offsets)`).map((row) => row.name))
|
||||
const missing = COLUMNS.filter(([name]) => !names.has(name))
|
||||
yield* Effect.forEach(missing, ([name, definition]) => db.unsafe(`ALTER TABLE conversation_offsets ADD COLUMN ${name} ${definition}`), {
|
||||
discard: true,
|
||||
})
|
||||
|
||||
yield* Effect.forEach(INDEXES, (index) => db.unsafe(index), { discard: true })
|
||||
})
|
||||
|
|
@ -1,14 +1,33 @@
|
|||
import { Client, GatewayIntentBits, Partials } from "discord.js";
|
||||
import { Client, GatewayIntentBits, Partials } from "discord.js"
|
||||
import { Context, Effect, Layer, Redacted } from "effect"
|
||||
import { AppConfig } from "../config"
|
||||
|
||||
export function createDiscordClient(): Client {
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
],
|
||||
partials: [Partials.Channel],
|
||||
});
|
||||
export class DiscordClient extends Context.Tag("@discord/DiscordClient")<DiscordClient, Client>() {
|
||||
static readonly layer = Layer.scoped(
|
||||
DiscordClient,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* AppConfig
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
],
|
||||
partials: [Partials.Channel],
|
||||
})
|
||||
|
||||
return client;
|
||||
yield* Effect.tryPromise(() => client.login(Redacted.value(config.discordToken)))
|
||||
yield* Effect.logInfo("Discord client logged in").pipe(
|
||||
Effect.annotateLogs({ event: "discord.login", tag: client.user?.tag ?? "unknown" }),
|
||||
)
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
client.destroy()
|
||||
}),
|
||||
)
|
||||
|
||||
return client
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
2
packages/discord/src/discord/constants.ts
Normal file
2
packages/discord/src/discord/constants.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const TYPING_INTERVAL = "8 seconds" as const
|
||||
export const TYPING_INTERVAL_MS = 8_000
|
||||
|
|
@ -1,321 +0,0 @@
|
|||
import type { Client, Message, TextChannel, ThreadChannel, GuildMember } from "discord.js";
|
||||
import { ChannelType } from "discord.js";
|
||||
import { getEnv } from "../../config";
|
||||
import { cleanResponse, splitForDiscord } from "../format";
|
||||
import { shouldRespondToOwnedThreadTurn } from "../turn-routing";
|
||||
import { generateThreadName } from "../thread-name";
|
||||
import type { SandboxManager } from "../../sandbox/manager";
|
||||
import { logger } from "../../observability/logger";
|
||||
|
||||
/**
|
||||
* Checks if a channel (or its parent for threads) is allowed.
|
||||
* Allowed means: in the ALLOWED_CHANNEL_IDS list, OR in the DISCORD_CATEGORY_ID category.
|
||||
*/
|
||||
function isChannelAllowed(channelId: string, categoryId: string | null, env: ReturnType<typeof getEnv>): boolean {
|
||||
if (env.ALLOWED_CHANNEL_IDS.length > 0 && env.ALLOWED_CHANNEL_IDS.includes(channelId)) {
|
||||
return true;
|
||||
}
|
||||
if (env.DISCORD_CATEGORY_ID && categoryId === env.DISCORD_CATEGORY_ID) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user has the required role (if configured).
|
||||
*/
|
||||
function hasRequiredRole(member: GuildMember | null, env: ReturnType<typeof getEnv>): boolean {
|
||||
if (!env.DISCORD_REQUIRED_ROLE_ID) return true; // no role requirement
|
||||
if (!member) return false;
|
||||
return member.roles.cache.has(env.DISCORD_REQUIRED_ROLE_ID);
|
||||
}
|
||||
|
||||
const HISTORY_FETCH_LIMIT = 40;
|
||||
const HISTORY_LINE_CHAR_LIMIT = 500;
|
||||
const HISTORY_TOTAL_CHAR_LIMIT = 6000;
|
||||
|
||||
function normalizeHistoryText(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
async function buildHistoryReplayPrompt(
|
||||
thread: ThreadChannel,
|
||||
currentMessage: Message,
|
||||
latestUserContent: string,
|
||||
): Promise<{ prompt: string; historyCount: number }> {
|
||||
try {
|
||||
const fetched = await thread.messages.fetch({ limit: HISTORY_FETCH_LIMIT });
|
||||
const ordered = [...fetched.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp);
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const prior of ordered) {
|
||||
if (prior.id === currentMessage.id || prior.system) continue;
|
||||
|
||||
let lineContent = normalizeHistoryText(prior.content);
|
||||
if (!lineContent && prior.attachments.size > 0) {
|
||||
const files = [...prior.attachments.values()].map((att) => att.name ?? "file").join(", ");
|
||||
lineContent = `[attachments: ${files}]`;
|
||||
}
|
||||
|
||||
if (!lineContent) continue;
|
||||
if (lineContent.length > HISTORY_LINE_CHAR_LIMIT) {
|
||||
lineContent = `${lineContent.slice(0, HISTORY_LINE_CHAR_LIMIT)}...`;
|
||||
}
|
||||
|
||||
const role = prior.author.bot ? "assistant" : "user";
|
||||
lines.push(`${role}: ${lineContent}`);
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return { prompt: latestUserContent, historyCount: 0 };
|
||||
}
|
||||
|
||||
const selected: string[] = [];
|
||||
let totalChars = 0;
|
||||
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
||||
const candidate = lines[i];
|
||||
if (totalChars + candidate.length > HISTORY_TOTAL_CHAR_LIMIT && selected.length > 0) break;
|
||||
selected.unshift(candidate);
|
||||
totalChars += candidate.length;
|
||||
}
|
||||
|
||||
return {
|
||||
prompt: [
|
||||
"Conversation history from this same Discord thread (oldest to newest):",
|
||||
selected.join("\n"),
|
||||
"",
|
||||
"Continue the same conversation and respond to the latest user message:",
|
||||
latestUserContent,
|
||||
].join("\n"),
|
||||
historyCount: selected.length,
|
||||
};
|
||||
} catch {
|
||||
return { prompt: latestUserContent, historyCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a messageCreate event handler bound to the given SandboxManager.
|
||||
*/
|
||||
export function createMessageHandler(client: Client, sandboxManager: SandboxManager) {
|
||||
const env = getEnv();
|
||||
|
||||
return async (message: Message): Promise<void> => {
|
||||
if (message.author.bot) return;
|
||||
|
||||
// Check if the bot is mentioned (ignore @everyone and @here)
|
||||
if (message.mentions.everyone) return;
|
||||
|
||||
const botUserId = client.user?.id ?? "";
|
||||
const roleId = env.DISCORD_ROLE_ID;
|
||||
|
||||
const mentionedByUser = client.user ? message.mentions.has(client.user, { ignoreEveryone: true, ignoreRoles: false }) : false;
|
||||
const mentionedByRole = roleId ? message.mentions.roles.has(roleId) : false;
|
||||
const mentionedInContent = message.content.includes(`<@${botUserId}>`) || message.content.includes(`<@!${botUserId}>`);
|
||||
const roleMentionInContent = roleId ? message.content.includes(`<@&${roleId}>`) : false;
|
||||
|
||||
const isMentioned = mentionedByUser || mentionedByRole || mentionedInContent || roleMentionInContent;
|
||||
|
||||
// In threads the bot owns, respond to ALL messages (no mention needed)
|
||||
const isInThread = message.channel.type === ChannelType.PublicThread || message.channel.type === ChannelType.PrivateThread;
|
||||
let isOwnedThread = false;
|
||||
if (isInThread && !isMentioned) {
|
||||
isOwnedThread = await sandboxManager.hasTrackedThread(message.channelId);
|
||||
|
||||
if (isOwnedThread) {
|
||||
const decision = await shouldRespondToOwnedThreadTurn({
|
||||
mode: env.TURN_ROUTING_MODE,
|
||||
model: env.TURN_ROUTING_MODEL,
|
||||
apiKey: env.OPENCODE_ZEN_API_KEY,
|
||||
content: message.content,
|
||||
botUserId,
|
||||
botRoleId: roleId,
|
||||
mentionedUserIds: [...message.mentions.users.keys()],
|
||||
mentionedRoleIds: [...message.mentions.roles.keys()],
|
||||
});
|
||||
|
||||
if (!decision.shouldRespond) {
|
||||
logger.info({
|
||||
event: "discord.message.skipped.not_directed",
|
||||
component: "message-handler",
|
||||
message: "Skipped turn not directed at bot",
|
||||
channelId: message.channelId,
|
||||
userId: message.author.id,
|
||||
reason: decision.reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isMentioned && !isOwnedThread) return;
|
||||
|
||||
// Check required role
|
||||
const member = message.member;
|
||||
if (!hasRequiredRole(member, env)) {
|
||||
logger.info({
|
||||
event: "discord.message.ignored.role",
|
||||
component: "message-handler",
|
||||
message: "Ignored message from user without required role",
|
||||
channelId: message.channelId,
|
||||
userId: message.author.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({
|
||||
event: "discord.message.triggered",
|
||||
component: "message-handler",
|
||||
message: "Bot triggered",
|
||||
channelId: message.channelId,
|
||||
userId: message.author.id,
|
||||
isMentioned,
|
||||
isOwnedThread,
|
||||
contentLength: message.content.length,
|
||||
});
|
||||
|
||||
// Strip mentions from content
|
||||
const content = message.content.replace(/<@[!&]?\d+>/g, "").trim();
|
||||
if (!content) {
|
||||
await message.reply("Tag me with a question!").catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
let thread: ThreadChannel;
|
||||
let parentChannelId: string;
|
||||
let parentCategoryId: string | null = null;
|
||||
|
||||
try {
|
||||
if (isInThread) {
|
||||
thread = message.channel as ThreadChannel;
|
||||
parentChannelId = thread.parentId ?? "";
|
||||
// Get the category from the parent channel
|
||||
const parentChannel = thread.parent;
|
||||
parentCategoryId = parentChannel?.parentId ?? null;
|
||||
} else {
|
||||
parentChannelId = message.channelId;
|
||||
parentCategoryId = (message.channel as TextChannel).parentId ?? null;
|
||||
|
||||
if (!isChannelAllowed(parentChannelId, parentCategoryId, env)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const threadName = await generateThreadName(content);
|
||||
thread = await (message.channel as TextChannel).threads.create({
|
||||
name: threadName,
|
||||
startMessage: message,
|
||||
autoArchiveDuration: 60,
|
||||
});
|
||||
}
|
||||
|
||||
// Check allowed for threads too
|
||||
if (!isOwnedThread && !isChannelAllowed(parentChannelId, parentCategoryId, env)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const threadId = thread.id;
|
||||
const channelId = parentChannelId;
|
||||
const guildId = message.guildId ?? "";
|
||||
|
||||
const trackedBeforeResolve = await sandboxManager.getTrackedSession(threadId);
|
||||
|
||||
// Typing indicator
|
||||
let typingActive = true;
|
||||
const sendTyping = () => {
|
||||
if (typingActive) thread.sendTyping().catch(() => {});
|
||||
};
|
||||
sendTyping();
|
||||
const typingInterval = setInterval(sendTyping, 8000);
|
||||
|
||||
try {
|
||||
let session = await sandboxManager.resolveSessionForMessage(threadId, channelId, guildId);
|
||||
let historyPromptCache: { prompt: string; historyCount: number } | null = null;
|
||||
|
||||
const getHistoryPrompt = async (): Promise<{ prompt: string; historyCount: number }> => {
|
||||
if (!historyPromptCache) {
|
||||
historyPromptCache = await buildHistoryReplayPrompt(thread, message, content);
|
||||
}
|
||||
return historyPromptCache;
|
||||
};
|
||||
|
||||
let promptForAgent = content;
|
||||
if (trackedBeforeResolve && trackedBeforeResolve.sessionId !== session.sessionId) {
|
||||
const replay = await getHistoryPrompt();
|
||||
promptForAgent = replay.prompt;
|
||||
logger.info({
|
||||
event: "discord.context.replayed",
|
||||
component: "message-handler",
|
||||
message: "Replayed thread history into replacement session",
|
||||
threadId,
|
||||
previousSessionId: trackedBeforeResolve.sessionId,
|
||||
sessionId: session.sessionId,
|
||||
historyMessages: replay.historyCount,
|
||||
});
|
||||
}
|
||||
|
||||
let response: string;
|
||||
try {
|
||||
response = await sandboxManager.sendMessage(session, promptForAgent);
|
||||
} catch (err: any) {
|
||||
if (err?.recoverable && err?.message === "SANDBOX_DEAD") {
|
||||
logger.warn({
|
||||
event: "discord.message.recovering",
|
||||
component: "message-handler",
|
||||
message: "Recovering by resolving session again",
|
||||
threadId,
|
||||
});
|
||||
await thread.send("*Session changed state, recovering...*").catch(() => {});
|
||||
const sessionBeforeRecovery = session.sessionId;
|
||||
session = await sandboxManager.resolveSessionForMessage(threadId, channelId, guildId);
|
||||
let recoveryPrompt = content;
|
||||
if (sessionBeforeRecovery !== session.sessionId) {
|
||||
const replay = await getHistoryPrompt();
|
||||
recoveryPrompt = replay.prompt;
|
||||
logger.info({
|
||||
event: "discord.context.replayed",
|
||||
component: "message-handler",
|
||||
message: "Replayed thread history after recovery",
|
||||
threadId,
|
||||
previousSessionId: sessionBeforeRecovery,
|
||||
sessionId: session.sessionId,
|
||||
historyMessages: replay.historyCount,
|
||||
});
|
||||
}
|
||||
response = await sandboxManager.sendMessage(session, recoveryPrompt);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const cleaned = cleanResponse(response);
|
||||
const chunks = splitForDiscord(cleaned);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
await thread.send(chunk);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({
|
||||
event: "discord.message.failed",
|
||||
component: "message-handler",
|
||||
message: "Error handling message",
|
||||
threadId,
|
||||
error: err,
|
||||
});
|
||||
const errorMsg = err instanceof Error ? err.message : "Unknown error";
|
||||
await thread.send(`Something went wrong: ${errorMsg}`).catch(() => {});
|
||||
} finally {
|
||||
typingActive = false;
|
||||
clearInterval(typingInterval);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({
|
||||
event: "discord.message.setup_failed",
|
||||
component: "message-handler",
|
||||
message: "Error processing message",
|
||||
channelId: message.channelId,
|
||||
error: err,
|
||||
});
|
||||
await message.reply("Something went wrong setting up the thread.").catch(() => {});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import { getEnv } from "../config";
|
||||
|
||||
const ZEN_MESSAGES_URL = "https://opencode.ai/zen/v1/messages";
|
||||
|
||||
/**
|
||||
* Uses Claude Haiku 4.5 via OpenCode Zen to generate a concise thread name
|
||||
* from the user's message. Falls back to truncation on error.
|
||||
*/
|
||||
export async function generateThreadName(userMessage: string): Promise<string> {
|
||||
try {
|
||||
const env = getEnv();
|
||||
|
||||
const res = await fetch(ZEN_MESSAGES_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": env.OPENCODE_ZEN_API_KEY,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "claude-haiku-4-5",
|
||||
max_tokens: 60,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `Generate a short, descriptive thread title (max 90 chars) for this Discord question. Return ONLY the title, no quotes, no explanation.\n\nQuestion: ${userMessage}`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`[thread-name] Zen API returned ${res.status}, falling back to truncation`);
|
||||
return fallback(userMessage);
|
||||
}
|
||||
|
||||
const data = await res.json() as {
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
|
||||
const title = data.content
|
||||
?.filter((c) => c.type === "text")
|
||||
.map((c) => c.text ?? "")
|
||||
.join("")
|
||||
.trim();
|
||||
|
||||
if (!title || title.length === 0) return fallback(userMessage);
|
||||
|
||||
// Ensure it fits Discord's thread name limit (100 chars)
|
||||
return title.slice(0, 95) + (title.length > 95 ? "..." : "");
|
||||
} catch (err) {
|
||||
console.warn("[thread-name] Failed to generate name:", err);
|
||||
return fallback(userMessage);
|
||||
}
|
||||
}
|
||||
|
||||
function fallback(message: string): string {
|
||||
return message.slice(0, 95) + (message.length > 95 ? "..." : "");
|
||||
}
|
||||
|
|
@ -1,127 +1,134 @@
|
|||
const ZEN_MESSAGES_URL = "https://opencode.ai/zen/v1/messages";
|
||||
import { LanguageModel } from "@effect/ai"
|
||||
import { AnthropicLanguageModel } from "@effect/ai-anthropic"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { AppConfig } from "../config"
|
||||
|
||||
export type TurnRoutingMode = "off" | "heuristic" | "ai";
|
||||
export class TurnRoutingDecision extends Schema.Class<TurnRoutingDecision>("TurnRoutingDecision")({
|
||||
shouldRespond: Schema.Boolean,
|
||||
reason: Schema.String,
|
||||
}) {}
|
||||
|
||||
type TurnRoutingInput = {
|
||||
mode: TurnRoutingMode;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
content: string;
|
||||
botUserId: string;
|
||||
botRoleId: string;
|
||||
mentionedUserIds: string[];
|
||||
mentionedRoleIds: string[];
|
||||
};
|
||||
export class TurnRoutingInput extends Schema.Class<TurnRoutingInput>("TurnRoutingInput")({
|
||||
content: Schema.String,
|
||||
botUserId: Schema.String,
|
||||
botRoleId: Schema.String,
|
||||
mentionedUserIds: Schema.Array(Schema.String),
|
||||
mentionedRoleIds: Schema.Array(Schema.String),
|
||||
}) {}
|
||||
|
||||
export type TurnRoutingDecision = {
|
||||
shouldRespond: boolean;
|
||||
reason: string;
|
||||
};
|
||||
const QUICK_CHAT_RE = /^(ok|okay|k|kk|thanks|thank you|thx|lol|lmao|haha|nice|cool|yup|yep|nah|nope|got it|sgtm)[!. ]*$/i
|
||||
|
||||
const QUICK_CHAT_RE = /^(ok|okay|k|kk|thanks|thank you|thx|lol|lmao|haha|nice|cool|yup|yep|nah|nope|got it|sgtm)[!. ]*$/i;
|
||||
const heuristicDecision = (input: TurnRoutingInput): TurnRoutingDecision | null => {
|
||||
const text = input.content.trim()
|
||||
const lower = text.toLowerCase()
|
||||
|
||||
function heuristicDecision(input: TurnRoutingInput): TurnRoutingDecision | null {
|
||||
const text = input.content.trim();
|
||||
const lower = text.toLowerCase();
|
||||
if (!text) return TurnRoutingDecision.make({ shouldRespond: false, reason: "empty-message" })
|
||||
|
||||
if (!text) {
|
||||
return { shouldRespond: false, reason: "empty-message" };
|
||||
}
|
||||
if (input.mentionedUserIds.some((id) => id !== input.botUserId))
|
||||
return TurnRoutingDecision.make({ shouldRespond: false, reason: "mentions-other-user" })
|
||||
|
||||
const mentionsOtherUser = input.mentionedUserIds.some((id) => id !== input.botUserId);
|
||||
if (mentionsOtherUser) {
|
||||
return { shouldRespond: false, reason: "mentions-other-user" };
|
||||
}
|
||||
if (input.mentionedRoleIds.some((id) => id !== input.botRoleId))
|
||||
return TurnRoutingDecision.make({ shouldRespond: false, reason: "mentions-other-role" })
|
||||
|
||||
const mentionsOtherRole = input.mentionedRoleIds.some((id) => id !== input.botRoleId);
|
||||
if (mentionsOtherRole) {
|
||||
return { shouldRespond: false, reason: "mentions-other-role" };
|
||||
}
|
||||
if (text.length <= 40 && QUICK_CHAT_RE.test(text))
|
||||
return TurnRoutingDecision.make({ shouldRespond: false, reason: "quick-chat" })
|
||||
|
||||
if (text.length <= 40 && QUICK_CHAT_RE.test(text)) {
|
||||
return { shouldRespond: false, reason: "quick-chat" };
|
||||
}
|
||||
if (/\b(opencode|bot)\b/i.test(text))
|
||||
return TurnRoutingDecision.make({ shouldRespond: true, reason: "bot-keyword" })
|
||||
|
||||
if (/\b(opencode|bot)\b/i.test(text)) {
|
||||
return { shouldRespond: true, reason: "bot-keyword" };
|
||||
}
|
||||
if (text.includes("?") && /\b(you|your|can you|could you|would you|please|help)\b/i.test(text))
|
||||
return TurnRoutingDecision.make({ shouldRespond: true, reason: "direct-question" })
|
||||
|
||||
if (text.includes("?") && /\b(you|your|can you|could you|would you|please|help)\b/i.test(text)) {
|
||||
return { shouldRespond: true, reason: "direct-question" };
|
||||
}
|
||||
if (text.includes("?") && /\b(how|what|why|where|when|which)\b/i.test(text))
|
||||
return TurnRoutingDecision.make({ shouldRespond: true, reason: "general-question" })
|
||||
|
||||
if (text.includes("?") && /\b(how|what|why|where|when|which)\b/i.test(text)) {
|
||||
return { shouldRespond: true, reason: "general-question" };
|
||||
}
|
||||
if (lower.startsWith("do this") || lower.startsWith("run ") || lower.startsWith("fix "))
|
||||
return TurnRoutingDecision.make({ shouldRespond: true, reason: "instruction" })
|
||||
|
||||
if (lower.startsWith("do this") || lower.startsWith("run ") || lower.startsWith("fix ")) {
|
||||
return { shouldRespond: true, reason: "instruction" };
|
||||
}
|
||||
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
async function aiDecision(input: TurnRoutingInput): Promise<TurnRoutingDecision> {
|
||||
const prompt = [
|
||||
"You route turns for an engineering Discord bot.",
|
||||
"Decide if the latest message is directed at the bot assistant or is side conversation.",
|
||||
"Return EXACTLY one token: RESPOND or SKIP.",
|
||||
"",
|
||||
`Message: ${input.content}`,
|
||||
`MentionsOtherUser: ${input.mentionedUserIds.some((id) => id !== input.botUserId)}`,
|
||||
`MentionsOtherRole: ${input.mentionedRoleIds.some((id) => id !== input.botRoleId)}`,
|
||||
].join("\n");
|
||||
const fallbackThreadName = (message: string): string =>
|
||||
message.slice(0, 95) + (message.length > 95 ? "..." : "")
|
||||
|
||||
const res = await fetch(ZEN_MESSAGES_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": input.apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: input.model,
|
||||
max_tokens: 10,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
export declare namespace TurnRouter {
|
||||
export interface Service {
|
||||
readonly shouldRespond: (input: TurnRoutingInput) => Effect.Effect<TurnRoutingDecision>
|
||||
readonly generateThreadName: (userMessage: string) => Effect.Effect<string>
|
||||
}
|
||||
}
|
||||
|
||||
export class TurnRouter extends Context.Tag("@discord/TurnRouter")<TurnRouter, TurnRouter.Service>() {
|
||||
static readonly layer = Layer.effect(
|
||||
TurnRouter,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* AppConfig
|
||||
const model = yield* LanguageModel.LanguageModel
|
||||
|
||||
const aiDecision = (input: TurnRoutingInput): Effect.Effect<TurnRoutingDecision> => {
|
||||
const prompt = [
|
||||
"You route turns for an engineering Discord bot.",
|
||||
"Decide if the latest message is directed at the bot assistant or is side conversation.",
|
||||
"Return EXACTLY one token: RESPOND or SKIP.",
|
||||
"",
|
||||
`Message: ${input.content}`,
|
||||
`MentionsOtherUser: ${input.mentionedUserIds.some((id) => id !== input.botUserId)}`,
|
||||
`MentionsOtherRole: ${input.mentionedRoleIds.some((id) => id !== input.botRoleId)}`,
|
||||
].join("\n")
|
||||
|
||||
return AnthropicLanguageModel.withConfigOverride(
|
||||
model.generateText({ prompt }).pipe(
|
||||
Effect.map((response) => {
|
||||
const output = response.text.trim().toUpperCase()
|
||||
if (output.includes("SKIP")) return TurnRoutingDecision.make({ shouldRespond: false, reason: "ai-skip" })
|
||||
return TurnRoutingDecision.make({
|
||||
shouldRespond: true,
|
||||
reason: output.includes("RESPOND") ? "ai-respond" : "ai-default-respond",
|
||||
})
|
||||
}),
|
||||
Effect.catchAll(() =>
|
||||
Effect.succeed(TurnRoutingDecision.make({ shouldRespond: true, reason: "ai-error-default-respond" })),
|
||||
),
|
||||
),
|
||||
{ model: config.turnRoutingModel, max_tokens: 10 },
|
||||
)
|
||||
}
|
||||
|
||||
const shouldRespond = Effect.fn("TurnRouter.shouldRespond")(function* (input: TurnRoutingInput) {
|
||||
if (config.turnRoutingMode === "off") {
|
||||
return TurnRoutingDecision.make({ shouldRespond: true, reason: "routing-off" })
|
||||
}
|
||||
|
||||
const heuristic = heuristicDecision(input)
|
||||
if (heuristic) return heuristic
|
||||
|
||||
if (config.turnRoutingMode === "heuristic") {
|
||||
return TurnRoutingDecision.make({
|
||||
shouldRespond: true,
|
||||
reason: "heuristic-uncertain-default-respond",
|
||||
})
|
||||
}
|
||||
|
||||
return yield* aiDecision(input)
|
||||
})
|
||||
|
||||
const generateThreadName = Effect.fn("TurnRouter.generateThreadName")(function* (userMessage: string) {
|
||||
return yield* AnthropicLanguageModel.withConfigOverride(
|
||||
model.generateText({
|
||||
prompt: `Generate a short, descriptive thread title (max 90 chars) for this Discord question. Return ONLY the title, no quotes, no explanation.\n\nQuestion: ${userMessage}`,
|
||||
}).pipe(
|
||||
Effect.map((response) => {
|
||||
const title = response.text.trim()
|
||||
if (!title || title.length === 0) return fallbackThreadName(userMessage)
|
||||
return title.slice(0, 95) + (title.length > 95 ? "..." : "")
|
||||
}),
|
||||
Effect.catchAll(() => Effect.succeed(fallbackThreadName(userMessage))),
|
||||
),
|
||||
{ model: "claude-haiku-4-5", max_tokens: 60 },
|
||||
)
|
||||
})
|
||||
|
||||
return TurnRouter.of({ shouldRespond, generateThreadName })
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return { shouldRespond: true, reason: `ai-http-${res.status}` };
|
||||
}
|
||||
|
||||
const data = await res.json() as { content?: Array<{ type: string; text?: string }> };
|
||||
const output = data.content
|
||||
?.filter((c) => c.type === "text")
|
||||
.map((c) => c.text ?? "")
|
||||
.join(" ")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
|
||||
if (output?.includes("SKIP")) {
|
||||
return { shouldRespond: false, reason: "ai-skip" };
|
||||
}
|
||||
|
||||
return { shouldRespond: true, reason: output?.includes("RESPOND") ? "ai-respond" : "ai-default-respond" };
|
||||
}
|
||||
|
||||
export async function shouldRespondToOwnedThreadTurn(input: TurnRoutingInput): Promise<TurnRoutingDecision> {
|
||||
if (input.mode === "off") {
|
||||
return { shouldRespond: true, reason: "routing-off" };
|
||||
}
|
||||
|
||||
const heuristic = heuristicDecision(input);
|
||||
if (heuristic) {
|
||||
return heuristic;
|
||||
}
|
||||
|
||||
if (input.mode === "heuristic") {
|
||||
return { shouldRespond: true, reason: "heuristic-uncertain-default-respond" };
|
||||
}
|
||||
|
||||
try {
|
||||
return await aiDecision(input);
|
||||
} catch {
|
||||
return { shouldRespond: true, reason: "ai-error-default-respond" };
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
100
packages/discord/src/errors.ts
Normal file
100
packages/discord/src/errors.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { Schema } from "effect"
|
||||
import { SandboxId, SessionId, ThreadId } from "./types"
|
||||
|
||||
// -- Sandbox errors (Daytona SDK) --
|
||||
|
||||
export class SandboxCreateError extends Schema.TaggedError<SandboxCreateError>()(
|
||||
"SandboxCreateError",
|
||||
{
|
||||
sandboxId: Schema.optional(SandboxId),
|
||||
cause: Schema.Defect,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class SandboxNotFoundError extends Schema.TaggedError<SandboxNotFoundError>()(
|
||||
"SandboxNotFoundError",
|
||||
{
|
||||
sandboxId: SandboxId,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class SandboxExecError extends Schema.TaggedError<SandboxExecError>()(
|
||||
"SandboxExecError",
|
||||
{
|
||||
sandboxId: SandboxId,
|
||||
label: Schema.String,
|
||||
exitCode: Schema.Number,
|
||||
output: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class SandboxStartError extends Schema.TaggedError<SandboxStartError>()(
|
||||
"SandboxStartError",
|
||||
{
|
||||
sandboxId: SandboxId,
|
||||
cause: Schema.Defect,
|
||||
},
|
||||
) {}
|
||||
|
||||
// -- OpenCode client errors --
|
||||
|
||||
export class HealthCheckError extends Schema.TaggedError<HealthCheckError>()(
|
||||
"HealthCheckError",
|
||||
{
|
||||
lastStatus: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class OpenCodeClientError extends Schema.TaggedError<OpenCodeClientError>()(
|
||||
"OpenCodeClientError",
|
||||
{
|
||||
operation: Schema.String,
|
||||
statusCode: Schema.Number,
|
||||
body: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class SessionMissingError extends Schema.TaggedError<SessionMissingError>()(
|
||||
"SessionMissingError",
|
||||
{
|
||||
sessionId: SessionId,
|
||||
},
|
||||
) {}
|
||||
|
||||
// -- Session lifecycle errors --
|
||||
|
||||
export class SandboxDeadError extends Schema.TaggedError<SandboxDeadError>()(
|
||||
"SandboxDeadError",
|
||||
{
|
||||
threadId: ThreadId,
|
||||
reason: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ResumeFailedError extends Schema.TaggedError<ResumeFailedError>()(
|
||||
"ResumeFailedError",
|
||||
{
|
||||
threadId: ThreadId,
|
||||
sandboxId: SandboxId,
|
||||
cause: Schema.Defect,
|
||||
},
|
||||
) {}
|
||||
|
||||
// -- Config errors --
|
||||
|
||||
export class ConfigEncodeError extends Schema.TaggedError<ConfigEncodeError>()(
|
||||
"ConfigEncodeError",
|
||||
{
|
||||
config: Schema.String,
|
||||
cause: Schema.Defect,
|
||||
},
|
||||
) {}
|
||||
|
||||
// -- Database errors --
|
||||
|
||||
export class DatabaseError extends Schema.TaggedError<DatabaseError>()(
|
||||
"DatabaseError",
|
||||
{
|
||||
cause: Schema.Defect,
|
||||
},
|
||||
) {}
|
||||
|
|
@ -1,43 +1,71 @@
|
|||
import type { Client } from "discord.js";
|
||||
import { HttpLayerRouter, HttpServerResponse } from "@effect/platform"
|
||||
import { BunHttpServer } from "@effect/platform-bun"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { AppConfig } from "../config"
|
||||
import { DiscordClient } from "../discord/client"
|
||||
import { ThreadAgentPool } from "../sandbox/pool"
|
||||
|
||||
type HealthDependencies = {
|
||||
client: Client;
|
||||
isCleanupLoopRunning: () => boolean;
|
||||
getActiveSessionCount: () => Promise<number>;
|
||||
};
|
||||
|
||||
export function startHealthServer(host: string, port: number, deps: HealthDependencies): Bun.Server<unknown> {
|
||||
const startedAt = Date.now();
|
||||
|
||||
return Bun.serve({
|
||||
hostname: host,
|
||||
port,
|
||||
fetch: async (request) => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === "/healthz") {
|
||||
const activeSessions = await deps.getActiveSessionCount().catch(() => 0);
|
||||
return Response.json({
|
||||
ok: true,
|
||||
uptimeSec: Math.floor((Date.now() - startedAt) / 1000),
|
||||
discordReady: deps.client.isReady(),
|
||||
cleanupLoopRunning: deps.isCleanupLoopRunning(),
|
||||
activeSessions,
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/readyz") {
|
||||
const ready = deps.client.isReady();
|
||||
return Response.json(
|
||||
{
|
||||
ok: ready,
|
||||
discordReady: ready,
|
||||
},
|
||||
{ status: ready ? 200 : 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
});
|
||||
export declare namespace HealthServer {
|
||||
export interface Service {
|
||||
readonly started: true
|
||||
}
|
||||
}
|
||||
|
||||
export class HealthServer extends Context.Tag("@discord/HealthServer")<HealthServer, HealthServer.Service>() {
|
||||
static readonly layer = Layer.scoped(
|
||||
HealthServer,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* AppConfig
|
||||
const client = yield* DiscordClient
|
||||
const pool = yield* ThreadAgentPool
|
||||
const startedAt = Date.now()
|
||||
|
||||
const routes = HttpLayerRouter.use((router) =>
|
||||
Effect.all([
|
||||
router.add(
|
||||
"GET",
|
||||
"/healthz",
|
||||
Effect.gen(function* () {
|
||||
const activeSessions = yield* pool.getActiveSessionCount().pipe(
|
||||
Effect.catchAll(() => Effect.succeed(0)),
|
||||
)
|
||||
return HttpServerResponse.unsafeJson({
|
||||
ok: true,
|
||||
uptimeSec: Math.floor((Date.now() - startedAt) / 1000),
|
||||
discordReady: client.isReady(),
|
||||
activeSessions,
|
||||
})
|
||||
}),
|
||||
),
|
||||
router.add(
|
||||
"GET",
|
||||
"/readyz",
|
||||
Effect.sync(() => {
|
||||
const ready = client.isReady()
|
||||
return HttpServerResponse.unsafeJson({ ok: ready, discordReady: ready }, { status: ready ? 200 : 503 })
|
||||
}),
|
||||
),
|
||||
]),
|
||||
)
|
||||
|
||||
const server = HttpLayerRouter.serve(routes, { disableLogger: true, disableListenLog: true }).pipe(
|
||||
Layer.provide(
|
||||
BunHttpServer.layer({
|
||||
hostname: config.healthHost,
|
||||
port: config.healthPort,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
yield* Layer.launch(server).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.logInfo("Health server started").pipe(
|
||||
Effect.annotateLogs({ event: "health.server.started", host: config.healthHost, port: config.healthPort }),
|
||||
)
|
||||
|
||||
return {
|
||||
started: true as const,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,83 +1,80 @@
|
|||
import { getEnv } from "./config";
|
||||
import { createDiscordClient } from "./discord/client";
|
||||
import { createMessageHandler } from "./discord/handlers/message-create";
|
||||
import { initializeDatabase } from "./db/init";
|
||||
import { startHealthServer } from "./http/health";
|
||||
import { logger } from "./observability/logger";
|
||||
import { SandboxManager } from "./sandbox/manager";
|
||||
import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic"
|
||||
import { FetchHttpClient } from "@effect/platform"
|
||||
import { BunContext, BunRuntime } from "@effect/platform-bun"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { DiscordConversationServicesLive } from "./conversation/implementations/discord"
|
||||
import { Conversation } from "./conversation/services/conversation"
|
||||
import { ConversationLedger } from "./conversation/services/ledger"
|
||||
import { AppConfig } from "./config"
|
||||
import { SqliteDb } from "./db/client"
|
||||
import { DiscordClient } from "./discord/client"
|
||||
import { TurnRouter } from "./discord/turn-routing"
|
||||
import { HealthServer } from "./http/health"
|
||||
import { LoggerLive } from "./observability/logger"
|
||||
import { DaytonaService } from "./sandbox/daytona"
|
||||
import { OpenCodeClient } from "./sandbox/opencode-client"
|
||||
import { ThreadAgentPool } from "./sandbox/pool"
|
||||
import { SandboxProvisioner } from "./sandbox/provisioner"
|
||||
import { SessionStore } from "./sessions/store"
|
||||
|
||||
async function main() {
|
||||
const env = getEnv();
|
||||
logger.info({ event: "app.starting", component: "index", message: "Starting Discord bot" });
|
||||
await initializeDatabase();
|
||||
logger.info({ event: "db.ready", component: "index", message: "Database ready" });
|
||||
const AnthropicLayer = Layer.unwrapEffect(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* AppConfig
|
||||
return AnthropicLanguageModel.layer({ model: config.turnRoutingModel }).pipe(
|
||||
Layer.provide(AnthropicClient.layer({
|
||||
apiKey: config.openCodeZenApiKey,
|
||||
apiUrl: "https://opencode.ai/zen",
|
||||
})),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const client = createDiscordClient();
|
||||
const sandboxManager = new SandboxManager();
|
||||
const healthServer = startHealthServer(env.HEALTH_HOST, env.HEALTH_PORT, {
|
||||
client,
|
||||
isCleanupLoopRunning: () => sandboxManager.isCleanupLoopRunning(),
|
||||
getActiveSessionCount: () => sandboxManager.getActiveSessionCount(),
|
||||
});
|
||||
type AppServices =
|
||||
| AppConfig
|
||||
| DiscordClient
|
||||
| HealthServer
|
||||
| OpenCodeClient
|
||||
| SessionStore
|
||||
| DaytonaService
|
||||
| TurnRouter
|
||||
| SandboxProvisioner
|
||||
| ThreadAgentPool
|
||||
| ConversationLedger
|
||||
| Conversation
|
||||
|
||||
logger.info({
|
||||
event: "health.server.started",
|
||||
component: "index",
|
||||
message: "Health server started",
|
||||
host: env.HEALTH_HOST,
|
||||
port: env.HEALTH_PORT,
|
||||
});
|
||||
const BaseLayer = Layer.mergeAll(AppConfig.layer, FetchHttpClient.layer, BunContext.layer, LoggerLive)
|
||||
const WithSqlite = Layer.provideMerge(SqliteDb.layer, BaseLayer)
|
||||
const WithAnthropic = Layer.provideMerge(AnthropicLayer, WithSqlite)
|
||||
const WithDaytona = Layer.provideMerge(DaytonaService.layer, WithAnthropic)
|
||||
const WithOpenCode = Layer.provideMerge(OpenCodeClient.layer, WithDaytona)
|
||||
const WithRouting = Layer.provideMerge(TurnRouter.layer, WithOpenCode)
|
||||
const WithSessions = Layer.provideMerge(SessionStore.layer, WithRouting)
|
||||
const WithProvisioner = Layer.provideMerge(SandboxProvisioner.layer, WithSessions)
|
||||
const WithSandbox = Layer.provideMerge(ThreadAgentPool.layer, WithProvisioner)
|
||||
const WithLedger = Layer.provideMerge(ConversationLedger.layer, WithSandbox)
|
||||
const WithDiscord = Layer.provideMerge(DiscordClient.layer, WithLedger)
|
||||
const WithDiscordConversation = Layer.provideMerge(DiscordConversationServicesLive, WithDiscord)
|
||||
const WithConversation = Layer.provideMerge(Conversation.layer, WithDiscordConversation)
|
||||
const AppLayer = Layer.provideMerge(HealthServer.layer, WithConversation) as Layer.Layer<AppServices | SqliteDb, never, never>
|
||||
|
||||
// Register message handler
|
||||
const messageHandler = createMessageHandler(client, sandboxManager);
|
||||
client.on("messageCreate", messageHandler);
|
||||
const main = Effect.gen(function* () {
|
||||
const client = yield* DiscordClient
|
||||
const conversation = yield* Conversation
|
||||
yield* ThreadAgentPool
|
||||
yield* HealthServer
|
||||
|
||||
// Ready event
|
||||
client.on("clientReady", () => {
|
||||
logger.info({
|
||||
event: "discord.ready",
|
||||
component: "index",
|
||||
message: "Discord client ready",
|
||||
tag: client.user?.tag,
|
||||
allowedChannels: env.ALLOWED_CHANNEL_IDS,
|
||||
});
|
||||
sandboxManager.startCleanupLoop();
|
||||
});
|
||||
yield* Effect.forkScoped(conversation.run)
|
||||
yield* Effect.logInfo("Discord bot ready").pipe(
|
||||
Effect.annotateLogs({ event: "discord.ready", tag: client.user?.tag }),
|
||||
)
|
||||
|
||||
// Login
|
||||
await client.login(env.DISCORD_TOKEN);
|
||||
yield* Effect.logInfo("Discord bot started")
|
||||
return yield* Effect.never
|
||||
})
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async (signal: string) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
|
||||
logger.info({
|
||||
event: "app.shutdown.start",
|
||||
component: "index",
|
||||
message: "Shutting down",
|
||||
signal,
|
||||
});
|
||||
healthServer.stop();
|
||||
sandboxManager.stopCleanupLoop();
|
||||
await sandboxManager.destroyAll();
|
||||
client.destroy();
|
||||
logger.info({ event: "app.shutdown.complete", component: "index", message: "Shutdown complete" });
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
logger.error({
|
||||
event: "app.fatal",
|
||||
component: "index",
|
||||
message: "Fatal error",
|
||||
error: err,
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
main.pipe(
|
||||
Effect.provide(AppLayer),
|
||||
Effect.scoped,
|
||||
BunRuntime.runMain,
|
||||
)
|
||||
|
|
|
|||
338
packages/discord/src/lib/actors/keyed.test.ts
Normal file
338
packages/discord/src/lib/actors/keyed.test.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Either, Exit, Option, Ref, Schema } from "effect"
|
||||
import { effectTest } from "../../test/effect"
|
||||
import { ActorMap } from "./keyed"
|
||||
|
||||
class LoadTestError extends Schema.TaggedError<LoadTestError>()("LoadTestError", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
describe("ActorMap", () => {
|
||||
effectTest("serializes work for the same key", () =>
|
||||
Effect.gen(function* () {
|
||||
const log: Array<string> = []
|
||||
const keyed = yield* ActorMap.make<string>()
|
||||
|
||||
const one = keyed.run(
|
||||
"t1",
|
||||
Effect.gen(function* () {
|
||||
log.push("one:start")
|
||||
yield* Effect.sleep("40 millis")
|
||||
log.push("one:end")
|
||||
return "one"
|
||||
}),
|
||||
)
|
||||
const two = keyed.run(
|
||||
"t1",
|
||||
Effect.gen(function* () {
|
||||
log.push("two:start")
|
||||
log.push("two:end")
|
||||
return "two"
|
||||
}),
|
||||
)
|
||||
|
||||
const out = yield* Effect.all([one, two], { concurrency: "unbounded" })
|
||||
expect(out).toEqual(["one", "two"])
|
||||
expect(log).toEqual(["one:start", "one:end", "two:start", "two:end"])
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("allows different keys to run concurrently", () =>
|
||||
Effect.gen(function* () {
|
||||
const log: Array<string> = []
|
||||
const keyed = yield* ActorMap.make<string>()
|
||||
|
||||
const slow = keyed.run(
|
||||
"a",
|
||||
Effect.gen(function* () {
|
||||
log.push("a:start")
|
||||
yield* Effect.sleep("50 millis")
|
||||
log.push("a:end")
|
||||
}),
|
||||
)
|
||||
const fast = keyed.run(
|
||||
"b",
|
||||
Effect.gen(function* () {
|
||||
log.push("b:start")
|
||||
log.push("b:end")
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.all([slow, fast], { concurrency: "unbounded" })
|
||||
expect(log.indexOf("b:end")).toBeLessThan(log.indexOf("a:end"))
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("triggers idle callback once after inactivity", () =>
|
||||
Effect.gen(function* () {
|
||||
const n = yield* Ref.make(0)
|
||||
const keyed = yield* ActorMap.make<string>({
|
||||
idleTimeout: "30 millis",
|
||||
onIdle: () => Ref.update(n, (x) => x + 1),
|
||||
})
|
||||
|
||||
yield* keyed.run("t1", Effect.void)
|
||||
yield* Effect.sleep("80 millis")
|
||||
expect(yield* Ref.get(n)).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("touch extends idle deadline", () =>
|
||||
Effect.gen(function* () {
|
||||
const n = yield* Ref.make(0)
|
||||
const keyed = yield* ActorMap.make<string>({
|
||||
idleTimeout: "40 millis",
|
||||
onIdle: () => Ref.update(n, (x) => x + 1),
|
||||
})
|
||||
|
||||
yield* keyed.run("t1", Effect.void)
|
||||
yield* Effect.sleep("25 millis")
|
||||
yield* keyed.touch("t1")
|
||||
yield* Effect.sleep("25 millis")
|
||||
expect(yield* Ref.get(n)).toBe(0)
|
||||
yield* Effect.sleep("40 millis")
|
||||
expect(yield* Ref.get(n)).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("run can skip idle touch", () =>
|
||||
Effect.gen(function* () {
|
||||
const n = yield* Ref.make(0)
|
||||
const keyed = yield* ActorMap.make<string>({
|
||||
idleTimeout: "25 millis",
|
||||
onIdle: () => Ref.update(n, (x) => x + 1),
|
||||
})
|
||||
|
||||
yield* keyed.run("t1", Effect.void, { touch: false })
|
||||
yield* Effect.sleep("40 millis")
|
||||
expect(yield* Ref.get(n)).toBe(0)
|
||||
yield* keyed.touch("t1")
|
||||
yield* Effect.sleep("40 millis")
|
||||
expect(yield* Ref.get(n)).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("remove clears entry and allows recreation", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string>()
|
||||
|
||||
yield* keyed.run("t1", Effect.void)
|
||||
expect(yield* keyed.size).toBe(1)
|
||||
yield* keyed.remove("t1")
|
||||
expect(yield* keyed.size).toBe(0)
|
||||
yield* keyed.run("t1", Effect.succeed("ok"))
|
||||
expect(yield* keyed.size).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("failure does not poison the key queue", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string>()
|
||||
|
||||
const first = yield* keyed.run("t1", Effect.fail("boom")).pipe(Effect.either)
|
||||
expect(Either.isLeft(first)).toBe(true)
|
||||
if (Either.isLeft(first)) {
|
||||
expect(first.left).toBe("boom")
|
||||
}
|
||||
|
||||
const second = yield* keyed.run("t1", Effect.succeed("ok"))
|
||||
expect(second).toBe("ok")
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("cancelIdle cancels the pending idle timer", () =>
|
||||
Effect.gen(function* () {
|
||||
const n = yield* Ref.make(0)
|
||||
const keyed = yield* ActorMap.make<string>({
|
||||
idleTimeout: "25 millis",
|
||||
onIdle: () => Ref.update(n, (x) => x + 1),
|
||||
})
|
||||
|
||||
yield* keyed.run("t1", Effect.void)
|
||||
yield* keyed.cancelIdle("t1")
|
||||
yield* Effect.sleep("60 millis")
|
||||
expect(yield* Ref.get(n)).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("stop removes all keys and cancels all idle timers", () =>
|
||||
Effect.gen(function* () {
|
||||
const n = yield* Ref.make(0)
|
||||
const keyed = yield* ActorMap.make<string>({
|
||||
idleTimeout: "30 millis",
|
||||
onIdle: () => Ref.update(n, (x) => x + 1),
|
||||
})
|
||||
|
||||
yield* Effect.all(
|
||||
[
|
||||
keyed.run("t1", Effect.void),
|
||||
keyed.run("t2", Effect.void),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
expect(yield* keyed.size).toBe(2)
|
||||
|
||||
yield* keyed.stop
|
||||
expect(yield* keyed.size).toBe(0)
|
||||
yield* Effect.sleep("70 millis")
|
||||
expect(yield* Ref.get(n)).toBe(0)
|
||||
|
||||
yield* keyed.run("t1", Effect.void)
|
||||
expect(yield* keyed.size).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("touch and cancelIdle on unknown key are no-ops", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string>({
|
||||
idleTimeout: "20 millis",
|
||||
onIdle: () => Effect.void,
|
||||
})
|
||||
|
||||
yield* keyed.touch("missing")
|
||||
yield* keyed.cancelIdle("missing")
|
||||
expect(yield* keyed.size).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("remove on unknown key is a no-op", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string>()
|
||||
yield* keyed.remove("missing")
|
||||
expect(yield* keyed.size).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("remove interrupts in-flight run calls", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string>()
|
||||
|
||||
const fiber = yield* keyed
|
||||
.run(
|
||||
"t1",
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.sleep("5 seconds")
|
||||
return "should not reach"
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.fork)
|
||||
|
||||
// Give the job time to start executing on the worker fiber
|
||||
yield* Effect.sleep("20 millis")
|
||||
yield* keyed.remove("t1")
|
||||
expect(yield* keyed.size).toBe(0)
|
||||
|
||||
const exit = yield* fiber.await
|
||||
expect(Exit.isInterrupted(exit)).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("ActorMap (stateful)", () => {
|
||||
effectTest("load hydrates state on first activation", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string, number>({
|
||||
load: (key) => Effect.succeed(key === "a" ? Option.some(42) : Option.none()),
|
||||
})
|
||||
|
||||
const result = yield* keyed.run("a", (state) =>
|
||||
Ref.get(state).pipe(Effect.map((s) => Option.isSome(s) ? s.value : -1)),
|
||||
)
|
||||
expect(result).toBe(42)
|
||||
|
||||
const result2 = yield* keyed.run("b", (state) =>
|
||||
Ref.get(state).pipe(Effect.map((s) => Option.isSome(s) ? s.value : -1)),
|
||||
)
|
||||
expect(result2).toBe(-1)
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("save is called when state changes during run", () =>
|
||||
Effect.gen(function* () {
|
||||
const saved: Array<[string, number]> = []
|
||||
const keyed = yield* ActorMap.make<string, number>({
|
||||
save: (key, value) =>
|
||||
Effect.sync(() => {
|
||||
saved.push([key, value])
|
||||
}),
|
||||
})
|
||||
|
||||
yield* keyed.run("a", (state) => Ref.set(state, Option.some(10)))
|
||||
expect(saved).toEqual([["a", 10]])
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("save is not called when state is unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const saved: Array<[string, number]> = []
|
||||
const keyed = yield* ActorMap.make<string, number>({
|
||||
load: () => Effect.succeed(Option.some(5)),
|
||||
save: (key, value) =>
|
||||
Effect.sync(() => {
|
||||
saved.push([key, value])
|
||||
}),
|
||||
})
|
||||
|
||||
// Run without touching state
|
||||
yield* keyed.run("a", (_state) => Effect.succeed("noop"))
|
||||
expect(saved).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("getState returns current state for existing key", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string, number>({
|
||||
load: () => Effect.succeed(Option.some(99)),
|
||||
})
|
||||
|
||||
yield* keyed.run("a", Effect.void)
|
||||
const result = yield* keyed.getState("a")
|
||||
expect(result).toEqual(Option.some(99))
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("getState returns None for unknown key", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string, number>()
|
||||
const result = yield* keyed.getState("missing")
|
||||
expect(result).toEqual(Option.none())
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("stateful run with function receives state ref", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string, string>()
|
||||
|
||||
yield* keyed.run("a", (state) => Ref.set(state, Option.some("hello")))
|
||||
const result = yield* keyed.getState("a")
|
||||
expect(result).toEqual(Option.some("hello"))
|
||||
|
||||
const read = yield* keyed.run("a", (state) =>
|
||||
Ref.get(state).pipe(Effect.map((s) => Option.isSome(s) ? s.value : "")),
|
||||
)
|
||||
expect(read).toBe("hello")
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("stateless run still works with stateful actor map", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string, number>()
|
||||
|
||||
const result = yield* keyed.run("a", Effect.succeed(42))
|
||||
expect(result).toBe(42)
|
||||
}),
|
||||
)
|
||||
|
||||
effectTest("load error falls back to None", () =>
|
||||
Effect.gen(function* () {
|
||||
const keyed = yield* ActorMap.make<string, number>({
|
||||
load: () => Effect.fail(LoadTestError.make({ message: "db down" })),
|
||||
})
|
||||
|
||||
const result = yield* keyed.run("a", (state) =>
|
||||
Ref.get(state).pipe(Effect.map(Option.isNone)),
|
||||
)
|
||||
expect(result).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
217
packages/discord/src/lib/actors/keyed.ts
Normal file
217
packages/discord/src/lib/actors/keyed.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { Deferred, Effect, FiberMap, Option, Queue, Ref, SynchronizedRef, type Duration } from "effect"
|
||||
import type * as Scope from "effect/Scope"
|
||||
|
||||
type Job = {
|
||||
run: Effect.Effect<void, never>
|
||||
cancel: Effect.Effect<void, never>
|
||||
}
|
||||
|
||||
/**
|
||||
* A keyed actor map — a concurrent map of serial work queues.
|
||||
*
|
||||
* Each key gets its own fiber-backed queue. Effects submitted to the same key
|
||||
* are executed sequentially (preserving order), while different keys run
|
||||
* concurrently. Optionally supports idle timeouts per key and per-key state
|
||||
* with persistence hooks.
|
||||
*/
|
||||
export declare namespace ActorMap {
|
||||
/**
|
||||
* Configuration for idle-timeout behavior and optional per-key state.
|
||||
*
|
||||
* When both `idleTimeout` and `onIdle` are provided, each key starts a timer
|
||||
* after activity. If no further activity (or explicit `touch`) occurs before
|
||||
* the timer expires, `onIdle` is called with that key.
|
||||
*
|
||||
* When `load` and/or `save` are provided, the actor map manages per-key
|
||||
* state of type `S`. `load` is called when an actor is first created to
|
||||
* hydrate state from storage. `save` is called after `run` completes when
|
||||
* the state has been modified.
|
||||
*/
|
||||
export interface Options<K, S = void> {
|
||||
/** How long a key must be idle before `onIdle` fires. */
|
||||
idleTimeout?: Duration.DurationInput
|
||||
/** Callback invoked when a key's idle timer expires. */
|
||||
onIdle?: (key: K) => Effect.Effect<void, unknown, never>
|
||||
/** Load persisted state when an actor is first activated. */
|
||||
load?: (key: K) => Effect.Effect<Option.Option<S>, unknown, never>
|
||||
/** Save state after it has been modified during `run`. */
|
||||
save?: (key: K, state: S) => Effect.Effect<void, unknown, never>
|
||||
}
|
||||
|
||||
export interface ActorMap<K, S = void> {
|
||||
/** Enqueue an effect onto a key's serial queue. Creates the actor if it
|
||||
* doesn't exist yet. By default resets the key's idle timer (`touch: true`). */
|
||||
run: {
|
||||
<A, E>(
|
||||
key: K,
|
||||
effect: Effect.Effect<A, E>,
|
||||
options?: { touch?: boolean },
|
||||
): Effect.Effect<A, E>
|
||||
<A, E>(
|
||||
key: K,
|
||||
f: (state: Ref.Ref<Option.Option<S>>) => Effect.Effect<A, E>,
|
||||
options?: { touch?: boolean },
|
||||
): Effect.Effect<A, E>
|
||||
}
|
||||
/** Reset the idle timer for a key without enqueuing work. No-op if the key
|
||||
* doesn't exist or no idle timeout is configured. */
|
||||
touch: (key: K) => Effect.Effect<void>
|
||||
/** Cancel the pending idle timer for a key without removing the actor. */
|
||||
cancelIdle: (key: K) => Effect.Effect<void>
|
||||
/** Tear down an actor: cancel its idle timer, interrupt its worker fiber,
|
||||
* and shut down its queue. In-flight `run` calls are interrupted.
|
||||
* The key can be re-created by a subsequent `run`. */
|
||||
remove: (key: K) => Effect.Effect<void>
|
||||
/** Remove all actors and cancel all idle timers. */
|
||||
stop: Effect.Effect<void>
|
||||
/** The number of currently active actor keys. */
|
||||
size: Effect.Effect<number>
|
||||
/** Read the current state for a key without running an effect.
|
||||
* Returns None if the actor doesn't exist or has no state. */
|
||||
getState: (key: K) => Effect.Effect<Option.Option<S>>
|
||||
}
|
||||
}
|
||||
|
||||
interface Entry<S> {
|
||||
queue: Queue.Queue<Job>
|
||||
state: Ref.Ref<Option.Option<S>>
|
||||
}
|
||||
|
||||
export const ActorMap = {
|
||||
make: <K, S = void>(options?: ActorMap.Options<K, S>): Effect.Effect<ActorMap.ActorMap<K, S>, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const workers = yield* FiberMap.make<K>()
|
||||
const timers = yield* FiberMap.make<K>()
|
||||
const state = yield* SynchronizedRef.make(new Map<K, Entry<S>>())
|
||||
|
||||
const has = (key: K) =>
|
||||
Effect.map(SynchronizedRef.get(state), (map) => map.has(key))
|
||||
|
||||
const ensure = (key: K): Effect.Effect<Entry<S>> =>
|
||||
SynchronizedRef.modifyEffect(state, (map) => {
|
||||
const current = map.get(key)
|
||||
if (current) {
|
||||
return Effect.succeed([current, map] as const)
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const q = yield* Queue.unbounded<Job>()
|
||||
// Load initial state from persistence hook if provided
|
||||
const initial: Option.Option<S> = options?.load
|
||||
? yield* options.load(key).pipe(Effect.catchAll(() => Effect.succeed(Option.none<S>())))
|
||||
: Option.none<S>()
|
||||
const stateRef = yield* Ref.make(initial)
|
||||
yield* FiberMap.run(
|
||||
workers,
|
||||
key,
|
||||
Effect.forever(
|
||||
q.take.pipe(Effect.flatMap((job) => job.run)),
|
||||
),
|
||||
).pipe(Effect.asVoid)
|
||||
const entry: Entry<S> = { queue: q, state: stateRef }
|
||||
const next = new Map(map)
|
||||
next.set(key, entry)
|
||||
return [entry, next] as const
|
||||
})
|
||||
})
|
||||
|
||||
const cancelIdle = (key: K) =>
|
||||
FiberMap.remove(timers, key)
|
||||
|
||||
const remove = (key: K) =>
|
||||
Effect.gen(function* () {
|
||||
const entry = yield* SynchronizedRef.modify(state, (map) => {
|
||||
const current = map.get(key)
|
||||
if (!current) return [null as Entry<S> | null, map] as const
|
||||
const next = new Map(map)
|
||||
next.delete(key)
|
||||
return [current, next] as const
|
||||
})
|
||||
if (!entry) {
|
||||
yield* cancelIdle(key)
|
||||
return
|
||||
}
|
||||
yield* cancelIdle(key)
|
||||
yield* FiberMap.remove(workers, key)
|
||||
yield* entry.queue.takeAll.pipe(
|
||||
Effect.flatMap(
|
||||
Effect.forEach((job) => job.cancel, { discard: true }),
|
||||
),
|
||||
)
|
||||
yield* entry.queue.shutdown
|
||||
})
|
||||
|
||||
const touch = (key: K) =>
|
||||
Effect.gen(function* () {
|
||||
if (!options?.idleTimeout || !options.onIdle) return
|
||||
if (!(yield* has(key))) return
|
||||
yield* FiberMap.run(
|
||||
timers,
|
||||
key,
|
||||
options.onIdle(key).pipe(
|
||||
Effect.catchAll(() => Effect.void),
|
||||
Effect.delay(options.idleTimeout),
|
||||
),
|
||||
).pipe(Effect.asVoid)
|
||||
})
|
||||
|
||||
const run = <A, E>(
|
||||
key: K,
|
||||
effectOrFn: Effect.Effect<A, E> | ((state: Ref.Ref<Option.Option<S>>) => Effect.Effect<A, E>),
|
||||
runOptions?: { touch?: boolean },
|
||||
): Effect.Effect<A, E> =>
|
||||
Effect.gen(function* () {
|
||||
const entry = yield* ensure(key)
|
||||
const done = yield* Deferred.make<A, E>()
|
||||
|
||||
// Snapshot state before running so we can detect changes
|
||||
const stateBefore = yield* Ref.get(entry.state)
|
||||
|
||||
const effect: Effect.Effect<A, E> = Effect.isEffect(effectOrFn)
|
||||
? effectOrFn
|
||||
: (effectOrFn as (state: Ref.Ref<Option.Option<S>>) => Effect.Effect<A, E>)(entry.state)
|
||||
|
||||
yield* entry.queue.offer({
|
||||
run: Effect.uninterruptibleMask((restore) =>
|
||||
restore(effect).pipe(
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => Deferred.done(done, exit)),
|
||||
Effect.asVoid,
|
||||
)),
|
||||
cancel: Deferred.interrupt(done).pipe(Effect.asVoid),
|
||||
}).pipe(Effect.asVoid)
|
||||
if (runOptions?.touch ?? true) {
|
||||
yield* touch(key)
|
||||
}
|
||||
const result = yield* Deferred.await(done)
|
||||
|
||||
// Persist state if it changed and a save hook is configured
|
||||
if (options?.save) {
|
||||
const stateAfter = yield* Ref.get(entry.state)
|
||||
if (stateBefore !== stateAfter && Option.isSome(stateAfter)) {
|
||||
yield* options.save(key, stateAfter.value).pipe(
|
||||
Effect.catchAll(() => Effect.void),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const stop = Effect.gen(function* () {
|
||||
const keys = [...(yield* SynchronizedRef.get(state)).keys()]
|
||||
yield* Effect.forEach(keys, (key) => remove(key), { discard: true, concurrency: "unbounded" })
|
||||
})
|
||||
|
||||
const size = Effect.map(SynchronizedRef.get(state), (map) => map.size)
|
||||
|
||||
const getState = (key: K): Effect.Effect<Option.Option<S>> =>
|
||||
Effect.gen(function* () {
|
||||
const map = yield* SynchronizedRef.get(state)
|
||||
const entry = map.get(key)
|
||||
if (!entry) return Option.none<S>()
|
||||
return yield* Ref.get(entry.state)
|
||||
})
|
||||
|
||||
return { run, touch, cancelIdle, remove, stop, size, getState } satisfies ActorMap.ActorMap<K, S>
|
||||
}),
|
||||
}
|
||||
9
packages/discord/src/lib/log.ts
Normal file
9
packages/discord/src/lib/log.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Effect } from "effect"
|
||||
|
||||
/** Swallow errors with a warning log. Use for best-effort bookkeeping writes. */
|
||||
export const logIgnore = <A>(effect: Effect.Effect<A, unknown>, context: string) =>
|
||||
effect.pipe(
|
||||
Effect.catchAll((err) =>
|
||||
Effect.logWarning(`${context} failed (ignored)`).pipe(Effect.annotateLogs({ error: String(err) })),
|
||||
),
|
||||
)
|
||||
4
packages/discord/src/md.d.ts
vendored
Normal file
4
packages/discord/src/md.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
declare module "*.md" {
|
||||
const text: string
|
||||
export default text
|
||||
}
|
||||
|
|
@ -1,76 +1,6 @@
|
|||
import { getEnv } from "../config";
|
||||
import { Layer, Logger, LogLevel } from "effect"
|
||||
|
||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
type LogFields = {
|
||||
event: string;
|
||||
message: string;
|
||||
component?: string;
|
||||
threadId?: string;
|
||||
channelId?: string;
|
||||
guildId?: string;
|
||||
sandboxId?: string;
|
||||
sessionId?: string;
|
||||
durationMs?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
const levelOrder: Record<LogLevel, number> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
};
|
||||
|
||||
function shouldLog(level: LogLevel): boolean {
|
||||
const env = getEnv();
|
||||
return levelOrder[level] >= levelOrder[env.LOG_LEVEL];
|
||||
}
|
||||
|
||||
function serializeError(err: unknown) {
|
||||
if (!(err instanceof Error)) return err;
|
||||
return {
|
||||
name: err.name,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
};
|
||||
}
|
||||
|
||||
function write(level: LogLevel, fields: LogFields): void {
|
||||
if (!shouldLog(level)) return;
|
||||
|
||||
const env = getEnv();
|
||||
const payload = {
|
||||
ts: new Date().toISOString(),
|
||||
level,
|
||||
...fields,
|
||||
};
|
||||
|
||||
if (env.LOG_PRETTY) {
|
||||
const line = `[${payload.ts}] ${level.toUpperCase()} ${fields.event} ${fields.message}`;
|
||||
console.log(line, JSON.stringify(payload));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug(fields: LogFields) {
|
||||
write("debug", fields);
|
||||
},
|
||||
info(fields: LogFields) {
|
||||
write("info", fields);
|
||||
},
|
||||
warn(fields: LogFields) {
|
||||
write("warn", fields);
|
||||
},
|
||||
error(fields: LogFields & { error?: unknown }) {
|
||||
write("error", {
|
||||
...fields,
|
||||
error: fields.error ? serializeError(fields.error) : undefined,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export type { LogFields, LogLevel };
|
||||
export const LoggerLive = Layer.merge(
|
||||
Logger.replace(Logger.defaultLogger, Logger.jsonLogger),
|
||||
Logger.minimumLogLevel(LogLevel.Debug),
|
||||
)
|
||||
|
|
|
|||
170
packages/discord/src/sandbox/daytona.ts
Normal file
170
packages/discord/src/sandbox/daytona.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { Daytona as DaytonaSDK, Image } from "@daytonaio/sdk"
|
||||
import { Context, Effect, Layer, Redacted, Schema } from "effect"
|
||||
import { AppConfig } from "../config"
|
||||
import { SandboxCreateError, SandboxExecError, SandboxNotFoundError, SandboxStartError } from "../errors"
|
||||
import { GuildId, SandboxId, ThreadId, PreviewAccess } from "../types"
|
||||
|
||||
export class SandboxHandle extends Schema.Class<SandboxHandle>("SandboxHandle")({
|
||||
id: SandboxId,
|
||||
previewUrl: Schema.String,
|
||||
previewToken: Schema.Union(Schema.Null, Schema.String),
|
||||
}) {}
|
||||
|
||||
export class ExecResult extends Schema.Class<ExecResult>("ExecResult")({
|
||||
exitCode: Schema.Number,
|
||||
output: Schema.String,
|
||||
}) {}
|
||||
|
||||
export declare namespace DaytonaService {
|
||||
export interface Service {
|
||||
readonly create: (opts: {
|
||||
threadId: ThreadId
|
||||
guildId: GuildId
|
||||
timeout: number
|
||||
}) => Effect.Effect<SandboxHandle, SandboxCreateError>
|
||||
readonly exec: (
|
||||
sandboxId: SandboxId,
|
||||
label: string,
|
||||
command: string,
|
||||
opts?: { cwd?: string; env?: Record<string, string> },
|
||||
) => Effect.Effect<ExecResult, SandboxExecError | SandboxNotFoundError>
|
||||
readonly start: (
|
||||
sandboxId: SandboxId,
|
||||
timeout: number,
|
||||
) => Effect.Effect<SandboxHandle, SandboxStartError | SandboxNotFoundError>
|
||||
readonly stop: (sandboxId: SandboxId) => Effect.Effect<void, SandboxNotFoundError>
|
||||
readonly destroy: (sandboxId: SandboxId) => Effect.Effect<void>
|
||||
readonly getPreview: (sandboxId: SandboxId) => Effect.Effect<PreviewAccess, SandboxNotFoundError>
|
||||
}
|
||||
}
|
||||
|
||||
const discordBotImage = Image.base("node:22-bookworm-slim")
|
||||
.runCommands(
|
||||
"apt-get update && apt-get install -y git curl && rm -rf /var/lib/apt/lists/*",
|
||||
"curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /usr/share/keyrings/githubcli-archive-keyring.gpg && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" > /etc/apt/sources.list.d/github-cli.list && apt-get update && apt-get install -y gh && rm -rf /var/lib/apt/lists/*",
|
||||
"npm install -g opencode-ai@latest bun",
|
||||
)
|
||||
.workdir("/home/daytona")
|
||||
|
||||
export class DaytonaService extends Context.Tag("@discord/DaytonaService")<DaytonaService, DaytonaService.Service>() {
|
||||
static readonly layer = Layer.effect(
|
||||
DaytonaService,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* AppConfig
|
||||
const sdk = new DaytonaSDK({
|
||||
apiKey: Redacted.value(config.daytonaApiKey),
|
||||
_experimental: {},
|
||||
})
|
||||
|
||||
const getSandbox = (sandboxId: SandboxId) =>
|
||||
Effect.tryPromise({
|
||||
try: () => sdk.get(sandboxId),
|
||||
catch: () => new SandboxNotFoundError({ sandboxId }),
|
||||
})
|
||||
|
||||
const toHandle = <E>(
|
||||
sandboxId: SandboxId,
|
||||
sandbox: { getPreviewLink: (timeout: number) => Promise<{ url: string; token?: string | null }> },
|
||||
error: (cause: unknown) => E,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => sandbox.getPreviewLink(4096),
|
||||
catch: error,
|
||||
}).pipe(
|
||||
Effect.map((preview) =>
|
||||
SandboxHandle.make({
|
||||
id: sandboxId,
|
||||
previewUrl: preview.url.replace(/\/$/, ""),
|
||||
previewToken: preview.token ?? null,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const create = Effect.fn("DaytonaService.create")(
|
||||
function* (opts: { threadId: ThreadId; guildId: GuildId; timeout: number }) {
|
||||
const sandbox = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
sdk.create(
|
||||
{
|
||||
image: discordBotImage,
|
||||
labels: { app: "opencord", threadId: opts.threadId, guildId: opts.guildId },
|
||||
autoStopInterval: 0,
|
||||
autoArchiveInterval: 0,
|
||||
},
|
||||
{ timeout: opts.timeout },
|
||||
),
|
||||
catch: (cause) => new SandboxCreateError({ cause }),
|
||||
})
|
||||
const sandboxId = SandboxId.make(sandbox.id)
|
||||
return yield* toHandle(
|
||||
sandboxId,
|
||||
sandbox,
|
||||
(cause) => new SandboxCreateError({ sandboxId, cause }),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const exec = Effect.fn("DaytonaService.exec")(
|
||||
function* (
|
||||
sandboxId: SandboxId,
|
||||
label: string,
|
||||
command: string,
|
||||
opts?: { cwd?: string; env?: Record<string, string> },
|
||||
) {
|
||||
const sandbox = yield* getSandbox(sandboxId)
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: () => sandbox.process.executeCommand(command, opts?.cwd, opts?.env),
|
||||
catch: () => new SandboxExecError({ sandboxId, label, exitCode: -1, output: "exec failed" }),
|
||||
})
|
||||
if (result.exitCode !== 0) {
|
||||
return yield* new SandboxExecError({
|
||||
sandboxId,
|
||||
label,
|
||||
exitCode: result.exitCode,
|
||||
output: result.result.slice(0, 500),
|
||||
})
|
||||
}
|
||||
return ExecResult.make({
|
||||
exitCode: result.exitCode,
|
||||
output: result.result.trim(),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const start = Effect.fn("DaytonaService.start")(function* (sandboxId: SandboxId, timeout: number) {
|
||||
const sandbox = yield* getSandbox(sandboxId)
|
||||
yield* Effect.tryPromise({
|
||||
try: () => sdk.start(sandbox, timeout),
|
||||
catch: (cause) => new SandboxStartError({ sandboxId, cause }),
|
||||
})
|
||||
return yield* toHandle(sandboxId, sandbox, (cause) => new SandboxStartError({ sandboxId, cause }))
|
||||
})
|
||||
|
||||
const stop = Effect.fn("DaytonaService.stop")(function* (sandboxId: SandboxId) {
|
||||
const sandbox = yield* getSandbox(sandboxId)
|
||||
yield* Effect.tryPromise({
|
||||
try: () => sdk.stop(sandbox),
|
||||
catch: () => new SandboxNotFoundError({ sandboxId }),
|
||||
})
|
||||
})
|
||||
|
||||
const destroy = Effect.fn("DaytonaService.destroy")(function* (sandboxId: SandboxId) {
|
||||
yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
const sandbox = await sdk.get(sandboxId)
|
||||
await sdk.delete(sandbox)
|
||||
},
|
||||
catch: () => undefined,
|
||||
}).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
const getPreview = Effect.fn("DaytonaService.getPreview")(function* (sandboxId: SandboxId) {
|
||||
const sandbox = yield* getSandbox(sandboxId)
|
||||
const handle = yield* toHandle(sandboxId, sandbox, () => new SandboxNotFoundError({ sandboxId }))
|
||||
return PreviewAccess.from(handle)
|
||||
})
|
||||
|
||||
return DaytonaService.of({ create, exec, start, stop, destroy, getPreview })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { Image } from "@daytonaio/sdk";
|
||||
|
||||
/**
|
||||
* Custom Daytona sandbox image with git, gh CLI, opencode, and bun.
|
||||
* Cached by Daytona for 24h — subsequent creates are near-instant.
|
||||
*/
|
||||
export function getDiscordBotImage() {
|
||||
return Image.base("node:22-bookworm-slim")
|
||||
.runCommands(
|
||||
"apt-get update && apt-get install -y git curl && rm -rf /var/lib/apt/lists/*",
|
||||
"curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /usr/share/keyrings/githubcli-archive-keyring.gpg && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" > /etc/apt/sources.list.d/github-cli.list && apt-get update && apt-get install -y gh && rm -rf /var/lib/apt/lists/*",
|
||||
"npm install -g opencode-ai@latest bun",
|
||||
)
|
||||
.workdir("/home/daytona");
|
||||
}
|
||||
|
|
@ -1,735 +0,0 @@
|
|||
import { Daytona } from "@daytonaio/sdk";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { getEnv } from "../config";
|
||||
import { logger } from "../observability/logger";
|
||||
import { getSessionStore } from "../sessions/store";
|
||||
import type { SessionInfo, SessionStatus } from "../types";
|
||||
import { getDiscordBotImage } from "./image";
|
||||
import { createSession, listSessions, sendPrompt, sessionExists, waitForHealthy } from "./opencode-client";
|
||||
|
||||
/** In-memory timeout handles keyed by threadId */
|
||||
const timeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
type ResumeAttemptResult = {
|
||||
session: SessionInfo | null;
|
||||
allowRecreate: boolean;
|
||||
};
|
||||
|
||||
function timer() {
|
||||
const start = Date.now();
|
||||
return {
|
||||
elapsedMs: () => Date.now() - start,
|
||||
};
|
||||
}
|
||||
|
||||
function createDaytona() {
|
||||
return new Daytona({
|
||||
apiKey: getEnv().DAYTONA_API_KEY,
|
||||
_experimental: {},
|
||||
});
|
||||
}
|
||||
|
||||
function isSandboxMissingError(error: unknown): boolean {
|
||||
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
||||
return message.includes("not found") || message.includes("does not exist") || message.includes("destroyed");
|
||||
}
|
||||
|
||||
async function exec(
|
||||
sandbox: {
|
||||
process: {
|
||||
executeCommand: (
|
||||
cmd: string,
|
||||
cwd?: string,
|
||||
env?: Record<string, string>,
|
||||
timeout?: number,
|
||||
) => Promise<{ exitCode: number; result: string }>;
|
||||
};
|
||||
},
|
||||
label: string,
|
||||
command: string,
|
||||
context: Pick<SessionInfo, "threadId" | "sandboxId">,
|
||||
options?: { cwd?: string; env?: Record<string, string> },
|
||||
): Promise<string> {
|
||||
const t = timer();
|
||||
const result = await sandbox.process.executeCommand(command, options?.cwd, options?.env);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
logger.error({
|
||||
event: "sandbox.exec.failed",
|
||||
component: "sandbox-manager",
|
||||
message: "Sandbox command failed",
|
||||
threadId: context.threadId,
|
||||
sandboxId: context.sandboxId,
|
||||
label,
|
||||
exitCode: result.exitCode,
|
||||
durationMs: t.elapsedMs(),
|
||||
stdout: result.result.slice(0, 500),
|
||||
});
|
||||
throw new Error(`${label} failed (exit ${result.exitCode})`);
|
||||
}
|
||||
|
||||
logger.debug({
|
||||
event: "sandbox.exec.ok",
|
||||
component: "sandbox-manager",
|
||||
message: "Sandbox command completed",
|
||||
threadId: context.threadId,
|
||||
sandboxId: context.sandboxId,
|
||||
label,
|
||||
durationMs: t.elapsedMs(),
|
||||
});
|
||||
|
||||
return result.result.trim();
|
||||
}
|
||||
|
||||
export class SandboxManager {
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private readonly store = getSessionStore();
|
||||
private readonly threadLocks = new Map<string, Promise<void>>();
|
||||
|
||||
async getActiveSessionCount(): Promise<number> {
|
||||
return (await this.store.listActive()).length;
|
||||
}
|
||||
|
||||
isCleanupLoopRunning(): boolean {
|
||||
return this.cleanupInterval !== null;
|
||||
}
|
||||
|
||||
async hasTrackedThread(threadId: string): Promise<boolean> {
|
||||
return this.store.hasTrackedThread(threadId);
|
||||
}
|
||||
|
||||
async getTrackedSession(threadId: string): Promise<SessionInfo | null> {
|
||||
return this.store.getByThread(threadId);
|
||||
}
|
||||
|
||||
async getSession(threadId: string): Promise<SessionInfo | null> {
|
||||
return this.store.getActive(threadId);
|
||||
}
|
||||
|
||||
async resolveSessionForMessage(threadId: string, channelId: string, guildId: string): Promise<SessionInfo> {
|
||||
return this.withThreadLock(threadId, async () => {
|
||||
const existing = await this.store.getByThread(threadId);
|
||||
const env = getEnv();
|
||||
|
||||
if (!existing) {
|
||||
return this.createSessionUnlocked(threadId, channelId, guildId);
|
||||
}
|
||||
|
||||
let candidate = existing;
|
||||
|
||||
if (candidate.status === "active") {
|
||||
const healthy = await this.ensureSessionHealthy(candidate, 15_000);
|
||||
if (healthy) return candidate;
|
||||
candidate = (await this.store.getByThread(threadId)) ?? { ...candidate, status: "error" };
|
||||
}
|
||||
|
||||
if (env.SANDBOX_REUSE_POLICY === "resume_preferred") {
|
||||
const resumed = await this.tryResumeSession(candidate);
|
||||
if (resumed.session) return resumed.session;
|
||||
|
||||
if (!resumed.allowRecreate) {
|
||||
throw new Error("Unable to reattach to existing sandbox session. Try again shortly.");
|
||||
}
|
||||
}
|
||||
|
||||
return this.createSessionUnlocked(threadId, channelId, guildId);
|
||||
});
|
||||
}
|
||||
|
||||
async createSession(threadId: string, channelId: string, guildId: string): Promise<SessionInfo> {
|
||||
return this.withThreadLock(threadId, async () => this.createSessionUnlocked(threadId, channelId, guildId));
|
||||
}
|
||||
|
||||
private async createSessionUnlocked(threadId: string, channelId: string, guildId: string): Promise<SessionInfo> {
|
||||
const env = getEnv();
|
||||
const totalTimer = timer();
|
||||
|
||||
await this.store.updateStatus(threadId, "creating").catch(() => {});
|
||||
|
||||
const daytona = createDaytona();
|
||||
const image = getDiscordBotImage();
|
||||
const sandbox = await daytona.create(
|
||||
{
|
||||
image,
|
||||
labels: {
|
||||
app: "opencord",
|
||||
threadId,
|
||||
guildId,
|
||||
},
|
||||
autoStopInterval: 0,
|
||||
autoArchiveInterval: 0,
|
||||
},
|
||||
{ timeout: env.SANDBOX_CREATION_TIMEOUT },
|
||||
);
|
||||
|
||||
const sandboxId = sandbox.id;
|
||||
logger.info({
|
||||
event: "sandbox.create.started",
|
||||
component: "sandbox-manager",
|
||||
message: "Created sandbox",
|
||||
threadId,
|
||||
channelId,
|
||||
guildId,
|
||||
sandboxId,
|
||||
});
|
||||
|
||||
try {
|
||||
const context = { threadId, sandboxId };
|
||||
const home = await exec(sandbox, "discover-home", "echo $HOME", context);
|
||||
|
||||
await exec(
|
||||
sandbox,
|
||||
"clone-opencode",
|
||||
`git clone --depth=1 https://github.com/anomalyco/opencode.git ${home}/opencode`,
|
||||
context,
|
||||
);
|
||||
|
||||
const authJson = JSON.stringify({
|
||||
opencode: { type: "api", key: env.OPENCODE_ZEN_API_KEY },
|
||||
});
|
||||
|
||||
await exec(
|
||||
sandbox,
|
||||
"write-auth",
|
||||
`mkdir -p ${home}/.local/share/opencode && cat > ${home}/.local/share/opencode/auth.json << 'AUTHEOF'\n${authJson}\nAUTHEOF`,
|
||||
context,
|
||||
);
|
||||
|
||||
const agentPromptPath = new URL("../agent-prompt.md", import.meta.url);
|
||||
const agentPrompt = readFileSync(agentPromptPath, "utf-8");
|
||||
|
||||
const opencodeConfig = JSON.stringify({
|
||||
model: env.OPENCODE_MODEL,
|
||||
share: "disabled",
|
||||
permission: "allow",
|
||||
agent: {
|
||||
build: {
|
||||
mode: "primary",
|
||||
prompt: agentPrompt,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const configB64 = Buffer.from(opencodeConfig).toString("base64");
|
||||
await exec(
|
||||
sandbox,
|
||||
"write-config",
|
||||
`echo "${configB64}" | base64 -d > ${home}/opencode/opencode.json`,
|
||||
context,
|
||||
);
|
||||
|
||||
const opencodeEnv = this.buildRuntimeEnv();
|
||||
const githubToken = opencodeEnv.GITHUB_TOKEN ?? "";
|
||||
|
||||
logger.info({
|
||||
event: "sandbox.github.auth",
|
||||
component: "sandbox-manager",
|
||||
message: githubToken.length > 0
|
||||
? "Configured authenticated gh CLI in sandbox runtime"
|
||||
: "Running sandbox gh CLI unauthenticated (no GITHUB_TOKEN provided)",
|
||||
threadId,
|
||||
sandboxId,
|
||||
authenticated: githubToken.length > 0,
|
||||
});
|
||||
|
||||
await exec(
|
||||
sandbox,
|
||||
"start-opencode",
|
||||
"setsid opencode serve --port 4096 --hostname 0.0.0.0 > /tmp/opencode.log 2>&1 &",
|
||||
context,
|
||||
{
|
||||
cwd: `${home}/opencode`,
|
||||
env: opencodeEnv,
|
||||
},
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
|
||||
const preview = await sandbox.getPreviewLink(4096);
|
||||
const previewUrl = preview.url.replace(/\/$/, "");
|
||||
const previewToken = preview.token ?? null;
|
||||
|
||||
const healthy = await waitForHealthy({ previewUrl, previewToken }, 120_000);
|
||||
if (!healthy) {
|
||||
const startupLog = await exec(sandbox, "read-opencode-log", "cat /tmp/opencode.log 2>/dev/null | tail -100", context);
|
||||
throw new Error(`OpenCode server did not become healthy: ${startupLog.slice(0, 400)}`);
|
||||
}
|
||||
|
||||
const sessionId = await createSession({ previewUrl, previewToken }, `Discord thread ${threadId}`);
|
||||
|
||||
const session: SessionInfo = {
|
||||
threadId,
|
||||
channelId,
|
||||
guildId,
|
||||
sandboxId,
|
||||
sessionId,
|
||||
previewUrl,
|
||||
previewToken,
|
||||
status: "active",
|
||||
};
|
||||
|
||||
await this.store.upsert(session);
|
||||
await this.store.markHealthOk(threadId);
|
||||
this.resetTimeout(threadId);
|
||||
|
||||
logger.info({
|
||||
event: "sandbox.create.ready",
|
||||
component: "sandbox-manager",
|
||||
message: "Session is ready",
|
||||
threadId,
|
||||
channelId,
|
||||
guildId,
|
||||
sandboxId,
|
||||
sessionId,
|
||||
durationMs: totalTimer.elapsedMs(),
|
||||
});
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
event: "sandbox.create.failed",
|
||||
component: "sandbox-manager",
|
||||
message: "Failed to create session",
|
||||
threadId,
|
||||
channelId,
|
||||
guildId,
|
||||
sandboxId,
|
||||
durationMs: totalTimer.elapsedMs(),
|
||||
error,
|
||||
});
|
||||
|
||||
await this.store.updateStatus(threadId, "error", error instanceof Error ? error.message : String(error)).catch(() => {});
|
||||
await daytona.delete(sandbox).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(session: SessionInfo, text: string): Promise<string> {
|
||||
return this.withThreadLock(session.threadId, async () => {
|
||||
const t = timer();
|
||||
await this.store.markActivity(session.threadId);
|
||||
this.resetTimeout(session.threadId);
|
||||
|
||||
try {
|
||||
const response = await sendPrompt(
|
||||
{ previewUrl: session.previewUrl, previewToken: session.previewToken },
|
||||
session.sessionId,
|
||||
text,
|
||||
);
|
||||
|
||||
logger.info({
|
||||
event: "session.message.ok",
|
||||
component: "sandbox-manager",
|
||||
message: "Message processed",
|
||||
threadId: session.threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
sessionId: session.sessionId,
|
||||
durationMs: t.elapsedMs(),
|
||||
responseChars: response.length,
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const sessionMissing = message.includes("Failed to send prompt (404");
|
||||
const recoverable =
|
||||
message.includes("no IP address found") ||
|
||||
message.includes("Is the Sandbox started") ||
|
||||
message.includes("sandbox not found") ||
|
||||
message.includes("Failed to send prompt (5") ||
|
||||
sessionMissing;
|
||||
|
||||
if (recoverable) {
|
||||
await this.store.incrementResumeFailure(session.threadId, message);
|
||||
if (sessionMissing) {
|
||||
await this.store.updateStatus(session.threadId, "error", "opencode-session-missing").catch(() => {});
|
||||
} else {
|
||||
await this.pauseSessionUnlocked(session.threadId, "recoverable send failure").catch(() => {});
|
||||
}
|
||||
const recoveryError = new Error("SANDBOX_DEAD");
|
||||
(recoveryError as any).recoverable = true;
|
||||
throw recoveryError;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async pauseSession(threadId: string, reason = "manual"): Promise<void> {
|
||||
await this.withThreadLock(threadId, async () => {
|
||||
await this.pauseSessionUnlocked(threadId, reason);
|
||||
});
|
||||
}
|
||||
|
||||
private async pauseSessionUnlocked(threadId: string, reason: string): Promise<void> {
|
||||
const session = await this.store.getByThread(threadId);
|
||||
if (!session) return;
|
||||
if (session.status === "paused") return;
|
||||
|
||||
await this.store.updateStatus(threadId, "pausing", reason);
|
||||
|
||||
try {
|
||||
const daytona = createDaytona();
|
||||
const sandbox = await daytona.get(session.sandboxId);
|
||||
await daytona.stop(sandbox);
|
||||
await this.store.updateStatus(threadId, "paused", null);
|
||||
this.clearTimeout(threadId);
|
||||
|
||||
logger.info({
|
||||
event: "sandbox.paused",
|
||||
component: "sandbox-manager",
|
||||
message: "Paused sandbox",
|
||||
threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
reason,
|
||||
});
|
||||
} catch (error) {
|
||||
await this.store.updateStatus(threadId, "destroyed", error instanceof Error ? error.message : String(error));
|
||||
logger.warn({
|
||||
event: "sandbox.pause.missing",
|
||||
component: "sandbox-manager",
|
||||
message: "Sandbox unavailable while pausing; marked destroyed",
|
||||
threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async tryResumeSession(session: SessionInfo): Promise<ResumeAttemptResult> {
|
||||
if (!["paused", "destroyed", "error", "pausing", "resuming"].includes(session.status)) {
|
||||
return { session: null, allowRecreate: true };
|
||||
}
|
||||
|
||||
await this.store.updateStatus(session.threadId, "resuming");
|
||||
|
||||
const daytona = createDaytona();
|
||||
let sandbox: Awaited<ReturnType<typeof daytona.get>>;
|
||||
|
||||
try {
|
||||
sandbox = await daytona.get(session.sandboxId);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await this.store.incrementResumeFailure(session.threadId, errorMessage).catch(() => {});
|
||||
await this.store.updateStatus(session.threadId, "destroyed", errorMessage).catch(() => {});
|
||||
|
||||
logger.warn({
|
||||
event: "sandbox.resume.sandbox_missing",
|
||||
component: "sandbox-manager",
|
||||
message: "Sandbox missing during resume; safe to recreate",
|
||||
threadId: session.threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
errorMessage,
|
||||
});
|
||||
|
||||
return { session: null, allowRecreate: true };
|
||||
}
|
||||
|
||||
try {
|
||||
await daytona.start(sandbox, getEnv().SANDBOX_CREATION_TIMEOUT);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await this.store.incrementResumeFailure(session.threadId, errorMessage).catch(() => {});
|
||||
await this.store.updateStatus(session.threadId, "error", errorMessage).catch(() => {});
|
||||
|
||||
const allowRecreate = isSandboxMissingError(error);
|
||||
logger.warn({
|
||||
event: "sandbox.resume.start_failed",
|
||||
component: "sandbox-manager",
|
||||
message: allowRecreate
|
||||
? "Sandbox no longer exists while starting; safe to recreate"
|
||||
: "Sandbox start failed; refusing automatic recreate to avoid context loss",
|
||||
threadId: session.threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
errorMessage,
|
||||
allowRecreate,
|
||||
});
|
||||
|
||||
return { session: null, allowRecreate };
|
||||
}
|
||||
|
||||
try {
|
||||
const preview = await sandbox.getPreviewLink(4096);
|
||||
const previewUrl = preview.url.replace(/\/$/, "");
|
||||
const previewToken = preview.token ?? null;
|
||||
|
||||
const context = { threadId: session.threadId, sandboxId: session.sandboxId };
|
||||
|
||||
logger.info({
|
||||
event: "sandbox.resume.restarting_opencode",
|
||||
component: "sandbox-manager",
|
||||
message: "Restarting opencode serve after sandbox start",
|
||||
threadId: session.threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
});
|
||||
|
||||
await exec(
|
||||
sandbox,
|
||||
"restart-opencode-serve",
|
||||
"pkill -f 'opencode serve --port 4096' >/dev/null 2>&1 || true; for d in \"$HOME/opencode\" \"/home/daytona/opencode\" \"/root/opencode\"; do if [ -d \"$d\" ]; then cd \"$d\" && setsid opencode serve --port 4096 --hostname 0.0.0.0 > /tmp/opencode.log 2>&1 & exit 0; fi; done; exit 1",
|
||||
context,
|
||||
{ env: this.buildRuntimeEnv() },
|
||||
);
|
||||
|
||||
const healthy = await waitForHealthy(
|
||||
{ previewUrl, previewToken },
|
||||
getEnv().RESUME_HEALTH_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (!healthy) {
|
||||
const startupLog = await exec(
|
||||
sandbox,
|
||||
"read-opencode-log-after-resume",
|
||||
"cat /tmp/opencode.log 2>/dev/null | tail -120",
|
||||
context,
|
||||
).catch(() => "(unable to read opencode log)");
|
||||
|
||||
const errorMessage = `OpenCode health check failed after resume. Log: ${startupLog.slice(0, 500)}`;
|
||||
await this.store.incrementResumeFailure(session.threadId, errorMessage).catch(() => {});
|
||||
await this.store.updateStatus(session.threadId, "error", errorMessage).catch(() => {});
|
||||
|
||||
logger.error({
|
||||
event: "sandbox.resume.health_failed",
|
||||
component: "sandbox-manager",
|
||||
message: "OpenCode did not become healthy after restart; refusing recreate",
|
||||
threadId: session.threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
errorMessage,
|
||||
});
|
||||
|
||||
return { session: null, allowRecreate: false };
|
||||
}
|
||||
|
||||
let sessionId = session.sessionId;
|
||||
const existingSession = await sessionExists({ previewUrl, previewToken }, sessionId);
|
||||
if (!existingSession) {
|
||||
const expectedTitle = `Discord thread ${session.threadId}`;
|
||||
const sessions = await listSessions({ previewUrl, previewToken }, 50).catch(() => []);
|
||||
|
||||
const replacement = sessions
|
||||
.filter((candidate) => candidate.title === expectedTitle)
|
||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
|
||||
|
||||
if (replacement) {
|
||||
sessionId = replacement.id;
|
||||
logger.info({
|
||||
event: "sandbox.resume.session_reused_by_title",
|
||||
component: "sandbox-manager",
|
||||
message: "Reattached to existing session by title",
|
||||
threadId: session.threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
previousSessionId: session.sessionId,
|
||||
sessionId,
|
||||
});
|
||||
} else {
|
||||
logger.warn({
|
||||
event: "sandbox.resume.session_missing",
|
||||
component: "sandbox-manager",
|
||||
message: "OpenCode session missing after resume; creating replacement session",
|
||||
threadId: session.threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
sessionId = await createSession({ previewUrl, previewToken }, expectedTitle);
|
||||
}
|
||||
}
|
||||
|
||||
const resumed: SessionInfo = {
|
||||
...session,
|
||||
sessionId,
|
||||
previewUrl,
|
||||
previewToken,
|
||||
status: "active",
|
||||
};
|
||||
|
||||
await this.store.upsert(resumed);
|
||||
await this.store.markHealthOk(session.threadId);
|
||||
this.resetTimeout(session.threadId);
|
||||
|
||||
logger.info({
|
||||
event: "sandbox.resumed",
|
||||
component: "sandbox-manager",
|
||||
message: "Resumed existing sandbox",
|
||||
threadId: session.threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
previousSessionId: session.sessionId,
|
||||
sessionId,
|
||||
sessionReattached: sessionId === session.sessionId,
|
||||
});
|
||||
|
||||
return { session: resumed, allowRecreate: false };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await this.store.incrementResumeFailure(session.threadId, errorMessage).catch(() => {});
|
||||
await this.store.updateStatus(session.threadId, "error", errorMessage).catch(() => {});
|
||||
|
||||
logger.warn({
|
||||
event: "sandbox.resume.failed",
|
||||
component: "sandbox-manager",
|
||||
message: "Resume failed after sandbox start; refusing automatic recreate",
|
||||
threadId: session.threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
errorMessage,
|
||||
});
|
||||
|
||||
return { session: null, allowRecreate: false };
|
||||
}
|
||||
}
|
||||
|
||||
async destroySession(threadId: string): Promise<void> {
|
||||
await this.withThreadLock(threadId, async () => {
|
||||
const session = await this.store.getByThread(threadId);
|
||||
if (!session) return;
|
||||
|
||||
await this.store.updateStatus(threadId, "destroying");
|
||||
|
||||
try {
|
||||
const daytona = createDaytona();
|
||||
const sandbox = await daytona.get(session.sandboxId);
|
||||
await daytona.delete(sandbox);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
|
||||
await this.store.updateStatus(threadId, "destroyed");
|
||||
this.clearTimeout(threadId);
|
||||
});
|
||||
}
|
||||
|
||||
resetTimeout(threadId: string): void {
|
||||
this.clearTimeout(threadId);
|
||||
const timeoutMs = getEnv().SANDBOX_TIMEOUT_MINUTES * 60 * 1000;
|
||||
|
||||
const handle = setTimeout(async () => {
|
||||
timeouts.delete(threadId);
|
||||
await this.pauseSession(threadId, "inactivity-timeout").catch((error) => {
|
||||
logger.error({
|
||||
event: "sandbox.pause.timeout.failed",
|
||||
component: "sandbox-manager",
|
||||
message: "Failed to pause sandbox on inactivity timeout",
|
||||
threadId,
|
||||
error,
|
||||
});
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
timeouts.set(threadId, handle);
|
||||
}
|
||||
|
||||
private clearTimeout(threadId: string): void {
|
||||
const existing = timeouts.get(threadId);
|
||||
if (!existing) return;
|
||||
clearTimeout(existing);
|
||||
timeouts.delete(threadId);
|
||||
}
|
||||
|
||||
startCleanupLoop(): void {
|
||||
const intervalMs = 5 * 60 * 1000;
|
||||
|
||||
this.cleanupInterval = setInterval(async () => {
|
||||
try {
|
||||
const env = getEnv();
|
||||
const staleActive = await this.store.listStaleActive(env.SANDBOX_TIMEOUT_MINUTES + 5);
|
||||
|
||||
for (const session of staleActive) {
|
||||
await this.pauseSession(session.threadId, "cleanup-stale-active");
|
||||
}
|
||||
|
||||
const expiredPaused = await this.store.listExpiredPaused(env.PAUSED_TTL_MINUTES);
|
||||
for (const session of expiredPaused) {
|
||||
await this.destroySession(session.threadId);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
event: "cleanup.loop.failed",
|
||||
component: "sandbox-manager",
|
||||
message: "Cleanup loop failed",
|
||||
error,
|
||||
});
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
logger.info({
|
||||
event: "cleanup.loop.started",
|
||||
component: "sandbox-manager",
|
||||
message: "Started cleanup loop",
|
||||
intervalMs,
|
||||
timeoutMinutes: getEnv().SANDBOX_TIMEOUT_MINUTES,
|
||||
pausedTtlMinutes: getEnv().PAUSED_TTL_MINUTES,
|
||||
});
|
||||
}
|
||||
|
||||
stopCleanupLoop(): void {
|
||||
if (!this.cleanupInterval) return;
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
|
||||
async destroyAll(): Promise<void> {
|
||||
const active = await this.store.listActive();
|
||||
await Promise.allSettled(active.map((session) => this.pauseSession(session.threadId, "shutdown")));
|
||||
}
|
||||
|
||||
private buildRuntimeEnv(): Record<string, string> {
|
||||
const env = getEnv();
|
||||
const runtimeEnv: Record<string, string> = {};
|
||||
const githubToken = env.GITHUB_TOKEN.trim();
|
||||
|
||||
if (githubToken.length > 0) {
|
||||
runtimeEnv.GH_TOKEN = githubToken;
|
||||
runtimeEnv.GITHUB_TOKEN = githubToken;
|
||||
}
|
||||
|
||||
return runtimeEnv;
|
||||
}
|
||||
|
||||
private async ensureSessionHealthy(session: SessionInfo, maxWaitMs: number): Promise<boolean> {
|
||||
const healthy = await waitForHealthy(
|
||||
{ previewUrl: session.previewUrl, previewToken: session.previewToken },
|
||||
maxWaitMs,
|
||||
);
|
||||
|
||||
if (!healthy) {
|
||||
await this.store.incrementResumeFailure(session.threadId, "active-session-healthcheck-failed").catch(() => {});
|
||||
await this.store.updateStatus(session.threadId, "error", "active-session-healthcheck-failed").catch(() => {});
|
||||
return false;
|
||||
}
|
||||
|
||||
const attached = await sessionExists(
|
||||
{ previewUrl: session.previewUrl, previewToken: session.previewToken },
|
||||
session.sessionId,
|
||||
).catch(() => false);
|
||||
|
||||
if (!attached) {
|
||||
await this.store.incrementResumeFailure(session.threadId, "active-session-missing").catch(() => {});
|
||||
await this.store.updateStatus(session.threadId, "error", "active-session-missing").catch(() => {});
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.store.markHealthOk(session.threadId).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
private async withThreadLock<T>(threadId: string, fn: () => Promise<T>): Promise<T> {
|
||||
const previous = this.threadLocks.get(threadId) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const current = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
this.threadLocks.set(threadId, previous.then(() => current));
|
||||
await previous;
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
if (this.threadLocks.get(threadId) === current) {
|
||||
this.threadLocks.delete(threadId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type { SessionStatus };
|
||||
|
|
@ -1,198 +1,242 @@
|
|||
import { Effect, Schedule } from "effect";
|
||||
import { logger } from "../observability/logger";
|
||||
import { Context, Effect, Layer, ParseResult, Schema, Schedule } from "effect"
|
||||
import { HttpBody, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "@effect/platform"
|
||||
import { HealthCheckError, OpenCodeClientError } from "../errors"
|
||||
import { PreviewAccess, SessionId } from "../types"
|
||||
|
||||
type PreviewAccess = {
|
||||
previewUrl: string;
|
||||
previewToken?: string | null;
|
||||
};
|
||||
const HealthResponse = Schema.Struct({
|
||||
healthy: Schema.Boolean,
|
||||
})
|
||||
|
||||
export type OpenCodeSessionSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
updatedAt?: number;
|
||||
};
|
||||
const CreateSessionResponse = Schema.Struct({
|
||||
id: SessionId,
|
||||
})
|
||||
|
||||
/**
|
||||
* Parse a Daytona preview URL into base URL and token.
|
||||
* Preview URLs look like: https://4096-xxx.proxy.daytona.works?tkn=abc123
|
||||
*/
|
||||
function parsePreview(input: string | PreviewAccess): { base: string; token: string | null } {
|
||||
const previewUrl = typeof input === "string" ? input : input.previewUrl;
|
||||
const url = new URL(previewUrl);
|
||||
const token = typeof input === "string"
|
||||
? url.searchParams.get("tkn")
|
||||
: (input.previewToken ?? url.searchParams.get("tkn"));
|
||||
url.searchParams.delete("tkn");
|
||||
return { base: url.toString().replace(/\/$/, ""), token };
|
||||
const ListSessionsResponse = Schema.Array(
|
||||
Schema.Struct({
|
||||
id: SessionId,
|
||||
title: Schema.optional(Schema.String),
|
||||
time: Schema.optional(Schema.Struct({ updated: Schema.optional(Schema.Number) })),
|
||||
}),
|
||||
)
|
||||
|
||||
const SendPromptResponse = Schema.Struct({
|
||||
parts: Schema.optional(
|
||||
Schema.Array(Schema.Struct({
|
||||
type: Schema.String,
|
||||
text: Schema.optional(Schema.String),
|
||||
content: Schema.optional(Schema.String),
|
||||
})),
|
||||
),
|
||||
})
|
||||
|
||||
export class OpenCodeSessionSummary extends Schema.Class<OpenCodeSessionSummary>("OpenCodeSessionSummary")({
|
||||
id: SessionId,
|
||||
title: Schema.String,
|
||||
updatedAt: Schema.optional(Schema.Number),
|
||||
}) {}
|
||||
|
||||
const parsePreview = (input: PreviewAccess): { base: string; token: string | null } => {
|
||||
const url = new URL(input.previewUrl)
|
||||
const token = input.previewToken ?? url.searchParams.get("tkn")
|
||||
url.searchParams.delete("tkn")
|
||||
return { base: url.toString().replace(/\/$/, ""), token }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch wrapper that properly handles Daytona preview URL token auth.
|
||||
* Sends token as x-daytona-preview-token header.
|
||||
* HTTP client for an OpenCode server running inside a Daytona sandbox.
|
||||
*
|
||||
* Each method takes a {@link PreviewAccess} to locate the sandbox's preview
|
||||
* tunnel. Typical lifecycle:
|
||||
*
|
||||
* 1. `waitForHealthy` — poll until the server is ready after creation/resume
|
||||
* 2. `createSession` — start a new chat session
|
||||
* 3. `sendPrompt` — send user messages, returns the agent's text response
|
||||
* 4. `abortSession` — cancel an in-flight generation
|
||||
*/
|
||||
async function previewFetch(preview: string | PreviewAccess, path: string, init?: RequestInit): Promise<Response> {
|
||||
const { base, token } = parsePreview(preview);
|
||||
const url = `${base}${path}`;
|
||||
const headers = new Headers(init?.headers);
|
||||
if (token) {
|
||||
headers.set("x-daytona-preview-token", token);
|
||||
export declare namespace OpenCodeClient {
|
||||
export interface Service {
|
||||
/** Poll the health endpoint until the server responds healthy, or timeout. */
|
||||
readonly waitForHealthy: (
|
||||
preview: PreviewAccess,
|
||||
maxWaitMs?: number,
|
||||
) => Effect.Effect<boolean, HealthCheckError>
|
||||
/** Create a new chat session with the given title. Returns the session ID. */
|
||||
readonly createSession: (
|
||||
preview: PreviewAccess,
|
||||
title: string,
|
||||
) => Effect.Effect<SessionId, OpenCodeClientError>
|
||||
/** Check whether a session still exists on the server. */
|
||||
readonly sessionExists: (
|
||||
preview: PreviewAccess,
|
||||
sessionId: SessionId,
|
||||
) => Effect.Effect<boolean, OpenCodeClientError>
|
||||
/** List recent sessions, ordered by update time. */
|
||||
readonly listSessions: (
|
||||
preview: PreviewAccess,
|
||||
limit?: number,
|
||||
) => Effect.Effect<ReadonlyArray<OpenCodeSessionSummary>, OpenCodeClientError>
|
||||
/** Send a user prompt and return the agent's text response. */
|
||||
readonly sendPrompt: (
|
||||
preview: PreviewAccess,
|
||||
sessionId: SessionId,
|
||||
text: string,
|
||||
) => Effect.Effect<string, OpenCodeClientError>
|
||||
/** Cancel an in-flight generation. Best-effort, errors are swallowed. */
|
||||
readonly abortSession: (
|
||||
preview: PreviewAccess,
|
||||
sessionId: SessionId,
|
||||
) => Effect.Effect<void>
|
||||
}
|
||||
return fetch(url, { ...init, headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the OpenCode server inside a sandbox to become healthy.
|
||||
* Polls GET /global/health every 2s up to maxWaitMs.
|
||||
*/
|
||||
export async function waitForHealthy(preview: string | PreviewAccess, maxWaitMs = 120_000): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
let lastStatus = "";
|
||||
export class OpenCodeClient extends Context.Tag("@discord/OpenCodeClient")<OpenCodeClient, OpenCodeClient.Service>() {
|
||||
static readonly layer = Layer.effect(
|
||||
OpenCodeClient,
|
||||
Effect.gen(function* () {
|
||||
const baseClient = yield* HttpClient.HttpClient
|
||||
|
||||
const poll = Effect.tryPromise(async () => {
|
||||
const res = await previewFetch(preview, "/global/health");
|
||||
lastStatus = `${res.status}`;
|
||||
/** Build a scoped client for a specific preview, with auth header and 2xx filtering. */
|
||||
const scopedClient = (preview: PreviewAccess) => {
|
||||
const { base, token } = parsePreview(preview)
|
||||
return baseClient.pipe(
|
||||
HttpClient.mapRequest((req) =>
|
||||
token ? HttpClientRequest.setHeader(req, "x-daytona-preview-token", token) : req
|
||||
),
|
||||
HttpClient.mapRequest(HttpClientRequest.prependUrl(base)),
|
||||
HttpClient.filterStatusOk,
|
||||
)
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
const body = await res.json() as { healthy?: boolean };
|
||||
if (body.healthy) return true;
|
||||
lastStatus = `200 but healthy=${body.healthy}`;
|
||||
throw new Error(lastStatus);
|
||||
}
|
||||
/** Map HttpClientError + ParseError to OpenCodeClientError for a given operation. */
|
||||
const mapErrors = <A, R>(
|
||||
operation: string,
|
||||
effect: Effect.Effect<A, HttpClientError.HttpClientError | ParseResult.ParseError, R>,
|
||||
) =>
|
||||
effect.pipe(
|
||||
Effect.catchTags({
|
||||
ResponseError: (err) =>
|
||||
new OpenCodeClientError({ operation, statusCode: err.response.status, body: err.message }),
|
||||
RequestError: (err) =>
|
||||
new OpenCodeClientError({ operation, statusCode: 0, body: err.message }),
|
||||
ParseError: (err) =>
|
||||
new OpenCodeClientError({ operation, statusCode: 0, body: `Decode: ${err.message}` }),
|
||||
}),
|
||||
)
|
||||
|
||||
const body = await res.text().catch(() => "");
|
||||
lastStatus = `${res.status}: ${body.slice(0, 150)}`;
|
||||
throw new Error(lastStatus);
|
||||
}).pipe(
|
||||
Effect.tapError(() =>
|
||||
Effect.sync(() => {
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(0);
|
||||
logger.warn({
|
||||
event: "opencode.health.poll",
|
||||
component: "opencode-client",
|
||||
message: "Health check poll failed",
|
||||
elapsedSec: Number(elapsed),
|
||||
lastStatus,
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
const waitForHealthy = Effect.fn("OpenCodeClient.waitForHealthy")(
|
||||
function* (preview: PreviewAccess, maxWaitMs = 120_000) {
|
||||
const maxAttempts = Math.max(1, Math.ceil(maxWaitMs / 2000))
|
||||
const api = scopedClient(preview)
|
||||
|
||||
const maxAttempts = Math.max(1, Math.ceil(maxWaitMs / 2000));
|
||||
const poll = api.get("/global/health").pipe(
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(HealthResponse)),
|
||||
Effect.scoped,
|
||||
Effect.flatMap((body) =>
|
||||
body.healthy
|
||||
? Effect.succeed(true)
|
||||
: new HealthCheckError({ lastStatus: `200 but healthy=${body.healthy}` }),
|
||||
),
|
||||
Effect.catchAll((cause) => new HealthCheckError({ lastStatus: String(cause) })),
|
||||
)
|
||||
|
||||
return Effect.runPromise(
|
||||
poll.pipe(
|
||||
Effect.retry(
|
||||
Schedule.intersect(
|
||||
Schedule.spaced("2 seconds"),
|
||||
Schedule.recurs(maxAttempts - 1),
|
||||
),
|
||||
),
|
||||
Effect.as(true),
|
||||
Effect.catchAll(() =>
|
||||
Effect.sync(() => {
|
||||
logger.error({
|
||||
event: "opencode.health.failed",
|
||||
component: "opencode-client",
|
||||
message: "Health check failed",
|
||||
maxWaitMs,
|
||||
lastStatus,
|
||||
});
|
||||
return false;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return yield* poll.pipe(
|
||||
Effect.retry(
|
||||
Schedule.intersect(
|
||||
Schedule.spaced("2 seconds"),
|
||||
Schedule.recurs(maxAttempts - 1),
|
||||
),
|
||||
),
|
||||
Effect.catchAll(() => Effect.succeed(false)),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a new OpenCode session and returns the session ID.
|
||||
*/
|
||||
export async function createSession(preview: string | PreviewAccess, title: string): Promise<string> {
|
||||
const res = await previewFetch(preview, "/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
const createSession = (preview: PreviewAccess, title: string) =>
|
||||
mapErrors(
|
||||
"createSession",
|
||||
scopedClient(preview)
|
||||
.post("/session", { body: HttpBody.unsafeJson({ title }) })
|
||||
.pipe(
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(CreateSessionResponse)),
|
||||
Effect.scoped,
|
||||
Effect.map((body) => body.id),
|
||||
),
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`Failed to create session (${res.status}): ${body}`);
|
||||
}
|
||||
const sessionExists = (preview: PreviewAccess, sessionId: SessionId) =>
|
||||
scopedClient(preview)
|
||||
.get(`/session/${sessionId}`)
|
||||
.pipe(
|
||||
Effect.scoped,
|
||||
Effect.as(true),
|
||||
Effect.catchTag("ResponseError", (err) =>
|
||||
err.response.status === 404
|
||||
? Effect.succeed(false)
|
||||
: new OpenCodeClientError({ operation: "sessionExists", statusCode: err.response.status, body: err.message }),
|
||||
),
|
||||
Effect.catchTag("RequestError", (err) =>
|
||||
new OpenCodeClientError({ operation: "sessionExists", statusCode: 0, body: err.message }),
|
||||
),
|
||||
)
|
||||
|
||||
const session = await res.json() as { id: string };
|
||||
return session.id;
|
||||
}
|
||||
const listSessions = (preview: PreviewAccess, limit = 50) =>
|
||||
mapErrors(
|
||||
"listSessions",
|
||||
scopedClient(preview)
|
||||
.get(`/session${limit > 0 ? `?limit=${limit}` : ""}`)
|
||||
.pipe(
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(ListSessionsResponse)),
|
||||
Effect.scoped,
|
||||
Effect.map((sessions) =>
|
||||
sessions.map((s) =>
|
||||
OpenCodeSessionSummary.make({
|
||||
id: s.id,
|
||||
title: s.title ?? "",
|
||||
...(s.time?.updated != null ? { updatedAt: s.time.updated } : {}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
export async function sessionExists(preview: string | PreviewAccess, sessionId: string): Promise<boolean> {
|
||||
const res = await previewFetch(preview, `/session/${sessionId}`, {
|
||||
method: "GET",
|
||||
});
|
||||
const sendPrompt = (preview: PreviewAccess, sessionId: SessionId, text: string) =>
|
||||
mapErrors(
|
||||
"sendPrompt",
|
||||
scopedClient(preview)
|
||||
.post(`/session/${sessionId}/message`, {
|
||||
body: HttpBody.unsafeJson({ parts: [{ type: "text", text }] }),
|
||||
})
|
||||
.pipe(
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(SendPromptResponse)),
|
||||
Effect.scoped,
|
||||
Effect.map((result) => {
|
||||
const parts = result.parts ?? []
|
||||
const textContent = parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text || p.content || "")
|
||||
.filter(Boolean)
|
||||
return textContent.join("\n\n") || "(No response from agent)"
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if (res.ok) return true;
|
||||
if (res.status === 404) return false;
|
||||
const abortSession = (preview: PreviewAccess, sessionId: SessionId) =>
|
||||
scopedClient(preview)
|
||||
.post(`/session/${sessionId}/abort`)
|
||||
.pipe(
|
||||
Effect.scoped,
|
||||
Effect.asVoid,
|
||||
Effect.catchAll(() => Effect.void),
|
||||
)
|
||||
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`Failed to check session (${res.status}): ${body}`);
|
||||
}
|
||||
|
||||
export async function listSessions(preview: string | PreviewAccess, limit = 50): Promise<OpenCodeSessionSummary[]> {
|
||||
const query = limit > 0 ? `?limit=${limit}` : "";
|
||||
const res = await previewFetch(preview, `/session${query}`, {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`Failed to list sessions (${res.status}): ${body}`);
|
||||
}
|
||||
|
||||
const sessions = await res.json() as Array<{
|
||||
id?: string;
|
||||
title?: string;
|
||||
time?: { updated?: number };
|
||||
}>;
|
||||
|
||||
return sessions
|
||||
.filter((session) => typeof session.id === "string")
|
||||
.map((session) => ({
|
||||
id: session.id as string,
|
||||
title: session.title ?? "",
|
||||
updatedAt: session.time?.updated,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a prompt to an existing session and returns the text response.
|
||||
* This call blocks until the agent finishes processing.
|
||||
*/
|
||||
export async function sendPrompt(preview: string | PreviewAccess, sessionId: string, text: string): Promise<string> {
|
||||
const res = await previewFetch(preview, `/session/${sessionId}/message`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
parts: [{ type: "text", text }],
|
||||
return OpenCodeClient.of({
|
||||
waitForHealthy,
|
||||
createSession,
|
||||
sessionExists,
|
||||
listSessions,
|
||||
sendPrompt,
|
||||
abortSession,
|
||||
})
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`Failed to send prompt (${res.status}): ${body}`);
|
||||
}
|
||||
|
||||
const result = await res.json() as { parts?: Array<{ type: string; text?: string; content?: string }> };
|
||||
const parts = result.parts ?? [];
|
||||
|
||||
const textContent = parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text || p.content || "")
|
||||
.filter(Boolean);
|
||||
|
||||
return textContent.join("\n\n") || "(No response from agent)";
|
||||
}
|
||||
|
||||
/**
|
||||
* Aborts a running session.
|
||||
*/
|
||||
export async function abortSession(preview: string | PreviewAccess, sessionId: string): Promise<void> {
|
||||
await previewFetch(preview, `/session/${sessionId}/abort`, { method: "POST" }).catch(() => {});
|
||||
)
|
||||
}
|
||||
|
|
|
|||
124
packages/discord/src/sandbox/pool.test.ts
Normal file
124
packages/discord/src/sandbox/pool.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { OpenCodeClientError } from "../errors"
|
||||
import { SessionStore } from "../sessions/store"
|
||||
import { effectTest, testConfigLayer } from "../test/effect"
|
||||
import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, ThreadId } from "../types"
|
||||
import { OpenCodeClient } from "./opencode-client"
|
||||
import { ResumeFailed, SandboxProvisioner } from "./provisioner"
|
||||
import { ThreadAgentPool } from "./pool"
|
||||
|
||||
const threadId = ThreadId.make("t1")
|
||||
const channelId = ChannelId.make("c1")
|
||||
const guildId = GuildId.make("g1")
|
||||
|
||||
const session = SessionInfo.make({
|
||||
threadId,
|
||||
channelId,
|
||||
guildId,
|
||||
sandboxId: SandboxId.make("sb1"),
|
||||
sessionId: SessionId.make("s1"),
|
||||
previewUrl: "https://preview.example",
|
||||
previewToken: null,
|
||||
status: "active",
|
||||
lastError: null,
|
||||
resumeFailCount: 0,
|
||||
})
|
||||
|
||||
const store = () => {
|
||||
let row = Option.some(session)
|
||||
return SessionStore.of({
|
||||
upsert: (next) =>
|
||||
Effect.sync(() => {
|
||||
row = Option.some(next)
|
||||
}),
|
||||
getByThread: () => Effect.succeed(row),
|
||||
hasTrackedThread: () => Effect.succeed(Option.isSome(row)),
|
||||
getActive: () =>
|
||||
Option.isSome(row) && row.value.status === "active"
|
||||
? Effect.succeed(row)
|
||||
: Effect.succeed(Option.none()),
|
||||
markActivity: () => Effect.void,
|
||||
markHealthOk: () => Effect.void,
|
||||
updateStatus: (_threadId, status) =>
|
||||
Effect.sync(() => {
|
||||
if (Option.isNone(row)) return
|
||||
row = Option.some(row.value.withStatus(status))
|
||||
}),
|
||||
incrementResumeFailure: () => Effect.void,
|
||||
listActive: () =>
|
||||
Option.isSome(row) && row.value.status === "active"
|
||||
? Effect.succeed([row.value] as const)
|
||||
: Effect.succeed([] as const),
|
||||
listTrackedThreads: () =>
|
||||
Option.isSome(row) && row.value.status !== "destroyed"
|
||||
? Effect.succeed([row.value.threadId] as const)
|
||||
: Effect.succeed([] as const),
|
||||
listStaleActive: () => Effect.succeed([] as const),
|
||||
listExpiredPaused: () => Effect.succeed([] as const),
|
||||
})
|
||||
}
|
||||
|
||||
const provisioner = SandboxProvisioner.of({
|
||||
provision: () => Effect.succeed(session),
|
||||
resume: () => Effect.succeed(ResumeFailed.make({ allowRecreate: true })),
|
||||
ensureActive: ({ current }) => Effect.succeed(Option.isSome(current) ? current.value : session),
|
||||
ensureHealthy: () => Effect.succeed(true),
|
||||
recoverSendFailure: (_threadId, next) => Effect.succeed(next.withStatus("paused")),
|
||||
pause: (_threadId, next) => Effect.succeed(next.withStatus("paused")),
|
||||
destroy: (_threadId, next) => Effect.succeed(next.withStatus("destroyed")),
|
||||
})
|
||||
|
||||
const client = (statusCode: number, body: string) =>
|
||||
OpenCodeClient.of({
|
||||
waitForHealthy: () => Effect.succeed(true),
|
||||
createSession: () => Effect.succeed(SessionId.make("s2")),
|
||||
sessionExists: () => Effect.succeed(true),
|
||||
listSessions: () => Effect.succeed([]),
|
||||
sendPrompt: () => Effect.fail(new OpenCodeClientError({ operation: "sendPrompt", statusCode, body })),
|
||||
abortSession: () => Effect.void,
|
||||
})
|
||||
|
||||
const withPool = <A, E, R>(
|
||||
statusCode: number,
|
||||
body: string,
|
||||
run: Effect.Effect<A, E, R>,
|
||||
) => {
|
||||
const deps = Layer.mergeAll(
|
||||
testConfigLayer,
|
||||
Layer.succeed(SessionStore, store()),
|
||||
Layer.succeed(SandboxProvisioner, provisioner),
|
||||
Layer.succeed(OpenCodeClient, client(statusCode, body)),
|
||||
)
|
||||
return run.pipe(
|
||||
Effect.provide(ThreadAgentPool.layer.pipe(Layer.provide(deps))),
|
||||
)
|
||||
}
|
||||
|
||||
describe("ThreadAgentPool", () => {
|
||||
effectTest("maps recoverable send failures to SandboxDeadError", () =>
|
||||
withPool(
|
||||
502,
|
||||
"bad gateway",
|
||||
Effect.gen(function* () {
|
||||
const pool = yield* ThreadAgentPool
|
||||
const agent = yield* pool.getOrCreate(threadId, channelId, guildId)
|
||||
const error = yield* agent.send("hello").pipe(Effect.flip)
|
||||
expect(error._tag).toBe("SandboxDeadError")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
effectTest("keeps non-recoverable send failures as OpenCodeClientError", () =>
|
||||
withPool(
|
||||
400,
|
||||
"bad request",
|
||||
Effect.gen(function* () {
|
||||
const pool = yield* ThreadAgentPool
|
||||
const agent = yield* pool.getOrCreate(threadId, channelId, guildId)
|
||||
const error = yield* agent.send("hello").pipe(Effect.flip)
|
||||
expect(error._tag).toBe("OpenCodeClientError")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
305
packages/discord/src/sandbox/pool.ts
Normal file
305
packages/discord/src/sandbox/pool.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
import { Context, Duration, Effect, Layer, Option, Ref, Schedule } from "effect"
|
||||
import { AppConfig } from "../config"
|
||||
import {
|
||||
type ConfigEncodeError,
|
||||
DatabaseError,
|
||||
type HealthCheckError,
|
||||
type OpenCodeClientError,
|
||||
type SandboxCreateError,
|
||||
type SandboxExecError,
|
||||
SandboxDeadError,
|
||||
type SandboxNotFoundError,
|
||||
type SandboxStartError,
|
||||
} from "../errors"
|
||||
import { ActorMap } from "../lib/actors/keyed"
|
||||
import { logIgnore } from "../lib/log"
|
||||
import { SessionStore } from "../sessions/store"
|
||||
import { ChannelId, GuildId, PreviewAccess, SessionInfo, ThreadId } from "../types"
|
||||
import { OpenCodeClient } from "./opencode-client"
|
||||
import { SandboxProvisioner } from "./provisioner"
|
||||
|
||||
/** Per-thread handle returned by ThreadAgentPool.resolve. */
|
||||
export interface ThreadAgent {
|
||||
readonly threadId: ThreadId
|
||||
/** Snapshot from when this agent handle was created. Prefer `current()` for live state. */
|
||||
readonly session: SessionInfo
|
||||
readonly current: () => Effect.Effect<SessionInfo, DatabaseError>
|
||||
readonly send: (text: string) => Effect.Effect<string, OpenCodeClientError | SandboxDeadError | DatabaseError>
|
||||
readonly pause: (reason?: string) => Effect.Effect<void, DatabaseError>
|
||||
readonly destroy: () => Effect.Effect<void, DatabaseError>
|
||||
}
|
||||
|
||||
type Runtime = {
|
||||
readonly current: () => Effect.Effect<SessionInfo, DatabaseError>
|
||||
readonly ensure: (
|
||||
channelId: ChannelId,
|
||||
guildId: GuildId,
|
||||
) => Effect.Effect<
|
||||
SessionInfo,
|
||||
| SandboxCreateError
|
||||
| SandboxExecError
|
||||
| SandboxNotFoundError
|
||||
| SandboxStartError
|
||||
| HealthCheckError
|
||||
| OpenCodeClientError
|
||||
| ConfigEncodeError
|
||||
| SandboxDeadError
|
||||
| DatabaseError
|
||||
>
|
||||
readonly send: (text: string) => Effect.Effect<string, OpenCodeClientError | SandboxDeadError | DatabaseError>
|
||||
readonly pause: (reason: string) => Effect.Effect<void, DatabaseError>
|
||||
readonly destroy: (reason: string) => Effect.Effect<void, DatabaseError>
|
||||
}
|
||||
|
||||
export declare namespace ThreadAgentPool {
|
||||
export interface Service {
|
||||
/** Get an existing healthy ThreadAgent or create one. */
|
||||
readonly getOrCreate: (
|
||||
threadId: ThreadId,
|
||||
channelId: ChannelId,
|
||||
guildId: GuildId,
|
||||
) => Effect.Effect<
|
||||
ThreadAgent,
|
||||
| SandboxCreateError
|
||||
| SandboxExecError
|
||||
| SandboxNotFoundError
|
||||
| SandboxStartError
|
||||
| HealthCheckError
|
||||
| OpenCodeClientError
|
||||
| ConfigEncodeError
|
||||
| SandboxDeadError
|
||||
| DatabaseError
|
||||
>
|
||||
readonly hasTrackedThread: (threadId: ThreadId) => Effect.Effect<boolean, DatabaseError>
|
||||
readonly getTrackedSession: (threadId: ThreadId) => Effect.Effect<Option.Option<SessionInfo>, DatabaseError>
|
||||
readonly getActiveSessionCount: () => Effect.Effect<number, DatabaseError>
|
||||
readonly pauseSession: (threadId: ThreadId, reason?: string) => Effect.Effect<void, DatabaseError>
|
||||
readonly destroySession: (threadId: ThreadId) => Effect.Effect<void, DatabaseError>
|
||||
}
|
||||
}
|
||||
|
||||
export class ThreadAgentPool extends Context.Tag("@discord/ThreadAgentPool")<
|
||||
ThreadAgentPool,
|
||||
ThreadAgentPool.Service
|
||||
>() {
|
||||
static readonly layer = Layer.scoped(
|
||||
ThreadAgentPool,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* AppConfig
|
||||
const provisioner = yield* SandboxProvisioner
|
||||
const oc = yield* OpenCodeClient
|
||||
const store = yield* SessionStore
|
||||
|
||||
const runtime = (
|
||||
threadId: ThreadId,
|
||||
state: Ref.Ref<Option.Option<SessionInfo>>,
|
||||
): Runtime => {
|
||||
const dead = (error: OpenCodeClientError) => {
|
||||
if (error.statusCode === 404) return true
|
||||
if (error.statusCode === 0 || error.statusCode >= 500) return true
|
||||
const body = error.body.toLowerCase()
|
||||
if (body.includes("sandbox not found")) return true
|
||||
if (body.includes("is the sandbox started")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const lookup = Effect.fnUntraced(function* () {
|
||||
const loaded = yield* Ref.get(state)
|
||||
if (Option.isSome(loaded)) return loaded
|
||||
return Option.none<SessionInfo>()
|
||||
})
|
||||
|
||||
const current = Effect.fnUntraced(function* () {
|
||||
const session = yield* lookup()
|
||||
if (Option.isSome(session)) return session.value
|
||||
return yield* new DatabaseError({
|
||||
cause: new Error(`missing session for thread ${threadId}`),
|
||||
})
|
||||
})
|
||||
|
||||
const ensure = (channelId: ChannelId, guildId: GuildId) =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* provisioner.ensureActive({
|
||||
threadId,
|
||||
channelId,
|
||||
guildId,
|
||||
current: yield* Ref.get(state),
|
||||
})
|
||||
yield* Ref.set(state, Option.some(next))
|
||||
return next
|
||||
})
|
||||
|
||||
const send = (text: string) =>
|
||||
Effect.gen(function* () {
|
||||
yield* store.markActivity(threadId)
|
||||
const session = yield* current()
|
||||
return yield* oc.sendPrompt(PreviewAccess.from(session), session.sessionId, text).pipe(
|
||||
Effect.catchTag("OpenCodeClientError", (error) =>
|
||||
provisioner.recoverSendFailure(threadId, session, error).pipe(
|
||||
Effect.flatMap((next) =>
|
||||
Ref.set(state, Option.some(next)),
|
||||
),
|
||||
Effect.flatMap(() => {
|
||||
const failure: OpenCodeClientError | SandboxDeadError = dead(error)
|
||||
? new SandboxDeadError({
|
||||
threadId,
|
||||
reason: `OpenCode send failed (${error.statusCode})`,
|
||||
})
|
||||
: error
|
||||
return Effect.fail(failure)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const pause = (reason: string) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* lookup()
|
||||
if (Option.isNone(session)) return
|
||||
const next = yield* provisioner.pause(threadId, session.value, reason)
|
||||
yield* Ref.set(state, Option.some(next))
|
||||
})
|
||||
|
||||
const destroy = (reason: string) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* lookup()
|
||||
if (Option.isNone(session)) return
|
||||
const next = yield* provisioner.destroy(threadId, session.value, reason)
|
||||
yield* Ref.set(state, Option.some(next))
|
||||
})
|
||||
|
||||
return { current, ensure, send, pause, destroy }
|
||||
}
|
||||
|
||||
const actors: ActorMap.ActorMap<ThreadId, SessionInfo> = yield* ActorMap.make<ThreadId, SessionInfo>({
|
||||
idleTimeout: config.sandboxTimeout,
|
||||
onIdle: (threadId) =>
|
||||
logIgnore(
|
||||
runRuntime(
|
||||
threadId,
|
||||
(rt) => rt.pause("inactivity-timeout"),
|
||||
{ touch: false },
|
||||
).pipe(
|
||||
Effect.tap(() => actors.remove(threadId)),
|
||||
),
|
||||
"idle-pause",
|
||||
),
|
||||
load: (threadId) => store.getByThread(threadId).pipe(Effect.catchAll(() => Effect.succeed(Option.none()))),
|
||||
save: (_threadId, session) => logIgnore(store.upsert(session), "save-session").pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const runRuntime = <A, E>(
|
||||
threadId: ThreadId,
|
||||
f: (rt: Runtime) => Effect.Effect<A, E>,
|
||||
options?: { touch?: boolean },
|
||||
) =>
|
||||
actors.run(
|
||||
threadId,
|
||||
(state) => f(runtime(threadId, state)),
|
||||
options,
|
||||
)
|
||||
|
||||
const pauseNow = (threadId: ThreadId, reason: string) =>
|
||||
runRuntime(threadId, (rt) => rt.pause(reason), { touch: false }).pipe(
|
||||
Effect.tap(() => actors.remove(threadId)),
|
||||
)
|
||||
|
||||
const destroyNow = (threadId: ThreadId, reason: string) =>
|
||||
runRuntime(threadId, (rt) => rt.destroy(reason), { touch: false }).pipe(
|
||||
Effect.tap(() => actors.remove(threadId)),
|
||||
)
|
||||
|
||||
const makeAgent = (threadId: ThreadId, session: SessionInfo): ThreadAgent => ({
|
||||
threadId,
|
||||
session,
|
||||
current: () =>
|
||||
runRuntime(threadId, (rt) => rt.current(), { touch: false }),
|
||||
send: (text: string) =>
|
||||
runRuntime(threadId, (rt) => rt.send(text)),
|
||||
pause: (reason = "manual") => pauseNow(threadId, reason),
|
||||
destroy: () => destroyNow(threadId, "agent-destroy"),
|
||||
})
|
||||
|
||||
const getOrCreate = Effect.fn("ThreadAgentPool.getOrCreate")(function* (
|
||||
threadId: ThreadId,
|
||||
channelId: ChannelId,
|
||||
guildId: GuildId,
|
||||
) {
|
||||
const session = yield* runRuntime(
|
||||
threadId,
|
||||
(rt) => rt.ensure(channelId, guildId),
|
||||
)
|
||||
return makeAgent(threadId, session)
|
||||
})
|
||||
|
||||
const hasTrackedThread = Effect.fn("ThreadAgentPool.hasTrackedThread")(function* (threadId: ThreadId) {
|
||||
return yield* store.hasTrackedThread(threadId)
|
||||
})
|
||||
|
||||
const getTrackedSession = Effect.fn("ThreadAgentPool.getTrackedSession")(function* (threadId: ThreadId) {
|
||||
return yield* store.getByThread(threadId)
|
||||
})
|
||||
|
||||
const getActiveSessionCount = Effect.fn("ThreadAgentPool.getActiveSessionCount")(function* () {
|
||||
return (yield* store.listActive()).length
|
||||
})
|
||||
|
||||
const pauseSession = Effect.fn("ThreadAgentPool.pauseSession")(function* (
|
||||
threadId: ThreadId,
|
||||
reason = "manual",
|
||||
) {
|
||||
yield* pauseNow(threadId, reason)
|
||||
})
|
||||
|
||||
const destroySession = Effect.fn("ThreadAgentPool.destroySession")(function* (threadId: ThreadId) {
|
||||
yield* destroyNow(threadId, "manual-destroy")
|
||||
})
|
||||
|
||||
const cleanupPass = Effect.fnUntraced(function* () {
|
||||
const stale = yield* store.listStaleActive(
|
||||
Math.ceil(Duration.toMinutes(config.sandboxTimeout)) + config.staleActiveGraceMinutes,
|
||||
)
|
||||
yield* Effect.forEach(
|
||||
stale,
|
||||
(row) =>
|
||||
logIgnore(
|
||||
pauseNow(row.threadId, "cleanup-stale-active"),
|
||||
"cleanup-pause",
|
||||
),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
|
||||
const expired = yield* store.listExpiredPaused(config.pausedTtlMinutes)
|
||||
yield* Effect.forEach(
|
||||
expired,
|
||||
(row) =>
|
||||
logIgnore(
|
||||
destroyNow(row.threadId, "cleanup-expired-paused"),
|
||||
"cleanup-destroy",
|
||||
),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
})
|
||||
|
||||
yield* cleanupPass().pipe(
|
||||
Effect.catchAll((error) =>
|
||||
Effect.logError("Cleanup loop failed").pipe(
|
||||
Effect.annotateLogs({ event: "cleanup.loop.failed", error: String(error) }),
|
||||
),
|
||||
),
|
||||
Effect.repeat(Schedule.spaced(config.cleanupInterval)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return ThreadAgentPool.of({
|
||||
getOrCreate,
|
||||
hasTrackedThread,
|
||||
getTrackedSession,
|
||||
getActiveSessionCount,
|
||||
pauseSession,
|
||||
destroySession,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
478
packages/discord/src/sandbox/provisioner.ts
Normal file
478
packages/discord/src/sandbox/provisioner.ts
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
import agentPrompt from "../agent-prompt.md" with { type: "text" }
|
||||
import { Context, Effect, Exit, Layer, Option, Redacted, Schema } from "effect"
|
||||
import { AppConfig } from "../config"
|
||||
import {
|
||||
ConfigEncodeError,
|
||||
DatabaseError,
|
||||
type HealthCheckError,
|
||||
type OpenCodeClientError,
|
||||
SandboxCreateError,
|
||||
SandboxDeadError,
|
||||
type SandboxExecError,
|
||||
type SandboxNotFoundError,
|
||||
type SandboxStartError,
|
||||
} from "../errors"
|
||||
import { SessionStore } from "../sessions/store"
|
||||
import { ChannelId, GuildId, PreviewAccess, SandboxId, SessionInfo, ThreadId } from "../types"
|
||||
import { DaytonaService, type SandboxHandle } from "./daytona"
|
||||
import { OpenCodeClient, OpenCodeSessionSummary } from "./opencode-client"
|
||||
|
||||
import { logIgnore } from "../lib/log"
|
||||
|
||||
const OpenCodeAuth = Schema.parseJson(
|
||||
Schema.Struct({
|
||||
opencode: Schema.Struct({
|
||||
type: Schema.Literal("api"),
|
||||
key: Schema.String,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const OpenCodeConfig = Schema.parseJson(
|
||||
Schema.Struct({
|
||||
model: Schema.String,
|
||||
share: Schema.String,
|
||||
permission: Schema.String,
|
||||
agent: Schema.Struct({
|
||||
build: Schema.Struct({
|
||||
mode: Schema.Literal("primary"),
|
||||
prompt: Schema.String,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
export class Resumed extends Schema.Class<Resumed>("Resumed")({
|
||||
session: SessionInfo,
|
||||
}) {}
|
||||
|
||||
export class ResumeFailed extends Schema.Class<ResumeFailed>("ResumeFailed")({
|
||||
allowRecreate: Schema.Boolean,
|
||||
}) {}
|
||||
|
||||
export type ResumeResult = Resumed | ResumeFailed
|
||||
type SendFailure = "session-missing" | "sandbox-down" | "non-recoverable"
|
||||
|
||||
export declare namespace SandboxProvisioner {
|
||||
export interface Service {
|
||||
/** Creates a brand new sandbox + OpenCode session. */
|
||||
readonly provision: (
|
||||
threadId: ThreadId,
|
||||
channelId: ChannelId,
|
||||
guildId: GuildId,
|
||||
) => Effect.Effect<
|
||||
SessionInfo,
|
||||
| SandboxCreateError
|
||||
| SandboxExecError
|
||||
| SandboxNotFoundError
|
||||
| SandboxStartError
|
||||
| HealthCheckError
|
||||
| OpenCodeClientError
|
||||
| ConfigEncodeError
|
||||
| DatabaseError
|
||||
>
|
||||
/** Attempts to resume an existing sandbox/session. Returns Resumed or Failed. */
|
||||
readonly resume: (
|
||||
session: SessionInfo,
|
||||
) => Effect.Effect<ResumeResult>
|
||||
/** Ensures a thread has an active healthy session, resuming or recreating if needed. */
|
||||
readonly ensureActive: (input: {
|
||||
threadId: ThreadId
|
||||
channelId: ChannelId
|
||||
guildId: GuildId
|
||||
current: Option.Option<SessionInfo>
|
||||
}) => Effect.Effect<
|
||||
SessionInfo,
|
||||
| SandboxCreateError
|
||||
| SandboxExecError
|
||||
| SandboxNotFoundError
|
||||
| SandboxStartError
|
||||
| HealthCheckError
|
||||
| OpenCodeClientError
|
||||
| ConfigEncodeError
|
||||
| SandboxDeadError
|
||||
| DatabaseError
|
||||
>
|
||||
/** Verifies active session health and attachment before reusing it. */
|
||||
readonly ensureHealthy: (
|
||||
session: SessionInfo,
|
||||
maxWaitMs: number,
|
||||
) => Effect.Effect<boolean, HealthCheckError>
|
||||
/** Applies session-state recovery policy after a send failure. */
|
||||
readonly recoverSendFailure: (
|
||||
threadId: ThreadId,
|
||||
session: SessionInfo,
|
||||
error: OpenCodeClientError,
|
||||
) => Effect.Effect<SessionInfo, DatabaseError>
|
||||
/** Pauses a session by stopping its sandbox. */
|
||||
readonly pause: (
|
||||
threadId: ThreadId,
|
||||
session: SessionInfo,
|
||||
reason: string,
|
||||
) => Effect.Effect<SessionInfo, DatabaseError>
|
||||
/** Destroys a session by destroying its sandbox. */
|
||||
readonly destroy: (
|
||||
threadId: ThreadId,
|
||||
session: SessionInfo,
|
||||
reason?: string,
|
||||
) => Effect.Effect<SessionInfo, DatabaseError>
|
||||
}
|
||||
}
|
||||
|
||||
export class SandboxProvisioner extends Context.Tag("@discord/SandboxProvisioner")<
|
||||
SandboxProvisioner,
|
||||
SandboxProvisioner.Service
|
||||
>() {
|
||||
static readonly layer = Layer.effect(
|
||||
SandboxProvisioner,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* AppConfig
|
||||
const daytonaService = yield* DaytonaService
|
||||
const oc = yield* OpenCodeClient
|
||||
const store = yield* SessionStore
|
||||
|
||||
/** Best-effort read of the OpenCode startup log from inside a sandbox. */
|
||||
const readStartupLog = (sandboxId: SandboxId, lines = 100) =>
|
||||
daytonaService.exec(sandboxId, "read-opencode-log", `cat /tmp/opencode.log 2>/dev/null | tail -${lines}`).pipe(
|
||||
Effect.map((r) => r.output),
|
||||
Effect.catchAll(() => Effect.succeed("(unable to read log)")),
|
||||
)
|
||||
|
||||
/** Locate the existing OpenCode session or create a fresh one for a thread. */
|
||||
const findOrCreateSessionId = Effect.fnUntraced(function* (preview: PreviewAccess, session: SessionInfo) {
|
||||
const exists = yield* oc
|
||||
.sessionExists(preview, session.sessionId)
|
||||
.pipe(Effect.catchAll(() => Effect.succeed(false)))
|
||||
if (exists) return session.sessionId
|
||||
|
||||
const title = `Discord thread ${session.threadId}`
|
||||
const sessions = yield* oc
|
||||
.listSessions(preview, 50)
|
||||
.pipe(Effect.catchAll(() => Effect.succeed([] as ReadonlyArray<OpenCodeSessionSummary>)))
|
||||
const match = [...sessions]
|
||||
.filter((c) => c.title === title)
|
||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0]
|
||||
|
||||
return match ? match.id : yield* oc.createSession(preview, title)
|
||||
})
|
||||
|
||||
const buildRuntimeEnv = (input?: Record<string, string>): Record<string, string> => {
|
||||
const runtimeEnv: Record<string, string> = {}
|
||||
const githubToken = config.githubToken.trim()
|
||||
if (githubToken.length > 0) {
|
||||
runtimeEnv.GH_TOKEN = githubToken
|
||||
runtimeEnv.GITHUB_TOKEN = githubToken
|
||||
}
|
||||
if (!input) return runtimeEnv
|
||||
return { ...runtimeEnv, ...input }
|
||||
}
|
||||
|
||||
/** Best-effort: record a resume failure reason and mark the session as errored. */
|
||||
const recordFailure = (threadId: ThreadId, reason: string) =>
|
||||
Effect.all(
|
||||
[
|
||||
logIgnore(store.incrementResumeFailure(threadId, reason), "incrementResumeFailure"),
|
||||
logIgnore(store.updateStatus(threadId, "error", reason), "updateStatus"),
|
||||
],
|
||||
{ discard: true },
|
||||
)
|
||||
|
||||
const restartOpenCodeServe =
|
||||
'pkill -f \'opencode serve --port 4096\' >/dev/null 2>&1 || true; for d in "$HOME/opencode" "/home/daytona/opencode" "/root/opencode"; do if [ -d "$d" ]; then cd "$d" && setsid opencode serve --port 4096 --hostname 0.0.0.0 > /tmp/opencode.log 2>&1 & exit 0; fi; done; exit 1'
|
||||
const classifySendError = (error: OpenCodeClientError): SendFailure => {
|
||||
if (error.statusCode === 404) return "session-missing"
|
||||
if (error.statusCode === 0 || error.statusCode >= 500) return "sandbox-down"
|
||||
const body = error.body.toLowerCase()
|
||||
if (body.includes("sandbox not found") || body.includes("is the sandbox started")) return "sandbox-down"
|
||||
return "non-recoverable"
|
||||
}
|
||||
|
||||
const provision = Effect.fn("SandboxProvisioner.provision")(function* (
|
||||
threadId: ThreadId,
|
||||
channelId: ChannelId,
|
||||
guildId: GuildId,
|
||||
) {
|
||||
yield* logIgnore(store.updateStatus(threadId, "creating"), "updateStatus")
|
||||
|
||||
return yield* Effect.acquireUseRelease(
|
||||
daytonaService.create({
|
||||
threadId,
|
||||
guildId,
|
||||
timeout: config.sandboxCreationTimeout,
|
||||
}),
|
||||
(handle) =>
|
||||
Effect.gen(function* () {
|
||||
const sandboxId = handle.id
|
||||
|
||||
yield* Effect.logInfo("Created sandbox").pipe(
|
||||
Effect.annotateLogs({ event: "sandbox.create.started", threadId, channelId, guildId, sandboxId }),
|
||||
)
|
||||
|
||||
const opencodeConfig: string = yield* Schema.encode(OpenCodeConfig)({
|
||||
model: config.openCodeModel,
|
||||
share: "disabled",
|
||||
permission: "allow",
|
||||
agent: { build: { mode: "primary", prompt: agentPrompt } },
|
||||
}).pipe(Effect.mapError((cause) => new ConfigEncodeError({ config: "OpenCodeConfig", cause })))
|
||||
|
||||
const authJson: string = yield* Schema.encode(OpenCodeAuth)({
|
||||
opencode: { type: "api", key: Redacted.value(config.openCodeZenApiKey) },
|
||||
}).pipe(Effect.mapError((cause) => new ConfigEncodeError({ config: "OpenCodeAuth", cause })))
|
||||
|
||||
yield* daytonaService.exec(
|
||||
sandboxId,
|
||||
"setup-opencode",
|
||||
[
|
||||
`set -e`,
|
||||
`git clone --depth=1 https://github.com/anomalyco/opencode.git $HOME/opencode`,
|
||||
`mkdir -p $HOME/.local/share/opencode`,
|
||||
`printf '%s' "$OPENCODE_AUTH_JSON" > $HOME/.local/share/opencode/auth.json`,
|
||||
`printf '%s' "$OPENCODE_CONFIG_JSON" > $HOME/opencode/opencode.json`,
|
||||
`cd $HOME/opencode`,
|
||||
`setsid opencode serve --port 4096 --hostname 0.0.0.0 > /tmp/opencode.log 2>&1 &`,
|
||||
].join("\n"),
|
||||
{
|
||||
env: buildRuntimeEnv({
|
||||
OPENCODE_AUTH_JSON: authJson,
|
||||
OPENCODE_CONFIG_JSON: opencodeConfig,
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
const healthy = yield* oc.waitForHealthy(
|
||||
PreviewAccess.from(handle),
|
||||
config.startupHealthTimeoutMs,
|
||||
)
|
||||
if (!healthy) {
|
||||
const startupLog = yield* readStartupLog(sandboxId)
|
||||
return yield* new SandboxCreateError({
|
||||
sandboxId: handle.id,
|
||||
cause: new Error(`OpenCode server did not become healthy: ${startupLog.slice(0, 400)}`),
|
||||
})
|
||||
}
|
||||
|
||||
const sessionId = yield* oc.createSession(
|
||||
PreviewAccess.from(handle),
|
||||
`Discord thread ${threadId}`,
|
||||
)
|
||||
|
||||
const session: SessionInfo = SessionInfo.make({
|
||||
threadId,
|
||||
channelId,
|
||||
guildId,
|
||||
sandboxId: handle.id,
|
||||
sessionId,
|
||||
previewUrl: handle.previewUrl,
|
||||
previewToken: handle.previewToken,
|
||||
status: "active",
|
||||
lastError: null,
|
||||
resumeFailCount: 0,
|
||||
})
|
||||
|
||||
yield* store.markHealthOk(threadId)
|
||||
|
||||
yield* Effect.logInfo("Session is ready").pipe(
|
||||
Effect.annotateLogs({ event: "sandbox.create.ready", threadId, sandboxId, sessionId }),
|
||||
)
|
||||
|
||||
return session
|
||||
}),
|
||||
(handle, exit) =>
|
||||
Exit.isFailure(exit)
|
||||
? Effect.gen(function* () {
|
||||
yield* Effect.logError("Failed to create session").pipe(
|
||||
Effect.annotateLogs({ event: "sandbox.create.failed", threadId, sandboxId: handle.id }),
|
||||
)
|
||||
yield* logIgnore(store.updateStatus(threadId, "error", "creation-failed"), "updateStatus")
|
||||
yield* daytonaService.destroy(handle.id)
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
})
|
||||
|
||||
const failed = (allowRecreate: boolean) => ResumeFailed.make({ allowRecreate })
|
||||
|
||||
const resume = (session: SessionInfo): Effect.Effect<ResumeResult> =>
|
||||
Effect.gen(function* () {
|
||||
if (!["paused", "destroyed", "error", "pausing", "resuming"].includes(session.status)) {
|
||||
return failed(true)
|
||||
}
|
||||
|
||||
yield* store.updateStatus(session.threadId, "resuming")
|
||||
|
||||
const startResult = yield* daytonaService.start(session.sandboxId, config.sandboxCreationTimeout).pipe(
|
||||
Effect.map(Option.some),
|
||||
Effect.catchTag("SandboxNotFoundError", (err) =>
|
||||
Effect.gen(function* () {
|
||||
yield* logIgnore(store.incrementResumeFailure(session.threadId, err.message), "incrementResumeFailure")
|
||||
yield* logIgnore(store.updateStatus(session.threadId, "destroyed", err.message), "updateStatus")
|
||||
return Option.none<SandboxHandle>()
|
||||
}),
|
||||
),
|
||||
Effect.catchTag("SandboxStartError", (err) =>
|
||||
recordFailure(session.threadId, String(err.cause)).pipe(Effect.as(Option.none<SandboxHandle>())),
|
||||
),
|
||||
)
|
||||
|
||||
if (Option.isNone(startResult)) return failed(true)
|
||||
|
||||
const handle = startResult.value
|
||||
|
||||
yield* logIgnore(
|
||||
daytonaService.exec(session.sandboxId, "restart-opencode-serve", restartOpenCodeServe, {
|
||||
env: buildRuntimeEnv(),
|
||||
}),
|
||||
"restart-opencode-serve",
|
||||
)
|
||||
|
||||
const preview = PreviewAccess.from(handle)
|
||||
|
||||
const healthy = yield* oc.waitForHealthy(preview, config.resumeHealthTimeoutMs)
|
||||
if (!healthy) {
|
||||
const startupLog = yield* readStartupLog(session.sandboxId, 120)
|
||||
yield* recordFailure(
|
||||
session.threadId,
|
||||
`OpenCode health check failed after resume. Log: ${startupLog.slice(0, 500)}`,
|
||||
)
|
||||
return failed(false)
|
||||
}
|
||||
|
||||
const sessionId = yield* findOrCreateSessionId(preview, session)
|
||||
|
||||
const resumed = SessionInfo.make({
|
||||
...session,
|
||||
sessionId,
|
||||
previewUrl: handle.previewUrl,
|
||||
previewToken: handle.previewToken,
|
||||
status: "active",
|
||||
})
|
||||
|
||||
yield* store.markHealthOk(session.threadId)
|
||||
|
||||
yield* Effect.logInfo("Resumed existing sandbox").pipe(
|
||||
Effect.annotateLogs({ event: "sandbox.resumed", threadId: session.threadId, sandboxId: session.sandboxId }),
|
||||
)
|
||||
|
||||
return Resumed.make({ session: resumed })
|
||||
}).pipe(
|
||||
Effect.catchAll((err) =>
|
||||
recordFailure(session.threadId, String(err)).pipe(Effect.as(failed(false))),
|
||||
),
|
||||
)
|
||||
|
||||
const ensureHealthy = Effect.fn("SandboxProvisioner.ensureHealthy")(function* (
|
||||
session: SessionInfo,
|
||||
maxWaitMs: number,
|
||||
) {
|
||||
const healthy = yield* oc.waitForHealthy(PreviewAccess.from(session), maxWaitMs)
|
||||
if (!healthy) {
|
||||
yield* recordFailure(session.threadId, "active-session-healthcheck-failed")
|
||||
return false
|
||||
}
|
||||
|
||||
const attached = yield* oc
|
||||
.sessionExists(PreviewAccess.from(session), session.sessionId)
|
||||
.pipe(Effect.catchAll(() => Effect.succeed(false)))
|
||||
if (!attached) {
|
||||
yield* recordFailure(session.threadId, "active-session-missing")
|
||||
return false
|
||||
}
|
||||
|
||||
yield* logIgnore(store.markHealthOk(session.threadId), "markHealthOk")
|
||||
return true
|
||||
})
|
||||
|
||||
const pause = Effect.fn("SandboxProvisioner.pause")(function* (
|
||||
threadId: ThreadId,
|
||||
session: SessionInfo,
|
||||
reason: string,
|
||||
) {
|
||||
if (session.status === "paused") return session
|
||||
|
||||
yield* store.updateStatus(threadId, "pausing", reason)
|
||||
|
||||
const stopped = Exit.isSuccess(yield* Effect.exit(daytonaService.stop(session.sandboxId)))
|
||||
|
||||
if (stopped) {
|
||||
yield* store.updateStatus(threadId, "paused", null)
|
||||
return session.withStatus("paused")
|
||||
}
|
||||
|
||||
yield* store.updateStatus(threadId, "destroyed", "sandbox-unavailable-during-pause")
|
||||
return session.withStatus("destroyed")
|
||||
})
|
||||
|
||||
const destroy = Effect.fn("SandboxProvisioner.destroy")(function* (
|
||||
threadId: ThreadId,
|
||||
session: SessionInfo,
|
||||
reason?: string,
|
||||
) {
|
||||
if (session.status === "destroyed") return session
|
||||
yield* store.updateStatus(threadId, "destroying", reason ?? null)
|
||||
yield* daytonaService.destroy(session.sandboxId)
|
||||
yield* store.updateStatus(threadId, "destroyed", reason ?? null)
|
||||
return session.withStatus("destroyed")
|
||||
})
|
||||
|
||||
const ensureActive = Effect.fn("SandboxProvisioner.ensureActive")(function* (input: {
|
||||
threadId: ThreadId
|
||||
channelId: ChannelId
|
||||
guildId: GuildId
|
||||
current: Option.Option<SessionInfo>
|
||||
}) {
|
||||
if (Option.isNone(input.current)) {
|
||||
return yield* provision(input.threadId, input.channelId, input.guildId)
|
||||
}
|
||||
|
||||
let candidate = input.current.value
|
||||
if (candidate.status === "active") {
|
||||
const healthy = yield* ensureHealthy(candidate, config.activeHealthCheckTimeoutMs)
|
||||
if (healthy) return candidate
|
||||
const refreshed = yield* store.getByThread(input.threadId)
|
||||
candidate = Option.isSome(refreshed) ? refreshed.value : candidate.withStatus("error")
|
||||
}
|
||||
|
||||
if (config.sandboxReusePolicy === "resume_preferred") {
|
||||
const resumed = yield* resume(candidate)
|
||||
if (resumed instanceof Resumed) return resumed.session
|
||||
if (!resumed.allowRecreate) {
|
||||
return yield* new SandboxDeadError({
|
||||
threadId: input.threadId,
|
||||
reason: "Unable to reattach to existing sandbox session. Try again shortly.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
yield* destroy(input.threadId, candidate, "recreate-after-resume-failure")
|
||||
return yield* provision(input.threadId, input.channelId, input.guildId)
|
||||
})
|
||||
|
||||
const recoverSendFailure = Effect.fn("SandboxProvisioner.recoverSendFailure")(function* (
|
||||
threadId: ThreadId,
|
||||
session: SessionInfo,
|
||||
error: OpenCodeClientError,
|
||||
) {
|
||||
const kind = classifySendError(error)
|
||||
if (kind === "non-recoverable") return session
|
||||
|
||||
yield* store.incrementResumeFailure(threadId, String(error))
|
||||
if (kind === "session-missing") {
|
||||
yield* store.updateStatus(threadId, "error", "opencode-session-missing")
|
||||
return session.withStatus("error")
|
||||
}
|
||||
|
||||
return yield* pause(threadId, session, "recoverable send failure")
|
||||
})
|
||||
|
||||
return SandboxProvisioner.of({
|
||||
provision,
|
||||
resume,
|
||||
ensureActive,
|
||||
ensureHealthy,
|
||||
recoverSendFailure,
|
||||
pause,
|
||||
destroy,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
198
packages/discord/src/sessions/store.test.ts
Normal file
198
packages/discord/src/sessions/store.test.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import type * as Client from "@effect/sql/SqlClient"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Layer, Option, Redacted } from "effect"
|
||||
import { AppConfig } from "../config"
|
||||
import { SqliteDb } from "../db/client"
|
||||
import { effectTest, withTempSqliteFile } from "../test/effect"
|
||||
import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, ThreadId } from "../types"
|
||||
import { SessionStore } from "./store"
|
||||
|
||||
const makeConfig = (databasePath: string) =>
|
||||
AppConfig.of({
|
||||
discordToken: Redacted.make("token"),
|
||||
allowedChannelIds: [],
|
||||
discordCategoryId: "",
|
||||
discordRoleId: "",
|
||||
discordRequiredRoleId: "",
|
||||
discordCommandGuildId: "",
|
||||
databasePath,
|
||||
daytonaApiKey: Redacted.make("daytona"),
|
||||
openCodeZenApiKey: Redacted.make("zen"),
|
||||
githubToken: "",
|
||||
logLevel: "info",
|
||||
healthHost: "127.0.0.1",
|
||||
healthPort: 8787,
|
||||
turnRoutingMode: "off",
|
||||
turnRoutingModel: "claude-haiku-4-5",
|
||||
sandboxReusePolicy: "resume_preferred",
|
||||
sandboxTimeout: Duration.minutes(30),
|
||||
cleanupInterval: Duration.minutes(5),
|
||||
staleActiveGraceMinutes: 5 as AppConfig.Service["staleActiveGraceMinutes"],
|
||||
pausedTtlMinutes: 180 as AppConfig.Service["pausedTtlMinutes"],
|
||||
activeHealthCheckTimeoutMs: 15000 as AppConfig.Service["activeHealthCheckTimeoutMs"],
|
||||
startupHealthTimeoutMs: 120000 as AppConfig.Service["startupHealthTimeoutMs"],
|
||||
resumeHealthTimeoutMs: 120000 as AppConfig.Service["resumeHealthTimeoutMs"],
|
||||
sandboxCreationTimeout: 180 as AppConfig.Service["sandboxCreationTimeout"],
|
||||
openCodeModel: "opencode/claude-sonnet-4-5",
|
||||
})
|
||||
|
||||
const makeSession = (threadId: string, status: "creating" | "active" | "paused" = "active") =>
|
||||
SessionInfo.make({
|
||||
threadId: ThreadId.make(threadId),
|
||||
channelId: ChannelId.make("c1"),
|
||||
guildId: GuildId.make("g1"),
|
||||
sandboxId: SandboxId.make("sb1"),
|
||||
sessionId: SessionId.make(`s-${threadId}`),
|
||||
previewUrl: `https://preview/${threadId}`,
|
||||
previewToken: null,
|
||||
status,
|
||||
lastError: null,
|
||||
resumeFailCount: 0,
|
||||
})
|
||||
|
||||
const withStore = <A, E, R>(run: (store: SessionStore.Service, sql: Client.SqlClient) => Effect.Effect<A, E, R>) =>
|
||||
withTempSqliteFile((databasePath) =>
|
||||
Effect.gen(function* () {
|
||||
const config = Layer.succeed(AppConfig, makeConfig(databasePath))
|
||||
const sqlite = SqliteDb.layer.pipe(
|
||||
Layer.provide(config),
|
||||
)
|
||||
const live = Layer.merge(
|
||||
SessionStore.layer.pipe(
|
||||
Layer.provide(sqlite),
|
||||
),
|
||||
sqlite,
|
||||
)
|
||||
|
||||
const program = Effect.all([SessionStore, SqliteDb]).pipe(
|
||||
Effect.flatMap(([store, sql]) => run(store, sql)),
|
||||
)
|
||||
return yield* program.pipe(Effect.provide(live))
|
||||
}),
|
||||
"discord-store-",
|
||||
)
|
||||
|
||||
const getTransitions = (sql: Client.SqlClient, threadId: ThreadId) =>
|
||||
sql<{
|
||||
pause_requested_at: string | null
|
||||
paused_at: string | null
|
||||
resume_attempted_at: string | null
|
||||
resumed_at: string | null
|
||||
destroyed_at: string | null
|
||||
}>`SELECT pause_requested_at, paused_at, resume_attempted_at, resumed_at, destroyed_at
|
||||
FROM discord_sessions
|
||||
WHERE thread_id = ${threadId}
|
||||
LIMIT 1`.pipe(
|
||||
Effect.map((rows) => rows[0] ?? null),
|
||||
)
|
||||
|
||||
describe("SessionStore", () => {
|
||||
effectTest("runs typed CRUD flow against sqlite", () =>
|
||||
withStore((store, sql) =>
|
||||
Effect.gen(function* () {
|
||||
const t1 = ThreadId.make("t1")
|
||||
const t2 = ThreadId.make("t2")
|
||||
|
||||
yield* store.upsert(makeSession("t1", "active"))
|
||||
expect(yield* store.hasTrackedThread(t1)).toBe(true)
|
||||
expect(Option.map(yield* store.getByThread(t1), (s) => s.threadId)).toEqual(Option.some(t1))
|
||||
expect(Option.map(yield* store.getActive(t1), (s) => s.status)).toEqual(Option.some("active"))
|
||||
|
||||
yield* store.updateStatus(t1, "paused", "pause")
|
||||
yield* store.incrementResumeFailure(t1, "resume-fail")
|
||||
expect(Option.isNone(yield* store.getActive(t1))).toBe(true)
|
||||
expect(Option.map(yield* store.getByThread(t1), (s) => s.resumeFailCount)).toEqual(Option.some(1))
|
||||
expect(Option.map(yield* store.getByThread(t1), (s) => s.lastError)).toEqual(Option.some("resume-fail"))
|
||||
|
||||
yield* store.updateStatus(t1, "active")
|
||||
yield* sql`UPDATE discord_sessions
|
||||
SET last_activity = datetime('now', '-40 minutes')
|
||||
WHERE thread_id = ${t1}`
|
||||
expect((yield* store.listStaleActive(30)).map((row) => row.threadId)).toEqual([t1])
|
||||
expect((yield* store.listStaleActive(120)).map((row) => row.threadId)).toEqual([])
|
||||
|
||||
yield* store.upsert(makeSession("t2", "creating"))
|
||||
yield* store.updateStatus(t2, "paused")
|
||||
yield* sql`UPDATE discord_sessions
|
||||
SET paused_at = datetime('now', '-40 minutes')
|
||||
WHERE thread_id = ${t2}`
|
||||
expect((yield* store.listExpiredPaused(30)).map((row) => row.threadId)).toEqual([t2])
|
||||
expect((yield* store.listExpiredPaused(120)).map((row) => row.threadId)).toEqual([])
|
||||
expect(new Set(yield* store.listTrackedThreads())).toEqual(new Set([t1, t2]))
|
||||
|
||||
yield* store.updateStatus(t2, "destroyed")
|
||||
expect(new Set(yield* store.listTrackedThreads())).toEqual(new Set([t1]))
|
||||
|
||||
const next = SessionInfo.make({
|
||||
...makeSession("t1", "active"),
|
||||
sessionId: SessionId.make("s-t1-new"),
|
||||
previewToken: "ptok",
|
||||
})
|
||||
yield* store.upsert(next)
|
||||
expect(Option.map(yield* store.getByThread(t1), (s) => s.sessionId)).toEqual(Option.some(SessionId.make("s-t1-new")))
|
||||
expect(Option.map(yield* store.getByThread(t1), (s) => s.previewToken)).toEqual(Option.some("ptok"))
|
||||
expect((yield* store.listActive()).map((row) => row.threadId)).toContain(t1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
effectTest("tracks lifecycle transition timestamps by status", () =>
|
||||
withStore((store, sql) =>
|
||||
Effect.gen(function* () {
|
||||
const t = ThreadId.make("tx")
|
||||
|
||||
yield* store.upsert(makeSession("tx", "creating"))
|
||||
|
||||
yield* store.updateStatus(t, "pausing", "queued")
|
||||
const pausing = yield* getTransitions(sql, t)
|
||||
expect(pausing).not.toBeNull()
|
||||
if (pausing === null) return
|
||||
expect(pausing.pause_requested_at).not.toBeNull()
|
||||
expect(pausing.paused_at).toBeNull()
|
||||
expect(pausing.resume_attempted_at).toBeNull()
|
||||
expect(pausing.resumed_at).toBeNull()
|
||||
expect(pausing.destroyed_at).toBeNull()
|
||||
|
||||
yield* store.updateStatus(t, "paused")
|
||||
const paused = yield* getTransitions(sql, t)
|
||||
expect(paused).not.toBeNull()
|
||||
if (paused === null) return
|
||||
expect(paused.pause_requested_at).not.toBeNull()
|
||||
expect(paused.paused_at).not.toBeNull()
|
||||
expect(paused.resume_attempted_at).toBeNull()
|
||||
expect(paused.resumed_at).toBeNull()
|
||||
expect(paused.destroyed_at).toBeNull()
|
||||
|
||||
yield* store.updateStatus(t, "resuming")
|
||||
const resuming = yield* getTransitions(sql, t)
|
||||
expect(resuming).not.toBeNull()
|
||||
if (resuming === null) return
|
||||
expect(resuming.pause_requested_at).not.toBeNull()
|
||||
expect(resuming.paused_at).not.toBeNull()
|
||||
expect(resuming.resume_attempted_at).not.toBeNull()
|
||||
expect(resuming.resumed_at).toBeNull()
|
||||
expect(resuming.destroyed_at).toBeNull()
|
||||
|
||||
yield* store.updateStatus(t, "active")
|
||||
const active = yield* getTransitions(sql, t)
|
||||
expect(active).not.toBeNull()
|
||||
if (active === null) return
|
||||
expect(active.pause_requested_at).not.toBeNull()
|
||||
expect(active.paused_at).not.toBeNull()
|
||||
expect(active.resume_attempted_at).not.toBeNull()
|
||||
expect(active.resumed_at).not.toBeNull()
|
||||
expect(active.destroyed_at).toBeNull()
|
||||
|
||||
yield* store.updateStatus(t, "destroyed")
|
||||
const destroyed = yield* getTransitions(sql, t)
|
||||
expect(destroyed).not.toBeNull()
|
||||
if (destroyed === null) return
|
||||
expect(destroyed.pause_requested_at).not.toBeNull()
|
||||
expect(destroyed.paused_at).not.toBeNull()
|
||||
expect(destroyed.resume_attempted_at).not.toBeNull()
|
||||
expect(destroyed.resumed_at).not.toBeNull()
|
||||
expect(destroyed.destroyed_at).not.toBeNull()
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,321 +1,268 @@
|
|||
import { getDb } from "../db/client"
|
||||
import type { SessionInfo, SessionStatus } from "../types"
|
||||
import * as Client from "@effect/sql/SqlClient"
|
||||
import * as SqlSchema from "@effect/sql/SqlSchema"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { SqliteDb } from "../db/client"
|
||||
import { initializeSchema } from "../db/init"
|
||||
import { DatabaseError } from "../errors"
|
||||
import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, SessionStatus, ThreadId } from "../types"
|
||||
|
||||
type SessionRow = {
|
||||
thread_id: string
|
||||
channel_id: string
|
||||
guild_id: string
|
||||
sandbox_id: string
|
||||
session_id: string
|
||||
preview_url: string
|
||||
preview_token: string | null
|
||||
status: SessionStatus
|
||||
last_error: string | null
|
||||
resume_fail_count: number
|
||||
}
|
||||
const ROW = `thread_id AS threadId, channel_id AS channelId, guild_id AS guildId, sandbox_id AS sandboxId, session_id AS sessionId,
|
||||
preview_url AS previewUrl, preview_token AS previewToken, status, last_error AS lastError, resume_fail_count AS resumeFailCount`
|
||||
|
||||
export interface SessionStore {
|
||||
upsert(session: SessionInfo): Promise<void>
|
||||
getByThread(threadId: string): Promise<SessionInfo | null>
|
||||
hasTrackedThread(threadId: string): Promise<boolean>
|
||||
getActive(threadId: string): Promise<SessionInfo | null>
|
||||
markActivity(threadId: string): Promise<void>
|
||||
markHealthOk(threadId: string): Promise<void>
|
||||
updateStatus(threadId: string, status: SessionStatus, lastError?: string | null): Promise<void>
|
||||
incrementResumeFailure(threadId: string, lastError: string): Promise<void>
|
||||
listActive(): Promise<SessionInfo[]>
|
||||
listStaleActive(cutoffMinutes: number): Promise<SessionInfo[]>
|
||||
listExpiredPaused(pausedTtlMinutes: number): Promise<SessionInfo[]>
|
||||
}
|
||||
class Write extends Schema.Class<Write>("Write")({
|
||||
thread_id: ThreadId,
|
||||
channel_id: ChannelId,
|
||||
guild_id: GuildId,
|
||||
sandbox_id: SandboxId,
|
||||
session_id: SessionId,
|
||||
preview_url: Schema.String,
|
||||
preview_token: Schema.Union(Schema.Null, Schema.String),
|
||||
status: SessionStatus,
|
||||
last_error: Schema.Union(Schema.Null, Schema.String),
|
||||
}) {}
|
||||
|
||||
class SqliteSessionStore implements SessionStore {
|
||||
private readonly db = getDb()
|
||||
const Thread = Schema.Struct({ thread_id: ThreadId })
|
||||
const Status = Schema.Struct({
|
||||
thread_id: ThreadId,
|
||||
status: SessionStatus,
|
||||
last_error: Schema.Union(Schema.Null, Schema.String),
|
||||
})
|
||||
const Resume = Schema.Struct({ thread_id: ThreadId, last_error: Schema.String })
|
||||
const Minutes = Schema.Struct({ minutes: Schema.Number })
|
||||
|
||||
async upsert(session: SessionInfo): Promise<void> {
|
||||
this.db
|
||||
.query(
|
||||
`
|
||||
INSERT INTO discord_sessions (
|
||||
thread_id,
|
||||
channel_id,
|
||||
guild_id,
|
||||
sandbox_id,
|
||||
session_id,
|
||||
preview_url,
|
||||
preview_token,
|
||||
status,
|
||||
last_error,
|
||||
last_activity,
|
||||
resumed_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
CURRENT_TIMESTAMP,
|
||||
CASE WHEN ? = 'active' THEN CURRENT_TIMESTAMP ELSE NULL END,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
ON CONFLICT(thread_id)
|
||||
DO UPDATE SET
|
||||
channel_id = excluded.channel_id,
|
||||
guild_id = excluded.guild_id,
|
||||
sandbox_id = excluded.sandbox_id,
|
||||
session_id = excluded.session_id,
|
||||
preview_url = excluded.preview_url,
|
||||
preview_token = excluded.preview_token,
|
||||
status = excluded.status,
|
||||
last_error = excluded.last_error,
|
||||
last_activity = CURRENT_TIMESTAMP,
|
||||
resumed_at = CASE WHEN excluded.status = 'active' THEN CURRENT_TIMESTAMP ELSE discord_sessions.resumed_at END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
session.threadId,
|
||||
session.channelId,
|
||||
session.guildId,
|
||||
session.sandboxId,
|
||||
session.sessionId,
|
||||
session.previewUrl,
|
||||
session.previewToken,
|
||||
session.status,
|
||||
session.lastError ?? null,
|
||||
session.status,
|
||||
)
|
||||
}
|
||||
const STATUS_COLUMNS = [
|
||||
["pausing", "pause_requested_at"],
|
||||
["paused", "paused_at"],
|
||||
["resuming", "resume_attempted_at"],
|
||||
["active", "resumed_at"],
|
||||
["destroyed", "destroyed_at"],
|
||||
] as const
|
||||
|
||||
async getByThread(threadId: string): Promise<SessionInfo | null> {
|
||||
const row = this.db
|
||||
.query(
|
||||
`
|
||||
SELECT
|
||||
thread_id,
|
||||
channel_id,
|
||||
guild_id,
|
||||
sandbox_id,
|
||||
session_id,
|
||||
preview_url,
|
||||
preview_token,
|
||||
status,
|
||||
last_error,
|
||||
resume_fail_count
|
||||
FROM discord_sessions
|
||||
WHERE thread_id = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(threadId) as SessionRow | null
|
||||
const toWrite = (session: SessionInfo) =>
|
||||
Write.make({
|
||||
thread_id: session.threadId,
|
||||
channel_id: session.channelId,
|
||||
guild_id: session.guildId,
|
||||
sandbox_id: session.sandboxId,
|
||||
session_id: session.sessionId,
|
||||
preview_url: session.previewUrl,
|
||||
preview_token: session.previewToken,
|
||||
status: session.status,
|
||||
last_error: session.lastError,
|
||||
})
|
||||
|
||||
if (!row) return null
|
||||
return toSessionInfo(row)
|
||||
}
|
||||
|
||||
async hasTrackedThread(threadId: string): Promise<boolean> {
|
||||
const row = this.db
|
||||
.query(
|
||||
`
|
||||
SELECT thread_id
|
||||
FROM discord_sessions
|
||||
WHERE thread_id = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(threadId) as { thread_id: string } | null
|
||||
|
||||
return Boolean(row)
|
||||
}
|
||||
|
||||
async getActive(threadId: string): Promise<SessionInfo | null> {
|
||||
const row = this.db
|
||||
.query(
|
||||
`
|
||||
SELECT
|
||||
thread_id,
|
||||
channel_id,
|
||||
guild_id,
|
||||
sandbox_id,
|
||||
session_id,
|
||||
preview_url,
|
||||
preview_token,
|
||||
status,
|
||||
last_error,
|
||||
resume_fail_count
|
||||
FROM discord_sessions
|
||||
WHERE thread_id = ?
|
||||
AND status = 'active'
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(threadId) as SessionRow | null
|
||||
|
||||
if (!row) return null
|
||||
return toSessionInfo(row)
|
||||
}
|
||||
|
||||
async markActivity(threadId: string): Promise<void> {
|
||||
this.db
|
||||
.query(
|
||||
`
|
||||
UPDATE discord_sessions
|
||||
SET last_activity = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE thread_id = ?
|
||||
`,
|
||||
)
|
||||
.run(threadId)
|
||||
}
|
||||
|
||||
async markHealthOk(threadId: string): Promise<void> {
|
||||
this.db
|
||||
.query(
|
||||
`
|
||||
UPDATE discord_sessions
|
||||
SET last_health_ok_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE thread_id = ?
|
||||
`,
|
||||
)
|
||||
.run(threadId)
|
||||
}
|
||||
|
||||
async updateStatus(threadId: string, status: SessionStatus, lastError?: string | null): Promise<void> {
|
||||
this.db
|
||||
.query(
|
||||
`
|
||||
UPDATE discord_sessions
|
||||
SET
|
||||
status = ?,
|
||||
last_error = ?,
|
||||
pause_requested_at = CASE WHEN ? = 'pausing' THEN CURRENT_TIMESTAMP ELSE pause_requested_at END,
|
||||
paused_at = CASE WHEN ? = 'paused' THEN CURRENT_TIMESTAMP ELSE paused_at END,
|
||||
resume_attempted_at = CASE WHEN ? = 'resuming' THEN CURRENT_TIMESTAMP ELSE resume_attempted_at END,
|
||||
resumed_at = CASE WHEN ? = 'active' THEN CURRENT_TIMESTAMP ELSE resumed_at END,
|
||||
destroyed_at = CASE WHEN ? = 'destroyed' THEN CURRENT_TIMESTAMP ELSE destroyed_at END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE thread_id = ?
|
||||
`,
|
||||
)
|
||||
.run(status, lastError ?? null, status, status, status, status, status, threadId)
|
||||
}
|
||||
|
||||
async incrementResumeFailure(threadId: string, lastError: string): Promise<void> {
|
||||
this.db
|
||||
.query(
|
||||
`
|
||||
UPDATE discord_sessions
|
||||
SET
|
||||
resume_fail_count = resume_fail_count + 1,
|
||||
last_error = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE thread_id = ?
|
||||
`,
|
||||
)
|
||||
.run(lastError, threadId)
|
||||
}
|
||||
|
||||
async listActive(): Promise<SessionInfo[]> {
|
||||
const rows = this.db
|
||||
.query(
|
||||
`
|
||||
SELECT
|
||||
thread_id,
|
||||
channel_id,
|
||||
guild_id,
|
||||
sandbox_id,
|
||||
session_id,
|
||||
preview_url,
|
||||
preview_token,
|
||||
status,
|
||||
last_error,
|
||||
resume_fail_count
|
||||
FROM discord_sessions
|
||||
WHERE status = 'active'
|
||||
ORDER BY last_activity DESC
|
||||
`,
|
||||
)
|
||||
.all() as SessionRow[]
|
||||
|
||||
return rows.map(toSessionInfo)
|
||||
}
|
||||
|
||||
async listStaleActive(cutoffMinutes: number): Promise<SessionInfo[]> {
|
||||
const rows = this.db
|
||||
.query(
|
||||
`
|
||||
SELECT
|
||||
thread_id,
|
||||
channel_id,
|
||||
guild_id,
|
||||
sandbox_id,
|
||||
session_id,
|
||||
preview_url,
|
||||
preview_token,
|
||||
status,
|
||||
last_error,
|
||||
resume_fail_count
|
||||
FROM discord_sessions
|
||||
WHERE status = 'active'
|
||||
AND last_activity < datetime('now', '-' || ? || ' minutes')
|
||||
ORDER BY last_activity ASC
|
||||
`,
|
||||
)
|
||||
.all(cutoffMinutes) as SessionRow[]
|
||||
|
||||
return rows.map(toSessionInfo)
|
||||
}
|
||||
|
||||
async listExpiredPaused(pausedTtlMinutes: number): Promise<SessionInfo[]> {
|
||||
const rows = this.db
|
||||
.query(
|
||||
`
|
||||
SELECT
|
||||
thread_id,
|
||||
channel_id,
|
||||
guild_id,
|
||||
sandbox_id,
|
||||
session_id,
|
||||
preview_url,
|
||||
preview_token,
|
||||
status,
|
||||
last_error,
|
||||
resume_fail_count
|
||||
FROM discord_sessions
|
||||
WHERE status = 'paused'
|
||||
AND paused_at IS NOT NULL
|
||||
AND paused_at < datetime('now', '-' || ? || ' minutes')
|
||||
ORDER BY paused_at ASC
|
||||
`,
|
||||
)
|
||||
.all(pausedTtlMinutes) as SessionRow[]
|
||||
|
||||
return rows.map(toSessionInfo)
|
||||
export declare namespace SessionStore {
|
||||
export interface Service {
|
||||
readonly upsert: (session: SessionInfo) => Effect.Effect<void, DatabaseError>
|
||||
readonly getByThread: (threadId: ThreadId) => Effect.Effect<Option.Option<SessionInfo>, DatabaseError>
|
||||
readonly hasTrackedThread: (threadId: ThreadId) => Effect.Effect<boolean, DatabaseError>
|
||||
readonly getActive: (threadId: ThreadId) => Effect.Effect<Option.Option<SessionInfo>, DatabaseError>
|
||||
readonly markActivity: (threadId: ThreadId) => Effect.Effect<void, DatabaseError>
|
||||
readonly markHealthOk: (threadId: ThreadId) => Effect.Effect<void, DatabaseError>
|
||||
readonly updateStatus: (threadId: ThreadId, status: SessionStatus, lastError?: string | null) => Effect.Effect<void, DatabaseError>
|
||||
readonly incrementResumeFailure: (threadId: ThreadId, lastError: string) => Effect.Effect<void, DatabaseError>
|
||||
readonly listActive: () => Effect.Effect<ReadonlyArray<SessionInfo>, DatabaseError>
|
||||
readonly listTrackedThreads: () => Effect.Effect<ReadonlyArray<ThreadId>, DatabaseError>
|
||||
readonly listStaleActive: (cutoffMinutes: number) => Effect.Effect<ReadonlyArray<SessionInfo>, DatabaseError>
|
||||
readonly listExpiredPaused: (pausedTtlMinutes: number) => Effect.Effect<ReadonlyArray<SessionInfo>, DatabaseError>
|
||||
}
|
||||
}
|
||||
|
||||
function toSessionInfo(row: SessionRow): SessionInfo {
|
||||
return {
|
||||
threadId: row.thread_id,
|
||||
channelId: row.channel_id,
|
||||
guildId: row.guild_id,
|
||||
sandboxId: row.sandbox_id,
|
||||
sessionId: row.session_id,
|
||||
previewUrl: row.preview_url,
|
||||
previewToken: row.preview_token,
|
||||
status: row.status,
|
||||
lastError: row.last_error,
|
||||
resumeFailCount: row.resume_fail_count,
|
||||
}
|
||||
}
|
||||
export class SessionStore extends Context.Tag("@discord/SessionStore")<SessionStore, SessionStore.Service>() {
|
||||
static readonly layer = Layer.effect(
|
||||
SessionStore,
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqliteDb
|
||||
const db = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(Effect.mapError((cause) => new DatabaseError({ cause })))
|
||||
|
||||
const sessionStore: SessionStore = new SqliteSessionStore()
|
||||
yield* db(initializeSchema.pipe(Effect.provideService(Client.SqlClient, sql)))
|
||||
|
||||
export function getSessionStore(): SessionStore {
|
||||
return sessionStore
|
||||
const statusSet = (status: SessionStatus) =>
|
||||
sql.join(",\n", false)(STATUS_COLUMNS.map(([value, column]) =>
|
||||
sql`${sql(column)} = CASE WHEN ${status} = ${value} THEN CURRENT_TIMESTAMP ELSE ${sql(column)} END`
|
||||
))
|
||||
|
||||
const touch = (column: "last_activity" | "last_health_ok_at") =>
|
||||
SqlSchema.void({
|
||||
Request: Thread,
|
||||
execute: ({ thread_id }) =>
|
||||
sql`UPDATE discord_sessions
|
||||
SET ${sql(column)} = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE thread_id = ${thread_id}`,
|
||||
})
|
||||
|
||||
const upsertQ = SqlSchema.void({
|
||||
Request: Write,
|
||||
execute: (session) =>
|
||||
sql`INSERT INTO discord_sessions (
|
||||
thread_id, channel_id, guild_id, sandbox_id, session_id,
|
||||
preview_url, preview_token, status, last_error,
|
||||
last_activity, resumed_at, created_at, updated_at
|
||||
) VALUES (
|
||||
${session.thread_id}, ${session.channel_id}, ${session.guild_id}, ${session.sandbox_id}, ${session.session_id},
|
||||
${session.preview_url}, ${session.preview_token}, ${session.status}, ${session.last_error},
|
||||
CURRENT_TIMESTAMP,
|
||||
CASE WHEN ${session.status} = 'active' THEN CURRENT_TIMESTAMP ELSE NULL END,
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
)
|
||||
ON CONFLICT(thread_id) DO UPDATE SET
|
||||
channel_id = excluded.channel_id,
|
||||
guild_id = excluded.guild_id,
|
||||
sandbox_id = excluded.sandbox_id,
|
||||
session_id = excluded.session_id,
|
||||
preview_url = excluded.preview_url,
|
||||
preview_token = excluded.preview_token,
|
||||
status = excluded.status,
|
||||
last_error = excluded.last_error,
|
||||
last_activity = CURRENT_TIMESTAMP,
|
||||
resumed_at = CASE WHEN excluded.status = 'active' THEN CURRENT_TIMESTAMP ELSE discord_sessions.resumed_at END,
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
})
|
||||
|
||||
const byThreadQ = SqlSchema.findOne({
|
||||
Request: Thread,
|
||||
Result: SessionInfo,
|
||||
execute: ({ thread_id }) => sql`SELECT ${sql.literal(ROW)} FROM discord_sessions WHERE thread_id = ${thread_id} LIMIT 1`,
|
||||
})
|
||||
|
||||
const activeQ = SqlSchema.findOne({
|
||||
Request: Thread,
|
||||
Result: SessionInfo,
|
||||
execute: ({ thread_id }) =>
|
||||
sql`SELECT ${sql.literal(ROW)} FROM discord_sessions WHERE thread_id = ${thread_id} AND status = 'active' LIMIT 1`,
|
||||
})
|
||||
|
||||
const markActivityQ = touch("last_activity")
|
||||
|
||||
const markHealthOkQ = touch("last_health_ok_at")
|
||||
|
||||
const updateStatusQ = SqlSchema.void({
|
||||
Request: Status,
|
||||
execute: ({ thread_id, status, last_error }) =>
|
||||
sql`UPDATE discord_sessions SET
|
||||
status = ${status}, last_error = ${last_error},
|
||||
${statusSet(status)},
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE thread_id = ${thread_id}`,
|
||||
})
|
||||
|
||||
const incrementResumeFailureQ = SqlSchema.void({
|
||||
Request: Resume,
|
||||
execute: ({ thread_id, last_error }) =>
|
||||
sql`UPDATE discord_sessions SET
|
||||
resume_fail_count = resume_fail_count + 1, last_error = ${last_error}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE thread_id = ${thread_id}`,
|
||||
})
|
||||
|
||||
const listActiveQ = SqlSchema.findAll({
|
||||
Request: Schema.Void,
|
||||
Result: SessionInfo,
|
||||
execute: () =>
|
||||
sql`SELECT ${sql.literal(ROW)}
|
||||
FROM discord_sessions
|
||||
WHERE status = 'active'
|
||||
ORDER BY last_activity DESC`,
|
||||
})
|
||||
|
||||
const listTrackedThreadsQ = SqlSchema.findAll({
|
||||
Request: Schema.Void,
|
||||
Result: Schema.Struct({ threadId: ThreadId }),
|
||||
execute: () =>
|
||||
sql`SELECT thread_id AS threadId
|
||||
FROM discord_sessions
|
||||
WHERE status != 'destroyed'
|
||||
ORDER BY updated_at DESC`,
|
||||
})
|
||||
|
||||
const listStaleActiveQ = SqlSchema.findAll({
|
||||
Request: Minutes,
|
||||
Result: SessionInfo,
|
||||
execute: ({ minutes }) =>
|
||||
sql`SELECT ${sql.literal(ROW)}
|
||||
FROM discord_sessions
|
||||
WHERE status = 'active' AND last_activity < datetime('now', '-' || ${minutes} || ' minutes')
|
||||
ORDER BY last_activity ASC`,
|
||||
})
|
||||
|
||||
const listExpiredPausedQ = SqlSchema.findAll({
|
||||
Request: Minutes,
|
||||
Result: SessionInfo,
|
||||
execute: ({ minutes }) =>
|
||||
sql`SELECT ${sql.literal(ROW)}
|
||||
FROM discord_sessions
|
||||
WHERE status = 'paused' AND paused_at IS NOT NULL
|
||||
AND paused_at < datetime('now', '-' || ${minutes} || ' minutes')
|
||||
ORDER BY paused_at ASC`,
|
||||
})
|
||||
|
||||
const upsert = Effect.fn("SessionStore.upsert")(function* (session: SessionInfo) {
|
||||
yield* db(upsertQ(toWrite(session)))
|
||||
})
|
||||
|
||||
const getByThread = Effect.fn("SessionStore.getByThread")(function* (threadId: ThreadId) {
|
||||
return yield* db(byThreadQ({ thread_id: threadId }))
|
||||
})
|
||||
|
||||
const hasTrackedThread = Effect.fn("SessionStore.hasTrackedThread")(function* (threadId: ThreadId) {
|
||||
const row = yield* db(byThreadQ({ thread_id: threadId }))
|
||||
return Option.isSome(row)
|
||||
})
|
||||
|
||||
const getActive = Effect.fn("SessionStore.getActive")(function* (threadId: ThreadId) {
|
||||
return yield* db(activeQ({ thread_id: threadId }))
|
||||
})
|
||||
|
||||
const markActivity = Effect.fn("SessionStore.markActivity")(function* (threadId: ThreadId) {
|
||||
yield* db(markActivityQ({ thread_id: threadId }))
|
||||
})
|
||||
|
||||
const markHealthOk = Effect.fn("SessionStore.markHealthOk")(function* (threadId: ThreadId) {
|
||||
yield* db(markHealthOkQ({ thread_id: threadId }))
|
||||
})
|
||||
|
||||
const updateStatus = Effect.fn("SessionStore.updateStatus")(function* (threadId: ThreadId, status: SessionStatus, lastError?: string | null) {
|
||||
yield* db(updateStatusQ({ thread_id: threadId, status, last_error: lastError ?? null }))
|
||||
})
|
||||
|
||||
const incrementResumeFailure = Effect.fn("SessionStore.incrementResumeFailure")(function* (threadId: ThreadId, lastError: string) {
|
||||
yield* db(incrementResumeFailureQ({ thread_id: threadId, last_error: lastError }))
|
||||
})
|
||||
|
||||
const listActive = Effect.fn("SessionStore.listActive")(function* () {
|
||||
return yield* db(listActiveQ(undefined))
|
||||
})
|
||||
|
||||
const listTrackedThreads = Effect.fn("SessionStore.listTrackedThreads")(function* () {
|
||||
return (yield* db(listTrackedThreadsQ(undefined))).map((row) => row.threadId)
|
||||
})
|
||||
|
||||
const listStaleActive = Effect.fn("SessionStore.listStaleActive")(function* (cutoffMinutes: number) {
|
||||
return yield* db(listStaleActiveQ({ minutes: cutoffMinutes }))
|
||||
})
|
||||
|
||||
const listExpiredPaused = Effect.fn("SessionStore.listExpiredPaused")(function* (pausedTtlMinutes: number) {
|
||||
return yield* db(listExpiredPausedQ({ minutes: pausedTtlMinutes }))
|
||||
})
|
||||
|
||||
return SessionStore.of({
|
||||
upsert,
|
||||
getByThread,
|
||||
hasTrackedThread,
|
||||
getActive,
|
||||
markActivity,
|
||||
markHealthOk,
|
||||
updateStatus,
|
||||
incrementResumeFailure,
|
||||
listActive,
|
||||
listTrackedThreads,
|
||||
listStaleActive,
|
||||
listExpiredPaused,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
static readonly defaultLayer = SessionStore.layer.pipe(
|
||||
Layer.provide(SqliteDb.layer),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
65
packages/discord/src/test/effect.ts
Normal file
65
packages/discord/src/test/effect.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Reactivity } from "@effect/experimental"
|
||||
import * as FileSystem from "@effect/platform/FileSystem"
|
||||
import { BunFileSystem } from "@effect/platform-bun"
|
||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||
import type * as Client from "@effect/sql/SqlClient"
|
||||
import { test } from "bun:test"
|
||||
import { Duration, Effect, Layer, Redacted } from "effect"
|
||||
import type { Scope } from "effect/Scope"
|
||||
import type { TestOptions } from "bun:test"
|
||||
import { AppConfig, Milliseconds, Minutes, Seconds } from "../config"
|
||||
|
||||
export const effectTest = (
|
||||
name: string,
|
||||
run: () => Effect.Effect<unknown, unknown, Scope | never>,
|
||||
options?: number | TestOptions,
|
||||
) =>
|
||||
test(name, () => Effect.runPromise(run().pipe(Effect.scoped)), options)
|
||||
|
||||
export const withSqlite = <A, E, R>(filename: string, run: (db: Client.SqlClient) => Effect.Effect<A, E, R>) =>
|
||||
SqliteClient.make({ filename }).pipe(
|
||||
Effect.provide(Reactivity.layer),
|
||||
Effect.flatMap(run),
|
||||
Effect.scoped,
|
||||
)
|
||||
|
||||
export const withTempSqliteFile = <A, E, R>(
|
||||
run: (filename: string) => Effect.Effect<A, E, R>,
|
||||
prefix = "discord-test-",
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const filename = yield* fs.makeTempFileScoped({ prefix, suffix: ".sqlite" })
|
||||
return yield* run(filename)
|
||||
}).pipe(Effect.provide(BunFileSystem.layer))
|
||||
|
||||
export const testConfigLayer = Layer.succeed(
|
||||
AppConfig,
|
||||
AppConfig.of({
|
||||
discordToken: Redacted.make("test"),
|
||||
allowedChannelIds: [],
|
||||
discordCategoryId: "",
|
||||
discordRoleId: "",
|
||||
discordRequiredRoleId: "",
|
||||
discordCommandGuildId: "",
|
||||
databasePath: ":memory:",
|
||||
daytonaApiKey: Redacted.make("test"),
|
||||
openCodeZenApiKey: Redacted.make("test"),
|
||||
githubToken: "",
|
||||
logLevel: "info" as const,
|
||||
healthHost: "0.0.0.0",
|
||||
healthPort: 8787,
|
||||
turnRoutingMode: "off" as const,
|
||||
turnRoutingModel: "test",
|
||||
sandboxReusePolicy: "resume_preferred" as const,
|
||||
sandboxTimeout: Duration.minutes(30),
|
||||
cleanupInterval: Duration.minutes(5),
|
||||
staleActiveGraceMinutes: Minutes.make(5),
|
||||
pausedTtlMinutes: Minutes.make(180),
|
||||
activeHealthCheckTimeoutMs: Milliseconds.make(15000),
|
||||
startupHealthTimeoutMs: Milliseconds.make(120000),
|
||||
resumeHealthTimeoutMs: Milliseconds.make(120000),
|
||||
sandboxCreationTimeout: Seconds.make(180),
|
||||
openCodeModel: "opencode/claude-sonnet-4-5",
|
||||
}),
|
||||
)
|
||||
|
|
@ -1,22 +1,63 @@
|
|||
export type SessionStatus =
|
||||
| "creating"
|
||||
| "active"
|
||||
| "pausing"
|
||||
| "paused"
|
||||
| "resuming"
|
||||
| "destroying"
|
||||
| "destroyed"
|
||||
| "error";
|
||||
import { Schema } from "effect"
|
||||
|
||||
export interface SessionInfo {
|
||||
threadId: string;
|
||||
channelId: string;
|
||||
guildId: string;
|
||||
sandboxId: string;
|
||||
sessionId: string;
|
||||
previewUrl: string;
|
||||
previewToken: string | null;
|
||||
status: SessionStatus;
|
||||
lastError?: string | null;
|
||||
resumeFailCount?: number;
|
||||
export const ThreadId = Schema.String.pipe(Schema.brand("ThreadId"))
|
||||
export type ThreadId = typeof ThreadId.Type
|
||||
|
||||
export const ChannelId = Schema.String.pipe(Schema.brand("ChannelId"))
|
||||
export type ChannelId = typeof ChannelId.Type
|
||||
|
||||
export const GuildId = Schema.String.pipe(Schema.brand("GuildId"))
|
||||
export type GuildId = typeof GuildId.Type
|
||||
|
||||
export const SandboxId = Schema.String.pipe(Schema.brand("SandboxId"))
|
||||
export type SandboxId = typeof SandboxId.Type
|
||||
|
||||
export const SessionId = Schema.String.pipe(Schema.brand("SessionId"))
|
||||
export type SessionId = typeof SessionId.Type
|
||||
|
||||
export const SessionStatus = Schema.Literal(
|
||||
"creating",
|
||||
"active",
|
||||
"pausing",
|
||||
"paused",
|
||||
"resuming",
|
||||
"destroying",
|
||||
"destroyed",
|
||||
"error",
|
||||
)
|
||||
export type SessionStatus = typeof SessionStatus.Type
|
||||
|
||||
/**
|
||||
* Daytona preview link — the URL + token from `sandbox.getPreviewLink()`.
|
||||
*
|
||||
* This is Daytona's canonical way to reach a port inside a sandbox over HTTP.
|
||||
* Used by {@link OpenCodeClient} to talk to the OpenCode server on port 4096.
|
||||
*/
|
||||
export class PreviewAccess extends Schema.Class<PreviewAccess>("PreviewAccess")({
|
||||
/** Daytona preview URL (HTTP tunnel into the sandbox). */
|
||||
previewUrl: Schema.String,
|
||||
/** Auth token for the preview link. May be embedded in the URL as `?tkn=`. */
|
||||
previewToken: Schema.Union(Schema.Null, Schema.String),
|
||||
}) {
|
||||
/** Derive from anything carrying `previewUrl` + `previewToken` (e.g. SandboxHandle, SessionInfo). */
|
||||
static from(source: { previewUrl: string; previewToken: string | null }) {
|
||||
return PreviewAccess.make({ previewUrl: source.previewUrl, previewToken: source.previewToken })
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionInfo extends Schema.Class<SessionInfo>("SessionInfo")({
|
||||
threadId: ThreadId,
|
||||
channelId: ChannelId,
|
||||
guildId: GuildId,
|
||||
sandboxId: SandboxId,
|
||||
sessionId: SessionId,
|
||||
previewUrl: Schema.String,
|
||||
previewToken: Schema.Union(Schema.Null, Schema.String),
|
||||
status: SessionStatus,
|
||||
lastError: Schema.Union(Schema.Null, Schema.String),
|
||||
resumeFailCount: Schema.Number,
|
||||
}) {
|
||||
withStatus(status: SessionStatus) {
|
||||
return SessionInfo.make({ ...this, status })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"moduleDetection": "force",
|
||||
"strict": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noUnusedLocals": true,
|
||||
"noImplicitOverride": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"types": ["bun-types", "node"]
|
||||
"types": ["bun"],
|
||||
"plugins": [{ "name": "@effect/language-service" }]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue