@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 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 newer in the `0.1.x` release line
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, so there is nothing extra to add to a profile. The supported peer
40
- range starts at `0.1.0-rc.6` and stops before `0.2.0`, allowing current DSH
41
- releases whose core packages resolve to newer release candidates. The local
42
- devDependencies and `overrides` stay pinned to rc.6 so CI continues to exercise
43
- the minimum supported contract.
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`, serving only `skills/`. DSH already mounts a
100
- `filesystem` provider over the project and user skill roots; an isolated
101
- provider adds the bundle's skill without shadowing or duplicating that catalog.
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
- - Failed writes enter the shared OpenViking pending queue for replay at the next session start.
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.1";
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
- config.peerId = resolveEffectivePeerId({ cfg: config, cwd }).peerId;
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 runtime.profileMessage(agent);
36
- if (signal.aborted) return decision;
37
- const recall = await runtime.recallMessage(agent, decision.messages);
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
- const env = {};
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.1",
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 peerId = resolveEffectivePeerId({
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
- }).peerId;
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
- await replayPending(
89
- (path, init) => this.client.fetchJSON(path, init),
90
- (stage, data) => this.log(stage, data),
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) state.hasPendingWrites = true;
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
+ }
@@ -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,
@@ -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 decision = shouldCaptureText(rawText, role, cfg);
375
- if (!decision.shouldCapture && parts.length === 0) continue;
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
- const capped = truncateCaptureText(sanitized, maxLength);
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