@openviking/opencode-plugin 2026.9.9 → 2026.9.21-1

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.
@@ -2,11 +2,11 @@
2
2
  import { readFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join, resolve as resolvePath } from "node:path";
5
- import { fileURLToPath } from "node:url";
6
5
 
7
6
  const DEFAULT_OVCLI_CONF_PATH = join(homedir(), ".openviking", "ovcli.conf");
8
7
  const DEFAULT_OV_CONF_PATH = join(homedir(), ".openviking", "ov.conf");
9
- const DEFAULT_BASE_URL = "http://127.0.0.1:1933";
8
+ const DEFAULT_TIMEOUT_MS = 15000;
9
+ const MIN_TIMEOUT_MS = 1000;
10
10
 
11
11
  function str(val, fallback = "") {
12
12
  if (typeof val === "string" && val.trim()) return val.trim();
@@ -108,6 +108,88 @@ export function loadCredentialFiles(env = process.env) {
108
108
  };
109
109
  }
110
110
 
111
+ /**
112
+ * The environment variables that name part of the connection. Any one of them
113
+ * set is what takes the chain off ovcli.conf in `auto` mode, so a variable
114
+ * that only tunes how the chain runs must not be on this list: an
115
+ * `OPENVIKING_AUTH_MODE` exported on its own would otherwise unpin the file.
116
+ */
117
+ export const CREDENTIAL_ENV_VARS = [
118
+ "OPENVIKING_URL",
119
+ "OPENVIKING_BASE_URL",
120
+ "OPENVIKING_MCP_URL",
121
+ "OPENVIKING_BEARER_TOKEN",
122
+ "OPENVIKING_API_KEY",
123
+ "OPENVIKING_ACCOUNT",
124
+ "OPENVIKING_USER",
125
+ "OPENVIKING_PEER_ID",
126
+ ];
127
+
128
+ /** Every environment variable `resolveConnection` reads. */
129
+ export const CONNECTION_ENV_VARS = [
130
+ ...CREDENTIAL_ENV_VARS,
131
+ "OPENVIKING_AUTH_MODE",
132
+ "OPENVIKING_CREDENTIAL_SOURCE",
133
+ "OPENVIKING_CREDENTIALS_SOURCE",
134
+ "OPENVIKING_CLI_CONFIG_FILE",
135
+ "OPENVIKING_CONFIG_FILE",
136
+ ];
137
+
138
+ function isSection(value) {
139
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
140
+ }
141
+
142
+ /**
143
+ * ov.conf spells its sections snake_case while a host may call itself
144
+ * `claude-code` or `trae-cn`, so both spellings land on the same block. The
145
+ * normalization stays inline rather than importing `config-schema.mjs`, which
146
+ * the portable agent-plugins bundle would then have to ship as well.
147
+ */
148
+ function sectionKey(harness) {
149
+ return String(harness || "").trim().toLowerCase().replace(/-/g, "_");
150
+ }
151
+
152
+ /** The calling harness's own section of ov.conf. */
153
+ function harnessSection(ovFile, harness) {
154
+ const section = ovFile[sectionKey(harness)];
155
+ return isSection(section) ? section : {};
156
+ }
157
+
158
+ /**
159
+ * ovcli.conf's `plugin` section as this harness sees it: the shared scalars,
160
+ * then `plugin.<harness>` over them in both spellings, the hyphenated one last.
161
+ * `loadPluginSettings` merges the same way for every other knob, and the two
162
+ * must agree or a key would win in the hooks and lose here.
163
+ */
164
+ function pluginSection(cliFile, harness) {
165
+ const plugin = isSection(cliFile.plugin) ? cliFile.plugin : {};
166
+ const merged = {};
167
+ for (const [key, value] of Object.entries(plugin)) {
168
+ if (!isSection(value)) merged[key] = value;
169
+ }
170
+ const key = sectionKey(harness);
171
+ for (const candidate of [key, key.replace(/_/g, "-")]) {
172
+ if (candidate && isSection(plugin[candidate])) Object.assign(merged, plugin[candidate]);
173
+ }
174
+ return merged;
175
+ }
176
+
177
+ /** A knob-file scalar as the schema coerces a string knob: `12345` is "12345". */
178
+ function scalar(value) {
179
+ return value === undefined || value === null ? "" : String(value).trim();
180
+ }
181
+
182
+ const AUTH_MODES = ["trusted", "api_key"];
183
+
184
+ function normalizeAuthMode(value) {
185
+ const mode = str(value, "").toLowerCase();
186
+ return AUTH_MODES.includes(mode) ? mode : "";
187
+ }
188
+
189
+ function authModeIn(section) {
190
+ return normalizeAuthMode(section.authMode) || normalizeAuthMode(section.auth_mode);
191
+ }
192
+
111
193
  function sourceMode(env) {
112
194
  const raw = str(env.OPENVIKING_CREDENTIAL_SOURCE, str(env.OPENVIKING_CREDENTIALS_SOURCE, "auto"))
113
195
  .toLowerCase();
@@ -117,123 +199,177 @@ function sourceMode(env) {
117
199
  }
118
200
 
119
201
  function hasEnvCredentialFields(env) {
120
- return Boolean(
121
- str(env.OPENVIKING_URL, str(env.OPENVIKING_BASE_URL, "")) ||
122
- str(env.OPENVIKING_MCP_URL, "") ||
123
- str(env.OPENVIKING_BEARER_TOKEN, str(env.OPENVIKING_API_KEY, "")) ||
124
- str(env.OPENVIKING_ACCOUNT, "") ||
125
- str(env.OPENVIKING_USER, "") ||
126
- str(env.OPENVIKING_PEER_ID, ""),
127
- );
202
+ return CREDENTIAL_ENV_VARS.some((name) => str(env[name], ""));
128
203
  }
129
204
 
130
- function deriveBaseUrl({ env, cliFile, ovFile, mode, useCli }) {
131
- const envUrl = str(env.OPENVIKING_URL, str(env.OPENVIKING_BASE_URL, ""));
132
- const cliUrl = str(cliFile.url, "");
133
-
134
- if (mode !== "cli" && envUrl) return envUrl.replace(/\/+$/, "");
135
- if (useCli && cliUrl) return cliUrl.replace(/\/+$/, "");
136
- if (mode !== "env" && cliUrl) return cliUrl.replace(/\/+$/, "");
137
-
138
- const server = ovFile.server || {};
139
- const ovUrl = str(server.url, "");
140
- if (ovUrl) return ovUrl.replace(/\/+$/, "");
205
+ function trimSlash(value) {
206
+ return value.replace(/\/+$/, "");
207
+ }
141
208
 
142
- const host = str(server.host, "127.0.0.1").replace("0.0.0.0", "127.0.0.1");
143
- const port = Number.isFinite(Number(server.port)) ? Math.floor(Number(server.port)) : 1933;
144
- return `http://${host}:${port}`;
209
+ /** The first layer that holds a value, with the source label and file it names. */
210
+ function firstLayer(layers) {
211
+ for (const [value, source, path = ""] of layers) {
212
+ if (value) return { value, source, path };
213
+ }
214
+ return { value: "", source: "none", path: "" };
145
215
  }
146
216
 
147
- export function resolveOpenVikingCredentials(env = process.env) {
148
- const files = loadCredentialFiles(env);
217
+ /**
218
+ * The one connection a harness has: which server, which key, whose identity,
219
+ * and whether that identity goes on the wire. Hooks reach it through
220
+ * `buildPluginConfig`, every MCP proxy through the same loader or through
221
+ * `buildProxyConnection`, so the two sides of one harness cannot disagree.
222
+ *
223
+ * Only three kinds of input count: what an embedding host named (`hostInput`),
224
+ * the environment, and the two files under `~/.openviking`. The working
225
+ * directory never does — an MCP proxy starts wherever its host launches it.
226
+ *
227
+ * ovcli.conf pins the chain when it names a url, key, identity or peer and the
228
+ * environment names none of those; pinned, the environment's credentials no
229
+ * longer apply. `OPENVIKING_CREDENTIAL_SOURCE` forces either side, and forced
230
+ * to `env` no file is read at all — which is what lets a parent process hand a
231
+ * resolved connection, including an empty key, to a child verbatim. Every chain
232
+ * runs host → env (unpinned) → ovcli.conf → its `plugin.<harness>` and
233
+ * `plugin` keys → ov.conf's harness section; the key then ends at
234
+ * `server.root_api_key`, which a pinned chain reaches only with
235
+ * `rootKeyFallback`, and the identity skips ov.conf's harness section when
236
+ * pinned. The auth mode reads the environment in either mode, then the
237
+ * `plugin` keys, the harness section and `server.auth_mode`; failing all of
238
+ * them, an identity from any layer means the deployment expects one.
239
+ */
240
+ export function resolveConnection(harness, {
241
+ env = process.env,
242
+ files = null,
243
+ hostInput = {},
244
+ rootKeyFallback = false,
245
+ } = {}) {
246
+ const name = str(harness, "");
247
+ const loaded = files || loadCredentialFiles(env);
248
+ const { cliFile, ovFile, cliPath, ovPath } = loaded;
249
+ const host = hostInput || {};
149
250
  const mode = sourceMode(env);
150
251
  const envHasCredentials = hasEnvCredentialFields(env);
151
- const useCli = mode === "cli" ||
152
- (mode === "auto" && !envHasCredentials && files.cliPath && hasCredentialFields(files.cliFile));
153
- const cx = files.ovFile.codex || {};
154
- const server = files.ovFile.server || {};
155
-
156
- const baseUrl = deriveBaseUrl({ env, ...files, mode, useCli });
157
-
158
- const apiKey = useCli
159
- ? str(files.cliFile.api_key, "")
160
- : (
161
- str(env.OPENVIKING_BEARER_TOKEN, "") ||
162
- str(env.OPENVIKING_API_KEY, "") ||
163
- str(files.cliFile.api_key, "") ||
164
- str(cx.apiKey, "") ||
165
- str(server.root_api_key, "")
166
- );
167
-
168
- const account = useCli
169
- ? str(files.cliFile.account, str(files.cliFile.account_id, ""))
170
- : (
171
- str(env.OPENVIKING_ACCOUNT, "") ||
172
- str(files.cliFile.account, str(files.cliFile.account_id, "")) ||
173
- str(cx.accountId, "")
174
- );
175
-
176
- const user = useCli
177
- ? str(files.cliFile.user, str(files.cliFile.user_id, ""))
178
- : (
179
- str(env.OPENVIKING_USER, "") ||
180
- str(files.cliFile.user, str(files.cliFile.user_id, "")) ||
181
- str(cx.userId, "")
182
- );
183
-
184
- const peerId = useCli
185
- ? str(files.cliFile.actor_peer_id, str(files.cliFile.peer_id, ""))
186
- : (
187
- str(env.OPENVIKING_PEER_ID, "") ||
188
- str(files.cliFile.actor_peer_id, str(files.cliFile.peer_id, "")) ||
189
- str(cx.peerId, str(cx.peer_id, ""))
190
- );
191
-
192
- const explicitMcpUrl = str(env.OPENVIKING_MCP_URL, "");
193
- const mcpUrl = (mode !== "cli" && explicitMcpUrl) ? explicitMcpUrl : `${baseUrl.replace(/\/+$/, "")}/mcp`;
194
-
195
- // The file that actually supplied the api_key, following the same chain —
196
- // empty when the key came from the environment or was never found.
197
- let credentialPath = "";
198
- if (apiKey) {
199
- if (useCli) credentialPath = files.cliPath;
200
- else if (str(env.OPENVIKING_BEARER_TOKEN, str(env.OPENVIKING_API_KEY, ""))) credentialPath = "";
201
- else if (str(files.cliFile.api_key, "")) credentialPath = files.cliPath;
202
- else credentialPath = files.ovPath;
203
- }
252
+ const pinned = mode === "cli" ||
253
+ (mode === "auto" && !envHasCredentials && Boolean(cliPath) && hasCredentialFields(cliFile));
254
+ const unpinned = (layer) => (pinned ? [] : [layer]);
255
+ const fromFiles = mode !== "env";
256
+ const filed = (...layers) => (fromFiles ? layers : []);
257
+ const plugin = pluginSection(cliFile, name);
258
+ const section = harnessSection(ovFile, name);
259
+ const server = isSection(ovFile.server) ? ovFile.server : {};
260
+
261
+ const key = firstLayer([
262
+ [str(host.apiKey, ""), "host"],
263
+ ...unpinned([str(env.OPENVIKING_BEARER_TOKEN, str(env.OPENVIKING_API_KEY, "")), "env"]),
264
+ ...filed(
265
+ [str(cliFile.api_key, ""), "ovcli", cliPath],
266
+ [scalar(plugin.apiKey), "ovcli", cliPath],
267
+ [scalar(section.apiKey), "ov", ovPath],
268
+ ),
269
+ ...((pinned && !rootKeyFallback) ? [] : filed([str(server.root_api_key, ""), "ov", ovPath])),
270
+ ]);
271
+
272
+ const identity = (hostValue, envName, cliValue, knob) => firstLayer([
273
+ [str(hostValue, ""), "host"],
274
+ ...unpinned([str(env[envName], ""), "env"]),
275
+ ...filed([cliValue, "ovcli", cliPath], [scalar(plugin[knob]), "ovcli", cliPath]),
276
+ ...(pinned ? [] : filed([str(section[knob], ""), "ov", ovPath])),
277
+ ]).value;
278
+ const account = identity(host.account, "OPENVIKING_ACCOUNT", str(cliFile.account, str(cliFile.account_id, "")), "accountId");
279
+ const user = identity(host.user, "OPENVIKING_USER", str(cliFile.user, str(cliFile.user_id, "")), "userId");
280
+
281
+ const peerId = firstLayer([
282
+ ...unpinned([str(env.OPENVIKING_PEER_ID, ""), "env"]),
283
+ ...filed([str(cliFile.actor_peer_id, str(cliFile.peer_id, "")), "ovcli"]),
284
+ ...(pinned ? [] : filed([str(section.peerId, str(section.peer_id, "")), "ov"])),
285
+ ]).value;
286
+
287
+ const authMode = normalizeAuthMode(host.authMode)
288
+ || normalizeAuthMode(env.OPENVIKING_AUTH_MODE)
289
+ || (fromFiles && (authModeIn(plugin) || authModeIn(section) || normalizeAuthMode(server.auth_mode)))
290
+ || ((account || user) ? "trusted" : "api_key");
291
+
292
+ const hostBaseUrl = trimSlash(str(host.baseUrl, ""));
293
+ const envUrl = pinned ? "" : str(env.OPENVIKING_URL, str(env.OPENVIKING_BASE_URL, ""));
294
+ const fileUrl = fromFiles ? (str(cliFile.url, "") || str(server.url, "")) : "";
295
+ const baseUrl = hostBaseUrl || trimSlash(envUrl || fileUrl) || serverAddress(fromFiles ? server : {});
296
+ const envMcpUrl = pinned ? "" : str(env.OPENVIKING_MCP_URL, "");
297
+ const mcpUrl = (!hostBaseUrl && envMcpUrl) ? envMcpUrl : `${baseUrl}/mcp`;
204
298
 
205
299
  return {
206
- ...files,
207
- credentialSource: useCli ? "ovcli" : ((mode === "env" || envHasCredentials) ? "env" : "auto"),
208
- credentialPath,
300
+ ...loaded,
301
+ harness: name,
302
+ credentialSource: pinned ? "ovcli" : ((mode === "env" || envHasCredentials) ? "env" : "auto"),
209
303
  baseUrl,
210
304
  mcpUrl,
211
- apiKey,
305
+ apiKey: key.value,
212
306
  account,
213
307
  user,
214
308
  peerId,
215
- hasApiKey: Boolean(apiKey),
309
+ authMode,
310
+ sendIdentityHeaders: authMode === "trusted",
311
+ hasApiKey: Boolean(key.value),
312
+ // Which layer supplied the key and the file behind it, empty when the key
313
+ // came from the host or the environment. A doctor and a 401 hint report it;
314
+ // `credentialSource` is the mode the chain ran in, not a file.
315
+ apiKeySource: key.source,
316
+ credentialPath: key.path,
216
317
  };
217
318
  }
218
319
 
219
- function main() {
220
- const cmd = process.argv[2] || "";
221
- if (cmd === "mcp-url") {
222
- process.stdout.write(resolveOpenVikingCredentials().mcpUrl);
223
- return;
224
- }
225
- if (cmd === "has-api-key") {
226
- process.stdout.write(resolveOpenVikingCredentials().hasApiKey ? "1" : "0");
227
- return;
228
- }
229
- if (cmd === "has-peer-id") {
230
- process.stdout.write(resolveOpenVikingCredentials().peerId ? "1" : "0");
231
- return;
232
- }
233
- process.stderr.write("usage: ov-credentials.mjs <mcp-url|has-api-key|has-peer-id>\n");
234
- process.exitCode = 2;
320
+ function serverAddress(server) {
321
+ const host = str(server.host, "127.0.0.1").replace("0.0.0.0", "127.0.0.1");
322
+ const port = Number.isFinite(Number(server.port)) ? Math.floor(Number(server.port)) : 1933;
323
+ return `http://${host}:${port}`;
324
+ }
325
+
326
+ function envFlag(env, name) {
327
+ const raw = str(env[name], "").toLowerCase();
328
+ return raw === "1" || raw === "true" || raw === "yes";
235
329
  }
236
330
 
237
- if (process.argv[1] && fileURLToPath(import.meta.url) === resolvePath(process.argv[1])) {
238
- main();
331
+ function clampTimeout(value) {
332
+ const raw = str(value, "");
333
+ const parsed = raw ? Number(raw) : NaN;
334
+ return Math.max(MIN_TIMEOUT_MS, Math.floor(Number.isFinite(parsed) ? parsed : DEFAULT_TIMEOUT_MS));
335
+ }
336
+
337
+ /**
338
+ * Everything a stdio MCP proxy needs, for a package that ships no hooks.
339
+ *
340
+ * `buildPluginConfig` answers the same question one layer up, but reaching it
341
+ * means vendoring the knob schema and the workspace layers — a closure the
342
+ * portable agent-plugins bundle would carry in git to send one HTTP header.
343
+ * This is the same connection plus what a proxy adds to it: the User-Agent,
344
+ * the request timeout, and the debug log.
345
+ *
346
+ * The api_key ends at `server.root_api_key` even when ovcli.conf pinned the
347
+ * chain to itself. A portable package has no installer to migrate anyone, so
348
+ * an install that names only a `url` there keeps the key it has always used.
349
+ */
350
+ export function buildProxyConnection(harness, { env = process.env, manifestUrl = "", version = "" } = {}) {
351
+ const name = str(harness);
352
+ const connection = resolveConnection(name, { env, rootKeyFallback: true });
353
+ return {
354
+ harness: name,
355
+ userAgent: buildUserAgent(name, str(version) || (manifestUrl ? readManifestVersion(manifestUrl) : "")),
356
+ baseUrl: connection.baseUrl,
357
+ mcpUrl: connection.mcpUrl,
358
+ apiKey: connection.apiKey,
359
+ account: connection.account,
360
+ user: connection.user,
361
+ peerId: connection.peerId,
362
+ authMode: connection.authMode,
363
+ sendIdentityHeaders: connection.sendIdentityHeaders,
364
+ credentialSource: connection.credentialSource,
365
+ apiKeySource: connection.apiKeySource,
366
+ credentialPath: connection.credentialPath,
367
+ hasApiKey: connection.hasApiKey,
368
+ cliPath: connection.cliPath,
369
+ ovPath: connection.ovPath,
370
+ timeoutMs: clampTimeout(env.OPENVIKING_TIMEOUT_MS),
371
+ debug: envFlag(env, "OPENVIKING_DEBUG"),
372
+ debugLogPath: str(env.OPENVIKING_DEBUG_LOG)
373
+ || join(homedir(), ".openviking", "logs", `${name}.log`),
374
+ };
239
375
  }
@@ -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
+ }