opencode-raven 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ayman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,111 +1,164 @@
1
- # opencode-raven
2
-
3
- Search-first subagent for [opencode](https://opencode.ai) — intercepts search tool calls from other agents and routes them to a dedicated **@raven** agent with Context7, Exa AI, and Grep.app MCPs.
4
-
5
- ## Why?
6
-
7
- Other agents (orchestrator, fixer, etc.) waste tokens and context on search tools. Raven gives them a single delegation target that's purpose-built for search, with the right MCPs wired in and a compact-output prompt.
8
-
9
- ## Install
10
-
11
- ```bash
12
- bun add opencode-raven
13
- ```
14
-
15
- Then add to your `opencode.jsonc`:
16
-
17
- ```jsonc
18
- {
19
- "plugin": ["opencode-raven"]
20
- }
21
- ```
22
-
23
- Restart opencode.
24
-
25
- ## Commands
26
-
27
- | Command | Action |
28
- |---------|--------|
29
- | `/raven` | Show status — enabled/disabled, current model |
30
- | `/raven on` | Enable search tool redirection to @raven (default) |
31
- | `/raven off` | Disable interception — all agents can use search tools directly |
32
- | `/raven model <name>` | Change Raven's model (e.g. `/raven model opencode/deepseek-v4-flash-free`) |
33
-
34
- Config persists across restarts in `raven-config.json` (next to your `opencode.jsonc`).
35
-
36
- ## Configuration
37
-
38
- ### raven-config.json
39
-
40
- Created automatically in your project root on first toggle. Edit manually or use `/raven` commands:
41
-
42
- ```json
43
- {
44
- "enabled": true,
45
- "model": "opencode/deepseek-v4-flash-free"
46
- }
47
- ```
48
-
49
- | Field | Default | Description |
50
- |-------|---------|-------------|
51
- | `enabled` | `true` | Whether search tool interception is active |
52
- | `model` | *(from Raven.md)* | Override Raven's model without editing package files |
53
-
54
- ### MCP servers
55
-
56
- All three MCPs are enabled by default:
57
-
58
- | MCP | URL | Notes |
59
- |-----|-----|-------|
60
- | Context7 | `https://mcp.context7.com/mcp` | No API key needed |
61
- | Exa AI | `https://mcp.exa.ai/mcp` | Requires Exa account |
62
- | Grep.app | `https://mcp.grep.app` | No API key needed |
63
-
64
- To disable an MCP, override it in your `opencode.jsonc`:
65
-
66
- ```jsonc
67
- {
68
- "mcp": {
69
- "exa": { "type": "remote", "url": "https://mcp.exa.ai/mcp", "enabled": false }
70
- }
71
- }
72
- ```
73
-
74
- ## How it works
75
-
76
- | Hook | What it does |
77
- |------|--------------|
78
- | `config` | Registers Raven agent, adds Context7/Exa/Grep.app MCPs, loads MCP guidance |
79
- | `chat.message` | Tracks Raven's session IDs so its own tools aren't blocked |
80
- | `command.execute.before` | Handles `/raven on\|off\|model\|status` |
81
- | `tool.execute.before` | Nukes search tool args for non-Raven agents (no wasted API calls) |
82
- | `tool.execute.after` | Replaces search tool output with redirect to Raven |
83
-
84
- **Blocked tools** (redirected to Raven for all agents except Raven itself):
85
-
86
- | Tool | Raven's equivalent |
87
- |------|-------------------|
88
- | `websearch_web_search_exa` | Exa AI (web search) |
89
- | `exa_web_search_exa` | Exa AI (web search via MCP) |
90
- | `exa_web_fetch_exa` | Exa AI (page fetch via MCP) |
91
- | `grep_app_searchGitHub` | Grep.app (GitHub examples) |
92
- | `grep` | grep/rg (local code search) |
93
- | `glob` | glob (file search) |
94
-
95
- **Unrestricted**: `webfetch`, `read`, `bash`, `task`, and all other tools.
96
-
97
- ## Agent capabilities
98
-
99
- | Tool / MCP | Purpose |
100
- |------------|---------|
101
- | `read`, `glob`, `grep`, `list` | Local codebase inspection |
102
- | `bash` (`rg`, `grep`, `git grep`) | Local search commands |
103
- | Context7 | Library/framework/SDK/API docs |
104
- | Exa AI | Web search, news, pages, products |
105
- | Grep.app | Public GitHub examples |
106
-
107
- Raven returns compact findings: answer, sources, relevant details, recommended next step, and uncertainty.
108
-
109
- ## License
110
-
1
+ # opencode-raven
2
+
3
+ <table>
4
+ <tr>
5
+ <td><img src="Raven.png" alt="Raven" width="768" /></td>
6
+ <td>
7
+ <strong>Search-first subagent for <a href="https://opencode.ai">opencode</a></strong><br/>
8
+ Intercepts search tool calls and routes them to a dedicated <strong>@raven</strong> agent with Context7, Exa AI, and Grep.app MCPs.
9
+ </td>
10
+ </tr>
11
+ </table>
12
+
13
+ ## Why?
14
+
15
+ Search is the most common thing agents do — and the most wasteful. Every search call burns tokens and context on results that a cheap, focused agent could handle better. Raven fixes three problems:
16
+
17
+ 1. **Cost** — Use a free model like `opencode/deepseek-v4-flash-free` for all search, saving your expensive model's context for actual work.
18
+ 2. **Reliability** — Hard-enforced interception. Other plugins suggest delegation; Raven *blocks* search tools for non-Raven agents and redirects them. No more agents ignoring your instructions and searching directly.
19
+ 3. **Simplicity** — One plugin, one agent, zero config. No bundled agents or features you don't need. Works with any agent or workflow. Just add it to `opencode.jsonc` and restart.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ bun add opencode-raven
25
+ # or
26
+ npm install opencode-raven
27
+ ```
28
+
29
+ Then add to your `opencode.jsonc`:
30
+
31
+ ```jsonc
32
+ {
33
+ "plugin": ["opencode-raven"]
34
+ }
35
+ ```
36
+
37
+ Restart opencode.
38
+
39
+ ## Commands
40
+
41
+ | Command | Action |
42
+ |---------|--------|
43
+ | `/raven` | Show status — enabled/disabled, current model |
44
+ | `/raven on` | Enable search tool redirection to @raven (default) |
45
+ | `/raven off` | Disable interception — all agents can use search tools directly |
46
+ | `/raven model <name>` | Change Raven's model (e.g. `/raven model opencode/deepseek-v4-flash-free`) |
47
+
48
+ Config persists across restarts in `raven-config.json` (next to your `opencode.jsonc`).
49
+
50
+ ## raven_seek — fallback for agents without task
51
+
52
+ When search tools are blocked, agents are told to delegate to Raven via the `task` tool (`subagent_type="raven"`). This is the preferred path — the Raven subagent runs visibly in its own session, and you can see its work.
53
+
54
+ Some agents have `task: deny` and can't delegate. **`raven_seek`** is the fallback for those agents — a custom tool that any agent can call directly, no `task` permission needed:
55
+
56
+ ```
57
+ raven_seek(query: "how to use useEffect cleanup")
58
+ ```
59
+
60
+ The tool creates a Raven session behind the scenes, sends the query, and returns results. It's less visible than task delegation, so it's only used when task isn't available.
61
+
62
+ ## Configuration
63
+
64
+ ### raven-config.json
65
+
66
+ Created automatically on first toggle. Edit manually or use `/raven` commands:
67
+
68
+ ```json
69
+ {
70
+ "enabled": true,
71
+ "model": "opencode/deepseek-v4-flash-free"
72
+ }
73
+ ```
74
+
75
+ | Field | Default | Description |
76
+ |-------|---------|-------------|
77
+ | `enabled` | `true` | Whether search tool interception is active |
78
+ | `model` | *(from Raven.md)* | Override Raven's model without editing package files |
79
+
80
+ ### MCP servers
81
+
82
+ All three MCPs work without API keys. Adding keys increases rate limits:
83
+
84
+ | MCP | URL | API key |
85
+ |-----|-----|---------|
86
+ | Context7 | `https://mcp.context7.com/mcp` | Free key at [context7.com/dashboard](https://context7.com/dashboard) — higher limits |
87
+ | Exa AI | `https://mcp.exa.ai/mcp` | Free key at [exa.ai](https://exa.ai) — higher limits |
88
+ | Grep.app | `https://mcp.grep.app` | Not available public API, no key needed |
89
+
90
+ To add an API key, override the MCP in your `opencode.jsonc` with a `headers` field:
91
+
92
+ ```jsonc
93
+ {
94
+ "mcp": {
95
+ "exa": {
96
+ "type": "remote",
97
+ "url": "https://mcp.exa.ai/mcp",
98
+ "headers": { "x-api-key": "{env:EXA_API_KEY}" },
99
+ "enabled": true
100
+ }
101
+ }
102
+ }
103
+ ```
104
+
105
+ To disable an MCP entirely:
106
+
107
+ ```jsonc
108
+ {
109
+ "mcp": {
110
+ "exa": { "type": "remote", "url": "https://mcp.exa.ai/mcp", "enabled": false }
111
+ }
112
+ }
113
+ ```
114
+
115
+ ## How it works
116
+
117
+ | Hook | What it does |
118
+ |------|--------------|
119
+ | `config` | Registers Raven agent, adds Context7/Exa/Grep.app MCPs, loads MCP guidance |
120
+ | `tool` | Registers `raven_seek` — fallback tool for agents that can't use `task` |
121
+ | `chat.message` | Tracks Raven's session IDs so its own tools aren't blocked |
122
+ | `command.execute.before` | Handles `/raven on\|off\|model\|status` |
123
+ | `tool.execute.before` | Throws to abort disabled tools before they execute — no wasted API calls |
124
+ | `tool.execute.after` | Safety net: replaces output if the tool somehow still ran |
125
+
126
+ ### Blocked tools (redirected for all agents except Raven itself)
127
+
128
+ **Dedicated search tools:**
129
+
130
+ | Tool | Source |
131
+ |------|--------|
132
+ | `grep`, `glob` | Built-in |
133
+ | `websearch_web_search_exa` | WebSearch MCP |
134
+ | `context7_resolve-library-id`, `context7_query-docs` | Context7 MCP |
135
+ | `exa_web_search_exa`, `exa_web_fetch_exa`, `exa_web_search_advanced_exa` | Exa AI MCP |
136
+ | `exa_company_research_exa`, `exa_crawling_exa`, `exa_people_search_exa` | Exa AI MCP |
137
+ | `exa_linkedin_search_exa`, `exa_get_code_context_exa` | Exa AI MCP |
138
+ | `exa_deep_researcher_start`, `exa_deep_researcher_check`, `exa_deep_search_exa` | Exa AI MCP |
139
+ | `grep_app_searchGitHub` | Grep.app MCP |
140
+
141
+ **Bash commands** — intercepted when the command or description matches a search pattern:
142
+
143
+ | Pattern | Examples |
144
+ |---------|----------|
145
+ | Content search | `rg`, `grep`, `egrep`, `fgrep`, `git grep`, `ack`, `ag`, `findstr`, `Select-String` |
146
+ | Filesystem exploration | `Get-ChildItem`, `gci`, `find -name`, `find -type`, `ls -R`, `dir /s` |
147
+
148
+ **Unrestricted**: `webfetch`, `read`, `task`, `raven_seek`, and non-search `bash` commands.
149
+
150
+ ## Agent capabilities
151
+
152
+ | Tool / MCP | Purpose |
153
+ |------------|---------|
154
+ | `read`, `glob`, `grep`, `list` | Local codebase inspection |
155
+ | `bash` (`rg`, `grep`, `git grep`) | Local search commands |
156
+ | Context7 | Library/framework/SDK/API docs |
157
+ | Exa AI | Web search, news, pages, products |
158
+ | Grep.app | Public GitHub examples |
159
+
160
+ Raven returns compact findings: answer, sources, relevant details, recommended next step, and uncertainty.
161
+
162
+ ## License
163
+
111
164
  MIT
package/index.ts CHANGED
@@ -1,237 +1,324 @@
1
- import type { Plugin, PluginInput } from "@opencode-ai/plugin"
2
- import { readFileSync, writeFileSync, existsSync } from "node:fs"
3
- import { join } from "node:path"
4
-
5
- // ── Resolve paths relative to this package (works in node_modules/) ──
6
- const PKG_DIR = import.meta.dirname!
7
-
8
- const RAVEN_MD = join(PKG_DIR, "Raven.md")
9
- const MCP_GUIDANCE_MD = join(PKG_DIR, "mcp-guidance.md")
10
-
11
- // ── Search tools that should be intercepted for non-Raven agents ──
12
- const SEARCH_TOOLS = [
13
- "websearch_web_search_exa",
14
- "exa_web_search_exa",
15
- "exa_web_fetch_exa",
16
- "grep_app_searchGitHub",
17
- "grep",
18
- "glob",
19
- ]
20
-
21
- // ── Config file shape ──
22
- interface RavenConfig {
23
- enabled: boolean
24
- model?: string
25
- }
26
-
27
- const DEFAULT_CONFIG: RavenConfig = { enabled: true }
28
-
29
- // ── Parse Raven.md frontmatter ──
30
- const ravenMd = readFileSync(RAVEN_MD, "utf-8")
31
- const { frontmatter: fm, prompt: ravenPrompt } = parseRavenMd(ravenMd)
32
-
33
- function parseRavenMd(raw: string): { frontmatter: Record<string, any>; prompt: string } {
34
- const parts = raw.split("---")
35
- if (parts.length < 3) {
36
- throw new Error("Raven.md missing frontmatter (--- delimiters)")
37
- }
38
- return { frontmatter: parseYaml(parts[1]), prompt: parts.slice(2).join("---").trim() }
39
- }
40
-
41
- // ── Minimal YAML parser (handles the structure used in Raven.md) ──
42
- function parseYaml(yaml: string): Record<string, any> {
43
- const lines = yaml.split("\n")
44
- const root: Record<string, any> = {}
45
- const stack: Array<{ obj: Record<string, any>; indent: number }> = [
46
- { obj: root, indent: -1 },
47
- ]
48
-
49
- for (const rawLine of lines) {
50
- const line = rawLine.trimEnd()
51
- if (!line.trim() || line.trim().startsWith("#")) continue
52
-
53
- const indent = line.search(/\S/)
54
- const colonIdx = line.indexOf(":")
55
- if (colonIdx === -1) continue
56
-
57
- const rawKey = line.slice(indent, colonIdx).trim()
58
- const key =
59
- (rawKey.startsWith('"') && rawKey.endsWith('"')) ||
60
- (rawKey.startsWith("'") && rawKey.endsWith("'"))
61
- ? rawKey.slice(1, -1)
62
- : rawKey
63
-
64
- const rawValue = line.slice(colonIdx + 1).trim()
65
-
66
- while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
67
- stack.pop()
68
- }
69
- const current = stack[stack.length - 1].obj
70
-
71
- if (!rawValue) {
72
- const nested: Record<string, any> = {}
73
- current[key] = nested
74
- stack.push({ obj: nested, indent })
75
- } else {
76
- let value: any = rawValue
77
- if (
78
- (value.startsWith('"') && value.endsWith('"')) ||
79
- (value.startsWith("'") && value.endsWith("'"))
80
- ) {
81
- value = value.slice(1, -1)
82
- } else if (value === "true") {
83
- value = true
84
- } else if (value === "false") {
85
- value = false
86
- }
87
- current[key] = value
88
- }
89
- }
90
-
91
- return root
92
- }
93
-
94
- // ── Move unknown frontmatter fields into options ──
95
- const KNOWN_KEYS = new Set([
96
- "description", "mode", "hidden", "model", "permission",
97
- "prompt", "name", "color", "steps", "disable",
98
- "temperature", "top_p", "variant",
99
- ])
100
-
101
- function extractOptions(fm: Record<string, any>): Record<string, any> {
102
- const options: Record<string, any> = {}
103
- for (const key of Object.keys(fm)) {
104
- if (!KNOWN_KEYS.has(key)) options[key] = fm[key]
105
- }
106
- return options
107
- }
108
-
109
- // ── Plugin ──
110
- export default ((input: PluginInput) => {
111
- // Config file lives in the project directory (next to opencode.jsonc)
112
- const configFile = join(input.directory, "raven-config.json")
113
-
114
- function loadConfig(): RavenConfig {
115
- try {
116
- if (existsSync(configFile)) {
117
- const raw = JSON.parse(readFileSync(configFile, "utf-8"))
118
- return {
119
- enabled: raw.enabled !== false,
120
- model: raw.model,
121
- }
122
- }
123
- } catch { /* ignore corruption, use defaults */ }
124
- return { ...DEFAULT_CONFIG }
125
- }
126
-
127
- function saveConfig(config: RavenConfig) {
128
- try {
129
- writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n")
130
- } catch { /* non-fatal: config won't persist but toggle still works in-session */ }
131
- }
132
-
133
- let config = loadConfig()
134
- const ravenSessions = new Set<string>()
135
-
136
- return {
137
- config(configInput: any) {
138
- // MCP servers
139
- configInput.mcp = configInput.mcp || {}
140
- configInput.mcp.context7 = {
141
- type: "remote", url: "https://mcp.context7.com/mcp", enabled: true,
142
- }
143
- configInput.mcp.exa = {
144
- type: "remote", url: "https://mcp.exa.ai/mcp", enabled: true,
145
- }
146
- configInput.mcp.grep_app = {
147
- type: "remote", url: "https://mcp.grep.app", enabled: true,
148
- }
149
-
150
- // Inject MCP guidance as a startup instruction file (absolute path for npm compat)
151
- configInput.instructions = configInput.instructions || []
152
- if (!configInput.instructions.includes(MCP_GUIDANCE_MD)) {
153
- configInput.instructions.push(MCP_GUIDANCE_MD)
154
- }
155
-
156
- // Register Raven from Raven.md, with config file overrides
157
- configInput.agent = configInput.agent || {}
158
- configInput.agent.raven = {
159
- description: fm.description || "",
160
- mode: fm.mode || "subagent",
161
- hidden: fm.hidden !== undefined ? fm.hidden : false,
162
- model: config.model || fm.model,
163
- options: extractOptions(fm),
164
- permission: fm.permission || {},
165
- prompt: ravenPrompt,
166
- }
167
-
168
- // Register /raven command
169
- configInput.command = configInput.command || {}
170
- if (!configInput.command.raven) {
171
- configInput.command.raven = {
172
- template: "Manage Raven: /raven on|off|model <name>|status",
173
- description: "Toggle search interception or change Raven's model",
174
- }
175
- }
176
- },
177
-
178
- // Track Raven sessions so we don't block its own tools
179
- "chat.message"(input: any, _output: any) {
180
- if (input.agent === "raven") {
181
- ravenSessions.add(input.sessionID)
182
- }
183
- },
184
-
185
- // /raven on|off|model <name>|status
186
- "command.execute.before"(input: any, output: any) {
187
- if (input.command !== "raven") return
188
- output.parts.length = 0
189
- const raw = input.arguments.trim()
190
- const arg = raw.toLowerCase()
191
-
192
- if (arg === "on") {
193
- config.enabled = true
194
- saveConfig(config)
195
- output.parts.push({ type: "text", text: "Raven search interception enabled. Non-Raven agents will be redirected to @raven for search tools." })
196
- } else if (arg === "off") {
197
- config.enabled = false
198
- saveConfig(config)
199
- output.parts.push({ type: "text", text: "Raven search interception disabled. All agents can use search tools directly." })
200
- } else if (arg.startsWith("model ")) {
201
- const model = raw.slice(6).trim()
202
- if (!model) {
203
- output.parts.push({ type: "text", text: `Usage: /raven model <name>\nCurrent model: ${config.model || fm.model || "(default)"}` })
204
- } else {
205
- config.model = model
206
- saveConfig(config)
207
- output.parts.push({ type: "text", text: `Raven model set to: ${model}\nRestart opencode for the change to take effect.` })
208
- }
209
- } else {
210
- const enabled = config.enabled ? "enabled" : "disabled"
211
- const model = config.model || fm.model || "(default)"
212
- output.parts.push({ type: "text", text: `Raven is ${enabled}. Model: ${model}\n\nCommands:\n /raven on — enable search interception\n /raven off disable search interception\n /raven model <name> change Raven's model (requires restart)` })
213
- }
214
- },
215
-
216
- "tool.execute.before"(input: any, output: any) {
217
- if (!config.enabled) return
218
- if (!SEARCH_TOOLS.includes(input.tool)) return
219
- if (ravenSessions.has(input.sessionID)) return
220
- // Break args to prevent actual API calls
221
- const args = output.args || {}
222
- if ("query" in args) args.query = ""
223
- output.args = { ...output.args, ...args }
224
- },
225
-
226
- "tool.execute.after"(input: any, output: any) {
227
- if (!config.enabled) return
228
- if (!SEARCH_TOOLS.includes(input.tool)) return
229
- if (ravenSessions.has(input.sessionID)) return
230
- const msg =
231
- "This tool is disabled. Delegate to Raven instead: use the task tool with subagent_type=\"raven\"."
232
- output.output = msg
233
- const raw = output as any
234
- if (raw.content?.[0]?.text) raw.content[0].text = msg
235
- },
236
- }
1
+ import type { Plugin, PluginInput } from "@opencode-ai/plugin"
2
+ import { tool } from "@opencode-ai/plugin"
3
+ import { readFileSync, writeFileSync, existsSync } from "node:fs"
4
+ import { join } from "node:path"
5
+
6
+ // ── Resolve paths relative to this package (works in node_modules/) ──
7
+ const PKG_DIR = import.meta.dirname!
8
+
9
+ const RAVEN_MD = join(PKG_DIR, "Raven.md")
10
+ const MCP_GUIDANCE_MD = join(PKG_DIR, "mcp-guidance.md")
11
+
12
+ // ── Search tools that should be intercepted for non-Raven agents ──
13
+ const SEARCH_TOOLS = [
14
+ // Built-in tools
15
+ "grep",
16
+ "glob",
17
+ // WebSearch MCP
18
+ "websearch_web_search_exa",
19
+ // Context7 MCP
20
+ "context7_resolve-library-id",
21
+ "context7_query-docs",
22
+ // Exa AI MCP
23
+ "exa_web_search_exa",
24
+ "exa_web_fetch_exa",
25
+ "exa_web_search_advanced_exa",
26
+ "exa_company_research_exa",
27
+ "exa_crawling_exa",
28
+ "exa_people_search_exa",
29
+ "exa_linkedin_search_exa",
30
+ "exa_get_code_context_exa",
31
+ "exa_deep_researcher_start",
32
+ "exa_deep_researcher_check",
33
+ "exa_deep_search_exa",
34
+ // Grep.app MCP
35
+ "grep_app_searchGitHub",
36
+ ]
37
+
38
+ // ── Bash commands that look like search workarounds ──
39
+ const SEARCH_BASH_RE = /\b(rg|ripgrep|grep|egrep|fgrep|git\s+grep|ack|ag\b|findstr|Select-String|Get-ChildItem|gci\b|dir\b\s+[/-][sS]|ls\b\s+-[rR]|find\b\s+.*-name|find\b\s+.*-type)\b/
40
+
41
+ function isSearchBash(tool: string, args: any): boolean {
42
+ if (tool !== "bash") return false
43
+ const cmd = String(args?.command ?? "")
44
+ const desc = String(args?.description ?? "")
45
+ return SEARCH_BASH_RE.test(cmd) || SEARCH_BASH_RE.test(desc)
46
+ }
47
+
48
+ // ── Config file shape ──
49
+ interface RavenConfig {
50
+ enabled: boolean
51
+ model?: string
52
+ }
53
+
54
+ const DEFAULT_CONFIG: RavenConfig = { enabled: true }
55
+
56
+ // ── Parse Raven.md frontmatter ──
57
+ const ravenMd = readFileSync(RAVEN_MD, "utf-8")
58
+ const { frontmatter: fm, prompt: ravenPrompt } = parseRavenMd(ravenMd)
59
+
60
+ function parseRavenMd(raw: string): { frontmatter: Record<string, any>; prompt: string } {
61
+ const parts = raw.split("---")
62
+ if (parts.length < 3) {
63
+ throw new Error("Raven.md missing frontmatter (--- delimiters)")
64
+ }
65
+ return { frontmatter: parseYaml(parts[1]), prompt: parts.slice(2).join("---").trim() }
66
+ }
67
+
68
+ // ── Minimal YAML parser (handles the structure used in Raven.md) ──
69
+ function parseYaml(yaml: string): Record<string, any> {
70
+ const lines = yaml.split("\n")
71
+ const root: Record<string, any> = {}
72
+ const stack: Array<{ obj: Record<string, any>; indent: number }> = [
73
+ { obj: root, indent: -1 },
74
+ ]
75
+
76
+ for (const rawLine of lines) {
77
+ const line = rawLine.trimEnd()
78
+ if (!line.trim() || line.trim().startsWith("#")) continue
79
+
80
+ const indent = line.search(/\S/)
81
+ const colonIdx = line.indexOf(":")
82
+ if (colonIdx === -1) continue
83
+
84
+ const rawKey = line.slice(indent, colonIdx).trim()
85
+ const key =
86
+ (rawKey.startsWith('"') && rawKey.endsWith('"')) ||
87
+ (rawKey.startsWith("'") && rawKey.endsWith("'"))
88
+ ? rawKey.slice(1, -1)
89
+ : rawKey
90
+
91
+ const rawValue = line.slice(colonIdx + 1).trim()
92
+
93
+ while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
94
+ stack.pop()
95
+ }
96
+ const current = stack[stack.length - 1].obj
97
+
98
+ if (!rawValue) {
99
+ const nested: Record<string, any> = {}
100
+ current[key] = nested
101
+ stack.push({ obj: nested, indent })
102
+ } else {
103
+ let value: any = rawValue
104
+ if (
105
+ (value.startsWith('"') && value.endsWith('"')) ||
106
+ (value.startsWith("'") && value.endsWith("'"))
107
+ ) {
108
+ value = value.slice(1, -1)
109
+ } else if (value === "true") {
110
+ value = true
111
+ } else if (value === "false") {
112
+ value = false
113
+ }
114
+ current[key] = value
115
+ }
116
+ }
117
+
118
+ return root
119
+ }
120
+
121
+ // ── Move unknown frontmatter fields into options ──
122
+ const KNOWN_KEYS = new Set([
123
+ "description", "mode", "hidden", "model", "permission",
124
+ "prompt", "name", "color", "steps", "disable",
125
+ "temperature", "top_p", "variant",
126
+ ])
127
+
128
+ function extractOptions(fm: Record<string, any>): Record<string, any> {
129
+ const options: Record<string, any> = {}
130
+ for (const key of Object.keys(fm)) {
131
+ if (!KNOWN_KEYS.has(key)) options[key] = fm[key]
132
+ }
133
+ return options
134
+ }
135
+
136
+ // ── Plugin ──
137
+ export default ((input: PluginInput) => {
138
+ const client = input.client
139
+
140
+ // Config file lives in the project directory (next to opencode.jsonc)
141
+ const configFile = join(input.directory, "raven-config.json")
142
+
143
+ function loadConfig(): RavenConfig {
144
+ try {
145
+ if (existsSync(configFile)) {
146
+ const raw = JSON.parse(readFileSync(configFile, "utf-8"))
147
+ return {
148
+ enabled: raw.enabled !== false,
149
+ model: raw.model,
150
+ }
151
+ }
152
+ } catch { /* ignore corruption, use defaults */ }
153
+ return { ...DEFAULT_CONFIG }
154
+ }
155
+
156
+ function saveConfig(config: RavenConfig) {
157
+ try {
158
+ writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n")
159
+ } catch { /* non-fatal: config won't persist but toggle still works in-session */ }
160
+ }
161
+
162
+ let config = loadConfig()
163
+ const ravenSessions = new Set<string>()
164
+
165
+ return {
166
+ config(configInput: any) {
167
+ // MCP servers
168
+ configInput.mcp = configInput.mcp || {}
169
+ configInput.mcp.context7 = {
170
+ type: "remote", url: "https://mcp.context7.com/mcp", enabled: true,
171
+ }
172
+ configInput.mcp.exa = {
173
+ type: "remote", url: "https://mcp.exa.ai/mcp", enabled: true,
174
+ }
175
+ configInput.mcp.grep_app = {
176
+ type: "remote", url: "https://mcp.grep.app", enabled: true,
177
+ }
178
+
179
+ // Inject MCP guidance as a startup instruction file (absolute path for npm compat)
180
+ configInput.instructions = configInput.instructions || []
181
+ if (!configInput.instructions.includes(MCP_GUIDANCE_MD)) {
182
+ configInput.instructions.push(MCP_GUIDANCE_MD)
183
+ }
184
+
185
+ // Register Raven from Raven.md, with config file overrides
186
+ configInput.agent = configInput.agent || {}
187
+ configInput.agent.raven = {
188
+ description: fm.description || "",
189
+ mode: fm.mode || "subagent",
190
+ hidden: fm.hidden !== undefined ? fm.hidden : false,
191
+ model: config.model || fm.model,
192
+ options: extractOptions(fm),
193
+ permission: fm.permission || {},
194
+ prompt: ravenPrompt,
195
+ }
196
+
197
+ // Register /raven command
198
+ configInput.command = configInput.command || {}
199
+ if (!configInput.command.raven) {
200
+ configInput.command.raven = {
201
+ template: "Manage Raven: /raven on|off|model <name>|status",
202
+ description: "Toggle search interception or change Raven's model",
203
+ }
204
+ }
205
+ },
206
+
207
+ // Register raven_seek tool lets agents with task:false still search through Raven
208
+ tool: {
209
+ "raven_seek": tool({
210
+ description: "Fallback search tool use only when task delegation to Raven (subagent_type=\"raven\") is unavailable. Raven has access to Context7, Exa AI, and Grep.app for web search, docs lookup, and GitHub examples.",
211
+ args: {
212
+ query: tool.schema.string().describe("What to search forbe specific about what you need (docs, code examples, web info, etc.)"),
213
+ },
214
+ async execute(args, context) {
215
+ try {
216
+ // Create a Raven session
217
+ const session = await client.session.create({
218
+ body: { title: `raven_seek: ${args.query.slice(0, 80)}` },
219
+ })
220
+
221
+ const sessionId = (session as any)?.data?.id ?? (session as any)?.id
222
+ if (!sessionId) {
223
+ return { title: "Raven Seek", output: "Failed to create Raven session." }
224
+ }
225
+
226
+ // Send the query to Raven
227
+ const result = await client.session.prompt({
228
+ path: { id: sessionId },
229
+ body: {
230
+ agent: "raven",
231
+ parts: [{ type: "text", text: args.query }],
232
+ },
233
+ })
234
+
235
+ // Extract text from the response
236
+ const parts = (result as any)?.data?.parts ?? []
237
+ const textParts = parts
238
+ .filter((p: any) => p.type === "text" && p.text)
239
+ .map((p: any) => p.text)
240
+ const output = textParts.join("\n") || "Raven returned no results."
241
+
242
+ // Clean up the session
243
+ try {
244
+ await client.session.delete({ path: { id: sessionId } })
245
+ } catch { /* non-fatal */ }
246
+
247
+ return { title: "Raven Seek", output }
248
+ } catch (err: any) {
249
+ return { title: "Raven Seek", output: `Raven search failed: ${err.message || err}` }
250
+ }
251
+ },
252
+ }),
253
+ },
254
+
255
+ // Track Raven sessions so we don't block its own tools
256
+ "chat.message"(input: any, _output: any) {
257
+ if (input.agent === "raven") {
258
+ ravenSessions.add(input.sessionID)
259
+ }
260
+ },
261
+
262
+ // /raven on|off|model <name>|status
263
+ "command.execute.before"(input: any, output: any) {
264
+ if (input.command !== "raven") return
265
+ output.parts.length = 0
266
+ const raw = input.arguments.trim()
267
+ const arg = raw.toLowerCase()
268
+
269
+ if (arg === "on") {
270
+ config.enabled = true
271
+ saveConfig(config)
272
+ output.parts.push({ type: "text", text: "Raven search interception enabled. Non-Raven agents will be redirected to @raven for search tools." })
273
+ } else if (arg === "off") {
274
+ config.enabled = false
275
+ saveConfig(config)
276
+ output.parts.push({ type: "text", text: "Raven search interception disabled. All agents can use search tools directly." })
277
+ } else if (arg.startsWith("model ")) {
278
+ const model = raw.slice(6).trim()
279
+ if (!model) {
280
+ output.parts.push({ type: "text", text: `Usage: /raven model <name>\nCurrent model: ${config.model || fm.model || "(default)"}` })
281
+ } else {
282
+ config.model = model
283
+ saveConfig(config)
284
+ output.parts.push({ type: "text", text: `Raven model set to: ${model}\nRestart opencode for the change to take effect.` })
285
+ }
286
+ } else {
287
+ const enabled = config.enabled ? "enabled" : "disabled"
288
+ const model = config.model || fm.model || "(default)"
289
+ output.parts.push({ type: "text", text: `Raven is ${enabled}. Model: ${model}\n\nCommands:\n /raven on — enable search interception\n /raven off — disable search interception\n /raven model <name> — change Raven's model (requires restart)` })
290
+ }
291
+ },
292
+
293
+ "tool.execute.before"(input: any, output: any) {
294
+ if (!config.enabled) return
295
+ if (ravenSessions.has(input.sessionID)) return
296
+
297
+ const isSearchTool = SEARCH_TOOLS.includes(input.tool)
298
+ const isSearchBashCmd = isSearchBash(input.tool, output.args || input.args)
299
+
300
+ if (!isSearchTool && !isSearchBashCmd) return
301
+
302
+ throw new Error(
303
+ "Search tool disabled — delegate to Raven via the task tool (subagent_type=\"raven\")"
304
+ )
305
+ },
306
+
307
+ "tool.execute.after"(input: any, output: any) {
308
+ if (!config.enabled) return
309
+ if (ravenSessions.has(input.sessionID)) return
310
+
311
+ const isSearchTool = SEARCH_TOOLS.includes(input.tool)
312
+ const isSearchBashCmd = isSearchBash(input.tool, input.args || output.args)
313
+
314
+ if (!isSearchTool && !isSearchBashCmd) return
315
+
316
+ const msg = "Search tool disabled — delegate to Raven via the task tool (subagent_type=\"raven\")"
317
+ output.output = msg
318
+ try {
319
+ const raw = output as any
320
+ if (raw.content?.[0]?.text) raw.content[0].text = msg
321
+ } catch {}
322
+ },
323
+ }
237
324
  }) satisfies Plugin
package/mcp-guidance.md CHANGED
@@ -1,5 +1,9 @@
1
- ## MCP usage guidance — delegate to Raven (subagent_type="raven") for these:
2
-
3
- - Context7: use when implementing, configuring, or debugging code that depends on a library, framework, SDK, package, or API. Prefer over memory when docs may be version-specific or recently changed.
4
- - Exa AI: use for live web search, current information, company/product research, reading webpages, comparing tools, and broad external research. Use when the answer depends on recent updates, pricing, docs pages, releases, or online sources.
5
- - Grep.app: use for searching public GitHub code examples, real-world usage patterns, config examples, and how other projects structure similar code. Use when docs are unclear or when implementation examples would help.
1
+ ## MCP usage guidance — delegate to Raven (subagent_type="raven") for these:
2
+
3
+ - Context7: use when implementing, configuring, or debugging code that depends on a library, framework, SDK, package, or API. Prefer over memory when docs may be version-specific or recently changed.
4
+ - Exa AI: use for live web search, current information, company/product research, reading webpages, comparing tools, and broad external research. Use when the answer depends on recent updates, pricing, docs pages, releases, or online sources.
5
+ - Grep.app: use for searching public GitHub code examples, real-world usage patterns, config examples, and how other projects structure similar code. Use when docs are unclear or when implementation examples would help.
6
+
7
+ ## Built-in search tools (grep, glob, search-like bash) are blocked and routed to Raven automatically.
8
+
9
+ If task delegation is unavailable, use the raven_seek tool as a fallback.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-raven",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Search-first subagent for opencode — intercepts search tools and routes them to a dedicated @raven agent with Context7, Exa AI, and Grep.app MCPs",
5
5
  "main": "./index.ts",
6
6
  "exports": {