@openviking/opencode-plugin 0.2.3 → 2026.9.9-1

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.
@@ -15,6 +15,9 @@ import {
15
15
  import {
16
16
  sendSessionMessages,
17
17
  } from "./shared/batch-send.mjs"
18
+ import {
19
+ isRetryableFailure,
20
+ } from "./shared/retryable.mjs"
18
21
  import {
19
22
  log,
20
23
  effectivePeerId,
@@ -27,9 +30,23 @@ export function createMemorySessionManager({ config, pluginRoot }) {
27
30
  const statePath = path.join(pluginRoot, "openviking-session-state.json")
28
31
  const oldSessionMapPath = path.join(pluginRoot, "openviking-session-map.json")
29
32
  let saveTimer = null
33
+ // Serialize saves: concurrent saveState() calls (a debounced save racing
34
+ // with flushAll / flushSession / session deletion) all share the same
35
+ // `${statePath}.tmp` temp file, so one rename can fail with ENOENT after
36
+ // another already moved it. Chaining through a promise queue keeps at most
37
+ // one write+rename in flight. Each queued save re-serializes the in-memory
38
+ // `sessions` map at execution time, so the last save always persists the
39
+ // latest state.
40
+ let saveChain = Promise.resolve()
41
+
42
+ function enqueueSave() {
43
+ const run = saveChain.then(() => saveState())
44
+ saveChain = run.catch(() => {})
45
+ return run
46
+ }
30
47
 
31
48
  async function init() {
32
- await migrateLegacySessionMap()
49
+ if (config.autoCapture) await migrateLegacySessionMap()
33
50
  await loadState()
34
51
  const health = await fetchJSON(config, "/health", {}, { timeoutMs: 5000 })
35
52
  if (health.ok) {
@@ -81,7 +98,7 @@ export function createMemorySessionManager({ config, pluginRoot }) {
81
98
  function debouncedSaveState() {
82
99
  if (saveTimer) clearTimeout(saveTimer)
83
100
  saveTimer = setTimeout(() => {
84
- saveState().catch((error) => {
101
+ enqueueSave().catch((error) => {
85
102
  log("ERROR", "persistence", "Debounced save failed", { error: error?.message })
86
103
  })
87
104
  }, 300)
@@ -140,9 +157,9 @@ export function createMemorySessionManager({ config, pluginRoot }) {
140
157
  await handleSessionCompacted(event)
141
158
  } else if (event.type === "session.idle") {
142
159
  await handleSessionIdle(event)
143
- } else if (event.type === "message.updated") {
160
+ } else if (event.type === "message.updated" && config.autoCapture) {
144
161
  await handleMessageUpdated(event)
145
- } else if (event.type === "message.part.updated") {
162
+ } else if (event.type === "message.part.updated" && config.autoCapture) {
146
163
  await handleMessagePartUpdated(event)
147
164
  }
148
165
  }
@@ -173,7 +190,7 @@ export function createMemorySessionManager({ config, pluginRoot }) {
173
190
  if (!sessionId) return
174
191
  await flushSession(sessionId, { commit: true, reason: event.type })
175
192
  sessions.delete(sessionId)
176
- await saveState()
193
+ await enqueueSave()
177
194
  }
178
195
 
179
196
  async function handleSessionError(event) {
@@ -247,10 +264,20 @@ export function createMemorySessionManager({ config, pluginRoot }) {
247
264
  clearTimeout(saveTimer)
248
265
  saveTimer = null
249
266
  }
267
+ // Do not abort the whole dispose flush when one session fails (e.g. server
268
+ // already GC'd a stale session). Continue so later sessions still commit.
269
+ // See https://github.com/volcengine/OpenViking/issues/4490
250
270
  for (const sessionId of sessions.keys()) {
251
- await flushSession(sessionId, { commit, reason: "flushAll" })
271
+ try {
272
+ await flushSession(sessionId, { commit, reason: "flushAll" })
273
+ } catch (err) {
274
+ console.warn(
275
+ `[opencode-plugin] flushAll skipped session ${sessionId}:`,
276
+ err?.message || err,
277
+ )
278
+ }
252
279
  }
253
- await saveState()
280
+ await enqueueSave()
254
281
  }
255
282
 
256
283
  async function flushSession(opencodeSessionId, { commit = false, reason = "manual" } = {}) {
@@ -259,12 +286,12 @@ export function createMemorySessionManager({ config, pluginRoot }) {
259
286
  if (!state) return false
260
287
 
261
288
  const added = await flushPendingMessages(opencodeSessionId, state)
262
- if (commit) {
289
+ if (commit && config.autoCapture) {
263
290
  await commitOvSession(state.ovSessionId, { force: true, reason })
264
291
  } else if (added > 0) {
265
292
  await maybeCommitByThreshold(state)
266
293
  }
267
- await saveState()
294
+ await enqueueSave()
268
295
  return true
269
296
  }
270
297
 
@@ -361,6 +388,7 @@ export function createMemorySessionManager({ config, pluginRoot }) {
361
388
  }
362
389
 
363
390
  async function flushPendingMessages(opencodeSessionId, state) {
391
+ if (!config.autoCapture) return 0
364
392
  const toSend = []
365
393
  for (const [messageId, message] of state.messages.entries()) {
366
394
  if (message.captured) continue
@@ -437,15 +465,35 @@ export function createMemorySessionManager({ config, pluginRoot }) {
437
465
  for (const state of sessions.values()) {
438
466
  if (state.ovSessionId === ovSessionId) state.lastCommitTime = Date.now()
439
467
  }
440
- log("INFO", "session", "Committed OpenViking session", { openviking_session: ovSessionId, reason })
441
- return { status: "accepted", result: res.result }
468
+ const traceId = res.traceId || res.result?.trace_id
469
+ log("INFO", "session", "Committed OpenViking session", {
470
+ openviking_session: ovSessionId,
471
+ reason,
472
+ trace_id: traceId,
473
+ })
474
+ return { status: "accepted", result: res.result, traceId }
442
475
  }
443
476
  if (isRetryableFailure(res)) {
444
477
  await enqueue("commitSession", ovSessionId, body)
445
- log("WARN", "session", "Queued OpenViking session commit", { openviking_session: ovSessionId, reason })
478
+ log("WARN", "session", "Queued OpenViking session commit", {
479
+ openviking_session: ovSessionId,
480
+ reason,
481
+ trace_id: res.traceId,
482
+ status: res.status,
483
+ })
446
484
  return { status: "queued" }
447
485
  }
448
- throw new Error(`Failed to commit OpenViking session ${ovSessionId}: ${res.error?.message || res.status}`)
486
+ log("ERROR", "session", "Failed to commit OpenViking session", {
487
+ openviking_session: ovSessionId,
488
+ reason,
489
+ trace_id: res.traceId,
490
+ status: res.status,
491
+ error: res.error?.message || res.error?.code,
492
+ })
493
+ throw new Error(
494
+ `Failed to commit OpenViking session ${ovSessionId}: ${res.error?.message || res.status}` +
495
+ (res.traceId ? ` (trace_id=${res.traceId})` : ""),
496
+ )
449
497
  }
450
498
 
451
499
  async function migrateLegacySessionMap() {
@@ -474,9 +522,4 @@ export function createMemorySessionManager({ config, pluginRoot }) {
474
522
  }
475
523
  }
476
524
 
477
- function isRetryableFailure(res) {
478
- if (!res || res.ok) return false
479
- const status = Number(res.status || 0)
480
- return !status || status >= 500 || status === 408 || status === 429
481
- }
482
525
  }
package/lib/runtime.mjs CHANGED
@@ -6,6 +6,7 @@ export async function checkServiceHealth(config, timeoutMs = 3000) {
6
6
  try {
7
7
  const response = await fetch(`${normalizeEndpoint(config.endpoint)}/health`, {
8
8
  method: "GET",
9
+ headers: config.userAgent ? { "User-Agent": config.userAgent } : undefined,
9
10
  signal: controller.signal,
10
11
  })
11
12
  return response.ok
@@ -1,12 +1,11 @@
1
1
  // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
2
  import { enqueue } from "./pending-queue.mjs";
3
+ import { isRetryableFailure } from "./retryable.mjs";
3
4
 
4
5
  export const BATCH_LIMIT = 100;
5
6
 
6
7
  export function isRetryableSendFailure(res) {
7
- if (!res || res.ok) return false;
8
- const status = Number(res.status || 0);
9
- return !status || status === 408 || status === 429 || status >= 500;
8
+ return isRetryableFailure(res);
10
9
  }
11
10
 
12
11
  function makeResult() {
@@ -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",
@@ -17,6 +19,11 @@ const TOOL_RESULT_TYPES = new Set([
17
19
  "functioncalloutput",
18
20
  ]);
19
21
 
22
+ // Tool output is reported verbatim; the server owns truncation via
23
+ // tool_output_externalization (threshold_chars, default 20000). This cap only
24
+ // guards against pathological payloads.
25
+ const DEFAULT_TOOL_MAX_CHARS = 1000000;
26
+
20
27
  const ACK_RE = /^(?:ok|okay|k|yes|yep|no|nope|thanks|thank you|thx|done|收到|好的|好|嗯|可以|继续|不用|不需要|没了|好了)[.!?。!?\s]*$/i;
21
28
  const SLASH_COMMAND_RE = /^\/[a-z0-9_-]{1,64}\b/i;
22
29
  const METADATA_KEYS = [
@@ -97,9 +104,9 @@ function toolName(block) {
97
104
  block?.name ||
98
105
  block?.tool_name ||
99
106
  block?.toolName ||
107
+ block?.tool ||
100
108
  block?.function?.name ||
101
109
  block?.call?.name ||
102
- block?.id ||
103
110
  "",
104
111
  );
105
112
  }
@@ -108,6 +115,7 @@ function toolPayload(block, kind) {
108
115
  if (!block || typeof block !== "object") return "";
109
116
  if (kind === "call") {
110
117
  return block.input ??
118
+ block.state?.input ??
111
119
  block.arguments ??
112
120
  block.args ??
113
121
  block.params ??
@@ -118,8 +126,10 @@ function toolPayload(block, kind) {
118
126
  "";
119
127
  }
120
128
  return block.output ??
129
+ block.state?.output ??
121
130
  block.result ??
122
131
  block.error ??
132
+ block.state?.error ??
123
133
  block.data ??
124
134
  block.content ??
125
135
  block.text ??
@@ -128,27 +138,36 @@ function toolPayload(block, kind) {
128
138
 
129
139
  function toolId(block) {
130
140
  return oneLine(
131
- block?.id ||
132
141
  block?.call_id ||
133
142
  block?.callId ||
143
+ block?.callID ||
134
144
  block?.tool_call_id ||
135
145
  block?.toolCallId ||
136
146
  block?.tool_use_id ||
137
147
  block?.toolUseId ||
138
148
  block?.function_call_id ||
139
149
  block?.functionCallId ||
150
+ block?.id ||
140
151
  "",
141
152
  );
142
153
  }
143
154
 
144
155
  function toolStatus(block, kind) {
145
156
  if (kind === "call") return "running";
146
- if (block?.is_error || block?.error) return "error";
147
- const status = oneLine(block?.status || "");
157
+ if (block?.is_error || block?.isError || block?.state?.isError || block?.error || block?.state?.error) return "error";
158
+ const status = oneLine(block?.status || block?.state?.status || "");
148
159
  return status || "completed";
149
160
  }
150
161
 
151
- function buildToolPart(block, kind, { toolMaxChars = 2000, toolNameById = {} } = {}) {
162
+ function setToolInput(part, payload) {
163
+ const input = parseMaybeJson(payload);
164
+ if (input === "" || input == null) return;
165
+ part.tool_input = typeof input === "object" && !Array.isArray(input)
166
+ ? input
167
+ : { value: input };
168
+ }
169
+
170
+ function buildToolPart(block, kind, { toolMaxChars = DEFAULT_TOOL_MAX_CHARS, toolNameById = {} } = {}) {
152
171
  const id = toolId(block);
153
172
  const name = toolName(block) || (id ? toolNameById[id] : "");
154
173
  const payload = toolPayload(block, kind);
@@ -159,13 +178,11 @@ function buildToolPart(block, kind, { toolMaxChars = 2000, toolNameById = {} } =
159
178
  tool_status: toolStatus(block, kind),
160
179
  };
161
180
  if (kind === "call") {
162
- const input = parseMaybeJson(payload);
163
- if (input !== "" && input != null) {
164
- part.tool_input = typeof input === "object" && !Array.isArray(input)
165
- ? input
166
- : { value: input };
167
- }
181
+ setToolInput(part, payload);
168
182
  } else {
183
+ if (block?.state?.input !== undefined) {
184
+ setToolInput(part, block.state.input);
185
+ }
169
186
  part.tool_output = stringifyCompact(payload, toolMaxChars);
170
187
  }
171
188
  return part;
@@ -202,7 +219,7 @@ function blockToText(block, options) {
202
219
  }
203
220
 
204
221
  export function extractTextFromContent(content, options = {}) {
205
- const opts = { toolMaxChars: 2000, ...options };
222
+ const opts = { toolMaxChars: DEFAULT_TOOL_MAX_CHARS, ...options };
206
223
  if (!content) return "";
207
224
  if (typeof content === "string") return content;
208
225
  if (Array.isArray(content)) {
@@ -222,7 +239,7 @@ export function extractTextFromPayload(payload, options = {}) {
222
239
  const chunks = [];
223
240
  const directType = normalizeType(payload.type || payload.kind || payload.role);
224
241
  if (TOOL_RESULT_TYPES.has(directType) || directType === "tool" || TOOL_CALL_TYPES.has(directType)) {
225
- const direct = blockToText(payload, { toolMaxChars: 2000, ...options });
242
+ const direct = blockToText(payload, { toolMaxChars: DEFAULT_TOOL_MAX_CHARS, ...options });
226
243
  if (direct) return direct;
227
244
  }
228
245
 
@@ -242,7 +259,7 @@ export function extractTextFromPayload(payload, options = {}) {
242
259
  }
243
260
 
244
261
  if (chunks.length === 0) {
245
- const direct = blockToText(payload, { toolMaxChars: 2000, ...options });
262
+ const direct = blockToText(payload, { toolMaxChars: DEFAULT_TOOL_MAX_CHARS, ...options });
246
263
  if (direct) chunks.push(direct);
247
264
  }
248
265
 
@@ -282,7 +299,7 @@ function collectToolNamesByIdFromPayload(payload, out) {
282
299
  }
283
300
 
284
301
  function extractPartsFromContent(content, options = {}) {
285
- const opts = { toolMaxChars: 2000, toolNameById: {}, ...options };
302
+ const opts = { toolMaxChars: DEFAULT_TOOL_MAX_CHARS, toolNameById: {}, ...options };
286
303
  const parts = [];
287
304
  if (!content) return parts;
288
305
  if (typeof content === "string") {
@@ -310,7 +327,7 @@ function extractPartsFromContent(content, options = {}) {
310
327
 
311
328
  export function extractPartsFromPayload(payload, options = {}) {
312
329
  if (!payload || typeof payload !== "object") return [];
313
- const opts = { toolMaxChars: 2000, toolNameById: {}, ...options };
330
+ const opts = { toolMaxChars: DEFAULT_TOOL_MAX_CHARS, toolNameById: {}, ...options };
314
331
  if (payload.message && typeof payload.message === "object") {
315
332
  return extractPartsFromPayload(payload.message, opts);
316
333
  }
@@ -339,6 +356,53 @@ export function extractPartsFromPayload(payload, options = {}) {
339
356
  return parts;
340
357
  }
341
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
+
342
406
  export function extractCaptureTurns(rolloutEntries, cfg = {}) {
343
407
  const toolNameById = collectToolNamesByIdFromEntries(rolloutEntries);
344
408
  const turns = [];
@@ -356,10 +420,15 @@ export function extractCaptureTurns(rolloutEntries, cfg = {}) {
356
420
  toolMaxChars: cfg.captureToolMaxChars,
357
421
  toolNameById,
358
422
  });
359
- const decision = shouldCaptureText(rawText, role, cfg);
360
- 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;
361
430
  const text = decision.shouldCapture ? decision.text : "";
362
- turns.push({ role, text, parts });
431
+ turns.push({ role, text, parts: shaped.parts });
363
432
  }
364
433
  return turns;
365
434
  }
@@ -471,12 +540,21 @@ function isPunctuationOnly(text) {
471
540
  return !/[a-z0-9\u3400-\u9fff]/i.test(text);
472
541
  }
473
542
 
474
- export function shouldCaptureText(text, role, cfg = {}) {
543
+ export function shouldCaptureText(text, role, cfg = {}, { filters = true } = {}) {
475
544
  const maxLength = cfg.captureMaxLength || 24000;
476
545
  const sanitized = sanitizeCapturedText(text);
477
546
  if (!sanitized) return { shouldCapture: false, reason: "empty", text: "" };
478
547
 
479
- 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
+ }
480
558
  const compact = oneLine(capped);
481
559
  const isToolSummary = /^\[tool-(?:call|result)\b/i.test(compact);
482
560
 
@@ -30,6 +30,27 @@ function tryLoadJson(path) {
30
30
  }
31
31
  }
32
32
 
33
+ /**
34
+ * Build the User-Agent every harness plugin sends on OpenViking-bound requests.
35
+ * Shape is `name/semver` so downstream stats layers can parse it as one token.
36
+ */
37
+ export function buildUserAgent(harness, version) {
38
+ return `openviking-memory-${harness}/${str(version, "") || "0.0.0"}`;
39
+ }
40
+
41
+ /**
42
+ * Read a plugin manifest's `version` field. Accepts a path or a URL (so callers
43
+ * can resolve relative to import.meta.url). Returns "" when unreadable so the
44
+ * User-Agent falls back to 0.0.0 instead of throwing inside a short-lived hook.
45
+ */
46
+ export function readManifestVersion(manifest) {
47
+ try {
48
+ return str(JSON.parse(readFileSync(manifest, "utf-8")).version, "");
49
+ } catch {
50
+ return "";
51
+ }
52
+ }
53
+
33
54
  function looksLikeOvcli(obj) {
34
55
  if (!obj || typeof obj !== "object") return false;
35
56
  if (obj.server && typeof obj.server === "object") return false;
@@ -171,9 +192,20 @@ export function resolveOpenVikingCredentials(env = process.env) {
171
192
  const explicitMcpUrl = str(env.OPENVIKING_MCP_URL, "");
172
193
  const mcpUrl = (mode !== "cli" && explicitMcpUrl) ? explicitMcpUrl : `${baseUrl.replace(/\/+$/, "")}/mcp`;
173
194
 
195
+ // The file that actually supplied the api_key, following the same chain —
196
+ // empty when the key came from the environment or was never found.
197
+ let credentialPath = "";
198
+ if (apiKey) {
199
+ if (useCli) credentialPath = files.cliPath;
200
+ else if (str(env.OPENVIKING_BEARER_TOKEN, str(env.OPENVIKING_API_KEY, ""))) credentialPath = "";
201
+ else if (str(files.cliFile.api_key, "")) credentialPath = files.cliPath;
202
+ else credentialPath = files.ovPath;
203
+ }
204
+
174
205
  return {
175
206
  ...files,
176
207
  credentialSource: useCli ? "ovcli" : ((mode === "env" || envHasCredentials) ? "env" : "auto"),
208
+ credentialPath,
177
209
  baseUrl,
178
210
  mcpUrl,
179
211
  apiKey,