opencode-raven 1.1.1 → 1.2.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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  <td><img src="Raven.png" alt="Raven" width="768" /></td>
6
6
  <td>
7
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.
8
+ Intercepts search tool calls and routes them to a hidden Raven agent with Context7, Exa AI, and Grep.app MCPs.
9
9
  </td>
10
10
  </tr>
11
11
  </table>
@@ -16,7 +16,7 @@ Search is the most common thing agents do — and the most wasteful. Every searc
16
16
 
17
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
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.
19
+ 3. **Simplicity** — One plugin, one agent, auto-configured. No bundled agents or features you don't need. Works with any agent or workflow. Just add it to `opencode.jsonc` and restart.
20
20
 
21
21
  ## Install
22
22
 
@@ -40,42 +40,45 @@ Restart opencode.
40
40
 
41
41
  | Command | Action |
42
42
  |---------|--------|
43
- | `/raven` | Show status — enabled/disabled, current model |
44
- | `/raven on` | Enable search tool redirection to @raven (default) |
43
+ | `/raven` | Show status — enabled/disabled, current model, reasoning effort |
44
+ | `/raven on` | Enable search tool redirection (default) |
45
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`) |
46
+ | `/raven model <name>` | Change Raven's model (requires restart) |
47
+ | `/raven effort <value>` | Change Raven's reasoning effort (requires restart) |
47
48
 
48
- Config persists across restarts in `raven-config.json` (next to your `opencode.jsonc`).
49
+ Config persists across restarts in `~/.config/opencode/raven-config.json` (global, shared across all projects). Auto-created on first run.
49
50
 
50
- ## raven_seek — fallback for agents without task
51
+ ## raven_seek
51
52
 
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:
53
+ When search tools are blocked, agents use **`raven_seek`** a tool that creates a hidden Raven session, runs the search, and returns compact results:
55
54
 
56
55
  ```
57
56
  raven_seek(query: "how to use useEffect cleanup")
58
57
  ```
59
58
 
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.
59
+ The agent doesn't see Raven's internal tool calls just the final findings. Raven parallelizes independent searches internally within a single session.
61
60
 
62
61
  ## Configuration
63
62
 
64
63
  ### raven-config.json
65
64
 
66
- Created automatically on first toggle. Edit manually or use `/raven` commands:
65
+ Located at `~/.config/opencode/raven-config.json`. Auto-created on first run. Edit manually or use `/raven` commands:
67
66
 
68
67
  ```json
69
68
  {
70
69
  "enabled": true,
71
- "model": "opencode/deepseek-v4-flash-free"
70
+ "model": "opencode/deepseek-v4-flash-free",
71
+ "reasoning_effort": "low",
72
+ "excludeAgents": []
72
73
  }
73
74
  ```
74
75
 
75
76
  | Field | Default | Description |
76
77
  |-------|---------|-------------|
77
78
  | `enabled` | `true` | Whether search tool interception is active |
78
- | `model` | *(built-in default)* | Override Raven's model without editing package files |
79
+ | `model` | *(from Raven.md)* | Override Raven's model without editing package files |
80
+ | `reasoning_effort` | *(from Raven.md)* | Override Raven's reasoning effort (e.g. `"low"`, `"medium"`, `"high"`) |
81
+ | `excludeAgents` | `[]` | Agents that bypass search tool blocking (case-insensitive). e.g. `["librarian", "explorer"]` |
79
82
 
80
83
  ### MCP servers
81
84
 
@@ -117,19 +120,18 @@ To disable an MCP entirely:
117
120
  | Hook | What it does |
118
121
  |------|--------------|
119
122
  | `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 |
123
+ | `tool` | Registers `raven_seek` — hidden Raven sessions with error recovery for API failures |
124
+ | `chat.message` | Tracks agent session mapping for allowlist and Raven exclusion |
125
+ | `command.execute.before` | Handles `/raven on\|off\|model\|effort\|status` |
126
+ | `tool.execute.before` | Blocks search tools for non-Raven, non-excluded agents. Injects `<raven_guidance>` into subagent prompts. Throttled: full message once per session, silent after. |
125
127
 
126
- ### Blocked tools (redirected for all agents except Raven itself)
128
+ ### Blocked tools (redirected except for Raven and any agents in `excludeAgents`)
127
129
 
128
130
  **Dedicated search tools:**
129
131
 
130
132
  | Tool | Source |
131
133
  |------|--------|
132
- | `grep`, `glob` | Built-in |
134
+ | `grep`, `glob`, `webfetch`, `fetch` | Built-in |
133
135
  | `websearch_web_search_exa` | WebSearch MCP |
134
136
  | `context7_resolve-library-id`, `context7_query-docs` | Context7 MCP |
135
137
  | `exa_web_search_exa`, `exa_web_fetch_exa`, `exa_web_search_advanced_exa` | Exa AI MCP |
@@ -145,7 +147,11 @@ To disable an MCP entirely:
145
147
  | Content search | `rg`, `grep`, `egrep`, `fgrep`, `git grep`, `ack`, `ag`, `findstr`, `Select-String` |
146
148
  | Filesystem exploration | `Get-ChildItem`, `gci`, `find -name`, `find -type`, `ls -R`, `dir /s` |
147
149
 
148
- **Unrestricted**: `webfetch`, `read`, `task`, `raven_seek`, and non-search `bash` commands.
150
+ **Unrestricted**: `read`, `task`, `subtask`, `raven_seek`, and non-search `bash` commands.
151
+
152
+ **Bash quote stripping**: Quoted content in bash commands is stripped before pattern matching — `echo "use grep here"` won't falsely trigger blocking.
153
+
154
+ **Subagent guidance**: Every non-Raven, non-excluded subagent gets `<raven_guidance>` injected into its prompt at spawn time.
149
155
 
150
156
  ## Agent capabilities
151
157
 
package/Raven.md CHANGED
@@ -25,6 +25,8 @@ You are Raven.
25
25
  You search only.
26
26
  You return compact findings only.
27
27
 
28
+ When a query implies multiple independent searches, run tools in parallel (single turn) for speed.
29
+
28
30
  Use tools/MCPs like this:
29
31
 
30
32
  **Local code search:** use rg, grep, glob, list, and read only small relevant sections.
package/index.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { Plugin, PluginInput } from "@opencode-ai/plugin"
2
2
  import { tool } from "@opencode-ai/plugin"
3
- import { readFileSync, writeFileSync, existsSync } from "node:fs"
3
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"
4
4
  import { join } from "node:path"
5
+ import { homedir } from "node:os"
5
6
 
6
7
  // ── Resolve paths relative to this package (works in node_modules/) ──
7
8
  const PKG_DIR = import.meta.dirname!
@@ -14,6 +15,8 @@ const SEARCH_TOOLS = [
14
15
  // Built-in tools
15
16
  "grep",
16
17
  "glob",
18
+ "webfetch",
19
+ "fetch",
17
20
  // WebSearch MCP
18
21
  "websearch_web_search_exa",
19
22
  // Context7 MCP
@@ -38,10 +41,21 @@ const SEARCH_TOOLS = [
38
41
  // ── Bash commands that look like search workarounds ──
39
42
  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
43
 
44
+ // Strip quoted content to avoid false positives (e.g. echo "use grep here")
45
+ function stripHeredocs(cmd: string): string {
46
+ return cmd.replace(/<<-?\s*["']?(\w+)["']?[\s\S]*?\n\s*\1/g, "")
47
+ }
48
+
49
+ function stripQuotedContent(cmd: string): string {
50
+ return stripHeredocs(cmd)
51
+ .replace(/'[^']*'/g, "''")
52
+ .replace(/"[^"]*"/g, '""')
53
+ }
54
+
41
55
  function isSearchBash(tool: string, args: any): boolean {
42
56
  if (tool !== "bash") return false
43
- const cmd = String(args?.command ?? "")
44
- const desc = String(args?.description ?? "")
57
+ const cmd = stripQuotedContent(String(args?.command ?? ""))
58
+ const desc = stripQuotedContent(String(args?.description ?? ""))
45
59
  return SEARCH_BASH_RE.test(cmd) || SEARCH_BASH_RE.test(desc)
46
60
  }
47
61
 
@@ -49,14 +63,21 @@ function isSearchBash(tool: string, args: any): boolean {
49
63
  interface RavenConfig {
50
64
  enabled: boolean
51
65
  model?: string
66
+ reasoning_effort?: string
67
+ excludeAgents?: string[]
52
68
  }
53
69
 
54
- const DEFAULT_CONFIG: RavenConfig = { enabled: true }
55
-
56
70
  // ── Parse Raven.md frontmatter ──
57
71
  const ravenMd = readFileSync(RAVEN_MD, "utf-8")
58
72
  const { frontmatter: fm, prompt: ravenPrompt } = parseRavenMd(ravenMd)
59
73
 
74
+ const DEFAULT_CONFIG: RavenConfig = {
75
+ enabled: true,
76
+ model: fm.model,
77
+ reasoning_effort: fm.reasoning_effort,
78
+ excludeAgents: [],
79
+ }
80
+
60
81
  function parseRavenMd(raw: string): { frontmatter: Record<string, any>; prompt: string } {
61
82
  const parts = raw.split("---")
62
83
  if (parts.length < 3) {
@@ -137,8 +158,8 @@ function extractOptions(fm: Record<string, any>): Record<string, any> {
137
158
  export default ((input: PluginInput) => {
138
159
  const client = input.client
139
160
 
140
- // Config file lives in the project directory (next to opencode.jsonc)
141
- const configFile = join(input.directory, "raven-config.json")
161
+ // Config file lives in the global opencode config directory
162
+ const configFile = join(homedir(), ".config", "opencode", "raven-config.json")
142
163
 
143
164
  function loadConfig(): RavenConfig {
144
165
  try {
@@ -147,20 +168,37 @@ export default ((input: PluginInput) => {
147
168
  return {
148
169
  enabled: raw.enabled !== false,
149
170
  model: raw.model,
171
+ reasoning_effort: raw.reasoning_effort,
172
+ excludeAgents: Array.isArray(raw.excludeAgents) ? raw.excludeAgents : [],
150
173
  }
151
174
  }
152
175
  } catch { /* ignore corruption, use defaults */ }
176
+ // Auto-create config file with defaults on first run
177
+ saveConfig(DEFAULT_CONFIG)
153
178
  return { ...DEFAULT_CONFIG }
154
179
  }
155
180
 
156
181
  function saveConfig(config: RavenConfig) {
157
182
  try {
183
+ mkdirSync(join(homedir(), ".config", "opencode"), { recursive: true })
158
184
  writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n")
159
185
  } catch { /* non-fatal: config won't persist but toggle still works in-session */ }
160
186
  }
161
187
 
162
188
  let config = loadConfig()
163
189
  const ravenSessions = new Set<string>()
190
+ const sessionAgents = new Map<string, string>()
191
+
192
+ // ── Check if an agent is excluded from Raven enforcement (case-insensitive) ──
193
+ function isExcluded(agent: string | undefined): boolean {
194
+ if (!agent || !config.excludeAgents?.length) return false
195
+ const lower = agent.toLowerCase()
196
+ return config.excludeAgents.some((a) => a.toLowerCase() === lower)
197
+ }
198
+
199
+ // Throttle: show the full error message once per session, then silent
200
+ const throttledSessions = new Set<string>()
201
+ const REROUTE_MSG = "Search tools are blocked. Use raven_seek(query=\"...\") to search through Raven."
164
202
 
165
203
  return {
166
204
  config(configInput: any) {
@@ -189,7 +227,10 @@ export default ((input: PluginInput) => {
189
227
  mode: fm.mode || "subagent",
190
228
  hidden: fm.hidden !== undefined ? fm.hidden : false,
191
229
  model: config.model || fm.model,
192
- options: extractOptions(fm),
230
+ options: {
231
+ ...extractOptions(fm),
232
+ ...(config.reasoning_effort ? { reasoning_effort: config.reasoning_effort } : {}),
233
+ },
193
234
  permission: fm.permission || {},
194
235
  prompt: ravenPrompt,
195
236
  }
@@ -246,16 +287,27 @@ export default ((input: PluginInput) => {
246
287
 
247
288
  return { title: "Raven Seek", output }
248
289
  } catch (err: any) {
249
- return { title: "Raven Seek", output: `Raven search failed: ${err.message || err}` }
290
+ const msg = String(err?.message ?? err ?? "").toLowerCase()
291
+ const hint =
292
+ /rate.?limit|too many requests|429/i.test(msg) ? "Raven rate limited — wait 30s then retry with a narrower query."
293
+ : /quota|usage.?limit|billing|insufficient.*(?:credit|balance|quota)/i.test(msg) ? "Raven API quota exhausted — proceed without search, tell user what's missing."
294
+ : /token|context.?length|too large|too long/i.test(msg) ? "Raven query too large — shorten your query and retry."
295
+ : /model|unavailable|down|not found/i.test(msg) ? "Raven model unavailable — retry later, or proceed without search."
296
+ : /timeout|timed.?out|econnrefused/i.test(msg) ? "Raven timed out — retry with a narrower query."
297
+ : `Raven search failed. Proceed without search — note gaps for the user. [${err.message || err}]`
298
+ return { title: "Raven Seek", output: hint }
250
299
  }
251
300
  },
252
301
  }),
253
302
  },
254
303
 
255
- // Track Raven sessions so we don't block its own tools
304
+ // Track agent session mapping for allowlist + Raven exclusion
256
305
  "chat.message"(input: any, _output: any) {
257
- if (input.agent === "raven") {
258
- ravenSessions.add(input.sessionID)
306
+ if (input.agent) {
307
+ sessionAgents.set(input.sessionID, input.agent)
308
+ if (input.agent === "raven") {
309
+ ravenSessions.add(input.sessionID)
310
+ }
259
311
  }
260
312
  },
261
313
 
@@ -283,42 +335,54 @@ export default ((input: PluginInput) => {
283
335
  saveConfig(config)
284
336
  output.parts.push({ type: "text", text: `Raven model set to: ${model}\nRestart opencode for the change to take effect.` })
285
337
  }
338
+ } else if (arg.startsWith("effort ")) {
339
+ const effort = raw.slice(7).trim()
340
+ if (!effort) {
341
+ output.parts.push({ type: "text", text: `Usage: /raven effort <value>\nCurrent: ${config.reasoning_effort || fm.reasoning_effort || "(default)"}` })
342
+ } else {
343
+ config.reasoning_effort = effort
344
+ saveConfig(config)
345
+ output.parts.push({ type: "text", text: `Raven reasoning effort set to: ${effort}\nRestart opencode for the change to take effect.` })
346
+ }
286
347
  } else {
287
348
  const enabled = config.enabled ? "enabled" : "disabled"
288
349
  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)` })
350
+ const effort = config.reasoning_effort || fm.reasoning_effort || "(default)"
351
+ output.parts.push({ type: "text", text: `Raven is ${enabled}. Model: ${model}. Reasoning: ${effort}\n\nCommands:\n /raven on — enable search interception\n /raven off — disable search interception\n /raven model <name> — change Raven's model (requires restart)\n /raven effort <value> — change Raven's reasoning effort (requires restart)` })
290
352
  }
291
353
  },
292
354
 
293
355
  "tool.execute.before"(input: any, output: any) {
294
356
  if (!config.enabled) return
295
357
  if (ravenSessions.has(input.sessionID)) return
358
+ if (isExcluded(sessionAgents.get(input.sessionID))) return
359
+
360
+ // ── Subagent prompt injection: inject Raven guidance into every subagent ──
361
+ if ((input.tool === "task" || input.tool === "subtask") && output.args) {
362
+ const subagentType = input.tool === "task" ? (output.args.subagent_type ?? "") : ""
363
+ if (subagentType !== "raven" && !isExcluded(subagentType)) {
364
+ const field = ["prompt", "description", "request", "objective", "query"].find(
365
+ (f) => f in output.args
366
+ ) ?? "prompt"
367
+ output.args[field] = `${output.args[field] ?? ""}\n\n<raven_guidance>\nSearch tools (grep, glob, Context7, Exa, Grep.app, bash search) are blocked. Use raven_seek(query="...") to search through Raven.\n</raven_guidance>`
368
+ }
369
+ }
296
370
 
371
+ // ── Block search tools for non-Raven agents ──
297
372
  const isSearchTool = SEARCH_TOOLS.includes(input.tool)
298
373
  const isSearchBashCmd = isSearchBash(input.tool, output.args || input.args)
299
374
 
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
- )
375
+ if (isSearchTool || isSearchBashCmd) {
376
+ if (throttledSessions.has(input.sessionID)) {
377
+ throw new Error("")
378
+ }
379
+ throttledSessions.add(input.sessionID)
380
+ throw new Error(REROUTE_MSG)
381
+ }
305
382
  },
306
383
 
307
384
  "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 {}
385
+ // Reserved for future analytics / redirect tracking (#5)
322
386
  },
323
387
  }
324
388
  }) satisfies Plugin
package/mcp-guidance.md CHANGED
@@ -6,4 +6,4 @@
6
6
 
7
7
  ## Built-in search tools (grep, glob, search-like bash) are blocked and routed to Raven automatically.
8
8
 
9
- If task delegation is unavailable, use the raven_seek tool as a fallback.
9
+ Use raven_seek(query="...") to search through Raven.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-raven",
3
- "version": "1.1.1",
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",
3
+ "version": "1.2.0",
4
+ "description": "Search-first subagent for opencode — intercepts search tools and routes them through a hidden Raven agent with Context7, Exa AI, and Grep.app MCPs",
5
5
  "main": "./index.ts",
6
6
  "exports": {
7
7
  ".": "./index.ts"