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 +28 -22
- package/Raven.md +2 -0
- package/index.ts +96 -32
- package/mcp-guidance.md +1 -1
- package/package.json +2 -2
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
|
|
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,
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
51
|
+
## raven_seek
|
|
51
52
|
|
|
52
|
-
When search tools are blocked, agents
|
|
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
|
|
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
|
-
|
|
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` | *(
|
|
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` —
|
|
121
|
-
| `chat.message` | Tracks
|
|
122
|
-
| `command.execute.before` | Handles `/raven on\|off\|model\|status` |
|
|
123
|
-
| `tool.execute.before` |
|
|
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
|
|
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**: `
|
|
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
|
|
141
|
-
const configFile = join(
|
|
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:
|
|
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
|
-
|
|
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
|
|
304
|
+
// Track agent ↔ session mapping for allowlist + Raven exclusion
|
|
256
305
|
"chat.message"(input: any, _output: any) {
|
|
257
|
-
if (input.agent
|
|
258
|
-
|
|
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
|
-
|
|
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 (
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-raven",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Search-first subagent for opencode — intercepts search tools and routes them
|
|
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"
|