diff --git a/bun.lock b/bun.lock index ec7e141e87..34bdcf98db 100644 --- a/bun.lock +++ b/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=="], diff --git a/packages/discord/.env.example b/packages/discord/.env.example index 9b7b870931..dbef3f0f78 100644 --- a/packages/discord/.env.example +++ b/packages/discord/.env.example @@ -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= diff --git a/packages/discord/AGENTS.md b/packages/discord/AGENTS.md deleted file mode 100644 index db9ad8b394..0000000000 --- a/packages/discord/AGENTS.md +++ /dev/null @@ -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//` -- 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 `) 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. diff --git a/packages/discord/AGENTS.md b/packages/discord/AGENTS.md new file mode 120000 index 0000000000..681311eb9c --- /dev/null +++ b/packages/discord/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/packages/discord/CLAUDE.md b/packages/discord/CLAUDE.md new file mode 100644 index 0000000000..7e5b243b2d --- /dev/null +++ b/packages/discord/CLAUDE.md @@ -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/")` +- 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`) + +## 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` 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 ` + - `logs --thread [--lines 120]` + - `pause --thread ` + - `destroy --thread ` + - `resume --thread [--channel --guild ]` + - `restart --thread ` + - `send --thread --text "" [--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 diff --git a/packages/discord/README.md b/packages/discord/README.md index 73ef40d41c..5d88a00ded 100644 --- a/packages/discord/README.md +++ b/packages/discord/README.md @@ -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. diff --git a/packages/discord/package.json b/packages/discord/package.json index 90d711d57b..00cc3c9095 100644 --- a/packages/discord/package.json +++ b/packages/discord/package.json @@ -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" diff --git a/packages/discord/src/agent-prompt.md b/packages/discord/src/agent-prompt.md index da5c77f11b..0be5561822 100644 --- a/packages/discord/src/agent-prompt.md +++ b/packages/discord/src/agent-prompt.md @@ -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. diff --git a/packages/discord/src/config.test.ts b/packages/discord/src/config.test.ts new file mode 100644 index 0000000000..93a14fd71c --- /dev/null +++ b/packages/discord/src/config.test.ts @@ -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) => + ConfigProvider.fromMap( + new Map([ + ["DISCORD_TOKEN", "discord-token"], + ["DAYTONA_API_KEY", "daytona-token"], + ["OPENCODE_ZEN_API_KEY", "zen-token"], + ...(input ?? []), + ]), + ) + +const load = (input?: ReadonlyArray) => + 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) + }), + ) +}) diff --git a/packages/discord/src/config.ts b/packages/discord/src/config.ts index 4bd6ebc0b0..5f454914b4 100644 --- a/packages/discord/src/config.ts +++ b/packages/discord/src/config.ts @@ -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 - -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 + 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")() { + 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)), + ) + 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) } diff --git a/packages/discord/src/conversation/README.md b/packages/discord/src/conversation/README.md new file mode 100644 index 0000000000..cde6733619 --- /dev/null +++ b/packages/discord/src/conversation/README.md @@ -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 ` + - `logs --thread [--lines 120]` + - `pause --thread ` + - `destroy --thread ` + - `resume --thread [--channel --guild ]` + - `restart --thread ` + - `send --thread --text "" [--follow --wait-ms 180000 --logs-every-ms 2000 --lines 80]` diff --git a/packages/discord/src/conversation/control/cli.ts b/packages/discord/src/conversation/control/cli.ts new file mode 100644 index 0000000000..2374ca0008 --- /dev/null +++ b/packages/discord/src/conversation/control/cli.ts @@ -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() + yield* Effect.addFinalizer(() => + Effect.sync(() => { + rl.close() + }), + ) + + const draw = (line: string, keep = true): Effect.Effect => + 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 => draw(stamp("info", colors.blue, text), false) + + const block = (head: string, body: string): Effect.Effect => + Effect.sync(() => { + stdout.write(`\r\x1b[2K${head}\n${body}\n`) + stdout.write(`${prompt(scope)}${rl.line}`) + }) + + const noteThread = (thread_id: ThreadId): Effect.Effect => + 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 ${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 ${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 ${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 ${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 ${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 ${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 ${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 ${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 = 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, +) diff --git a/packages/discord/src/conversation/control/controller.ts b/packages/discord/src/conversation/control/controller.ts new file mode 100644 index 0000000000..e195a770e8 --- /dev/null +++ b/packages/discord/src/conversation/control/controller.ts @@ -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 + +class CtlUsageError extends Schema.TaggedError()("CtlUsageError", { + message: Schema.String, +}) {} + +class CtlInternalError extends Schema.TaggedError()("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) => { + const input = argv.slice(2) + const cmd = input.at(0)?.toLowerCase() ?? "help" + const scan = input.slice(1).reduce( + (state: { opts: Opt; args: ReadonlyArray; 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, 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) => + Effect.sync(() => { + process.stdout.write( + `${JSON.stringify({ + ok, + command, + ...payload, + }, null, 2)}\n`, + ) + }) + +const event = (command: string, name: string, payload: Record) => + 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 => + 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 ", + "conversation:ctl logs --thread [--lines 120]", + "conversation:ctl pause --thread ", + "conversation:ctl destroy --thread ", + "conversation:ctl resume --thread [--channel --guild ]", + "conversation:ctl restart --thread ", + "conversation:ctl send --thread --text [--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(Option.isSome(known) ? known.value.sandboxId : null) + const last = yield* Ref.make("") + 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, +) diff --git a/packages/discord/src/conversation/control/state.test.ts b/packages/discord/src/conversation/control/state.test.ts new file mode 100644 index 0000000000..d703aa6b2e --- /dev/null +++ b/packages/discord/src/conversation/control/state.test.ts @@ -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"))) + }) +}) diff --git a/packages/discord/src/conversation/control/state.ts b/packages/discord/src/conversation/control/state.ts new file mode 100644 index 0000000000..082d27cf91 --- /dev/null +++ b/packages/discord/src/conversation/control/state.ts @@ -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) +} diff --git a/packages/discord/src/conversation/implementations/discord/index.ts b/packages/discord/src/conversation/implementations/discord/index.ts new file mode 100644 index 0000000000..15d22e0604 --- /dev/null +++ b/packages/discord/src/conversation/implementations/discord/index.ts @@ -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, 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() + const input = yield* Queue.unbounded() + const chats = new Map() + const texts = new Map() + const refs = new Map() + const roots = new Map() + const seen = new Set() + const order: Array = [] + const ref_ids: Array = [] + const root_ids: Array = [] + + 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 = (map: Map, keys: Array, 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 = (values: ReadonlyArray): Array => [...new Set(values)] + + const offer = (event: Inbound, onFresh: Effect.Effect) => + 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(null) + const guild = message.guild + if (!guild) return Effect.succeed(null) + return Effect.tryPromise(() => guild.members.fetch(message.author.id)).pipe( + Effect.catchAll(() => Effect.succeed(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 => + 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 + 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)), + ), + { 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((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) => + 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 = (thread_id: ThreadId, self: Effect.Effect) => + 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 diff --git a/packages/discord/src/conversation/implementations/local/index.test.ts b/packages/discord/src/conversation/implementations/local/index.test.ts new file mode 100644 index 0000000000..d3aff5a2e1 --- /dev/null +++ b/packages/discord/src/conversation/implementations/local/index.test.ts @@ -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 + gate?: Deferred.Deferred +}) => { + 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 = [] + 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() + 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)) + }), + ) +}) diff --git a/packages/discord/src/conversation/implementations/local/index.ts b/packages/discord/src/conversation/implementations/local/index.ts new file mode 100644 index 0000000000..13d74a2ad2 --- /dev/null +++ b/packages/discord/src/conversation/implementations/local/index.ts @@ -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 + send: (text: string) => Effect.Effect + sendTo: (thread_id: ThreadId, text: string) => Effect.Effect + take: Effect.Effect + actions: Stream.Stream +} + +export const makeTui = Effect.gen(function* () { + const input = yield* Queue.unbounded() + const output = yield* Queue.unbounded() + const history = new Map>() + const roots = new Map() + const parents = new Map() + 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: (thread_id: ThreadId, self: Effect.Effect) => + 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 +}) diff --git a/packages/discord/src/conversation/model/errors.ts b/packages/discord/src/conversation/model/errors.ts new file mode 100644 index 0000000000..f2debaf029 --- /dev/null +++ b/packages/discord/src/conversation/model/errors.ts @@ -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", + { + channel_id: ChannelId, + message: Schema.String, + retriable: Schema.Boolean, + }, +) {} + +export class HistoryError extends Schema.TaggedError()( + "HistoryError", + { + thread_id: ThreadId, + message: Schema.String, + retriable: Schema.Boolean, + }, +) {} + +export class DeliveryError extends Schema.TaggedError()( + "DeliveryError", + { + thread_id: ThreadId, + action: DeliveryAction, + message: Schema.String, + retriable: Schema.Boolean, + }, +) {} + +export class RoutingError extends Schema.TaggedError()( + "RoutingError", + { + message: Schema.String, + retriable: Schema.Boolean, + }, +) {} + +export class SandboxSendError extends Schema.TaggedError()( + "SandboxSendError", + { + thread_id: ThreadId, + message: Schema.String, + retriable: Schema.Boolean, + }, +) {} + +export class ReliabilityError extends Schema.TaggedError()( + "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) diff --git a/packages/discord/src/conversation/model/schema.ts b/packages/discord/src/conversation/model/schema.ts new file mode 100644 index 0000000000..ac7d8a827c --- /dev/null +++ b/packages/discord/src/conversation/model/schema.ts @@ -0,0 +1,73 @@ +import { Schema } from "effect" +import { ThreadId, ChannelId, GuildId } from "../../types" + +export class Mention extends Schema.Class("Mention")({ + user_ids: Schema.Array(Schema.String), + role_ids: Schema.Array(Schema.String), +}) {} + +export class ThreadMessage extends Schema.Class("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")({ + 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")({ + thread_id: ThreadId, + channel_id: ChannelId, +}) {} + +export class Send extends Schema.Class("Send")({ + kind: Schema.Literal("send"), + thread_id: ThreadId, + text: Schema.String, +}) {} + +export class Reply extends Schema.Class("Reply")({ + kind: Schema.Literal("reply"), + thread_id: ThreadId, + text: Schema.String, +}) {} + +export class Typing extends Schema.Class("Typing")({ + kind: Schema.Literal("typing"), + thread_id: ThreadId, +}) {} + +export const Action = Schema.Union( + Send, + Reply, + Typing, +) + +export type Action = typeof Action.Type diff --git a/packages/discord/src/conversation/services/conversation.test.ts b/packages/discord/src/conversation/services/conversation.test.ts new file mode 100644 index 0000000000..9f0cf321e6 --- /dev/null +++ b/packages/discord/src/conversation/services/conversation.test.ts @@ -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, + prompts: Array, +): 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 + tracked: Option.Option + resolves: ReadonlyArray + resolve?: (threadId: ThreadId, channelId: ChannelId, guildId: GuildId) => SessionInfo + send: ( + session: SessionInfo, + text: string, + ) => Effect.Effect + rehydrate: (threadId: ThreadId, latest: string) => Effect.Effect + shouldRespond?: boolean + actions: Array + prompts: Array +}) => { + 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: (thread_id: ThreadId, self: Effect.Effect) => + 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 = [] + const prompts: Array = [] + 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 = [] + const prompts: Array = [] + 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 = [] + const prompts: Array = [] + 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 = [] + const prompts: Array = [] + 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 = [] + const prompts: Array = [] + 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 = [] + const prompts: Array = [] + 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 = [] + const prompts: Array = [] + const gate = Effect.runSync(Deferred.make()) + const fast = Effect.runSync(Deferred.make()) + 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() + const started = new Set() + const completed = new Set() + const admitCalls: Array = [] + const startCalls: Array = [] + + 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 + tracked: Option.Option + resolves: ReadonlyArray + resolve?: (threadId: ThreadId, channelId: ChannelId, guildId: GuildId) => SessionInfo + send: ( + session: SessionInfo, + text: string, + ) => Effect.Effect + rehydrate: (threadId: ThreadId, latest: string) => Effect.Effect + shouldRespond?: boolean + actions: Array + prompts: Array + 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: (thread_id: ThreadId, self: Effect.Effect) => + 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 = [] + const prompts: Array = [] + 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 = [] + const prompts: Array = [] + 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 = [] + const prompts: Array = [] + 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 = [] + const prompts: Array = [] + const sendCount = { value: 0 } + + // A ledger that allows retry -> re-process flow + const admitted = new Set() + const state = new Map() + 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 = [] + const prompts: Array = [] + 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)) + }) +}) diff --git a/packages/discord/src/conversation/services/conversation.ts b/packages/discord/src/conversation/services/conversation.ts new file mode 100644 index 0000000000..d188664c99 --- /dev/null +++ b/packages/discord/src/conversation/services/conversation.ts @@ -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 + readonly run: Effect.Effect + } +} + +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() + const RETRIABLE_TAGS: ReadonlySet = 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 }) + }), + ) +} diff --git a/packages/discord/src/conversation/services/history.ts b/packages/discord/src/conversation/services/history.ts new file mode 100644 index 0000000000..08bf70f23b --- /dev/null +++ b/packages/discord/src/conversation/services/history.ts @@ -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 + } +} + +export class History extends Context.Tag("@discord/conversation/History")() { + static readonly passthrough = Layer.succeed( + History, + History.of({ + rehydrate: (_thread_id: ThreadId, latest: string) => Effect.succeed(latest), + }), + ) +} diff --git a/packages/discord/src/conversation/services/inbox.ts b/packages/discord/src/conversation/services/inbox.ts new file mode 100644 index 0000000000..787c75eaaf --- /dev/null +++ b/packages/discord/src/conversation/services/inbox.ts @@ -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 + } +} + +export class Inbox extends Context.Tag("@discord/conversation/Inbox")() { + static readonly empty = Layer.succeed( + Inbox, + Inbox.of({ + events: Stream.empty as Stream.Stream, + }), + ) +} diff --git a/packages/discord/src/conversation/services/index.ts b/packages/discord/src/conversation/services/index.ts new file mode 100644 index 0000000000..dbd297fc40 --- /dev/null +++ b/packages/discord/src/conversation/services/index.ts @@ -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" diff --git a/packages/discord/src/conversation/services/ledger.test.ts b/packages/discord/src/conversation/services/ledger.test.ts new file mode 100644 index 0000000000..305a3073f5 --- /dev/null +++ b/packages/discord/src/conversation/services/ledger.test.ts @@ -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 = ( + run: (ledger: ConversationLedger.Service, sql: Client.SqlClient) => Effect.Effect, +) => + 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")) + }), + ), + ) +}) diff --git a/packages/discord/src/conversation/services/ledger.ts b/packages/discord/src/conversation/services/ledger.ts new file mode 100644 index 0000000000..6d5142854f --- /dev/null +++ b/packages/discord/src/conversation/services/ledger.ts @@ -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")({ + 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 = (effect: Effect.Effect) => + 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 + readonly replayPending: () => Effect.Effect, DatabaseError> + readonly start: (message_id: string) => Effect.Effect, DatabaseError> + readonly setTarget: (message_id: string, thread_id: ThreadId, channel_id: ChannelId) => Effect.Effect + readonly setPrompt: (message_id: string, prompt: string, session_id: SessionId) => Effect.Effect + readonly setResponse: (message_id: string, response: string) => Effect.Effect + readonly complete: (message_id: string) => Effect.Effect + readonly retry: (message_id: string, error: string) => Effect.Effect + readonly prune: () => Effect.Effect + readonly getOffset: (source_id: string) => Effect.Effect, DatabaseError> + readonly setOffset: (source_id: string, message_id: string) => Effect.Effect + } +} + +export class ConversationLedger extends Context.Tag("@discord/conversation/ConversationLedger")< + ConversationLedger, + ConversationLedger.Service +>() { + static readonly noop = Layer.effect( + ConversationLedger, + Effect.sync(() => { + const pending = new Set() + const completed = new Set() + 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() + const rows = yield* db( + sql`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() + 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() + 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, + }) + }), + ) +} diff --git a/packages/discord/src/conversation/services/outbox.ts b/packages/discord/src/conversation/services/outbox.ts new file mode 100644 index 0000000000..51a4c096fb --- /dev/null +++ b/packages/discord/src/conversation/services/outbox.ts @@ -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 + readonly withTyping: (thread_id: ThreadId, self: Effect.Effect) => Effect.Effect + } +} + +export class Outbox extends Context.Tag("@discord/conversation/Outbox")() { + static readonly noop = Layer.succeed( + Outbox, + Outbox.of({ + publish: () => Effect.void, + withTyping: (_thread_id, self) => self, + }), + ) +} diff --git a/packages/discord/src/conversation/services/threads.ts b/packages/discord/src/conversation/services/threads.ts new file mode 100644 index 0000000000..5e37800078 --- /dev/null +++ b/packages/discord/src/conversation/services/threads.ts @@ -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 + } +} + +export class Threads extends Context.Tag("@discord/conversation/Threads")() { + 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, + }), + ) + }, + }), + ) +} diff --git a/packages/discord/src/db/client.ts b/packages/discord/src/db/client.ts index d350dadf6f..5ee4527dde 100644 --- a/packages/discord/src/db/client.ts +++ b/packages/discord/src/db/client.ts @@ -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")() { + 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, + ) } diff --git a/packages/discord/src/db/init.test.ts b/packages/discord/src/db/init.test.ts new file mode 100644 index 0000000000..d88a7d1cbd --- /dev/null +++ b/packages/discord/src/db/init.test.ts @@ -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-", + ), + ) +}) diff --git a/packages/discord/src/db/init.ts b/packages/discord/src/db/init.ts index 22fbfba3ad..00bf68c10e 100644 --- a/packages/discord/src/db/init.ts +++ b/packages/discord/src/db/init.ts @@ -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 { - 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 + ) diff --git a/packages/discord/src/db/migrations/0001_discord_sessions.ts b/packages/discord/src/db/migrations/0001_discord_sessions.ts new file mode 100644 index 0000000000..3ac7b84142 --- /dev/null +++ b/packages/discord/src/db/migrations/0001_discord_sessions.ts @@ -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 }) +}) diff --git a/packages/discord/src/db/migrations/0002_conversation_offsets.ts b/packages/discord/src/db/migrations/0002_conversation_offsets.ts new file mode 100644 index 0000000000..799d24b182 --- /dev/null +++ b/packages/discord/src/db/migrations/0002_conversation_offsets.ts @@ -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 }) +}) diff --git a/packages/discord/src/discord/client.ts b/packages/discord/src/discord/client.ts index 9cc3c0467d..bdff8747a9 100644 --- a/packages/discord/src/discord/client.ts +++ b/packages/discord/src/discord/client.ts @@ -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")() { + 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 + }), + ) } diff --git a/packages/discord/src/discord/constants.ts b/packages/discord/src/discord/constants.ts new file mode 100644 index 0000000000..701180a67f --- /dev/null +++ b/packages/discord/src/discord/constants.ts @@ -0,0 +1,2 @@ +export const TYPING_INTERVAL = "8 seconds" as const +export const TYPING_INTERVAL_MS = 8_000 diff --git a/packages/discord/src/discord/handlers/message-create.ts b/packages/discord/src/discord/handlers/message-create.ts deleted file mode 100644 index 3615bf45fc..0000000000 --- a/packages/discord/src/discord/handlers/message-create.ts +++ /dev/null @@ -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): 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): 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 => { - 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(() => {}); - } - }; -} diff --git a/packages/discord/src/discord/thread-name.ts b/packages/discord/src/discord/thread-name.ts deleted file mode 100644 index 4acc7c9b4f..0000000000 --- a/packages/discord/src/discord/thread-name.ts +++ /dev/null @@ -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 { - 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 ? "..." : ""); -} diff --git a/packages/discord/src/discord/turn-routing.ts b/packages/discord/src/discord/turn-routing.ts index 42975bed5d..7dc02a120a 100644 --- a/packages/discord/src/discord/turn-routing.ts +++ b/packages/discord/src/discord/turn-routing.ts @@ -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")({ + 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")({ + 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 { - 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 + readonly generateThreadName: (userMessage: string) => Effect.Effect + } +} + +export class TurnRouter extends Context.Tag("@discord/TurnRouter")() { + static readonly layer = Layer.effect( + TurnRouter, + Effect.gen(function* () { + const config = yield* AppConfig + const model = yield* LanguageModel.LanguageModel + + const aiDecision = (input: TurnRoutingInput): Effect.Effect => { + 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 { - 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" }; - } + ) } diff --git a/packages/discord/src/errors.ts b/packages/discord/src/errors.ts new file mode 100644 index 0000000000..847c703f8a --- /dev/null +++ b/packages/discord/src/errors.ts @@ -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", + { + sandboxId: Schema.optional(SandboxId), + cause: Schema.Defect, + }, +) {} + +export class SandboxNotFoundError extends Schema.TaggedError()( + "SandboxNotFoundError", + { + sandboxId: SandboxId, + }, +) {} + +export class SandboxExecError extends Schema.TaggedError()( + "SandboxExecError", + { + sandboxId: SandboxId, + label: Schema.String, + exitCode: Schema.Number, + output: Schema.String, + }, +) {} + +export class SandboxStartError extends Schema.TaggedError()( + "SandboxStartError", + { + sandboxId: SandboxId, + cause: Schema.Defect, + }, +) {} + +// -- OpenCode client errors -- + +export class HealthCheckError extends Schema.TaggedError()( + "HealthCheckError", + { + lastStatus: Schema.String, + }, +) {} + +export class OpenCodeClientError extends Schema.TaggedError()( + "OpenCodeClientError", + { + operation: Schema.String, + statusCode: Schema.Number, + body: Schema.String, + }, +) {} + +export class SessionMissingError extends Schema.TaggedError()( + "SessionMissingError", + { + sessionId: SessionId, + }, +) {} + +// -- Session lifecycle errors -- + +export class SandboxDeadError extends Schema.TaggedError()( + "SandboxDeadError", + { + threadId: ThreadId, + reason: Schema.String, + }, +) {} + +export class ResumeFailedError extends Schema.TaggedError()( + "ResumeFailedError", + { + threadId: ThreadId, + sandboxId: SandboxId, + cause: Schema.Defect, + }, +) {} + +// -- Config errors -- + +export class ConfigEncodeError extends Schema.TaggedError()( + "ConfigEncodeError", + { + config: Schema.String, + cause: Schema.Defect, + }, +) {} + +// -- Database errors -- + +export class DatabaseError extends Schema.TaggedError()( + "DatabaseError", + { + cause: Schema.Defect, + }, +) {} diff --git a/packages/discord/src/http/health.ts b/packages/discord/src/http/health.ts index a0d526ff7e..62c244689c 100644 --- a/packages/discord/src/http/health.ts +++ b/packages/discord/src/http/health.ts @@ -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; -}; - -export function startHealthServer(host: string, port: number, deps: HealthDependencies): Bun.Server { - 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")() { + 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, + } + }), + ) } diff --git a/packages/discord/src/index.ts b/packages/discord/src/index.ts index ad48fc00a2..d0ca89825a 100644 --- a/packages/discord/src/index.ts +++ b/packages/discord/src/index.ts @@ -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 - // 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, +) diff --git a/packages/discord/src/lib/actors/keyed.test.ts b/packages/discord/src/lib/actors/keyed.test.ts new file mode 100644 index 0000000000..1d58cb90c3 --- /dev/null +++ b/packages/discord/src/lib/actors/keyed.test.ts @@ -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", { + message: Schema.String, +}) {} + +describe("ActorMap", () => { + effectTest("serializes work for the same key", () => + Effect.gen(function* () { + const log: Array = [] + const keyed = yield* ActorMap.make() + + 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 = [] + const keyed = yield* ActorMap.make() + + 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({ + 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({ + 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({ + 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() + + 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() + + 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({ + 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({ + 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({ + 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() + 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() + + 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({ + 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({ + 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({ + 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({ + 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() + 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() + + 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() + + 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({ + 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) + }), + ) +}) diff --git a/packages/discord/src/lib/actors/keyed.ts b/packages/discord/src/lib/actors/keyed.ts new file mode 100644 index 0000000000..aa970da00e --- /dev/null +++ b/packages/discord/src/lib/actors/keyed.ts @@ -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 + cancel: Effect.Effect +} + +/** + * 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 { + /** 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 + /** Load persisted state when an actor is first activated. */ + load?: (key: K) => Effect.Effect, unknown, never> + /** Save state after it has been modified during `run`. */ + save?: (key: K, state: S) => Effect.Effect + } + + export interface ActorMap { + /** 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: { + ( + key: K, + effect: Effect.Effect, + options?: { touch?: boolean }, + ): Effect.Effect + ( + key: K, + f: (state: Ref.Ref>) => Effect.Effect, + options?: { touch?: boolean }, + ): Effect.Effect + } + /** 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 + /** Cancel the pending idle timer for a key without removing the actor. */ + cancelIdle: (key: K) => Effect.Effect + /** 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 + /** Remove all actors and cancel all idle timers. */ + stop: Effect.Effect + /** The number of currently active actor keys. */ + size: Effect.Effect + /** 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> + } +} + +interface Entry { + queue: Queue.Queue + state: Ref.Ref> +} + +export const ActorMap = { + make: (options?: ActorMap.Options): Effect.Effect, never, Scope.Scope> => + Effect.gen(function* () { + const workers = yield* FiberMap.make() + const timers = yield* FiberMap.make() + const state = yield* SynchronizedRef.make(new Map>()) + + const has = (key: K) => + Effect.map(SynchronizedRef.get(state), (map) => map.has(key)) + + const ensure = (key: K): Effect.Effect> => + 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() + // Load initial state from persistence hook if provided + const initial: Option.Option = options?.load + ? yield* options.load(key).pipe(Effect.catchAll(() => Effect.succeed(Option.none()))) + : Option.none() + 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 = { 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 | 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 = ( + key: K, + effectOrFn: Effect.Effect | ((state: Ref.Ref>) => Effect.Effect), + runOptions?: { touch?: boolean }, + ): Effect.Effect => + Effect.gen(function* () { + const entry = yield* ensure(key) + const done = yield* Deferred.make() + + // Snapshot state before running so we can detect changes + const stateBefore = yield* Ref.get(entry.state) + + const effect: Effect.Effect = Effect.isEffect(effectOrFn) + ? effectOrFn + : (effectOrFn as (state: Ref.Ref>) => Effect.Effect)(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> => + Effect.gen(function* () { + const map = yield* SynchronizedRef.get(state) + const entry = map.get(key) + if (!entry) return Option.none() + return yield* Ref.get(entry.state) + }) + + return { run, touch, cancelIdle, remove, stop, size, getState } satisfies ActorMap.ActorMap + }), +} diff --git a/packages/discord/src/lib/log.ts b/packages/discord/src/lib/log.ts new file mode 100644 index 0000000000..e4281f218a --- /dev/null +++ b/packages/discord/src/lib/log.ts @@ -0,0 +1,9 @@ +import { Effect } from "effect" + +/** Swallow errors with a warning log. Use for best-effort bookkeeping writes. */ +export const logIgnore = (effect: Effect.Effect, context: string) => + effect.pipe( + Effect.catchAll((err) => + Effect.logWarning(`${context} failed (ignored)`).pipe(Effect.annotateLogs({ error: String(err) })), + ), + ) diff --git a/packages/discord/src/md.d.ts b/packages/discord/src/md.d.ts new file mode 100644 index 0000000000..aed1ccdc6a --- /dev/null +++ b/packages/discord/src/md.d.ts @@ -0,0 +1,4 @@ +declare module "*.md" { + const text: string + export default text +} diff --git a/packages/discord/src/observability/logger.ts b/packages/discord/src/observability/logger.ts index 183483fb0b..2a8ab066ef 100644 --- a/packages/discord/src/observability/logger.ts +++ b/packages/discord/src/observability/logger.ts @@ -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 = { - 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), +) diff --git a/packages/discord/src/sandbox/daytona.ts b/packages/discord/src/sandbox/daytona.ts new file mode 100644 index 0000000000..a0c64fc541 --- /dev/null +++ b/packages/discord/src/sandbox/daytona.ts @@ -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")({ + id: SandboxId, + previewUrl: Schema.String, + previewToken: Schema.Union(Schema.Null, Schema.String), +}) {} + +export class ExecResult extends Schema.Class("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 + readonly exec: ( + sandboxId: SandboxId, + label: string, + command: string, + opts?: { cwd?: string; env?: Record }, + ) => Effect.Effect + readonly start: ( + sandboxId: SandboxId, + timeout: number, + ) => Effect.Effect + readonly stop: (sandboxId: SandboxId) => Effect.Effect + readonly destroy: (sandboxId: SandboxId) => Effect.Effect + readonly getPreview: (sandboxId: SandboxId) => Effect.Effect + } +} + +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")() { + 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 = ( + 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 }, + ) { + 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 }) + }), + ) +} diff --git a/packages/discord/src/sandbox/image.ts b/packages/discord/src/sandbox/image.ts deleted file mode 100644 index 0d175d50ca..0000000000 --- a/packages/discord/src/sandbox/image.ts +++ /dev/null @@ -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"); -} diff --git a/packages/discord/src/sandbox/manager.ts b/packages/discord/src/sandbox/manager.ts deleted file mode 100644 index baa276ab02..0000000000 --- a/packages/discord/src/sandbox/manager.ts +++ /dev/null @@ -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>(); - -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, - timeout?: number, - ) => Promise<{ exitCode: number; result: string }>; - }; - }, - label: string, - command: string, - context: Pick, - options?: { cwd?: string; env?: Record }, -): Promise { - 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 | null = null; - private readonly store = getSessionStore(); - private readonly threadLocks = new Map>(); - - async getActiveSessionCount(): Promise { - return (await this.store.listActive()).length; - } - - isCleanupLoopRunning(): boolean { - return this.cleanupInterval !== null; - } - - async hasTrackedThread(threadId: string): Promise { - return this.store.hasTrackedThread(threadId); - } - - async getTrackedSession(threadId: string): Promise { - return this.store.getByThread(threadId); - } - - async getSession(threadId: string): Promise { - return this.store.getActive(threadId); - } - - async resolveSessionForMessage(threadId: string, channelId: string, guildId: string): Promise { - 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 { - return this.withThreadLock(threadId, async () => this.createSessionUnlocked(threadId, channelId, guildId)); - } - - private async createSessionUnlocked(threadId: string, channelId: string, guildId: string): Promise { - 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 { - 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 { - await this.withThreadLock(threadId, async () => { - await this.pauseSessionUnlocked(threadId, reason); - }); - } - - private async pauseSessionUnlocked(threadId: string, reason: string): Promise { - 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 { - 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>; - - 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 { - 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 { - const active = await this.store.listActive(); - await Promise.allSettled(active.map((session) => this.pauseSession(session.threadId, "shutdown"))); - } - - private buildRuntimeEnv(): Record { - const env = getEnv(); - const runtimeEnv: Record = {}; - 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 { - 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(threadId: string, fn: () => Promise): Promise { - const previous = this.threadLocks.get(threadId) ?? Promise.resolve(); - let release!: () => void; - const current = new Promise((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 }; diff --git a/packages/discord/src/sandbox/opencode-client.ts b/packages/discord/src/sandbox/opencode-client.ts index 32c6fc2d8b..ba79711226 100644 --- a/packages/discord/src/sandbox/opencode-client.ts +++ b/packages/discord/src/sandbox/opencode-client.ts @@ -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")({ + 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 { - 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 + /** Create a new chat session with the given title. Returns the session ID. */ + readonly createSession: ( + preview: PreviewAccess, + title: string, + ) => Effect.Effect + /** Check whether a session still exists on the server. */ + readonly sessionExists: ( + preview: PreviewAccess, + sessionId: SessionId, + ) => Effect.Effect + /** List recent sessions, ordered by update time. */ + readonly listSessions: ( + preview: PreviewAccess, + limit?: number, + ) => Effect.Effect, OpenCodeClientError> + /** Send a user prompt and return the agent's text response. */ + readonly sendPrompt: ( + preview: PreviewAccess, + sessionId: SessionId, + text: string, + ) => Effect.Effect + /** Cancel an in-flight generation. Best-effort, errors are swallowed. */ + readonly abortSession: ( + preview: PreviewAccess, + sessionId: SessionId, + ) => Effect.Effect } - 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 { - const start = Date.now(); - let lastStatus = ""; +export class OpenCodeClient extends Context.Tag("@discord/OpenCodeClient")() { + 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 = ( + operation: string, + effect: Effect.Effect, + ) => + 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 { - 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 { - 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 { - 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 { - 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 { - await previewFetch(preview, `/session/${sessionId}/abort`, { method: "POST" }).catch(() => {}); + ) } diff --git a/packages/discord/src/sandbox/pool.test.ts b/packages/discord/src/sandbox/pool.test.ts new file mode 100644 index 0000000000..d2f8b82be9 --- /dev/null +++ b/packages/discord/src/sandbox/pool.test.ts @@ -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 = ( + statusCode: number, + body: string, + run: Effect.Effect, +) => { + 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") + }), + ), + ) +}) diff --git a/packages/discord/src/sandbox/pool.ts b/packages/discord/src/sandbox/pool.ts new file mode 100644 index 0000000000..b3f5767741 --- /dev/null +++ b/packages/discord/src/sandbox/pool.ts @@ -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 + readonly send: (text: string) => Effect.Effect + readonly pause: (reason?: string) => Effect.Effect + readonly destroy: () => Effect.Effect +} + +type Runtime = { + readonly current: () => Effect.Effect + readonly ensure: ( + channelId: ChannelId, + guildId: GuildId, + ) => Effect.Effect< + SessionInfo, + | SandboxCreateError + | SandboxExecError + | SandboxNotFoundError + | SandboxStartError + | HealthCheckError + | OpenCodeClientError + | ConfigEncodeError + | SandboxDeadError + | DatabaseError + > + readonly send: (text: string) => Effect.Effect + readonly pause: (reason: string) => Effect.Effect + readonly destroy: (reason: string) => Effect.Effect +} + +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 + readonly getTrackedSession: (threadId: ThreadId) => Effect.Effect, DatabaseError> + readonly getActiveSessionCount: () => Effect.Effect + readonly pauseSession: (threadId: ThreadId, reason?: string) => Effect.Effect + readonly destroySession: (threadId: ThreadId) => Effect.Effect + } +} + +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>, + ): 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() + }) + + 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 = yield* ActorMap.make({ + 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 = ( + threadId: ThreadId, + f: (rt: Runtime) => Effect.Effect, + 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, + }) + }), + ) +} diff --git a/packages/discord/src/sandbox/provisioner.ts b/packages/discord/src/sandbox/provisioner.ts new file mode 100644 index 0000000000..f62976a820 --- /dev/null +++ b/packages/discord/src/sandbox/provisioner.ts @@ -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")({ + session: SessionInfo, +}) {} + +export class ResumeFailed extends Schema.Class("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 + /** 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 + }) => 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 + /** Applies session-state recovery policy after a send failure. */ + readonly recoverSendFailure: ( + threadId: ThreadId, + session: SessionInfo, + error: OpenCodeClientError, + ) => Effect.Effect + /** Pauses a session by stopping its sandbox. */ + readonly pause: ( + threadId: ThreadId, + session: SessionInfo, + reason: string, + ) => Effect.Effect + /** Destroys a session by destroying its sandbox. */ + readonly destroy: ( + threadId: ThreadId, + session: SessionInfo, + reason?: string, + ) => Effect.Effect + } +} + +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))) + 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): Record => { + const runtimeEnv: Record = {} + 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 => + 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() + }), + ), + Effect.catchTag("SandboxStartError", (err) => + recordFailure(session.threadId, String(err.cause)).pipe(Effect.as(Option.none())), + ), + ) + + 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 + }) { + 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, + }) + }), + ) +} diff --git a/packages/discord/src/sessions/store.test.ts b/packages/discord/src/sessions/store.test.ts new file mode 100644 index 0000000000..ce710cc313 --- /dev/null +++ b/packages/discord/src/sessions/store.test.ts @@ -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 = (run: (store: SessionStore.Service, sql: Client.SqlClient) => Effect.Effect) => + 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() + }), + ), + ) +}) diff --git a/packages/discord/src/sessions/store.ts b/packages/discord/src/sessions/store.ts index d43dd39690..13e3235004 100644 --- a/packages/discord/src/sessions/store.ts +++ b/packages/discord/src/sessions/store.ts @@ -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 - getByThread(threadId: string): Promise - hasTrackedThread(threadId: string): Promise - getActive(threadId: string): Promise - markActivity(threadId: string): Promise - markHealthOk(threadId: string): Promise - updateStatus(threadId: string, status: SessionStatus, lastError?: string | null): Promise - incrementResumeFailure(threadId: string, lastError: string): Promise - listActive(): Promise - listStaleActive(cutoffMinutes: number): Promise - listExpiredPaused(pausedTtlMinutes: number): Promise -} +class Write extends Schema.Class("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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 + readonly getByThread: (threadId: ThreadId) => Effect.Effect, DatabaseError> + readonly hasTrackedThread: (threadId: ThreadId) => Effect.Effect + readonly getActive: (threadId: ThreadId) => Effect.Effect, DatabaseError> + readonly markActivity: (threadId: ThreadId) => Effect.Effect + readonly markHealthOk: (threadId: ThreadId) => Effect.Effect + readonly updateStatus: (threadId: ThreadId, status: SessionStatus, lastError?: string | null) => Effect.Effect + readonly incrementResumeFailure: (threadId: ThreadId, lastError: string) => Effect.Effect + readonly listActive: () => Effect.Effect, DatabaseError> + readonly listTrackedThreads: () => Effect.Effect, DatabaseError> + readonly listStaleActive: (cutoffMinutes: number) => Effect.Effect, DatabaseError> + readonly listExpiredPaused: (pausedTtlMinutes: number) => Effect.Effect, 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")() { + static readonly layer = Layer.effect( + SessionStore, + Effect.gen(function* () { + const sql = yield* SqliteDb + const db = (effect: Effect.Effect) => + 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), + ) } diff --git a/packages/discord/src/test/effect.ts b/packages/discord/src/test/effect.ts new file mode 100644 index 0000000000..7d6502598e --- /dev/null +++ b/packages/discord/src/test/effect.ts @@ -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, + options?: number | TestOptions, +) => + test(name, () => Effect.runPromise(run().pipe(Effect.scoped)), options) + +export const withSqlite = (filename: string, run: (db: Client.SqlClient) => Effect.Effect) => + SqliteClient.make({ filename }).pipe( + Effect.provide(Reactivity.layer), + Effect.flatMap(run), + Effect.scoped, + ) + +export const withTempSqliteFile = ( + run: (filename: string) => Effect.Effect, + 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", + }), +) diff --git a/packages/discord/src/types.ts b/packages/discord/src/types.ts index 1bfd3cdf55..4fcecc785a 100644 --- a/packages/discord/src/types.ts +++ b/packages/discord/src/types.ts @@ -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")({ + /** 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")({ + 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 }) + } } diff --git a/packages/discord/tsconfig.json b/packages/discord/tsconfig.json index 7ca1581be4..12422c1441 100644 --- a/packages/discord/tsconfig.json +++ b/packages/discord/tsconfig.json @@ -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"] }