feat(agents): add opencode plugin integration (#107)
* feat(agents): add opencode plugin integration
Adds @struktoai/mirage-agents/opencode subpath: mirageTools(ws) and
miragePlugin(ws) that expose a Mirage Workspace as OpenCode's read,
write, edit, ls, bash, glob, and grep tools. Includes docs page,
example plugin file, and 15 vitest tests against a RAM workspace.
* feat(agents): runnable opencode example + plain-string tool results
OpenCode pins @opencode-ai/plugin@1.1.6, which requires tool execute()
to return Promise<string>. Our tools returned {title, output, metadata}
which crashed with "text2.split is not a function" inside the OpenCode
subprocess. Return plain strings to match.
Drop "Mirage workspace" wording from tool descriptions so the agent
sees plain capability descriptions.
Adds examples/typescript/agents/opencode with a runnable runner that
spawns OpenCode via @opencode-ai/sdk, loads a RAM-backed plugin that
pre-writes /hello.txt, and prompts gpt-5.4-mini to cat it.
* feat(agents): per-session workspace resolver for opencode
mirageTools and miragePlugin now accept either a Workspace or a
(ctx: ToolContext) => Workspace | Promise<Workspace> resolver. The
resolver is called per tool invocation, so apps can pool workspaces
by sessionID, directory, or any other key.
Example updated to demo per-session pooling: each session gets its
own RAM workspace seeded with its sessionID; the runner drives two
sessions in parallel and shows each receives its own file.
* docs(agents): use opencode logo for card icon
* fix(docs): add trailing newline to opencode logo svg
This commit is contained in:
@@ -303,6 +303,7 @@
|
||||
"typescript/agents/pi",
|
||||
"typescript/agents/vercel",
|
||||
"typescript/agents/mastra",
|
||||
"typescript/agents/opencode",
|
||||
"typescript/agents/claude-code",
|
||||
"typescript/agents/codex"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512"><svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" fill="#131010"></rect>
|
||||
<path d="M320 224V352H192V224H320Z" fill="#5A5858"></path>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z" fill="white"></path>
|
||||
</svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
|
||||
@media (prefers-color-scheme: dark) { :root { filter: none; } }
|
||||
</style></svg>
|
||||
|
After Width: | Height: | Size: 613 B |
@@ -28,6 +28,9 @@ For browser apps, swap `@struktoai/mirage-node` for `@struktoai/mirage-browser`.
|
||||
<Card title="Mastra" icon="wand-magic-sparkles" href="/typescript/agents/mastra">
|
||||
For [Mastra](https://mastra.ai) `Agent` definitions.
|
||||
</Card>
|
||||
<Card title="OpenCode" icon="/images/opencode-logo.svg" href="/typescript/agents/opencode">
|
||||
Plugin tools for [OpenCode](https://opencode.ai) that swap `read`, `write`, `edit`, and `bash`.
|
||||
</Card>
|
||||
<Card title="Claude Code" icon="/images/claude-logo.svg" href="/typescript/agents/claude-code">
|
||||
Mount a workspace via FUSE and run `claude` against it.
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: OpenCode
|
||||
description: Hand a Mirage workspace to OpenCode as a plugin via the @struktoai/mirage-agents/opencode adapter.
|
||||
icon: /images/opencode-logo.svg
|
||||
---
|
||||
|
||||
[OpenCode](https://opencode.ai) is an open-source coding agent that runs in your terminal, IDE, or desktop. Its plugin API lets you register tools that take precedence over the built-ins by name, so a Mirage plugin can swap OpenCode's `read`, `write`, `edit`, `ls`, `bash`, `glob`, and `grep` to operate on a `Workspace` instead of the local disk. Once installed, anything OpenCode reads or writes flows through Mirage and reaches every mounted resource (S3, GCS, Postgres, Linear, Slack, ...) as files.
|
||||
|
||||
## Install
|
||||
|
||||
`@struktoai/mirage-agents/opencode` is a tool factory plus a thin plugin shim. Pair it with `@struktoai/mirage-node` for any Node or Bun project.
|
||||
|
||||
```bash
|
||||
bun add @struktoai/mirage-agents @struktoai/mirage-node
|
||||
```
|
||||
|
||||
OpenCode runs `bun install` on startup against `.opencode/package.json`, so this works whether you use bun, pnpm, or npm in the host project.
|
||||
|
||||
## Usage
|
||||
|
||||
Drop a plugin file into `.opencode/plugins/` (project-local) or `~/.config/opencode/plugins/` (global). OpenCode auto-discovers it and merges the returned `tool` dict over its built-ins.
|
||||
|
||||
```ts .opencode/plugins/mirage.ts
|
||||
import { MountMode, OpsRegistry, RAMResource, Workspace } from '@struktoai/mirage-node'
|
||||
import { miragePlugin } from '@struktoai/mirage-agents/opencode'
|
||||
|
||||
const ram = new RAMResource()
|
||||
const ops = new OpsRegistry()
|
||||
for (const op of ram.ops()) ops.register(op)
|
||||
const ws = new Workspace({ '/': ram }, { mode: MountMode.WRITE, ops })
|
||||
|
||||
export default miragePlugin(ws)
|
||||
```
|
||||
|
||||
Add the dependencies to `.opencode/package.json` so OpenCode resolves them on startup:
|
||||
|
||||
```json .opencode/package.json
|
||||
{
|
||||
"dependencies": {
|
||||
"@struktoai/mirage-agents": "*",
|
||||
"@struktoai/mirage-node": "*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then run `opencode` in that directory. The agent's `read /hello.txt`, `bash 'find /'`, `grep foo /`, etc. now all flow through the Mirage workspace.
|
||||
|
||||
## Exports
|
||||
|
||||
| Symbol | Purpose |
|
||||
| --- | --- |
|
||||
| `mirageTools(ws)` | Returns `{ read, write, edit, ls, bash, glob, grep }`, each shaped as an OpenCode `ToolDefinition`. Use this if you want to compose with other plugin hooks. |
|
||||
| `miragePlugin(ws)` | Returns a `Plugin` function for default-exporting from a plugin file. Wraps `mirageTools` under the `tool` hook. |
|
||||
|
||||
### Tool reference
|
||||
|
||||
Each tool's `execute` returns a plain string (matching the OpenCode plugin contract).
|
||||
|
||||
| Tool | Input | Output |
|
||||
| --- | --- | --- |
|
||||
| `read` | `{ filePath }` | UTF-8 text, or a binary-stub note for non-text files |
|
||||
| `write` | `{ filePath, content }` | Confirmation line (auto-mkdirs parent) |
|
||||
| `edit` | `{ filePath, oldString, newString, replaceAll? }` | Confirmation line with occurrence count |
|
||||
| `ls` | `{ path }` | Newline-separated entries, dirs suffixed with `/` |
|
||||
| `bash` | `{ command }` | Merged stdout/stderr (routes through Mirage shell) |
|
||||
| `glob` | `{ pattern, path? }` | `find -name` results |
|
||||
| `grep` | `{ pattern, path? }` | `grep -rn` results |
|
||||
|
||||
`bash`, `glob`, and `grep` go through `ws.execute()`, so they pick up every Mirage shell builtin (resource-aware `head`, `tail`, `find`, `grep`, etc.) across mounts.
|
||||
|
||||
## Examples
|
||||
|
||||
- [`examples/typescript/agents/opencode/ram_opencode.ts`](https://github.com/strukto-ai/mirage/blob/main/examples/typescript/agents/opencode/ram_opencode.ts), an end-to-end runner that spawns OpenCode via `@opencode-ai/sdk`, loads a RAM-backed plugin that pre-writes `/hello.txt`, and asks `gpt-5.4-mini` to `cat` it.
|
||||
@@ -0,0 +1,44 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import { MountMode, OpsRegistry, RAMResource, Workspace } from '@struktoai/mirage-node'
|
||||
import { miragePlugin } from '@struktoai/mirage-agents/opencode'
|
||||
|
||||
async function makeWs(sessionID: string): Promise<Workspace> {
|
||||
const ram = new RAMResource()
|
||||
const ops = new OpsRegistry()
|
||||
for (const op of ram.ops()) ops.register(op)
|
||||
const ws = new Workspace({ '/': ram }, { mode: MountMode.WRITE, ops })
|
||||
await ws.fs.writeFile('/hello.txt', `hi from session ${sessionID}`)
|
||||
return ws
|
||||
}
|
||||
|
||||
const workspaces = new Map<string, Workspace>()
|
||||
const pending = new Map<string, Promise<Workspace>>()
|
||||
|
||||
async function wsFor(ctx: { sessionID: string }): Promise<Workspace> {
|
||||
const cached = workspaces.get(ctx.sessionID)
|
||||
if (cached !== undefined) return cached
|
||||
const inflight = pending.get(ctx.sessionID)
|
||||
if (inflight !== undefined) return inflight
|
||||
const p = makeWs(ctx.sessionID).then((ws) => {
|
||||
workspaces.set(ctx.sessionID, ws)
|
||||
pending.delete(ctx.sessionID)
|
||||
return ws
|
||||
})
|
||||
pending.set(ctx.sessionID, p)
|
||||
return p
|
||||
}
|
||||
|
||||
export default miragePlugin(wsFor)
|
||||
@@ -0,0 +1,70 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import { config as loadEnv } from 'dotenv'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
createOpencodeServer,
|
||||
createOpencodeClient,
|
||||
type OpencodeClient,
|
||||
} from '@opencode-ai/sdk'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
loadEnv({ path: resolve(here, '../../../../.env.development') })
|
||||
|
||||
if (process.env.OPENAI_API_KEY === undefined || process.env.OPENAI_API_KEY === '') {
|
||||
console.error('OPENAI_API_KEY missing in .env.development')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.chdir(here)
|
||||
|
||||
const server = await createOpencodeServer({
|
||||
timeout: 30_000,
|
||||
config: {
|
||||
provider: {
|
||||
openai: {
|
||||
npm: '@ai-sdk/openai',
|
||||
name: 'OpenAI',
|
||||
models: { 'gpt-5.4-mini': { name: 'GPT-5.4 mini' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const client = createOpencodeClient({ baseUrl: server.url })
|
||||
|
||||
const MODEL = { providerID: 'openai', modelID: 'gpt-5.4-mini' }
|
||||
const PROMPT = 'Run `cat /hello.txt` with the bash tool and report exactly what it printed.'
|
||||
|
||||
async function runSession(c: OpencodeClient, title: string): Promise<void> {
|
||||
const session = await c.session.create({ body: { title } })
|
||||
const sessionId = (session.data as { id: string }).id
|
||||
const result = await c.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: { model: MODEL, parts: [{ type: 'text', text: PROMPT }] },
|
||||
})
|
||||
const data = result.data as { parts?: Array<{ type: string; text?: string }> }
|
||||
for (const part of data.parts ?? []) {
|
||||
if (part.type === 'text' && part.text !== undefined && part.text.length > 0) {
|
||||
console.log(`[${title}] ${part.text}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all([runSession(client, 'alice'), runSession(client, 'bob')])
|
||||
} finally {
|
||||
server.close()
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
"@mariozechner/pi-coding-agent": "^0.70.2",
|
||||
"@mastra/core": "^1.29.1",
|
||||
"@opencode-ai/sdk": "^1.15.11",
|
||||
"@openai/agents": "^0.8.0",
|
||||
"@struktoai/mirage-agents": "workspace:*",
|
||||
"@struktoai/mirage-browser": "workspace:*",
|
||||
|
||||
@@ -57,6 +57,10 @@
|
||||
"./mastra": {
|
||||
"types": "./dist/mastra/index.d.ts",
|
||||
"import": "./dist/mastra/index.js"
|
||||
},
|
||||
"./opencode": {
|
||||
"types": "./dist/opencode/index.d.ts",
|
||||
"import": "./dist/opencode/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { OpsRegistry, RAMResource, MountMode, Workspace } from '@struktoai/mirage-node'
|
||||
import { mirageTools, miragePlugin } from './index.ts'
|
||||
|
||||
function mkWs(): Workspace {
|
||||
const ram = new RAMResource()
|
||||
const ops = new OpsRegistry()
|
||||
for (const op of ram.ops()) ops.register(op)
|
||||
return new Workspace({ '/': ram }, { mode: MountMode.WRITE, ops })
|
||||
}
|
||||
|
||||
async function callTool(t: unknown, input: unknown): Promise<string> {
|
||||
const exec = (t as { execute?: (input: unknown, ctx: unknown) => unknown }).execute
|
||||
if (typeof exec !== 'function') throw new Error('tool has no execute')
|
||||
const ctx = {
|
||||
sessionID: 's',
|
||||
messageID: 'm',
|
||||
agent: 'a',
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
const result = await exec(input, ctx)
|
||||
return result as string
|
||||
}
|
||||
|
||||
describe('opencode mirageTools.read', () => {
|
||||
it('reads a text file', async () => {
|
||||
const ws = mkWs()
|
||||
await ws.fs.writeFile('/notes.txt', 'hello')
|
||||
const out = await callTool(mirageTools(ws).read, { filePath: '/notes.txt' })
|
||||
expect(out).toBe('hello')
|
||||
})
|
||||
|
||||
it('returns error message for missing file', async () => {
|
||||
const out = await callTool(mirageTools(mkWs()).read, { filePath: '/missing.txt' })
|
||||
expect(out.startsWith('Error:')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns binary stub for non-text files', async () => {
|
||||
const ws = mkWs()
|
||||
await ws.fs.writeFile('/blob.bin', new Uint8Array([0, 1, 2, 3]))
|
||||
const out = await callTool(mirageTools(ws).read, { filePath: '/blob.bin' })
|
||||
expect(out).toContain('Binary file')
|
||||
})
|
||||
})
|
||||
|
||||
describe('opencode mirageTools.write', () => {
|
||||
it('writes a new file', async () => {
|
||||
const ws = mkWs()
|
||||
const out = await callTool(mirageTools(ws).write, { filePath: '/out.txt', content: 'data' })
|
||||
expect(out).toContain('/out.txt')
|
||||
expect(await ws.fs.readFileText('/out.txt')).toBe('data')
|
||||
})
|
||||
|
||||
it('creates missing parent directories', async () => {
|
||||
const ws = mkWs()
|
||||
await callTool(mirageTools(ws).write, { filePath: '/a/b/c.txt', content: 'x' })
|
||||
expect(await ws.fs.readFileText('/a/b/c.txt')).toBe('x')
|
||||
})
|
||||
})
|
||||
|
||||
describe('opencode mirageTools.edit', () => {
|
||||
it('replaces single occurrence', async () => {
|
||||
const ws = mkWs()
|
||||
await ws.fs.writeFile('/f.txt', 'foo bar baz')
|
||||
const out = await callTool(mirageTools(ws).edit, {
|
||||
filePath: '/f.txt',
|
||||
oldString: 'bar',
|
||||
newString: 'BAR',
|
||||
})
|
||||
expect(out).toContain('1 occurrence')
|
||||
expect(await ws.fs.readFileText('/f.txt')).toBe('foo BAR baz')
|
||||
})
|
||||
|
||||
it('rejects multiple occurrences without replaceAll', async () => {
|
||||
const ws = mkWs()
|
||||
await ws.fs.writeFile('/f.txt', 'aa aa')
|
||||
const out = await callTool(mirageTools(ws).edit, {
|
||||
filePath: '/f.txt',
|
||||
oldString: 'aa',
|
||||
newString: 'X',
|
||||
})
|
||||
expect(out).toContain('appears 2 times')
|
||||
})
|
||||
|
||||
it('replaces all when replaceAll is true', async () => {
|
||||
const ws = mkWs()
|
||||
await ws.fs.writeFile('/f.txt', 'aa aa')
|
||||
const out = await callTool(mirageTools(ws).edit, {
|
||||
filePath: '/f.txt',
|
||||
oldString: 'aa',
|
||||
newString: 'X',
|
||||
replaceAll: true,
|
||||
})
|
||||
expect(out).toContain('2 occurrences')
|
||||
expect(await ws.fs.readFileText('/f.txt')).toBe('X X')
|
||||
})
|
||||
|
||||
it('returns error when string not found', async () => {
|
||||
const ws = mkWs()
|
||||
await ws.fs.writeFile('/f.txt', 'hello')
|
||||
const out = await callTool(mirageTools(ws).edit, {
|
||||
filePath: '/f.txt',
|
||||
oldString: 'world',
|
||||
newString: 'X',
|
||||
})
|
||||
expect(out).toContain('string not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('opencode mirageTools.ls', () => {
|
||||
it('lists entries with trailing slash for dirs', async () => {
|
||||
const ws = mkWs()
|
||||
await ws.fs.writeFile('/a.txt', 'a')
|
||||
await ws.fs.mkdir('/d')
|
||||
const out = await callTool(mirageTools(ws).ls, { path: '/' })
|
||||
const entries = out.split('\n').sort()
|
||||
expect(entries).toContain('/a.txt')
|
||||
expect(entries).toContain('/d/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('opencode mirageTools.bash', () => {
|
||||
it('runs a shell command and returns stdout', async () => {
|
||||
const out = await callTool(mirageTools(mkWs()).bash, { command: 'echo hello' })
|
||||
expect(out).toBe('hello')
|
||||
})
|
||||
|
||||
it('captures stderr on failure', async () => {
|
||||
const out = await callTool(mirageTools(mkWs()).bash, { command: 'cat /nope.txt' })
|
||||
expect(out.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('opencode mirageTools.glob', () => {
|
||||
it('finds files matching a name pattern', async () => {
|
||||
const ws = mkWs()
|
||||
await ws.fs.writeFile('/a.ts', '')
|
||||
await ws.fs.writeFile('/b.ts', '')
|
||||
await ws.fs.writeFile('/c.md', '')
|
||||
const out = await callTool(mirageTools(ws).glob, { pattern: '*.ts' })
|
||||
expect(out).toContain('/a.ts')
|
||||
expect(out).toContain('/b.ts')
|
||||
expect(out).not.toContain('/c.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('opencode mirageTools.grep', () => {
|
||||
it('finds text matches across files', async () => {
|
||||
const ws = mkWs()
|
||||
await ws.fs.writeFile('/a.txt', 'hello world')
|
||||
await ws.fs.writeFile('/b.txt', 'goodbye')
|
||||
const out = await callTool(mirageTools(ws).grep, { pattern: 'hello' })
|
||||
expect(out).toContain('/a.txt')
|
||||
expect(out).toContain('hello')
|
||||
})
|
||||
})
|
||||
|
||||
describe('opencode miragePlugin', () => {
|
||||
it('returns a plugin that registers tools', async () => {
|
||||
const ws = mkWs()
|
||||
const plugin = miragePlugin(ws)
|
||||
const hooks = await plugin({})
|
||||
expect(hooks.tool).toBeDefined()
|
||||
expect(Object.keys(hooks.tool ?? {}).sort()).toEqual([
|
||||
'bash',
|
||||
'edit',
|
||||
'glob',
|
||||
'grep',
|
||||
'ls',
|
||||
'read',
|
||||
'write',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('opencode resolver (per-session workspace)', () => {
|
||||
it('routes each session to its own workspace', async () => {
|
||||
const wsA = mkWs()
|
||||
const wsB = mkWs()
|
||||
await wsA.fs.writeFile('/note.txt', 'alice')
|
||||
await wsB.fs.writeFile('/note.txt', 'bob')
|
||||
const tools = mirageTools((ctx) => (ctx.sessionID === 'a' ? wsA : wsB))
|
||||
|
||||
const exec = (t: unknown) =>
|
||||
(t as { execute: (a: unknown, c: unknown) => Promise<string> }).execute
|
||||
const ctxA = { sessionID: 'a', messageID: 'm', agent: '', abort: new AbortController().signal }
|
||||
const ctxB = { sessionID: 'b', messageID: 'm', agent: '', abort: new AbortController().signal }
|
||||
|
||||
expect(await exec(tools.read)({ filePath: '/note.txt' }, ctxA)).toBe('alice')
|
||||
expect(await exec(tools.read)({ filePath: '/note.txt' }, ctxB)).toBe('bob')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,268 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import type { Workspace } from '@struktoai/mirage-node'
|
||||
import { z } from 'zod'
|
||||
|
||||
export interface ToolContext {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
abort: AbortSignal
|
||||
}
|
||||
|
||||
export type WsResolver = (ctx: ToolContext) => Workspace | Promise<Workspace>
|
||||
export type WsLike = Workspace | WsResolver
|
||||
|
||||
interface ToolDefinition<Args extends z.ZodRawShape = z.ZodRawShape> {
|
||||
description: string
|
||||
args: Args
|
||||
execute(args: z.infer<z.ZodObject<Args>>, context: ToolContext): Promise<string>
|
||||
}
|
||||
|
||||
function tool<Args extends z.ZodRawShape>(input: ToolDefinition<Args>): ToolDefinition<Args> {
|
||||
return input
|
||||
}
|
||||
|
||||
function isResolver(ws: WsLike): ws is WsResolver {
|
||||
return typeof ws === 'function'
|
||||
}
|
||||
|
||||
async function resolveWs(ws: WsLike, ctx: ToolContext): Promise<Workspace> {
|
||||
return isResolver(ws) ? ws(ctx) : ws
|
||||
}
|
||||
|
||||
function parentOf(path: string): string {
|
||||
const trimmed = path.replace(/\/+$/, '')
|
||||
const idx = trimmed.lastIndexOf('/')
|
||||
if (idx <= 0) return '/'
|
||||
return trimmed.slice(0, idx)
|
||||
}
|
||||
|
||||
async function ensureParent(ws: Workspace, path: string): Promise<void> {
|
||||
const parent = parentOf(path)
|
||||
if (parent === '/' || parent === '') return
|
||||
if (await ws.fs.exists(parent)) return
|
||||
await ensureParent(ws, parent)
|
||||
try {
|
||||
await ws.fs.mkdir(parent)
|
||||
} catch (err) {
|
||||
if (!(await ws.fs.exists(parent))) throw err
|
||||
}
|
||||
}
|
||||
|
||||
const TEXT_EXTS = new Set([
|
||||
'txt',
|
||||
'md',
|
||||
'json',
|
||||
'jsonl',
|
||||
'yaml',
|
||||
'yml',
|
||||
'csv',
|
||||
'tsv',
|
||||
'xml',
|
||||
'html',
|
||||
'htm',
|
||||
'js',
|
||||
'mjs',
|
||||
'cjs',
|
||||
'ts',
|
||||
'tsx',
|
||||
'jsx',
|
||||
'py',
|
||||
'rb',
|
||||
'rs',
|
||||
'go',
|
||||
'java',
|
||||
'c',
|
||||
'cpp',
|
||||
'h',
|
||||
'hpp',
|
||||
'sh',
|
||||
'bash',
|
||||
'zsh',
|
||||
'sql',
|
||||
'log',
|
||||
'env',
|
||||
'ini',
|
||||
'toml',
|
||||
'conf',
|
||||
'cfg',
|
||||
])
|
||||
|
||||
function extOf(path: string): string {
|
||||
const dot = path.lastIndexOf('.')
|
||||
if (dot < 0) return ''
|
||||
return path.slice(dot + 1).toLowerCase()
|
||||
}
|
||||
|
||||
function isLikelyText(path: string): boolean {
|
||||
return TEXT_EXTS.has(extOf(path))
|
||||
}
|
||||
|
||||
function errMsg(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
||||
export function mirageTools(ws: WsLike): Record<string, ToolDefinition> {
|
||||
const read = tool({
|
||||
description:
|
||||
'Read a file. Returns UTF-8 text for source/data files; for binary files returns a metadata stub. Use the bash tool to inspect binaries.',
|
||||
args: {
|
||||
filePath: z.string().describe('Absolute path of the file to read.'),
|
||||
},
|
||||
execute: async ({ filePath }, ctx) => {
|
||||
const w = await resolveWs(ws, ctx)
|
||||
let bytes: Uint8Array
|
||||
try {
|
||||
bytes = await w.fs.readFile(filePath)
|
||||
} catch (err) {
|
||||
return `Error: ${errMsg(err)}`
|
||||
}
|
||||
if (isLikelyText(filePath)) {
|
||||
return new TextDecoder('utf-8', { fatal: false }).decode(bytes)
|
||||
}
|
||||
return `Binary file ${filePath} (${String(bytes.length)} bytes). Use the bash tool with head/file/wc/od to inspect.`
|
||||
},
|
||||
})
|
||||
|
||||
const write = tool({
|
||||
description: 'Write content to a file. Creates missing parent directories.',
|
||||
args: {
|
||||
filePath: z.string().describe('Absolute path of the file to write.'),
|
||||
content: z.string().describe('UTF-8 text content to write.'),
|
||||
},
|
||||
execute: async ({ filePath, content }, ctx) => {
|
||||
const w = await resolveWs(ws, ctx)
|
||||
await ensureParent(w, filePath)
|
||||
await w.fs.writeFile(filePath, content)
|
||||
return `Wrote ${String(content.length)} bytes to ${filePath}`
|
||||
},
|
||||
})
|
||||
|
||||
const edit = tool({
|
||||
description:
|
||||
'Replace a string inside an existing file. Errors if the string appears more than once unless replaceAll is true.',
|
||||
args: {
|
||||
filePath: z.string().describe('Absolute path of the file to edit.'),
|
||||
oldString: z.string().describe('The exact string to replace.'),
|
||||
newString: z.string().describe('The replacement string.'),
|
||||
replaceAll: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Replace every occurrence rather than requiring a unique match.'),
|
||||
},
|
||||
execute: async ({ filePath, oldString, newString, replaceAll }, ctx) => {
|
||||
const w = await resolveWs(ws, ctx)
|
||||
let current: string
|
||||
try {
|
||||
current = await w.fs.readFileText(filePath)
|
||||
} catch {
|
||||
return `Error: file '${filePath}' not found`
|
||||
}
|
||||
const count = current.split(oldString).length - 1
|
||||
if (count === 0) {
|
||||
return `Error: string not found in file: '${oldString}'`
|
||||
}
|
||||
if (count > 1 && replaceAll !== true) {
|
||||
return `Error: string '${oldString}' appears ${String(count)} times. Use replaceAll=true`
|
||||
}
|
||||
const next =
|
||||
replaceAll === true
|
||||
? current.split(oldString).join(newString)
|
||||
: current.replace(oldString, newString)
|
||||
await w.fs.writeFile(filePath, next)
|
||||
const occurrences = replaceAll === true ? count : 1
|
||||
return `Edited ${filePath} (${String(occurrences)} occurrence${occurrences === 1 ? '' : 's'})`
|
||||
},
|
||||
})
|
||||
|
||||
const ls = tool({
|
||||
description: 'List entries of a directory.',
|
||||
args: {
|
||||
path: z.string().describe('Absolute directory path.'),
|
||||
},
|
||||
execute: async ({ path }, ctx) => {
|
||||
const w = await resolveWs(ws, ctx)
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = await w.fs.readdir(path)
|
||||
} catch (err) {
|
||||
return `Error: ${errMsg(err)}`
|
||||
}
|
||||
const lines: string[] = []
|
||||
for (const entry of entries) {
|
||||
const isDir = await w.fs.isDir(entry)
|
||||
lines.push(isDir ? `${entry}/` : entry)
|
||||
}
|
||||
return lines.join('\n')
|
||||
},
|
||||
})
|
||||
|
||||
const bash = tool({
|
||||
description: 'Execute a shell command and return stdout, stderr, and exit code.',
|
||||
args: {
|
||||
command: z.string().describe('The shell command to execute.'),
|
||||
},
|
||||
execute: async ({ command }, ctx) => {
|
||||
const w = await resolveWs(ws, ctx)
|
||||
const io = await w.execute(command)
|
||||
const parts: string[] = []
|
||||
if (io.stdoutText.length > 0) parts.push(io.stdoutText)
|
||||
if (io.stderrText.length > 0) parts.push(io.stderrText)
|
||||
return parts.join('\n').trim()
|
||||
},
|
||||
})
|
||||
|
||||
const glob = tool({
|
||||
description: 'Find files matching a name pattern.',
|
||||
args: {
|
||||
pattern: z.string().describe('Filename pattern (e.g. "*.ts").'),
|
||||
path: z.string().optional().describe('Directory to search under. Defaults to /.'),
|
||||
},
|
||||
execute: async ({ pattern, path }, ctx) => {
|
||||
const w = await resolveWs(ws, ctx)
|
||||
const root = path ?? '/'
|
||||
const io = await w.execute(`find ${root} -name '${pattern.replace(/'/g, "'\\''")}'`)
|
||||
return io.stdoutText.trim()
|
||||
},
|
||||
})
|
||||
|
||||
const grep = tool({
|
||||
description: 'Search for a regex pattern in files.',
|
||||
args: {
|
||||
pattern: z.string().describe('Pattern to search for.'),
|
||||
path: z.string().optional().describe('Directory or file to search under. Defaults to /.'),
|
||||
},
|
||||
execute: async ({ pattern, path }, ctx) => {
|
||||
const w = await resolveWs(ws, ctx)
|
||||
const root = path ?? '/'
|
||||
const escaped = pattern.replace(/'/g, "'\\''")
|
||||
const io = await w.execute(`grep -rn '${escaped}' ${root}`)
|
||||
return io.stdoutText.trim()
|
||||
},
|
||||
})
|
||||
|
||||
return { read, write, edit, ls, bash, glob, grep }
|
||||
}
|
||||
|
||||
interface Hooks {
|
||||
tool?: Record<string, ToolDefinition>
|
||||
}
|
||||
|
||||
type PluginFn = (input: unknown) => Promise<Hooks>
|
||||
|
||||
export function miragePlugin(ws: WsLike): PluginFn {
|
||||
return () => Promise.resolve({ tool: mirageTools(ws) })
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export default defineConfig({
|
||||
'src/pi/index.ts',
|
||||
'src/vercel/index.ts',
|
||||
'src/mastra/index.ts',
|
||||
'src/opencode/index.ts',
|
||||
],
|
||||
format: ['esm'],
|
||||
dts: {
|
||||
|
||||
Generated
+11
-9
@@ -83,6 +83,9 @@ importers:
|
||||
'@openai/agents':
|
||||
specifier: ^0.8.0
|
||||
version: 0.8.5(@cfworker/json-schema@4.1.1)(ws@8.20.0)(zod@4.3.6)
|
||||
'@opencode-ai/sdk':
|
||||
specifier: ^1.15.11
|
||||
version: 1.15.11
|
||||
'@struktoai/mirage-agents':
|
||||
specifier: workspace:*
|
||||
version: link:../../typescript/packages/agents
|
||||
@@ -1390,6 +1393,9 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^4.0.0
|
||||
|
||||
'@opencode-ai/sdk@1.15.11':
|
||||
resolution: {integrity: sha512-IyYyDVsO8SKbKbkSadHpDuYnYC+2vmEeLU+rW+rH2M54Sigq6l3gDHno16+U6SRut+lowbph7v/ry3WbV67V3w==}
|
||||
|
||||
'@opentelemetry/api-logs@0.217.0':
|
||||
resolution: {integrity: sha512-Cdq0jW2lknrNfrAm92MyEAvpe2cRsKjdnQLHUL6xRA4IVUnsWx6P65E7NcUO0Y+L4w1Aee5iV8FvjSwd+lrs9A==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@@ -7011,6 +7017,10 @@ snapshots:
|
||||
- utf-8-validate
|
||||
- ws
|
||||
|
||||
'@opencode-ai/sdk@1.15.11':
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
|
||||
'@opentelemetry/api-logs@0.217.0':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
@@ -8094,14 +8104,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
vite: 7.3.2(@types/node@22.19.17)(tsx@4.21.0)(yaml@2.8.3)
|
||||
|
||||
'@vitest/mocker@3.2.4(vite@7.3.2(@types/node@24.12.2)(tsx@4.21.0)(yaml@2.8.3))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.4
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 7.3.2(@types/node@24.12.2)(tsx@4.21.0)(yaml@2.8.3)
|
||||
|
||||
'@vitest/pretty-format@3.2.4':
|
||||
dependencies:
|
||||
tinyrainbow: 2.0.0
|
||||
@@ -11332,7 +11334,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
'@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@24.12.2)(tsx@4.21.0)(yaml@2.8.3))
|
||||
'@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@22.19.17)(tsx@4.21.0)(yaml@2.8.3))
|
||||
'@vitest/pretty-format': 3.2.4
|
||||
'@vitest/runner': 3.2.4
|
||||
'@vitest/snapshot': 3.2.4
|
||||
|
||||
Reference in New Issue
Block a user