opencode-raven 2.1.6 → 2.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.8
4
+
5
+ ### Added
6
+ - `raven_seek` now returns a model-visible session ID and accepts it through the optional `sessionId` argument, allowing follow-up research to continue in the same Raven session.
7
+ - Resumed sessions are validated as Raven children of the current main session before they can be prompted again.
8
+
9
+ ### Fixed
10
+ - Context estimates for resumed sessions count only the newly handled context instead of counting earlier Raven turns again.
11
+
12
+ ## 2.1.7
13
+
14
+ ### Fixed
15
+ - Stats and `/raven` setting updates now patch the latest on-disk configuration instead of rewriting stale in-memory state, preserving externally edited MCP servers and headers.
16
+ - Existing malformed or partially written configuration files are preserved for repair instead of being replaced with defaults.
17
+
3
18
  ## 2.1.6
4
19
 
5
20
  ### Added
package/README.md CHANGED
@@ -54,10 +54,10 @@ In this run, Raven handled the search-heavy web research in a child session and
54
54
 
55
55
  1. Your agent attempts to use a routed search, fetch, shell-discovery, or MCP tool.
56
56
  2. Raven blocks the noisy direct call and gives the agent the exact `raven_seek` retry.
57
- 3. The agent continues automatically through a linked Raven child session.
57
+ 3. The agent creates a linked Raven child session or resumes one for a related follow-up.
58
58
  4. Raven performs the tool-heavy work and returns only compact findings to the main session.
59
59
 
60
- The main model receives Raven's final answer instead of its raw tool output. Child sessions remain available in OpenCode's session/subagent view for inspection.
60
+ The main model receives Raven's final answer instead of its raw tool output. Each result includes a Raven session ID that the main agent can pass back for a context-aware follow-up. Child sessions remain available in OpenCode's session/subagent view for inspection.
61
61
 
62
62
  For direct control, mention `@Raven` in any chat. This sends the request straight to the Raven agent, but it is optional and not required for automatic routing.
63
63
 
@@ -65,9 +65,18 @@ Under the hood, Raven registers two tools:
65
65
 
66
66
  | Tool | Purpose |
67
67
  |------|---------|
68
- | `raven_seek` | Creates a linked Raven child session and returns its compact result. |
68
+ | `raven_seek` | Creates or resumes a linked Raven child session and returns its compact result. |
69
69
  | `raven_mcp` | Raven-only bridge to MCPs configured under `onDemandMcpServers`. |
70
70
 
71
+ For a related follow-up, the main agent can reuse the ID returned by the earlier call:
72
+
73
+ ```text
74
+ raven_seek(query="Research the available options")
75
+ raven_seek(query="Compare the first two in more detail", sessionId="ses_...")
76
+ ```
77
+
78
+ Raven accepts a resumed session only when it is a Raven child of the current main session. Unrelated work should omit `sessionId` and start a fresh child.
79
+
71
80
  OpenCode's built-in tools already exist; Raven routes selected calls instead of replacing those tools. Global MCPs also remain globally registered, so their schemas still enter the main session even when Raven routes their calls.
72
81
 
73
82
  On-demand MCPs are different: Raven discovers and calls them internally, so their full schemas are not registered globally. Enabled servers are probed after startup to discover tools and health, then per-session connections are opened when Raven uses them. "On-demand" describes schema exposure and normal use, not the absence of a startup health probe.
package/index.ts CHANGED
@@ -469,28 +469,27 @@ export default (async (input: PluginInput) => {
469
469
  }
470
470
 
471
471
  function loadConfig(): RavenConfig {
472
- try {
473
- if (existsSync(configFile)) {
472
+ if (existsSync(configFile)) {
473
+ try {
474
474
  const raw = JSON.parse(readFileSync(configFile, "utf-8"))
475
- const normalized = normalizeConfig(raw)
476
- if (JSON.stringify(raw) !== JSON.stringify(normalized)) {
477
- saveConfig(normalized)
478
- }
479
- return normalized
475
+ return normalizeConfig(raw)
476
+ } catch {
477
+ // Preserve malformed or partially-written config so the user can repair it.
478
+ return { ...DEFAULT_CONFIG }
480
479
  }
481
- } catch { /* ignore corruption, use defaults */ }
482
- // Auto-create config file with defaults on first run
483
- saveConfig(DEFAULT_CONFIG)
484
- return { ...DEFAULT_CONFIG }
485
- }
486
-
487
- function saveConfig(config: RavenConfig) {
488
- try {
489
- mkdirSync(ravenConfigDir, { recursive: true })
490
- writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n")
491
- } catch { /* non-fatal: config won't persist but toggle still works in-session */ }
492
- }
493
-
480
+ }
481
+ // Auto-create config file with defaults on first run
482
+ writeConfig(DEFAULT_CONFIG)
483
+ return { ...DEFAULT_CONFIG }
484
+ }
485
+
486
+ function writeConfig(config: RavenConfig) {
487
+ try {
488
+ mkdirSync(ravenConfigDir, { recursive: true })
489
+ writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n")
490
+ } catch { /* non-fatal: config won't persist but toggle still works in-session */ }
491
+ }
492
+
494
493
  function loadMcpMetadata(): Record<string, OnDemandMcpMetadata> {
495
494
  const defaults = cloneOnDemandMcpMetadata(DEFAULT_ON_DEMAND_MCP_METADATA)
496
495
  try {
@@ -515,11 +514,27 @@ export default (async (input: PluginInput) => {
515
514
  writeFileSync(dynamicGuidanceFile, `## Raven on-demand MCP capabilities\n\n${onDemandMcpGuidance("delegate", dynamicGuidanceMcpOrder())}\n`, "utf-8")
516
515
  } catch { /* non-fatal: guidance still exists in tool/subagent paths */ }
517
516
  }
518
-
517
+
519
518
  let config = loadConfig()
519
+
520
+ function updateConfig(update: Partial<RavenConfig> | ((latest: RavenConfig) => Partial<RavenConfig>)): RavenConfig {
521
+ try {
522
+ const raw = existsSync(configFile) ? JSON.parse(readFileSync(configFile, "utf-8")) : config
523
+ const latest = normalizeConfig(raw)
524
+ const patch = typeof update === "function" ? update(latest) : update
525
+ config = normalizeConfig({ ...latest, ...patch })
526
+ writeConfig({ ...raw, ...patch })
527
+ } catch {
528
+ // Keep malformed or partially-written external edits intact instead of replacing them.
529
+ const patch = typeof update === "function" ? update(config) : update
530
+ config = normalizeConfig({ ...config, ...patch })
531
+ }
532
+ return config
533
+ }
520
534
  let mcpMetadata = loadMcpMetadata()
521
- const ravenSessions = new Set<string>()
522
- const ravenTaskCalls = new Set<string>()
535
+ const ravenSessions = new Set<string>()
536
+ const activeRavenPrompts = new Set<string>()
537
+ const ravenTaskCalls = new Set<string>()
523
538
  const sessionAgents = new Map<string, string>()
524
539
  const ravenSessionParents = new Map<string, string>()
525
540
  const ravenErrorWaiters = new Map<string, (error: Error) => void>()
@@ -1274,16 +1289,17 @@ Then restart opencode for the model change to take effect. If the task can be co
1274
1289
  return parsed
1275
1290
  }
1276
1291
 
1277
- // ── Context saved by Raven delegation ──
1278
- let sessionBytes = 0
1279
- let totalBytes = config.stats?.bytes ?? 0
1280
-
1281
- function addBytes(bytes: number) {
1282
- sessionBytes += bytes
1283
- totalBytes += bytes
1284
- config.stats = { bytes: totalBytes }
1285
- saveConfig(config)
1286
- }
1292
+ // ── Context saved by Raven delegation ──
1293
+ let sessionBytes = 0
1294
+ let totalBytes = config.stats?.bytes ?? 0
1295
+
1296
+ function addBytes(bytes: number) {
1297
+ sessionBytes += bytes
1298
+ const updated = updateConfig((latest) => ({
1299
+ stats: { bytes: Math.max(totalBytes, latest.stats?.bytes ?? 0) + bytes },
1300
+ }))
1301
+ totalBytes = updated.stats?.bytes ?? totalBytes + bytes
1302
+ }
1287
1303
 
1288
1304
  function formatBytes(bytes: number): string {
1289
1305
  return bytes >= 1_000_000 ? `${(bytes / 1_000_000).toFixed(1)}MB`
@@ -1331,12 +1347,10 @@ Then restart opencode for the model change to take effect. If the task can be co
1331
1347
  return { current: PACKAGE_VERSION, latest, available: !!latest && compareVersions(latest, PACKAGE_VERSION) > 0 }
1332
1348
  }
1333
1349
 
1334
- async function countRavenSavedCandidateBytes(sessionId: string): Promise<number> {
1335
- const messagesResp = await client.session.messages({ path: { id: sessionId }, query: { limit: 200 } })
1336
- const messages = (messagesResp as any)?.data ?? []
1337
- const assistantMessages = messages.filter((m: any) =>
1338
- m?.info?.role === "assistant" && m?.info?.tokens?.output > 0
1339
- )
1350
+ function estimateRavenSavedCandidateBytes(messages: any[]): number {
1351
+ const assistantMessages = messages.filter((m: any) =>
1352
+ m?.info?.role === "assistant" && m?.info?.tokens?.output > 0
1353
+ )
1340
1354
  const first = assistantMessages[0]
1341
1355
  const last = assistantMessages[assistantMessages.length - 1]
1342
1356
  const t = last?.info?.tokens
@@ -1348,9 +1362,15 @@ Then restart opencode for the model change to take effect. If the task can be co
1348
1362
  const baselineTokens = firstTokens
1349
1363
  ? (firstTokens.input ?? 0) + (firstTokens.cache?.read ?? 0) + (firstTokens.cache?.write ?? 0)
1350
1364
  : 0
1351
- const savedCandidateTokens = Math.max(0, totalTokens - baselineTokens)
1352
- return savedCandidateTokens * 4
1353
- }
1365
+ const savedCandidateTokens = Math.max(0, totalTokens - baselineTokens)
1366
+ return savedCandidateTokens * 4
1367
+ }
1368
+
1369
+ async function countRavenSavedCandidateBytes(sessionId: string, signal?: AbortSignal): Promise<number> {
1370
+ const messagesResp = await client.session.messages({ path: { id: sessionId }, signal })
1371
+ const messages = (messagesResp as any)?.data ?? []
1372
+ return estimateRavenSavedCandidateBytes(messages)
1373
+ }
1354
1374
 
1355
1375
  async function getUpdateInfo(): Promise<{ current: string; latest?: string; available: boolean }> {
1356
1376
  if (updateInfo) return updateInfo
@@ -1514,33 +1534,85 @@ Then restart opencode for the model change to take effect. If the task can be co
1514
1534
  duplicateMcpToastPending = duplicateMcpServers.length > 0
1515
1535
  },
1516
1536
 
1517
- // Register raven_seek tool — lets agents with task:false still delegate through Raven
1518
- tool: {
1519
- "raven_seek": tool({
1520
- 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.",
1521
- args: {
1522
- 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."),
1523
- },
1524
- async execute(args, context) {
1525
- const started = Date.now()
1526
- const timeout = (config.timeout ?? 600) * 1000
1527
- try {
1528
- // Create a Raven session
1529
- const session = await client.session.create({
1530
- body: {
1531
- parentID: context.sessionID,
1532
- title: `raven_seek: ${args.query.slice(0, 80)}`,
1533
- },
1534
- signal: context.abort,
1535
- })
1536
-
1537
- const sessionId = (session as any)?.data?.id ?? (session as any)?.id
1538
- if (!sessionId) {
1539
- return { title: "Raven Seek", output: "Failed to create Raven session." }
1540
- }
1541
-
1542
- ravenSessions.add(sessionId)
1543
-
1537
+ // Register raven_seek tool — lets agents with task:false still delegate through Raven
1538
+ tool: {
1539
+ "raven_seek": tool({
1540
+ description: "Unified Raven delegation tool. Creates a Raven child session or resumes one for follow-up research. 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.",
1541
+ args: {
1542
+ 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."),
1543
+ sessionId: tool.schema.string().optional().describe("Raven session ID returned by an earlier raven_seek call. Provide it only when this request should continue that session's research context."),
1544
+ },
1545
+ async execute(args, context) {
1546
+ const started = Date.now()
1547
+ const timeout = (config.timeout ?? 600) * 1000
1548
+ let sessionId: string | undefined
1549
+ let resumed = false
1550
+ let previousSavedCandidate = 0
1551
+ try {
1552
+ const requestedSessionId = args.sessionId?.trim()
1553
+ if (args.sessionId !== undefined && !requestedSessionId) {
1554
+ return { title: "Raven Seek", output: "Cannot resume Raven: sessionId must not be empty." }
1555
+ }
1556
+
1557
+ if (requestedSessionId) {
1558
+ const knownRavenSession = ravenSessions.has(requestedSessionId) || sessionAgents.get(requestedSessionId) === "raven"
1559
+ let existingSession: any
1560
+ try {
1561
+ const sessionResp = await client.session.get({ path: { id: requestedSessionId }, signal: context.abort })
1562
+ existingSession = (sessionResp as any)?.data ?? sessionResp
1563
+ } catch (err) {
1564
+ if (context.abort.aborted) throw err
1565
+ return { title: "Raven Seek", output: `Cannot resume Raven session ${requestedSessionId}: the session was not found or could not be read.` }
1566
+ }
1567
+
1568
+ if (!existingSession?.id || existingSession.id !== requestedSessionId) {
1569
+ return { title: "Raven Seek", output: `Cannot resume Raven session ${requestedSessionId}: the session was not found or could not be read.` }
1570
+ }
1571
+ if (existingSession.parentID !== context.sessionID || existingSession.projectID !== input.project.id) {
1572
+ return { title: "Raven Seek", output: "Cannot resume Raven: sessionId is not a Raven child of this main session." }
1573
+ }
1574
+
1575
+ let messages: any[]
1576
+ try {
1577
+ const messagesResp = await client.session.messages({ path: { id: requestedSessionId }, signal: context.abort })
1578
+ const messageData = (messagesResp as any)?.data
1579
+ if (!Array.isArray(messageData)) {
1580
+ return { title: "Raven Seek", output: `Cannot resume Raven session ${requestedSessionId}: its message history could not be read.` }
1581
+ }
1582
+ messages = messageData
1583
+ } catch (err) {
1584
+ if (context.abort.aborted) throw err
1585
+ return { title: "Raven Seek", output: `Cannot resume Raven session ${requestedSessionId}: its message history could not be read.` }
1586
+ }
1587
+
1588
+ const userMessages = messages.filter((message: any) => message?.info?.role === "user")
1589
+ const persistedRavenSession = userMessages.length > 0
1590
+ && userMessages.every((message: any) => message.info.agent === "raven")
1591
+ if (!persistedRavenSession && !(knownRavenSession && userMessages.length === 0)) {
1592
+ return { title: "Raven Seek", output: "Cannot resume Raven: sessionId is not a Raven child of this main session." }
1593
+ }
1594
+
1595
+ sessionId = requestedSessionId
1596
+ resumed = true
1597
+ previousSavedCandidate = estimateRavenSavedCandidateBytes(messages)
1598
+ } else {
1599
+ const session = await client.session.create({
1600
+ body: {
1601
+ parentID: context.sessionID,
1602
+ title: `raven_seek: ${args.query.slice(0, 80)}`,
1603
+ },
1604
+ signal: context.abort,
1605
+ })
1606
+
1607
+ sessionId = (session as any)?.data?.id ?? (session as any)?.id
1608
+ if (!sessionId) {
1609
+ return { title: "Raven Seek", output: "Failed to create Raven session." }
1610
+ }
1611
+ }
1612
+
1613
+ ravenSessions.add(sessionId)
1614
+ const activeSessionId = sessionId
1615
+
1544
1616
  // Preserve the child session link for TUI clients that render it.
1545
1617
  context.metadata({ metadata: { sessionId } })
1546
1618
 
@@ -1549,15 +1621,35 @@ Then restart opencode for the model change to take effect. If the task can be co
1549
1621
  const logFile = join(tmpdir(), "raven-sessions.log")
1550
1622
  const ts = new Date().toISOString()
1551
1623
  writeFileSync(logFile, `${ts} ${sessionId}\n`)
1552
- } catch { /* non-fatal */ }
1624
+ } catch { /* non-fatal */ }
1625
+
1626
+ if (activeRavenPrompts.has(activeSessionId)) {
1627
+ return {
1628
+ title: "Raven Seek",
1629
+ metadata: { sessionId: activeSessionId },
1630
+ output: `Raven session \`${activeSessionId}\` is already handling a request. Wait for it to finish before sending another follow-up.`,
1631
+ }
1632
+ }
1633
+ activeRavenPrompts.add(activeSessionId)
1553
1634
 
1554
1635
  // Send the query to Raven with timeout. Provider/API errors are also
1555
1636
  // surfaced through message.updated events; fail as soon as one lands
1556
1637
  // so the caller does not wait for the full timeout on quota/auth issues.
1557
1638
  let timer: ReturnType<typeof setTimeout> | undefined
1558
1639
  let promptCompleted = false
1640
+ let sessionAbort: Promise<unknown> | undefined
1559
1641
  const promptController = new AbortController()
1560
- const abortPrompt = () => promptController.abort(context.abort.reason)
1642
+ const abortPrompt = () => {
1643
+ if (promptController.signal.aborted) return
1644
+ promptController.abort(context.abort.reason)
1645
+ const abortController = new AbortController()
1646
+ const abortTimer = setTimeout(() => abortController.abort(), Math.min(timeout, 5_000))
1647
+ sessionAbort = client.session.abort({
1648
+ path: { id: activeSessionId },
1649
+ signal: abortController.signal,
1650
+ throwOnError: true,
1651
+ }).catch(() => undefined).finally(() => clearTimeout(abortTimer))
1652
+ }
1561
1653
  if (context.abort.aborted) abortPrompt()
1562
1654
  else context.abort.addEventListener("abort", abortPrompt, { once: true })
1563
1655
  const providerError = waitForRavenProviderError(sessionId)
@@ -1571,12 +1663,13 @@ Then restart opencode for the model change to take effect. If the task can be co
1571
1663
  parts: [{ type: "text", text: args.query }],
1572
1664
  },
1573
1665
  signal: promptController.signal,
1666
+ throwOnError: true,
1574
1667
  }),
1575
1668
  providerError,
1576
1669
  firstProgress,
1577
1670
  new Promise<never>((_, reject) => {
1578
1671
  timer = setTimeout(() => {
1579
- promptController.abort()
1672
+ abortPrompt()
1580
1673
  reject(new Error(`Raven timed out after ${timeout / 1000}s — child session retained: ${sessionId}`))
1581
1674
  }, timeout)
1582
1675
  }),
@@ -1584,8 +1677,9 @@ Then restart opencode for the model change to take effect. If the task can be co
1584
1677
  promptCompleted = true
1585
1678
  if (timer) clearTimeout(timer)
1586
1679
  clearRavenWaiters(sessionId)
1680
+ if ((result as any)?.error) throw new Error(providerErrorMessage((result as any).error))
1587
1681
 
1588
- const elapsed = ((Date.now() - started) / 1000).toFixed(1)
1682
+ const elapsed = ((Date.now() - started) / 1000).toFixed(1)
1589
1683
 
1590
1684
  // Extract text from the response
1591
1685
  const parts = (result as any)?.data?.parts ?? []
@@ -1594,46 +1688,64 @@ Then restart opencode for the model change to take effect. If the task can be co
1594
1688
  .map((p: any) => p.text)
1595
1689
  const output = textParts.join("\n") || "Raven returned no results."
1596
1690
 
1597
- // Context saved = Raven output/reasoning/cache context - compact answer returned to main session.
1598
- let savedCandidate = 0
1599
- try {
1600
- savedCandidate = await countRavenSavedCandidateBytes(sessionId)
1601
- } catch { /* best-effort */ }
1691
+ // Context saved = Raven output/reasoning/cache context - compact answer returned to main session.
1692
+ let savedCandidate = 0
1693
+ const statsController = new AbortController()
1694
+ const abortStats = () => statsController.abort(context.abort.reason)
1695
+ const statsTimer = setTimeout(() => statsController.abort(), Math.min(timeout, 5_000))
1696
+ if (context.abort.aborted) abortStats()
1697
+ else context.abort.addEventListener("abort", abortStats, { once: true })
1698
+ try {
1699
+ const totalSavedCandidate = await countRavenSavedCandidateBytes(sessionId, statsController.signal)
1700
+ savedCandidate = Math.max(0, totalSavedCandidate - previousSavedCandidate)
1701
+ } catch { /* best-effort */ }
1702
+ finally {
1703
+ clearTimeout(statsTimer)
1704
+ context.abort.removeEventListener("abort", abortStats)
1705
+ }
1602
1706
  if (savedCandidate <= 0) {
1603
1707
  for (const part of parts) {
1604
1708
  if (part.args) savedCandidate += JSON.stringify(part.args).length
1605
1709
  if (part.content) savedCandidate += typeof part.content === "string" ? part.content.length : JSON.stringify(part.content).length
1606
1710
  }
1607
1711
  }
1608
- const saved = Math.max(0, savedCandidate - output.length)
1609
- addBytes(saved)
1610
- ravenProviderIssue = undefined
1611
-
1612
- return { title: "Raven Seek", metadata: { sessionId }, output: `${output}\n\n*Raven searched for ${elapsed}s — ${formatBytes(savedCandidate)} handled, ${formatTokens(savedCandidate)} tokens*` }
1712
+ const continuation = `Raven session: \`${sessionId}\`${resumed ? " (continued)" : ""}. Pass this as \`sessionId\` in a later \`raven_seek\` call to continue the same research.`
1713
+ const resultOutput = `${output}\n\n*Raven searched for ${elapsed}s — ${formatBytes(savedCandidate)} handled, ${formatTokens(savedCandidate)} tokens*\n\n${continuation}`
1714
+ addBytes(Math.max(0, savedCandidate - resultOutput.length))
1715
+ ravenProviderIssue = undefined
1716
+
1717
+ return { title: "Raven Seek", metadata: { sessionId }, output: resultOutput }
1613
1718
  } finally {
1614
1719
  context.abort.removeEventListener("abort", abortPrompt)
1615
- if (!promptCompleted) promptController.abort()
1720
+ if (!promptCompleted) abortPrompt()
1721
+ if (sessionAbort) await sessionAbort
1616
1722
  if (timer) clearTimeout(timer)
1617
1723
  clearRavenWaiters(sessionId)
1724
+ activeRavenPrompts.delete(activeSessionId)
1618
1725
  }
1619
- } catch (err: any) {
1620
- const elapsed = ((Date.now() - started) / 1000).toFixed(1)
1726
+ } catch (err: any) {
1727
+ const elapsed = ((Date.now() - started) / 1000).toFixed(1)
1621
1728
  const msg = String(err?.message ?? err ?? "").toLowerCase()
1729
+ const missingSession = /session.*not found|not found.*session/i.test(msg)
1622
1730
  const hint =
1623
1731
  context.abort.aborted ? "Raven search cancelled."
1624
1732
  : /rate.?limit|too many requests|429/i.test(msg) ? "Raven rate limited — wait 30s then retry with a narrower query."
1625
- : /quota|usage.?limit|usage exceeded|free usage|subscribe to go|subscription|billing|insufficient.*(?:credit|balance|quota)/i.test(msg) ? "Raven API quota/subscription exhausted — proceed without Raven, tell the user Raven could not run, and mention what information may be missing."
1626
- : /auth|unauthorized|forbidden|401|403|api key|login/i.test(msg) ? "Raven provider authentication failed — proceed without Raven and tell the user to check the Raven model/provider login or API key."
1627
- : /token|context.?length|too large|too long/i.test(msg) ? "Raven query too large — shorten your query and retry."
1628
- : /model|unavailable|down|not found/i.test(msg) ? "Raven model unavailable — retry later, or proceed without search."
1629
- : /did not produce an initial response|retry dialog/i.test(msg) ? err.message
1630
- : /timeout|timed.?out|session kept/i.test(msg) ? err.message
1631
- : `Raven search failed. Proceed without search — note gaps for the user. [${err.message || err}]`
1632
- if (/quota|usage.?limit|usage exceeded|free usage|subscribe to go|subscription|billing|auth|unauthorized|forbidden|401|403|api key|login|model|unavailable|down|not found|did not produce an initial response|retry dialog/i.test(msg)) {
1633
- ravenProviderIssue = hint
1634
- }
1635
- const recovery = ravenProviderIssue ? `\n\n${ravenRecoveryInstructions()}` : ""
1636
- return { title: "Raven Seek", output: `${hint}${recovery}\n\n*Attempt took ${elapsed}s*` }
1733
+ : /quota|usage.?limit|usage exceeded|free usage|subscribe to go|subscription|billing|insufficient.*(?:credit|balance|quota)/i.test(msg) ? "Raven API quota/subscription exhausted — proceed without Raven, tell the user Raven could not run, and mention what information may be missing."
1734
+ : /auth|unauthorized|forbidden|401|403|api key|login/i.test(msg) ? "Raven provider authentication failed — proceed without Raven and tell the user to check the Raven model/provider login or API key."
1735
+ : /token|context.?length|too large|too long/i.test(msg) ? "Raven query too large — shorten your query and retry."
1736
+ : missingSession ? "The Raven session is no longer available — retry without sessionId to start a new Raven search."
1737
+ : /model|unavailable|down|not found/i.test(msg) ? "Raven model unavailable — retry later, or proceed without search."
1738
+ : /did not produce an initial response|retry dialog/i.test(msg) ? err.message
1739
+ : /timeout|timed.?out|session kept/i.test(msg) ? err.message
1740
+ : `Raven search failed. Proceed without search — note gaps for the user. [${err.message || err}]`
1741
+ if (!missingSession && /quota|usage.?limit|usage exceeded|free usage|subscribe to go|subscription|billing|auth|unauthorized|forbidden|401|403|api key|login|model|unavailable|down|not found|did not produce an initial response|retry dialog/i.test(msg)) {
1742
+ ravenProviderIssue = hint
1743
+ }
1744
+ const recovery = ravenProviderIssue ? `\n\n${ravenRecoveryInstructions()}` : ""
1745
+ const continuation = sessionId && !missingSession
1746
+ ? `\n\nRaven session: \`${sessionId}\`. Pass this as \`sessionId\` in a later \`raven_seek\` call to retry or continue.`
1747
+ : ""
1748
+ return { title: "Raven Seek", metadata: sessionId ? { sessionId } : undefined, output: `${hint}${recovery}\n\n*Attempt took ${elapsed}s*${continuation}` }
1637
1749
  }
1638
1750
  },
1639
1751
  }),
@@ -1753,6 +1865,7 @@ Then restart opencode for the model change to take effect. If the task can be co
1753
1865
  const sessionId = evt.properties?.info?.id
1754
1866
  if (sessionId) {
1755
1867
  ravenSessions.delete(sessionId)
1868
+ activeRavenPrompts.delete(sessionId)
1756
1869
  sessionAgents.delete(sessionId)
1757
1870
  clearRavenWaiters(sessionId)
1758
1871
  for (const [parentId, childId] of ravenSessionParents) {
@@ -1814,16 +1927,14 @@ Then restart opencode for the model change to take effect. If the task can be co
1814
1927
  const raw = input.arguments.trim()
1815
1928
  const arg = raw.toLowerCase()
1816
1929
 
1817
- if (arg === "on") {
1818
- config.enabled = true
1819
- saveConfig(config)
1820
- output.parts.push({ type: "text", text: "Raven tool/MCP routing enabled. Non-Raven agents will be redirected to raven_seek for configured tools." })
1930
+ if (arg === "on") {
1931
+ updateConfig({ enabled: true })
1932
+ output.parts.push({ type: "text", text: "Raven tool/MCP routing enabled. Non-Raven agents will be redirected to raven_seek for configured tools." })
1821
1933
  } else if (arg === "help") {
1822
1934
  output.parts.push({ type: "text", text: helpText() })
1823
- } else if (arg === "off") {
1824
- config.enabled = false
1825
- saveConfig(config)
1826
- output.parts.push({ type: "text", text: "Raven tool/MCP routing disabled. All agents can use tools directly." })
1935
+ } else if (arg === "off") {
1936
+ updateConfig({ enabled: false })
1937
+ output.parts.push({ type: "text", text: "Raven tool/MCP routing disabled. All agents can use tools directly." })
1827
1938
  } else if (arg === "stats") {
1828
1939
  output.parts.push({ type: "text", text: statsText() })
1829
1940
  } else if (arg === "route") {
@@ -1835,16 +1946,18 @@ Then restart opencode for the model change to take effect. If the task can be co
1835
1946
  const name = parts.slice(3).join(" ").trim()
1836
1947
  const key = kind === "tool" ? "routeTools" : kind === "keyword" ? "routeToolKeywords" : undefined
1837
1948
 
1838
- if (!key || !["add", "remove", "rm"].includes(action) || !name) {
1839
- 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." })
1840
- } else {
1841
- const values = uniqueStrings(config[key])
1842
- const exists = values.some((value) => value.toLowerCase() === name.toLowerCase())
1843
- config[key] = action === "add"
1844
- ? exists ? values : [...values, name]
1845
- : values.filter((value) => value.toLowerCase() !== name.toLowerCase())
1846
- saveConfig(config)
1847
- output.parts.push({ type: "text", text: routeSummary() })
1949
+ if (!key || !["add", "remove", "rm"].includes(action) || !name) {
1950
+ 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." })
1951
+ } else {
1952
+ updateConfig((latest) => {
1953
+ const values = uniqueStrings(latest[key])
1954
+ const exists = values.some((value) => value.toLowerCase() === name.toLowerCase())
1955
+ const next = action === "add"
1956
+ ? exists ? values : [...values, name]
1957
+ : values.filter((value) => value.toLowerCase() !== name.toLowerCase())
1958
+ return key === "routeTools" ? { routeTools: next } : { routeToolKeywords: next }
1959
+ })
1960
+ output.parts.push({ type: "text", text: routeSummary() })
1848
1961
  }
1849
1962
  } else if (arg === "mcp") {
1850
1963
  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` })
@@ -1861,12 +1974,11 @@ Then restart opencode for the model change to take effect. If the task can be co
1861
1974
  })
1862
1975
  } else if (action === "detail") {
1863
1976
  const detail = parts[2]?.toLowerCase()
1864
- if (detail !== "full" && detail !== "minimized") {
1865
- output.parts.push({ type: "text", text: `Usage: /raven mcp detail full|minimized\nCurrent: ${config.onDemandMcpDescriptionDetail ?? "full"}` })
1866
- } else {
1867
- config.onDemandMcpDescriptionDetail = detail
1868
- saveConfig(config)
1869
- output.parts.push({ type: "text", text: `On-demand MCP guidance detail set to: ${detail}\nRestart opencode for startup instructions/tool descriptions to fully refresh.` })
1977
+ if (detail !== "full" && detail !== "minimized") {
1978
+ output.parts.push({ type: "text", text: `Usage: /raven mcp detail full|minimized\nCurrent: ${config.onDemandMcpDescriptionDetail ?? "full"}` })
1979
+ } else {
1980
+ updateConfig({ onDemandMcpDescriptionDetail: detail })
1981
+ output.parts.push({ type: "text", text: `On-demand MCP guidance detail set to: ${detail}\nRestart opencode for startup instructions/tool descriptions to fully refresh.` })
1870
1982
  }
1871
1983
  } else {
1872
1984
  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" })
@@ -1887,31 +1999,28 @@ Then restart opencode for the model change to take effect. If the task can be co
1887
1999
  }
1888
2000
  } else if (arg.startsWith("model ")) {
1889
2001
  const model = raw.slice(6).trim()
1890
- if (!model) {
1891
- output.parts.push({ type: "text", text: `Usage: /raven model <name>\nCurrent model: ${config.model || fm.model || "(default)"}` })
1892
- } else {
1893
- config.model = model
1894
- saveConfig(config)
1895
- ravenProviderIssue = undefined
2002
+ if (!model) {
2003
+ output.parts.push({ type: "text", text: `Usage: /raven model <name>\nCurrent model: ${config.model || fm.model || "(default)"}` })
2004
+ } else {
2005
+ updateConfig({ model })
2006
+ ravenProviderIssue = undefined
1896
2007
  output.parts.push({ type: "text", text: `Raven model set to: ${model}\nRestart opencode for the change to take effect.` })
1897
2008
  }
1898
2009
  } else if (arg.startsWith("effort ")) {
1899
2010
  const effort = raw.slice(7).trim()
1900
- if (!effort) {
1901
- output.parts.push({ type: "text", text: `Usage: /raven effort <value>\nCurrent: ${config.reasoning_effort || fm.reasoning_effort || "(default)"}` })
1902
- } else {
1903
- config.reasoning_effort = effort
1904
- saveConfig(config)
1905
- output.parts.push({ type: "text", text: `Raven reasoning effort set to: ${effort}\nRestart opencode for the change to take effect.` })
2011
+ if (!effort) {
2012
+ output.parts.push({ type: "text", text: `Usage: /raven effort <value>\nCurrent: ${config.reasoning_effort || fm.reasoning_effort || "(default)"}` })
2013
+ } else {
2014
+ updateConfig({ reasoning_effort: effort })
2015
+ output.parts.push({ type: "text", text: `Raven reasoning effort set to: ${effort}\nRestart opencode for the change to take effect.` })
1906
2016
  }
1907
2017
  } else if (arg.startsWith("timeout ")) {
1908
2018
  const secs = Number(raw.slice(8).trim())
1909
2019
  if (!Number.isInteger(secs) || secs < 10 || secs > MAX_TIMEOUT_SECONDS) {
1910
2020
  output.parts.push({ type: "text", text: `Usage: /raven timeout <seconds>\nMust be between 10 and ${MAX_TIMEOUT_SECONDS}. Current: ${config.timeout ?? 600}s` })
1911
- } else {
1912
- config.timeout = secs
1913
- saveConfig(config)
1914
- output.parts.push({ type: "text", text: `Raven timeout set to ${secs}s. Takes effect immediately.` })
2021
+ } else {
2022
+ updateConfig({ timeout: secs })
2023
+ output.parts.push({ type: "text", text: `Raven timeout set to ${secs}s. Takes effect immediately.` })
1915
2024
  }
1916
2025
  } else {
1917
2026
  const enabled = config.enabled ? "enabled" : "disabled"
package/mcp-guidance.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  Do not call tools or MCPs that Raven blocks directly. Raven may route search/fetch tools, docs/web/GitHub MCPs, user-configured tool/MCP prefixes, or on-demand MCP capabilities through `raven_seek` to save context.
4
4
 
5
- Use `raven_seek(query="...")` as the next tool call for blocked tool/MCP requests. Include the original tool/MCP name, intent, and relevant arguments.
5
+ Use `raven_seek(query="...")` as the next tool call for blocked tool/MCP requests. Include the original tool/MCP name, intent, and relevant arguments.
6
+
7
+ When a request follows up on an earlier Raven result, pass the Raven session ID returned by that call as `sessionId` so Raven retains its research context. Omit `sessionId` for unrelated work.
6
8
 
7
9
  For on-demand MCP requests, include the target MCP server and likely tool name in the `raven_seek` query when the listed capabilities make that obvious. Do not call `raven_mcp` directly unless you are Raven.
8
10
 
@@ -26,6 +28,8 @@ Examples:
26
28
 
27
29
  `raven_seek(query="Check whether archivemount/libarchive supports ISO or UDF. Use docs, web, or command output as needed.")`
28
30
 
29
- `raven_seek(query="Use the Linear MCP to find open bugs assigned to me and summarize the top 3")`
31
+ `raven_seek(query="Use the Linear MCP to find open bugs assigned to me and summarize the top 3")`
32
+
33
+ `raven_seek(query="Compare the first two bugs in more detail", sessionId="ses_...")`
30
34
 
31
35
  Simple piped output filters like `command | grep ...`, `command | rg ...`, `command | findstr ...`, or `command | head ...` are allowed when they only filter bounded output from the immediately preceding command.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-raven",
3
- "version": "2.1.6",
3
+ "version": "2.1.8",
4
4
  "description": "Context-saving search and on-demand MCP routing for OpenCode through a focused Raven subagent.",
5
5
  "main": "./index.ts",
6
6
  "exports": {
@@ -18,6 +18,7 @@
18
18
  "LICENSE"
19
19
  ],
20
20
  "scripts": {
21
+ "test": "bun test",
21
22
  "typecheck": "tsc --noEmit"
22
23
  },
23
24
  "keywords": [