opencode-raven 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +209 -204
- package/Raven.md +14 -10
- package/example.gif +0 -0
- package/index.ts +1019 -250
- package/mcp-guidance.md +24 -21
- package/package.json +6 -2
package/index.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { Plugin, PluginInput } from "@opencode-ai/plugin"
|
|
2
2
|
import { tool } from "@opencode-ai/plugin"
|
|
3
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
|
4
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
|
5
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
|
6
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
3
7
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, readdirSync, rmSync } from "node:fs"
|
|
4
8
|
import { join } from "node:path"
|
|
5
9
|
import { homedir, tmpdir } from "node:os"
|
|
@@ -13,28 +17,49 @@ const PACKAGE_JSON = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf
|
|
|
13
17
|
const PACKAGE_NAME = PACKAGE_JSON.name || "opencode-raven"
|
|
14
18
|
const PACKAGE_VERSION = PACKAGE_JSON.version || "0.0.0"
|
|
15
19
|
|
|
16
|
-
// ── Tools/MCPs that should be intercepted for non-Raven agents ──
|
|
17
|
-
const DEFAULT_ROUTE_TOOLS = [
|
|
18
|
-
"grep",
|
|
19
|
-
"glob",
|
|
20
|
-
"webfetch",
|
|
21
|
-
"fetch",
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
20
|
+
// ── Tools/MCPs that should be intercepted for non-Raven agents ──
|
|
21
|
+
const DEFAULT_ROUTE_TOOLS = [
|
|
22
|
+
"grep",
|
|
23
|
+
"glob",
|
|
24
|
+
"webfetch",
|
|
25
|
+
"fetch",
|
|
26
|
+
"websearch",
|
|
27
|
+
"bash",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const DEFAULT_ROUTE_TOOL_KEYWORDS: string[] = []
|
|
31
|
+
|
|
32
|
+
const DEFAULT_ON_DEMAND_MCP_SERVERS: Record<string, OnDemandMcpServer> = {
|
|
33
|
+
context7: {
|
|
34
|
+
type: "remote",
|
|
35
|
+
url: "https://mcp.context7.com/mcp",
|
|
36
|
+
description: "Library/framework documentation lookup.",
|
|
37
|
+
},
|
|
38
|
+
exa: {
|
|
39
|
+
type: "remote",
|
|
40
|
+
url: "https://mcp.exa.ai/mcp",
|
|
41
|
+
description: "Live web search and webpage fetching.",
|
|
42
|
+
},
|
|
43
|
+
grep_app: {
|
|
44
|
+
type: "remote",
|
|
45
|
+
url: "https://mcp.grep.app",
|
|
46
|
+
description: "Public GitHub code search and real-world examples.",
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const DEFAULT_ON_DEMAND_MCP_METADATA: Record<string, OnDemandMcpMetadata> = {
|
|
51
|
+
context7: {
|
|
52
|
+
autoGeneratedDescription: "Finds current library, framework, SDK, package, and API documentation; useful for implementation, configuration, and debugging with version-specific docs.",
|
|
53
|
+
},
|
|
54
|
+
exa: {
|
|
55
|
+
autoGeneratedDescription: "Searches the live web, fetches webpages, researches companies/products, and gathers current information from online sources.",
|
|
56
|
+
},
|
|
57
|
+
grep_app: {
|
|
58
|
+
autoGeneratedDescription: "Searches public GitHub repositories for real-world code examples, usage patterns, configuration snippets, and implementation references.",
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const NEVER_ROUTE_TOOLS = new Set(["raven_seek", "raven_mcp", "task", "subtask"])
|
|
38
63
|
|
|
39
64
|
// ── Bash commands that look like search workarounds ──
|
|
40
65
|
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
|
|
@@ -107,36 +132,74 @@ function isSearchBash(tool: string, args: any): boolean {
|
|
|
107
132
|
}
|
|
108
133
|
|
|
109
134
|
// ── Config file shape ──
|
|
110
|
-
interface
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
135
|
+
interface OnDemandMcpToolInfo {
|
|
136
|
+
name: string
|
|
137
|
+
description?: string
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface BaseOnDemandMcpServer {
|
|
141
|
+
description?: string
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface OnDemandMcpMetadata {
|
|
145
|
+
autoGeneratedDescription?: string
|
|
146
|
+
autoGeneratedTools?: OnDemandMcpToolInfo[]
|
|
147
|
+
avoidedSchemaBytes?: number
|
|
148
|
+
status?: "pending" | "ready" | "failed"
|
|
149
|
+
lastError?: string
|
|
150
|
+
lastCheckedAt?: string
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
interface RemoteOnDemandMcpServer extends BaseOnDemandMcpServer {
|
|
154
|
+
type: "remote"
|
|
155
|
+
url: string
|
|
156
|
+
headers?: Record<string, string>
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
interface StdioOnDemandMcpServer extends BaseOnDemandMcpServer {
|
|
160
|
+
type: "stdio"
|
|
161
|
+
command: string
|
|
162
|
+
args?: string[]
|
|
163
|
+
env?: Record<string, string>
|
|
164
|
+
cwd?: string
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
type OnDemandMcpServer = RemoteOnDemandMcpServer | StdioOnDemandMcpServer
|
|
168
|
+
type OnDemandMcpDescriptionDetail = "full" | "minimized"
|
|
169
|
+
type RavenMcpOperation = "list_tools" | "call_tool" | "list_resources" | "read_resource" | "list_prompts" | "get_prompt"
|
|
170
|
+
|
|
171
|
+
interface RavenConfig {
|
|
172
|
+
enabled: boolean
|
|
173
|
+
model?: string
|
|
174
|
+
reasoning_effort?: string
|
|
175
|
+
ravenInstructions?: string
|
|
176
|
+
routeTools?: string[]
|
|
177
|
+
routeToolKeywords?: string[]
|
|
178
|
+
onDemandMcpDescriptionDetail?: OnDemandMcpDescriptionDetail
|
|
179
|
+
onDemandMcpServers?: Record<string, OnDemandMcpServer>
|
|
180
|
+
excludeAgents?: string[]
|
|
181
|
+
excludeTools?: string[]
|
|
182
|
+
timeout?: number
|
|
183
|
+
stats?: { bytes: number }
|
|
184
|
+
}
|
|
123
185
|
|
|
124
186
|
// ── Parse Raven.md frontmatter ──
|
|
125
187
|
const ravenMd = readFileSync(RAVEN_MD, "utf-8")
|
|
126
188
|
const { frontmatter: fm, prompt: ravenPrompt } = parseRavenMd(ravenMd)
|
|
127
189
|
|
|
128
190
|
const DEFAULT_CONFIG: RavenConfig = {
|
|
129
|
-
enabled: true,
|
|
130
|
-
model: fm.model,
|
|
131
|
-
reasoning_effort: fm.reasoning_effort,
|
|
132
|
-
ravenInstructions: "",
|
|
133
|
-
routeTools: DEFAULT_ROUTE_TOOLS,
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
191
|
+
enabled: true,
|
|
192
|
+
model: fm.model,
|
|
193
|
+
reasoning_effort: fm.reasoning_effort,
|
|
194
|
+
ravenInstructions: "",
|
|
195
|
+
routeTools: DEFAULT_ROUTE_TOOLS,
|
|
196
|
+
routeToolKeywords: DEFAULT_ROUTE_TOOL_KEYWORDS,
|
|
197
|
+
onDemandMcpDescriptionDetail: "full",
|
|
198
|
+
onDemandMcpServers: DEFAULT_ON_DEMAND_MCP_SERVERS,
|
|
199
|
+
excludeAgents: [],
|
|
200
|
+
excludeTools: [],
|
|
201
|
+
timeout: 180,
|
|
202
|
+
}
|
|
140
203
|
|
|
141
204
|
function parseRavenMd(raw: string): { frontmatter: Record<string, any>; prompt: string } {
|
|
142
205
|
const parts = raw.split("---")
|
|
@@ -208,53 +271,158 @@ const KNOWN_KEYS = new Set([
|
|
|
208
271
|
"temperature", "top_p", "variant",
|
|
209
272
|
])
|
|
210
273
|
|
|
211
|
-
function extractOptions(fm: Record<string, any>): Record<string, any> {
|
|
212
|
-
const options: Record<string, any> = {}
|
|
213
|
-
for (const key of Object.keys(fm)) {
|
|
214
|
-
if (!KNOWN_KEYS.has(key)) options[key] = fm[key]
|
|
215
|
-
}
|
|
216
|
-
return options
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function uniqueStrings(value: unknown, fallback: string[] = []): string[] {
|
|
220
|
-
const source = Array.isArray(value) ? value : fallback
|
|
221
|
-
return [...new Set(source.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim()))]
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
274
|
+
function extractOptions(fm: Record<string, any>): Record<string, any> {
|
|
275
|
+
const options: Record<string, any> = {}
|
|
276
|
+
for (const key of Object.keys(fm)) {
|
|
277
|
+
if (!KNOWN_KEYS.has(key)) options[key] = fm[key]
|
|
278
|
+
}
|
|
279
|
+
return options
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function uniqueStrings(value: unknown, fallback: string[] = []): string[] {
|
|
283
|
+
const source = Array.isArray(value) ? value : fallback
|
|
284
|
+
return [...new Set(source.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim()))]
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function cloneOnDemandMcpServers(value: Record<string, OnDemandMcpServer>): Record<string, OnDemandMcpServer> {
|
|
288
|
+
return JSON.parse(JSON.stringify(value))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function cloneOnDemandMcpMetadata(value: Record<string, OnDemandMcpMetadata>): Record<string, OnDemandMcpMetadata> {
|
|
292
|
+
return JSON.parse(JSON.stringify(value))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function normalizeToolInfo(value: unknown): OnDemandMcpToolInfo[] | undefined {
|
|
296
|
+
if (!Array.isArray(value)) return undefined
|
|
297
|
+
const tools = value
|
|
298
|
+
.filter((item) => item && typeof item === "object" && typeof item.name === "string" && item.name.trim())
|
|
299
|
+
.map((item) => ({
|
|
300
|
+
name: item.name.trim(),
|
|
301
|
+
...(typeof item.description === "string" && item.description.trim() ? { description: item.description.trim() } : {}),
|
|
302
|
+
}))
|
|
303
|
+
return tools.length ? tools : undefined
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function normalizeOnDemandMcpServer(value: unknown): OnDemandMcpServer | undefined {
|
|
307
|
+
if (!value || typeof value !== "object") return undefined
|
|
308
|
+
const source = value as Record<string, any>
|
|
309
|
+
const base = {
|
|
310
|
+
...(typeof source.description === "string" && source.description.trim() ? { description: source.description.trim() } : {}),
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (source.type === "stdio") {
|
|
314
|
+
if (typeof source.command !== "string" || !source.command.trim()) return undefined
|
|
315
|
+
return {
|
|
316
|
+
...base,
|
|
317
|
+
type: "stdio",
|
|
318
|
+
command: source.command.trim(),
|
|
319
|
+
...(Array.isArray(source.args) ? { args: source.args.filter((arg: unknown) => typeof arg === "string") } : {}),
|
|
320
|
+
...(source.env && typeof source.env === "object" ? { env: Object.fromEntries(Object.entries(source.env).filter((entry): entry is [string, string] => typeof entry[1] === "string")) } : {}),
|
|
321
|
+
...(typeof source.cwd === "string" && source.cwd.trim() ? { cwd: source.cwd.trim() } : {}),
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (source.type === "remote" || typeof source.url === "string") {
|
|
326
|
+
if (typeof source.url !== "string" || !source.url.trim()) return undefined
|
|
327
|
+
return {
|
|
328
|
+
...base,
|
|
329
|
+
type: "remote",
|
|
330
|
+
url: source.url.trim(),
|
|
331
|
+
...(source.headers && typeof source.headers === "object" ? { headers: Object.fromEntries(Object.entries(source.headers).filter((entry): entry is [string, string] => typeof entry[1] === "string")) } : {}),
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return undefined
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function normalizeOnDemandMcpMetadata(value: unknown, fallback: Record<string, OnDemandMcpMetadata> = {}): Record<string, OnDemandMcpMetadata> {
|
|
339
|
+
const source = value && typeof value === "object" ? value as Record<string, any> : fallback
|
|
340
|
+
const result: Record<string, OnDemandMcpMetadata> = {}
|
|
341
|
+
for (const [key, raw] of Object.entries(source)) {
|
|
342
|
+
if (!key.trim() || !raw || typeof raw !== "object") continue
|
|
343
|
+
const item = raw as Record<string, unknown>
|
|
344
|
+
const metadata: OnDemandMcpMetadata = {}
|
|
345
|
+
if (typeof item.autoGeneratedDescription === "string" && item.autoGeneratedDescription.trim()) {
|
|
346
|
+
metadata.autoGeneratedDescription = item.autoGeneratedDescription.trim()
|
|
347
|
+
}
|
|
348
|
+
const tools = normalizeToolInfo(item.autoGeneratedTools)
|
|
349
|
+
if (tools) metadata.autoGeneratedTools = tools
|
|
350
|
+
if (typeof item.avoidedSchemaBytes === "number" && Number.isFinite(item.avoidedSchemaBytes) && item.avoidedSchemaBytes >= 0) {
|
|
351
|
+
metadata.avoidedSchemaBytes = Math.round(item.avoidedSchemaBytes)
|
|
352
|
+
}
|
|
353
|
+
if (item.status === "pending" || item.status === "ready" || item.status === "failed") metadata.status = item.status
|
|
354
|
+
if (typeof item.lastError === "string" && item.lastError.trim()) metadata.lastError = item.lastError.trim()
|
|
355
|
+
if (typeof item.lastCheckedAt === "string" && item.lastCheckedAt.trim()) metadata.lastCheckedAt = item.lastCheckedAt.trim()
|
|
356
|
+
if (metadata.autoGeneratedDescription || metadata.autoGeneratedTools?.length || metadata.avoidedSchemaBytes || metadata.status || metadata.lastError || metadata.lastCheckedAt) result[key.trim()] = metadata
|
|
357
|
+
}
|
|
358
|
+
return result
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function extractOnDemandMcpMetadata(value: unknown): Record<string, OnDemandMcpMetadata> {
|
|
362
|
+
return normalizeOnDemandMcpMetadata(value)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function normalizeOnDemandMcpServers(value: unknown, fallback: Record<string, OnDemandMcpServer> = {}): Record<string, OnDemandMcpServer> {
|
|
366
|
+
const source = value && typeof value === "object" ? value as Record<string, unknown> : fallback
|
|
367
|
+
const result: Record<string, OnDemandMcpServer> = {}
|
|
368
|
+
for (const [key, raw] of Object.entries(source)) {
|
|
369
|
+
if (!key.trim()) continue
|
|
370
|
+
const server = normalizeOnDemandMcpServer(raw)
|
|
371
|
+
if (server) result[key.trim()] = server
|
|
372
|
+
}
|
|
373
|
+
return result
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── Plugin ──
|
|
377
|
+
export default ((input: PluginInput) => {
|
|
226
378
|
const client = input.client
|
|
227
379
|
|
|
228
|
-
//
|
|
229
|
-
const
|
|
380
|
+
// Raven state lives under the global opencode config directory.
|
|
381
|
+
const opencodeConfigDir = join(homedir(), ".config", "opencode")
|
|
382
|
+
const ravenConfigDir = join(opencodeConfigDir, "opencode-raven")
|
|
383
|
+
const oldConfigFile = join(opencodeConfigDir, "raven-config.json")
|
|
384
|
+
const configFile = join(ravenConfigDir, "raven-config.json")
|
|
385
|
+
const mcpMetadataFile = join(ravenConfigDir, "autogenerated-on-demand-mcp-metadata.json")
|
|
386
|
+
const dynamicGuidanceFile = join(ravenConfigDir, "autogenerated-on-demand-guidance.md")
|
|
387
|
+
let migratedFromOldConfig = false
|
|
388
|
+
let initialMcpMetadata: Record<string, OnDemandMcpMetadata> = {}
|
|
230
389
|
|
|
231
390
|
function normalizeConfig(raw: any): RavenConfig {
|
|
232
391
|
const source = raw && typeof raw === "object" ? raw : {}
|
|
233
|
-
const normalized: RavenConfig = { ...DEFAULT_CONFIG
|
|
234
|
-
|
|
235
|
-
normalized.enabled = source.enabled !== false
|
|
236
|
-
normalized.model = typeof source.model === "string" ? source.model : DEFAULT_CONFIG.model
|
|
237
|
-
normalized.reasoning_effort = typeof source.reasoning_effort === "string" ? source.reasoning_effort : DEFAULT_CONFIG.reasoning_effort
|
|
238
|
-
normalized.ravenInstructions = typeof source.ravenInstructions === "string" ? source.ravenInstructions : DEFAULT_CONFIG.ravenInstructions
|
|
239
|
-
normalized.routeTools = uniqueStrings(source.routeTools, DEFAULT_ROUTE_TOOLS)
|
|
240
|
-
normalized.
|
|
241
|
-
normalized.
|
|
242
|
-
normalized.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
normalized.
|
|
392
|
+
const normalized: RavenConfig = { ...DEFAULT_CONFIG }
|
|
393
|
+
|
|
394
|
+
normalized.enabled = source.enabled !== false
|
|
395
|
+
normalized.model = typeof source.model === "string" ? source.model : DEFAULT_CONFIG.model
|
|
396
|
+
normalized.reasoning_effort = typeof source.reasoning_effort === "string" ? source.reasoning_effort : DEFAULT_CONFIG.reasoning_effort
|
|
397
|
+
normalized.ravenInstructions = typeof source.ravenInstructions === "string" ? source.ravenInstructions : DEFAULT_CONFIG.ravenInstructions
|
|
398
|
+
normalized.routeTools = uniqueStrings(source.routeTools, DEFAULT_ROUTE_TOOLS)
|
|
399
|
+
normalized.routeToolKeywords = uniqueStrings(source.routeToolKeywords, DEFAULT_ROUTE_TOOL_KEYWORDS)
|
|
400
|
+
normalized.onDemandMcpDescriptionDetail = source.onDemandMcpDescriptionDetail === "minimized" ? "minimized" : "full"
|
|
401
|
+
normalized.onDemandMcpServers = source.onDemandMcpServers === undefined
|
|
402
|
+
? cloneOnDemandMcpServers(DEFAULT_ON_DEMAND_MCP_SERVERS)
|
|
403
|
+
: normalizeOnDemandMcpServers(source.onDemandMcpServers)
|
|
404
|
+
normalized.excludeAgents = uniqueStrings(source.excludeAgents)
|
|
405
|
+
normalized.excludeTools = uniqueStrings(source.excludeTools)
|
|
406
|
+
normalized.timeout = typeof source.timeout === "number" ? source.timeout : DEFAULT_CONFIG.timeout
|
|
407
|
+
normalized.stats = source.stats || undefined
|
|
246
408
|
|
|
247
409
|
return normalized
|
|
248
410
|
}
|
|
249
411
|
|
|
250
412
|
function loadConfig(): RavenConfig {
|
|
251
413
|
try {
|
|
252
|
-
|
|
253
|
-
|
|
414
|
+
const sourceFile = existsSync(configFile) ? configFile : existsSync(oldConfigFile) ? oldConfigFile : undefined
|
|
415
|
+
if (sourceFile) {
|
|
416
|
+
const raw = JSON.parse(readFileSync(sourceFile, "utf-8"))
|
|
417
|
+
migratedFromOldConfig = sourceFile === oldConfigFile
|
|
418
|
+
initialMcpMetadata = extractOnDemandMcpMetadata(raw.onDemandMcpServers)
|
|
254
419
|
const normalized = normalizeConfig(raw)
|
|
255
|
-
if (JSON.stringify(raw) !== JSON.stringify(normalized)) {
|
|
420
|
+
if (migratedFromOldConfig || JSON.stringify(raw) !== JSON.stringify(normalized)) {
|
|
256
421
|
saveConfig(normalized)
|
|
257
422
|
}
|
|
423
|
+
if (migratedFromOldConfig) {
|
|
424
|
+
try { rmSync(oldConfigFile, { force: true }) } catch { /* best-effort */ }
|
|
425
|
+
}
|
|
258
426
|
return normalized
|
|
259
427
|
}
|
|
260
428
|
} catch { /* ignore corruption, use defaults */ }
|
|
@@ -265,20 +433,75 @@ export default ((input: PluginInput) => {
|
|
|
265
433
|
|
|
266
434
|
function saveConfig(config: RavenConfig) {
|
|
267
435
|
try {
|
|
268
|
-
mkdirSync(
|
|
436
|
+
mkdirSync(ravenConfigDir, { recursive: true })
|
|
269
437
|
writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n")
|
|
270
438
|
} catch { /* non-fatal: config won't persist but toggle still works in-session */ }
|
|
271
439
|
}
|
|
272
440
|
|
|
441
|
+
function loadMcpMetadata(seed: Record<string, OnDemandMcpMetadata>): Record<string, OnDemandMcpMetadata> {
|
|
442
|
+
const defaults = cloneOnDemandMcpMetadata(DEFAULT_ON_DEMAND_MCP_METADATA)
|
|
443
|
+
try {
|
|
444
|
+
if (existsSync(mcpMetadataFile)) {
|
|
445
|
+
return { ...defaults, ...normalizeOnDemandMcpMetadata(JSON.parse(readFileSync(mcpMetadataFile, "utf-8"))) }
|
|
446
|
+
}
|
|
447
|
+
} catch { /* ignore corruption, use defaults + seed */ }
|
|
448
|
+
const metadata = { ...defaults, ...seed }
|
|
449
|
+
saveMcpMetadata(metadata)
|
|
450
|
+
return metadata
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function saveMcpMetadata(metadata: Record<string, OnDemandMcpMetadata>) {
|
|
454
|
+
try {
|
|
455
|
+
mkdirSync(ravenConfigDir, { recursive: true })
|
|
456
|
+
writeFileSync(mcpMetadataFile, JSON.stringify(metadata, null, 2) + "\n")
|
|
457
|
+
} catch { /* non-fatal: generated metadata can be refreshed */ }
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function saveDynamicGuidance() {
|
|
461
|
+
try {
|
|
462
|
+
mkdirSync(ravenConfigDir, { recursive: true })
|
|
463
|
+
writeFileSync(dynamicGuidanceFile, `## Raven on-demand MCP capabilities\n\n${onDemandMcpGuidance("delegate")}\n`, "utf-8")
|
|
464
|
+
} catch { /* non-fatal: guidance still exists in tool/subagent paths */ }
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function cleanupOldRootFiles() {
|
|
468
|
+
try {
|
|
469
|
+
if (existsSync(configFile)) rmSync(oldConfigFile, { force: true })
|
|
470
|
+
} catch { /* best-effort migration cleanup */ }
|
|
471
|
+
}
|
|
472
|
+
|
|
273
473
|
let config = loadConfig()
|
|
474
|
+
let mcpMetadata = loadMcpMetadata(initialMcpMetadata)
|
|
274
475
|
const ravenSessions = new Set<string>()
|
|
275
476
|
const ravenTaskCalls = new Set<string>()
|
|
276
|
-
const ravenTaskPrompts = new Map<string, number>()
|
|
277
477
|
const sessionAgents = new Map<string, string>()
|
|
278
478
|
const ravenSessionParents = new Map<string, string>()
|
|
479
|
+
let globalMcpServers: string[] = []
|
|
480
|
+
let duplicateMcpServers: string[] = []
|
|
279
481
|
let updateInfo: { current: string; latest?: string; available: boolean } | undefined
|
|
280
482
|
let updateCheckPromise: Promise<{ current: string; latest?: string; available: boolean }> | undefined
|
|
281
483
|
let updateToastPending = false
|
|
484
|
+
let mcpFailureToastPending = false
|
|
485
|
+
let duplicateMcpToastPending = false
|
|
486
|
+
let mcpRefreshScheduled = false
|
|
487
|
+
let mcpRefreshPromise: Promise<{ refreshed: string[]; failed: string[] }> | undefined
|
|
488
|
+
let lastNotifiedMcpFailureKey = ""
|
|
489
|
+
|
|
490
|
+
resetOnDemandMcpRuntimeStatuses()
|
|
491
|
+
|
|
492
|
+
function resetOnDemandMcpRuntimeStatuses() {
|
|
493
|
+
let changed = false
|
|
494
|
+
for (const name of Object.keys(config.onDemandMcpServers ?? {})) {
|
|
495
|
+
const metadata = mcpMetadata[name] ?? {}
|
|
496
|
+
if (metadata.status !== "pending" || metadata.lastError || metadata.lastCheckedAt) {
|
|
497
|
+
mcpMetadata[name] = { ...metadata, status: "pending" }
|
|
498
|
+
delete mcpMetadata[name].lastError
|
|
499
|
+
delete mcpMetadata[name].lastCheckedAt
|
|
500
|
+
changed = true
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (changed) saveMcpMetadata(mcpMetadata)
|
|
504
|
+
}
|
|
282
505
|
|
|
283
506
|
// ── Check if an agent is excluded from Raven enforcement (case-insensitive) ──
|
|
284
507
|
function isExcluded(agent: string | undefined): boolean {
|
|
@@ -287,65 +510,472 @@ export default ((input: PluginInput) => {
|
|
|
287
510
|
return config.excludeAgents.some((a) => a.toLowerCase() === lower)
|
|
288
511
|
}
|
|
289
512
|
|
|
290
|
-
function ravenGuidance(): string {
|
|
291
|
-
const tools = config.routeTools?.length ? config.routeTools.join(", ") : "none"
|
|
292
|
-
const mcps =
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
if (
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
if (
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
return `Raven
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
|
|
513
|
+
function ravenGuidance(): string {
|
|
514
|
+
const tools = config.routeTools?.length ? config.routeTools.join(", ") : "none"
|
|
515
|
+
const mcps = globalMcpServers.length ? globalMcpServers.map((server) => `${server}_*`).join(", ") : "none"
|
|
516
|
+
const keywords = config.routeToolKeywords?.length ? config.routeToolKeywords.join(", ") : "none"
|
|
517
|
+
return `Some tools/MCPs are routed through Raven to save context. Routed tools: ${tools}. Auto-routed global MCP prefixes: ${mcps}. Routed tool-name keywords: ${keywords}. ${onDemandMcpGuidance("delegate")} If one is blocked, your next tool call should be raven_seek(query="<same request>"). Include the original tool/MCP name and relevant arguments.`
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function isRouteConfigured(toolName: string): boolean {
|
|
521
|
+
const tool = toolName.toLowerCase()
|
|
522
|
+
if (NEVER_ROUTE_TOOLS.has(tool)) return false
|
|
523
|
+
if (config.routeTools?.some((name) => name.toLowerCase() === tool)) return true
|
|
524
|
+
if (config.routeToolKeywords?.some((keyword) => tool.includes(keyword.toLowerCase()))) return true
|
|
525
|
+
return globalMcpServers.some((server) => tool.startsWith(`${server.toLowerCase()}_`))
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function compactArgs(value: any): any {
|
|
529
|
+
if (Array.isArray(value)) {
|
|
530
|
+
return value.map(compactArgs).filter((item) => item !== undefined)
|
|
531
|
+
}
|
|
532
|
+
if (!value || typeof value !== "object") return value
|
|
533
|
+
const result: Record<string, any> = {}
|
|
534
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
535
|
+
const item = compactArgs(raw)
|
|
536
|
+
if (item === undefined || item === null || item === "") continue
|
|
537
|
+
if (Array.isArray(item) && item.length === 0) continue
|
|
538
|
+
if (typeof item === "object" && !Array.isArray(item) && Object.keys(item).length === 0) continue
|
|
539
|
+
result[key] = item
|
|
540
|
+
}
|
|
541
|
+
return result
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function attemptedQuery(tool: string, args: any): string {
|
|
545
|
+
const compact = compactArgs(args)
|
|
546
|
+
if (!compact || typeof compact !== "object") return `${tool}: ${JSON.stringify(compact)}`
|
|
547
|
+
const direct = compact.query ?? compact.pattern ?? compact.url ?? compact.urls ?? compact.command ?? compact.path ?? compact.filePath
|
|
548
|
+
const value = direct !== undefined ? direct : compact
|
|
549
|
+
const text = typeof value === "string" ? value : JSON.stringify(value)
|
|
550
|
+
const query = value === compact ? `${tool} ${text}` : `${tool}: ${text}`
|
|
551
|
+
return query.length > 300 ? `${query.slice(0, 297)}...` : query
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function rerouteMessage(tool: string, args: any): string {
|
|
555
|
+
return `The '${tool}' tool call is blocked by Raven. Your next tool call should be raven_seek(query="${attemptedQuery(tool, args).replace(/"/g, "'")}").`
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function routeSummary(): string {
|
|
559
|
+
const tools = config.routeTools?.length ? config.routeTools.join(", ") : "(none)"
|
|
560
|
+
const mcps = globalMcpServers.length ? `${globalMcpServers.join(", ")} (auto-detected)` : "none detected"
|
|
561
|
+
const keywords = config.routeToolKeywords?.length ? config.routeToolKeywords.join(", ") : "(none)"
|
|
562
|
+
const duplicates = duplicateMcpServers.length ? `\n Duplicate MCP config: ${duplicateMcpServers.join(", ")} (configured globally and on-demand)` : ""
|
|
563
|
+
return `Raven routing:\n Routed tools: ${tools}\n Routed global MCPs: ${mcps}\n Routed tool keywords: ${keywords}${duplicates}`
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function mcpSummary(): string {
|
|
567
|
+
const buckets = onDemandMcpStatusBuckets()
|
|
568
|
+
const total = buckets.loaded.length + buckets.failed.length + buckets.pending.length
|
|
569
|
+
if (!total) return `Raven on-demand MCPs (always behind Raven): none configured.\n Guidance detail: ${config.onDemandMcpDescriptionDetail ?? "full"}`
|
|
570
|
+
const lines = [
|
|
571
|
+
"Raven on-demand MCPs (always behind Raven):",
|
|
572
|
+
` Loaded: ${buckets.loaded.length ? buckets.loaded.join(", ") : "none"}`,
|
|
573
|
+
` Failed: ${buckets.failed.length ? buckets.failed.join(", ") : "none"}`,
|
|
574
|
+
` Pending: ${buckets.pending.length ? buckets.pending.join(", ") : "none"}`,
|
|
575
|
+
` Guidance detail: ${config.onDemandMcpDescriptionDetail ?? "full"}`,
|
|
576
|
+
]
|
|
577
|
+
return lines.join("\n")
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function ravenAgentPrompt(): string {
|
|
581
|
+
const extra = config.ravenInstructions?.trim()
|
|
582
|
+
const onDemand = onDemandMcpGuidance("raven")
|
|
583
|
+
const prompt = `${ravenPrompt}\n\nOn-demand MCP guidance:\n${onDemand}\nUse direct MCP tools when they are available globally. Use raven_mcp for on-demand MCPs listed above. If you do not know the exact tool name for an on-demand MCP, call raven_mcp with operation list_tools first, then call the selected tool with operation call_tool. For call_tool and get_prompt, pass arguments as a JSON object string in argumentsJson.`
|
|
584
|
+
return extra ? `${prompt}\n\nAdditional user instructions:\n${extra}` : prompt
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function truncateText(value: unknown, max = 240): string {
|
|
588
|
+
const text = String(value ?? "").replace(/\s+/g, " ").trim()
|
|
589
|
+
return text.length > max ? `${text.slice(0, Math.max(0, max - 3))}...` : text
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function onDemandMcpGuidance(target: "delegate" | "raven" = "delegate"): string {
|
|
593
|
+
const servers = config.onDemandMcpServers ?? {}
|
|
594
|
+
const entries = Object.entries(servers)
|
|
595
|
+
if (!entries.length) return "No on-demand MCPs are configured."
|
|
596
|
+
|
|
597
|
+
const detail = config.onDemandMcpDescriptionDetail ?? "full"
|
|
598
|
+
const lines = entries.map(([name, server]) => {
|
|
599
|
+
const metadata = mcpMetadata[name] ?? {}
|
|
600
|
+
const userDescription = truncateText(server.description, 220)
|
|
601
|
+
const generated = truncateText(metadata.autoGeneratedDescription, 260)
|
|
602
|
+
const parts = [userDescription, generated ? `Discovered capabilities: ${generated}` : ""].filter(Boolean)
|
|
603
|
+
const summary = parts.length ? parts.join(" ") : "Description discovery pending."
|
|
604
|
+
if (detail === "minimized" || !metadata.autoGeneratedTools?.length) return `- ${name}: ${summary}`
|
|
605
|
+
const tools = metadata.autoGeneratedTools
|
|
606
|
+
.map((item) => `${item.name}${item.description ? ` - ${truncateText(item.description, 140)}` : ""}`)
|
|
607
|
+
.join("; ")
|
|
608
|
+
return `- ${name}: ${summary} Tools: ${tools}`
|
|
609
|
+
})
|
|
610
|
+
if (target === "raven") {
|
|
611
|
+
return `Raven can handle these on-demand MCP capabilities through raven_mcp:\n${lines.join("\n")}`
|
|
612
|
+
}
|
|
613
|
+
return `Raven can handle these on-demand MCP capabilities. Delegate matching requests with raven_seek; include the target MCP server and likely tool name when obvious, but do not call raven_mcp directly:\n${lines.join("\n")}`
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function resolveEnvPlaceholders(value: string): string {
|
|
617
|
+
return value.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => {
|
|
618
|
+
const envValue = process.env[name]
|
|
619
|
+
if (envValue === undefined) throw new Error(`Missing environment variable ${name}`)
|
|
620
|
+
return envValue
|
|
621
|
+
})
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function resolveStringRecord(value: Record<string, string> | undefined): Record<string, string> | undefined {
|
|
625
|
+
if (!value) return undefined
|
|
626
|
+
const result: Record<string, string> = {}
|
|
627
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
628
|
+
result[key] = resolveEnvPlaceholders(raw)
|
|
629
|
+
}
|
|
630
|
+
return result
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function cleanProcessEnv(): Record<string, string> {
|
|
634
|
+
const result: Record<string, string> = {}
|
|
635
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
636
|
+
if (typeof value === "string") result[key] = value
|
|
637
|
+
}
|
|
638
|
+
return result
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
642
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
643
|
+
try {
|
|
644
|
+
return await Promise.race([
|
|
645
|
+
promise,
|
|
646
|
+
new Promise<never>((_, reject) => {
|
|
647
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${Math.round(ms / 1000)}s`)), ms)
|
|
648
|
+
}),
|
|
649
|
+
])
|
|
650
|
+
} finally {
|
|
651
|
+
if (timer) clearTimeout(timer)
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
type McpConnection = {
|
|
656
|
+
client: Client
|
|
657
|
+
transport: any
|
|
658
|
+
serverName: string
|
|
659
|
+
lastUsed: number
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const mcpConnections = new Map<string, McpConnection>()
|
|
663
|
+
|
|
664
|
+
async function closeMcpConnection(connection: McpConnection) {
|
|
665
|
+
try {
|
|
666
|
+
if (typeof connection.transport?.terminateSession === "function") {
|
|
667
|
+
await connection.transport.terminateSession()
|
|
668
|
+
}
|
|
669
|
+
} catch { /* best-effort */ }
|
|
670
|
+
try {
|
|
671
|
+
await connection.client.close()
|
|
672
|
+
} catch { /* best-effort */ }
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async function closeMcpConnectionKey(key: string) {
|
|
676
|
+
const existing = mcpConnections.get(key)
|
|
677
|
+
if (!existing) return
|
|
678
|
+
mcpConnections.delete(key)
|
|
679
|
+
await closeMcpConnection(existing)
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function closeIdleMcpConnections(maxAgeMs = 10 * 60 * 1000) {
|
|
683
|
+
const now = Date.now()
|
|
684
|
+
for (const [key, connection] of mcpConnections) {
|
|
685
|
+
if (now - connection.lastUsed > maxAgeMs) {
|
|
686
|
+
void closeMcpConnectionKey(key)
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function mcpConnectionKey(sessionId: string, serverName: string): string {
|
|
692
|
+
return `${sessionId}:${serverName.toLowerCase()}`
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function getOnDemandMcpServer(serverName: string): [string, OnDemandMcpServer] | undefined {
|
|
696
|
+
const entries = Object.entries(config.onDemandMcpServers ?? {})
|
|
697
|
+
return entries.find(([name]) => name.toLowerCase() === serverName.toLowerCase())
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async function connectOnDemandMcp(serverName: string, server: OnDemandMcpServer): Promise<McpConnection> {
|
|
701
|
+
const timeout = (config.timeout ?? 180) * 1000
|
|
702
|
+
const client = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
|
|
703
|
+
|
|
704
|
+
if (server.type === "stdio") {
|
|
705
|
+
const env = { ...cleanProcessEnv(), ...resolveStringRecord(server.env) }
|
|
706
|
+
const transport = new StdioClientTransport({
|
|
707
|
+
command: resolveEnvPlaceholders(server.command),
|
|
708
|
+
args: server.args?.map((arg) => resolveEnvPlaceholders(arg)),
|
|
709
|
+
env,
|
|
710
|
+
cwd: server.cwd ? resolveEnvPlaceholders(server.cwd) : undefined,
|
|
711
|
+
})
|
|
712
|
+
await withTimeout(client.connect(transport), timeout, `Connecting to ${serverName}`)
|
|
713
|
+
return { client, transport, serverName, lastUsed: Date.now() }
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const url = new URL(resolveEnvPlaceholders(server.url))
|
|
717
|
+
const headers = resolveStringRecord(server.headers)
|
|
718
|
+
try {
|
|
719
|
+
const transport = new StreamableHTTPClientTransport(url, headers ? { requestInit: { headers } } : undefined)
|
|
720
|
+
await withTimeout(client.connect(transport), timeout, `Connecting to ${serverName}`)
|
|
721
|
+
return { client, transport, serverName, lastUsed: Date.now() }
|
|
722
|
+
} catch (err) {
|
|
723
|
+
try { await client.close() } catch { /* best-effort */ }
|
|
724
|
+
const fallbackClient = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
|
|
725
|
+
const fallback = new SSEClientTransport(url, headers ? { requestInit: { headers } } : undefined)
|
|
726
|
+
await withTimeout(fallbackClient.connect(fallback), timeout, `Connecting to ${serverName} via SSE`)
|
|
727
|
+
return { client: fallbackClient, transport: fallback, serverName, lastUsed: Date.now() }
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async function getMcpConnection(sessionId: string, serverName: string): Promise<McpConnection> {
|
|
732
|
+
closeIdleMcpConnections()
|
|
733
|
+
const match = getOnDemandMcpServer(serverName)
|
|
734
|
+
if (!match) throw new Error(`Unknown on-demand MCP server: ${serverName}`)
|
|
735
|
+
const [canonicalName, server] = match
|
|
736
|
+
const key = mcpConnectionKey(sessionId, canonicalName)
|
|
737
|
+
const existing = mcpConnections.get(key)
|
|
738
|
+
if (existing) {
|
|
739
|
+
existing.lastUsed = Date.now()
|
|
740
|
+
return existing
|
|
741
|
+
}
|
|
742
|
+
const connection = await connectOnDemandMcp(canonicalName, server)
|
|
743
|
+
mcpConnections.set(key, connection)
|
|
744
|
+
return connection
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function listAllMcpTools(client: Client): Promise<any[]> {
|
|
748
|
+
const tools: any[] = []
|
|
749
|
+
let cursor: string | undefined
|
|
750
|
+
do {
|
|
751
|
+
const result: any = await client.listTools(cursor ? { cursor } : undefined as any)
|
|
752
|
+
tools.push(...(result.tools ?? []))
|
|
753
|
+
cursor = result.nextCursor
|
|
754
|
+
} while (cursor)
|
|
755
|
+
return tools
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function listAllMcpResources(client: Client): Promise<any[]> {
|
|
759
|
+
const resources: any[] = []
|
|
760
|
+
let cursor: string | undefined
|
|
761
|
+
do {
|
|
762
|
+
const result: any = await client.listResources(cursor ? { cursor } : undefined as any)
|
|
763
|
+
resources.push(...(result.resources ?? []))
|
|
764
|
+
cursor = result.nextCursor
|
|
765
|
+
} while (cursor)
|
|
766
|
+
return resources
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function listAllMcpPrompts(client: Client): Promise<any[]> {
|
|
770
|
+
const prompts: any[] = []
|
|
771
|
+
let cursor: string | undefined
|
|
772
|
+
do {
|
|
773
|
+
const result: any = await client.listPrompts(cursor ? { cursor } : undefined as any)
|
|
774
|
+
prompts.push(...(result.prompts ?? []))
|
|
775
|
+
cursor = result.nextCursor
|
|
776
|
+
} while (cursor)
|
|
777
|
+
return prompts
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function generatedToolsFromMcpTools(tools: any[]): OnDemandMcpToolInfo[] {
|
|
781
|
+
return tools
|
|
782
|
+
.filter((item) => item?.name)
|
|
783
|
+
.map((item) => ({
|
|
784
|
+
name: String(item.name),
|
|
785
|
+
...(item.description ? { description: truncateText(item.description, 500) } : {}),
|
|
786
|
+
}))
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function estimateAvoidedMcpSchemaBytes(tools: any[]): number {
|
|
790
|
+
if (!tools.length) return 0
|
|
791
|
+
return JSON.stringify(tools).length
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function synthesizeMcpDescription(serverName: string, tools: OnDemandMcpToolInfo[]): string {
|
|
795
|
+
if (!tools.length) return `${serverName} exposes on-demand MCP capabilities. Use list_tools for available operations.`
|
|
796
|
+
const descriptions = tools.map((item) => item.description || item.name).filter(Boolean)
|
|
797
|
+
const joined = descriptions.slice(0, 6).join("; ")
|
|
798
|
+
return truncateText(`${serverName} provides on-demand MCP tools for ${joined}.`, 300)
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async function refreshOnDemandMcpDescriptions(force = false, serverName?: string): Promise<{ refreshed: string[]; failed: string[] }> {
|
|
802
|
+
const entries = Object.entries(config.onDemandMcpServers ?? {})
|
|
803
|
+
.filter(([name]) => !serverName || name.toLowerCase() === serverName.toLowerCase())
|
|
804
|
+
.filter(([name]) => {
|
|
805
|
+
const metadata = mcpMetadata[name]
|
|
806
|
+
return force || metadata?.status === "pending" || metadata?.status === "failed" || !metadata?.autoGeneratedDescription || !metadata.autoGeneratedTools?.length || metadata.avoidedSchemaBytes === undefined
|
|
807
|
+
})
|
|
808
|
+
const refreshed: string[] = []
|
|
809
|
+
const failed: string[] = []
|
|
810
|
+
if (!entries.length) return { refreshed, failed }
|
|
811
|
+
let changed = false
|
|
812
|
+
for (const [name, server] of entries) {
|
|
813
|
+
let connection: McpConnection | undefined
|
|
814
|
+
const checkedAt = new Date().toISOString()
|
|
815
|
+
try {
|
|
816
|
+
connection = await connectOnDemandMcp(name, server)
|
|
817
|
+
const rawTools = await listAllMcpTools(connection.client)
|
|
818
|
+
const tools = generatedToolsFromMcpTools(rawTools)
|
|
819
|
+
mcpMetadata[name] = {
|
|
820
|
+
autoGeneratedTools: tools,
|
|
821
|
+
autoGeneratedDescription: synthesizeMcpDescription(name, tools),
|
|
822
|
+
avoidedSchemaBytes: estimateAvoidedMcpSchemaBytes(rawTools),
|
|
823
|
+
status: "ready",
|
|
824
|
+
lastCheckedAt: checkedAt,
|
|
825
|
+
}
|
|
826
|
+
changed = true
|
|
827
|
+
refreshed.push(name)
|
|
828
|
+
} catch (err: any) {
|
|
829
|
+
mcpMetadata[name] = {
|
|
830
|
+
...(mcpMetadata[name] ?? {}),
|
|
831
|
+
status: "failed",
|
|
832
|
+
lastError: truncateText(err?.message ?? err, 500),
|
|
833
|
+
lastCheckedAt: checkedAt,
|
|
834
|
+
}
|
|
835
|
+
changed = true
|
|
836
|
+
failed.push(name)
|
|
837
|
+
// Keep startup/discovery best-effort; /raven mcp exposes failures.
|
|
838
|
+
} finally {
|
|
839
|
+
if (connection) await closeMcpConnection(connection)
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
if (changed) {
|
|
843
|
+
saveMcpMetadata(mcpMetadata)
|
|
844
|
+
saveDynamicGuidance()
|
|
845
|
+
}
|
|
846
|
+
return { refreshed, failed }
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function scheduleOnDemandMcpRefresh(): Promise<{ refreshed: string[]; failed: string[] }> {
|
|
850
|
+
if (mcpRefreshScheduled && mcpRefreshPromise) return mcpRefreshPromise
|
|
851
|
+
mcpRefreshScheduled = true
|
|
852
|
+
mcpRefreshPromise = new Promise((resolve) => {
|
|
853
|
+
setTimeout(() => {
|
|
854
|
+
void refreshOnDemandMcpDescriptions().then(resolve)
|
|
855
|
+
}, 1000)
|
|
856
|
+
})
|
|
857
|
+
return mcpRefreshPromise
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function onDemandMcpRuntimeStatus(name: string): "pending" | "ready" | "failed" {
|
|
861
|
+
const metadata = mcpMetadata[name]
|
|
862
|
+
if (metadata?.status) return metadata.status
|
|
863
|
+
return "pending"
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function displayMcpStatus(status: "pending" | "ready" | "failed"): "pending" | "loaded" | "failed" {
|
|
867
|
+
return status === "ready" ? "loaded" : status
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function onDemandMcpStatusBuckets(): { loaded: string[]; failed: string[]; pending: string[] } {
|
|
871
|
+
const buckets = { loaded: [] as string[], failed: [] as string[], pending: [] as string[] }
|
|
872
|
+
for (const name of Object.keys(config.onDemandMcpServers ?? {})) {
|
|
873
|
+
const status = onDemandMcpRuntimeStatus(name)
|
|
874
|
+
if (status === "ready") buckets.loaded.push(name)
|
|
875
|
+
else if (status === "failed") buckets.failed.push(name)
|
|
876
|
+
else buckets.pending.push(name)
|
|
877
|
+
}
|
|
878
|
+
return buckets
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function failedOnDemandMcpNames(names?: string[]): string[] {
|
|
882
|
+
const configured = Object.keys(config.onDemandMcpServers ?? {})
|
|
883
|
+
const wanted = names?.length ? names : configured
|
|
884
|
+
return wanted.filter((name) => configured.includes(name) && onDemandMcpRuntimeStatus(name) === "failed")
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function setOnDemandMcpHealth(serverName: string, status: "ready" | "failed", error?: unknown) {
|
|
888
|
+
const match = getOnDemandMcpServer(serverName)
|
|
889
|
+
if (!match) return undefined
|
|
890
|
+
const [name] = match
|
|
891
|
+
const next: OnDemandMcpMetadata = {
|
|
892
|
+
...(mcpMetadata[name] ?? {}),
|
|
893
|
+
status,
|
|
894
|
+
lastCheckedAt: new Date().toISOString(),
|
|
895
|
+
}
|
|
896
|
+
if (status === "failed") next.lastError = truncateText(error, 500)
|
|
897
|
+
else delete next.lastError
|
|
898
|
+
mcpMetadata[name] = next
|
|
899
|
+
saveMcpMetadata(mcpMetadata)
|
|
900
|
+
return name
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function onDemandMcpStatus(): string {
|
|
904
|
+
const entries = Object.entries(config.onDemandMcpServers ?? {})
|
|
905
|
+
if (!entries.length) return "Raven on-demand MCPs: none configured."
|
|
906
|
+
const buckets = onDemandMcpStatusBuckets()
|
|
907
|
+
const lines = entries.map(([name, server]) => {
|
|
908
|
+
const metadata = mcpMetadata[name]
|
|
909
|
+
const source = server.description ? "user description" : "no user description"
|
|
910
|
+
const generated = metadata?.autoGeneratedDescription ? "generated" : "missing generated description"
|
|
911
|
+
const tools = metadata?.autoGeneratedTools?.length ? `${metadata.autoGeneratedTools.length} tools` : "tools pending"
|
|
912
|
+
const status = onDemandMcpRuntimeStatus(name)
|
|
913
|
+
const avoided = status === "ready" && metadata?.avoidedSchemaBytes ? `, ${formatBytes(metadata.avoidedSchemaBytes)} avoided schema` : ""
|
|
914
|
+
const error = status === "failed" && metadata?.lastError ? `, error: ${metadata.lastError}` : ""
|
|
915
|
+
return `- ${name} (${server.type}): ${displayMcpStatus(status)}, ${source}, ${generated}, ${tools}${avoided}${error}`
|
|
916
|
+
})
|
|
917
|
+
return `Raven on-demand MCPs (${config.onDemandMcpDescriptionDetail ?? "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")}`
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function avoidedMcpSchemaBytes(): number {
|
|
921
|
+
return Object.keys(config.onDemandMcpServers ?? {}).reduce((sum, name) => {
|
|
922
|
+
const metadata = mcpMetadata[name]
|
|
923
|
+
if (onDemandMcpRuntimeStatus(name) !== "ready") return sum
|
|
924
|
+
return sum + Math.max(0, metadata?.avoidedSchemaBytes ?? 0)
|
|
925
|
+
}, 0)
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function statsText(): string {
|
|
929
|
+
const avoided = avoidedMcpSchemaBytes()
|
|
930
|
+
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)`
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function helpText(): string {
|
|
934
|
+
return `Raven commands:\n /raven help — show this help\n /raven on — enable tool/MCP routing\n /raven off — disable tool/MCP routing\n /raven route — show or edit routed tools/keywords\n /raven mcp — show or refresh on-demand MCP metadata\n /raven update — check npm, clear plugin cache if newer, then restart opencode\n /raven model <name> — change Raven's model (requires restart)\n /raven effort <value> — change Raven's reasoning effort (requires restart)\n /raven timeout <seconds> — change raven_seek timeout\n /raven stats — show context saved`
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function formatMcpList(title: string, items: any[], fields: string[] = ["name", "description"]): string {
|
|
938
|
+
if (!items.length) return `${title}: none found.`
|
|
939
|
+
const lines = items.map((item) => {
|
|
940
|
+
const primary = fields.map((field) => item?.[field]).find(Boolean) ?? JSON.stringify(item)
|
|
941
|
+
const description = item?.description && primary !== item.description ? ` - ${truncateText(item.description, 300)}` : ""
|
|
942
|
+
return `- ${truncateText(primary, 180)}${description}`
|
|
943
|
+
})
|
|
944
|
+
return `${title}:\n${lines.join("\n")}`
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function formatMcpToolsForRaven(serverName: string, tools: any[]): string {
|
|
948
|
+
if (!tools.length) return `Tools for ${serverName}: none found.`
|
|
949
|
+
const lines = tools.map((item) => {
|
|
950
|
+
const description = item?.description ? `\n Description: ${truncateText(item.description, 600)}` : ""
|
|
951
|
+
const schema = item?.inputSchema ? `\n Input schema: ${truncateText(JSON.stringify(item.inputSchema), 3000)}` : ""
|
|
952
|
+
return `- ${item?.name ?? "(unnamed)"}${description}${schema}`
|
|
953
|
+
})
|
|
954
|
+
return `Tools for ${serverName}:\n${lines.join("\n")}`
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function formatMcpResult(result: any): string {
|
|
958
|
+
if (!result) return "No result."
|
|
959
|
+
if (Array.isArray(result.content)) {
|
|
960
|
+
const parts = result.content.map((part: any) => {
|
|
961
|
+
if (part?.type === "text") return part.text
|
|
962
|
+
return JSON.stringify(part)
|
|
963
|
+
})
|
|
964
|
+
return parts.join("\n") || JSON.stringify(result)
|
|
965
|
+
}
|
|
966
|
+
return typeof result === "string" ? result : JSON.stringify(result, null, 2)
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function parseMcpArguments(raw: string | undefined): Record<string, any> {
|
|
970
|
+
if (!raw?.trim()) return {}
|
|
971
|
+
const parsed = JSON.parse(raw)
|
|
972
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
973
|
+
throw new Error("argumentsJson must be a JSON object")
|
|
974
|
+
}
|
|
975
|
+
return parsed
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// ── Context saved by Raven delegation ──
|
|
349
979
|
let sessionBytes = 0
|
|
350
980
|
let totalBytes = config.stats?.bytes ?? 0
|
|
351
981
|
|
|
@@ -400,18 +1030,25 @@ export default ((input: PluginInput) => {
|
|
|
400
1030
|
return { current: PACKAGE_VERSION, latest, available: !!latest && compareVersions(latest, PACKAGE_VERSION) > 0 }
|
|
401
1031
|
}
|
|
402
1032
|
|
|
403
|
-
async function
|
|
404
|
-
// Get last assistant message token counts (matches TUI bottom bar)
|
|
1033
|
+
async function countRavenSavedCandidateBytes(sessionId: string): Promise<number> {
|
|
405
1034
|
const messagesResp = await client.session.messages({ path: { id: sessionId }, query: { limit: 200 } })
|
|
406
1035
|
const messages = (messagesResp as any)?.data ?? []
|
|
407
|
-
|
|
408
|
-
const last = [...messages].reverse().find((m: any) =>
|
|
1036
|
+
const assistantMessages = messages.filter((m: any) =>
|
|
409
1037
|
m?.info?.role === "assistant" && m?.info?.tokens?.output > 0
|
|
410
1038
|
)
|
|
1039
|
+
const first = assistantMessages[0]
|
|
1040
|
+
const last = assistantMessages[assistantMessages.length - 1]
|
|
411
1041
|
const t = last?.info?.tokens
|
|
1042
|
+
const firstTokens = first?.info?.tokens
|
|
412
1043
|
if (!t) return 0
|
|
1044
|
+
|
|
1045
|
+
// Balanced estimate: total Raven context minus the first-turn prompt/schema/cache baseline.
|
|
413
1046
|
const totalTokens = (t.input ?? 0) + (t.output ?? 0) + (t.reasoning ?? 0) + (t.cache?.read ?? 0) + (t.cache?.write ?? 0)
|
|
414
|
-
|
|
1047
|
+
const baselineTokens = firstTokens
|
|
1048
|
+
? (firstTokens.input ?? 0) + (firstTokens.cache?.read ?? 0) + (firstTokens.cache?.write ?? 0)
|
|
1049
|
+
: 0
|
|
1050
|
+
const savedCandidateTokens = Math.max(0, totalTokens - baselineTokens)
|
|
1051
|
+
return savedCandidateTokens * 4
|
|
415
1052
|
}
|
|
416
1053
|
|
|
417
1054
|
async function getUpdateInfo(): Promise<{ current: string; latest?: string; available: boolean }> {
|
|
@@ -477,34 +1114,58 @@ export default ((input: PluginInput) => {
|
|
|
477
1114
|
} catch { /* update checks are best-effort */ }
|
|
478
1115
|
}
|
|
479
1116
|
|
|
480
|
-
function
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
1117
|
+
async function notifyIfMcpFailures(names?: string[]) {
|
|
1118
|
+
try {
|
|
1119
|
+
const failed = failedOnDemandMcpNames(names)
|
|
1120
|
+
if (!failed.length) return
|
|
1121
|
+
const key = failed.sort().join("|")
|
|
1122
|
+
if (key === lastNotifiedMcpFailureKey) return
|
|
1123
|
+
lastNotifiedMcpFailureKey = key
|
|
1124
|
+
const label = failed.length === 1 ? failed[0] : `${failed.length} on-demand MCPs`
|
|
1125
|
+
await (client as any).tui?.showToast?.({
|
|
1126
|
+
body: {
|
|
1127
|
+
title: "Raven MCP warning",
|
|
1128
|
+
message: `${label} failed to load. Run /raven mcp for details.`,
|
|
1129
|
+
variant: "warning",
|
|
1130
|
+
duration: 12000,
|
|
1131
|
+
},
|
|
1132
|
+
})
|
|
1133
|
+
} catch { /* MCP failure notifications are best-effort */ }
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
async function notifyIfDuplicateMcps() {
|
|
1137
|
+
try {
|
|
1138
|
+
if (!duplicateMcpServers.length) return
|
|
1139
|
+
const label = duplicateMcpServers.length === 1 ? duplicateMcpServers[0] : `${duplicateMcpServers.length} MCPs`
|
|
1140
|
+
await (client as any).tui?.showToast?.({
|
|
1141
|
+
body: {
|
|
1142
|
+
title: "Raven MCP duplicate config",
|
|
1143
|
+
message: `${label} configured both globally and on-demand. Prefer one location to avoid duplicate schema/connection behavior.`,
|
|
1144
|
+
variant: "warning",
|
|
1145
|
+
duration: 12000,
|
|
1146
|
+
},
|
|
1147
|
+
})
|
|
1148
|
+
} catch { /* duplicate MCP notifications are best-effort */ }
|
|
491
1149
|
}
|
|
492
1150
|
|
|
493
1151
|
return {
|
|
494
1152
|
config(configInput: any) {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
}
|
|
501
|
-
}
|
|
1153
|
+
globalMcpServers = uniqueStrings(Object.entries(configInput.mcp ?? {})
|
|
1154
|
+
.filter(([, value]) => !value || typeof value !== "object" || (value as any).enabled !== false)
|
|
1155
|
+
.map(([name]) => name))
|
|
1156
|
+
const onDemandNames = new Set(Object.keys(config.onDemandMcpServers ?? {}).map((name) => name.toLowerCase()))
|
|
1157
|
+
duplicateMcpServers = globalMcpServers.filter((name) => onDemandNames.has(name.toLowerCase()))
|
|
502
1158
|
|
|
503
1159
|
// Inject MCP guidance as a startup instruction file (absolute path for npm compat)
|
|
1160
|
+
saveDynamicGuidance()
|
|
1161
|
+
cleanupOldRootFiles()
|
|
504
1162
|
configInput.instructions = configInput.instructions || []
|
|
505
1163
|
if (!configInput.instructions.includes(MCP_GUIDANCE_MD)) {
|
|
506
1164
|
configInput.instructions.push(MCP_GUIDANCE_MD)
|
|
507
1165
|
}
|
|
1166
|
+
if (!configInput.instructions.includes(dynamicGuidanceFile)) {
|
|
1167
|
+
configInput.instructions.push(dynamicGuidanceFile)
|
|
1168
|
+
}
|
|
508
1169
|
|
|
509
1170
|
// Register Raven from Raven.md, with config file overrides
|
|
510
1171
|
configInput.agent = configInput.agent || {}
|
|
@@ -518,27 +1179,30 @@ export default ((input: PluginInput) => {
|
|
|
518
1179
|
...(config.reasoning_effort ? { reasoning_effort: config.reasoning_effort } : {}),
|
|
519
1180
|
},
|
|
520
1181
|
permission: fm.permission || {},
|
|
521
|
-
prompt: ravenAgentPrompt(),
|
|
522
|
-
}
|
|
1182
|
+
prompt: ravenAgentPrompt(),
|
|
1183
|
+
}
|
|
523
1184
|
|
|
524
1185
|
// Register /raven command
|
|
525
1186
|
configInput.command = configInput.command || {}
|
|
526
1187
|
if (!configInput.command.raven) {
|
|
527
|
-
configInput.command.raven = {
|
|
528
|
-
template: "Manage Raven: /raven on|off|route|update|model <name>|status",
|
|
529
|
-
description: "Toggle Raven routing, manage routed
|
|
530
|
-
}
|
|
1188
|
+
configInput.command.raven = {
|
|
1189
|
+
template: "Manage Raven: /raven on|off|route|mcp|update|model <name>|status",
|
|
1190
|
+
description: "Toggle Raven routing, manage routed/on-demand MCPs, or change Raven's model",
|
|
1191
|
+
}
|
|
531
1192
|
}
|
|
532
1193
|
|
|
1194
|
+
scheduleOnDemandMcpRefresh()
|
|
533
1195
|
updateToastPending = true
|
|
1196
|
+
mcpFailureToastPending = true
|
|
1197
|
+
duplicateMcpToastPending = duplicateMcpServers.length > 0
|
|
534
1198
|
},
|
|
535
1199
|
|
|
536
|
-
// Register raven_seek tool — lets agents with task:false still delegate through Raven
|
|
1200
|
+
// Register raven_seek tool — lets agents with task:false still delegate through Raven
|
|
537
1201
|
tool: {
|
|
538
1202
|
"raven_seek": tool({
|
|
539
|
-
description: "Unified Raven delegation tool. Use this whenever a tool/MCP is blocked by Raven, or when grep, glob, WebFetch/fetch, websearch, docs lookup, GitHub search, or search-like bash would be used. Handles routed MCP requests, local codebase search, filesystem discovery, specific URL/page reads, web/docs research, GitHub examples, and command-output/system inspection via Raven.",
|
|
1203
|
+
description: "Unified Raven delegation tool. Use this whenever a tool/MCP is blocked by Raven, or when grep, glob, WebFetch/fetch, websearch, docs lookup, GitHub search, on-demand MCP capabilities, or search-like bash would be used. Handles routed MCP requests, on-demand MCPs, local codebase search, filesystem discovery, specific URL/page reads, web/docs research, GitHub examples, and command-output/system inspection via Raven.",
|
|
540
1204
|
args: {
|
|
541
|
-
query: tool.schema.string().describe("What Raven should do. Include the original blocked tool/MCP request and relevant args, exact URLs when replacing WebFetch, and commands/output checks when replacing grep/rg/head over command output."),
|
|
1205
|
+
query: tool.schema.string().describe("What Raven should do. Include the original blocked tool/MCP request and relevant args, exact URLs when replacing WebFetch, and commands/output checks when replacing grep/rg/head over command output."),
|
|
542
1206
|
},
|
|
543
1207
|
async execute(args, context) {
|
|
544
1208
|
const started = Date.now()
|
|
@@ -593,23 +1257,21 @@ export default ((input: PluginInput) => {
|
|
|
593
1257
|
.map((p: any) => p.text)
|
|
594
1258
|
const output = textParts.join("\n") || "Raven returned no results."
|
|
595
1259
|
|
|
596
|
-
//
|
|
597
|
-
let
|
|
1260
|
+
// Context saved = Raven output/reasoning/cache context - compact answer returned to main session.
|
|
1261
|
+
let savedCandidate = 0
|
|
598
1262
|
try {
|
|
599
|
-
|
|
1263
|
+
savedCandidate = await countRavenSavedCandidateBytes(sessionId)
|
|
600
1264
|
} catch { /* best-effort */ }
|
|
601
|
-
if (
|
|
1265
|
+
if (savedCandidate <= 0) {
|
|
602
1266
|
for (const part of parts) {
|
|
603
|
-
if (part.
|
|
604
|
-
if (part.
|
|
605
|
-
if (part.content) totalProcessed += typeof part.content === "string" ? part.content.length : JSON.stringify(part.content).length
|
|
1267
|
+
if (part.args) savedCandidate += JSON.stringify(part.args).length
|
|
1268
|
+
if (part.content) savedCandidate += typeof part.content === "string" ? part.content.length : JSON.stringify(part.content).length
|
|
606
1269
|
}
|
|
607
1270
|
}
|
|
608
|
-
|
|
609
|
-
const saved = Math.max(0, totalProcessed - output.length - String(args.query).length)
|
|
1271
|
+
const saved = Math.max(0, savedCandidate - output.length)
|
|
610
1272
|
addBytes(saved)
|
|
611
1273
|
|
|
612
|
-
return { title: "Raven Seek", metadata: { sessionId }, output: `${output}\n\n*Raven searched for ${elapsed}s — ${formatBytes(
|
|
1274
|
+
return { title: "Raven Seek", metadata: { sessionId }, output: `${output}\n\n*Raven searched for ${elapsed}s — ${formatBytes(savedCandidate)} handled, ${formatTokens(savedCandidate)} tokens*` }
|
|
613
1275
|
} catch (err: any) {
|
|
614
1276
|
const elapsed = ((Date.now() - started) / 1000).toFixed(1)
|
|
615
1277
|
const msg = String(err?.message ?? err ?? "").toLowerCase()
|
|
@@ -624,6 +1286,85 @@ export default ((input: PluginInput) => {
|
|
|
624
1286
|
}
|
|
625
1287
|
},
|
|
626
1288
|
}),
|
|
1289
|
+
"raven_mcp": tool({
|
|
1290
|
+
description: "Raven-only bridge for on-demand MCP servers. Non-Raven agents must not call this directly; use raven_seek instead. Raven uses this to list and call tools/resources/prompts from MCPs configured in Raven's onDemandMcpServers without exposing their full schemas to the main model.",
|
|
1291
|
+
args: {
|
|
1292
|
+
server: tool.schema.string().describe("On-demand MCP server name from Raven config, e.g. context7, exa, grep_app, unityMCP."),
|
|
1293
|
+
operation: tool.schema.enum(["list_tools", "call_tool", "list_resources", "read_resource", "list_prompts", "get_prompt"]).describe("MCP operation to perform."),
|
|
1294
|
+
name: tool.schema.string().optional().describe("Tool name for call_tool, resource URI for read_resource, or prompt name for get_prompt."),
|
|
1295
|
+
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."),
|
|
1296
|
+
},
|
|
1297
|
+
async execute(args, context) {
|
|
1298
|
+
if (!ravenSessions.has(context.sessionID) && sessionAgents.get(context.sessionID) !== "raven") {
|
|
1299
|
+
return { title: "Raven MCP", output: "raven_mcp is only available inside Raven. Use raven_seek for MCP requests." }
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const operation = args.operation as RavenMcpOperation
|
|
1303
|
+
const timeout = (config.timeout ?? 180) * 1000
|
|
1304
|
+
let connected = false
|
|
1305
|
+
try {
|
|
1306
|
+
const connection = await getMcpConnection(context.sessionID, args.server)
|
|
1307
|
+
connected = true
|
|
1308
|
+
setOnDemandMcpHealth(args.server, "ready")
|
|
1309
|
+
connection.lastUsed = Date.now()
|
|
1310
|
+
const client = connection.client
|
|
1311
|
+
|
|
1312
|
+
if (operation === "list_tools") {
|
|
1313
|
+
const tools = await withTimeout(listAllMcpTools(client), timeout, `Listing ${args.server} tools`)
|
|
1314
|
+
const match = getOnDemandMcpServer(args.server)
|
|
1315
|
+
if (match) {
|
|
1316
|
+
const [name] = match
|
|
1317
|
+
const generatedTools = generatedToolsFromMcpTools(tools)
|
|
1318
|
+
mcpMetadata[name] = {
|
|
1319
|
+
autoGeneratedTools: generatedTools,
|
|
1320
|
+
autoGeneratedDescription: mcpMetadata[name]?.autoGeneratedDescription || synthesizeMcpDescription(name, generatedTools),
|
|
1321
|
+
avoidedSchemaBytes: estimateAvoidedMcpSchemaBytes(tools),
|
|
1322
|
+
status: "ready",
|
|
1323
|
+
lastCheckedAt: new Date().toISOString(),
|
|
1324
|
+
}
|
|
1325
|
+
saveMcpMetadata(mcpMetadata)
|
|
1326
|
+
}
|
|
1327
|
+
return { title: `Raven MCP: ${args.server}`, output: formatMcpToolsForRaven(args.server, tools) }
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
if (operation === "call_tool") {
|
|
1331
|
+
if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "call_tool requires name." }
|
|
1332
|
+
const result = await withTimeout(client.callTool({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }), timeout, `Calling ${args.server}.${args.name}`)
|
|
1333
|
+
return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
if (operation === "list_resources") {
|
|
1337
|
+
const resources = await withTimeout(listAllMcpResources(client), timeout, `Listing ${args.server} resources`)
|
|
1338
|
+
return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Resources for ${args.server}`, resources, ["uri", "name", "description"]) }
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
if (operation === "read_resource") {
|
|
1342
|
+
if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "read_resource requires name as the resource URI." }
|
|
1343
|
+
const result = await withTimeout(client.readResource({ uri: args.name }), timeout, `Reading ${args.server} resource`)
|
|
1344
|
+
return { title: `Raven MCP: ${args.server}`, output: formatMcpResult(result) }
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
if (operation === "list_prompts") {
|
|
1348
|
+
const prompts = await withTimeout(listAllMcpPrompts(client), timeout, `Listing ${args.server} prompts`)
|
|
1349
|
+
return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Prompts for ${args.server}`, prompts) }
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
if (operation === "get_prompt") {
|
|
1353
|
+
if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "get_prompt requires name." }
|
|
1354
|
+
const result = await withTimeout(client.getPrompt({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }), timeout, `Getting ${args.server} prompt`)
|
|
1355
|
+
return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
return { title: `Raven MCP: ${args.server}`, output: `Unsupported operation: ${operation}` }
|
|
1359
|
+
} catch (err: any) {
|
|
1360
|
+
if (!connected) {
|
|
1361
|
+
const name = setOnDemandMcpHealth(args.server, "failed", err?.message ?? err)
|
|
1362
|
+
if (name) await notifyIfMcpFailures([name])
|
|
1363
|
+
}
|
|
1364
|
+
return { title: `Raven MCP: ${args.server}`, output: `MCP call failed: ${err?.message ?? err}` }
|
|
1365
|
+
}
|
|
1366
|
+
},
|
|
1367
|
+
}),
|
|
627
1368
|
},
|
|
628
1369
|
|
|
629
1370
|
// Track agent ↔ session mapping for allowlist + Raven exclusion
|
|
@@ -643,49 +1384,85 @@ export default ((input: PluginInput) => {
|
|
|
643
1384
|
ravenSessionParents.set(evt.properties.parentID, evt.properties.id)
|
|
644
1385
|
}
|
|
645
1386
|
|
|
646
|
-
if (
|
|
647
|
-
|
|
648
|
-
|
|
1387
|
+
if (updateToastPending) {
|
|
1388
|
+
updateToastPending = false
|
|
1389
|
+
setTimeout(() => void notifyIfUpdateAvailable(), 500)
|
|
1390
|
+
}
|
|
1391
|
+
if (mcpFailureToastPending) {
|
|
1392
|
+
mcpFailureToastPending = false
|
|
1393
|
+
void scheduleOnDemandMcpRefresh().then((result) => notifyIfMcpFailures(result.failed))
|
|
1394
|
+
}
|
|
1395
|
+
if (duplicateMcpToastPending) {
|
|
1396
|
+
duplicateMcpToastPending = false
|
|
1397
|
+
setTimeout(() => void notifyIfDuplicateMcps(), 700)
|
|
1398
|
+
}
|
|
649
1399
|
},
|
|
650
1400
|
|
|
651
|
-
// /raven on|off|route|model <name>|effort <value>|timeout <seconds>|stats|status
|
|
1401
|
+
// /raven help|on|off|route|mcp|model <name>|effort <value>|timeout <seconds>|stats|status
|
|
652
1402
|
async "command.execute.before"(input: any, output: any) {
|
|
653
1403
|
if (input.command !== "raven") return
|
|
654
1404
|
output.parts.length = 0
|
|
655
1405
|
const raw = input.arguments.trim()
|
|
656
1406
|
const arg = raw.toLowerCase()
|
|
657
1407
|
|
|
658
|
-
if (arg === "on") {
|
|
659
|
-
config.enabled = true
|
|
660
|
-
saveConfig(config)
|
|
661
|
-
output.parts.push({ type: "text", text: "Raven tool/MCP routing enabled. Non-Raven agents will be redirected to raven_seek for configured tools." })
|
|
662
|
-
} else if (arg === "
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
output.parts.push({ type: "text", text:
|
|
668
|
-
} else if (arg === "
|
|
669
|
-
output.parts.push({ type: "text", text:
|
|
670
|
-
} else if (arg
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
const
|
|
674
|
-
const
|
|
675
|
-
const
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
config[key]
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
1408
|
+
if (arg === "on") {
|
|
1409
|
+
config.enabled = true
|
|
1410
|
+
saveConfig(config)
|
|
1411
|
+
output.parts.push({ type: "text", text: "Raven tool/MCP routing enabled. Non-Raven agents will be redirected to raven_seek for configured tools." })
|
|
1412
|
+
} else if (arg === "help") {
|
|
1413
|
+
output.parts.push({ type: "text", text: helpText() })
|
|
1414
|
+
} else if (arg === "off") {
|
|
1415
|
+
config.enabled = false
|
|
1416
|
+
saveConfig(config)
|
|
1417
|
+
output.parts.push({ type: "text", text: "Raven tool/MCP routing disabled. All agents can use tools directly." })
|
|
1418
|
+
} else if (arg === "stats") {
|
|
1419
|
+
output.parts.push({ type: "text", text: statsText() })
|
|
1420
|
+
} else if (arg === "route") {
|
|
1421
|
+
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.` })
|
|
1422
|
+
} else if (arg.startsWith("route ")) {
|
|
1423
|
+
const parts = raw.split(/\s+/)
|
|
1424
|
+
const kind = parts[1]?.toLowerCase()
|
|
1425
|
+
const action = parts[2]?.toLowerCase()
|
|
1426
|
+
const name = parts.slice(3).join(" ").trim()
|
|
1427
|
+
const key = kind === "tool" ? "routeTools" : kind === "keyword" ? "routeToolKeywords" : undefined
|
|
1428
|
+
|
|
1429
|
+
if (!key || !["add", "remove", "rm"].includes(action) || !name) {
|
|
1430
|
+
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." })
|
|
1431
|
+
} else {
|
|
1432
|
+
const values = uniqueStrings(config[key])
|
|
1433
|
+
const exists = values.some((value) => value.toLowerCase() === name.toLowerCase())
|
|
1434
|
+
config[key] = action === "add"
|
|
1435
|
+
? exists ? values : [...values, name]
|
|
1436
|
+
: values.filter((value) => value.toLowerCase() !== name.toLowerCase())
|
|
1437
|
+
saveConfig(config)
|
|
1438
|
+
output.parts.push({ type: "text", text: routeSummary() })
|
|
1439
|
+
}
|
|
1440
|
+
} else if (arg === "mcp") {
|
|
1441
|
+
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` })
|
|
1442
|
+
} else if (arg.startsWith("mcp ")) {
|
|
1443
|
+
const parts = raw.split(/\s+/)
|
|
1444
|
+
const action = parts[1]?.toLowerCase()
|
|
1445
|
+
if (action === "refresh") {
|
|
1446
|
+
const serverName = parts.slice(2).join(" ").trim() || undefined
|
|
1447
|
+
const result = await refreshOnDemandMcpDescriptions(true, serverName)
|
|
1448
|
+
await notifyIfMcpFailures(result.failed)
|
|
1449
|
+
output.parts.push({
|
|
1450
|
+
type: "text",
|
|
1451
|
+
text: `On-demand MCP refresh complete.\n Refreshed: ${result.refreshed.length ? result.refreshed.join(", ") : "none"}\n Failed: ${result.failed.length ? result.failed.join(", ") : "none"}`,
|
|
1452
|
+
})
|
|
1453
|
+
} else if (action === "detail") {
|
|
1454
|
+
const detail = parts[2]?.toLowerCase()
|
|
1455
|
+
if (detail !== "full" && detail !== "minimized") {
|
|
1456
|
+
output.parts.push({ type: "text", text: `Usage: /raven mcp detail full|minimized\nCurrent: ${config.onDemandMcpDescriptionDetail ?? "full"}` })
|
|
1457
|
+
} else {
|
|
1458
|
+
config.onDemandMcpDescriptionDetail = detail
|
|
1459
|
+
saveConfig(config)
|
|
1460
|
+
output.parts.push({ type: "text", text: `On-demand MCP guidance detail set to: ${detail}\nRestart opencode for startup instructions/tool descriptions to fully refresh.` })
|
|
1461
|
+
}
|
|
1462
|
+
} else {
|
|
1463
|
+
output.parts.push({ type: "text", text: "Usage:\n /raven mcp\n /raven mcp refresh [server_name]\n /raven mcp detail full\n /raven mcp detail minimized" })
|
|
1464
|
+
}
|
|
1465
|
+
} else if (arg === "update") {
|
|
689
1466
|
try {
|
|
690
1467
|
const info = await refreshUpdateInfo()
|
|
691
1468
|
if (!info.latest) {
|
|
@@ -738,8 +1515,8 @@ export default ((input: PluginInput) => {
|
|
|
738
1515
|
? `Update: ${info.latest} available. Run /raven update, then restart opencode.`
|
|
739
1516
|
: `Update: up to date${info.latest ? ` (latest ${info.latest})` : ""}.`
|
|
740
1517
|
} catch { /* keep fallback */ }
|
|
741
|
-
output.parts.push({ type: "text", text: `Raven is ${enabled}. Version: ${PACKAGE_VERSION}. Model: ${model}. Reasoning: ${effort}. Timeout: ${timeout}s\n${update}\n\n${routeSummary()}\n\n${mcpSummary()}\n\
|
|
742
|
-
}
|
|
1518
|
+
output.parts.push({ type: "text", text: `Raven is ${enabled}. Version: ${PACKAGE_VERSION}. Model: ${model}. Reasoning: ${effort}. Timeout: ${timeout}s\n${update}\n\n${routeSummary()}\n\n${mcpSummary()}\n\n${statsText()}\n\nRun /raven help for commands.` })
|
|
1519
|
+
}
|
|
743
1520
|
},
|
|
744
1521
|
|
|
745
1522
|
"tool.execute.before"(input: any, output: any) {
|
|
@@ -747,65 +1524,57 @@ export default ((input: PluginInput) => {
|
|
|
747
1524
|
throw new Error("raven_seek is disabled inside Raven. Use Raven's direct search/fetch tools instead.")
|
|
748
1525
|
}
|
|
749
1526
|
|
|
1527
|
+
if (input.tool === "raven_mcp" && !ravenSessions.has(input.sessionID) && sessionAgents.get(input.sessionID) !== "raven") {
|
|
1528
|
+
throw new Error("raven_mcp is only available inside Raven. Use raven_seek(query=\"Use the relevant on-demand MCP to handle this request\") instead.")
|
|
1529
|
+
}
|
|
1530
|
+
|
|
750
1531
|
if (!config.enabled) return
|
|
751
1532
|
if (ravenSessions.has(input.sessionID)) return
|
|
752
1533
|
if (isExcluded(sessionAgents.get(input.sessionID))) return
|
|
753
|
-
if (config.excludeTools?.some((name) => name.toLowerCase() === input.tool.toLowerCase())) return
|
|
1534
|
+
if (config.excludeTools?.some((name) => name.toLowerCase() === input.tool.toLowerCase())) return
|
|
754
1535
|
|
|
755
1536
|
// ── Subagent prompt injection: inject Raven guidance into every subagent ──
|
|
756
1537
|
if ((input.tool === "task" || input.tool === "subtask") && output.args) {
|
|
757
1538
|
const subagentType = input.tool === "task" ? (output.args.subagent_type ?? "") : ""
|
|
758
1539
|
if (subagentType === "raven") {
|
|
759
1540
|
ravenTaskCalls.add(input.callID)
|
|
760
|
-
const promptField = ["prompt", "description", "request", "objective", "query"].find(
|
|
761
|
-
(f) => f in output.args
|
|
762
|
-
) ?? "prompt"
|
|
763
|
-
ravenTaskPrompts.set(input.callID, String(output.args[promptField] ?? "").length)
|
|
764
1541
|
}
|
|
765
1542
|
if (subagentType !== "raven" && !isExcluded(subagentType)) {
|
|
766
1543
|
const field = ["prompt", "description", "request", "objective", "query"].find(
|
|
767
1544
|
(f) => f in output.args
|
|
768
1545
|
) ?? "prompt"
|
|
769
|
-
output.args[field] = `${output.args[field] ?? ""}\n\n<raven_guidance>\n${ravenGuidance()}\n</raven_guidance>`
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
// ── Block routed tools/MCPs for non-Raven agents ──
|
|
774
|
-
const shouldRouteTool = input.tool === "bash" ? false : isRouteConfigured(input.tool)
|
|
775
|
-
const isSearchBashCmd = isRouteConfigured("bash") && isSearchBash(input.tool, output.args || input.args)
|
|
776
|
-
|
|
777
|
-
if (shouldRouteTool || isSearchBashCmd) {
|
|
778
|
-
const args = compactArgs(output.args || input.args)
|
|
779
|
-
if (output.args && typeof output.args === "object") output.args = args
|
|
780
|
-
throw new Error(rerouteMessage(input.tool, args))
|
|
781
|
-
}
|
|
1546
|
+
output.args[field] = `${output.args[field] ?? ""}\n\n<raven_guidance>\n${ravenGuidance()}\n</raven_guidance>`
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// ── Block routed tools/MCPs for non-Raven agents ──
|
|
1551
|
+
const shouldRouteTool = input.tool === "bash" ? false : isRouteConfigured(input.tool)
|
|
1552
|
+
const isSearchBashCmd = isRouteConfigured("bash") && isSearchBash(input.tool, output.args || input.args)
|
|
1553
|
+
|
|
1554
|
+
if (shouldRouteTool || isSearchBashCmd) {
|
|
1555
|
+
const args = compactArgs(output.args || input.args)
|
|
1556
|
+
if (output.args && typeof output.args === "object") output.args = args
|
|
1557
|
+
throw new Error(rerouteMessage(input.tool, args))
|
|
1558
|
+
}
|
|
782
1559
|
},
|
|
783
1560
|
|
|
784
1561
|
"tool.execute.after"(input: any, output: any) {
|
|
785
1562
|
if (ravenTaskCalls.has(input.callID)) {
|
|
786
1563
|
ravenTaskCalls.delete(input.callID)
|
|
787
|
-
const promptBytes = ravenTaskPrompts.get(input.callID) ?? 0
|
|
788
|
-
ravenTaskPrompts.delete(input.callID)
|
|
789
1564
|
// Try task metadata first (built-in tools preserve metadata)
|
|
790
1565
|
const ravenSessionId = output.metadata?.sessionId ?? ravenSessionParents.get(input.sessionID)
|
|
791
1566
|
if (ravenSessionId) {
|
|
792
1567
|
if (ravenSessionParents.has(input.sessionID)) ravenSessionParents.delete(input.sessionID)
|
|
793
|
-
void
|
|
1568
|
+
void countRavenSavedCandidateBytes(ravenSessionId)
|
|
794
1569
|
.then((total) => {
|
|
795
|
-
const saved = Math.max(0, total -
|
|
1570
|
+
const saved = Math.max(0, total - String(output.output ?? "").length)
|
|
796
1571
|
if (saved > 0) addBytes(saved)
|
|
797
1572
|
})
|
|
798
1573
|
.catch(() => {
|
|
799
|
-
|
|
800
|
-
const saved = Math.max(0, outputLen - promptBytes)
|
|
801
|
-
if (saved > 0) addBytes(saved)
|
|
1574
|
+
// Without token metadata we cannot separate saved context from the compact answer.
|
|
802
1575
|
})
|
|
803
|
-
} else {
|
|
804
|
-
const outputLen = String(output.output ?? "").length
|
|
805
|
-
const saved = Math.max(0, outputLen - promptBytes)
|
|
806
|
-
if (saved > 0) addBytes(saved)
|
|
807
1576
|
}
|
|
808
1577
|
}
|
|
809
1578
|
},
|
|
810
|
-
}
|
|
811
|
-
}) satisfies Plugin
|
|
1579
|
+
}
|
|
1580
|
+
}) satisfies Plugin
|