@openviking/dsh-memory-plugin 0.3.0 → 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 +86 -22
- package/capture.mjs +14 -0
- package/client.mjs +6 -155
- package/config.mjs +36 -166
- package/index.mjs +17 -4
- package/mcp-env.mjs +33 -0
- package/mcp.mjs +2 -33
- package/package.json +9 -6
- package/runtime.mjs +115 -28
- package/servers/mcp-proxy.mjs +13 -19
- package/shared/capture-utils.mjs +146 -7
- package/shared/config-schema.mjs +359 -0
- package/shared/credentials.mjs +238 -102
- package/shared/input-filters.mjs +219 -0
- package/shared/mcp-proxy-config.mjs +171 -9
- package/shared/mcp-proxy-core.mjs +35 -15
- package/shared/ov-http.mjs +112 -0
- package/shared/pending-queue.mjs +41 -5
- package/shared/plugin-config.mjs +239 -29
- package/shared/recall-compress-core.mjs +17 -6
- package/shared/recall-core.mjs +128 -10
- package/shared/uri-guard.mjs +173 -1
- package/shared/workspace-config.mjs +433 -0
- package/shared/workspace-identity.mjs +334 -0
- package/shared/workspace-peer.mjs +189 -8
- package/shared/workspace-registry.mjs +87 -0
- package/skills/openviking-memory/SKILL.md +19 -0
- package/skills.mjs +3 -4
- package/uri-guard.mjs +20 -10
- package/shared/setup-wizard.mjs +0 -114
package/mcp-env.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
|
|
3
|
+
import { MCP_SERVER_NAME } from "./config.mjs";
|
|
4
|
+
import { forwardConnectionEnv } from "./shared/mcp-proxy-config.mjs";
|
|
5
|
+
|
|
6
|
+
/** The same stdio proxy every other OpenViking memory integration starts. */
|
|
7
|
+
export const PROXY_PATH = fileURLToPath(new URL("./servers/mcp-proxy.mjs", import.meta.url));
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Build the dsh-mcp-client config for the OpenViking stdio proxy.
|
|
11
|
+
*
|
|
12
|
+
* DSH scrubs credential-shaped names out of the environment a subprocess
|
|
13
|
+
* inherits, and the Cordis patch is invisible to one, so the proxy cannot
|
|
14
|
+
* resolve the connection the way this process did. It gets the answer instead:
|
|
15
|
+
* the resolved connection and this session's peer, forwarded with the forced
|
|
16
|
+
* `env` source so the child reads no file and lands on exactly this url, key,
|
|
17
|
+
* identity, auth mode and peer — the empty ones included.
|
|
18
|
+
*/
|
|
19
|
+
export function buildMcpConfig(config) {
|
|
20
|
+
// In DSH Desktop, process.execPath is Electron's executable rather than a
|
|
21
|
+
// standalone Node binary. This tells Electron to run the proxy script as
|
|
22
|
+
// Node instead of attempting to launch a second Desktop instance.
|
|
23
|
+
const env = { ELECTRON_RUN_AS_NODE: "1", ...forwardConnectionEnv(config) };
|
|
24
|
+
if (config.timeoutMs) env.OPENVIKING_TIMEOUT_MS = String(config.timeoutMs);
|
|
25
|
+
return {
|
|
26
|
+
transport: "stdio",
|
|
27
|
+
serverName: MCP_SERVER_NAME,
|
|
28
|
+
command: process.execPath,
|
|
29
|
+
args: [PROXY_PATH],
|
|
30
|
+
env,
|
|
31
|
+
toolCallTimeoutMs: config.mcpToolCallTimeoutMs,
|
|
32
|
+
};
|
|
33
|
+
}
|
package/mcp.mjs
CHANGED
|
@@ -1,39 +1,8 @@
|
|
|
1
|
-
import { fileURLToPath } from "node:url";
|
|
2
1
|
import * as mcpClient from "@deepseek-ai/dsh-mcp-client";
|
|
3
2
|
|
|
4
|
-
import {
|
|
3
|
+
import { buildMcpConfig } from "./mcp-env.mjs";
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
export const PROXY_PATH = fileURLToPath(new URL("./servers/mcp-proxy.mjs", import.meta.url));
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Build the dsh-mcp-client config for the OpenViking stdio proxy.
|
|
11
|
-
*
|
|
12
|
-
* The bundle's own credential resolution (`OPENVIKING_*` → `ovcli.conf` →
|
|
13
|
-
* `ov.conf`, plus anything set in the Cordis patch) is forwarded through the
|
|
14
|
-
* child environment: DSH scrubs credential-shaped names out of the inherited
|
|
15
|
-
* env, and the patch is invisible to a subprocess, so values the runtime
|
|
16
|
-
* already resolved have to be passed explicitly.
|
|
17
|
-
*/
|
|
18
|
-
export function buildMcpConfig(config) {
|
|
19
|
-
// In DSH Desktop, process.execPath is Electron's executable rather than a
|
|
20
|
-
// standalone Node binary. This tells Electron to run the proxy script as
|
|
21
|
-
// Node instead of attempting to launch a second Desktop instance.
|
|
22
|
-
const env = { ELECTRON_RUN_AS_NODE: "1" };
|
|
23
|
-
if (config.endpoint) env.OPENVIKING_URL = config.endpoint;
|
|
24
|
-
if (config.apiKey) env.OPENVIKING_API_KEY = config.apiKey;
|
|
25
|
-
if (config.account) env.OPENVIKING_ACCOUNT = config.account;
|
|
26
|
-
if (config.user) env.OPENVIKING_USER = config.user;
|
|
27
|
-
if (config.peerId) env.OPENVIKING_PEER_ID = config.peerId;
|
|
28
|
-
return {
|
|
29
|
-
transport: "stdio",
|
|
30
|
-
serverName: MCP_SERVER_NAME,
|
|
31
|
-
command: process.execPath,
|
|
32
|
-
args: [PROXY_PATH],
|
|
33
|
-
env,
|
|
34
|
-
toolCallTimeoutMs: config.mcpToolCallTimeoutMs,
|
|
35
|
-
};
|
|
36
|
-
}
|
|
5
|
+
export { buildMcpConfig, PROXY_PATH } from "./mcp-env.mjs";
|
|
37
6
|
|
|
38
7
|
/**
|
|
39
8
|
* Mount the tool surface. The bridge ships with dsh itself, so it resolves
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openviking/dsh-memory-plugin",
|
|
3
|
-
"version": "0.3
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "OpenViking memory and context bundle for DeepSeek Harness",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.mjs",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"capture.mjs",
|
|
15
15
|
"lifecycle.mjs",
|
|
16
16
|
"mcp.mjs",
|
|
17
|
+
"mcp-env.mjs",
|
|
17
18
|
"runtime.mjs",
|
|
18
19
|
"skills.mjs",
|
|
19
20
|
"uri-guard.mjs",
|
|
@@ -29,21 +30,23 @@
|
|
|
29
30
|
}
|
|
30
31
|
},
|
|
31
32
|
"scripts": {
|
|
33
|
+
"prepack": "node ../memory-plugin-shared/sync.mjs",
|
|
32
34
|
"check": "for f in *.mjs shared/*.mjs; do node --check \"$f\" || exit 1; done && npm run check:version",
|
|
33
35
|
"check:version": "node --input-type=module -e \"import {readFileSync} from 'node:fs'; const {PLUGIN_VERSION} = await import('./config.mjs'); const v = JSON.parse(readFileSync('package.json','utf8')).version; if (PLUGIN_VERSION !== v) throw new Error('PLUGIN_VERSION ' + PLUGIN_VERSION + ' does not match package.json version ' + v);\"",
|
|
34
36
|
"test": "node --test *.test.mjs",
|
|
35
|
-
"prepublishOnly": "npm run check && npm test"
|
|
37
|
+
"prepublishOnly": "npm run prepack && npm run check && npm test"
|
|
36
38
|
},
|
|
37
39
|
"peerDependencies": {
|
|
38
|
-
"@deepseek-ai/dsh-llm": ">=0.1.0-rc.6 <0.2.0",
|
|
39
|
-
"@deepseek-ai/dsh-mcp-client": ">=0.1.0-rc.6 <0.2.0",
|
|
40
|
-
"@deepseek-ai/dsh-skill-filesystem": ">=0.1.0-rc.6 <0.2.0"
|
|
40
|
+
"@deepseek-ai/dsh-llm": ">=0.1.0-rc.6 <0.2.0 || ^0.1.5-rc.1",
|
|
41
|
+
"@deepseek-ai/dsh-mcp-client": ">=0.1.0-rc.6 <0.2.0 || ^0.1.5-rc.1",
|
|
42
|
+
"@deepseek-ai/dsh-skill-filesystem": ">=0.1.0-rc.6 <0.2.0 || ^0.1.5-rc.1"
|
|
41
43
|
},
|
|
42
44
|
"devDependencies": {
|
|
43
45
|
"@deepseek-ai/dsh-llm": "0.1.0-rc.6",
|
|
44
46
|
"@deepseek-ai/dsh-mcp-client": "0.1.0-rc.6",
|
|
45
47
|
"@deepseek-ai/dsh-skill-filesystem": "0.1.0-rc.6",
|
|
46
|
-
"@deepseek-ai/dsh-tools": "0.1.0-rc.6"
|
|
48
|
+
"@deepseek-ai/dsh-tools": "0.1.0-rc.6",
|
|
49
|
+
"semver": "7.8.5"
|
|
47
50
|
},
|
|
48
51
|
"overrides": {
|
|
49
52
|
"@deepseek-ai/dsh-agent": "0.1.0-rc.6",
|
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
|
|
|
@@ -22,23 +23,29 @@ export class OpenVikingRuntime {
|
|
|
22
23
|
this.config = config;
|
|
23
24
|
this.logger = logger;
|
|
24
25
|
this.states = new Map();
|
|
26
|
+
this.drainTimer = null;
|
|
27
|
+
this.drainRunning = false;
|
|
28
|
+
this.drainPromise = Promise.resolve();
|
|
29
|
+
this.drainHealth = null;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
stateFor(session) {
|
|
28
33
|
let state = this.states.get(session.id);
|
|
29
34
|
if (state) return state;
|
|
30
35
|
const cwd = session.header?.cwd || process.cwd();
|
|
31
|
-
const
|
|
36
|
+
const peer = resolveEffectivePeerId({
|
|
32
37
|
cfg: {
|
|
33
38
|
peerId: this.config.explicitPeerId,
|
|
39
|
+
peerSource: this.config.peerSource,
|
|
34
40
|
workspacePeer: this.config.workspacePeer,
|
|
41
|
+
harness: this.config.harness,
|
|
35
42
|
},
|
|
36
43
|
cwd,
|
|
37
|
-
})
|
|
44
|
+
});
|
|
38
45
|
state = {
|
|
39
46
|
dshSessionId: String(session.id),
|
|
40
47
|
ovSessionId: deriveHarnessSessionId("dsh-", String(session.id)),
|
|
41
|
-
config: { ...this.config, peerId },
|
|
48
|
+
config: { ...this.config, peerId: peer.peerId, legacyPeerId: peer.legacyPeerId },
|
|
42
49
|
ready: false,
|
|
43
50
|
profileBlock: "",
|
|
44
51
|
profileDelivered: false,
|
|
@@ -85,10 +92,11 @@ export class OpenVikingRuntime {
|
|
|
85
92
|
state.initializationRetryable = isRetryableFailure(ensured);
|
|
86
93
|
return state;
|
|
87
94
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
95
|
+
// Replay is a write, so it stays behind the same toggle: a backlog queued
|
|
96
|
+
// while capture was on waits for a session that still writes.
|
|
97
|
+
if (isCaptureEnabled(state.config)) {
|
|
98
|
+
await this.replayPendingQueue();
|
|
99
|
+
}
|
|
92
100
|
await this.refreshPendingState(state);
|
|
93
101
|
const profile = await buildProfileBlock(
|
|
94
102
|
(path, init, options) => this.client.fetchJSON(path, init, options),
|
|
@@ -114,12 +122,12 @@ export class OpenVikingRuntime {
|
|
|
114
122
|
return null;
|
|
115
123
|
}
|
|
116
124
|
state.profileDelivered = true;
|
|
117
|
-
return pluginMessage(state.profileBlock, "instructions");
|
|
125
|
+
return pluginMessage(state.profileBlock, { form: "instructions" });
|
|
118
126
|
}
|
|
119
127
|
|
|
120
128
|
async recallMessage(agent, messages) {
|
|
121
129
|
const state = await this.initialize(agent);
|
|
122
|
-
if (!state.ready) return null;
|
|
130
|
+
if (!state.ready || !isRecallEnabled(state.config)) return null;
|
|
123
131
|
const query = promptText(messages);
|
|
124
132
|
if (query.length < state.config.minQueryLength) return null;
|
|
125
133
|
const block = await buildRecallBlock(
|
|
@@ -128,16 +136,17 @@ export class OpenVikingRuntime {
|
|
|
128
136
|
query,
|
|
129
137
|
{
|
|
130
138
|
actorPeerId: state.config.peerId,
|
|
139
|
+
legacyPeerId: state.config.legacyPeerId,
|
|
131
140
|
sessionId: state.ovSessionId,
|
|
132
141
|
log: (stage, data) => this.log(stage, data),
|
|
133
142
|
},
|
|
134
143
|
);
|
|
135
|
-
return block ? pluginMessage(block, "recall") : null;
|
|
144
|
+
return block ? pluginMessage(block, { form: "recall" }) : null;
|
|
136
145
|
}
|
|
137
146
|
|
|
138
147
|
capture(session, event) {
|
|
139
148
|
const state = this.stateFor(session);
|
|
140
|
-
if (!state.config
|
|
149
|
+
if (!isCaptureEnabled(state.config)) return;
|
|
141
150
|
const payload = captureEvent(event, state.config, state.toolNames);
|
|
142
151
|
if (!payload) return;
|
|
143
152
|
this.enqueueWrite(state, async () => {
|
|
@@ -169,6 +178,7 @@ export class OpenVikingRuntime {
|
|
|
169
178
|
maybeCommit(session, event) {
|
|
170
179
|
if (event.type !== "turn/end") return;
|
|
171
180
|
const state = this.stateFor(session);
|
|
181
|
+
if (!isCaptureEnabled(state.config)) return;
|
|
172
182
|
this.enqueueWrite(state, async () => {
|
|
173
183
|
if (state.hasPendingWrites) return;
|
|
174
184
|
if (!state.ready && !(await this.ensureState(state)).ready) return;
|
|
@@ -201,6 +211,7 @@ export class OpenVikingRuntime {
|
|
|
201
211
|
if (state.disposing) return state.disposing;
|
|
202
212
|
state.disposing = (async () => {
|
|
203
213
|
this.enqueueWrite(state, async () => {
|
|
214
|
+
if (!isCaptureEnabled(state.config)) return;
|
|
204
215
|
const commitPayload = {
|
|
205
216
|
keep_recent_count: state.config.commitKeepRecentCount,
|
|
206
217
|
};
|
|
@@ -251,7 +262,15 @@ export class OpenVikingRuntime {
|
|
|
251
262
|
const createdAt = Math.max(Date.now(), state.pendingCreatedAt + 1);
|
|
252
263
|
state.pendingCreatedAt = createdAt;
|
|
253
264
|
const result = await enqueue(type, state.ovSessionId, payload, { createdAt });
|
|
254
|
-
if (result.ok)
|
|
265
|
+
if (result.ok) {
|
|
266
|
+
// Log the latch transition only: every message that follows while the
|
|
267
|
+
// latch holds takes the cheap enqueue path, so this fires once per
|
|
268
|
+
// outage, not once per message.
|
|
269
|
+
if (!state.hasPendingWrites) {
|
|
270
|
+
this.log("pending_latched", { sessionId: state.ovSessionId, type });
|
|
271
|
+
}
|
|
272
|
+
state.hasPendingWrites = true;
|
|
273
|
+
}
|
|
255
274
|
if (!result.ok) {
|
|
256
275
|
this.log("pending_enqueue_error", {
|
|
257
276
|
sessionId: state.ovSessionId,
|
|
@@ -286,6 +305,7 @@ export class OpenVikingRuntime {
|
|
|
286
305
|
}
|
|
287
306
|
|
|
288
307
|
async refreshPendingState(state) {
|
|
308
|
+
const wasPending = state.hasPendingWrites;
|
|
289
309
|
const pending = (await listPending()).filter(
|
|
290
310
|
item => item.entry?.sessionId === state.ovSessionId,
|
|
291
311
|
);
|
|
@@ -294,6 +314,84 @@ export class OpenVikingRuntime {
|
|
|
294
314
|
(latest, item) => Math.max(latest, Number(item.entry?.createdAt || 0)),
|
|
295
315
|
state.pendingCreatedAt,
|
|
296
316
|
);
|
|
317
|
+
// Only the flip back to direct sends is logged: the recovery moment that
|
|
318
|
+
// proves the drainer worked, once per outage.
|
|
319
|
+
if (wasPending && !state.hasPendingWrites) {
|
|
320
|
+
this.log("pending_cleared", { sessionId: state.ovSessionId });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Replay the pending queue through this runtime's client. The session-start
|
|
326
|
+
* path calls it without options and keeps consuming retries; the drainer
|
|
327
|
+
* passes consumeRetries:false so transient failures stay retryable.
|
|
328
|
+
*/
|
|
329
|
+
async replayPendingQueue(options = {}) {
|
|
330
|
+
await replayPending(
|
|
331
|
+
(path, init) => this.client.fetchJSON(path, init),
|
|
332
|
+
(stage, data) => this.log(stage, data),
|
|
333
|
+
options,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* One drainer tick, following the session-start replay flow: probe health
|
|
339
|
+
* first and only replay when the server answers. Replays run without
|
|
340
|
+
* consuming retry budgets, then every session's latch is re-derived from
|
|
341
|
+
* the queue. An empty queue clears the latches with zero HTTP traffic, so
|
|
342
|
+
* once a transient write failure recovers, capture and commit resume on
|
|
343
|
+
* their own without restarting the long-lived dsh process.
|
|
344
|
+
*/
|
|
345
|
+
async drainTick() {
|
|
346
|
+
if (this.drainRunning) return;
|
|
347
|
+
this.drainRunning = true;
|
|
348
|
+
try {
|
|
349
|
+
const pending = await listPending();
|
|
350
|
+
if (pending.length > 0) {
|
|
351
|
+
const health = await this.client.healthResult();
|
|
352
|
+
if (!health.ok) {
|
|
353
|
+
// Only the flip into the outage is logged, not every 60s probe.
|
|
354
|
+
if (this.drainHealth !== false) {
|
|
355
|
+
this.log("drain_health_down", { status: health.status || 0 });
|
|
356
|
+
}
|
|
357
|
+
this.drainHealth = false;
|
|
358
|
+
} else {
|
|
359
|
+
if (this.drainHealth === false) {
|
|
360
|
+
this.log("drain_health_restored", {});
|
|
361
|
+
}
|
|
362
|
+
this.drainHealth = true;
|
|
363
|
+
await this.replayPendingQueue({ consumeRetries: false });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
for (const state of this.states.values()) {
|
|
367
|
+
await this.refreshPendingState(state);
|
|
368
|
+
}
|
|
369
|
+
} finally {
|
|
370
|
+
this.drainRunning = false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Start the background drainer. The interval is fixed per process; each tick
|
|
376
|
+
* is single-flight, so a slow replay run never overlaps the next one.
|
|
377
|
+
*/
|
|
378
|
+
startDrainer() {
|
|
379
|
+
if (this.drainTimer) return this.drainTimer;
|
|
380
|
+
const parsed = parseInt(process.env.OPENVIKING_PENDING_DRAIN_INTERVAL_MS || "", 10);
|
|
381
|
+
const intervalMs = Number.isFinite(parsed) && parsed > 0 ? parsed : 60000;
|
|
382
|
+
this.drainTimer = setInterval(() => {
|
|
383
|
+
this.drainPromise = this.drainTick().catch(error => this.log("drain_error", {
|
|
384
|
+
error: error instanceof Error ? error.message : String(error),
|
|
385
|
+
}));
|
|
386
|
+
}, intervalMs);
|
|
387
|
+
this.drainTimer.unref?.();
|
|
388
|
+
return this.drainTimer;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
stopDrainer() {
|
|
392
|
+
if (!this.drainTimer) return;
|
|
393
|
+
clearInterval(this.drainTimer);
|
|
394
|
+
this.drainTimer = null;
|
|
297
395
|
}
|
|
298
396
|
|
|
299
397
|
async flush(session) {
|
|
@@ -306,22 +404,11 @@ export class OpenVikingRuntime {
|
|
|
306
404
|
}
|
|
307
405
|
}
|
|
308
406
|
|
|
309
|
-
function pluginMessage(content, form) {
|
|
310
|
-
// dsh's own constructor: identity, normalization, and any future Message
|
|
311
|
-
// invariants come from the pinned peer instead of a hand-built object.
|
|
312
|
-
return createUserMessage({
|
|
313
|
-
content: [{ type: "text", text: content }],
|
|
314
|
-
source: {
|
|
315
|
-
kind: "plugin",
|
|
316
|
-
plugin: OPENVIKING_PLUGIN_SOURCE,
|
|
317
|
-
form,
|
|
318
|
-
},
|
|
319
|
-
});
|
|
320
|
-
}
|
|
321
|
-
|
|
322
407
|
function hasStartupProfile(agent) {
|
|
323
408
|
const session = agent.session;
|
|
324
|
-
const ownEvents =
|
|
409
|
+
const ownEvents = typeof session?.ownEvents === "function"
|
|
410
|
+
? session.ownEvents()
|
|
411
|
+
: (session?.events || []).slice(session?.header?.seedLength ?? 0);
|
|
325
412
|
const inHistory = ownEvents.some(event => (
|
|
326
413
|
event?.type === "user/message" && isStartupProfile(event.data)
|
|
327
414
|
));
|
package/servers/mcp-proxy.mjs
CHANGED
|
@@ -4,36 +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
|
-
return buildMcpProxyConfig({
|
|
24
|
-
baseUrl: cfg.endpoint,
|
|
25
|
-
apiKey: cfg.apiKey,
|
|
26
|
-
account: cfg.account,
|
|
27
|
-
user: cfg.user,
|
|
28
|
-
peerId: cfg.peerId,
|
|
29
|
-
userAgent: cfg.userAgent,
|
|
30
|
-
timeoutMs: cfg.requestTimeoutMs,
|
|
31
|
-
debug: Boolean(env.OV_DEBUG_LOG),
|
|
32
|
-
debugLogPath: env.OV_DEBUG_LOG,
|
|
33
|
-
credentialSource: creds.credentialSource,
|
|
34
|
-
credentialPath: creds.cliPath || creds.ovPath,
|
|
35
|
-
watchedPaths: [creds.cliPath, creds.ovPath, creds.cliPathCandidate],
|
|
22
|
+
return toMcpProxyConfig(cfg, {
|
|
36
23
|
env,
|
|
24
|
+
// Not gated through `resolveMcpActorPeerId` like the other proxies: DSH's
|
|
25
|
+
// parent process resolves the peer per session and hands it over in the
|
|
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(),
|
|
29
|
+
debug: Boolean(env.OV_DEBUG_LOG),
|
|
30
|
+
debugLogPath: env.OV_DEBUG_LOG || "",
|
|
37
31
|
});
|
|
38
32
|
}
|
|
39
33
|
|
package/shared/capture-utils.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
|
|
2
|
+
import { applyInputFilters, compileInputFilters } from "./input-filters.mjs";
|
|
3
|
+
|
|
2
4
|
const TEXT_BLOCK_TYPES = new Set(["text", "input_text", "output_text"]);
|
|
3
5
|
const TOOL_CALL_TYPES = new Set([
|
|
4
6
|
"tool_call",
|
|
@@ -152,7 +154,7 @@ function toolId(block) {
|
|
|
152
154
|
|
|
153
155
|
function toolStatus(block, kind) {
|
|
154
156
|
if (kind === "call") return "running";
|
|
155
|
-
if (block?.is_error || block?.error || block?.state?.error) return "error";
|
|
157
|
+
if (block?.is_error || block?.isError || block?.state?.isError || block?.error || block?.state?.error) return "error";
|
|
156
158
|
const status = oneLine(block?.status || block?.state?.status || "");
|
|
157
159
|
return status || "completed";
|
|
158
160
|
}
|
|
@@ -264,7 +266,13 @@ export function extractTextFromPayload(payload, options = {}) {
|
|
|
264
266
|
return chunks.join("\n\n");
|
|
265
267
|
}
|
|
266
268
|
|
|
267
|
-
|
|
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) {
|
|
268
276
|
const map = {};
|
|
269
277
|
for (const entry of entries || []) {
|
|
270
278
|
const payload = entry?.payload && typeof entry.payload === "object" ? entry.payload : entry;
|
|
@@ -354,6 +362,53 @@ export function extractPartsFromPayload(payload, options = {}) {
|
|
|
354
362
|
return parts;
|
|
355
363
|
}
|
|
356
364
|
|
|
365
|
+
/**
|
|
366
|
+
* Prune blank text parts and run `cfg.captureFilters` over the ones that remain.
|
|
367
|
+
*
|
|
368
|
+
* One drop verdict is taken per turn, on the aggregate of its text parts; the
|
|
369
|
+
* individual parts are then rewritten with the substitutions only, so a rule can
|
|
370
|
+
* never both keep and drop the same turn. Tool call/result parts are carried
|
|
371
|
+
* through untouched — filters match conversation text, not tool payloads — but a
|
|
372
|
+
* turn dropped on its text takes its tool parts with it.
|
|
373
|
+
*/
|
|
374
|
+
export function filterCaptureParts(parts, role, cfg = {}) {
|
|
375
|
+
const kept = [];
|
|
376
|
+
for (const part of parts || []) {
|
|
377
|
+
if (!part) continue;
|
|
378
|
+
if (part.type !== "text") {
|
|
379
|
+
kept.push(part);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const text = typeof part.text === "string" ? part.text.trim() : "";
|
|
383
|
+
if (!text) continue;
|
|
384
|
+
kept.push(text === part.text ? part : { ...part, text });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const blank = { parts: kept, dropped: false };
|
|
388
|
+
const compiled = compileInputFilters(cfg?.captureFilters);
|
|
389
|
+
if (!compiled.rules.length || kept.length === 0) return blank;
|
|
390
|
+
|
|
391
|
+
const textParts = kept.filter((part) => part.type === "text");
|
|
392
|
+
if (textParts.length === 0) return blank;
|
|
393
|
+
|
|
394
|
+
const verdict = applyInputFilters(textParts.map((part) => part.text).join("\n\n"), compiled.rules, {
|
|
395
|
+
role,
|
|
396
|
+
});
|
|
397
|
+
if (verdict.dropped) return { parts: [], dropped: true };
|
|
398
|
+
|
|
399
|
+
const out = [];
|
|
400
|
+
for (const part of kept) {
|
|
401
|
+
if (part.type !== "text") {
|
|
402
|
+
out.push(part);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const shaped = applyInputFilters(part.text, compiled.rules, { role, substituteOnly: true });
|
|
406
|
+
if (!shaped.text) continue;
|
|
407
|
+
out.push(shaped.text === part.text ? part : { ...part, text: shaped.text });
|
|
408
|
+
}
|
|
409
|
+
return { parts: out, dropped: out.length === 0 };
|
|
410
|
+
}
|
|
411
|
+
|
|
357
412
|
export function extractCaptureTurns(rolloutEntries, cfg = {}) {
|
|
358
413
|
const toolNameById = collectToolNamesByIdFromEntries(rolloutEntries);
|
|
359
414
|
const turns = [];
|
|
@@ -371,14 +426,37 @@ export function extractCaptureTurns(rolloutEntries, cfg = {}) {
|
|
|
371
426
|
toolMaxChars: cfg.captureToolMaxChars,
|
|
372
427
|
toolNameById,
|
|
373
428
|
});
|
|
374
|
-
const
|
|
375
|
-
if (
|
|
429
|
+
const shaped = filterCaptureParts(parts, role, cfg);
|
|
430
|
+
if (shaped.dropped) continue;
|
|
431
|
+
// With parts on the wire the drop decision was already taken above, so the
|
|
432
|
+
// text path only runs the filters for the `content` fallback. That also
|
|
433
|
+
// keeps `turn.text` faithful for callers that scan it for trigger words.
|
|
434
|
+
const decision = shouldCaptureText(rawText, role, cfg, { filters: shaped.parts.length === 0 });
|
|
435
|
+
if (!decision.shouldCapture && shaped.parts.length === 0) continue;
|
|
376
436
|
const text = decision.shouldCapture ? decision.text : "";
|
|
377
|
-
turns.push({ role, text, parts });
|
|
437
|
+
turns.push({ role, text, parts: shaped.parts });
|
|
378
438
|
}
|
|
379
439
|
return turns;
|
|
380
440
|
}
|
|
381
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
|
+
|
|
382
460
|
export function normalizeCaptureRole(role) {
|
|
383
461
|
const value = normalizeType(role);
|
|
384
462
|
if (value === "user") return "user";
|
|
@@ -457,12 +535,24 @@ function stripInjectedDigestBlocks(text) {
|
|
|
457
535
|
return out.join("\n");
|
|
458
536
|
}
|
|
459
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
|
+
*/
|
|
460
546
|
export function sanitizeCapturedText(text) {
|
|
461
547
|
let value = String(text || "");
|
|
462
548
|
value = value
|
|
463
549
|
.replace(/\u0000/g, "")
|
|
464
550
|
.replace(/<openviking-context\b[^>]*>[\s\S]*?<\/openviking-context>/gi, " ")
|
|
465
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, " ")
|
|
466
556
|
.replace(/^\s*Sender\s*\([^)]+\)\s*```[\s\S]*?```\s*/gim, " ")
|
|
467
557
|
.replace(/^\s*Conversation (?:metadata|info):\s*```[\s\S]*?```\s*/gim, " ")
|
|
468
558
|
.replace(/^\s*\[?\d{4}-\d{2}-\d{2}[T ][^\]\n]{3,80}\]?\s*/gm, "")
|
|
@@ -486,12 +576,38 @@ function isPunctuationOnly(text) {
|
|
|
486
576
|
return !/[a-z0-9\u3400-\u9fff]/i.test(text);
|
|
487
577
|
}
|
|
488
578
|
|
|
489
|
-
|
|
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
|
+
|
|
596
|
+
export function shouldCaptureText(text, role, cfg = {}, { filters = true } = {}) {
|
|
490
597
|
const maxLength = cfg.captureMaxLength || 24000;
|
|
491
598
|
const sanitized = sanitizeCapturedText(text);
|
|
492
599
|
if (!sanitized) return { shouldCapture: false, reason: "empty", text: "" };
|
|
493
600
|
|
|
494
|
-
|
|
601
|
+
let capped = truncateCaptureText(sanitized, maxLength);
|
|
602
|
+
if (filters) {
|
|
603
|
+
const compiled = compileInputFilters(cfg?.captureFilters);
|
|
604
|
+
if (compiled.rules.length) {
|
|
605
|
+
const verdict = applyInputFilters(capped, compiled.rules, { role });
|
|
606
|
+
if (verdict.dropped) return { shouldCapture: false, reason: "filtered", text: "" };
|
|
607
|
+
capped = verdict.text;
|
|
608
|
+
if (!capped) return { shouldCapture: false, reason: "empty", text: "" };
|
|
609
|
+
}
|
|
610
|
+
}
|
|
495
611
|
const compact = oneLine(capped);
|
|
496
612
|
const isToolSummary = /^\[tool-(?:call|result)\b/i.test(compact);
|
|
497
613
|
|
|
@@ -513,3 +629,26 @@ export function shouldCaptureText(text, role, cfg = {}) {
|
|
|
513
629
|
|
|
514
630
|
return { shouldCapture: true, reason: "ok", text: capped };
|
|
515
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
|
+
}
|