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