opencode-raven 2.3.0 → 2.3.2
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 +95 -76
- package/README.md +25 -1
- package/background.ts +144 -0
- package/package.json +27 -20
- package/raven-core.ts +1715 -1692
- package/rpc.ts +45 -27
- package/tui.ts +105 -36
- package/v2.ts +355 -320
package/raven-core.ts
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
import type { PluginInput } from "@opencode-ai/plugin"
|
|
2
|
-
import type { tool as defineTool } from "@opencode-ai/plugin"
|
|
3
|
-
import { z } from "zod"
|
|
1
|
+
import type { PluginInput } from "@opencode-ai/plugin"
|
|
2
|
+
import type { tool as defineTool } from "@opencode-ai/plugin"
|
|
3
|
+
import { z } from "zod"
|
|
4
4
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
|
5
5
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
|
6
6
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
|
7
7
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
8
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"
|
|
9
9
|
import { join } from "node:path"
|
|
10
10
|
import { homedir, tmpdir } from "node:os"
|
|
11
11
|
|
|
12
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")
|
|
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
21
|
const MCP_GUIDANCE_MD = join(PKG_DIR, "mcp-guidance.md")
|
|
22
22
|
const PACKAGE_JSON = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf-8"))
|
|
23
23
|
const PACKAGE_NAME = PACKAGE_JSON.name || "opencode-raven"
|
|
@@ -68,10 +68,10 @@ const DEFAULT_ON_DEMAND_MCP_METADATA: Record<string, OnDemandMcpMetadata> = {
|
|
|
68
68
|
},
|
|
69
69
|
}
|
|
70
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)
|
|
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
75
|
|
|
76
76
|
// ── Bash commands that look like search workarounds ──
|
|
77
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
|
|
@@ -88,49 +88,49 @@ function stripShellComments(cmd: string): string {
|
|
|
88
88
|
.join("\n")
|
|
89
89
|
}
|
|
90
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
|
-
}
|
|
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
134
|
|
|
135
135
|
function splitPipelineSegments(cmd: string): string[] {
|
|
136
136
|
const segments: string[] = []
|
|
@@ -159,16 +159,16 @@ function hasSearchAfterCommandSeparator(segment: string): boolean {
|
|
|
159
159
|
return separator !== -1 && SEARCH_BASH_RE.test(segment.slice(separator))
|
|
160
160
|
}
|
|
161
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) => {
|
|
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
172
|
// Allow bounded filters over output already produced by the previous command:
|
|
173
173
|
// pacman -Qi libarchive | head -15
|
|
174
174
|
// 7z i | grep -i udf
|
|
@@ -178,10 +178,10 @@ function commandLooksLikeSearch(cmd: string): boolean {
|
|
|
178
178
|
})
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
function isSearchBash(tool: string, args: any): boolean {
|
|
182
|
-
if (tool !== "bash" && tool !== "shell") return false
|
|
183
|
-
return commandLooksLikeSearch(String(args?.command ?? ""))
|
|
184
|
-
}
|
|
181
|
+
function isSearchBash(tool: string, args: any): boolean {
|
|
182
|
+
if (tool !== "bash" && tool !== "shell") return false
|
|
183
|
+
return commandLooksLikeSearch(String(args?.command ?? ""))
|
|
184
|
+
}
|
|
185
185
|
|
|
186
186
|
// ── Config file shape ──
|
|
187
187
|
interface OnDemandMcpToolInfo {
|
|
@@ -204,11 +204,11 @@ interface OnDemandMcpMetadata {
|
|
|
204
204
|
lastCheckedAt?: string
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
interface RemoteOnDemandMcpServer extends BaseOnDemandMcpServer {
|
|
208
|
-
type: "remote"
|
|
209
|
-
url: string
|
|
210
|
-
headers?: Record<string, string>
|
|
211
|
-
}
|
|
207
|
+
interface RemoteOnDemandMcpServer extends BaseOnDemandMcpServer {
|
|
208
|
+
type: "remote"
|
|
209
|
+
url: string
|
|
210
|
+
headers?: Record<string, string>
|
|
211
|
+
}
|
|
212
212
|
|
|
213
213
|
interface LocalOnDemandMcpServer extends BaseOnDemandMcpServer {
|
|
214
214
|
type: "local"
|
|
@@ -221,60 +221,60 @@ type OnDemandMcpServer = RemoteOnDemandMcpServer | LocalOnDemandMcpServer
|
|
|
221
221
|
type OnDemandMcpDescriptionDetail = "full" | "minimized"
|
|
222
222
|
type RavenMcpOperation = "list_tools" | "call_tool" | "list_resources" | "read_resource" | "list_prompts" | "get_prompt"
|
|
223
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
|
-
}
|
|
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
251
|
|
|
252
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
|
-
}
|
|
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
278
|
|
|
279
279
|
function parseRavenMd(raw: string): { frontmatter: Record<string, any>; prompt: string } {
|
|
280
280
|
const parts = raw.split("---")
|
|
@@ -386,19 +386,19 @@ function normalizeStringRecord(value: unknown): Record<string, string> | undefin
|
|
|
386
386
|
|
|
387
387
|
function normalizeOnDemandMcpServer(value: unknown): OnDemandMcpServer | undefined {
|
|
388
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
|
-
}
|
|
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
397
|
|
|
398
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
|
-
: []
|
|
399
|
+
const commandParts = Array.isArray(source.command)
|
|
400
|
+
? source.command.filter((arg: unknown): arg is string => typeof arg === "string" && !!arg.trim())
|
|
401
|
+
: []
|
|
402
402
|
if (!commandParts.length) return undefined
|
|
403
403
|
const environment = normalizeStringRecord(source.environment)
|
|
404
404
|
return {
|
|
@@ -415,10 +415,10 @@ function normalizeOnDemandMcpServer(value: unknown): OnDemandMcpServer | undefin
|
|
|
415
415
|
const headers = normalizeStringRecord(source.headers)
|
|
416
416
|
return {
|
|
417
417
|
...base,
|
|
418
|
-
type: "remote",
|
|
419
|
-
url: source.url.trim(),
|
|
420
|
-
...(headers ? { headers } : {}),
|
|
421
|
-
}
|
|
418
|
+
type: "remote",
|
|
419
|
+
url: source.url.trim(),
|
|
420
|
+
...(headers ? { headers } : {}),
|
|
421
|
+
}
|
|
422
422
|
}
|
|
423
423
|
|
|
424
424
|
return undefined
|
|
@@ -447,7 +447,7 @@ function normalizeOnDemandMcpMetadata(value: unknown, fallback: Record<string, O
|
|
|
447
447
|
return result
|
|
448
448
|
}
|
|
449
449
|
|
|
450
|
-
function normalizeOnDemandMcpServers(value: unknown, fallback: Record<string, OnDemandMcpServer> = {}): Record<string, OnDemandMcpServer> {
|
|
450
|
+
function normalizeOnDemandMcpServers(value: unknown, fallback: Record<string, OnDemandMcpServer> = {}): Record<string, OnDemandMcpServer> {
|
|
451
451
|
const source = value && typeof value === "object" ? value as Record<string, unknown> : fallback
|
|
452
452
|
const result: Record<string, OnDemandMcpServer> = {}
|
|
453
453
|
for (const [key, raw] of Object.entries(source)) {
|
|
@@ -459,96 +459,96 @@ function normalizeOnDemandMcpServers(value: unknown, fallback: Record<string, On
|
|
|
459
459
|
}
|
|
460
460
|
|
|
461
461
|
// ── Plugin ──
|
|
462
|
-
export default (async (input: Pick<PluginInput, "client" | "directory"> & { project: Pick<PluginInput["project"], "id"> }, host: "v1" | "v2" = "v1") => {
|
|
462
|
+
export default (async (input: Pick<PluginInput, "client" | "directory"> & { project: Pick<PluginInput["project"], "id"> }, host: "v1" | "v2" = "v1") => {
|
|
463
463
|
const client = input.client
|
|
464
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
|
|
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
515
|
|
|
516
516
|
return normalized
|
|
517
517
|
}
|
|
518
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)
|
|
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
545
|
try {
|
|
546
546
|
if (existsSync(mcpMetadataFile)) {
|
|
547
547
|
return { ...defaults, ...normalizeOnDemandMcpMetadata(JSON.parse(readFileSync(mcpMetadataFile, "utf-8"))) }
|
|
548
548
|
}
|
|
549
|
-
} catch { /* ignore corruption, use defaults */ }
|
|
550
|
-
saveMcpMetadata(defaults)
|
|
551
|
-
return defaults
|
|
549
|
+
} catch { /* ignore corruption, use defaults */ }
|
|
550
|
+
saveMcpMetadata(defaults)
|
|
551
|
+
return defaults
|
|
552
552
|
}
|
|
553
553
|
|
|
554
554
|
function saveMcpMetadata(metadata: Record<string, OnDemandMcpMetadata>) {
|
|
@@ -558,154 +558,154 @@ export default (async (input: Pick<PluginInput, "client" | "directory"> & { proj
|
|
|
558
558
|
} catch { /* non-fatal: generated metadata can be refreshed */ }
|
|
559
559
|
}
|
|
560
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>()
|
|
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
709
|
const sessionAgents = new Map<string, string>()
|
|
710
710
|
const ravenSessionParents = new Map<string, string>()
|
|
711
711
|
const ravenErrorWaiters = new Map<string, (error: Error) => void>()
|
|
@@ -716,28 +716,30 @@ export default (async (input: Pick<PluginInput, "client" | "directory"> & { proj
|
|
|
716
716
|
let updateCheckPromise: Promise<{ current: string; latest?: string; available: boolean }> | undefined
|
|
717
717
|
let updateToastPending = false
|
|
718
718
|
let mcpFailureToastPending = false
|
|
719
|
-
let duplicateMcpToastPending = false
|
|
720
|
-
let modelSetupToastPending = false
|
|
721
|
-
let mcpRefreshScheduled = false
|
|
722
|
-
let
|
|
723
|
-
let
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
719
|
+
let duplicateMcpToastPending = false
|
|
720
|
+
let modelSetupToastPending = false
|
|
721
|
+
let mcpRefreshScheduled = false
|
|
722
|
+
let mcpStartupRefreshComplete = false
|
|
723
|
+
let mcpRefreshPromise: Promise<{ refreshed: string[]; failed: string[] }> | undefined
|
|
724
|
+
let mcpRefreshQueue: Promise<unknown> = Promise.resolve()
|
|
725
|
+
const pendingMcpRefreshes = new Map<string, Promise<{ refreshed: string[]; failed: string[] }>>()
|
|
726
|
+
const mcpRefreshControllers = new Set<AbortController>()
|
|
727
|
+
let lastNotifiedMcpFailureKey = ""
|
|
728
|
+
const ravenProviderIssues: Partial<Record<DelegationKind, string>> = {}
|
|
729
|
+
|
|
730
|
+
async function refreshModelsIfPending() {
|
|
731
|
+
if (modelRefreshPromise) return modelRefreshPromise
|
|
732
|
+
if (!modelRefreshPending) return
|
|
733
|
+
modelRefreshPending = false
|
|
734
|
+
modelRefreshPromise = ensureRavenModels().then(() => {
|
|
735
|
+
modelSetupToastPending = !!modelSetupInstructions()
|
|
736
|
+
})
|
|
737
|
+
return modelRefreshPromise
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Replace generated instructions from older releases before OpenCode consumes them.
|
|
741
|
+
saveDynamicGuidance()
|
|
742
|
+
resetOnDemandMcpRuntimeStatuses()
|
|
741
743
|
|
|
742
744
|
function resetOnDemandMcpRuntimeStatuses() {
|
|
743
745
|
let changed = false
|
|
@@ -754,19 +756,19 @@ export default (async (input: Pick<PluginInput, "client" | "directory"> & { proj
|
|
|
754
756
|
}
|
|
755
757
|
|
|
756
758
|
// ── 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
|
|
759
|
+
function isExcluded(agent: string | undefined): boolean {
|
|
760
|
+
if (!agent || !config.raven_seek.excludeAgents?.length) return false
|
|
761
|
+
const lower = agent.toLowerCase()
|
|
762
|
+
return config.raven_seek.excludeAgents.some((a) => a.toLowerCase() === lower)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function isRouteConfigured(toolName: string): boolean {
|
|
766
|
+
if (!modelOperational("seek")) return false
|
|
765
767
|
const tool = toolName.toLowerCase()
|
|
766
768
|
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
|
|
769
|
+
const aliases = tool === "bash" || tool === "shell" ? ["bash", "shell"] : [tool]
|
|
770
|
+
if (config.raven_seek.routeTools?.some((name) => aliases.includes(name.toLowerCase()))) return true
|
|
771
|
+
if (config.raven_seek.routeToolKeywords?.some((keyword) => tool.includes(keyword.toLowerCase()))) return true
|
|
770
772
|
return globalMcpServers.some((server) => tool.startsWith(`${server.toLowerCase()}_`))
|
|
771
773
|
}
|
|
772
774
|
|
|
@@ -796,168 +798,169 @@ export default (async (input: Pick<PluginInput, "client" | "directory"> & { proj
|
|
|
796
798
|
return query.length > 300 ? `${query.slice(0, 297)}...` : query
|
|
797
799
|
}
|
|
798
800
|
|
|
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
|
-
}
|
|
801
|
+
function rerouteMessage(tool: string, args: any): string {
|
|
802
|
+
if (ravenProviderIssues.seek) {
|
|
803
|
+
return `Raven search is currently unavailable: ${ravenProviderIssues.seek}
|
|
804
|
+
|
|
805
|
+
${ravenRecoveryInstructions("seek")}`
|
|
806
|
+
}
|
|
805
807
|
return `The '${tool}' tool call is blocked by Raven. Your next tool call should be raven_seek(query="${attemptedQuery(tool, args).replace(/"/g, "'")}").`
|
|
806
808
|
}
|
|
807
809
|
|
|
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")}
|
|
810
|
+
function ravenRecoveryInstructions(kind: DelegationKind): string {
|
|
811
|
+
const command = kind === "mcp" ? "/raven mcp model <provider/model>" : "/raven seek model <provider/model>"
|
|
812
|
+
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.
|
|
813
|
+
|
|
814
|
+
Tell the user to switch Raven's model with:
|
|
815
|
+
${command}
|
|
816
|
+
|
|
817
|
+
Example:
|
|
818
|
+
${command.replace("<provider/model>", "openai/gpt-5.5")}
|
|
817
819
|
|
|
818
820
|
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
821
|
}
|
|
820
822
|
|
|
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)"
|
|
823
|
+
function routeSummary(): string {
|
|
824
|
+
if (!modelOperational("seek")) return "Raven routing: inactive until the configured Search model is loaded."
|
|
825
|
+
const tools = config.raven_seek.routeTools?.length ? config.raven_seek.routeTools.join(", ") : "(none)"
|
|
826
|
+
const mcps = globalMcpServers.length ? `${globalMcpServers.join(", ")} (auto-detected)` : "none detected"
|
|
827
|
+
const keywords = config.raven_seek.routeToolKeywords?.length ? config.raven_seek.routeToolKeywords.join(", ") : "(none)"
|
|
826
828
|
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
|
-
}
|
|
829
|
+
return `Raven routing:\n Routed tools: ${tools}\n Routed global MCPs: ${mcps}\n Routed tool keywords: ${keywords}${duplicates}`
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function modelSetupInstructions(kind?: DelegationKind): string {
|
|
833
|
+
const unavailable = [
|
|
834
|
+
...(!modelOperational("seek") && kind !== "mcp" ? ["seek"] : []),
|
|
835
|
+
...(!modelOperational("mcp") && kind !== "seek" ? ["mcp"] : []),
|
|
836
|
+
] as DelegationKind[]
|
|
837
|
+
if (!unavailable.length) return ""
|
|
838
|
+
const pendingRestart = unavailable.filter((item) => item === "seek" ? !!config.raven_seek.model : !!config.raven_mcp.model)
|
|
839
|
+
const missing = unavailable.filter((item) => item === "seek" ? !config.raven_seek.model : !config.raven_mcp.model)
|
|
840
|
+
const lines: string[] = []
|
|
841
|
+
if (pendingRestart.length) {
|
|
842
|
+
const models = pendingRestart.map((item) => `${item === "seek" ? "Search" : "MCP"}: ${item === "seek" ? modelLabel(config.raven_seek) : modelLabel(config.raven_mcp)}`)
|
|
843
|
+
lines.push(`Raven model update ready (${models.join(", ")}). Restart OpenCode to load it.`)
|
|
844
|
+
}
|
|
845
|
+
if (!missing.length) return lines.join("\n")
|
|
846
|
+
const reason = modelDiscoveryError
|
|
847
|
+
? `Automatic free-model detection failed: ${modelDiscoveryError}.`
|
|
848
|
+
: "No connected, zero-cost OpenCode model with tool support was detected."
|
|
849
|
+
const commands = missing.map((item) => item === "seek"
|
|
850
|
+
? " /raven seek model <provider/model>"
|
|
851
|
+
: " /raven mcp model <provider/model>")
|
|
852
|
+
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.`)
|
|
853
|
+
return lines.join("\n\n")
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function modelOperational(kind: DelegationKind): boolean {
|
|
857
|
+
const active = kind === "seek" ? activeSeekModel : activeMcpModel
|
|
858
|
+
const configured = kind === "seek" ? config.raven_seek.model : config.raven_mcp.model
|
|
859
|
+
return !!active && active === configured
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function modelLabel(section: RavenSeekConfig | RavenMcpConfig): string {
|
|
863
|
+
if (!section.model) return "(not configured)"
|
|
864
|
+
return section.autoSelectedModel === section.model ? `${section.model} (auto)` : section.model
|
|
865
|
+
}
|
|
864
866
|
|
|
865
867
|
function mcpSummary(): string {
|
|
866
868
|
const buckets = onDemandMcpStatusBuckets()
|
|
867
869
|
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"}`
|
|
870
|
+
if (!total) return `Raven on-demand MCPs (always behind Raven): none configured.\n Guidance detail: ${config.raven_mcp.descriptionDetail ?? "full"}`
|
|
869
871
|
const lines = [
|
|
870
872
|
"Raven on-demand MCPs (always behind Raven):",
|
|
871
873
|
` Loaded: ${buckets.loaded.length ? buckets.loaded.join(", ") : "none"}`,
|
|
872
874
|
` Failed: ${buckets.failed.length ? buckets.failed.join(", ") : "none"}`,
|
|
873
875
|
` Pending: ${buckets.pending.length ? buckets.pending.join(", ") : "none"}`,
|
|
874
|
-
` Guidance detail: ${config.raven_mcp.descriptionDetail ?? "full"}`,
|
|
876
|
+
` Guidance detail: ${config.raven_mcp.descriptionDetail ?? "full"}`,
|
|
875
877
|
]
|
|
876
878
|
return lines.join("\n")
|
|
877
879
|
}
|
|
878
880
|
|
|
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
|
-
|
|
888
|
-
const prompt =
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
.
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
const
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
const
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
if (!
|
|
942
|
-
if (!
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
const
|
|
946
|
-
|
|
947
|
-
const
|
|
948
|
-
const
|
|
949
|
-
const
|
|
950
|
-
const
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
.
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
881
|
+
function ravenAgentPrompt(): string {
|
|
882
|
+
const extra = config.raven_seek.instructions?.trim()
|
|
883
|
+
const prompt = ravenPrompt
|
|
884
|
+
return extra ? `${prompt}\n\nAdditional user instructions:\n${extra}` : prompt
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function ravenMcpAgentPrompt(): string {
|
|
888
|
+
const extra = config.raven_mcp.instructions?.trim()
|
|
889
|
+
// V2 supplies current capabilities through the session context hook.
|
|
890
|
+
const prompt = host === "v2" ? ravenMcpPrompt
|
|
891
|
+
: `${ravenMcpPrompt}\n\nOn-demand MCP guidance:\n${onDemandMcpGuidance("raven")}`
|
|
892
|
+
return extra ? `${prompt}\n\nAdditional user instructions:\n${extra}` : prompt
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function compactText(value: unknown): string {
|
|
896
|
+
return String(value ?? "").replace(/\s+/g, " ").trim()
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function truncateText(value: unknown, max = 240): string {
|
|
900
|
+
const text = compactText(value)
|
|
901
|
+
return text.length > max ? `${text.slice(0, Math.max(0, max - 3))}...` : text
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function dynamicGuidanceMcpOrder(): string[] | undefined {
|
|
905
|
+
try {
|
|
906
|
+
if (!existsSync(dynamicGuidanceFile)) return undefined
|
|
907
|
+
const names: string[] = []
|
|
908
|
+
for (const line of readFileSync(dynamicGuidanceFile, "utf-8").split("\n")) {
|
|
909
|
+
const name = line.match(/^-\s+([^:]+):/)?.[1]?.trim()
|
|
910
|
+
if (name && !names.includes(name)) names.push(name)
|
|
911
|
+
}
|
|
912
|
+
return names
|
|
913
|
+
} catch {
|
|
914
|
+
return undefined
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function orderedOnDemandMcpEntries(entries: [string, OnDemandMcpServer][], preferredOrder?: string[]): [string, OnDemandMcpServer][] {
|
|
919
|
+
if (!preferredOrder?.length) return entries
|
|
920
|
+
const order = new Map(preferredOrder.map((name, index) => [name.toLowerCase(), index]))
|
|
921
|
+
const existing = entries
|
|
922
|
+
.filter(([name]) => order.has(name.toLowerCase()))
|
|
923
|
+
.sort(([left], [right]) => order.get(left.toLowerCase())! - order.get(right.toLowerCase())!)
|
|
924
|
+
const added = entries.filter(([name]) => !order.has(name.toLowerCase()))
|
|
925
|
+
return [...existing, ...added]
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function firstSentenceText(value: unknown): string {
|
|
929
|
+
const text = compactText(value)
|
|
930
|
+
const period = text.indexOf(".")
|
|
931
|
+
return period === -1 ? text : text.slice(0, period + 1)
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function onDemandMcpGuidance(target: "delegate" | "raven" = "delegate", preferredOrder?: string[]): string {
|
|
935
|
+
const servers = config.raven_mcp.onDemandMcpServers ?? {}
|
|
936
|
+
const activeEntries = activeOnDemandMcpEntries()
|
|
937
|
+
const entries = orderedOnDemandMcpEntries(
|
|
938
|
+
target === "raven"
|
|
939
|
+
? activeEntries
|
|
940
|
+
: activeEntries.filter(([name]) => onDemandMcpRuntimeStatus(name) === "ready"),
|
|
941
|
+
preferredOrder,
|
|
942
|
+
)
|
|
943
|
+
if (!Object.keys(servers).length) return "No on-demand MCPs are configured."
|
|
944
|
+
if (!activeEntries.length) return "No enabled on-demand MCPs are configured."
|
|
945
|
+
if (!entries.length) return "No loaded on-demand MCPs are available."
|
|
946
|
+
|
|
947
|
+
const detail = config.raven_mcp.descriptionDetail ?? "full"
|
|
948
|
+
const lines = entries.map(([name, server]) => {
|
|
949
|
+
const metadata = mcpMetadata[name] ?? {}
|
|
950
|
+
const userDescription = firstSentenceText(server.description)
|
|
951
|
+
const generated = firstSentenceText(metadata.autoGeneratedDescription)
|
|
952
|
+
const parts = [userDescription, generated ? `Discovered capabilities: ${generated}` : ""].filter(Boolean)
|
|
953
|
+
const summary = parts.length ? parts.join(" ") : "Description discovery pending."
|
|
954
|
+
if (detail === "minimized" || !metadata.autoGeneratedTools?.length) return `- ${name}: ${summary}`
|
|
955
|
+
const tools = metadata.autoGeneratedTools
|
|
956
|
+
.map((item) => `${item.name}${item.description ? ` - ${firstSentenceText(item.description)}` : ""}`)
|
|
957
|
+
.join("; ")
|
|
958
|
+
return `- ${name}: ${summary} Tools: ${tools}`
|
|
959
|
+
})
|
|
960
|
+
if (target === "raven") {
|
|
961
|
+
return `Use raven_mcp_bridge for these on-demand MCP capabilities:\n${lines.join("\n")}`
|
|
959
962
|
}
|
|
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")}`
|
|
963
|
+
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
964
|
}
|
|
962
965
|
|
|
963
966
|
function resolveEnvPlaceholders(value: string): string {
|
|
@@ -1028,114 +1031,114 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1028
1031
|
ravenProgressWaiters.get(sessionId)?.()
|
|
1029
1032
|
}
|
|
1030
1033
|
|
|
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
|
-
}
|
|
1034
|
+
type McpConnection = {
|
|
1035
|
+
client: Client
|
|
1036
|
+
transport: any
|
|
1037
|
+
lastUsed: number
|
|
1038
|
+
active: number
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
type McpConnectionAttempt = {
|
|
1042
|
+
promise: Promise<McpConnection>
|
|
1043
|
+
controller: AbortController
|
|
1044
|
+
waiters: number
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const mcpConnections = new Map<string, McpConnection>()
|
|
1048
|
+
const mcpConnectionAttempts = new Map<string, McpConnectionAttempt>()
|
|
1049
|
+
let mcpIdleTimer: ReturnType<typeof setInterval> | undefined
|
|
1050
|
+
|
|
1051
|
+
function mcpTimeoutMs(server: OnDemandMcpServer): number {
|
|
1052
|
+
return server.timeout ?? (config.raven_mcp.timeout ?? 600) * 1000
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
async function closeMcpClient(client: Client, transport: any) {
|
|
1056
|
+
try {
|
|
1057
|
+
await transport?.close?.()
|
|
1058
|
+
} catch { /* best-effort */ }
|
|
1059
|
+
try {
|
|
1060
|
+
await client.close()
|
|
1061
|
+
} catch { /* best-effort */ }
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
async function connectMcpClient(client: Client, transport: any, timeout: number, label: string, signal?: AbortSignal) {
|
|
1065
|
+
const controller = new AbortController()
|
|
1066
|
+
let timedOut = false
|
|
1067
|
+
const abort = () => controller.abort(signal?.reason)
|
|
1068
|
+
if (signal?.aborted) abort()
|
|
1069
|
+
else signal?.addEventListener("abort", abort, { once: true })
|
|
1070
|
+
const timer = setTimeout(() => {
|
|
1071
|
+
timedOut = true
|
|
1072
|
+
controller.abort()
|
|
1073
|
+
}, timeout)
|
|
1074
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
1075
|
+
const fail = () => reject(new Error(timedOut ? `${label} timed out after ${Math.round(timeout / 1000)}s` : `${label} cancelled`))
|
|
1076
|
+
if (controller.signal.aborted) fail()
|
|
1077
|
+
else controller.signal.addEventListener("abort", fail, { once: true })
|
|
1078
|
+
})
|
|
1079
|
+
|
|
1080
|
+
try {
|
|
1081
|
+
await Promise.race([
|
|
1082
|
+
client.connect(transport, { signal: controller.signal, timeout }),
|
|
1083
|
+
aborted,
|
|
1084
|
+
])
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
await closeMcpClient(client, transport)
|
|
1087
|
+
throw error
|
|
1088
|
+
} finally {
|
|
1089
|
+
clearTimeout(timer)
|
|
1090
|
+
signal?.removeEventListener("abort", abort)
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
async function closeMcpConnection(connection: McpConnection) {
|
|
1095
|
+
await closeMcpClient(connection.client, connection.transport)
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function stopMcpIdleTimerIfUnused() {
|
|
1099
|
+
if (mcpConnections.size || mcpConnectionAttempts.size || !mcpIdleTimer) return
|
|
1100
|
+
clearInterval(mcpIdleTimer)
|
|
1101
|
+
mcpIdleTimer = undefined
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function ensureMcpIdleTimer() {
|
|
1105
|
+
if (mcpIdleTimer) return
|
|
1106
|
+
mcpIdleTimer = setInterval(() => closeIdleMcpConnections(), 60_000)
|
|
1107
|
+
;(mcpIdleTimer as any).unref?.()
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
async function closeMcpConnectionKey(key: string, expected?: McpConnection) {
|
|
1111
|
+
const existing = mcpConnections.get(key)
|
|
1112
|
+
if (!existing || (expected && existing !== expected)) return
|
|
1113
|
+
mcpConnections.delete(key)
|
|
1114
|
+
await closeMcpConnection(existing)
|
|
1115
|
+
stopMcpIdleTimerIfUnused()
|
|
1116
|
+
}
|
|
1114
1117
|
|
|
1115
1118
|
function closeIdleMcpConnections(maxAgeMs = 10 * 60 * 1000) {
|
|
1116
1119
|
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
|
-
}
|
|
1120
|
+
for (const [key, connection] of mcpConnections) {
|
|
1121
|
+
if (!connection.active && now - connection.lastUsed > maxAgeMs) {
|
|
1122
|
+
void closeMcpConnectionKey(key, connection)
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
async function closeMcpConnectionsForSession(sessionId: string) {
|
|
1128
|
+
const prefix = `${sessionId}:`
|
|
1129
|
+
const pending: Promise<McpConnection>[] = []
|
|
1130
|
+
for (const [key, attempt] of mcpConnectionAttempts) {
|
|
1131
|
+
if (!key.startsWith(prefix)) continue
|
|
1132
|
+
attempt.controller.abort()
|
|
1133
|
+
pending.push(attempt.promise)
|
|
1134
|
+
}
|
|
1135
|
+
await Promise.allSettled(pending)
|
|
1136
|
+
await Promise.all(
|
|
1137
|
+
[...mcpConnections.keys()]
|
|
1138
|
+
.filter((key) => key.startsWith(prefix))
|
|
1139
|
+
.map((key) => closeMcpConnectionKey(key)),
|
|
1140
|
+
)
|
|
1141
|
+
}
|
|
1139
1142
|
|
|
1140
1143
|
function mcpConnectionKey(sessionId: string, serverName: string): string {
|
|
1141
1144
|
return `${sessionId}:${serverName.toLowerCase()}`
|
|
@@ -1146,44 +1149,44 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1146
1149
|
return entries.find(([name]) => name.toLowerCase() === serverName.toLowerCase())
|
|
1147
1150
|
}
|
|
1148
1151
|
|
|
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") {
|
|
1152
|
+
function activeOnDemandMcpEntries(): [string, OnDemandMcpServer][] {
|
|
1153
|
+
return Object.entries(config.raven_mcp.onDemandMcpServers ?? {}).filter(([, server]) => server.enabled !== false)
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function waitForMcpConnection(attempt: McpConnectionAttempt, signal?: AbortSignal): Promise<McpConnection> {
|
|
1157
|
+
if (!signal) {
|
|
1158
|
+
attempt.waiters++
|
|
1159
|
+
return attempt.promise.finally(() => attempt.waiters--)
|
|
1160
|
+
}
|
|
1161
|
+
if (signal.aborted) return Promise.reject(new Error("MCP request cancelled"))
|
|
1162
|
+
attempt.waiters++
|
|
1163
|
+
return new Promise((resolve, reject) => {
|
|
1164
|
+
let settled = false
|
|
1165
|
+
const finish = () => {
|
|
1166
|
+
if (settled) return false
|
|
1167
|
+
settled = true
|
|
1168
|
+
attempt.waiters--
|
|
1169
|
+
signal.removeEventListener("abort", abort)
|
|
1170
|
+
return true
|
|
1171
|
+
}
|
|
1172
|
+
const abort = () => {
|
|
1173
|
+
if (!finish()) return
|
|
1174
|
+
if (!attempt.waiters) attempt.controller.abort(signal.reason)
|
|
1175
|
+
reject(new Error("MCP request cancelled"))
|
|
1176
|
+
}
|
|
1177
|
+
signal.addEventListener("abort", abort, { once: true })
|
|
1178
|
+
attempt.promise.then(
|
|
1179
|
+
(connection) => { if (finish()) resolve(connection) },
|
|
1180
|
+
(error) => { if (finish()) reject(error) },
|
|
1181
|
+
)
|
|
1182
|
+
})
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
async function connectOnDemandMcp(serverName: string, server: OnDemandMcpServer, signal?: AbortSignal): Promise<McpConnection> {
|
|
1186
|
+
const timeout = mcpTimeoutMs(server)
|
|
1187
|
+
const client = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
|
|
1188
|
+
|
|
1189
|
+
if (server.type === "local") {
|
|
1187
1190
|
const env = { ...cleanProcessEnv(), ...resolveStringRecord(server.environment) }
|
|
1188
1191
|
const transport = new StdioClientTransport({
|
|
1189
1192
|
command: resolveEnvPlaceholders(server.command[0]),
|
|
@@ -1191,114 +1194,114 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1191
1194
|
env,
|
|
1192
1195
|
stderr: "pipe",
|
|
1193
1196
|
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}`)
|
|
1197
|
+
})
|
|
1198
|
+
transport.stderr?.on("data", () => {})
|
|
1199
|
+
transport.stderr?.on("error", () => {})
|
|
1200
|
+
await connectMcpClient(client, transport, timeout, `Connecting to ${serverName}`, signal)
|
|
1201
|
+
return { client, transport, lastUsed: Date.now(), active: 0 }
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const url = new URL(resolveEnvPlaceholders(server.url))
|
|
1205
|
+
const headers = resolveStringRecord(server.headers)
|
|
1206
|
+
const transport = new StreamableHTTPClientTransport(url, headers ? { requestInit: { headers } } : undefined)
|
|
1207
|
+
try {
|
|
1208
|
+
await connectMcpClient(client, transport, timeout, `Connecting to ${serverName}`, signal)
|
|
1209
|
+
return { client, transport, lastUsed: Date.now(), active: 0 }
|
|
1210
|
+
} catch (err) {
|
|
1211
|
+
if (signal?.aborted) throw err
|
|
1212
|
+
const fallbackClient = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
|
|
1213
|
+
const fallback = new SSEClientTransport(url, headers ? { requestInit: { headers } } : undefined)
|
|
1214
|
+
try {
|
|
1215
|
+
await connectMcpClient(fallbackClient, fallback, timeout, `Connecting to ${serverName} via SSE`, signal)
|
|
1216
|
+
return { client: fallbackClient, transport: fallback, lastUsed: Date.now(), active: 0 }
|
|
1217
|
+
} catch (fallbackError) {
|
|
1218
|
+
throw fallbackError
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
async function getMcpConnection(sessionId: string, serverName: string, signal?: AbortSignal): Promise<McpConnection> {
|
|
1224
|
+
if (signal?.aborted) throw new Error("MCP request cancelled")
|
|
1225
|
+
closeIdleMcpConnections()
|
|
1226
|
+
const match = getOnDemandMcpServer(serverName)
|
|
1227
|
+
if (!match) throw new Error(`Unknown on-demand MCP server: ${serverName}`)
|
|
1225
1228
|
const [canonicalName, server] = match
|
|
1226
1229
|
const key = mcpConnectionKey(sessionId, canonicalName)
|
|
1227
1230
|
const existing = mcpConnections.get(key)
|
|
1228
1231
|
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
|
|
1232
|
+
existing.lastUsed = Date.now()
|
|
1233
|
+
return existing
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
const pending = mcpConnectionAttempts.get(key)
|
|
1237
|
+
if (pending && !pending.controller.signal.aborted) return waitForMcpConnection(pending, signal)
|
|
1238
|
+
if (pending) mcpConnectionAttempts.delete(key)
|
|
1239
|
+
|
|
1240
|
+
const controller = new AbortController()
|
|
1241
|
+
const attempt = { controller, waiters: 0 } as McpConnectionAttempt
|
|
1242
|
+
attempt.promise = connectOnDemandMcp(canonicalName, server, controller.signal)
|
|
1243
|
+
.then(async (connection) => {
|
|
1244
|
+
if (controller.signal.aborted || mcpConnectionAttempts.get(key) !== attempt) {
|
|
1245
|
+
await closeMcpConnection(connection)
|
|
1246
|
+
throw new Error("MCP connection attempt cancelled")
|
|
1247
|
+
}
|
|
1248
|
+
mcpConnections.set(key, connection)
|
|
1249
|
+
connection.client.onclose = () => {
|
|
1250
|
+
if (mcpConnections.get(key) === connection) mcpConnections.delete(key)
|
|
1251
|
+
stopMcpIdleTimerIfUnused()
|
|
1252
|
+
}
|
|
1253
|
+
ensureMcpIdleTimer()
|
|
1254
|
+
return connection
|
|
1255
|
+
})
|
|
1256
|
+
.finally(() => {
|
|
1257
|
+
if (mcpConnectionAttempts.get(key) === attempt) mcpConnectionAttempts.delete(key)
|
|
1258
|
+
stopMcpIdleTimerIfUnused()
|
|
1259
|
+
})
|
|
1260
|
+
mcpConnectionAttempts.set(key, attempt)
|
|
1261
|
+
ensureMcpIdleTimer()
|
|
1262
|
+
return waitForMcpConnection(attempt, signal)
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
async function listAllMcpTools(client: Client, options?: { signal?: AbortSignal; timeout?: number }): Promise<any[]> {
|
|
1266
|
+
const tools: any[] = []
|
|
1267
|
+
let cursor: string | undefined
|
|
1268
|
+
do {
|
|
1269
|
+
const result: any = await client.listTools(cursor ? { cursor } : undefined as any, options)
|
|
1270
|
+
tools.push(...(result.tools ?? []))
|
|
1271
|
+
cursor = result.nextCursor
|
|
1272
|
+
} while (cursor)
|
|
1273
|
+
return tools
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
async function listAllMcpResources(client: Client, options?: { signal?: AbortSignal; timeout?: number }): Promise<any[]> {
|
|
1277
|
+
const resources: any[] = []
|
|
1278
|
+
let cursor: string | undefined
|
|
1279
|
+
do {
|
|
1280
|
+
const result: any = await client.listResources(cursor ? { cursor } : undefined as any, options)
|
|
1281
|
+
resources.push(...(result.resources ?? []))
|
|
1282
|
+
cursor = result.nextCursor
|
|
1283
|
+
} while (cursor)
|
|
1284
|
+
return resources
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
async function listAllMcpPrompts(client: Client, options?: { signal?: AbortSignal; timeout?: number }): Promise<any[]> {
|
|
1288
|
+
const prompts: any[] = []
|
|
1289
|
+
let cursor: string | undefined
|
|
1290
|
+
do {
|
|
1291
|
+
const result: any = await client.listPrompts(cursor ? { cursor } : undefined as any, options)
|
|
1292
|
+
prompts.push(...(result.prompts ?? []))
|
|
1293
|
+
cursor = result.nextCursor
|
|
1291
1294
|
} while (cursor)
|
|
1292
1295
|
return prompts
|
|
1293
1296
|
}
|
|
1294
1297
|
|
|
1295
1298
|
function generatedToolsFromMcpTools(tools: any[]): OnDemandMcpToolInfo[] {
|
|
1296
1299
|
return tools
|
|
1297
|
-
.filter((item) => item?.name)
|
|
1298
|
-
.map((item) => ({
|
|
1299
|
-
name: String(item.name),
|
|
1300
|
-
...(item.description ? { description: compactText(item.description) } : {}),
|
|
1301
|
-
}))
|
|
1300
|
+
.filter((item) => item?.name)
|
|
1301
|
+
.map((item) => ({
|
|
1302
|
+
name: String(item.name),
|
|
1303
|
+
...(item.description ? { description: compactText(item.description) } : {}),
|
|
1304
|
+
}))
|
|
1302
1305
|
}
|
|
1303
1306
|
|
|
1304
1307
|
function estimateAvoidedMcpSchemaBytes(tools: any[]): number {
|
|
@@ -1307,15 +1310,15 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1307
1310
|
}
|
|
1308
1311
|
|
|
1309
1312
|
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()
|
|
1313
|
+
if (!tools.length) return `${serverName} exposes on-demand MCP capabilities. Use list_tools for available operations.`
|
|
1314
|
+
const descriptions = tools.map((item) => item.description || item.name).filter(Boolean)
|
|
1315
|
+
const joined = descriptions.slice(0, 6).join("; ")
|
|
1316
|
+
return compactText(`${serverName} provides on-demand MCP tools for ${joined}.`)
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
async function refreshOnDemandMcpDescriptions(force = false, serverName?: string, signal?: AbortSignal): Promise<{ refreshed: string[]; failed: string[] }> {
|
|
1320
|
+
if (signal?.aborted) return { refreshed: [], failed: [] }
|
|
1321
|
+
const entries = activeOnDemandMcpEntries()
|
|
1319
1322
|
.filter(([name]) => !serverName || name.toLowerCase() === serverName.toLowerCase())
|
|
1320
1323
|
.filter(([name]) => {
|
|
1321
1324
|
const metadata = mcpMetadata[name]
|
|
@@ -1324,14 +1327,14 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1324
1327
|
const refreshed: string[] = []
|
|
1325
1328
|
const failed: string[] = []
|
|
1326
1329
|
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) })
|
|
1330
|
+
let changed = false
|
|
1331
|
+
for (const [name, server] of entries) {
|
|
1332
|
+
if (signal?.aborted) break
|
|
1333
|
+
let connection: McpConnection | undefined
|
|
1334
|
+
const checkedAt = new Date().toISOString()
|
|
1335
|
+
try {
|
|
1336
|
+
connection = await connectOnDemandMcp(name, server, signal)
|
|
1337
|
+
const rawTools = await listAllMcpTools(connection.client, { signal, timeout: mcpTimeoutMs(server) })
|
|
1335
1338
|
const tools = generatedToolsFromMcpTools(rawTools)
|
|
1336
1339
|
mcpMetadata[name] = {
|
|
1337
1340
|
autoGeneratedTools: tools,
|
|
@@ -1340,11 +1343,11 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1340
1343
|
status: "ready",
|
|
1341
1344
|
lastCheckedAt: checkedAt,
|
|
1342
1345
|
}
|
|
1343
|
-
changed = true
|
|
1344
|
-
refreshed.push(name)
|
|
1345
|
-
} catch (err: any) {
|
|
1346
|
-
if (signal?.aborted) break
|
|
1347
|
-
mcpMetadata[name] = {
|
|
1346
|
+
changed = true
|
|
1347
|
+
refreshed.push(name)
|
|
1348
|
+
} catch (err: any) {
|
|
1349
|
+
if (signal?.aborted) break
|
|
1350
|
+
mcpMetadata[name] = {
|
|
1348
1351
|
...(mcpMetadata[name] ?? {}),
|
|
1349
1352
|
status: "failed",
|
|
1350
1353
|
lastError: truncateText(err?.message ?? err, 500),
|
|
@@ -1364,38 +1367,46 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1364
1367
|
return { refreshed, failed }
|
|
1365
1368
|
}
|
|
1366
1369
|
|
|
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
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
return
|
|
1370
|
+
function waitForRefreshDelay(ms: number, signal: AbortSignal): Promise<void> {
|
|
1371
|
+
if (signal.aborted) return Promise.resolve()
|
|
1372
|
+
return new Promise((resolve) => {
|
|
1373
|
+
const timer = setTimeout(done, ms)
|
|
1374
|
+
function done() {
|
|
1375
|
+
clearTimeout(timer)
|
|
1376
|
+
signal.removeEventListener("abort", done)
|
|
1377
|
+
resolve()
|
|
1378
|
+
}
|
|
1379
|
+
signal.addEventListener("abort", done, { once: true })
|
|
1380
|
+
})
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
function queueOnDemandMcpRefresh(force = false, serverName?: string, delay = 0): Promise<{ refreshed: string[]; failed: string[] }> {
|
|
1384
|
+
const key = `${force}:${serverName?.toLowerCase() ?? "*"}`
|
|
1385
|
+
const pending = pendingMcpRefreshes.get(key)
|
|
1386
|
+
if (pending) return pending
|
|
1387
|
+
const controller = new AbortController()
|
|
1388
|
+
mcpRefreshControllers.add(controller)
|
|
1389
|
+
const run = mcpRefreshQueue
|
|
1390
|
+
.catch(() => undefined)
|
|
1391
|
+
.then(async () => {
|
|
1392
|
+
if (delay) await waitForRefreshDelay(delay, controller.signal)
|
|
1393
|
+
return refreshOnDemandMcpDescriptions(force, serverName, controller.signal)
|
|
1394
|
+
})
|
|
1395
|
+
.finally(() => {
|
|
1396
|
+
mcpRefreshControllers.delete(controller)
|
|
1397
|
+
pendingMcpRefreshes.delete(key)
|
|
1398
|
+
})
|
|
1399
|
+
pendingMcpRefreshes.set(key, run)
|
|
1400
|
+
mcpRefreshQueue = run
|
|
1401
|
+
return run
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function scheduleOnDemandMcpRefresh(): Promise<{ refreshed: string[]; failed: string[] }> {
|
|
1405
|
+
if (mcpRefreshScheduled) return mcpRefreshPromise!
|
|
1406
|
+
mcpRefreshScheduled = true
|
|
1407
|
+
mcpRefreshPromise = queueOnDemandMcpRefresh(false, undefined, 1000)
|
|
1408
|
+
.finally(() => { mcpStartupRefreshComplete = true })
|
|
1409
|
+
return mcpRefreshPromise
|
|
1399
1410
|
}
|
|
1400
1411
|
|
|
1401
1412
|
function onDemandMcpRuntimeStatus(name: string): "pending" | "ready" | "failed" {
|
|
@@ -1444,7 +1455,7 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1444
1455
|
|
|
1445
1456
|
function onDemandMcpStatus(): string {
|
|
1446
1457
|
const entries = activeOnDemandMcpEntries()
|
|
1447
|
-
if (!Object.keys(config.raven_mcp.onDemandMcpServers ?? {}).length) return "Raven on-demand MCPs: none configured."
|
|
1458
|
+
if (!Object.keys(config.raven_mcp.onDemandMcpServers ?? {}).length) return "Raven on-demand MCPs: none configured."
|
|
1448
1459
|
if (!entries.length) return "Raven on-demand MCPs: none enabled."
|
|
1449
1460
|
const buckets = onDemandMcpStatusBuckets()
|
|
1450
1461
|
const lines = entries.map(([name, server]) => {
|
|
@@ -1457,7 +1468,7 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1457
1468
|
const error = status === "failed" && metadata?.lastError ? `, error: ${metadata.lastError}` : ""
|
|
1458
1469
|
return `- ${name} (${server.type}): ${displayMcpStatus(status)}, ${source}, ${generated}, ${tools}${avoided}${error}`
|
|
1459
1470
|
})
|
|
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")}`
|
|
1471
|
+
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
1472
|
}
|
|
1462
1473
|
|
|
1463
1474
|
function avoidedMcpSchemaBytes(): number {
|
|
@@ -1473,9 +1484,9 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1473
1484
|
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
1485
|
}
|
|
1475
1486
|
|
|
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
|
-
}
|
|
1487
|
+
function helpText(): string {
|
|
1488
|
+
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`
|
|
1489
|
+
}
|
|
1479
1490
|
|
|
1480
1491
|
function formatMcpList(title: string, items: any[], fields: string[] = ["name", "description"]): string {
|
|
1481
1492
|
if (!items.length) return `${title}: none found.`
|
|
@@ -1509,26 +1520,26 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1509
1520
|
return typeof result === "string" ? result : JSON.stringify(result, null, 2)
|
|
1510
1521
|
}
|
|
1511
1522
|
|
|
1512
|
-
function parseMcpArguments(raw: string | undefined): Record<string, any> {
|
|
1523
|
+
function parseMcpArguments(raw: string | undefined): Record<string, any> {
|
|
1513
1524
|
if (!raw?.trim()) return {}
|
|
1514
1525
|
const parsed = JSON.parse(raw)
|
|
1515
1526
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1516
1527
|
throw new Error("argumentsJson must be a JSON object")
|
|
1517
1528
|
}
|
|
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
|
-
}
|
|
1529
|
+
return parsed
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// ── Context saved by Raven delegation ──
|
|
1533
|
+
let sessionBytes = 0
|
|
1534
|
+
let totalBytes = config.stats?.bytes ?? 0
|
|
1535
|
+
|
|
1536
|
+
function addBytes(bytes: number) {
|
|
1537
|
+
sessionBytes += bytes
|
|
1538
|
+
const updated = updateConfig((latest) => ({
|
|
1539
|
+
stats: { bytes: Math.max(totalBytes, latest.stats?.bytes ?? 0) + bytes },
|
|
1540
|
+
}))
|
|
1541
|
+
totalBytes = updated.stats?.bytes ?? totalBytes + bytes
|
|
1542
|
+
}
|
|
1532
1543
|
|
|
1533
1544
|
function formatBytes(bytes: number): string {
|
|
1534
1545
|
return bytes >= 1_000_000 ? `${(bytes / 1_000_000).toFixed(1)}MB`
|
|
@@ -1576,10 +1587,10 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1576
1587
|
return { current: PACKAGE_VERSION, latest, available: !!latest && compareVersions(latest, PACKAGE_VERSION) > 0 }
|
|
1577
1588
|
}
|
|
1578
1589
|
|
|
1579
|
-
function estimateRavenSavedCandidateBytes(messages: any[]): number {
|
|
1580
|
-
const assistantMessages = messages.filter((m: any) =>
|
|
1581
|
-
m?.info?.role === "assistant" && m?.info?.tokens?.output > 0
|
|
1582
|
-
)
|
|
1590
|
+
function estimateRavenSavedCandidateBytes(messages: any[]): number {
|
|
1591
|
+
const assistantMessages = messages.filter((m: any) =>
|
|
1592
|
+
m?.info?.role === "assistant" && m?.info?.tokens?.output > 0
|
|
1593
|
+
)
|
|
1583
1594
|
const first = assistantMessages[0]
|
|
1584
1595
|
const last = assistantMessages[assistantMessages.length - 1]
|
|
1585
1596
|
const t = last?.info?.tokens
|
|
@@ -1591,15 +1602,15 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1591
1602
|
const baselineTokens = firstTokens
|
|
1592
1603
|
? (firstTokens.input ?? 0) + (firstTokens.cache?.read ?? 0) + (firstTokens.cache?.write ?? 0)
|
|
1593
1604
|
: 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
|
-
}
|
|
1605
|
+
const savedCandidateTokens = Math.max(0, totalTokens - baselineTokens)
|
|
1606
|
+
return savedCandidateTokens * 4
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
async function countRavenSavedCandidateBytes(sessionId: string, signal?: AbortSignal): Promise<number> {
|
|
1610
|
+
const messagesResp = await client.session.messages({ path: { id: sessionId }, signal })
|
|
1611
|
+
const messages = (messagesResp as any)?.data ?? []
|
|
1612
|
+
return estimateRavenSavedCandidateBytes(messages)
|
|
1613
|
+
}
|
|
1603
1614
|
|
|
1604
1615
|
async function getUpdateInfo(): Promise<{ current: string; latest?: string; available: boolean }> {
|
|
1605
1616
|
if (updateInfo) return updateInfo
|
|
@@ -1631,8 +1642,8 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1631
1642
|
return updateCheckPromise
|
|
1632
1643
|
}
|
|
1633
1644
|
|
|
1634
|
-
function clearPluginCache(): string[] {
|
|
1635
|
-
if (host === "v2") return []
|
|
1645
|
+
function clearPluginCache(): string[] {
|
|
1646
|
+
if (host === "v2") return []
|
|
1636
1647
|
const packagesDir = join(homedir(), ".cache", "opencode", "packages")
|
|
1637
1648
|
if (!existsSync(packagesDir)) return []
|
|
1638
1649
|
|
|
@@ -1646,8 +1657,8 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1646
1657
|
return removed
|
|
1647
1658
|
}
|
|
1648
1659
|
|
|
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.`
|
|
1660
|
+
function manualUpdateText(latest = "latest"): string {
|
|
1661
|
+
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
1662
|
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
1663
|
}
|
|
1653
1664
|
|
|
@@ -1666,13 +1677,13 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1666
1677
|
} catch { /* update checks are best-effort */ }
|
|
1667
1678
|
}
|
|
1668
1679
|
|
|
1669
|
-
async function notifyIfMcpFailures(names?: string[]) {
|
|
1670
|
-
try {
|
|
1671
|
-
const failed = failedOnDemandMcpNames(names)
|
|
1672
|
-
if (!failed.length) {
|
|
1673
|
-
lastNotifiedMcpFailureKey = ""
|
|
1674
|
-
return
|
|
1675
|
-
}
|
|
1680
|
+
async function notifyIfMcpFailures(names?: string[]) {
|
|
1681
|
+
try {
|
|
1682
|
+
const failed = failedOnDemandMcpNames(names)
|
|
1683
|
+
if (!failed.length) {
|
|
1684
|
+
lastNotifiedMcpFailureKey = ""
|
|
1685
|
+
return
|
|
1686
|
+
}
|
|
1676
1687
|
const key = failed.sort().join("|")
|
|
1677
1688
|
if (key === lastNotifiedMcpFailureKey) return
|
|
1678
1689
|
lastNotifiedMcpFailureKey = key
|
|
@@ -1688,7 +1699,7 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1688
1699
|
} catch { /* MCP failure notifications are best-effort */ }
|
|
1689
1700
|
}
|
|
1690
1701
|
|
|
1691
|
-
async function notifyIfDuplicateMcps() {
|
|
1702
|
+
async function notifyIfDuplicateMcps() {
|
|
1692
1703
|
try {
|
|
1693
1704
|
if (!duplicateMcpServers.length) return
|
|
1694
1705
|
const label = duplicateMcpServers.length === 1 ? duplicateMcpServers[0] : `${duplicateMcpServers.length} MCPs`
|
|
@@ -1700,317 +1711,330 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
1700
1711
|
duration: 12000,
|
|
1701
1712
|
},
|
|
1702
1713
|
})
|
|
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
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
}
|
|
1714
|
+
} catch { /* duplicate MCP notifications are best-effort */ }
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
async function notifyIfModelSetupRequired() {
|
|
1718
|
+
const message = modelSetupInstructions()
|
|
1719
|
+
if (!message) return
|
|
1720
|
+
const updateReady = (!!config.raven_seek.model && activeSeekModel !== config.raven_seek.model)
|
|
1721
|
+
|| (!!config.raven_mcp.model && activeMcpModel !== config.raven_mcp.model)
|
|
1722
|
+
try {
|
|
1723
|
+
await (client as any).tui?.showToast?.({
|
|
1724
|
+
body: {
|
|
1725
|
+
title: updateReady ? "Raven model selected" : "Raven first-time setup",
|
|
1726
|
+
message: updateReady
|
|
1727
|
+
? "A free Raven model was selected. Restart OpenCode to load it."
|
|
1728
|
+
: "No free Raven model was detected. Run /raven for setup instructions; routing is inactive until configured.",
|
|
1729
|
+
variant: updateReady ? "info" : "warning",
|
|
1730
|
+
duration: 15000,
|
|
1731
|
+
},
|
|
1732
|
+
})
|
|
1733
|
+
} catch { /* model setup remains visible in /raven */ }
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
function delegationAgent(kind: DelegationKind): "raven" | "raven-mcp" {
|
|
1737
|
+
return kind === "mcp" ? "raven-mcp" : "raven"
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
function delegationToolName(kind: DelegationKind): "raven_seek" | "raven_mcp" {
|
|
1741
|
+
return kind === "mcp" ? "raven_mcp" : "raven_seek"
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
function delegationTitle(kind: DelegationKind): string {
|
|
1745
|
+
return kind === "mcp" ? "Raven MCP" : "Raven Seek"
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
async function rootSessionId(sessionId: string, signal?: AbortSignal): Promise<string> {
|
|
1749
|
+
let current = sessionId
|
|
1750
|
+
const seen = new Set<string>()
|
|
1751
|
+
while (!seen.has(current)) {
|
|
1752
|
+
seen.add(current)
|
|
1753
|
+
const response = await client.session.get({ path: { id: current }, signal })
|
|
1754
|
+
const session = (response as any)?.data ?? response
|
|
1755
|
+
if (!session?.parentID) return current
|
|
1756
|
+
current = session.parentID
|
|
1757
|
+
}
|
|
1758
|
+
return sessionId
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
async function syncCreatedSession(sessionId: string, title: string, signal?: AbortSignal) {
|
|
1762
|
+
try {
|
|
1763
|
+
await client.session.update({
|
|
1764
|
+
path: { id: sessionId },
|
|
1765
|
+
body: { title },
|
|
1766
|
+
signal,
|
|
1767
|
+
})
|
|
1768
|
+
} catch (error) {
|
|
1769
|
+
if (signal?.aborted) throw error
|
|
1770
|
+
// Delegation still works if an older or disconnected TUI misses the sync event.
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
async function executeRavenDelegation(kind: DelegationKind, args: { query: string; sessionId?: string }, context: any) {
|
|
1775
|
+
const started = Date.now()
|
|
1776
|
+
const agent = delegationAgent(kind)
|
|
1777
|
+
const toolName = delegationToolName(kind)
|
|
1778
|
+
const title = delegationTitle(kind)
|
|
1779
|
+
await refreshModelsIfPending()
|
|
1780
|
+
if (!modelOperational(kind)) {
|
|
1781
|
+
return { title, output: modelSetupInstructions(kind) }
|
|
1782
|
+
}
|
|
1783
|
+
const timeout = (kind === "mcp" ? config.raven_mcp.timeout : config.raven_seek.timeout) ?? 600
|
|
1784
|
+
const timeoutMs = timeout * 1000
|
|
1785
|
+
let sessionId: string | undefined
|
|
1786
|
+
let resumed = false
|
|
1787
|
+
let previousSavedCandidate = 0
|
|
1788
|
+
|
|
1789
|
+
try {
|
|
1790
|
+
const requestedSessionId = args.sessionId?.trim()
|
|
1791
|
+
if (args.sessionId !== undefined && !requestedSessionId) {
|
|
1792
|
+
return { title, output: "Cannot resume Raven: sessionId must not be empty." }
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
let rootId = context.sessionID
|
|
1796
|
+
try {
|
|
1797
|
+
rootId = await rootSessionId(context.sessionID, context.abort)
|
|
1798
|
+
} catch (error) {
|
|
1799
|
+
if (context.abort.aborted) throw error
|
|
1800
|
+
// Falling back preserves delegation if an older OpenCode build cannot read the caller session.
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
if (requestedSessionId) {
|
|
1804
|
+
const knownKind = ravenSessionKinds.get(requestedSessionId)
|
|
1805
|
+
const knownRavenSession = knownKind === kind || sessionAgents.get(requestedSessionId) === agent
|
|
1806
|
+
let existingSession: any
|
|
1807
|
+
try {
|
|
1808
|
+
const sessionResp = await client.session.get({ path: { id: requestedSessionId }, signal: context.abort })
|
|
1809
|
+
existingSession = (sessionResp as any)?.data ?? sessionResp
|
|
1810
|
+
} catch (error) {
|
|
1811
|
+
if (context.abort.aborted) throw error
|
|
1812
|
+
return { title, output: `Cannot resume Raven session ${requestedSessionId}: the session was not found or could not be read.` }
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
if (!existingSession?.id || existingSession.id !== requestedSessionId) {
|
|
1816
|
+
return { title, output: `Cannot resume Raven session ${requestedSessionId}: the session was not found or could not be read.` }
|
|
1817
|
+
}
|
|
1818
|
+
const validParent = existingSession.parentID === rootId || existingSession.parentID === context.sessionID
|
|
1819
|
+
if (!validParent || existingSession.projectID !== input.project.id) {
|
|
1820
|
+
return { title, output: "Cannot resume Raven: sessionId is not part of this main session's Raven tree." }
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
let messages: any[]
|
|
1824
|
+
try {
|
|
1825
|
+
const messagesResp = await client.session.messages({ path: { id: requestedSessionId }, signal: context.abort })
|
|
1826
|
+
const messageData = (messagesResp as any)?.data
|
|
1827
|
+
if (!Array.isArray(messageData)) {
|
|
1828
|
+
return { title, output: `Cannot resume Raven session ${requestedSessionId}: its message history could not be read.` }
|
|
1829
|
+
}
|
|
1830
|
+
messages = messageData
|
|
1831
|
+
} catch (error) {
|
|
1832
|
+
if (context.abort.aborted) throw error
|
|
1833
|
+
return { title, output: `Cannot resume Raven session ${requestedSessionId}: its message history could not be read.` }
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
const userMessages = messages.filter((message: any) => message?.info?.role === "user")
|
|
1837
|
+
const persistedRavenSession = (userMessages.length > 0 || (host === "v2"
|
|
1838
|
+
&& existingSession.agent === agent && existingSession.metadata?.opencodeRavenAgent === agent))
|
|
1839
|
+
&& userMessages.every((message: any) => message.info.agent === agent)
|
|
1840
|
+
if (!persistedRavenSession && !(knownRavenSession && userMessages.length === 0)) {
|
|
1841
|
+
return { title, output: `Cannot resume Raven: sessionId does not belong to the ${agent} agent.` }
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
sessionId = requestedSessionId
|
|
1845
|
+
resumed = true
|
|
1846
|
+
previousSavedCandidate = estimateRavenSavedCandidateBytes(messages)
|
|
1847
|
+
} else {
|
|
1848
|
+
const sessionTitle = `${toolName}: ${args.query.slice(0, 80)}`
|
|
1849
|
+
const session = await client.session.create({
|
|
1850
|
+
body: {
|
|
1851
|
+
parentID: rootId,
|
|
1852
|
+
title: sessionTitle,
|
|
1853
|
+
...(host === "v2" ? { agent } : {}),
|
|
1854
|
+
},
|
|
1855
|
+
signal: context.abort,
|
|
1856
|
+
})
|
|
1857
|
+
sessionId = (session as any)?.data?.id ?? (session as any)?.id
|
|
1858
|
+
if (!sessionId) return { title, output: "Failed to create Raven session." }
|
|
1859
|
+
await syncCreatedSession(sessionId, sessionTitle, context.abort)
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
ravenSessions.add(sessionId)
|
|
1863
|
+
ravenSessionKinds.set(sessionId, kind)
|
|
1864
|
+
const activeSessionId = sessionId
|
|
1865
|
+
|
|
1866
|
+
try {
|
|
1867
|
+
writeFileSync(join(tmpdir(), "raven-sessions.log"), `${new Date().toISOString()} ${sessionId}\n`)
|
|
1868
|
+
} catch { /* non-fatal */ }
|
|
1869
|
+
|
|
1870
|
+
if (activeRavenPrompts.has(activeSessionId)) {
|
|
1871
|
+
return {
|
|
1872
|
+
title,
|
|
1873
|
+
metadata: { sessionId: activeSessionId },
|
|
1874
|
+
output: `Raven session \`${activeSessionId}\` is already handling a request. Wait for it to finish before sending another follow-up.`,
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1867
1877
|
activeRavenPrompts.add(activeSessionId)
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
let
|
|
1871
|
-
let
|
|
1872
|
-
|
|
1873
|
-
const
|
|
1874
|
-
|
|
1875
|
-
promptController.
|
|
1876
|
-
|
|
1877
|
-
const
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
const
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
const
|
|
1913
|
-
const
|
|
1914
|
-
|
|
1915
|
-
.
|
|
1916
|
-
.
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
const
|
|
1921
|
-
const
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
if (part.
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
const
|
|
1941
|
-
const
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
if (
|
|
1949
|
-
if (
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
const
|
|
1956
|
-
const
|
|
1957
|
-
const
|
|
1958
|
-
const
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
: /
|
|
1962
|
-
: /
|
|
1963
|
-
: /
|
|
1964
|
-
:
|
|
1965
|
-
:
|
|
1966
|
-
: /
|
|
1967
|
-
:
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
const
|
|
1973
|
-
|
|
1974
|
-
:
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
async
|
|
1878
|
+
context.metadata({ metadata: { sessionId } })
|
|
1879
|
+
|
|
1880
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1881
|
+
let promptCompleted = false
|
|
1882
|
+
let sessionAbort: Promise<unknown> | undefined
|
|
1883
|
+
const promptController = new AbortController()
|
|
1884
|
+
const abortPrompt = () => {
|
|
1885
|
+
if (promptController.signal.aborted) return
|
|
1886
|
+
promptController.abort(context.abort.reason)
|
|
1887
|
+
const abortController = new AbortController()
|
|
1888
|
+
const abortTimer = setTimeout(() => abortController.abort(), Math.min(timeoutMs, 5_000))
|
|
1889
|
+
sessionAbort = client.session.abort({
|
|
1890
|
+
path: { id: activeSessionId },
|
|
1891
|
+
signal: abortController.signal,
|
|
1892
|
+
throwOnError: true,
|
|
1893
|
+
}).catch(() => undefined).finally(() => clearTimeout(abortTimer))
|
|
1894
|
+
}
|
|
1895
|
+
if (context.abort.aborted) abortPrompt()
|
|
1896
|
+
else context.abort.addEventListener("abort", abortPrompt, { once: true })
|
|
1897
|
+
const providerError = waitForRavenProviderError(sessionId)
|
|
1898
|
+
const firstProgress = waitForRavenFirstProgress(sessionId, Math.min(timeoutMs, 30_000))
|
|
1899
|
+
|
|
1900
|
+
try {
|
|
1901
|
+
const result = await Promise.race([
|
|
1902
|
+
client.session.prompt({
|
|
1903
|
+
path: { id: sessionId },
|
|
1904
|
+
body: { agent, parts: [{ type: "text", text: args.query }] },
|
|
1905
|
+
signal: promptController.signal,
|
|
1906
|
+
throwOnError: true,
|
|
1907
|
+
}),
|
|
1908
|
+
providerError,
|
|
1909
|
+
firstProgress,
|
|
1910
|
+
new Promise<never>((_, reject) => {
|
|
1911
|
+
timer = setTimeout(() => {
|
|
1912
|
+
abortPrompt()
|
|
1913
|
+
reject(new Error(`Raven timed out after ${timeout}s - child session retained: ${sessionId}`))
|
|
1914
|
+
}, timeoutMs)
|
|
1915
|
+
}),
|
|
1916
|
+
])
|
|
1917
|
+
promptCompleted = true
|
|
1918
|
+
if (timer) clearTimeout(timer)
|
|
1919
|
+
clearRavenWaiters(sessionId)
|
|
1920
|
+
if ((result as any)?.error) throw new Error(providerErrorMessage((result as any).error))
|
|
1921
|
+
|
|
1922
|
+
const elapsed = ((Date.now() - started) / 1000).toFixed(1)
|
|
1923
|
+
const parts = (result as any)?.data?.parts ?? []
|
|
1924
|
+
const output = parts
|
|
1925
|
+
.filter((part: any) => part.type === "text" && part.text)
|
|
1926
|
+
.map((part: any) => part.text)
|
|
1927
|
+
.join("\n") || "Raven returned no results."
|
|
1928
|
+
|
|
1929
|
+
let savedCandidate = 0
|
|
1930
|
+
const statsController = new AbortController()
|
|
1931
|
+
const abortStats = () => statsController.abort(context.abort.reason)
|
|
1932
|
+
const statsTimer = setTimeout(() => statsController.abort(), Math.min(timeoutMs, 5_000))
|
|
1933
|
+
if (context.abort.aborted) abortStats()
|
|
1934
|
+
else context.abort.addEventListener("abort", abortStats, { once: true })
|
|
1935
|
+
try {
|
|
1936
|
+
const totalSavedCandidate = await countRavenSavedCandidateBytes(sessionId, statsController.signal)
|
|
1937
|
+
savedCandidate = Math.max(0, totalSavedCandidate - previousSavedCandidate)
|
|
1938
|
+
} catch { /* best-effort */ }
|
|
1939
|
+
finally {
|
|
1940
|
+
clearTimeout(statsTimer)
|
|
1941
|
+
context.abort.removeEventListener("abort", abortStats)
|
|
1942
|
+
}
|
|
1943
|
+
if (savedCandidate <= 0) {
|
|
1944
|
+
for (const part of parts) {
|
|
1945
|
+
if (part.args) savedCandidate += JSON.stringify(part.args).length
|
|
1946
|
+
if (part.content) savedCandidate += typeof part.content === "string" ? part.content.length : JSON.stringify(part.content).length
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
const continuation = `Raven session: \`${sessionId}\`${resumed ? " (continued)" : ""}. Pass this as \`sessionId\` in a later \`${toolName}\` call to continue the same retrieval.`
|
|
1951
|
+
const activity = kind === "mcp" ? "used MCPs" : "searched"
|
|
1952
|
+
const resultOutput = `${output}\n\n*Raven ${activity} for ${elapsed}s - ${formatBytes(savedCandidate)} handled, ${formatTokens(savedCandidate)} tokens*\n\n${continuation}`
|
|
1953
|
+
addBytes(Math.max(0, savedCandidate - resultOutput.length))
|
|
1954
|
+
delete ravenProviderIssues[kind]
|
|
1955
|
+
return { title, metadata: { sessionId }, output: resultOutput }
|
|
1956
|
+
} finally {
|
|
1957
|
+
context.abort.removeEventListener("abort", abortPrompt)
|
|
1958
|
+
if (!promptCompleted) abortPrompt()
|
|
1959
|
+
if (sessionAbort) await sessionAbort
|
|
1960
|
+
if (timer) clearTimeout(timer)
|
|
1961
|
+
clearRavenWaiters(sessionId)
|
|
1962
|
+
activeRavenPrompts.delete(activeSessionId)
|
|
1963
|
+
}
|
|
1964
|
+
} catch (error: any) {
|
|
1965
|
+
const elapsed = ((Date.now() - started) / 1000).toFixed(1)
|
|
1966
|
+
const message = String(error?.message ?? error ?? "").toLowerCase()
|
|
1967
|
+
const missingSession = /session.*not found|not found.*session/i.test(message)
|
|
1968
|
+
const activity = kind === "mcp" ? "MCP retrieval" : "search"
|
|
1969
|
+
const hint =
|
|
1970
|
+
context.abort.aborted ? `Raven ${activity} cancelled.`
|
|
1971
|
+
: /rate.?limit|too many requests|429/i.test(message) ? "Raven rate limited - wait 30s, then retry with a narrower query."
|
|
1972
|
+
: /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."
|
|
1973
|
+
: /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."
|
|
1974
|
+
: /token|context.?length|too large|too long/i.test(message) ? "Raven query too large - shorten your query and retry."
|
|
1975
|
+
: missingSession ? `The Raven session is no longer available - retry without sessionId to start a new ${activity}.`
|
|
1976
|
+
: /model|unavailable|down|not found/i.test(message) ? "Raven model unavailable - retry later, or proceed without the missing information."
|
|
1977
|
+
: /did not produce an initial response|retry dialog|timeout|timed.?out|session retained/i.test(message) ? error.message
|
|
1978
|
+
: `Raven ${activity} failed. Note the resulting information gap. [${error.message || error}]`
|
|
1979
|
+
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)) {
|
|
1980
|
+
ravenProviderIssues[kind] = hint
|
|
1981
|
+
}
|
|
1982
|
+
const recovery = ravenProviderIssues[kind] ? `\n\n${ravenRecoveryInstructions(kind)}` : ""
|
|
1983
|
+
const continuation = sessionId && !missingSession
|
|
1984
|
+
? `\n\nRaven session: \`${sessionId}\`. Pass this as \`sessionId\` in a later \`${toolName}\` call to retry or continue.`
|
|
1985
|
+
: ""
|
|
1986
|
+
return { title, metadata: sessionId ? { sessionId } : undefined, output: `${hint}${recovery}\n\n*Attempt took ${elapsed}s*${continuation}` }
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
return {
|
|
1991
|
+
async refreshMcps(serverName?: string) {
|
|
1992
|
+
// V2 clients present the refresh lifecycle themselves, including failures.
|
|
1993
|
+
mcpFailureToastPending = false
|
|
1994
|
+
// First attachment shares the startup probe; later attachments force a
|
|
1995
|
+
// fresh health/tool-discovery check even when cached status is ready.
|
|
1996
|
+
const result = await (!serverName && !mcpStartupRefreshComplete
|
|
1997
|
+
? scheduleOnDemandMcpRefresh()
|
|
1998
|
+
: queueOnDemandMcpRefresh(true, serverName))
|
|
1999
|
+
return result
|
|
2000
|
+
},
|
|
2001
|
+
globalMcpRouting(names: string[]) {
|
|
2002
|
+
globalMcpServers = uniqueStrings(names)
|
|
2003
|
+
const onDemandNames = new Set(activeOnDemandMcpEntries().map(([name]) => name.toLowerCase()))
|
|
2004
|
+
duplicateMcpServers = globalMcpServers.filter((name) => onDemandNames.has(name.toLowerCase()))
|
|
2005
|
+
duplicateMcpToastPending = duplicateMcpServers.length > 0
|
|
2006
|
+
},
|
|
2007
|
+
// V2 injects these texts at model-request time instead of using config.instructions.
|
|
2008
|
+
guidance() {
|
|
2009
|
+
return `${readFileSync(MCP_GUIDANCE_MD, "utf-8")}\n\n${onDemandMcpGuidance("delegate")}`
|
|
2010
|
+
},
|
|
2011
|
+
mcpGuidance() {
|
|
2012
|
+
return `On-demand MCP guidance:\n${onDemandMcpGuidance("raven")}`
|
|
2013
|
+
},
|
|
2014
|
+
async dispose() {
|
|
2015
|
+
for (const controller of mcpRefreshControllers) controller.abort()
|
|
2016
|
+
for (const attempt of mcpConnectionAttempts.values()) attempt.controller.abort()
|
|
2017
|
+
await Promise.allSettled([
|
|
2018
|
+
...[...mcpConnectionAttempts.values()].map((attempt) => attempt.promise),
|
|
2019
|
+
mcpRefreshQueue,
|
|
2020
|
+
])
|
|
2021
|
+
await Promise.all([...mcpConnections.keys()].map((key) => closeMcpConnectionKey(key)))
|
|
2022
|
+
if (mcpIdleTimer) clearInterval(mcpIdleTimer)
|
|
2023
|
+
mcpIdleTimer = undefined
|
|
2024
|
+
for (const sessionId of ravenProgressWaiters.keys()) clearRavenWaiters(sessionId)
|
|
2025
|
+
ravenErrorWaiters.clear()
|
|
2026
|
+
},
|
|
2027
|
+
|
|
2028
|
+
async config(configInput: any) {
|
|
2005
2029
|
globalMcpServers = uniqueStrings(Object.entries(configInput.mcp ?? {})
|
|
2006
2030
|
.filter(([, value]) => !value || typeof value !== "object" || (value as any).enabled !== false)
|
|
2007
2031
|
.map(([name]) => name))
|
|
2008
2032
|
const onDemandNames = new Set(activeOnDemandMcpEntries().map(([name]) => name.toLowerCase()))
|
|
2009
2033
|
duplicateMcpServers = globalMcpServers.filter((name) => onDemandNames.has(name.toLowerCase()))
|
|
2010
2034
|
|
|
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 || []
|
|
2035
|
+
// Inject MCP guidance as a startup instruction file (absolute path for npm compat).
|
|
2036
|
+
// The file is regenerated after MCP health checks, not during the startup pending reset.
|
|
2037
|
+
configInput.instructions = configInput.instructions || []
|
|
2014
2038
|
if (!configInput.instructions.includes(MCP_GUIDANCE_MD)) {
|
|
2015
2039
|
configInput.instructions.push(MCP_GUIDANCE_MD)
|
|
2016
2040
|
}
|
|
@@ -2018,215 +2042,214 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
2018
2042
|
configInput.instructions.push(dynamicGuidanceFile)
|
|
2019
2043
|
}
|
|
2020
2044
|
|
|
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
|
-
}
|
|
2045
|
+
// Register Raven from Raven.md, with config file overrides
|
|
2046
|
+
configInput.agent = configInput.agent || {}
|
|
2047
|
+
const seekModel = activeSeekModel || fm.model
|
|
2048
|
+
const mcpModel = activeMcpModel || mcpFm.model
|
|
2049
|
+
configInput.agent.raven = {
|
|
2050
|
+
description: fm.description || "",
|
|
2051
|
+
mode: fm.mode || "subagent",
|
|
2052
|
+
hidden: fm.hidden !== undefined ? fm.hidden : false,
|
|
2053
|
+
disable: !seekModel,
|
|
2054
|
+
...(seekModel ? { model: seekModel } : {}),
|
|
2055
|
+
options: {
|
|
2056
|
+
...extractOptions(fm),
|
|
2057
|
+
...(config.raven_seek.reasoning_effort ? { reasoning_effort: config.raven_seek.reasoning_effort } : {}),
|
|
2058
|
+
},
|
|
2059
|
+
permission: fm.permission || {},
|
|
2060
|
+
prompt: ravenAgentPrompt(),
|
|
2061
|
+
}
|
|
2062
|
+
configInput.agent["raven-mcp"] = {
|
|
2063
|
+
description: mcpFm.description || "",
|
|
2064
|
+
mode: mcpFm.mode || "subagent",
|
|
2065
|
+
hidden: mcpFm.hidden !== undefined ? mcpFm.hidden : true,
|
|
2066
|
+
disable: !mcpModel,
|
|
2067
|
+
...(mcpModel ? { model: mcpModel } : {}),
|
|
2068
|
+
options: {
|
|
2069
|
+
...extractOptions(mcpFm),
|
|
2070
|
+
...(config.raven_mcp.reasoning_effort ? { reasoning_effort: config.raven_mcp.reasoning_effort } : {}),
|
|
2071
|
+
},
|
|
2072
|
+
permission: mcpFm.permission || {},
|
|
2073
|
+
prompt: ravenMcpAgentPrompt(),
|
|
2074
|
+
}
|
|
2051
2075
|
|
|
2052
2076
|
// Register /raven command
|
|
2053
2077
|
configInput.command = configInput.command || {}
|
|
2054
2078
|
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
|
-
}
|
|
2079
|
+
configInput.command.raven = {
|
|
2080
|
+
template: "Manage Raven search routing, on-demand MCPs, models, timeouts, updates, and status",
|
|
2081
|
+
description: "Manage Raven search routing, on-demand MCPs, and per-agent models",
|
|
2082
|
+
}
|
|
2059
2083
|
}
|
|
2060
2084
|
|
|
2061
2085
|
scheduleOnDemandMcpRefresh()
|
|
2062
2086
|
updateToastPending = true
|
|
2063
|
-
mcpFailureToastPending = true
|
|
2064
|
-
duplicateMcpToastPending = duplicateMcpServers.length > 0
|
|
2065
|
-
modelSetupToastPending = !!modelSetupInstructions()
|
|
2087
|
+
mcpFailureToastPending = true
|
|
2088
|
+
duplicateMcpToastPending = duplicateMcpServers.length > 0
|
|
2089
|
+
modelSetupToastPending = !!modelSetupInstructions()
|
|
2066
2090
|
},
|
|
2067
2091
|
|
|
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 } })
|
|
2092
|
+
// Register raven_seek tool — lets agents with task:false still delegate through Raven
|
|
2093
|
+
tool: {
|
|
2094
|
+
"raven_seek": tool({
|
|
2095
|
+
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.",
|
|
2096
|
+
args: {
|
|
2097
|
+
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."),
|
|
2098
|
+
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."),
|
|
2099
|
+
},
|
|
2100
|
+
async execute(args, context) {
|
|
2101
|
+
const started = Date.now()
|
|
2102
|
+
await refreshModelsIfPending()
|
|
2103
|
+
if (!modelOperational("seek")) {
|
|
2104
|
+
return { title: "Raven Seek", output: modelSetupInstructions("seek") }
|
|
2105
|
+
}
|
|
2106
|
+
const timeout = (config.raven_seek.timeout ?? 600) * 1000
|
|
2107
|
+
let sessionId: string | undefined
|
|
2108
|
+
let resumed = false
|
|
2109
|
+
let previousSavedCandidate = 0
|
|
2110
|
+
try {
|
|
2111
|
+
const requestedSessionId = args.sessionId?.trim()
|
|
2112
|
+
if (args.sessionId !== undefined && !requestedSessionId) {
|
|
2113
|
+
return { title: "Raven Seek", output: "Cannot resume Raven: sessionId must not be empty." }
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
let rootId = context.sessionID
|
|
2117
|
+
try {
|
|
2118
|
+
rootId = await rootSessionId(context.sessionID, context.abort)
|
|
2119
|
+
} catch (error) {
|
|
2120
|
+
if (context.abort.aborted) throw error
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
if (requestedSessionId) {
|
|
2124
|
+
const knownRavenSession = ravenSessionKinds.get(requestedSessionId) === "seek" || sessionAgents.get(requestedSessionId) === "raven"
|
|
2125
|
+
let existingSession: any
|
|
2126
|
+
try {
|
|
2127
|
+
const sessionResp = await client.session.get({ path: { id: requestedSessionId }, signal: context.abort })
|
|
2128
|
+
existingSession = (sessionResp as any)?.data ?? sessionResp
|
|
2129
|
+
} catch (err) {
|
|
2130
|
+
if (context.abort.aborted) throw err
|
|
2131
|
+
return { title: "Raven Seek", output: `Cannot resume Raven session ${requestedSessionId}: the session was not found or could not be read.` }
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
if (!existingSession?.id || existingSession.id !== requestedSessionId) {
|
|
2135
|
+
return { title: "Raven Seek", output: `Cannot resume Raven session ${requestedSessionId}: the session was not found or could not be read.` }
|
|
2136
|
+
}
|
|
2137
|
+
if ((existingSession.parentID !== rootId && existingSession.parentID !== context.sessionID) || existingSession.projectID !== input.project.id) {
|
|
2138
|
+
return { title: "Raven Seek", output: "Cannot resume Raven: sessionId is not a Raven child of this main session." }
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
let messages: any[]
|
|
2142
|
+
try {
|
|
2143
|
+
const messagesResp = await client.session.messages({ path: { id: requestedSessionId }, signal: context.abort })
|
|
2144
|
+
const messageData = (messagesResp as any)?.data
|
|
2145
|
+
if (!Array.isArray(messageData)) {
|
|
2146
|
+
return { title: "Raven Seek", output: `Cannot resume Raven session ${requestedSessionId}: its message history could not be read.` }
|
|
2147
|
+
}
|
|
2148
|
+
messages = messageData
|
|
2149
|
+
} catch (err) {
|
|
2150
|
+
if (context.abort.aborted) throw err
|
|
2151
|
+
return { title: "Raven Seek", output: `Cannot resume Raven session ${requestedSessionId}: its message history could not be read.` }
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
const userMessages = messages.filter((message: any) => message?.info?.role === "user")
|
|
2155
|
+
const persistedRavenSession = (userMessages.length > 0 || (host === "v2"
|
|
2156
|
+
&& existingSession.agent === "raven" && existingSession.metadata?.opencodeRavenAgent === "raven"))
|
|
2157
|
+
&& userMessages.every((message: any) => message.info.agent === "raven")
|
|
2158
|
+
if (!persistedRavenSession && !(knownRavenSession && userMessages.length === 0)) {
|
|
2159
|
+
return { title: "Raven Seek", output: "Cannot resume Raven: sessionId is not a Raven child of this main session." }
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
sessionId = requestedSessionId
|
|
2163
|
+
resumed = true
|
|
2164
|
+
previousSavedCandidate = estimateRavenSavedCandidateBytes(messages)
|
|
2165
|
+
} else {
|
|
2166
|
+
const sessionTitle = `raven_seek: ${args.query.slice(0, 80)}`
|
|
2167
|
+
const session = await client.session.create({
|
|
2168
|
+
body: {
|
|
2169
|
+
parentID: rootId,
|
|
2170
|
+
title: sessionTitle,
|
|
2171
|
+
...(host === "v2" ? { agent: "raven" } : {}),
|
|
2172
|
+
},
|
|
2173
|
+
signal: context.abort,
|
|
2174
|
+
})
|
|
2175
|
+
|
|
2176
|
+
sessionId = (session as any)?.data?.id ?? (session as any)?.id
|
|
2177
|
+
if (!sessionId) {
|
|
2178
|
+
return { title: "Raven Seek", output: "Failed to create Raven session." }
|
|
2179
|
+
}
|
|
2180
|
+
await syncCreatedSession(sessionId, sessionTitle, context.abort)
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
ravenSessions.add(sessionId)
|
|
2184
|
+
ravenSessionKinds.set(sessionId, "seek")
|
|
2185
|
+
const activeSessionId = sessionId
|
|
2165
2186
|
|
|
2166
2187
|
// Log session for debugging
|
|
2167
2188
|
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
|
-
}
|
|
2189
|
+
const logFile = join(tmpdir(), "raven-sessions.log")
|
|
2190
|
+
const ts = new Date().toISOString()
|
|
2191
|
+
writeFileSync(logFile, `${ts} ${sessionId}\n`)
|
|
2192
|
+
} catch { /* non-fatal */ }
|
|
2193
|
+
|
|
2194
|
+
if (activeRavenPrompts.has(activeSessionId)) {
|
|
2195
|
+
return {
|
|
2196
|
+
title: "Raven Seek",
|
|
2197
|
+
metadata: { sessionId: activeSessionId },
|
|
2198
|
+
output: `Raven session \`${activeSessionId}\` is already handling a request. Wait for it to finish before sending another follow-up.`,
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2180
2201
|
activeRavenPrompts.add(activeSessionId)
|
|
2202
|
+
// Publish only after validation and acquiring the continuation lock.
|
|
2203
|
+
context.metadata({ metadata: { sessionId } })
|
|
2181
2204
|
|
|
2182
2205
|
// Send the query to Raven with timeout. Provider/API errors are also
|
|
2183
2206
|
// 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
|
+
// so the caller does not wait for the full timeout on quota/auth issues.
|
|
2208
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
2209
|
+
let promptCompleted = false
|
|
2210
|
+
let sessionAbort: Promise<unknown> | undefined
|
|
2211
|
+
const promptController = new AbortController()
|
|
2212
|
+
const abortPrompt = () => {
|
|
2213
|
+
if (promptController.signal.aborted) return
|
|
2214
|
+
promptController.abort(context.abort.reason)
|
|
2215
|
+
const abortController = new AbortController()
|
|
2216
|
+
const abortTimer = setTimeout(() => abortController.abort(), Math.min(timeout, 5_000))
|
|
2217
|
+
sessionAbort = client.session.abort({
|
|
2218
|
+
path: { id: activeSessionId },
|
|
2219
|
+
signal: abortController.signal,
|
|
2220
|
+
throwOnError: true,
|
|
2221
|
+
}).catch(() => undefined).finally(() => clearTimeout(abortTimer))
|
|
2222
|
+
}
|
|
2223
|
+
if (context.abort.aborted) abortPrompt()
|
|
2224
|
+
else context.abort.addEventListener("abort", abortPrompt, { once: true })
|
|
2225
|
+
const providerError = waitForRavenProviderError(sessionId)
|
|
2226
|
+
const firstProgress = waitForRavenFirstProgress(sessionId, Math.min(timeout, 30_000))
|
|
2227
|
+
try {
|
|
2228
|
+
const result = await Promise.race([
|
|
2229
|
+
client.session.prompt({
|
|
2207
2230
|
path: { id: sessionId },
|
|
2208
2231
|
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)
|
|
2232
|
+
agent: "raven",
|
|
2233
|
+
parts: [{ type: "text", text: args.query }],
|
|
2234
|
+
},
|
|
2235
|
+
signal: promptController.signal,
|
|
2236
|
+
throwOnError: true,
|
|
2237
|
+
}),
|
|
2238
|
+
providerError,
|
|
2239
|
+
firstProgress,
|
|
2240
|
+
new Promise<never>((_, reject) => {
|
|
2241
|
+
timer = setTimeout(() => {
|
|
2242
|
+
abortPrompt()
|
|
2243
|
+
reject(new Error(`Raven timed out after ${timeout / 1000}s — child session retained: ${sessionId}`))
|
|
2244
|
+
}, timeout)
|
|
2245
|
+
}),
|
|
2246
|
+
])
|
|
2247
|
+
promptCompleted = true
|
|
2248
|
+
if (timer) clearTimeout(timer)
|
|
2249
|
+
clearRavenWaiters(sessionId)
|
|
2250
|
+
if ((result as any)?.error) throw new Error(providerErrorMessage((result as any).error))
|
|
2251
|
+
|
|
2252
|
+
const elapsed = ((Date.now() - started) / 1000).toFixed(1)
|
|
2230
2253
|
|
|
2231
2254
|
// Extract text from the response
|
|
2232
2255
|
const parts = (result as any)?.data?.parts ?? []
|
|
@@ -2235,207 +2258,207 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
2235
2258
|
.map((p: any) => p.text)
|
|
2236
2259
|
const output = textParts.join("\n") || "Raven returned no results."
|
|
2237
2260
|
|
|
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
|
-
}
|
|
2261
|
+
// Context saved = Raven output/reasoning/cache context - compact answer returned to main session.
|
|
2262
|
+
let savedCandidate = 0
|
|
2263
|
+
const statsController = new AbortController()
|
|
2264
|
+
const abortStats = () => statsController.abort(context.abort.reason)
|
|
2265
|
+
const statsTimer = setTimeout(() => statsController.abort(), Math.min(timeout, 5_000))
|
|
2266
|
+
if (context.abort.aborted) abortStats()
|
|
2267
|
+
else context.abort.addEventListener("abort", abortStats, { once: true })
|
|
2268
|
+
try {
|
|
2269
|
+
const totalSavedCandidate = await countRavenSavedCandidateBytes(sessionId, statsController.signal)
|
|
2270
|
+
savedCandidate = Math.max(0, totalSavedCandidate - previousSavedCandidate)
|
|
2271
|
+
} catch { /* best-effort */ }
|
|
2272
|
+
finally {
|
|
2273
|
+
clearTimeout(statsTimer)
|
|
2274
|
+
context.abort.removeEventListener("abort", abortStats)
|
|
2275
|
+
}
|
|
2253
2276
|
if (savedCandidate <= 0) {
|
|
2254
2277
|
for (const part of parts) {
|
|
2255
2278
|
if (part.args) savedCandidate += JSON.stringify(part.args).length
|
|
2256
2279
|
if (part.content) savedCandidate += typeof part.content === "string" ? part.content.length : JSON.stringify(part.content).length
|
|
2257
2280
|
}
|
|
2258
2281
|
}
|
|
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}` }
|
|
2282
|
+
const continuation = `Raven session: \`${sessionId}\`${resumed ? " (continued)" : ""}. Pass this as \`sessionId\` in a later \`raven_seek\` call to continue the same research.`
|
|
2283
|
+
const resultOutput = `${output}\n\n*Raven searched for ${elapsed}s — ${formatBytes(savedCandidate)} handled, ${formatTokens(savedCandidate)} tokens*\n\n${continuation}`
|
|
2284
|
+
addBytes(Math.max(0, savedCandidate - resultOutput.length))
|
|
2285
|
+
delete ravenProviderIssues.seek
|
|
2286
|
+
|
|
2287
|
+
return { title: "Raven Seek", metadata: { sessionId }, output: resultOutput }
|
|
2288
|
+
} finally {
|
|
2289
|
+
context.abort.removeEventListener("abort", abortPrompt)
|
|
2290
|
+
if (!promptCompleted) abortPrompt()
|
|
2291
|
+
if (sessionAbort) await sessionAbort
|
|
2292
|
+
if (timer) clearTimeout(timer)
|
|
2293
|
+
clearRavenWaiters(sessionId)
|
|
2294
|
+
activeRavenPrompts.delete(activeSessionId)
|
|
2295
|
+
}
|
|
2296
|
+
} catch (err: any) {
|
|
2297
|
+
const elapsed = ((Date.now() - started) / 1000).toFixed(1)
|
|
2298
|
+
const msg = String(err?.message ?? err ?? "").toLowerCase()
|
|
2299
|
+
const missingSession = /session.*not found|not found.*session/i.test(msg)
|
|
2300
|
+
const hint =
|
|
2301
|
+
context.abort.aborted ? "Raven search cancelled."
|
|
2302
|
+
: /rate.?limit|too many requests|429/i.test(msg) ? "Raven rate limited — wait 30s then retry with a narrower query."
|
|
2303
|
+
: /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."
|
|
2304
|
+
: /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."
|
|
2305
|
+
: /token|context.?length|too large|too long/i.test(msg) ? "Raven query too large — shorten your query and retry."
|
|
2306
|
+
: missingSession ? "The Raven session is no longer available — retry without sessionId to start a new Raven search."
|
|
2307
|
+
: /model|unavailable|down|not found/i.test(msg) ? "Raven model unavailable — retry later, or proceed without search."
|
|
2308
|
+
: /did not produce an initial response|retry dialog/i.test(msg) ? err.message
|
|
2309
|
+
: /timeout|timed.?out|session kept/i.test(msg) ? err.message
|
|
2310
|
+
: `Raven search failed. Proceed without search — note gaps for the user. [${err.message || err}]`
|
|
2311
|
+
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)) {
|
|
2312
|
+
ravenProviderIssues.seek = hint
|
|
2313
|
+
}
|
|
2314
|
+
const recovery = ravenProviderIssues.seek ? `\n\n${ravenRecoveryInstructions("seek")}` : ""
|
|
2315
|
+
const continuation = sessionId && !missingSession
|
|
2316
|
+
? `\n\nRaven session: \`${sessionId}\`. Pass this as \`sessionId\` in a later \`raven_seek\` call to retry or continue.`
|
|
2317
|
+
: ""
|
|
2318
|
+
return { title: "Raven Seek", metadata: sessionId ? { sessionId } : undefined, output: `${hint}${recovery}\n\n*Attempt took ${elapsed}s*${continuation}` }
|
|
2296
2319
|
}
|
|
2297
2320
|
},
|
|
2298
2321
|
}),
|
|
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.",
|
|
2322
|
+
"raven_mcp": tool({
|
|
2323
|
+
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.",
|
|
2324
|
+
args: {
|
|
2325
|
+
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."),
|
|
2326
|
+
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."),
|
|
2327
|
+
},
|
|
2328
|
+
async execute(args, context) {
|
|
2329
|
+
return executeRavenDelegation("mcp", args, context)
|
|
2330
|
+
},
|
|
2331
|
+
}),
|
|
2332
|
+
"raven_mcp_bridge": tool({
|
|
2333
|
+
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
2334
|
args: {
|
|
2312
2335
|
server: tool.schema.string().describe("On-demand MCP server name from Raven config, e.g. context7, exa, grep_app, unityMCP."),
|
|
2313
2336
|
operation: tool.schema.enum(["list_tools", "call_tool", "list_resources", "read_resource", "list_prompts", "get_prompt"]).describe("MCP operation to perform."),
|
|
2314
2337
|
name: tool.schema.string().optional().describe("Tool name for call_tool, resource URI for read_resource, or prompt name for get_prompt."),
|
|
2315
2338
|
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
2339
|
},
|
|
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
|
+
async execute(args, context) {
|
|
2341
|
+
if (ravenSessionKinds.get(context.sessionID) !== "mcp" && sessionAgents.get(context.sessionID) !== "raven-mcp") {
|
|
2342
|
+
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." }
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
const operation = args.operation as RavenMcpOperation
|
|
2346
|
+
const server = getOnDemandMcpServer(args.server)?.[1]
|
|
2347
|
+
const timeout = server ? mcpTimeoutMs(server) : (config.raven_mcp.timeout ?? 600) * 1000
|
|
2348
|
+
const requestOptions = { signal: context.abort, timeout }
|
|
2349
|
+
let connected = false
|
|
2350
|
+
let connection: McpConnection | undefined
|
|
2351
|
+
try {
|
|
2352
|
+
connection = await getMcpConnection(context.sessionID, args.server, context.abort)
|
|
2353
|
+
connected = true
|
|
2354
|
+
connection.active++
|
|
2355
|
+
connection.lastUsed = Date.now()
|
|
2356
|
+
const client = connection.client
|
|
2357
|
+
|
|
2358
|
+
if (operation === "list_tools") {
|
|
2359
|
+
const tools = await listAllMcpTools(client, requestOptions)
|
|
2360
|
+
const match = getOnDemandMcpServer(args.server)
|
|
2361
|
+
if (match) {
|
|
2362
|
+
const [name] = match
|
|
2340
2363
|
const generatedTools = generatedToolsFromMcpTools(tools)
|
|
2341
2364
|
mcpMetadata[name] = {
|
|
2342
2365
|
autoGeneratedTools: generatedTools,
|
|
2343
2366
|
autoGeneratedDescription: mcpMetadata[name]?.autoGeneratedDescription || synthesizeMcpDescription(name, generatedTools),
|
|
2344
2367
|
avoidedSchemaBytes: estimateAvoidedMcpSchemaBytes(tools),
|
|
2345
2368
|
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
|
-
}
|
|
2369
|
+
lastCheckedAt: new Date().toISOString(),
|
|
2370
|
+
}
|
|
2371
|
+
saveMcpMetadata(mcpMetadata)
|
|
2372
|
+
saveDynamicGuidance()
|
|
2373
|
+
}
|
|
2374
|
+
return { title: `Raven MCP: ${args.server}`, output: formatMcpToolsForRaven(args.server, tools) }
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
if (operation === "call_tool") {
|
|
2378
|
+
if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "call_tool requires name." }
|
|
2379
|
+
const result = await client.callTool({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }, undefined, requestOptions)
|
|
2380
|
+
setOnDemandMcpHealth(args.server, "ready")
|
|
2381
|
+
return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
if (operation === "list_resources") {
|
|
2385
|
+
const resources = await listAllMcpResources(client, requestOptions)
|
|
2386
|
+
setOnDemandMcpHealth(args.server, "ready")
|
|
2387
|
+
return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Resources for ${args.server}`, resources, ["uri", "name", "description"]) }
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
if (operation === "read_resource") {
|
|
2391
|
+
if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "read_resource requires name as the resource URI." }
|
|
2392
|
+
const result = await client.readResource({ uri: args.name }, requestOptions)
|
|
2393
|
+
setOnDemandMcpHealth(args.server, "ready")
|
|
2394
|
+
return { title: `Raven MCP: ${args.server}`, output: formatMcpResult(result) }
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
if (operation === "list_prompts") {
|
|
2398
|
+
const prompts = await listAllMcpPrompts(client, requestOptions)
|
|
2399
|
+
setOnDemandMcpHealth(args.server, "ready")
|
|
2400
|
+
return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Prompts for ${args.server}`, prompts) }
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
if (operation === "get_prompt") {
|
|
2404
|
+
if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "get_prompt requires name." }
|
|
2405
|
+
const result = await client.getPrompt({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }, requestOptions)
|
|
2406
|
+
setOnDemandMcpHealth(args.server, "ready")
|
|
2407
|
+
return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
return { title: `Raven MCP: ${args.server}`, output: `Unsupported operation: ${operation}` }
|
|
2411
|
+
} catch (err: any) {
|
|
2412
|
+
if (!connected && !context.abort.aborted) {
|
|
2413
|
+
const name = setOnDemandMcpHealth(args.server, "failed", err?.message ?? err)
|
|
2414
|
+
if (name) await notifyIfMcpFailures([name])
|
|
2415
|
+
}
|
|
2416
|
+
return { title: `Raven MCP: ${args.server}`, output: `MCP call failed: ${err?.message ?? err}` }
|
|
2417
|
+
} finally {
|
|
2418
|
+
if (connection) {
|
|
2419
|
+
connection.active = Math.max(0, connection.active - 1)
|
|
2420
|
+
connection.lastUsed = Date.now()
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2400
2423
|
},
|
|
2401
2424
|
}),
|
|
2402
2425
|
},
|
|
2403
2426
|
|
|
2404
2427
|
// 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
|
-
}
|
|
2428
|
+
async "chat.message"(input: any, _output: any) {
|
|
2429
|
+
if (input.agent) {
|
|
2430
|
+
sessionAgents.set(input.sessionID, input.agent)
|
|
2431
|
+
if (input.agent === "raven") {
|
|
2432
|
+
ravenSessions.add(input.sessionID)
|
|
2433
|
+
ravenSessionKinds.set(input.sessionID, "seek")
|
|
2434
|
+
} else if (input.agent === "raven-mcp") {
|
|
2435
|
+
ravenSessions.add(input.sessionID)
|
|
2436
|
+
ravenSessionKinds.set(input.sessionID, "mcp")
|
|
2437
|
+
}
|
|
2415
2438
|
}
|
|
2416
2439
|
},
|
|
2417
2440
|
|
|
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
|
-
}
|
|
2441
|
+
async event(input: { event: any }) {
|
|
2442
|
+
// Track subagent session → parent mapping for accurate context counting
|
|
2443
|
+
const evt = input.event
|
|
2444
|
+
if (evt?.type === "session.created") {
|
|
2445
|
+
const info = evt.properties?.info
|
|
2446
|
+
if (info?.parentID && info?.id) ravenSessionParents.set(info.parentID, info.id)
|
|
2447
|
+
}
|
|
2448
|
+
if (evt?.type === "session.deleted") {
|
|
2449
|
+
const sessionId = evt.properties?.info?.id
|
|
2450
|
+
if (sessionId) {
|
|
2451
|
+
ravenSessions.delete(sessionId)
|
|
2452
|
+
ravenSessionKinds.delete(sessionId)
|
|
2453
|
+
activeRavenPrompts.delete(sessionId)
|
|
2454
|
+
sessionAgents.delete(sessionId)
|
|
2455
|
+
clearRavenWaiters(sessionId)
|
|
2456
|
+
for (const [parentId, childId] of ravenSessionParents) {
|
|
2457
|
+
if (parentId === sessionId || childId === sessionId) ravenSessionParents.delete(parentId)
|
|
2458
|
+
}
|
|
2459
|
+
await closeMcpConnectionsForSession(sessionId)
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2439
2462
|
if (evt?.type === "message.updated") {
|
|
2440
2463
|
const info = evt.properties?.info
|
|
2441
2464
|
if (info?.role === "assistant" && info?.sessionID && info.error && ravenSessions.has(info.sessionID)) {
|
|
@@ -2461,16 +2484,16 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
2461
2484
|
}
|
|
2462
2485
|
}
|
|
2463
2486
|
}
|
|
2464
|
-
if (evt?.type === "session.error") {
|
|
2487
|
+
if (evt?.type === "session.error") {
|
|
2465
2488
|
const sessionId = evt.properties?.sessionID
|
|
2466
2489
|
if (sessionId && evt.properties?.error && ravenSessions.has(sessionId)) {
|
|
2467
2490
|
reportRavenProviderError(sessionId, evt.properties.error)
|
|
2468
|
-
}
|
|
2469
|
-
}
|
|
2470
|
-
|
|
2471
|
-
await refreshModelsIfPending()
|
|
2472
|
-
|
|
2473
|
-
if (updateToastPending) {
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
await refreshModelsIfPending()
|
|
2495
|
+
|
|
2496
|
+
if (updateToastPending) {
|
|
2474
2497
|
updateToastPending = false
|
|
2475
2498
|
setTimeout(() => void notifyIfUpdateAvailable(), 500)
|
|
2476
2499
|
}
|
|
@@ -2478,27 +2501,27 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
2478
2501
|
mcpFailureToastPending = false
|
|
2479
2502
|
void scheduleOnDemandMcpRefresh().then((result) => notifyIfMcpFailures(result.failed))
|
|
2480
2503
|
}
|
|
2481
|
-
if (duplicateMcpToastPending) {
|
|
2482
|
-
duplicateMcpToastPending = false
|
|
2483
|
-
setTimeout(() => void notifyIfDuplicateMcps(), 700)
|
|
2484
|
-
}
|
|
2485
|
-
if (modelSetupToastPending) {
|
|
2486
|
-
modelSetupToastPending = false
|
|
2487
|
-
setTimeout(() => void notifyIfModelSetupRequired(), 300)
|
|
2488
|
-
}
|
|
2504
|
+
if (duplicateMcpToastPending) {
|
|
2505
|
+
duplicateMcpToastPending = false
|
|
2506
|
+
setTimeout(() => void notifyIfDuplicateMcps(), 700)
|
|
2507
|
+
}
|
|
2508
|
+
if (modelSetupToastPending) {
|
|
2509
|
+
modelSetupToastPending = false
|
|
2510
|
+
setTimeout(() => void notifyIfModelSetupRequired(), 300)
|
|
2511
|
+
}
|
|
2489
2512
|
},
|
|
2490
2513
|
|
|
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") {
|
|
2514
|
+
// /raven help|route|seek ...|mcp ...|update|stats|status
|
|
2515
|
+
async "command.execute.before"(input: any, output: any) {
|
|
2516
|
+
if (input.command !== "raven") return
|
|
2517
|
+
output.parts.length = 0
|
|
2518
|
+
const raw = input.arguments.trim()
|
|
2519
|
+
const arg = raw.toLowerCase()
|
|
2520
|
+
if (!arg) await refreshModelsIfPending()
|
|
2521
|
+
|
|
2522
|
+
if (arg === "help") {
|
|
2523
|
+
output.parts.push({ type: "text", text: helpText() })
|
|
2524
|
+
} else if (arg === "stats") {
|
|
2502
2525
|
output.parts.push({ type: "text", text: statsText() })
|
|
2503
2526
|
} else if (arg === "route") {
|
|
2504
2527
|
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.` })
|
|
@@ -2509,95 +2532,95 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
2509
2532
|
const name = parts.slice(3).join(" ").trim()
|
|
2510
2533
|
const key = kind === "tool" ? "routeTools" : kind === "keyword" ? "routeToolKeywords" : undefined
|
|
2511
2534
|
|
|
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") {
|
|
2535
|
+
if (!key || !["add", "remove", "rm"].includes(action) || !name) {
|
|
2536
|
+
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." })
|
|
2537
|
+
} else {
|
|
2538
|
+
updateConfig((latest) => {
|
|
2539
|
+
const values = uniqueStrings(latest.raven_seek[key])
|
|
2540
|
+
const exists = values.some((value) => value.toLowerCase() === name.toLowerCase())
|
|
2541
|
+
const next = action === "add"
|
|
2542
|
+
? exists ? values : [...values, name]
|
|
2543
|
+
: values.filter((value) => value.toLowerCase() !== name.toLowerCase())
|
|
2544
|
+
return { raven_seek: key === "routeTools" ? { routeTools: next } : { routeToolKeywords: next } }
|
|
2545
|
+
})
|
|
2546
|
+
output.parts.push({ type: "text", text: routeSummary() })
|
|
2547
|
+
}
|
|
2548
|
+
} else if (arg.startsWith("seek ")) {
|
|
2549
|
+
const parts = raw.split(/\s+/)
|
|
2550
|
+
const action = parts[1]?.toLowerCase()
|
|
2551
|
+
const value = parts.slice(2).join(" ").trim()
|
|
2552
|
+
if (action === "model" && value) {
|
|
2553
|
+
if (value.toLowerCase() === "auto") {
|
|
2554
|
+
updateConfig({ raven_seek: { model: undefined, autoSelectedModel: undefined } })
|
|
2555
|
+
await ensureRavenModels()
|
|
2556
|
+
} else {
|
|
2557
|
+
updateConfig({ raven_seek: { model: value, autoSelectedModel: undefined } })
|
|
2558
|
+
}
|
|
2559
|
+
delete ravenProviderIssues.seek
|
|
2560
|
+
output.parts.push({ type: "text", text: config.raven_seek.model
|
|
2561
|
+
? `Raven Search model set to: ${modelLabel(config.raven_seek)}\nRestart opencode for the change to take effect.`
|
|
2562
|
+
: modelSetupInstructions("seek") })
|
|
2563
|
+
} else if (action === "effort" && value) {
|
|
2564
|
+
updateConfig({ raven_seek: { reasoning_effort: value } })
|
|
2565
|
+
output.parts.push({ type: "text", text: `Raven Search reasoning effort set to: ${value}\nRestart opencode for the change to take effect.` })
|
|
2566
|
+
} else if (action === "timeout") {
|
|
2567
|
+
const seconds = Number(value)
|
|
2568
|
+
if (!Number.isInteger(seconds) || seconds < 10 || seconds > MAX_TIMEOUT_SECONDS) {
|
|
2569
|
+
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` })
|
|
2570
|
+
} else {
|
|
2571
|
+
updateConfig({ raven_seek: { timeout: seconds } })
|
|
2572
|
+
output.parts.push({ type: "text", text: `Raven Search timeout set to ${seconds}s. Takes effect immediately.` })
|
|
2573
|
+
}
|
|
2574
|
+
} else {
|
|
2575
|
+
output.parts.push({ type: "text", text: "Usage:\n /raven seek model <provider/model>\n /raven seek effort <value>\n /raven seek timeout <seconds>" })
|
|
2576
|
+
}
|
|
2577
|
+
} else if (arg === "mcp") {
|
|
2555
2578
|
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
2579
|
} else if (arg.startsWith("mcp ")) {
|
|
2557
2580
|
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)
|
|
2581
|
+
const action = parts[1]?.toLowerCase()
|
|
2582
|
+
if (action === "refresh") {
|
|
2583
|
+
const serverName = parts.slice(2).join(" ").trim() || undefined
|
|
2584
|
+
const result = await queueOnDemandMcpRefresh(true, serverName)
|
|
2562
2585
|
await notifyIfMcpFailures(result.failed)
|
|
2563
2586
|
output.parts.push({
|
|
2564
2587
|
type: "text",
|
|
2565
2588
|
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
2589
|
})
|
|
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>" })
|
|
2590
|
+
} else if (action === "detail") {
|
|
2591
|
+
const detail = parts[2]?.toLowerCase()
|
|
2592
|
+
if (detail !== "full" && detail !== "minimized") {
|
|
2593
|
+
output.parts.push({ type: "text", text: `Usage: /raven mcp detail full|minimized\nCurrent: ${config.raven_mcp.descriptionDetail ?? "full"}` })
|
|
2594
|
+
} else {
|
|
2595
|
+
updateConfig({ raven_mcp: { descriptionDetail: detail } })
|
|
2596
|
+
output.parts.push({ type: "text", text: `On-demand MCP guidance detail set to: ${detail}\nRestart opencode for startup instructions/tool descriptions to fully refresh.` })
|
|
2597
|
+
}
|
|
2598
|
+
} else if (action === "model" && parts.slice(2).join(" ").trim()) {
|
|
2599
|
+
const model = parts.slice(2).join(" ").trim()
|
|
2600
|
+
if (model.toLowerCase() === "auto") {
|
|
2601
|
+
updateConfig({ raven_mcp: { model: undefined, autoSelectedModel: undefined } })
|
|
2602
|
+
await ensureRavenModels()
|
|
2603
|
+
} else {
|
|
2604
|
+
updateConfig({ raven_mcp: { model, autoSelectedModel: undefined } })
|
|
2605
|
+
}
|
|
2606
|
+
delete ravenProviderIssues.mcp
|
|
2607
|
+
output.parts.push({ type: "text", text: config.raven_mcp.model
|
|
2608
|
+
? `Raven MCP model set to: ${modelLabel(config.raven_mcp)}\nRestart opencode for the change to take effect.`
|
|
2609
|
+
: modelSetupInstructions("mcp") })
|
|
2610
|
+
} else if (action === "effort" && parts.slice(2).join(" ").trim()) {
|
|
2611
|
+
const effort = parts.slice(2).join(" ").trim()
|
|
2612
|
+
updateConfig({ raven_mcp: { reasoning_effort: effort } })
|
|
2613
|
+
output.parts.push({ type: "text", text: `Raven MCP reasoning effort set to: ${effort}\nRestart opencode for the change to take effect.` })
|
|
2614
|
+
} else if (action === "timeout") {
|
|
2615
|
+
const seconds = Number(parts[2])
|
|
2616
|
+
if (!Number.isInteger(seconds) || seconds < 10 || seconds > MAX_TIMEOUT_SECONDS) {
|
|
2617
|
+
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` })
|
|
2618
|
+
} else {
|
|
2619
|
+
updateConfig({ raven_mcp: { timeout: seconds } })
|
|
2620
|
+
output.parts.push({ type: "text", text: `Raven MCP timeout set to ${seconds}s. Takes effect immediately.` })
|
|
2621
|
+
}
|
|
2622
|
+
} else {
|
|
2623
|
+
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
2624
|
}
|
|
2602
2625
|
} else if (arg === "update") {
|
|
2603
2626
|
try {
|
|
@@ -2608,50 +2631,50 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
2608
2631
|
output.parts.push({ type: "text", text: `Raven is up to date (${info.current}). Latest on npm: ${info.latest}.` })
|
|
2609
2632
|
} else {
|
|
2610
2633
|
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)}` })
|
|
2634
|
+
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
2635
|
}
|
|
2613
2636
|
} catch (err: any) {
|
|
2614
2637
|
output.parts.push({ type: "text", text: `Raven update check failed: ${err?.message ?? err}\n\n${manualUpdateText()}` })
|
|
2615
2638
|
}
|
|
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}` : ""
|
|
2639
|
+
} else {
|
|
2640
|
+
const seek = config.raven_seek
|
|
2641
|
+
const mcp = config.raven_mcp
|
|
2642
|
+
const seekIssue = ravenProviderIssues.seek ? `\n Provider issue: ${ravenProviderIssues.seek}` : ""
|
|
2643
|
+
const mcpIssue = ravenProviderIssues.mcp ? `\n Provider issue: ${ravenProviderIssues.mcp}` : ""
|
|
2621
2644
|
let update = "Update: unable to check npm."
|
|
2622
2645
|
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."
|
|
2646
|
+
const info = await getUpdateInfo()
|
|
2647
|
+
update = info.available && info.latest
|
|
2648
|
+
? `Update: ${info.latest} available. Run /raven update, then restart opencode.`
|
|
2649
|
+
: info.latest
|
|
2650
|
+
? `Update: up to date (latest ${info.latest}).`
|
|
2651
|
+
: "Update: unable to check npm."
|
|
2629
2652
|
} 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.` })
|
|
2653
|
+
const setup = modelSetupInstructions()
|
|
2654
|
+
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
2655
|
}
|
|
2633
2656
|
},
|
|
2634
2657
|
|
|
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") {
|
|
2658
|
+
async "tool.execute.before"(input: any, output: any) {
|
|
2659
|
+
if ((input.tool === "raven_seek" || input.tool === "raven_mcp") && ravenSessions.has(input.sessionID)) {
|
|
2660
|
+
throw new Error(`${input.tool} cannot be called from inside a Raven agent. Use that agent's direct tools instead.`)
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
if (input.tool === "raven_mcp_bridge" && ravenSessionKinds.get(input.sessionID) !== "mcp" && sessionAgents.get(input.sessionID) !== "raven-mcp") {
|
|
2664
|
+
throw new Error("raven_mcp_bridge is only available inside the Raven MCP agent. Use raven_mcp for on-demand MCP requests.")
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
if (ravenSessions.has(input.sessionID)) return
|
|
2668
|
+
if (isExcluded(sessionAgents.get(input.sessionID))) return
|
|
2669
|
+
if (config.raven_seek.excludeTools?.some((name) => name.toLowerCase() === input.tool.toLowerCase())) return
|
|
2670
|
+
|
|
2671
|
+
if ((input.tool === "task" || input.tool === "subagent") && (output.args?.subagent_type ?? output.args?.agent) === "raven") {
|
|
2649
2672
|
ravenTaskCalls.add(input.callID)
|
|
2650
2673
|
}
|
|
2651
2674
|
|
|
2652
2675
|
// ── 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)
|
|
2676
|
+
const shouldRouteTool = input.tool === "bash" || input.tool === "shell" ? false : isRouteConfigured(input.tool)
|
|
2677
|
+
const isSearchBashCmd = isRouteConfigured(input.tool) && isSearchBash(input.tool, output.args || input.args)
|
|
2655
2678
|
|
|
2656
2679
|
if (shouldRouteTool || isSearchBashCmd) {
|
|
2657
2680
|
const args = compactArgs(output.args || input.args)
|
|
@@ -2660,7 +2683,7 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
2660
2683
|
}
|
|
2661
2684
|
},
|
|
2662
2685
|
|
|
2663
|
-
async "tool.execute.after"(input: any, output: any) {
|
|
2686
|
+
async "tool.execute.after"(input: any, output: any) {
|
|
2664
2687
|
if (ravenTaskCalls.has(input.callID)) {
|
|
2665
2688
|
ravenTaskCalls.delete(input.callID)
|
|
2666
2689
|
// Try task metadata first (built-in tools preserve metadata)
|
|
@@ -2679,4 +2702,4 @@ Then restart opencode for the model change to take effect. If the task can be co
|
|
|
2679
2702
|
}
|
|
2680
2703
|
},
|
|
2681
2704
|
}
|
|
2682
|
-
})
|
|
2705
|
+
})
|