@openviking/dsh-memory-plugin 0.2.1 → 0.3.2
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 +81 -16
- package/config.mjs +8 -2
- package/index.mjs +22 -3
- package/mcp.mjs +4 -1
- package/package.json +6 -5
- package/runtime.mjs +129 -8
- package/servers/mcp-proxy.mjs +4 -0
- package/shared/capture-utils.mjs +69 -6
- package/shared/credentials.mjs +11 -0
- package/shared/input-filters.mjs +219 -0
- package/shared/mcp-proxy-config.mjs +34 -0
- package/shared/mcp-proxy-core.mjs +13 -1
- package/shared/pending-queue.mjs +41 -5
- package/shared/recall-compress-core.mjs +17 -6
- package/shared/recall-core.mjs +73 -4
- package/shared/uri-guard.mjs +28 -6
- package/shared/workspace-identity.mjs +334 -0
- package/shared/workspace-peer.mjs +126 -8
- package/skills/openviking-memory/SKILL.md +19 -0
- package/skills.mjs +1 -4
- package/shared/plugin-config.mjs +0 -90
- package/shared/setup-wizard.mjs +0 -111
package/README.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
An installable DeepSeek Harness bundle that adds OpenViking auto-recall, session capture, `viking://` URI protection, and the OpenViking MCP tool surface.
|
|
4
4
|
|
|
5
|
+
> **Requires an OpenViking server with `viking://~` home-alias support.** Recall targets the
|
|
6
|
+
> caller's own context space through `viking://~/memories` and `viking://~/skills`; the uid-less
|
|
7
|
+
> `viking://user/memories` shorthand is rejected by newer servers.
|
|
8
|
+
|
|
5
9
|
Published as [`@openviking/dsh-memory-plugin`](https://www.npmjs.com/package/@openviking/dsh-memory-plugin).
|
|
6
10
|
|
|
7
11
|
## Install
|
|
@@ -28,7 +32,7 @@ tree's realpath rather than from the profile.
|
|
|
28
32
|
|
|
29
33
|
## Requirements
|
|
30
34
|
|
|
31
|
-
- `@deepseek-ai/dsh` `0.1.0-rc.6` or
|
|
35
|
+
- `@deepseek-ai/dsh` `0.1.0-rc.6`, `0.1.5-rc.1`, or `0.1.5-rc.2`; stable `0.1.x` releases are also admitted by the peer range
|
|
32
36
|
- Node.js `^22.19.0` or `>=24`
|
|
33
37
|
- A reachable OpenViking server
|
|
34
38
|
|
|
@@ -36,17 +40,62 @@ The bundle has no runtime npm dependencies. Its message structures come from
|
|
|
36
40
|
`createUserMessage` (`@deepseek-ai/dsh-llm`), its tool surface from
|
|
37
41
|
`@deepseek-ai/dsh-mcp-client`, and its skill provider from
|
|
38
42
|
`@deepseek-ai/dsh-skill-filesystem` — peerDependencies that DSH itself already
|
|
39
|
-
installs
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
installs. Keep those packages in the host installation rather than adding
|
|
44
|
+
individual DSH core packages to the profile. DSH initializes profiles with
|
|
45
|
+
`nodeLinker: hoisted` and `autoInstallPeers: false`, then makes the host packages
|
|
46
|
+
available through its module fallback at boot. A missing-peer warning during
|
|
47
|
+
`dsh plugin add` alone does not prove startup is broken.
|
|
48
|
+
|
|
49
|
+
The peer range is `>=0.1.0-rc.6 <0.2.0 || ^0.1.5-rc.1`. The second clause
|
|
50
|
+
explicitly admits the `0.1.5` release candidates: semver does not include them
|
|
51
|
+
in the first clause merely because they compare above `0.1.0-rc.6`. Other
|
|
52
|
+
pre-release series need separate verification. Local devDependencies and
|
|
53
|
+
`overrides` stay pinned to rc.6 to exercise the minimum supported contract.
|
|
44
54
|
|
|
45
55
|
Everything under `shared/` and `skills/` is generated by
|
|
46
56
|
`node examples/memory-plugin-shared/sync.mjs` — edit
|
|
47
57
|
`examples/memory-plugin-shared/lib/` and `examples/skills/`, never the vendored
|
|
48
58
|
copies.
|
|
49
59
|
|
|
60
|
+
## Troubleshooting startup
|
|
61
|
+
|
|
62
|
+
If startup reports that `@deepseek-ai/dsh-llm` does not export `assertNever`,
|
|
63
|
+
inspect the affected profile before changing dependencies:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
dsh --version
|
|
67
|
+
dsh plugin --profile web why @deepseek-ai/dsh-skill
|
|
68
|
+
dsh plugin --profile web why @deepseek-ai/dsh-skill-filesystem
|
|
69
|
+
dsh plugin --profile web why @deepseek-ai/dsh-llm
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Replace `web` with the affected profile name. Check its `package.json`,
|
|
73
|
+
`pnpm-lock.yaml`, and `pnpm-workspace.yaml` under
|
|
74
|
+
`${DSH_HOME:-$HOME/.dsh}/profiles/<profile>/`, including dependency overrides.
|
|
75
|
+
`dsh-skill@0.0.1-rc.1` imports `assertNever` from the old location. A profile-local
|
|
76
|
+
copy can shadow the newer host package and break both DSH's own skill loader
|
|
77
|
+
and this plugin. As of 2026-09-14, the skill and skill-filesystem npm `latest`
|
|
78
|
+
tags still point to `0.0.1` release candidates; installing either without a
|
|
79
|
+
version is not an upgrade strategy.
|
|
80
|
+
|
|
81
|
+
If these old packages are direct profile dependencies added only to satisfy
|
|
82
|
+
this plugin, remove those constraints and let DSH supply its matching packages:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
dsh plugin --profile web remove @deepseek-ai/dsh-skill @deepseek-ai/dsh-skill-filesystem
|
|
86
|
+
dsh plugin --profile web add @openviking/dsh-memory-plugin@latest
|
|
87
|
+
dsh --profile web
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
If another plugin requires them, update that plugin's constraints instead of
|
|
91
|
+
removing them blindly. Keep `autoInstallPeers: false` and `nodeLinker: hoisted`
|
|
92
|
+
in the profile's pnpm settings. Do not patch the import in `node_modules`: a
|
|
93
|
+
mixed DSH package family can have other incompatible interfaces. Updating this
|
|
94
|
+
memory plugin alone does not remove conflicting profile-local packages.
|
|
95
|
+
|
|
96
|
+
Use `dsh --profile <profile>` to start a named profile. `dsh web` is an alias for
|
|
97
|
+
the stock `web` profile, not a subcommand to append after `--profile`.
|
|
98
|
+
|
|
50
99
|
## Design notes
|
|
51
100
|
|
|
52
101
|
### Why injection uses pre-step user messages, not the system prompt
|
|
@@ -81,10 +130,7 @@ change.
|
|
|
81
130
|
|
|
82
131
|
Two consequences follow from the proxy being one process per profile:
|
|
83
132
|
|
|
84
|
-
- **The actor peer is process-level.** Recall, capture, and commit still resolve
|
|
85
|
-
a peer per session from that session's workspace, but tool calls carry the
|
|
86
|
-
peer resolved at boot. Set `OPENVIKING_PEER_ID` when one process serves
|
|
87
|
-
several workspaces and you need tool calls attributed exactly.
|
|
133
|
+
- **The actor peer is process-level.** Recall, capture, and commit still resolve a peer per session from that session's workspace repository, but tool calls carry the peer resolved at boot. Set `OPENVIKING_PEER_ID` when one process serves several repositories and you need tool calls attributed exactly.
|
|
88
134
|
- **`remember` is not session-scoped.** The server's MCP `remember` stores into
|
|
89
135
|
its own short-lived session rather than the live `dsh-<session-id>` stream —
|
|
90
136
|
the same behavior the Claude Code, Codex, and Cursor integrations have.
|
|
@@ -96,9 +142,11 @@ lifecycle registration, so a proxy that cannot start holds up nothing above it.
|
|
|
96
142
|
### Why the skill gets its own provider
|
|
97
143
|
|
|
98
144
|
`skills.mjs` registers a second `ctx.skills` provider with
|
|
99
|
-
`includeDefaultRoots: false
|
|
100
|
-
|
|
101
|
-
|
|
145
|
+
`includeDefaultRoots: false` and `bundledSkillDir` pointing at `skills/`. DSH
|
|
146
|
+
reads this host-owned bundle directly rather than asking a workspace filesystem
|
|
147
|
+
to resolve a path outside its root. Its existing `filesystem` provider still
|
|
148
|
+
owns the project and user skill roots; the bundle does not duplicate that
|
|
149
|
+
catalog or override higher-priority project skills.
|
|
102
150
|
|
|
103
151
|
## Configuration
|
|
104
152
|
|
|
@@ -117,7 +165,7 @@ Common environment variables:
|
|
|
117
165
|
| `OPENVIKING_ACCOUNT` | Trusted-mode account |
|
|
118
166
|
| `OPENVIKING_USER` | Trusted-mode user |
|
|
119
167
|
| `OPENVIKING_PEER_ID` | Explicit actor peer |
|
|
120
|
-
| `OPENVIKING_WORKSPACE_PEER` | Derive a peer from each DSH session workspace by default |
|
|
168
|
+
| `OPENVIKING_WORKSPACE_PEER` | Derive a peer from each DSH session workspace's git identity by default; `0` sends no peer |
|
|
121
169
|
| `OPENVIKING_RECALL_PEER_SCOPE` | `all` for cross-workspace recall or `actor` for isolation |
|
|
122
170
|
|
|
123
171
|
The patch can also carry plugin config:
|
|
@@ -137,6 +185,7 @@ The patch can also carry plugin config:
|
|
|
137
185
|
recallTokenBudget: 2000
|
|
138
186
|
scoreThreshold: 0.35
|
|
139
187
|
captureToolResults: false
|
|
188
|
+
skipSubagentSessions: true
|
|
140
189
|
commitTokenThreshold: 20000
|
|
141
190
|
mcpToolCallTimeoutMs: 60000
|
|
142
191
|
```
|
|
@@ -147,10 +196,12 @@ The patch can also carry plugin config:
|
|
|
147
196
|
- `agent/pre-step` retrieves with the current step input and appends a durable plugin message to that same step.
|
|
148
197
|
- `session/event` captures user, assistant, and optionally tool-result messages without scraping a transcript.
|
|
149
198
|
- `turn/end` checks the OpenViking pending-token threshold and commits when required.
|
|
150
|
-
-
|
|
199
|
+
- `skipSubagentSessions: true` excludes sessions marked with `header.origin: subagent` from automatic profile, recall, capture, and commit; it defaults to `false`.
|
|
200
|
+
- `syncTurns: false` stops every new write: no captured messages, no threshold or shutdown commit. Writes queued while the toggle was on are still replayed by the background drainer once the server recovers — they were captured with the toggle on. Profile injection and recall are unaffected; it defaults to `true`.
|
|
201
|
+
- Failed writes enter the shared OpenViking pending queue. A background drainer (default every 60s, `OPENVIKING_PENDING_DRAIN_INTERVAL_MS`) probes the server health and replays the queue in-process, so a transient write failure recovers without restarting dsh; it does not consume the session-start retry budget. Session-start replays keep consuming retries as before.
|
|
151
202
|
- `tools/pre-execute` blocks DSH filesystem and shell tools from treating `viking://` URIs as local paths, pointing the model at the bridged `mcp__openviking__*` tools instead.
|
|
152
203
|
|
|
153
|
-
Each DSH session maps to `dsh-<session-id>` in OpenViking. Workspace-derived actor peers are resolved per session and sent on every session-specific request.
|
|
204
|
+
Each DSH session maps to `dsh-<session-id>` in OpenViking. Workspace-derived actor peers are resolved per session and sent on every session-specific request: the peer is the git identity of the session's workspace — the normalized `origin` URL (`git@github.com:volcengine/OpenViking.git` becomes `github.com-volcengine-openviking`), else the repository root path, that fallback keeping the older rule where every non-letter-or-digit character becomes `-`. Outside a git repository no peer is sent at all, and what is remembered there goes to the user-level space `viking://user/<you>/memories`. One repository therefore keeps one peer across subdirectories, worktrees, clones and machines, while a fork's different origin keeps it separate. DSH does not read workspace `.openviking/config.json` files, so a `peer.id` written there has no effect; pin a peer with `OPENVIKING_PEER_ID` instead. Memories written under the older path-derived peer stay reachable: the default `recallPeerScope: all` sweeps every peer under the user.
|
|
154
205
|
|
|
155
206
|
## Tools
|
|
156
207
|
|
|
@@ -178,6 +229,20 @@ npm run check # syntax check every shipped module + package/PLUGIN_VERSION agr
|
|
|
178
229
|
npm test # node --test *.test.mjs — runs in the repo's PR workflow
|
|
179
230
|
```
|
|
180
231
|
|
|
232
|
+
The `0.3.2` compatibility check installed the packed bundle through
|
|
233
|
+
`dsh plugin add` in isolated profiles and exercised DSH `0.1.0-rc.6`,
|
|
234
|
+
`0.1.5-rc.1`, and `0.1.5-rc.2` with matching core packages. It covered Web startup
|
|
235
|
+
and browser loading, skill discovery/read, MCP tool calls, profile/recall
|
|
236
|
+
injection, user/assistant capture, resume deduplication, threshold commits, and
|
|
237
|
+
disposal commits. The check used a local HTTP/MCP fixture and a deterministic
|
|
238
|
+
model adapter on macOS with Node.js 26.5.0; it verifies harness integration, not
|
|
239
|
+
real-server memory extraction. The rc.2 CLI also exited cleanly on SIGTERM.
|
|
240
|
+
|
|
241
|
+
The startup failure from issue #4944 was reproduced by adding the reported
|
|
242
|
+
old skill packages to an rc.2 profile; removing those direct dependencies and
|
|
243
|
+
reinstalling the bundle restored the same integration checks. A clean rc.2
|
|
244
|
+
profile with the published `0.3.0` bundle did not reproduce that import error.
|
|
245
|
+
|
|
181
246
|
`live-recall.test.mjs` is an opt-in end-to-end gate against a real OpenViking
|
|
182
247
|
server: it stores a sentinel memory through a session commit, waits for
|
|
183
248
|
extraction, and asserts recall returns that sentinel — the property no stub
|
package/config.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildUserAgent, resolveOpenVikingCredentials } from "./shared/credentials.mjs";
|
|
2
2
|
import { resolveEffectivePeerId } from "./shared/workspace-peer.mjs";
|
|
3
3
|
|
|
4
|
-
export const PLUGIN_VERSION = "0.2
|
|
4
|
+
export const PLUGIN_VERSION = "0.3.2";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Namespace for the bridged OpenViking MCP tools. DSH publishes every MCP tool
|
|
@@ -18,6 +18,7 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
18
18
|
peerId: "",
|
|
19
19
|
userAgent: "",
|
|
20
20
|
workspacePeer: true,
|
|
21
|
+
peerSource: "",
|
|
21
22
|
recallPeerScope: "all",
|
|
22
23
|
recallQueryExpansion: "auto",
|
|
23
24
|
recallQueryExpansionConfigured: false,
|
|
@@ -37,6 +38,7 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
37
38
|
captureMaxLength: 24000,
|
|
38
39
|
captureToolMaxChars: 1000000,
|
|
39
40
|
captureAssistantTurns: true,
|
|
41
|
+
skipSubagentSessions: false,
|
|
40
42
|
requestTimeoutMs: 10000,
|
|
41
43
|
mcpToolCallTimeoutMs: 60000,
|
|
42
44
|
});
|
|
@@ -54,6 +56,7 @@ export function resolveConfig(input = {}, env = process.env, cwd = process.cwd()
|
|
|
54
56
|
peerId: explicitPeerId,
|
|
55
57
|
explicitPeerId,
|
|
56
58
|
userAgent: buildUserAgent("dsh", PLUGIN_VERSION),
|
|
59
|
+
harness: "dsh",
|
|
57
60
|
recallLimitConfigured: Object.prototype.hasOwnProperty.call(input, "recallLimit"),
|
|
58
61
|
recallQueryExpansionConfigured: Object.prototype.hasOwnProperty.call(input, "recallQueryExpansion"),
|
|
59
62
|
};
|
|
@@ -78,7 +81,9 @@ export function resolveConfig(input = {}, env = process.env, cwd = process.cwd()
|
|
|
78
81
|
|
|
79
82
|
config.endpoint = String(config.endpoint || DEFAULT_CONFIG.endpoint).replace(/\/+$/, "");
|
|
80
83
|
config.workspacePeer = config.workspacePeer !== false;
|
|
81
|
-
|
|
84
|
+
const effectivePeer = resolveEffectivePeerId({ cfg: config, cwd });
|
|
85
|
+
config.peerId = effectivePeer.peerId;
|
|
86
|
+
config.legacyPeerId = effectivePeer.legacyPeerId;
|
|
82
87
|
config.recallPeerScope = config.recallPeerScope === "actor" ? "actor" : "all";
|
|
83
88
|
config.recallQueryExpansion = config.recallQueryExpansion === "off" ? "off" : "auto";
|
|
84
89
|
config.recallLimit = clampInteger(config.recallLimit, 1, 50, DEFAULT_CONFIG.recallLimit);
|
|
@@ -152,6 +157,7 @@ export function resolveConfig(input = {}, env = process.env, cwd = process.cwd()
|
|
|
152
157
|
config.syncTurns = config.syncTurns !== false;
|
|
153
158
|
config.captureAssistantTurns = config.captureAssistantTurns !== false;
|
|
154
159
|
config.captureToolResults = config.captureToolResults === true;
|
|
160
|
+
config.skipSubagentSessions = config.skipSubagentSessions === true;
|
|
155
161
|
return config;
|
|
156
162
|
}
|
|
157
163
|
|
package/index.mjs
CHANGED
|
@@ -13,13 +13,25 @@ export function apply(ctx, input = {}) {
|
|
|
13
13
|
const config = resolveConfig(input);
|
|
14
14
|
const client = new OpenVikingClient(config);
|
|
15
15
|
const runtime = new OpenVikingRuntime(client, config, ctx.logger);
|
|
16
|
+
const skipMemory = session => (
|
|
17
|
+
config.skipSubagentSessions && session?.header?.origin === "subagent"
|
|
18
|
+
);
|
|
16
19
|
ctx.provide("openvikingMemory", runtime);
|
|
17
20
|
ctx.effect(
|
|
18
21
|
() => () => runtime.disposeAll(),
|
|
19
22
|
"openvikingMemory.disposeAll()",
|
|
20
23
|
);
|
|
24
|
+
// The pending-queue drainer is the in-process recovery path: without it a
|
|
25
|
+
// single transient write failure latches capture/commit until the next dsh
|
|
26
|
+
// restart. Started here so every session shares one single-flight drainer.
|
|
27
|
+
runtime.startDrainer();
|
|
28
|
+
ctx.effect(
|
|
29
|
+
() => () => runtime.stopDrainer(),
|
|
30
|
+
"openvikingMemory.stopDrainer()",
|
|
31
|
+
);
|
|
21
32
|
|
|
22
33
|
ctx.on("agent/session-start", ({ agent }) => {
|
|
34
|
+
if (skipMemory(agent.session)) return false;
|
|
23
35
|
agent.ctx.effect(
|
|
24
36
|
() => () => runtime.dispose(agent.session),
|
|
25
37
|
"openvikingMemory.disposeSession()",
|
|
@@ -29,12 +41,17 @@ export function apply(ctx, input = {}) {
|
|
|
29
41
|
|
|
30
42
|
// prepend: downstream waterfall listeners run first, so this plugin sees
|
|
31
43
|
// the final claimed batch and appends after every other contributor.
|
|
44
|
+
// Profile + recall are independent after `next()`; run them concurrently so
|
|
45
|
+
// the agent/pre-step waterfall (which currently gates user/message push in
|
|
46
|
+
// dsh-agent-loop) spends less wall time (#4515).
|
|
32
47
|
ctx.on("agent/pre-step", async ({ agent, messages, signal }, next) => {
|
|
33
48
|
const decision = await next();
|
|
49
|
+
if (skipMemory(agent.session)) return decision;
|
|
34
50
|
if (decision.kind !== "enter" || signal.aborted) return decision;
|
|
35
|
-
const profile = await
|
|
36
|
-
|
|
37
|
-
|
|
51
|
+
const [profile, recall] = await Promise.all([
|
|
52
|
+
runtime.profileMessage(agent),
|
|
53
|
+
runtime.recallMessage(agent, decision.messages),
|
|
54
|
+
]);
|
|
38
55
|
if (signal.aborted) return decision;
|
|
39
56
|
const additions = [profile, recall].filter(Boolean);
|
|
40
57
|
return additions.length > 0
|
|
@@ -43,11 +60,13 @@ export function apply(ctx, input = {}) {
|
|
|
43
60
|
}, { prepend: true });
|
|
44
61
|
|
|
45
62
|
ctx.on("session/event", (session, event) => {
|
|
63
|
+
if (skipMemory(session)) return;
|
|
46
64
|
runtime.capture(session, event);
|
|
47
65
|
runtime.maybeCommit(session, event);
|
|
48
66
|
});
|
|
49
67
|
|
|
50
68
|
ctx.on("session/flush", async session => {
|
|
69
|
+
if (skipMemory(session)) return;
|
|
51
70
|
await runtime.flush(session);
|
|
52
71
|
});
|
|
53
72
|
|
package/mcp.mjs
CHANGED
|
@@ -16,7 +16,10 @@ export const PROXY_PATH = fileURLToPath(new URL("./servers/mcp-proxy.mjs", impor
|
|
|
16
16
|
* already resolved have to be passed explicitly.
|
|
17
17
|
*/
|
|
18
18
|
export function buildMcpConfig(config) {
|
|
19
|
-
|
|
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" };
|
|
20
23
|
if (config.endpoint) env.OPENVIKING_URL = config.endpoint;
|
|
21
24
|
if (config.apiKey) env.OPENVIKING_API_KEY = config.apiKey;
|
|
22
25
|
if (config.account) env.OPENVIKING_ACCOUNT = config.account;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openviking/dsh-memory-plugin",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "OpenViking memory and context bundle for DeepSeek Harness",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.mjs",
|
|
@@ -35,15 +35,16 @@
|
|
|
35
35
|
"prepublishOnly": "npm run check && npm test"
|
|
36
36
|
},
|
|
37
37
|
"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"
|
|
38
|
+
"@deepseek-ai/dsh-llm": ">=0.1.0-rc.6 <0.2.0 || ^0.1.5-rc.1",
|
|
39
|
+
"@deepseek-ai/dsh-mcp-client": ">=0.1.0-rc.6 <0.2.0 || ^0.1.5-rc.1",
|
|
40
|
+
"@deepseek-ai/dsh-skill-filesystem": ">=0.1.0-rc.6 <0.2.0 || ^0.1.5-rc.1"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@deepseek-ai/dsh-llm": "0.1.0-rc.6",
|
|
44
44
|
"@deepseek-ai/dsh-mcp-client": "0.1.0-rc.6",
|
|
45
45
|
"@deepseek-ai/dsh-skill-filesystem": "0.1.0-rc.6",
|
|
46
|
-
"@deepseek-ai/dsh-tools": "0.1.0-rc.6"
|
|
46
|
+
"@deepseek-ai/dsh-tools": "0.1.0-rc.6",
|
|
47
|
+
"semver": "7.8.5"
|
|
47
48
|
},
|
|
48
49
|
"overrides": {
|
|
49
50
|
"@deepseek-ai/dsh-agent": "0.1.0-rc.6",
|
package/runtime.mjs
CHANGED
|
@@ -22,23 +22,29 @@ export class OpenVikingRuntime {
|
|
|
22
22
|
this.config = config;
|
|
23
23
|
this.logger = logger;
|
|
24
24
|
this.states = new Map();
|
|
25
|
+
this.drainTimer = null;
|
|
26
|
+
this.drainRunning = false;
|
|
27
|
+
this.drainPromise = Promise.resolve();
|
|
28
|
+
this.drainHealth = null;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
stateFor(session) {
|
|
28
32
|
let state = this.states.get(session.id);
|
|
29
33
|
if (state) return state;
|
|
30
34
|
const cwd = session.header?.cwd || process.cwd();
|
|
31
|
-
const
|
|
35
|
+
const peer = resolveEffectivePeerId({
|
|
32
36
|
cfg: {
|
|
33
37
|
peerId: this.config.explicitPeerId,
|
|
38
|
+
peerSource: this.config.peerSource,
|
|
34
39
|
workspacePeer: this.config.workspacePeer,
|
|
40
|
+
harness: this.config.harness,
|
|
35
41
|
},
|
|
36
42
|
cwd,
|
|
37
|
-
})
|
|
43
|
+
});
|
|
38
44
|
state = {
|
|
39
45
|
dshSessionId: String(session.id),
|
|
40
46
|
ovSessionId: deriveHarnessSessionId("dsh-", String(session.id)),
|
|
41
|
-
config: { ...this.config, peerId },
|
|
47
|
+
config: { ...this.config, peerId: peer.peerId, legacyPeerId: peer.legacyPeerId },
|
|
42
48
|
ready: false,
|
|
43
49
|
profileBlock: "",
|
|
44
50
|
profileDelivered: false,
|
|
@@ -85,10 +91,11 @@ export class OpenVikingRuntime {
|
|
|
85
91
|
state.initializationRetryable = isRetryableFailure(ensured);
|
|
86
92
|
return state;
|
|
87
93
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
// Replay is a write, so it stays behind the same toggle: a backlog queued
|
|
95
|
+
// while capture was on waits for a session that still writes.
|
|
96
|
+
if (state.config.syncTurns) {
|
|
97
|
+
await this.replayPendingQueue();
|
|
98
|
+
}
|
|
92
99
|
await this.refreshPendingState(state);
|
|
93
100
|
const profile = await buildProfileBlock(
|
|
94
101
|
(path, init, options) => this.client.fetchJSON(path, init, options),
|
|
@@ -109,6 +116,10 @@ export class OpenVikingRuntime {
|
|
|
109
116
|
async profileMessage(agent) {
|
|
110
117
|
const state = await this.initialize(agent);
|
|
111
118
|
if (!state.ready || !state.profileBlock || state.profileDelivered) return null;
|
|
119
|
+
if (hasStartupProfile(agent)) {
|
|
120
|
+
state.profileDelivered = true;
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
112
123
|
state.profileDelivered = true;
|
|
113
124
|
return pluginMessage(state.profileBlock, "instructions");
|
|
114
125
|
}
|
|
@@ -124,6 +135,7 @@ export class OpenVikingRuntime {
|
|
|
124
135
|
query,
|
|
125
136
|
{
|
|
126
137
|
actorPeerId: state.config.peerId,
|
|
138
|
+
legacyPeerId: state.config.legacyPeerId,
|
|
127
139
|
sessionId: state.ovSessionId,
|
|
128
140
|
log: (stage, data) => this.log(stage, data),
|
|
129
141
|
},
|
|
@@ -165,6 +177,7 @@ export class OpenVikingRuntime {
|
|
|
165
177
|
maybeCommit(session, event) {
|
|
166
178
|
if (event.type !== "turn/end") return;
|
|
167
179
|
const state = this.stateFor(session);
|
|
180
|
+
if (!state.config.syncTurns) return;
|
|
168
181
|
this.enqueueWrite(state, async () => {
|
|
169
182
|
if (state.hasPendingWrites) return;
|
|
170
183
|
if (!state.ready && !(await this.ensureState(state)).ready) return;
|
|
@@ -197,6 +210,7 @@ export class OpenVikingRuntime {
|
|
|
197
210
|
if (state.disposing) return state.disposing;
|
|
198
211
|
state.disposing = (async () => {
|
|
199
212
|
this.enqueueWrite(state, async () => {
|
|
213
|
+
if (!state.config.syncTurns) return;
|
|
200
214
|
const commitPayload = {
|
|
201
215
|
keep_recent_count: state.config.commitKeepRecentCount,
|
|
202
216
|
};
|
|
@@ -247,7 +261,15 @@ export class OpenVikingRuntime {
|
|
|
247
261
|
const createdAt = Math.max(Date.now(), state.pendingCreatedAt + 1);
|
|
248
262
|
state.pendingCreatedAt = createdAt;
|
|
249
263
|
const result = await enqueue(type, state.ovSessionId, payload, { createdAt });
|
|
250
|
-
if (result.ok)
|
|
264
|
+
if (result.ok) {
|
|
265
|
+
// Log the latch transition only: every message that follows while the
|
|
266
|
+
// latch holds takes the cheap enqueue path, so this fires once per
|
|
267
|
+
// outage, not once per message.
|
|
268
|
+
if (!state.hasPendingWrites) {
|
|
269
|
+
this.log("pending_latched", { sessionId: state.ovSessionId, type });
|
|
270
|
+
}
|
|
271
|
+
state.hasPendingWrites = true;
|
|
272
|
+
}
|
|
251
273
|
if (!result.ok) {
|
|
252
274
|
this.log("pending_enqueue_error", {
|
|
253
275
|
sessionId: state.ovSessionId,
|
|
@@ -282,6 +304,7 @@ export class OpenVikingRuntime {
|
|
|
282
304
|
}
|
|
283
305
|
|
|
284
306
|
async refreshPendingState(state) {
|
|
307
|
+
const wasPending = state.hasPendingWrites;
|
|
285
308
|
const pending = (await listPending()).filter(
|
|
286
309
|
item => item.entry?.sessionId === state.ovSessionId,
|
|
287
310
|
);
|
|
@@ -290,6 +313,84 @@ export class OpenVikingRuntime {
|
|
|
290
313
|
(latest, item) => Math.max(latest, Number(item.entry?.createdAt || 0)),
|
|
291
314
|
state.pendingCreatedAt,
|
|
292
315
|
);
|
|
316
|
+
// Only the flip back to direct sends is logged: the recovery moment that
|
|
317
|
+
// proves the drainer worked, once per outage.
|
|
318
|
+
if (wasPending && !state.hasPendingWrites) {
|
|
319
|
+
this.log("pending_cleared", { sessionId: state.ovSessionId });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Replay the pending queue through this runtime's client. The session-start
|
|
325
|
+
* path calls it without options and keeps consuming retries; the drainer
|
|
326
|
+
* passes consumeRetries:false so transient failures stay retryable.
|
|
327
|
+
*/
|
|
328
|
+
async replayPendingQueue(options = {}) {
|
|
329
|
+
await replayPending(
|
|
330
|
+
(path, init) => this.client.fetchJSON(path, init),
|
|
331
|
+
(stage, data) => this.log(stage, data),
|
|
332
|
+
options,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* One drainer tick, following the session-start replay flow: probe health
|
|
338
|
+
* first and only replay when the server answers. Replays run without
|
|
339
|
+
* consuming retry budgets, then every session's latch is re-derived from
|
|
340
|
+
* the queue. An empty queue clears the latches with zero HTTP traffic, so
|
|
341
|
+
* once a transient write failure recovers, capture and commit resume on
|
|
342
|
+
* their own without restarting the long-lived dsh process.
|
|
343
|
+
*/
|
|
344
|
+
async drainTick() {
|
|
345
|
+
if (this.drainRunning) return;
|
|
346
|
+
this.drainRunning = true;
|
|
347
|
+
try {
|
|
348
|
+
const pending = await listPending();
|
|
349
|
+
if (pending.length > 0) {
|
|
350
|
+
const health = await this.client.healthResult();
|
|
351
|
+
if (!health.ok) {
|
|
352
|
+
// Only the flip into the outage is logged, not every 60s probe.
|
|
353
|
+
if (this.drainHealth !== false) {
|
|
354
|
+
this.log("drain_health_down", { status: health.status || 0 });
|
|
355
|
+
}
|
|
356
|
+
this.drainHealth = false;
|
|
357
|
+
} else {
|
|
358
|
+
if (this.drainHealth === false) {
|
|
359
|
+
this.log("drain_health_restored", {});
|
|
360
|
+
}
|
|
361
|
+
this.drainHealth = true;
|
|
362
|
+
await this.replayPendingQueue({ consumeRetries: false });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
for (const state of this.states.values()) {
|
|
366
|
+
await this.refreshPendingState(state);
|
|
367
|
+
}
|
|
368
|
+
} finally {
|
|
369
|
+
this.drainRunning = false;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Start the background drainer. The interval is fixed per process; each tick
|
|
375
|
+
* is single-flight, so a slow replay run never overlaps the next one.
|
|
376
|
+
*/
|
|
377
|
+
startDrainer() {
|
|
378
|
+
if (this.drainTimer) return this.drainTimer;
|
|
379
|
+
const parsed = parseInt(process.env.OPENVIKING_PENDING_DRAIN_INTERVAL_MS || "", 10);
|
|
380
|
+
const intervalMs = Number.isFinite(parsed) && parsed > 0 ? parsed : 60000;
|
|
381
|
+
this.drainTimer = setInterval(() => {
|
|
382
|
+
this.drainPromise = this.drainTick().catch(error => this.log("drain_error", {
|
|
383
|
+
error: error instanceof Error ? error.message : String(error),
|
|
384
|
+
}));
|
|
385
|
+
}, intervalMs);
|
|
386
|
+
this.drainTimer.unref?.();
|
|
387
|
+
return this.drainTimer;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
stopDrainer() {
|
|
391
|
+
if (!this.drainTimer) return;
|
|
392
|
+
clearInterval(this.drainTimer);
|
|
393
|
+
this.drainTimer = null;
|
|
293
394
|
}
|
|
294
395
|
|
|
295
396
|
async flush(session) {
|
|
@@ -314,3 +415,23 @@ function pluginMessage(content, form) {
|
|
|
314
415
|
},
|
|
315
416
|
});
|
|
316
417
|
}
|
|
418
|
+
|
|
419
|
+
function hasStartupProfile(agent) {
|
|
420
|
+
const session = agent.session;
|
|
421
|
+
const ownEvents = typeof session?.ownEvents === "function"
|
|
422
|
+
? session.ownEvents()
|
|
423
|
+
: (session?.events || []).slice(session?.header?.seedLength ?? 0);
|
|
424
|
+
const inHistory = ownEvents.some(event => (
|
|
425
|
+
event?.type === "user/message" && isStartupProfile(event.data)
|
|
426
|
+
));
|
|
427
|
+
if (inHistory) return true;
|
|
428
|
+
return [agent.inbox?.nextTurn, agent.inbox?.nextStep].some(messages => (
|
|
429
|
+
(messages || []).some(isStartupProfile)
|
|
430
|
+
));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function isStartupProfile(message) {
|
|
434
|
+
return message?.source?.kind === "plugin"
|
|
435
|
+
&& message.source.plugin === OPENVIKING_PLUGIN_SOURCE
|
|
436
|
+
&& message.source.form === "instructions";
|
|
437
|
+
}
|
package/servers/mcp-proxy.mjs
CHANGED
|
@@ -25,6 +25,10 @@ export function readProxyConfig(env = process.env, cwd = process.cwd()) {
|
|
|
25
25
|
apiKey: cfg.apiKey,
|
|
26
26
|
account: cfg.account,
|
|
27
27
|
user: cfg.user,
|
|
28
|
+
// Not gated through `resolveMcpActorPeerId` like the other proxies: DSH's
|
|
29
|
+
// 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.
|
|
28
32
|
peerId: cfg.peerId,
|
|
29
33
|
userAgent: cfg.userAgent,
|
|
30
34
|
timeoutMs: cfg.requestTimeoutMs,
|
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
|
}
|
|
@@ -354,6 +356,53 @@ export function extractPartsFromPayload(payload, options = {}) {
|
|
|
354
356
|
return parts;
|
|
355
357
|
}
|
|
356
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Prune blank text parts and run `cfg.captureFilters` over the ones that remain.
|
|
361
|
+
*
|
|
362
|
+
* One drop verdict is taken per turn, on the aggregate of its text parts; the
|
|
363
|
+
* individual parts are then rewritten with the substitutions only, so a rule can
|
|
364
|
+
* never both keep and drop the same turn. Tool call/result parts are carried
|
|
365
|
+
* through untouched — filters match conversation text, not tool payloads — but a
|
|
366
|
+
* turn dropped on its text takes its tool parts with it.
|
|
367
|
+
*/
|
|
368
|
+
export function filterCaptureParts(parts, role, cfg = {}) {
|
|
369
|
+
const kept = [];
|
|
370
|
+
for (const part of parts || []) {
|
|
371
|
+
if (!part) continue;
|
|
372
|
+
if (part.type !== "text") {
|
|
373
|
+
kept.push(part);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const text = typeof part.text === "string" ? part.text.trim() : "";
|
|
377
|
+
if (!text) continue;
|
|
378
|
+
kept.push(text === part.text ? part : { ...part, text });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const blank = { parts: kept, dropped: false };
|
|
382
|
+
const compiled = compileInputFilters(cfg?.captureFilters);
|
|
383
|
+
if (!compiled.rules.length || kept.length === 0) return blank;
|
|
384
|
+
|
|
385
|
+
const textParts = kept.filter((part) => part.type === "text");
|
|
386
|
+
if (textParts.length === 0) return blank;
|
|
387
|
+
|
|
388
|
+
const verdict = applyInputFilters(textParts.map((part) => part.text).join("\n\n"), compiled.rules, {
|
|
389
|
+
role,
|
|
390
|
+
});
|
|
391
|
+
if (verdict.dropped) return { parts: [], dropped: true };
|
|
392
|
+
|
|
393
|
+
const out = [];
|
|
394
|
+
for (const part of kept) {
|
|
395
|
+
if (part.type !== "text") {
|
|
396
|
+
out.push(part);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const shaped = applyInputFilters(part.text, compiled.rules, { role, substituteOnly: true });
|
|
400
|
+
if (!shaped.text) continue;
|
|
401
|
+
out.push(shaped.text === part.text ? part : { ...part, text: shaped.text });
|
|
402
|
+
}
|
|
403
|
+
return { parts: out, dropped: out.length === 0 };
|
|
404
|
+
}
|
|
405
|
+
|
|
357
406
|
export function extractCaptureTurns(rolloutEntries, cfg = {}) {
|
|
358
407
|
const toolNameById = collectToolNamesByIdFromEntries(rolloutEntries);
|
|
359
408
|
const turns = [];
|
|
@@ -371,10 +420,15 @@ export function extractCaptureTurns(rolloutEntries, cfg = {}) {
|
|
|
371
420
|
toolMaxChars: cfg.captureToolMaxChars,
|
|
372
421
|
toolNameById,
|
|
373
422
|
});
|
|
374
|
-
const
|
|
375
|
-
if (
|
|
423
|
+
const shaped = filterCaptureParts(parts, role, cfg);
|
|
424
|
+
if (shaped.dropped) continue;
|
|
425
|
+
// With parts on the wire the drop decision was already taken above, so the
|
|
426
|
+
// text path only runs the filters for the `content` fallback. That also
|
|
427
|
+
// keeps `turn.text` faithful for callers that scan it for trigger words.
|
|
428
|
+
const decision = shouldCaptureText(rawText, role, cfg, { filters: shaped.parts.length === 0 });
|
|
429
|
+
if (!decision.shouldCapture && shaped.parts.length === 0) continue;
|
|
376
430
|
const text = decision.shouldCapture ? decision.text : "";
|
|
377
|
-
turns.push({ role, text, parts });
|
|
431
|
+
turns.push({ role, text, parts: shaped.parts });
|
|
378
432
|
}
|
|
379
433
|
return turns;
|
|
380
434
|
}
|
|
@@ -486,12 +540,21 @@ function isPunctuationOnly(text) {
|
|
|
486
540
|
return !/[a-z0-9\u3400-\u9fff]/i.test(text);
|
|
487
541
|
}
|
|
488
542
|
|
|
489
|
-
export function shouldCaptureText(text, role, cfg = {}) {
|
|
543
|
+
export function shouldCaptureText(text, role, cfg = {}, { filters = true } = {}) {
|
|
490
544
|
const maxLength = cfg.captureMaxLength || 24000;
|
|
491
545
|
const sanitized = sanitizeCapturedText(text);
|
|
492
546
|
if (!sanitized) return { shouldCapture: false, reason: "empty", text: "" };
|
|
493
547
|
|
|
494
|
-
|
|
548
|
+
let capped = truncateCaptureText(sanitized, maxLength);
|
|
549
|
+
if (filters) {
|
|
550
|
+
const compiled = compileInputFilters(cfg?.captureFilters);
|
|
551
|
+
if (compiled.rules.length) {
|
|
552
|
+
const verdict = applyInputFilters(capped, compiled.rules, { role });
|
|
553
|
+
if (verdict.dropped) return { shouldCapture: false, reason: "filtered", text: "" };
|
|
554
|
+
capped = verdict.text;
|
|
555
|
+
if (!capped) return { shouldCapture: false, reason: "empty", text: "" };
|
|
556
|
+
}
|
|
557
|
+
}
|
|
495
558
|
const compact = oneLine(capped);
|
|
496
559
|
const isToolSummary = /^\[tool-(?:call|result)\b/i.test(compact);
|
|
497
560
|
|