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 | 64 | ## Design Principles |
| 19 | 65 | |
| 20 | −1. **Config-file-driven.** One config file is the source of truth for what's loaded. | |
| 21 | −2. **No runtime magic.** Discovery is explicit scan, not auto-import. No decorators, no file-convention routing. | |
| 66 | +1. **Convention over configuration.** Drop a `.ts` file in `tools/`, it becomes a tool. No manifest needed for simple cases. | |
| 67 | +2. **Config when you need it.** Full plugins with config schemas exist for complex integrations, but aren't required. | |
| 22 | 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. | |
| 24 | −5. **Monorepo-friendly.** Works whether your plugin is a workspace package, a path on disk, or an npm install. | |
| 25 | −6. **Minimal surface.** Plugin API is `defineTool()` + a manifest. That's it for MVP. | |
| 69 | +4. **TypeScript-native.** Bun loads `.ts` directly. No build step. No jiti (Bun-only for MVP). | |
| 70 | +5. **Monorepo-friendly.** Works whether your tool is a file in `.openassistant/tools/`, a workspace package, or an npm install. | |
| 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/ | |
| 35 | − package.json # Must have "openassistant" key | |
| 36 | − openassistant.json # Plugin manifest (metadata, config schema) | |
| 37 | − index.ts # Entry point, default exports a ToolTree or register function | |
| 261 | +.openassistant/ | |
| 262 | + plugins/ | |
| 263 | + posthog/ | |
| 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 | 275 | ```json |
| 43 | 276 | { |
| 44 | 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. | |
| 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 | − | |
| 291 | +**`openassistant.json`** (optional, for config validation): | |
| 65 | 292 | ```json |
| 66 | 293 | { |
| 67 | 294 | "id": "posthog", |
| 68 | 295 | "name": "PostHog Analytics", |
| 69 | 296 | "description": "Read analytics and create monitors via PostHog API.", |
| 70 | − "version": "0.1.0", | |
| 71 | 297 | "configSchema": { |
| 72 | 298 | "type": "object", |
| 73 | 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. | |
| 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 | − | |
| 310 | +**`index.ts`** (plugin entry): | |
| 113 | 311 | ```ts |
| 114 | −import { defineTool, type PluginContext, type ToolTree } from "@openassistant/sdk"; | |
| 115 | −import { Effect } from "effect"; | |
| 312 | +import { defineTool, z, type PluginContext, type ToolTree } from "@openassistant/sdk"; | |
| 313 | +import { PostHogClient } from "posthog-node"; | |
| 116 | 314 | |
| 117 | 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 | 318 | return { |
| 121 | 319 | analytics: { |
| 122 | 320 | getVisitors: defineTool({ |
| 123 | − kind: "read", | |
| 321 | + description: "Get visitor count for a website", | |
| 124 | 322 | approval: "auto", |
| 125 | − run: (input: { website: string }) => | |
| 126 | − Effect.tryPromise(() => client.getInsight(input.website)), | |
| 323 | + args: z.object({ website: z.string() }), | |
| 324 | + run: async (input) => client.getInsight(input.website), | |
| 127 | 325 | }), |
| 128 | 326 | }, |
| 129 | 327 | monitor: { |
| 130 | 328 | createThreshold: defineTool({ |
| 131 | − kind: "write", | |
| 329 | + description: "Create a threshold alert for visitor count", | |
| 132 | 330 | approval: "required", |
| 133 | − run: (input: { website: string; threshold: number }) => | |
| 134 | − Effect.tryPromise(() => client.createAlert(input)), | |
| 331 | + args: z.object({ website: z.string(), threshold: z.number() }), | |
| 332 | + run: async (input) => client.createAlert(input), | |
| 135 | 333 | }), |
| 136 | 334 | }, |
| 137 | 335 | }; |
| 138 | 336 | } |
| 139 | 337 | ``` |
| 140 | 338 | |
| 141 | −`PluginContext` provides: | |
| 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>/`). | |
| 339 | +Same `defineTool()`, same API. The only difference is the plugin exports a function that receives config via `PluginContext`, instead of exporting tools directly. | |
| 145 | 340 | |
| 146 | 341 | --- |
| 147 | 342 | |
| 148 | 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: | |
| 155 | −1. Project-local: `<project>/.openassistant/config.json` | |
| 156 | −2. Global: `~/.config/openassistant/config.json` | |
| 157 | −3. Merged (project overrides global). | |
| 349 | +| Priority | Location | Env Override | | |
| 350 | +|----------|----------|--------------| | |
| 351 | +| 1 (highest) | `OPENASSISTANT_CONFIG` env var (path to file) | — | | |
| 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": { | |
| 162 | − "paths": [ | |
| 163 | − "./tools/posthog", | |
| 164 | − "./tools/github", | |
| 165 | − "/absolute/path/to/custom-plugin" | |
| 166 | − ], | |
| 167 | − "npm": [ | |
| 168 | − "@openassistant/plugin-github", | |
| 169 | − "@openassistant/plugin-posthog@^0.2.0" | |
| 170 | − ], | |
| 171 | − "config": { | |
| 172 | − "posthog": { | |
| 173 | − "enabled": true, | |
| 174 | − "apiKey": "${POSTHOG_API_KEY}", | |
| 175 | − "projectId": "12345", | |
| 176 | − "host": "https://us.posthog.com" | |
| 177 | − }, | |
| 178 | − "github": { | |
| 179 | − "enabled": true, | |
| 180 | − "token": "${GITHUB_TOKEN}" | |
| 181 | − } | |
| 363 | + // Plugin specifiers — npm packages or file:// URLs | |
| 364 | + "plugins": [ | |
| 365 | + "@openassistant/plugin-posthog", | |
| 366 | + "@openassistant/plugin-github@^0.2.0", | |
| 367 | + "file:///absolute/path/to/plugin", | |
| 368 | + "./relative/path/to/plugin" | |
| 369 | + ], | |
| 370 | + | |
| 371 | + // Per-plugin configuration | |
| 372 | + "config": { | |
| 373 | + "posthog": { | |
| 374 | + "enabled": true, | |
| 375 | + "apiKey": "${POSTHOG_API_KEY}", | |
| 376 | + "projectId": "12345", | |
| 377 | + "host": "https://us.posthog.com" | |
| 378 | + }, | |
| 379 | + "github": { | |
| 380 | + "enabled": true, | |
| 381 | + "token": "${GITHUB_TOKEN}" | |
| 182 | 382 | } |
| 183 | 383 | }, |
| 384 | + | |
| 385 | + // Auth profiles | |
| 184 | 386 | "auth": { |
| 185 | 387 | "anthropic": { |
| 186 | 388 | "type": "token", |
| 187 | 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 | | |
| 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 | | |
| 408 | +## Discovery and Loading | |
| 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 | 445 | 1. RESOLVE CONFIG |
| 219 | − - Find config file (project-local, then global) | |
| 220 | − - Parse and merge | |
| 221 | − | |
| 222 | −2. DISCOVER PLUGINS | |
| 223 | − - Scan each origin (paths, workspace, global, npm, bundled) | |
| 224 | − - For each candidate directory: | |
| 225 | − a. Read package.json, check for "openassistant" key | |
| 226 | − b. Read openassistant.json (or derive defaults from package.json) | |
| 227 | − c. Deduplicate by plugin ID (first origin wins) | |
| 228 | − | |
| 229 | −3. FILTER | |
| 230 | − - Check enabled/disabled state from config | |
| 231 | − - Skip plugins with missing required config | |
| 232 | − | |
| 233 | −4. VALIDATE CONFIG | |
| 234 | − - Run JSON Schema validation on each plugin's config block | |
| 235 | − - Log warnings for invalid configs, skip those plugins | |
| 236 | − | |
| 237 | −5. LOAD MODULE | |
| 238 | − - Use jiti (or Bun native import) to load the TypeScript entry point | |
| 239 | − - Alias "@openassistant/sdk" to the host's SDK module | |
| 240 | − | |
| 241 | −6. RESOLVE EXPORT | |
| 242 | − - If default export is a function: call it with PluginContext -> get ToolTree | |
| 243 | − - If default export is a ToolTree: use directly | |
| 244 | − - Namespace the tree under the plugin ID | |
| 245 | − | |
| 246 | −7. MERGE TOOL TREES | |
| 247 | − - Combine all plugin ToolTrees into the master ToolTree | |
| 446 | + - Find config file (env > project > global) | |
| 447 | + - Parse JSONC with ${ENV_VAR} interpolation | |
| 448 | + - Deep merge if multiple sources | |
| 449 | + | |
| 450 | +2. SCAN CONVENTION DIRECTORIES | |
| 451 | + - .openassistant/tools/*.{ts,js} → standalone tool candidates | |
| 452 | + - .openassistant/plugins/*/ → plugin candidates (must have package.json) | |
| 453 | + - Same for global ~/.config/openassistant/ | |
| 454 | + | |
| 455 | +3. RESOLVE PLUGIN SPECIFIERS | |
| 456 | + - For each entry in config.plugins[]: | |
| 457 | + a. npm package name → resolve from node_modules | |
| 458 | + b. file:// URL → resolve absolute path | |
| 459 | + c. Relative path → resolve from project root | |
| 460 | + | |
| 461 | +4. AUTO-INSTALL DEPENDENCIES | |
| 462 | + - Ensure .openassistant/package.json exists with SDK dep | |
| 463 | + - Run bun install if needed | |
| 464 | + | |
| 465 | +5. DEDUPLICATE | |
| 466 | + - By plugin ID (from manifest) or tool filename | |
| 467 | + - First-found wins (project > global > npm > bundled) | |
| 468 | + | |
| 469 | +6. LOAD MODULES | |
| 470 | + - Use dynamic import() (Bun handles .ts natively) | |
| 471 | + - For standalone tools: import file, extract default + named exports | |
| 472 | + - For plugins: import entry, resolve function vs ToolTree export | |
| 473 | + | |
| 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 | 487 | - Detect ID collisions (error) |
| 249 | 488 | |
| 250 | −8. GENERATE TYPE DECLARATIONS | |
| 251 | − - Walk the merged ToolTree | |
| 252 | − - Emit TypeScript ambient declarations for the codemode typechecker | |
| 253 | − - This replaces the hardcoded TOOL_DECLARATIONS string | |
| 489 | +10. GENERATE TYPE DECLARATIONS | |
| 490 | + - Walk merged ToolTree | |
| 491 | + - Derive TypeScript types from zod args schemas | |
| 492 | + - This replaces the hardcoded TOOL_DECLARATIONS | |
| 254 | 493 | |
| 255 | −9. READY | |
| 256 | − - Pass merged ToolTree to createCodeModeRunner() | |
| 257 | − - Plugin tools are now available in the sandbox | |
| 494 | +11. READY | |
| 495 | + - Pass merged ToolTree to createCodeModeRunner() | |
| 496 | + - Pass declarations to typechecker | |
| 258 | 497 | ``` |
| 259 | 498 | |
| 260 | 499 | --- |
| 261 | 500 | |
| 262 | 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: | |
| 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) | |
| 505 | +### Exports | |
| 272 | 506 | |
| 273 | 507 | ```ts |
| 274 | 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 | 510 | export type { PluginContext } from "./plugin-context.js"; |
| 277 | 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 | 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: | |
| 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. | |
| 642 | +`code-typecheck.ts` currently has a hardcoded `TOOL_DECLARATIONS` string. When tools are dynamically loaded, we need to generate this. | |
| 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 | 648 | ```ts |
| 298 | −// Plugin can optionally export declarations | |
| 299 | −export const declarations = `{ | |
| 300 | − analytics: { | |
| 301 | − getVisitors(input: { projectId: string; website: string }): Promise<unknown>; | |
| 302 | − }; | |
| 303 | − monitor: { | |
| 304 | − createThreshold(input: { website: string; threshold: number }): Promise<unknown>; | |
| 305 | − }; | |
| 306 | −}`; | |
| 649 | +// Given: | |
| 650 | +defineTool({ | |
| 651 | + description: "Get weather", | |
| 652 | + approval: "auto", | |
| 653 | + args: z.object({ | |
| 654 | + location: z.string(), | |
| 655 | + units: z.enum(["metric", "imperial"]).default("metric"), | |
| 656 | + }), | |
| 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 | 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 | −``` | |
| 318 | −my-project/ | |
| 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/ | |
| 685 | +```bash | |
| 686 | +mkdir -p .openassistant/tools | |
| 330 | 687 | ``` |
| 331 | 688 | |
| 332 | −`.openassistant/config.json`: | |
| 333 | −```json | |
| 334 | −{ | |
| 335 | − "plugins": { | |
| 336 | − "config": { | |
| 337 | − "posthog": { | |
| 338 | − "apiKey": "${POSTHOG_API_KEY}", | |
| 339 | − "projectId": "12345" | |
| 340 | − } | |
| 341 | − } | |
| 342 | − } | |
| 343 | −} | |
| 689 | +Create `.openassistant/tools/weather.ts`: | |
| 690 | +```ts | |
| 691 | +import { defineTool, z } from "@openassistant/sdk"; | |
| 692 | + | |
| 693 | +export default defineTool({ | |
| 694 | + description: "Get current weather", | |
| 695 | + approval: "auto", | |
| 696 | + args: z.object({ location: z.string() }), | |
| 697 | + run: async ({ location }) => { | |
| 698 | + const res = await fetch(`https://wttr.in/${location}?format=j1`); | |
| 699 | + return res.json(); | |
| 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 | 709 | my-project/ |
| 352 | 710 | .openassistant/ |
| 353 | 711 | config.json |
| 354 | 712 | packages/ |
| 355 | − oa-tools/ # Workspace package with tools | |
| 356 | − package.json # has "openassistant" key | |
| 357 | − openassistant.json | |
| 358 | − src/ | |
| 359 | − index.ts | |
| 713 | + oa-posthog/ | |
| 714 | + package.json # has "openassistant" key | |
| 715 | + openassistant.json # config schema | |
| 716 | + index.ts | |
| 360 | 717 | ``` |
| 361 | 718 | |
| 362 | 719 | `.openassistant/config.json`: |
| 363 | −```json | |
| 720 | +```jsonc | |
| 364 | 721 | { |
| 365 | − "plugins": { | |
| 366 | − "paths": ["./packages/oa-tools"] | |
| 722 | + "plugins": ["./packages/oa-posthog"], | |
| 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. | |
| 372 | − | |
| 373 | −### Scenario: User installs from npm | |
| 732 | +### Scenario 3: npm package | |
| 374 | 733 | |
| 375 | 734 | ```bash |
| 376 | 735 | bun add @openassistant/plugin-github |
| 377 | 736 | ``` |
| 378 | 737 | |
| 379 | 738 | `.openassistant/config.json`: |
| 380 | −```json | |
| 739 | +```jsonc | |
| 381 | 740 | { |
| 382 | − "plugins": { | |
| 383 | − "npm": ["@openassistant/plugin-github"], | |
| 384 | − "config": { | |
| 385 | − "github": { | |
| 386 | − "token": "${GITHUB_TOKEN}" | |
| 387 | − } | |
| 388 | − } | |
| 741 | + "plugins": ["@openassistant/plugin-github"], | |
| 742 | + "config": { | |
| 743 | + "github": { "token": "${GITHUB_TOKEN}" } | |
| 389 | 744 | } |
| 390 | 745 | } |
| 391 | 746 | ``` |
| 392 | 747 | |
| 393 | −The loader resolves `@openassistant/plugin-github` from `node_modules`. | |
| 394 | − | |
| 395 | −--- | |
| 396 | − | |
| 397 | −## CLI Surface (Future) | |
| 398 | − | |
| 399 | −```bash | |
| 400 | −# Initialize config in current project | |
| 401 | −openassistant init | |
| 748 | +### Scenario 4: Multiple tools in one file | |
| 402 | 749 | |
| 403 | −# List discovered plugins and their status | |
| 404 | −openassistant plugins list | |
| 405 | − | |
| 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 | |
| 750 | +`.openassistant/tools/github-issues.ts`: | |
| 751 | +```ts | |
| 752 | +import { defineTool, z } from "@openassistant/sdk"; | |
| 411 | 753 | |
| 412 | −# Scaffold a new plugin | |
| 413 | −openassistant plugins create my-plugin | |
| 754 | +export const list = defineTool({ | |
| 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 | 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/`) | |
| 425 | − - Find config file (project-local walk-up, global fallback) | |
| 426 | − - Parse JSON with `${ENV_VAR}` interpolation | |
| 427 | − - Type-safe config shape with validation | |
| 428 | − - Export `resolveConfig(): OpenAssistantConfig` | |
| 784 | +1. **Change `ToolDefinition` in core** — `run` becomes `Promise`, drop `kind`, add `args` + `description` | |
| 785 | +2. **Update `createToolInvoker()`** — wrap async `run` in Effect internally, add zod validation | |
| 786 | +3. **Update `ToolCallReceipt`** — drop `kind` field | |
| 787 | +4. **Create `packages/sdk/`** — `defineTool()`, `z` re-export, `PluginContext` type | |
| 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`) | |
| 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 | |
| 791 | +### Phase 2: Config + Discovery | |
| 435 | 792 | |
| 436 | −3. **Plugin loader** (`packages/core/src/plugins/loader.ts`) | |
| 437 | − - Load TypeScript entry via `jiti` or dynamic `import()` | |
| 438 | − - Resolve export pattern (function vs ToolTree) | |
| 439 | − - Validate plugin config against schema | |
| 440 | − - Return `LoadedPlugin { id, toolTree, declarations? }` | |
| 793 | +6. **Config loader** (`packages/core/src/config/`) | |
| 794 | + - Config file resolution (env > project > global) | |
| 795 | + - JSONC parsing + `${ENV_VAR}` interpolation | |
| 796 | + - Zod schema for config shape validation | |
| 441 | 797 | |
| 442 | −4. **Plugin registry** (`packages/core/src/plugins/registry.ts`) | |
| 443 | − - Merge all loaded plugin ToolTrees into master tree | |
| 444 | − - Namespace under plugin IDs | |
| 445 | − - Detect collisions | |
| 446 | − - Export `createPluginRegistry(config): { toolTree: ToolTree, declarations: string }` | |
| 798 | +7. **Tool/Plugin discovery** (`packages/core/src/plugins/`) | |
| 799 | + - Scan convention directories (`tools/`, `plugins/`) | |
| 800 | + - Resolve plugin specifiers (npm, file://, relative path) | |
| 801 | + - Auto-dependency management for `.openassistant/` | |
| 802 | + - Deduplicate by ID | |
| 447 | 803 | |
| 448 | −### Phase 2: SDK Package | |
| 449 | − | |
| 450 | −5. **Create `packages/sdk/`** | |
| 451 | − - Barrel export of `defineTool`, types, `PluginContext`, `z`, `Effect` | |
| 452 | − - `package.json` with proper `exports` map | |
| 453 | − - Will eventually be publishable to npm (remove `private: true`) | |
| 804 | +8. **Plugin loader** (`packages/core/src/plugins/loader.ts`) | |
| 805 | + - Dynamic `import()` for `.ts` files | |
| 806 | + - Resolve export pattern (function vs ToolTree vs individual tools) | |
| 807 | + - Config validation against manifest schema | |
| 808 | + - Build merged ToolTree + declarations | |
| 454 | 809 | |
| 455 | 810 | ### Phase 3: Wire Into Runtime |
| 456 | 811 | |
| 457 | −6. **Update `apps/discord-bot/`** | |
| 458 | − - Replace hardcoded `createToolTree()` with `createPluginRegistry(config).toolTree` | |
| 459 | − - Replace hardcoded `TOOL_DECLARATIONS` with generated declarations | |
| 460 | − - Config-driven tool loading | |
| 461 | − | |
| 462 | −7. **Update `apps/gateway/`** | |
| 463 | − - Same plugin registry integration | |
| 812 | +9. **Update `apps/discord-bot/`** — use plugin registry instead of hardcoded tools | |
| 813 | +10. **Update `apps/gateway/`** — same | |
| 464 | 814 | |
| 465 | −### Phase 4: Developer Experience | |
| 815 | +### Phase 4: DX Polish | |
| 466 | 816 | |
| 467 | −8. **`openassistant init` command** (scaffold `.openassistant/config.json`) | |
| 468 | −9. **`openassistant plugins create` command** (scaffold plugin directory) | |
| 469 | −10. **Documentation** for plugin authors | |
| 817 | +11. **`openassistant init`** — scaffold `.openassistant/config.json` | |
| 818 | +12. **`openassistant tools create <name>`** — scaffold a tool file | |
| 819 | +13. **`openassistant plugins list`** — show discovered tools/plugins and their status | |
| 470 | 820 | |
| 471 | 821 | --- |
| 472 | 822 | |
| 473 | −## Key Decisions to Make | |
| 474 | − | |
| 475 | −### 1. Module Loading Strategy | |
| 476 | − | |
| 477 | −**Option A: jiti** (like OpenClaw) | |
| 478 | −- Pros: Battle-tested, works in Node and Bun, handles TypeScript natively. | |
| 479 | −- Cons: Another dependency, may have edge cases with Effect imports. | |
| 480 | − | |
| 481 | −**Option B: Bun native `import()`** | |
| 482 | −- Pros: Zero deps, Bun handles TypeScript natively, fast. | |
| 483 | −- Cons: Only works in Bun runtime, may need `file://` URL construction, plugin resolution is less configurable. | |
| 484 | − | |
| 485 | −**Option C: Bun `import()` with fallback to jiti for Node compat** | |
| 486 | −- Pros: Best of both. Fast path for Bun, fallback for portability. | |
| 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+. | |
| 823 | +## Key Decisions (Resolved) | |
| 824 | + | |
| 825 | +| Decision | Choice | Rationale | | |
| 826 | +|----------|--------|-----------| | |
| 827 | +| Tool authoring API | Plain async `run` function | Effect is a runtime internal. Tool authors shouldn't know it exists. | | |
| 828 | +| Tool policy | `approval: "auto" \| "required"` only | No `kind`/`risk` classification. Tool author sets approval default. Operators can override via config. | | |
| 829 | +| Module loading | Bun native `import()` | We're Bun-only. Zero deps. Bun handles .ts natively. | | |
| 830 | +| Config format | JSONC | Comments are essential for config files. | | |
| 831 | +| Config location | `.openassistant/config.json` | Follows OpenCode's `.opencode/` pattern. | | |
| 832 | +| Plugin specifiers | Flat array (like OpenCode) | Simpler than `{ paths, npm }`. Auto-detect type from specifier. | | |
| 833 | +| Tool schemas | Required zod `args` on every tool | Enables type declaration gen, validation, and docs from one source. | | |
| 834 | +| Namespacing | Plugin ID from manifest or dirname | Plugin tools namespaced automatically. Standalone tools are flat. | | |
| 835 | +| Hot reload | No (MVP) | Bun `--hot` covers dev. True registry reload is Phase 2+. | | |
| 836 | +| SDK package | Separate (`packages/sdk/`) | Clean boundary. No Effect dependency for tool authors. | | |
| 523 | 837 | |
| 524 | 838 | --- |
| 525 | 839 | |
| 526 | 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 | 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 | 857 | src/ |
| 544 | 858 | index.ts |
| 545 | 859 | codemode/ |
| 546 | − runner.ts # Existing, unchanged | |
| 860 | + runner.ts # UPDATED: ToolDefinition uses Promise, adds zod validation | |
| 861 | + runner.test.ts | |
| 547 | 862 | config/ |
| 548 | − loader.ts # NEW: config file resolution + parsing | |
| 549 | − types.ts # NEW: config schema types | |
| 550 | − interpolation.ts # NEW: ${ENV_VAR} expansion | |
| 863 | + loader.ts # NEW: config file resolution + parsing | |
| 864 | + types.ts # NEW: config shape (Zod schema) | |
| 865 | + interpolation.ts # NEW: ${ENV_VAR} expansion | |
| 551 | 866 | plugins/ |
| 552 | − discovery.ts # NEW: scan origins for plugin candidates | |
| 553 | − loader.ts # NEW: load plugin TypeScript modules | |
| 554 | − manifest.ts # NEW: parse/validate openassistant.json | |
| 555 | − registry.ts # NEW: merge ToolTrees, generate declarations | |
| 556 | − types.ts # NEW: DiscoveredPlugin, LoadedPlugin, etc. | |
| 867 | + discovery.ts # NEW: scan convention dirs + resolve specifiers | |
| 868 | + loader.ts # NEW: dynamic import + export resolution | |
| 869 | + manifest.ts # NEW: parse/validate openassistant.json | |
| 870 | + registry.ts # NEW: merge ToolTrees, deduplicate, enable/disable | |
| 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 | 874 | sdk/ |
| 558 | − package.json # NEW: @openassistant/sdk | |
| 875 | + package.json # NEW: @openassistant/sdk | |
| 559 | 876 | src/ |
| 560 | − index.ts # NEW: public API barrel | |
| 561 | − plugin-context.ts # NEW: PluginContext type | |
| 877 | + index.ts # NEW: exports defineTool, z, PluginContext | |
| 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 | 884 | ## Open Questions |
| 567 | 885 | |
| 568 | −1. **Should the SDK be a separate package or an export path from core?** | |
| 569 | − Leaning separate package (`packages/sdk/`) for clean dependency boundary. Plugin authors only need the SDK, not core internals. | |
| 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`). | |
| 886 | +1. **Do we validate tool inputs at runtime?** | |
| 887 | + Yes. Parse input through zod schema before calling `run`. This catches bad LLM-generated inputs early with clear error messages in receipts. | |
| 573 | 888 | |
| 574 | −3. **Do we need a plugin lockfile?** | |
| 575 | − Probably not for MVP. Config file is declarative. `bun.lock` in the host project handles npm-sourced plugins. | |
| 889 | +2. **How do we handle tools that need persistent state?** | |
| 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?** | |
| 578 | − Not for MVP. But the `register(ctx)` function pattern leaves the door open for `ctx.registerHook()`, `ctx.registerCommand()`, etc. later. | |
| 892 | +3. **Should we support `openassistant.jsonc` (comments)?** | |
| 893 | + Yes. Both `config.json` and `config.jsonc` should be accepted. | |
| @@ -13,6 +13,8 @@ · 12 unmodified lines | ||
| 13 | 13 | }, |
| 14 | 14 | "dependencies": { |
| 15 | 15 | "@ai-sdk/anthropic": "^2.0.0", |
| 16 | + "@effect/platform": "latest", | |
| 17 | + "@effect/rpc": "latest", | |
| 16 | 18 | "@openassistant/core": "workspace:*", |
| 17 | 19 | "@openassistant/reacord": "workspace:*", |
| 18 | 20 | "ai": "^6.0.73", |
| @@ -180,7 +180,7 @@ function buildAgentPrompt(userPrompt: string, now: Date): string { · 179 unmodified lines | ||
| 180 | 180 | "run_code expects JavaScript function-body code executed as new AsyncFunction('tools', code).", |
| 181 | 181 | "Inside code, call available tools directly like: await tools.calendar.update({ title, startsAt, notes }).", |
| 182 | 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 | 184 | `Current timestamp: ${now.toISOString()}`, |
| 185 | 185 | `User request: ${userPrompt}`, |
| 186 | 186 | ].join("\n"); |
| @@ -1,96 +1,23 @@ | ||
| 1 | −import type { CodeModeRunResult } from "@openassistant/core"; | |
| 2 | 1 | import { describe, expect, it } from "vitest"; |
| 3 | 2 | import { formatDiscordResponse } from "./format-response.js"; |
| 4 | 3 | |
| 5 | 4 | describe("formatDiscordResponse", () => { |
| 6 | − it("renders a polished verified calendar summary", () => { | |
| 5 | + it("returns trimmed text and footer", () => { | |
| 7 | 6 | const output = formatDiscordResponse({ |
| 8 | − prompt: "Add 3 calendar events", | |
| 9 | − text: "Added three events.", | |
| 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 | − ], | |
| 7 | + text: " Added dinner to your calendar. ", | |
| 8 | + footer: "planner details", | |
| 17 | 9 | }); |
| 18 | 10 | |
| 19 | − expect(output.message).toContain("Added 3 calendar events."); | |
| 20 | − expect(output.message).toContain("- Dinner -"); | |
| 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."); | |
| 11 | + expect(output.message).toBe("Added dinner to your calendar."); | |
| 12 | + expect(output.footer).toBe("planner details"); | |
| 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 | 16 | const output = formatDiscordResponse({ |
| 29 | − prompt: "Delete event", | |
| 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 | − ], | |
| 17 | + text: " ", | |
| 44 | 18 | }); |
| 45 | 19 | |
| 46 | − expect(output.message).toContain("Added 1 calendar event."); | |
| 47 | − expect(output.message).toContain("Recovered from 1 issue during execution."); | |
| 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"); | |
| 20 | + expect(output.message).toBe("Done."); | |
| 21 | + expect(output.footer).toBeUndefined(); | |
| 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 | 1 | export interface FormattedDiscordResponse { |
| 5 | 2 | message: string; |
| 6 | 3 | footer?: string; |
| 7 | 4 | } |
| 8 | 5 | |
| 9 | 6 | export function formatDiscordResponse(params: { |
| 10 | − prompt: string; | |
| 11 | 7 | text: string; |
| 12 | − planner: string; | |
| 13 | − provider: "claude"; | |
| 14 | − runs: AgentCodeRun[]; | |
| 8 | + footer?: string | undefined; | |
| 15 | 9 | }): FormattedDiscordResponse { |
| 16 | − const { text, planner, provider, runs } = params; | |
| 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 | − } | |
| 10 | + const message = params.text.trim().length > 0 ? params.text.trim() : "Done."; | |
| 45 | 11 | |
| 46 | 12 | return { |
| 47 | − message: truncateDiscord(messageSections.filter((value) => value.trim().length > 0).join("\n\n")), | |
| 48 | − ...(footerParts.length > 0 ? { footer: truncateFooter(footerParts.join(" | ")) } : {}), | |
| 13 | + message: truncateDiscord(message), | |
| 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 | 18 | function truncateDiscord(value: string): string { |
| 274 | 19 | const limit = 1_900; |
| 275 | 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 | 1 | import { makeReacord } from "@openassistant/reacord"; |
| 3 | 2 | import { |
| 4 | 3 | ChannelType, |
| @@ -10,20 +9,15 @@ import { · 5 unmodified lines | ||
| 10 | 9 | type SendableChannels, |
| 11 | 10 | } from "discord.js"; |
| 12 | 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 | 12 | import { AssistantReplyView, AssistantWorkingView } from "./discord-views.js"; |
| 17 | 13 | import { formatDiscordResponse } from "./format-response.js"; |
| 18 | −import { createToolTree } from "./tools.js"; | |
| 14 | +import { runGatewayTurn } from "./gateway-client.js"; | |
| 19 | 15 | |
| 20 | 16 | const token = Bun.env.DISCORD_BOT_TOKEN; |
| 21 | 17 | if (!token) { |
| 22 | 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 | 21 | const client = new Client({ |
| 28 | 22 | intents: [ |
| 29 | 23 | GatewayIntentBits.Guilds, |
| @@ -34,10 +28,7 @@ const client = new Client({ · 4 unmodified lines | ||
| 34 | 28 | partials: [Partials.Channel], |
| 35 | 29 | }); |
| 36 | 30 | |
| 37 | −const approvalBridge = new DiscordApprovalBridge(client, approvalTimeoutMs); | |
| 38 | 31 | const reacord = makeReacord(client); |
| 39 | −const calendarStore = new InMemoryCalendarStore(); | |
| 40 | −const tools = createToolTree(calendarStore); | |
| 41 | 32 | |
| 42 | 33 | client.once(Events.ClientReady, (readyClient) => { |
| 43 | 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 | 63 | try { |
| 73 | − const runner = createCodeModeRunner({ | |
| 74 | − tools, | |
| 75 | − requestApproval: (request) => | |
| 76 | − Effect.tryPromise({ | |
| 77 | − try: () => | |
| 78 | − approvalBridge.requestApproval({ | |
| 79 | − request, | |
| 80 | − channel: approvalChannel, | |
| 81 | − requesterId: message.author.id, | |
| 82 | − }), | |
| 83 | − catch: (error) => error, | |
| 84 | − }), | |
| 64 | + const generated = await runGatewayTurn({ | |
| 65 | + prompt: message.content, | |
| 66 | + requesterId: message.author.id, | |
| 67 | + channelId: message.channelId, | |
| 85 | 68 | }); |
| 86 | 69 | |
| 87 | − const generated = await runAgentLoop(message.content, (code) => | |
| 88 | − Effect.runPromise(runner.run({ code })), | |
| 89 | − ); | |
| 90 | 70 | const response = formatDiscordResponse({ |
| 91 | − prompt: message.content, | |
| 92 | − text: generated.text, | |
| 93 | − planner: generated.planner, | |
| 94 | − provider: generated.provider, | |
| 95 | − runs: generated.runs, | |
| 71 | + text: generated.message, | |
| 72 | + footer: generated.footer, | |
| 96 | 73 | }); |
| 97 | 74 | |
| 98 | 75 | instance.render(<AssistantReplyView message={response.message} footer={response.footer} />); |
| @@ -15,7 +15,13 @@ · 14 unmodified lines | ||
| 15 | 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 | 17 | "dependencies": { |
| 18 | + "@ai-sdk/anthropic": "^2.0.0", | |
| 19 | + "@effect/platform": "latest", | |
| 20 | + "@effect/platform-bun": "latest", | |
| 21 | + "@effect/rpc": "latest", | |
| 18 | 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"; | |
| 2 | −import { Effect } from "effect"; | |
| 1 | +import { HttpRouter } from "@effect/platform"; | |
| 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) => | |
| 5 | − Effect.sync(() => { | |
| 6 | − console.log(`[gateway] healthcheck=${status}`); | |
| 10 | +const port = Number(readEnv("OPENASSISTANT_GATEWAY_PORT") ?? "8787"); | |
| 11 | + | |
| 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 | 17 | "name": "@openassistant/discord-bot", |
| 18 | 18 | "dependencies": { |
| 19 | 19 | "@ai-sdk/anthropic": "^2.0.0", |
| 20 | + "@effect/platform": "latest", | |
| 21 | + "@effect/rpc": "latest", | |
| 20 | 22 | "@openassistant/core": "workspace:*", |
| 21 | 23 | "@openassistant/reacord": "workspace:*", |
| 22 | 24 | "ai": "^6.0.73", |
| @@ -32,13 +34,20 @@ · 9 unmodified lines | ||
| 32 | 34 | "apps/gateway": { |
| 33 | 35 | "name": "@openassistant/gateway", |
| 34 | 36 | "dependencies": { |
| 37 | + "@ai-sdk/anthropic": "^2.0.0", | |
| 38 | + "@effect/platform": "latest", | |
| 39 | + "@effect/platform-bun": "latest", | |
| 40 | + "@effect/rpc": "latest", | |
| 35 | 41 | "@openassistant/core": "workspace:*", |
| 42 | + "ai": "^6.0.73", | |
| 36 | 43 | "effect": "latest", |
| 44 | + "zod": "^4.3.6", | |
| 37 | 45 | }, |
| 38 | 46 | }, |
| 39 | 47 | "packages/core": { |
| 40 | 48 | "name": "@openassistant/core", |
| 41 | 49 | "dependencies": { |
| 50 | + "@effect/rpc": "latest", | |
| 42 | 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 | 86 | "packages": { |
| 62 | 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 | 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 | 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 | 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 | 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 | 131 | "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], |
| 97 | 132 | |
| 98 | 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 | 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 | 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 | 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 | 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 | 393 | "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], |
| 327 | 394 | |
| 328 | 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 | 399 | "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], |
| 331 | 400 | |
| 332 | 401 | "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], |
| @@ -347,6 +416,8 @@ · 14 unmodified lines | ||
| 347 | 416 | |
| 348 | 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 | 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 | 423 | "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], |
| @@ -16,6 +16,7 @@ · 15 unmodified lines | ||
| 16 | 16 | "test": "vitest run", |
| 17 | 17 | "test:watch": "vitest", |
| 18 | 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 | 20 | "dev:gateway": "bun run --cwd apps/gateway dev", |
| 20 | 21 | "dev:discord": "bun run --cwd apps/discord-bot dev" |
| 21 | 22 | }, |
| @@ -26,5 +27,21 @@ · 4 unmodified lines | ||
| 26 | 27 | "@typescript/native-preview": "7.0.0-dev.20260205.1", |
| 27 | 28 | "typescript": "latest", |
| 28 | 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 | 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 | 14 | "dependencies": { |
| 15 | + "@effect/rpc": "latest", | |
| 15 | 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 | 3 | export const healthcheck = Effect.succeed("ok"); |
| 4 | 4 | export * from "./codemode/runner.js"; |
| 5 | +export * from "./agent-rpc.js"; | |