refactor: restructure conversation runtime around thread entities

This commit is contained in:
Kit Langton 2026-02-14 13:40:52 -05:00
commit 46cc9e7567
59 changed files with 2518 additions and 2066 deletions

View file

@ -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=="],

View file

@ -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=

View file

@ -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.

View file

@ -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"

View file

@ -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:

View 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)

View file

@ -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,

View file

@ -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
})

View file

@ -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,
)

View file

@ -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))
}),

View file

@ -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 })
}),
}),
),

View file

@ -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")))

View file

@ -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)
}

View file

@ -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.

View file

@ -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* () {

View 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 })
}),
)
}

View 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)),
)
})

View 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
}

View file

@ -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),
}),
)
}

View file

@ -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 {

View file

@ -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"

View file

@ -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,
},

View file

@ -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(

View 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)),
)
})

View 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 })
}),
)
}

View file

@ -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,
}),
)
}

View file

@ -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 })
}),
)
}

View file

@ -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)),
)
})

View file

@ -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,
})
}),
)
}

View file

@ -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 })
}),
)

View file

@ -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
>() {}

View file

@ -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 })
}),
)

View 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))
})
})

View 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" },
)

View 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"

View file

@ -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,
}),

View file

@ -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)
}

View file

@ -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,
}),
})

View file

@ -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 })
})

View file

@ -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 })
})
},
})

View 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"])
})),
)
})
})

View 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)
}),
)
}

View 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
}

View 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 ""
}

View 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),
)

View 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")
},
)

View file

@ -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",
{

View file

@ -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,

View file

@ -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)

View 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

View file

@ -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 }),
)
},
)

View 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)
})

View file

@ -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),
),
)

View file

@ -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")
}),
),
)
})

View file

@ -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,
})
}),
)
}

View file

@ -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))

View file

@ -17,6 +17,7 @@ const makeConfig = (databasePath: string) =>
discordCommandGuildId: "",
databasePath,
daytonaApiKey: Redacted.make("daytona"),
daytonaSnapshot: "",
openCodeZenApiKey: Redacted.make("zen"),
githubToken: "",
logLevel: "info",

View file

@ -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,