opencode-raven 2.2.2 → 2.3.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/index.ts CHANGED
@@ -1,2660 +1,14 @@
1
- import type { Plugin, PluginInput } from "@opencode-ai/plugin"
2
- import { tool } from "@opencode-ai/plugin"
3
- import { Client } from "@modelcontextprotocol/sdk/client/index.js"
4
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
5
- import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
6
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
7
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"
8
- import { join } from "node:path"
9
- import { homedir, tmpdir } from "node:os"
10
-
11
- // ── Resolve paths relative to this package (works in node_modules/) ──
12
- const PKG_DIR = import.meta.dirname!
13
-
14
- const RAVEN_MD = join(PKG_DIR, "Raven.md")
15
- const RAVEN_MCP_MD = join(PKG_DIR, "RavenMcp.md")
16
- const MCP_GUIDANCE_MD = join(PKG_DIR, "mcp-guidance.md")
17
- const PACKAGE_JSON = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf-8"))
18
- const PACKAGE_NAME = PACKAGE_JSON.name || "opencode-raven"
19
- const PACKAGE_VERSION = PACKAGE_JSON.version || "0.0.0"
20
-
21
- // ── Tools/MCPs that should be intercepted for non-Raven agents ──
22
- const DEFAULT_ROUTE_TOOLS = [
23
- "grep",
24
- "glob",
25
- "webfetch",
26
- "fetch",
27
- "websearch",
28
- "bash",
29
- ]
30
-
31
- const DEFAULT_ROUTE_TOOL_KEYWORDS: string[] = []
32
-
33
- const DEFAULT_ON_DEMAND_MCP_SERVERS: Record<string, OnDemandMcpServer> = {
34
- context7: {
35
- enabled: true,
36
- type: "remote",
37
- url: "https://mcp.context7.com/mcp",
38
- description: "Library/framework documentation lookup.",
39
- },
40
- exa: {
41
- enabled: true,
42
- type: "remote",
43
- url: "https://mcp.exa.ai/mcp",
44
- description: "Live web search and webpage fetching.",
45
- },
46
- grep_app: {
47
- enabled: true,
48
- type: "remote",
49
- url: "https://mcp.grep.app",
50
- description: "Public GitHub code search and real-world examples.",
51
- },
52
- }
53
-
54
- const DEFAULT_ON_DEMAND_MCP_METADATA: Record<string, OnDemandMcpMetadata> = {
55
- context7: {
56
- autoGeneratedDescription: "Finds current library, framework, SDK, package, and API documentation; useful for implementation, configuration, and debugging with version-specific docs.",
57
- },
58
- exa: {
59
- autoGeneratedDescription: "Searches the live web, fetches webpages, researches companies/products, and gathers current information from online sources.",
60
- },
61
- grep_app: {
62
- autoGeneratedDescription: "Searches public GitHub repositories for real-world code examples, usage patterns, configuration snippets, and implementation references.",
63
- },
64
- }
65
-
66
- const NEVER_ROUTE_TOOLS = new Set(["raven_seek", "raven_mcp", "raven_mcp_bridge", "task", "subtask"])
67
- const RETIRED_DEFAULT_MODELS = new Set(["opencode/deepseek-v4-flash-free"])
68
- const MAX_TIMER_MS = 2_147_483_647
69
- const MAX_TIMEOUT_SECONDS = Math.floor(MAX_TIMER_MS / 1000)
70
-
71
- // ── Bash commands that look like search workarounds ──
72
- const SEARCH_BASH_RE = /\b(?:rg|ripgrep|grep|egrep|fgrep|git\s+grep|ack|ag\b|findstr|Select-String)\b|\b(?:Get-ChildItem|gci)\b(?=[^|;&\n]*(?:-Recurse|-Filter|-Include|\s-[A-Za-z]*r[A-Za-z]*\b))|\bdir\b(?=[^|;&\n]*(?:[/-][sS]\b|-Recurse|-Filter|-Include))|\bls\b(?=[^|;&\n]*(?:\s-[A-Za-z]*R[A-Za-z]*\b|--recursive\b))|\bfind\b\s+.*(?:-name|-type)\b/i
73
-
74
- // Strip quoted content to avoid false positives (e.g. echo "use grep here")
75
- function stripHeredocs(cmd: string): string {
76
- return cmd.replace(/<<-?\s*["']?(\w+)["']?[\s\S]*?\n\s*\1/g, "")
77
- }
78
-
79
- function stripShellComments(cmd: string): string {
80
- return cmd
81
- .split("\n")
82
- .map((line) => line.replace(/(^|\s)#.*$/, "$1").trimEnd())
83
- .join("\n")
84
- }
85
-
86
- function stripQuotedContent(cmd: string): string {
87
- return stripShellComments(stripHeredocs(cmd)
88
- .replace(/'[^']*'/g, "''")
89
- .replace(/"[^"]*"/g, '""'))
90
- }
91
-
92
- function unquoteShellPayload(value: string): string {
93
- const payload = value.trim()
94
- const quote = payload[0]
95
- return (quote === '"' || quote === "'") && payload.endsWith(quote)
96
- ? payload.slice(1, -1)
97
- : payload
98
- }
99
-
100
- function wrappedShellPayload(cmd: string): string | undefined {
101
- const command = stripShellComments(stripHeredocs(cmd)).trim()
102
- const match = command.match(/^(?:"([^"]+)"|'([^']+)'|(\S+))(?:\s+([\s\S]+))?$/)
103
- if (!match) return undefined
104
-
105
- const executable = (match[1] ?? match[2] ?? match[3]).replace(/\\/g, "/").split("/").pop()?.toLowerCase()
106
- const rest = match[4] ?? ""
107
- if (executable === "cmd" || executable === "cmd.exe") {
108
- const payload = rest.match(/^(?:\/[dqs]\s+)*\/c\s+([\s\S]+)$/i)?.[1]
109
- return payload === undefined ? undefined : unquoteShellPayload(payload)
110
- }
111
-
112
- if (["sh", "sh.exe", "bash", "bash.exe", "zsh", "zsh.exe", "dash", "dash.exe"].includes(executable ?? "")) {
113
- const payload = rest.match(/^-[a-z]*c\s+([\s\S]+)$/i)?.[1]
114
- return payload === undefined ? undefined : unquoteShellPayload(payload)
115
- }
116
-
117
- if (["powershell", "powershell.exe", "pwsh", "pwsh.exe"].includes(executable ?? "")) {
118
- const payload = rest.match(/(?:^|\s)-(?:command|c)\s+([\s\S]+)$/i)?.[1]
119
- if (payload === undefined) return undefined
120
- const unquoted = unquoteShellPayload(payload)
121
- return unquoted.replace(
122
- /(^|(?:(?:&&|\|\||[;|\n{(])\s*))&\s+(?:"([^"]+)"|'([^']+)'|(\S+))/g,
123
- (_match, prefix, doubleQuoted, singleQuoted, bare) => `${prefix}${doubleQuoted ?? singleQuoted ?? bare}`,
124
- )
125
- }
126
-
127
- return undefined
128
- }
129
-
130
- function splitPipelineSegments(cmd: string): string[] {
131
- const segments: string[] = []
132
- let current = ""
133
- for (let i = 0; i < cmd.length; i++) {
134
- const char = cmd[i]
135
- const prev = cmd[i - 1]
136
- const next = cmd[i + 1]
137
- if (char === "|" && prev !== "|" && next !== "|") {
138
- segments.push(current)
139
- current = ""
140
- } else {
141
- current += char
142
- }
143
- }
144
- segments.push(current)
145
- return segments
146
- }
147
-
148
- function isOutputFilterSegment(segment: string): boolean {
149
- return /^\s*(?:grep|ripgrep|rg|egrep|fgrep|findstr|Select-String|head|tail)\b/i.test(segment)
150
- }
151
-
152
- function hasSearchAfterCommandSeparator(segment: string): boolean {
153
- const separator = segment.search(/;|&&|\|\|/)
154
- return separator !== -1 && SEARCH_BASH_RE.test(segment.slice(separator))
155
- }
156
-
157
- function commandLooksLikeSearch(cmd: string): boolean {
158
- const wrapped = wrappedShellPayload(cmd)
159
- if (wrapped !== undefined) return commandLooksLikeSearch(wrapped)
160
-
161
- const stripped = stripQuotedContent(cmd)
162
- const lower = stripped.toLowerCase().trim()
163
- if (/^cmd\s+\/c\s+"?(?:findstr|find|tree)\b/.test(lower)) return true
164
- if (/^cmd\s+\/c\s+"?dir\b[^\n]*\s\/s\b/.test(lower)) return true
165
-
166
- return splitPipelineSegments(stripped).some((segment, index) => {
167
- // Allow bounded filters over output already produced by the previous command:
168
- // pacman -Qi libarchive | head -15
169
- // 7z i | grep -i udf
170
- // These are not Raven-worthy filesystem/web/doc searches.
171
- if (index > 0 && isOutputFilterSegment(segment)) return hasSearchAfterCommandSeparator(segment)
172
- return SEARCH_BASH_RE.test(segment)
173
- })
174
- }
175
-
176
- function isSearchBash(tool: string, args: any): boolean {
177
- if (tool !== "bash") return false
178
- return commandLooksLikeSearch(String(args?.command ?? ""))
179
- }
180
-
181
- // ── Config file shape ──
182
- interface OnDemandMcpToolInfo {
183
- name: string
184
- description?: string
185
- }
186
-
187
- interface BaseOnDemandMcpServer {
188
- enabled?: boolean
189
- timeout?: number
190
- description?: string
191
- }
192
-
193
- interface OnDemandMcpMetadata {
194
- autoGeneratedDescription?: string
195
- autoGeneratedTools?: OnDemandMcpToolInfo[]
196
- avoidedSchemaBytes?: number
197
- status?: "pending" | "ready" | "failed"
198
- lastError?: string
199
- lastCheckedAt?: string
200
- }
201
-
202
- interface RemoteOnDemandMcpServer extends BaseOnDemandMcpServer {
203
- type: "remote"
204
- url: string
205
- headers?: Record<string, string>
206
- }
207
-
208
- interface LocalOnDemandMcpServer extends BaseOnDemandMcpServer {
209
- type: "local"
210
- command: string[]
211
- environment?: Record<string, string>
212
- cwd?: string
213
- }
214
-
215
- type OnDemandMcpServer = RemoteOnDemandMcpServer | LocalOnDemandMcpServer
216
- type OnDemandMcpDescriptionDetail = "full" | "minimized"
217
- type RavenMcpOperation = "list_tools" | "call_tool" | "list_resources" | "read_resource" | "list_prompts" | "get_prompt"
218
-
219
- interface RavenSeekConfig {
220
- model?: string
221
- autoSelectedModel?: string
222
- reasoning_effort?: string
223
- instructions?: string
224
- routeTools?: string[]
225
- routeToolKeywords?: string[]
226
- excludeAgents?: string[]
227
- excludeTools?: string[]
228
- timeout?: number
229
- }
230
-
231
- interface RavenMcpConfig {
232
- model?: string
233
- autoSelectedModel?: string
234
- reasoning_effort?: string
235
- instructions?: string
236
- timeout?: number
237
- descriptionDetail?: OnDemandMcpDescriptionDetail
238
- onDemandMcpServers?: Record<string, OnDemandMcpServer>
239
- }
240
-
241
- interface RavenConfig {
242
- raven_seek: RavenSeekConfig
243
- raven_mcp: RavenMcpConfig
244
- stats?: { bytes: number }
245
- }
246
-
247
- // ── Parse Raven.md frontmatter ──
248
- const ravenMd = readFileSync(RAVEN_MD, "utf-8")
249
- const { frontmatter: fm, prompt: ravenPrompt } = parseRavenMd(ravenMd)
250
- const ravenMcpMd = readFileSync(RAVEN_MCP_MD, "utf-8")
251
- const { frontmatter: mcpFm, prompt: ravenMcpPrompt } = parseRavenMd(ravenMcpMd)
252
-
253
- const DEFAULT_CONFIG: RavenConfig = {
254
- raven_seek: {
255
- model: fm.model,
256
- reasoning_effort: fm.reasoning_effort,
257
- instructions: "",
258
- routeTools: DEFAULT_ROUTE_TOOLS,
259
- routeToolKeywords: DEFAULT_ROUTE_TOOL_KEYWORDS,
260
- excludeAgents: [],
261
- excludeTools: [],
262
- timeout: 600,
1
+ import type { Plugin as V1Plugin } from "@opencode-ai/plugin"
2
+ import type { Plugin as V2Plugin } from "@opencode/plugin"
3
+ import createRavenRuntime from "./raven-core"
4
+
5
+ // Object entrypoints are supported by OpenCode 1.18.29+. Each host selects its
6
+ // own lifecycle; the v2 SDK is never loaded by a v1 installation.
7
+ export default {
8
+ id: "opencode-raven",
9
+ server: (async (input) => createRavenRuntime(input)) satisfies V1Plugin,
10
+ async setup(context) {
11
+ const { setupRaven } = await import("./v2")
12
+ return setupRaven(context)
263
13
  },
264
- raven_mcp: {
265
- model: mcpFm.model,
266
- reasoning_effort: mcpFm.reasoning_effort,
267
- instructions: "",
268
- timeout: 600,
269
- descriptionDetail: "full",
270
- onDemandMcpServers: DEFAULT_ON_DEMAND_MCP_SERVERS,
271
- },
272
- }
273
-
274
- function parseRavenMd(raw: string): { frontmatter: Record<string, any>; prompt: string } {
275
- const parts = raw.split("---")
276
- if (parts.length < 3) {
277
- throw new Error("Raven.md missing frontmatter (--- delimiters)")
278
- }
279
- return { frontmatter: parseYaml(parts[1]), prompt: parts.slice(2).join("---").trim() }
280
- }
281
-
282
- // ── Minimal YAML parser (handles the structure used in Raven.md) ──
283
- function parseYaml(yaml: string): Record<string, any> {
284
- const lines = yaml.split("\n")
285
- const root: Record<string, any> = {}
286
- const stack: Array<{ obj: Record<string, any>; indent: number }> = [
287
- { obj: root, indent: -1 },
288
- ]
289
-
290
- for (const rawLine of lines) {
291
- const line = rawLine.trimEnd()
292
- if (!line.trim() || line.trim().startsWith("#")) continue
293
-
294
- const indent = line.search(/\S/)
295
- const colonIdx = line.indexOf(":")
296
- if (colonIdx === -1) continue
297
-
298
- const rawKey = line.slice(indent, colonIdx).trim()
299
- const key =
300
- (rawKey.startsWith('"') && rawKey.endsWith('"')) ||
301
- (rawKey.startsWith("'") && rawKey.endsWith("'"))
302
- ? rawKey.slice(1, -1)
303
- : rawKey
304
-
305
- const rawValue = line.slice(colonIdx + 1).trim()
306
-
307
- while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
308
- stack.pop()
309
- }
310
- const current = stack[stack.length - 1].obj
311
-
312
- if (!rawValue) {
313
- const nested: Record<string, any> = {}
314
- current[key] = nested
315
- stack.push({ obj: nested, indent })
316
- } else {
317
- let value: any = rawValue
318
- if (
319
- (value.startsWith('"') && value.endsWith('"')) ||
320
- (value.startsWith("'") && value.endsWith("'"))
321
- ) {
322
- value = value.slice(1, -1)
323
- } else if (value === "true") {
324
- value = true
325
- } else if (value === "false") {
326
- value = false
327
- } else if (/^\d+$/.test(value)) {
328
- value = parseInt(value, 10)
329
- }
330
- current[key] = value
331
- }
332
- }
333
-
334
- return root
335
- }
336
-
337
- // ── Move unknown frontmatter fields into options ──
338
- const KNOWN_KEYS = new Set([
339
- "description", "mode", "hidden", "model", "permission",
340
- "prompt", "name", "color", "steps", "disable",
341
- "temperature", "top_p", "variant",
342
- ])
343
-
344
- function extractOptions(fm: Record<string, any>): Record<string, any> {
345
- const options: Record<string, any> = {}
346
- for (const key of Object.keys(fm)) {
347
- if (!KNOWN_KEYS.has(key)) options[key] = fm[key]
348
- }
349
- return options
350
- }
351
-
352
- function uniqueStrings(value: unknown, fallback: string[] = []): string[] {
353
- const source = Array.isArray(value) ? value : fallback
354
- return [...new Set(source.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim()))]
355
- }
356
-
357
- function cloneOnDemandMcpServers(value: Record<string, OnDemandMcpServer>): Record<string, OnDemandMcpServer> {
358
- return JSON.parse(JSON.stringify(value))
359
- }
360
-
361
- function cloneOnDemandMcpMetadata(value: Record<string, OnDemandMcpMetadata>): Record<string, OnDemandMcpMetadata> {
362
- return JSON.parse(JSON.stringify(value))
363
- }
364
-
365
- function normalizeToolInfo(value: unknown): OnDemandMcpToolInfo[] | undefined {
366
- if (!Array.isArray(value)) return undefined
367
- const tools = value
368
- .filter((item) => item && typeof item === "object" && typeof item.name === "string" && item.name.trim())
369
- .map((item) => ({
370
- name: item.name.trim(),
371
- ...(typeof item.description === "string" && item.description.trim() ? { description: item.description.trim() } : {}),
372
- }))
373
- return tools.length ? tools : undefined
374
- }
375
-
376
- function normalizeStringRecord(value: unknown): Record<string, string> | undefined {
377
- if (!value || typeof value !== "object") return undefined
378
- const entries = Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string")
379
- return entries.length ? Object.fromEntries(entries) : undefined
380
- }
381
-
382
- function normalizeOnDemandMcpServer(value: unknown): OnDemandMcpServer | undefined {
383
- if (!value || typeof value !== "object") return undefined
384
- const source = value as Record<string, any>
385
- const base = {
386
- enabled: source.enabled !== false,
387
- ...(typeof source.timeout === "number" && Number.isFinite(source.timeout) && source.timeout >= 1 && source.timeout <= MAX_TIMER_MS
388
- ? { timeout: Math.floor(source.timeout) }
389
- : {}),
390
- ...(typeof source.description === "string" && source.description.trim() ? { description: source.description.trim() } : {}),
391
- }
392
-
393
- if (source.type === "local") {
394
- const commandParts = Array.isArray(source.command)
395
- ? source.command.filter((arg: unknown): arg is string => typeof arg === "string" && !!arg.trim())
396
- : []
397
- if (!commandParts.length) return undefined
398
- const environment = normalizeStringRecord(source.environment)
399
- return {
400
- ...base,
401
- type: "local",
402
- command: commandParts,
403
- ...(environment ? { environment } : {}),
404
- ...(typeof source.cwd === "string" && source.cwd.trim() ? { cwd: source.cwd.trim() } : {}),
405
- }
406
- }
407
-
408
- if (source.type === "remote" || typeof source.url === "string") {
409
- if (typeof source.url !== "string" || !source.url.trim()) return undefined
410
- const headers = normalizeStringRecord(source.headers)
411
- return {
412
- ...base,
413
- type: "remote",
414
- url: source.url.trim(),
415
- ...(headers ? { headers } : {}),
416
- }
417
- }
418
-
419
- return undefined
420
- }
421
-
422
- function normalizeOnDemandMcpMetadata(value: unknown, fallback: Record<string, OnDemandMcpMetadata> = {}): Record<string, OnDemandMcpMetadata> {
423
- const source = value && typeof value === "object" ? value as Record<string, any> : fallback
424
- const result: Record<string, OnDemandMcpMetadata> = {}
425
- for (const [key, raw] of Object.entries(source)) {
426
- if (!key.trim() || !raw || typeof raw !== "object") continue
427
- const item = raw as Record<string, unknown>
428
- const metadata: OnDemandMcpMetadata = {}
429
- if (typeof item.autoGeneratedDescription === "string" && item.autoGeneratedDescription.trim()) {
430
- metadata.autoGeneratedDescription = item.autoGeneratedDescription.trim()
431
- }
432
- const tools = normalizeToolInfo(item.autoGeneratedTools)
433
- if (tools) metadata.autoGeneratedTools = tools
434
- if (typeof item.avoidedSchemaBytes === "number" && Number.isFinite(item.avoidedSchemaBytes) && item.avoidedSchemaBytes >= 0) {
435
- metadata.avoidedSchemaBytes = Math.round(item.avoidedSchemaBytes)
436
- }
437
- if (item.status === "pending" || item.status === "ready" || item.status === "failed") metadata.status = item.status
438
- if (typeof item.lastError === "string" && item.lastError.trim()) metadata.lastError = item.lastError.trim()
439
- if (typeof item.lastCheckedAt === "string" && item.lastCheckedAt.trim()) metadata.lastCheckedAt = item.lastCheckedAt.trim()
440
- if (metadata.autoGeneratedDescription || metadata.autoGeneratedTools?.length || metadata.avoidedSchemaBytes || metadata.status || metadata.lastError || metadata.lastCheckedAt) result[key.trim()] = metadata
441
- }
442
- return result
443
- }
444
-
445
- function normalizeOnDemandMcpServers(value: unknown, fallback: Record<string, OnDemandMcpServer> = {}): Record<string, OnDemandMcpServer> {
446
- const source = value && typeof value === "object" ? value as Record<string, unknown> : fallback
447
- const result: Record<string, OnDemandMcpServer> = {}
448
- for (const [key, raw] of Object.entries(source)) {
449
- if (!key.trim()) continue
450
- const server = normalizeOnDemandMcpServer(raw)
451
- if (server) result[key.trim()] = server
452
- }
453
- return result
454
- }
455
-
456
- // ── Plugin ──
457
- export default (async (input: PluginInput) => {
458
- const client = input.client
459
-
460
- // Raven state lives under the global opencode config directory.
461
- const opencodeConfigDir = join(homedir(), ".config", "opencode")
462
- const ravenConfigDir = join(opencodeConfigDir, "opencode-raven")
463
- const configFile = join(ravenConfigDir, "raven-config.json")
464
- const mcpMetadataFile = join(ravenConfigDir, "autogenerated-on-demand-mcp-metadata.json")
465
- const dynamicGuidanceFile = join(ravenConfigDir, "autogenerated-on-demand-guidance.md")
466
-
467
- function normalizeConfig(raw: any): RavenConfig {
468
- const source = raw && typeof raw === "object" ? raw : {}
469
- const seekSource = source.raven_seek && typeof source.raven_seek === "object" ? source.raven_seek : source
470
- const mcpSource = source.raven_mcp && typeof source.raven_mcp === "object" ? source.raven_mcp : source
471
- const legacyInstructions = typeof source.ravenInstructions === "string" ? source.ravenInstructions : undefined
472
- const normalized: RavenConfig = {
473
- raven_seek: {
474
- model: typeof seekSource.model === "string" ? seekSource.model : DEFAULT_CONFIG.raven_seek.model,
475
- autoSelectedModel: typeof seekSource.autoSelectedModel === "string" ? seekSource.autoSelectedModel : undefined,
476
- reasoning_effort: typeof seekSource.reasoning_effort === "string" ? seekSource.reasoning_effort : DEFAULT_CONFIG.raven_seek.reasoning_effort,
477
- instructions: typeof seekSource.instructions === "string" ? seekSource.instructions : legacyInstructions ?? "",
478
- routeTools: uniqueStrings(seekSource.routeTools, DEFAULT_ROUTE_TOOLS),
479
- routeToolKeywords: uniqueStrings(seekSource.routeToolKeywords, DEFAULT_ROUTE_TOOL_KEYWORDS),
480
- excludeAgents: uniqueStrings(seekSource.excludeAgents),
481
- excludeTools: uniqueStrings(seekSource.excludeTools),
482
- timeout: typeof seekSource.timeout === "number" && Number.isFinite(seekSource.timeout)
483
- && seekSource.timeout >= 10 && seekSource.timeout <= MAX_TIMEOUT_SECONDS
484
- ? Math.floor(seekSource.timeout)
485
- : DEFAULT_CONFIG.raven_seek.timeout,
486
- },
487
- raven_mcp: {
488
- model: typeof mcpSource.model === "string" ? mcpSource.model : DEFAULT_CONFIG.raven_mcp.model,
489
- autoSelectedModel: typeof mcpSource.autoSelectedModel === "string" ? mcpSource.autoSelectedModel : undefined,
490
- reasoning_effort: typeof mcpSource.reasoning_effort === "string" ? mcpSource.reasoning_effort : DEFAULT_CONFIG.raven_mcp.reasoning_effort,
491
- instructions: typeof mcpSource.instructions === "string" ? mcpSource.instructions : legacyInstructions ?? "",
492
- timeout: typeof mcpSource.timeout === "number" && Number.isFinite(mcpSource.timeout)
493
- && mcpSource.timeout >= 10 && mcpSource.timeout <= MAX_TIMEOUT_SECONDS
494
- ? Math.floor(mcpSource.timeout)
495
- : DEFAULT_CONFIG.raven_mcp.timeout,
496
- descriptionDetail: mcpSource.descriptionDetail === "minimized"
497
- ? "minimized"
498
- : mcpSource.descriptionDetail === "full"
499
- ? "full"
500
- : source.onDemandMcpDescriptionDetail === "minimized" ? "minimized" : "full",
501
- onDemandMcpServers: mcpSource.onDemandMcpServers === undefined
502
- ? cloneOnDemandMcpServers(DEFAULT_ON_DEMAND_MCP_SERVERS)
503
- : normalizeOnDemandMcpServers(mcpSource.onDemandMcpServers),
504
- },
505
- }
506
- normalized.stats = source.stats && typeof source.stats === "object"
507
- && typeof source.stats.bytes === "number" && Number.isFinite(source.stats.bytes) && source.stats.bytes >= 0
508
- ? { bytes: Math.round(source.stats.bytes) }
509
- : undefined
510
-
511
- return normalized
512
- }
513
-
514
- function loadConfig(): RavenConfig {
515
- if (existsSync(configFile)) {
516
- try {
517
- const raw = JSON.parse(readFileSync(configFile, "utf-8"))
518
- const normalized = normalizeConfig(raw)
519
- if (!raw.raven_seek || !raw.raven_mcp) writeConfig(normalized)
520
- return normalized
521
- } catch {
522
- // Preserve malformed or partially-written config so the user can repair it.
523
- return { ...DEFAULT_CONFIG }
524
- }
525
- }
526
- // Auto-create config file with defaults on first run
527
- writeConfig(DEFAULT_CONFIG)
528
- return { ...DEFAULT_CONFIG }
529
- }
530
-
531
- function writeConfig(config: RavenConfig) {
532
- try {
533
- mkdirSync(ravenConfigDir, { recursive: true })
534
- writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n")
535
- } catch { /* non-fatal: config changes remain in-session */ }
536
- }
537
-
538
- function loadMcpMetadata(): Record<string, OnDemandMcpMetadata> {
539
- const defaults = cloneOnDemandMcpMetadata(DEFAULT_ON_DEMAND_MCP_METADATA)
540
- try {
541
- if (existsSync(mcpMetadataFile)) {
542
- return { ...defaults, ...normalizeOnDemandMcpMetadata(JSON.parse(readFileSync(mcpMetadataFile, "utf-8"))) }
543
- }
544
- } catch { /* ignore corruption, use defaults */ }
545
- saveMcpMetadata(defaults)
546
- return defaults
547
- }
548
-
549
- function saveMcpMetadata(metadata: Record<string, OnDemandMcpMetadata>) {
550
- try {
551
- mkdirSync(ravenConfigDir, { recursive: true })
552
- writeFileSync(mcpMetadataFile, JSON.stringify(metadata, null, 2) + "\n")
553
- } catch { /* non-fatal: generated metadata can be refreshed */ }
554
- }
555
-
556
- function saveDynamicGuidance() {
557
- try {
558
- mkdirSync(ravenConfigDir, { recursive: true })
559
- writeFileSync(dynamicGuidanceFile, `## Raven on-demand MCP capabilities\n\n${onDemandMcpGuidance("delegate", dynamicGuidanceMcpOrder())}\n`, "utf-8")
560
- } catch { /* non-fatal: guidance still exists in tool/subagent paths */ }
561
- }
562
-
563
- let config = loadConfig()
564
- let modelDiscoveryError: string | undefined
565
-
566
- function updateConfig(update: Partial<RavenConfig> | ((latest: RavenConfig) => Partial<RavenConfig>)): RavenConfig {
567
- try {
568
- const raw = existsSync(configFile) ? JSON.parse(readFileSync(configFile, "utf-8")) : config
569
- const latest = normalizeConfig(raw)
570
- const patch = typeof update === "function" ? update(latest) : update
571
- config = normalizeConfig({
572
- ...latest,
573
- ...patch,
574
- raven_seek: { ...latest.raven_seek, ...patch.raven_seek },
575
- raven_mcp: { ...latest.raven_mcp, ...patch.raven_mcp },
576
- })
577
- writeConfig(config)
578
- } catch {
579
- // Keep malformed or partially-written external edits intact instead of replacing them.
580
- const patch = typeof update === "function" ? update(config) : update
581
- config = normalizeConfig({
582
- ...config,
583
- ...patch,
584
- raven_seek: { ...config.raven_seek, ...patch.raven_seek },
585
- raven_mcp: { ...config.raven_mcp, ...patch.raven_mcp },
586
- })
587
- }
588
- return config
589
- }
590
-
591
- function modelsNeedingDiscovery(): boolean {
592
- return [config.raven_seek, config.raven_mcp].some((section) =>
593
- !section.model || RETIRED_DEFAULT_MODELS.has(section.model) || section.autoSelectedModel === section.model
594
- )
595
- }
596
-
597
- function freeOpenCodeModels(providers: any[], connected?: string[]): string[] {
598
- const connectedProviders = Array.isArray(connected) ? new Set(connected.map((id) => String(id).toLowerCase())) : undefined
599
- const candidates: Array<{ id: string; status: number; released: number; context: number }> = []
600
- for (const provider of providers) {
601
- if (String(provider?.id ?? "").toLowerCase() !== "opencode") continue
602
- if (connectedProviders && !connectedProviders.has("opencode")) continue
603
- for (const [key, model] of Object.entries(provider?.models ?? {}) as Array<[string, any]>) {
604
- const id = String(model?.id ?? key).trim()
605
- const cost = model?.cost
606
- const free = (Number(cost?.input) === 0 && Number(cost?.output) === 0) || /(?:^|-)free$/i.test(id)
607
- const toolCall = model?.tool_call ?? model?.capabilities?.toolcall
608
- if (!id || !free || toolCall !== true || model?.status === "deprecated") continue
609
- const fullId = id.includes("/") ? id : `opencode/${id}`
610
- const status = model?.status === "active" ? 3 : model?.status === "beta" || !model?.status ? 2 : 1
611
- const released = Date.parse(model?.release_date ?? model?.releaseDate ?? "") || 0
612
- const context = Number(model?.limit?.context) || 0
613
- candidates.push({ id: fullId, status, released, context })
614
- }
615
- }
616
- candidates.sort((left, right) =>
617
- right.status - left.status || right.released - left.released || right.context - left.context || left.id.localeCompare(right.id)
618
- )
619
- return [...new Set(candidates.map((candidate) => candidate.id))]
620
- }
621
-
622
- async function discoverFreeOpenCodeModels(): Promise<string[] | undefined> {
623
- modelDiscoveryError = undefined
624
- const controller = new AbortController()
625
- const timer = setTimeout(() => controller.abort(), 3000)
626
- let lastError: unknown
627
- try {
628
- if ((client as any).provider?.list) {
629
- try {
630
- const response = await (client as any).provider.list({ query: { directory: input.directory }, signal: controller.signal })
631
- const data = response?.data ?? response
632
- if (Array.isArray(data?.all)) return freeOpenCodeModels(data.all, data.connected)
633
- } catch (error) {
634
- lastError = error
635
- }
636
- }
637
- if ((client as any).config?.providers) {
638
- try {
639
- const response = await (client as any).config.providers({ query: { directory: input.directory }, signal: controller.signal })
640
- const data = response?.data ?? response
641
- if (Array.isArray(data?.providers)) return freeOpenCodeModels(data.providers)
642
- } catch (error) {
643
- lastError = error
644
- }
645
- }
646
- modelDiscoveryError = lastError ? truncateText((lastError as any)?.message ?? lastError, 240) : "OpenCode's provider catalog was unavailable"
647
- return undefined
648
- } finally {
649
- clearTimeout(timer)
650
- }
651
- }
652
-
653
- async function ensureRavenModels(): Promise<boolean> {
654
- if (!modelsNeedingDiscovery()) return false
655
- const available = await discoverFreeOpenCodeModels()
656
- const availableSet = new Set(available ?? [])
657
- const selected = available?.[0]
658
-
659
- function resolve(section: RavenSeekConfig | RavenMcpConfig) {
660
- const current = section.model
661
- const retired = !!current && RETIRED_DEFAULT_MODELS.has(current)
662
- const managed = !!current && section.autoSelectedModel === current
663
- if (available === undefined) {
664
- return retired ? { model: undefined, autoSelectedModel: undefined } : { model: current, autoSelectedModel: section.autoSelectedModel }
665
- }
666
- if (!current || retired || (managed && !availableSet.has(current))) {
667
- return { model: selected, autoSelectedModel: selected }
668
- }
669
- return { model: current, autoSelectedModel: section.autoSelectedModel }
670
- }
671
-
672
- const seek = resolve(config.raven_seek)
673
- const mcp = resolve(config.raven_mcp)
674
- if (seek.model !== config.raven_seek.model || seek.autoSelectedModel !== config.raven_seek.autoSelectedModel
675
- || mcp.model !== config.raven_mcp.model || mcp.autoSelectedModel !== config.raven_mcp.autoSelectedModel) {
676
- updateConfig({ raven_seek: seek, raven_mcp: mcp })
677
- return true
678
- }
679
- return false
680
- }
681
-
682
- function clearRetiredDefaults() {
683
- const seekRetired = !!config.raven_seek.model && RETIRED_DEFAULT_MODELS.has(config.raven_seek.model)
684
- const mcpRetired = !!config.raven_mcp.model && RETIRED_DEFAULT_MODELS.has(config.raven_mcp.model)
685
- if (!seekRetired && !mcpRetired) return
686
- updateConfig({
687
- ...(seekRetired ? { raven_seek: { model: undefined, autoSelectedModel: undefined } } : {}),
688
- ...(mcpRetired ? { raven_mcp: { model: undefined, autoSelectedModel: undefined } } : {}),
689
- })
690
- }
691
-
692
- clearRetiredDefaults()
693
- const activeSeekModel = config.raven_seek.model
694
- const activeMcpModel = config.raven_mcp.model
695
- let modelRefreshPending = modelsNeedingDiscovery()
696
- let modelRefreshPromise: Promise<void> | undefined
697
-
698
- let mcpMetadata = loadMcpMetadata()
699
- type DelegationKind = "seek" | "mcp"
700
- const ravenSessions = new Set<string>()
701
- const ravenSessionKinds = new Map<string, DelegationKind>()
702
- const activeRavenPrompts = new Set<string>()
703
- const ravenTaskCalls = new Set<string>()
704
- const sessionAgents = new Map<string, string>()
705
- const ravenSessionParents = new Map<string, string>()
706
- const ravenErrorWaiters = new Map<string, (error: Error) => void>()
707
- const ravenProgressWaiters = new Map<string, () => void>()
708
- let globalMcpServers: string[] = []
709
- let duplicateMcpServers: string[] = []
710
- let updateInfo: { current: string; latest?: string; available: boolean } | undefined
711
- let updateCheckPromise: Promise<{ current: string; latest?: string; available: boolean }> | undefined
712
- let updateToastPending = false
713
- let mcpFailureToastPending = false
714
- let duplicateMcpToastPending = false
715
- let modelSetupToastPending = false
716
- let mcpRefreshScheduled = false
717
- let mcpRefreshPromise: Promise<{ refreshed: string[]; failed: string[] }> | undefined
718
- let mcpRefreshQueue: Promise<unknown> = Promise.resolve()
719
- const mcpRefreshControllers = new Set<AbortController>()
720
- let lastNotifiedMcpFailureKey = ""
721
- const ravenProviderIssues: Partial<Record<DelegationKind, string>> = {}
722
-
723
- async function refreshModelsIfPending() {
724
- if (modelRefreshPromise) return modelRefreshPromise
725
- if (!modelRefreshPending) return
726
- modelRefreshPending = false
727
- modelRefreshPromise = ensureRavenModels().then(() => {
728
- modelSetupToastPending = !!modelSetupInstructions()
729
- })
730
- return modelRefreshPromise
731
- }
732
-
733
- // Replace generated instructions from older releases before OpenCode consumes them.
734
- saveDynamicGuidance()
735
- resetOnDemandMcpRuntimeStatuses()
736
-
737
- function resetOnDemandMcpRuntimeStatuses() {
738
- let changed = false
739
- for (const [name] of activeOnDemandMcpEntries()) {
740
- const metadata = mcpMetadata[name] ?? {}
741
- if (metadata.status !== "pending" || metadata.lastError || metadata.lastCheckedAt) {
742
- mcpMetadata[name] = { ...metadata, status: "pending" }
743
- delete mcpMetadata[name].lastError
744
- delete mcpMetadata[name].lastCheckedAt
745
- changed = true
746
- }
747
- }
748
- if (changed) saveMcpMetadata(mcpMetadata)
749
- }
750
-
751
- // ── Check if an agent is excluded from Raven enforcement (case-insensitive) ──
752
- function isExcluded(agent: string | undefined): boolean {
753
- if (!agent || !config.raven_seek.excludeAgents?.length) return false
754
- const lower = agent.toLowerCase()
755
- return config.raven_seek.excludeAgents.some((a) => a.toLowerCase() === lower)
756
- }
757
-
758
- function isRouteConfigured(toolName: string): boolean {
759
- if (!modelOperational("seek")) return false
760
- const tool = toolName.toLowerCase()
761
- if (NEVER_ROUTE_TOOLS.has(tool)) return false
762
- if (config.raven_seek.routeTools?.some((name) => name.toLowerCase() === tool)) return true
763
- if (config.raven_seek.routeToolKeywords?.some((keyword) => tool.includes(keyword.toLowerCase()))) return true
764
- return globalMcpServers.some((server) => tool.startsWith(`${server.toLowerCase()}_`))
765
- }
766
-
767
- function compactArgs(value: any): any {
768
- if (Array.isArray(value)) {
769
- return value.map(compactArgs).filter((item) => item !== undefined)
770
- }
771
- if (!value || typeof value !== "object") return value
772
- const result: Record<string, any> = {}
773
- for (const [key, raw] of Object.entries(value)) {
774
- const item = compactArgs(raw)
775
- if (item === undefined || item === null || item === "") continue
776
- if (Array.isArray(item) && item.length === 0) continue
777
- if (typeof item === "object" && !Array.isArray(item) && Object.keys(item).length === 0) continue
778
- result[key] = item
779
- }
780
- return result
781
- }
782
-
783
- function attemptedQuery(tool: string, args: any): string {
784
- const compact = compactArgs(args)
785
- if (!compact || typeof compact !== "object") return `${tool}: ${JSON.stringify(compact)}`
786
- const direct = compact.query ?? compact.pattern ?? compact.url ?? compact.urls ?? compact.command ?? compact.path ?? compact.filePath
787
- const value = direct !== undefined ? direct : compact
788
- const text = typeof value === "string" ? value : JSON.stringify(value)
789
- const query = value === compact ? `${tool} ${text}` : `${tool}: ${text}`
790
- return query.length > 300 ? `${query.slice(0, 297)}...` : query
791
- }
792
-
793
- function rerouteMessage(tool: string, args: any): string {
794
- if (ravenProviderIssues.seek) {
795
- return `Raven search is currently unavailable: ${ravenProviderIssues.seek}
796
-
797
- ${ravenRecoveryInstructions("seek")}`
798
- }
799
- return `The '${tool}' tool call is blocked by Raven. Your next tool call should be raven_seek(query="${attemptedQuery(tool, args).replace(/"/g, "'")}").`
800
- }
801
-
802
- function ravenRecoveryInstructions(kind: DelegationKind): string {
803
- const command = kind === "mcp" ? "/raven mcp model <provider/model>" : "/raven seek model <provider/model>"
804
- 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.
805
-
806
- Tell the user to switch Raven's model with:
807
- ${command}
808
-
809
- Example:
810
- ${command.replace("<provider/model>", "openai/gpt-5.5")}
811
-
812
- 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.`
813
- }
814
-
815
- function routeSummary(): string {
816
- if (!modelOperational("seek")) return "Raven routing: inactive until the configured Search model is loaded."
817
- const tools = config.raven_seek.routeTools?.length ? config.raven_seek.routeTools.join(", ") : "(none)"
818
- const mcps = globalMcpServers.length ? `${globalMcpServers.join(", ")} (auto-detected)` : "none detected"
819
- const keywords = config.raven_seek.routeToolKeywords?.length ? config.raven_seek.routeToolKeywords.join(", ") : "(none)"
820
- const duplicates = duplicateMcpServers.length ? `\n Duplicate MCP config: ${duplicateMcpServers.join(", ")} (configured globally and on-demand)` : ""
821
- return `Raven routing:\n Routed tools: ${tools}\n Routed global MCPs: ${mcps}\n Routed tool keywords: ${keywords}${duplicates}`
822
- }
823
-
824
- function modelSetupInstructions(kind?: DelegationKind): string {
825
- const unavailable = [
826
- ...(!modelOperational("seek") && kind !== "mcp" ? ["seek"] : []),
827
- ...(!modelOperational("mcp") && kind !== "seek" ? ["mcp"] : []),
828
- ] as DelegationKind[]
829
- if (!unavailable.length) return ""
830
- const pendingRestart = unavailable.filter((item) => item === "seek" ? !!config.raven_seek.model : !!config.raven_mcp.model)
831
- const missing = unavailable.filter((item) => item === "seek" ? !config.raven_seek.model : !config.raven_mcp.model)
832
- const lines: string[] = []
833
- if (pendingRestart.length) {
834
- const models = pendingRestart.map((item) => `${item === "seek" ? "Search" : "MCP"}: ${item === "seek" ? modelLabel(config.raven_seek) : modelLabel(config.raven_mcp)}`)
835
- lines.push(`Raven model update ready (${models.join(", ")}). Restart OpenCode to load it.`)
836
- }
837
- if (!missing.length) return lines.join("\n")
838
- const reason = modelDiscoveryError
839
- ? `Automatic free-model detection failed: ${modelDiscoveryError}.`
840
- : "No connected, zero-cost OpenCode model with tool support was detected."
841
- const commands = missing.map((item) => item === "seek"
842
- ? " /raven seek model <provider/model>"
843
- : " /raven mcp model <provider/model>")
844
- lines.push(`Raven setup required. ${reason}\nOpen OpenCode's model picker to choose an available model, then run:\n${commands.join("\n")}\nRestart OpenCode afterward. To retry automatic detection, replace <provider/model> with auto.`)
845
- return lines.join("\n\n")
846
- }
847
-
848
- function modelOperational(kind: DelegationKind): boolean {
849
- const active = kind === "seek" ? activeSeekModel : activeMcpModel
850
- const configured = kind === "seek" ? config.raven_seek.model : config.raven_mcp.model
851
- return !!active && active === configured
852
- }
853
-
854
- function modelLabel(section: RavenSeekConfig | RavenMcpConfig): string {
855
- if (!section.model) return "(not configured)"
856
- return section.autoSelectedModel === section.model ? `${section.model} (auto)` : section.model
857
- }
858
-
859
- function mcpSummary(): string {
860
- const buckets = onDemandMcpStatusBuckets()
861
- const total = buckets.loaded.length + buckets.failed.length + buckets.pending.length
862
- if (!total) return `Raven on-demand MCPs (always behind Raven): none configured.\n Guidance detail: ${config.raven_mcp.descriptionDetail ?? "full"}`
863
- const lines = [
864
- "Raven on-demand MCPs (always behind Raven):",
865
- ` Loaded: ${buckets.loaded.length ? buckets.loaded.join(", ") : "none"}`,
866
- ` Failed: ${buckets.failed.length ? buckets.failed.join(", ") : "none"}`,
867
- ` Pending: ${buckets.pending.length ? buckets.pending.join(", ") : "none"}`,
868
- ` Guidance detail: ${config.raven_mcp.descriptionDetail ?? "full"}`,
869
- ]
870
- return lines.join("\n")
871
- }
872
-
873
- function ravenAgentPrompt(): string {
874
- const extra = config.raven_seek.instructions?.trim()
875
- const prompt = ravenPrompt
876
- return extra ? `${prompt}\n\nAdditional user instructions:\n${extra}` : prompt
877
- }
878
-
879
- function ravenMcpAgentPrompt(): string {
880
- const extra = config.raven_mcp.instructions?.trim()
881
- const onDemand = onDemandMcpGuidance("raven")
882
- const prompt = `${ravenMcpPrompt}\n\nOn-demand MCP guidance:\n${onDemand}`
883
- return extra ? `${prompt}\n\nAdditional user instructions:\n${extra}` : prompt
884
- }
885
-
886
- function compactText(value: unknown): string {
887
- return String(value ?? "").replace(/\s+/g, " ").trim()
888
- }
889
-
890
- function truncateText(value: unknown, max = 240): string {
891
- const text = compactText(value)
892
- return text.length > max ? `${text.slice(0, Math.max(0, max - 3))}...` : text
893
- }
894
-
895
- function dynamicGuidanceMcpOrder(): string[] | undefined {
896
- try {
897
- if (!existsSync(dynamicGuidanceFile)) return undefined
898
- const names: string[] = []
899
- for (const line of readFileSync(dynamicGuidanceFile, "utf-8").split("\n")) {
900
- const name = line.match(/^-\s+([^:]+):/)?.[1]?.trim()
901
- if (name && !names.includes(name)) names.push(name)
902
- }
903
- return names
904
- } catch {
905
- return undefined
906
- }
907
- }
908
-
909
- function orderedOnDemandMcpEntries(entries: [string, OnDemandMcpServer][], preferredOrder?: string[]): [string, OnDemandMcpServer][] {
910
- if (!preferredOrder?.length) return entries
911
- const order = new Map(preferredOrder.map((name, index) => [name.toLowerCase(), index]))
912
- const existing = entries
913
- .filter(([name]) => order.has(name.toLowerCase()))
914
- .sort(([left], [right]) => order.get(left.toLowerCase())! - order.get(right.toLowerCase())!)
915
- const added = entries.filter(([name]) => !order.has(name.toLowerCase()))
916
- return [...existing, ...added]
917
- }
918
-
919
- function firstSentenceText(value: unknown): string {
920
- const text = compactText(value)
921
- const period = text.indexOf(".")
922
- return period === -1 ? text : text.slice(0, period + 1)
923
- }
924
-
925
- function onDemandMcpGuidance(target: "delegate" | "raven" = "delegate", preferredOrder?: string[]): string {
926
- const servers = config.raven_mcp.onDemandMcpServers ?? {}
927
- const activeEntries = activeOnDemandMcpEntries()
928
- const entries = orderedOnDemandMcpEntries(
929
- target === "raven"
930
- ? activeEntries
931
- : activeEntries.filter(([name]) => onDemandMcpRuntimeStatus(name) === "ready"),
932
- preferredOrder,
933
- )
934
- if (!Object.keys(servers).length) return "No on-demand MCPs are configured."
935
- if (!activeEntries.length) return "No enabled on-demand MCPs are configured."
936
- if (!entries.length) return "No loaded on-demand MCPs are available."
937
-
938
- const detail = config.raven_mcp.descriptionDetail ?? "full"
939
- const lines = entries.map(([name, server]) => {
940
- const metadata = mcpMetadata[name] ?? {}
941
- const userDescription = firstSentenceText(server.description)
942
- const generated = firstSentenceText(metadata.autoGeneratedDescription)
943
- const parts = [userDescription, generated ? `Discovered capabilities: ${generated}` : ""].filter(Boolean)
944
- const summary = parts.length ? parts.join(" ") : "Description discovery pending."
945
- if (detail === "minimized" || !metadata.autoGeneratedTools?.length) return `- ${name}: ${summary}`
946
- const tools = metadata.autoGeneratedTools
947
- .map((item) => `${item.name}${item.description ? ` - ${firstSentenceText(item.description)}` : ""}`)
948
- .join("; ")
949
- return `- ${name}: ${summary} Tools: ${tools}`
950
- })
951
- if (target === "raven") {
952
- return `Use raven_mcp_bridge for these on-demand MCP capabilities:\n${lines.join("\n")}`
953
- }
954
- return `Non-Raven agents can use these on-demand MCP capabilities through the public raven_mcp delegator. Use raven_mcp for matching requests and include the target MCP server and likely capability when obvious. The raven-mcp agent itself must call raven_mcp_bridge directly instead:\n${lines.join("\n")}`
955
- }
956
-
957
- function resolveEnvPlaceholders(value: string): string {
958
- return value.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => {
959
- const envValue = process.env[name]
960
- if (envValue === undefined) throw new Error(`Missing environment variable ${name}`)
961
- return envValue
962
- })
963
- }
964
-
965
- function resolveStringRecord(value: Record<string, string> | undefined): Record<string, string> | undefined {
966
- if (!value) return undefined
967
- const result: Record<string, string> = {}
968
- for (const [key, raw] of Object.entries(value)) {
969
- result[key] = resolveEnvPlaceholders(raw)
970
- }
971
- return result
972
- }
973
-
974
- function cleanProcessEnv(): Record<string, string> {
975
- const result: Record<string, string> = {}
976
- for (const [key, value] of Object.entries(process.env)) {
977
- if (typeof value === "string") result[key] = value
978
- }
979
- return result
980
- }
981
-
982
- function providerErrorMessage(error: any): string {
983
- const name = String(error?.name ?? "")
984
- const data = error?.data && typeof error.data === "object" ? error.data : {}
985
- const status = typeof data.statusCode === "number" ? ` HTTP ${data.statusCode}.` : ""
986
- const body = typeof data.responseBody === "string" && data.responseBody.trim() ? ` ${truncateText(data.responseBody, 500)}` : ""
987
- const message = String(data.message ?? error?.message ?? error ?? "Unknown provider error")
988
- return `Raven provider/API error${name ? ` (${name})` : ""}.${status} ${message}${body}`.trim()
989
- }
990
-
991
- function waitForRavenProviderError(sessionId: string): Promise<never> {
992
- return new Promise((_resolve, reject) => {
993
- ravenErrorWaiters.set(sessionId, reject)
994
- })
995
- }
996
-
997
- function waitForRavenFirstProgress(sessionId: string, ms: number): Promise<never> {
998
- return new Promise((_resolve, reject) => {
999
- const timer = setTimeout(() => {
1000
- ravenProgressWaiters.delete(sessionId)
1001
- 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.`))
1002
- }, ms)
1003
- ravenProgressWaiters.set(sessionId, () => {
1004
- clearTimeout(timer)
1005
- ravenProgressWaiters.delete(sessionId)
1006
- })
1007
- })
1008
- }
1009
-
1010
- function reportRavenProviderError(sessionId: string, error: any) {
1011
- const waiter = ravenErrorWaiters.get(sessionId)
1012
- if (!waiter) return
1013
- waiter(new Error(providerErrorMessage(error)))
1014
- }
1015
-
1016
- function reportRavenProgress(sessionId: string) {
1017
- ravenProgressWaiters.get(sessionId)?.()
1018
- }
1019
-
1020
- function clearRavenWaiters(sessionId: string) {
1021
- ravenErrorWaiters.delete(sessionId)
1022
- ravenProgressWaiters.get(sessionId)?.()
1023
- }
1024
-
1025
- type McpConnection = {
1026
- client: Client
1027
- transport: any
1028
- lastUsed: number
1029
- active: number
1030
- }
1031
-
1032
- type McpConnectionAttempt = {
1033
- promise: Promise<McpConnection>
1034
- controller: AbortController
1035
- waiters: number
1036
- }
1037
-
1038
- const mcpConnections = new Map<string, McpConnection>()
1039
- const mcpConnectionAttempts = new Map<string, McpConnectionAttempt>()
1040
- let mcpIdleTimer: ReturnType<typeof setInterval> | undefined
1041
-
1042
- function mcpTimeoutMs(server: OnDemandMcpServer): number {
1043
- return server.timeout ?? (config.raven_mcp.timeout ?? 600) * 1000
1044
- }
1045
-
1046
- async function closeMcpClient(client: Client, transport: any) {
1047
- try {
1048
- await transport?.close?.()
1049
- } catch { /* best-effort */ }
1050
- try {
1051
- await client.close()
1052
- } catch { /* best-effort */ }
1053
- }
1054
-
1055
- async function connectMcpClient(client: Client, transport: any, timeout: number, label: string, signal?: AbortSignal) {
1056
- const controller = new AbortController()
1057
- let timedOut = false
1058
- const abort = () => controller.abort(signal?.reason)
1059
- if (signal?.aborted) abort()
1060
- else signal?.addEventListener("abort", abort, { once: true })
1061
- const timer = setTimeout(() => {
1062
- timedOut = true
1063
- controller.abort()
1064
- }, timeout)
1065
- const aborted = new Promise<never>((_resolve, reject) => {
1066
- const fail = () => reject(new Error(timedOut ? `${label} timed out after ${Math.round(timeout / 1000)}s` : `${label} cancelled`))
1067
- if (controller.signal.aborted) fail()
1068
- else controller.signal.addEventListener("abort", fail, { once: true })
1069
- })
1070
-
1071
- try {
1072
- await Promise.race([
1073
- client.connect(transport, { signal: controller.signal, timeout }),
1074
- aborted,
1075
- ])
1076
- } catch (error) {
1077
- await closeMcpClient(client, transport)
1078
- throw error
1079
- } finally {
1080
- clearTimeout(timer)
1081
- signal?.removeEventListener("abort", abort)
1082
- }
1083
- }
1084
-
1085
- async function closeMcpConnection(connection: McpConnection) {
1086
- await closeMcpClient(connection.client, connection.transport)
1087
- }
1088
-
1089
- function stopMcpIdleTimerIfUnused() {
1090
- if (mcpConnections.size || mcpConnectionAttempts.size || !mcpIdleTimer) return
1091
- clearInterval(mcpIdleTimer)
1092
- mcpIdleTimer = undefined
1093
- }
1094
-
1095
- function ensureMcpIdleTimer() {
1096
- if (mcpIdleTimer) return
1097
- mcpIdleTimer = setInterval(() => closeIdleMcpConnections(), 60_000)
1098
- ;(mcpIdleTimer as any).unref?.()
1099
- }
1100
-
1101
- async function closeMcpConnectionKey(key: string, expected?: McpConnection) {
1102
- const existing = mcpConnections.get(key)
1103
- if (!existing || (expected && existing !== expected)) return
1104
- mcpConnections.delete(key)
1105
- await closeMcpConnection(existing)
1106
- stopMcpIdleTimerIfUnused()
1107
- }
1108
-
1109
- function closeIdleMcpConnections(maxAgeMs = 10 * 60 * 1000) {
1110
- const now = Date.now()
1111
- for (const [key, connection] of mcpConnections) {
1112
- if (!connection.active && now - connection.lastUsed > maxAgeMs) {
1113
- void closeMcpConnectionKey(key, connection)
1114
- }
1115
- }
1116
- }
1117
-
1118
- async function closeMcpConnectionsForSession(sessionId: string) {
1119
- const prefix = `${sessionId}:`
1120
- const pending: Promise<McpConnection>[] = []
1121
- for (const [key, attempt] of mcpConnectionAttempts) {
1122
- if (!key.startsWith(prefix)) continue
1123
- attempt.controller.abort()
1124
- pending.push(attempt.promise)
1125
- }
1126
- await Promise.allSettled(pending)
1127
- await Promise.all(
1128
- [...mcpConnections.keys()]
1129
- .filter((key) => key.startsWith(prefix))
1130
- .map((key) => closeMcpConnectionKey(key)),
1131
- )
1132
- }
1133
-
1134
- function mcpConnectionKey(sessionId: string, serverName: string): string {
1135
- return `${sessionId}:${serverName.toLowerCase()}`
1136
- }
1137
-
1138
- function getOnDemandMcpServer(serverName: string): [string, OnDemandMcpServer] | undefined {
1139
- const entries = activeOnDemandMcpEntries()
1140
- return entries.find(([name]) => name.toLowerCase() === serverName.toLowerCase())
1141
- }
1142
-
1143
- function activeOnDemandMcpEntries(): [string, OnDemandMcpServer][] {
1144
- return Object.entries(config.raven_mcp.onDemandMcpServers ?? {}).filter(([, server]) => server.enabled !== false)
1145
- }
1146
-
1147
- function waitForMcpConnection(attempt: McpConnectionAttempt, signal?: AbortSignal): Promise<McpConnection> {
1148
- if (!signal) {
1149
- attempt.waiters++
1150
- return attempt.promise.finally(() => attempt.waiters--)
1151
- }
1152
- if (signal.aborted) return Promise.reject(new Error("MCP request cancelled"))
1153
- attempt.waiters++
1154
- return new Promise((resolve, reject) => {
1155
- let settled = false
1156
- const finish = () => {
1157
- if (settled) return false
1158
- settled = true
1159
- attempt.waiters--
1160
- signal.removeEventListener("abort", abort)
1161
- return true
1162
- }
1163
- const abort = () => {
1164
- if (!finish()) return
1165
- if (!attempt.waiters) attempt.controller.abort(signal.reason)
1166
- reject(new Error("MCP request cancelled"))
1167
- }
1168
- signal.addEventListener("abort", abort, { once: true })
1169
- attempt.promise.then(
1170
- (connection) => { if (finish()) resolve(connection) },
1171
- (error) => { if (finish()) reject(error) },
1172
- )
1173
- })
1174
- }
1175
-
1176
- async function connectOnDemandMcp(serverName: string, server: OnDemandMcpServer, signal?: AbortSignal): Promise<McpConnection> {
1177
- const timeout = mcpTimeoutMs(server)
1178
- const client = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
1179
-
1180
- if (server.type === "local") {
1181
- const env = { ...cleanProcessEnv(), ...resolveStringRecord(server.environment) }
1182
- const transport = new StdioClientTransport({
1183
- command: resolveEnvPlaceholders(server.command[0]),
1184
- args: server.command.slice(1).map((arg) => resolveEnvPlaceholders(arg)),
1185
- env,
1186
- stderr: "pipe",
1187
- cwd: server.cwd ? resolveEnvPlaceholders(server.cwd) : undefined,
1188
- })
1189
- transport.stderr?.on("data", () => {})
1190
- transport.stderr?.on("error", () => {})
1191
- await connectMcpClient(client, transport, timeout, `Connecting to ${serverName}`, signal)
1192
- return { client, transport, lastUsed: Date.now(), active: 0 }
1193
- }
1194
-
1195
- const url = new URL(resolveEnvPlaceholders(server.url))
1196
- const headers = resolveStringRecord(server.headers)
1197
- const transport = new StreamableHTTPClientTransport(url, headers ? { requestInit: { headers } } : undefined)
1198
- try {
1199
- await connectMcpClient(client, transport, timeout, `Connecting to ${serverName}`, signal)
1200
- return { client, transport, lastUsed: Date.now(), active: 0 }
1201
- } catch (err) {
1202
- if (signal?.aborted) throw err
1203
- const fallbackClient = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
1204
- const fallback = new SSEClientTransport(url, headers ? { requestInit: { headers } } : undefined)
1205
- try {
1206
- await connectMcpClient(fallbackClient, fallback, timeout, `Connecting to ${serverName} via SSE`, signal)
1207
- return { client: fallbackClient, transport: fallback, lastUsed: Date.now(), active: 0 }
1208
- } catch (fallbackError) {
1209
- throw fallbackError
1210
- }
1211
- }
1212
- }
1213
-
1214
- async function getMcpConnection(sessionId: string, serverName: string, signal?: AbortSignal): Promise<McpConnection> {
1215
- if (signal?.aborted) throw new Error("MCP request cancelled")
1216
- closeIdleMcpConnections()
1217
- const match = getOnDemandMcpServer(serverName)
1218
- if (!match) throw new Error(`Unknown on-demand MCP server: ${serverName}`)
1219
- const [canonicalName, server] = match
1220
- const key = mcpConnectionKey(sessionId, canonicalName)
1221
- const existing = mcpConnections.get(key)
1222
- if (existing) {
1223
- existing.lastUsed = Date.now()
1224
- return existing
1225
- }
1226
-
1227
- const pending = mcpConnectionAttempts.get(key)
1228
- if (pending && !pending.controller.signal.aborted) return waitForMcpConnection(pending, signal)
1229
- if (pending) mcpConnectionAttempts.delete(key)
1230
-
1231
- const controller = new AbortController()
1232
- const attempt = { controller, waiters: 0 } as McpConnectionAttempt
1233
- attempt.promise = connectOnDemandMcp(canonicalName, server, controller.signal)
1234
- .then(async (connection) => {
1235
- if (controller.signal.aborted || mcpConnectionAttempts.get(key) !== attempt) {
1236
- await closeMcpConnection(connection)
1237
- throw new Error("MCP connection attempt cancelled")
1238
- }
1239
- mcpConnections.set(key, connection)
1240
- connection.client.onclose = () => {
1241
- if (mcpConnections.get(key) === connection) mcpConnections.delete(key)
1242
- stopMcpIdleTimerIfUnused()
1243
- }
1244
- ensureMcpIdleTimer()
1245
- return connection
1246
- })
1247
- .finally(() => {
1248
- if (mcpConnectionAttempts.get(key) === attempt) mcpConnectionAttempts.delete(key)
1249
- stopMcpIdleTimerIfUnused()
1250
- })
1251
- mcpConnectionAttempts.set(key, attempt)
1252
- ensureMcpIdleTimer()
1253
- return waitForMcpConnection(attempt, signal)
1254
- }
1255
-
1256
- async function listAllMcpTools(client: Client, options?: { signal?: AbortSignal; timeout?: number }): Promise<any[]> {
1257
- const tools: any[] = []
1258
- let cursor: string | undefined
1259
- do {
1260
- const result: any = await client.listTools(cursor ? { cursor } : undefined as any, options)
1261
- tools.push(...(result.tools ?? []))
1262
- cursor = result.nextCursor
1263
- } while (cursor)
1264
- return tools
1265
- }
1266
-
1267
- async function listAllMcpResources(client: Client, options?: { signal?: AbortSignal; timeout?: number }): Promise<any[]> {
1268
- const resources: any[] = []
1269
- let cursor: string | undefined
1270
- do {
1271
- const result: any = await client.listResources(cursor ? { cursor } : undefined as any, options)
1272
- resources.push(...(result.resources ?? []))
1273
- cursor = result.nextCursor
1274
- } while (cursor)
1275
- return resources
1276
- }
1277
-
1278
- async function listAllMcpPrompts(client: Client, options?: { signal?: AbortSignal; timeout?: number }): Promise<any[]> {
1279
- const prompts: any[] = []
1280
- let cursor: string | undefined
1281
- do {
1282
- const result: any = await client.listPrompts(cursor ? { cursor } : undefined as any, options)
1283
- prompts.push(...(result.prompts ?? []))
1284
- cursor = result.nextCursor
1285
- } while (cursor)
1286
- return prompts
1287
- }
1288
-
1289
- function generatedToolsFromMcpTools(tools: any[]): OnDemandMcpToolInfo[] {
1290
- return tools
1291
- .filter((item) => item?.name)
1292
- .map((item) => ({
1293
- name: String(item.name),
1294
- ...(item.description ? { description: compactText(item.description) } : {}),
1295
- }))
1296
- }
1297
-
1298
- function estimateAvoidedMcpSchemaBytes(tools: any[]): number {
1299
- if (!tools.length) return 0
1300
- return JSON.stringify(tools).length
1301
- }
1302
-
1303
- function synthesizeMcpDescription(serverName: string, tools: OnDemandMcpToolInfo[]): string {
1304
- if (!tools.length) return `${serverName} exposes on-demand MCP capabilities. Use list_tools for available operations.`
1305
- const descriptions = tools.map((item) => item.description || item.name).filter(Boolean)
1306
- const joined = descriptions.slice(0, 6).join("; ")
1307
- return compactText(`${serverName} provides on-demand MCP tools for ${joined}.`)
1308
- }
1309
-
1310
- async function refreshOnDemandMcpDescriptions(force = false, serverName?: string, signal?: AbortSignal): Promise<{ refreshed: string[]; failed: string[] }> {
1311
- if (signal?.aborted) return { refreshed: [], failed: [] }
1312
- const entries = activeOnDemandMcpEntries()
1313
- .filter(([name]) => !serverName || name.toLowerCase() === serverName.toLowerCase())
1314
- .filter(([name]) => {
1315
- const metadata = mcpMetadata[name]
1316
- return force || metadata?.status === "pending" || metadata?.status === "failed" || !metadata?.autoGeneratedDescription || !metadata.autoGeneratedTools?.length || metadata.avoidedSchemaBytes === undefined
1317
- })
1318
- const refreshed: string[] = []
1319
- const failed: string[] = []
1320
- if (!entries.length) return { refreshed, failed }
1321
- let changed = false
1322
- for (const [name, server] of entries) {
1323
- if (signal?.aborted) break
1324
- let connection: McpConnection | undefined
1325
- const checkedAt = new Date().toISOString()
1326
- try {
1327
- connection = await connectOnDemandMcp(name, server, signal)
1328
- const rawTools = await listAllMcpTools(connection.client, { signal, timeout: mcpTimeoutMs(server) })
1329
- const tools = generatedToolsFromMcpTools(rawTools)
1330
- mcpMetadata[name] = {
1331
- autoGeneratedTools: tools,
1332
- autoGeneratedDescription: synthesizeMcpDescription(name, tools),
1333
- avoidedSchemaBytes: estimateAvoidedMcpSchemaBytes(rawTools),
1334
- status: "ready",
1335
- lastCheckedAt: checkedAt,
1336
- }
1337
- changed = true
1338
- refreshed.push(name)
1339
- } catch (err: any) {
1340
- if (signal?.aborted) break
1341
- mcpMetadata[name] = {
1342
- ...(mcpMetadata[name] ?? {}),
1343
- status: "failed",
1344
- lastError: truncateText(err?.message ?? err, 500),
1345
- lastCheckedAt: checkedAt,
1346
- }
1347
- changed = true
1348
- failed.push(name)
1349
- // Keep startup/discovery best-effort; /raven mcp exposes failures.
1350
- } finally {
1351
- if (connection) await closeMcpConnection(connection)
1352
- }
1353
- }
1354
- if (changed) {
1355
- saveMcpMetadata(mcpMetadata)
1356
- saveDynamicGuidance()
1357
- }
1358
- return { refreshed, failed }
1359
- }
1360
-
1361
- function waitForRefreshDelay(ms: number, signal: AbortSignal): Promise<void> {
1362
- if (signal.aborted) return Promise.resolve()
1363
- return new Promise((resolve) => {
1364
- const timer = setTimeout(done, ms)
1365
- function done() {
1366
- clearTimeout(timer)
1367
- signal.removeEventListener("abort", done)
1368
- resolve()
1369
- }
1370
- signal.addEventListener("abort", done, { once: true })
1371
- })
1372
- }
1373
-
1374
- function queueOnDemandMcpRefresh(force = false, serverName?: string, delay = 0): Promise<{ refreshed: string[]; failed: string[] }> {
1375
- const controller = new AbortController()
1376
- mcpRefreshControllers.add(controller)
1377
- const run = mcpRefreshQueue
1378
- .catch(() => undefined)
1379
- .then(async () => {
1380
- if (delay) await waitForRefreshDelay(delay, controller.signal)
1381
- return refreshOnDemandMcpDescriptions(force, serverName, controller.signal)
1382
- })
1383
- .finally(() => mcpRefreshControllers.delete(controller))
1384
- mcpRefreshQueue = run
1385
- return run
1386
- }
1387
-
1388
- function scheduleOnDemandMcpRefresh(): Promise<{ refreshed: string[]; failed: string[] }> {
1389
- if (mcpRefreshScheduled) return mcpRefreshPromise!
1390
- mcpRefreshScheduled = true
1391
- mcpRefreshPromise = queueOnDemandMcpRefresh(false, undefined, 1000)
1392
- return mcpRefreshPromise
1393
- }
1394
-
1395
- function onDemandMcpRuntimeStatus(name: string): "pending" | "ready" | "failed" {
1396
- const metadata = mcpMetadata[name]
1397
- if (metadata?.status) return metadata.status
1398
- return "pending"
1399
- }
1400
-
1401
- function displayMcpStatus(status: "pending" | "ready" | "failed"): "pending" | "loaded" | "failed" {
1402
- return status === "ready" ? "loaded" : status
1403
- }
1404
-
1405
- function onDemandMcpStatusBuckets(): { loaded: string[]; failed: string[]; pending: string[] } {
1406
- const buckets = { loaded: [] as string[], failed: [] as string[], pending: [] as string[] }
1407
- for (const [name] of activeOnDemandMcpEntries()) {
1408
- const status = onDemandMcpRuntimeStatus(name)
1409
- if (status === "ready") buckets.loaded.push(name)
1410
- else if (status === "failed") buckets.failed.push(name)
1411
- else buckets.pending.push(name)
1412
- }
1413
- return buckets
1414
- }
1415
-
1416
- function failedOnDemandMcpNames(names?: string[]): string[] {
1417
- const configured = activeOnDemandMcpEntries().map(([name]) => name)
1418
- const wanted = names?.length ? names : configured
1419
- return wanted.filter((name) => configured.includes(name) && onDemandMcpRuntimeStatus(name) === "failed")
1420
- }
1421
-
1422
- function setOnDemandMcpHealth(serverName: string, status: "ready" | "failed", error?: unknown) {
1423
- const match = getOnDemandMcpServer(serverName)
1424
- if (!match) return undefined
1425
- const [name] = match
1426
- const next: OnDemandMcpMetadata = {
1427
- ...(mcpMetadata[name] ?? {}),
1428
- status,
1429
- lastCheckedAt: new Date().toISOString(),
1430
- }
1431
- if (status === "failed") next.lastError = truncateText(error, 500)
1432
- else delete next.lastError
1433
- mcpMetadata[name] = next
1434
- saveMcpMetadata(mcpMetadata)
1435
- saveDynamicGuidance()
1436
- return name
1437
- }
1438
-
1439
- function onDemandMcpStatus(): string {
1440
- const entries = activeOnDemandMcpEntries()
1441
- if (!Object.keys(config.raven_mcp.onDemandMcpServers ?? {}).length) return "Raven on-demand MCPs: none configured."
1442
- if (!entries.length) return "Raven on-demand MCPs: none enabled."
1443
- const buckets = onDemandMcpStatusBuckets()
1444
- const lines = entries.map(([name, server]) => {
1445
- const metadata = mcpMetadata[name]
1446
- const source = server.description ? "user description" : "no user description"
1447
- const generated = metadata?.autoGeneratedDescription ? "generated" : "missing generated description"
1448
- const tools = metadata?.autoGeneratedTools?.length ? `${metadata.autoGeneratedTools.length} tools` : "tools pending"
1449
- const status = onDemandMcpRuntimeStatus(name)
1450
- const avoided = status === "ready" && metadata?.avoidedSchemaBytes ? `, ${formatBytes(metadata.avoidedSchemaBytes)} avoided schema` : ""
1451
- const error = status === "failed" && metadata?.lastError ? `, error: ${metadata.lastError}` : ""
1452
- return `- ${name} (${server.type}): ${displayMcpStatus(status)}, ${source}, ${generated}, ${tools}${avoided}${error}`
1453
- })
1454
- return `Raven on-demand MCPs (${config.raven_mcp.descriptionDetail ?? "full"} guidance):\n Loaded: ${buckets.loaded.length ? buckets.loaded.join(", ") : "none"}\n Failed: ${buckets.failed.length ? buckets.failed.join(", ") : "none"}\n Pending: ${buckets.pending.length ? buckets.pending.join(", ") : "none"}\n${lines.join("\n")}`
1455
- }
1456
-
1457
- function avoidedMcpSchemaBytes(): number {
1458
- return activeOnDemandMcpEntries().reduce((sum, [name]) => {
1459
- const metadata = mcpMetadata[name]
1460
- if (onDemandMcpRuntimeStatus(name) !== "ready") return sum
1461
- return sum + Math.max(0, metadata?.avoidedSchemaBytes ?? 0)
1462
- }, 0)
1463
- }
1464
-
1465
- function statsText(): string {
1466
- const avoided = avoidedMcpSchemaBytes()
1467
- return `Raven context saved:\n Avoided MCP schema load: ${formatBytes(avoided)} (~${formatTokens(avoided)} context)\n This session: ${formatBytes(sessionBytes)} (~${formatTokens(sessionBytes)} context)\n All time: ${formatBytes(totalBytes)} (~${formatTokens(totalBytes)} context)`
1468
- }
1469
-
1470
- function helpText(): string {
1471
- return `Raven commands:\n /raven help - show this help\n /raven route - show or edit routed tools/keywords\n /raven seek model <provider/model>|auto - change or auto-detect the search model (requires restart)\n /raven seek effort <value> - change search reasoning effort (requires restart)\n /raven seek timeout <seconds> - change search timeout\n /raven mcp - show on-demand MCP metadata\n /raven mcp refresh [server] - refresh MCP metadata\n /raven mcp detail full|minimized - change MCP guidance detail\n /raven mcp model <provider/model>|auto - change or auto-detect the MCP model (requires restart)\n /raven mcp effort <value> - change MCP reasoning effort (requires restart)\n /raven mcp timeout <seconds> - change MCP delegation timeout\n /raven update - check for an update\n /raven stats - show context saved`
1472
- }
1473
-
1474
- function formatMcpList(title: string, items: any[], fields: string[] = ["name", "description"]): string {
1475
- if (!items.length) return `${title}: none found.`
1476
- const lines = items.map((item) => {
1477
- const primary = fields.map((field) => item?.[field]).find(Boolean) ?? JSON.stringify(item)
1478
- const description = item?.description && primary !== item.description ? ` - ${truncateText(item.description, 300)}` : ""
1479
- return `- ${truncateText(primary, 180)}${description}`
1480
- })
1481
- return `${title}:\n${lines.join("\n")}`
1482
- }
1483
-
1484
- function formatMcpToolsForRaven(serverName: string, tools: any[]): string {
1485
- if (!tools.length) return `Tools for ${serverName}: none found.`
1486
- const lines = tools.map((item) => {
1487
- const description = item?.description ? `\n Description: ${truncateText(item.description, 600)}` : ""
1488
- const schema = item?.inputSchema ? `\n Input schema: ${truncateText(JSON.stringify(item.inputSchema), 3000)}` : ""
1489
- return `- ${item?.name ?? "(unnamed)"}${description}${schema}`
1490
- })
1491
- return `Tools for ${serverName}:\n${lines.join("\n")}`
1492
- }
1493
-
1494
- function formatMcpResult(result: any): string {
1495
- if (!result) return "No result."
1496
- if (Array.isArray(result.content)) {
1497
- const parts = result.content.map((part: any) => {
1498
- if (part?.type === "text") return part.text
1499
- return JSON.stringify(part)
1500
- })
1501
- return parts.join("\n") || JSON.stringify(result)
1502
- }
1503
- return typeof result === "string" ? result : JSON.stringify(result, null, 2)
1504
- }
1505
-
1506
- function parseMcpArguments(raw: string | undefined): Record<string, any> {
1507
- if (!raw?.trim()) return {}
1508
- const parsed = JSON.parse(raw)
1509
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1510
- throw new Error("argumentsJson must be a JSON object")
1511
- }
1512
- return parsed
1513
- }
1514
-
1515
- // ── Context saved by Raven delegation ──
1516
- let sessionBytes = 0
1517
- let totalBytes = config.stats?.bytes ?? 0
1518
-
1519
- function addBytes(bytes: number) {
1520
- sessionBytes += bytes
1521
- const updated = updateConfig((latest) => ({
1522
- stats: { bytes: Math.max(totalBytes, latest.stats?.bytes ?? 0) + bytes },
1523
- }))
1524
- totalBytes = updated.stats?.bytes ?? totalBytes + bytes
1525
- }
1526
-
1527
- function formatBytes(bytes: number): string {
1528
- return bytes >= 1_000_000 ? `${(bytes / 1_000_000).toFixed(1)}MB`
1529
- : bytes >= 1000 ? `${(bytes / 1000).toFixed(1)}KB`
1530
- : `${bytes}B`
1531
- }
1532
-
1533
- function formatTokens(bytes: number): string {
1534
- const tokens = Math.round(bytes / 4)
1535
- return tokens >= 1_000_000 ? `${(tokens / 1_000_000).toFixed(1)}M`
1536
- : tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}K`
1537
- : `${tokens}`
1538
- }
1539
-
1540
- function compareVersions(a: string, b: string): number {
1541
- const parse = (v: string) => v.replace(/^v/, "").split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0)
1542
- const left = parse(a)
1543
- const right = parse(b)
1544
- const len = Math.max(left.length, right.length)
1545
- for (let i = 0; i < len; i++) {
1546
- const diff = (left[i] ?? 0) - (right[i] ?? 0)
1547
- if (diff !== 0) return diff
1548
- }
1549
- return 0
1550
- }
1551
-
1552
- async function fetchLatestVersion(): Promise<string | undefined> {
1553
- const controller = new AbortController()
1554
- const timeout = setTimeout(() => controller.abort(), 5000)
1555
- try {
1556
- const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
1557
- headers: { accept: "application/json" },
1558
- signal: controller.signal,
1559
- })
1560
- if (!res.ok) return undefined
1561
- const data = await res.json() as { version?: string }
1562
- return data.version
1563
- } finally {
1564
- clearTimeout(timeout)
1565
- }
1566
- }
1567
-
1568
- async function checkForUpdate(): Promise<{ current: string; latest?: string; available: boolean }> {
1569
- const latest = await fetchLatestVersion()
1570
- return { current: PACKAGE_VERSION, latest, available: !!latest && compareVersions(latest, PACKAGE_VERSION) > 0 }
1571
- }
1572
-
1573
- function estimateRavenSavedCandidateBytes(messages: any[]): number {
1574
- const assistantMessages = messages.filter((m: any) =>
1575
- m?.info?.role === "assistant" && m?.info?.tokens?.output > 0
1576
- )
1577
- const first = assistantMessages[0]
1578
- const last = assistantMessages[assistantMessages.length - 1]
1579
- const t = last?.info?.tokens
1580
- const firstTokens = first?.info?.tokens
1581
- if (!t) return 0
1582
-
1583
- // Balanced estimate: total Raven context minus the first-turn prompt/schema/cache baseline.
1584
- const totalTokens = (t.input ?? 0) + (t.output ?? 0) + (t.reasoning ?? 0) + (t.cache?.read ?? 0) + (t.cache?.write ?? 0)
1585
- const baselineTokens = firstTokens
1586
- ? (firstTokens.input ?? 0) + (firstTokens.cache?.read ?? 0) + (firstTokens.cache?.write ?? 0)
1587
- : 0
1588
- const savedCandidateTokens = Math.max(0, totalTokens - baselineTokens)
1589
- return savedCandidateTokens * 4
1590
- }
1591
-
1592
- async function countRavenSavedCandidateBytes(sessionId: string, signal?: AbortSignal): Promise<number> {
1593
- const messagesResp = await client.session.messages({ path: { id: sessionId }, signal })
1594
- const messages = (messagesResp as any)?.data ?? []
1595
- return estimateRavenSavedCandidateBytes(messages)
1596
- }
1597
-
1598
- async function getUpdateInfo(): Promise<{ current: string; latest?: string; available: boolean }> {
1599
- if (updateInfo) return updateInfo
1600
- if (!updateCheckPromise) {
1601
- updateCheckPromise = checkForUpdate()
1602
- .then((info) => {
1603
- updateInfo = info
1604
- return info
1605
- })
1606
- .catch((err) => {
1607
- updateCheckPromise = undefined
1608
- throw err
1609
- })
1610
- }
1611
- return updateCheckPromise
1612
- }
1613
-
1614
- async function refreshUpdateInfo(): Promise<{ current: string; latest?: string; available: boolean }> {
1615
- updateInfo = undefined
1616
- updateCheckPromise = checkForUpdate()
1617
- .then((info) => {
1618
- updateInfo = info
1619
- return info
1620
- })
1621
- .catch((err) => {
1622
- updateCheckPromise = undefined
1623
- throw err
1624
- })
1625
- return updateCheckPromise
1626
- }
1627
-
1628
- function clearPluginCache(): string[] {
1629
- const packagesDir = join(homedir(), ".cache", "opencode", "packages")
1630
- if (!existsSync(packagesDir)) return []
1631
-
1632
- const removed: string[] = []
1633
- for (const entry of readdirSync(packagesDir)) {
1634
- if (entry !== PACKAGE_NAME && !entry.startsWith(`${PACKAGE_NAME}@`)) continue
1635
- const target = join(packagesDir, entry)
1636
- rmSync(target, { recursive: true, force: true })
1637
- removed.push(target)
1638
- }
1639
- return removed
1640
- }
1641
-
1642
- function manualUpdateText(latest = "latest"): string {
1643
- return `Restart opencode to load the update.\n\nManual alternatives:\n bun update --latest ${PACKAGE_NAME}\n npm install ${PACKAGE_NAME}@${latest}\n\nIf opencode still loads the old version, clear its plugin cache and restart:\n PowerShell: Remove-Item -Recurse -Force "$HOME\\.cache\\opencode\\packages\\${PACKAGE_NAME}*"\n macOS/Linux: rm -rf ~/.cache/opencode/packages/${PACKAGE_NAME}*`
1644
- }
1645
-
1646
- async function notifyIfUpdateAvailable() {
1647
- try {
1648
- const info = await getUpdateInfo()
1649
- if (!info.available || !info.latest) return
1650
- await (client as any).tui?.showToast?.({
1651
- body: {
1652
- title: "Raven update available",
1653
- message: `${PACKAGE_NAME} ${info.current} → ${info.latest}. Run /raven update, then restart opencode.`,
1654
- variant: "info",
1655
- duration: 10000,
1656
- },
1657
- })
1658
- } catch { /* update checks are best-effort */ }
1659
- }
1660
-
1661
- async function notifyIfMcpFailures(names?: string[]) {
1662
- try {
1663
- const failed = failedOnDemandMcpNames(names)
1664
- if (!failed.length) {
1665
- lastNotifiedMcpFailureKey = ""
1666
- return
1667
- }
1668
- const key = failed.sort().join("|")
1669
- if (key === lastNotifiedMcpFailureKey) return
1670
- lastNotifiedMcpFailureKey = key
1671
- const label = failed.length === 1 ? failed[0] : `${failed.length} on-demand MCPs`
1672
- await (client as any).tui?.showToast?.({
1673
- body: {
1674
- title: "Raven MCP warning",
1675
- message: `${label} failed to load. Run /raven mcp for details.`,
1676
- variant: "warning",
1677
- duration: 12000,
1678
- },
1679
- })
1680
- } catch { /* MCP failure notifications are best-effort */ }
1681
- }
1682
-
1683
- async function notifyIfDuplicateMcps() {
1684
- try {
1685
- if (!duplicateMcpServers.length) return
1686
- const label = duplicateMcpServers.length === 1 ? duplicateMcpServers[0] : `${duplicateMcpServers.length} MCPs`
1687
- await (client as any).tui?.showToast?.({
1688
- body: {
1689
- title: "Raven MCP duplicate config",
1690
- message: `${label} configured both globally and on-demand. Prefer one location to avoid duplicate schema/connection behavior.`,
1691
- variant: "warning",
1692
- duration: 12000,
1693
- },
1694
- })
1695
- } catch { /* duplicate MCP notifications are best-effort */ }
1696
- }
1697
-
1698
- async function notifyIfModelSetupRequired() {
1699
- const message = modelSetupInstructions()
1700
- if (!message) return
1701
- const updateReady = (!!config.raven_seek.model && activeSeekModel !== config.raven_seek.model)
1702
- || (!!config.raven_mcp.model && activeMcpModel !== config.raven_mcp.model)
1703
- try {
1704
- await (client as any).tui?.showToast?.({
1705
- body: {
1706
- title: updateReady ? "Raven model selected" : "Raven first-time setup",
1707
- message: updateReady
1708
- ? "A free Raven model was selected. Restart OpenCode to load it."
1709
- : "No free Raven model was detected. Run /raven for setup instructions; routing is inactive until configured.",
1710
- variant: updateReady ? "info" : "warning",
1711
- duration: 15000,
1712
- },
1713
- })
1714
- } catch { /* model setup remains visible in /raven */ }
1715
- }
1716
-
1717
- function delegationAgent(kind: DelegationKind): "raven" | "raven-mcp" {
1718
- return kind === "mcp" ? "raven-mcp" : "raven"
1719
- }
1720
-
1721
- function delegationToolName(kind: DelegationKind): "raven_seek" | "raven_mcp" {
1722
- return kind === "mcp" ? "raven_mcp" : "raven_seek"
1723
- }
1724
-
1725
- function delegationTitle(kind: DelegationKind): string {
1726
- return kind === "mcp" ? "Raven MCP" : "Raven Seek"
1727
- }
1728
-
1729
- async function rootSessionId(sessionId: string, signal?: AbortSignal): Promise<string> {
1730
- let current = sessionId
1731
- const seen = new Set<string>()
1732
- while (!seen.has(current)) {
1733
- seen.add(current)
1734
- const response = await client.session.get({ path: { id: current }, signal })
1735
- const session = (response as any)?.data ?? response
1736
- if (!session?.parentID) return current
1737
- current = session.parentID
1738
- }
1739
- return sessionId
1740
- }
1741
-
1742
- async function syncCreatedSession(sessionId: string, title: string, signal?: AbortSignal) {
1743
- try {
1744
- await client.session.update({
1745
- path: { id: sessionId },
1746
- body: { title },
1747
- signal,
1748
- })
1749
- } catch (error) {
1750
- if (signal?.aborted) throw error
1751
- // Delegation still works if an older or disconnected TUI misses the sync event.
1752
- }
1753
- }
1754
-
1755
- async function executeRavenDelegation(kind: DelegationKind, args: { query: string; sessionId?: string }, context: any) {
1756
- const started = Date.now()
1757
- const agent = delegationAgent(kind)
1758
- const toolName = delegationToolName(kind)
1759
- const title = delegationTitle(kind)
1760
- await refreshModelsIfPending()
1761
- if (!modelOperational(kind)) {
1762
- return { title, output: modelSetupInstructions(kind) }
1763
- }
1764
- const timeout = (kind === "mcp" ? config.raven_mcp.timeout : config.raven_seek.timeout) ?? 600
1765
- const timeoutMs = timeout * 1000
1766
- let sessionId: string | undefined
1767
- let resumed = false
1768
- let previousSavedCandidate = 0
1769
-
1770
- try {
1771
- const requestedSessionId = args.sessionId?.trim()
1772
- if (args.sessionId !== undefined && !requestedSessionId) {
1773
- return { title, output: "Cannot resume Raven: sessionId must not be empty." }
1774
- }
1775
-
1776
- let rootId = context.sessionID
1777
- try {
1778
- rootId = await rootSessionId(context.sessionID, context.abort)
1779
- } catch (error) {
1780
- if (context.abort.aborted) throw error
1781
- // Falling back preserves delegation if an older OpenCode build cannot read the caller session.
1782
- }
1783
-
1784
- if (requestedSessionId) {
1785
- const knownKind = ravenSessionKinds.get(requestedSessionId)
1786
- const knownRavenSession = knownKind === kind || sessionAgents.get(requestedSessionId) === agent
1787
- let existingSession: any
1788
- try {
1789
- const sessionResp = await client.session.get({ path: { id: requestedSessionId }, signal: context.abort })
1790
- existingSession = (sessionResp as any)?.data ?? sessionResp
1791
- } catch (error) {
1792
- if (context.abort.aborted) throw error
1793
- return { title, output: `Cannot resume Raven session ${requestedSessionId}: the session was not found or could not be read.` }
1794
- }
1795
-
1796
- if (!existingSession?.id || existingSession.id !== requestedSessionId) {
1797
- return { title, output: `Cannot resume Raven session ${requestedSessionId}: the session was not found or could not be read.` }
1798
- }
1799
- const validParent = existingSession.parentID === rootId || existingSession.parentID === context.sessionID
1800
- if (!validParent || existingSession.projectID !== input.project.id) {
1801
- return { title, output: "Cannot resume Raven: sessionId is not part of this main session's Raven tree." }
1802
- }
1803
-
1804
- let messages: any[]
1805
- try {
1806
- const messagesResp = await client.session.messages({ path: { id: requestedSessionId }, signal: context.abort })
1807
- const messageData = (messagesResp as any)?.data
1808
- if (!Array.isArray(messageData)) {
1809
- return { title, output: `Cannot resume Raven session ${requestedSessionId}: its message history could not be read.` }
1810
- }
1811
- messages = messageData
1812
- } catch (error) {
1813
- if (context.abort.aborted) throw error
1814
- return { title, output: `Cannot resume Raven session ${requestedSessionId}: its message history could not be read.` }
1815
- }
1816
-
1817
- const userMessages = messages.filter((message: any) => message?.info?.role === "user")
1818
- const persistedRavenSession = userMessages.length > 0
1819
- && userMessages.every((message: any) => message.info.agent === agent)
1820
- if (!persistedRavenSession && !(knownRavenSession && userMessages.length === 0)) {
1821
- return { title, output: `Cannot resume Raven: sessionId does not belong to the ${agent} agent.` }
1822
- }
1823
-
1824
- sessionId = requestedSessionId
1825
- resumed = true
1826
- previousSavedCandidate = estimateRavenSavedCandidateBytes(messages)
1827
- } else {
1828
- const sessionTitle = `${toolName}: ${args.query.slice(0, 80)}`
1829
- const session = await client.session.create({
1830
- body: {
1831
- parentID: rootId,
1832
- title: sessionTitle,
1833
- },
1834
- signal: context.abort,
1835
- })
1836
- sessionId = (session as any)?.data?.id ?? (session as any)?.id
1837
- if (!sessionId) return { title, output: "Failed to create Raven session." }
1838
- await syncCreatedSession(sessionId, sessionTitle, context.abort)
1839
- }
1840
-
1841
- ravenSessions.add(sessionId)
1842
- ravenSessionKinds.set(sessionId, kind)
1843
- const activeSessionId = sessionId
1844
- context.metadata({ metadata: { sessionId } })
1845
-
1846
- try {
1847
- writeFileSync(join(tmpdir(), "raven-sessions.log"), `${new Date().toISOString()} ${sessionId}\n`)
1848
- } catch { /* non-fatal */ }
1849
-
1850
- if (activeRavenPrompts.has(activeSessionId)) {
1851
- return {
1852
- title,
1853
- metadata: { sessionId: activeSessionId },
1854
- output: `Raven session \`${activeSessionId}\` is already handling a request. Wait for it to finish before sending another follow-up.`,
1855
- }
1856
- }
1857
- activeRavenPrompts.add(activeSessionId)
1858
-
1859
- let timer: ReturnType<typeof setTimeout> | undefined
1860
- let promptCompleted = false
1861
- let sessionAbort: Promise<unknown> | undefined
1862
- const promptController = new AbortController()
1863
- const abortPrompt = () => {
1864
- if (promptController.signal.aborted) return
1865
- promptController.abort(context.abort.reason)
1866
- const abortController = new AbortController()
1867
- const abortTimer = setTimeout(() => abortController.abort(), Math.min(timeoutMs, 5_000))
1868
- sessionAbort = client.session.abort({
1869
- path: { id: activeSessionId },
1870
- signal: abortController.signal,
1871
- throwOnError: true,
1872
- }).catch(() => undefined).finally(() => clearTimeout(abortTimer))
1873
- }
1874
- if (context.abort.aborted) abortPrompt()
1875
- else context.abort.addEventListener("abort", abortPrompt, { once: true })
1876
- const providerError = waitForRavenProviderError(sessionId)
1877
- const firstProgress = waitForRavenFirstProgress(sessionId, Math.min(timeoutMs, 30_000))
1878
-
1879
- try {
1880
- const result = await Promise.race([
1881
- client.session.prompt({
1882
- path: { id: sessionId },
1883
- body: { agent, parts: [{ type: "text", text: args.query }] },
1884
- signal: promptController.signal,
1885
- throwOnError: true,
1886
- }),
1887
- providerError,
1888
- firstProgress,
1889
- new Promise<never>((_, reject) => {
1890
- timer = setTimeout(() => {
1891
- abortPrompt()
1892
- reject(new Error(`Raven timed out after ${timeout}s - child session retained: ${sessionId}`))
1893
- }, timeoutMs)
1894
- }),
1895
- ])
1896
- promptCompleted = true
1897
- if (timer) clearTimeout(timer)
1898
- clearRavenWaiters(sessionId)
1899
- if ((result as any)?.error) throw new Error(providerErrorMessage((result as any).error))
1900
-
1901
- const elapsed = ((Date.now() - started) / 1000).toFixed(1)
1902
- const parts = (result as any)?.data?.parts ?? []
1903
- const output = parts
1904
- .filter((part: any) => part.type === "text" && part.text)
1905
- .map((part: any) => part.text)
1906
- .join("\n") || "Raven returned no results."
1907
-
1908
- let savedCandidate = 0
1909
- const statsController = new AbortController()
1910
- const abortStats = () => statsController.abort(context.abort.reason)
1911
- const statsTimer = setTimeout(() => statsController.abort(), Math.min(timeoutMs, 5_000))
1912
- if (context.abort.aborted) abortStats()
1913
- else context.abort.addEventListener("abort", abortStats, { once: true })
1914
- try {
1915
- const totalSavedCandidate = await countRavenSavedCandidateBytes(sessionId, statsController.signal)
1916
- savedCandidate = Math.max(0, totalSavedCandidate - previousSavedCandidate)
1917
- } catch { /* best-effort */ }
1918
- finally {
1919
- clearTimeout(statsTimer)
1920
- context.abort.removeEventListener("abort", abortStats)
1921
- }
1922
- if (savedCandidate <= 0) {
1923
- for (const part of parts) {
1924
- if (part.args) savedCandidate += JSON.stringify(part.args).length
1925
- if (part.content) savedCandidate += typeof part.content === "string" ? part.content.length : JSON.stringify(part.content).length
1926
- }
1927
- }
1928
-
1929
- const continuation = `Raven session: \`${sessionId}\`${resumed ? " (continued)" : ""}. Pass this as \`sessionId\` in a later \`${toolName}\` call to continue the same retrieval.`
1930
- const activity = kind === "mcp" ? "used MCPs" : "searched"
1931
- const resultOutput = `${output}\n\n*Raven ${activity} for ${elapsed}s - ${formatBytes(savedCandidate)} handled, ${formatTokens(savedCandidate)} tokens*\n\n${continuation}`
1932
- addBytes(Math.max(0, savedCandidate - resultOutput.length))
1933
- delete ravenProviderIssues[kind]
1934
- return { title, metadata: { sessionId }, output: resultOutput }
1935
- } finally {
1936
- context.abort.removeEventListener("abort", abortPrompt)
1937
- if (!promptCompleted) abortPrompt()
1938
- if (sessionAbort) await sessionAbort
1939
- if (timer) clearTimeout(timer)
1940
- clearRavenWaiters(sessionId)
1941
- activeRavenPrompts.delete(activeSessionId)
1942
- }
1943
- } catch (error: any) {
1944
- const elapsed = ((Date.now() - started) / 1000).toFixed(1)
1945
- const message = String(error?.message ?? error ?? "").toLowerCase()
1946
- const missingSession = /session.*not found|not found.*session/i.test(message)
1947
- const activity = kind === "mcp" ? "MCP retrieval" : "search"
1948
- const hint =
1949
- context.abort.aborted ? `Raven ${activity} cancelled.`
1950
- : /rate.?limit|too many requests|429/i.test(message) ? "Raven rate limited - wait 30s, then retry with a narrower query."
1951
- : /quota|usage.?limit|usage exceeded|free usage|subscribe to go|subscription|billing|insufficient.*(?:credit|balance|quota)/i.test(message) ? "Raven API quota/subscription exhausted - proceed without Raven and mention what information may be missing."
1952
- : /auth|unauthorized|forbidden|401|403|api key|login/i.test(message) ? "Raven provider authentication failed - proceed without Raven and check the configured provider login or API key."
1953
- : /token|context.?length|too large|too long/i.test(message) ? "Raven query too large - shorten your query and retry."
1954
- : missingSession ? `The Raven session is no longer available - retry without sessionId to start a new ${activity}.`
1955
- : /model|unavailable|down|not found/i.test(message) ? "Raven model unavailable - retry later, or proceed without the missing information."
1956
- : /did not produce an initial response|retry dialog|timeout|timed.?out|session retained/i.test(message) ? error.message
1957
- : `Raven ${activity} failed. Note the resulting information gap. [${error.message || error}]`
1958
- if (!missingSession && /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(message)) {
1959
- ravenProviderIssues[kind] = hint
1960
- }
1961
- const recovery = ravenProviderIssues[kind] ? `\n\n${ravenRecoveryInstructions(kind)}` : ""
1962
- const continuation = sessionId && !missingSession
1963
- ? `\n\nRaven session: \`${sessionId}\`. Pass this as \`sessionId\` in a later \`${toolName}\` call to retry or continue.`
1964
- : ""
1965
- return { title, metadata: sessionId ? { sessionId } : undefined, output: `${hint}${recovery}\n\n*Attempt took ${elapsed}s*${continuation}` }
1966
- }
1967
- }
1968
-
1969
- return {
1970
- async dispose() {
1971
- for (const controller of mcpRefreshControllers) controller.abort()
1972
- for (const attempt of mcpConnectionAttempts.values()) attempt.controller.abort()
1973
- await Promise.allSettled([
1974
- ...[...mcpConnectionAttempts.values()].map((attempt) => attempt.promise),
1975
- mcpRefreshQueue,
1976
- ])
1977
- await Promise.all([...mcpConnections.keys()].map((key) => closeMcpConnectionKey(key)))
1978
- if (mcpIdleTimer) clearInterval(mcpIdleTimer)
1979
- mcpIdleTimer = undefined
1980
- for (const sessionId of ravenProgressWaiters.keys()) clearRavenWaiters(sessionId)
1981
- ravenErrorWaiters.clear()
1982
- },
1983
-
1984
- async config(configInput: any) {
1985
- globalMcpServers = uniqueStrings(Object.entries(configInput.mcp ?? {})
1986
- .filter(([, value]) => !value || typeof value !== "object" || (value as any).enabled !== false)
1987
- .map(([name]) => name))
1988
- const onDemandNames = new Set(activeOnDemandMcpEntries().map(([name]) => name.toLowerCase()))
1989
- duplicateMcpServers = globalMcpServers.filter((name) => onDemandNames.has(name.toLowerCase()))
1990
-
1991
- // Inject MCP guidance as a startup instruction file (absolute path for npm compat).
1992
- // The file is regenerated after MCP health checks, not during the startup pending reset.
1993
- configInput.instructions = configInput.instructions || []
1994
- if (!configInput.instructions.includes(MCP_GUIDANCE_MD)) {
1995
- configInput.instructions.push(MCP_GUIDANCE_MD)
1996
- }
1997
- if (!configInput.instructions.includes(dynamicGuidanceFile)) {
1998
- configInput.instructions.push(dynamicGuidanceFile)
1999
- }
2000
-
2001
- // Register Raven from Raven.md, with config file overrides
2002
- configInput.agent = configInput.agent || {}
2003
- const seekModel = activeSeekModel || fm.model
2004
- const mcpModel = activeMcpModel || mcpFm.model
2005
- configInput.agent.raven = {
2006
- description: fm.description || "",
2007
- mode: fm.mode || "subagent",
2008
- hidden: fm.hidden !== undefined ? fm.hidden : false,
2009
- disable: !seekModel,
2010
- ...(seekModel ? { model: seekModel } : {}),
2011
- options: {
2012
- ...extractOptions(fm),
2013
- ...(config.raven_seek.reasoning_effort ? { reasoning_effort: config.raven_seek.reasoning_effort } : {}),
2014
- },
2015
- permission: fm.permission || {},
2016
- prompt: ravenAgentPrompt(),
2017
- }
2018
- configInput.agent["raven-mcp"] = {
2019
- description: mcpFm.description || "",
2020
- mode: mcpFm.mode || "subagent",
2021
- hidden: mcpFm.hidden !== undefined ? mcpFm.hidden : true,
2022
- disable: !mcpModel,
2023
- ...(mcpModel ? { model: mcpModel } : {}),
2024
- options: {
2025
- ...extractOptions(mcpFm),
2026
- ...(config.raven_mcp.reasoning_effort ? { reasoning_effort: config.raven_mcp.reasoning_effort } : {}),
2027
- },
2028
- permission: mcpFm.permission || {},
2029
- prompt: ravenMcpAgentPrompt(),
2030
- }
2031
-
2032
- // Register /raven command
2033
- configInput.command = configInput.command || {}
2034
- if (!configInput.command.raven) {
2035
- configInput.command.raven = {
2036
- template: "Manage Raven search routing, on-demand MCPs, models, timeouts, updates, and status",
2037
- description: "Manage Raven search routing, on-demand MCPs, and per-agent models",
2038
- }
2039
- }
2040
-
2041
- scheduleOnDemandMcpRefresh()
2042
- updateToastPending = true
2043
- mcpFailureToastPending = true
2044
- duplicateMcpToastPending = duplicateMcpServers.length > 0
2045
- modelSetupToastPending = !!modelSetupInstructions()
2046
- },
2047
-
2048
- // Register raven_seek tool — lets agents with task:false still delegate through Raven
2049
- tool: {
2050
- "raven_seek": tool({
2051
- description: "Search and evidence retrieval only. Creates or resumes a Raven Search session for routed local search, filesystem discovery, URL fetching, web/docs/GitHub search, globally configured MCP searches, or command-output inspection. Do not delegate code review, auditing, correctness judgments, implementation, debugging decisions, planning, or general task execution; the calling agent must perform that work.",
2052
- args: {
2053
- query: tool.schema.string().describe("A narrowly scoped search or evidence-retrieval request. Include original blocked tool args, exact URLs, paths, patterns, or bounded command-output checks. Do not ask Raven to review or audit code."),
2054
- sessionId: tool.schema.string().optional().describe("Raven session ID returned by an earlier raven_seek call. Provide it only when this request should continue that session's research context."),
2055
- },
2056
- async execute(args, context) {
2057
- const started = Date.now()
2058
- await refreshModelsIfPending()
2059
- if (!modelOperational("seek")) {
2060
- return { title: "Raven Seek", output: modelSetupInstructions("seek") }
2061
- }
2062
- const timeout = (config.raven_seek.timeout ?? 600) * 1000
2063
- let sessionId: string | undefined
2064
- let resumed = false
2065
- let previousSavedCandidate = 0
2066
- try {
2067
- const requestedSessionId = args.sessionId?.trim()
2068
- if (args.sessionId !== undefined && !requestedSessionId) {
2069
- return { title: "Raven Seek", output: "Cannot resume Raven: sessionId must not be empty." }
2070
- }
2071
-
2072
- let rootId = context.sessionID
2073
- try {
2074
- rootId = await rootSessionId(context.sessionID, context.abort)
2075
- } catch (error) {
2076
- if (context.abort.aborted) throw error
2077
- }
2078
-
2079
- if (requestedSessionId) {
2080
- const knownRavenSession = ravenSessionKinds.get(requestedSessionId) === "seek" || sessionAgents.get(requestedSessionId) === "raven"
2081
- let existingSession: any
2082
- try {
2083
- const sessionResp = await client.session.get({ path: { id: requestedSessionId }, signal: context.abort })
2084
- existingSession = (sessionResp as any)?.data ?? sessionResp
2085
- } catch (err) {
2086
- if (context.abort.aborted) throw err
2087
- return { title: "Raven Seek", output: `Cannot resume Raven session ${requestedSessionId}: the session was not found or could not be read.` }
2088
- }
2089
-
2090
- if (!existingSession?.id || existingSession.id !== requestedSessionId) {
2091
- return { title: "Raven Seek", output: `Cannot resume Raven session ${requestedSessionId}: the session was not found or could not be read.` }
2092
- }
2093
- if ((existingSession.parentID !== rootId && existingSession.parentID !== context.sessionID) || existingSession.projectID !== input.project.id) {
2094
- return { title: "Raven Seek", output: "Cannot resume Raven: sessionId is not a Raven child of this main session." }
2095
- }
2096
-
2097
- let messages: any[]
2098
- try {
2099
- const messagesResp = await client.session.messages({ path: { id: requestedSessionId }, signal: context.abort })
2100
- const messageData = (messagesResp as any)?.data
2101
- if (!Array.isArray(messageData)) {
2102
- return { title: "Raven Seek", output: `Cannot resume Raven session ${requestedSessionId}: its message history could not be read.` }
2103
- }
2104
- messages = messageData
2105
- } catch (err) {
2106
- if (context.abort.aborted) throw err
2107
- return { title: "Raven Seek", output: `Cannot resume Raven session ${requestedSessionId}: its message history could not be read.` }
2108
- }
2109
-
2110
- const userMessages = messages.filter((message: any) => message?.info?.role === "user")
2111
- const persistedRavenSession = userMessages.length > 0
2112
- && userMessages.every((message: any) => message.info.agent === "raven")
2113
- if (!persistedRavenSession && !(knownRavenSession && userMessages.length === 0)) {
2114
- return { title: "Raven Seek", output: "Cannot resume Raven: sessionId is not a Raven child of this main session." }
2115
- }
2116
-
2117
- sessionId = requestedSessionId
2118
- resumed = true
2119
- previousSavedCandidate = estimateRavenSavedCandidateBytes(messages)
2120
- } else {
2121
- const sessionTitle = `raven_seek: ${args.query.slice(0, 80)}`
2122
- const session = await client.session.create({
2123
- body: {
2124
- parentID: rootId,
2125
- title: sessionTitle,
2126
- },
2127
- signal: context.abort,
2128
- })
2129
-
2130
- sessionId = (session as any)?.data?.id ?? (session as any)?.id
2131
- if (!sessionId) {
2132
- return { title: "Raven Seek", output: "Failed to create Raven session." }
2133
- }
2134
- await syncCreatedSession(sessionId, sessionTitle, context.abort)
2135
- }
2136
-
2137
- ravenSessions.add(sessionId)
2138
- ravenSessionKinds.set(sessionId, "seek")
2139
- const activeSessionId = sessionId
2140
-
2141
- // Preserve the child session link for TUI clients that render it.
2142
- context.metadata({ metadata: { sessionId } })
2143
-
2144
- // Log session for debugging
2145
- try {
2146
- const logFile = join(tmpdir(), "raven-sessions.log")
2147
- const ts = new Date().toISOString()
2148
- writeFileSync(logFile, `${ts} ${sessionId}\n`)
2149
- } catch { /* non-fatal */ }
2150
-
2151
- if (activeRavenPrompts.has(activeSessionId)) {
2152
- return {
2153
- title: "Raven Seek",
2154
- metadata: { sessionId: activeSessionId },
2155
- output: `Raven session \`${activeSessionId}\` is already handling a request. Wait for it to finish before sending another follow-up.`,
2156
- }
2157
- }
2158
- activeRavenPrompts.add(activeSessionId)
2159
-
2160
- // Send the query to Raven with timeout. Provider/API errors are also
2161
- // surfaced through message.updated events; fail as soon as one lands
2162
- // so the caller does not wait for the full timeout on quota/auth issues.
2163
- let timer: ReturnType<typeof setTimeout> | undefined
2164
- let promptCompleted = false
2165
- let sessionAbort: Promise<unknown> | undefined
2166
- const promptController = new AbortController()
2167
- const abortPrompt = () => {
2168
- if (promptController.signal.aborted) return
2169
- promptController.abort(context.abort.reason)
2170
- const abortController = new AbortController()
2171
- const abortTimer = setTimeout(() => abortController.abort(), Math.min(timeout, 5_000))
2172
- sessionAbort = client.session.abort({
2173
- path: { id: activeSessionId },
2174
- signal: abortController.signal,
2175
- throwOnError: true,
2176
- }).catch(() => undefined).finally(() => clearTimeout(abortTimer))
2177
- }
2178
- if (context.abort.aborted) abortPrompt()
2179
- else context.abort.addEventListener("abort", abortPrompt, { once: true })
2180
- const providerError = waitForRavenProviderError(sessionId)
2181
- const firstProgress = waitForRavenFirstProgress(sessionId, Math.min(timeout, 30_000))
2182
- try {
2183
- const result = await Promise.race([
2184
- client.session.prompt({
2185
- path: { id: sessionId },
2186
- body: {
2187
- agent: "raven",
2188
- parts: [{ type: "text", text: args.query }],
2189
- },
2190
- signal: promptController.signal,
2191
- throwOnError: true,
2192
- }),
2193
- providerError,
2194
- firstProgress,
2195
- new Promise<never>((_, reject) => {
2196
- timer = setTimeout(() => {
2197
- abortPrompt()
2198
- reject(new Error(`Raven timed out after ${timeout / 1000}s — child session retained: ${sessionId}`))
2199
- }, timeout)
2200
- }),
2201
- ])
2202
- promptCompleted = true
2203
- if (timer) clearTimeout(timer)
2204
- clearRavenWaiters(sessionId)
2205
- if ((result as any)?.error) throw new Error(providerErrorMessage((result as any).error))
2206
-
2207
- const elapsed = ((Date.now() - started) / 1000).toFixed(1)
2208
-
2209
- // Extract text from the response
2210
- const parts = (result as any)?.data?.parts ?? []
2211
- const textParts = parts
2212
- .filter((p: any) => p.type === "text" && p.text)
2213
- .map((p: any) => p.text)
2214
- const output = textParts.join("\n") || "Raven returned no results."
2215
-
2216
- // Context saved = Raven output/reasoning/cache context - compact answer returned to main session.
2217
- let savedCandidate = 0
2218
- const statsController = new AbortController()
2219
- const abortStats = () => statsController.abort(context.abort.reason)
2220
- const statsTimer = setTimeout(() => statsController.abort(), Math.min(timeout, 5_000))
2221
- if (context.abort.aborted) abortStats()
2222
- else context.abort.addEventListener("abort", abortStats, { once: true })
2223
- try {
2224
- const totalSavedCandidate = await countRavenSavedCandidateBytes(sessionId, statsController.signal)
2225
- savedCandidate = Math.max(0, totalSavedCandidate - previousSavedCandidate)
2226
- } catch { /* best-effort */ }
2227
- finally {
2228
- clearTimeout(statsTimer)
2229
- context.abort.removeEventListener("abort", abortStats)
2230
- }
2231
- if (savedCandidate <= 0) {
2232
- for (const part of parts) {
2233
- if (part.args) savedCandidate += JSON.stringify(part.args).length
2234
- if (part.content) savedCandidate += typeof part.content === "string" ? part.content.length : JSON.stringify(part.content).length
2235
- }
2236
- }
2237
- const continuation = `Raven session: \`${sessionId}\`${resumed ? " (continued)" : ""}. Pass this as \`sessionId\` in a later \`raven_seek\` call to continue the same research.`
2238
- const resultOutput = `${output}\n\n*Raven searched for ${elapsed}s — ${formatBytes(savedCandidate)} handled, ${formatTokens(savedCandidate)} tokens*\n\n${continuation}`
2239
- addBytes(Math.max(0, savedCandidate - resultOutput.length))
2240
- delete ravenProviderIssues.seek
2241
-
2242
- return { title: "Raven Seek", metadata: { sessionId }, output: resultOutput }
2243
- } finally {
2244
- context.abort.removeEventListener("abort", abortPrompt)
2245
- if (!promptCompleted) abortPrompt()
2246
- if (sessionAbort) await sessionAbort
2247
- if (timer) clearTimeout(timer)
2248
- clearRavenWaiters(sessionId)
2249
- activeRavenPrompts.delete(activeSessionId)
2250
- }
2251
- } catch (err: any) {
2252
- const elapsed = ((Date.now() - started) / 1000).toFixed(1)
2253
- const msg = String(err?.message ?? err ?? "").toLowerCase()
2254
- const missingSession = /session.*not found|not found.*session/i.test(msg)
2255
- const hint =
2256
- context.abort.aborted ? "Raven search cancelled."
2257
- : /rate.?limit|too many requests|429/i.test(msg) ? "Raven rate limited — wait 30s then retry with a narrower query."
2258
- : /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."
2259
- : /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."
2260
- : /token|context.?length|too large|too long/i.test(msg) ? "Raven query too large — shorten your query and retry."
2261
- : missingSession ? "The Raven session is no longer available — retry without sessionId to start a new Raven search."
2262
- : /model|unavailable|down|not found/i.test(msg) ? "Raven model unavailable — retry later, or proceed without search."
2263
- : /did not produce an initial response|retry dialog/i.test(msg) ? err.message
2264
- : /timeout|timed.?out|session kept/i.test(msg) ? err.message
2265
- : `Raven search failed. Proceed without search — note gaps for the user. [${err.message || err}]`
2266
- if (!missingSession && /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)) {
2267
- ravenProviderIssues.seek = hint
2268
- }
2269
- const recovery = ravenProviderIssues.seek ? `\n\n${ravenRecoveryInstructions("seek")}` : ""
2270
- const continuation = sessionId && !missingSession
2271
- ? `\n\nRaven session: \`${sessionId}\`. Pass this as \`sessionId\` in a later \`raven_seek\` call to retry or continue.`
2272
- : ""
2273
- return { title: "Raven Seek", metadata: sessionId ? { sessionId } : undefined, output: `${hint}${recovery}\n\n*Attempt took ${elapsed}s*${continuation}` }
2274
- }
2275
- },
2276
- }),
2277
- "raven_mcp": tool({
2278
- description: "On-demand MCP information retrieval only. Creates or resumes a dedicated Raven MCP session that selects and calls MCPs configured under raven_mcp.onDemandMcpServers. Use for matching MCP capabilities, not local search, code review, auditing, implementation, planning, or general task delegation.",
2279
- args: {
2280
- query: tool.schema.string().describe("The information to retrieve and, when known, the target MCP server or capability. The calling agent retains analysis and judgment."),
2281
- sessionId: tool.schema.string().optional().describe("Raven MCP session ID returned by an earlier raven_mcp call. Provide it only to continue that MCP retrieval context."),
2282
- },
2283
- async execute(args, context) {
2284
- return executeRavenDelegation("mcp", args, context)
2285
- },
2286
- }),
2287
- "raven_mcp_bridge": tool({
2288
- description: "Internal bridge for the Raven MCP agent. Other agents must not call this tool. Lists and calls tools, resources, and prompts from configured on-demand MCP servers without globally exposing their schemas.",
2289
- args: {
2290
- server: tool.schema.string().describe("On-demand MCP server name from Raven config, e.g. context7, exa, grep_app, unityMCP."),
2291
- operation: tool.schema.enum(["list_tools", "call_tool", "list_resources", "read_resource", "list_prompts", "get_prompt"]).describe("MCP operation to perform."),
2292
- name: tool.schema.string().optional().describe("Tool name for call_tool, resource URI for read_resource, or prompt name for get_prompt."),
2293
- argumentsJson: tool.schema.string().optional().describe("JSON object string with arguments for call_tool or get_prompt, e.g. {\"query\":\"latest AI news\"}. Omit for no arguments."),
2294
- },
2295
- async execute(args, context) {
2296
- if (ravenSessionKinds.get(context.sessionID) !== "mcp" && sessionAgents.get(context.sessionID) !== "raven-mcp") {
2297
- return { title: "Raven MCP Bridge", output: "raven_mcp_bridge is only available inside the Raven MCP agent. Use raven_mcp for on-demand MCP requests." }
2298
- }
2299
-
2300
- const operation = args.operation as RavenMcpOperation
2301
- const server = getOnDemandMcpServer(args.server)?.[1]
2302
- const timeout = server ? mcpTimeoutMs(server) : (config.raven_mcp.timeout ?? 600) * 1000
2303
- const requestOptions = { signal: context.abort, timeout }
2304
- let connected = false
2305
- let connection: McpConnection | undefined
2306
- try {
2307
- connection = await getMcpConnection(context.sessionID, args.server, context.abort)
2308
- connected = true
2309
- connection.active++
2310
- connection.lastUsed = Date.now()
2311
- const client = connection.client
2312
-
2313
- if (operation === "list_tools") {
2314
- const tools = await listAllMcpTools(client, requestOptions)
2315
- const match = getOnDemandMcpServer(args.server)
2316
- if (match) {
2317
- const [name] = match
2318
- const generatedTools = generatedToolsFromMcpTools(tools)
2319
- mcpMetadata[name] = {
2320
- autoGeneratedTools: generatedTools,
2321
- autoGeneratedDescription: mcpMetadata[name]?.autoGeneratedDescription || synthesizeMcpDescription(name, generatedTools),
2322
- avoidedSchemaBytes: estimateAvoidedMcpSchemaBytes(tools),
2323
- status: "ready",
2324
- lastCheckedAt: new Date().toISOString(),
2325
- }
2326
- saveMcpMetadata(mcpMetadata)
2327
- saveDynamicGuidance()
2328
- }
2329
- return { title: `Raven MCP: ${args.server}`, output: formatMcpToolsForRaven(args.server, tools) }
2330
- }
2331
-
2332
- if (operation === "call_tool") {
2333
- if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "call_tool requires name." }
2334
- const result = await client.callTool({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }, undefined, requestOptions)
2335
- setOnDemandMcpHealth(args.server, "ready")
2336
- return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
2337
- }
2338
-
2339
- if (operation === "list_resources") {
2340
- const resources = await listAllMcpResources(client, requestOptions)
2341
- setOnDemandMcpHealth(args.server, "ready")
2342
- return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Resources for ${args.server}`, resources, ["uri", "name", "description"]) }
2343
- }
2344
-
2345
- if (operation === "read_resource") {
2346
- if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "read_resource requires name as the resource URI." }
2347
- const result = await client.readResource({ uri: args.name }, requestOptions)
2348
- setOnDemandMcpHealth(args.server, "ready")
2349
- return { title: `Raven MCP: ${args.server}`, output: formatMcpResult(result) }
2350
- }
2351
-
2352
- if (operation === "list_prompts") {
2353
- const prompts = await listAllMcpPrompts(client, requestOptions)
2354
- setOnDemandMcpHealth(args.server, "ready")
2355
- return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Prompts for ${args.server}`, prompts) }
2356
- }
2357
-
2358
- if (operation === "get_prompt") {
2359
- if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "get_prompt requires name." }
2360
- const result = await client.getPrompt({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }, requestOptions)
2361
- setOnDemandMcpHealth(args.server, "ready")
2362
- return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
2363
- }
2364
-
2365
- return { title: `Raven MCP: ${args.server}`, output: `Unsupported operation: ${operation}` }
2366
- } catch (err: any) {
2367
- if (!connected && !context.abort.aborted) {
2368
- const name = setOnDemandMcpHealth(args.server, "failed", err?.message ?? err)
2369
- if (name) await notifyIfMcpFailures([name])
2370
- }
2371
- return { title: `Raven MCP: ${args.server}`, output: `MCP call failed: ${err?.message ?? err}` }
2372
- } finally {
2373
- if (connection) {
2374
- connection.active = Math.max(0, connection.active - 1)
2375
- connection.lastUsed = Date.now()
2376
- }
2377
- }
2378
- },
2379
- }),
2380
- },
2381
-
2382
- // Track agent ↔ session mapping for allowlist + Raven exclusion
2383
- async "chat.message"(input: any, _output: any) {
2384
- if (input.agent) {
2385
- sessionAgents.set(input.sessionID, input.agent)
2386
- if (input.agent === "raven") {
2387
- ravenSessions.add(input.sessionID)
2388
- ravenSessionKinds.set(input.sessionID, "seek")
2389
- } else if (input.agent === "raven-mcp") {
2390
- ravenSessions.add(input.sessionID)
2391
- ravenSessionKinds.set(input.sessionID, "mcp")
2392
- }
2393
- }
2394
- },
2395
-
2396
- async event(input: { event: any }) {
2397
- // Track subagent session → parent mapping for accurate context counting
2398
- const evt = input.event
2399
- if (evt?.type === "session.created") {
2400
- const info = evt.properties?.info
2401
- if (info?.parentID && info?.id) ravenSessionParents.set(info.parentID, info.id)
2402
- }
2403
- if (evt?.type === "session.deleted") {
2404
- const sessionId = evt.properties?.info?.id
2405
- if (sessionId) {
2406
- ravenSessions.delete(sessionId)
2407
- ravenSessionKinds.delete(sessionId)
2408
- activeRavenPrompts.delete(sessionId)
2409
- sessionAgents.delete(sessionId)
2410
- clearRavenWaiters(sessionId)
2411
- for (const [parentId, childId] of ravenSessionParents) {
2412
- if (parentId === sessionId || childId === sessionId) ravenSessionParents.delete(parentId)
2413
- }
2414
- await closeMcpConnectionsForSession(sessionId)
2415
- }
2416
- }
2417
- if (evt?.type === "message.updated") {
2418
- const info = evt.properties?.info
2419
- if (info?.role === "assistant" && info?.sessionID && info.error && ravenSessions.has(info.sessionID)) {
2420
- reportRavenProviderError(info.sessionID, info.error)
2421
- }
2422
- }
2423
- if (evt?.type === "message.part.updated") {
2424
- const part = evt.properties?.part
2425
- if (part?.sessionID && ravenSessions.has(part.sessionID)) {
2426
- if (part.type === "retry" && part.error) {
2427
- reportRavenProviderError(part.sessionID, part.error)
2428
- } else {
2429
- reportRavenProgress(part.sessionID)
2430
- }
2431
- }
2432
- }
2433
- if (evt?.type === "session.status") {
2434
- const sessionId = evt.properties?.sessionID
2435
- const status = evt.properties?.status
2436
- if (sessionId && ravenSessions.has(sessionId)) {
2437
- if (status?.type === "retry") {
2438
- reportRavenProviderError(sessionId, { name: "RetryError", data: { message: status.message } })
2439
- }
2440
- }
2441
- }
2442
- if (evt?.type === "session.error") {
2443
- const sessionId = evt.properties?.sessionID
2444
- if (sessionId && evt.properties?.error && ravenSessions.has(sessionId)) {
2445
- reportRavenProviderError(sessionId, evt.properties.error)
2446
- }
2447
- }
2448
-
2449
- await refreshModelsIfPending()
2450
-
2451
- if (updateToastPending) {
2452
- updateToastPending = false
2453
- setTimeout(() => void notifyIfUpdateAvailable(), 500)
2454
- }
2455
- if (mcpFailureToastPending) {
2456
- mcpFailureToastPending = false
2457
- void scheduleOnDemandMcpRefresh().then((result) => notifyIfMcpFailures(result.failed))
2458
- }
2459
- if (duplicateMcpToastPending) {
2460
- duplicateMcpToastPending = false
2461
- setTimeout(() => void notifyIfDuplicateMcps(), 700)
2462
- }
2463
- if (modelSetupToastPending) {
2464
- modelSetupToastPending = false
2465
- setTimeout(() => void notifyIfModelSetupRequired(), 300)
2466
- }
2467
- },
2468
-
2469
- // /raven help|route|seek ...|mcp ...|update|stats|status
2470
- async "command.execute.before"(input: any, output: any) {
2471
- if (input.command !== "raven") return
2472
- output.parts.length = 0
2473
- const raw = input.arguments.trim()
2474
- const arg = raw.toLowerCase()
2475
- if (!arg) await refreshModelsIfPending()
2476
-
2477
- if (arg === "help") {
2478
- output.parts.push({ type: "text", text: helpText() })
2479
- } else if (arg === "stats") {
2480
- output.parts.push({ type: "text", text: statsText() })
2481
- } else if (arg === "route") {
2482
- output.parts.push({ type: "text", text: `${routeSummary()}\n\nUsage:\n /raven route tool add <tool_name>\n /raven route tool remove <tool_name>\n /raven route keyword add <keyword>\n /raven route keyword remove <keyword>\n\nGlobal MCPs are auto-routed by detected OpenCode MCP server name.` })
2483
- } else if (arg.startsWith("route ")) {
2484
- const parts = raw.split(/\s+/)
2485
- const kind = parts[1]?.toLowerCase()
2486
- const action = parts[2]?.toLowerCase()
2487
- const name = parts.slice(3).join(" ").trim()
2488
- const key = kind === "tool" ? "routeTools" : kind === "keyword" ? "routeToolKeywords" : undefined
2489
-
2490
- if (!key || !["add", "remove", "rm"].includes(action) || !name) {
2491
- output.parts.push({ type: "text", text: "Usage:\n /raven route tool add <tool_name>\n /raven route tool remove <tool_name>\n /raven route keyword add <keyword>\n /raven route keyword remove <keyword>\n\nGlobal MCPs are auto-routed by detected OpenCode MCP server name." })
2492
- } else {
2493
- updateConfig((latest) => {
2494
- const values = uniqueStrings(latest.raven_seek[key])
2495
- const exists = values.some((value) => value.toLowerCase() === name.toLowerCase())
2496
- const next = action === "add"
2497
- ? exists ? values : [...values, name]
2498
- : values.filter((value) => value.toLowerCase() !== name.toLowerCase())
2499
- return { raven_seek: key === "routeTools" ? { routeTools: next } : { routeToolKeywords: next } }
2500
- })
2501
- output.parts.push({ type: "text", text: routeSummary() })
2502
- }
2503
- } else if (arg.startsWith("seek ")) {
2504
- const parts = raw.split(/\s+/)
2505
- const action = parts[1]?.toLowerCase()
2506
- const value = parts.slice(2).join(" ").trim()
2507
- if (action === "model" && value) {
2508
- if (value.toLowerCase() === "auto") {
2509
- updateConfig({ raven_seek: { model: undefined, autoSelectedModel: undefined } })
2510
- await ensureRavenModels()
2511
- } else {
2512
- updateConfig({ raven_seek: { model: value, autoSelectedModel: undefined } })
2513
- }
2514
- delete ravenProviderIssues.seek
2515
- output.parts.push({ type: "text", text: config.raven_seek.model
2516
- ? `Raven Search model set to: ${modelLabel(config.raven_seek)}\nRestart opencode for the change to take effect.`
2517
- : modelSetupInstructions("seek") })
2518
- } else if (action === "effort" && value) {
2519
- updateConfig({ raven_seek: { reasoning_effort: value } })
2520
- output.parts.push({ type: "text", text: `Raven Search reasoning effort set to: ${value}\nRestart opencode for the change to take effect.` })
2521
- } else if (action === "timeout") {
2522
- const seconds = Number(value)
2523
- if (!Number.isInteger(seconds) || seconds < 10 || seconds > MAX_TIMEOUT_SECONDS) {
2524
- output.parts.push({ type: "text", text: `Usage: /raven seek timeout <seconds>\nMust be between 10 and ${MAX_TIMEOUT_SECONDS}. Current: ${config.raven_seek.timeout ?? 600}s` })
2525
- } else {
2526
- updateConfig({ raven_seek: { timeout: seconds } })
2527
- output.parts.push({ type: "text", text: `Raven Search timeout set to ${seconds}s. Takes effect immediately.` })
2528
- }
2529
- } else {
2530
- output.parts.push({ type: "text", text: "Usage:\n /raven seek model <provider/model>\n /raven seek effort <value>\n /raven seek timeout <seconds>" })
2531
- }
2532
- } else if (arg === "mcp") {
2533
- output.parts.push({ type: "text", text: `${onDemandMcpStatus()}\n\nUsage:\n /raven mcp refresh [server_name]\n /raven mcp detail full\n /raven mcp detail minimized` })
2534
- } else if (arg.startsWith("mcp ")) {
2535
- const parts = raw.split(/\s+/)
2536
- const action = parts[1]?.toLowerCase()
2537
- if (action === "refresh") {
2538
- const serverName = parts.slice(2).join(" ").trim() || undefined
2539
- const result = await queueOnDemandMcpRefresh(true, serverName)
2540
- await notifyIfMcpFailures(result.failed)
2541
- output.parts.push({
2542
- type: "text",
2543
- text: `On-demand MCP refresh complete.\n Refreshed: ${result.refreshed.length ? result.refreshed.join(", ") : "none"}\n Failed: ${result.failed.length ? result.failed.join(", ") : "none"}`,
2544
- })
2545
- } else if (action === "detail") {
2546
- const detail = parts[2]?.toLowerCase()
2547
- if (detail !== "full" && detail !== "minimized") {
2548
- output.parts.push({ type: "text", text: `Usage: /raven mcp detail full|minimized\nCurrent: ${config.raven_mcp.descriptionDetail ?? "full"}` })
2549
- } else {
2550
- updateConfig({ raven_mcp: { descriptionDetail: detail } })
2551
- output.parts.push({ type: "text", text: `On-demand MCP guidance detail set to: ${detail}\nRestart opencode for startup instructions/tool descriptions to fully refresh.` })
2552
- }
2553
- } else if (action === "model" && parts.slice(2).join(" ").trim()) {
2554
- const model = parts.slice(2).join(" ").trim()
2555
- if (model.toLowerCase() === "auto") {
2556
- updateConfig({ raven_mcp: { model: undefined, autoSelectedModel: undefined } })
2557
- await ensureRavenModels()
2558
- } else {
2559
- updateConfig({ raven_mcp: { model, autoSelectedModel: undefined } })
2560
- }
2561
- delete ravenProviderIssues.mcp
2562
- output.parts.push({ type: "text", text: config.raven_mcp.model
2563
- ? `Raven MCP model set to: ${modelLabel(config.raven_mcp)}\nRestart opencode for the change to take effect.`
2564
- : modelSetupInstructions("mcp") })
2565
- } else if (action === "effort" && parts.slice(2).join(" ").trim()) {
2566
- const effort = parts.slice(2).join(" ").trim()
2567
- updateConfig({ raven_mcp: { reasoning_effort: effort } })
2568
- output.parts.push({ type: "text", text: `Raven MCP reasoning effort set to: ${effort}\nRestart opencode for the change to take effect.` })
2569
- } else if (action === "timeout") {
2570
- const seconds = Number(parts[2])
2571
- if (!Number.isInteger(seconds) || seconds < 10 || seconds > MAX_TIMEOUT_SECONDS) {
2572
- output.parts.push({ type: "text", text: `Usage: /raven mcp timeout <seconds>\nMust be between 10 and ${MAX_TIMEOUT_SECONDS}. Current: ${config.raven_mcp.timeout ?? 600}s` })
2573
- } else {
2574
- updateConfig({ raven_mcp: { timeout: seconds } })
2575
- output.parts.push({ type: "text", text: `Raven MCP timeout set to ${seconds}s. Takes effect immediately.` })
2576
- }
2577
- } else {
2578
- output.parts.push({ type: "text", text: "Usage:\n /raven mcp\n /raven mcp refresh [server_name]\n /raven mcp detail full|minimized\n /raven mcp model <provider/model>\n /raven mcp effort <value>\n /raven mcp timeout <seconds>" })
2579
- }
2580
- } else if (arg === "update") {
2581
- try {
2582
- const info = await refreshUpdateInfo()
2583
- if (!info.latest) {
2584
- output.parts.push({ type: "text", text: `Could not check npm for ${PACKAGE_NAME}. Try again later.\n\n${manualUpdateText()}` })
2585
- } else if (!info.available) {
2586
- output.parts.push({ type: "text", text: `Raven is up to date (${info.current}). Latest on npm: ${info.latest}.` })
2587
- } else {
2588
- const removed = clearPluginCache()
2589
- output.parts.push({ type: "text", text: `Raven update available: ${info.current} → ${info.latest}.\n\nCleared ${removed.length} opencode plugin cache entr${removed.length === 1 ? "y" : "ies"}. ${manualUpdateText(info.latest)}` })
2590
- }
2591
- } catch (err: any) {
2592
- output.parts.push({ type: "text", text: `Raven update check failed: ${err?.message ?? err}\n\n${manualUpdateText()}` })
2593
- }
2594
- } else {
2595
- const seek = config.raven_seek
2596
- const mcp = config.raven_mcp
2597
- const seekIssue = ravenProviderIssues.seek ? `\n Provider issue: ${ravenProviderIssues.seek}` : ""
2598
- const mcpIssue = ravenProviderIssues.mcp ? `\n Provider issue: ${ravenProviderIssues.mcp}` : ""
2599
- let update = "Update: unable to check npm."
2600
- try {
2601
- const info = await getUpdateInfo()
2602
- update = info.available && info.latest
2603
- ? `Update: ${info.latest} available. Run /raven update, then restart opencode.`
2604
- : info.latest
2605
- ? `Update: up to date (latest ${info.latest}).`
2606
- : "Update: unable to check npm."
2607
- } catch { /* keep fallback */ }
2608
- const setup = modelSetupInstructions()
2609
- output.parts.push({ type: "text", text: `Raven version: ${PACKAGE_VERSION}.\nSearch agent: ${modelLabel(seek)}. Reasoning: ${seek.reasoning_effort || fm.reasoning_effort || "(default)"}. Timeout: ${seek.timeout ?? 600}s.${seekIssue}\nMCP agent: ${modelLabel(mcp)}. Reasoning: ${mcp.reasoning_effort || mcpFm.reasoning_effort || "(default)"}. Timeout: ${mcp.timeout ?? 600}s.${mcpIssue}\n${update}${setup ? `\n\n${setup}` : ""}\n\n${routeSummary()}\n\n${mcpSummary()}\n\n${statsText()}\n\nRun /raven help for commands.` })
2610
- }
2611
- },
2612
-
2613
- async "tool.execute.before"(input: any, output: any) {
2614
- if ((input.tool === "raven_seek" || input.tool === "raven_mcp") && ravenSessions.has(input.sessionID)) {
2615
- throw new Error(`${input.tool} cannot be called from inside a Raven agent. Use that agent's direct tools instead.`)
2616
- }
2617
-
2618
- if (input.tool === "raven_mcp_bridge" && ravenSessionKinds.get(input.sessionID) !== "mcp" && sessionAgents.get(input.sessionID) !== "raven-mcp") {
2619
- throw new Error("raven_mcp_bridge is only available inside the Raven MCP agent. Use raven_mcp for on-demand MCP requests.")
2620
- }
2621
-
2622
- if (ravenSessions.has(input.sessionID)) return
2623
- if (isExcluded(sessionAgents.get(input.sessionID))) return
2624
- if (config.raven_seek.excludeTools?.some((name) => name.toLowerCase() === input.tool.toLowerCase())) return
2625
-
2626
- if (input.tool === "task" && output.args?.subagent_type === "raven") {
2627
- ravenTaskCalls.add(input.callID)
2628
- }
2629
-
2630
- // ── Block routed tools/MCPs for non-Raven agents ──
2631
- const shouldRouteTool = input.tool === "bash" ? false : isRouteConfigured(input.tool)
2632
- const isSearchBashCmd = isRouteConfigured("bash") && isSearchBash(input.tool, output.args || input.args)
2633
-
2634
- if (shouldRouteTool || isSearchBashCmd) {
2635
- const args = compactArgs(output.args || input.args)
2636
- if (output.args && typeof output.args === "object") output.args = args
2637
- throw new Error(rerouteMessage(input.tool, args))
2638
- }
2639
- },
2640
-
2641
- async "tool.execute.after"(input: any, output: any) {
2642
- if (ravenTaskCalls.has(input.callID)) {
2643
- ravenTaskCalls.delete(input.callID)
2644
- // Try task metadata first (built-in tools preserve metadata)
2645
- const ravenSessionId = output.metadata?.sessionId ?? ravenSessionParents.get(input.sessionID)
2646
- if (ravenSessionId) {
2647
- if (ravenSessionParents.has(input.sessionID)) ravenSessionParents.delete(input.sessionID)
2648
- void countRavenSavedCandidateBytes(ravenSessionId)
2649
- .then((total) => {
2650
- const saved = Math.max(0, total - String(output.output ?? "").length)
2651
- if (saved > 0) addBytes(saved)
2652
- })
2653
- .catch(() => {
2654
- // Without token metadata we cannot separate saved context from the compact answer.
2655
- })
2656
- }
2657
- }
2658
- },
2659
- }
2660
- }) satisfies Plugin
14
+ } satisfies V2Plugin.Plugin & { server: V1Plugin }