opencode-raven 1.2.6 → 1.2.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.
Files changed (3) hide show
  1. package/README.md +4 -3
  2. package/index.ts +127 -16
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -40,14 +40,14 @@ Restart opencode.
40
40
 
41
41
  | Command | Action |
42
42
  |---------|--------|
43
- | `/raven` | Show status — enabled/disabled, model, reasoning effort, timeout |
43
+ | `/raven` | Show status — enabled/disabled, version, update availability, model, reasoning effort, timeout (no args) |
44
44
  | `/raven on` | Enable search tool redirection (default) |
45
45
  | `/raven off` | Disable interception — all agents can use search tools directly |
46
46
  | `/raven update` | Check npm for a newer Raven, clear opencode's plugin cache if needed, then restart opencode |
47
47
  | `/raven model <name>` | Change Raven's model (requires restart) |
48
48
  | `/raven effort <value>` | Change Raven's reasoning effort (requires restart) |
49
49
  | `/raven timeout <seconds>` | Change raven_seek timeout (min 10s, takes effect immediately) |
50
- | `/raven stats` | Show context processed (session + all-time, bytes + tokens) |
50
+ | `/raven stats` | Show context saved (session + all-time, bytes + tokens) |
51
51
 
52
52
  Config persists across restarts in `~/.config/opencode/raven-config.json` (global, shared across all projects). Auto-created on first run.
53
53
 
@@ -55,7 +55,7 @@ Config persists across restarts in `~/.config/opencode/raven-config.json` (globa
55
55
 
56
56
  opencode caches npm plugins, so `"opencode-raven"` / `"opencode-raven@latest"` may not automatically refresh after a new npm release.
57
57
 
58
- Raven checks npm at startup. If an update is available, it shows a TUI notification. To update:
58
+ Raven checks npm after the TUI starts. If an update is available, it shows a notification. `/raven` also shows the current version and update availability. To update:
59
59
 
60
60
  ```txt
61
61
  /raven update
@@ -168,6 +168,7 @@ To disable an MCP entirely:
168
168
  | `config` | Registers Raven agent, merges Context7/Exa/Grep.app MCP defaults, loads MCP guidance |
169
169
  | `tool` | Registers `raven_seek` — creates Raven sessions with timeout, error recovery, timing, and session tree visibility. Tracks context processed for stats (both `raven_seek` and direct `@Raven`). |
170
170
  | `chat.message` | Tracks agent ↔ session mapping for allowlist and Raven exclusion |
171
+ | `event` | Shows startup update notifications after the TUI event stream is ready |
171
172
  | `command.execute.before` | Handles `/raven on\|off\|update\|model\|effort\|timeout\|stats\|status` |
172
173
  | `tool.execute.before` | Blocks search tools for non-Raven, non-excluded agents (respects `excludeTools`). Error output gives the next `raven_seek(query="...")` call. Injects concise `<raven_guidance>` into subagent prompts. |
173
174
  | `tool.execute.after` | Counts output bytes from direct `@Raven` calls for accurate stats. |
package/index.ts CHANGED
@@ -262,7 +262,12 @@ export default ((input: PluginInput) => {
262
262
  let config = loadConfig()
263
263
  const ravenSessions = new Set<string>()
264
264
  const ravenTaskCalls = new Set<string>()
265
+ const ravenTaskPrompts = new Map<string, number>()
265
266
  const sessionAgents = new Map<string, string>()
267
+ const ravenSessionParents = new Map<string, string>()
268
+ let updateInfo: { current: string; latest?: string; available: boolean } | undefined
269
+ let updateCheckPromise: Promise<{ current: string; latest?: string; available: boolean }> | undefined
270
+ let updateToastPending = false
266
271
 
267
272
  // ── Check if an agent is excluded from Raven enforcement (case-insensitive) ──
268
273
  function isExcluded(agent: string | undefined): boolean {
@@ -320,12 +325,19 @@ export default ((input: PluginInput) => {
320
325
  }
321
326
 
322
327
  async function fetchLatestVersion(): Promise<string | undefined> {
323
- const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
324
- headers: { accept: "application/json" },
325
- })
326
- if (!res.ok) return undefined
327
- const data = await res.json() as { version?: string }
328
- return data.version
328
+ const controller = new AbortController()
329
+ const timeout = setTimeout(() => controller.abort(), 5000)
330
+ try {
331
+ const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
332
+ headers: { accept: "application/json" },
333
+ signal: controller.signal,
334
+ })
335
+ if (!res.ok) return undefined
336
+ const data = await res.json() as { version?: string }
337
+ return data.version
338
+ } finally {
339
+ clearTimeout(timeout)
340
+ }
329
341
  }
330
342
 
331
343
  async function checkForUpdate(): Promise<{ current: string; latest?: string; available: boolean }> {
@@ -333,6 +345,50 @@ export default ((input: PluginInput) => {
333
345
  return { current: PACKAGE_VERSION, latest, available: !!latest && compareVersions(latest, PACKAGE_VERSION) > 0 }
334
346
  }
335
347
 
348
+ async function countRavenSessionBytes(sessionId: string): Promise<number> {
349
+ // Get last assistant message token counts (matches TUI bottom bar)
350
+ const messagesResp = await client.session.messages({ path: { id: sessionId }, query: { limit: 200 } })
351
+ const messages = (messagesResp as any)?.data ?? []
352
+ // Find last assistant message with output tokens (same logic as TUI subagent-footer.tsx)
353
+ const last = [...messages].reverse().find((m: any) =>
354
+ m?.info?.role === "assistant" && m?.info?.tokens?.output > 0
355
+ )
356
+ const t = last?.info?.tokens
357
+ if (!t) return 0
358
+ const totalTokens = (t.input ?? 0) + (t.output ?? 0) + (t.reasoning ?? 0) + (t.cache?.read ?? 0) + (t.cache?.write ?? 0)
359
+ return totalTokens * 4
360
+ }
361
+
362
+ async function getUpdateInfo(): Promise<{ current: string; latest?: string; available: boolean }> {
363
+ if (updateInfo) return updateInfo
364
+ if (!updateCheckPromise) {
365
+ updateCheckPromise = checkForUpdate()
366
+ .then((info) => {
367
+ updateInfo = info
368
+ return info
369
+ })
370
+ .catch((err) => {
371
+ updateCheckPromise = undefined
372
+ throw err
373
+ })
374
+ }
375
+ return updateCheckPromise
376
+ }
377
+
378
+ async function refreshUpdateInfo(): Promise<{ current: string; latest?: string; available: boolean }> {
379
+ updateInfo = undefined
380
+ updateCheckPromise = checkForUpdate()
381
+ .then((info) => {
382
+ updateInfo = info
383
+ return info
384
+ })
385
+ .catch((err) => {
386
+ updateCheckPromise = undefined
387
+ throw err
388
+ })
389
+ return updateCheckPromise
390
+ }
391
+
336
392
  function clearPluginCache(): string[] {
337
393
  const packagesDir = join(homedir(), ".cache", "opencode", "packages")
338
394
  if (!existsSync(packagesDir)) return []
@@ -353,7 +409,7 @@ export default ((input: PluginInput) => {
353
409
 
354
410
  async function notifyIfUpdateAvailable() {
355
411
  try {
356
- const info = await checkForUpdate()
412
+ const info = await getUpdateInfo()
357
413
  if (!info.available || !info.latest) return
358
414
  await (client as any).tui?.showToast?.({
359
415
  body: {
@@ -417,7 +473,7 @@ export default ((input: PluginInput) => {
417
473
  }
418
474
  }
419
475
 
420
- void notifyIfUpdateAvailable()
476
+ updateToastPending = true
421
477
  },
422
478
 
423
479
  // Register raven_seek tool — lets agents with task:false still search through Raven
@@ -480,10 +536,23 @@ export default ((input: PluginInput) => {
480
536
  .map((p: any) => p.text)
481
537
  const output = textParts.join("\n") || "Raven returned no results."
482
538
 
483
- // Track context saved
484
- addBytes(output.length)
539
+ // Get total Raven session context and subtract input/output to get context saved
540
+ let totalProcessed = 0
541
+ try {
542
+ totalProcessed = await countRavenSessionBytes(sessionId)
543
+ } catch { /* best-effort */ }
544
+ if (totalProcessed <= 0) {
545
+ for (const part of parts) {
546
+ if (part.text) totalProcessed += part.text.length
547
+ if (part.args) totalProcessed += JSON.stringify(part.args).length
548
+ if (part.content) totalProcessed += typeof part.content === "string" ? part.content.length : JSON.stringify(part.content).length
549
+ }
550
+ }
551
+ // Context saved = total session context − input query − compact answer returned
552
+ const saved = Math.max(0, totalProcessed - output.length - String(args.query).length)
553
+ addBytes(saved)
485
554
 
486
- return { title: "Raven Seek", metadata: { sessionId }, output: `${output}\n\n*Raven searched for ${elapsed}s — ${formatBytes(output.length)}, ~${formatTokens(output.length)} tokens*` }
555
+ return { title: "Raven Seek", metadata: { sessionId }, output: `${output}\n\n*Raven searched for ${elapsed}s — ${formatBytes(totalProcessed)} processed, ${formatTokens(totalProcessed)} tokens*` }
487
556
  } catch (err: any) {
488
557
  const elapsed = ((Date.now() - started) / 1000).toFixed(1)
489
558
  const msg = String(err?.message ?? err ?? "").toLowerCase()
@@ -510,6 +579,18 @@ export default ((input: PluginInput) => {
510
579
  }
511
580
  },
512
581
 
582
+ event(input: { event: any }) {
583
+ // Track subagent session → parent mapping for accurate context counting
584
+ const evt = input.event
585
+ if (evt?.type === "session.created" && evt?.properties?.parentID) {
586
+ ravenSessionParents.set(evt.properties.parentID, evt.properties.id)
587
+ }
588
+
589
+ if (!updateToastPending) return
590
+ updateToastPending = false
591
+ setTimeout(() => void notifyIfUpdateAvailable(), 500)
592
+ },
593
+
513
594
  // /raven on|off|model <name>|effort <value>|timeout <seconds>|stats|status
514
595
  async "command.execute.before"(input: any, output: any) {
515
596
  if (input.command !== "raven") return
@@ -526,10 +607,10 @@ export default ((input: PluginInput) => {
526
607
  saveConfig(config)
527
608
  output.parts.push({ type: "text", text: "Raven search interception disabled. All agents can use search tools directly." })
528
609
  } else if (arg === "stats") {
529
- output.parts.push({ type: "text", text: `Raven context processed:\n This session: ${formatBytes(sessionBytes)} (~${formatTokens(sessionBytes)} tokens)\n All time: ${formatBytes(totalBytes)} (~${formatTokens(totalBytes)} tokens)` })
610
+ 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)` })
530
611
  } else if (arg === "update") {
531
612
  try {
532
- const info = await checkForUpdate()
613
+ const info = await refreshUpdateInfo()
533
614
  if (!info.latest) {
534
615
  output.parts.push({ type: "text", text: `Could not check npm for ${PACKAGE_NAME}. Try again later.\n\n${manualUpdateText()}` })
535
616
  } else if (!info.available) {
@@ -573,7 +654,14 @@ export default ((input: PluginInput) => {
573
654
  const model = config.model || fm.model || "(default)"
574
655
  const effort = config.reasoning_effort || fm.reasoning_effort || "(default)"
575
656
  const timeout = config.timeout ?? 180
576
- output.parts.push({ type: "text", text: `Raven is ${enabled}. Model: ${model}. Reasoning: ${effort}. Timeout: ${timeout}s\n\nCommands:\n /raven on — enable search interception\n /raven off — disable search interception\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 blocked calls and context saved` })
657
+ let update = "Update: unable to check npm."
658
+ try {
659
+ const info = await getUpdateInfo()
660
+ update = info.available && info.latest
661
+ ? `Update: ${info.latest} available. Run /raven update, then restart opencode.`
662
+ : `Update: up to date${info.latest ? ` (latest ${info.latest})` : ""}.`
663
+ } catch { /* keep fallback */ }
664
+ output.parts.push({ type: "text", text: `Raven is ${enabled}. Version: ${PACKAGE_VERSION}. Model: ${model}. Reasoning: ${effort}. Timeout: ${timeout}s\n${update}\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 search interception\n /raven off — disable search interception\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` })
577
665
  }
578
666
  },
579
667
 
@@ -592,6 +680,10 @@ export default ((input: PluginInput) => {
592
680
  const subagentType = input.tool === "task" ? (output.args.subagent_type ?? "") : ""
593
681
  if (subagentType === "raven") {
594
682
  ravenTaskCalls.add(input.callID)
683
+ const promptField = ["prompt", "description", "request", "objective", "query"].find(
684
+ (f) => f in output.args
685
+ ) ?? "prompt"
686
+ ravenTaskPrompts.set(input.callID, String(output.args[promptField] ?? "").length)
595
687
  }
596
688
  if (subagentType !== "raven" && !isExcluded(subagentType)) {
597
689
  const field = ["prompt", "description", "request", "objective", "query"].find(
@@ -613,8 +705,27 @@ export default ((input: PluginInput) => {
613
705
  "tool.execute.after"(input: any, output: any) {
614
706
  if (ravenTaskCalls.has(input.callID)) {
615
707
  ravenTaskCalls.delete(input.callID)
616
- const outputLen = String(output.output ?? "").length
617
- if (outputLen > 0) addBytes(outputLen)
708
+ const promptBytes = ravenTaskPrompts.get(input.callID) ?? 0
709
+ ravenTaskPrompts.delete(input.callID)
710
+ // Try task metadata first (built-in tools preserve metadata)
711
+ const ravenSessionId = output.metadata?.sessionId ?? ravenSessionParents.get(input.sessionID)
712
+ if (ravenSessionId) {
713
+ if (ravenSessionParents.has(input.sessionID)) ravenSessionParents.delete(input.sessionID)
714
+ void countRavenSessionBytes(ravenSessionId)
715
+ .then((total) => {
716
+ const saved = Math.max(0, total - promptBytes - String(output.output ?? "").length)
717
+ if (saved > 0) addBytes(saved)
718
+ })
719
+ .catch(() => {
720
+ const outputLen = String(output.output ?? "").length
721
+ const saved = Math.max(0, outputLen - promptBytes)
722
+ if (saved > 0) addBytes(saved)
723
+ })
724
+ } else {
725
+ const outputLen = String(output.output ?? "").length
726
+ const saved = Math.max(0, outputLen - promptBytes)
727
+ if (saved > 0) addBytes(saved)
728
+ }
618
729
  }
619
730
  },
620
731
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-raven",
3
- "version": "1.2.6",
3
+ "version": "1.2.8",
4
4
  "description": "Search-first subagent for opencode — intercepts search tools and routes them through a hidden Raven agent with Context7, Exa AI, and Grep.app MCPs",
5
5
  "main": "./index.ts",
6
6
  "exports": {