Unified · Split
| @@ -15,30 +15,263 @@ We need a plugin system that makes tools **installable, discoverable, and config · 14 unmodified lines | |||
| 15 | 15 | ||
| 16 | --- | 16 | --- |
| 17 | 17 | ||
| 18 | ## Reference Analysis | ||
| 19 | |||
| 20 | We studied two production systems before designing this: | ||
| 21 | |||
| 22 | ### OpenCode (`.reference/opencode/`) | ||
| 23 | |||
| 24 | OpenCode's approach is the primary inspiration. Key patterns worth adopting: | ||
| 25 | |||
| 26 | 1. **Convention directories**: `.opencode/tool/`, `.opencode/plugin/`, `.opencode/skill/` — drop a `.ts` file in and it just works. No manifest file needed for simple tools. | ||
| 27 | 2. **Auto-dependency management**: When `.opencode/` contains tools or plugins, OpenCode auto-generates a `package.json` with `@opencode-ai/plugin` and runs `bun install`. Zero friction. | ||
| 28 | 3. **Plugin = function that returns hooks**: `Plugin = (input: PluginInput) => Promise<Hooks>`. Clean, no class ceremony. | ||
| 29 | 4. **Tool definition via SDK**: `tool({ description, args: z.object({...}), execute(args) })` — zod schema inline, description as a string. Simple. | ||
| 30 | 5. **File naming = tool ID**: Default export of `github-pr-search.ts` becomes tool `github-pr-search`. Named exports become `filename_exportname`. | ||
| 31 | 6. **Multiple config sources**: Global (`~/.config/opencode/`), home (`~/.opencode/`), project (`.opencode/`), remote (`.well-known/opencode`). Deep merge with priority ordering. | ||
| 32 | 7. **Bun-native loading**: Uses `import()` directly (Bun handles TypeScript). Also uses `Bun.Glob` for file discovery. | ||
| 33 | 8. **Skills as Markdown**: `SKILL.md` files with YAML frontmatter. Separate from tools — skills are context/instructions, tools are callable functions. | ||
| 34 | |||
| 35 | ### OpenClaw (`.context/openclaw/`) | ||
| 36 | |||
| 37 | More complex but some patterns worth noting: | ||
| 38 | |||
| 39 | 1. **jiti for TypeScript loading**: Works across Node and Bun, handles SDK aliasing at import time. | ||
| 40 | 2. **Plugin manifest (`openclaw.plugin.json`)**: JSON Schema for config validation without executing plugin code. | ||
| 41 | 3. **Layered precedence**: Config > Workspace > Global > Bundled, with first-match-wins deduplication. | ||
| 42 | 4. **Registration API pattern**: `register(api)` where `api.registerTool()`, `api.registerHook()`, etc. Very extensible but heavyweight for MVP. | ||
| 43 | |||
| 44 | ### What We're Taking | ||
| 45 | |||
| 46 | From OpenCode: | ||
| 47 | - Convention directories (`.openassistant/tools/`, `.openassistant/plugins/`) | ||
| 48 | - File-as-tool pattern (drop `.ts` in `tools/`, it just works) | ||
| 49 | - Bun-native `import()` for module loading | ||
| 50 | - Zod schemas on tool definitions (args + description) | ||
| 51 | - Auto-dependency management for `.openassistant/` directories | ||
| 52 | - Simple plugin function pattern | ||
| 53 | |||
| 54 | From OpenClaw: | ||
| 55 | - Plugin manifest for config schema validation (but optional, not required) | ||
| 56 | - Config interpolation (`${ENV_VAR}`) for secrets | ||
| 57 | |||
| 58 | Avoiding from both: | ||
| 59 | - OpenClaw's heavyweight registration API (too many `registerX()` methods for MVP) | ||
| 60 | - Over-engineered config merging (keep it simple: project > global) | ||
| 61 | |||
| 62 | --- | ||
| 63 | |||
| 18 | ## Design Principles | 64 | ## Design Principles |
| 19 | 65 | ||
| 20 | 1. **Config-file-driven.** One config file is the source of truth for what's loaded. | 66 | 1. **Convention over configuration.** Drop a `.ts` file in `tools/`, it becomes a tool. No manifest needed for simple cases. |
| 21 | 2. **No runtime magic.** Discovery is explicit scan, not auto-import. No decorators, no file-convention routing. | 67 | 2. **Config when you need it.** Full plugins with config schemas exist for complex integrations, but aren't required. |
| 22 | 3. **Tools are the plugin unit.** MVP plugins = tool packages. Hooks, channels, services come later. | 68 | 3. **Tools are the plugin unit.** MVP plugins = tool packages. Hooks, channels, services come later. |
| 23 | 4. **TypeScript-native.** Plugins are authored in TypeScript, loaded via `jiti` (or Bun's native loader). No build step required for plugin authors. | 69 | 4. **TypeScript-native.** Bun loads `.ts` directly. No build step. No jiti (Bun-only for MVP). |
| 24 | 5. **Monorepo-friendly.** Works whether your plugin is a workspace package, a path on disk, or an npm install. | 70 | 5. **Monorepo-friendly.** Works whether your tool is a file in `.openassistant/tools/`, a workspace package, or an npm install. |
| 25 | 6. **Minimal surface.** Plugin API is `defineTool()` + a manifest. That's it for MVP. | 71 | 6. **Zod schemas are the contract.** Input schemas enable type declaration generation, validation, and documentation — all from one source. |
| 72 | 7. **Effect is an internal.** Tool authors never see Effect. The `run` function is a plain `async` function. The core runtime wraps it in Effect internally. | ||
| 73 | 8. **Approval is the only policy knob.** No `kind`/`risk`/`type` classification. The tool author says `"auto"` or `"required"`. Operators can override via config. | ||
| 74 | |||
| 75 | --- | ||
| 76 | |||
| 77 | ## `defineTool()` — The Only API | ||
| 78 | |||
| 79 | There's one function to define a tool: `defineTool()`. No tiers, no `tool()` vs `defineTool()` split. | ||
| 80 | |||
| 81 | ```ts | ||
| 82 | import { defineTool, z } from "@openassistant/sdk"; | ||
| 83 | |||
| 84 | export default defineTool({ | ||
| 85 | description: "Get current weather for a location", | ||
| 86 | approval: "auto", | ||
| 87 | args: z.object({ | ||
| 88 | location: z.string().describe("City name or coordinates"), | ||
| 89 | }), | ||
| 90 | run: async (input) => { | ||
| 91 | const res = await fetch(`https://wttr.in/${input.location}?format=j1`); | ||
| 92 | return res.json(); | ||
| 93 | }, | ||
| 94 | }); | ||
| 95 | ``` | ||
| 96 | |||
| 97 | That's it. `run` is an async function. No Effect. No special return types. Throw to fail, return to succeed. | ||
| 98 | |||
| 99 | ### Full Signature | ||
| 100 | |||
| 101 | ```ts | ||
| 102 | function defineTool<TArgs extends z.ZodRawShape>(options: { | ||
| 103 | /** Human description of what this tool does (shown to the LLM) */ | ||
| 104 | description: string; | ||
| 105 | /** Whether the tool auto-runs or requires human approval before execution */ | ||
| 106 | approval: "auto" | "required"; | ||
| 107 | /** Zod schema for the input arguments */ | ||
| 108 | args: z.ZodObject<TArgs>; | ||
| 109 | /** The tool implementation. Just an async function. */ | ||
| 110 | run: (input: z.infer<z.ZodObject<TArgs>>) => Promise<unknown>; | ||
| 111 | /** Optional: format the input for the approval UI / receipt preview */ | ||
| 112 | previewInput?: (input: z.infer<z.ZodObject<TArgs>>) => string; | ||
| 113 | /** Optional: format the output for the receipt preview */ | ||
| 114 | previewOutput?: (output: unknown) => string; | ||
| 115 | }): ToolDefinition | ||
| 116 | ``` | ||
| 117 | |||
| 118 | `approval` is the only field that controls execution policy at the tool level. There's no `kind` / `risk` classification — the tool author decides whether their tool needs human sign-off or not. Operators can override this via config/policy later (force approval on auto tools, auto-approve required tools for trusted contexts, deny tools entirely). | ||
| 119 | |||
| 120 | ### What Happens Internally | ||
| 121 | |||
| 122 | The core runner (`packages/core/src/codemode/runner.ts`) wraps the plain async `run` function in Effect at materialization time — in `createToolInvoker()`. Plugin authors never know or care. | ||
| 123 | |||
| 124 | ```ts | ||
| 125 | // Inside createToolInvoker (core internal): | ||
| 126 | const execution = await Effect.runPromiseExit( | ||
| 127 | Effect.tryPromise(() => definition.run(validatedInput)) | ||
| 128 | ); | ||
| 129 | ``` | ||
| 130 | |||
| 131 | The current `defineTool()` in core takes `run: (input) => Effect.Effect<T, E>`. That signature changes to `run: (input) => Promise<T>`. The core runner wraps it. The existing `tools.ts` in discord-bot updates accordingly (just remove the `Effect.sync()` / `Effect.tryPromise()` wrappers from tool definitions). | ||
| 132 | |||
| 133 | ### Before and After | ||
| 134 | |||
| 135 | **Before** (current `apps/discord-bot/src/tools.ts`): | ||
| 136 | ```ts | ||
| 137 | import { defineTool } from "@openassistant/core"; | ||
| 138 | import { Effect } from "effect"; | ||
| 139 | |||
| 140 | defineTool({ | ||
| 141 | kind: "write", | ||
| 142 | approval: "required", | ||
| 143 | run: (input: { title: string; startsAt: string }) => | ||
| 144 | Effect.sync(() => calendarStore.update(input)), // Effect leaks into authoring | ||
| 145 | previewInput: (input) => `${input.title} @ ${input.startsAt}`, | ||
| 146 | }); | ||
| 147 | ``` | ||
| 148 | |||
| 149 | **After** (new API): | ||
| 150 | ```ts | ||
| 151 | import { defineTool, z } from "@openassistant/sdk"; | ||
| 152 | |||
| 153 | defineTool({ | ||
| 154 | description: "Update a calendar event", | ||
| 155 | approval: "required", | ||
| 156 | args: z.object({ | ||
| 157 | title: z.string(), | ||
| 158 | startsAt: z.string(), | ||
| 159 | notes: z.string().optional(), | ||
| 160 | }), | ||
| 161 | run: async (input) => calendarStore.update(input), | ||
| 162 | previewInput: (input) => `${input.title} @ ${input.startsAt}`, | ||
| 163 | }); | ||
| 164 | ``` | ||
| 165 | |||
| 166 | Effect is gone from the authoring surface. `kind` is gone — `approval` is the only policy field. The `args` zod schema replaces the inline TypeScript type annotation on `run`'s parameter. `description` is now required. | ||
| 167 | |||
| 168 | --- | ||
| 169 | |||
| 170 | ## Where `defineTool()` Lives | ||
| 171 | |||
| 172 | Two packages, two roles: | ||
| 173 | |||
| 174 | | Package | Role | Contains | | ||
| 175 | |---------|------|----------| | ||
| 176 | | `packages/core` | **Runtime engine** | `ToolDefinition` type, `createCodeModeRunner()`, materialization, receipts, approval flow. Effect lives here. | | ||
| 177 | | `packages/sdk` | **Authoring surface** | `defineTool()` function, `z` re-export, `PluginContext` type. This is what plugin authors import. No Effect. | | ||
| 178 | |||
| 179 | The SDK's `defineTool()` creates a `ToolDefinition` object (with `_tag: "ToolDefinition"`). The core runtime consumes it. The SDK doesn't import Effect at all — it's a thin layer that produces the right shape. | ||
| 180 | |||
| 181 | ```ts | ||
| 182 | // packages/sdk/src/define-tool.ts | ||
| 183 | import { z } from "zod"; | ||
| 184 | |||
| 185 | export interface ToolDefinition { | ||
| 186 | readonly _tag: "ToolDefinition"; | ||
| 187 | readonly description: string; | ||
| 188 | readonly approval: "auto" | "required"; | ||
| 189 | readonly args: z.ZodObject<any>; | ||
| 190 | readonly run: (input: unknown) => Promise<unknown>; | ||
| 191 | readonly previewInput?: (input: any) => string; | ||
| 192 | readonly previewOutput?: (output: unknown) => string; | ||
| 193 | } | ||
| 194 | |||
| 195 | export function defineTool<TArgs extends z.ZodRawShape>(options: { | ||
| 196 | description: string; | ||
| 197 | approval: "auto" | "required"; | ||
| 198 | args: z.ZodObject<TArgs>; | ||
| 199 | run: (input: z.infer<z.ZodObject<TArgs>>) => Promise<unknown>; | ||
| 200 | previewInput?: (input: z.infer<z.ZodObject<TArgs>>) => string; | ||
| 201 | previewOutput?: (output: unknown) => string; | ||
| 202 | }): ToolDefinition { | ||
| 203 | return { | ||
| 204 | _tag: "ToolDefinition", | ||
| 205 | description: options.description, | ||
| 206 | approval: options.approval, | ||
| 207 | args: options.args, | ||
| 208 | run: options.run, | ||
| 209 | previewInput: options.previewInput, | ||
| 210 | previewOutput: options.previewOutput, | ||
| 211 | }; | ||
| 212 | } | ||
| 213 | ``` | ||
| 214 | |||
| 215 | The core's `createToolInvoker()` then does: | ||
| 216 | 1. Parse input through `definition.args` (zod validation) | ||
| 217 | 2. Call `definition.run(validatedInput)` | ||
| 218 | 3. Wrap in Effect for receipt tracking, approval flow, etc. | ||
| 26 | 219 | ||
| 27 | --- | 220 | --- |
| 28 | 221 | ||
| 29 | ## What Is a Plugin? | 222 | ## Standalone Tools vs Full Plugins |
| 223 | |||
| 224 | ### Standalone Tools (Zero Friction) | ||
| 225 | |||
| 226 | A tool is a single `.ts` file in a convention directory. No `package.json`, no manifest. | ||
| 227 | |||
| 228 | ``` | ||
| 229 | .openassistant/ | ||
| 230 | tools/ | ||
| 231 | weather.ts | ||
| 232 | github-issues.ts | ||
| 233 | ``` | ||
| 234 | |||
| 235 | **`weather.ts`**: | ||
| 236 | ```ts | ||
| 237 | import { defineTool, z } from "@openassistant/sdk"; | ||
| 238 | |||
| 239 | export default defineTool({ | ||
| 240 | description: "Get current weather for a location", | ||
| 241 | approval: "auto", | ||
| 242 | args: z.object({ | ||
| 243 | location: z.string().describe("City name or coordinates"), | ||
| 244 | }), | ||
| 245 | run: async (input) => { | ||
| 246 | const res = await fetch(`https://wttr.in/${input.location}?format=j1`); | ||
| 247 | return res.json(); | ||
| 248 | }, | ||
| 249 | }); | ||
| 250 | ``` | ||
| 251 | |||
| 252 | Rules: | ||
| 253 | - **Default export** → tool ID = filename without extension (`weather`) | ||
| 254 | - **Named exports** → tool ID = `filename.exportName` (`github_issues.list`) | ||
| 30 | 255 | ||
| 31 | A plugin is a directory (or npm package) containing: | 256 | ### Full Plugins (Config + Multiple Tools + Dependencies) |
| 257 | |||
| 258 | A plugin is a directory with `package.json` (has `"openassistant"` key) and optionally `openassistant.json` for config schema. | ||
| 32 | 259 | ||
| 33 | ``` | 260 | ``` |
| 34 | my-plugin/ | 261 | .openassistant/ |
| 35 | package.json # Must have "openassistant" key | 262 | plugins/ |
| 36 | openassistant.json # Plugin manifest (metadata, config schema) | 263 | posthog/ |
| 37 | index.ts # Entry point, default exports a ToolTree or register function | 264 | package.json |
| 265 | openassistant.json # Optional: config schema | ||
| 266 | index.ts | ||
| 38 | ``` | 267 | ``` |
| 39 | 268 | ||
| 40 | ### `package.json` (minimal) | 269 | Or as an npm package: |
| 270 | ```bash | ||
| 271 | bun add @openassistant/plugin-posthog | ||
| 272 | ``` | ||
| 41 | 273 | ||
| 274 | **`package.json`** (minimal): | ||
| 42 | ```json | 275 | ```json |
| 43 | { | 276 | { |
| 44 | "name": "@myorg/oa-posthog", | 277 | "name": "@myorg/oa-posthog", |
| @@ -55,19 +288,12 @@ my-plugin/ · 10 unmodified lines | |||
| 55 | } | 288 | } |
| 56 | ``` | 289 | ``` |
| 57 | 290 | ||
| 58 | The `"openassistant"` key is the marker. It tells the loader this is a plugin, and where to find the entry point. | 291 | **`openassistant.json`** (optional, for config validation): |
| 59 | |||
| 60 | - `entry` defaults to `./index.ts` (then `./index.js`, `./src/index.ts`). | ||
| 61 | - Plugin-specific dependencies go in `dependencies`. `@openassistant/sdk` is a `peerDependency` (the host provides it at load time). | ||
| 62 | |||
| 63 | ### `openassistant.json` (plugin manifest) | ||
| 64 | |||
| 65 | ```json | 292 | ```json |
| 66 | { | 293 | { |
| 67 | "id": "posthog", | 294 | "id": "posthog", |
| 68 | "name": "PostHog Analytics", | 295 | "name": "PostHog Analytics", |
| 69 | "description": "Read analytics and create monitors via PostHog API.", | 296 | "description": "Read analytics and create monitors via PostHog API.", |
| 70 | "version": "0.1.0", | ||
| 71 | "configSchema": { | 297 | "configSchema": { |
| 72 | "type": "object", | 298 | "type": "object", |
| 73 | "required": ["apiKey", "projectId"], | 299 | "required": ["apiKey", "projectId"], |
| @@ -81,457 +307,545 @@ The `"openassistant"` key is the marker. It tells the loader this is a plugin, a · 7 unmodified lines | |||
| 81 | } | 307 | } |
| 82 | ``` | 308 | ``` |
| 83 | 309 | ||
| 84 | - `id` is the unique namespace. Tool paths become `posthog.analytics.getVisitors` etc. | 310 | **`index.ts`** (plugin entry): |
| 85 | - `configSchema` is JSON Schema. Validated at load time against the user's config. | ||
| 86 | - If no `openassistant.json` exists, the loader reads `id` from `package.json` name (strip scope) and config schema is treated as empty (no config required). | ||
| 87 | |||
| 88 | ### Entry Module | ||
| 89 | |||
| 90 | Two supported patterns: | ||
| 91 | |||
| 92 | **Pattern A: Export a ToolTree directly (simple case)** | ||
| 93 | |||
| 94 | ```ts | ||
| 95 | import { defineTool } from "@openassistant/sdk"; | ||
| 96 | import { Effect } from "effect"; | ||
| 97 | |||
| 98 | export default { | ||
| 99 | analytics: { | ||
| 100 | getVisitors: defineTool({ | ||
| 101 | id: "posthog.analytics.getVisitors", | ||
| 102 | kind: "read", | ||
| 103 | approval: "auto", | ||
| 104 | run: (input: { projectId: string }) => | ||
| 105 | Effect.tryPromise(() => fetch(`...`).then(r => r.json())), | ||
| 106 | }), | ||
| 107 | }, | ||
| 108 | } satisfies ToolTree; | ||
| 109 | ``` | ||
| 110 | |||
| 111 | **Pattern B: Export a register function (needs config/context)** | ||
| 112 | |||
| 113 | ```ts | 311 | ```ts |
| 114 | import { defineTool, type PluginContext, type ToolTree } from "@openassistant/sdk"; | 312 | import { defineTool, z, type PluginContext, type ToolTree } from "@openassistant/sdk"; |
| 115 | import { Effect } from "effect"; | 313 | import { PostHogClient } from "posthog-node"; |
| 116 | 314 | ||
| 117 | export default function register(ctx: PluginContext): ToolTree { | 315 | export default function register(ctx: PluginContext): ToolTree { |
| 118 | const client = new PostHogClient(ctx.config.apiKey, ctx.config.host); | 316 | const client = new PostHogClient(ctx.config.apiKey as string); |
| 119 | 317 | ||
| 120 | return { | 318 | return { |
| 121 | analytics: { | 319 | analytics: { |
| 122 | getVisitors: defineTool({ | 320 | getVisitors: defineTool({ |
| 123 | kind: "read", | 321 | description: "Get visitor count for a website", |
| 124 | approval: "auto", | 322 | approval: "auto", |
| 125 | run: (input: { website: string }) => | 323 | args: z.object({ website: z.string() }), |
| 126 | Effect.tryPromise(() => client.getInsight(input.website)), | 324 | run: async (input) => client.getInsight(input.website), |
| 127 | }), | 325 | }), |
| 128 | }, | 326 | }, |
| 129 | monitor: { | 327 | monitor: { |
| 130 | createThreshold: defineTool({ | 328 | createThreshold: defineTool({ |
| 131 | kind: "write", | 329 | description: "Create a threshold alert for visitor count", |
| 132 | approval: "required", | 330 | approval: "required", |
| 133 | run: (input: { website: string; threshold: number }) => | 331 | args: z.object({ website: z.string(), threshold: z.number() }), |
| 134 | Effect.tryPromise(() => client.createAlert(input)), | 332 | run: async (input) => client.createAlert(input), |
| 135 | }), | 333 | }), |
| 136 | }, | 334 | }, |
| 137 | }; | 335 | }; |
| 138 | } | 336 | } |
| 139 | ``` | 337 | ``` |
| 140 | 338 | ||
| 141 | `PluginContext` provides: | 339 | Same `defineTool()`, same API. The only difference is the plugin exports a function that receives config via `PluginContext`, instead of exporting tools directly. |
| 142 | - `config`: validated plugin config from the user's config file. | ||
| 143 | - `logger`: scoped logger. | ||
| 144 | - `dataDir`: per-plugin persistent data directory (`~/.config/openassistant/data/<pluginId>/`). | ||
| 145 | 340 | ||
| 146 | --- | 341 | --- |
| 147 | 342 | ||
| 148 | ## Config File | 343 | ## Config File |
| 149 | 344 | ||
| 150 | Location: `~/.config/openassistant/config.json` (or `OPENASSISTANT_CONFIG_PATH` env var). | 345 | ### Location Resolution |
| 151 | 346 | ||
| 152 | For monorepo users: `.openassistant/config.json` in the project root (detected by walking up from cwd). | 347 | Config is found by scanning these locations (higher priority wins): |
| 153 | 348 | ||
| 154 | Resolution order: | 349 | | Priority | Location | Env Override | |
| 155 | 1. Project-local: `<project>/.openassistant/config.json` | 350 | |----------|----------|--------------| |
| 156 | 2. Global: `~/.config/openassistant/config.json` | 351 | | 1 (highest) | `OPENASSISTANT_CONFIG` env var (path to file) | — | |
| 157 | 3. Merged (project overrides global). | 352 | | 2 | `<project>/.openassistant/config.json` | — | |
| 353 | | 3 (lowest) | `~/.config/openassistant/config.json` | `OPENASSISTANT_CONFIG_DIR` | | ||
| 158 | 354 | ||
| 159 | ```json | 355 | Project root is detected by walking up from cwd looking for `.openassistant/` directory or `package.json`. |
| 356 | |||
| 357 | When both project and global exist, they're deep-merged (project overrides global). | ||
| 358 | |||
| 359 | ### Config Shape | ||
| 360 | |||
| 361 | ```jsonc | ||
| 160 | { | 362 | { |
| 161 | "plugins": { | 363 | // Plugin specifiers — npm packages or file:// URLs |
| 162 | "paths": [ | 364 | "plugins": [ |
| 163 | "./tools/posthog", | 365 | "@openassistant/plugin-posthog", |
| 164 | "./tools/github", | 366 | "@openassistant/plugin-github@^0.2.0", |
| 165 | "/absolute/path/to/custom-plugin" | 367 | "file:///absolute/path/to/plugin", |
| 166 | ], | 368 | "./relative/path/to/plugin" |
| 167 | "npm": [ | 369 | ], |
| 168 | "@openassistant/plugin-github", | 370 | |
| 169 | "@openassistant/plugin-posthog@^0.2.0" | 371 | // Per-plugin configuration |
| 170 | ], | 372 | "config": { |
| 171 | "config": { | 373 | "posthog": { |
| 172 | "posthog": { | 374 | "enabled": true, |
| 173 | "enabled": true, | 375 | "apiKey": "${POSTHOG_API_KEY}", |
| 174 | "apiKey": "${POSTHOG_API_KEY}", | 376 | "projectId": "12345", |
| 175 | "projectId": "12345", | 377 | "host": "https://us.posthog.com" |
| 176 | "host": "https://us.posthog.com" | 378 | }, |
| 177 | }, | 379 | "github": { |
| 178 | "github": { | 380 | "enabled": true, |
| 179 | "enabled": true, | 381 | "token": "${GITHUB_TOKEN}" |
| 180 | "token": "${GITHUB_TOKEN}" | ||
| 181 | } | ||
| 182 | } | 382 | } |
| 183 | }, | 383 | }, |
| 384 | |||
| 385 | // Auth profiles | ||
| 184 | "auth": { | 386 | "auth": { |
| 185 | "anthropic": { | 387 | "anthropic": { |
| 186 | "type": "token", | 388 | "type": "token", |
| 187 | "profileOrder": ["manual", "env"] | 389 | "profileOrder": ["manual", "env"] |
| 188 | } | 390 | } |
| 391 | }, | ||
| 392 | |||
| 393 | // Tool-level overrides | ||
| 394 | "tools": { | ||
| 395 | "weather": true, // enable (default) | ||
| 396 | "dangerous-tool": false // disable | ||
| 189 | } | 397 | } |
| 190 | } | 398 | } |
| 191 | ``` | 399 | ``` |
| 192 | 400 | ||
| 193 | ### Plugin Sources (Discovery Origins) | 401 | - **`plugins` is a flat array** (like OpenCode). The loader auto-detects whether a specifier is an npm package, a path, or a URL. |
| 402 | - **`config` is top-level**, keyed by plugin ID. | ||
| 403 | - **`tools` is a simple enable/disable map.** Individual tools can be toggled without touching plugin config. | ||
| 404 | - **`${ENV_VAR}` interpolation** for secrets. Only in string values. No shell expansion. | ||
| 194 | 405 | ||
| 195 | Plugins are discovered from these origins, in priority order: | 406 | --- |
| 196 | 407 | ||
| 197 | | Origin | Location | Use Case | | 408 | ## Discovery and Loading |
| 198 | |--------|----------|----------| | ||
| 199 | | `config.plugins.paths` | Explicit paths from config | Monorepo workspace tools, local dev | | ||
| 200 | | `workspace` | `<project>/.openassistant/plugins/` | Project-scoped plugins dropped into a convention dir | | ||
| 201 | | `global` | `~/.config/openassistant/plugins/` | User-global plugins (installed via CLI) | | ||
| 202 | | `npm` | `config.plugins.npm` entries resolved from `node_modules` | npm-installed plugins | | ||
| 203 | | `bundled` | `<openassistant-package>/plugins/` | Shipped with the core package | | ||
| 204 | 409 | ||
| 205 | ### Plugin Config | 410 | ### Convention Directories |
| 206 | 411 | ||
| 207 | Config values support `${ENV_VAR}` interpolation so secrets don't live in config files. | 412 | These directories are auto-scanned for tools and plugins: |
| 208 | 413 | ||
| 209 | Per-plugin config is validated against the plugin's `configSchema` at load time. If validation fails, the plugin is skipped with a warning (not a crash). | 414 | | Directory | What's Scanned | |
| 415 | |-----------|----------------| | ||
| 416 | | `<project>/.openassistant/tools/` | Standalone `.ts`/`.js` files | | ||
| 417 | | `<project>/.openassistant/plugins/` | Plugin directories with `package.json` | | ||
| 418 | | `~/.config/openassistant/tools/` | Global standalone tools | | ||
| 419 | | `~/.config/openassistant/plugins/` | Global plugin directories | | ||
| 210 | 420 | ||
| 211 | `enabled: false` in a plugin's config block disables it without removing its config. | 421 | ### Auto-Dependency Management |
| 212 | 422 | ||
| 213 | --- | 423 | When the `.openassistant/` directory contains tools or plugins, the loader: |
| 424 | |||
| 425 | 1. Checks for `package.json` in `.openassistant/`. | ||
| 426 | 2. If missing, creates one with `@openassistant/sdk` as a dependency. | ||
| 427 | 3. Runs `bun install` if `node_modules` doesn't exist or `@openassistant/sdk` is missing. | ||
| 428 | |||
| 429 | Users can just drop a `.ts` file in `tools/` and it works — the SDK dependency is auto-managed. | ||
| 430 | |||
| 431 | ```json | ||
| 432 | // Auto-generated .openassistant/package.json | ||
| 433 | { | ||
| 434 | "private": true, | ||
| 435 | "type": "module", | ||
| 436 | "dependencies": { | ||
| 437 | "@openassistant/sdk": "^0.1.0" | ||
| 438 | } | ||
| 439 | } | ||
| 440 | ``` | ||
| 214 | 441 | ||
| 215 | ## Discovery and Loading Pipeline | 442 | ### Full Pipeline |
| 216 | 443 | ||
| 217 | ``` | 444 | ``` |
| 218 | 1. RESOLVE CONFIG | 445 | 1. RESOLVE CONFIG |
| 219 | - Find config file (project-local, then global) | 446 | - Find config file (env > project > global) |
| 220 | - Parse and merge | 447 | - Parse JSONC with ${ENV_VAR} interpolation |
| 221 | 448 | - Deep merge if multiple sources | |
| 222 | 2. DISCOVER PLUGINS | 449 | |
| 223 | - Scan each origin (paths, workspace, global, npm, bundled) | 450 | 2. SCAN CONVENTION DIRECTORIES |
| 224 | - For each candidate directory: | 451 | - .openassistant/tools/*.{ts,js} → standalone tool candidates |
| 225 | a. Read package.json, check for "openassistant" key | 452 | - .openassistant/plugins/*/ → plugin candidates (must have package.json) |
| 226 | b. Read openassistant.json (or derive defaults from package.json) | 453 | - Same for global ~/.config/openassistant/ |
| 227 | c. Deduplicate by plugin ID (first origin wins) | 454 | |
| 228 | 455 | 3. RESOLVE PLUGIN SPECIFIERS | |
| 229 | 3. FILTER | 456 | - For each entry in config.plugins[]: |
| 230 | - Check enabled/disabled state from config | 457 | a. npm package name → resolve from node_modules |
| 231 | - Skip plugins with missing required config | 458 | b. file:// URL → resolve absolute path |
| 232 | 459 | c. Relative path → resolve from project root | |
| 233 | 4. VALIDATE CONFIG | 460 | |
| 234 | - Run JSON Schema validation on each plugin's config block | 461 | 4. AUTO-INSTALL DEPENDENCIES |
| 235 | - Log warnings for invalid configs, skip those plugins | 462 | - Ensure .openassistant/package.json exists with SDK dep |
| 236 | 463 | - Run bun install if needed | |
| 237 | 5. LOAD MODULE | 464 | |
| 238 | - Use jiti (or Bun native import) to load the TypeScript entry point | 465 | 5. DEDUPLICATE |
| 239 | - Alias "@openassistant/sdk" to the host's SDK module | 466 | - By plugin ID (from manifest) or tool filename |
| 240 | 467 | - First-found wins (project > global > npm > bundled) | |
| 241 | 6. RESOLVE EXPORT | 468 | |
| 242 | - If default export is a function: call it with PluginContext -> get ToolTree | 469 | 6. LOAD MODULES |
| 243 | - If default export is a ToolTree: use directly | 470 | - Use dynamic import() (Bun handles .ts natively) |
| 244 | - Namespace the tree under the plugin ID | 471 | - For standalone tools: import file, extract default + named exports |
| 245 | 472 | - For plugins: import entry, resolve function vs ToolTree export | |
| 246 | 7. MERGE TOOL TREES | 473 | |
| 247 | - Combine all plugin ToolTrees into the master ToolTree | 474 | 7. VALIDATE CONFIG |
| 475 | - If plugin has openassistant.json with configSchema: | ||
| 476 | validate config[pluginId] against JSON Schema | ||
| 477 | - Skip with warning on validation failure | ||
| 478 | |||
| 479 | 8. VALIDATE INPUTS (at tool definition time) | ||
| 480 | - Each tool has a zod args schema | ||
| 481 | - At invocation time, parse input through schema before calling run | ||
| 482 | |||
| 483 | 9. BUILD TOOL TREE | ||
| 484 | - Standalone tools: flat under their filename ID | ||
| 485 | - Plugin tools: namespaced under plugin ID | ||
| 486 | - Check tools config for enable/disable overrides | ||
| 248 | - Detect ID collisions (error) | 487 | - Detect ID collisions (error) |
| 249 | 488 | ||
| 250 | 8. GENERATE TYPE DECLARATIONS | 489 | 10. GENERATE TYPE DECLARATIONS |
| 251 | - Walk the merged ToolTree | 490 | - Walk merged ToolTree |
| 252 | - Emit TypeScript ambient declarations for the codemode typechecker | 491 | - Derive TypeScript types from zod args schemas |
| 253 | - This replaces the hardcoded TOOL_DECLARATIONS string | 492 | - This replaces the hardcoded TOOL_DECLARATIONS |
| 254 | 493 | ||
| 255 | 9. READY | 494 | 11. READY |
| 256 | - Pass merged ToolTree to createCodeModeRunner() | 495 | - Pass merged ToolTree to createCodeModeRunner() |
| 257 | - Plugin tools are now available in the sandbox | 496 | - Pass declarations to typechecker |
| 258 | ``` | 497 | ``` |
| 259 | 498 | ||
| 260 | --- | 499 | --- |
| 261 | 500 | ||
| 262 | ## The SDK Package (`@openassistant/sdk`) | 501 | ## The SDK Package (`@openassistant/sdk`) |
| 263 | 502 | ||
| 264 | New package: `packages/sdk/`. This is the public API surface for plugin authors. | 503 | New package: `packages/sdk/`. This is the **only** package tool authors need. No Effect, no core internals. |
| 265 | 504 | ||
| 266 | Exports: | 505 | ### Exports |
| 267 | - `defineTool()` (re-exported from core) | ||
| 268 | - `ToolTree`, `ToolDefinition`, `ToolKind`, `ToolApprovalMode` types | ||
| 269 | - `PluginContext` type | ||
| 270 | - `z` (re-export zod for schema definitions) | ||
| 271 | - `Effect` (re-export for handler authoring) | ||
| 272 | 506 | ||
| 273 | ```ts | 507 | ```ts |
| 274 | // packages/sdk/src/index.ts | 508 | // packages/sdk/src/index.ts |
| 275 | export { defineTool, type ToolTree, type ToolDefinition, type ToolKind, type ToolApprovalMode } from "@openassistant/core"; | 509 | export { defineTool, type ToolDefinition, type ToolTree } from "./define-tool.js"; |
| 276 | export type { PluginContext } from "./plugin-context.js"; | 510 | export type { PluginContext } from "./plugin-context.js"; |
| 277 | export { z } from "zod"; | 511 | export { z } from "zod"; |
| 278 | export { Effect } from "effect"; | ||
| 279 | ``` | 512 | ``` |
| 280 | 513 | ||
| 281 | Plugin authors install `@openassistant/sdk` as a `peerDependency`. The host resolves it via aliasing at load time (same pattern as OpenClaw's jiti alias for `openclaw/plugin-sdk`). | 514 | That's it. Three things: `defineTool`, `PluginContext`, `z`. |
| 515 | |||
| 516 | ### What the SDK does NOT export | ||
| 517 | |||
| 518 | - `Effect` — internal to core runtime | ||
| 519 | - `ToolCallReceipt` — internal to core runtime | ||
| 520 | - `ApprovalRequest` — internal to core runtime | ||
| 521 | - `createCodeModeRunner` — internal to core runtime | ||
| 522 | |||
| 523 | --- | ||
| 524 | |||
| 525 | ## Changes to Core | ||
| 526 | |||
| 527 | ### `ToolDefinition` (the shape) | ||
| 528 | |||
| 529 | The `ToolDefinition` interface changes from Effect-based to plain async, and drops `kind`: | ||
| 530 | |||
| 531 | ```ts | ||
| 532 | // packages/core — BEFORE | ||
| 533 | export interface ToolDefinition<TInput = unknown, TOutput = unknown> { | ||
| 534 | readonly _tag: "ToolDefinition"; | ||
| 535 | readonly kind: ToolKind; // "read" | "write" | ||
| 536 | readonly approval: ToolApprovalMode; // "auto" | "required" | ||
| 537 | readonly run: (input: TInput) => Effect.Effect<TOutput, unknown>; | ||
| 538 | readonly previewInput?: (input: TInput) => string | undefined; | ||
| 539 | readonly previewOutput?: (output: TOutput) => string | undefined; | ||
| 540 | } | ||
| 541 | |||
| 542 | // packages/core — AFTER | ||
| 543 | export interface ToolDefinition { | ||
| 544 | readonly _tag: "ToolDefinition"; | ||
| 545 | readonly description: string; | ||
| 546 | readonly approval: "auto" | "required"; | ||
| 547 | readonly args: z.ZodObject<any>; | ||
| 548 | readonly run: (input: unknown) => Promise<unknown>; | ||
| 549 | readonly previewInput?: (input: any) => string | undefined; | ||
| 550 | readonly previewOutput?: (output: unknown) => string | undefined; | ||
| 551 | } | ||
| 552 | ``` | ||
| 553 | |||
| 554 | Changes: | ||
| 555 | 1. `run` returns `Promise<unknown>` instead of `Effect.Effect<TOutput, unknown>` | ||
| 556 | 2. `kind` removed — `approval` is the only policy field | ||
| 557 | 3. `args` (zod schema) added — required | ||
| 558 | 4. `description` added — required | ||
| 559 | 5. Generic type parameters removed (zod handles type safety at the authoring site, core treats everything as `unknown` internally) | ||
| 560 | |||
| 561 | ### `createToolInvoker()` (the runtime) | ||
| 562 | |||
| 563 | The invoker validates input via zod, then wraps the plain async `run` in Effect: | ||
| 564 | |||
| 565 | ```ts | ||
| 566 | // BEFORE: runs Effect directly, no input validation | ||
| 567 | const execution = await Effect.runPromiseExit(params.definition.run(input)); | ||
| 568 | |||
| 569 | // AFTER: validates with zod, wraps async function in Effect | ||
| 570 | const validated = params.definition.args.safeParse(input); | ||
| 571 | if (!validated.success) { | ||
| 572 | // emit failed receipt with validation error, throw | ||
| 573 | } | ||
| 574 | const execution = await Effect.runPromiseExit( | ||
| 575 | Effect.tryPromise(() => params.definition.run(validated.data)) | ||
| 576 | ); | ||
| 577 | ``` | ||
| 578 | |||
| 579 | ### `createCodeModeRunner()` (unchanged API) | ||
| 580 | |||
| 581 | The runner still takes a `ToolTree` and a `requestApproval` callback. Still returns an Effect. The public contract of the runner doesn't change — only the internal `ToolDefinition` shape does. | ||
| 582 | |||
| 583 | ### Migration of existing tools | ||
| 584 | |||
| 585 | `apps/discord-bot/src/tools.ts` drops Effect imports and uses the new API: | ||
| 586 | |||
| 587 | ```ts | ||
| 588 | // BEFORE | ||
| 589 | import { defineTool } from "@openassistant/core"; | ||
| 590 | import { Effect } from "effect"; | ||
| 591 | |||
| 592 | calendar: { | ||
| 593 | update: defineTool({ | ||
| 594 | kind: "write", | ||
| 595 | approval: "required", | ||
| 596 | run: (input: { title: string; startsAt: string }) => | ||
| 597 | Effect.sync(() => calendarStore.update(input)), | ||
| 598 | }), | ||
| 599 | } | ||
| 600 | |||
| 601 | // AFTER | ||
| 602 | import { defineTool, z } from "@openassistant/sdk"; | ||
| 603 | |||
| 604 | calendar: { | ||
| 605 | update: defineTool({ | ||
| 606 | description: "Create or update a calendar event", | ||
| 607 | approval: "required", | ||
| 608 | args: z.object({ | ||
| 609 | title: z.string(), | ||
| 610 | startsAt: z.string(), | ||
| 611 | notes: z.string().optional(), | ||
| 612 | }), | ||
| 613 | run: async (input) => calendarStore.update(input), | ||
| 614 | }), | ||
| 615 | } | ||
| 616 | ``` | ||
| 617 | |||
| 618 | ### Migration of `ToolCallReceipt` | ||
| 619 | |||
| 620 | The `kind` field is also removed from `ToolCallReceipt`. Receipts track `approval` mode and `decision` — that's sufficient. The receipt shape becomes: | ||
| 621 | |||
| 622 | ```ts | ||
| 623 | export interface ToolCallReceipt { | ||
| 624 | callId: string; | ||
| 625 | toolPath: string; | ||
| 626 | approval: "auto" | "required"; | ||
| 627 | decision: "auto" | "approved" | "denied"; | ||
| 628 | status: "succeeded" | "failed" | "denied"; | ||
| 629 | timestamp: string; | ||
| 630 | inputPreview?: string; | ||
| 631 | outputPreview?: string; | ||
| 632 | error?: string; | ||
| 633 | } | ||
| 634 | ``` | ||
| 282 | 635 | ||
| 283 | --- | 636 | --- |
| 284 | 637 | ||
| 285 | ## Type Declaration Generation | 638 | ## Type Declaration Generation |
| 286 | 639 | ||
| 287 | The current `code-typecheck.ts` has a hardcoded `TOOL_DECLARATIONS` string. This needs to be generated dynamically from the merged ToolTree. | 640 | ### The Problem |
| 288 | 641 | ||
| 289 | Approach: | 642 | `code-typecheck.ts` currently has a hardcoded `TOOL_DECLARATIONS` string. When tools are dynamically loaded, we need to generate this. |
| 290 | - After loading all plugins and merging the ToolTree, walk the tree. | ||
| 291 | - For each `ToolDefinition`, inspect the `run` function's parameter type (via the zod schema on the definition, which we add in the next iteration). | ||
| 292 | - Generate ambient declarations: `declare const tools: { calendar: { update(input: { title: string; startsAt: string; notes?: string }): Promise<unknown>; }; };` | ||
| 293 | - Pass this to the typechecker instead of the hardcoded string. | ||
| 294 | 643 | ||
| 295 | **MVP shortcut**: For MVP, require plugins to export a `declarations` string alongside their ToolTree. Long-term, derive from schemas. | 644 | ### Approach: Derive from Zod Schemas |
| 645 | |||
| 646 | Every tool now has a zod `args` schema. We walk the merged ToolTree, convert each schema to a TypeScript type string, and emit ambient declarations. | ||
| 296 | 647 | ||
| 297 | ```ts | 648 | ```ts |
| 298 | // Plugin can optionally export declarations | 649 | // Given: |
| 299 | export const declarations = `{ | 650 | defineTool({ |
| 300 | analytics: { | 651 | description: "Get weather", |
| 301 | getVisitors(input: { projectId: string; website: string }): Promise<unknown>; | 652 | approval: "auto", |
| 302 | }; | 653 | args: z.object({ |
| 303 | monitor: { | 654 | location: z.string(), |
| 304 | createThreshold(input: { website: string; threshold: number }): Promise<unknown>; | 655 | units: z.enum(["metric", "imperial"]).default("metric"), |
| 305 | }; | 656 | }), |
| 306 | }`; | 657 | run: async (args) => { ... }, |
| 658 | }); | ||
| 659 | |||
| 660 | // Generates: | ||
| 661 | // weather(input: { location: string; units?: "metric" | "imperial" }): Promise<unknown> | ||
| 307 | ``` | 662 | ``` |
| 308 | 663 | ||
| 309 | The loader concatenates all plugin declaration fragments into a single `declare const tools: { ... }` block. | 664 | For the full merged tree: |
| 665 | ```ts | ||
| 666 | declare const tools: { | ||
| 667 | weather(input: { location: string; units?: "metric" | "imperial" }): Promise<unknown>; | ||
| 668 | posthog: { | ||
| 669 | analytics: { | ||
| 670 | getVisitors(input: { website: string }): Promise<unknown>; | ||
| 671 | }; | ||
| 672 | monitor: { | ||
| 673 | createThreshold(input: { website: string; threshold: number }): Promise<unknown>; | ||
| 674 | }; | ||
| 675 | }; | ||
| 676 | }; | ||
| 677 | ``` | ||
| 310 | 678 | ||
| 311 | --- | 679 | --- |
| 312 | 680 | ||
| 313 | ## Monorepo User Experience | 681 | ## Monorepo User Experience |
| 314 | 682 | ||
| 315 | ### Scenario: User has a Bun monorepo | 683 | ### Scenario 1: Quick tool (30 seconds to working) |
| 316 | 684 | ||
| 317 | ``` | 685 | ```bash |
| 318 | my-project/ | 686 | mkdir -p .openassistant/tools |
| 319 | .openassistant/ | ||
| 320 | config.json | ||
| 321 | plugins/ # Convention dir for local plugins | ||
| 322 | posthog/ | ||
| 323 | package.json | ||
| 324 | openassistant.json | ||
| 325 | index.ts | ||
| 326 | apps/ | ||
| 327 | my-app/ | ||
| 328 | packages/ | ||
| 329 | my-lib/ | ||
| 330 | ``` | 687 | ``` |
| 331 | 688 | ||
| 332 | `.openassistant/config.json`: | 689 | Create `.openassistant/tools/weather.ts`: |
| 333 | ```json | 690 | ```ts |
| 334 | { | 691 | import { defineTool, z } from "@openassistant/sdk"; |
| 335 | "plugins": { | 692 | |
| 336 | "config": { | 693 | export default defineTool({ |
| 337 | "posthog": { | 694 | description: "Get current weather", |
| 338 | "apiKey": "${POSTHOG_API_KEY}", | 695 | approval: "auto", |
| 339 | "projectId": "12345" | 696 | args: z.object({ location: z.string() }), |
| 340 | } | 697 | run: async ({ location }) => { |
| 341 | } | 698 | const res = await fetch(`https://wttr.in/${location}?format=j1`); |
| 342 | } | 699 | return res.json(); |
| 343 | } | 700 | }, |
| 701 | }); | ||
| 344 | ``` | 702 | ``` |
| 345 | 703 | ||
| 346 | That's it. The convention directory is auto-scanned. Config is in one place. | 704 | Done. OpenAssistant auto-creates `package.json`, installs SDK, discovers the tool. |
| 347 | 705 | ||
| 348 | ### Scenario: User has tools as a workspace package | 706 | ### Scenario 2: Workspace package plugin |
| 349 | 707 | ||
| 350 | ``` | 708 | ``` |
| 351 | my-project/ | 709 | my-project/ |
| 352 | .openassistant/ | 710 | .openassistant/ |
| 353 | config.json | 711 | config.json |
| 354 | packages/ | 712 | packages/ |
| 355 | oa-tools/ # Workspace package with tools | 713 | oa-posthog/ |
| 356 | package.json # has "openassistant" key | 714 | package.json # has "openassistant" key |
| 357 | openassistant.json | 715 | openassistant.json # config schema |
| 358 | src/ | 716 | index.ts |
| 359 | index.ts | ||
| 360 | ``` | 717 | ``` |
| 361 | 718 | ||
| 362 | `.openassistant/config.json`: | 719 | `.openassistant/config.json`: |
| 363 | ```json | 720 | ```jsonc |
| 364 | { | 721 | { |
| 365 | "plugins": { | 722 | "plugins": ["./packages/oa-posthog"], |
| 366 | "paths": ["./packages/oa-tools"] | 723 | "config": { |
| 724 | "posthog": { | ||
| 725 | "apiKey": "${POSTHOG_API_KEY}", | ||
| 726 | "projectId": "12345" | ||
| 727 | } | ||
| 367 | } | 728 | } |
| 368 | } | 729 | } |
| 369 | ``` | 730 | ``` |
| 370 | 731 | ||
| 371 | Explicit path reference. Clean, no magic. | 732 | ### Scenario 3: npm package |
| 372 | |||
| 373 | ### Scenario: User installs from npm | ||
| 374 | 733 | ||
| 375 | ```bash | 734 | ```bash |
| 376 | bun add @openassistant/plugin-github | 735 | bun add @openassistant/plugin-github |
| 377 | ``` | 736 | ``` |
| 378 | 737 | ||
| 379 | `.openassistant/config.json`: | 738 | `.openassistant/config.json`: |
| 380 | ```json | 739 | ```jsonc |
| 381 | { | 740 | { |
| 382 | "plugins": { | 741 | "plugins": ["@openassistant/plugin-github"], |
| 383 | "npm": ["@openassistant/plugin-github"], | 742 | "config": { |
| 384 | "config": { | 743 | "github": { "token": "${GITHUB_TOKEN}" } |
| 385 | "github": { | ||
| 386 | "token": "${GITHUB_TOKEN}" | ||
| 387 | } | ||
| 388 | } | ||
| 389 | } | 744 | } |
| 390 | } | 745 | } |
| 391 | ``` | 746 | ``` |
| 392 | 747 | ||
| 393 | The loader resolves `@openassistant/plugin-github` from `node_modules`. | 748 | ### Scenario 4: Multiple tools in one file |
| 394 | |||
| 395 | --- | ||
| 396 | |||
| 397 | ## CLI Surface (Future) | ||
| 398 | |||
| 399 | ```bash | ||
| 400 | # Initialize config in current project | ||
| 401 | openassistant init | ||
| 402 | 749 | ||
| 403 | # List discovered plugins and their status | 750 | `.openassistant/tools/github-issues.ts`: |
| 404 | openassistant plugins list | 751 | ```ts |
| 405 | 752 | import { defineTool, z } from "@openassistant/sdk"; | |
| 406 | # Validate plugin configs | ||
| 407 | openassistant plugins check | ||
| 408 | |||
| 409 | # Install a plugin from npm to global plugins dir | ||
| 410 | openassistant plugins install @openassistant/plugin-github | ||
| 411 | 753 | ||
| 412 | # Scaffold a new plugin | 754 | export const list = defineTool({ |
| 413 | openassistant plugins create my-plugin | 755 | description: "List GitHub issues", |
| 756 | approval: "auto", | ||
| 757 | args: z.object({ | ||
| 758 | repo: z.string(), | ||
| 759 | state: z.enum(["open", "closed"]).default("open"), | ||
| 760 | }), | ||
| 761 | run: async ({ repo, state }) => { ... }, | ||
| 762 | }); | ||
| 763 | |||
| 764 | export const create = defineTool({ | ||
| 765 | description: "Create a GitHub issue", | ||
| 766 | approval: "required", | ||
| 767 | args: z.object({ | ||
| 768 | repo: z.string(), | ||
| 769 | title: z.string(), | ||
| 770 | body: z.string().optional(), | ||
| 771 | }), | ||
| 772 | run: async ({ repo, title, body }) => { ... }, | ||
| 773 | }); | ||
| 414 | ``` | 774 | ``` |
| 415 | 775 | ||
| 416 | Not needed for MVP. The config file + discovery is enough. | 776 | Tools become: `tools.github_issues.list(...)` and `tools.github_issues.create(...)`. |
| 417 | 777 | ||
| 418 | --- | 778 | --- |
| 419 | 779 | ||
| 420 | ## Implementation Plan | 780 | ## Implementation Plan |
| 421 | 781 | ||
| 422 | ### Phase 1: Config + Discovery (Foundation) | 782 | ### Phase 1: New `defineTool()` + SDK |
| 423 | 783 | ||
| 424 | 1. **Config loader** (`packages/core/src/config/`) | 784 | 1. **Change `ToolDefinition` in core** — `run` becomes `Promise`, drop `kind`, add `args` + `description` |
| 425 | - Find config file (project-local walk-up, global fallback) | 785 | 2. **Update `createToolInvoker()`** — wrap async `run` in Effect internally, add zod validation |
| 426 | - Parse JSON with `${ENV_VAR}` interpolation | 786 | 3. **Update `ToolCallReceipt`** — drop `kind` field |
| 427 | - Type-safe config shape with validation | 787 | 4. **Create `packages/sdk/`** — `defineTool()`, `z` re-export, `PluginContext` type |
| 428 | - Export `resolveConfig(): OpenAssistantConfig` | 788 | 5. **Migrate `apps/discord-bot/src/tools.ts`** — use new API, drop Effect |
| 789 | 6. **Type declaration generator** — walk ToolTree, emit declarations from zod schemas | ||
| 429 | 790 | ||
| 430 | 2. **Plugin discovery** (`packages/core/src/plugins/discovery.ts`) | 791 | ### Phase 2: Config + Discovery |
| 431 | - Scan each origin (paths, workspace dir, global dir) | ||
| 432 | - Read `package.json` "openassistant" key | ||
| 433 | - Read `openassistant.json` manifest | ||
| 434 | - Return `DiscoveredPlugin[]` with id, path, manifest, origin | ||
| 435 | 792 | ||
| 436 | 3. **Plugin loader** (`packages/core/src/plugins/loader.ts`) | 793 | 6. **Config loader** (`packages/core/src/config/`) |
| 437 | - Load TypeScript entry via `jiti` or dynamic `import()` | 794 | - Config file resolution (env > project > global) |
| 438 | - Resolve export pattern (function vs ToolTree) | 795 | - JSONC parsing + `${ENV_VAR}` interpolation |
| 439 | - Validate plugin config against schema | 796 | - Zod schema for config shape validation |
| 440 | - Return `LoadedPlugin { id, toolTree, declarations? }` | ||
| 441 | 797 | ||
| 442 | 4. **Plugin registry** (`packages/core/src/plugins/registry.ts`) | 798 | 7. **Tool/Plugin discovery** (`packages/core/src/plugins/`) |
| 443 | - Merge all loaded plugin ToolTrees into master tree | 799 | - Scan convention directories (`tools/`, `plugins/`) |
| 444 | - Namespace under plugin IDs | 800 | - Resolve plugin specifiers (npm, file://, relative path) |
| 445 | - Detect collisions | 801 | - Auto-dependency management for `.openassistant/` |
| 446 | - Export `createPluginRegistry(config): { toolTree: ToolTree, declarations: string }` | 802 | - Deduplicate by ID |
| 447 | 803 | ||
| 448 | ### Phase 2: SDK Package | 804 | 8. **Plugin loader** (`packages/core/src/plugins/loader.ts`) |
| 449 | 805 | - Dynamic `import()` for `.ts` files | |
| 450 | 5. **Create `packages/sdk/`** | 806 | - Resolve export pattern (function vs ToolTree vs individual tools) |
| 451 | - Barrel export of `defineTool`, types, `PluginContext`, `z`, `Effect` | 807 | - Config validation against manifest schema |
| 452 | - `package.json` with proper `exports` map | 808 | - Build merged ToolTree + declarations |
| 453 | - Will eventually be publishable to npm (remove `private: true`) | ||
| 454 | 809 | ||
| 455 | ### Phase 3: Wire Into Runtime | 810 | ### Phase 3: Wire Into Runtime |
| 456 | 811 | ||
| 457 | 6. **Update `apps/discord-bot/`** | 812 | 9. **Update `apps/discord-bot/`** — use plugin registry instead of hardcoded tools |
| 458 | - Replace hardcoded `createToolTree()` with `createPluginRegistry(config).toolTree` | 813 | 10. **Update `apps/gateway/`** — same |
| 459 | - Replace hardcoded `TOOL_DECLARATIONS` with generated declarations | ||
| 460 | - Config-driven tool loading | ||
| 461 | |||
| 462 | 7. **Update `apps/gateway/`** | ||
| 463 | - Same plugin registry integration | ||
| 464 | 814 | ||
| 465 | ### Phase 4: Developer Experience | 815 | ### Phase 4: DX Polish |
| 466 | 816 | ||
| 467 | 8. **`openassistant init` command** (scaffold `.openassistant/config.json`) | 817 | 11. **`openassistant init`** — scaffold `.openassistant/config.json` |
| 468 | 9. **`openassistant plugins create` command** (scaffold plugin directory) | 818 | 12. **`openassistant tools create <name>`** — scaffold a tool file |
| 469 | 10. **Documentation** for plugin authors | 819 | 13. **`openassistant plugins list`** — show discovered tools/plugins and their status |
| 470 | 820 | ||
| 471 | --- | 821 | --- |
| 472 | 822 | ||
| 473 | ## Key Decisions to Make | 823 | ## Key Decisions (Resolved) |
| 474 | 824 | ||
| 475 | ### 1. Module Loading Strategy | 825 | | Decision | Choice | Rationale | |
| 476 | 826 | |----------|--------|-----------| | |
| 477 | **Option A: jiti** (like OpenClaw) | 827 | | Tool authoring API | Plain async `run` function | Effect is a runtime internal. Tool authors shouldn't know it exists. | |
| 478 | - Pros: Battle-tested, works in Node and Bun, handles TypeScript natively. | 828 | | Tool policy | `approval: "auto" \| "required"` only | No `kind`/`risk` classification. Tool author sets approval default. Operators can override via config. | |
| 479 | - Cons: Another dependency, may have edge cases with Effect imports. | 829 | | Module loading | Bun native `import()` | We're Bun-only. Zero deps. Bun handles .ts natively. | |
| 480 | 830 | | Config format | JSONC | Comments are essential for config files. | | |
| 481 | **Option B: Bun native `import()`** | 831 | | Config location | `.openassistant/config.json` | Follows OpenCode's `.opencode/` pattern. | |
| 482 | - Pros: Zero deps, Bun handles TypeScript natively, fast. | 832 | | Plugin specifiers | Flat array (like OpenCode) | Simpler than `{ paths, npm }`. Auto-detect type from specifier. | |
| 483 | - Cons: Only works in Bun runtime, may need `file://` URL construction, plugin resolution is less configurable. | 833 | | Tool schemas | Required zod `args` on every tool | Enables type declaration gen, validation, and docs from one source. | |
| 484 | 834 | | Namespacing | Plugin ID from manifest or dirname | Plugin tools namespaced automatically. Standalone tools are flat. | | |
| 485 | **Option C: Bun `import()` with fallback to jiti for Node compat** | 835 | | Hot reload | No (MVP) | Bun `--hot` covers dev. True registry reload is Phase 2+. | |
| 486 | - Pros: Best of both. Fast path for Bun, fallback for portability. | 836 | | SDK package | Separate (`packages/sdk/`) | Clean boundary. No Effect dependency for tool authors. | |
| 487 | |||
| 488 | **Recommendation: Option B for MVP (Bun-only).** We're already Bun-native. Use dynamic `import()` with path resolution. Add jiti as a fallback later if Node support matters. | ||
| 489 | |||
| 490 | ### 2. Where Does Config Live? | ||
| 491 | |||
| 492 | **Recommendation:** | ||
| 493 | - Project-local: `.openassistant/config.json` (detected by walking up from cwd looking for `.openassistant/` or `package.json` with `"openassistant"` key). | ||
| 494 | - Global: `~/.config/openassistant/config.json`. | ||
| 495 | - Merge strategy: project-local overrides global (deep merge for `plugins.config`). | ||
| 496 | |||
| 497 | ### 3. Plugin ID Namespacing | ||
| 498 | |||
| 499 | **Option A:** Plugin tools are auto-namespaced: `tools.<pluginId>.<toolName>`. | ||
| 500 | **Option B:** Plugin chooses its own namespace via manifest `id`. | ||
| 501 | **Option C:** Flat merge, plugin responsible for unique paths. | ||
| 502 | |||
| 503 | **Recommendation: Option B.** The manifest `id` is the namespace. `tools.posthog.analytics.getVisitors`. The `id` is the top-level key in the merged tree. | ||
| 504 | |||
| 505 | ### 4. Schema on Tool Definitions | ||
| 506 | |||
| 507 | Currently `defineTool()` doesn't take an input schema -- the `run` function signature is the contract. For type declaration generation, we need schemas. | ||
| 508 | |||
| 509 | **Recommendation:** Add optional `input` schema (zod) to `defineTool()`. Not required for MVP (use exported `declarations` string as bridge), but strongly encouraged. | ||
| 510 | |||
| 511 | ```ts | ||
| 512 | defineTool({ | ||
| 513 | kind: "read", | ||
| 514 | approval: "auto", | ||
| 515 | input: z.object({ projectId: z.string() }), // NEW | ||
| 516 | run: (input) => Effect.tryPromise(() => ...), | ||
| 517 | }) | ||
| 518 | ``` | ||
| 519 | |||
| 520 | ### 5. Hot Reload | ||
| 521 | |||
| 522 | **MVP: No.** Restart to pick up plugin changes. Gateway already supports `--hot` via Bun, which covers dev workflow. True hot-reload of plugin registry is Phase 2+. | ||
| 523 | 837 | ||
| 524 | --- | 838 | --- |
| 525 | 839 | ||
| 526 | ## Security Considerations | 840 | ## Security Considerations |
| 527 | 841 | ||
| 528 | 1. **Plugins run in the same process.** No sandbox between plugins and the gateway. This is intentional for MVP -- the sandbox is between the LLM-generated code and the tool implementations, not between plugins. | 842 | 1. **Plugins run in the same process.** No sandbox between plugins and the gateway. The sandbox boundary is between LLM-generated code and tool implementations, not between plugins. |
| 529 | 843 | ||
| 530 | 2. **Config interpolation** only supports `${ENV_VAR}` syntax. No shell expansion, no nested interpolation, no code execution. | 844 | 2. **Config interpolation** only supports `${ENV_VAR}` syntax. No shell expansion, no nested interpolation, no code execution. |
| 531 | 845 | ||
| 532 | 3. **Plugin code is trusted.** If a user puts a plugin in their config, they trust it. We don't scan for malicious code (unlike OpenClaw's warning-only scan). | 846 | 3. **Plugin code is trusted.** If a user puts a plugin in their config, they trust it. No malicious code scanning. |
| 533 | 847 | ||
| 534 | 4. **Secrets in config** should use env var interpolation. We should warn (not block) if literal API keys appear in config files. | 848 | 4. **Secrets in config** should use env var interpolation. We log a warning (not block) if literal API keys appear in config files. |
| 535 | 849 | ||
| 536 | --- | 850 | --- |
| 537 | 851 | ||
| @@ -543,36 +857,37 @@ packages/ · 5 unmodified lines | |||
| 543 | src/ | 857 | src/ |
| 544 | index.ts | 858 | index.ts |
| 545 | codemode/ | 859 | codemode/ |
| 546 | runner.ts # Existing, unchanged | 860 | runner.ts # UPDATED: ToolDefinition uses Promise, adds zod validation |
| 861 | runner.test.ts | ||
| 547 | config/ | 862 | config/ |
| 548 | loader.ts # NEW: config file resolution + parsing | 863 | loader.ts # NEW: config file resolution + parsing |
| 549 | types.ts # NEW: config schema types | 864 | types.ts # NEW: config shape (Zod schema) |
| 550 | interpolation.ts # NEW: ${ENV_VAR} expansion | 865 | interpolation.ts # NEW: ${ENV_VAR} expansion |
| 551 | plugins/ | 866 | plugins/ |
| 552 | discovery.ts # NEW: scan origins for plugin candidates | 867 | discovery.ts # NEW: scan convention dirs + resolve specifiers |
| 553 | loader.ts # NEW: load plugin TypeScript modules | 868 | loader.ts # NEW: dynamic import + export resolution |
| 554 | manifest.ts # NEW: parse/validate openassistant.json | 869 | manifest.ts # NEW: parse/validate openassistant.json |
| 555 | registry.ts # NEW: merge ToolTrees, generate declarations | 870 | registry.ts # NEW: merge ToolTrees, deduplicate, enable/disable |
| 556 | types.ts # NEW: DiscoveredPlugin, LoadedPlugin, etc. | 871 | declarations.ts # NEW: generate TypeScript declarations from zod schemas |
| 872 | auto-install.ts # NEW: auto-create package.json + bun install | ||
| 873 | types.ts # NEW: DiscoveredPlugin, LoadedPlugin, etc. | ||
| 557 | sdk/ | 874 | sdk/ |
| 558 | package.json # NEW: @openassistant/sdk | 875 | package.json # NEW: @openassistant/sdk |
| 559 | src/ | 876 | src/ |
| 560 | index.ts # NEW: public API barrel | 877 | index.ts # NEW: exports defineTool, z, PluginContext |
| 561 | plugin-context.ts # NEW: PluginContext type | 878 | define-tool.ts # NEW: defineTool() — creates ToolDefinition, no Effect |
| 879 | plugin-context.ts # NEW: PluginContext type | ||
| 562 | ``` | 880 | ``` |
| 563 | 881 | ||
| 564 | --- | 882 | --- |
| 565 | 883 | ||
| 566 | ## Open Questions | 884 | ## Open Questions |
| 567 | 885 | ||
| 568 | 1. **Should the SDK be a separate package or an export path from core?** | 886 | 1. **Do we validate tool inputs at runtime?** |
| 569 | Leaning separate package (`packages/sdk/`) for clean dependency boundary. Plugin authors only need the SDK, not core internals. | 887 | Yes. Parse input through zod schema before calling `run`. This catches bad LLM-generated inputs early with clear error messages in receipts. |
| 570 | |||
| 571 | 2. **How do we handle plugin dependencies at install time?** | ||
| 572 | For workspace/path plugins: they manage their own deps. For npm plugins: deps come with the package. For global install: we may need `bun install` in the plugin dir (like OpenClaw does `npm install --omit=dev`). | ||
| 573 | 888 | ||
| 574 | 3. **Do we need a plugin lockfile?** | 889 | 2. **How do we handle tools that need persistent state?** |
| 575 | Probably not for MVP. Config file is declarative. `bun.lock` in the host project handles npm-sourced plugins. | 890 | `PluginContext.dataDir` gives plugins a persistent directory. Not needed for standalone tools in MVP. |
| 576 | 891 | ||
| 577 | 4. **Should plugins be able to register things other than tools?** | 892 | 3. **Should we support `openassistant.jsonc` (comments)?** |
| 578 | Not for MVP. But the `register(ctx)` function pattern leaves the door open for `ctx.registerHook()`, `ctx.registerCommand()`, etc. later. | 893 | Yes. Both `config.json` and `config.jsonc` should be accepted. |
| @@ -13,6 +13,8 @@ · 12 unmodified lines | |||
| 13 | }, | 13 | }, |
| 14 | "dependencies": { | 14 | "dependencies": { |
| 15 | "@ai-sdk/anthropic": "^2.0.0", | 15 | "@ai-sdk/anthropic": "^2.0.0", |
| 16 | "@effect/platform": "latest", | ||
| 17 | "@effect/rpc": "latest", | ||
| 16 | "@openassistant/core": "workspace:*", | 18 | "@openassistant/core": "workspace:*", |
| 17 | "@openassistant/reacord": "workspace:*", | 19 | "@openassistant/reacord": "workspace:*", |
| 18 | "ai": "^6.0.73", | 20 | "ai": "^6.0.73", |
| @@ -180,7 +180,7 @@ function buildAgentPrompt(userPrompt: string, now: Date): string { · 179 unmodified lines | |||
| 180 | "run_code expects JavaScript function-body code executed as new AsyncFunction('tools', code).", | 180 | "run_code expects JavaScript function-body code executed as new AsyncFunction('tools', code).", |
| 181 | "Inside code, call available tools directly like: await tools.calendar.update({ title, startsAt, notes }).", | 181 | "Inside code, call available tools directly like: await tools.calendar.update({ title, startsAt, notes }).", |
| 182 | "For multiple events, produce multiple tool calls in the same code block.", | 182 | "For multiple events, produce multiple tool calls in the same code block.", |
| 183 | "Never claim an action succeeded unless run_code returned ok=true and receipts show succeeded calls.", | 183 | "Never claim an action succeeded unless run_code returned ok=true.", |
| 184 | `Current timestamp: ${now.toISOString()}`, | 184 | `Current timestamp: ${now.toISOString()}`, |
| 185 | `User request: ${userPrompt}`, | 185 | `User request: ${userPrompt}`, |
| 186 | ].join("\n"); | 186 | ].join("\n"); |
| @@ -1,96 +1,23 @@ | |||
| 1 | import type { CodeModeRunResult } from "@openassistant/core"; | ||
| 2 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 3 | import { formatDiscordResponse } from "./format-response.js"; | 2 | import { formatDiscordResponse } from "./format-response.js"; |
| 4 | 3 | ||
| 5 | describe("formatDiscordResponse", () => { | 4 | describe("formatDiscordResponse", () => { |
| 6 | it("renders a polished verified calendar summary", () => { | 5 | it("returns trimmed text and footer", () => { |
| 7 | const output = formatDiscordResponse({ | 6 | const output = formatDiscordResponse({ |
| 8 | prompt: "Add 3 calendar events", | 7 | text: " Added dinner to your calendar. ", |
| 9 | text: "Added three events.", | 8 | footer: "planner details", |
| 10 | planner: "Claude tool-loop (3 code runs).", | ||
| 11 | provider: "claude", | ||
| 12 | runs: [ | ||
| 13 | { code: "run1", result: runResult("a", "succeeded", "Dinner @ 2026-02-07T17:00:00.000Z") }, | ||
| 14 | { code: "run2", result: runResult("b", "succeeded", "Lunch @ 2026-02-08T14:00:00.000Z") }, | ||
| 15 | { code: "run3", result: runResult("c", "succeeded", "Breakfast @ 2026-02-09T03:00:00.000Z") }, | ||
| 16 | ], | ||
| 17 | }); | 9 | }); |
| 18 | 10 | ||
| 19 | expect(output.message).toContain("Added 3 calendar events."); | 11 | expect(output.message).toBe("Added dinner to your calendar."); |
| 20 | expect(output.message).toContain("- Dinner -"); | 12 | expect(output.footer).toBe("planner details"); |
| 21 | expect(output.message).toContain("- Lunch -"); | ||
| 22 | expect(output.message).toContain("- Breakfast -"); | ||
| 23 | expect(output.footer).toBeDefined(); | ||
| 24 | expect(output.footer).toContain("Activity: 3 tool calls, 3 approved."); | ||
| 25 | }); | 13 | }); |
| 26 | 14 | ||
| 27 | it("shows concise recovery message when some attempts fail but success exists", () => { | 15 | it("falls back to Done when text is empty", () => { |
| 28 | const output = formatDiscordResponse({ | 16 | const output = formatDiscordResponse({ |
| 29 | prompt: "Delete event", | 17 | text: " ", |
| 30 | text: "Could not complete request.", | ||
| 31 | planner: "Claude tool-loop (2 code runs).", | ||
| 32 | provider: "claude", | ||
| 33 | runs: [ | ||
| 34 | { | ||
| 35 | code: "bad", | ||
| 36 | result: { | ||
| 37 | ok: false, | ||
| 38 | error: "Typecheck failed: Property 'create' does not exist", | ||
| 39 | receipts: [], | ||
| 40 | }, | ||
| 41 | }, | ||
| 42 | { code: "good", result: runResult("ok", "succeeded", "Dinner @ 2026-02-07T17:00:00.000Z") }, | ||
| 43 | ], | ||
| 44 | }); | 18 | }); |
| 45 | 19 | ||
| 46 | expect(output.message).toContain("Added 1 calendar event."); | 20 | expect(output.message).toBe("Done."); |
| 47 | expect(output.message).toContain("Recovered from 1 issue during execution."); | 21 | expect(output.footer).toBeUndefined(); |
| 48 | expect(output.message).not.toContain("code run failed"); | ||
| 49 | }); | ||
| 50 | |||
| 51 | it("shows actionable error message when nothing succeeded", () => { | ||
| 52 | const output = formatDiscordResponse({ | ||
| 53 | prompt: "Delete event", | ||
| 54 | text: "Could not complete request.", | ||
| 55 | planner: "Claude tool-loop (1 code run).", | ||
| 56 | provider: "claude", | ||
| 57 | runs: [ | ||
| 58 | { | ||
| 59 | code: "run", | ||
| 60 | result: { | ||
| 61 | ok: false, | ||
| 62 | error: "Typecheck failed: Property 'create' does not exist", | ||
| 63 | receipts: [], | ||
| 64 | }, | ||
| 65 | }, | ||
| 66 | ], | ||
| 67 | }); | ||
| 68 | |||
| 69 | expect(output.message).toContain("I couldn't complete that request."); | ||
| 70 | expect(output.message).toContain("I hit an issue:"); | ||
| 71 | expect(output.message).toContain("Typecheck failed"); | ||
| 72 | }); | 22 | }); |
| 73 | }); | 23 | }); |
| 74 | |||
| 75 | function runResult( | ||
| 76 | callId: string, | ||
| 77 | status: "succeeded" | "failed", | ||
| 78 | inputPreview: string, | ||
| 79 | ): CodeModeRunResult { | ||
| 80 | return { | ||
| 81 | ok: true, | ||
| 82 | value: { ok: true }, | ||
| 83 | receipts: [ | ||
| 84 | { | ||
| 85 | callId, | ||
| 86 | toolPath: "calendar.update", | ||
| 87 | kind: "write", | ||
| 88 | approval: "required", | ||
| 89 | decision: "approved", | ||
| 90 | status, | ||
| 91 | timestamp: "2026-02-06T01:00:00.000Z", | ||
| 92 | inputPreview, | ||
| 93 | }, | ||
| 94 | ], | ||
| 95 | }; | ||
| 96 | } | ||
| @@ -1,275 +1,20 @@ | |||
| 1 | import type { ToolCallReceipt } from "@openassistant/core"; | ||
| 2 | import type { AgentCodeRun } from "./agent-loop.js"; | ||
| 3 | |||
| 4 | export interface FormattedDiscordResponse { | 1 | export interface FormattedDiscordResponse { |
| 5 | message: string; | 2 | message: string; |
| 6 | footer?: string; | 3 | footer?: string; |
| 7 | } | 4 | } |
| 8 | 5 | ||
| 9 | export function formatDiscordResponse(params: { | 6 | export function formatDiscordResponse(params: { |
| 10 | prompt: string; | ||
| 11 | text: string; | 7 | text: string; |
| 12 | planner: string; | 8 | footer?: string | undefined; |
| 13 | provider: "claude"; | ||
| 14 | runs: AgentCodeRun[]; | ||
| 15 | }): FormattedDiscordResponse { | 9 | }): FormattedDiscordResponse { |
| 16 | const { text, planner, provider, runs } = params; | 10 | const message = params.text.trim().length > 0 ? params.text.trim() : "Done."; |
| 17 | const receipts = runs.flatMap((run) => run.result.receipts); | ||
| 18 | const stats = summarizeReceipts(receipts); | ||
| 19 | const failures = summarizeFailures(runs); | ||
| 20 | |||
| 21 | const messageSections: string[] = []; | ||
| 22 | messageSections.push(buildPrimaryReply(text, receipts, stats)); | ||
| 23 | |||
| 24 | const issueSummary = buildIssueSummary(stats, failures); | ||
| 25 | if (issueSummary) { | ||
| 26 | messageSections.push(issueSummary); | ||
| 27 | } | ||
| 28 | |||
| 29 | const footerParts: string[] = []; | ||
| 30 | const toolFooter = buildToolFooter(receipts, stats); | ||
| 31 | if (toolFooter) { | ||
| 32 | footerParts.push(toolFooter); | ||
| 33 | } | ||
| 34 | |||
| 35 | if (isVerboseMode()) { | ||
| 36 | footerParts.push(`debug: ${provider} - ${planner}`); | ||
| 37 | footerParts.push(`runs: ${runs.length}`); | ||
| 38 | footerParts.push(`tools: ${formatToolStats(stats.byTool)}`); | ||
| 39 | footerParts.push( | ||
| 40 | failures.length > 0 | ||
| 41 | ? `failures: ${failures.map((failure) => failure.message).join(" | ")}` | ||
| 42 | : "failures: none", | ||
| 43 | ); | ||
| 44 | } | ||
| 45 | 11 | ||
| 46 | return { | 12 | return { |
| 47 | message: truncateDiscord(messageSections.filter((value) => value.trim().length > 0).join("\n\n")), | 13 | message: truncateDiscord(message), |
| 48 | ...(footerParts.length > 0 ? { footer: truncateFooter(footerParts.join(" | ")) } : {}), | 14 | ...(params.footer ? { footer: truncateFooter(params.footer) } : {}), |
| 49 | }; | 15 | }; |
| 50 | } | 16 | } |
| 51 | 17 | ||
| 52 | type ToolStats = { | ||
| 53 | calls: number; | ||
| 54 | approved: number; | ||
| 55 | auto: number; | ||
| 56 | denied: number; | ||
| 57 | succeeded: number; | ||
| 58 | failed: number; | ||
| 59 | }; | ||
| 60 | |||
| 61 | type ReceiptSummary = { | ||
| 62 | totals: ToolStats; | ||
| 63 | byTool: Map<string, ToolStats>; | ||
| 64 | }; | ||
| 65 | |||
| 66 | type FailureEntry = { | ||
| 67 | message: string; | ||
| 68 | }; | ||
| 69 | |||
| 70 | function summarizeReceipts(receipts: ToolCallReceipt[]): ReceiptSummary { | ||
| 71 | const totals: ToolStats = { | ||
| 72 | calls: 0, | ||
| 73 | approved: 0, | ||
| 74 | auto: 0, | ||
| 75 | denied: 0, | ||
| 76 | succeeded: 0, | ||
| 77 | failed: 0, | ||
| 78 | }; | ||
| 79 | |||
| 80 | const byTool = new Map<string, ToolStats>(); | ||
| 81 | |||
| 82 | for (const receipt of receipts) { | ||
| 83 | totals.calls += 1; | ||
| 84 | if (receipt.decision === "approved") { | ||
| 85 | totals.approved += 1; | ||
| 86 | } else if (receipt.decision === "auto") { | ||
| 87 | totals.auto += 1; | ||
| 88 | } else { | ||
| 89 | totals.denied += 1; | ||
| 90 | } | ||
| 91 | |||
| 92 | if (receipt.status === "succeeded") { | ||
| 93 | totals.succeeded += 1; | ||
| 94 | } else if (receipt.status === "failed") { | ||
| 95 | totals.failed += 1; | ||
| 96 | } else { | ||
| 97 | totals.denied += 1; | ||
| 98 | } | ||
| 99 | |||
| 100 | const current = byTool.get(receipt.toolPath) ?? { | ||
| 101 | calls: 0, | ||
| 102 | approved: 0, | ||
| 103 | auto: 0, | ||
| 104 | denied: 0, | ||
| 105 | succeeded: 0, | ||
| 106 | failed: 0, | ||
| 107 | }; | ||
| 108 | |||
| 109 | current.calls += 1; | ||
| 110 | if (receipt.decision === "approved") { | ||
| 111 | current.approved += 1; | ||
| 112 | } else if (receipt.decision === "auto") { | ||
| 113 | current.auto += 1; | ||
| 114 | } else { | ||
| 115 | current.denied += 1; | ||
| 116 | } | ||
| 117 | |||
| 118 | if (receipt.status === "succeeded") { | ||
| 119 | current.succeeded += 1; | ||
| 120 | } else if (receipt.status === "failed") { | ||
| 121 | current.failed += 1; | ||
| 122 | } else { | ||
| 123 | current.denied += 1; | ||
| 124 | } | ||
| 125 | |||
| 126 | byTool.set(receipt.toolPath, current); | ||
| 127 | } | ||
| 128 | |||
| 129 | return { totals, byTool }; | ||
| 130 | } | ||
| 131 | |||
| 132 | function summarizeFailures(runs: AgentCodeRun[]): FailureEntry[] { | ||
| 133 | const failures: FailureEntry[] = []; | ||
| 134 | for (const run of runs) { | ||
| 135 | if (!run.result.ok) { | ||
| 136 | failures.push({ message: run.result.error }); | ||
| 137 | } | ||
| 138 | |||
| 139 | for (const receipt of run.result.receipts) { | ||
| 140 | if (receipt.status === "failed") { | ||
| 141 | failures.push({ | ||
| 142 | message: `${receipt.toolPath} failed${receipt.error ? `: ${receipt.error}` : ""}`, | ||
| 143 | }); | ||
| 144 | } | ||
| 145 | if (receipt.status === "denied") { | ||
| 146 | failures.push({ | ||
| 147 | message: `${receipt.toolPath} was denied`, | ||
| 148 | }); | ||
| 149 | } | ||
| 150 | } | ||
| 151 | } | ||
| 152 | return failures; | ||
| 153 | } | ||
| 154 | |||
| 155 | function buildPrimaryReply(text: string, receipts: ToolCallReceipt[], stats: ReceiptSummary): string { | ||
| 156 | const calendarUpdates = extractCalendarUpdates(receipts); | ||
| 157 | if (calendarUpdates.length > 0) { | ||
| 158 | const header = | ||
| 159 | calendarUpdates.length === 1 | ||
| 160 | ? "Added 1 calendar event." | ||
| 161 | : `Added ${calendarUpdates.length} calendar events.`; | ||
| 162 | const lines = [header, ...calendarUpdates.map((entry) => `- ${entry.title} - ${formatIsoUtc(entry.startsAt)}`)]; | ||
| 163 | return lines.join("\n"); | ||
| 164 | } | ||
| 165 | |||
| 166 | if (stats.totals.succeeded > 0) { | ||
| 167 | return text.trim() || "Completed."; | ||
| 168 | } | ||
| 169 | |||
| 170 | return "I couldn't complete that request."; | ||
| 171 | } | ||
| 172 | |||
| 173 | function buildToolFooter(receipts: ToolCallReceipt[], stats: ReceiptSummary): string | null { | ||
| 174 | if (receipts.length === 0) { | ||
| 175 | return null; | ||
| 176 | } | ||
| 177 | |||
| 178 | const parts: string[] = []; | ||
| 179 | parts.push(`${stats.totals.calls} tool call${stats.totals.calls === 1 ? "" : "s"}`); | ||
| 180 | if (stats.totals.approved > 0) { | ||
| 181 | parts.push(`${stats.totals.approved} approved`); | ||
| 182 | } | ||
| 183 | if (stats.totals.auto > 0) { | ||
| 184 | parts.push(`${stats.totals.auto} auto`); | ||
| 185 | } | ||
| 186 | if (stats.totals.failed > 0) { | ||
| 187 | parts.push(`${stats.totals.failed} failed`); | ||
| 188 | } | ||
| 189 | if (stats.totals.denied > 0) { | ||
| 190 | parts.push(`${stats.totals.denied} denied`); | ||
| 191 | } | ||
| 192 | |||
| 193 | return `Activity: ${parts.join(", ")}.`; | ||
| 194 | } | ||
| 195 | |||
| 196 | function buildIssueSummary(stats: ReceiptSummary, failures: FailureEntry[]): string | null { | ||
| 197 | if (failures.length === 0) { | ||
| 198 | return null; | ||
| 199 | } | ||
| 200 | |||
| 201 | if (stats.totals.succeeded > 0) { | ||
| 202 | return `Recovered from ${failures.length} issue${failures.length === 1 ? "" : "s"} during execution.`; | ||
| 203 | } | ||
| 204 | |||
| 205 | const top = failures.slice(0, 2).map((failure) => `- ${failure.message}`); | ||
| 206 | return ["I hit an issue:", ...top].join("\n"); | ||
| 207 | } | ||
| 208 | |||
| 209 | function extractCalendarUpdates(receipts: ToolCallReceipt[]): Array<{ title: string; startsAt: string }> { | ||
| 210 | const updates: Array<{ title: string; startsAt: string }> = []; | ||
| 211 | |||
| 212 | for (const receipt of receipts) { | ||
| 213 | if (receipt.toolPath !== "calendar.update" || receipt.status !== "succeeded") { | ||
| 214 | continue; | ||
| 215 | } | ||
| 216 | const preview = receipt.inputPreview; | ||
| 217 | if (!preview) { | ||
| 218 | continue; | ||
| 219 | } | ||
| 220 | const split = preview.lastIndexOf(" @ "); | ||
| 221 | if (split <= 0 || split >= preview.length - 3) { | ||
| 222 | continue; | ||
| 223 | } | ||
| 224 | |||
| 225 | const title = preview.slice(0, split).trim(); | ||
| 226 | const startsAt = preview.slice(split + 3).trim(); | ||
| 227 | if (!title || !startsAt) { | ||
| 228 | continue; | ||
| 229 | } | ||
| 230 | |||
| 231 | updates.push({ title, startsAt }); | ||
| 232 | } | ||
| 233 | |||
| 234 | return updates; | ||
| 235 | } | ||
| 236 | |||
| 237 | function formatIsoUtc(value: string): string { | ||
| 238 | const date = new Date(value); | ||
| 239 | if (Number.isNaN(date.getTime())) { | ||
| 240 | return value; | ||
| 241 | } | ||
| 242 | return new Intl.DateTimeFormat("en-US", { | ||
| 243 | timeZone: "UTC", | ||
| 244 | weekday: "short", | ||
| 245 | month: "short", | ||
| 246 | day: "numeric", | ||
| 247 | year: "numeric", | ||
| 248 | hour: "numeric", | ||
| 249 | minute: "2-digit", | ||
| 250 | hour12: true, | ||
| 251 | }).format(date) + " UTC"; | ||
| 252 | } | ||
| 253 | |||
| 254 | function formatToolStats(byTool: Map<string, ToolStats>): string { | ||
| 255 | if (byTool.size === 0) { | ||
| 256 | return "none"; | ||
| 257 | } | ||
| 258 | return Array.from(byTool.entries()) | ||
| 259 | .map(([tool, stat]) => `${tool}: ${stat.calls} calls, ${stat.succeeded} succeeded${stat.failed ? `, ${stat.failed} failed` : ""}${stat.denied ? `, ${stat.denied} denied` : ""}`) | ||
| 260 | .join("; "); | ||
| 261 | } | ||
| 262 | |||
| 263 | function isVerboseMode(): boolean { | ||
| 264 | const value = readEnv("OPENASSISTANT_VERBOSE_RESPONSE")?.trim().toLowerCase(); | ||
| 265 | return value === "1" || value === "true" || value === "yes"; | ||
| 266 | } | ||
| 267 | |||
| 268 | function readEnv(key: string): string | undefined { | ||
| 269 | const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun; | ||
| 270 | return bun?.env?.[key] ?? process.env[key]; | ||
| 271 | } | ||
| 272 | |||
| 273 | function truncateDiscord(value: string): string { | 18 | function truncateDiscord(value: string): string { |
| 274 | const limit = 1_900; | 19 | const limit = 1_900; |
| 275 | if (value.length <= limit) { | 20 | if (value.length <= limit) { |
| @@ -0,0 +1,35 @@ | |||
| 1 | import { FetchHttpClient } from "@effect/platform"; | ||
| 2 | import { RpcClient, RpcSerialization } from "@effect/rpc"; | ||
| 3 | import { AgentRpcs, type RunTurnOutput } from "@openassistant/core"; | ||
| 4 | import { Effect, Layer } from "effect"; | ||
| 5 | |||
| 6 | const gatewayUrl = readEnv("OPENASSISTANT_GATEWAY_URL") ?? "http://127.0.0.1:8787/rpc"; | ||
| 7 | |||
| 8 | const GatewayRpcClientLive = RpcClient.layerProtocolHttp({ | ||
| 9 | url: gatewayUrl, | ||
| 10 | }).pipe(Layer.provide([FetchHttpClient.layer, RpcSerialization.layerNdjson])); | ||
| 11 | |||
| 12 | export interface RunGatewayTurnInput { | ||
| 13 | prompt: string; | ||
| 14 | requesterId: string; | ||
| 15 | channelId: string; | ||
| 16 | } | ||
| 17 | |||
| 18 | export async function runGatewayTurn(input: RunGatewayTurnInput): Promise<RunTurnOutput> { | ||
| 19 | return Effect.runPromise( | ||
| 20 | Effect.gen(function* () { | ||
| 21 | const client = yield* RpcClient.make(AgentRpcs); | ||
| 22 | return yield* client.RunTurn({ | ||
| 23 | prompt: input.prompt, | ||
| 24 | requesterId: input.requesterId, | ||
| 25 | channelId: input.channelId, | ||
| 26 | nowIso: new Date().toISOString(), | ||
| 27 | }); | ||
| 28 | }).pipe(Effect.scoped, Effect.provide(GatewayRpcClientLive)), | ||
| 29 | ); | ||
| 30 | } | ||
| 31 | |||
| 32 | function readEnv(key: string): string | undefined { | ||
| 33 | const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun; | ||
| 34 | return bun?.env?.[key] ?? process.env[key]; | ||
| 35 | } | ||
| @@ -1,4 +1,3 @@ | |||
| 1 | import { createCodeModeRunner } from "@openassistant/core"; | ||
| 2 | import { makeReacord } from "@openassistant/reacord"; | 1 | import { makeReacord } from "@openassistant/reacord"; |
| 3 | import { | 2 | import { |
| 4 | ChannelType, | 3 | ChannelType, |
| @@ -10,20 +9,15 @@ import { · 5 unmodified lines | |||
| 10 | type SendableChannels, | 9 | type SendableChannels, |
| 11 | } from "discord.js"; | 10 | } from "discord.js"; |
| 12 | import { Effect } from "effect"; | 11 | import { Effect } from "effect"; |
| 13 | import { DiscordApprovalBridge } from "./approval-bridge.js"; | ||
| 14 | import { runAgentLoop } from "./agent-loop.js"; | ||
| 15 | import { InMemoryCalendarStore } from "./calendar-store.js"; | ||
| 16 | import { AssistantReplyView, AssistantWorkingView } from "./discord-views.js"; | 12 | import { AssistantReplyView, AssistantWorkingView } from "./discord-views.js"; |
| 17 | import { formatDiscordResponse } from "./format-response.js"; | 13 | import { formatDiscordResponse } from "./format-response.js"; |
| 18 | import { createToolTree } from "./tools.js"; | 14 | import { runGatewayTurn } from "./gateway-client.js"; |
| 19 | 15 | ||
| 20 | const token = Bun.env.DISCORD_BOT_TOKEN; | 16 | const token = Bun.env.DISCORD_BOT_TOKEN; |
| 21 | if (!token) { | 17 | if (!token) { |
| 22 | throw new Error("Missing DISCORD_BOT_TOKEN"); | 18 | throw new Error("Missing DISCORD_BOT_TOKEN"); |
| 23 | } | 19 | } |
| 24 | 20 | ||
| 25 | const approvalTimeoutMs = Number(Bun.env.OPENASSISTANT_APPROVAL_TIMEOUT_MS ?? 300_000); | ||
| 26 | |||
| 27 | const client = new Client({ | 21 | const client = new Client({ |
| 28 | intents: [ | 22 | intents: [ |
| 29 | GatewayIntentBits.Guilds, | 23 | GatewayIntentBits.Guilds, |
| @@ -34,10 +28,7 @@ const client = new Client({ · 4 unmodified lines | |||
| 34 | partials: [Partials.Channel], | 28 | partials: [Partials.Channel], |
| 35 | }); | 29 | }); |
| 36 | 30 | ||
| 37 | const approvalBridge = new DiscordApprovalBridge(client, approvalTimeoutMs); | ||
| 38 | const reacord = makeReacord(client); | 31 | const reacord = makeReacord(client); |
| 39 | const calendarStore = new InMemoryCalendarStore(); | ||
| 40 | const tools = createToolTree(calendarStore); | ||
| 41 | 32 | ||
| 42 | client.once(Events.ClientReady, (readyClient) => { | 33 | client.once(Events.ClientReady, (readyClient) => { |
| 43 | console.log(`[discord-bot] logged in as ${readyClient.user.tag}`); | 34 | console.log(`[discord-bot] logged in as ${readyClient.user.tag}`); |
| @@ -70,29 +61,15 @@ async function handleMessage(message: Message): Promise<void> { · 26 unmodified lines | |||
| 70 | ); | 61 | ); |
| 71 | 62 | ||
| 72 | try { | 63 | try { |
| 73 | const runner = createCodeModeRunner({ | 64 | const generated = await runGatewayTurn({ |
| 74 | tools, | 65 | prompt: message.content, |
| 75 | requestApproval: (request) => | 66 | requesterId: message.author.id, |
| 76 | Effect.tryPromise({ | 67 | channelId: message.channelId, |
| 77 | try: () => | ||
| 78 | approvalBridge.requestApproval({ | ||
| 79 | request, | ||
| 80 | channel: approvalChannel, | ||
| 81 | requesterId: message.author.id, | ||
| 82 | }), | ||
| 83 | catch: (error) => error, | ||
| 84 | }), | ||
| 85 | }); | 68 | }); |
| 86 | 69 | ||
| 87 | const generated = await runAgentLoop(message.content, (code) => | ||
| 88 | Effect.runPromise(runner.run({ code })), | ||
| 89 | ); | ||
| 90 | const response = formatDiscordResponse({ | 70 | const response = formatDiscordResponse({ |
| 91 | prompt: message.content, | 71 | text: generated.message, |
| 92 | text: generated.text, | 72 | footer: generated.footer, |
| 93 | planner: generated.planner, | ||
| 94 | provider: generated.provider, | ||
| 95 | runs: generated.runs, | ||
| 96 | }); | 73 | }); |
| 97 | 74 | ||
| 98 | instance.render(<AssistantReplyView message={response.message} footer={response.footer} />); | 75 | instance.render(<AssistantReplyView message={response.message} footer={response.footer} />); |
| @@ -15,7 +15,13 @@ · 14 unmodified lines | |||
| 15 | "clean": "bun -e \"import { rmSync } from 'node:fs'; for (const p of ['dist','node_modules','.turbo','coverage','.tsbuildinfo']) rmSync(p,{recursive:true,force:true});\"" | 15 | "clean": "bun -e \"import { rmSync } from 'node:fs'; for (const p of ['dist','node_modules','.turbo','coverage','.tsbuildinfo']) rmSync(p,{recursive:true,force:true});\"" |
| 16 | }, | 16 | }, |
| 17 | "dependencies": { | 17 | "dependencies": { |
| 18 | "@ai-sdk/anthropic": "^2.0.0", | ||
| 19 | "@effect/platform": "latest", | ||
| 20 | "@effect/platform-bun": "latest", | ||
| 21 | "@effect/rpc": "latest", | ||
| 18 | "@openassistant/core": "workspace:*", | 22 | "@openassistant/core": "workspace:*", |
| 19 | "effect": "latest" | 23 | "ai": "^6.0.73", |
| 24 | "effect": "latest", | ||
| 25 | "zod": "^4.3.6" | ||
| 20 | } | 26 | } |
| 21 | } | 27 | } |
| @@ -0,0 +1,199 @@ | |||
| 1 | import type { CodeModeRunResult, ToolCallReceipt } from "@openassistant/core"; | ||
| 2 | import { generateText, stepCountIs, tool } from "ai"; | ||
| 3 | import { getAnthropicModel } from "./anthropic-provider.js"; | ||
| 4 | import { typecheckCodeSnippet } from "./code-typecheck.js"; | ||
| 5 | import { z } from "zod"; | ||
| 6 | |||
| 7 | export interface AgentCodeRun { | ||
| 8 | code: string; | ||
| 9 | result: CodeModeRunResult; | ||
| 10 | reason?: string | undefined; | ||
| 11 | } | ||
| 12 | |||
| 13 | export interface AgentLoopResult { | ||
| 14 | provider: "claude"; | ||
| 15 | planner: string; | ||
| 16 | text: string; | ||
| 17 | runs: AgentCodeRun[]; | ||
| 18 | } | ||
| 19 | |||
| 20 | interface RunCodeToolInput { | ||
| 21 | code: string; | ||
| 22 | reason?: string | undefined; | ||
| 23 | } | ||
| 24 | |||
| 25 | interface RunCodeToolOutput { | ||
| 26 | ok: boolean; | ||
| 27 | receipts: ToolCallReceipt[]; | ||
| 28 | value?: unknown; | ||
| 29 | error?: string; | ||
| 30 | } | ||
| 31 | |||
| 32 | interface RunWithClaudeInput { | ||
| 33 | prompt: string; | ||
| 34 | now: Date; | ||
| 35 | executeCode: (input: RunCodeToolInput) => Promise<RunCodeToolOutput>; | ||
| 36 | } | ||
| 37 | |||
| 38 | interface RunWithClaudeOutput { | ||
| 39 | text: string; | ||
| 40 | modelID: string; | ||
| 41 | authSource: string; | ||
| 42 | } | ||
| 43 | |||
| 44 | interface AgentLoopOptions { | ||
| 45 | now?: Date; | ||
| 46 | runWithClaude?: (input: RunWithClaudeInput) => Promise<RunWithClaudeOutput>; | ||
| 47 | } | ||
| 48 | |||
| 49 | const DEFAULT_ANTHROPIC_MODEL = | ||
| 50 | readEnv("OPENASSISTANT_ANTHROPIC_MODEL")?.trim() ?? | ||
| 51 | readEnv("OPENASSISTANT_CLAUDE_MODEL")?.trim() ?? | ||
| 52 | "claude-opus-4-5-20251101"; | ||
| 53 | const CLAUDE_TIMEOUT_MS = Number(readEnv("OPENASSISTANT_CLAUDE_TIMEOUT_MS") ?? 60_000); | ||
| 54 | const CLAUDE_MAX_STEPS = Number(readEnv("OPENASSISTANT_AGENT_MAX_STEPS") ?? 8); | ||
| 55 | |||
| 56 | const RUN_CODE_TOOL_SCHEMA = z.object({ | ||
| 57 | code: z.string().min(1), | ||
| 58 | reason: z.string().min(1).optional(), | ||
| 59 | }); | ||
| 60 | |||
| 61 | export async function runAgentLoop( | ||
| 62 | prompt: string, | ||
| 63 | runCode: (code: string) => Promise<CodeModeRunResult>, | ||
| 64 | options: AgentLoopOptions = {}, | ||
| 65 | ): Promise<AgentLoopResult> { | ||
| 66 | const now = options.now ?? new Date(); | ||
| 67 | const runs: AgentCodeRun[] = []; | ||
| 68 | |||
| 69 | const executeCode = async (input: RunCodeToolInput): Promise<RunCodeToolOutput> => { | ||
| 70 | const typecheck = typecheckCodeSnippet(input.code); | ||
| 71 | if (!typecheck.ok) { | ||
| 72 | const failed: CodeModeRunResult = { | ||
| 73 | ok: false, | ||
| 74 | error: `Typecheck failed: ${typecheck.error}`, | ||
| 75 | receipts: [], | ||
| 76 | }; | ||
| 77 | runs.push({ | ||
| 78 | code: input.code, | ||
| 79 | result: failed, | ||
| 80 | ...(input.reason ? { reason: input.reason } : {}), | ||
| 81 | }); | ||
| 82 | return { | ||
| 83 | ok: false, | ||
| 84 | error: failed.error, | ||
| 85 | receipts: [], | ||
| 86 | }; | ||
| 87 | } | ||
| 88 | |||
| 89 | const result = await runCode(input.code); | ||
| 90 | runs.push({ | ||
| 91 | code: input.code, | ||
| 92 | result, | ||
| 93 | ...(input.reason ? { reason: input.reason } : {}), | ||
| 94 | }); | ||
| 95 | |||
| 96 | if (result.ok) { | ||
| 97 | return { | ||
| 98 | ok: true, | ||
| 99 | value: result.value, | ||
| 100 | receipts: result.receipts, | ||
| 101 | }; | ||
| 102 | } | ||
| 103 | |||
| 104 | return { | ||
| 105 | ok: false, | ||
| 106 | error: result.error, | ||
| 107 | receipts: result.receipts, | ||
| 108 | }; | ||
| 109 | }; | ||
| 110 | |||
| 111 | try { | ||
| 112 | const generated = await (options.runWithClaude ?? runWithClaude)({ | ||
| 113 | prompt, | ||
| 114 | now, | ||
| 115 | executeCode, | ||
| 116 | }); | ||
| 117 | |||
| 118 | return { | ||
| 119 | provider: "claude", | ||
| 120 | planner: `Claude tool-loop (${runs.length} code run${runs.length === 1 ? "" : "s"}, model=${generated.modelID}, auth=${generated.authSource}).`, | ||
| 121 | text: generated.text, | ||
| 122 | runs, | ||
| 123 | }; | ||
| 124 | } catch (error) { | ||
| 125 | return { | ||
| 126 | provider: "claude", | ||
| 127 | planner: `Claude tool-loop failed before completion (model=${DEFAULT_ANTHROPIC_MODEL}).`, | ||
| 128 | text: `Agent loop failed: ${describeUnknown(error)}. No actions were executed.`, | ||
| 129 | runs, | ||
| 130 | }; | ||
| 131 | } | ||
| 132 | } | ||
| 133 | |||
| 134 | async function runWithClaude(input: RunWithClaudeInput): Promise<RunWithClaudeOutput> { | ||
| 135 | const abortController = new AbortController(); | ||
| 136 | const timeout = setTimeout(() => abortController.abort(), CLAUDE_TIMEOUT_MS); | ||
| 137 | |||
| 138 | try { | ||
| 139 | const { model, authSource, authMode } = await getAnthropicModel(DEFAULT_ANTHROPIC_MODEL); | ||
| 140 | const result = await generateText({ | ||
| 141 | model, | ||
| 142 | temperature: 0, | ||
| 143 | stopWhen: stepCountIs(CLAUDE_MAX_STEPS), | ||
| 144 | prompt: buildAgentPrompt(input.prompt, input.now), | ||
| 145 | system: buildSystemPrompt(authMode), | ||
| 146 | tools: { | ||
| 147 | run_code: tool({ | ||
| 148 | description: | ||
| 149 | "Execute Bun TypeScript function body in codemode runtime. Use this for every action that requires tools.* calls.", | ||
| 150 | inputSchema: RUN_CODE_TOOL_SCHEMA, | ||
| 151 | execute: (args) => input.executeCode(args), | ||
| 152 | }), | ||
| 153 | }, | ||
| 154 | abortSignal: abortController.signal, | ||
| 155 | }); | ||
| 156 | |||
| 157 | return { | ||
| 158 | text: result.text.trim() || "Done.", | ||
| 159 | modelID: DEFAULT_ANTHROPIC_MODEL, | ||
| 160 | authSource, | ||
| 161 | }; | ||
| 162 | } finally { | ||
| 163 | clearTimeout(timeout); | ||
| 164 | } | ||
| 165 | } | ||
| 166 | |||
| 167 | function buildSystemPrompt(authMode: "api" | "oauth"): string { | ||
| 168 | if (authMode === "oauth") { | ||
| 169 | return "You are Claude Code, Anthropic's official CLI for Claude."; | ||
| 170 | } | ||
| 171 | |||
| 172 | return [ | ||
| 173 | "You are OpenAssistant. Execute work via the run_code tool and then report what happened.", | ||
| 174 | "In your final response, summarize relevant tool activity and mention failures/denials clearly.", | ||
| 175 | ].join("\n"); | ||
| 176 | } | ||
| 177 | |||
| 178 | function buildAgentPrompt(userPrompt: string, now: Date): string { | ||
| 179 | return [ | ||
| 180 | "run_code expects JavaScript function-body code executed as new AsyncFunction('tools', code).", | ||
| 181 | "Inside code, call available tools directly like: await tools.calendar.update({ title, startsAt, notes }).", | ||
| 182 | "For multiple events, produce multiple tool calls in the same code block.", | ||
| 183 | "Never claim an action succeeded unless run_code returned ok=true.", | ||
| 184 | `Current timestamp: ${now.toISOString()}`, | ||
| 185 | `User request: ${userPrompt}`, | ||
| 186 | ].join("\n"); | ||
| 187 | } | ||
| 188 | |||
| 189 | function describeUnknown(error: unknown): string { | ||
| 190 | if (error instanceof Error) { | ||
| 191 | return error.message; | ||
| 192 | } | ||
| 193 | return String(error); | ||
| 194 | } | ||
| 195 | |||
| 196 | function readEnv(key: string): string | undefined { | ||
| 197 | const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun; | ||
| 198 | return bun?.env?.[key] ?? process.env[key]; | ||
| 199 | } | ||
| @@ -0,0 +1,133 @@ | |||
| 1 | import { homedir } from "node:os"; | ||
| 2 | import { join } from "node:path"; | ||
| 3 | |||
| 4 | const DEFAULT_ANTHROPIC_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; | ||
| 5 | |||
| 6 | export type ResolvedAnthropicAuth = | ||
| 7 | | { | ||
| 8 | mode: "api"; | ||
| 9 | apiKey: string; | ||
| 10 | source: string; | ||
| 11 | } | ||
| 12 | | { | ||
| 13 | mode: "oauth"; | ||
| 14 | accessToken: string; | ||
| 15 | refreshToken: string | undefined; | ||
| 16 | expiresAt: number | undefined; | ||
| 17 | clientId: string; | ||
| 18 | source: string; | ||
| 19 | }; | ||
| 20 | |||
| 21 | export async function resolveAnthropicAuth(): Promise<ResolvedAnthropicAuth | null> { | ||
| 22 | const explicitApiKey = env("OPENASSISTANT_ANTHROPIC_API_KEY") ?? env("ANTHROPIC_API_KEY"); | ||
| 23 | if (explicitApiKey) { | ||
| 24 | return { | ||
| 25 | mode: "api", | ||
| 26 | apiKey: explicitApiKey, | ||
| 27 | source: env("OPENASSISTANT_ANTHROPIC_API_KEY") ? "env:OPENASSISTANT_ANTHROPIC_API_KEY" : "env:ANTHROPIC_API_KEY", | ||
| 28 | }; | ||
| 29 | } | ||
| 30 | |||
| 31 | const explicitAccess = env("OPENASSISTANT_ANTHROPIC_ACCESS_TOKEN"); | ||
| 32 | if (explicitAccess) { | ||
| 33 | const refreshToken = env("OPENASSISTANT_ANTHROPIC_REFRESH_TOKEN"); | ||
| 34 | const expiresAt = parseMaybeNumber(env("OPENASSISTANT_ANTHROPIC_EXPIRES_AT")); | ||
| 35 | return { | ||
| 36 | mode: "oauth", | ||
| 37 | accessToken: explicitAccess, | ||
| 38 | refreshToken, | ||
| 39 | expiresAt, | ||
| 40 | clientId: env("OPENASSISTANT_ANTHROPIC_CLIENT_ID") ?? DEFAULT_ANTHROPIC_OAUTH_CLIENT_ID, | ||
| 41 | source: "env:OPENASSISTANT_ANTHROPIC_ACCESS_TOKEN", | ||
| 42 | }; | ||
| 43 | } | ||
| 44 | |||
| 45 | const opencodeAuth = await loadOpencodeAnthropicAuth(); | ||
| 46 | if (opencodeAuth) { | ||
| 47 | return opencodeAuth; | ||
| 48 | } | ||
| 49 | |||
| 50 | return null; | ||
| 51 | } | ||
| 52 | |||
| 53 | async function loadOpencodeAnthropicAuth(): Promise<ResolvedAnthropicAuth | null> { | ||
| 54 | const filepath = join(opencodeDataDir(), "auth.json"); | ||
| 55 | const file = Bun.file(filepath); | ||
| 56 | const exists = await file.exists(); | ||
| 57 | if (!exists) { | ||
| 58 | return null; | ||
| 59 | } | ||
| 60 | |||
| 61 | const raw = (await file.json().catch(() => null)) as Record<string, unknown> | null; | ||
| 62 | if (!raw || typeof raw !== "object") { | ||
| 63 | return null; | ||
| 64 | } | ||
| 65 | |||
| 66 | const anthropic = raw["anthropic"]; | ||
| 67 | if (!anthropic || typeof anthropic !== "object") { | ||
| 68 | return null; | ||
| 69 | } | ||
| 70 | |||
| 71 | const record = anthropic as Record<string, unknown>; | ||
| 72 | const type = asString(record["type"]); | ||
| 73 | if (type === "api") { | ||
| 74 | const key = asString(record["key"]); | ||
| 75 | if (!key) { | ||
| 76 | return null; | ||
| 77 | } | ||
| 78 | return { | ||
| 79 | mode: "api", | ||
| 80 | apiKey: key, | ||
| 81 | source: "opencode:auth.json(api)", | ||
| 82 | }; | ||
| 83 | } | ||
| 84 | |||
| 85 | if (type === "oauth") { | ||
| 86 | const accessToken = asString(record["access"]); | ||
| 87 | if (!accessToken) { | ||
| 88 | return null; | ||
| 89 | } | ||
| 90 | const refreshToken = asString(record["refresh"]); | ||
| 91 | const expiresAt = asNumber(record["expires"]); | ||
| 92 | |||
| 93 | return { | ||
| 94 | mode: "oauth", | ||
| 95 | accessToken, | ||
| 96 | refreshToken, | ||
| 97 | expiresAt, | ||
| 98 | clientId: env("OPENASSISTANT_ANTHROPIC_CLIENT_ID") ?? DEFAULT_ANTHROPIC_OAUTH_CLIENT_ID, | ||
| 99 | source: "opencode:auth.json(oauth)", | ||
| 100 | }; | ||
| 101 | } | ||
| 102 | |||
| 103 | return null; | ||
| 104 | } | ||
| 105 | |||
| 106 | function opencodeDataDir(): string { | ||
| 107 | const xdg = env("XDG_DATA_HOME"); | ||
| 108 | if (xdg) { | ||
| 109 | return join(xdg, "opencode"); | ||
| 110 | } | ||
| 111 | return join(homedir(), ".local", "share", "opencode"); | ||
| 112 | } | ||
| 113 | |||
| 114 | function asString(value: unknown): string | undefined { | ||
| 115 | return typeof value === "string" && value.length > 0 ? value : undefined; | ||
| 116 | } | ||
| 117 | |||
| 118 | function asNumber(value: unknown): number | undefined { | ||
| 119 | return typeof value === "number" && Number.isFinite(value) ? value : undefined; | ||
| 120 | } | ||
| 121 | |||
| 122 | function parseMaybeNumber(value: string | undefined): number | undefined { | ||
| 123 | if (!value) { | ||
| 124 | return undefined; | ||
| 125 | } | ||
| 126 | const parsed = Number(value); | ||
| 127 | return Number.isFinite(parsed) ? parsed : undefined; | ||
| 128 | } | ||
| 129 | |||
| 130 | function env(key: string): string | undefined { | ||
| 131 | const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun; | ||
| 132 | return bun?.env?.[key] ?? process.env[key]; | ||
| 133 | } | ||
| @@ -0,0 +1,284 @@ | |||
| 1 | import { createAnthropic } from "@ai-sdk/anthropic"; | ||
| 2 | import type { LanguageModel } from "ai"; | ||
| 3 | import { resolveAnthropicAuth, type ResolvedAnthropicAuth } from "./anthropic-auth.js"; | ||
| 4 | |||
| 5 | const OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"; | ||
| 6 | const REQUIRED_OAUTH_BETAS = ["oauth-2025-04-20", "interleaved-thinking-2025-05-14"]; | ||
| 7 | const CLAUDE_CLI_USER_AGENT = "claude-cli/2.1.2 (external, cli)"; | ||
| 8 | const TOOL_PREFIX = "mcp_"; | ||
| 9 | |||
| 10 | let cachedAuthPromise: Promise<ResolvedAnthropicAuth | null> | undefined; | ||
| 11 | let refreshInFlight: Promise<void> | undefined; | ||
| 12 | |||
| 13 | export interface AnthropicModelSelection { | ||
| 14 | model: LanguageModel; | ||
| 15 | authSource: string; | ||
| 16 | authMode: "api" | "oauth"; | ||
| 17 | } | ||
| 18 | |||
| 19 | export async function getAnthropicModel(modelID: string): Promise<AnthropicModelSelection> { | ||
| 20 | const auth = await getResolvedAuth(); | ||
| 21 | if (!auth) { | ||
| 22 | throw new Error( | ||
| 23 | "No Anthropic credentials found. Set OPENASSISTANT_ANTHROPIC_API_KEY or OPENASSISTANT_ANTHROPIC_ACCESS_TOKEN (or configure opencode auth).", | ||
| 24 | ); | ||
| 25 | } | ||
| 26 | |||
| 27 | if (auth.mode === "api") { | ||
| 28 | const anthropic = createAnthropic({ | ||
| 29 | apiKey: auth.apiKey, | ||
| 30 | }); | ||
| 31 | return { | ||
| 32 | model: anthropic(modelID), | ||
| 33 | authSource: auth.source, | ||
| 34 | authMode: "api", | ||
| 35 | }; | ||
| 36 | } | ||
| 37 | |||
| 38 | const oauthFetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { | ||
| 39 | await ensureFreshToken(auth); | ||
| 40 | |||
| 41 | const headers = mergeHeaders(input, init?.headers); | ||
| 42 | headers.set("authorization", `Bearer ${auth.accessToken}`); | ||
| 43 | headers.set("anthropic-beta", mergeBetas(headers.get("anthropic-beta"))); | ||
| 44 | headers.set("user-agent", CLAUDE_CLI_USER_AGENT); | ||
| 45 | headers.delete("x-api-key"); | ||
| 46 | |||
| 47 | const transformedBody = transformRequestBody(init?.body); | ||
| 48 | const withBetaURL = ensureBetaMessagesURL(input); | ||
| 49 | |||
| 50 | const response = await fetch(withBetaURL, { | ||
| 51 | ...init, | ||
| 52 | headers, | ||
| 53 | ...(transformedBody !== undefined ? { body: transformedBody } : {}), | ||
| 54 | }); | ||
| 55 | |||
| 56 | return transformResponseBody(response); | ||
| 57 | }) as typeof fetch; | ||
| 58 | |||
| 59 | const anthropic = createAnthropic({ | ||
| 60 | apiKey: "opencode-oauth-dummy-key", | ||
| 61 | fetch: oauthFetch, | ||
| 62 | }); | ||
| 63 | |||
| 64 | return { | ||
| 65 | model: anthropic(modelID), | ||
| 66 | authSource: auth.source, | ||
| 67 | authMode: "oauth", | ||
| 68 | }; | ||
| 69 | } | ||
| 70 | |||
| 71 | async function getResolvedAuth(): Promise<ResolvedAnthropicAuth | null> { | ||
| 72 | if (!cachedAuthPromise) { | ||
| 73 | cachedAuthPromise = resolveAnthropicAuth(); | ||
| 74 | } | ||
| 75 | return cachedAuthPromise; | ||
| 76 | } | ||
| 77 | |||
| 78 | async function ensureFreshToken(auth: Extract<ResolvedAnthropicAuth, { mode: "oauth" }>): Promise<void> { | ||
| 79 | const now = Date.now(); | ||
| 80 | const expiresAt = auth.expiresAt; | ||
| 81 | const mustRefresh = !auth.accessToken || (typeof expiresAt === "number" && now + 30_000 >= expiresAt); | ||
| 82 | if (!mustRefresh) { | ||
| 83 | return; | ||
| 84 | } | ||
| 85 | |||
| 86 | if (!auth.refreshToken) { | ||
| 87 | throw new Error("Anthropic OAuth access token expired and no refresh token is available."); | ||
| 88 | } | ||
| 89 | |||
| 90 | if (!refreshInFlight) { | ||
| 91 | refreshInFlight = refreshAccessToken(auth).finally(() => { | ||
| 92 | refreshInFlight = undefined; | ||
| 93 | }); | ||
| 94 | } | ||
| 95 | |||
| 96 | await refreshInFlight; | ||
| 97 | } | ||
| 98 | |||
| 99 | async function refreshAccessToken(auth: Extract<ResolvedAnthropicAuth, { mode: "oauth" }>): Promise<void> { | ||
| 100 | const response = await fetch(OAUTH_TOKEN_URL, { | ||
| 101 | method: "POST", | ||
| 102 | headers: { | ||
| 103 | "Content-Type": "application/json", | ||
| 104 | }, | ||
| 105 | body: JSON.stringify({ | ||
| 106 | grant_type: "refresh_token", | ||
| 107 | refresh_token: auth.refreshToken, | ||
| 108 | client_id: auth.clientId, | ||
| 109 | }), | ||
| 110 | }); | ||
| 111 | |||
| 112 | if (!response.ok) { | ||
| 113 | throw new Error(`Anthropic OAuth token refresh failed (${response.status}).`); | ||
| 114 | } | ||
| 115 | |||
| 116 | const json = (await response.json()) as { | ||
| 117 | access_token?: unknown; | ||
| 118 | refresh_token?: unknown; | ||
| 119 | expires_in?: unknown; | ||
| 120 | }; | ||
| 121 | |||
| 122 | if (typeof json.access_token !== "string" || json.access_token.length === 0) { | ||
| 123 | throw new Error("Anthropic OAuth token refresh returned no access token."); | ||
| 124 | } | ||
| 125 | |||
| 126 | auth.accessToken = json.access_token; | ||
| 127 | if (typeof json.refresh_token === "string" && json.refresh_token.length > 0) { | ||
| 128 | auth.refreshToken = json.refresh_token; | ||
| 129 | } | ||
| 130 | if (typeof json.expires_in === "number" && Number.isFinite(json.expires_in)) { | ||
| 131 | auth.expiresAt = Date.now() + json.expires_in * 1_000; | ||
| 132 | } | ||
| 133 | } | ||
| 134 | |||
| 135 | function mergeBetas(existing: string | null): string { | ||
| 136 | const current = (existing ?? "") | ||
| 137 | .split(",") | ||
| 138 | .map((item) => item.trim()) | ||
| 139 | .filter((item) => item.length > 0); | ||
| 140 | |||
| 141 | return [...new Set([...REQUIRED_OAUTH_BETAS, ...current])].join(","); | ||
| 142 | } | ||
| 143 | |||
| 144 | function mergeHeaders(input: RequestInfo | URL, initHeaders: HeadersInit | undefined): Headers { | ||
| 145 | const headers = new Headers(); | ||
| 146 | |||
| 147 | if (input instanceof Request) { | ||
| 148 | input.headers.forEach((value, key) => { | ||
| 149 | headers.set(key, value); | ||
| 150 | }); | ||
| 151 | } | ||
| 152 | |||
| 153 | if (initHeaders) { | ||
| 154 | const candidate = new Headers(initHeaders); | ||
| 155 | candidate.forEach((value, key) => { | ||
| 156 | headers.set(key, value); | ||
| 157 | }); | ||
| 158 | } | ||
| 159 | |||
| 160 | return headers; | ||
| 161 | } | ||
| 162 | |||
| 163 | function transformRequestBody(body: RequestInit["body"]): RequestInit["body"] { | ||
| 164 | if (typeof body !== "string") { | ||
| 165 | return body; | ||
| 166 | } | ||
| 167 | |||
| 168 | try { | ||
| 169 | const parsed = JSON.parse(body) as Record<string, unknown>; | ||
| 170 | const tools = parsed["tools"]; | ||
| 171 | if (Array.isArray(tools)) { | ||
| 172 | parsed["tools"] = tools.map((tool) => { | ||
| 173 | if (!tool || typeof tool !== "object") { | ||
| 174 | return tool; | ||
| 175 | } | ||
| 176 | const candidate = tool as Record<string, unknown>; | ||
| 177 | const name = candidate["name"]; | ||
| 178 | if (typeof name === "string" && name.length > 0 && !name.startsWith(TOOL_PREFIX)) { | ||
| 179 | return { | ||
| 180 | ...candidate, | ||
| 181 | name: `${TOOL_PREFIX}${name}`, | ||
| 182 | }; | ||
| 183 | } | ||
| 184 | return tool; | ||
| 185 | }); | ||
| 186 | } | ||
| 187 | |||
| 188 | const messages = parsed["messages"]; | ||
| 189 | if (Array.isArray(messages)) { | ||
| 190 | parsed["messages"] = messages.map((message) => { | ||
| 191 | if (!message || typeof message !== "object") { | ||
| 192 | return message; | ||
| 193 | } | ||
| 194 | const candidate = message as Record<string, unknown>; | ||
| 195 | const content = candidate["content"]; | ||
| 196 | if (!Array.isArray(content)) { | ||
| 197 | return message; | ||
| 198 | } | ||
| 199 | return { | ||
| 200 | ...candidate, | ||
| 201 | content: content.map((block) => { | ||
| 202 | if (!block || typeof block !== "object") { | ||
| 203 | return block; | ||
| 204 | } | ||
| 205 | const value = block as Record<string, unknown>; | ||
| 206 | if (value["type"] !== "tool_use") { | ||
| 207 | return block; | ||
| 208 | } | ||
| 209 | const name = value["name"]; | ||
| 210 | if (typeof name !== "string" || name.length === 0 || name.startsWith(TOOL_PREFIX)) { | ||
| 211 | return block; | ||
| 212 | } | ||
| 213 | return { | ||
| 214 | ...value, | ||
| 215 | name: `${TOOL_PREFIX}${name}`, | ||
| 216 | }; | ||
| 217 | }), | ||
| 218 | }; | ||
| 219 | }); | ||
| 220 | } | ||
| 221 | |||
| 222 | return JSON.stringify(parsed); | ||
| 223 | } catch { | ||
| 224 | return body; | ||
| 225 | } | ||
| 226 | } | ||
| 227 | |||
| 228 | function ensureBetaMessagesURL(input: RequestInfo | URL): RequestInfo | URL { | ||
| 229 | let inputIsRequest = false; | ||
| 230 | let parsed: URL | null = null; | ||
| 231 | try { | ||
| 232 | if (input instanceof Request) { | ||
| 233 | inputIsRequest = true; | ||
| 234 | parsed = new URL(input.url); | ||
| 235 | } else if (input instanceof URL) { | ||
| 236 | parsed = new URL(input.toString()); | ||
| 237 | } else if (typeof input === "string") { | ||
| 238 | parsed = new URL(input); | ||
| 239 | } | ||
| 240 | } catch { | ||
| 241 | parsed = null; | ||
| 242 | } | ||
| 243 | |||
| 244 | if (!parsed || parsed.pathname !== "/v1/messages" || parsed.searchParams.has("beta")) { | ||
| 245 | return input; | ||
| 246 | } | ||
| 247 | |||
| 248 | parsed.searchParams.set("beta", "true"); | ||
| 249 | if (inputIsRequest && input instanceof Request) { | ||
| 250 | return new Request(parsed.toString(), input); | ||
| 251 | } | ||
| 252 | return parsed; | ||
| 253 | } | ||
| 254 | |||
| 255 | async function transformResponseBody(response: Response): Promise<Response> { | ||
| 256 | if (!response.body) { | ||
| 257 | return response; | ||
| 258 | } | ||
| 259 | |||
| 260 | const reader = response.body.getReader(); | ||
| 261 | const decoder = new TextDecoder(); | ||
| 262 | const encoder = new TextEncoder(); | ||
| 263 | |||
| 264 | const stream = new ReadableStream({ | ||
| 265 | async pull(controller) { | ||
| 266 | const { done, value } = await reader.read(); | ||
| 267 | if (done) { | ||
| 268 | controller.close(); | ||
| 269 | return; | ||
| 270 | } | ||
| 271 | |||
| 272 | const text = decoder | ||
| 273 | .decode(value, { stream: true }) | ||
| 274 | .replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name": "$1"'); | ||
| 275 | controller.enqueue(encoder.encode(text)); | ||
| 276 | }, | ||
| 277 | }); | ||
| 278 | |||
| 279 | return new Response(stream, { | ||
| 280 | status: response.status, | ||
| 281 | statusText: response.statusText, | ||
| 282 | headers: response.headers, | ||
| 283 | }); | ||
| 284 | } | ||
| @@ -0,0 +1,32 @@ | |||
| 1 | export interface CalendarEvent { | ||
| 2 | id: string; | ||
| 3 | title: string; | ||
| 4 | startsAt: string; | ||
| 5 | notes?: string; | ||
| 6 | } | ||
| 7 | |||
| 8 | export interface CalendarUpdateInput { | ||
| 9 | title: string; | ||
| 10 | startsAt: string; | ||
| 11 | notes?: string; | ||
| 12 | } | ||
| 13 | |||
| 14 | export class InMemoryCalendarStore { | ||
| 15 | private readonly events = new Map<string, CalendarEvent>(); | ||
| 16 | |||
| 17 | update(input: CalendarUpdateInput): CalendarEvent { | ||
| 18 | const id = `evt_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; | ||
| 19 | const event: CalendarEvent = { | ||
| 20 | id, | ||
| 21 | title: input.title, | ||
| 22 | startsAt: input.startsAt, | ||
| 23 | ...(input.notes ? { notes: input.notes } : {}), | ||
| 24 | }; | ||
| 25 | this.events.set(id, event); | ||
| 26 | return event; | ||
| 27 | } | ||
| 28 | |||
| 29 | list(): CalendarEvent[] { | ||
| 30 | return [...this.events.values()]; | ||
| 31 | } | ||
| 32 | } | ||
| @@ -0,0 +1,87 @@ | |||
| 1 | import ts from "typescript"; | ||
| 2 | |||
| 3 | const VIRTUAL_FILE = "/__openassistant__/codemode-check.ts"; | ||
| 4 | |||
| 5 | const TOOL_DECLARATIONS = [ | ||
| 6 | "type CalendarUpdateInput = { title: string; startsAt: string; notes?: string };", | ||
| 7 | "declare const tools: {", | ||
| 8 | " calendar: {", | ||
| 9 | " update(input: CalendarUpdateInput): Promise<unknown>;", | ||
| 10 | " list(): Promise<unknown>;", | ||
| 11 | " };", | ||
| 12 | "};", | ||
| 13 | ].join("\n"); | ||
| 14 | |||
| 15 | export type CodeTypecheckResult = | ||
| 16 | | { ok: true } | ||
| 17 | | { ok: false; error: string }; | ||
| 18 | |||
| 19 | export function typecheckCodeSnippet(code: string): CodeTypecheckResult { | ||
| 20 | const source = [ | ||
| 21 | TOOL_DECLARATIONS, | ||
| 22 | "async function __openassistant_check() {", | ||
| 23 | code, | ||
| 24 | "}", | ||
| 25 | ].join("\n\n"); | ||
| 26 | |||
| 27 | const compilerOptions: ts.CompilerOptions = { | ||
| 28 | target: ts.ScriptTarget.ES2022, | ||
| 29 | module: ts.ModuleKind.ESNext, | ||
| 30 | moduleResolution: ts.ModuleResolutionKind.Bundler, | ||
| 31 | strict: true, | ||
| 32 | noEmit: true, | ||
| 33 | skipLibCheck: true, | ||
| 34 | }; | ||
| 35 | |||
| 36 | const host = ts.createCompilerHost(compilerOptions, true); | ||
| 37 | const originalGetSourceFile = host.getSourceFile.bind(host); | ||
| 38 | const originalReadFile = host.readFile.bind(host); | ||
| 39 | const originalFileExists = host.fileExists.bind(host); | ||
| 40 | |||
| 41 | host.getSourceFile = (fileName, languageVersion, onError, shouldCreateNewSourceFile) => { | ||
| 42 | if (fileName === VIRTUAL_FILE) { | ||
| 43 | return ts.createSourceFile(fileName, source, languageVersion, true); | ||
| 44 | } | ||
| 45 | return originalGetSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile); | ||
| 46 | }; | ||
| 47 | |||
| 48 | host.readFile = (fileName) => { | ||
| 49 | if (fileName === VIRTUAL_FILE) { | ||
| 50 | return source; | ||
| 51 | } | ||
| 52 | return originalReadFile(fileName); | ||
| 53 | }; | ||
| 54 | |||
| 55 | host.fileExists = (fileName) => { | ||
| 56 | if (fileName === VIRTUAL_FILE) { | ||
| 57 | return true; | ||
| 58 | } | ||
| 59 | return originalFileExists(fileName); | ||
| 60 | }; | ||
| 61 | |||
| 62 | const program = ts.createProgram([VIRTUAL_FILE], compilerOptions, host); | ||
| 63 | const diagnostics = ts.getPreEmitDiagnostics(program).filter((diagnostic) => { | ||
| 64 | if (!diagnostic.file) { | ||
| 65 | return true; | ||
| 66 | } | ||
| 67 | return diagnostic.file.fileName === VIRTUAL_FILE; | ||
| 68 | }); | ||
| 69 | |||
| 70 | if (diagnostics.length === 0) { | ||
| 71 | return { ok: true }; | ||
| 72 | } | ||
| 73 | |||
| 74 | const message = diagnostics | ||
| 75 | .slice(0, 3) | ||
| 76 | .map((diagnostic) => { | ||
| 77 | const text = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); | ||
| 78 | if (!diagnostic.file || diagnostic.start === undefined) { | ||
| 79 | return text; | ||
| 80 | } | ||
| 81 | const lineAndCharacter = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); | ||
| 82 | return `${lineAndCharacter.line + 1}:${lineAndCharacter.character + 1} ${text}`; | ||
| 83 | }) | ||
| 84 | .join(" | "); | ||
| 85 | |||
| 86 | return { ok: false, error: message }; | ||
| 87 | } | ||
| @@ -1,10 +1,74 @@ | |||
| 1 | import { healthcheck } from "@openassistant/core"; | 1 | import { HttpRouter } from "@effect/platform"; |
| 2 | import { Effect } from "effect"; | 2 | import { BunHttpServer, BunRuntime } from "@effect/platform-bun"; |
| 3 | import { RpcSerialization, RpcServer } from "@effect/rpc"; | ||
| 4 | import { AgentRpcs, RunTurnOutput, createCodeModeRunner } from "@openassistant/core"; | ||
| 5 | import { Effect, Layer } from "effect"; | ||
| 6 | import { runAgentLoop } from "./agent-loop.js"; | ||
| 7 | import { InMemoryCalendarStore } from "./calendar-store.js"; | ||
| 8 | import { createToolTree } from "./tools.js"; | ||
| 3 | 9 | ||
| 4 | const program = Effect.flatMap(healthcheck, (status) => | 10 | const port = Number(readEnv("OPENASSISTANT_GATEWAY_PORT") ?? "8787"); |
| 5 | Effect.sync(() => { | 11 | |
| 6 | console.log(`[gateway] healthcheck=${status}`); | 12 | const calendarStore = new InMemoryCalendarStore(); |
| 13 | const tools = createToolTree(calendarStore); | ||
| 14 | const runner = createCodeModeRunner({ | ||
| 15 | tools, | ||
| 16 | requestApproval: () => Effect.succeed("approved"), | ||
| 17 | }); | ||
| 18 | |||
| 19 | const AgentHandlersLive = AgentRpcs.toLayer( | ||
| 20 | Effect.succeed({ | ||
| 21 | RunTurn: (input: { prompt: string; nowIso: string }) => | ||
| 22 | Effect.tryPromise({ | ||
| 23 | try: async () => { | ||
| 24 | const generated = await runAgentLoop( | ||
| 25 | input.prompt, | ||
| 26 | (code) => Effect.runPromise(runner.run({ code })), | ||
| 27 | { | ||
| 28 | now: new Date(input.nowIso), | ||
| 29 | }, | ||
| 30 | ); | ||
| 31 | |||
| 32 | return new RunTurnOutput({ | ||
| 33 | message: generated.text, | ||
| 34 | planner: generated.planner, | ||
| 35 | codeRuns: generated.runs.length, | ||
| 36 | ...(isVerboseMode() ? { footer: generated.planner } : {}), | ||
| 37 | }); | ||
| 38 | }, | ||
| 39 | catch: (error) => `RunTurn failed: ${describeUnknown(error)}`, | ||
| 40 | }), | ||
| 7 | }), | 41 | }), |
| 8 | ); | 42 | ); |
| 9 | 43 | ||
| 10 | await Effect.runPromise(program); | 44 | const RpcLayer = RpcServer.layer(AgentRpcs).pipe(Layer.provide(AgentHandlersLive)); |
| 45 | |||
| 46 | const HttpProtocolLayer = RpcServer.layerProtocolHttp({ | ||
| 47 | path: "/rpc", | ||
| 48 | }).pipe(Layer.provide(RpcSerialization.layerNdjson)); | ||
| 49 | |||
| 50 | const MainLayer = HttpRouter.Default.serve().pipe( | ||
| 51 | Layer.provide(RpcLayer), | ||
| 52 | Layer.provide(HttpProtocolLayer), | ||
| 53 | Layer.provide(BunHttpServer.layer({ port })), | ||
| 54 | ); | ||
| 55 | |||
| 56 | console.log(`[gateway] listening on http://localhost:${port}/rpc`); | ||
| 57 | BunRuntime.runMain(Layer.launch(MainLayer)); | ||
| 58 | |||
| 59 | function describeUnknown(error: unknown): string { | ||
| 60 | if (error instanceof Error) { | ||
| 61 | return error.message; | ||
| 62 | } | ||
| 63 | return String(error); | ||
| 64 | } | ||
| 65 | |||
| 66 | function isVerboseMode(): boolean { | ||
| 67 | const value = readEnv("OPENASSISTANT_VERBOSE_RESPONSE")?.trim().toLowerCase(); | ||
| 68 | return value === "1" || value === "true" || value === "yes"; | ||
| 69 | } | ||
| 70 | |||
| 71 | function readEnv(key: string): string | undefined { | ||
| 72 | const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun; | ||
| 73 | return bun?.env?.[key] ?? process.env[key]; | ||
| 74 | } | ||
| @@ -0,0 +1,22 @@ | |||
| 1 | import { defineTool, type ToolTree } from "@openassistant/core"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | import type { InMemoryCalendarStore } from "./calendar-store.js"; | ||
| 4 | |||
| 5 | export function createToolTree(calendarStore: InMemoryCalendarStore): ToolTree { | ||
| 6 | return { | ||
| 7 | calendar: { | ||
| 8 | update: defineTool({ | ||
| 9 | kind: "write", | ||
| 10 | approval: "required", | ||
| 11 | run: (input: { title: string; startsAt: string; notes?: string }) => | ||
| 12 | Effect.sync(() => calendarStore.update(input)), | ||
| 13 | previewInput: (input) => `${input.title} @ ${input.startsAt}`, | ||
| 14 | }), | ||
| 15 | list: defineTool({ | ||
| 16 | kind: "read", | ||
| 17 | approval: "auto", | ||
| 18 | run: () => Effect.sync(() => calendarStore.list()), | ||
| 19 | }), | ||
| 20 | }, | ||
| 21 | }; | ||
| 22 | } | ||
| @@ -17,6 +17,8 @@ · 16 unmodified lines | |||
| 17 | "name": "@openassistant/discord-bot", | 17 | "name": "@openassistant/discord-bot", |
| 18 | "dependencies": { | 18 | "dependencies": { |
| 19 | "@ai-sdk/anthropic": "^2.0.0", | 19 | "@ai-sdk/anthropic": "^2.0.0", |
| 20 | "@effect/platform": "latest", | ||
| 21 | "@effect/rpc": "latest", | ||
| 20 | "@openassistant/core": "workspace:*", | 22 | "@openassistant/core": "workspace:*", |
| 21 | "@openassistant/reacord": "workspace:*", | 23 | "@openassistant/reacord": "workspace:*", |
| 22 | "ai": "^6.0.73", | 24 | "ai": "^6.0.73", |
| @@ -32,13 +34,20 @@ · 9 unmodified lines | |||
| 32 | "apps/gateway": { | 34 | "apps/gateway": { |
| 33 | "name": "@openassistant/gateway", | 35 | "name": "@openassistant/gateway", |
| 34 | "dependencies": { | 36 | "dependencies": { |
| 37 | "@ai-sdk/anthropic": "^2.0.0", | ||
| 38 | "@effect/platform": "latest", | ||
| 39 | "@effect/platform-bun": "latest", | ||
| 40 | "@effect/rpc": "latest", | ||
| 35 | "@openassistant/core": "workspace:*", | 41 | "@openassistant/core": "workspace:*", |
| 42 | "ai": "^6.0.73", | ||
| 36 | "effect": "latest", | 43 | "effect": "latest", |
| 44 | "zod": "^4.3.6", | ||
| 37 | }, | 45 | }, |
| 38 | }, | 46 | }, |
| 39 | "packages/core": { | 47 | "packages/core": { |
| 40 | "name": "@openassistant/core", | 48 | "name": "@openassistant/core", |
| 41 | "dependencies": { | 49 | "dependencies": { |
| 50 | "@effect/rpc": "latest", | ||
| 42 | "effect": "latest", | 51 | "effect": "latest", |
| 43 | }, | 52 | }, |
| 44 | }, | 53 | }, |
| @@ -58,6 +67,22 @@ · 13 unmodified lines | |||
| 58 | }, | 67 | }, |
| 59 | }, | 68 | }, |
| 60 | }, | 69 | }, |
| 70 | "overrides": { | ||
| 71 | "@ai-sdk/anthropic": "^2.0.0", | ||
| 72 | "@effect-atom/atom": "^0.4.11", | ||
| 73 | "@effect-atom/atom-react": "^0.4.4", | ||
| 74 | "@effect/platform": "latest", | ||
| 75 | "@effect/platform-bun": "latest", | ||
| 76 | "@effect/rpc": "latest", | ||
| 77 | "@types/react": "^19.0.0", | ||
| 78 | "@types/react-reconciler": "^0.31.0", | ||
| 79 | "ai": "^6.0.73", | ||
| 80 | "discord.js": "^14.17.3", | ||
| 81 | "effect": "latest", | ||
| 82 | "react": "^19.0.0", | ||
| 83 | "react-reconciler": "^0.31.0", | ||
| 84 | "zod": "^4.3.6", | ||
| 85 | }, | ||
| 61 | "packages": { | 86 | "packages": { |
| 62 | "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.59", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-s7T5etVW7k4y3p1TDpTGa7xBddvSPWTvRa+EHAI0MPCrVh2dVFmAk+Dm6I8zlr1vz45SBpHvKR3SP29aGXdrUQ=="], | 87 | "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.59", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-s7T5etVW7k4y3p1TDpTGa7xBddvSPWTvRa+EHAI0MPCrVh2dVFmAk+Dm6I8zlr1vz45SBpHvKR3SP29aGXdrUQ=="], |
| 63 | 88 | ||
| @@ -83,16 +108,26 @@ · 19 unmodified lines | |||
| 83 | 108 | ||
| 84 | "@effect-atom/atom-react": ["@effect-atom/atom-react@0.4.6", "", { "dependencies": { "@effect-atom/atom": "^0.4.13" }, "peerDependencies": { "effect": "^3.19", "react": ">=18 <20", "scheduler": "*" } }, "sha512-ZvncuSQsCcUrsddD00JcKDMraPge0AiDMGiJqKc7ProvagCVrxB+Emy5uHM0l0SLGUx4X56bFE0d1tp9Z/Bf+g=="], | 109 | "@effect-atom/atom-react": ["@effect-atom/atom-react@0.4.6", "", { "dependencies": { "@effect-atom/atom": "^0.4.13" }, "peerDependencies": { "effect": "^3.19", "react": ">=18 <20", "scheduler": "*" } }, "sha512-ZvncuSQsCcUrsddD00JcKDMraPge0AiDMGiJqKc7ProvagCVrxB+Emy5uHM0l0SLGUx4X56bFE0d1tp9Z/Bf+g=="], |
| 85 | 110 | ||
| 111 | "@effect/cluster": ["@effect/cluster@0.56.2", "", { "dependencies": { "kubernetes-types": "^1.30.0" }, "peerDependencies": { "@effect/platform": "^0.94.3", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "@effect/workflow": "^0.16.0", "effect": "^3.19.16" } }, "sha512-BtYorswZ0MlaTqa7k+57AD80gp/TJfej85vx0gDnIxhbPQ3+xoa3EXlPrfLV7XsiAokOxE5qs/X6UoZtAVeKug=="], | ||
| 112 | |||
| 86 | "@effect/experimental": ["@effect/experimental@0.57.11", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/platform": "^0.93.6", "effect": "^3.19.9", "ioredis": "^5", "lmdb": "^3" }, "optionalPeers": ["ioredis", "lmdb"] }, "sha512-M5uug3Drs/gyTHLfA+XzcIZQGUEV/Jn5yi1POki4oZswhpzNmsVTHl4THpxAordRKwa5lFvTSlsRP684YH7pSw=="], | 113 | "@effect/experimental": ["@effect/experimental@0.57.11", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/platform": "^0.93.6", "effect": "^3.19.9", "ioredis": "^5", "lmdb": "^3" }, "optionalPeers": ["ioredis", "lmdb"] }, "sha512-M5uug3Drs/gyTHLfA+XzcIZQGUEV/Jn5yi1POki4oZswhpzNmsVTHl4THpxAordRKwa5lFvTSlsRP684YH7pSw=="], |
| 87 | 114 | ||
| 88 | "@effect/language-service": ["@effect/language-service@0.73.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-/SGoq50VDm/XwQI6d0cReYcLjwfdqwZv7uEJp92+ssx5vLCsm+QvHf4Ul6l6PYVzorsgAp/b6fhAwI2VSkqcJQ=="], | 115 | "@effect/language-service": ["@effect/language-service@0.73.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-/SGoq50VDm/XwQI6d0cReYcLjwfdqwZv7uEJp92+ssx5vLCsm+QvHf4Ul6l6PYVzorsgAp/b6fhAwI2VSkqcJQ=="], |
| 89 | 116 | ||
| 90 | "@effect/platform": ["@effect/platform@0.93.8", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.19.12" } }, "sha512-xTEy6fyTy4ijmFC3afKgtvYtn/JyPoIov4ZSUWJZUv3VeOcUPNGrrqG6IJlWkXs3NhvSywKv7wc1kw3epCQVZw=="], | 117 | "@effect/platform": ["@effect/platform@0.94.3", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.19.16" } }, "sha512-bvTR8xLQoRpKgHuprZDOeQdPkhyVw+WT05iI9jl2s8Qiblyk5Dz2JLwJU+EFeksIBaPYz49xa635Om91T1CefQ=="], |
| 118 | |||
| 119 | "@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=="], | ||
| 120 | |||
| 121 | "@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=="], | ||
| 91 | 122 | ||
| 92 | "@effect/rpc": ["@effect/rpc@0.72.2", "", { "dependencies": { "msgpackr": "^1.11.4" }, "peerDependencies": { "@effect/platform": "^0.93.3", "effect": "^3.19.5" } }, "sha512-BmTXybXCOq96D2r9mvSW/YdiTQs5CStnd4II+lfVKrMr3pMNERKLZ2LG37Tfm4Sy3Q8ire6IVVKO/CN+VR0uQQ=="], | 123 | "@effect/rpc": ["@effect/rpc@0.73.0", "", { "dependencies": { "msgpackr": "^1.11.4" }, "peerDependencies": { "@effect/platform": "^0.94.0", "effect": "^3.19.13" } }, "sha512-iMPf6tTriz8sK0l5x4koFId8Hz5nFptHYg8WqyjHGIIVLTpZxuiSqhmXZG7FnAs5N2n6uCEws4wWGcIgXNUrFg=="], |
| 124 | |||
| 125 | "@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=="], | ||
| 93 | 126 | ||
| 94 | "@effect/vitest": ["@effect/vitest@0.27.0", "", { "peerDependencies": { "effect": "^3.19.0", "vitest": "^3.2.0" } }, "sha512-8bM7n9xlMUYw9GqPIVgXFwFm2jf27m/R7psI64PGpwU5+26iwyxp9eAXEsfT5S6lqztYfpQQ1Ubp5o6HfNYzJQ=="], | 127 | "@effect/vitest": ["@effect/vitest@0.27.0", "", { "peerDependencies": { "effect": "^3.19.0", "vitest": "^3.2.0" } }, "sha512-8bM7n9xlMUYw9GqPIVgXFwFm2jf27m/R7psI64PGpwU5+26iwyxp9eAXEsfT5S6lqztYfpQQ1Ubp5o6HfNYzJQ=="], |
| 95 | 128 | ||
| 129 | "@effect/workflow": ["@effect/workflow@0.16.0", "", { "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.0", "@effect/rpc": "^0.73.0", "effect": "^3.19.13" } }, "sha512-MiAdlxx3TixkgHdbw+Yf1Z3tHAAE0rOQga12kIydJqj05Fnod+W/I+kQGRMY/XWRg+QUsVxhmh1qTr7Ype6lrw=="], | ||
| 130 | |||
| 96 | "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], | 131 | "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], |
| 97 | 132 | ||
| 98 | "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], | 133 | "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], |
| @@ -169,6 +204,34 @@ · 70 unmodified lines | |||
| 169 | 204 | ||
| 170 | "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], | 205 | "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], |
| 171 | 206 | ||
| 207 | "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="], | ||
| 208 | |||
| 209 | "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="], | ||
| 210 | |||
| 211 | "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="], | ||
| 212 | |||
| 213 | "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="], | ||
| 214 | |||
| 215 | "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="], | ||
| 216 | |||
| 217 | "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="], | ||
| 218 | |||
| 219 | "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="], | ||
| 220 | |||
| 221 | "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="], | ||
| 222 | |||
| 223 | "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="], | ||
| 224 | |||
| 225 | "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="], | ||
| 226 | |||
| 227 | "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="], | ||
| 228 | |||
| 229 | "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="], | ||
| 230 | |||
| 231 | "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="], | ||
| 232 | |||
| 233 | "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], | ||
| 234 | |||
| 172 | "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="], | 235 | "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="], |
| 173 | 236 | ||
| 174 | "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="], | 237 | "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="], |
| @@ -323,10 +386,16 @@ · 148 unmodified lines | |||
| 323 | 386 | ||
| 324 | "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], | 387 | "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], |
| 325 | 388 | ||
| 389 | "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], | ||
| 390 | |||
| 391 | "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], | ||
| 392 | |||
| 326 | "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], | 393 | "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], |
| 327 | 394 | ||
| 328 | "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], | 395 | "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], |
| 329 | 396 | ||
| 397 | "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], | ||
| 398 | |||
| 330 | "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], | 399 | "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], |
| 331 | 400 | ||
| 332 | "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], | 401 | "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], |
| @@ -347,6 +416,8 @@ · 14 unmodified lines | |||
| 347 | 416 | ||
| 348 | "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], | 417 | "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], |
| 349 | 418 | ||
| 419 | "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], | ||
| 420 | |||
| 350 | "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], | 421 | "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], |
| 351 | 422 | ||
| 352 | "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], | 423 | "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], |
| @@ -16,6 +16,7 @@ · 15 unmodified lines | |||
| 16 | "test": "vitest run", | 16 | "test": "vitest run", |
| 17 | "test:watch": "vitest", | 17 | "test:watch": "vitest", |
| 18 | "clean": "bun run --filter '@openassistant/core' clean && bun run --filter '@openassistant/reacord' clean && bun run --filter '@openassistant/gateway' clean && bun run --filter '@openassistant/discord-bot' clean", | 18 | "clean": "bun run --filter '@openassistant/core' clean && bun run --filter '@openassistant/reacord' clean && bun run --filter '@openassistant/gateway' clean && bun run --filter '@openassistant/discord-bot' clean", |
| 19 | "dev": "bun run dev:gateway & bun run dev:discord & wait", | ||
| 19 | "dev:gateway": "bun run --cwd apps/gateway dev", | 20 | "dev:gateway": "bun run --cwd apps/gateway dev", |
| 20 | "dev:discord": "bun run --cwd apps/discord-bot dev" | 21 | "dev:discord": "bun run --cwd apps/discord-bot dev" |
| 21 | }, | 22 | }, |
| @@ -26,5 +27,21 @@ · 4 unmodified lines | |||
| 26 | "@typescript/native-preview": "7.0.0-dev.20260205.1", | 27 | "@typescript/native-preview": "7.0.0-dev.20260205.1", |
| 27 | "typescript": "latest", | 28 | "typescript": "latest", |
| 28 | "vitest": "^3.2.0" | 29 | "vitest": "^3.2.0" |
| 30 | }, | ||
| 31 | "overrides": { | ||
| 32 | "@ai-sdk/anthropic": "^2.0.0", | ||
| 33 | "@effect/platform": "latest", | ||
| 34 | "@effect/platform-bun": "latest", | ||
| 35 | "@effect/rpc": "latest", | ||
| 36 | "@effect-atom/atom": "^0.4.11", | ||
| 37 | "@effect-atom/atom-react": "^0.4.4", | ||
| 38 | "@types/react": "^19.0.0", | ||
| 39 | "@types/react-reconciler": "^0.31.0", | ||
| 40 | "ai": "^6.0.73", | ||
| 41 | "discord.js": "^14.17.3", | ||
| 42 | "effect": "latest", | ||
| 43 | "react": "^19.0.0", | ||
| 44 | "react-reconciler": "^0.31.0", | ||
| 45 | "zod": "^4.3.6" | ||
| 29 | } | 46 | } |
| 30 | } | 47 | } |
| @@ -12,6 +12,7 @@ · 11 unmodified lines | |||
| 12 | "clean": "bun -e \"import { rmSync } from 'node:fs'; for (const p of ['dist','node_modules','.turbo','coverage','.tsbuildinfo']) rmSync(p,{recursive:true,force:true});\"" | 12 | "clean": "bun -e \"import { rmSync } from 'node:fs'; for (const p of ['dist','node_modules','.turbo','coverage','.tsbuildinfo']) rmSync(p,{recursive:true,force:true});\"" |
| 13 | }, | 13 | }, |
| 14 | "dependencies": { | 14 | "dependencies": { |
| 15 | "@effect/rpc": "latest", | ||
| 15 | "effect": "latest" | 16 | "effect": "latest" |
| 16 | } | 17 | } |
| 17 | } | 18 | } |
| @@ -0,0 +1,24 @@ | |||
| 1 | import { Rpc, RpcGroup } from "@effect/rpc"; | ||
| 2 | import { Schema } from "effect"; | ||
| 3 | |||
| 4 | export class RunTurnInput extends Schema.Class<RunTurnInput>("RunTurnInput")({ | ||
| 5 | prompt: Schema.String, | ||
| 6 | requesterId: Schema.String, | ||
| 7 | channelId: Schema.String, | ||
| 8 | nowIso: Schema.String, | ||
| 9 | }) {} | ||
| 10 | |||
| 11 | export class RunTurnOutput extends Schema.Class<RunTurnOutput>("RunTurnOutput")({ | ||
| 12 | message: Schema.String, | ||
| 13 | planner: Schema.String, | ||
| 14 | codeRuns: Schema.Number, | ||
| 15 | footer: Schema.optional(Schema.String), | ||
| 16 | }) {} | ||
| 17 | |||
| 18 | export class AgentRpcs extends RpcGroup.make( | ||
| 19 | Rpc.make("RunTurn", { | ||
| 20 | payload: RunTurnInput, | ||
| 21 | success: RunTurnOutput, | ||
| 22 | error: Schema.String, | ||
| 23 | }), | ||
| 24 | ) {} | ||
| @@ -2,3 +2,4 @@ import { Effect } from "effect"; · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | export const healthcheck = Effect.succeed("ok"); | 3 | export const healthcheck = Effect.succeed("ok"); |
| 4 | export * from "./codemode/runner.js"; | 4 | export * from "./codemode/runner.js"; |
| 5 | export * from "./agent-rpc.js"; | ||