opencode-raven 2.1.1 → 2.1.3
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 +49 -35
- package/index.ts +216 -71
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,27 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
<table>
|
|
4
4
|
<tr>
|
|
5
|
-
<td><img src="Raven.png" alt="Raven" width="
|
|
5
|
+
<td width="40%"><img src="Raven.png" alt="Raven" width="100%" /></td>
|
|
6
6
|
<td>
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
Raven gives OpenCode useful search tools by default and optional on-demand MCPs for any remote or local MCP you want to add. It keeps noisy tool work behind a focused Raven agent, so your main model gets compact answers instead of raw search results, docs pages, GitHub examples, MCP output, or large MCP schemas.
|
|
8
|
+
|
|
9
|
+
## Why Use Raven?
|
|
10
|
+
|
|
11
|
+
* Routed search, fetch, and bash discovery work through Raven.
|
|
12
|
+
* Context7, Exa, and Grep.app are bundled as on-demand MCP defaults.
|
|
13
|
+
* Any extra MCP can be added under `onDemandMcpServers` when you want it.
|
|
14
|
+
* Configured noisy tools and MCPs are blocked for non-Raven agents and forced through `raven_seek`.
|
|
9
15
|
</td>
|
|
10
16
|
</tr>
|
|
11
17
|
</table>
|
|
12
18
|
|
|
13
|
-
##
|
|
14
|
-
|
|
15
|
-
Tool-heavy work floods context. Search, docs, web, GitHub examples, and verbose MCP calls can dump raw results into your expensive main model. Raven is a hard blocker-rerouter: configured tools fail closed for non-Raven agents and must be delegated through `raven_seek`, where a focused Raven agent performs the work and returns a compact answer.
|
|
16
|
-
|
|
17
|
-
Raven fixes three problems:
|
|
18
|
-
|
|
19
|
-
1. **Context flooding** - Keep noisy tool results out of the main session. Raven summarizes tool/MCP output before returning it.
|
|
20
|
-
2. **Cost** - Use a cheaper model like `opencode/deepseek-v4-flash-free` for tool-heavy work while saving your main model's context for decisions and edits.
|
|
21
|
-
3. **Enforcement** - This is not soft nudging. Raven blocks configured tools/MCP prefixes for non-Raven agents and gives them the exact `raven_seek` retry path.
|
|
19
|
+
## Example
|
|
22
20
|
|
|
23
|
-
|
|
21
|
+
<p align="center">
|
|
22
|
+
<img src="example.gif" alt="Raven example: searching today's top 30 AI news items while saving 71K context" />
|
|
23
|
+
</p>
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
In this example, the main model asks for the top 30 AI news items from today. Raven handles the noisy search/web work in a focused agent and returns the compact result, saving about 71K context that would otherwise have landed in the main session.
|
|
26
26
|
|
|
27
27
|
## Install
|
|
28
28
|
|
|
@@ -36,6 +36,8 @@ Add Raven to your `opencode.jsonc` plugins:
|
|
|
36
36
|
|
|
37
37
|
Restart opencode. It will resolve Raven from npm and cache the plugin automatically.
|
|
38
38
|
|
|
39
|
+
That is enough to start using Raven. The default config already includes routed search/fetch tools and bundled on-demand MCPs for Context7, Exa, and Grep.app. Add any other MCP under `onDemandMcpServers` only if you want more capabilities.
|
|
40
|
+
|
|
39
41
|
Raven checks for package updates and notifies you when a newer version is available. Run `/raven update`, then restart opencode.
|
|
40
42
|
|
|
41
43
|
## Quick Use
|
|
@@ -78,17 +80,11 @@ The main agent does not see Raven's internal tool calls or raw tool output, just
|
|
|
78
80
|
|
|
79
81
|
Config persists across restarts in `~/.config/opencode/opencode-raven/raven-config.json`. It is global and shared across all projects.
|
|
80
82
|
|
|
81
|
-
## Example
|
|
82
|
-
|
|
83
|
-
<p align="center">
|
|
84
|
-
<img src="example.gif" alt="Raven example: searching today's top 30 AI news items while saving 71K context" />
|
|
85
|
-
</p>
|
|
86
|
-
|
|
87
|
-
In this example, the main model asks for the top 30 AI news items from today. Raven handles the noisy search/web work in a focused agent and returns the compact result, saving about 71K context that would otherwise have landed in the main session.
|
|
88
|
-
|
|
89
83
|
## Recommended MCP Setup
|
|
90
84
|
|
|
91
|
-
|
|
85
|
+
You do not need to configure MCPs just to try Raven. Raven gives you the search tools up front, supports any MCP as an on-demand MCP, and bundles Context7, Exa, and Grep.app as defaults. Add more MCPs only when you want extra capabilities.
|
|
86
|
+
|
|
87
|
+
On-demand MCPs live in `onDemandMcpServers`, so Raven connects to them internally through `raven_mcp` only when needed instead of registering their full tool schemas in OpenCode's global MCP config. Context7, Exa, and Grep.app are included there by default, but the same setup works for any remote or local MCP you want to add.
|
|
92
88
|
|
|
93
89
|
On-demand MCPs are always behind Raven and do not need routing entries.
|
|
94
90
|
|
|
@@ -96,6 +92,8 @@ Raven checks configured on-demand MCPs after startup. `/raven` and `/raven mcp`
|
|
|
96
92
|
|
|
97
93
|
Raven also warns if the same MCP server name is configured both globally in OpenCode and in Raven's `onDemandMcpServers`. Prefer one location: on-demand for schema hiding, or global only when direct main-agent access is required.
|
|
98
94
|
|
|
95
|
+
Important limitation: MCPs enabled directly in OpenCode config still load their tool schemas into the main session. Raven saves context from tool calls and raw results for those global MCPs, but schema hiding requires putting the MCP in Raven's `onDemandMcpServers`. **Strongly recommended:** configure noisy/search/docs/web MCPs as on-demand MCPs unless you need direct main-agent access.
|
|
96
|
+
|
|
99
97
|
All three bundled MCPs work without API keys. Add keys for higher rate limits:
|
|
100
98
|
|
|
101
99
|
| MCP | URL | API key |
|
|
@@ -119,23 +117,36 @@ To add an API key to an on-demand MCP, edit Raven config:
|
|
|
119
117
|
}
|
|
120
118
|
```
|
|
121
119
|
|
|
122
|
-
|
|
120
|
+
### Add Your Own On-Demand MCP
|
|
121
|
+
|
|
122
|
+
To add another MCP, put it under `onDemandMcpServers`. Raven will connect to it only when needed, discover its tools, and generate guidance automatically. Your main model does not need to load that MCP's full schema up front.
|
|
123
|
+
|
|
124
|
+
Use `remote` for hosted MCPs and `local` for command-based MCPs.
|
|
125
|
+
|
|
126
|
+
To add an OpenCode-style local MCP:
|
|
123
127
|
|
|
124
128
|
```jsonc
|
|
125
129
|
{
|
|
126
130
|
"onDemandMcpServers": {
|
|
127
|
-
"
|
|
128
|
-
"type": "
|
|
129
|
-
"command":
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
"unityMCP": {
|
|
132
|
+
"type": "local",
|
|
133
|
+
"command": [
|
|
134
|
+
"C:\\Users\\evila\\.local\\bin\\uvx.exe",
|
|
135
|
+
"--offline",
|
|
136
|
+
"--from",
|
|
137
|
+
"mcpforunityserver==9.7.1",
|
|
138
|
+
"mcp-for-unity",
|
|
139
|
+
"--transport",
|
|
140
|
+
"stdio"
|
|
141
|
+
],
|
|
142
|
+
"enabled": true
|
|
134
143
|
}
|
|
135
144
|
}
|
|
136
145
|
}
|
|
137
146
|
```
|
|
138
147
|
|
|
148
|
+
This matches OpenCode's local MCP shape: `type: "local"`, `command` as an array, optional `environment`, optional `timeout`, and optional `enabled`. Raven also supports `cwd` and `description`. `enabled` defaults to `true`; set it to `false` to keep a server in config without loading it.
|
|
149
|
+
|
|
139
150
|
`description` is user-authored and never overwritten. Raven fills missing generated metadata after startup or when you run `/raven mcp refresh [server]`, storing tool summaries, MCP health, and avoided schema-load estimates separately in `~/.config/opencode/opencode-raven/autogenerated-on-demand-mcp-metadata.json`.
|
|
140
151
|
|
|
141
152
|
Raven also writes generated main-model guidance to `~/.config/opencode/opencode-raven/autogenerated-on-demand-guidance.md` and injects that file into OpenCode instructions at startup.
|
|
@@ -157,16 +168,19 @@ Edit config manually or use `/raven` commands:
|
|
|
157
168
|
"onDemandMcpDescriptionDetail": "full",
|
|
158
169
|
"onDemandMcpServers": {
|
|
159
170
|
"context7": {
|
|
171
|
+
"enabled": true,
|
|
160
172
|
"type": "remote",
|
|
161
173
|
"url": "https://mcp.context7.com/mcp",
|
|
162
174
|
"description": "Library/framework documentation lookup."
|
|
163
175
|
},
|
|
164
176
|
"exa": {
|
|
177
|
+
"enabled": true,
|
|
165
178
|
"type": "remote",
|
|
166
179
|
"url": "https://mcp.exa.ai/mcp",
|
|
167
180
|
"description": "Live web search and webpage fetching."
|
|
168
181
|
},
|
|
169
182
|
"grep_app": {
|
|
183
|
+
"enabled": true,
|
|
170
184
|
"type": "remote",
|
|
171
185
|
"url": "https://mcp.grep.app",
|
|
172
186
|
"description": "Public GitHub code search and real-world examples."
|
|
@@ -174,7 +188,7 @@ Edit config manually or use `/raven` commands:
|
|
|
174
188
|
},
|
|
175
189
|
"excludeAgents": [],
|
|
176
190
|
"excludeTools": [],
|
|
177
|
-
"timeout":
|
|
191
|
+
"timeout": 600
|
|
178
192
|
}
|
|
179
193
|
```
|
|
180
194
|
|
|
@@ -187,10 +201,10 @@ Edit config manually or use `/raven` commands:
|
|
|
187
201
|
| `routeTools` | built-in search/fetch tools plus `bash` | Exact tool names hard-routed through Raven. `bash` routes only search-like bash commands, not every bash call. |
|
|
188
202
|
| `routeToolKeywords` | `[]` | Case-insensitive substrings hard-routed through Raven for globally registered tools with non-standard names. Catches suffix-style names like `web_search_exa`. |
|
|
189
203
|
| `onDemandMcpDescriptionDetail` | `"full"` | On-demand MCP guidance detail. `full` includes tool names/descriptions; `minimized` uses compact capability summaries. |
|
|
190
|
-
| `onDemandMcpServers` | Context7, Exa, Grep.app | MCPs Raven connects to internally on demand, without registering full schemas in OpenCode global MCP config. Supports `remote` and `
|
|
204
|
+
| `onDemandMcpServers` | Context7, Exa, Grep.app | MCPs Raven connects to internally on demand, without registering full schemas in OpenCode global MCP config. Supports OpenCode-style `remote` and `local` entries. Each server supports `enabled`, defaulting to `true`. |
|
|
191
205
|
| `excludeAgents` | `[]` | Agents that bypass Raven routing, case-insensitive. |
|
|
192
206
|
| `excludeTools` | `[]` | Exact tools that never get blocked, even if matched by auto-routed global MCP prefixes. |
|
|
193
|
-
| `timeout` | `
|
|
207
|
+
| `timeout` | `600` | Max seconds for a `raven_seek` call. Provider/API retry errors and no-initial-response startup failures are reported quickly; after provider failures, routed tools tell the user how to switch Raven's model instead of retrying Raven. On timeout the session is kept for inspection. |
|
|
194
208
|
| `stats` | auto | Session and global estimated context saved by Raven. Managed automatically. |
|
|
195
209
|
|
|
196
210
|
Use `ravenInstructions` for extra Raven-only guidance, such as how to use custom MCPs:
|
|
@@ -285,7 +299,7 @@ Raven itself has access to these tools, blocked for other agents when configured
|
|
|
285
299
|
| `read`, `glob`, `grep`, `list` | Local codebase inspection |
|
|
286
300
|
| `bash` | Full local shell access, including `rg`, `grep`, `dir`, `ls`, `Get-ChildItem`, and `find` |
|
|
287
301
|
| `external_directory` | Allowed, with no permission prompts when accessing paths outside the workspace |
|
|
288
|
-
| `raven_mcp` | Raven-only bridge to on-demand remote/
|
|
302
|
+
| `raven_mcp` | Raven-only bridge to on-demand remote/local MCP servers |
|
|
289
303
|
| Context7 | On-demand library/framework/SDK/API docs |
|
|
290
304
|
| Exa AI | On-demand web search, news, pages, products |
|
|
291
305
|
| Grep.app | On-demand public GitHub examples |
|
package/index.ts
CHANGED
|
@@ -31,16 +31,19 @@ const DEFAULT_ROUTE_TOOL_KEYWORDS: string[] = []
|
|
|
31
31
|
|
|
32
32
|
const DEFAULT_ON_DEMAND_MCP_SERVERS: Record<string, OnDemandMcpServer> = {
|
|
33
33
|
context7: {
|
|
34
|
+
enabled: true,
|
|
34
35
|
type: "remote",
|
|
35
36
|
url: "https://mcp.context7.com/mcp",
|
|
36
37
|
description: "Library/framework documentation lookup.",
|
|
37
38
|
},
|
|
38
39
|
exa: {
|
|
40
|
+
enabled: true,
|
|
39
41
|
type: "remote",
|
|
40
42
|
url: "https://mcp.exa.ai/mcp",
|
|
41
43
|
description: "Live web search and webpage fetching.",
|
|
42
44
|
},
|
|
43
45
|
grep_app: {
|
|
46
|
+
enabled: true,
|
|
44
47
|
type: "remote",
|
|
45
48
|
url: "https://mcp.grep.app",
|
|
46
49
|
description: "Public GitHub code search and real-world examples.",
|
|
@@ -138,6 +141,8 @@ interface OnDemandMcpToolInfo {
|
|
|
138
141
|
}
|
|
139
142
|
|
|
140
143
|
interface BaseOnDemandMcpServer {
|
|
144
|
+
enabled?: boolean
|
|
145
|
+
timeout?: number
|
|
141
146
|
description?: string
|
|
142
147
|
}
|
|
143
148
|
|
|
@@ -154,17 +159,17 @@ interface RemoteOnDemandMcpServer extends BaseOnDemandMcpServer {
|
|
|
154
159
|
type: "remote"
|
|
155
160
|
url: string
|
|
156
161
|
headers?: Record<string, string>
|
|
162
|
+
oauth?: Record<string, unknown> | false
|
|
157
163
|
}
|
|
158
164
|
|
|
159
|
-
interface
|
|
160
|
-
type: "
|
|
161
|
-
command: string
|
|
162
|
-
|
|
163
|
-
env?: Record<string, string>
|
|
165
|
+
interface LocalOnDemandMcpServer extends BaseOnDemandMcpServer {
|
|
166
|
+
type: "local"
|
|
167
|
+
command: string[]
|
|
168
|
+
environment?: Record<string, string>
|
|
164
169
|
cwd?: string
|
|
165
170
|
}
|
|
166
171
|
|
|
167
|
-
type OnDemandMcpServer = RemoteOnDemandMcpServer |
|
|
172
|
+
type OnDemandMcpServer = RemoteOnDemandMcpServer | LocalOnDemandMcpServer
|
|
168
173
|
type OnDemandMcpDescriptionDetail = "full" | "minimized"
|
|
169
174
|
type RavenMcpOperation = "list_tools" | "call_tool" | "list_resources" | "read_resource" | "list_prompts" | "get_prompt"
|
|
170
175
|
|
|
@@ -198,7 +203,7 @@ const DEFAULT_CONFIG: RavenConfig = {
|
|
|
198
203
|
onDemandMcpServers: DEFAULT_ON_DEMAND_MCP_SERVERS,
|
|
199
204
|
excludeAgents: [],
|
|
200
205
|
excludeTools: [],
|
|
201
|
-
timeout:
|
|
206
|
+
timeout: 600,
|
|
202
207
|
}
|
|
203
208
|
|
|
204
209
|
function parseRavenMd(raw: string): { frontmatter: Record<string, any>; prompt: string } {
|
|
@@ -303,32 +308,45 @@ function normalizeToolInfo(value: unknown): OnDemandMcpToolInfo[] | undefined {
|
|
|
303
308
|
return tools.length ? tools : undefined
|
|
304
309
|
}
|
|
305
310
|
|
|
311
|
+
function normalizeStringRecord(value: unknown): Record<string, string> | undefined {
|
|
312
|
+
if (!value || typeof value !== "object") return undefined
|
|
313
|
+
const entries = Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string")
|
|
314
|
+
return entries.length ? Object.fromEntries(entries) : undefined
|
|
315
|
+
}
|
|
316
|
+
|
|
306
317
|
function normalizeOnDemandMcpServer(value: unknown): OnDemandMcpServer | undefined {
|
|
307
318
|
if (!value || typeof value !== "object") return undefined
|
|
308
319
|
const source = value as Record<string, any>
|
|
309
320
|
const base = {
|
|
321
|
+
enabled: source.enabled !== false,
|
|
322
|
+
...(typeof source.timeout === "number" && source.timeout > 0 ? { timeout: Math.floor(source.timeout) } : {}),
|
|
310
323
|
...(typeof source.description === "string" && source.description.trim() ? { description: source.description.trim() } : {}),
|
|
311
324
|
}
|
|
312
325
|
|
|
313
|
-
if (source.type === "
|
|
314
|
-
|
|
326
|
+
if (source.type === "local") {
|
|
327
|
+
const commandParts = Array.isArray(source.command)
|
|
328
|
+
? source.command.filter((arg: unknown): arg is string => typeof arg === "string" && arg.trim())
|
|
329
|
+
: []
|
|
330
|
+
if (!commandParts.length) return undefined
|
|
331
|
+
const environment = normalizeStringRecord(source.environment)
|
|
315
332
|
return {
|
|
316
333
|
...base,
|
|
317
|
-
type: "
|
|
318
|
-
command:
|
|
319
|
-
...(
|
|
320
|
-
...(source.env && typeof source.env === "object" ? { env: Object.fromEntries(Object.entries(source.env).filter((entry): entry is [string, string] => typeof entry[1] === "string")) } : {}),
|
|
334
|
+
type: "local",
|
|
335
|
+
command: commandParts,
|
|
336
|
+
...(environment ? { environment } : {}),
|
|
321
337
|
...(typeof source.cwd === "string" && source.cwd.trim() ? { cwd: source.cwd.trim() } : {}),
|
|
322
338
|
}
|
|
323
339
|
}
|
|
324
340
|
|
|
325
341
|
if (source.type === "remote" || typeof source.url === "string") {
|
|
326
342
|
if (typeof source.url !== "string" || !source.url.trim()) return undefined
|
|
343
|
+
const headers = normalizeStringRecord(source.headers)
|
|
327
344
|
return {
|
|
328
345
|
...base,
|
|
329
346
|
type: "remote",
|
|
330
347
|
url: source.url.trim(),
|
|
331
|
-
...(
|
|
348
|
+
...(headers ? { headers } : {}),
|
|
349
|
+
...(source.oauth === false ? { oauth: false } : source.oauth && typeof source.oauth === "object" ? { oauth: source.oauth } : {}),
|
|
332
350
|
}
|
|
333
351
|
}
|
|
334
352
|
|
|
@@ -476,6 +494,8 @@ export default ((input: PluginInput) => {
|
|
|
476
494
|
const ravenTaskCalls = new Set<string>()
|
|
477
495
|
const sessionAgents = new Map<string, string>()
|
|
478
496
|
const ravenSessionParents = new Map<string, string>()
|
|
497
|
+
const ravenErrorWaiters = new Map<string, (error: Error) => void>()
|
|
498
|
+
const ravenProgressWaiters = new Map<string, () => void>()
|
|
479
499
|
let globalMcpServers: string[] = []
|
|
480
500
|
let duplicateMcpServers: string[] = []
|
|
481
501
|
let updateInfo: { current: string; latest?: string; available: boolean } | undefined
|
|
@@ -486,12 +506,13 @@ export default ((input: PluginInput) => {
|
|
|
486
506
|
let mcpRefreshScheduled = false
|
|
487
507
|
let mcpRefreshPromise: Promise<{ refreshed: string[]; failed: string[] }> | undefined
|
|
488
508
|
let lastNotifiedMcpFailureKey = ""
|
|
509
|
+
let ravenProviderIssue: string | undefined
|
|
489
510
|
|
|
490
511
|
resetOnDemandMcpRuntimeStatuses()
|
|
491
512
|
|
|
492
513
|
function resetOnDemandMcpRuntimeStatuses() {
|
|
493
514
|
let changed = false
|
|
494
|
-
for (const name of
|
|
515
|
+
for (const [name] of activeOnDemandMcpEntries()) {
|
|
495
516
|
const metadata = mcpMetadata[name] ?? {}
|
|
496
517
|
if (metadata.status !== "pending" || metadata.lastError || metadata.lastCheckedAt) {
|
|
497
518
|
mcpMetadata[name] = { ...metadata, status: "pending" }
|
|
@@ -552,9 +573,26 @@ export default ((input: PluginInput) => {
|
|
|
552
573
|
}
|
|
553
574
|
|
|
554
575
|
function rerouteMessage(tool: string, args: any): string {
|
|
576
|
+
if (ravenProviderIssue) {
|
|
577
|
+
return `Raven is currently unavailable: ${ravenProviderIssue}
|
|
578
|
+
|
|
579
|
+
${ravenRecoveryInstructions()}`
|
|
580
|
+
}
|
|
555
581
|
return `The '${tool}' tool call is blocked by Raven. Your next tool call should be raven_seek(query="${attemptedQuery(tool, args).replace(/"/g, "'")}").`
|
|
556
582
|
}
|
|
557
583
|
|
|
584
|
+
function ravenRecoveryInstructions(): string {
|
|
585
|
+
return `Stop trying Raven-routed tools or MCPs for this request. Inform the user that Raven cannot run until its model/provider is changed or fixed.
|
|
586
|
+
|
|
587
|
+
Tell the user to switch Raven's model with:
|
|
588
|
+
/raven model <provider/model>
|
|
589
|
+
|
|
590
|
+
Example:
|
|
591
|
+
/raven model openai/gpt-5.5
|
|
592
|
+
|
|
593
|
+
Then restart opencode for the model change to take effect. If the task can be completed without Raven, continue without routed tools and clearly mention what could not be checked.`
|
|
594
|
+
}
|
|
595
|
+
|
|
558
596
|
function routeSummary(): string {
|
|
559
597
|
const tools = config.routeTools?.length ? config.routeTools.join(", ") : "(none)"
|
|
560
598
|
const mcps = globalMcpServers.length ? `${globalMcpServers.join(", ")} (auto-detected)` : "none detected"
|
|
@@ -591,8 +629,10 @@ export default ((input: PluginInput) => {
|
|
|
591
629
|
|
|
592
630
|
function onDemandMcpGuidance(target: "delegate" | "raven" = "delegate"): string {
|
|
593
631
|
const servers = config.onDemandMcpServers ?? {}
|
|
594
|
-
const
|
|
632
|
+
const activeEntries = activeOnDemandMcpEntries()
|
|
633
|
+
const entries = activeEntries.filter(([name]) => onDemandMcpRuntimeStatus(name) === "ready")
|
|
595
634
|
if (!Object.keys(servers).length) return "No on-demand MCPs are configured."
|
|
635
|
+
if (!activeEntries.length) return "No enabled on-demand MCPs are configured."
|
|
596
636
|
if (!entries.length) return "No loaded on-demand MCPs are available."
|
|
597
637
|
|
|
598
638
|
const detail = config.onDemandMcpDescriptionDetail ?? "full"
|
|
@@ -653,6 +693,49 @@ export default ((input: PluginInput) => {
|
|
|
653
693
|
}
|
|
654
694
|
}
|
|
655
695
|
|
|
696
|
+
function providerErrorMessage(error: any): string {
|
|
697
|
+
const name = String(error?.name ?? "")
|
|
698
|
+
const data = error?.data && typeof error.data === "object" ? error.data : {}
|
|
699
|
+
const status = typeof data.statusCode === "number" ? ` HTTP ${data.statusCode}.` : ""
|
|
700
|
+
const body = typeof data.responseBody === "string" && data.responseBody.trim() ? ` ${truncateText(data.responseBody, 500)}` : ""
|
|
701
|
+
const message = String(data.message ?? error?.message ?? error ?? "Unknown provider error")
|
|
702
|
+
return `Raven provider/API error${name ? ` (${name})` : ""}.${status} ${message}${body}`.trim()
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function waitForRavenProviderError(sessionId: string): Promise<never> {
|
|
706
|
+
return new Promise((_resolve, reject) => {
|
|
707
|
+
ravenErrorWaiters.set(sessionId, reject)
|
|
708
|
+
})
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function waitForRavenFirstProgress(sessionId: string, ms: number): Promise<never> {
|
|
712
|
+
return new Promise((_resolve, reject) => {
|
|
713
|
+
const timer = setTimeout(() => {
|
|
714
|
+
ravenProgressWaiters.delete(sessionId)
|
|
715
|
+
reject(new Error(`Raven did not produce an initial response within ${Math.round(ms / 1000)}s. If the Raven provider showed a retry dialog, this is likely an auth/quota/subscription issue. Check the Raven subagent/provider account, or retry after switching Raven to a working model.`))
|
|
716
|
+
}, ms)
|
|
717
|
+
ravenProgressWaiters.set(sessionId, () => {
|
|
718
|
+
clearTimeout(timer)
|
|
719
|
+
ravenProgressWaiters.delete(sessionId)
|
|
720
|
+
})
|
|
721
|
+
})
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function reportRavenProviderError(sessionId: string, error: any) {
|
|
725
|
+
const waiter = ravenErrorWaiters.get(sessionId)
|
|
726
|
+
if (!waiter) return
|
|
727
|
+
waiter(new Error(providerErrorMessage(error)))
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function reportRavenProgress(sessionId: string) {
|
|
731
|
+
ravenProgressWaiters.get(sessionId)?.()
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function clearRavenWaiters(sessionId: string) {
|
|
735
|
+
ravenErrorWaiters.delete(sessionId)
|
|
736
|
+
ravenProgressWaiters.get(sessionId)?.()
|
|
737
|
+
}
|
|
738
|
+
|
|
656
739
|
type McpConnection = {
|
|
657
740
|
client: Client
|
|
658
741
|
transport: any
|
|
@@ -694,22 +777,29 @@ export default ((input: PluginInput) => {
|
|
|
694
777
|
}
|
|
695
778
|
|
|
696
779
|
function getOnDemandMcpServer(serverName: string): [string, OnDemandMcpServer] | undefined {
|
|
697
|
-
const entries =
|
|
780
|
+
const entries = activeOnDemandMcpEntries()
|
|
698
781
|
return entries.find(([name]) => name.toLowerCase() === serverName.toLowerCase())
|
|
699
782
|
}
|
|
700
783
|
|
|
784
|
+
function activeOnDemandMcpEntries(): [string, OnDemandMcpServer][] {
|
|
785
|
+
return Object.entries(config.onDemandMcpServers ?? {}).filter(([, server]) => server.enabled !== false)
|
|
786
|
+
}
|
|
787
|
+
|
|
701
788
|
async function connectOnDemandMcp(serverName: string, server: OnDemandMcpServer): Promise<McpConnection> {
|
|
702
|
-
const timeout = (config.timeout ?? 180) * 1000
|
|
789
|
+
const timeout = server.timeout ?? (config.timeout ?? 180) * 1000
|
|
703
790
|
const client = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
|
|
704
791
|
|
|
705
|
-
if (server.type === "
|
|
706
|
-
const env = { ...cleanProcessEnv(), ...resolveStringRecord(server.
|
|
792
|
+
if (server.type === "local") {
|
|
793
|
+
const env = { ...cleanProcessEnv(), ...resolveStringRecord(server.environment) }
|
|
707
794
|
const transport = new StdioClientTransport({
|
|
708
|
-
command: resolveEnvPlaceholders(server.command),
|
|
709
|
-
args: server.
|
|
795
|
+
command: resolveEnvPlaceholders(server.command[0]),
|
|
796
|
+
args: server.command.slice(1).map((arg) => resolveEnvPlaceholders(arg)),
|
|
710
797
|
env,
|
|
798
|
+
stderr: "pipe",
|
|
711
799
|
cwd: server.cwd ? resolveEnvPlaceholders(server.cwd) : undefined,
|
|
712
800
|
})
|
|
801
|
+
transport.stderr?.on("data", () => {})
|
|
802
|
+
transport.stderr?.on("error", () => {})
|
|
713
803
|
await withTimeout(client.connect(transport), timeout, `Connecting to ${serverName}`)
|
|
714
804
|
return { client, transport, serverName, lastUsed: Date.now() }
|
|
715
805
|
}
|
|
@@ -800,7 +890,7 @@ export default ((input: PluginInput) => {
|
|
|
800
890
|
}
|
|
801
891
|
|
|
802
892
|
async function refreshOnDemandMcpDescriptions(force = false, serverName?: string): Promise<{ refreshed: string[]; failed: string[] }> {
|
|
803
|
-
const entries =
|
|
893
|
+
const entries = activeOnDemandMcpEntries()
|
|
804
894
|
.filter(([name]) => !serverName || name.toLowerCase() === serverName.toLowerCase())
|
|
805
895
|
.filter(([name]) => {
|
|
806
896
|
const metadata = mcpMetadata[name]
|
|
@@ -870,7 +960,7 @@ export default ((input: PluginInput) => {
|
|
|
870
960
|
|
|
871
961
|
function onDemandMcpStatusBuckets(): { loaded: string[]; failed: string[]; pending: string[] } {
|
|
872
962
|
const buckets = { loaded: [] as string[], failed: [] as string[], pending: [] as string[] }
|
|
873
|
-
for (const name of
|
|
963
|
+
for (const [name] of activeOnDemandMcpEntries()) {
|
|
874
964
|
const status = onDemandMcpRuntimeStatus(name)
|
|
875
965
|
if (status === "ready") buckets.loaded.push(name)
|
|
876
966
|
else if (status === "failed") buckets.failed.push(name)
|
|
@@ -880,7 +970,7 @@ export default ((input: PluginInput) => {
|
|
|
880
970
|
}
|
|
881
971
|
|
|
882
972
|
function failedOnDemandMcpNames(names?: string[]): string[] {
|
|
883
|
-
const configured =
|
|
973
|
+
const configured = activeOnDemandMcpEntries().map(([name]) => name)
|
|
884
974
|
const wanted = names?.length ? names : configured
|
|
885
975
|
return wanted.filter((name) => configured.includes(name) && onDemandMcpRuntimeStatus(name) === "failed")
|
|
886
976
|
}
|
|
@@ -903,8 +993,9 @@ export default ((input: PluginInput) => {
|
|
|
903
993
|
}
|
|
904
994
|
|
|
905
995
|
function onDemandMcpStatus(): string {
|
|
906
|
-
const entries =
|
|
907
|
-
if (!
|
|
996
|
+
const entries = activeOnDemandMcpEntries()
|
|
997
|
+
if (!Object.keys(config.onDemandMcpServers ?? {}).length) return "Raven on-demand MCPs: none configured."
|
|
998
|
+
if (!entries.length) return "Raven on-demand MCPs: none enabled."
|
|
908
999
|
const buckets = onDemandMcpStatusBuckets()
|
|
909
1000
|
const lines = entries.map(([name, server]) => {
|
|
910
1001
|
const metadata = mcpMetadata[name]
|
|
@@ -920,7 +1011,7 @@ export default ((input: PluginInput) => {
|
|
|
920
1011
|
}
|
|
921
1012
|
|
|
922
1013
|
function avoidedMcpSchemaBytes(): number {
|
|
923
|
-
return
|
|
1014
|
+
return activeOnDemandMcpEntries().reduce((sum, [name]) => {
|
|
924
1015
|
const metadata = mcpMetadata[name]
|
|
925
1016
|
if (onDemandMcpRuntimeStatus(name) !== "ready") return sum
|
|
926
1017
|
return sum + Math.max(0, metadata?.avoidedSchemaBytes ?? 0)
|
|
@@ -1157,7 +1248,7 @@ export default ((input: PluginInput) => {
|
|
|
1157
1248
|
globalMcpServers = uniqueStrings(Object.entries(configInput.mcp ?? {})
|
|
1158
1249
|
.filter(([, value]) => !value || typeof value !== "object" || (value as any).enabled !== false)
|
|
1159
1250
|
.map(([name]) => name))
|
|
1160
|
-
const onDemandNames = new Set(
|
|
1251
|
+
const onDemandNames = new Set(activeOnDemandMcpEntries().map(([name]) => name.toLowerCase()))
|
|
1161
1252
|
duplicateMcpServers = globalMcpServers.filter((name) => onDemandNames.has(name.toLowerCase()))
|
|
1162
1253
|
|
|
1163
1254
|
// Inject MCP guidance as a startup instruction file (absolute path for npm compat).
|
|
@@ -1210,7 +1301,7 @@ export default ((input: PluginInput) => {
|
|
|
1210
1301
|
},
|
|
1211
1302
|
async execute(args, context) {
|
|
1212
1303
|
const started = Date.now()
|
|
1213
|
-
const timeout = (config.timeout ??
|
|
1304
|
+
const timeout = (config.timeout ?? 600) * 1000
|
|
1214
1305
|
try {
|
|
1215
1306
|
// Create a Raven session
|
|
1216
1307
|
const session = await client.session.create({
|
|
@@ -1238,55 +1329,76 @@ export default ((input: PluginInput) => {
|
|
|
1238
1329
|
appendFileSync(logFile, `${ts} ${sessionId} "${q}"\n`)
|
|
1239
1330
|
} catch { /* non-fatal */ }
|
|
1240
1331
|
|
|
1241
|
-
// Send the query to Raven with timeout
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
parts: [{ type: "text", text: args.query }],
|
|
1248
|
-
},
|
|
1249
|
-
}),
|
|
1250
|
-
new Promise<never>((_, reject) =>
|
|
1251
|
-
setTimeout(() => reject(new Error(`Raven timed out after ${timeout / 1000}s — session kept: ${sessionId}`)), timeout)
|
|
1252
|
-
),
|
|
1253
|
-
])
|
|
1254
|
-
|
|
1255
|
-
const elapsed = ((Date.now() - started) / 1000).toFixed(1)
|
|
1256
|
-
|
|
1257
|
-
// Extract text from the response
|
|
1258
|
-
const parts = (result as any)?.data?.parts ?? []
|
|
1259
|
-
const textParts = parts
|
|
1260
|
-
.filter((p: any) => p.type === "text" && p.text)
|
|
1261
|
-
.map((p: any) => p.text)
|
|
1262
|
-
const output = textParts.join("\n") || "Raven returned no results."
|
|
1263
|
-
|
|
1264
|
-
// Context saved = Raven output/reasoning/cache context - compact answer returned to main session.
|
|
1265
|
-
let savedCandidate = 0
|
|
1332
|
+
// Send the query to Raven with timeout. Provider/API errors are also
|
|
1333
|
+
// surfaced through message.updated events; fail as soon as one lands
|
|
1334
|
+
// so the caller does not wait for the full timeout on quota/auth issues.
|
|
1335
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1336
|
+
const providerError = waitForRavenProviderError(sessionId)
|
|
1337
|
+
const firstProgress = waitForRavenFirstProgress(sessionId, Math.min(timeout, 30_000))
|
|
1266
1338
|
try {
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1339
|
+
const result = await Promise.race([
|
|
1340
|
+
client.session.prompt({
|
|
1341
|
+
path: { id: sessionId },
|
|
1342
|
+
body: {
|
|
1343
|
+
agent: "raven",
|
|
1344
|
+
parts: [{ type: "text", text: args.query }],
|
|
1345
|
+
},
|
|
1346
|
+
}),
|
|
1347
|
+
providerError,
|
|
1348
|
+
firstProgress,
|
|
1349
|
+
new Promise<never>((_, reject) => {
|
|
1350
|
+
timer = setTimeout(() => reject(new Error(`Raven timed out after ${timeout / 1000}s — session kept: ${sessionId}`)), timeout)
|
|
1351
|
+
}),
|
|
1352
|
+
])
|
|
1353
|
+
if (timer) clearTimeout(timer)
|
|
1354
|
+
clearRavenWaiters(sessionId)
|
|
1355
|
+
|
|
1356
|
+
const elapsed = ((Date.now() - started) / 1000).toFixed(1)
|
|
1357
|
+
|
|
1358
|
+
// Extract text from the response
|
|
1359
|
+
const parts = (result as any)?.data?.parts ?? []
|
|
1360
|
+
const textParts = parts
|
|
1361
|
+
.filter((p: any) => p.type === "text" && p.text)
|
|
1362
|
+
.map((p: any) => p.text)
|
|
1363
|
+
const output = textParts.join("\n") || "Raven returned no results."
|
|
1364
|
+
|
|
1365
|
+
// Context saved = Raven output/reasoning/cache context - compact answer returned to main session.
|
|
1366
|
+
let savedCandidate = 0
|
|
1367
|
+
try {
|
|
1368
|
+
savedCandidate = await countRavenSavedCandidateBytes(sessionId)
|
|
1369
|
+
} catch { /* best-effort */ }
|
|
1370
|
+
if (savedCandidate <= 0) {
|
|
1371
|
+
for (const part of parts) {
|
|
1372
|
+
if (part.args) savedCandidate += JSON.stringify(part.args).length
|
|
1373
|
+
if (part.content) savedCandidate += typeof part.content === "string" ? part.content.length : JSON.stringify(part.content).length
|
|
1374
|
+
}
|
|
1273
1375
|
}
|
|
1376
|
+
const saved = Math.max(0, savedCandidate - output.length)
|
|
1377
|
+
addBytes(saved)
|
|
1378
|
+
ravenProviderIssue = undefined
|
|
1379
|
+
|
|
1380
|
+
return { title: "Raven Seek", metadata: { sessionId }, output: `${output}\n\n*Raven searched for ${elapsed}s — ${formatBytes(savedCandidate)} handled, ${formatTokens(savedCandidate)} tokens*` }
|
|
1381
|
+
} finally {
|
|
1382
|
+
if (timer) clearTimeout(timer)
|
|
1383
|
+
clearRavenWaiters(sessionId)
|
|
1274
1384
|
}
|
|
1275
|
-
const saved = Math.max(0, savedCandidate - output.length)
|
|
1276
|
-
addBytes(saved)
|
|
1277
|
-
|
|
1278
|
-
return { title: "Raven Seek", metadata: { sessionId }, output: `${output}\n\n*Raven searched for ${elapsed}s — ${formatBytes(savedCandidate)} handled, ${formatTokens(savedCandidate)} tokens*` }
|
|
1279
1385
|
} catch (err: any) {
|
|
1280
1386
|
const elapsed = ((Date.now() - started) / 1000).toFixed(1)
|
|
1281
1387
|
const msg = String(err?.message ?? err ?? "").toLowerCase()
|
|
1282
1388
|
const hint =
|
|
1283
1389
|
/rate.?limit|too many requests|429/i.test(msg) ? "Raven rate limited — wait 30s then retry with a narrower query."
|
|
1284
|
-
: /quota|usage.?limit|billing|insufficient.*(?:credit|balance|quota)/i.test(msg) ? "Raven API quota exhausted — proceed without
|
|
1390
|
+
: /quota|usage.?limit|usage exceeded|free usage|subscribe to go|subscription|billing|insufficient.*(?:credit|balance|quota)/i.test(msg) ? "Raven API quota/subscription exhausted — proceed without Raven, tell the user Raven could not run, and mention what information may be missing."
|
|
1391
|
+
: /auth|unauthorized|forbidden|401|403|api key|login/i.test(msg) ? "Raven provider authentication failed — proceed without Raven and tell the user to check the Raven model/provider login or API key."
|
|
1285
1392
|
: /token|context.?length|too large|too long/i.test(msg) ? "Raven query too large — shorten your query and retry."
|
|
1286
1393
|
: /model|unavailable|down|not found/i.test(msg) ? "Raven model unavailable — retry later, or proceed without search."
|
|
1394
|
+
: /did not produce an initial response|retry dialog/i.test(msg) ? err.message
|
|
1287
1395
|
: /timeout|timed.?out|session kept/i.test(msg) ? err.message
|
|
1288
1396
|
: `Raven search failed. Proceed without search — note gaps for the user. [${err.message || err}]`
|
|
1289
|
-
|
|
1397
|
+
if (/quota|usage.?limit|usage exceeded|free usage|subscribe to go|subscription|billing|auth|unauthorized|forbidden|401|403|api key|login|model|unavailable|down|not found|did not produce an initial response|retry dialog/i.test(msg)) {
|
|
1398
|
+
ravenProviderIssue = hint
|
|
1399
|
+
}
|
|
1400
|
+
const recovery = ravenProviderIssue ? `\n\n${ravenRecoveryInstructions()}` : ""
|
|
1401
|
+
return { title: "Raven Seek", output: `${hint}${recovery}\n\n*Attempt took ${elapsed}s*` }
|
|
1290
1402
|
}
|
|
1291
1403
|
},
|
|
1292
1404
|
}),
|
|
@@ -1304,7 +1416,7 @@ export default ((input: PluginInput) => {
|
|
|
1304
1416
|
}
|
|
1305
1417
|
|
|
1306
1418
|
const operation = args.operation as RavenMcpOperation
|
|
1307
|
-
const timeout = (config.timeout ??
|
|
1419
|
+
const timeout = (config.timeout ?? 600) * 1000
|
|
1308
1420
|
let connected = false
|
|
1309
1421
|
try {
|
|
1310
1422
|
const connection = await getMcpConnection(context.sessionID, args.server)
|
|
@@ -1387,6 +1499,37 @@ export default ((input: PluginInput) => {
|
|
|
1387
1499
|
if (evt?.type === "session.created" && evt?.properties?.parentID) {
|
|
1388
1500
|
ravenSessionParents.set(evt.properties.parentID, evt.properties.id)
|
|
1389
1501
|
}
|
|
1502
|
+
if (evt?.type === "message.updated") {
|
|
1503
|
+
const info = evt.properties?.info
|
|
1504
|
+
if (info?.role === "assistant" && info?.sessionID && info.error && ravenSessions.has(info.sessionID)) {
|
|
1505
|
+
reportRavenProviderError(info.sessionID, info.error)
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
if (evt?.type === "message.part.updated") {
|
|
1509
|
+
const part = evt.properties?.part
|
|
1510
|
+
if (part?.sessionID && ravenSessions.has(part.sessionID)) {
|
|
1511
|
+
if (part.type === "retry" && part.error) {
|
|
1512
|
+
reportRavenProviderError(part.sessionID, part.error)
|
|
1513
|
+
} else {
|
|
1514
|
+
reportRavenProgress(part.sessionID)
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
if (evt?.type === "session.status") {
|
|
1519
|
+
const sessionId = evt.properties?.sessionID
|
|
1520
|
+
const status = evt.properties?.status
|
|
1521
|
+
if (sessionId && ravenSessions.has(sessionId)) {
|
|
1522
|
+
if (status?.type === "retry") {
|
|
1523
|
+
reportRavenProviderError(sessionId, { name: "RetryError", data: { message: status.message } })
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
if (evt?.type === "session.error") {
|
|
1528
|
+
const sessionId = evt.properties?.sessionID
|
|
1529
|
+
if (sessionId && evt.properties?.error && ravenSessions.has(sessionId)) {
|
|
1530
|
+
reportRavenProviderError(sessionId, evt.properties.error)
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1390
1533
|
|
|
1391
1534
|
if (updateToastPending) {
|
|
1392
1535
|
updateToastPending = false
|
|
@@ -1487,6 +1630,7 @@ export default ((input: PluginInput) => {
|
|
|
1487
1630
|
} else {
|
|
1488
1631
|
config.model = model
|
|
1489
1632
|
saveConfig(config)
|
|
1633
|
+
ravenProviderIssue = undefined
|
|
1490
1634
|
output.parts.push({ type: "text", text: `Raven model set to: ${model}\nRestart opencode for the change to take effect.` })
|
|
1491
1635
|
}
|
|
1492
1636
|
} else if (arg.startsWith("effort ")) {
|
|
@@ -1501,7 +1645,7 @@ export default ((input: PluginInput) => {
|
|
|
1501
1645
|
} else if (arg.startsWith("timeout ")) {
|
|
1502
1646
|
const secs = parseInt(raw.slice(8).trim(), 10)
|
|
1503
1647
|
if (!secs || secs < 10) {
|
|
1504
|
-
output.parts.push({ type: "text", text: `Usage: /raven timeout <seconds>\nMust be at least 10. Current: ${config.timeout ??
|
|
1648
|
+
output.parts.push({ type: "text", text: `Usage: /raven timeout <seconds>\nMust be at least 10. Current: ${config.timeout ?? 600}s` })
|
|
1505
1649
|
} else {
|
|
1506
1650
|
config.timeout = secs
|
|
1507
1651
|
saveConfig(config)
|
|
@@ -1511,7 +1655,8 @@ export default ((input: PluginInput) => {
|
|
|
1511
1655
|
const enabled = config.enabled ? "enabled" : "disabled"
|
|
1512
1656
|
const model = config.model || fm.model || "(default)"
|
|
1513
1657
|
const effort = config.reasoning_effort || fm.reasoning_effort || "(default)"
|
|
1514
|
-
const timeout = config.timeout ??
|
|
1658
|
+
const timeout = config.timeout ?? 600
|
|
1659
|
+
const providerIssue = ravenProviderIssue ? `\nProvider issue: ${ravenProviderIssue}` : ""
|
|
1515
1660
|
let update = "Update: unable to check npm."
|
|
1516
1661
|
try {
|
|
1517
1662
|
const info = await getUpdateInfo()
|
|
@@ -1519,7 +1664,7 @@ export default ((input: PluginInput) => {
|
|
|
1519
1664
|
? `Update: ${info.latest} available. Run /raven update, then restart opencode.`
|
|
1520
1665
|
: `Update: up to date${info.latest ? ` (latest ${info.latest})` : ""}.`
|
|
1521
1666
|
} catch { /* keep fallback */ }
|
|
1522
|
-
output.parts.push({ type: "text", text: `Raven is ${enabled}. Version: ${PACKAGE_VERSION}. Model: ${model}. Reasoning: ${effort}. Timeout: ${timeout}s\n${update}\n\n${routeSummary()}\n\n${mcpSummary()}\n\n${statsText()}\n\nRun /raven help for commands.` })
|
|
1667
|
+
output.parts.push({ type: "text", text: `Raven is ${enabled}. Version: ${PACKAGE_VERSION}. Model: ${model}. Reasoning: ${effort}. Timeout: ${timeout}s${providerIssue}\n${update}\n\n${routeSummary()}\n\n${mcpSummary()}\n\n${statsText()}\n\nRun /raven help for commands.` })
|
|
1523
1668
|
}
|
|
1524
1669
|
},
|
|
1525
1670
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-raven",
|
|
3
|
-
"version": "2.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.1.3",
|
|
4
|
+
"description": "Ready-to-use search tools and optional on-demand MCPs for OpenCode, routed through Raven to save your main model's context and cost.",
|
|
5
5
|
"main": "./index.ts",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./index.ts"
|