@openviking/opencode-plugin 2026.9.9 → 2026.9.10

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.
@@ -1,4 +1,6 @@
1
1
  // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
+ import { applyInputFilters, compileInputFilters } from "./input-filters.mjs";
3
+
2
4
  const TEXT_BLOCK_TYPES = new Set(["text", "input_text", "output_text"]);
3
5
  const TOOL_CALL_TYPES = new Set([
4
6
  "tool_call",
@@ -354,6 +356,53 @@ export function extractPartsFromPayload(payload, options = {}) {
354
356
  return parts;
355
357
  }
356
358
 
359
+ /**
360
+ * Prune blank text parts and run `cfg.captureFilters` over the ones that remain.
361
+ *
362
+ * One drop verdict is taken per turn, on the aggregate of its text parts; the
363
+ * individual parts are then rewritten with the substitutions only, so a rule can
364
+ * never both keep and drop the same turn. Tool call/result parts are carried
365
+ * through untouched — filters match conversation text, not tool payloads — but a
366
+ * turn dropped on its text takes its tool parts with it.
367
+ */
368
+ export function filterCaptureParts(parts, role, cfg = {}) {
369
+ const kept = [];
370
+ for (const part of parts || []) {
371
+ if (!part) continue;
372
+ if (part.type !== "text") {
373
+ kept.push(part);
374
+ continue;
375
+ }
376
+ const text = typeof part.text === "string" ? part.text.trim() : "";
377
+ if (!text) continue;
378
+ kept.push(text === part.text ? part : { ...part, text });
379
+ }
380
+
381
+ const blank = { parts: kept, dropped: false };
382
+ const compiled = compileInputFilters(cfg?.captureFilters);
383
+ if (!compiled.rules.length || kept.length === 0) return blank;
384
+
385
+ const textParts = kept.filter((part) => part.type === "text");
386
+ if (textParts.length === 0) return blank;
387
+
388
+ const verdict = applyInputFilters(textParts.map((part) => part.text).join("\n\n"), compiled.rules, {
389
+ role,
390
+ });
391
+ if (verdict.dropped) return { parts: [], dropped: true };
392
+
393
+ const out = [];
394
+ for (const part of kept) {
395
+ if (part.type !== "text") {
396
+ out.push(part);
397
+ continue;
398
+ }
399
+ const shaped = applyInputFilters(part.text, compiled.rules, { role, substituteOnly: true });
400
+ if (!shaped.text) continue;
401
+ out.push(shaped.text === part.text ? part : { ...part, text: shaped.text });
402
+ }
403
+ return { parts: out, dropped: out.length === 0 };
404
+ }
405
+
357
406
  export function extractCaptureTurns(rolloutEntries, cfg = {}) {
358
407
  const toolNameById = collectToolNamesByIdFromEntries(rolloutEntries);
359
408
  const turns = [];
@@ -371,10 +420,15 @@ export function extractCaptureTurns(rolloutEntries, cfg = {}) {
371
420
  toolMaxChars: cfg.captureToolMaxChars,
372
421
  toolNameById,
373
422
  });
374
- const decision = shouldCaptureText(rawText, role, cfg);
375
- if (!decision.shouldCapture && parts.length === 0) continue;
423
+ const shaped = filterCaptureParts(parts, role, cfg);
424
+ if (shaped.dropped) continue;
425
+ // With parts on the wire the drop decision was already taken above, so the
426
+ // text path only runs the filters for the `content` fallback. That also
427
+ // keeps `turn.text` faithful for callers that scan it for trigger words.
428
+ const decision = shouldCaptureText(rawText, role, cfg, { filters: shaped.parts.length === 0 });
429
+ if (!decision.shouldCapture && shaped.parts.length === 0) continue;
376
430
  const text = decision.shouldCapture ? decision.text : "";
377
- turns.push({ role, text, parts });
431
+ turns.push({ role, text, parts: shaped.parts });
378
432
  }
379
433
  return turns;
380
434
  }
@@ -486,12 +540,21 @@ function isPunctuationOnly(text) {
486
540
  return !/[a-z0-9\u3400-\u9fff]/i.test(text);
487
541
  }
488
542
 
489
- export function shouldCaptureText(text, role, cfg = {}) {
543
+ export function shouldCaptureText(text, role, cfg = {}, { filters = true } = {}) {
490
544
  const maxLength = cfg.captureMaxLength || 24000;
491
545
  const sanitized = sanitizeCapturedText(text);
492
546
  if (!sanitized) return { shouldCapture: false, reason: "empty", text: "" };
493
547
 
494
- const capped = truncateCaptureText(sanitized, maxLength);
548
+ let capped = truncateCaptureText(sanitized, maxLength);
549
+ if (filters) {
550
+ const compiled = compileInputFilters(cfg?.captureFilters);
551
+ if (compiled.rules.length) {
552
+ const verdict = applyInputFilters(capped, compiled.rules, { role });
553
+ if (verdict.dropped) return { shouldCapture: false, reason: "filtered", text: "" };
554
+ capped = verdict.text;
555
+ if (!capped) return { shouldCapture: false, reason: "empty", text: "" };
556
+ }
557
+ }
495
558
  const compact = oneLine(capped);
496
559
  const isToolSummary = /^\[tool-(?:call|result)\b/i.test(compact);
497
560
 
@@ -0,0 +1,219 @@
1
+ // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
+ /**
3
+ * Ordered, operator-configured regex filters for memory plugin input.
4
+ *
5
+ * Rules are sed-style strings, applied in order to a single piece of text:
6
+ * s/^\s*ultrathink\s+//i substitute (add `g` to replace every match)
7
+ * d|^\s*[/!]| drop the text when the pattern matches
8
+ * k/^\?ov\b/ keep the text only when the pattern matches
9
+ * user:d/^\s*\/clear\b/ apply to one role only
10
+ *
11
+ * A malformed rule becomes an entry in `errors` and is skipped rather than
12
+ * throwing, so a typo in a config file cannot take a hook down.
13
+ */
14
+
15
+ const OPS = new Set(["s", "d", "k"]);
16
+ const SCOPES = ["user", "assistant"];
17
+ const ALLOWED_FLAGS = "imsug";
18
+
19
+ export const INPUT_FILTER_KNOBS = [
20
+ {
21
+ key: "recallQueryFilters",
22
+ env: "OPENVIKING_RECALL_QUERY_FILTERS",
23
+ label: "recall query filters",
24
+ },
25
+ {
26
+ key: "captureFilters",
27
+ env: "OPENVIKING_CAPTURE_FILTERS",
28
+ label: "capture filters",
29
+ },
30
+ ];
31
+
32
+ /**
33
+ * Read one delimited field. `\` always consumes the next character, and only
34
+ * `\<delim>` is un-escaped, so `\d` / `\b` / `\x2c` reach RegExp verbatim.
35
+ * Returns the index of the closing delimiter, or -1 when it is missing.
36
+ */
37
+ function scanField(source, start, delim) {
38
+ let out = "";
39
+ let i = start;
40
+ while (i < source.length) {
41
+ const c = source[i];
42
+ if (c === "\\") {
43
+ const next = source[i + 1];
44
+ if (next === undefined) {
45
+ out += "\\";
46
+ i += 1;
47
+ continue;
48
+ }
49
+ out += next === delim ? next : "\\" + next;
50
+ i += 2;
51
+ continue;
52
+ }
53
+ if (c === delim) return { value: out, end: i };
54
+ out += c;
55
+ i += 1;
56
+ }
57
+ return { value: out, end: -1 };
58
+ }
59
+
60
+ export function parseInputFilterRule(source) {
61
+ if (typeof source !== "string") {
62
+ return { source: String(source ?? ""), error: "rule must be a string" };
63
+ }
64
+ const raw = source.trim();
65
+ if (!raw) return { source: raw, error: "rule is empty" };
66
+
67
+ let scope = "";
68
+ let rest = raw;
69
+ for (const candidate of SCOPES) {
70
+ if (rest.startsWith(`${candidate}:`)) {
71
+ scope = candidate;
72
+ rest = rest.slice(candidate.length + 1);
73
+ break;
74
+ }
75
+ }
76
+
77
+ const op = rest[0] || "";
78
+ if (!OPS.has(op)) {
79
+ return {
80
+ source: raw,
81
+ error: `unknown operation "${op}" — expected s (substitute), d (drop) or k (keep)`,
82
+ };
83
+ }
84
+ const delim = rest[1];
85
+ if (delim === undefined) {
86
+ return { source: raw, error: `missing delimiter after "${op}"` };
87
+ }
88
+ if (/[A-Za-z0-9\s\\]/.test(delim)) {
89
+ return {
90
+ source: raw,
91
+ error: `invalid delimiter ${JSON.stringify(delim)} — use punctuation such as / | # or :`,
92
+ };
93
+ }
94
+
95
+ const first = scanField(rest, 2, delim);
96
+ const pattern = first.value;
97
+ let replacement = null;
98
+ let flagsRaw = "";
99
+ if (op === "s") {
100
+ if (first.end < 0) {
101
+ return { source: raw, error: `missing ${JSON.stringify(delim)} after the pattern` };
102
+ }
103
+ const second = scanField(rest, first.end + 1, delim);
104
+ if (second.end < 0) {
105
+ return {
106
+ source: raw,
107
+ error: `missing ${JSON.stringify(delim)} after the replacement — write s${delim}pattern${delim}replacement${delim}`,
108
+ };
109
+ }
110
+ replacement = second.value;
111
+ flagsRaw = rest.slice(second.end + 1);
112
+ } else {
113
+ flagsRaw = first.end < 0 ? "" : rest.slice(first.end + 1);
114
+ }
115
+
116
+ if (!pattern) return { source: raw, error: "pattern is empty" };
117
+
118
+ const flags = new Set();
119
+ for (const flag of flagsRaw) {
120
+ if (!ALLOWED_FLAGS.includes(flag)) {
121
+ return { source: raw, error: `unknown flag "${flag}" — allowed flags are i, m, s, u, g` };
122
+ }
123
+ if (flags.has(flag)) return { source: raw, error: `duplicate flag "${flag}"` };
124
+ flags.add(flag);
125
+ }
126
+ // d/k decide with .test(), which advances lastIndex on a global regex and
127
+ // would then alternate between calls. `g` means nothing there, so drop it.
128
+ if (op !== "s") flags.delete("g");
129
+
130
+ let re;
131
+ try {
132
+ re = new RegExp(pattern, [...flags].join(""));
133
+ } catch (err) {
134
+ // V8 already says "Invalid regular expression: <pattern>: <why>"; keep that
135
+ // detail without stuttering the prefix the doctors key off.
136
+ const message = err?.message || String(err);
137
+ return {
138
+ source: raw,
139
+ error: /^invalid regular expression/i.test(message)
140
+ ? `invalid${message.slice("Invalid".length)}`
141
+ : `invalid regular expression: ${message}`,
142
+ };
143
+ }
144
+
145
+ return { op, scope, re, replacement, source: raw };
146
+ }
147
+
148
+ export function compileInputFilters(rules) {
149
+ const compiled = [];
150
+ const errors = [];
151
+ const list = Array.isArray(rules) ? rules : [];
152
+ list.forEach((entry, index) => {
153
+ if (typeof entry !== "string") {
154
+ errors.push({ index, source: String(entry ?? ""), message: "rule must be a string" });
155
+ return;
156
+ }
157
+ if (!entry.trim()) return;
158
+ const parsed = parseInputFilterRule(entry);
159
+ if (parsed.error) errors.push({ index, source: parsed.source, message: parsed.error });
160
+ else compiled.push({ ...parsed, index });
161
+ });
162
+ return { rules: compiled, errors };
163
+ }
164
+
165
+ /**
166
+ * Run compiled rules over `text`.
167
+ *
168
+ * `substituteOnly` skips the d/k operations, so a caller that already took one
169
+ * drop decision for a turn can rewrite its individual pieces without taking a
170
+ * second, possibly contradictory one.
171
+ */
172
+ export function applyInputFilters(text, compiled, { role = "", substituteOnly = false } = {}) {
173
+ const original = typeof text === "string" ? text : String(text ?? "");
174
+ let current = original;
175
+ for (const rule of Array.isArray(compiled) ? compiled : []) {
176
+ if (rule.scope && rule.scope !== role) continue;
177
+ if (rule.op === "s") {
178
+ current = current.replace(rule.re, rule.replacement);
179
+ continue;
180
+ }
181
+ if (substituteOnly) continue;
182
+ const matched = rule.re.test(current);
183
+ if ((rule.op === "d" && matched) || (rule.op === "k" && !matched)) {
184
+ return { text: "", changed: false, dropped: true, ruleIndex: rule.index, op: rule.op };
185
+ }
186
+ }
187
+ const finalText = current.trim();
188
+ return {
189
+ text: finalText,
190
+ changed: finalText !== original,
191
+ dropped: false,
192
+ ruleIndex: -1,
193
+ op: "",
194
+ };
195
+ }
196
+
197
+ /** Doctor-facing summary of both filter knobs on a resolved plugin config. */
198
+ export function describeInputFilters(cfg = {}) {
199
+ return INPUT_FILTER_KNOBS.map(({ key, env, label }) => {
200
+ const configured = Array.isArray(cfg?.[key]) ? cfg[key] : [];
201
+ const compiled = compileInputFilters(configured);
202
+ const ops = { s: 0, d: 0, k: 0 };
203
+ for (const rule of compiled.rules) ops[rule.op] += 1;
204
+ const bits = [];
205
+ if (ops.s) bits.push(`${ops.s} substitute`);
206
+ if (ops.d) bits.push(`${ops.d} drop`);
207
+ if (ops.k) bits.push(`${ops.k} keep-only`);
208
+ const active = compiled.rules.length;
209
+ return {
210
+ key,
211
+ env,
212
+ label,
213
+ total: configured.length,
214
+ active,
215
+ summary: `${active} rule${active === 1 ? "" : "s"}${bits.length ? ` (${bits.join(", ")})` : ""}`,
216
+ errors: compiled.errors,
217
+ };
218
+ });
219
+ }
@@ -4,9 +4,10 @@
4
4
  *
5
5
  * When the OpenViking server is temporarily unreachable, write operations
6
6
  * (addMessage, commitSession) serialize their payloads to
7
- * `~/.openviking/pending/` as JSON files. On the next session-start, the
8
- * queue is replayed in small batches. This is a session-start-triggered retry
9
- * path with maxRetries/TTL, not a long-running background worker.
7
+ * `~/.openviking/pending/` as JSON files. The queue is replayed in small
8
+ * batches, either at session-start (consuming retry budgets, with
9
+ * maxRetries/TTL) or by a long-running drainer that passes
10
+ * `consumeRetries: false` so transient failures stay retryable.
10
11
  *
11
12
  * Each file contains: { type, sessionId, payload, createdAt, retries, dedupKey }
12
13
  *
@@ -274,6 +275,23 @@ export async function claimForReplay(filename) {
274
275
  }
275
276
  }
276
277
 
278
+ /**
279
+ * Release a claimed file back to the queue without consuming a retry. The
280
+ * entry keeps its original filename, retry count, and createdAt position, so a
281
+ * later run retries it as if this attempt never happened.
282
+ */
283
+ export async function releaseClaim(claimedFilename) {
284
+ if (!claimedFilename.endsWith(".processing")) return null;
285
+ const dir = getPendingDir();
286
+ const restored = pendingFromProcessingFilename(claimedFilename);
287
+ try {
288
+ await rename(join(dir, claimedFilename), join(dir, restored));
289
+ return restored;
290
+ } catch {
291
+ return null;
292
+ }
293
+ }
294
+
277
295
  /**
278
296
  * Remove a pending entry after successful replay.
279
297
  */
@@ -350,9 +368,16 @@ export async function cleanStale() {
350
368
  *
351
369
  * @param {Function} fetchJSON - the configured fetchJSON from makeFetchJSON
352
370
  * @param {Function} log - logger function
371
+ * @param {object} [options]
372
+ * @param {boolean} [options.consumeRetries=true] - when false, retryable
373
+ * failures release their claim instead of incrementing the retry count, so
374
+ * a background drainer can keep retrying a transient failure without
375
+ * burning the session-start retry budget. Exhausted and non-retryable
376
+ * entries are deleted exactly as in the default mode.
353
377
  * @returns {{ replayed: number, failed: number, skipped: number, deferred: number }}
354
378
  */
355
- export async function replayPending(fetchJSON, log) {
379
+ export async function replayPending(fetchJSON, log, options = {}) {
380
+ const consumeRetries = options.consumeRetries !== false;
356
381
  const pending = await listPending();
357
382
 
358
383
  if (pending.length === 0) {
@@ -427,7 +452,18 @@ export async function replayPending(fetchJSON, log) {
427
452
  await dequeue(claimedFilename);
428
453
  skipped++;
429
454
  } else {
430
- await incrementRetry(claimedFilename, entry);
455
+ if (consumeRetries) {
456
+ await incrementRetry(claimedFilename, entry);
457
+ } else {
458
+ const released = await releaseClaim(claimedFilename);
459
+ log("pending-queue", {
460
+ action: released ? "replay-deferred" : "release-failed",
461
+ sessionId: entry.sessionId,
462
+ type: entry.type,
463
+ status: res?.result?.status || res?.status,
464
+ retries: entry.retries || 0,
465
+ });
466
+ }
431
467
  failed++;
432
468
  if (entry.type === "addMessage") {
433
469
  deferred += Math.max(0, pending.length - processed);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openviking/opencode-plugin",
3
- "version": "2026.9.9",
3
+ "version": "2026.9.10",
4
4
  "author": "@openviking",
5
5
  "description": "Unified OpenCode plugin for OpenViking repository retrieval and long-term memory",
6
6
  "type": "module",
@@ -44,5 +44,5 @@
44
44
  "code-search"
45
45
  ],
46
46
  "license": "Apache-2.0",
47
- "openvikingSourceCommit": "26cb585cae254c28f17e715f906cddf9f6b45f71"
47
+ "openvikingSourceCommit": "3841e6f2992bc2299142f5ec50143e6b30e55ac0"
48
48
  }