@openviking/dsh-memory-plugin 0.3.0 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp-env.mjs ADDED
@@ -0,0 +1,33 @@
1
+ import { fileURLToPath } from "node:url";
2
+
3
+ import { MCP_SERVER_NAME } from "./config.mjs";
4
+ import { forwardConnectionEnv } from "./shared/mcp-proxy-config.mjs";
5
+
6
+ /** The same stdio proxy every other OpenViking memory integration starts. */
7
+ export const PROXY_PATH = fileURLToPath(new URL("./servers/mcp-proxy.mjs", import.meta.url));
8
+
9
+ /**
10
+ * Build the dsh-mcp-client config for the OpenViking stdio proxy.
11
+ *
12
+ * DSH scrubs credential-shaped names out of the environment a subprocess
13
+ * inherits, and the Cordis patch is invisible to one, so the proxy cannot
14
+ * resolve the connection the way this process did. It gets the answer instead:
15
+ * the resolved connection and this session's peer, forwarded with the forced
16
+ * `env` source so the child reads no file and lands on exactly this url, key,
17
+ * identity, auth mode and peer — the empty ones included.
18
+ */
19
+ export function buildMcpConfig(config) {
20
+ // In DSH Desktop, process.execPath is Electron's executable rather than a
21
+ // standalone Node binary. This tells Electron to run the proxy script as
22
+ // Node instead of attempting to launch a second Desktop instance.
23
+ const env = { ELECTRON_RUN_AS_NODE: "1", ...forwardConnectionEnv(config) };
24
+ if (config.timeoutMs) env.OPENVIKING_TIMEOUT_MS = String(config.timeoutMs);
25
+ return {
26
+ transport: "stdio",
27
+ serverName: MCP_SERVER_NAME,
28
+ command: process.execPath,
29
+ args: [PROXY_PATH],
30
+ env,
31
+ toolCallTimeoutMs: config.mcpToolCallTimeoutMs,
32
+ };
33
+ }
package/mcp.mjs CHANGED
@@ -1,39 +1,8 @@
1
- import { fileURLToPath } from "node:url";
2
1
  import * as mcpClient from "@deepseek-ai/dsh-mcp-client";
3
2
 
4
- import { MCP_SERVER_NAME } from "./config.mjs";
3
+ import { buildMcpConfig } from "./mcp-env.mjs";
5
4
 
6
- /** The same stdio proxy every other OpenViking memory integration starts. */
7
- export const PROXY_PATH = fileURLToPath(new URL("./servers/mcp-proxy.mjs", import.meta.url));
8
-
9
- /**
10
- * Build the dsh-mcp-client config for the OpenViking stdio proxy.
11
- *
12
- * The bundle's own credential resolution (`OPENVIKING_*` → `ovcli.conf` →
13
- * `ov.conf`, plus anything set in the Cordis patch) is forwarded through the
14
- * child environment: DSH scrubs credential-shaped names out of the inherited
15
- * env, and the patch is invisible to a subprocess, so values the runtime
16
- * already resolved have to be passed explicitly.
17
- */
18
- export function buildMcpConfig(config) {
19
- // In DSH Desktop, process.execPath is Electron's executable rather than a
20
- // standalone Node binary. This tells Electron to run the proxy script as
21
- // Node instead of attempting to launch a second Desktop instance.
22
- const env = { ELECTRON_RUN_AS_NODE: "1" };
23
- if (config.endpoint) env.OPENVIKING_URL = config.endpoint;
24
- if (config.apiKey) env.OPENVIKING_API_KEY = config.apiKey;
25
- if (config.account) env.OPENVIKING_ACCOUNT = config.account;
26
- if (config.user) env.OPENVIKING_USER = config.user;
27
- if (config.peerId) env.OPENVIKING_PEER_ID = config.peerId;
28
- return {
29
- transport: "stdio",
30
- serverName: MCP_SERVER_NAME,
31
- command: process.execPath,
32
- args: [PROXY_PATH],
33
- env,
34
- toolCallTimeoutMs: config.mcpToolCallTimeoutMs,
35
- };
36
- }
5
+ export { buildMcpConfig, PROXY_PATH } from "./mcp-env.mjs";
37
6
 
38
7
  /**
39
8
  * Mount the tool surface. The bridge ships with dsh itself, so it resolves
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openviking/dsh-memory-plugin",
3
- "version": "0.3.0",
3
+ "version": "0.4.3",
4
4
  "description": "OpenViking memory and context bundle for DeepSeek Harness",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
@@ -14,6 +14,7 @@
14
14
  "capture.mjs",
15
15
  "lifecycle.mjs",
16
16
  "mcp.mjs",
17
+ "mcp-env.mjs",
17
18
  "runtime.mjs",
18
19
  "skills.mjs",
19
20
  "uri-guard.mjs",
@@ -29,21 +30,23 @@
29
30
  }
30
31
  },
31
32
  "scripts": {
33
+ "prepack": "node ../memory-plugin-shared/sync.mjs",
32
34
  "check": "for f in *.mjs shared/*.mjs; do node --check \"$f\" || exit 1; done && npm run check:version",
33
35
  "check:version": "node --input-type=module -e \"import {readFileSync} from 'node:fs'; const {PLUGIN_VERSION} = await import('./config.mjs'); const v = JSON.parse(readFileSync('package.json','utf8')).version; if (PLUGIN_VERSION !== v) throw new Error('PLUGIN_VERSION ' + PLUGIN_VERSION + ' does not match package.json version ' + v);\"",
34
36
  "test": "node --test *.test.mjs",
35
- "prepublishOnly": "npm run check && npm test"
37
+ "prepublishOnly": "npm run prepack && npm run check && npm test"
36
38
  },
37
39
  "peerDependencies": {
38
- "@deepseek-ai/dsh-llm": ">=0.1.0-rc.6 <0.2.0",
39
- "@deepseek-ai/dsh-mcp-client": ">=0.1.0-rc.6 <0.2.0",
40
- "@deepseek-ai/dsh-skill-filesystem": ">=0.1.0-rc.6 <0.2.0"
40
+ "@deepseek-ai/dsh-llm": ">=0.1.0-rc.6 <0.2.0 || ^0.1.5-rc.1",
41
+ "@deepseek-ai/dsh-mcp-client": ">=0.1.0-rc.6 <0.2.0 || ^0.1.5-rc.1",
42
+ "@deepseek-ai/dsh-skill-filesystem": ">=0.1.0-rc.6 <0.2.0 || ^0.1.5-rc.1"
41
43
  },
42
44
  "devDependencies": {
43
45
  "@deepseek-ai/dsh-llm": "0.1.0-rc.6",
44
46
  "@deepseek-ai/dsh-mcp-client": "0.1.0-rc.6",
45
47
  "@deepseek-ai/dsh-skill-filesystem": "0.1.0-rc.6",
46
- "@deepseek-ai/dsh-tools": "0.1.0-rc.6"
48
+ "@deepseek-ai/dsh-tools": "0.1.0-rc.6",
49
+ "semver": "7.8.5"
47
50
  },
48
51
  "overrides": {
49
52
  "@deepseek-ai/dsh-agent": "0.1.0-rc.6",
package/runtime.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { createUserMessage } from "@deepseek-ai/dsh-llm";
1
+ import { isCaptureEnabled } from "./shared/capture-utils.mjs";
2
2
  import { buildProfileBlock } from "./shared/profile-inject.mjs";
3
- import { buildRecallBlock } from "./shared/recall-core.mjs";
3
+ import { buildRecallBlock, isRecallEnabled } from "./shared/recall-core.mjs";
4
4
  import { deriveHarnessSessionId } from "./shared/session-model.mjs";
5
5
  import {
6
6
  dequeue,
@@ -13,6 +13,7 @@ import { resolveEffectivePeerId } from "./shared/workspace-peer.mjs";
13
13
  import {
14
14
  captureEvent,
15
15
  OPENVIKING_PLUGIN_SOURCE,
16
+ pluginMessage,
16
17
  promptText,
17
18
  } from "./capture.mjs";
18
19
 
@@ -22,23 +23,29 @@ export class OpenVikingRuntime {
22
23
  this.config = config;
23
24
  this.logger = logger;
24
25
  this.states = new Map();
26
+ this.drainTimer = null;
27
+ this.drainRunning = false;
28
+ this.drainPromise = Promise.resolve();
29
+ this.drainHealth = null;
25
30
  }
26
31
 
27
32
  stateFor(session) {
28
33
  let state = this.states.get(session.id);
29
34
  if (state) return state;
30
35
  const cwd = session.header?.cwd || process.cwd();
31
- const peerId = resolveEffectivePeerId({
36
+ const peer = resolveEffectivePeerId({
32
37
  cfg: {
33
38
  peerId: this.config.explicitPeerId,
39
+ peerSource: this.config.peerSource,
34
40
  workspacePeer: this.config.workspacePeer,
41
+ harness: this.config.harness,
35
42
  },
36
43
  cwd,
37
- }).peerId;
44
+ });
38
45
  state = {
39
46
  dshSessionId: String(session.id),
40
47
  ovSessionId: deriveHarnessSessionId("dsh-", String(session.id)),
41
- config: { ...this.config, peerId },
48
+ config: { ...this.config, peerId: peer.peerId, legacyPeerId: peer.legacyPeerId },
42
49
  ready: false,
43
50
  profileBlock: "",
44
51
  profileDelivered: false,
@@ -85,10 +92,11 @@ export class OpenVikingRuntime {
85
92
  state.initializationRetryable = isRetryableFailure(ensured);
86
93
  return state;
87
94
  }
88
- await replayPending(
89
- (path, init) => this.client.fetchJSON(path, init),
90
- (stage, data) => this.log(stage, data),
91
- );
95
+ // Replay is a write, so it stays behind the same toggle: a backlog queued
96
+ // while capture was on waits for a session that still writes.
97
+ if (isCaptureEnabled(state.config)) {
98
+ await this.replayPendingQueue();
99
+ }
92
100
  await this.refreshPendingState(state);
93
101
  const profile = await buildProfileBlock(
94
102
  (path, init, options) => this.client.fetchJSON(path, init, options),
@@ -114,12 +122,12 @@ export class OpenVikingRuntime {
114
122
  return null;
115
123
  }
116
124
  state.profileDelivered = true;
117
- return pluginMessage(state.profileBlock, "instructions");
125
+ return pluginMessage(state.profileBlock, { form: "instructions" });
118
126
  }
119
127
 
120
128
  async recallMessage(agent, messages) {
121
129
  const state = await this.initialize(agent);
122
- if (!state.ready) return null;
130
+ if (!state.ready || !isRecallEnabled(state.config)) return null;
123
131
  const query = promptText(messages);
124
132
  if (query.length < state.config.minQueryLength) return null;
125
133
  const block = await buildRecallBlock(
@@ -128,16 +136,17 @@ export class OpenVikingRuntime {
128
136
  query,
129
137
  {
130
138
  actorPeerId: state.config.peerId,
139
+ legacyPeerId: state.config.legacyPeerId,
131
140
  sessionId: state.ovSessionId,
132
141
  log: (stage, data) => this.log(stage, data),
133
142
  },
134
143
  );
135
- return block ? pluginMessage(block, "recall") : null;
144
+ return block ? pluginMessage(block, { form: "recall" }) : null;
136
145
  }
137
146
 
138
147
  capture(session, event) {
139
148
  const state = this.stateFor(session);
140
- if (!state.config.syncTurns) return;
149
+ if (!isCaptureEnabled(state.config)) return;
141
150
  const payload = captureEvent(event, state.config, state.toolNames);
142
151
  if (!payload) return;
143
152
  this.enqueueWrite(state, async () => {
@@ -169,6 +178,7 @@ export class OpenVikingRuntime {
169
178
  maybeCommit(session, event) {
170
179
  if (event.type !== "turn/end") return;
171
180
  const state = this.stateFor(session);
181
+ if (!isCaptureEnabled(state.config)) return;
172
182
  this.enqueueWrite(state, async () => {
173
183
  if (state.hasPendingWrites) return;
174
184
  if (!state.ready && !(await this.ensureState(state)).ready) return;
@@ -201,6 +211,7 @@ export class OpenVikingRuntime {
201
211
  if (state.disposing) return state.disposing;
202
212
  state.disposing = (async () => {
203
213
  this.enqueueWrite(state, async () => {
214
+ if (!isCaptureEnabled(state.config)) return;
204
215
  const commitPayload = {
205
216
  keep_recent_count: state.config.commitKeepRecentCount,
206
217
  };
@@ -251,7 +262,15 @@ export class OpenVikingRuntime {
251
262
  const createdAt = Math.max(Date.now(), state.pendingCreatedAt + 1);
252
263
  state.pendingCreatedAt = createdAt;
253
264
  const result = await enqueue(type, state.ovSessionId, payload, { createdAt });
254
- if (result.ok) state.hasPendingWrites = true;
265
+ if (result.ok) {
266
+ // Log the latch transition only: every message that follows while the
267
+ // latch holds takes the cheap enqueue path, so this fires once per
268
+ // outage, not once per message.
269
+ if (!state.hasPendingWrites) {
270
+ this.log("pending_latched", { sessionId: state.ovSessionId, type });
271
+ }
272
+ state.hasPendingWrites = true;
273
+ }
255
274
  if (!result.ok) {
256
275
  this.log("pending_enqueue_error", {
257
276
  sessionId: state.ovSessionId,
@@ -286,6 +305,7 @@ export class OpenVikingRuntime {
286
305
  }
287
306
 
288
307
  async refreshPendingState(state) {
308
+ const wasPending = state.hasPendingWrites;
289
309
  const pending = (await listPending()).filter(
290
310
  item => item.entry?.sessionId === state.ovSessionId,
291
311
  );
@@ -294,6 +314,84 @@ export class OpenVikingRuntime {
294
314
  (latest, item) => Math.max(latest, Number(item.entry?.createdAt || 0)),
295
315
  state.pendingCreatedAt,
296
316
  );
317
+ // Only the flip back to direct sends is logged: the recovery moment that
318
+ // proves the drainer worked, once per outage.
319
+ if (wasPending && !state.hasPendingWrites) {
320
+ this.log("pending_cleared", { sessionId: state.ovSessionId });
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Replay the pending queue through this runtime's client. The session-start
326
+ * path calls it without options and keeps consuming retries; the drainer
327
+ * passes consumeRetries:false so transient failures stay retryable.
328
+ */
329
+ async replayPendingQueue(options = {}) {
330
+ await replayPending(
331
+ (path, init) => this.client.fetchJSON(path, init),
332
+ (stage, data) => this.log(stage, data),
333
+ options,
334
+ );
335
+ }
336
+
337
+ /**
338
+ * One drainer tick, following the session-start replay flow: probe health
339
+ * first and only replay when the server answers. Replays run without
340
+ * consuming retry budgets, then every session's latch is re-derived from
341
+ * the queue. An empty queue clears the latches with zero HTTP traffic, so
342
+ * once a transient write failure recovers, capture and commit resume on
343
+ * their own without restarting the long-lived dsh process.
344
+ */
345
+ async drainTick() {
346
+ if (this.drainRunning) return;
347
+ this.drainRunning = true;
348
+ try {
349
+ const pending = await listPending();
350
+ if (pending.length > 0) {
351
+ const health = await this.client.healthResult();
352
+ if (!health.ok) {
353
+ // Only the flip into the outage is logged, not every 60s probe.
354
+ if (this.drainHealth !== false) {
355
+ this.log("drain_health_down", { status: health.status || 0 });
356
+ }
357
+ this.drainHealth = false;
358
+ } else {
359
+ if (this.drainHealth === false) {
360
+ this.log("drain_health_restored", {});
361
+ }
362
+ this.drainHealth = true;
363
+ await this.replayPendingQueue({ consumeRetries: false });
364
+ }
365
+ }
366
+ for (const state of this.states.values()) {
367
+ await this.refreshPendingState(state);
368
+ }
369
+ } finally {
370
+ this.drainRunning = false;
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Start the background drainer. The interval is fixed per process; each tick
376
+ * is single-flight, so a slow replay run never overlaps the next one.
377
+ */
378
+ startDrainer() {
379
+ if (this.drainTimer) return this.drainTimer;
380
+ const parsed = parseInt(process.env.OPENVIKING_PENDING_DRAIN_INTERVAL_MS || "", 10);
381
+ const intervalMs = Number.isFinite(parsed) && parsed > 0 ? parsed : 60000;
382
+ this.drainTimer = setInterval(() => {
383
+ this.drainPromise = this.drainTick().catch(error => this.log("drain_error", {
384
+ error: error instanceof Error ? error.message : String(error),
385
+ }));
386
+ }, intervalMs);
387
+ this.drainTimer.unref?.();
388
+ return this.drainTimer;
389
+ }
390
+
391
+ stopDrainer() {
392
+ if (!this.drainTimer) return;
393
+ clearInterval(this.drainTimer);
394
+ this.drainTimer = null;
297
395
  }
298
396
 
299
397
  async flush(session) {
@@ -306,22 +404,11 @@ export class OpenVikingRuntime {
306
404
  }
307
405
  }
308
406
 
309
- function pluginMessage(content, form) {
310
- // dsh's own constructor: identity, normalization, and any future Message
311
- // invariants come from the pinned peer instead of a hand-built object.
312
- return createUserMessage({
313
- content: [{ type: "text", text: content }],
314
- source: {
315
- kind: "plugin",
316
- plugin: OPENVIKING_PLUGIN_SOURCE,
317
- form,
318
- },
319
- });
320
- }
321
-
322
407
  function hasStartupProfile(agent) {
323
408
  const session = agent.session;
324
- const ownEvents = (session?.events || []).slice(session?.header?.seedLength ?? 0);
409
+ const ownEvents = typeof session?.ownEvents === "function"
410
+ ? session.ownEvents()
411
+ : (session?.events || []).slice(session?.header?.seedLength ?? 0);
325
412
  const inHistory = ownEvents.some(event => (
326
413
  event?.type === "user/message" && isStartupProfile(event.data)
327
414
  ));
@@ -4,36 +4,30 @@
4
4
  * stdio -> streamable-HTTP MCP proxy for the OpenViking DSH bundle.
5
5
  *
6
6
  * DSH's MCP bridge starts this process as a local stdio MCP server. The proxy
7
- * reads the same OpenViking credential sources as the in-process runtime; the
8
- * bundle also forwards its resolved config through the child environment, so
9
- * values that came from the Cordis patch survive the process boundary.
7
+ * resolves its connection through the same `resolveConfig()` as the in-process
8
+ * runtime, from the child environment the bundle builds in `mcp.mjs`: DSH
9
+ * scrubs credential-shaped names out of what it inherits, and values that came
10
+ * from the Cordis patch are invisible to a subprocess otherwise.
10
11
  */
11
12
 
12
13
  import { resolve as resolvePath } from "node:path";
13
14
  import { fileURLToPath } from "node:url";
14
15
  import { resolveConfig } from "../config.mjs";
15
- import { resolveOpenVikingCredentials } from "../shared/credentials.mjs";
16
16
  import { createLogger } from "../shared/debug-log.mjs";
17
- import { buildMcpProxyConfig } from "../shared/mcp-proxy-config.mjs";
17
+ import { toMcpProxyConfig } from "../shared/mcp-proxy-config.mjs";
18
18
  import { createOpenVikingMcpProxy } from "../shared/mcp-proxy-core.mjs";
19
19
 
20
20
  export function readProxyConfig(env = process.env, cwd = process.cwd()) {
21
21
  const cfg = resolveConfig({}, env, cwd);
22
- const creds = resolveOpenVikingCredentials(env);
23
- return buildMcpProxyConfig({
24
- baseUrl: cfg.endpoint,
25
- apiKey: cfg.apiKey,
26
- account: cfg.account,
27
- user: cfg.user,
28
- peerId: cfg.peerId,
29
- userAgent: cfg.userAgent,
30
- timeoutMs: cfg.requestTimeoutMs,
31
- debug: Boolean(env.OV_DEBUG_LOG),
32
- debugLogPath: env.OV_DEBUG_LOG,
33
- credentialSource: creds.credentialSource,
34
- credentialPath: creds.cliPath || creds.ovPath,
35
- watchedPaths: [creds.cliPath, creds.ovPath, creds.cliPathCandidate],
22
+ return toMcpProxyConfig(cfg, {
36
23
  env,
24
+ // Not gated through `resolveMcpActorPeerId` like the other proxies: DSH's
25
+ // parent process resolves the peer per session and hands it over in the
26
+ // child env. Empty means it has none, and deriving one from wherever DSH
27
+ // launched this process would send a peer the runtime does not.
28
+ peerId: String(env.OPENVIKING_PEER_ID || "").trim(),
29
+ debug: Boolean(env.OV_DEBUG_LOG),
30
+ debugLogPath: env.OV_DEBUG_LOG || "",
37
31
  });
38
32
  }
39
33
 
@@ -1,4 +1,6 @@
1
1
  // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
+ import { applyInputFilters, compileInputFilters } from "./input-filters.mjs";
3
+
2
4
  const TEXT_BLOCK_TYPES = new Set(["text", "input_text", "output_text"]);
3
5
  const TOOL_CALL_TYPES = new Set([
4
6
  "tool_call",
@@ -152,7 +154,7 @@ function toolId(block) {
152
154
 
153
155
  function toolStatus(block, kind) {
154
156
  if (kind === "call") return "running";
155
- if (block?.is_error || block?.error || block?.state?.error) return "error";
157
+ if (block?.is_error || block?.isError || block?.state?.isError || block?.error || block?.state?.error) return "error";
156
158
  const status = oneLine(block?.status || block?.state?.status || "");
157
159
  return status || "completed";
158
160
  }
@@ -264,7 +266,13 @@ export function extractTextFromPayload(payload, options = {}) {
264
266
  return chunks.join("\n\n");
265
267
  }
266
268
 
267
- function collectToolNamesByIdFromEntries(entries) {
269
+ /**
270
+ * Map every tool call id in a transcript to the name of the tool it invoked.
271
+ *
272
+ * A result block names the call by id only, so the name has to come from the
273
+ * call that preceded it. Pass the map as `toolNameById` to the extractors.
274
+ */
275
+ export function collectToolNamesByIdFromEntries(entries) {
268
276
  const map = {};
269
277
  for (const entry of entries || []) {
270
278
  const payload = entry?.payload && typeof entry.payload === "object" ? entry.payload : entry;
@@ -354,6 +362,53 @@ export function extractPartsFromPayload(payload, options = {}) {
354
362
  return parts;
355
363
  }
356
364
 
365
+ /**
366
+ * Prune blank text parts and run `cfg.captureFilters` over the ones that remain.
367
+ *
368
+ * One drop verdict is taken per turn, on the aggregate of its text parts; the
369
+ * individual parts are then rewritten with the substitutions only, so a rule can
370
+ * never both keep and drop the same turn. Tool call/result parts are carried
371
+ * through untouched — filters match conversation text, not tool payloads — but a
372
+ * turn dropped on its text takes its tool parts with it.
373
+ */
374
+ export function filterCaptureParts(parts, role, cfg = {}) {
375
+ const kept = [];
376
+ for (const part of parts || []) {
377
+ if (!part) continue;
378
+ if (part.type !== "text") {
379
+ kept.push(part);
380
+ continue;
381
+ }
382
+ const text = typeof part.text === "string" ? part.text.trim() : "";
383
+ if (!text) continue;
384
+ kept.push(text === part.text ? part : { ...part, text });
385
+ }
386
+
387
+ const blank = { parts: kept, dropped: false };
388
+ const compiled = compileInputFilters(cfg?.captureFilters);
389
+ if (!compiled.rules.length || kept.length === 0) return blank;
390
+
391
+ const textParts = kept.filter((part) => part.type === "text");
392
+ if (textParts.length === 0) return blank;
393
+
394
+ const verdict = applyInputFilters(textParts.map((part) => part.text).join("\n\n"), compiled.rules, {
395
+ role,
396
+ });
397
+ if (verdict.dropped) return { parts: [], dropped: true };
398
+
399
+ const out = [];
400
+ for (const part of kept) {
401
+ if (part.type !== "text") {
402
+ out.push(part);
403
+ continue;
404
+ }
405
+ const shaped = applyInputFilters(part.text, compiled.rules, { role, substituteOnly: true });
406
+ if (!shaped.text) continue;
407
+ out.push(shaped.text === part.text ? part : { ...part, text: shaped.text });
408
+ }
409
+ return { parts: out, dropped: out.length === 0 };
410
+ }
411
+
357
412
  export function extractCaptureTurns(rolloutEntries, cfg = {}) {
358
413
  const toolNameById = collectToolNamesByIdFromEntries(rolloutEntries);
359
414
  const turns = [];
@@ -371,14 +426,37 @@ export function extractCaptureTurns(rolloutEntries, cfg = {}) {
371
426
  toolMaxChars: cfg.captureToolMaxChars,
372
427
  toolNameById,
373
428
  });
374
- const decision = shouldCaptureText(rawText, role, cfg);
375
- if (!decision.shouldCapture && parts.length === 0) continue;
429
+ const shaped = filterCaptureParts(parts, role, cfg);
430
+ if (shaped.dropped) continue;
431
+ // With parts on the wire the drop decision was already taken above, so the
432
+ // text path only runs the filters for the `content` fallback. That also
433
+ // keeps `turn.text` faithful for callers that scan it for trigger words.
434
+ const decision = shouldCaptureText(rawText, role, cfg, { filters: shaped.parts.length === 0 });
435
+ if (!decision.shouldCapture && shaped.parts.length === 0) continue;
376
436
  const text = decision.shouldCapture ? decision.text : "";
377
- turns.push({ role, text, parts });
437
+ turns.push({ role, text, parts: shaped.parts });
378
438
  }
379
439
  return turns;
380
440
  }
381
441
 
442
+ /**
443
+ * Index of the last turn that came from a human prompt, or -1.
444
+ *
445
+ * `role === "user"` alone is not enough: normalizeCaptureRole() maps tool
446
+ * results onto the user role too, and those carry `tool` parts rather than
447
+ * `text` parts. Used by the post-compact shrink path to find where the current
448
+ * interaction starts.
449
+ */
450
+ export function findLastHumanTurnIndex(turns) {
451
+ const list = Array.isArray(turns) ? turns : [];
452
+ for (let i = list.length - 1; i >= 0; i -= 1) {
453
+ const turn = list[i];
454
+ if (turn?.role !== "user") continue;
455
+ if (turn.parts?.some((part) => part?.type === "text")) return i;
456
+ }
457
+ return -1;
458
+ }
459
+
382
460
  export function normalizeCaptureRole(role) {
383
461
  const value = normalizeType(role);
384
462
  if (value === "user") return "user";
@@ -457,12 +535,24 @@ function stripInjectedDigestBlocks(text) {
457
535
  return out.join("\n");
458
536
  }
459
537
 
538
+ /**
539
+ * Drop everything in a turn that the conversation did not put there.
540
+ *
541
+ * Recall injects a context block into the prompt, and the host adds notes of
542
+ * its own; captured back unchanged, this turn's injection becomes next turn's
543
+ * memory and the loop feeds on itself. Formatting the conversation did author
544
+ * — newlines, code fences — survives.
545
+ */
460
546
  export function sanitizeCapturedText(text) {
461
547
  let value = String(text || "");
462
548
  value = value
463
549
  .replace(/\u0000/g, "")
464
550
  .replace(/<openviking-context\b[^>]*>[\s\S]*?<\/openviking-context>/gi, " ")
465
551
  .replace(/<relevant-memor(?:y|ies)\b[^>]*>[\s\S]*?<\/relevant-memor(?:y|ies)>/gi, " ")
552
+ // Claude Code wraps its own out-of-band notes to the model in these two
553
+ // shapes. They are the host talking to itself, not the conversation.
554
+ .replace(/<system-reminder\b[^>]*>[\s\S]*?<\/system-reminder>/gi, " ")
555
+ .replace(/^[ \t]*\[Subagent Context\][^\n]*$/gim, " ")
466
556
  .replace(/^\s*Sender\s*\([^)]+\)\s*```[\s\S]*?```\s*/gim, " ")
467
557
  .replace(/^\s*Conversation (?:metadata|info):\s*```[\s\S]*?```\s*/gim, " ")
468
558
  .replace(/^\s*\[?\d{4}-\d{2}-\d{2}[T ][^\]\n]{3,80}\]?\s*/gm, "")
@@ -486,12 +576,38 @@ function isPunctuationOnly(text) {
486
576
  return !/[a-z0-9\u3400-\u9fff]/i.test(text);
487
577
  }
488
578
 
489
- export function shouldCaptureText(text, role, cfg = {}) {
579
+ /**
580
+ * Is capture on for this config?
581
+ *
582
+ * The switch has four spellings in the wild: a boolean `autoCapture`, opencode's
583
+ * `{ enabled }` object, dsh and pi's `syncTurns`, and the global `enabled`
584
+ * that turns the whole plugin off. Reading it here rather than in each loader
585
+ * is what keeps it from drifting a fifth time — and any spelling that says off
586
+ * wins, so a config that disables capture under an older name still disables it.
587
+ */
588
+ export function isCaptureEnabled(cfg = {}) {
589
+ for (const value of [cfg.enabled, cfg.autoCapture, cfg.capture, cfg.syncTurns]) {
590
+ if (value === false) return false;
591
+ if (value && typeof value === "object" && !Array.isArray(value) && value.enabled === false) return false;
592
+ }
593
+ return true;
594
+ }
595
+
596
+ export function shouldCaptureText(text, role, cfg = {}, { filters = true } = {}) {
490
597
  const maxLength = cfg.captureMaxLength || 24000;
491
598
  const sanitized = sanitizeCapturedText(text);
492
599
  if (!sanitized) return { shouldCapture: false, reason: "empty", text: "" };
493
600
 
494
- const capped = truncateCaptureText(sanitized, maxLength);
601
+ let capped = truncateCaptureText(sanitized, maxLength);
602
+ if (filters) {
603
+ const compiled = compileInputFilters(cfg?.captureFilters);
604
+ if (compiled.rules.length) {
605
+ const verdict = applyInputFilters(capped, compiled.rules, { role });
606
+ if (verdict.dropped) return { shouldCapture: false, reason: "filtered", text: "" };
607
+ capped = verdict.text;
608
+ if (!capped) return { shouldCapture: false, reason: "empty", text: "" };
609
+ }
610
+ }
495
611
  const compact = oneLine(capped);
496
612
  const isToolSummary = /^\[tool-(?:call|result)\b/i.test(compact);
497
613
 
@@ -513,3 +629,26 @@ export function shouldCaptureText(text, role, cfg = {}) {
513
629
 
514
630
  return { shouldCapture: true, reason: "ok", text: capped };
515
631
  }
632
+
633
+ /**
634
+ * Apply the capture filter to a list of `{ role, content }` turns.
635
+ *
636
+ * The harnesses that compose `agent-hook-runtime` used to send whatever their
637
+ * transcript parser produced: an acknowledgement, a slash command, a stray
638
+ * `ok`, or a turn far past `captureMaxLength` all reached the extractor
639
+ * verbatim. This is the same decision every other harness makes, in one place,
640
+ * so a thin harness gets it by calling rather than by reimplementing it.
641
+ *
642
+ * Returns the surviving turns with `content` replaced by the sanitized and
643
+ * capped text, and the dropped ones with the reason, for the debug log.
644
+ */
645
+ export function filterCaptureTurns(turns, cfg = {}) {
646
+ const kept = [];
647
+ const dropped = [];
648
+ for (const turn of Array.isArray(turns) ? turns : []) {
649
+ const decision = shouldCaptureText(turn?.content, turn?.role, cfg);
650
+ if (decision.shouldCapture) kept.push({ ...turn, content: decision.text });
651
+ else dropped.push({ role: turn?.role, reason: decision.reason });
652
+ }
653
+ return { kept, dropped };
654
+ }