refactor: restructure conversation runtime around thread entities
This commit is contained in:
parent
1885db3d8b
commit
46cc9e7567
59 changed files with 2518 additions and 2066 deletions
26
bun.lock
26
bun.lock
|
|
@ -218,19 +218,21 @@
|
|||
"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/ai": "0.33.2",
|
||||
"@effect/ai-anthropic": "0.23.0",
|
||||
"@effect/cluster": "0.56.4",
|
||||
"@effect/experimental": "0.58.0",
|
||||
"@effect/platform": "0.94.5",
|
||||
"@effect/platform-bun": "0.87.1",
|
||||
"@effect/sql-sqlite-bun": "^0.50.0",
|
||||
"@effect/rpc": "0.73.1",
|
||||
"@effect/sql-sqlite-bun": "0.50.2",
|
||||
"@opencode-ai/sdk": "latest",
|
||||
"discord.js": "^14",
|
||||
"effect": "^3",
|
||||
"effect": "3.19.17",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/language-service": "0.73.1",
|
||||
"@effect/sql": "^0.49.0",
|
||||
"@effect/sql": "0.49.0",
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "^22",
|
||||
"typescript": "^5",
|
||||
|
|
@ -907,19 +909,19 @@
|
|||
|
||||
"@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/cluster": ["@effect/cluster@0.56.4", "", { "dependencies": { "kubernetes-types": "^1.30.0" }, "peerDependencies": { "@effect/platform": "^0.94.5", "@effect/rpc": "^0.73.1", "@effect/sql": "^0.49.0", "@effect/workflow": "^0.16.0", "effect": "^3.19.17" } }, "sha512-7Je5/JlbZOlsSxsbKjr97dJed2cNGWsb+TLNgMcr5mRDbcWlFOTUGvsrisEJV6waosYLIg+2omPdvnvRoYKdhA=="],
|
||||
|
||||
"@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": ["@effect/platform@0.94.5", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.19.17" } }, "sha512-z05APUiDDPbodhTkH/RJqOLoCU11bU2IZLfcwLFrld03+ob1VeqRnELQlmueLIYm6NZifHAtjl32V+GRt34y4A=="],
|
||||
|
||||
"@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/rpc": ["@effect/rpc@0.73.1", "", { "dependencies": { "msgpackr": "^1.11.4" }, "peerDependencies": { "@effect/platform": "^0.94.5", "effect": "^3.19.17" } }, "sha512-1Pn6GJl+gCdVl0O38zK8wU/I5UsG6RArDKd8Ndx8BhntZAPIBbuUFgG7KlAKTJjumBTO/YOEgKYdwma0rJq/Tw=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
|
|
@ -2615,7 +2617,7 @@
|
|||
|
||||
"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=="],
|
||||
"effect": ["effect@3.19.17", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-MChgn9z0y3KJ2DJ+VsZt1bdVDJ0bDIZt9zm4s3iPpRLzgcEGDOIP8pwADjAWifaP0S672nlRE5rZPBbyDcjn1g=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.282", "", {}, "sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ=="],
|
||||
|
||||
|
|
@ -4639,7 +4641,7 @@
|
|||
|
||||
"@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=="],
|
||||
"@opencode/discord/@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.1", "", {}, "sha512-K5e15mIXTyAykBw0GX+8O28IJHlPMw1jI/m3SDu+hgUHjmg2refqLPqyuqv8hE2nRcuGi8HajhpDJjkO7H2S0A=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ DISCORD_COMMAND_GUILD_ID= # Optional guild ID for instant slash command updat
|
|||
|
||||
# Daytona
|
||||
DAYTONA_API_KEY=
|
||||
DAYTONA_SNAPSHOT= # Optional; prebuilt snapshot name for faster sandbox creation
|
||||
|
||||
# OpenCode (injected into sandboxes)
|
||||
OPENCODE_ZEN_API_KEY=
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ This image does not require Docker Compose or special network wiring; only outbo
|
|||
| ---------------- | ---------------------------- | -------------------------------------------------- |
|
||||
| `DATABASE_PATH` | `discord.sqlite` | Path to the local SQLite file |
|
||||
| `GITHUB_TOKEN` | _(empty)_ | Injected into sandboxes for authenticated `gh` CLI |
|
||||
| `DAYTONA_SNAPSHOT` | _(empty)_ | Prebuilt Daytona snapshot name for faster startup |
|
||||
| `OPENCODE_MODEL` | `opencode/claude-sonnet-4-5` | Model used inside OpenCode sessions |
|
||||
|
||||
#### Optional — Bot Behavior
|
||||
|
|
@ -127,10 +128,25 @@ This image does not require Docker Compose or special network wiring; only outbo
|
|||
| `bun run dev` | Watch mode |
|
||||
| `bun run start` | Production run |
|
||||
| `bun run db:init` | Initialize/migrate database |
|
||||
| `bun run snapshot:create` | Build/activate a Daytona snapshot |
|
||||
| `bun run typecheck` | TypeScript checks |
|
||||
| `bun run build` | Bundle for deployment |
|
||||
| `bun run check` | Typecheck + build |
|
||||
|
||||
### Faster Sandbox Startup (Snapshot)
|
||||
|
||||
Build and activate a reusable Daytona snapshot once:
|
||||
|
||||
```bash
|
||||
bun run snapshot:create opencode-discord-v1
|
||||
```
|
||||
|
||||
Then set this in `.env`:
|
||||
|
||||
```bash
|
||||
DAYTONA_SNAPSHOT=opencode-discord-v1
|
||||
```
|
||||
|
||||
### Discord Slash Commands
|
||||
|
||||
- `/status` — show current sandbox session for the thread
|
||||
|
|
@ -148,11 +164,13 @@ These map to the existing `!status` / `!reset` behavior.
|
|||
```
|
||||
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? → SandboxProvisioner.resume() → reattach session
|
||||
└─ missing? → SandboxProvisioner.provision() → new sandbox + session
|
||||
├─ IngressDedup (message-id dedup in conversation path)
|
||||
├─ OffsetStore (durable Discord catch-up offsets)
|
||||
└─ ThreadChatCluster.send(threadId)
|
||||
└─ ThreadEntity (cluster actor per thread)
|
||||
├─ active? → health check → reuse
|
||||
├─ paused? → SandboxProvisioner.resume() → reattach session
|
||||
└─ missing? → SandboxProvisioner.provision() → new sandbox + session
|
||||
```
|
||||
|
||||
Sessions are persisted in a local SQLite file. Sandbox filesystem (including OpenCode session state) survives pause/resume cycles via Daytona stop/start.
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@
|
|||
"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",
|
||||
"conversation:cli": "bun run src/control/cli.ts",
|
||||
"conversation:controller": "bun run src/control/controller.ts",
|
||||
"conversation:ctl": "bun run src/control/controller.ts",
|
||||
"snapshot:create": "bun run src/sandbox/daytona/snapshot.ts",
|
||||
"build": "bun build src/index.ts --target=bun --outdir=dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "bun run typecheck && bun run build",
|
||||
|
|
@ -18,19 +19,21 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@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/ai": "0.33.2",
|
||||
"@effect/ai-anthropic": "0.23.0",
|
||||
"@effect/cluster": "0.56.4",
|
||||
"@effect/experimental": "0.58.0",
|
||||
"@effect/platform": "0.94.5",
|
||||
"@effect/platform-bun": "0.87.1",
|
||||
"@effect/rpc": "0.73.1",
|
||||
"@effect/sql-sqlite-bun": "0.50.2",
|
||||
"@opencode-ai/sdk": "latest",
|
||||
"discord.js": "^14",
|
||||
"effect": "^3"
|
||||
"effect": "3.19.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/language-service": "0.73.1",
|
||||
"@effect/sql": "^0.49.0",
|
||||
"@effect/sql": "0.49.0",
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "^22",
|
||||
"typescript": "^5"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,15 @@ your job is to help people solve real code and operations problems quickly.
|
|||
- 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 identity and capabilities
|
||||
|
||||
- your github account name is `opendude`.
|
||||
- your github identity is the account authenticated in the sandbox via `GH_TOKEN` / `GITHUB_TOKEN`.
|
||||
- if the user asks who you are on github, check with `gh auth status` or `gh api user`.
|
||||
- you can create branches, push commits, open pull requests, open issues, and post issue/pr comments when repo permissions allow.
|
||||
- default to creating pull requests against `dev` unless the user specifies another base branch.
|
||||
- do not merge pull requests unless the user explicitly asks and permissions allow it.
|
||||
|
||||
## github issue workflow
|
||||
|
||||
when creating an issue, include:
|
||||
|
|
|
|||
79
packages/discord/src/app/layers.ts
Normal file
79
packages/discord/src/app/layers.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic"
|
||||
import { SingleRunner, TestRunner } from "@effect/cluster"
|
||||
import { FetchHttpClient } from "@effect/platform"
|
||||
import { BunContext } from "@effect/platform-bun"
|
||||
import { Effect, Layer, LogLevel, Logger } from "effect"
|
||||
import { Conversation } from "../conversation/conversation"
|
||||
import { IngressDedup } from "../conversation/dedup"
|
||||
import { History } from "../conversation/history"
|
||||
import { OffsetStore } from "../conversation/offsets"
|
||||
import { ThreadChatClusterLive, ThreadControlClusterLive, ThreadEntityLive } from "../conversation/thread/cluster"
|
||||
import { AppConfig } from "../config"
|
||||
import { SqliteDb } from "../db/client"
|
||||
import { DiscordConversationServicesLive } from "../discord/adapter"
|
||||
import { DiscordClient } from "../discord/client"
|
||||
import { TurnRouter } from "../discord/turn-routing"
|
||||
import { LoggerLive } from "../observability/logger"
|
||||
import { DaytonaService } from "../sandbox/daytona/service"
|
||||
import { OpenCodeClient } from "../sandbox/opencode/client"
|
||||
import { SandboxProvisioner } from "../sandbox/provisioner"
|
||||
import { SessionStore } from "../session/store"
|
||||
|
||||
export 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 Base = Layer.mergeAll(AppConfig.layer, FetchHttpClient.layer, BunContext.layer)
|
||||
|
||||
const AppBase = Layer.mergeAll(Base, LoggerLive)
|
||||
const AppWithSqlite = Layer.provideMerge(SqliteDb.layer, AppBase)
|
||||
const AppWithAnthropic = Layer.provideMerge(AnthropicLayer, AppWithSqlite)
|
||||
const AppWithDaytona = Layer.provideMerge(DaytonaService.layer, AppWithAnthropic)
|
||||
const AppWithOpenCode = Layer.provideMerge(OpenCodeClient.layer, AppWithDaytona)
|
||||
const AppWithRouting = Layer.provideMerge(TurnRouter.layer, AppWithOpenCode)
|
||||
const AppWithSessions = Layer.provideMerge(SessionStore.layer, AppWithRouting)
|
||||
const AppWithProvisioner = Layer.provideMerge(SandboxProvisioner.layer, AppWithSessions)
|
||||
const AppWithOffsets = Layer.provideMerge(OffsetStore.layer, AppWithProvisioner)
|
||||
const AppWithDedup = Layer.provideMerge(IngressDedup.layer, AppWithOffsets)
|
||||
const AppWithDiscordClient = Layer.provideMerge(DiscordClient.layer, AppWithDedup)
|
||||
const AppWithDiscordAdapters = Layer.provideMerge(DiscordConversationServicesLive, AppWithDiscordClient)
|
||||
const AppWithRunner = Layer.provideMerge(SingleRunner.layer({ runnerStorage: "memory" }), AppWithDiscordAdapters)
|
||||
const AppWithThreadEntity = Layer.provideMerge(ThreadEntityLive, AppWithRunner)
|
||||
const AppWithThreadChat = Layer.provideMerge(ThreadChatClusterLive, AppWithThreadEntity)
|
||||
export const AppConversationLayer = Layer.provideMerge(Conversation.layer, AppWithThreadChat)
|
||||
|
||||
const ControlBase = Layer.mergeAll(Base, Logger.minimumLogLevel(LogLevel.None))
|
||||
const ControlWithSqlite = Layer.provideMerge(SqliteDb.layer, ControlBase)
|
||||
const ControlWithDaytona = Layer.provideMerge(DaytonaService.layer, ControlWithSqlite)
|
||||
const ControlWithOpenCode = Layer.provideMerge(OpenCodeClient.layer, ControlWithDaytona)
|
||||
const ControlWithSessions = Layer.provideMerge(SessionStore.layer, ControlWithOpenCode)
|
||||
const ControlWithProvisioner = Layer.provideMerge(SandboxProvisioner.layer, ControlWithSessions)
|
||||
const ControlWithRunner = Layer.provideMerge(TestRunner.layer, ControlWithProvisioner)
|
||||
const ControlWithHistory = Layer.provideMerge(History.passthrough, ControlWithRunner)
|
||||
const ControlWithThreadEntity = Layer.provideMerge(ThreadEntityLive, ControlWithHistory)
|
||||
const ControlWithThreadChat = Layer.provideMerge(ThreadChatClusterLive, ControlWithThreadEntity)
|
||||
export const ControlThreadLayer = Layer.provideMerge(ThreadControlClusterLive, ControlWithThreadChat)
|
||||
|
||||
const CliBase = Layer.mergeAll(Base, Logger.minimumLogLevel(LogLevel.Warning))
|
||||
const CliWithSqlite = Layer.provideMerge(SqliteDb.layer, CliBase)
|
||||
const CliWithAnthropic = Layer.provideMerge(AnthropicLayer, CliWithSqlite)
|
||||
const CliWithDaytona = Layer.provideMerge(DaytonaService.layer, CliWithAnthropic)
|
||||
const CliWithOpenCode = Layer.provideMerge(OpenCodeClient.layer, CliWithDaytona)
|
||||
const CliWithRouting = Layer.provideMerge(TurnRouter.layer, CliWithOpenCode)
|
||||
const CliWithSessions = Layer.provideMerge(SessionStore.layer, CliWithRouting)
|
||||
const CliWithProvisioner = Layer.provideMerge(SandboxProvisioner.layer, CliWithSessions)
|
||||
const CliWithRunner = Layer.provideMerge(TestRunner.layer, CliWithProvisioner)
|
||||
const CliWithHistory = Layer.provideMerge(History.passthrough, CliWithRunner)
|
||||
const CliWithThreadEntity = Layer.provideMerge(ThreadEntityLive, CliWithHistory)
|
||||
const CliWithThreadChat = Layer.provideMerge(ThreadChatClusterLive, CliWithThreadEntity)
|
||||
const CliWithThreadControl = Layer.provideMerge(ThreadControlClusterLive, CliWithThreadChat)
|
||||
export const CliConversationStaticLayer = Layer.provideMerge(IngressDedup.noop, CliWithThreadControl)
|
||||
|
|
@ -58,6 +58,7 @@ export declare namespace AppConfig {
|
|||
readonly discordCommandGuildId: string
|
||||
readonly databasePath: string
|
||||
readonly daytonaApiKey: Redacted.Redacted
|
||||
readonly daytonaSnapshot: string
|
||||
readonly openCodeZenApiKey: Redacted.Redacted
|
||||
readonly githubToken: string
|
||||
readonly logLevel: LogLevel
|
||||
|
|
@ -102,6 +103,9 @@ export class AppConfig extends Context.Tag("@discord/AppConfig")<AppConfig, AppC
|
|||
Config.withDefault("discord.sqlite"),
|
||||
)
|
||||
const daytonaApiKey = yield* Config.redacted("DAYTONA_API_KEY")
|
||||
const daytonaSnapshot = yield* Config.string("DAYTONA_SNAPSHOT").pipe(
|
||||
Config.withDefault(""),
|
||||
)
|
||||
const openCodeZenApiKey = yield* Config.redacted("OPENCODE_ZEN_API_KEY")
|
||||
const githubToken = yield* Config.string("GITHUB_TOKEN").pipe(
|
||||
Config.withDefault(""),
|
||||
|
|
@ -166,6 +170,7 @@ export class AppConfig extends Context.Tag("@discord/AppConfig")<AppConfig, AppC
|
|||
discordCommandGuildId,
|
||||
databasePath,
|
||||
daytonaApiKey,
|
||||
daytonaSnapshot,
|
||||
openCodeZenApiKey,
|
||||
githubToken,
|
||||
logLevel,
|
||||
|
|
|
|||
|
|
@ -1,52 +1,15 @@
|
|||
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 { 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 { CliConversationStaticLayer } from "../app/layers"
|
||||
import { ThreadChatCluster, ThreadControlCluster } from "../conversation/thread/cluster"
|
||||
import { ThreadId } from "../types"
|
||||
import type { Action } from "../conversation/model/schema"
|
||||
import { makeTui } from "./local-adapter"
|
||||
import { Conversation } from "../conversation/conversation"
|
||||
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",
|
||||
|
|
@ -63,20 +26,14 @@ 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),
|
||||
Layer.provideMerge(CliConversationStaticLayer),
|
||||
)
|
||||
|
||||
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
|
||||
yield* Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
const threadChat = yield* ThreadChatCluster
|
||||
const threadControl = yield* ThreadControlCluster
|
||||
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
|
||||
|
|
@ -106,55 +63,55 @@ const run = Effect.gen(function* () {
|
|||
stdout.write(`${prompt(scope)}${rl.line}`)
|
||||
})
|
||||
|
||||
const noteThread = (thread_id: ThreadId): Effect.Effect<void> =>
|
||||
const noteThread = (threadId: ThreadId): Effect.Effect<void> =>
|
||||
Effect.sync(() => {
|
||||
seen.add(thread_id)
|
||||
last = thread_id
|
||||
seen.add(threadId)
|
||||
last = threadId
|
||||
})
|
||||
|
||||
const pick = (thread_id: ThreadId | null): ThreadId | null => {
|
||||
if (thread_id) return thread_id
|
||||
if (scope.kind === "thread") return scope.thread_id
|
||||
const pick = (threadId: ThreadId | null): ThreadId | null => {
|
||||
if (threadId) return threadId
|
||||
if (scope.kind === "thread") return scope.threadId
|
||||
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 fromRef = (threadId: ThreadId | null) => {
|
||||
if (!threadId) return null
|
||||
const raw = `${threadId}`.trim()
|
||||
if (!/^\d+$/.test(raw)) return threadId
|
||||
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(
|
||||
const tracked = (threadId: ThreadId) =>
|
||||
threadChat.status(threadId).pipe(
|
||||
Effect.map((row) => Option.isSome(row) ? row.value : null),
|
||||
Effect.catchAll(() => Effect.succeed(null)),
|
||||
)
|
||||
|
||||
const sessionText = (thread_id: ThreadId, session: {
|
||||
const sessionText = (threadId: 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)}` : ""}`
|
||||
`${colors.dim}${threadId}${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("typing", colors.yellow, `${colors.dim}[${action.threadId}]${colors.reset}`)
|
||||
}
|
||||
return stamp("assistant", colors.cyan, `${colors.dim}[${action.thread_id}]${colors.reset} ${action.text}`)
|
||||
return stamp("assistant", colors.cyan, `${colors.dim}[${action.threadId}]${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}`,
|
||||
`${colors.dim}Type messages. /thread [id|n], /pick [n], /channel, /threads, /status, /logs, /pause, /recreate, /resume, /active, /help, /exit${colors.reset}`,
|
||||
),
|
||||
false,
|
||||
)
|
||||
|
|
@ -164,8 +121,8 @@ const run = Effect.gen(function* () {
|
|||
tui.actions,
|
||||
(action) =>
|
||||
Effect.gen(function* () {
|
||||
const known = seen.has(action.thread_id)
|
||||
yield* noteThread(action.thread_id)
|
||||
const known = seen.has(action.threadId)
|
||||
yield* noteThread(action.threadId)
|
||||
const next = autoThread(scope, action, known)
|
||||
const switched = scope.kind === "channel" && next.kind === "thread"
|
||||
scope = next
|
||||
|
|
@ -188,7 +145,7 @@ const run = Effect.gen(function* () {
|
|||
if (scope.kind === "channel") {
|
||||
yield* tui.send(text)
|
||||
} else {
|
||||
yield* tui.sendTo(scope.thread_id, text)
|
||||
yield* tui.sendTo(scope.threadId, text)
|
||||
}
|
||||
pending += 1
|
||||
yield* draw(stamp("queued", colors.green, `${colors.dim}[${target}]${colors.reset} ${text}`), false)
|
||||
|
|
@ -210,7 +167,7 @@ const run = Effect.gen(function* () {
|
|||
|
||||
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}`,
|
||||
`${colors.dim}/thread [id|n], /pick [n], /channel, /threads, /status [thread], /logs [lines] [thread], /pause [thread], /recreate [thread], /resume [thread], /active, /exit${colors.reset}`,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
|
@ -234,18 +191,18 @@ const run = Effect.gen(function* () {
|
|||
yield* info(`${colors.dim}pick one with /pick <n>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
const thread_id = byIndex(cmd.index)
|
||||
if (!thread_id) {
|
||||
const threadId = byIndex(cmd.index)
|
||||
if (!threadId) {
|
||||
yield* info(`${colors.dim}invalid thread index ${cmd.index}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
scope = threadFrom(scope, thread_id)
|
||||
scope = threadFrom(scope, threadId)
|
||||
yield* info(`${colors.dim}using ${scopeText(scope)}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "active") {
|
||||
yield* sessions.listActive().pipe(
|
||||
yield* threadControl.active.pipe(
|
||||
Effect.matchEffect({
|
||||
onFailure: (error) =>
|
||||
info(`${colors.red}active query failed${colors.reset} ${String(error)}`),
|
||||
|
|
@ -265,14 +222,14 @@ const run = Effect.gen(function* () {
|
|||
}
|
||||
|
||||
if (cmd.kind === "thread") {
|
||||
const selected = fromRef(cmd.thread_id)
|
||||
const selected = fromRef(cmd.threadId)
|
||||
if (selected) {
|
||||
scope = threadFrom(scope, selected)
|
||||
yield* noteThread(selected)
|
||||
yield* info(`${colors.dim}using ${scopeText(scope)}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
if (cmd.thread_id) {
|
||||
if (cmd.threadId) {
|
||||
yield* info(`${colors.dim}invalid thread id/index${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
|
|
@ -286,149 +243,107 @@ const run = Effect.gen(function* () {
|
|||
}
|
||||
|
||||
if (cmd.kind === "status") {
|
||||
const thread_id = pick(cmd.thread_id)
|
||||
if (!thread_id) {
|
||||
const threadId = pick(cmd.threadId)
|
||||
if (!threadId) {
|
||||
yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* noteThread(thread_id)
|
||||
const session = yield* tracked(thread_id)
|
||||
yield* noteThread(threadId)
|
||||
const session = yield* tracked(threadId)
|
||||
if (!session) {
|
||||
yield* info(`${colors.dim}no tracked session for ${thread_id}${colors.reset}`)
|
||||
yield* info(`${colors.dim}no tracked session for ${threadId}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* info(sessionText(thread_id, session))
|
||||
yield* info(sessionText(threadId, session))
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "logs") {
|
||||
const thread_id = pick(cmd.thread_id)
|
||||
if (!thread_id) {
|
||||
const threadId = pick(cmd.threadId)
|
||||
if (!threadId) {
|
||||
yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* noteThread(thread_id)
|
||||
const session = yield* tracked(thread_id)
|
||||
if (!session) {
|
||||
yield* info(`${colors.dim}no tracked session for ${thread_id}${colors.reset}`)
|
||||
yield* noteThread(threadId)
|
||||
const row = yield* threadControl.logs({ threadId, lines: cmd.lines }).pipe(
|
||||
Effect.catchAll(() => Effect.succeed(Option.none())),
|
||||
)
|
||||
if (Option.isNone(row)) {
|
||||
yield* info(`${colors.dim}no tracked session for ${threadId}${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)",
|
||||
),
|
||||
}),
|
||||
yield* block(
|
||||
stamp("logs", colors.blue, `${colors.dim}[${threadId}]${colors.reset}`),
|
||||
row.value.output.trim() || "(empty log)",
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "pause") {
|
||||
const thread_id = pick(cmd.thread_id)
|
||||
if (!thread_id) {
|
||||
const threadId = pick(cmd.threadId)
|
||||
if (!threadId) {
|
||||
yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* noteThread(thread_id)
|
||||
yield* pool.pauseSession(thread_id, "manual-cli").pipe(
|
||||
Effect.matchEffect({
|
||||
onFailure: (error) =>
|
||||
info(`${colors.red}pause failed${colors.reset} ${String(error)}`),
|
||||
onSuccess: () =>
|
||||
info(`${colors.dim}paused ${thread_id}${colors.reset}`),
|
||||
}),
|
||||
yield* noteThread(threadId)
|
||||
const row = yield* threadControl.pause({ threadId, reason: "manual-cli" }).pipe(
|
||||
Effect.catchAll(() => Effect.succeed(Option.none())),
|
||||
)
|
||||
if (Option.isNone(row)) {
|
||||
yield* info(`${colors.dim}no tracked session for ${threadId}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* info(`${colors.dim}paused ${threadId}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "destroy") {
|
||||
const thread_id = pick(cmd.thread_id)
|
||||
if (!thread_id) {
|
||||
if (cmd.kind === "recreate") {
|
||||
const threadId = pick(cmd.threadId)
|
||||
if (!threadId) {
|
||||
yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* noteThread(thread_id)
|
||||
yield* pool.destroySession(thread_id).pipe(
|
||||
yield* noteThread(threadId)
|
||||
yield* threadChat.recreate(threadId).pipe(
|
||||
Effect.matchEffect({
|
||||
onFailure: (error) =>
|
||||
info(`${colors.red}destroy failed${colors.reset} ${String(error)}`),
|
||||
info(`${colors.red}recreate failed${colors.reset} ${String(error)}`),
|
||||
onSuccess: () =>
|
||||
info(`${colors.dim}destroyed ${thread_id}${colors.reset}`),
|
||||
info(`${colors.dim}recreated ${threadId}${colors.reset}`),
|
||||
}),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "resume") {
|
||||
const thread_id = pick(cmd.thread_id)
|
||||
if (!thread_id) {
|
||||
const threadId = pick(cmd.threadId)
|
||||
if (!threadId) {
|
||||
yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* noteThread(thread_id)
|
||||
const session = yield* tracked(thread_id)
|
||||
if (!session) {
|
||||
yield* info(`${colors.dim}no tracked session for ${thread_id}${colors.reset}`)
|
||||
const session = yield* tracked(threadId)
|
||||
if (session === null) {
|
||||
yield* info(`${colors.dim}no tracked session for ${threadId}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* pool.getOrCreate(thread_id, session.channelId, session.guildId).pipe(
|
||||
yield* noteThread(threadId)
|
||||
yield* threadControl.resume({
|
||||
threadId,
|
||||
channelId: session.channelId,
|
||||
guildId: 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)}`),
|
||||
),
|
||||
onSuccess: (current) =>
|
||||
info(
|
||||
`${colors.dim}resumed ${threadId} sandbox=${current.sandboxId} session=${current.sessionId}${colors.reset}`,
|
||||
),
|
||||
}),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd.kind === "restart") {
|
||||
const thread_id = pick(cmd.thread_id)
|
||||
if (!thread_id) {
|
||||
yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* noteThread(thread_id)
|
||||
const session = yield* tracked(thread_id)
|
||||
if (!session) {
|
||||
yield* info(`${colors.dim}no tracked session for ${thread_id}${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
const restarted = yield* daytona.exec(session.sandboxId, "restart-opencode-serve", restart).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catchAll((error) =>
|
||||
info(`${colors.red}restart failed${colors.reset} ${String(error)}`).pipe(Effect.as(false)),
|
||||
),
|
||||
)
|
||||
if (!restarted) return true
|
||||
const healthy = yield* oc.waitForHealthy(PreviewAccess.from(session), config.activeHealthCheckTimeoutMs).pipe(
|
||||
Effect.catchAll(() => Effect.succeed(false)),
|
||||
)
|
||||
if (!healthy) {
|
||||
yield* info(`${colors.red}restart ran, but health check failed${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
yield* info(`${colors.dim}restart complete and healthy${colors.reset}`)
|
||||
return true
|
||||
}
|
||||
|
||||
yield* info(`${colors.dim}unknown command: /${cmd.name}${colors.reset}`)
|
||||
return true
|
||||
})
|
||||
|
|
@ -1,30 +1,8 @@
|
|||
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'
|
||||
import { BunRuntime } from "@effect/platform-bun"
|
||||
import { Duration, Effect, Exit, Fiber, Option, Ref, Schema } from "effect"
|
||||
import { ControlThreadLayer } from "../app/layers"
|
||||
import { ThreadChatCluster, ThreadControlCluster } from "../conversation/thread/cluster"
|
||||
import { ChannelId, GuildId, SandboxId, SessionInfo, ThreadId } from "../types"
|
||||
|
||||
type Opt = Record<string, string | boolean>
|
||||
|
||||
|
|
@ -111,6 +89,7 @@ const flag = (opts: Opt, key: string) => {
|
|||
}
|
||||
|
||||
let ctlSeq = 0
|
||||
let msgSeq = 0
|
||||
const pick = (opts: Opt, active: ReadonlyArray<{ threadId: ThreadId }>) => {
|
||||
const raw = value(opts, "thread")
|
||||
if (raw) return Effect.succeed(ThreadId.make(raw))
|
||||
|
|
@ -119,6 +98,10 @@ const pick = (opts: Opt, active: ReadonlyArray<{ threadId: ThreadId }>) => {
|
|||
ctlSeq += 1
|
||||
return Effect.succeed(ThreadId.make(`ctl-${ctlSeq}`))
|
||||
}
|
||||
const messageId = () => {
|
||||
msgSeq += 1
|
||||
return `ctl-msg-${Date.now()}-${msgSeq}`
|
||||
}
|
||||
|
||||
const print = (ok: boolean, command: string, payload: Record<string, unknown>) =>
|
||||
Effect.sync(() => {
|
||||
|
|
@ -145,49 +128,28 @@ const event = (command: string, name: string, payload: Record<string, unknown>)
|
|||
|
||||
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) =>
|
||||
const threadChat = yield* ThreadChatCluster
|
||||
const threadControl = yield* ThreadControlCluster
|
||||
const active = yield* threadControl.active
|
||||
const status = (threadId: ThreadId) =>
|
||||
threadChat.status(threadId).pipe(
|
||||
Effect.catchAll((cause) => internal(cause)),
|
||||
)
|
||||
const context = (threadId: ThreadId, opts: Opt) =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* pool.getTrackedSession(thread_id)
|
||||
if (Option.isSome(row)) return row.value
|
||||
return yield* usage(`no tracked session for thread ${thread_id}`)
|
||||
})
|
||||
const resolve = (thread_id: ThreadId, opts: Opt): Effect.Effect<SessionInfo, CtlUsageError | CtlInternalError> =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* pool.getTrackedSession(thread_id).pipe(
|
||||
Effect.catchAll((cause) =>
|
||||
internal(cause),
|
||||
),
|
||||
)
|
||||
const row = yield* status(threadId)
|
||||
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),
|
||||
),
|
||||
)
|
||||
return {
|
||||
channelId: row.value.channelId,
|
||||
guildId: row.value.guildId,
|
||||
}
|
||||
}
|
||||
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),
|
||||
),
|
||||
)
|
||||
return {
|
||||
channelId: ChannelId.make(channel),
|
||||
guildId: GuildId.make(guild),
|
||||
}
|
||||
})
|
||||
|
||||
if (ctl.cmd === "help") {
|
||||
|
|
@ -197,9 +159,8 @@ const run = Effect.gen(function* () {
|
|||
"conversation:ctl status --thread <id>",
|
||||
"conversation:ctl logs --thread <id> [--lines 120]",
|
||||
"conversation:ctl pause --thread <id>",
|
||||
"conversation:ctl destroy --thread <id>",
|
||||
"conversation:ctl recreate --thread <id>",
|
||||
"conversation:ctl resume --thread <id> [--channel <id> --guild <id>]",
|
||||
"conversation:ctl restart --thread <id>",
|
||||
"conversation:ctl send --thread <id> --text <message> [--follow --wait-ms 180000 --logs-every-ms 2000 --lines 80]",
|
||||
],
|
||||
})
|
||||
|
|
@ -222,62 +183,69 @@ const run = Effect.gen(function* () {
|
|||
}
|
||||
|
||||
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,
|
||||
})),
|
||||
)
|
||||
const threadId = yield* pick(ctl.opts, active)
|
||||
const row = yield* status(threadId)
|
||||
return yield* print(true, ctl.cmd, {
|
||||
threadId: threadId,
|
||||
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 threadId = 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}`,
|
||||
const row = yield* threadControl.logs({ threadId, lines }).pipe(
|
||||
Effect.catchAll((cause) => internal(cause)),
|
||||
)
|
||||
if (Option.isNone(row)) return yield* usage(`no tracked session for thread ${threadId}`)
|
||||
return yield* print(true, ctl.cmd, {
|
||||
threadId: thread_id,
|
||||
sandboxId: row.sandboxId,
|
||||
threadId: threadId,
|
||||
sandboxId: row.value.sandboxId,
|
||||
lines,
|
||||
output: out.output,
|
||||
output: row.value.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 })
|
||||
const threadId = yield* pick(ctl.opts, active)
|
||||
const row = yield* threadControl.pause({ threadId, reason: "manual-ctl" }).pipe(
|
||||
Effect.catchAll((cause) => internal(cause)),
|
||||
)
|
||||
if (Option.isNone(row)) return yield* usage(`no tracked session for thread ${threadId}`)
|
||||
return yield* print(true, ctl.cmd, { threadId: threadId })
|
||||
}
|
||||
|
||||
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 === "recreate" || ctl.cmd === "destroy") {
|
||||
const threadId = yield* pick(ctl.opts, active)
|
||||
yield* threadChat.recreate(threadId)
|
||||
return yield* print(true, ctl.cmd, { threadId: threadId })
|
||||
}
|
||||
|
||||
if (ctl.cmd === "resume") {
|
||||
const thread_id = yield* pick(ctl.opts, active)
|
||||
const row = yield* resolve(thread_id, ctl.opts)
|
||||
const threadId = yield* pick(ctl.opts, active)
|
||||
const channel = value(ctl.opts, "channel")
|
||||
const guild = value(ctl.opts, "guild")
|
||||
const row = yield* threadControl.resume({
|
||||
threadId,
|
||||
channelId: channel ? ChannelId.make(channel) : null,
|
||||
guildId: guild ? GuildId.make(guild) : null,
|
||||
}).pipe(
|
||||
Effect.catchAll((cause) => internal(cause)),
|
||||
)
|
||||
return yield* print(true, ctl.cmd, {
|
||||
threadId: thread_id,
|
||||
threadId: threadId,
|
||||
session: {
|
||||
sandboxId: row.sandboxId,
|
||||
sessionId: row.sessionId,
|
||||
|
|
@ -286,22 +254,8 @@ const run = Effect.gen(function* () {
|
|||
})
|
||||
}
|
||||
|
||||
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 threadId = 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 \"...\")")
|
||||
|
|
@ -312,19 +266,23 @@ const run = Effect.gen(function* () {
|
|||
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()
|
||||
const input = yield* context(threadId, ctl.opts)
|
||||
const reply = yield* threadChat.send({
|
||||
threadId,
|
||||
channelId: input.channelId,
|
||||
guildId: input.guildId,
|
||||
messageId: messageId(),
|
||||
text: message,
|
||||
})
|
||||
return yield* print(true, ctl.cmd, {
|
||||
threadId: thread_id,
|
||||
sandboxId: current.sandboxId,
|
||||
sessionId: current.sessionId,
|
||||
reply,
|
||||
threadId: threadId,
|
||||
sandboxId: reply.session.sandboxId,
|
||||
sessionId: reply.session.sessionId,
|
||||
reply: reply.text,
|
||||
})
|
||||
}
|
||||
|
||||
const known = yield* pool.getTrackedSession(thread_id).pipe(
|
||||
const known = yield* threadChat.status(threadId).pipe(
|
||||
Effect.catchAll(() => Effect.succeed(Option.none())),
|
||||
)
|
||||
const sandbox = yield* Ref.make<SandboxId | null>(Option.isSome(known) ? known.value.sandboxId : null)
|
||||
|
|
@ -333,17 +291,21 @@ const run = Effect.gen(function* () {
|
|||
|
||||
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 }
|
||||
const input = yield* context(threadId, ctl.opts)
|
||||
const reply = yield* threadChat.send({
|
||||
threadId,
|
||||
channelId: input.channelId,
|
||||
guildId: input.guildId,
|
||||
messageId: messageId(),
|
||||
text: message,
|
||||
})
|
||||
yield* Ref.set(sandbox, reply.session.sandboxId)
|
||||
return { row: reply.session, reply: reply.text }
|
||||
}),
|
||||
)
|
||||
|
||||
yield* event(ctl.cmd, "started", {
|
||||
threadId: thread_id,
|
||||
threadId: threadId,
|
||||
waitMs: wait,
|
||||
logsEveryMs: every,
|
||||
lines,
|
||||
|
|
@ -367,7 +329,7 @@ const run = Effect.gen(function* () {
|
|||
const sandboxId = yield* Ref.get(sandbox)
|
||||
if (!sandboxId) {
|
||||
yield* event(ctl.cmd, "progress", {
|
||||
threadId: thread_id,
|
||||
threadId: threadId,
|
||||
elapsedMs: elapsed,
|
||||
stage: "resolving-session",
|
||||
})
|
||||
|
|
@ -375,29 +337,23 @@ const run = Effect.gen(function* () {
|
|||
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 output = yield* threadControl.logs({ threadId, lines }).pipe(
|
||||
Effect.map((row) => Option.isSome(row) ? row.value.output : "(no tracked session)"),
|
||||
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,
|
||||
threadId: threadId,
|
||||
elapsedMs: elapsed,
|
||||
sandboxId,
|
||||
logs: output,
|
||||
})
|
||||
} else {
|
||||
yield* event(ctl.cmd, "progress", {
|
||||
threadId: thread_id,
|
||||
threadId: threadId,
|
||||
elapsedMs: elapsed,
|
||||
sandboxId,
|
||||
logs: "(no change)",
|
||||
|
|
@ -410,7 +366,7 @@ const run = Effect.gen(function* () {
|
|||
|
||||
const result = yield* loop()
|
||||
return yield* print(true, ctl.cmd, {
|
||||
threadId: thread_id,
|
||||
threadId: threadId,
|
||||
sandboxId: result.row.sandboxId,
|
||||
sessionId: result.row.sessionId,
|
||||
reply: result.reply,
|
||||
|
|
@ -430,7 +386,7 @@ const run = Effect.gen(function* () {
|
|||
)
|
||||
|
||||
run.pipe(
|
||||
Effect.provide(CoreLayer),
|
||||
Effect.provide(ControlThreadLayer),
|
||||
Effect.scoped,
|
||||
BunRuntime.runMain,
|
||||
)
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
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"
|
||||
import { ThreadChatCluster, ThreadChatError } from "../conversation/thread/cluster"
|
||||
import { TurnRouter, TurnRoutingDecision } from "../discord/turn-routing"
|
||||
import { effectTest, testConfigLayer } from "../test/effect"
|
||||
import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, ThreadId } from "../types"
|
||||
import { Conversation } from "../conversation/conversation"
|
||||
import { IngressDedup } from "../conversation/dedup"
|
||||
import { makeTui } from "./local-adapter"
|
||||
|
||||
const makeSession = (id: string) =>
|
||||
SessionInfo.make({
|
||||
|
|
@ -31,42 +31,34 @@ const routerLayer = Layer.succeed(
|
|||
}),
|
||||
)
|
||||
|
||||
const makePoolLayer = (opts: {
|
||||
getOrCreate?: ThreadAgentPool.Service["getOrCreate"]
|
||||
const makeThreadChatLayer = (opts: {
|
||||
send?: (prompt: string) => string
|
||||
seen?: Array<string>
|
||||
gate?: Deferred.Deferred<void>
|
||||
}) => {
|
||||
const session = makeSession("s1")
|
||||
const seen = opts.seen ?? []
|
||||
const defaultGetOrCreate: ThreadAgentPool.Service["getOrCreate"] = () =>
|
||||
Effect.gen(function* () {
|
||||
if (opts.gate) yield* Deferred.await(opts.gate)
|
||||
return {
|
||||
threadId: session.threadId,
|
||||
session,
|
||||
current: () => Effect.succeed(session),
|
||||
send: (prompt: string) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(prompt)
|
||||
return opts.send ? opts.send(prompt) : `local:${prompt}`
|
||||
}),
|
||||
pause: () => Effect.void,
|
||||
destroy: () => Effect.void,
|
||||
} satisfies ThreadAgent
|
||||
})
|
||||
|
||||
return Layer.succeed(
|
||||
ThreadAgentPool,
|
||||
ThreadAgentPool.of({
|
||||
getOrCreate: opts.getOrCreate ?? defaultGetOrCreate,
|
||||
hasTrackedThread: () => Effect.succeed(true),
|
||||
getTrackedSession: () => Effect.succeed(Option.none()),
|
||||
getActiveSessionCount: () => Effect.succeed(0),
|
||||
pauseSession: () => Effect.void,
|
||||
destroySession: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
return Layer.succeed(ThreadChatCluster, ThreadChatCluster.of({
|
||||
send: (input) =>
|
||||
Effect.gen(function* () {
|
||||
if (opts.gate) yield* Deferred.await(opts.gate)
|
||||
seen.push(input.text)
|
||||
return {
|
||||
text: opts.send ? opts.send(input.text) : `local:${input.text}`,
|
||||
session,
|
||||
changedSession: false,
|
||||
}
|
||||
}).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ThreadChatError.make({
|
||||
threadId: input.threadId,
|
||||
cause,
|
||||
retriable: false,
|
||||
})),
|
||||
),
|
||||
status: () => Effect.succeed(Option.some(session)),
|
||||
recreate: () => Effect.void,
|
||||
}))
|
||||
}
|
||||
|
||||
describe("makeTui", () => {
|
||||
|
|
@ -74,13 +66,13 @@ describe("makeTui", () => {
|
|||
Effect.gen(function* () {
|
||||
const seen: Array<string> = []
|
||||
const tui = yield* makeTui
|
||||
const poolLayer = makePoolLayer({ seen })
|
||||
const threadChatLayer = makeThreadChatLayer({ seen })
|
||||
|
||||
const live = Conversation.layer.pipe(
|
||||
Layer.provideMerge(tui.layer),
|
||||
Layer.provideMerge(ConversationLedger.noop),
|
||||
Layer.provideMerge(IngressDedup.noop),
|
||||
Layer.provideMerge(routerLayer),
|
||||
Layer.provideMerge(poolLayer),
|
||||
Layer.provideMerge(threadChatLayer),
|
||||
Layer.provideMerge(testConfigLayer),
|
||||
)
|
||||
|
||||
|
|
@ -96,7 +88,7 @@ describe("makeTui", () => {
|
|||
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)
|
||||
expect(/^thread-[a-z]+-[a-z]+-\d+$/.test(String(second.threadId))).toBe(true)
|
||||
if (second.kind === "send") expect(second.text).toBe("local:hello local")
|
||||
}).pipe(Effect.provide(live))
|
||||
}),
|
||||
|
|
@ -106,13 +98,13 @@ describe("makeTui", () => {
|
|||
Effect.gen(function* () {
|
||||
const gate = yield* Deferred.make<void>()
|
||||
const tui = yield* makeTui
|
||||
const poolLayer = makePoolLayer({ gate })
|
||||
const threadChatLayer = makeThreadChatLayer({ gate })
|
||||
|
||||
const live = Conversation.layer.pipe(
|
||||
Layer.provideMerge(tui.layer),
|
||||
Layer.provideMerge(ConversationLedger.noop),
|
||||
Layer.provideMerge(IngressDedup.noop),
|
||||
Layer.provideMerge(routerLayer),
|
||||
Layer.provideMerge(poolLayer),
|
||||
Layer.provideMerge(threadChatLayer),
|
||||
Layer.provideMerge(testConfigLayer),
|
||||
)
|
||||
|
||||
|
|
@ -134,13 +126,13 @@ describe("makeTui", () => {
|
|||
effectTest("channel messages create distinct threads", () =>
|
||||
Effect.gen(function* () {
|
||||
const tui = yield* makeTui
|
||||
const poolLayer = makePoolLayer({})
|
||||
const threadChatLayer = makeThreadChatLayer({})
|
||||
|
||||
const live = Conversation.layer.pipe(
|
||||
Layer.provideMerge(tui.layer),
|
||||
Layer.provideMerge(ConversationLedger.noop),
|
||||
Layer.provideMerge(IngressDedup.noop),
|
||||
Layer.provideMerge(routerLayer),
|
||||
Layer.provideMerge(poolLayer),
|
||||
Layer.provideMerge(threadChatLayer),
|
||||
Layer.provideMerge(testConfigLayer),
|
||||
)
|
||||
|
||||
|
|
@ -161,7 +153,7 @@ describe("makeTui", () => {
|
|||
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)
|
||||
expect(firstTyping.threadId === secondTyping.threadId).toBe(false)
|
||||
}
|
||||
}).pipe(Effect.provide(live))
|
||||
}),
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
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"
|
||||
import { TYPING_INTERVAL } from "../discord/constants"
|
||||
import { ChannelId, GuildId, ThreadId } from "../types"
|
||||
import { ChannelMessage, Mention, ThreadMessage, ThreadRef, Typing, type Action, type Inbound } from "../conversation/model/schema"
|
||||
import { History, Inbox, Outbox, Threads } from "../conversation"
|
||||
|
||||
export type Tui = {
|
||||
layer: Layer.Layer<Inbox | Outbox | History | Threads, never, never>
|
||||
send: (text: string) => Effect.Effect<void>
|
||||
sendTo: (thread_id: ThreadId, text: string) => Effect.Effect<void>
|
||||
sendTo: (threadId: ThreadId, text: string) => Effect.Effect<void>
|
||||
take: Effect.Effect<Action>
|
||||
actions: Stream.Stream<Action>
|
||||
}
|
||||
|
|
@ -33,32 +33,32 @@ export const makeTui = Effect.gen(function* () {
|
|||
return ThreadId.make(`thread-${x}-${y}-${z}`)
|
||||
}
|
||||
|
||||
const remember = (thread_id: ThreadId, line: string) => {
|
||||
const current = history.get(thread_id)
|
||||
const remember = (threadId: ThreadId, line: string) => {
|
||||
const current = history.get(threadId)
|
||||
if (current) {
|
||||
current.push(line)
|
||||
return
|
||||
}
|
||||
history.set(thread_id, [line])
|
||||
history.set(threadId, [line])
|
||||
}
|
||||
|
||||
const sendTo = (thread_id: ThreadId, text: string) =>
|
||||
const sendTo = (threadId: ThreadId, text: string) =>
|
||||
Effect.gen(function* () {
|
||||
remember(thread_id, `user: ${text}`)
|
||||
const channel_id = parents.get(thread_id) ?? ChannelId.make(`channel-${thread_id}`)
|
||||
remember(threadId, `user: ${text}`)
|
||||
const channelId = parents.get(threadId) ?? ChannelId.make(`channel-${threadId}`)
|
||||
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: [] }),
|
||||
threadId,
|
||||
channelId,
|
||||
messageId: crypto.randomUUID(),
|
||||
guildId: GuildId.make("local"),
|
||||
botUserId: "local-bot",
|
||||
botRoleId: "",
|
||||
authorId: "local-user",
|
||||
authorIsBot: false,
|
||||
mentionsEveryone: false,
|
||||
mentions: Mention.make({ userIds: [], roleIds: [] }),
|
||||
content: text,
|
||||
}),
|
||||
).pipe(Effect.asVoid)
|
||||
|
|
@ -66,19 +66,19 @@ export const makeTui = Effect.gen(function* () {
|
|||
|
||||
const send = (text: string) =>
|
||||
Effect.gen(function* () {
|
||||
const channel_id = ChannelId.make("local-channel")
|
||||
const channelId = 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: [] }),
|
||||
channelId,
|
||||
messageId: crypto.randomUUID(),
|
||||
guildId: GuildId.make("local"),
|
||||
botUserId: "local-bot",
|
||||
botRoleId: "",
|
||||
authorId: "local-user",
|
||||
authorIsBot: false,
|
||||
mentionsEveryone: false,
|
||||
mentions: Mention.make({ userIds: ["local-bot"], roleIds: [] }),
|
||||
content: text,
|
||||
}),
|
||||
).pipe(Effect.asVoid)
|
||||
|
|
@ -97,17 +97,17 @@ export const makeTui = Effect.gen(function* () {
|
|||
publish: (action) =>
|
||||
Effect.gen(function* () {
|
||||
if (action.kind === "send" || action.kind === "reply") {
|
||||
remember(action.thread_id, `assistant: ${action.text}`)
|
||||
remember(action.threadId, `assistant: ${action.text}`)
|
||||
}
|
||||
yield* output.offer(action).pipe(Effect.asVoid)
|
||||
}),
|
||||
withTyping: <A, E, R>(thread_id: ThreadId, self: Effect.Effect<A, E, R>) =>
|
||||
withTyping: <A, E, R>(threadId: ThreadId, self: Effect.Effect<A, E, R>) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const pulse = output.offer(
|
||||
Typing.make({
|
||||
kind: "typing",
|
||||
thread_id,
|
||||
threadId,
|
||||
}),
|
||||
).pipe(Effect.asVoid)
|
||||
yield* pulse
|
||||
|
|
@ -124,9 +124,9 @@ export const makeTui = Effect.gen(function* () {
|
|||
Layer.succeed(
|
||||
History,
|
||||
History.of({
|
||||
rehydrate: (thread_id, latest: string) =>
|
||||
rehydrate: (threadId, latest: string) =>
|
||||
Effect.sync(() => {
|
||||
const lines = history.get(thread_id) ?? []
|
||||
const lines = history.get(threadId) ?? []
|
||||
const prior = lines.at(-1) === `user: ${latest}` ? lines.slice(0, -1) : lines
|
||||
if (prior.length === 0) return latest
|
||||
return [
|
||||
|
|
@ -145,15 +145,15 @@ export const makeTui = Effect.gen(function* () {
|
|||
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 })
|
||||
parents.set(event.threadId, event.channelId)
|
||||
return ThreadRef.make({ threadId: event.threadId, channelId: event.channelId })
|
||||
}
|
||||
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 })
|
||||
const known = roots.get(event.messageId)
|
||||
if (known) return ThreadRef.make({ threadId: known, channelId: event.channelId })
|
||||
const threadId = name()
|
||||
roots.set(event.messageId, threadId)
|
||||
parents.set(threadId, event.channelId)
|
||||
return ThreadRef.make({ threadId, channelId: event.channelId })
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import { ThreadId } from "../../types"
|
||||
import { Send, Typing } from "../model/schema"
|
||||
import { ThreadId } from "../types"
|
||||
import { Send, Typing } from "../conversation/model/schema"
|
||||
import { autoThread, base, channelFrom, parse, prompt, queueTarget, scopeText, threadFrom } from "./state"
|
||||
|
||||
describe("cli-state", () => {
|
||||
|
|
@ -12,18 +12,18 @@ describe("cli-state", () => {
|
|||
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("/thread")).toEqual({ kind: "thread", threadId: null })
|
||||
expect(parse("/thread abc")).toEqual({ kind: "thread", threadId: ThreadId.make("abc") })
|
||||
expect(parse("/status")).toEqual({ kind: "status", threadId: null })
|
||||
expect(parse("/status abc")).toEqual({ kind: "status", threadId: ThreadId.make("abc") })
|
||||
expect(parse("/logs")).toEqual({ kind: "logs", lines: 120, threadId: null })
|
||||
expect(parse("/logs 80")).toEqual({ kind: "logs", lines: 80, threadId: null })
|
||||
expect(parse("/logs abc")).toEqual({ kind: "logs", lines: 120, threadId: ThreadId.make("abc") })
|
||||
expect(parse("/logs 80 abc")).toEqual({ kind: "logs", lines: 80, threadId: ThreadId.make("abc") })
|
||||
expect(parse("/pause")).toEqual({ kind: "pause", threadId: null })
|
||||
expect(parse("/recreate")).toEqual({ kind: "recreate", threadId: null })
|
||||
expect(parse("/destroy")).toEqual({ kind: "recreate", threadId: null })
|
||||
expect(parse("/resume")).toEqual({ kind: "resume", threadId: null })
|
||||
expect(parse("/nope")).toEqual({ kind: "unknown", name: "nope" })
|
||||
})
|
||||
|
||||
|
|
@ -41,8 +41,8 @@ describe("cli-state", () => {
|
|||
|
||||
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" })
|
||||
const typing = Typing.make({ kind: "typing", threadId: ThreadId.make("t-a") })
|
||||
const send = Send.make({ kind: "send", threadId: 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")))
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { ThreadId } from "../../types"
|
||||
import type { Action } from "../model/schema"
|
||||
import { ThreadId } from "../types"
|
||||
import type { Action } from "../conversation/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 }
|
||||
| { kind: "channel"; channelId: typeof LOCAL_CHANNEL }
|
||||
| { kind: "thread"; threadId: ThreadId; channelId: typeof LOCAL_CHANNEL }
|
||||
|
||||
export type Command =
|
||||
| { kind: "channel" }
|
||||
|
|
@ -13,16 +13,15 @@ export type Command =
|
|||
| { 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: "thread"; threadId: ThreadId | null }
|
||||
| { kind: "status"; threadId: ThreadId | null }
|
||||
| { kind: "logs"; threadId: ThreadId | null; lines: number }
|
||||
| { kind: "pause"; threadId: ThreadId | null }
|
||||
| { kind: "recreate"; threadId: ThreadId | null }
|
||||
| { kind: "resume"; threadId: ThreadId | null }
|
||||
| { kind: "unknown"; name: string }
|
||||
|
||||
export const base = (): Scope => ({ kind: "channel", channel_id: LOCAL_CHANNEL })
|
||||
export const base = (): Scope => ({ kind: "channel", channelId: LOCAL_CHANNEL })
|
||||
|
||||
const target = (value: string | undefined) => {
|
||||
const raw = value?.trim() ?? ""
|
||||
|
|
@ -56,49 +55,48 @@ export const parse = (line: string): Command | null => {
|
|||
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 === "thread") return { kind: "thread", threadId: target(args.at(0)) }
|
||||
if (head === "status") return { kind: "status", threadId: 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: 120, threadId: target(args.at(0)) }
|
||||
}
|
||||
return { kind: "logs", lines, thread_id: target(args.at(1)) }
|
||||
return { kind: "logs", lines, threadId: 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)) }
|
||||
if (head === "pause") return { kind: "pause", threadId: target(args.at(0)) }
|
||||
if (head === "recreate" || head === "destroy") return { kind: "recreate", threadId: target(args.at(0)) }
|
||||
if (head === "resume") return { kind: "resume", threadId: 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}`
|
||||
? `channel:${scope.channelId}`
|
||||
: `thread:${scope.threadId}`
|
||||
|
||||
export const prompt = (scope: Scope) => scope.kind === "channel"
|
||||
? "channel> "
|
||||
: `thread:${scope.thread_id}> `
|
||||
: `thread:${scope.threadId}> `
|
||||
|
||||
export const queueTarget = (scope: Scope) => scope.kind === "channel" ? "channel" : "thread"
|
||||
|
||||
export const threadFrom = (scope: Scope, thread_id: ThreadId): Scope => ({
|
||||
export const threadFrom = (scope: Scope, threadId: ThreadId): Scope => ({
|
||||
kind: "thread",
|
||||
channel_id: scope.channel_id,
|
||||
thread_id,
|
||||
channelId: scope.channelId,
|
||||
threadId,
|
||||
})
|
||||
|
||||
export const channelFrom = (scope: Scope): Scope => ({
|
||||
kind: "channel",
|
||||
channel_id: scope.channel_id,
|
||||
channelId: scope.channelId,
|
||||
})
|
||||
|
||||
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)
|
||||
return threadFrom(scope, action.threadId)
|
||||
}
|
||||
|
|
@ -1,51 +1,25 @@
|
|||
# Conversation Experiment
|
||||
# Conversation Runtime
|
||||
|
||||
This folder contains the active Discord conversation runtime.
|
||||
This folder contains the active conversation runtime used by the Discord bot.
|
||||
|
||||
Goal:
|
||||
## Active layout
|
||||
|
||||
- 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
|
||||
- `model/*`: normalized inbound events + outbound actions and conversation error model.
|
||||
- `conversation.ts`: core orchestration loop.
|
||||
- `inbox.ts`, `outbox.ts`, `threads.ts`, `history.ts`, `dedup.ts`, `offsets.ts`: service contracts used by the runtime.
|
||||
- `thread/cluster/*`: thread-scoped cluster runtime (`ThreadEntity`, `ThreadChatCluster`, `ThreadControlCluster`) for send/status/recreate/control.
|
||||
- `../discord/adapter.ts`: Discord adapter implementation for inbox/outbox/thread/history ports.
|
||||
- `../control/*`: local CLI/controller tooling.
|
||||
|
||||
Current status:
|
||||
## Current execution path
|
||||
|
||||
- `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
|
||||
1. `Conversation.run` consumes `Inbox.events`.
|
||||
2. `Conversation.turn` routes and resolves a thread target.
|
||||
3. `Conversation` calls `ThreadChatCluster.send`.
|
||||
4. `ThreadEntity` handles per-thread lifecycle/recovery/idempotent send.
|
||||
5. `Outbox` publishes `typing` / `send` actions.
|
||||
|
||||
This module is wired into `src/index.ts`.
|
||||
## Notes
|
||||
|
||||
Reliability semantics:
|
||||
|
||||
- in-memory dedup by `message_id` prevents double-processing within a session
|
||||
- startup catch-up fetches missed Discord messages from tracked thread sources and allowed channels using persisted offsets
|
||||
- Discord is the durable inbox — on startup we resume from where we left off per source
|
||||
|
||||
Local CLI notes (`bun run conversation:cli`):
|
||||
|
||||
- `typing` now emits as soon as a target thread is resolved (before sandbox/session resolution), so startup latency is visible.
|
||||
- channel and thread modes are explicit:
|
||||
- `/channel` routes to top-level channel mode
|
||||
- `/thread [id|n]` routes directly to thread mode (`n` is 1-based index from `/threads`; without arg, uses last seen thread)
|
||||
- `/threads` lists known thread ids with indexes
|
||||
- `/pick [n]` shows/selects a thread by index
|
||||
- auto-switch from channel mode now only follows newly-seen threads (prevents jumping to old threads still emitting output)
|
||||
- local thread simulation now mirrors Discord intent: each channel-mode message creates a new thread root, while explicit thread mode continues an existing thread
|
||||
- local thread ids are human-readable (`thread-adjective-noun-n`) to make `/threads` easy to scan
|
||||
|
||||
Agent CLI notes (`bun run conversation:controller` or `bun run conversation:ctl`):
|
||||
|
||||
- non-interactive JSON output for automation (`ok: true/false`)
|
||||
- commands:
|
||||
- `active`
|
||||
- `status --thread <id>`
|
||||
- `logs --thread <id> [--lines 120]`
|
||||
- `pause --thread <id>`
|
||||
- `destroy --thread <id>`
|
||||
- `resume --thread <id> [--channel <id> --guild <id>]`
|
||||
- `restart --thread <id>`
|
||||
- `send --thread <id> --text "<message>" [--follow --wait-ms 180000 --logs-every-ms 2000 --lines 80]`
|
||||
- This module is wired from `packages/discord/src/index.ts`.
|
||||
- Per-thread send idempotency is keyed by `messageId` in `thread/cluster` `send` RPC.
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
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 { Deferred, Effect, Fiber, Layer, Option, Predicate, Stream } from "effect"
|
||||
import { ThreadChatCluster, ThreadChatError } from "./thread/cluster"
|
||||
import { TurnRouter, TurnRoutingDecision } from "../discord/turn-routing"
|
||||
import { classifyOpenCodeFailure, DatabaseError, OpenCodeClientError, SandboxDeadError } from "../errors"
|
||||
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 { Inbox } from "./inbox"
|
||||
import { ConversationLedger } from "./ledger"
|
||||
import { IngressDedup } from "./dedup"
|
||||
import { Outbox } from "./outbox"
|
||||
import { Threads } from "./threads"
|
||||
import { Conversation } from "./conversation"
|
||||
|
|
@ -35,16 +34,16 @@ const makeThreadEvent = (props: {
|
|||
}) =>
|
||||
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: [] }),
|
||||
threadId: ThreadId.make(props.threadId),
|
||||
channelId: ChannelId.make(props.channelId),
|
||||
messageId: props.messageId,
|
||||
guildId: GuildId.make("g1"),
|
||||
botUserId: "bot-1",
|
||||
botRoleId: "role-1",
|
||||
authorId: "u1",
|
||||
authorIsBot: false,
|
||||
mentionsEveryone: false,
|
||||
mentions: Mention.make({ userIds: [], roleIds: [] }),
|
||||
content: props.content,
|
||||
})
|
||||
|
||||
|
|
@ -53,15 +52,15 @@ const makeEvent = (content: string) =>
|
|||
|
||||
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: [] }),
|
||||
channelId: ChannelId.make("c-root"),
|
||||
messageId: "m-root",
|
||||
guildId: GuildId.make("g1"),
|
||||
botUserId: "bot-1",
|
||||
botRoleId: "role-1",
|
||||
authorId: "u1",
|
||||
authorIsBot: false,
|
||||
mentionsEveryone: false,
|
||||
mentions: Mention.make({ userIds: ["bot-1"], roleIds: [] }),
|
||||
content,
|
||||
})
|
||||
|
||||
|
|
@ -80,28 +79,6 @@ const makeRouterLayer = (shouldRespond: boolean) =>
|
|||
}),
|
||||
)
|
||||
|
||||
const makeAgent = (
|
||||
session: SessionInfo,
|
||||
send: (
|
||||
session: SessionInfo,
|
||||
text: string,
|
||||
) => Effect.Effect<string, OpenCodeClientError | SandboxDeadError | DatabaseError>,
|
||||
prompts: Array<string>,
|
||||
): ThreadAgent => ({
|
||||
threadId: session.threadId,
|
||||
session,
|
||||
current: () => Effect.succeed(session),
|
||||
send: (text: string) =>
|
||||
Effect.sync(() => {
|
||||
prompts.push(text)
|
||||
return { session, text }
|
||||
}).pipe(
|
||||
Effect.flatMap(({ session, text }) => send(session, text)),
|
||||
),
|
||||
pause: () => Effect.void,
|
||||
destroy: () => Effect.void,
|
||||
})
|
||||
|
||||
const makeConversationLayer = (props: {
|
||||
events: ReadonlyArray<Inbound>
|
||||
tracked: Option.Option<SessionInfo>
|
||||
|
|
@ -115,8 +92,11 @@ const makeConversationLayer = (props: {
|
|||
shouldRespond?: boolean
|
||||
actions: Array<Action>
|
||||
prompts: Array<string>
|
||||
recreateCalls?: Array<ThreadId>
|
||||
}) => {
|
||||
const resolveIndex = { value: 0 }
|
||||
const state = new Map<string, SessionInfo>()
|
||||
if (Option.isSome(props.tracked)) state.set(props.tracked.value.threadId, props.tracked.value)
|
||||
|
||||
const inboxLayer = Layer.succeed(
|
||||
Inbox,
|
||||
|
|
@ -132,12 +112,12 @@ const makeConversationLayer = (props: {
|
|||
Effect.sync(() => {
|
||||
props.actions.push(action)
|
||||
}),
|
||||
withTyping: <A, E, R>(thread_id: ThreadId, self: Effect.Effect<A, E, R>) =>
|
||||
withTyping: <A, E, R>(threadId: ThreadId, self: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
props.actions.push(
|
||||
Typing.make({
|
||||
kind: "typing",
|
||||
thread_id,
|
||||
threadId,
|
||||
}),
|
||||
)
|
||||
return yield* self
|
||||
|
|
@ -145,21 +125,14 @@ const makeConversationLayer = (props: {
|
|||
}),
|
||||
)
|
||||
|
||||
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({ threadId: event.threadId, channelId: event.channelId }))
|
||||
}
|
||||
return Effect.succeed(ThreadRef.make({ thread_id: ThreadId.make("t-new"), channel_id: event.channel_id }))
|
||||
return Effect.succeed(ThreadRef.make({ threadId: ThreadId.make("t-new"), channelId: event.channelId }))
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
|
@ -172,29 +145,69 @@ const makeConversationLayer = (props: {
|
|||
return session
|
||||
}
|
||||
|
||||
const poolLayer = Layer.succeed(
|
||||
ThreadAgentPool,
|
||||
ThreadAgentPool.of({
|
||||
getOrCreate: (threadId, channelId, guildId) =>
|
||||
const toThreadError = (threadId: ThreadId, cause: unknown): ThreadChatError => {
|
||||
if (cause instanceof ThreadChatError) return cause
|
||||
return ThreadChatError.make({
|
||||
threadId,
|
||||
cause,
|
||||
retriable: Predicate.isTagged(cause, "SandboxDeadError") || Predicate.isTagged(cause, "OpenCodeClientError"),
|
||||
})
|
||||
}
|
||||
|
||||
const threadChatLayer = Layer.succeed(
|
||||
ThreadChatCluster,
|
||||
ThreadChatCluster.of({
|
||||
send: (input) =>
|
||||
Effect.sync(() => {
|
||||
const session = resolveSession(threadId, channelId, guildId)
|
||||
return makeAgent(session, props.send, props.prompts)
|
||||
props.prompts.push(input.text)
|
||||
const session = resolveSession(input.threadId, input.channelId, input.guildId)
|
||||
return session
|
||||
}).pipe(
|
||||
Effect.flatMap((session) =>
|
||||
props.send(session, input.text).pipe(
|
||||
Effect.map((text) => {
|
||||
const prev = state.get(input.threadId)
|
||||
state.set(input.threadId, session)
|
||||
return {
|
||||
text,
|
||||
session,
|
||||
changedSession: prev ? prev.sessionId !== session.sessionId : true,
|
||||
}
|
||||
}),
|
||||
Effect.mapError((cause) => toThreadError(input.threadId, cause)),
|
||||
)),
|
||||
),
|
||||
status: (threadId) =>
|
||||
Effect.succeed(
|
||||
Option.fromNullable(
|
||||
state.get(threadId) ?? SessionInfo.make({
|
||||
threadId,
|
||||
channelId: ChannelId.make("c1"),
|
||||
guildId: GuildId.make("g1"),
|
||||
sandboxId: SandboxId.make("sb1"),
|
||||
sessionId: SessionId.make("s-tracked"),
|
||||
previewUrl: "https://preview",
|
||||
previewToken: null,
|
||||
status: "active",
|
||||
lastError: null,
|
||||
resumeFailCount: 0,
|
||||
}),
|
||||
),
|
||||
),
|
||||
recreate: (threadId) =>
|
||||
Effect.sync(() => {
|
||||
props.recreateCalls?.push(threadId)
|
||||
state.delete(threadId)
|
||||
}),
|
||||
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(IngressDedup.noop),
|
||||
Layer.provideMerge(threadsLayer),
|
||||
Layer.provideMerge(poolLayer),
|
||||
Layer.provideMerge(threadChatLayer),
|
||||
Layer.provideMerge(makeRouterLayer(props.shouldRespond ?? true)),
|
||||
Layer.provideMerge(testConfigLayer),
|
||||
)
|
||||
|
|
@ -223,12 +236,12 @@ describe("Conversation", () => {
|
|||
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"))
|
||||
expect(sent.threadId).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", () => {
|
||||
effectTest("turn sends thread prompt through ThreadChatCluster", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const live = makeConversationLayer({
|
||||
|
|
@ -245,17 +258,17 @@ describe("Conversation", () => {
|
|||
const conversation = yield* Conversation
|
||||
yield* conversation.turn(makeEvent("help me"))
|
||||
|
||||
expect(prompts).toEqual(["rehydrated:help me"])
|
||||
expect(prompts).toEqual(["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"))
|
||||
expect(sent.threadId).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", () => {
|
||||
effectTest("turn returns retriable error for dead sandbox from ThreadChatCluster", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const calls = { value: 0 }
|
||||
|
|
@ -283,21 +296,11 @@ describe("Conversation", () => {
|
|||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* conversation.turn(makeEvent("fix build"))
|
||||
const out = yield* conversation.turn(makeEvent("fix build")).pipe(Effect.either)
|
||||
|
||||
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")
|
||||
expect(out._tag).toBe("Left")
|
||||
expect(prompts).toEqual(["fix build"])
|
||||
expect(actions.map((x) => x.kind)).toEqual(["typing"])
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
|
|
@ -323,11 +326,65 @@ describe("Conversation", () => {
|
|||
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"))
|
||||
expect(sent.threadId).toBe(ThreadId.make("t-new"))
|
||||
if (sent.kind === "send") expect(sent.text).toBe("echo:from channel")
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("!status is handled as a command without sending to agent", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const live = makeConversationLayer({
|
||||
events: [],
|
||||
tracked: Option.some(makeSession("s1")),
|
||||
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(makeEvent("!status"))
|
||||
|
||||
expect(prompts).toEqual([])
|
||||
expect(actions.filter((action) => action.kind === "send").length).toBe(1)
|
||||
const sent = actions.find((action) => action.kind === "send")
|
||||
if (!sent || sent.kind !== "send") throw new Error("missing status output")
|
||||
expect(sent.text.includes("**Status:**")).toBe(true)
|
||||
expect(sent.text.includes("**Sandbox:**")).toBe(true)
|
||||
expect(sent.text.includes("**Session:**")).toBe(true)
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("!recreate invokes recreate command and does not call agent", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const recreateCalls: Array<ThreadId> = []
|
||||
const live = makeConversationLayer({
|
||||
events: [],
|
||||
tracked: Option.some(makeSession("s1")),
|
||||
resolves: [makeSession("s1")],
|
||||
send: (_session, text) => Effect.succeed(`echo:${text}`),
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
recreateCalls,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
yield* conversation.turn(makeEvent("!recreate"))
|
||||
|
||||
expect(prompts).toEqual([])
|
||||
expect(recreateCalls).toEqual([ThreadId.make("t1")])
|
||||
const sent = actions.find((action) => action.kind === "send")
|
||||
if (!sent || sent.kind !== "send") throw new Error("missing recreate output")
|
||||
expect(sent.text.includes("Session recreated")).toBe(true)
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("run retries retriable failures in-process", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
|
|
@ -340,11 +397,15 @@ describe("Conversation", () => {
|
|||
Effect.gen(function* () {
|
||||
calls.value += 1
|
||||
if (calls.value === 1) {
|
||||
return yield* Effect.fail(new OpenCodeClientError({
|
||||
return yield* new OpenCodeClientError({
|
||||
operation: "sendPrompt",
|
||||
statusCode: 502,
|
||||
body: "StatusCode: non 2xx status code (502 POST https://proxy.daytona.works/session/s1/message)",
|
||||
}))
|
||||
kind: classifyOpenCodeFailure(
|
||||
502,
|
||||
"StatusCode: non 2xx status code (502 POST https://proxy.daytona.works/session/s1/message)",
|
||||
),
|
||||
})
|
||||
}
|
||||
return `ok:${text}`
|
||||
}),
|
||||
|
|
@ -362,7 +423,7 @@ describe("Conversation", () => {
|
|||
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"))
|
||||
expect(sent.threadId).toBe(ThreadId.make("t1"))
|
||||
if (sent.kind === "send") expect(sent.text).toBe("ok:retry now")
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
|
@ -392,7 +453,7 @@ describe("Conversation", () => {
|
|||
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"))
|
||||
expect(sent.threadId).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)
|
||||
|
|
@ -447,27 +508,25 @@ describe("Conversation", () => {
|
|||
|
||||
// --- Duplicate processing tests ---
|
||||
|
||||
/** A ledger that tracks dedup calls */
|
||||
const makeTrackingLedger = () => {
|
||||
/** A dedup service that tracks dedup calls */
|
||||
const makeTrackingDedup = () => {
|
||||
const seen = new Set<string>()
|
||||
const dedupCalls: Array<string> = []
|
||||
|
||||
const service: ConversationLedger.Service = {
|
||||
dedup: (message_id) =>
|
||||
const service: IngressDedup.Service = {
|
||||
dedup: (messageId) =>
|
||||
Effect.sync(() => {
|
||||
dedupCalls.push(message_id)
|
||||
if (seen.has(message_id)) return false
|
||||
seen.add(message_id)
|
||||
dedupCalls.push(messageId)
|
||||
if (seen.has(messageId)) return false
|
||||
seen.add(messageId)
|
||||
return true
|
||||
}),
|
||||
getOffset: () => Effect.succeed(Option.none()),
|
||||
setOffset: () => Effect.void,
|
||||
}
|
||||
|
||||
return { service, seen, dedupCalls }
|
||||
}
|
||||
|
||||
const makeConversationLayerWithLedger = (props: {
|
||||
const makeConversationLayerWithDedup = (props: {
|
||||
events: ReadonlyArray<Inbound>
|
||||
tracked: Option.Option<SessionInfo>
|
||||
resolves: ReadonlyArray<SessionInfo>
|
||||
|
|
@ -480,9 +539,11 @@ const makeConversationLayerWithLedger = (props: {
|
|||
shouldRespond?: boolean
|
||||
actions: Array<Action>
|
||||
prompts: Array<string>
|
||||
ledger: ConversationLedger.Service
|
||||
dedup: IngressDedup.Service
|
||||
}) => {
|
||||
const resolveIndex = { value: 0 }
|
||||
const state = new Map<string, SessionInfo>()
|
||||
if (Option.isSome(props.tracked)) state.set(props.tracked.value.threadId, props.tracked.value)
|
||||
|
||||
const inboxLayer = Layer.succeed(
|
||||
Inbox,
|
||||
|
|
@ -498,12 +559,12 @@ const makeConversationLayerWithLedger = (props: {
|
|||
Effect.sync(() => {
|
||||
props.actions.push(action)
|
||||
}),
|
||||
withTyping: <A, E, R>(thread_id: ThreadId, self: Effect.Effect<A, E, R>) =>
|
||||
withTyping: <A, E, R>(threadId: ThreadId, self: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
props.actions.push(
|
||||
Typing.make({
|
||||
kind: "typing",
|
||||
thread_id,
|
||||
threadId,
|
||||
}),
|
||||
)
|
||||
return yield* self
|
||||
|
|
@ -511,21 +572,14 @@ const makeConversationLayerWithLedger = (props: {
|
|||
}),
|
||||
)
|
||||
|
||||
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({ threadId: event.threadId, channelId: event.channelId }))
|
||||
}
|
||||
return Effect.succeed(ThreadRef.make({ thread_id: ThreadId.make("t-new"), channel_id: event.channel_id }))
|
||||
return Effect.succeed(ThreadRef.make({ threadId: ThreadId.make("t-new"), channelId: event.channelId }))
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
|
@ -538,31 +592,70 @@ const makeConversationLayerWithLedger = (props: {
|
|||
return session
|
||||
}
|
||||
|
||||
const poolLayer = Layer.succeed(
|
||||
ThreadAgentPool,
|
||||
ThreadAgentPool.of({
|
||||
getOrCreate: (threadId, channelId, guildId) =>
|
||||
const toThreadError = (threadId: ThreadId, cause: unknown): ThreadChatError => {
|
||||
if (cause instanceof ThreadChatError) return cause
|
||||
return ThreadChatError.make({
|
||||
threadId,
|
||||
cause,
|
||||
retriable: Predicate.isTagged(cause, "SandboxDeadError") || Predicate.isTagged(cause, "OpenCodeClientError"),
|
||||
})
|
||||
}
|
||||
|
||||
const threadChatLayer = Layer.succeed(
|
||||
ThreadChatCluster,
|
||||
ThreadChatCluster.of({
|
||||
send: (input) =>
|
||||
Effect.sync(() => {
|
||||
const session = resolveSession(threadId, channelId, guildId)
|
||||
return makeAgent(session, props.send, props.prompts)
|
||||
props.prompts.push(input.text)
|
||||
const session = resolveSession(input.threadId, input.channelId, input.guildId)
|
||||
return session
|
||||
}).pipe(
|
||||
Effect.flatMap((session) =>
|
||||
props.send(session, input.text).pipe(
|
||||
Effect.map((text) => {
|
||||
const prev = state.get(input.threadId)
|
||||
state.set(input.threadId, session)
|
||||
return {
|
||||
text,
|
||||
session,
|
||||
changedSession: prev ? prev.sessionId !== session.sessionId : true,
|
||||
}
|
||||
}),
|
||||
Effect.mapError((cause) => toThreadError(input.threadId, cause)),
|
||||
)),
|
||||
),
|
||||
status: (threadId) =>
|
||||
Effect.succeed(
|
||||
Option.fromNullable(
|
||||
state.get(threadId) ?? SessionInfo.make({
|
||||
threadId,
|
||||
channelId: ChannelId.make("c1"),
|
||||
guildId: GuildId.make("g1"),
|
||||
sandboxId: SandboxId.make("sb1"),
|
||||
sessionId: SessionId.make("s-tracked"),
|
||||
previewUrl: "https://preview",
|
||||
previewToken: null,
|
||||
status: "active",
|
||||
lastError: null,
|
||||
resumeFailCount: 0,
|
||||
}),
|
||||
),
|
||||
),
|
||||
recreate: (threadId) =>
|
||||
Effect.sync(() => {
|
||||
state.delete(threadId)
|
||||
}),
|
||||
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))
|
||||
const dedupLayer = Layer.succeed(IngressDedup, IngressDedup.of(props.dedup))
|
||||
|
||||
return Conversation.layer.pipe(
|
||||
Layer.provideMerge(inboxLayer),
|
||||
Layer.provideMerge(outboxLayer),
|
||||
Layer.provideMerge(historyLayer),
|
||||
Layer.provideMerge(ledgerLayer),
|
||||
Layer.provideMerge(dedupLayer),
|
||||
Layer.provideMerge(threadsLayer),
|
||||
Layer.provideMerge(poolLayer),
|
||||
Layer.provideMerge(threadChatLayer),
|
||||
Layer.provideMerge(makeRouterLayer(props.shouldRespond ?? true)),
|
||||
Layer.provideMerge(testConfigLayer),
|
||||
)
|
||||
|
|
@ -572,10 +665,10 @@ describe("Conversation duplicate processing", () => {
|
|||
effectTest("same message_id queued twice via run is only sent once", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const ledger = makeTrackingLedger()
|
||||
const dedup = makeTrackingDedup()
|
||||
const event = makeEvent("hello")
|
||||
|
||||
const live = makeConversationLayerWithLedger({
|
||||
const live = makeConversationLayerWithDedup({
|
||||
// Feed the same event twice to simulate catch-up + real-time race
|
||||
events: [event, event],
|
||||
tracked: Option.none(),
|
||||
|
|
@ -584,7 +677,7 @@ describe("Conversation duplicate processing", () => {
|
|||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
ledger: ledger.service,
|
||||
dedup: dedup.service,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
|
|
@ -592,7 +685,7 @@ describe("Conversation duplicate processing", () => {
|
|||
yield* conversation.run
|
||||
|
||||
// The ledger should have been called twice with dedup
|
||||
expect(ledger.dedupCalls).toEqual(["m1", "m1"])
|
||||
expect(dedup.dedupCalls).toEqual(["m1", "m1"])
|
||||
// The agent should have received the prompt only once
|
||||
expect(prompts).toEqual(["hello"])
|
||||
// Only one typing + one send
|
||||
|
|
@ -600,12 +693,12 @@ describe("Conversation duplicate processing", () => {
|
|||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("noop ledger deduplicates", () => {
|
||||
effectTest("noop dedup service deduplicates", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const event = makeEvent("hello")
|
||||
|
||||
// The noop ledger tracks seen message_ids
|
||||
// The noop dedup service tracks seen message_ids
|
||||
const live = makeConversationLayer({
|
||||
events: [event, event],
|
||||
tracked: Option.none(),
|
||||
|
|
@ -626,13 +719,13 @@ describe("Conversation duplicate processing", () => {
|
|||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("turn called twice with same event only processes once with tracking ledger", () => {
|
||||
effectTest("turn called twice with same event only processes once with tracking dedup service", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const ledger = makeTrackingLedger()
|
||||
const dedup = makeTrackingDedup()
|
||||
const event = makeEvent("help me")
|
||||
|
||||
const live = makeConversationLayerWithLedger({
|
||||
const live = makeConversationLayerWithDedup({
|
||||
events: [],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s1")],
|
||||
|
|
@ -640,7 +733,7 @@ describe("Conversation duplicate processing", () => {
|
|||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
ledger: ledger.service,
|
||||
dedup: dedup.service,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
|
|
@ -657,11 +750,11 @@ describe("Conversation duplicate processing", () => {
|
|||
effectTest("two different messages on same thread are processed sequentially (not lost)", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const ledger = makeTrackingLedger()
|
||||
const dedup = makeTrackingDedup()
|
||||
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({
|
||||
const live = makeConversationLayerWithDedup({
|
||||
events: [event1, event2],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s1")],
|
||||
|
|
@ -669,7 +762,7 @@ describe("Conversation duplicate processing", () => {
|
|||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
ledger: ledger.service,
|
||||
dedup: dedup.service,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
270
packages/discord/src/conversation/conversation.ts
Normal file
270
packages/discord/src/conversation/conversation.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import { Context, Effect, Layer, Option, Predicate, Schedule, Stream } from "effect"
|
||||
import { ThreadChatCluster, ThreadChatError } from "./thread/cluster"
|
||||
import { AppConfig } from "../config"
|
||||
import { TurnRouter } from "../discord/turn-routing"
|
||||
import type { HealthCheckError, OpenCodeClientError, SandboxDeadError, SandboxStartError } from "../errors"
|
||||
import { ActorMap } from "../lib/actors/keyed"
|
||||
import type { ChannelId, SessionInfo, ThreadId } from "../types"
|
||||
import { type ConversationError, messageOf, RoutingError, SandboxSendError } from "./model/errors"
|
||||
import { Send, type Inbound } from "./model/schema"
|
||||
import { Inbox } from "./inbox"
|
||||
import { IngressDedup } from "./dedup"
|
||||
import { Outbox } from "./outbox"
|
||||
import { Threads } from "./threads"
|
||||
|
||||
export declare namespace Conversation {
|
||||
export interface Service {
|
||||
readonly turn: (event: Inbound) => Effect.Effect<void, ConversationError>
|
||||
readonly run: Effect.Effect<void>
|
||||
}
|
||||
}
|
||||
|
||||
export class Conversation extends Context.Tag("@discord/conversation/Conversation")<
|
||||
Conversation,
|
||||
Conversation.Service
|
||||
>() {
|
||||
static readonly layer = Layer.scoped(
|
||||
Conversation,
|
||||
Effect.gen(function* () {
|
||||
const inbox = yield* Inbox
|
||||
const outbox = yield* Outbox
|
||||
const threads = yield* Threads
|
||||
const dedup = yield* IngressDedup
|
||||
const config = yield* AppConfig
|
||||
const threadChat = yield* ThreadChatCluster
|
||||
const router = yield* TurnRouter
|
||||
const actors = yield* ActorMap.make<string>()
|
||||
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),
|
||||
)
|
||||
|
||||
type RetriableCause = SandboxDeadError | OpenCodeClientError | HealthCheckError | SandboxStartError
|
||||
|
||||
const isRetriableCause = (cause: unknown): cause is RetriableCause =>
|
||||
Predicate.isTagged(cause, "SandboxDeadError") ||
|
||||
Predicate.isTagged(cause, "OpenCodeClientError") ||
|
||||
Predicate.isTagged(cause, "HealthCheckError") ||
|
||||
Predicate.isTagged(cause, "SandboxStartError")
|
||||
|
||||
const asSendError =
|
||||
(threadId: ThreadId) =>
|
||||
(cause: unknown): SandboxSendError => {
|
||||
if (cause instanceof ThreadChatError) {
|
||||
return SandboxSendError.make({
|
||||
threadId,
|
||||
message: messageOf(cause.cause),
|
||||
retriable: cause.retriable,
|
||||
})
|
||||
}
|
||||
return SandboxSendError.make({
|
||||
threadId,
|
||||
message: messageOf(cause),
|
||||
retriable: isRetriableCause(cause),
|
||||
})
|
||||
}
|
||||
|
||||
const publishText = (threadId: ThreadId, text: string) =>
|
||||
outbox.publish(
|
||||
Send.make({
|
||||
kind: "send",
|
||||
threadId,
|
||||
text,
|
||||
}),
|
||||
)
|
||||
|
||||
const renderStatus = (session: SessionInfo) => {
|
||||
const model = config.openCodeModel.replace("opencode/", "")
|
||||
return [
|
||||
`**Status:** ${session.status}`,
|
||||
`**Model:** \`${model}\``,
|
||||
`**Sandbox:** \`${session.sandboxId}\``,
|
||||
`**Session:** \`${session.sessionId}\``,
|
||||
session.resumeFailCount > 0 ? `**Resume failures:** ${session.resumeFailCount}` : null,
|
||||
session.lastError ? `**Last error:** ${session.lastError.slice(0, 200)}` : null,
|
||||
]
|
||||
.filter((line): line is string => line !== null)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
const reportFailure = (threadId: ThreadId) => (error: ConversationError) => {
|
||||
if (error.retriable) return Effect.fail(error)
|
||||
return publishText(threadId, RETRY_MESSAGE).pipe(
|
||||
Effect.catchAll(() => Effect.void),
|
||||
Effect.zipRight(Effect.fail(error)),
|
||||
)
|
||||
}
|
||||
|
||||
const route = Effect.fn("Conversation.route")(function* (event: Inbound) {
|
||||
if (event.authorIsBot) return false
|
||||
if (event.mentionsEveryone) return false
|
||||
if (!event.content.trim()) return false
|
||||
|
||||
const mentioned =
|
||||
event.mentions.userIds.includes(event.botUserId) ||
|
||||
(event.botRoleId.length > 0 && event.mentions.roleIds.includes(event.botRoleId))
|
||||
if (event.kind === "channel_message") return mentioned
|
||||
if (mentioned) return true
|
||||
|
||||
const owned = yield* threadChat
|
||||
.status(event.threadId)
|
||||
.pipe(
|
||||
Effect.map((session) => Option.isSome(session)),
|
||||
Effect.mapError(asSendError(event.threadId)),
|
||||
)
|
||||
if (!owned) return false
|
||||
|
||||
const decision = yield* router
|
||||
.shouldRespond({
|
||||
content: event.content,
|
||||
botUserId: event.botUserId,
|
||||
botRoleId: event.botRoleId,
|
||||
mentionedUserIds: event.mentions.userIds,
|
||||
mentionedRoleIds: event.mentions.roleIds,
|
||||
})
|
||||
.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 { threadId: event.threadId, channelId: event.channelId }
|
||||
}
|
||||
const name = yield* router.generateThreadName(event.content)
|
||||
return yield* threads.ensure(event, name)
|
||||
})
|
||||
|
||||
const commandStatus = (threadId: ThreadId) =>
|
||||
Effect.gen(function* () {
|
||||
const tracked = yield* threadChat
|
||||
.status(threadId)
|
||||
.pipe(Effect.catchAll(() => Effect.succeed(Option.none())))
|
||||
if (Option.isNone(tracked)) {
|
||||
yield* publishText(threadId, "*No active session for this thread.*")
|
||||
return
|
||||
}
|
||||
yield* publishText(threadId, renderStatus(tracked.value))
|
||||
})
|
||||
|
||||
const commandRecreate = (threadId: ThreadId) =>
|
||||
threadChat.recreate(threadId).pipe(
|
||||
Effect.catchAll(() => Effect.void),
|
||||
Effect.zipRight(
|
||||
publishText(threadId, "*☠️ Session recreated. Next message will provision a fresh sandbox.*"),
|
||||
),
|
||||
)
|
||||
|
||||
const commands = {
|
||||
"!status": commandStatus,
|
||||
"!reset": commandRecreate,
|
||||
"!recreate": commandRecreate,
|
||||
} as const
|
||||
type CommandName = keyof typeof commands
|
||||
const isCommand = (text: string): text is CommandName => text in commands
|
||||
|
||||
const command = (event: Inbound, target: { threadId: ThreadId; channelId: ChannelId }) =>
|
||||
Effect.gen(function* () {
|
||||
const text = event.content.trim().toLowerCase()
|
||||
if (!isCommand(text)) return false
|
||||
yield* commands[text](target.threadId)
|
||||
return true
|
||||
})
|
||||
|
||||
const turnRaw = Effect.fn("Conversation.turnRaw")(function* (event: Inbound) {
|
||||
if (!(yield* route(event))) return
|
||||
|
||||
const target = yield* resolve(event)
|
||||
|
||||
if (yield* command(event, target)) return
|
||||
|
||||
yield* Effect.logInfo("User message").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.user.message",
|
||||
thread_id: target.threadId,
|
||||
author_id: event.authorId,
|
||||
content: event.content.slice(0, 200),
|
||||
}),
|
||||
)
|
||||
|
||||
yield* outbox
|
||||
.withTyping(
|
||||
target.threadId,
|
||||
Effect.gen(function* () {
|
||||
const reply = yield* threadChat.send({
|
||||
threadId: target.threadId,
|
||||
channelId: target.channelId,
|
||||
guildId: event.guildId,
|
||||
messageId: event.messageId,
|
||||
text: event.content,
|
||||
}).pipe(
|
||||
Effect.map((out) => out.text),
|
||||
Effect.mapError(asSendError(target.threadId)),
|
||||
)
|
||||
|
||||
yield* Effect.logInfo("Bot reply").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.bot.reply",
|
||||
thread_id: target.threadId,
|
||||
content: reply.slice(0, 200),
|
||||
}),
|
||||
)
|
||||
yield* publishText(target.threadId, reply)
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.catchAll(reportFailure(target.threadId)))
|
||||
})
|
||||
|
||||
const keyOf = (event: Inbound) =>
|
||||
event.kind === "thread_message" ? `thread:${event.threadId}` : `channel:${event.channelId}`
|
||||
|
||||
const processEvent = (event: Inbound) => actors.run(keyOf(event), turnRaw(event), { touch: false })
|
||||
const processFresh = (event: Inbound) =>
|
||||
dedup.dedup(event.messageId).pipe(
|
||||
Effect.flatMap((fresh) => {
|
||||
if (!fresh) return Effect.void
|
||||
return processEvent(event)
|
||||
}),
|
||||
)
|
||||
|
||||
const turn = Effect.fn("Conversation.turn")(function* (event: Inbound) {
|
||||
yield* processFresh(event)
|
||||
})
|
||||
|
||||
const run = inbox.events.pipe(
|
||||
Stream.mapEffect(
|
||||
(event) =>
|
||||
dedup.dedup(event.messageId).pipe(
|
||||
Effect.flatMap((fresh) => {
|
||||
if (!fresh) return Effect.void
|
||||
return processEvent(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 })
|
||||
}),
|
||||
)
|
||||
}
|
||||
25
packages/discord/src/conversation/dedup.test.ts
Normal file
25
packages/discord/src/conversation/dedup.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { effectTest } from "../test/effect"
|
||||
import { IngressDedup } from "./dedup"
|
||||
|
||||
describe("IngressDedup", () => {
|
||||
effectTest("dedup returns true first time, false second time", () =>
|
||||
Effect.gen(function* () {
|
||||
const dedup = yield* IngressDedup
|
||||
expect(yield* dedup.dedup("m1")).toBe(true)
|
||||
expect(yield* dedup.dedup("m1")).toBe(false)
|
||||
expect(yield* dedup.dedup("m2")).toBe(true)
|
||||
expect(yield* dedup.dedup("m2")).toBe(false)
|
||||
}).pipe(Effect.provide(IngressDedup.noop)),
|
||||
)
|
||||
|
||||
effectTest("layer mode behaves as memory dedup", () =>
|
||||
Effect.gen(function* () {
|
||||
const dedup = yield* IngressDedup
|
||||
expect(yield* dedup.dedup("m1")).toBe(true)
|
||||
expect(yield* dedup.dedup("m1")).toBe(false)
|
||||
expect(yield* dedup.dedup("m2")).toBe(true)
|
||||
}).pipe(Effect.provide(IngressDedup.layer)),
|
||||
)
|
||||
})
|
||||
35
packages/discord/src/conversation/dedup.ts
Normal file
35
packages/discord/src/conversation/dedup.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Context, Effect, Layer } from "effect"
|
||||
|
||||
const DEDUP_LIMIT = 4_000
|
||||
|
||||
const makeDedupSet = () => {
|
||||
const seen = new Set<string>()
|
||||
const order: Array<string> = []
|
||||
return (messageId: string): boolean => {
|
||||
if (seen.has(messageId)) return false
|
||||
seen.add(messageId)
|
||||
order.push(messageId)
|
||||
if (order.length > DEDUP_LIMIT) {
|
||||
const oldest = order.shift()
|
||||
if (oldest) seen.delete(oldest)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export declare namespace IngressDedup {
|
||||
export interface Service {
|
||||
readonly dedup: (messageId: string) => Effect.Effect<boolean>
|
||||
}
|
||||
}
|
||||
|
||||
export class IngressDedup extends Context.Tag("@discord/conversation/IngressDedup")<IngressDedup, IngressDedup.Service>() {
|
||||
static readonly noop = Layer.sync(IngressDedup, () => {
|
||||
const check = makeDedupSet()
|
||||
return IngressDedup.of({
|
||||
dedup: (messageId) => Effect.sync(() => check(messageId)),
|
||||
})
|
||||
})
|
||||
|
||||
static readonly layer = IngressDedup.noop
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { Context, Effect, Layer } from "effect"
|
||||
import type { ThreadId } from "../../types"
|
||||
import type { HistoryError } from "../model/errors"
|
||||
import type { ThreadId } from "../types"
|
||||
import type { HistoryError } from "./model/errors"
|
||||
|
||||
export declare namespace History {
|
||||
export interface Service {
|
||||
readonly rehydrate: (thread_id: ThreadId, latest: string) => Effect.Effect<string, HistoryError>
|
||||
readonly rehydrate: (threadId: ThreadId, latest: string) => Effect.Effect<string, HistoryError>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ export class History extends Context.Tag("@discord/conversation/History")<Histor
|
|||
static readonly passthrough = Layer.succeed(
|
||||
History,
|
||||
History.of({
|
||||
rehydrate: (_thread_id: ThreadId, latest: string) => Effect.succeed(latest),
|
||||
rehydrate: (_threadId: ThreadId, latest: string) => Effect.succeed(latest),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { Context, Layer, Stream } from "effect"
|
||||
import type { Inbound } from "../model/schema"
|
||||
import type { Inbound } from "./model/schema"
|
||||
|
||||
export declare namespace Inbox {
|
||||
export interface Service {
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
export { Conversation } from "./conversation"
|
||||
export { History } from "./history"
|
||||
export { Inbox } from "./inbox"
|
||||
export { ConversationLedger } from "./ledger"
|
||||
export { IngressDedup } from "./dedup"
|
||||
export { OffsetStore } from "./offsets"
|
||||
export { Outbox } from "./outbox"
|
||||
export { Threads } from "./threads"
|
||||
|
|
@ -6,7 +6,7 @@ const DeliveryAction = Schema.Literal("typing", "send", "reply")
|
|||
export class ThreadEnsureError extends Schema.TaggedError<ThreadEnsureError>()(
|
||||
"ThreadEnsureError",
|
||||
{
|
||||
channel_id: ChannelId,
|
||||
channelId: ChannelId,
|
||||
message: Schema.String,
|
||||
retriable: Schema.Boolean,
|
||||
},
|
||||
|
|
@ -15,7 +15,7 @@ export class ThreadEnsureError extends Schema.TaggedError<ThreadEnsureError>()(
|
|||
export class HistoryError extends Schema.TaggedError<HistoryError>()(
|
||||
"HistoryError",
|
||||
{
|
||||
thread_id: ThreadId,
|
||||
threadId: ThreadId,
|
||||
message: Schema.String,
|
||||
retriable: Schema.Boolean,
|
||||
},
|
||||
|
|
@ -24,7 +24,7 @@ export class HistoryError extends Schema.TaggedError<HistoryError>()(
|
|||
export class DeliveryError extends Schema.TaggedError<DeliveryError>()(
|
||||
"DeliveryError",
|
||||
{
|
||||
thread_id: ThreadId,
|
||||
threadId: ThreadId,
|
||||
action: DeliveryAction,
|
||||
message: Schema.String,
|
||||
retriable: Schema.Boolean,
|
||||
|
|
@ -42,7 +42,7 @@ export class RoutingError extends Schema.TaggedError<RoutingError>()(
|
|||
export class SandboxSendError extends Schema.TaggedError<SandboxSendError>()(
|
||||
"SandboxSendError",
|
||||
{
|
||||
thread_id: ThreadId,
|
||||
threadId: ThreadId,
|
||||
message: Schema.String,
|
||||
retriable: Schema.Boolean,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,35 +2,35 @@ import { Schema } from "effect"
|
|||
import { ThreadId, ChannelId, GuildId } from "../../types"
|
||||
|
||||
export class Mention extends Schema.Class<Mention>("Mention")({
|
||||
user_ids: Schema.Array(Schema.String),
|
||||
role_ids: Schema.Array(Schema.String),
|
||||
userIds: Schema.Array(Schema.String),
|
||||
roleIds: Schema.Array(Schema.String),
|
||||
}) {}
|
||||
|
||||
export class ThreadMessage extends Schema.Class<ThreadMessage>("ThreadMessage")({
|
||||
kind: Schema.Literal("thread_message"),
|
||||
thread_id: ThreadId,
|
||||
channel_id: ChannelId,
|
||||
message_id: Schema.String,
|
||||
guild_id: GuildId,
|
||||
bot_user_id: Schema.String,
|
||||
bot_role_id: Schema.String,
|
||||
author_id: Schema.String,
|
||||
author_is_bot: Schema.Boolean,
|
||||
mentions_everyone: Schema.Boolean,
|
||||
threadId: ThreadId,
|
||||
channelId: ChannelId,
|
||||
messageId: Schema.String,
|
||||
guildId: GuildId,
|
||||
botUserId: Schema.String,
|
||||
botRoleId: Schema.String,
|
||||
authorId: Schema.String,
|
||||
authorIsBot: Schema.Boolean,
|
||||
mentionsEveryone: Schema.Boolean,
|
||||
mentions: Mention,
|
||||
content: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class ChannelMessage extends Schema.Class<ChannelMessage>("ChannelMessage")({
|
||||
kind: Schema.Literal("channel_message"),
|
||||
channel_id: ChannelId,
|
||||
message_id: Schema.String,
|
||||
guild_id: GuildId,
|
||||
bot_user_id: Schema.String,
|
||||
bot_role_id: Schema.String,
|
||||
author_id: Schema.String,
|
||||
author_is_bot: Schema.Boolean,
|
||||
mentions_everyone: Schema.Boolean,
|
||||
channelId: ChannelId,
|
||||
messageId: Schema.String,
|
||||
guildId: GuildId,
|
||||
botUserId: Schema.String,
|
||||
botRoleId: Schema.String,
|
||||
authorId: Schema.String,
|
||||
authorIsBot: Schema.Boolean,
|
||||
mentionsEveryone: Schema.Boolean,
|
||||
mentions: Mention,
|
||||
content: Schema.String,
|
||||
}) {}
|
||||
|
|
@ -43,25 +43,25 @@ export const Inbound = Schema.Union(
|
|||
export type Inbound = typeof Inbound.Type
|
||||
|
||||
export class ThreadRef extends Schema.Class<ThreadRef>("ThreadRef")({
|
||||
thread_id: ThreadId,
|
||||
channel_id: ChannelId,
|
||||
threadId: ThreadId,
|
||||
channelId: ChannelId,
|
||||
}) {}
|
||||
|
||||
export class Send extends Schema.Class<Send>("Send")({
|
||||
kind: Schema.Literal("send"),
|
||||
thread_id: ThreadId,
|
||||
threadId: ThreadId,
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class Reply extends Schema.Class<Reply>("Reply")({
|
||||
kind: Schema.Literal("reply"),
|
||||
thread_id: ThreadId,
|
||||
threadId: ThreadId,
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class Typing extends Schema.Class<Typing>("Typing")({
|
||||
kind: Schema.Literal("typing"),
|
||||
thread_id: ThreadId,
|
||||
threadId: ThreadId,
|
||||
}) {}
|
||||
|
||||
export const Action = Schema.Union(
|
||||
|
|
|
|||
74
packages/discord/src/conversation/offsets.test.ts
Normal file
74
packages/discord/src/conversation/offsets.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
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 { OffsetStore } from "./offsets"
|
||||
|
||||
const makeConfig = (databasePath: string) =>
|
||||
AppConfig.of({
|
||||
discordToken: Redacted.make("token"),
|
||||
allowedChannelIds: [],
|
||||
discordCategoryId: "",
|
||||
discordRoleId: "",
|
||||
discordRequiredRoleId: "",
|
||||
discordCommandGuildId: "",
|
||||
databasePath,
|
||||
daytonaApiKey: Redacted.make("daytona"),
|
||||
daytonaSnapshot: "",
|
||||
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 withOffsets = <A, E, R>(
|
||||
run: (offsets: OffsetStore.Service) => Effect.Effect<A, E, R>,
|
||||
) =>
|
||||
withTempSqliteFile((databasePath) =>
|
||||
Effect.gen(function* () {
|
||||
const config = Layer.succeed(AppConfig, makeConfig(databasePath))
|
||||
const sqlite = SqliteDb.layer.pipe(Layer.provide(config))
|
||||
const deps = Layer.merge(sqlite, config)
|
||||
const live = OffsetStore.layer.pipe(Layer.provide(deps))
|
||||
const program = Effect.flatMap(OffsetStore, (offsets) => run(offsets))
|
||||
return yield* program.pipe(Effect.provide(live))
|
||||
}),
|
||||
"discord-offsets-",
|
||||
)
|
||||
|
||||
describe("OffsetStore", () => {
|
||||
effectTest("stores and updates source offsets", () =>
|
||||
withOffsets((offsets) =>
|
||||
Effect.gen(function* () {
|
||||
expect(Option.isNone(yield* offsets.getOffset("thread:t1"))).toBe(true)
|
||||
yield* offsets.setOffset("thread:t1", "m1")
|
||||
expect(yield* offsets.getOffset("thread:t1")).toEqual(Option.some("m1"))
|
||||
yield* offsets.setOffset("thread:t1", "m9")
|
||||
expect(yield* offsets.getOffset("thread:t1")).toEqual(Option.some("m9"))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
effectTest("noop offsets always return none", () =>
|
||||
Effect.gen(function* () {
|
||||
const offsets = yield* OffsetStore
|
||||
expect(Option.isNone(yield* offsets.getOffset("thread:t1"))).toBe(true)
|
||||
yield* offsets.setOffset("thread:t1", "m1")
|
||||
expect(Option.isNone(yield* offsets.getOffset("thread:t1"))).toBe(true)
|
||||
}).pipe(Effect.provide(OffsetStore.noop)),
|
||||
)
|
||||
})
|
||||
57
packages/discord/src/conversation/offsets.ts
Normal file
57
packages/discord/src/conversation/offsets.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import * as Client from "@effect/sql/SqlClient"
|
||||
import { Context, Effect, Layer, Option } from "effect"
|
||||
import { SqliteDb } from "../db/client"
|
||||
import { initializeSchema } from "../db/init"
|
||||
import { DatabaseError } from "../errors"
|
||||
|
||||
const db = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(Effect.mapError((cause) => new DatabaseError({ cause })))
|
||||
|
||||
export declare namespace OffsetStore {
|
||||
export interface Service {
|
||||
readonly getOffset: (source_id: string) => Effect.Effect<Option.Option<string>, DatabaseError>
|
||||
readonly setOffset: (source_id: string, messageId: string) => Effect.Effect<void, DatabaseError>
|
||||
}
|
||||
}
|
||||
|
||||
export class OffsetStore extends Context.Tag("@discord/conversation/OffsetStore")<OffsetStore, OffsetStore.Service>() {
|
||||
static readonly noop = Layer.succeed(
|
||||
OffsetStore,
|
||||
OffsetStore.of({
|
||||
getOffset: () => Effect.succeed(Option.none()),
|
||||
setOffset: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
|
||||
static readonly layer = Layer.effect(
|
||||
OffsetStore,
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqliteDb
|
||||
yield* db(initializeSchema.pipe(Effect.provideService(Client.SqlClient, sql)))
|
||||
|
||||
const getOffset = Effect.fn("OffsetStore.getOffset")(function* (source_id: string) {
|
||||
const rows = yield* db(
|
||||
sql<{ last_message_id: string }>`SELECT last_message_id
|
||||
FROM conversation_offsets
|
||||
WHERE source_id = ${source_id}
|
||||
LIMIT 1`,
|
||||
)
|
||||
const row = rows[0]
|
||||
if (!row) return Option.none<string>()
|
||||
return Option.some(row.last_message_id)
|
||||
})
|
||||
|
||||
const setOffset = Effect.fn("OffsetStore.setOffset")(function* (source_id: string, messageId: string) {
|
||||
yield* db(
|
||||
sql`INSERT INTO conversation_offsets (source_id, last_message_id, updated_at)
|
||||
VALUES (${source_id}, ${messageId}, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(source_id) DO UPDATE SET
|
||||
last_message_id = excluded.last_message_id,
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
)
|
||||
})
|
||||
|
||||
return OffsetStore.of({ getOffset, setOffset })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import { Context, Effect, Layer } from "effect"
|
||||
import type { ThreadId } from "../../types"
|
||||
import type { DeliveryError } from "../model/errors"
|
||||
import type { Action } from "../model/schema"
|
||||
import type { ThreadId } from "../types"
|
||||
import type { DeliveryError } from "./model/errors"
|
||||
import type { Action } from "./model/schema"
|
||||
|
||||
export declare namespace Outbox {
|
||||
export interface Service {
|
||||
readonly publish: (action: Action) => Effect.Effect<void, DeliveryError>
|
||||
readonly withTyping: <A, E, R>(thread_id: ThreadId, self: Effect.Effect<A, E, R>) => Effect.Effect<A, E | DeliveryError, R>
|
||||
readonly withTyping: <A, E, R>(threadId: ThreadId, self: Effect.Effect<A, E, R>) => Effect.Effect<A, E | DeliveryError, R>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ export class Outbox extends Context.Tag("@discord/conversation/Outbox")<Outbox,
|
|||
Outbox,
|
||||
Outbox.of({
|
||||
publish: () => Effect.void,
|
||||
withTyping: (_thread_id, self) => self,
|
||||
withTyping: (_threadId, self) => self,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,260 +0,0 @@
|
|||
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, RoutingError, SandboxSendError } from "../model/errors"
|
||||
import { Send, type Inbound } from "../model/schema"
|
||||
import { History } from "./history"
|
||||
import { Inbox } from "./inbox"
|
||||
import { ConversationLedger } from "./ledger"
|
||||
import { Outbox } from "./outbox"
|
||||
import { Threads } from "./threads"
|
||||
|
||||
export declare namespace Conversation {
|
||||
export interface Service {
|
||||
readonly turn: (event: Inbound) => Effect.Effect<void, ConversationError>
|
||||
readonly run: Effect.Effect<void>
|
||||
}
|
||||
}
|
||||
|
||||
export class Conversation extends Context.Tag("@discord/conversation/Conversation")<
|
||||
Conversation,
|
||||
Conversation.Service
|
||||
>() {
|
||||
static readonly layer = Layer.scoped(
|
||||
Conversation,
|
||||
Effect.gen(function* () {
|
||||
const inbox = yield* Inbox
|
||||
const outbox = yield* Outbox
|
||||
const history = yield* History
|
||||
const threads = yield* Threads
|
||||
const ledger = yield* ConversationLedger
|
||||
const config = yield* AppConfig
|
||||
const pool = yield* ThreadAgentPool
|
||||
const router = yield* TurnRouter
|
||||
const actors = yield* ActorMap.make<string>()
|
||||
const RETRIABLE_TAGS: ReadonlySet<string> = new Set([
|
||||
"SandboxDeadError",
|
||||
"OpenCodeClientError",
|
||||
"HealthCheckError",
|
||||
"SandboxStartError",
|
||||
])
|
||||
const RETRY_MESSAGE = "Something went wrong. Please try again in a moment."
|
||||
const turnRetry = Schedule.exponential("500 millis").pipe(
|
||||
Schedule.intersect(Schedule.recurs(2)),
|
||||
Schedule.whileInput((error: ConversationError) => error.retriable),
|
||||
)
|
||||
|
||||
const asSendError =
|
||||
(thread_id: ThreadId) =>
|
||||
(cause: { readonly _tag: string }): SandboxSendError =>
|
||||
SandboxSendError.make({
|
||||
thread_id,
|
||||
message: messageOf(cause),
|
||||
retriable: RETRIABLE_TAGS.has(cause._tag),
|
||||
})
|
||||
|
||||
const 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) {
|
||||
if (!(yield* route(event))) return
|
||||
|
||||
const target = yield* resolve(event)
|
||||
|
||||
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 reply = yield* input.agent.send(input.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* 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 processEvent = (event: Inbound) => actors.run(keyOf(event), turnRaw(event), { touch: false })
|
||||
|
||||
const turn = Effect.fn("Conversation.turn")(function* (event: Inbound) {
|
||||
const fresh = yield* ledger.dedup(event.message_id)
|
||||
if (!fresh) return
|
||||
yield* processEvent(event)
|
||||
})
|
||||
|
||||
const run = inbox.events.pipe(
|
||||
Stream.mapEffect(
|
||||
(event) =>
|
||||
ledger.dedup(event.message_id).pipe(
|
||||
Effect.flatMap((fresh) => {
|
||||
if (!fresh) return Effect.void
|
||||
return processEvent(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 })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
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 { 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 withLedger = <A, E, R>(
|
||||
run: (ledger: ConversationLedger.Service) => Effect.Effect<A, E, R>,
|
||||
) =>
|
||||
withTempSqliteFile((databasePath) =>
|
||||
Effect.gen(function* () {
|
||||
const config = Layer.succeed(AppConfig, makeConfig(databasePath))
|
||||
const sqlite = SqliteDb.layer.pipe(Layer.provide(config))
|
||||
const deps = Layer.merge(sqlite, config)
|
||||
const live = ConversationLedger.layer.pipe(Layer.provide(deps))
|
||||
const program = Effect.flatMap(ConversationLedger, (ledger) => run(ledger))
|
||||
return yield* program.pipe(Effect.provide(live))
|
||||
}),
|
||||
"discord-ledger-",
|
||||
)
|
||||
|
||||
describe("ConversationLedger", () => {
|
||||
effectTest("dedup returns true first time, false second time", () =>
|
||||
Effect.gen(function* () {
|
||||
const ledger = yield* ConversationLedger
|
||||
expect(yield* ledger.dedup("m1")).toBe(true)
|
||||
expect(yield* ledger.dedup("m1")).toBe(false)
|
||||
expect(yield* ledger.dedup("m2")).toBe(true)
|
||||
expect(yield* ledger.dedup("m2")).toBe(false)
|
||||
}).pipe(Effect.provide(ConversationLedger.noop)),
|
||||
)
|
||||
|
||||
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"))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
effectTest("dedup works in layer mode", () =>
|
||||
withLedger((ledger) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* ledger.dedup("m1")).toBe(true)
|
||||
expect(yield* ledger.dedup("m1")).toBe(false)
|
||||
expect(yield* ledger.dedup("m2")).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
effectTest("noop offsets always return none", () =>
|
||||
Effect.gen(function* () {
|
||||
const ledger = yield* ConversationLedger
|
||||
expect(Option.isNone(yield* ledger.getOffset("thread:t1"))).toBe(true)
|
||||
yield* ledger.setOffset("thread:t1", "m1")
|
||||
expect(Option.isNone(yield* ledger.getOffset("thread:t1"))).toBe(true)
|
||||
}).pipe(Effect.provide(ConversationLedger.noop)),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import * as Client from "@effect/sql/SqlClient"
|
||||
import { Context, Effect, Layer, Option } from "effect"
|
||||
import { SqliteDb } from "../../db/client"
|
||||
import { initializeSchema } from "../../db/init"
|
||||
import { DatabaseError } from "../../errors"
|
||||
|
||||
const DEDUP_LIMIT = 4_000
|
||||
|
||||
const db = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(Effect.mapError((cause) => new DatabaseError({ cause })))
|
||||
|
||||
const makeDedupSet = () => {
|
||||
const seen = new Set<string>()
|
||||
const order: Array<string> = []
|
||||
return (message_id: string): boolean => {
|
||||
if (seen.has(message_id)) return false
|
||||
seen.add(message_id)
|
||||
order.push(message_id)
|
||||
if (order.length > DEDUP_LIMIT) {
|
||||
const oldest = order.shift()
|
||||
if (oldest) seen.delete(oldest)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export declare namespace ConversationLedger {
|
||||
export interface Service {
|
||||
readonly dedup: (message_id: string) => Effect.Effect<boolean>
|
||||
readonly getOffset: (source_id: string) => Effect.Effect<Option.Option<string>, DatabaseError>
|
||||
readonly setOffset: (source_id: string, message_id: string) => Effect.Effect<void, DatabaseError>
|
||||
}
|
||||
}
|
||||
|
||||
export class ConversationLedger extends Context.Tag("@discord/conversation/ConversationLedger")<
|
||||
ConversationLedger,
|
||||
ConversationLedger.Service
|
||||
>() {
|
||||
static readonly noop = Layer.sync(ConversationLedger, () => {
|
||||
const check = makeDedupSet()
|
||||
return ConversationLedger.of({
|
||||
dedup: (message_id) => Effect.sync(() => check(message_id)),
|
||||
getOffset: () => Effect.succeed(Option.none()),
|
||||
setOffset: () => Effect.void,
|
||||
})
|
||||
})
|
||||
|
||||
static readonly layer = Layer.effect(
|
||||
ConversationLedger,
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqliteDb
|
||||
yield* db(initializeSchema.pipe(Effect.provideService(Client.SqlClient, sql)))
|
||||
|
||||
const check = makeDedupSet()
|
||||
|
||||
const getOffset = Effect.fn("ConversationLedger.getOffset")(function* (source_id: string) {
|
||||
const rows = yield* db(
|
||||
sql<{ last_message_id: string }>`SELECT last_message_id
|
||||
FROM conversation_offsets
|
||||
WHERE source_id = ${source_id}
|
||||
LIMIT 1`,
|
||||
)
|
||||
const row = rows[0]
|
||||
if (!row) return Option.none<string>()
|
||||
return Option.some(row.last_message_id)
|
||||
})
|
||||
|
||||
const setOffset = Effect.fn("ConversationLedger.setOffset")(function* (source_id: string, message_id: string) {
|
||||
yield* db(
|
||||
sql`INSERT INTO conversation_offsets (source_id, last_message_id, updated_at)
|
||||
VALUES (${source_id}, ${message_id}, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(source_id) DO UPDATE SET
|
||||
last_message_id = excluded.last_message_id,
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
)
|
||||
})
|
||||
|
||||
return ConversationLedger.of({
|
||||
dedup: (message_id) => Effect.sync(() => check(message_id)),
|
||||
getOffset,
|
||||
setOffset,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { Effect, Layer, Option } from "effect"
|
||||
import { DatabaseError } from "../../../errors"
|
||||
import type { ThreadId } from "../../../types"
|
||||
import { SendInput, ThreadChatCluster, ThreadChatError } from "./contracts"
|
||||
import { ThreadEntity } from "./entity"
|
||||
|
||||
const asThreadError = (threadId: ThreadId, cause: unknown): ThreadChatError => {
|
||||
if (cause instanceof ThreadChatError) return cause
|
||||
return ThreadChatError.make({
|
||||
threadId,
|
||||
cause,
|
||||
retriable: false,
|
||||
})
|
||||
}
|
||||
|
||||
const asDatabaseError = (cause: unknown): DatabaseError => {
|
||||
if (cause instanceof DatabaseError) return cause
|
||||
return DatabaseError.make({ cause })
|
||||
}
|
||||
|
||||
export const ThreadChatClusterLive = Layer.effect(
|
||||
ThreadChatCluster,
|
||||
Effect.gen(function* () {
|
||||
const make = yield* ThreadEntity.client
|
||||
|
||||
const send = Effect.fn("ThreadChatCluster.send")(function* (input) {
|
||||
const rpc = make(input.threadId)
|
||||
const out = yield* rpc
|
||||
.send(
|
||||
SendInput.make({
|
||||
channelId: input.channelId,
|
||||
guildId: input.guildId,
|
||||
messageId: input.messageId,
|
||||
text: input.text,
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError((cause) => asThreadError(input.threadId, cause)),
|
||||
)
|
||||
return {
|
||||
text: out.text,
|
||||
session: out.session,
|
||||
changedSession: out.changedSession,
|
||||
}
|
||||
})
|
||||
|
||||
const status = Effect.fn("ThreadChatCluster.status")(function* (threadId) {
|
||||
const rpc = make(threadId)
|
||||
const row = yield* rpc.status(undefined).pipe(Effect.mapError(asDatabaseError))
|
||||
if (row === null) return Option.none()
|
||||
return Option.some(row)
|
||||
})
|
||||
|
||||
const recreate = Effect.fn("ThreadChatCluster.recreate")(function* (threadId) {
|
||||
const rpc = make(threadId)
|
||||
yield* rpc.recreate(undefined).pipe(Effect.mapError(asDatabaseError))
|
||||
})
|
||||
|
||||
return ThreadChatCluster.of({ send, status, recreate })
|
||||
}),
|
||||
)
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import { Context, Effect, Option, PrimaryKey, Schema } from "effect"
|
||||
import { DatabaseError } from "../../../errors"
|
||||
import { ChannelId, GuildId, SandboxId, SessionInfo, ThreadId } from "../../../types"
|
||||
|
||||
export class ThreadChatError extends Schema.TaggedError<ThreadChatError>()(
|
||||
"ThreadChatError",
|
||||
{
|
||||
threadId: ThreadId,
|
||||
cause: Schema.Defect,
|
||||
retriable: Schema.Boolean,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class SendInput extends Schema.Class<SendInput>("ClusterMode/SendInput")({
|
||||
channelId: ChannelId,
|
||||
guildId: GuildId,
|
||||
messageId: Schema.String,
|
||||
text: Schema.String,
|
||||
}) {
|
||||
[PrimaryKey.symbol]() {
|
||||
return this.messageId
|
||||
}
|
||||
}
|
||||
|
||||
export class SendOutput extends Schema.Class<SendOutput>("ClusterMode/SendOutput")({
|
||||
text: Schema.String,
|
||||
session: SessionInfo,
|
||||
changedSession: Schema.Boolean,
|
||||
}) {}
|
||||
|
||||
export class PauseInput extends Schema.Class<PauseInput>("ClusterMode/PauseInput")({
|
||||
reason: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class ResumeInput extends Schema.Class<ResumeInput>("ClusterMode/ResumeInput")({
|
||||
channelId: Schema.NullOr(ChannelId),
|
||||
guildId: Schema.NullOr(GuildId),
|
||||
}) {}
|
||||
|
||||
export class LogsInput extends Schema.Class<LogsInput>("ClusterMode/LogsInput")({
|
||||
lines: Schema.Number.pipe(
|
||||
Schema.int(),
|
||||
Schema.between(1, 500),
|
||||
),
|
||||
}) {}
|
||||
|
||||
export class LogsOutput extends Schema.Class<LogsOutput>("ClusterMode/LogsOutput")({
|
||||
sandboxId: SandboxId,
|
||||
output: Schema.String,
|
||||
}) {}
|
||||
|
||||
export declare namespace ThreadChatCluster {
|
||||
export interface Service {
|
||||
readonly send: (input: {
|
||||
threadId: ThreadId
|
||||
channelId: ChannelId
|
||||
guildId: GuildId
|
||||
messageId: string
|
||||
text: string
|
||||
}) => Effect.Effect<{
|
||||
text: string
|
||||
session: SessionInfo
|
||||
changedSession: boolean
|
||||
}, ThreadChatError>
|
||||
readonly status: (threadId: ThreadId) => Effect.Effect<Option.Option<SessionInfo>, DatabaseError>
|
||||
readonly recreate: (threadId: ThreadId) => Effect.Effect<void, DatabaseError>
|
||||
}
|
||||
}
|
||||
|
||||
export class ThreadChatCluster extends Context.Tag("@discord/conversation/thread/cluster/ThreadChatCluster")<
|
||||
ThreadChatCluster,
|
||||
ThreadChatCluster.Service
|
||||
>() {}
|
||||
|
||||
export declare namespace ThreadControlCluster {
|
||||
export interface Service {
|
||||
readonly active: Effect.Effect<ReadonlyArray<SessionInfo>, DatabaseError>
|
||||
readonly pause: (input: {
|
||||
threadId: ThreadId
|
||||
reason: string
|
||||
}) => Effect.Effect<Option.Option<SessionInfo>, ThreadChatError>
|
||||
readonly resume: (input: {
|
||||
threadId: ThreadId
|
||||
channelId: ChannelId | null
|
||||
guildId: GuildId | null
|
||||
}) => Effect.Effect<SessionInfo, ThreadChatError>
|
||||
readonly logs: (input: {
|
||||
threadId: ThreadId
|
||||
lines: number
|
||||
}) => Effect.Effect<Option.Option<{ sandboxId: SandboxId; output: string }>, ThreadChatError>
|
||||
}
|
||||
}
|
||||
|
||||
export class ThreadControlCluster extends Context.Tag("@discord/conversation/thread/cluster/ThreadControlCluster")<
|
||||
ThreadControlCluster,
|
||||
ThreadControlCluster.Service
|
||||
>() {}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { Effect, Layer, Option } from "effect"
|
||||
import { DatabaseError } from "../../../errors"
|
||||
import { SessionStore } from "../../../session/store"
|
||||
import type { ThreadId } from "../../../types"
|
||||
import { LogsInput, PauseInput, ResumeInput, ThreadChatError, ThreadControlCluster } from "./contracts"
|
||||
import { ThreadEntity } from "./entity"
|
||||
|
||||
const asThreadError = (threadId: ThreadId, cause: unknown): ThreadChatError => {
|
||||
if (cause instanceof ThreadChatError) return cause
|
||||
return ThreadChatError.make({
|
||||
threadId,
|
||||
cause,
|
||||
retriable: false,
|
||||
})
|
||||
}
|
||||
|
||||
const asDatabaseError = (cause: unknown): DatabaseError => {
|
||||
if (cause instanceof DatabaseError) return cause
|
||||
return DatabaseError.make({ cause })
|
||||
}
|
||||
|
||||
export const ThreadControlClusterLive = Layer.effect(
|
||||
ThreadControlCluster,
|
||||
Effect.gen(function* () {
|
||||
const make = yield* ThreadEntity.client
|
||||
const store = yield* SessionStore
|
||||
|
||||
const active = store.listActive().pipe(Effect.mapError(asDatabaseError))
|
||||
|
||||
const pause = Effect.fn("ThreadControlCluster.pause")(function* (input) {
|
||||
const rpc = make(input.threadId)
|
||||
const row = yield* rpc.pause(PauseInput.make({ reason: input.reason })).pipe(
|
||||
Effect.mapError((cause) => asThreadError(input.threadId, cause)),
|
||||
)
|
||||
if (row === null) return Option.none()
|
||||
return Option.some(row)
|
||||
})
|
||||
|
||||
const resume = Effect.fn("ThreadControlCluster.resume")(function* (input) {
|
||||
const rpc = make(input.threadId)
|
||||
return yield* rpc.resume(
|
||||
ResumeInput.make({
|
||||
channelId: input.channelId,
|
||||
guildId: input.guildId,
|
||||
}),
|
||||
).pipe(
|
||||
Effect.mapError((cause) => asThreadError(input.threadId, cause)),
|
||||
)
|
||||
})
|
||||
|
||||
const logs = Effect.fn("ThreadControlCluster.logs")(function* (input) {
|
||||
const rpc = make(input.threadId)
|
||||
const row = yield* rpc.logs(LogsInput.make({ lines: input.lines })).pipe(
|
||||
Effect.mapError((cause) => asThreadError(input.threadId, cause)),
|
||||
)
|
||||
if (row === null) return Option.none()
|
||||
return Option.some({
|
||||
sandboxId: row.sandboxId,
|
||||
output: row.output,
|
||||
})
|
||||
})
|
||||
|
||||
return ThreadControlCluster.of({ active, pause, resume, logs })
|
||||
}),
|
||||
)
|
||||
150
packages/discord/src/conversation/thread/cluster/entity.test.ts
Normal file
150
packages/discord/src/conversation/thread/cluster/entity.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { TestRunner } from "@effect/cluster"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { History } from "../../history"
|
||||
import { ThreadChatCluster, ThreadChatClusterLive, ThreadEntityLive } from "."
|
||||
import { OpenCodeClient, OpenCodeSessionSummary } from "../../../sandbox/opencode/client"
|
||||
import { SandboxProvisioner } from "../../../sandbox/provisioner"
|
||||
import { SessionStore } from "../../../session/store"
|
||||
import { ChannelId, GuildId, PreviewAccess, SandboxId, SessionId, SessionInfo, ThreadId } from "../../../types"
|
||||
import { DaytonaService } from "../../../sandbox/daytona/service"
|
||||
import { OpenCodeClientError } from "../../../errors"
|
||||
import { SqliteDb } from "../../../db/client"
|
||||
import { effectTest, testConfigLayer } from "../../../test/effect"
|
||||
|
||||
const makeSession = (input: { threadId: ThreadId; channelId: ChannelId; guildId: GuildId; sandboxId: string; sessionId: string }) =>
|
||||
SessionInfo.make({
|
||||
threadId: input.threadId,
|
||||
channelId: input.channelId,
|
||||
guildId: input.guildId,
|
||||
sandboxId: SandboxId.make(input.sandboxId),
|
||||
sessionId: SessionId.make(input.sessionId),
|
||||
previewUrl: "https://preview",
|
||||
previewToken: null,
|
||||
status: "active",
|
||||
lastError: null,
|
||||
resumeFailCount: 0,
|
||||
})
|
||||
|
||||
describe("ThreadEntity", () => {
|
||||
effectTest("retries after sandbox-down by re-ensuring session and rehydrating prompt", () => {
|
||||
const threadId = ThreadId.make("t-dead")
|
||||
const channelId = ChannelId.make("c-dead")
|
||||
const guildId = GuildId.make("g-dead")
|
||||
const first = makeSession({ threadId, channelId, guildId, sandboxId: "sb-1", sessionId: "s-1" })
|
||||
const second = makeSession({ threadId, channelId, guildId, sandboxId: "sb-2", sessionId: "s-2" })
|
||||
|
||||
const ensured: Array<string> = []
|
||||
const recovered: Array<string> = []
|
||||
const sent: Array<string> = []
|
||||
const rehydrated: Array<string> = []
|
||||
|
||||
const historyLayer = Layer.succeed(
|
||||
History,
|
||||
History.of({
|
||||
rehydrate: (_threadId, latest) =>
|
||||
Effect.sync(() => {
|
||||
rehydrated.push(latest)
|
||||
return `rehydrated:${latest}`
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const provisionerLayer = Layer.succeed(
|
||||
SandboxProvisioner,
|
||||
SandboxProvisioner.of({
|
||||
provision: () => Effect.dieMessage("unused"),
|
||||
resume: () => Effect.dieMessage("unused"),
|
||||
ensureActive: (input) =>
|
||||
Effect.sync(() => {
|
||||
ensured.push(Option.isSome(input.current) ? String(input.current.value.sessionId) : "none")
|
||||
return ensured.length === 1 ? first : second
|
||||
}),
|
||||
ensureHealthy: () => Effect.succeed(true),
|
||||
recoverSendFailure: (_threadId, session) =>
|
||||
Effect.sync(() => {
|
||||
recovered.push(String(session.sessionId))
|
||||
return SessionInfo.make({
|
||||
...session,
|
||||
status: "error",
|
||||
lastError: "send-failed",
|
||||
})
|
||||
}),
|
||||
pause: (_threadId, session) => Effect.succeed(session),
|
||||
destroy: (_threadId, session) => Effect.succeed(session),
|
||||
}),
|
||||
)
|
||||
|
||||
const openCodeLayer = Layer.succeed(
|
||||
OpenCodeClient,
|
||||
OpenCodeClient.of({
|
||||
waitForHealthy: () => Effect.succeed(true),
|
||||
createSession: () => Effect.succeed(SessionId.make("unused")),
|
||||
sessionExists: () => Effect.succeed(true),
|
||||
listSessions: () => Effect.succeed([] as ReadonlyArray<OpenCodeSessionSummary>),
|
||||
sendPrompt: (_preview, _sessionId, text) =>
|
||||
Effect.gen(function* () {
|
||||
sent.push(text)
|
||||
if (sent.length === 1) {
|
||||
return yield* OpenCodeClientError.make({
|
||||
operation: "sendPrompt",
|
||||
statusCode: 502,
|
||||
body: "bad gateway",
|
||||
kind: "sandbox-down",
|
||||
})
|
||||
}
|
||||
return `ok:${text}`
|
||||
}),
|
||||
abortSession: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
|
||||
const daytonaLayer = Layer.succeed(
|
||||
DaytonaService,
|
||||
DaytonaService.of({
|
||||
create: () => Effect.dieMessage("unused"),
|
||||
exec: () => Effect.dieMessage("unused"),
|
||||
start: () => Effect.dieMessage("unused"),
|
||||
stop: () => Effect.dieMessage("unused"),
|
||||
destroy: () => Effect.void,
|
||||
getPreview: () =>
|
||||
Effect.succeed(
|
||||
PreviewAccess.make({
|
||||
previewUrl: "https://preview",
|
||||
previewToken: null,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
const live = ThreadChatClusterLive.pipe(
|
||||
Layer.provideMerge(ThreadEntityLive),
|
||||
Layer.provideMerge(TestRunner.layer),
|
||||
Layer.provideMerge(SessionStore.layer),
|
||||
Layer.provideMerge(SqliteDb.layer),
|
||||
Layer.provideMerge(testConfigLayer),
|
||||
Layer.provideMerge(daytonaLayer),
|
||||
Layer.provideMerge(openCodeLayer),
|
||||
Layer.provideMerge(provisionerLayer),
|
||||
Layer.provideMerge(historyLayer),
|
||||
)
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const threadChat = yield* ThreadChatCluster
|
||||
const out = yield* threadChat.send({
|
||||
threadId,
|
||||
channelId,
|
||||
guildId,
|
||||
messageId: "m-1",
|
||||
text: "hello",
|
||||
})
|
||||
|
||||
expect(ensured).toEqual(["none", "s-1"])
|
||||
expect(recovered).toEqual(["s-1"])
|
||||
expect(rehydrated).toEqual(["hello"])
|
||||
expect(sent).toEqual(["hello", "rehydrated:hello"])
|
||||
expect(out.text).toBe("ok:rehydrated:hello")
|
||||
expect(out.session.sessionId).toBe(SessionId.make("s-2"))
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
})
|
||||
315
packages/discord/src/conversation/thread/cluster/entity.ts
Normal file
315
packages/discord/src/conversation/thread/cluster/entity.ts
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import { ClusterSchema, Entity } from "@effect/cluster"
|
||||
import { Rpc } from "@effect/rpc"
|
||||
import { Effect, Option, Predicate, Schema } from "effect"
|
||||
import {
|
||||
DatabaseError,
|
||||
type HealthCheckError,
|
||||
isOpenCodeSandboxUnavailable,
|
||||
OpenCodeClientError,
|
||||
SandboxDeadError,
|
||||
type SandboxStartError,
|
||||
} from "../../../errors"
|
||||
import { StatefulActor } from "../../../lib/actors/stateful"
|
||||
import { DaytonaService } from "../../../sandbox/daytona/service"
|
||||
import { OpenCodeClient } from "../../../sandbox/opencode/client"
|
||||
import { SandboxProvisioner } from "../../../sandbox/provisioner"
|
||||
import { SessionStore } from "../../../session/store"
|
||||
import { ChannelId, GuildId, PreviewAccess, SessionInfo, ThreadId } from "../../../types"
|
||||
import { History } from "../../history"
|
||||
import {
|
||||
LogsInput,
|
||||
LogsOutput,
|
||||
PauseInput,
|
||||
ResumeInput,
|
||||
SendInput,
|
||||
SendOutput,
|
||||
ThreadChatError,
|
||||
} from "./contracts"
|
||||
|
||||
const StatusInput = Schema.Void
|
||||
const RecreateInput = Schema.Void
|
||||
|
||||
type RetriableCause = SandboxDeadError | OpenCodeClientError | HealthCheckError | SandboxStartError
|
||||
|
||||
const isRetriableCause = (cause: unknown): cause is RetriableCause =>
|
||||
Predicate.isTagged(cause, "SandboxDeadError") ||
|
||||
Predicate.isTagged(cause, "OpenCodeClientError") ||
|
||||
Predicate.isTagged(cause, "HealthCheckError") ||
|
||||
Predicate.isTagged(cause, "SandboxStartError")
|
||||
|
||||
const toThreadError = (threadId: ThreadId, cause: unknown): ThreadChatError =>
|
||||
ThreadChatError.make({
|
||||
threadId,
|
||||
cause,
|
||||
retriable: isRetriableCause(cause),
|
||||
})
|
||||
|
||||
class ThreadState extends Schema.Class<ThreadState>("ClusterMode/ThreadState")({
|
||||
loaded: Schema.Boolean,
|
||||
session: Schema.NullOr(SessionInfo),
|
||||
}) {
|
||||
static empty() {
|
||||
return ThreadState.make({ loaded: false, session: null })
|
||||
}
|
||||
|
||||
option() {
|
||||
return Option.fromNullable(this.session)
|
||||
}
|
||||
|
||||
hydrate(row: Option.Option<SessionInfo>) {
|
||||
if (Option.isNone(row)) return ThreadState.make({ loaded: true, session: null })
|
||||
return ThreadState.make({ loaded: true, session: row.value })
|
||||
}
|
||||
|
||||
with(session: SessionInfo) {
|
||||
return ThreadState.make({ loaded: true, session })
|
||||
}
|
||||
|
||||
clear() {
|
||||
return ThreadState.make({ loaded: true, session: null })
|
||||
}
|
||||
}
|
||||
|
||||
const SendRpc = Rpc.make("send", {
|
||||
payload: SendInput,
|
||||
success: SendOutput,
|
||||
error: ThreadChatError,
|
||||
}).annotate(ClusterSchema.Persisted, true)
|
||||
|
||||
const StatusRpc = Rpc.make("status", {
|
||||
payload: StatusInput,
|
||||
success: Schema.NullOr(SessionInfo),
|
||||
error: DatabaseError,
|
||||
})
|
||||
|
||||
const RecreateRpc = Rpc.make("recreate", {
|
||||
payload: RecreateInput,
|
||||
success: Schema.Void,
|
||||
error: DatabaseError,
|
||||
})
|
||||
|
||||
const PauseRpc = Rpc.make("pause", {
|
||||
payload: PauseInput,
|
||||
success: Schema.NullOr(SessionInfo),
|
||||
error: ThreadChatError,
|
||||
})
|
||||
|
||||
const ResumeRpc = Rpc.make("resume", {
|
||||
payload: ResumeInput,
|
||||
success: SessionInfo,
|
||||
error: ThreadChatError,
|
||||
})
|
||||
|
||||
const LogsRpc = Rpc.make("logs", {
|
||||
payload: LogsInput,
|
||||
success: Schema.NullOr(LogsOutput),
|
||||
error: ThreadChatError,
|
||||
})
|
||||
|
||||
export const ThreadEntity = Entity.make("ThreadChat", [
|
||||
SendRpc,
|
||||
StatusRpc,
|
||||
RecreateRpc,
|
||||
PauseRpc,
|
||||
ResumeRpc,
|
||||
LogsRpc,
|
||||
])
|
||||
|
||||
export const ThreadEntityLive = ThreadEntity.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const oc = yield* OpenCodeClient
|
||||
const daytona = yield* DaytonaService
|
||||
const store = yield* SessionStore
|
||||
const history = yield* History
|
||||
const provisioner = yield* SandboxProvisioner
|
||||
const entityId = String((yield* Entity.CurrentAddress).entityId)
|
||||
if (entityId.includes("/")) {
|
||||
return yield* Effect.dieMessage(`ThreadEntity expected raw threadId entityId, got "${entityId}"`)
|
||||
}
|
||||
const threadId = ThreadId.make(entityId)
|
||||
const thread = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(Effect.mapError((cause) => toThreadError(threadId, cause)))
|
||||
|
||||
const state = StatefulActor.make(ThreadState.empty())
|
||||
const stateNow = () => state.get().option()
|
||||
const setState = (session: SessionInfo) => {
|
||||
state.update((current) => current.with(session))
|
||||
}
|
||||
const clearState = () => {
|
||||
state.update((current) => current.clear())
|
||||
}
|
||||
|
||||
/** Bootstrap state from DB once, then serve from in-memory state. */
|
||||
const load = Effect.fn("ThreadEntity.load")(function* () {
|
||||
const current = state.get()
|
||||
if (current.loaded) return current.option()
|
||||
const row = yield* store.getByThread(threadId)
|
||||
const hydrated = current.hydrate(row)
|
||||
state.set(hydrated)
|
||||
return hydrated.option()
|
||||
})
|
||||
|
||||
/** Persist the latest session snapshot for crash recovery. */
|
||||
const saveSession = Effect.fnUntraced(function* (session: SessionInfo) {
|
||||
yield* store.upsert(session)
|
||||
})
|
||||
|
||||
/** Update in-memory state and persist it as one operation. */
|
||||
const commitSession = Effect.fnUntraced(function* (session: SessionInfo) {
|
||||
setState(session)
|
||||
yield* saveSession(session)
|
||||
})
|
||||
|
||||
/** Ensure there is an active session for this thread (resume or create). */
|
||||
const ensureSession = Effect.fnUntraced(function* (
|
||||
channelId: ChannelId,
|
||||
guildId: GuildId,
|
||||
current: Option.Option<SessionInfo>,
|
||||
) {
|
||||
return yield* provisioner.ensureActive({
|
||||
threadId,
|
||||
channelId,
|
||||
guildId,
|
||||
current,
|
||||
})
|
||||
})
|
||||
|
||||
/** Rebuild prompt context when session changed; otherwise keep prompt as-is. */
|
||||
const rehydrate = (latest: string, before: Option.Option<SessionInfo>, next: SessionInfo) => {
|
||||
if (Option.isNone(before)) return Effect.succeed(latest)
|
||||
if (before.value.sessionId === next.sessionId) return Effect.succeed(latest)
|
||||
return history.rehydrate(threadId, latest)
|
||||
}
|
||||
|
||||
const recoverFailure = (session: SessionInfo, error: OpenCodeClientError) =>
|
||||
isOpenCodeSandboxUnavailable(error)
|
||||
? SandboxDeadError.make({
|
||||
threadId: session.threadId,
|
||||
reason: `OpenCode send failed (${error.statusCode})`,
|
||||
})
|
||||
: error
|
||||
|
||||
const recoverSendError = Effect.fnUntraced(function* (session: SessionInfo, error: OpenCodeClientError) {
|
||||
const next = yield* provisioner.recoverSendFailure(session.threadId, session, error)
|
||||
yield* commitSession(next)
|
||||
return yield* recoverFailure(session, error)
|
||||
})
|
||||
|
||||
/** Send prompt to OpenCode and record recovery state if send failed. */
|
||||
const sendPrompt = Effect.fnUntraced(function* (session: SessionInfo, text: string) {
|
||||
yield* store.markActivity(session.threadId)
|
||||
return yield* oc
|
||||
.sendPrompt(PreviewAccess.from(session), session.sessionId, text)
|
||||
.pipe(Effect.catchTag("OpenCodeClientError", (error) => recoverSendError(session, error)))
|
||||
})
|
||||
|
||||
/** Retry exactly once after sandbox-dead by re-ensuring and rehydrating. */
|
||||
const retrySend = Effect.fnUntraced(function* (payload: SendInput, prior: SessionInfo, text: string) {
|
||||
return yield* sendPrompt(prior, text).pipe(
|
||||
Effect.catchTag("SandboxDeadError", () =>
|
||||
Effect.gen(function* () {
|
||||
const resumed = yield* ensureSession(payload.channelId, payload.guildId, stateNow())
|
||||
yield* commitSession(resumed)
|
||||
const next = yield* rehydrate(payload.text, Option.some(prior), resumed)
|
||||
return yield* sendPrompt(resumed, next)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
/** Full send lifecycle: load, ensure, persist, rehydrate, send, retry, return output. */
|
||||
const sendNow = Effect.fnUntraced(function* (payload: SendInput) {
|
||||
return yield* Effect.gen(function* () {
|
||||
const before = yield* load()
|
||||
const active = yield* ensureSession(payload.channelId, payload.guildId, before)
|
||||
yield* commitSession(active)
|
||||
const first = yield* rehydrate(payload.text, before, active)
|
||||
const text = yield* retrySend(payload, active, first)
|
||||
const session = Option.getOrElse(stateNow(), () => active)
|
||||
return SendOutput.make({
|
||||
text,
|
||||
session,
|
||||
changedSession: Option.isSome(before) ? before.value.sessionId !== session.sessionId : false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/** Full send lifecycle: load, ensure, persist, rehydrate, send, retry, return output. */
|
||||
const send = Effect.fn("ThreadEntity.send")(function* ({ payload }) {
|
||||
return yield* thread(sendNow(payload))
|
||||
})
|
||||
|
||||
/** Read current session from actor state (bootstrapping from DB on first access). */
|
||||
const status = Effect.fn("ThreadEntity.status")(function* () {
|
||||
const row = yield* load()
|
||||
if (Option.isNone(row)) return null
|
||||
return row.value
|
||||
})
|
||||
|
||||
/** Destroy current session resources for this thread and clear actor state. */
|
||||
const recreate = Effect.fn("ThreadEntity.recreate")(function* () {
|
||||
const row = yield* load()
|
||||
if (Option.isNone(row)) {
|
||||
clearState()
|
||||
return
|
||||
}
|
||||
const next = yield* provisioner.destroy(threadId, row.value, "cluster-recreate")
|
||||
yield* store.upsert(next)
|
||||
clearState()
|
||||
})
|
||||
|
||||
/** Pause active session if present and commit paused state. */
|
||||
const pause = Effect.fn("ThreadEntity.pause")(function* ({ payload }) {
|
||||
return yield* thread(Effect.gen(function* () {
|
||||
const row = yield* load()
|
||||
if (Option.isNone(row)) return null
|
||||
const next = yield* provisioner.pause(threadId, row.value, payload.reason)
|
||||
yield* commitSession(next)
|
||||
return next
|
||||
}))
|
||||
})
|
||||
|
||||
/** Resume existing session or create one when missing, then commit state. */
|
||||
const resume = Effect.fn("ThreadEntity.resume")(function* ({ payload }) {
|
||||
return yield* thread(Effect.gen(function* () {
|
||||
const before = yield* load()
|
||||
const channelId = payload.channelId === null
|
||||
? Option.match(before, {
|
||||
onNone: () => ChannelId.make("ctl"),
|
||||
onSome: (row) => row.channelId,
|
||||
})
|
||||
: payload.channelId
|
||||
const guildId = payload.guildId === null
|
||||
? Option.match(before, {
|
||||
onNone: () => GuildId.make("local"),
|
||||
onSome: (row) => row.guildId,
|
||||
})
|
||||
: payload.guildId
|
||||
const next = yield* ensureSession(channelId, guildId, before)
|
||||
yield* commitSession(next)
|
||||
return next
|
||||
}))
|
||||
})
|
||||
|
||||
/** Read sandbox log tail for active session if present. */
|
||||
const logs = Effect.fn("ThreadEntity.logs")(function* ({ payload }) {
|
||||
return yield* thread(Effect.gen(function* () {
|
||||
const row = yield* load()
|
||||
if (Option.isNone(row)) return null
|
||||
const out = yield* daytona.exec(
|
||||
row.value.sandboxId,
|
||||
"read-opencode-log",
|
||||
`cat /tmp/opencode.log 2>/dev/null | tail -${payload.lines}`,
|
||||
)
|
||||
return LogsOutput.make({
|
||||
sandboxId: row.value.sandboxId,
|
||||
output: out.output,
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
const handlers = { send, status, recreate, pause, resume, logs }
|
||||
return ThreadEntity.of(handlers)
|
||||
}),
|
||||
{ maxIdleTime: "30 minutes" },
|
||||
)
|
||||
14
packages/discord/src/conversation/thread/cluster/index.ts
Normal file
14
packages/discord/src/conversation/thread/cluster/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export {
|
||||
LogsInput,
|
||||
LogsOutput,
|
||||
PauseInput,
|
||||
ResumeInput,
|
||||
SendInput,
|
||||
SendOutput,
|
||||
ThreadChatCluster,
|
||||
ThreadControlCluster,
|
||||
ThreadChatError,
|
||||
} from "./contracts"
|
||||
export { ThreadEntity, ThreadEntityLive } from "./entity"
|
||||
export { ThreadChatClusterLive } from "./chat-live"
|
||||
export { ThreadControlClusterLive } from "./control-live"
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Context, Effect, Layer } from "effect"
|
||||
import { ThreadEnsureError } from "../model/errors"
|
||||
import type { Inbound, ThreadRef } from "../model/schema"
|
||||
import { ThreadEnsureError } from "./model/errors"
|
||||
import type { Inbound, ThreadRef } from "./model/schema"
|
||||
|
||||
export declare namespace Threads {
|
||||
export interface Service {
|
||||
|
|
@ -14,11 +14,11 @@ export class Threads extends Context.Tag("@discord/conversation/Threads")<Thread
|
|||
Threads.of({
|
||||
ensure: (event) => {
|
||||
if (event.kind === "thread_message") {
|
||||
return Effect.succeed({ thread_id: event.thread_id, channel_id: event.channel_id })
|
||||
return Effect.succeed({ threadId: event.threadId, channelId: event.channelId })
|
||||
}
|
||||
return Effect.fail(
|
||||
ThreadEnsureError.make({
|
||||
channel_id: event.channel_id,
|
||||
channelId: event.channelId,
|
||||
message: "threads adapter missing for channel message",
|
||||
retriable: false,
|
||||
}),
|
||||
|
|
@ -1,22 +1,21 @@
|
|||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||
import { Reactivity } from "@effect/experimental"
|
||||
import * as Client from "@effect/sql/SqlClient"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { AppConfig } from "../config"
|
||||
|
||||
export class SqliteDb extends Context.Tag("@discord/SqliteDb")<SqliteDb, Client.SqlClient>() {
|
||||
static readonly layer = Layer.effect(
|
||||
SqliteDb,
|
||||
static readonly layer = Layer.scopedContext(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* Client.SqlClient
|
||||
const config = yield* AppConfig
|
||||
const db = yield* SqliteClient.make({ filename: config.databasePath }).pipe(
|
||||
Effect.provide(Reactivity.layer),
|
||||
)
|
||||
yield* db`PRAGMA busy_timeout = 5000`
|
||||
return db
|
||||
return Context.empty().pipe(
|
||||
Context.add(SqliteDb, db),
|
||||
Context.add(Client.SqlClient, db),
|
||||
)
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provide(
|
||||
Layer.unwrapEffect(
|
||||
Effect.map(AppConfig, (config) => SqliteClient.layer({ filename: config.databasePath })),
|
||||
),
|
||||
),
|
||||
Layer.orDie,
|
||||
)
|
||||
).pipe(Layer.orDie)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,10 @@ 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 run = Migrator.make({})({
|
||||
loader: Migrator.fromRecord({
|
||||
"0001_discord_sessions": migration0001,
|
||||
"0002_conversation_offsets": migration0002,
|
||||
}),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
import * as Client from "@effect/sql/SqlClient"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const TABLE = `CREATE TABLE IF NOT EXISTS conversation_offsets (
|
||||
source_id TEXT PRIMARY KEY,
|
||||
last_message_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`
|
||||
|
||||
const COLUMNS = [
|
||||
["last_message_id", "TEXT NOT NULL"],
|
||||
["updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"],
|
||||
] as const
|
||||
|
||||
const INDEXES = [
|
||||
`CREATE INDEX IF NOT EXISTS conversation_offsets_updated_at_idx
|
||||
ON conversation_offsets (updated_at)`,
|
||||
] as const
|
||||
|
||||
export default Effect.gen(function* () {
|
||||
const db = yield* Client.SqlClient
|
||||
yield* db.unsafe(TABLE)
|
||||
|
||||
const names = new Set((yield* db<{ name: string }>`PRAGMA table_info(conversation_offsets)`).map((row) => row.name))
|
||||
const missing = COLUMNS.filter(([name]) => !names.has(name))
|
||||
yield* Effect.forEach(missing, ([name, definition]) => db.unsafe(`ALTER TABLE conversation_offsets ADD COLUMN ${name} ${definition}`), {
|
||||
discard: true,
|
||||
})
|
||||
|
||||
yield* Effect.forEach(INDEXES, (index) => db.unsafe(index), { discard: true })
|
||||
})
|
||||
|
|
@ -1,222 +1,81 @@
|
|||
import type { ChatInputCommandInteraction, GuildMember, Interaction, Message, TextChannel, ThreadChannel } from "discord.js"
|
||||
import type { ChatInputCommandInteraction, GuildMember, Interaction, Message, TextChannel } 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"
|
||||
import { AppConfig } from "../config"
|
||||
import { DiscordClient } from "./client"
|
||||
import { TYPING_INTERVAL } from "./constants"
|
||||
import { cleanResponse, splitForDiscord } from "./format"
|
||||
import { SessionStore } from "../session/store"
|
||||
import { ChannelId, GuildId, ThreadId } from "../types"
|
||||
import { DeliveryError, HistoryError, messageOf, ThreadEnsureError } from "../conversation/model/errors"
|
||||
import { ChannelMessage, Mention, ThreadMessage, ThreadRef, Typing, type Action, type Inbound } from "../conversation/model/schema"
|
||||
import { History, Inbox, OffsetStore, Outbox, Threads } from "../conversation"
|
||||
import {
|
||||
COMMAND_ACK,
|
||||
COMMAND_CHANNEL_REPLY,
|
||||
COMMAND_FORBIDDEN_REPLY,
|
||||
COMMAND_NOT_THREAD_REPLY,
|
||||
COMMANDS,
|
||||
EMPTY_MENTION_REPLY,
|
||||
SETUP_FAILURE_REPLY,
|
||||
commandText,
|
||||
} from "./conversation-commands"
|
||||
import {
|
||||
asTextChannel,
|
||||
asThreadChannel,
|
||||
type ChatChannel,
|
||||
hasRequiredRole,
|
||||
isChannelAllowed,
|
||||
isMentioned,
|
||||
} from "./conversation-channels"
|
||||
import { catchupBenign, deliveryRetriable, deliveryRetry } from "./conversation-delivery"
|
||||
import { buildHistoryReplayPrompt } from "./conversation-history"
|
||||
import { catchupFromOffset } from "./catchup"
|
||||
|
||||
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 CACHE_LIMIT = 4_000
|
||||
const CATCHUP_PAGE_SIZE = 100
|
||||
const COMMANDS = [
|
||||
{
|
||||
name: "status",
|
||||
description: "Show sandbox status for this thread",
|
||||
},
|
||||
{
|
||||
name: "reset",
|
||||
description: "Destroy the sandbox session for this thread",
|
||||
},
|
||||
] as const
|
||||
|
||||
const commandText = (name: string): string => {
|
||||
if (name === "status") return "!status"
|
||||
if (name === "reset") return "!reset"
|
||||
return ""
|
||||
}
|
||||
|
||||
const isChannelAllowed = (channelId: string, categoryId: string | null, config: AppConfig.Service): boolean => {
|
||||
if (config.allowedChannelIds.length > 0 && config.allowedChannelIds.includes(channelId)) return true
|
||||
if (config.discordCategoryId && categoryId === config.discordCategoryId) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const hasRequiredRole = (member: GuildMember | null, config: AppConfig.Service): boolean => {
|
||||
if (!config.discordRequiredRoleId) return true
|
||||
if (!member) return false
|
||||
return member.roles.cache.has(config.discordRequiredRoleId)
|
||||
}
|
||||
|
||||
const asThreadChannel = (value: unknown): ThreadChannel | null => {
|
||||
if (typeof value !== "object" || value === null) return null
|
||||
const type = (value as { type?: unknown }).type
|
||||
if (type === ChannelType.PublicThread || type === ChannelType.PrivateThread) return value as ThreadChannel
|
||||
return null
|
||||
}
|
||||
|
||||
const asTextChannel = (value: unknown): TextChannel | null => {
|
||||
if (typeof value !== "object" || value === null) return null
|
||||
const type = (value as { type?: unknown }).type
|
||||
if (type === ChannelType.GuildText) return value as TextChannel
|
||||
return null
|
||||
}
|
||||
|
||||
const isMentioned = (message: Message, botUserId: string, botRoleId: string): boolean => {
|
||||
if (botUserId.length > 0 && message.mentions.users.has(botUserId)) return true
|
||||
if (botRoleId.length > 0 && message.mentions.roles.has(botRoleId)) return true
|
||||
if (botUserId.length > 0 && message.content.includes(`<@${botUserId}>`)) return true
|
||||
if (botUserId.length > 0 && message.content.includes(`<@!${botUserId}>`)) return true
|
||||
if (botRoleId.length > 0 && message.content.includes(`<@&${botRoleId}>`)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const buildHistoryReplayPrompt = Effect.fn("DiscordAdapter.buildHistoryReplayPrompt")(
|
||||
function* (channel: ChatChannel, latest: string) {
|
||||
const fetched = yield* Effect.tryPromise(() => channel.messages.fetch({ limit: HISTORY_FETCH_LIMIT }))
|
||||
const ordered = [...fetched.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp)
|
||||
const lines = ordered
|
||||
.filter((prior) => !prior.system)
|
||||
.flatMap((prior) => {
|
||||
const text = prior.content.replace(/\s+/g, " ").trim()
|
||||
const files = prior.attachments.size > 0
|
||||
? `[attachments: ${[...prior.attachments.values()].map((att) => att.name ?? "file").join(", ")}]`
|
||||
: ""
|
||||
const line = text || files
|
||||
if (!line) return []
|
||||
const value = line.length > HISTORY_LINE_CHAR_LIMIT ? `${line.slice(0, HISTORY_LINE_CHAR_LIMIT)}...` : line
|
||||
return [`${prior.author.bot ? "assistant" : "user"}: ${value}`]
|
||||
})
|
||||
|
||||
const prior = lines.at(-1) === `user: ${latest}` ? lines.slice(0, -1) : lines
|
||||
if (prior.length === 0) return latest
|
||||
|
||||
const selected = prior.reduceRight(
|
||||
(state, candidate) => {
|
||||
if (state.stop) return state
|
||||
if (state.total + candidate.length > HISTORY_TOTAL_CHAR_LIMIT && state.list.length > 0) {
|
||||
return { ...state, stop: true }
|
||||
}
|
||||
return { list: [candidate, ...state.list], total: state.total + candidate.length, stop: false }
|
||||
},
|
||||
{ list: [] as ReadonlyArray<string>, total: 0, stop: false },
|
||||
).list
|
||||
|
||||
return [
|
||||
"Conversation history from this same Discord thread (oldest to newest):",
|
||||
selected.join("\n"),
|
||||
"",
|
||||
"Continue the same conversation and respond to the latest user message:",
|
||||
latest,
|
||||
].join("\n")
|
||||
},
|
||||
)
|
||||
|
||||
const statusOf = (cause: unknown): number | null => {
|
||||
if (typeof cause !== "object" || cause === null) return null
|
||||
const status = (cause as { status?: unknown }).status
|
||||
if (typeof status === "number") return status
|
||||
const code = (cause as { code?: unknown }).code
|
||||
if (typeof code === "number") return code
|
||||
return null
|
||||
}
|
||||
|
||||
const deliveryRetriable = (cause: unknown): boolean => {
|
||||
const status = statusOf(cause)
|
||||
if (status === 429) return true
|
||||
if (status !== null && status >= 500) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const catchupBenign = (cause: unknown): boolean => {
|
||||
const text = messageOf(cause).toLowerCase()
|
||||
if (text.includes("missing access")) return true
|
||||
if (text.includes("missing permissions")) return true
|
||||
if (text.includes("unknown channel")) return true
|
||||
if (text.includes("50001")) return true
|
||||
if (text.includes("50013")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const deliveryRetry = Schedule.exponential("200 millis").pipe(
|
||||
Schedule.intersect(Schedule.recurs(3)),
|
||||
Schedule.whileInput((error: DeliveryError) => error.retriable),
|
||||
)
|
||||
|
||||
export class DiscordConversationServices {
|
||||
static readonly portLayer = Layer.scopedContext(
|
||||
Effect.gen(function* () {
|
||||
const client = yield* DiscordClient
|
||||
const config = yield* AppConfig
|
||||
const pool = yield* ThreadAgentPool
|
||||
const sessions = yield* SessionStore
|
||||
const ledger = yield* ConversationLedger
|
||||
const offsets = yield* OffsetStore
|
||||
const runtime = yield* Effect.runtime<never>()
|
||||
const input = yield* Queue.unbounded<Inbound>()
|
||||
const chats = new Map<string, ChatChannel>()
|
||||
const texts = new Map<string, TextChannel>()
|
||||
const refs = new Map<string, Message>()
|
||||
const roots = new Map<string, ThreadId>()
|
||||
const seen = new Set<string>()
|
||||
const order: Array<string> = []
|
||||
const ref_ids: Array<string> = []
|
||||
const root_ids: Array<string> = []
|
||||
|
||||
const mark = (message_id: string): boolean => {
|
||||
if (seen.has(message_id)) return false
|
||||
seen.add(message_id)
|
||||
order.push(message_id)
|
||||
if (order.length <= INGRESS_DEDUP_LIMIT) return true
|
||||
const oldest = order.shift()
|
||||
if (!oldest) return true
|
||||
seen.delete(oldest)
|
||||
return true
|
||||
}
|
||||
|
||||
const stash = <A>(map: Map<string, A>, keys: Array<string>, key: string, value: A) => {
|
||||
if (!map.has(key)) keys.push(key)
|
||||
map.set(key, value)
|
||||
if (keys.length <= INGRESS_DEDUP_LIMIT) return
|
||||
if (keys.length <= CACHE_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 sourceChannel = (channelId: string) => `channel:${channelId}`
|
||||
const sourceThread = (threadId: string) => `thread:${threadId}`
|
||||
const uniq = <A>(values: ReadonlyArray<A>): Array<A> => [...new Set(values)]
|
||||
|
||||
const offer = (event: Inbound, onFresh: Effect.Effect<void>) =>
|
||||
ledger.dedup(event.message_id).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,
|
||||
)
|
||||
Effect.logInfo("Message queued").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.message.queued",
|
||||
kind: event.kind,
|
||||
message_id: event.messageId,
|
||||
author_id: event.authorId,
|
||||
content: event.content.slice(0, 200),
|
||||
}),
|
||||
Effect.zipRight(onFresh),
|
||||
Effect.zipRight(input.offer(event)),
|
||||
Effect.asVoid,
|
||||
)
|
||||
|
||||
const memberOf = (message: Message) => {
|
||||
|
|
@ -238,102 +97,103 @@ export class DiscordConversationServices {
|
|||
if (source === null) return
|
||||
|
||||
if (message.author.bot || message.mentions.everyone) {
|
||||
yield* ledger.setOffset(source, message.id)
|
||||
yield* offsets.setOffset(source, message.id)
|
||||
return
|
||||
}
|
||||
const member = yield* memberOf(message)
|
||||
if (!hasRequiredRole(member, config)) {
|
||||
yield* ledger.setOffset(source, message.id)
|
||||
yield* offsets.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 botUserId = client.user?.id ?? ""
|
||||
const botRoleId = config.discordRoleId
|
||||
const mentioned = isMentioned(message, botUserId, botRoleId)
|
||||
const content = message.content.replace(/<@[!&]?\d+>/g, "").trim()
|
||||
const mentions = Mention.make({
|
||||
user_ids: [...message.mentions.users.keys()],
|
||||
role_ids: [...message.mentions.roles.keys()],
|
||||
userIds: [...message.mentions.users.keys()],
|
||||
roleIds: [...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)
|
||||
yield* offsets.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 thread = asThreadChannel(message.channel)
|
||||
if (!thread) return
|
||||
const threadId = ThreadId.make(thread.id)
|
||||
const channelId = 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(
|
||||
const owned = yield* sessions.hasTrackedThread(threadId).pipe(
|
||||
Effect.catchAll(() => Effect.succeed(false)),
|
||||
)
|
||||
if (!owned || mentioned) {
|
||||
yield* ledger.setOffset(source, message.id)
|
||||
yield* offsets.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,
|
||||
threadId,
|
||||
channelId,
|
||||
messageId: message.id,
|
||||
guildId: GuildId.make(message.guildId ?? ""),
|
||||
botUserId,
|
||||
botRoleId,
|
||||
authorId: message.author.id,
|
||||
authorIsBot: message.author.bot,
|
||||
mentionsEveryone: message.mentions.everyone,
|
||||
mentions,
|
||||
content,
|
||||
})
|
||||
yield* offer(
|
||||
event,
|
||||
Effect.sync(() => {
|
||||
chats.set(event.thread_id, thread)
|
||||
stash(refs, ref_ids, event.message_id, message)
|
||||
chats.set(event.threadId, thread)
|
||||
stash(refs, ref_ids, event.messageId, message)
|
||||
}),
|
||||
)
|
||||
yield* ledger.setOffset(source, message.id)
|
||||
yield* offsets.setOffset(source, message.id)
|
||||
return
|
||||
}
|
||||
|
||||
const channel = message.channel as TextChannel
|
||||
const channel = asTextChannel(message.channel)
|
||||
if (!channel) return
|
||||
if (!isChannelAllowed(channel.id, channel.parentId ?? null, config)) {
|
||||
yield* ledger.setOffset(source, message.id)
|
||||
yield* offsets.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,
|
||||
channelId: ChannelId.make(channel.id),
|
||||
messageId: message.id,
|
||||
guildId: GuildId.make(message.guildId ?? ""),
|
||||
botUserId,
|
||||
botRoleId,
|
||||
authorId: message.author.id,
|
||||
authorIsBot: message.author.bot,
|
||||
mentionsEveryone: message.mentions.everyone,
|
||||
mentions,
|
||||
content,
|
||||
})
|
||||
yield* offer(
|
||||
event,
|
||||
Effect.sync(() => {
|
||||
texts.set(event.channel_id, channel)
|
||||
stash(refs, ref_ids, event.message_id, message)
|
||||
texts.set(event.channelId, channel)
|
||||
stash(refs, ref_ids, event.messageId, message)
|
||||
}),
|
||||
)
|
||||
yield* ledger.setOffset(source, message.id)
|
||||
yield* offsets.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(
|
||||
|
|
@ -347,38 +207,28 @@ export class DiscordConversationServices {
|
|||
void Runtime.runPromise(runtime)(run)
|
||||
}
|
||||
|
||||
const pullAfter = (channel: ChatChannel, after: string): Effect.Effect<number, unknown> =>
|
||||
Effect.tryPromise(() =>
|
||||
channel.messages.fetch({
|
||||
limit: CATCHUP_PAGE_SIZE,
|
||||
after,
|
||||
})
|
||||
).pipe(
|
||||
Effect.map((page) => [...page.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp)),
|
||||
Effect.flatMap((rows) => {
|
||||
if (rows.length === 0) return Effect.succeed(0)
|
||||
const last = rows.at(-1)
|
||||
if (!last) return Effect.succeed(0)
|
||||
return Effect.forEach(rows, (row) => ingestMessage(row), { discard: true }).pipe(
|
||||
Effect.zipRight(
|
||||
rows.length < CATCHUP_PAGE_SIZE
|
||||
? Effect.succeed(rows.length)
|
||||
: pullAfter(channel, last.id).pipe(Effect.map((tail: number) => rows.length + tail)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const catchupSource = (source: string, channel: ChatChannel) =>
|
||||
Effect.gen(function* () {
|
||||
const offset = yield* ledger.getOffset(source)
|
||||
if (Option.isNone(offset)) {
|
||||
const page = yield* Effect.tryPromise(() => channel.messages.fetch({ limit: 1 }))
|
||||
const latest = page.first()
|
||||
if (latest) yield* ledger.setOffset(source, latest.id)
|
||||
return 0
|
||||
}
|
||||
return yield* pullAfter(channel, offset.value)
|
||||
catchupFromOffset({
|
||||
source,
|
||||
pageSize: CATCHUP_PAGE_SIZE,
|
||||
offsets,
|
||||
fetchLatest: Effect.tryPromise(() => channel.messages.fetch({ limit: 1 })).pipe(
|
||||
Effect.map((page) => {
|
||||
const latest = page.first()
|
||||
return latest ? Option.some(latest) : Option.none()
|
||||
}),
|
||||
),
|
||||
fetchAfter: (after) =>
|
||||
Effect.tryPromise(() =>
|
||||
channel.messages.fetch({
|
||||
limit: CATCHUP_PAGE_SIZE,
|
||||
after,
|
||||
})
|
||||
).pipe(
|
||||
Effect.map((page) => [...page.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp)),
|
||||
),
|
||||
idOf: (message) => message.id,
|
||||
ingest: (message) => ingestMessage(message),
|
||||
})
|
||||
|
||||
const categoryChannels = () =>
|
||||
|
|
@ -404,14 +254,14 @@ export class DiscordConversationServices {
|
|||
return nested.flat()
|
||||
})
|
||||
|
||||
const fetchText = (channel_id: string) =>
|
||||
Effect.tryPromise(() => client.channels.fetch(channel_id)).pipe(
|
||||
const fetchText = (channelId: string) =>
|
||||
Effect.tryPromise(() => client.channels.fetch(channelId)).pipe(
|
||||
Effect.map((channel) => asTextChannel(channel)),
|
||||
Effect.catchAll(() => Effect.succeed(null)),
|
||||
)
|
||||
|
||||
const fetchThread = (thread_id: string) =>
|
||||
Effect.tryPromise(() => client.channels.fetch(thread_id)).pipe(
|
||||
const fetchThread = (threadId: string) =>
|
||||
Effect.tryPromise(() => client.channels.fetch(threadId)).pipe(
|
||||
Effect.map((channel) => asThreadChannel(channel)),
|
||||
Effect.catchAll(() => Effect.succeed(null)),
|
||||
)
|
||||
|
|
@ -424,18 +274,18 @@ export class DiscordConversationServices {
|
|||
|
||||
const fromChannels = yield* Effect.forEach(
|
||||
channels,
|
||||
(channel_id) =>
|
||||
fetchText(channel_id).pipe(
|
||||
(channelId) =>
|
||||
fetchText(channelId).pipe(
|
||||
Effect.flatMap((channel) => {
|
||||
if (!channel) return Effect.succeed(0)
|
||||
return catchupSource(sourceChannel(channel_id), channel)
|
||||
return catchupSource(sourceChannel(channelId), 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,
|
||||
channel_id: channelId,
|
||||
error: messageOf(error),
|
||||
}),
|
||||
Effect.as(0),
|
||||
|
|
@ -447,18 +297,18 @@ export class DiscordConversationServices {
|
|||
|
||||
const fromThreads = yield* Effect.forEach(
|
||||
threads,
|
||||
(thread_id) =>
|
||||
fetchThread(thread_id).pipe(
|
||||
(threadId) =>
|
||||
fetchThread(threadId).pipe(
|
||||
Effect.flatMap((thread) => {
|
||||
if (!thread) return Effect.succeed(0)
|
||||
return catchupSource(sourceThread(thread_id), thread)
|
||||
return catchupSource(sourceThread(threadId), 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,
|
||||
thread_id: threadId,
|
||||
error: messageOf(error),
|
||||
}),
|
||||
Effect.as(0),
|
||||
|
|
@ -492,7 +342,6 @@ export class DiscordConversationServices {
|
|||
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({
|
||||
|
|
@ -504,11 +353,11 @@ export class DiscordConversationServices {
|
|||
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 threadId = ThreadId.make(thread.id)
|
||||
const channelId = 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(
|
||||
const owned = yield* sessions.hasTrackedThread(threadId).pipe(
|
||||
Effect.catchAll(() => Effect.succeed(false)),
|
||||
)
|
||||
if (!owned) {
|
||||
|
|
@ -525,35 +374,28 @@ export class DiscordConversationServices {
|
|||
return
|
||||
}
|
||||
|
||||
const bot_user_id = client.user?.id ?? ""
|
||||
const botUserId = 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,
|
||||
threadId,
|
||||
channelId,
|
||||
messageId: interaction.id,
|
||||
guildId: GuildId.make(interaction.guildId ?? ""),
|
||||
botUserId,
|
||||
botRoleId: config.discordRoleId,
|
||||
authorId: interaction.user.id,
|
||||
authorIsBot: false,
|
||||
mentionsEveryone: false,
|
||||
mentions: Mention.make({
|
||||
user_ids: bot_user_id.length > 0 ? [bot_user_id] : [],
|
||||
role_ids: [],
|
||||
userIds: botUserId.length > 0 ? [botUserId] : [],
|
||||
roleIds: [],
|
||||
}),
|
||||
content: text,
|
||||
})
|
||||
const ingest = ledger.dedup(event.message_id).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* Effect.sync(() => {
|
||||
chats.set(event.threadId, thread)
|
||||
input.unsafeOffer(event)
|
||||
})
|
||||
yield* acknowledge(interaction, COMMAND_ACK)
|
||||
})
|
||||
void Runtime.runPromise(runtime)(handle)
|
||||
|
|
@ -621,18 +463,18 @@ export class DiscordConversationServices {
|
|||
events: Stream.fromQueue(input, { shutdown: false }),
|
||||
})
|
||||
|
||||
const channelOf = (thread_id: ThreadId, action: Action["kind"]) => {
|
||||
const channel = chats.get(thread_id)
|
||||
const channelOf = (threadId: ThreadId, action: Action["kind"]) => {
|
||||
const channel = chats.get(threadId)
|
||||
if (channel) return Effect.succeed(channel)
|
||||
return Effect.tryPromise(() => client.channels.fetch(thread_id)).pipe(
|
||||
return Effect.tryPromise(() => client.channels.fetch(threadId)).pipe(
|
||||
Effect.flatMap((fetched) => {
|
||||
const thread = asThreadChannel(fetched)
|
||||
if (thread) {
|
||||
chats.set(thread_id, thread)
|
||||
chats.set(threadId, thread)
|
||||
return Effect.succeed(thread)
|
||||
}
|
||||
return DeliveryError.make({
|
||||
thread_id,
|
||||
threadId,
|
||||
action,
|
||||
message: "missing-thread-channel",
|
||||
retriable: false,
|
||||
|
|
@ -640,7 +482,7 @@ export class DiscordConversationServices {
|
|||
}),
|
||||
Effect.mapError((cause) =>
|
||||
DeliveryError.make({
|
||||
thread_id,
|
||||
threadId,
|
||||
action,
|
||||
message: messageOf(cause),
|
||||
retriable: deliveryRetriable(cause),
|
||||
|
|
@ -648,13 +490,13 @@ export class DiscordConversationServices {
|
|||
)
|
||||
}
|
||||
|
||||
const deliver = (thread_id: ThreadId, action: Action["kind"], send: Effect.Effect<unknown, unknown>) =>
|
||||
const deliver = (threadId: ThreadId, action: Action["kind"], send: Effect.Effect<unknown, unknown>) =>
|
||||
Effect.gen(function* () {
|
||||
const attempts = yield* Ref.make(0)
|
||||
yield* send.pipe(
|
||||
Effect.mapError((cause) =>
|
||||
DeliveryError.make({
|
||||
thread_id,
|
||||
threadId,
|
||||
action,
|
||||
message: messageOf(cause),
|
||||
retriable: deliveryRetriable(cause),
|
||||
|
|
@ -665,7 +507,7 @@ export class DiscordConversationServices {
|
|||
Effect.logWarning("Discord delivery attempt failed").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.delivery.retry",
|
||||
thread_id,
|
||||
thread_id: threadId,
|
||||
action,
|
||||
attempt,
|
||||
retriable: error.retriable,
|
||||
|
|
@ -681,7 +523,7 @@ export class DiscordConversationServices {
|
|||
Effect.logError("Discord delivery failed").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.delivery.failed",
|
||||
thread_id,
|
||||
thread_id: threadId,
|
||||
action,
|
||||
attempts: attempt,
|
||||
retriable: error.retriable,
|
||||
|
|
@ -693,34 +535,34 @@ export class DiscordConversationServices {
|
|||
)
|
||||
})
|
||||
|
||||
const sendTyping = (thread_id: ThreadId) =>
|
||||
const sendTyping = (threadId: ThreadId) =>
|
||||
Effect.gen(function* () {
|
||||
const channel = yield* channelOf(thread_id, "typing")
|
||||
yield* deliver(thread_id, "typing", Effect.tryPromise(() => channel.sendTyping()))
|
||||
const channel = yield* channelOf(threadId, "typing")
|
||||
yield* deliver(threadId, "typing", Effect.tryPromise(() => channel.sendTyping()))
|
||||
})
|
||||
|
||||
const sendText = (thread_id: ThreadId, action: "send" | "reply", text: string) =>
|
||||
const sendText = (threadId: ThreadId, action: "send" | "reply", text: string) =>
|
||||
Effect.gen(function* () {
|
||||
const channel = yield* channelOf(thread_id, action)
|
||||
const channel = yield* channelOf(threadId, action)
|
||||
yield* Effect.forEach(
|
||||
splitForDiscord(cleanResponse(text)),
|
||||
(chunk) => deliver(thread_id, action, Effect.tryPromise(() => channel.send(chunk))),
|
||||
(chunk) => deliver(threadId, 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)
|
||||
if (action.kind === "typing") return sendTyping(action.threadId)
|
||||
return sendText(action.threadId, action.kind, action.text)
|
||||
}
|
||||
|
||||
const withTyping = <A, E, R>(thread_id: ThreadId, self: Effect.Effect<A, E, R>) =>
|
||||
const withTyping = <A, E, R>(threadId: ThreadId, self: Effect.Effect<A, E, R>) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const pulse = publish(
|
||||
Typing.make({
|
||||
kind: "typing",
|
||||
thread_id,
|
||||
threadId,
|
||||
}),
|
||||
).pipe(Effect.catchAll(() => Effect.void))
|
||||
yield* pulse
|
||||
|
|
@ -736,14 +578,14 @@ export class DiscordConversationServices {
|
|||
const outbox = Outbox.of({ publish, withTyping })
|
||||
|
||||
const history = History.of({
|
||||
rehydrate: (thread_id, latest: string) =>
|
||||
rehydrate: (threadId, latest: string) =>
|
||||
Effect.gen(function* () {
|
||||
const channel = chats.get(thread_id)
|
||||
const channel = chats.get(threadId)
|
||||
if (!channel) return latest
|
||||
return yield* buildHistoryReplayPrompt(channel, latest).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
HistoryError.make({
|
||||
thread_id,
|
||||
threadId,
|
||||
message: messageOf(cause),
|
||||
retriable: true,
|
||||
})),
|
||||
|
|
@ -754,40 +596,40 @@ export class DiscordConversationServices {
|
|||
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 }))
|
||||
return Effect.succeed(ThreadRef.make({ threadId: event.threadId, channelId: event.channelId }))
|
||||
}
|
||||
|
||||
const known = roots.get(event.message_id)
|
||||
const known = roots.get(event.messageId)
|
||||
if (known) {
|
||||
return Effect.succeed(ThreadRef.make({ thread_id: known, channel_id: event.channel_id }))
|
||||
return Effect.succeed(ThreadRef.make({ threadId: known, channelId: event.channelId }))
|
||||
}
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const local = texts.get(event.channel_id)
|
||||
const local = texts.get(event.channelId)
|
||||
const channel = local
|
||||
? local
|
||||
: yield* Effect.tryPromise(() => client.channels.fetch(event.channel_id)).pipe(
|
||||
: yield* Effect.tryPromise(() => client.channels.fetch(event.channelId)).pipe(
|
||||
Effect.map((fetched) => asTextChannel(fetched)),
|
||||
Effect.mapError((cause) =>
|
||||
ThreadEnsureError.make({
|
||||
channel_id: event.channel_id,
|
||||
channelId: event.channelId,
|
||||
message: messageOf(cause),
|
||||
retriable: deliveryRetriable(cause),
|
||||
})),
|
||||
)
|
||||
if (!channel) {
|
||||
return yield* ThreadEnsureError.make({
|
||||
channel_id: event.channel_id,
|
||||
channelId: event.channelId,
|
||||
message: "missing-parent-channel",
|
||||
retriable: false,
|
||||
})
|
||||
}
|
||||
texts.set(event.channel_id, channel)
|
||||
const base = refs.get(event.message_id)
|
||||
texts.set(event.channelId, channel)
|
||||
const base = refs.get(event.messageId)
|
||||
const thread = yield* Effect.tryPromise(() =>
|
||||
channel.threads.create({
|
||||
name,
|
||||
startMessage: base ?? event.message_id,
|
||||
startMessage: base ?? event.messageId,
|
||||
autoArchiveDuration: 60,
|
||||
}),
|
||||
).pipe(
|
||||
|
|
@ -801,15 +643,15 @@ export class DiscordConversationServices {
|
|||
)),
|
||||
Effect.mapError((cause) =>
|
||||
ThreadEnsureError.make({
|
||||
channel_id: event.channel_id,
|
||||
channelId: event.channelId,
|
||||
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 })
|
||||
const threadId = ThreadId.make(thread.id)
|
||||
chats.set(threadId, thread)
|
||||
stash(roots, root_ids, event.messageId, threadId)
|
||||
return ThreadRef.make({ threadId, channelId: event.channelId })
|
||||
})
|
||||
},
|
||||
})
|
||||
70
packages/discord/src/discord/catchup.test.ts
Normal file
70
packages/discord/src/discord/catchup.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Option } from "effect"
|
||||
import type { OffsetStore } from "../conversation/offsets"
|
||||
import { effectTest } from "../test/effect"
|
||||
import { catchupFromOffset } from "./catchup"
|
||||
|
||||
const makeOffsets = () => {
|
||||
const map = new Map<string, string>()
|
||||
const service: OffsetStore.Service = {
|
||||
getOffset: (source_id) => Effect.succeed(Option.fromNullable(map.get(source_id))),
|
||||
setOffset: (source_id, messageId) =>
|
||||
Effect.sync(() => {
|
||||
map.set(source_id, messageId)
|
||||
}),
|
||||
}
|
||||
return { service, map }
|
||||
}
|
||||
|
||||
describe("catchupFromOffset", () => {
|
||||
effectTest("no offset seeds latest and does not ingest", () => {
|
||||
const offsets = makeOffsets()
|
||||
const ingested: Array<string> = []
|
||||
return catchupFromOffset({
|
||||
source: "thread:t1",
|
||||
pageSize: 2,
|
||||
offsets: offsets.service,
|
||||
fetchLatest: Effect.succeed(Option.some({ id: "m9" })),
|
||||
fetchAfter: () => Effect.succeed([]),
|
||||
idOf: (message) => message.id,
|
||||
ingest: (message) =>
|
||||
Effect.sync(() => {
|
||||
ingested.push(message.id)
|
||||
}),
|
||||
}).pipe(
|
||||
Effect.tap((count) => Effect.sync(() => {
|
||||
expect(count).toBe(0)
|
||||
expect(ingested).toEqual([])
|
||||
expect(offsets.map.get("thread:t1")).toBe("m9")
|
||||
})),
|
||||
)
|
||||
})
|
||||
|
||||
effectTest("existing offset replays all pages in order", () => {
|
||||
const offsets = makeOffsets()
|
||||
offsets.map.set("thread:t1", "m1")
|
||||
const ingested: Array<string> = []
|
||||
const pages = new Map<string, ReadonlyArray<{ id: string }>>([
|
||||
["m1", [{ id: "m2" }, { id: "m3" }]],
|
||||
["m3", [{ id: "m4" }]],
|
||||
])
|
||||
|
||||
return catchupFromOffset({
|
||||
source: "thread:t1",
|
||||
pageSize: 2,
|
||||
offsets: offsets.service,
|
||||
fetchLatest: Effect.succeed(Option.none()),
|
||||
fetchAfter: (after) => Effect.succeed(pages.get(after) ?? []),
|
||||
idOf: (message) => message.id,
|
||||
ingest: (message) =>
|
||||
Effect.sync(() => {
|
||||
ingested.push(message.id)
|
||||
}),
|
||||
}).pipe(
|
||||
Effect.tap((count) => Effect.sync(() => {
|
||||
expect(count).toBe(3)
|
||||
expect(ingested).toEqual(["m2", "m3", "m4"])
|
||||
})),
|
||||
)
|
||||
})
|
||||
})
|
||||
42
packages/discord/src/discord/catchup.ts
Normal file
42
packages/discord/src/discord/catchup.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Effect, Option } from "effect"
|
||||
import { OffsetStore } from "../conversation/offsets"
|
||||
|
||||
export const catchupFromOffset = <M>(input: {
|
||||
source: string
|
||||
pageSize: number
|
||||
offsets: OffsetStore.Service
|
||||
fetchLatest: Effect.Effect<Option.Option<M>, unknown>
|
||||
fetchAfter: (after: string) => Effect.Effect<ReadonlyArray<M>, unknown>
|
||||
idOf: (message: M) => string
|
||||
ingest: (message: M) => Effect.Effect<void, unknown>
|
||||
}): Effect.Effect<number, unknown> => {
|
||||
const pull = (after: string): Effect.Effect<number, unknown> =>
|
||||
input.fetchAfter(after).pipe(
|
||||
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, input.ingest, { discard: true }).pipe(
|
||||
Effect.zipRight(
|
||||
rows.length < input.pageSize
|
||||
? Effect.succeed(rows.length)
|
||||
: pull(input.idOf(last)).pipe(Effect.map((tail) => rows.length + tail)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
return input.offsets.getOffset(input.source).pipe(
|
||||
Effect.flatMap((offset) => {
|
||||
if (Option.isNone(offset)) {
|
||||
return input.fetchLatest.pipe(
|
||||
Effect.flatMap((latest) => {
|
||||
if (Option.isNone(latest)) return Effect.succeed(0)
|
||||
return input.offsets.setOffset(input.source, input.idOf(latest.value)).pipe(Effect.as(0))
|
||||
}),
|
||||
)
|
||||
}
|
||||
return pull(offset.value)
|
||||
}),
|
||||
)
|
||||
}
|
||||
40
packages/discord/src/discord/conversation-channels.ts
Normal file
40
packages/discord/src/discord/conversation-channels.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { ChannelType } from "discord.js"
|
||||
import type { GuildMember, Message, TextChannel, ThreadChannel } from "discord.js"
|
||||
import { AppConfig } from "../config"
|
||||
|
||||
export type ChatChannel = TextChannel | ThreadChannel
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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)
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
23
packages/discord/src/discord/conversation-commands.ts
Normal file
23
packages/discord/src/discord/conversation-commands.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export const EMPTY_MENTION_REPLY = "Tag me with a question!"
|
||||
export const SETUP_FAILURE_REPLY = "Something went wrong setting up the thread."
|
||||
export const COMMAND_NOT_THREAD_REPLY = "Use this command inside a Discord thread."
|
||||
export const COMMAND_FORBIDDEN_REPLY = "You don't have the required role for this command."
|
||||
export const COMMAND_CHANNEL_REPLY = "This thread is not allowed for the bot."
|
||||
export const COMMAND_ACK = "Running command in this thread..."
|
||||
|
||||
export const COMMANDS = [
|
||||
{
|
||||
name: "status",
|
||||
description: "Show sandbox status for this thread",
|
||||
},
|
||||
{
|
||||
name: "reset",
|
||||
description: "Destroy the sandbox session for this thread",
|
||||
},
|
||||
] as const
|
||||
|
||||
export const commandText = (name: string): string => {
|
||||
if (name === "status") return "!status"
|
||||
if (name === "reset") return "!reset"
|
||||
return ""
|
||||
}
|
||||
33
packages/discord/src/discord/conversation-delivery.ts
Normal file
33
packages/discord/src/discord/conversation-delivery.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { Schedule } from "effect"
|
||||
import { DeliveryError, messageOf } from "../conversation/model/errors"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export const deliveryRetriable = (cause: unknown): boolean => {
|
||||
const status = statusOf(cause)
|
||||
if (status === 429) return true
|
||||
if (status !== null && status >= 500) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export const deliveryRetry = Schedule.exponential("200 millis").pipe(
|
||||
Schedule.intersect(Schedule.recurs(3)),
|
||||
Schedule.whileInput((error: DeliveryError) => error.retriable),
|
||||
)
|
||||
47
packages/discord/src/discord/conversation-history.ts
Normal file
47
packages/discord/src/discord/conversation-history.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { Effect } from "effect"
|
||||
import { type ChatChannel } from "./conversation-channels"
|
||||
|
||||
const HISTORY_FETCH_LIMIT = 40
|
||||
const HISTORY_LINE_CHAR_LIMIT = 500
|
||||
const HISTORY_TOTAL_CHAR_LIMIT = 6000
|
||||
|
||||
export const buildHistoryReplayPrompt = Effect.fn("DiscordAdapter.buildHistoryReplayPrompt")(
|
||||
function* (channel: ChatChannel, latest: string) {
|
||||
const fetched = yield* Effect.tryPromise(() => channel.messages.fetch({ limit: HISTORY_FETCH_LIMIT }))
|
||||
const ordered = [...fetched.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp)
|
||||
const lines = ordered
|
||||
.filter((prior) => !prior.system)
|
||||
.flatMap((prior) => {
|
||||
const text = prior.content.replace(/\s+/g, " ").trim()
|
||||
const files = prior.attachments.size > 0
|
||||
? `[attachments: ${[...prior.attachments.values()].map((att) => att.name ?? "file").join(", ")}]`
|
||||
: ""
|
||||
const line = text || files
|
||||
if (!line) return []
|
||||
const value = line.length > HISTORY_LINE_CHAR_LIMIT ? `${line.slice(0, HISTORY_LINE_CHAR_LIMIT)}...` : line
|
||||
return [`${prior.author.bot ? "assistant" : "user"}: ${value}`]
|
||||
})
|
||||
|
||||
const prior = lines.at(-1) === `user: ${latest}` ? lines.slice(0, -1) : lines
|
||||
if (prior.length === 0) return latest
|
||||
|
||||
const selected = prior.reduceRight(
|
||||
(state, candidate) => {
|
||||
if (state.stop) return state
|
||||
if (state.total + candidate.length > HISTORY_TOTAL_CHAR_LIMIT && state.list.length > 0) {
|
||||
return { ...state, stop: true }
|
||||
}
|
||||
return { list: [candidate, ...state.list], total: state.total + candidate.length, stop: false }
|
||||
},
|
||||
{ list: [] as ReadonlyArray<string>, total: 0, stop: false },
|
||||
).list
|
||||
|
||||
return [
|
||||
"Conversation history from this same Discord thread (oldest to newest):",
|
||||
selected.join("\n"),
|
||||
"",
|
||||
"Continue the same conversation and respond to the latest user message:",
|
||||
latest,
|
||||
].join("\n")
|
||||
},
|
||||
)
|
||||
|
|
@ -45,15 +45,32 @@ export class HealthCheckError extends Schema.TaggedError<HealthCheckError>()(
|
|||
},
|
||||
) {}
|
||||
|
||||
export const OpenCodeFailureKind = Schema.Literal("session-missing", "sandbox-down", "non-recoverable")
|
||||
export type OpenCodeFailureKind = typeof OpenCodeFailureKind.Type
|
||||
|
||||
export const classifyOpenCodeFailure = (statusCode: number, body: string): OpenCodeFailureKind => {
|
||||
if (statusCode === 404) return "session-missing"
|
||||
if (statusCode === 0 || statusCode >= 500) return "sandbox-down"
|
||||
const text = body.toLowerCase()
|
||||
if (text.includes("sandbox not found") || text.includes("is the sandbox started")) return "sandbox-down"
|
||||
return "non-recoverable"
|
||||
}
|
||||
|
||||
export class OpenCodeClientError extends Schema.TaggedError<OpenCodeClientError>()(
|
||||
"OpenCodeClientError",
|
||||
{
|
||||
operation: Schema.String,
|
||||
statusCode: Schema.Number,
|
||||
body: Schema.String,
|
||||
kind: OpenCodeFailureKind,
|
||||
},
|
||||
) {}
|
||||
|
||||
export const isOpenCodeSandboxUnavailable = (error: OpenCodeClientError) => {
|
||||
if (error.kind === "session-missing") return true
|
||||
return error.kind === "sandbox-down"
|
||||
}
|
||||
|
||||
export class SessionMissingError extends Schema.TaggedError<SessionMissingError>()(
|
||||
"SessionMissingError",
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ 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"
|
||||
import { SessionStore } from "../session/store"
|
||||
|
||||
export declare namespace HealthServer {
|
||||
export interface Service {
|
||||
|
|
@ -17,7 +17,7 @@ export class HealthServer extends Context.Tag("@discord/HealthServer")<HealthSer
|
|||
Effect.gen(function* () {
|
||||
const config = yield* AppConfig
|
||||
const client = yield* DiscordClient
|
||||
const pool = yield* ThreadAgentPool
|
||||
const sessions = yield* SessionStore
|
||||
const startedAt = Date.now()
|
||||
|
||||
const routes = HttpLayerRouter.use((router) =>
|
||||
|
|
@ -26,8 +26,9 @@ export class HealthServer extends Context.Tag("@discord/HealthServer")<HealthSer
|
|||
"GET",
|
||||
"/healthz",
|
||||
Effect.gen(function* () {
|
||||
const activeSessions = yield* pool.getActiveSessionCount().pipe(
|
||||
Effect.catchAll(() => Effect.succeed(0)),
|
||||
const activeSessions = yield* sessions.listActive().pipe(
|
||||
Effect.catchAll(() => Effect.succeed([])),
|
||||
Effect.map((rows) => rows.length),
|
||||
)
|
||||
return HttpServerResponse.unsafeJson({
|
||||
ok: true,
|
||||
|
|
|
|||
|
|
@ -1,67 +1,15 @@
|
|||
import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic"
|
||||
import { FetchHttpClient } from "@effect/platform"
|
||||
import { BunContext, BunRuntime } from "@effect/platform-bun"
|
||||
import { 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 { AppConversationLayer } from "./app/layers"
|
||||
import { Conversation } from "./conversation/conversation"
|
||||
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"
|
||||
|
||||
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),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
type AppServices =
|
||||
| AppConfig
|
||||
| DiscordClient
|
||||
| HealthServer
|
||||
| OpenCodeClient
|
||||
| SessionStore
|
||||
| DaytonaService
|
||||
| TurnRouter
|
||||
| SandboxProvisioner
|
||||
| ThreadAgentPool
|
||||
| ConversationLedger
|
||||
| Conversation
|
||||
|
||||
const BaseLayer = Layer.mergeAll(AppConfig.layer, FetchHttpClient.layer, BunContext.layer, LoggerLive)
|
||||
const WithSqlite = Layer.provideMerge(SqliteDb.layer, BaseLayer)
|
||||
const WithAnthropic = Layer.provideMerge(AnthropicLayer, WithSqlite)
|
||||
const WithDaytona = Layer.provideMerge(DaytonaService.layer, WithAnthropic)
|
||||
const WithOpenCode = Layer.provideMerge(OpenCodeClient.layer, WithDaytona)
|
||||
const WithRouting = Layer.provideMerge(TurnRouter.layer, WithOpenCode)
|
||||
const WithSessions = Layer.provideMerge(SessionStore.layer, WithRouting)
|
||||
const WithProvisioner = Layer.provideMerge(SandboxProvisioner.layer, WithSessions)
|
||||
const WithSandbox = Layer.provideMerge(ThreadAgentPool.layer, WithProvisioner)
|
||||
const WithLedger = Layer.provideMerge(ConversationLedger.layer, WithSandbox)
|
||||
const WithDiscord = Layer.provideMerge(DiscordClient.layer, WithLedger)
|
||||
const WithDiscordConversation = Layer.provideMerge(DiscordConversationServicesLive, WithDiscord)
|
||||
const WithConversation = Layer.provideMerge(Conversation.layer, WithDiscordConversation)
|
||||
const AppLayer = Layer.provideMerge(HealthServer.layer, WithConversation) as Layer.Layer<AppServices | SqliteDb, never, never>
|
||||
const AppLayer = Layer.provideMerge(HealthServer.layer, AppConversationLayer)
|
||||
|
||||
const main = Effect.gen(function* () {
|
||||
const client = yield* DiscordClient
|
||||
const conversation = yield* Conversation
|
||||
yield* ThreadAgentPool
|
||||
yield* HealthServer
|
||||
|
||||
yield* Effect.forkScoped(conversation.run)
|
||||
|
|
|
|||
23
packages/discord/src/lib/actors/stateful.ts
Normal file
23
packages/discord/src/lib/actors/stateful.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export declare namespace StatefulActor {
|
||||
export interface Stateful<S> {
|
||||
readonly get: () => S
|
||||
readonly set: (state: S) => void
|
||||
readonly update: (f: (state: S) => S) => S
|
||||
}
|
||||
}
|
||||
|
||||
export const StatefulActor = {
|
||||
make: <S>(initial: S): StatefulActor.Stateful<S> => {
|
||||
let state = initial
|
||||
return {
|
||||
get: () => state,
|
||||
set: (next) => {
|
||||
state = next
|
||||
},
|
||||
update: (f) => {
|
||||
state = f(state)
|
||||
return state
|
||||
},
|
||||
}
|
||||
},
|
||||
} as const
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
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"
|
||||
import { AppConfig } from "../../config"
|
||||
import { SandboxCreateError, SandboxExecError, SandboxNotFoundError, SandboxStartError } from "../../errors"
|
||||
import { GuildId, SandboxId, ThreadId, PreviewAccess } from "../../types"
|
||||
|
||||
export class SandboxHandle extends Schema.Class<SandboxHandle>("SandboxHandle")({
|
||||
id: SandboxId,
|
||||
|
|
@ -38,7 +38,7 @@ export declare namespace DaytonaService {
|
|||
}
|
||||
}
|
||||
|
||||
const discordBotImage = Image.base("node:22-bookworm-slim")
|
||||
export 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/*",
|
||||
|
|
@ -82,24 +82,36 @@ export class DaytonaService extends Context.Tag("@discord/DaytonaService")<Dayto
|
|||
|
||||
const create = Effect.fn("DaytonaService.create")(
|
||||
function* (opts: { threadId: ThreadId; guildId: GuildId; timeout: number }) {
|
||||
const base = {
|
||||
labels: { app: "opencord", threadId: opts.threadId, guildId: opts.guildId },
|
||||
autoStopInterval: 0,
|
||||
autoArchiveInterval: 0,
|
||||
}
|
||||
const snapshot = config.daytonaSnapshot.trim()
|
||||
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 },
|
||||
),
|
||||
snapshot.length > 0
|
||||
? sdk.create(
|
||||
{
|
||||
...base,
|
||||
snapshot,
|
||||
},
|
||||
{ timeout: opts.timeout },
|
||||
)
|
||||
: sdk.create(
|
||||
{
|
||||
...base,
|
||||
image: discordBotImage,
|
||||
},
|
||||
{ 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 sandboxId = SandboxId.make(sandbox.id)
|
||||
return yield* toHandle(
|
||||
sandboxId,
|
||||
sandbox,
|
||||
(cause) => new SandboxCreateError({ sandboxId, cause }),
|
||||
)
|
||||
},
|
||||
)
|
||||
44
packages/discord/src/sandbox/daytona/snapshot.ts
Normal file
44
packages/discord/src/sandbox/daytona/snapshot.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { Daytona as DaytonaSDK } from "@daytonaio/sdk"
|
||||
import { discordBotImage } from "./service"
|
||||
|
||||
const now = () => new Date().toISOString().slice(0, 10).replaceAll("-", "")
|
||||
|
||||
const apiKey = process.env["DAYTONA_API_KEY"]?.trim() ?? ""
|
||||
if (apiKey.length === 0) {
|
||||
console.error("DAYTONA_API_KEY is required")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const name = Bun.argv[2]?.trim() || `opencode-discord-${now()}`
|
||||
const regionId = process.env["DAYTONA_REGION_ID"]?.trim() ?? ""
|
||||
|
||||
const run = async () => {
|
||||
const sdk = new DaytonaSDK({
|
||||
apiKey,
|
||||
_experimental: {},
|
||||
})
|
||||
const snapshot = await sdk.snapshot.create(
|
||||
regionId.length > 0
|
||||
? {
|
||||
name,
|
||||
image: discordBotImage,
|
||||
regionId,
|
||||
}
|
||||
: {
|
||||
name,
|
||||
image: discordBotImage,
|
||||
},
|
||||
{
|
||||
onLogs: (chunk) => process.stdout.write(chunk),
|
||||
},
|
||||
)
|
||||
const active = await sdk.snapshot.activate(snapshot)
|
||||
console.log(`snapshot ready: ${active.name}`)
|
||||
console.log(`set DAYTONA_SNAPSHOT=${active.name}`)
|
||||
}
|
||||
|
||||
void run().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`snapshot creation failed: ${message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
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"
|
||||
import { classifyOpenCodeFailure, HealthCheckError, OpenCodeClientError } from "../../errors"
|
||||
import { PreviewAccess, SessionId } from "../../types"
|
||||
|
||||
const HealthResponse = Schema.Struct({
|
||||
healthy: Schema.Boolean,
|
||||
|
|
@ -108,6 +108,14 @@ export class OpenCodeClient extends Context.Tag("@discord/OpenCodeClient")<OpenC
|
|||
}
|
||||
|
||||
/** Map HttpClientError + ParseError to OpenCodeClientError for a given operation. */
|
||||
const openCodeError = (operation: string, statusCode: number, body: string) =>
|
||||
OpenCodeClientError.make({
|
||||
operation,
|
||||
statusCode,
|
||||
body,
|
||||
kind: classifyOpenCodeFailure(statusCode, body),
|
||||
})
|
||||
|
||||
const mapErrors = <A, R>(
|
||||
operation: string,
|
||||
effect: Effect.Effect<A, HttpClientError.HttpClientError | ParseResult.ParseError, R>,
|
||||
|
|
@ -115,11 +123,11 @@ export class OpenCodeClient extends Context.Tag("@discord/OpenCodeClient")<OpenC
|
|||
effect.pipe(
|
||||
Effect.catchTags({
|
||||
ResponseError: (err) =>
|
||||
new OpenCodeClientError({ operation, statusCode: err.response.status, body: err.message }),
|
||||
openCodeError(operation, err.response.status, err.message),
|
||||
RequestError: (err) =>
|
||||
new OpenCodeClientError({ operation, statusCode: 0, body: err.message }),
|
||||
openCodeError(operation, 0, err.message),
|
||||
ParseError: (err) =>
|
||||
new OpenCodeClientError({ operation, statusCode: 0, body: `Decode: ${err.message}` }),
|
||||
openCodeError(operation, 0, `Decode: ${err.message}`),
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -172,10 +180,10 @@ export class OpenCodeClient extends Context.Tag("@discord/OpenCodeClient")<OpenC
|
|||
Effect.catchTag("ResponseError", (err) =>
|
||||
err.response.status === 404
|
||||
? Effect.succeed(false)
|
||||
: new OpenCodeClientError({ operation: "sessionExists", statusCode: err.response.status, body: err.message }),
|
||||
: openCodeError("sessionExists", err.response.status, err.message),
|
||||
),
|
||||
Effect.catchTag("RequestError", (err) =>
|
||||
new OpenCodeClientError({ operation: "sessionExists", statusCode: 0, body: err.message }),
|
||||
openCodeError("sessionExists", 0, err.message),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { OpenCodeClientError } from "../errors"
|
||||
import { SessionStore } from "../sessions/store"
|
||||
import { effectTest, testConfigLayer } from "../test/effect"
|
||||
import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, ThreadId } from "../types"
|
||||
import { OpenCodeClient } from "./opencode-client"
|
||||
import { ResumeFailed, SandboxProvisioner } from "./provisioner"
|
||||
import { ThreadAgentPool } from "./pool"
|
||||
|
||||
const threadId = ThreadId.make("t1")
|
||||
const channelId = ChannelId.make("c1")
|
||||
const guildId = GuildId.make("g1")
|
||||
|
||||
const session = SessionInfo.make({
|
||||
threadId,
|
||||
channelId,
|
||||
guildId,
|
||||
sandboxId: SandboxId.make("sb1"),
|
||||
sessionId: SessionId.make("s1"),
|
||||
previewUrl: "https://preview.example",
|
||||
previewToken: null,
|
||||
status: "active",
|
||||
lastError: null,
|
||||
resumeFailCount: 0,
|
||||
})
|
||||
|
||||
const store = () => {
|
||||
let row = Option.some(session)
|
||||
return SessionStore.of({
|
||||
upsert: (next) =>
|
||||
Effect.sync(() => {
|
||||
row = Option.some(next)
|
||||
}),
|
||||
getByThread: () => Effect.succeed(row),
|
||||
hasTrackedThread: () => Effect.succeed(Option.isSome(row)),
|
||||
getActive: () =>
|
||||
Option.isSome(row) && row.value.status === "active"
|
||||
? Effect.succeed(row)
|
||||
: Effect.succeed(Option.none()),
|
||||
markActivity: () => Effect.void,
|
||||
markHealthOk: () => Effect.void,
|
||||
updateStatus: (_threadId, status) =>
|
||||
Effect.sync(() => {
|
||||
if (Option.isNone(row)) return
|
||||
row = Option.some(row.value.withStatus(status))
|
||||
}),
|
||||
incrementResumeFailure: () => Effect.void,
|
||||
listActive: () =>
|
||||
Option.isSome(row) && row.value.status === "active"
|
||||
? Effect.succeed([row.value] as const)
|
||||
: Effect.succeed([] as const),
|
||||
listTrackedThreads: () =>
|
||||
Option.isSome(row) && row.value.status !== "destroyed"
|
||||
? Effect.succeed([row.value.threadId] as const)
|
||||
: Effect.succeed([] as const),
|
||||
listStaleActive: () => Effect.succeed([] as const),
|
||||
listExpiredPaused: () => Effect.succeed([] as const),
|
||||
})
|
||||
}
|
||||
|
||||
const provisioner = SandboxProvisioner.of({
|
||||
provision: () => Effect.succeed(session),
|
||||
resume: () => Effect.succeed(ResumeFailed.make({ allowRecreate: true })),
|
||||
ensureActive: ({ current }) => Effect.succeed(Option.isSome(current) ? current.value : session),
|
||||
ensureHealthy: () => Effect.succeed(true),
|
||||
recoverSendFailure: (_threadId, next) => Effect.succeed(next.withStatus("paused")),
|
||||
pause: (_threadId, next) => Effect.succeed(next.withStatus("paused")),
|
||||
destroy: (_threadId, next) => Effect.succeed(next.withStatus("destroyed")),
|
||||
})
|
||||
|
||||
const client = (statusCode: number, body: string) =>
|
||||
OpenCodeClient.of({
|
||||
waitForHealthy: () => Effect.succeed(true),
|
||||
createSession: () => Effect.succeed(SessionId.make("s2")),
|
||||
sessionExists: () => Effect.succeed(true),
|
||||
listSessions: () => Effect.succeed([]),
|
||||
sendPrompt: () => Effect.fail(new OpenCodeClientError({ operation: "sendPrompt", statusCode, body })),
|
||||
abortSession: () => Effect.void,
|
||||
})
|
||||
|
||||
const withPool = <A, E, R>(
|
||||
statusCode: number,
|
||||
body: string,
|
||||
run: Effect.Effect<A, E, R>,
|
||||
) => {
|
||||
const deps = Layer.mergeAll(
|
||||
testConfigLayer,
|
||||
Layer.succeed(SessionStore, store()),
|
||||
Layer.succeed(SandboxProvisioner, provisioner),
|
||||
Layer.succeed(OpenCodeClient, client(statusCode, body)),
|
||||
)
|
||||
return run.pipe(
|
||||
Effect.provide(ThreadAgentPool.layer.pipe(Layer.provide(deps))),
|
||||
)
|
||||
}
|
||||
|
||||
describe("ThreadAgentPool", () => {
|
||||
effectTest("maps recoverable send failures to SandboxDeadError", () =>
|
||||
withPool(
|
||||
502,
|
||||
"bad gateway",
|
||||
Effect.gen(function* () {
|
||||
const pool = yield* ThreadAgentPool
|
||||
const agent = yield* pool.getOrCreate(threadId, channelId, guildId)
|
||||
const error = yield* agent.send("hello").pipe(Effect.flip)
|
||||
expect(error._tag).toBe("SandboxDeadError")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
effectTest("keeps non-recoverable send failures as OpenCodeClientError", () =>
|
||||
withPool(
|
||||
400,
|
||||
"bad request",
|
||||
Effect.gen(function* () {
|
||||
const pool = yield* ThreadAgentPool
|
||||
const agent = yield* pool.getOrCreate(threadId, channelId, guildId)
|
||||
const error = yield* agent.send("hello").pipe(Effect.flip)
|
||||
expect(error._tag).toBe("OpenCodeClientError")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,305 +0,0 @@
|
|||
import { Context, Duration, Effect, Layer, Option, Ref, Schedule } from "effect"
|
||||
import { AppConfig } from "../config"
|
||||
import {
|
||||
type ConfigEncodeError,
|
||||
DatabaseError,
|
||||
type HealthCheckError,
|
||||
type OpenCodeClientError,
|
||||
type SandboxCreateError,
|
||||
type SandboxExecError,
|
||||
SandboxDeadError,
|
||||
type SandboxNotFoundError,
|
||||
type SandboxStartError,
|
||||
} from "../errors"
|
||||
import { ActorMap } from "../lib/actors/keyed"
|
||||
import { logIgnore } from "../lib/log"
|
||||
import { SessionStore } from "../sessions/store"
|
||||
import { ChannelId, GuildId, PreviewAccess, SessionInfo, ThreadId } from "../types"
|
||||
import { OpenCodeClient } from "./opencode-client"
|
||||
import { SandboxProvisioner } from "./provisioner"
|
||||
|
||||
/** Per-thread handle returned by ThreadAgentPool.resolve. */
|
||||
export interface ThreadAgent {
|
||||
readonly threadId: ThreadId
|
||||
/** Snapshot from when this agent handle was created. Prefer `current()` for live state. */
|
||||
readonly session: SessionInfo
|
||||
readonly current: () => Effect.Effect<SessionInfo, DatabaseError>
|
||||
readonly send: (text: string) => Effect.Effect<string, OpenCodeClientError | SandboxDeadError | DatabaseError>
|
||||
readonly pause: (reason?: string) => Effect.Effect<void, DatabaseError>
|
||||
readonly destroy: () => Effect.Effect<void, DatabaseError>
|
||||
}
|
||||
|
||||
type Runtime = {
|
||||
readonly current: () => Effect.Effect<SessionInfo, DatabaseError>
|
||||
readonly ensure: (
|
||||
channelId: ChannelId,
|
||||
guildId: GuildId,
|
||||
) => Effect.Effect<
|
||||
SessionInfo,
|
||||
| SandboxCreateError
|
||||
| SandboxExecError
|
||||
| SandboxNotFoundError
|
||||
| SandboxStartError
|
||||
| HealthCheckError
|
||||
| OpenCodeClientError
|
||||
| ConfigEncodeError
|
||||
| SandboxDeadError
|
||||
| DatabaseError
|
||||
>
|
||||
readonly send: (text: string) => Effect.Effect<string, OpenCodeClientError | SandboxDeadError | DatabaseError>
|
||||
readonly pause: (reason: string) => Effect.Effect<void, DatabaseError>
|
||||
readonly destroy: (reason: string) => Effect.Effect<void, DatabaseError>
|
||||
}
|
||||
|
||||
export declare namespace ThreadAgentPool {
|
||||
export interface Service {
|
||||
/** Get an existing healthy ThreadAgent or create one. */
|
||||
readonly getOrCreate: (
|
||||
threadId: ThreadId,
|
||||
channelId: ChannelId,
|
||||
guildId: GuildId,
|
||||
) => Effect.Effect<
|
||||
ThreadAgent,
|
||||
| SandboxCreateError
|
||||
| SandboxExecError
|
||||
| SandboxNotFoundError
|
||||
| SandboxStartError
|
||||
| HealthCheckError
|
||||
| OpenCodeClientError
|
||||
| ConfigEncodeError
|
||||
| SandboxDeadError
|
||||
| DatabaseError
|
||||
>
|
||||
readonly hasTrackedThread: (threadId: ThreadId) => Effect.Effect<boolean, DatabaseError>
|
||||
readonly getTrackedSession: (threadId: ThreadId) => Effect.Effect<Option.Option<SessionInfo>, DatabaseError>
|
||||
readonly getActiveSessionCount: () => Effect.Effect<number, DatabaseError>
|
||||
readonly pauseSession: (threadId: ThreadId, reason?: string) => Effect.Effect<void, DatabaseError>
|
||||
readonly destroySession: (threadId: ThreadId) => Effect.Effect<void, DatabaseError>
|
||||
}
|
||||
}
|
||||
|
||||
export class ThreadAgentPool extends Context.Tag("@discord/ThreadAgentPool")<
|
||||
ThreadAgentPool,
|
||||
ThreadAgentPool.Service
|
||||
>() {
|
||||
static readonly layer = Layer.scoped(
|
||||
ThreadAgentPool,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* AppConfig
|
||||
const provisioner = yield* SandboxProvisioner
|
||||
const oc = yield* OpenCodeClient
|
||||
const store = yield* SessionStore
|
||||
|
||||
const runtime = (
|
||||
threadId: ThreadId,
|
||||
state: Ref.Ref<Option.Option<SessionInfo>>,
|
||||
): Runtime => {
|
||||
const dead = (error: OpenCodeClientError) => {
|
||||
if (error.statusCode === 404) return true
|
||||
if (error.statusCode === 0 || error.statusCode >= 500) return true
|
||||
const body = error.body.toLowerCase()
|
||||
if (body.includes("sandbox not found")) return true
|
||||
if (body.includes("is the sandbox started")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const lookup = Effect.fnUntraced(function* () {
|
||||
const loaded = yield* Ref.get(state)
|
||||
if (Option.isSome(loaded)) return loaded
|
||||
return Option.none<SessionInfo>()
|
||||
})
|
||||
|
||||
const current = Effect.fnUntraced(function* () {
|
||||
const session = yield* lookup()
|
||||
if (Option.isSome(session)) return session.value
|
||||
return yield* new DatabaseError({
|
||||
cause: new Error(`missing session for thread ${threadId}`),
|
||||
})
|
||||
})
|
||||
|
||||
const ensure = (channelId: ChannelId, guildId: GuildId) =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* provisioner.ensureActive({
|
||||
threadId,
|
||||
channelId,
|
||||
guildId,
|
||||
current: yield* Ref.get(state),
|
||||
})
|
||||
yield* Ref.set(state, Option.some(next))
|
||||
return next
|
||||
})
|
||||
|
||||
const send = (text: string) =>
|
||||
Effect.gen(function* () {
|
||||
yield* store.markActivity(threadId)
|
||||
const session = yield* current()
|
||||
return yield* oc.sendPrompt(PreviewAccess.from(session), session.sessionId, text).pipe(
|
||||
Effect.catchTag("OpenCodeClientError", (error) =>
|
||||
provisioner.recoverSendFailure(threadId, session, error).pipe(
|
||||
Effect.flatMap((next) =>
|
||||
Ref.set(state, Option.some(next)),
|
||||
),
|
||||
Effect.flatMap(() => {
|
||||
const failure: OpenCodeClientError | SandboxDeadError = dead(error)
|
||||
? new SandboxDeadError({
|
||||
threadId,
|
||||
reason: `OpenCode send failed (${error.statusCode})`,
|
||||
})
|
||||
: error
|
||||
return Effect.fail(failure)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const pause = (reason: string) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* lookup()
|
||||
if (Option.isNone(session)) return
|
||||
const next = yield* provisioner.pause(threadId, session.value, reason)
|
||||
yield* Ref.set(state, Option.some(next))
|
||||
})
|
||||
|
||||
const destroy = (reason: string) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* lookup()
|
||||
if (Option.isNone(session)) return
|
||||
const next = yield* provisioner.destroy(threadId, session.value, reason)
|
||||
yield* Ref.set(state, Option.some(next))
|
||||
})
|
||||
|
||||
return { current, ensure, send, pause, destroy }
|
||||
}
|
||||
|
||||
const actors: ActorMap.ActorMap<ThreadId, SessionInfo> = yield* ActorMap.make<ThreadId, SessionInfo>({
|
||||
idleTimeout: config.sandboxTimeout,
|
||||
onIdle: (threadId) =>
|
||||
logIgnore(
|
||||
runRuntime(
|
||||
threadId,
|
||||
(rt) => rt.pause("inactivity-timeout"),
|
||||
{ touch: false },
|
||||
).pipe(
|
||||
Effect.tap(() => actors.remove(threadId)),
|
||||
),
|
||||
"idle-pause",
|
||||
),
|
||||
load: (threadId) => store.getByThread(threadId).pipe(Effect.catchAll(() => Effect.succeed(Option.none()))),
|
||||
save: (_threadId, session) => logIgnore(store.upsert(session), "save-session").pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const runRuntime = <A, E>(
|
||||
threadId: ThreadId,
|
||||
f: (rt: Runtime) => Effect.Effect<A, E>,
|
||||
options?: { touch?: boolean },
|
||||
) =>
|
||||
actors.run(
|
||||
threadId,
|
||||
(state) => f(runtime(threadId, state)),
|
||||
options,
|
||||
)
|
||||
|
||||
const pauseNow = (threadId: ThreadId, reason: string) =>
|
||||
runRuntime(threadId, (rt) => rt.pause(reason), { touch: false }).pipe(
|
||||
Effect.tap(() => actors.remove(threadId)),
|
||||
)
|
||||
|
||||
const destroyNow = (threadId: ThreadId, reason: string) =>
|
||||
runRuntime(threadId, (rt) => rt.destroy(reason), { touch: false }).pipe(
|
||||
Effect.tap(() => actors.remove(threadId)),
|
||||
)
|
||||
|
||||
const makeAgent = (threadId: ThreadId, session: SessionInfo): ThreadAgent => ({
|
||||
threadId,
|
||||
session,
|
||||
current: () =>
|
||||
runRuntime(threadId, (rt) => rt.current(), { touch: false }),
|
||||
send: (text: string) =>
|
||||
runRuntime(threadId, (rt) => rt.send(text)),
|
||||
pause: (reason = "manual") => pauseNow(threadId, reason),
|
||||
destroy: () => destroyNow(threadId, "agent-destroy"),
|
||||
})
|
||||
|
||||
const getOrCreate = Effect.fn("ThreadAgentPool.getOrCreate")(function* (
|
||||
threadId: ThreadId,
|
||||
channelId: ChannelId,
|
||||
guildId: GuildId,
|
||||
) {
|
||||
const session = yield* runRuntime(
|
||||
threadId,
|
||||
(rt) => rt.ensure(channelId, guildId),
|
||||
)
|
||||
return makeAgent(threadId, session)
|
||||
})
|
||||
|
||||
const hasTrackedThread = Effect.fn("ThreadAgentPool.hasTrackedThread")(function* (threadId: ThreadId) {
|
||||
return yield* store.hasTrackedThread(threadId)
|
||||
})
|
||||
|
||||
const getTrackedSession = Effect.fn("ThreadAgentPool.getTrackedSession")(function* (threadId: ThreadId) {
|
||||
return yield* store.getByThread(threadId)
|
||||
})
|
||||
|
||||
const getActiveSessionCount = Effect.fn("ThreadAgentPool.getActiveSessionCount")(function* () {
|
||||
return (yield* store.listActive()).length
|
||||
})
|
||||
|
||||
const pauseSession = Effect.fn("ThreadAgentPool.pauseSession")(function* (
|
||||
threadId: ThreadId,
|
||||
reason = "manual",
|
||||
) {
|
||||
yield* pauseNow(threadId, reason)
|
||||
})
|
||||
|
||||
const destroySession = Effect.fn("ThreadAgentPool.destroySession")(function* (threadId: ThreadId) {
|
||||
yield* destroyNow(threadId, "manual-destroy")
|
||||
})
|
||||
|
||||
const cleanupPass = Effect.fnUntraced(function* () {
|
||||
const stale = yield* store.listStaleActive(
|
||||
Math.ceil(Duration.toMinutes(config.sandboxTimeout)) + config.staleActiveGraceMinutes,
|
||||
)
|
||||
yield* Effect.forEach(
|
||||
stale,
|
||||
(row) =>
|
||||
logIgnore(
|
||||
pauseNow(row.threadId, "cleanup-stale-active"),
|
||||
"cleanup-pause",
|
||||
),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
|
||||
const expired = yield* store.listExpiredPaused(config.pausedTtlMinutes)
|
||||
yield* Effect.forEach(
|
||||
expired,
|
||||
(row) =>
|
||||
logIgnore(
|
||||
destroyNow(row.threadId, "cleanup-expired-paused"),
|
||||
"cleanup-destroy",
|
||||
),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
})
|
||||
|
||||
yield* cleanupPass().pipe(
|
||||
Effect.catchAll((error) =>
|
||||
Effect.logError("Cleanup loop failed").pipe(
|
||||
Effect.annotateLogs({ event: "cleanup.loop.failed", error: String(error) }),
|
||||
),
|
||||
),
|
||||
Effect.repeat(Schedule.spaced(config.cleanupInterval)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return ThreadAgentPool.of({
|
||||
getOrCreate,
|
||||
hasTrackedThread,
|
||||
getTrackedSession,
|
||||
getActiveSessionCount,
|
||||
pauseSession,
|
||||
destroySession,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -12,10 +12,10 @@ import {
|
|||
type SandboxNotFoundError,
|
||||
type SandboxStartError,
|
||||
} from "../errors"
|
||||
import { SessionStore } from "../sessions/store"
|
||||
import { SessionStore } from "../session/store"
|
||||
import { ChannelId, GuildId, PreviewAccess, SandboxId, SessionInfo, ThreadId } from "../types"
|
||||
import { DaytonaService, type SandboxHandle } from "./daytona"
|
||||
import { OpenCodeClient, OpenCodeSessionSummary } from "./opencode-client"
|
||||
import { DaytonaService, type SandboxHandle } from "./daytona/service"
|
||||
import { OpenCodeClient, OpenCodeSessionSummary } from "./opencode/client"
|
||||
|
||||
import { logIgnore } from "../lib/log"
|
||||
|
||||
|
|
@ -51,7 +51,6 @@ export class ResumeFailed extends Schema.Class<ResumeFailed>("ResumeFailed")({
|
|||
}) {}
|
||||
|
||||
export type ResumeResult = Resumed | ResumeFailed
|
||||
type SendFailure = "session-missing" | "sandbox-down" | "non-recoverable"
|
||||
|
||||
export declare namespace SandboxProvisioner {
|
||||
export interface Service {
|
||||
|
|
@ -179,13 +178,6 @@ export class SandboxProvisioner extends Context.Tag("@discord/SandboxProvisioner
|
|||
|
||||
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,
|
||||
|
|
@ -452,7 +444,7 @@ export class SandboxProvisioner extends Context.Tag("@discord/SandboxProvisioner
|
|||
session: SessionInfo,
|
||||
error: OpenCodeClientError,
|
||||
) {
|
||||
const kind = classifySendError(error)
|
||||
const kind = error.kind
|
||||
if (kind === "non-recoverable") return session
|
||||
|
||||
yield* store.incrementResumeFailure(threadId, String(error))
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const makeConfig = (databasePath: string) =>
|
|||
discordCommandGuildId: "",
|
||||
databasePath,
|
||||
daytonaApiKey: Redacted.make("daytona"),
|
||||
daytonaSnapshot: "",
|
||||
openCodeZenApiKey: Redacted.make("zen"),
|
||||
githubToken: "",
|
||||
logLevel: "info",
|
||||
|
|
@ -44,6 +44,7 @@ export const testConfigLayer = Layer.succeed(
|
|||
discordCommandGuildId: "",
|
||||
databasePath: ":memory:",
|
||||
daytonaApiKey: Redacted.make("test"),
|
||||
daytonaSnapshot: "",
|
||||
openCodeZenApiKey: Redacted.make("test"),
|
||||
githubToken: "",
|
||||
logLevel: "info" as const,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue