@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 +75 -16
- package/config.mjs +6 -2
- package/index.mjs +15 -3
- package/package.json +6 -5
- package/runtime.mjs +108 -9
- package/servers/mcp-proxy.mjs +4 -0
- package/shared/capture-utils.mjs +69 -6
- package/shared/input-filters.mjs +219 -0
- package/shared/mcp-proxy-config.mjs +18 -8
- package/shared/mcp-proxy-core.mjs +12 -0
- package/shared/pending-queue.mjs +41 -5
- package/shared/recall-compress-core.mjs +17 -6
- package/shared/recall-core.mjs +65 -0
- package/shared/workspace-identity.mjs +334 -0
- package/shared/workspace-peer.mjs +126 -8
- package/skills/openviking-memory/SKILL.md +19 -0
- package/skills.mjs +1 -4
- package/shared/plugin-config.mjs +0 -90
- package/shared/setup-wizard.mjs +0 -114
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
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "OpenViking memory and context bundle for DeepSeek Harness",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.mjs",
|
|
@@ -35,15 +35,16 @@
|
|
|
35
35
|
"prepublishOnly": "npm run check && npm test"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"@deepseek-ai/dsh-llm": ">=0.1.0-rc.6 <0.2.0",
|
|
39
|
-
"@deepseek-ai/dsh-mcp-client": ">=0.1.0-rc.6 <0.2.0",
|
|
40
|
-
"@deepseek-ai/dsh-skill-filesystem": ">=0.1.0-rc.6 <0.2.0"
|
|
38
|
+
"@deepseek-ai/dsh-llm": ">=0.1.0-rc.6 <0.2.0 || ^0.1.5-rc.1",
|
|
39
|
+
"@deepseek-ai/dsh-mcp-client": ">=0.1.0-rc.6 <0.2.0 || ^0.1.5-rc.1",
|
|
40
|
+
"@deepseek-ai/dsh-skill-filesystem": ">=0.1.0-rc.6 <0.2.0 || ^0.1.5-rc.1"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@deepseek-ai/dsh-llm": "0.1.0-rc.6",
|
|
44
44
|
"@deepseek-ai/dsh-mcp-client": "0.1.0-rc.6",
|
|
45
45
|
"@deepseek-ai/dsh-skill-filesystem": "0.1.0-rc.6",
|
|
46
|
-
"@deepseek-ai/dsh-tools": "0.1.0-rc.6"
|
|
46
|
+
"@deepseek-ai/dsh-tools": "0.1.0-rc.6",
|
|
47
|
+
"semver": "7.8.5"
|
|
47
48
|
},
|
|
48
49
|
"overrides": {
|
|
49
50
|
"@deepseek-ai/dsh-agent": "0.1.0-rc.6",
|
package/runtime.mjs
CHANGED
|
@@ -22,23 +22,29 @@ export class OpenVikingRuntime {
|
|
|
22
22
|
this.config = config;
|
|
23
23
|
this.logger = logger;
|
|
24
24
|
this.states = new Map();
|
|
25
|
+
this.drainTimer = null;
|
|
26
|
+
this.drainRunning = false;
|
|
27
|
+
this.drainPromise = Promise.resolve();
|
|
28
|
+
this.drainHealth = null;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
stateFor(session) {
|
|
28
32
|
let state = this.states.get(session.id);
|
|
29
33
|
if (state) return state;
|
|
30
34
|
const cwd = session.header?.cwd || process.cwd();
|
|
31
|
-
const
|
|
35
|
+
const peer = resolveEffectivePeerId({
|
|
32
36
|
cfg: {
|
|
33
37
|
peerId: this.config.explicitPeerId,
|
|
38
|
+
peerSource: this.config.peerSource,
|
|
34
39
|
workspacePeer: this.config.workspacePeer,
|
|
40
|
+
harness: this.config.harness,
|
|
35
41
|
},
|
|
36
42
|
cwd,
|
|
37
|
-
})
|
|
43
|
+
});
|
|
38
44
|
state = {
|
|
39
45
|
dshSessionId: String(session.id),
|
|
40
46
|
ovSessionId: deriveHarnessSessionId("dsh-", String(session.id)),
|
|
41
|
-
config: { ...this.config, peerId },
|
|
47
|
+
config: { ...this.config, peerId: peer.peerId, legacyPeerId: peer.legacyPeerId },
|
|
42
48
|
ready: false,
|
|
43
49
|
profileBlock: "",
|
|
44
50
|
profileDelivered: false,
|
|
@@ -85,10 +91,11 @@ export class OpenVikingRuntime {
|
|
|
85
91
|
state.initializationRetryable = isRetryableFailure(ensured);
|
|
86
92
|
return state;
|
|
87
93
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
// Replay is a write, so it stays behind the same toggle: a backlog queued
|
|
95
|
+
// while capture was on waits for a session that still writes.
|
|
96
|
+
if (state.config.syncTurns) {
|
|
97
|
+
await this.replayPendingQueue();
|
|
98
|
+
}
|
|
92
99
|
await this.refreshPendingState(state);
|
|
93
100
|
const profile = await buildProfileBlock(
|
|
94
101
|
(path, init, options) => this.client.fetchJSON(path, init, options),
|
|
@@ -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)
|
|
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 =
|
|
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
|
));
|
package/servers/mcp-proxy.mjs
CHANGED
|
@@ -25,6 +25,10 @@ export function readProxyConfig(env = process.env, cwd = process.cwd()) {
|
|
|
25
25
|
apiKey: cfg.apiKey,
|
|
26
26
|
account: cfg.account,
|
|
27
27
|
user: cfg.user,
|
|
28
|
+
// Not gated through `resolveMcpActorPeerId` like the other proxies: DSH's
|
|
29
|
+
// parent process resolves the peer per session and hands it over in the
|
|
30
|
+
// child env (`mcp.mjs` `buildMcpConfig`), so this one is not a guess at the
|
|
31
|
+
// launch directory.
|
|
28
32
|
peerId: cfg.peerId,
|
|
29
33
|
userAgent: cfg.userAgent,
|
|
30
34
|
timeoutMs: cfg.requestTimeoutMs,
|
package/shared/capture-utils.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
|
|
2
|
+
import { applyInputFilters, compileInputFilters } from "./input-filters.mjs";
|
|
3
|
+
|
|
2
4
|
const TEXT_BLOCK_TYPES = new Set(["text", "input_text", "output_text"]);
|
|
3
5
|
const TOOL_CALL_TYPES = new Set([
|
|
4
6
|
"tool_call",
|
|
@@ -152,7 +154,7 @@ function toolId(block) {
|
|
|
152
154
|
|
|
153
155
|
function toolStatus(block, kind) {
|
|
154
156
|
if (kind === "call") return "running";
|
|
155
|
-
if (block?.is_error || block?.error || block?.state?.error) return "error";
|
|
157
|
+
if (block?.is_error || block?.isError || block?.state?.isError || block?.error || block?.state?.error) return "error";
|
|
156
158
|
const status = oneLine(block?.status || block?.state?.status || "");
|
|
157
159
|
return status || "completed";
|
|
158
160
|
}
|
|
@@ -354,6 +356,53 @@ export function extractPartsFromPayload(payload, options = {}) {
|
|
|
354
356
|
return parts;
|
|
355
357
|
}
|
|
356
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Prune blank text parts and run `cfg.captureFilters` over the ones that remain.
|
|
361
|
+
*
|
|
362
|
+
* One drop verdict is taken per turn, on the aggregate of its text parts; the
|
|
363
|
+
* individual parts are then rewritten with the substitutions only, so a rule can
|
|
364
|
+
* never both keep and drop the same turn. Tool call/result parts are carried
|
|
365
|
+
* through untouched — filters match conversation text, not tool payloads — but a
|
|
366
|
+
* turn dropped on its text takes its tool parts with it.
|
|
367
|
+
*/
|
|
368
|
+
export function filterCaptureParts(parts, role, cfg = {}) {
|
|
369
|
+
const kept = [];
|
|
370
|
+
for (const part of parts || []) {
|
|
371
|
+
if (!part) continue;
|
|
372
|
+
if (part.type !== "text") {
|
|
373
|
+
kept.push(part);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const text = typeof part.text === "string" ? part.text.trim() : "";
|
|
377
|
+
if (!text) continue;
|
|
378
|
+
kept.push(text === part.text ? part : { ...part, text });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const blank = { parts: kept, dropped: false };
|
|
382
|
+
const compiled = compileInputFilters(cfg?.captureFilters);
|
|
383
|
+
if (!compiled.rules.length || kept.length === 0) return blank;
|
|
384
|
+
|
|
385
|
+
const textParts = kept.filter((part) => part.type === "text");
|
|
386
|
+
if (textParts.length === 0) return blank;
|
|
387
|
+
|
|
388
|
+
const verdict = applyInputFilters(textParts.map((part) => part.text).join("\n\n"), compiled.rules, {
|
|
389
|
+
role,
|
|
390
|
+
});
|
|
391
|
+
if (verdict.dropped) return { parts: [], dropped: true };
|
|
392
|
+
|
|
393
|
+
const out = [];
|
|
394
|
+
for (const part of kept) {
|
|
395
|
+
if (part.type !== "text") {
|
|
396
|
+
out.push(part);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const shaped = applyInputFilters(part.text, compiled.rules, { role, substituteOnly: true });
|
|
400
|
+
if (!shaped.text) continue;
|
|
401
|
+
out.push(shaped.text === part.text ? part : { ...part, text: shaped.text });
|
|
402
|
+
}
|
|
403
|
+
return { parts: out, dropped: out.length === 0 };
|
|
404
|
+
}
|
|
405
|
+
|
|
357
406
|
export function extractCaptureTurns(rolloutEntries, cfg = {}) {
|
|
358
407
|
const toolNameById = collectToolNamesByIdFromEntries(rolloutEntries);
|
|
359
408
|
const turns = [];
|
|
@@ -371,10 +420,15 @@ export function extractCaptureTurns(rolloutEntries, cfg = {}) {
|
|
|
371
420
|
toolMaxChars: cfg.captureToolMaxChars,
|
|
372
421
|
toolNameById,
|
|
373
422
|
});
|
|
374
|
-
const
|
|
375
|
-
if (
|
|
423
|
+
const shaped = filterCaptureParts(parts, role, cfg);
|
|
424
|
+
if (shaped.dropped) continue;
|
|
425
|
+
// With parts on the wire the drop decision was already taken above, so the
|
|
426
|
+
// text path only runs the filters for the `content` fallback. That also
|
|
427
|
+
// keeps `turn.text` faithful for callers that scan it for trigger words.
|
|
428
|
+
const decision = shouldCaptureText(rawText, role, cfg, { filters: shaped.parts.length === 0 });
|
|
429
|
+
if (!decision.shouldCapture && shaped.parts.length === 0) continue;
|
|
376
430
|
const text = decision.shouldCapture ? decision.text : "";
|
|
377
|
-
turns.push({ role, text, parts });
|
|
431
|
+
turns.push({ role, text, parts: shaped.parts });
|
|
378
432
|
}
|
|
379
433
|
return turns;
|
|
380
434
|
}
|
|
@@ -486,12 +540,21 @@ function isPunctuationOnly(text) {
|
|
|
486
540
|
return !/[a-z0-9\u3400-\u9fff]/i.test(text);
|
|
487
541
|
}
|
|
488
542
|
|
|
489
|
-
export function shouldCaptureText(text, role, cfg = {}) {
|
|
543
|
+
export function shouldCaptureText(text, role, cfg = {}, { filters = true } = {}) {
|
|
490
544
|
const maxLength = cfg.captureMaxLength || 24000;
|
|
491
545
|
const sanitized = sanitizeCapturedText(text);
|
|
492
546
|
if (!sanitized) return { shouldCapture: false, reason: "empty", text: "" };
|
|
493
547
|
|
|
494
|
-
|
|
548
|
+
let capped = truncateCaptureText(sanitized, maxLength);
|
|
549
|
+
if (filters) {
|
|
550
|
+
const compiled = compileInputFilters(cfg?.captureFilters);
|
|
551
|
+
if (compiled.rules.length) {
|
|
552
|
+
const verdict = applyInputFilters(capped, compiled.rules, { role });
|
|
553
|
+
if (verdict.dropped) return { shouldCapture: false, reason: "filtered", text: "" };
|
|
554
|
+
capped = verdict.text;
|
|
555
|
+
if (!capped) return { shouldCapture: false, reason: "empty", text: "" };
|
|
556
|
+
}
|
|
557
|
+
}
|
|
495
558
|
const compact = oneLine(capped);
|
|
496
559
|
const isToolSummary = /^\[tool-(?:call|result)\b/i.test(compact);
|
|
497
560
|
|