@openviking/dsh-memory-plugin 0.3.2 → 0.4.3

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/runtime.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { createUserMessage } from "@deepseek-ai/dsh-llm";
1
+ import { isCaptureEnabled } from "./shared/capture-utils.mjs";
2
2
  import { buildProfileBlock } from "./shared/profile-inject.mjs";
3
- import { buildRecallBlock } from "./shared/recall-core.mjs";
3
+ import { buildRecallBlock, isRecallEnabled } from "./shared/recall-core.mjs";
4
4
  import { deriveHarnessSessionId } from "./shared/session-model.mjs";
5
5
  import {
6
6
  dequeue,
@@ -13,6 +13,7 @@ import { resolveEffectivePeerId } from "./shared/workspace-peer.mjs";
13
13
  import {
14
14
  captureEvent,
15
15
  OPENVIKING_PLUGIN_SOURCE,
16
+ pluginMessage,
16
17
  promptText,
17
18
  } from "./capture.mjs";
18
19
 
@@ -93,7 +94,7 @@ export class OpenVikingRuntime {
93
94
  }
94
95
  // Replay is a write, so it stays behind the same toggle: a backlog queued
95
96
  // while capture was on waits for a session that still writes.
96
- if (state.config.syncTurns) {
97
+ if (isCaptureEnabled(state.config)) {
97
98
  await this.replayPendingQueue();
98
99
  }
99
100
  await this.refreshPendingState(state);
@@ -121,12 +122,12 @@ export class OpenVikingRuntime {
121
122
  return null;
122
123
  }
123
124
  state.profileDelivered = true;
124
- return pluginMessage(state.profileBlock, "instructions");
125
+ return pluginMessage(state.profileBlock, { form: "instructions" });
125
126
  }
126
127
 
127
128
  async recallMessage(agent, messages) {
128
129
  const state = await this.initialize(agent);
129
- if (!state.ready) return null;
130
+ if (!state.ready || !isRecallEnabled(state.config)) return null;
130
131
  const query = promptText(messages);
131
132
  if (query.length < state.config.minQueryLength) return null;
132
133
  const block = await buildRecallBlock(
@@ -140,12 +141,12 @@ export class OpenVikingRuntime {
140
141
  log: (stage, data) => this.log(stage, data),
141
142
  },
142
143
  );
143
- return block ? pluginMessage(block, "recall") : null;
144
+ return block ? pluginMessage(block, { form: "recall" }) : null;
144
145
  }
145
146
 
146
147
  capture(session, event) {
147
148
  const state = this.stateFor(session);
148
- if (!state.config.syncTurns) return;
149
+ if (!isCaptureEnabled(state.config)) return;
149
150
  const payload = captureEvent(event, state.config, state.toolNames);
150
151
  if (!payload) return;
151
152
  this.enqueueWrite(state, async () => {
@@ -177,7 +178,7 @@ export class OpenVikingRuntime {
177
178
  maybeCommit(session, event) {
178
179
  if (event.type !== "turn/end") return;
179
180
  const state = this.stateFor(session);
180
- if (!state.config.syncTurns) return;
181
+ if (!isCaptureEnabled(state.config)) return;
181
182
  this.enqueueWrite(state, async () => {
182
183
  if (state.hasPendingWrites) return;
183
184
  if (!state.ready && !(await this.ensureState(state)).ready) return;
@@ -210,7 +211,7 @@ export class OpenVikingRuntime {
210
211
  if (state.disposing) return state.disposing;
211
212
  state.disposing = (async () => {
212
213
  this.enqueueWrite(state, async () => {
213
- if (!state.config.syncTurns) return;
214
+ if (!isCaptureEnabled(state.config)) return;
214
215
  const commitPayload = {
215
216
  keep_recent_count: state.config.commitKeepRecentCount,
216
217
  };
@@ -403,19 +404,6 @@ export class OpenVikingRuntime {
403
404
  }
404
405
  }
405
406
 
406
- function pluginMessage(content, form) {
407
- // dsh's own constructor: identity, normalization, and any future Message
408
- // invariants come from the pinned peer instead of a hand-built object.
409
- return createUserMessage({
410
- content: [{ type: "text", text: content }],
411
- source: {
412
- kind: "plugin",
413
- plugin: OPENVIKING_PLUGIN_SOURCE,
414
- form,
415
- },
416
- });
417
- }
418
-
419
407
  function hasStartupProfile(agent) {
420
408
  const session = agent.session;
421
409
  const ownEvents = typeof session?.ownEvents === "function"
@@ -4,40 +4,30 @@
4
4
  * stdio -> streamable-HTTP MCP proxy for the OpenViking DSH bundle.
5
5
  *
6
6
  * DSH's MCP bridge starts this process as a local stdio MCP server. The proxy
7
- * reads the same OpenViking credential sources as the in-process runtime; the
8
- * bundle also forwards its resolved config through the child environment, so
9
- * values that came from the Cordis patch survive the process boundary.
7
+ * resolves its connection through the same `resolveConfig()` as the in-process
8
+ * runtime, from the child environment the bundle builds in `mcp.mjs`: DSH
9
+ * scrubs credential-shaped names out of what it inherits, and values that came
10
+ * from the Cordis patch are invisible to a subprocess otherwise.
10
11
  */
11
12
 
12
13
  import { resolve as resolvePath } from "node:path";
13
14
  import { fileURLToPath } from "node:url";
14
15
  import { resolveConfig } from "../config.mjs";
15
- import { resolveOpenVikingCredentials } from "../shared/credentials.mjs";
16
16
  import { createLogger } from "../shared/debug-log.mjs";
17
- import { buildMcpProxyConfig } from "../shared/mcp-proxy-config.mjs";
17
+ import { toMcpProxyConfig } from "../shared/mcp-proxy-config.mjs";
18
18
  import { createOpenVikingMcpProxy } from "../shared/mcp-proxy-core.mjs";
19
19
 
20
20
  export function readProxyConfig(env = process.env, cwd = process.cwd()) {
21
21
  const cfg = resolveConfig({}, env, cwd);
22
- const creds = resolveOpenVikingCredentials(env);
23
- return buildMcpProxyConfig({
24
- baseUrl: cfg.endpoint,
25
- apiKey: cfg.apiKey,
26
- account: cfg.account,
27
- user: cfg.user,
22
+ return toMcpProxyConfig(cfg, {
23
+ env,
28
24
  // Not gated through `resolveMcpActorPeerId` like the other proxies: DSH's
29
25
  // parent process resolves the peer per session and hands it over in the
30
- // child env (`mcp.mjs` `buildMcpConfig`), so this one is not a guess at the
31
- // launch directory.
32
- peerId: cfg.peerId,
33
- userAgent: cfg.userAgent,
34
- timeoutMs: cfg.requestTimeoutMs,
26
+ // child env. Empty means it has none, and deriving one from wherever DSH
27
+ // launched this process would send a peer the runtime does not.
28
+ peerId: String(env.OPENVIKING_PEER_ID || "").trim(),
35
29
  debug: Boolean(env.OV_DEBUG_LOG),
36
- debugLogPath: env.OV_DEBUG_LOG,
37
- credentialSource: creds.credentialSource,
38
- credentialPath: creds.cliPath || creds.ovPath,
39
- watchedPaths: [creds.cliPath, creds.ovPath, creds.cliPathCandidate],
40
- env,
30
+ debugLogPath: env.OV_DEBUG_LOG || "",
41
31
  });
42
32
  }
43
33
 
@@ -266,7 +266,13 @@ export function extractTextFromPayload(payload, options = {}) {
266
266
  return chunks.join("\n\n");
267
267
  }
268
268
 
269
- function collectToolNamesByIdFromEntries(entries) {
269
+ /**
270
+ * Map every tool call id in a transcript to the name of the tool it invoked.
271
+ *
272
+ * A result block names the call by id only, so the name has to come from the
273
+ * call that preceded it. Pass the map as `toolNameById` to the extractors.
274
+ */
275
+ export function collectToolNamesByIdFromEntries(entries) {
270
276
  const map = {};
271
277
  for (const entry of entries || []) {
272
278
  const payload = entry?.payload && typeof entry.payload === "object" ? entry.payload : entry;
@@ -433,6 +439,24 @@ export function extractCaptureTurns(rolloutEntries, cfg = {}) {
433
439
  return turns;
434
440
  }
435
441
 
442
+ /**
443
+ * Index of the last turn that came from a human prompt, or -1.
444
+ *
445
+ * `role === "user"` alone is not enough: normalizeCaptureRole() maps tool
446
+ * results onto the user role too, and those carry `tool` parts rather than
447
+ * `text` parts. Used by the post-compact shrink path to find where the current
448
+ * interaction starts.
449
+ */
450
+ export function findLastHumanTurnIndex(turns) {
451
+ const list = Array.isArray(turns) ? turns : [];
452
+ for (let i = list.length - 1; i >= 0; i -= 1) {
453
+ const turn = list[i];
454
+ if (turn?.role !== "user") continue;
455
+ if (turn.parts?.some((part) => part?.type === "text")) return i;
456
+ }
457
+ return -1;
458
+ }
459
+
436
460
  export function normalizeCaptureRole(role) {
437
461
  const value = normalizeType(role);
438
462
  if (value === "user") return "user";
@@ -511,12 +535,24 @@ function stripInjectedDigestBlocks(text) {
511
535
  return out.join("\n");
512
536
  }
513
537
 
538
+ /**
539
+ * Drop everything in a turn that the conversation did not put there.
540
+ *
541
+ * Recall injects a context block into the prompt, and the host adds notes of
542
+ * its own; captured back unchanged, this turn's injection becomes next turn's
543
+ * memory and the loop feeds on itself. Formatting the conversation did author
544
+ * — newlines, code fences — survives.
545
+ */
514
546
  export function sanitizeCapturedText(text) {
515
547
  let value = String(text || "");
516
548
  value = value
517
549
  .replace(/\u0000/g, "")
518
550
  .replace(/<openviking-context\b[^>]*>[\s\S]*?<\/openviking-context>/gi, " ")
519
551
  .replace(/<relevant-memor(?:y|ies)\b[^>]*>[\s\S]*?<\/relevant-memor(?:y|ies)>/gi, " ")
552
+ // Claude Code wraps its own out-of-band notes to the model in these two
553
+ // shapes. They are the host talking to itself, not the conversation.
554
+ .replace(/<system-reminder\b[^>]*>[\s\S]*?<\/system-reminder>/gi, " ")
555
+ .replace(/^[ \t]*\[Subagent Context\][^\n]*$/gim, " ")
520
556
  .replace(/^\s*Sender\s*\([^)]+\)\s*```[\s\S]*?```\s*/gim, " ")
521
557
  .replace(/^\s*Conversation (?:metadata|info):\s*```[\s\S]*?```\s*/gim, " ")
522
558
  .replace(/^\s*\[?\d{4}-\d{2}-\d{2}[T ][^\]\n]{3,80}\]?\s*/gm, "")
@@ -540,6 +576,23 @@ function isPunctuationOnly(text) {
540
576
  return !/[a-z0-9\u3400-\u9fff]/i.test(text);
541
577
  }
542
578
 
579
+ /**
580
+ * Is capture on for this config?
581
+ *
582
+ * The switch has four spellings in the wild: a boolean `autoCapture`, opencode's
583
+ * `{ enabled }` object, dsh and pi's `syncTurns`, and the global `enabled`
584
+ * that turns the whole plugin off. Reading it here rather than in each loader
585
+ * is what keeps it from drifting a fifth time — and any spelling that says off
586
+ * wins, so a config that disables capture under an older name still disables it.
587
+ */
588
+ export function isCaptureEnabled(cfg = {}) {
589
+ for (const value of [cfg.enabled, cfg.autoCapture, cfg.capture, cfg.syncTurns]) {
590
+ if (value === false) return false;
591
+ if (value && typeof value === "object" && !Array.isArray(value) && value.enabled === false) return false;
592
+ }
593
+ return true;
594
+ }
595
+
543
596
  export function shouldCaptureText(text, role, cfg = {}, { filters = true } = {}) {
544
597
  const maxLength = cfg.captureMaxLength || 24000;
545
598
  const sanitized = sanitizeCapturedText(text);
@@ -576,3 +629,26 @@ export function shouldCaptureText(text, role, cfg = {}, { filters = true } = {})
576
629
 
577
630
  return { shouldCapture: true, reason: "ok", text: capped };
578
631
  }
632
+
633
+ /**
634
+ * Apply the capture filter to a list of `{ role, content }` turns.
635
+ *
636
+ * The harnesses that compose `agent-hook-runtime` used to send whatever their
637
+ * transcript parser produced: an acknowledgement, a slash command, a stray
638
+ * `ok`, or a turn far past `captureMaxLength` all reached the extractor
639
+ * verbatim. This is the same decision every other harness makes, in one place,
640
+ * so a thin harness gets it by calling rather than by reimplementing it.
641
+ *
642
+ * Returns the surviving turns with `content` replaced by the sanitized and
643
+ * capped text, and the dropped ones with the reason, for the debug log.
644
+ */
645
+ export function filterCaptureTurns(turns, cfg = {}) {
646
+ const kept = [];
647
+ const dropped = [];
648
+ for (const turn of Array.isArray(turns) ? turns : []) {
649
+ const decision = shouldCaptureText(turn?.content, turn?.role, cfg);
650
+ if (decision.shouldCapture) kept.push({ ...turn, content: decision.text });
651
+ else dropped.push({ role: turn?.role, reason: decision.reason });
652
+ }
653
+ return { kept, dropped };
654
+ }
@@ -0,0 +1,359 @@
1
+ // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
+ /**
3
+ * Every knob a memory plugin reads, declared once.
4
+ *
5
+ * A setting stayed uniform across harnesses only when a shared module read it
6
+ * on the hot path: `recallLimit`, `scoreThreshold` and `captureMaxLength` agree
7
+ * everywhere because `recall-core` and `capture-utils` read them. Everything
8
+ * else drifted — the same switch is `autoRecall` in three loaders, an
9
+ * `autoRecall` *object* in opencode, `syncTurns` in dsh and pi, and absent from
10
+ * the thin hook runtime — because each loader hand-wrote its own list and the
11
+ * author picked a name. Three more lists then had to be kept in step by hand:
12
+ * the doctor's known-knob set, the workspace file's dotted-key map, and the
13
+ * implicit inventory inside `loadAgentHookConfig`.
14
+ *
15
+ * This module is the one list. The others are projections of it:
16
+ * `pluginConfigKeys()` for the doctor, `WORKSPACE_KNOB_MAP` for the workspace
17
+ * file, and `resolveKnobs()` for every loader. A knob added here reaches all of
18
+ * them; a knob spelled wrong reaches none.
19
+ *
20
+ * Declaration fields:
21
+ * name canonical key, and the property the loaders read
22
+ * type bool | int | number | enum | string | list
23
+ * default value when no layer supplies one
24
+ * harness per-harness default, where one genuinely differs
25
+ * min/max clamped, not rejected: a config file must not kill a hook
26
+ * values the enum's members; anything else falls back to the default
27
+ * env OPENVIKING_* variable, the highest-priority layer
28
+ * aliases older spellings, honoured forever; the doctor names them
29
+ * workspace dotted key in `.openviking/config.json`
30
+ * capability which capability owns it, for the switches in recall/capture
31
+ * sendOnlyWhenConfigured
32
+ * the request omits this field unless a layer actually set it,
33
+ * so the server's own default stands. Loaders project the flag
34
+ * as `<name>Configured` and `recall-core` reads it from there.
35
+ */
36
+
37
+ export const CAPABILITIES = ["connection", "peer", "recall", "capture", "session", "debug"];
38
+
39
+ /** @type {ReadonlyArray<object>} */
40
+ export const KNOBS = [
41
+ // ── connection and identity ────────────────────────────────────────────
42
+ // Credentials resolve through `credentials.mjs`; these entries exist so the
43
+ // doctor recognises them inside `plugin` and so ov.conf's harness section
44
+ // keeps working.
45
+ { name: "enabled", type: "bool", default: true, env: "OPENVIKING_MEMORY_ENABLED", capability: "connection" },
46
+ { name: "apiKey", type: "string", default: "", capability: "connection" },
47
+ { name: "accountId", type: "string", default: "", capability: "connection" },
48
+ { name: "userId", type: "string", default: "", capability: "connection" },
49
+ { name: "authMode", type: "enum", values: ["trusted", "api_key"], default: "", env: "OPENVIKING_AUTH_MODE", aliases: ["auth_mode"], capability: "connection" },
50
+ {
51
+ name: "timeoutMs",
52
+ type: "int",
53
+ default: 15000,
54
+ // opencode drives an editor session that tolerates a slower call; dsh runs
55
+ // inside a chat host that does not.
56
+ harness: { opencode: 30000, dsh: 10000 },
57
+ min: 1000,
58
+ max: 300000,
59
+ env: "OPENVIKING_TIMEOUT_MS",
60
+ aliases: ["requestTimeoutMs"],
61
+ capability: "connection",
62
+ },
63
+ { name: "mcpEnabled", type: "bool", default: true, capability: "connection" },
64
+ { name: "mcpToolCallTimeoutMs", type: "int", default: 60000, min: 1000, max: 600000, capability: "connection" },
65
+ { name: "dataDir", type: "string", default: "", capability: "connection" },
66
+
67
+ { name: "peerId", type: "string", default: "", env: "OPENVIKING_PEER_ID", aliases: ["peer_id"], workspace: "peer.id", capability: "peer" },
68
+ { name: "peerSource", type: "template", default: "", env: "OPENVIKING_PEER_SOURCE", workspace: "peer.source", capability: "peer" },
69
+ { name: "workspacePeer", type: "bool", default: true, env: "OPENVIKING_WORKSPACE_PEER", capability: "peer" },
70
+
71
+ // ── recall ────────────────────────────────────────────────────────────
72
+ {
73
+ name: "autoRecall",
74
+ type: "bool",
75
+ default: true,
76
+ env: "OPENVIKING_AUTO_RECALL",
77
+ workspace: "recall.enabled",
78
+ capability: "recall",
79
+ },
80
+ { name: "recallLimit", type: "int", default: 10, min: 1, max: 50, env: "OPENVIKING_RECALL_LIMIT", workspace: "recall.max_items", sendOnlyWhenConfigured: true, capability: "recall" },
81
+ { name: "scoreThreshold", type: "number", default: 0.35, min: 0, max: 1, env: "OPENVIKING_SCORE_THRESHOLD", aliases: ["recallScoreThreshold"], workspace: "recall.score_threshold", capability: "recall" },
82
+ { name: "minQueryLength", type: "int", default: 3, min: 1, max: 64, env: "OPENVIKING_MIN_QUERY_LENGTH", aliases: ["recallMinQueryLength"], capability: "recall" },
83
+ { name: "recallTokenBudget", type: "int", default: 2000, min: 200, max: 50000, env: "OPENVIKING_RECALL_TOKEN_BUDGET", aliases: ["recallBudget"], capability: "recall" },
84
+ { name: "recallMaxContentChars", type: "int", default: 500, min: 100, max: 5000, env: "OPENVIKING_RECALL_MAX_CONTENT_CHARS", capability: "recall" },
85
+ { name: "recallPreferAbstract", type: "bool", default: true, env: "OPENVIKING_RECALL_PREFER_ABSTRACT", capability: "recall" },
86
+ { name: "recallPeerScope", type: "enum", values: ["all", "actor"], default: "all", env: "OPENVIKING_RECALL_PEER_SCOPE", workspace: "recall.peer_scope", capability: "recall" },
87
+ { name: "recallDedupTurns", type: "int", default: 5, min: 0, max: 20, env: "OPENVIKING_RECALL_DEDUP_TURNS", workspace: "recall.dedup_turns", capability: "recall" },
88
+ { name: "recallMaxTokens", type: "int", default: 1600, min: 64, max: 200000, env: "OPENVIKING_RECALL_MAX_TOKENS", sendOnlyWhenConfigured: true, capability: "recall" },
89
+ { name: "recallQueryExpansion", type: "enum", values: ["auto", "off"], default: "auto", env: "OPENVIKING_RECALL_QUERY_EXPANSION", sendOnlyWhenConfigured: true, capability: "recall" },
90
+ { name: "recallTimeoutMs", type: "int", default: 120000, min: 1000, max: 600000, env: "OPENVIKING_RECALL_TIMEOUT_MS", capability: "recall" },
91
+ // 0 keeps the built-in default, which outlasts the server's rewrite fuse.
92
+ { name: "recallContextTimeoutMs", type: "int", default: 0, min: 0, max: 600000, env: "OPENVIKING_RECALL_CONTEXT_TIMEOUT_MS", capability: "recall" },
93
+ { name: "logRankingDetails", type: "bool", default: false, env: "OPENVIKING_LOG_RANKING_DETAILS", capability: "recall" },
94
+ { name: "recallLedger", type: "bool", default: true, env: "OPENVIKING_RECALL_LEDGER", capability: "recall" },
95
+ { name: "recallQueryFilters", type: "list", default: [], env: "OPENVIKING_RECALL_QUERY_FILTERS", capability: "recall" },
96
+
97
+ // Digest compression. Claude Code reads this as the tri-state
98
+ // off/client/server/auto through `normalizeRewriteMode`; Codex reads the same
99
+ // key as on/off. Both spellings stay declared so neither harness's users are
100
+ // told their config is a typo.
101
+ { name: "recallCompress", type: "string", default: "auto", env: "OPENVIKING_RECALL_COMPRESS", aliases: ["recallRewrite"], capability: "recall" },
102
+ { name: "recallCompressModel", type: "string", default: "", env: "OPENVIKING_RECALL_COMPRESS_MODEL", capability: "recall" },
103
+ { name: "recallCompressBaseUrl", type: "string", default: "", env: "OPENVIKING_RECALL_COMPRESS_BASE_URL", capability: "recall" },
104
+ { name: "recallCompressThinking", type: "string", default: "", env: "OPENVIKING_RECALL_COMPRESS_THINKING", aliases: ["recallCompressReasoningEffort"], capability: "recall" },
105
+ // 0 means "derive from recallTimeoutMs" — the loader that owns the fallback
106
+ // is the one that knows the request budget it has to fit inside.
107
+ { name: "recallCompressTimeoutMs", type: "int", default: 0, min: 0, max: 600000, env: "OPENVIKING_RECALL_COMPRESS_TIMEOUT_MS", capability: "recall" },
108
+ { name: "recallCompressDetectOnStartup", type: "bool", default: true, env: "OPENVIKING_RECALL_COMPRESS_DETECT_ON_STARTUP", capability: "recall" },
109
+ { name: "recallCompressDetectTimeoutMs", type: "int", default: 15000, min: 1000, max: 600000, env: "OPENVIKING_RECALL_COMPRESS_DETECT_TIMEOUT_MS", capability: "recall" },
110
+ { name: "recallCompressDetectTtlMs", type: "int", default: 604800000, min: 0, capability: "recall", env: "OPENVIKING_RECALL_COMPRESS_DETECT_TTL_MS" },
111
+ { name: "recallCompressMinInputChars", type: "int", default: 1500, min: 0, max: 100000, env: "OPENVIKING_RECALL_COMPRESS_MIN_INPUT_CHARS", capability: "recall" },
112
+ { name: "recallCompressMaxInputChars", type: "int", default: 18000, min: 1000, max: 200000, env: "OPENVIKING_RECALL_COMPRESS_MAX_INPUT_CHARS", capability: "recall" },
113
+ { name: "recallCompressMaxBullets", type: "int", default: 6, min: 1, max: 50, env: "OPENVIKING_RECALL_COMPRESS_MAX_BULLETS", sendOnlyWhenConfigured: true, capability: "recall" },
114
+
115
+ // ── capture ───────────────────────────────────────────────────────────
116
+ // `syncTurns` is dsh and pi's spelling of the same switch. It stays an alias
117
+ // rather than a second knob so `ov config switch` moves both.
118
+ {
119
+ name: "autoCapture",
120
+ type: "bool",
121
+ default: true,
122
+ env: "OPENVIKING_AUTO_CAPTURE",
123
+ aliases: ["syncTurns"],
124
+ workspace: "capture.enabled",
125
+ capability: "capture",
126
+ },
127
+ { name: "captureMode", type: "enum", values: ["semantic", "keyword"], default: "semantic", env: "OPENVIKING_CAPTURE_MODE", capability: "capture" },
128
+ { name: "captureMaxLength", type: "int", default: 24000, min: 200, max: 100000, env: "OPENVIKING_CAPTURE_MAX_LENGTH", capability: "capture" },
129
+ // Tool output is reported verbatim; the server owns truncation via
130
+ // tool_output_externalization. This cap only guards pathological payloads.
131
+ { name: "captureToolMaxChars", type: "int", default: 1000000, min: 200, max: 1000000, env: "OPENVIKING_CAPTURE_TOOL_MAX_CHARS", capability: "capture" },
132
+ // Default true: a memory plugin that only sees the user's half of the
133
+ // conversation extracts noticeably worse.
134
+ { name: "captureAssistantTurns", type: "bool", default: true, env: "OPENVIKING_CAPTURE_ASSISTANT_TURNS", capability: "capture" },
135
+ { name: "captureLastAssistantOnStop", type: "bool", default: true, env: "OPENVIKING_CAPTURE_LAST_ASSISTANT_ON_STOP", capability: "capture" },
136
+ { name: "captureToolResults", type: "bool", default: false, env: "OPENVIKING_CAPTURE_TOOL_RESULTS", capability: "capture" },
137
+ { name: "captureFilters", type: "list", default: [], env: "OPENVIKING_CAPTURE_FILTERS", capability: "capture" },
138
+ // 0 means "derive from timeoutMs": a write gets a longer budget than a read.
139
+ { name: "captureTimeoutMs", type: "int", default: 0, min: 0, max: 600000, env: "OPENVIKING_CAPTURE_TIMEOUT_MS", capability: "capture" },
140
+ { name: "commitTokenThreshold", type: "int", default: 20000, min: 1000, max: 1000000, env: "OPENVIKING_COMMIT_TOKEN_THRESHOLD", workspace: "capture.commit_token_threshold", capability: "capture" },
141
+ { name: "commitKeepRecentCount", type: "int", default: 10, min: 0, max: 1000, env: "OPENVIKING_COMMIT_KEEP_RECENT_COUNT", capability: "capture" },
142
+ { name: "commitTurnThreshold", type: "int", default: 8, min: 1, max: 1000, env: "OPENVIKING_COMMIT_TURN_THRESHOLD", capability: "capture" },
143
+ { name: "autoCommitOnCompact", type: "bool", default: true, env: "OPENVIKING_AUTO_COMMIT_ON_COMPACT", capability: "capture" },
144
+ { name: "writePathAsync", type: "bool", default: true, env: "OPENVIKING_WRITE_PATH_ASYNC", capability: "capture" },
145
+
146
+ // ── session lifecycle ─────────────────────────────────────────────────
147
+ { name: "noAutoInject", type: "bool", default: false, env: "OPENVIKING_NO_AUTO_INJECT", capability: "session" },
148
+ { name: "profileTokenBudget", type: "int", default: 10000, min: 500, max: 50000, env: "OPENVIKING_PROFILE_TOKEN_BUDGET", aliases: ["profileBudget"], capability: "session" },
149
+ { name: "resumeContextBudget", type: "int", default: 32000, min: 1024, max: 128000, env: "OPENVIKING_RESUME_CONTEXT_BUDGET", capability: "session" },
150
+ { name: "resumeArchiveInject", type: "bool", default: true, env: "OPENVIKING_RESUME_ARCHIVE_INJECT", capability: "session" },
151
+ { name: "resumeArchiveTokenBudget", type: "int", default: 32000, min: 0, max: 128000, env: "OPENVIKING_RESUME_ARCHIVE_TOKEN_BUDGET", capability: "session" },
152
+ { name: "resumeArchiveMaxChars", type: "int", default: 6000, min: 1000, max: 200000, env: "OPENVIKING_RESUME_ARCHIVE_MAX_CHARS", capability: "session" },
153
+ { name: "skillExperience", type: "bool", default: false, env: "OPENVIKING_SKILL_EXPERIENCE", capability: "session" },
154
+ { name: "skillExperienceLimit", type: "int", default: 3, min: 1, max: 50, env: "OPENVIKING_SKILL_EXPERIENCE_LIMIT", capability: "session" },
155
+ { name: "skipSubagentSessions", type: "bool", default: false, env: "OPENVIKING_SKIP_SUBAGENT_SESSIONS", capability: "session" },
156
+ { name: "repoContext", type: "bool", default: true, capability: "session" },
157
+ { name: "repoContextCacheTtlMs", type: "int", default: 60000, min: 1000, max: 3600000, capability: "session" },
158
+
159
+ { name: "takeoverEnabled", type: "bool", default: true, env: "OPENVIKING_TAKEOVER", capability: "session" },
160
+ { name: "takeoverTokenThreshold", type: "int", default: 30000, min: 1, max: 1000000, capability: "session" },
161
+ { name: "takeoverKeepRecentTurns", type: "int", default: 3, min: 0, max: 100, capability: "session" },
162
+ { name: "takeoverOverviewBudget", type: "int", default: 3000, min: 100, max: 50000, capability: "session" },
163
+ { name: "takeoverOverviewPollMs", type: "int", default: 2000, min: 0, max: 60000, capability: "session" },
164
+ { name: "takeoverOverviewPollMax", type: "int", default: 15, min: 1, max: 120, capability: "session" },
165
+
166
+ { name: "bypassSession", type: "bool", default: false, env: "OPENVIKING_BYPASS_SESSION", capability: "session" },
167
+ {
168
+ name: "bypassSessionPatterns",
169
+ type: "list",
170
+ default: [],
171
+ env: "OPENVIKING_BYPASS_SESSION_PATTERNS",
172
+ // pi shipped this under its own name before the shared matcher existed.
173
+ aliases: ["bypassPatterns"],
174
+ workspace: "bypass.session_patterns",
175
+ capability: "session",
176
+ },
177
+
178
+ // ── debug ─────────────────────────────────────────────────────────────
179
+ { name: "debug", type: "bool", default: false, env: "OPENVIKING_DEBUG", capability: "debug" },
180
+ // The default path is per-harness and built from a log directory the loader
181
+ // owns, so the schema only carries what a user set.
182
+ { name: "debugLogPath", type: "string", default: "", env: "OPENVIKING_DEBUG_LOG", capability: "debug" },
183
+ { name: "logLevel", type: "enum", values: ["silent", "error", "info"], default: "error", env: "OPENVIKING_LOG_LEVEL", capability: "debug" },
184
+ ];
185
+
186
+ export const KNOB_BY_NAME = new Map(KNOBS.map((knob) => [knob.name, knob]));
187
+
188
+ /** Every accepted spelling → the canonical knob it means. */
189
+ export const KNOB_BY_KEY = new Map();
190
+ for (const knob of KNOBS) {
191
+ KNOB_BY_KEY.set(knob.name, knob);
192
+ for (const alias of knob.aliases || []) KNOB_BY_KEY.set(alias, knob);
193
+ }
194
+
195
+ /** Every key a user may legitimately write in `ovcli.conf`'s plugin section. */
196
+ export function pluginConfigKeys() {
197
+ return new Set(KNOB_BY_KEY.keys());
198
+ }
199
+
200
+ /** The workspace file's dotted keys → the knob each one sets. */
201
+ export const WORKSPACE_KNOB_MAP = Object.fromEntries(
202
+ KNOBS.filter((knob) => knob.workspace).map((knob) => [knob.workspace, knob.name]),
203
+ );
204
+
205
+ /** The enum members and clamps the workspace validator reports on. */
206
+ export const WORKSPACE_ENUMS = Object.fromEntries(
207
+ KNOBS.filter((knob) => knob.workspace).map((knob) => [knob.workspace, knob.values || null]),
208
+ );
209
+
210
+ export const WORKSPACE_RANGES = Object.fromEntries(
211
+ KNOBS.filter((knob) => knob.workspace && knob.min !== undefined && knob.max !== undefined)
212
+ .map((knob) => [knob.workspace, {
213
+ min: knob.min,
214
+ max: knob.max,
215
+ integer: knob.type === "int",
216
+ }]),
217
+ );
218
+
219
+ /**
220
+ * The harnesses that may appear as a per-harness object inside `plugin`.
221
+ *
222
+ * The key is snake_case because that is what ov.conf's own sections use; the
223
+ * hyphenated spelling a host calls itself (`claude-code`, `trae-cn`) is
224
+ * accepted too, so a user who copies the harness name out of the installer
225
+ * lands on the right override instead of on a silent no-op.
226
+ */
227
+ export const HARNESS_KEYS = {
228
+ claudeCode: "claude_code",
229
+ codex: "codex",
230
+ cursor: "cursor",
231
+ trae: "trae",
232
+ traeCn: "trae_cn",
233
+ zcode: "zcode",
234
+ opencode: "opencode",
235
+ dsh: "dsh",
236
+ pi: "pi",
237
+ openclaw: "openclaw",
238
+ };
239
+
240
+ export function harnessKey(harness) {
241
+ return String(harness || "").trim().toLowerCase().replace(/-/g, "_");
242
+ }
243
+
244
+ /** Both accepted spellings of every harness, for the `plugin` section. */
245
+ export const HARNESS_CONFIG_KEYS = new Set(
246
+ Object.values(HARNESS_KEYS).flatMap((key) => [key, key.replace(/_/g, "-")]),
247
+ );
248
+
249
+ export function knobDefault(knob, harness = "") {
250
+ if (harness && knob.harness && knob.harness[harness] !== undefined) return knob.harness[harness];
251
+ return Array.isArray(knob.default) ? [...knob.default] : knob.default;
252
+ }
253
+
254
+ const TRUE_WORDS = ["1", "true", "yes", "on"];
255
+ const FALSE_WORDS = ["0", "false", "no", "off"];
256
+
257
+ /**
258
+ * Coerce one raw value to the knob's type.
259
+ *
260
+ * A value out of range is clamped and a value of the wrong shape falls back to
261
+ * the default: a hook that dies because someone typed a letter into a number
262
+ * would take the host's session down with it.
263
+ */
264
+ export function coerceKnobValue(knob, raw, fallback) {
265
+ if (raw === undefined || raw === null) return fallback;
266
+ switch (knob.type) {
267
+ case "bool": {
268
+ if (typeof raw === "boolean") return raw;
269
+ const word = String(raw).trim().toLowerCase();
270
+ if (TRUE_WORDS.includes(word)) return true;
271
+ if (FALSE_WORDS.includes(word)) return false;
272
+ return fallback;
273
+ }
274
+ case "int":
275
+ case "number": {
276
+ if (typeof raw === "string" && !raw.trim()) return fallback;
277
+ const value = knob.type === "int" ? Math.round(Number(raw)) : Number(raw);
278
+ if (!Number.isFinite(value)) return fallback;
279
+ const floored = knob.min === undefined ? value : Math.max(knob.min, value);
280
+ return knob.max === undefined ? floored : Math.min(knob.max, floored);
281
+ }
282
+ case "enum": {
283
+ const word = String(raw ?? "").trim();
284
+ return (knob.values || []).includes(word) ? word : fallback;
285
+ }
286
+ // A peer template may be one string or an ordered list of fallbacks, and
287
+ // `String([a, b])` would quietly turn the list into one bogus template.
288
+ case "template": {
289
+ if (Array.isArray(raw)) {
290
+ const items = raw.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
291
+ return items.length ? items : fallback;
292
+ }
293
+ const text = String(raw ?? "").trim();
294
+ return text ? text : fallback;
295
+ }
296
+ case "list": {
297
+ const items = Array.isArray(raw)
298
+ ? raw
299
+ : typeof raw === "string" ? raw.split(",") : null;
300
+ if (!items) return fallback;
301
+ return items.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
302
+ }
303
+ default: {
304
+ const text = String(raw ?? "").trim();
305
+ return text ? text : fallback;
306
+ }
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Resolve every knob through the layers, lowest priority first.
312
+ *
313
+ * `layers` is `[{ name, data }]`; `env` is the top layer and needs no entry.
314
+ * Returns the settings plus the names a layer actually supplied, which is what
315
+ * the `*Configured` flags report — several call sites send a field to the
316
+ * server only when the user asked for it, and a default must not look asked-for.
317
+ */
318
+ export function resolveKnobs({ harness = "", layers = [], env = {} } = {}) {
319
+ const settings = {};
320
+ const configured = new Set();
321
+ const sources = {};
322
+ // A value that would not parse is not a configuration: without this sentinel
323
+ // `recallLimit: "abc"` would report as configured and send a default to the
324
+ // server dressed up as the user's choice.
325
+ const UNPARSEABLE = Symbol("unparseable");
326
+
327
+ const take = (knob, raw, source, current) => {
328
+ const value = coerceKnobValue(knob, raw, UNPARSEABLE);
329
+ if (value === UNPARSEABLE) return current;
330
+ configured.add(knob.name);
331
+ sources[knob.name] = source;
332
+ return value;
333
+ };
334
+
335
+ for (const knob of KNOBS) {
336
+ let value = knobDefault(knob, harness);
337
+ for (const layer of layers) {
338
+ const data = layer?.data;
339
+ if (!data || typeof data !== "object") continue;
340
+ // Aliases first so the canonical name wins when one layer carries both:
341
+ // a file with `recallCompressThinking` beside its older
342
+ // `recallCompressReasoningEffort` means the newer one.
343
+ for (const key of [...(knob.aliases || []), knob.name]) {
344
+ if (!Object.prototype.hasOwnProperty.call(data, key)) continue;
345
+ if (data[key] === undefined) continue;
346
+ value = take(knob, data[key], layer.name || "file", value);
347
+ }
348
+ }
349
+ if (knob.env) {
350
+ const raw = env[knob.env];
351
+ if (raw !== undefined && raw !== null && String(raw) !== "") {
352
+ value = take(knob, raw, "env", value);
353
+ }
354
+ }
355
+ settings[knob.name] = value;
356
+ }
357
+
358
+ return { settings, configured, sources };
359
+ }