@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/README.md +11 -6
- package/capture.mjs +14 -0
- package/client.mjs +6 -155
- package/config.mjs +36 -170
- package/index.mjs +2 -1
- package/mcp-env.mjs +33 -0
- package/mcp.mjs +2 -33
- package/package.json +4 -2
- package/runtime.mjs +10 -22
- package/servers/mcp-proxy.mjs +11 -21
- package/shared/capture-utils.mjs +77 -1
- package/shared/config-schema.mjs +359 -0
- package/shared/credentials.mjs +238 -102
- package/shared/mcp-proxy-config.mjs +154 -2
- package/shared/mcp-proxy-core.mjs +23 -15
- package/shared/ov-http.mjs +112 -0
- package/shared/plugin-config.mjs +300 -0
- package/shared/recall-core.mjs +66 -13
- package/shared/uri-guard.mjs +173 -1
- package/shared/workspace-config.mjs +433 -0
- package/shared/workspace-peer.mjs +63 -0
- package/shared/workspace-registry.mjs +87 -0
- package/skills.mjs +2 -0
- package/uri-guard.mjs +20 -10
package/runtime.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"
|
package/servers/mcp-proxy.mjs
CHANGED
|
@@ -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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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 {
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
31
|
-
//
|
|
32
|
-
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
|
|
package/shared/capture-utils.mjs
CHANGED
|
@@ -266,7 +266,13 @@ export function extractTextFromPayload(payload, options = {}) {
|
|
|
266
266
|
return chunks.join("\n\n");
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
-
|
|
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
|
+
}
|