@openviking/dsh-memory-plugin 0.3.0 → 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
@@ -32,7 +32,7 @@ tree's realpath rather than from the profile.
32
32
 
33
33
  ## Requirements
34
34
 
35
- - `@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
36
36
  - Node.js `^22.19.0` or `>=24`
37
37
  - A reachable OpenViking server
38
38
 
@@ -40,17 +40,62 @@ The bundle has no runtime npm dependencies. Its message structures come from
40
40
  `createUserMessage` (`@deepseek-ai/dsh-llm`), its tool surface from
41
41
  `@deepseek-ai/dsh-mcp-client`, and its skill provider from
42
42
  `@deepseek-ai/dsh-skill-filesystem` — peerDependencies that DSH itself already
43
- installs, so there is nothing extra to add to a profile. The supported peer
44
- range starts at `0.1.0-rc.6` and stops before `0.2.0`, allowing current DSH
45
- releases whose core packages resolve to newer release candidates. The local
46
- devDependencies and `overrides` stay pinned to rc.6 so CI continues to exercise
47
- 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.
48
54
 
49
55
  Everything under `shared/` and `skills/` is generated by
50
56
  `node examples/memory-plugin-shared/sync.mjs` — edit
51
57
  `examples/memory-plugin-shared/lib/` and `examples/skills/`, never the vendored
52
58
  copies.
53
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
+
54
99
  ## Design notes
55
100
 
56
101
  ### Why injection uses pre-step user messages, not the system prompt
@@ -85,10 +130,7 @@ change.
85
130
 
86
131
  Two consequences follow from the proxy being one process per profile:
87
132
 
88
- - **The actor peer is process-level.** Recall, capture, and commit still resolve
89
- a peer per session from that session's workspace, but tool calls carry the
90
- peer resolved at boot. Set `OPENVIKING_PEER_ID` when one process serves
91
- 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.
92
134
  - **`remember` is not session-scoped.** The server's MCP `remember` stores into
93
135
  its own short-lived session rather than the live `dsh-<session-id>` stream —
94
136
  the same behavior the Claude Code, Codex, and Cursor integrations have.
@@ -100,9 +142,11 @@ lifecycle registration, so a proxy that cannot start holds up nothing above it.
100
142
  ### Why the skill gets its own provider
101
143
 
102
144
  `skills.mjs` registers a second `ctx.skills` provider with
103
- `includeDefaultRoots: false`, serving only `skills/`. DSH already mounts a
104
- `filesystem` provider over the project and user skill roots; an isolated
105
- 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.
106
150
 
107
151
  ## Configuration
108
152
 
@@ -121,7 +165,7 @@ Common environment variables:
121
165
  | `OPENVIKING_ACCOUNT` | Trusted-mode account |
122
166
  | `OPENVIKING_USER` | Trusted-mode user |
123
167
  | `OPENVIKING_PEER_ID` | Explicit actor peer |
124
- | `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 |
125
169
  | `OPENVIKING_RECALL_PEER_SCOPE` | `all` for cross-workspace recall or `actor` for isolation |
126
170
 
127
171
  The patch can also carry plugin config:
@@ -153,10 +197,11 @@ The patch can also carry plugin config:
153
197
  - `session/event` captures user, assistant, and optionally tool-result messages without scraping a transcript.
154
198
  - `turn/end` checks the OpenViking pending-token threshold and commits when required.
155
199
  - `skipSubagentSessions: true` excludes sessions marked with `header.origin: subagent` from automatic profile, recall, capture, and commit; it defaults to `false`.
156
- - Failed writes enter the shared OpenViking pending queue for replay at the next session start.
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.
157
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.
158
203
 
159
- 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.
160
205
 
161
206
  ## Tools
162
207
 
@@ -184,6 +229,20 @@ npm run check # syntax check every shipped module + package/PLUGIN_VERSION agr
184
229
  npm test # node --test *.test.mjs — runs in the repo's PR workflow
185
230
  ```
186
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
+
187
246
  `live-recall.test.mjs` is an opt-in end-to-end gate against a real OpenViking
188
247
  server: it stores a sentinel memory through a session commit, waits for
189
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.3.0";
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,
@@ -55,6 +56,7 @@ export function resolveConfig(input = {}, env = process.env, cwd = process.cwd()
55
56
  peerId: explicitPeerId,
56
57
  explicitPeerId,
57
58
  userAgent: buildUserAgent("dsh", PLUGIN_VERSION),
59
+ harness: "dsh",
58
60
  recallLimitConfigured: Object.prototype.hasOwnProperty.call(input, "recallLimit"),
59
61
  recallQueryExpansionConfigured: Object.prototype.hasOwnProperty.call(input, "recallQueryExpansion"),
60
62
  };
@@ -79,7 +81,9 @@ export function resolveConfig(input = {}, env = process.env, cwd = process.cwd()
79
81
 
80
82
  config.endpoint = String(config.endpoint || DEFAULT_CONFIG.endpoint).replace(/\/+$/, "");
81
83
  config.workspacePeer = config.workspacePeer !== false;
82
- config.peerId = resolveEffectivePeerId({ cfg: config, cwd }).peerId;
84
+ const effectivePeer = resolveEffectivePeerId({ cfg: config, cwd });
85
+ config.peerId = effectivePeer.peerId;
86
+ config.legacyPeerId = effectivePeer.legacyPeerId;
83
87
  config.recallPeerScope = config.recallPeerScope === "actor" ? "actor" : "all";
84
88
  config.recallQueryExpansion = config.recallQueryExpansion === "off" ? "off" : "auto";
85
89
  config.recallLimit = clampInteger(config.recallLimit, 1, 50, DEFAULT_CONFIG.recallLimit);
package/index.mjs CHANGED
@@ -21,6 +21,14 @@ export function apply(ctx, input = {}) {
21
21
  () => () => runtime.disposeAll(),
22
22
  "openvikingMemory.disposeAll()",
23
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
+ );
24
32
 
25
33
  ctx.on("agent/session-start", ({ agent }) => {
26
34
  if (skipMemory(agent.session)) return false;
@@ -33,13 +41,17 @@ export function apply(ctx, input = {}) {
33
41
 
34
42
  // prepend: downstream waterfall listeners run first, so this plugin sees
35
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).
36
47
  ctx.on("agent/pre-step", async ({ agent, messages, signal }, next) => {
37
48
  const decision = await next();
38
49
  if (skipMemory(agent.session)) return decision;
39
50
  if (decision.kind !== "enter" || signal.aborted) return decision;
40
- const profile = await runtime.profileMessage(agent);
41
- if (signal.aborted) return decision;
42
- 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
+ ]);
43
55
  if (signal.aborted) return decision;
44
56
  const additions = [profile, recall].filter(Boolean);
45
57
  return additions.length > 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openviking/dsh-memory-plugin",
3
- "version": "0.3.0",
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),
@@ -128,6 +135,7 @@ export class OpenVikingRuntime {
128
135
  query,
129
136
  {
130
137
  actorPeerId: state.config.peerId,
138
+ legacyPeerId: state.config.legacyPeerId,
131
139
  sessionId: state.ovSessionId,
132
140
  log: (stage, data) => this.log(stage, data),
133
141
  },
@@ -169,6 +177,7 @@ export class OpenVikingRuntime {
169
177
  maybeCommit(session, event) {
170
178
  if (event.type !== "turn/end") return;
171
179
  const state = this.stateFor(session);
180
+ if (!state.config.syncTurns) return;
172
181
  this.enqueueWrite(state, async () => {
173
182
  if (state.hasPendingWrites) return;
174
183
  if (!state.ready && !(await this.ensureState(state)).ready) return;
@@ -201,6 +210,7 @@ export class OpenVikingRuntime {
201
210
  if (state.disposing) return state.disposing;
202
211
  state.disposing = (async () => {
203
212
  this.enqueueWrite(state, async () => {
213
+ if (!state.config.syncTurns) return;
204
214
  const commitPayload = {
205
215
  keep_recent_count: state.config.commitKeepRecentCount,
206
216
  };
@@ -251,7 +261,15 @@ export class OpenVikingRuntime {
251
261
  const createdAt = Math.max(Date.now(), state.pendingCreatedAt + 1);
252
262
  state.pendingCreatedAt = createdAt;
253
263
  const result = await enqueue(type, state.ovSessionId, payload, { createdAt });
254
- 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
+ }
255
273
  if (!result.ok) {
256
274
  this.log("pending_enqueue_error", {
257
275
  sessionId: state.ovSessionId,
@@ -286,6 +304,7 @@ export class OpenVikingRuntime {
286
304
  }
287
305
 
288
306
  async refreshPendingState(state) {
307
+ const wasPending = state.hasPendingWrites;
289
308
  const pending = (await listPending()).filter(
290
309
  item => item.entry?.sessionId === state.ovSessionId,
291
310
  );
@@ -294,6 +313,84 @@ export class OpenVikingRuntime {
294
313
  (latest, item) => Math.max(latest, Number(item.entry?.createdAt || 0)),
295
314
  state.pendingCreatedAt,
296
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;
297
394
  }
298
395
 
299
396
  async flush(session) {
@@ -321,7 +418,9 @@ function pluginMessage(content, form) {
321
418
 
322
419
  function hasStartupProfile(agent) {
323
420
  const session = agent.session;
324
- const ownEvents = (session?.events || []).slice(session?.header?.seedLength ?? 0);
421
+ const ownEvents = typeof session?.ownEvents === "function"
422
+ ? session.ownEvents()
423
+ : (session?.events || []).slice(session?.header?.seedLength ?? 0);
325
424
  const inHistory = ownEvents.some(event => (
326
425
  event?.type === "user/message" && isStartupProfile(event.data)
327
426
  ));
@@ -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