@openviking/opencode-plugin 2026.9.24-3 → 2026.9.24-5

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.mjs CHANGED
@@ -56,9 +56,18 @@ function v1Hooks(runtime) {
56
56
 
57
57
  "chat.message": async (input, output) => {
58
58
  try {
59
- await sessionInject.injectSessionContext(input, output)
60
- if (!isRecallEnabled(config)) return
61
- await recall.injectRelevantMemories(input, output)
59
+ // opencode awaits this hook before persisting/broadcasting the user
60
+ // message, so serial awaits here stack remote latency onto message
61
+ // display (#5148). Session inject and recall are independent after
62
+ // entry; overlap them the way the dsh plugin does (#4643).
63
+ if (!isRecallEnabled(config)) {
64
+ await sessionInject.injectSessionContext(input, output)
65
+ return
66
+ }
67
+ await Promise.all([
68
+ sessionInject.injectSessionContext(input, output),
69
+ recall.injectRelevantMemories(input, output),
70
+ ])
62
71
  } catch (error) {
63
72
  log("WARN", "recall", "Auto recall failed", { error: error?.message ?? String(error) })
64
73
  }
@@ -1,10 +1,9 @@
1
1
  import fs from "fs"
2
2
  import path from "path"
3
3
  import {
4
- extractPartsFromPayload,
5
4
  extractTextFromPayload,
6
5
  isCaptureEnabled,
7
- shouldCaptureText,
6
+ shapeCapturePayload,
8
7
  } from "./shared/capture-utils.mjs"
9
8
  import {
10
9
  deriveHarnessSessionId,
@@ -383,18 +382,17 @@ export function createMemorySessionManager({ config, pluginRoot }) {
383
382
  if (!role) return null
384
383
  if (role === "assistant" && !config.captureAssistantTurns) return null
385
384
 
386
- const rawText = partsRaw
387
- .map((part) => extractTextFromPayload(part, { toolMaxChars: config.captureToolMaxChars }))
388
- .filter(Boolean)
389
- .join("\n\n")
390
- const captureParts = partsRaw.flatMap((part) => extractPartsFromPayload(part, {
391
- toolMaxChars: config.captureToolMaxChars,
392
- }))
393
- const decision = shouldCaptureText(rawText, role, config)
394
- if (!decision.shouldCapture && captureParts.length === 0) return null
395
- const body = captureParts.length > 0
396
- ? { role, parts: captureParts }
397
- : { role, content: decision.text }
385
+ const shaped = shapeCapturePayload({ role, content: partsRaw }, role, config)
386
+ const toolParts = shaped.parts.filter((part) => part.type !== "text")
387
+ if (shaped.dropped || (!shaped.text && toolParts.length === 0)) return null
388
+ const body = toolParts.length > 0
389
+ ? { role, parts: [
390
+ ...(shaped.text && shaped.parts.some((part) => part.type === "text")
391
+ ? [{ type: "text", text: shaped.text }]
392
+ : []),
393
+ ...toolParts,
394
+ ] }
395
+ : { role, content: shaped.text }
398
396
  const peerId = effectivePeerId(config)
399
397
  if (peerId) body.peer_id = peerId
400
398
  return body
@@ -409,6 +409,61 @@ export function filterCaptureParts(parts, role, cfg = {}) {
409
409
  return { parts: out, dropped: out.length === 0 };
410
410
  }
411
411
 
412
+ /** Sanitize already-extracted message parts before applying capture rules. */
413
+ export function shapeCaptureParts(parts, role, cfg = {}) {
414
+ const sanitized = (parts || []).map((part) => part?.type === "text"
415
+ ? { ...part, text: sanitizeCapturedText(part.text) }
416
+ : part);
417
+ return filterCaptureParts(sanitized, role, cfg);
418
+ }
419
+
420
+ function faithfulCaptureDecision(text, cfg) {
421
+ const sanitized = sanitizeCapturedText(text);
422
+ if (!sanitized) return { shouldCapture: false, text: "" };
423
+ const capped = truncateCaptureText(sanitized, cfg.captureMaxLength || 24000);
424
+ const compact = oneLine(capped);
425
+ if (/^\[openviking-memory\]/i.test(compact) || SLASH_COMMAND_RE.test(compact)) {
426
+ return { shouldCapture: false, text: "" };
427
+ }
428
+ return { shouldCapture: true, text: capped };
429
+ }
430
+
431
+ /**
432
+ * Shape one host message before an adapter builds its session payload.
433
+ * Sanitize injected context before evaluating keep/drop rules, so text that
434
+ * will not be sent cannot satisfy a keep rule. Tool parts are never filtered.
435
+ */
436
+ export function shapeCapturePayload(payload, role, cfg = {}, { toolNameById = {}, faithful = false } = {}) {
437
+ const options = { toolMaxChars: cfg.captureToolMaxChars, toolNameById };
438
+ const rawText = extractTextFromPayload(payload, options);
439
+ const sourceParts = extractPartsFromPayload(payload, options);
440
+ const hasTextPart = sourceParts.some((part) => part?.type === "text");
441
+ const sanitizedText = sanitizeCapturedText(rawText);
442
+ // Some hosts supply an array of plain strings. It has text but no structured
443
+ // parts, so use a temporary part for the same filter verdict.
444
+ const fallback = sourceParts.length === 0 && sanitizedText
445
+ ? [{ type: "text", text: rawText }]
446
+ : [];
447
+ const shaped = shapeCaptureParts(sourceParts.length ? sourceParts : fallback, role, cfg);
448
+ if (shaped.dropped) return { parts: [], text: "", signalText: "", dropped: true };
449
+
450
+ const parts = sourceParts.length ? shaped.parts : [];
451
+ const decisionText = hasTextPart || fallback.length
452
+ ? shaped.parts.filter((part) => part.type === "text").map((part) => part.text).join("\n\n")
453
+ : sanitizedText;
454
+ const decision = faithful
455
+ ? faithfulCaptureDecision(decisionText, cfg)
456
+ : shouldCaptureText(decisionText, role, cfg, { filters: false });
457
+ // Codex scans turn.text for explicit-memory keywords; keep that scan based
458
+ // on the user's original words while the filtered parts go on the wire.
459
+ const signalText = decision.shouldCapture && parts.length
460
+ ? (faithful
461
+ ? faithfulCaptureDecision(sanitizedText, cfg)
462
+ : shouldCaptureText(sanitizedText, role, cfg, { filters: false })).text
463
+ : decision.text;
464
+ return { parts, text: decision.shouldCapture ? decision.text : "", signalText: signalText || "", dropped: false };
465
+ }
466
+
412
467
  export function extractCaptureTurns(rolloutEntries, cfg = {}) {
413
468
  const toolNameById = collectToolNamesByIdFromEntries(rolloutEntries);
414
469
  const turns = [];
@@ -421,20 +476,9 @@ export function extractCaptureTurns(rolloutEntries, cfg = {}) {
421
476
  if (!role) continue;
422
477
  if (isAssistantSideCaptureRole(rawRole) && !cfg.captureAssistantTurns) continue;
423
478
 
424
- const rawText = extractTextFromPayload(payload, { toolMaxChars: cfg.captureToolMaxChars });
425
- const parts = extractPartsFromPayload(payload, {
426
- toolMaxChars: cfg.captureToolMaxChars,
427
- toolNameById,
428
- });
429
- const shaped = filterCaptureParts(parts, role, cfg);
430
- if (shaped.dropped) continue;
431
- // With parts on the wire the drop decision was already taken above, so the
432
- // text path only runs the filters for the `content` fallback. That also
433
- // keeps `turn.text` faithful for callers that scan it for trigger words.
434
- const decision = shouldCaptureText(rawText, role, cfg, { filters: shaped.parts.length === 0 });
435
- if (!decision.shouldCapture && shaped.parts.length === 0) continue;
436
- const text = decision.shouldCapture ? decision.text : "";
437
- turns.push({ role, text, parts: shaped.parts });
479
+ const shaped = shapeCapturePayload(payload, role, cfg, { toolNameById });
480
+ if (shaped.dropped || (!shaped.text && shaped.parts.length === 0)) continue;
481
+ turns.push({ role, text: shaped.parts.length ? shaped.signalText : shaped.text, parts: shaped.parts });
438
482
  }
439
483
  return turns;
440
484
  }
@@ -555,7 +599,7 @@ export function sanitizeCapturedText(text) {
555
599
  .replace(/^[ \t]*\[Subagent Context\][^\n]*$/gim, " ")
556
600
  .replace(/^\s*Sender\s*\([^)]+\)\s*```[\s\S]*?```\s*/gim, " ")
557
601
  .replace(/^\s*Conversation (?:metadata|info):\s*```[\s\S]*?```\s*/gim, " ")
558
- .replace(/^\s*\[?\d{4}-\d{2}-\d{2}[T ][^\]\n]{3,80}\]?\s*/gm, "")
602
+ .replace(/^[ \t]*\[\d{4}-\d{2}-\d{2}[T ][-+0-9:.Z]{5,35}\][ \t]*/gm, "")
559
603
  .replace(/^\s*\d{10,13}\s+/gm, "");
560
604
  value = stripMetadataFences(value);
561
605
  value = stripInjectedDigestBlocks(value);
@@ -598,16 +642,18 @@ export function shouldCaptureText(text, role, cfg = {}, { filters = true } = {})
598
642
  const sanitized = sanitizeCapturedText(text);
599
643
  if (!sanitized) return { shouldCapture: false, reason: "empty", text: "" };
600
644
 
601
- let capped = truncateCaptureText(sanitized, maxLength);
645
+ // Rules judge the full sanitized turn; the wire cap applies afterward.
646
+ let filtered = sanitized;
602
647
  if (filters) {
603
648
  const compiled = compileInputFilters(cfg?.captureFilters);
604
649
  if (compiled.rules.length) {
605
- const verdict = applyInputFilters(capped, compiled.rules, { role });
650
+ const verdict = applyInputFilters(filtered, compiled.rules, { role });
606
651
  if (verdict.dropped) return { shouldCapture: false, reason: "filtered", text: "" };
607
- capped = verdict.text;
608
- if (!capped) return { shouldCapture: false, reason: "empty", text: "" };
652
+ filtered = verdict.text;
653
+ if (!filtered) return { shouldCapture: false, reason: "empty", text: "" };
609
654
  }
610
655
  }
656
+ const capped = truncateCaptureText(filtered, maxLength);
611
657
  const compact = oneLine(capped);
612
658
  const isToolSummary = /^\[tool-(?:call|result)\b/i.test(compact);
613
659
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openviking/opencode-plugin",
3
- "version": "2026.9.24-3",
3
+ "version": "2026.9.24-5",
4
4
  "author": "@openviking",
5
5
  "description": "Unified OpenCode plugin for OpenViking repository retrieval and long-term memory",
6
6
  "type": "module",
@@ -45,5 +45,5 @@
45
45
  "code-search"
46
46
  ],
47
47
  "license": "Apache-2.0",
48
- "openvikingSourceCommit": "37390e6d71fb2f2ac62bd2c736be7a7ceab9043e"
48
+ "openvikingSourceCommit": "4edc30b068934893bc94a4e1b8e87bab2100bce5"
49
49
  }