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/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
- "bash",
23
- ]
24
-
25
- const DEFAULT_ROUTE_MCP_SERVERS = [
26
- "context7",
27
- "exa",
28
- "grep_app",
29
- ]
30
-
31
- const DEFAULT_MCP_SERVERS: Record<string, string> = {
32
- context7: "https://mcp.context7.com/mcp",
33
- exa: "https://mcp.exa.ai/mcp",
34
- grep_app: "https://mcp.grep.app",
35
- }
36
-
37
- const NEVER_ROUTE_TOOLS = new Set(["raven_seek", "task", "subtask"])
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 RavenConfig {
111
- enabled: boolean
112
- model?: string
113
- reasoning_effort?: string
114
- ravenInstructions?: string
115
- routeTools?: string[]
116
- routeMcpServers?: string[]
117
- allowBundledMCPServers?: boolean
118
- excludeAgents?: string[]
119
- excludeTools?: string[]
120
- timeout?: number
121
- stats?: { bytes: number }
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
- routeMcpServers: DEFAULT_ROUTE_MCP_SERVERS,
135
- allowBundledMCPServers: true,
136
- excludeAgents: [],
137
- excludeTools: [],
138
- timeout: 180,
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
- // ── Plugin ──
225
- export default ((input: PluginInput) => {
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
- // Config file lives in the global opencode config directory
229
- const configFile = join(homedir(), ".config", "opencode", "raven-config.json")
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, ...source }
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.routeMcpServers = uniqueStrings(source.routeMcpServers, DEFAULT_ROUTE_MCP_SERVERS)
241
- normalized.allowBundledMCPServers = source.allowBundledMCPServers !== false
242
- normalized.excludeAgents = uniqueStrings(source.excludeAgents)
243
- normalized.excludeTools = uniqueStrings(source.excludeTools)
244
- normalized.timeout = typeof source.timeout === "number" ? source.timeout : DEFAULT_CONFIG.timeout
245
- normalized.stats = source.stats || undefined
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
- if (existsSync(configFile)) {
253
- const raw = JSON.parse(readFileSync(configFile, "utf-8"))
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(join(homedir(), ".config", "opencode"), { recursive: true })
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 = config.routeMcpServers?.length ? config.routeMcpServers.map((server) => `${server}_*`).join(", ") : "none"
293
- return `Some tools/MCPs are routed through Raven to save context. Routed tools: ${tools}. Routed MCP prefixes: ${mcps}. If one is blocked, your next tool call should be raven_seek(query="<same request>"). Include the original tool/MCP name and relevant arguments.`
294
- }
295
-
296
- function isRouteConfigured(toolName: string): boolean {
297
- const tool = toolName.toLowerCase()
298
- if (NEVER_ROUTE_TOOLS.has(tool)) return false
299
- if (config.routeTools?.some((name) => name.toLowerCase() === tool)) return true
300
- return config.routeMcpServers?.some((server) => tool.startsWith(`${server.toLowerCase()}_`)) ?? false
301
- }
302
-
303
- function compactArgs(value: any): any {
304
- if (Array.isArray(value)) {
305
- return value.map(compactArgs).filter((item) => item !== undefined)
306
- }
307
- if (!value || typeof value !== "object") return value
308
- const result: Record<string, any> = {}
309
- for (const [key, raw] of Object.entries(value)) {
310
- const item = compactArgs(raw)
311
- if (item === undefined || item === null || item === "") continue
312
- if (Array.isArray(item) && item.length === 0) continue
313
- if (typeof item === "object" && !Array.isArray(item) && Object.keys(item).length === 0) continue
314
- result[key] = item
315
- }
316
- return result
317
- }
318
-
319
- function attemptedQuery(tool: string, args: any): string {
320
- const compact = compactArgs(args)
321
- if (!compact || typeof compact !== "object") return `${tool}: ${JSON.stringify(compact)}`
322
- const direct = compact.query ?? compact.pattern ?? compact.url ?? compact.urls ?? compact.command ?? compact.path ?? compact.filePath
323
- const value = direct !== undefined ? direct : compact
324
- const text = typeof value === "string" ? value : JSON.stringify(value)
325
- const query = value === compact ? `${tool} ${text}` : `${tool}: ${text}`
326
- return query.length > 300 ? `${query.slice(0, 297)}...` : query
327
- }
328
-
329
- function rerouteMessage(tool: string, args: any): string {
330
- return `The '${tool}' tool call is blocked by Raven. Your next tool call should be raven_seek(query="${attemptedQuery(tool, args).replace(/"/g, "'")}").`
331
- }
332
-
333
- function routeSummary(): string {
334
- const tools = config.routeTools?.length ? config.routeTools.join(", ") : "(none)"
335
- const mcps = config.routeMcpServers?.length ? config.routeMcpServers.join(", ") : "(none)"
336
- return `Raven routed tools/MCPs:\n Tools: ${tools}\n MCP servers: ${mcps}`
337
- }
338
-
339
- function mcpSummary(): string {
340
- return `Raven bundled MCPs: ${config.allowBundledMCPServers === false ? "disabled" : Object.keys(DEFAULT_MCP_SERVERS).join(", ")}`
341
- }
342
-
343
- function ravenAgentPrompt(): string {
344
- const extra = config.ravenInstructions?.trim()
345
- return extra ? `${ravenPrompt}\n\nAdditional user instructions:\n${extra}` : ravenPrompt
346
- }
347
-
348
- // ── Context saved by Raven delegation ──
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 countRavenSessionBytes(sessionId: string): Promise<number> {
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
- // Find last assistant message with output tokens (same logic as TUI subagent-footer.tsx)
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
- return totalTokens * 4
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 ensureRemoteMcp(configInput: any, key: string, url: string) {
481
- const existing = configInput.mcp?.[key] && typeof configInput.mcp[key] === "object"
482
- ? configInput.mcp[key]
483
- : {}
484
- const type = existing.type ?? "remote"
485
- configInput.mcp[key] = {
486
- ...existing,
487
- type,
488
- ...(type === "local" ? {} : { url: existing.url ?? url }),
489
- enabled: existing.enabled ?? true,
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
- // Bundled MCP defaults. Existing opencode.jsonc entries are merged, not overwritten.
496
- if (config.allowBundledMCPServers !== false) {
497
- configInput.mcp = configInput.mcp || {}
498
- for (const [key, url] of Object.entries(DEFAULT_MCP_SERVERS)) {
499
- ensureRemoteMcp(configInput, key, url)
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 tools/MCPs, or change Raven's model",
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
- // Get total Raven session context and subtract input/output to get context saved
597
- let totalProcessed = 0
1260
+ // Context saved = Raven output/reasoning/cache context - compact answer returned to main session.
1261
+ let savedCandidate = 0
598
1262
  try {
599
- totalProcessed = await countRavenSessionBytes(sessionId)
1263
+ savedCandidate = await countRavenSavedCandidateBytes(sessionId)
600
1264
  } catch { /* best-effort */ }
601
- if (totalProcessed <= 0) {
1265
+ if (savedCandidate <= 0) {
602
1266
  for (const part of parts) {
603
- if (part.text) totalProcessed += part.text.length
604
- if (part.args) totalProcessed += JSON.stringify(part.args).length
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
- // Context saved = total session context − input query − compact answer returned
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(totalProcessed)} processed, ${formatTokens(totalProcessed)} tokens*` }
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 (!updateToastPending) return
647
- updateToastPending = false
648
- setTimeout(() => void notifyIfUpdateAvailable(), 500)
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 === "off") {
663
- config.enabled = false
664
- saveConfig(config)
665
- output.parts.push({ type: "text", text: "Raven tool/MCP routing disabled. All agents can use tools directly." })
666
- } else if (arg === "stats") {
667
- output.parts.push({ type: "text", text: `Raven context saved:\n This session: ${formatBytes(sessionBytes)} (~${formatTokens(sessionBytes)} context)\n All time: ${formatBytes(totalBytes)} (~${formatTokens(totalBytes)} context)` })
668
- } else if (arg === "route") {
669
- 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 mcp add <server_name>\n /raven route mcp remove <server_name>` })
670
- } else if (arg.startsWith("route ")) {
671
- const parts = raw.split(/\s+/)
672
- const kind = parts[1]?.toLowerCase()
673
- const action = parts[2]?.toLowerCase()
674
- const name = parts.slice(3).join(" ").trim()
675
- const key = kind === "tool" ? "routeTools" : kind === "mcp" || kind === "server" ? "routeMcpServers" : undefined
676
-
677
- if (!key || !["add", "remove", "rm"].includes(action) || !name) {
678
- output.parts.push({ type: "text", text: "Usage:\n /raven route tool add <tool_name>\n /raven route tool remove <tool_name>\n /raven route mcp add <server_name>\n /raven route mcp remove <server_name>" })
679
- } else {
680
- const values = uniqueStrings(config[key])
681
- const exists = values.some((value) => value.toLowerCase() === name.toLowerCase())
682
- config[key] = action === "add"
683
- ? exists ? values : [...values, name]
684
- : values.filter((value) => value.toLowerCase() !== name.toLowerCase())
685
- saveConfig(config)
686
- output.parts.push({ type: "text", text: routeSummary() })
687
- }
688
- } else if (arg === "update") {
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\nRaven context saved:\n This session: ${formatBytes(sessionBytes)} (~${formatTokens(sessionBytes)} context)\n All time: ${formatBytes(totalBytes)} (~${formatTokens(totalBytes)} context)\n\nCommands:\n /raven on — enable tool/MCP routing\n /raven off — disable tool/MCP routing\n /raven route — show or edit routed tools/MCP servers\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` })
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 countRavenSessionBytes(ravenSessionId)
1568
+ void countRavenSavedCandidateBytes(ravenSessionId)
794
1569
  .then((total) => {
795
- const saved = Math.max(0, total - promptBytes - String(output.output ?? "").length)
1570
+ const saved = Math.max(0, total - String(output.output ?? "").length)
796
1571
  if (saved > 0) addBytes(saved)
797
1572
  })
798
1573
  .catch(() => {
799
- const outputLen = String(output.output ?? "").length
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