@openviking/opencode-plugin 0.2.1 → 0.2.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.
@@ -12,6 +12,9 @@ import {
12
12
  enqueue,
13
13
  replayPending,
14
14
  } from "./shared/pending-queue.mjs"
15
+ import {
16
+ sendSessionMessages,
17
+ } from "./shared/batch-send.mjs"
15
18
  import {
16
19
  log,
17
20
  effectivePeerId,
@@ -358,7 +361,7 @@ export function createMemorySessionManager({ config, pluginRoot }) {
358
361
  }
359
362
 
360
363
  async function flushPendingMessages(opencodeSessionId, state) {
361
- let added = 0
364
+ const toSend = []
362
365
  for (const [messageId, message] of state.messages.entries()) {
363
366
  if (message.captured) continue
364
367
  const body = buildCapturePayload(message)
@@ -366,40 +369,45 @@ export function createMemorySessionManager({ config, pluginRoot }) {
366
369
  message.captured = true
367
370
  continue
368
371
  }
369
- const ok = await addMessageToSession(state.ovSessionId, body)
370
- if (!ok) break
371
- message.captured = true
372
- added += 1
373
- }
374
- if (added > 0) {
375
- state.lastActivityAt = Date.now()
376
- debouncedSaveState()
372
+ toSend.push({ messageId, message, body })
377
373
  }
378
- return added
379
- }
374
+ if (toSend.length === 0) return 0
380
375
 
381
- async function addMessageToSession(ovSessionId, body) {
376
+ let added = 0
382
377
  const health = await fetchJSON(config, "/health", {}, { timeoutMs: 5000 })
383
378
  if (!health.ok) {
384
- await enqueue("addMessage", ovSessionId, body)
385
- return true
379
+ for (const item of toSend) {
380
+ const queued = await enqueue("addMessage", state.ovSessionId, item.body)
381
+ if (!queued.ok) break
382
+ item.message.captured = true
383
+ added += 1
384
+ }
385
+ } else {
386
+ const res = await sendSessionMessages(
387
+ (endpoint, init = {}, options = {}) => fetchJSON(config, endpoint, init, { timeoutMs: 10000, ...options }),
388
+ state.ovSessionId,
389
+ toSend.map((item) => item.body),
390
+ { enqueueOnRetryable: true },
391
+ )
392
+ added = res.sent + res.queued
393
+ for (const item of toSend.slice(0, added)) {
394
+ item.message.captured = true
395
+ }
396
+ if (res.failed > 0 || res.enqueueFailed > 0) {
397
+ log("ERROR", "message", "Failed to add message to OpenViking session", {
398
+ openviking_session: state.ovSessionId,
399
+ status: res.lastError?.status,
400
+ error: res.lastError,
401
+ failed: res.failed,
402
+ enqueueFailed: res.enqueueFailed,
403
+ })
404
+ }
386
405
  }
387
- const res = await fetchJSON(config, `/api/v1/sessions/${encodeURIComponent(ovSessionId)}/messages`, {
388
- method: "POST",
389
- body: JSON.stringify(body),
390
- }, { timeoutMs: 10000 })
391
- if (res.ok) return true
392
- if (isRetryableFailure(res)) {
393
- const queued = await enqueue("addMessage", ovSessionId, body)
394
- return Boolean(queued.ok)
406
+ if (added > 0) {
407
+ state.lastActivityAt = Date.now()
408
+ debouncedSaveState()
395
409
  }
396
- log("ERROR", "message", "Failed to add message to OpenViking session", {
397
- openviking_session: ovSessionId,
398
- role: body.role,
399
- status: res.status,
400
- error: res.error,
401
- })
402
- return false
410
+ return added
403
411
  }
404
412
 
405
413
  async function maybeCommitByThreshold(state) {
@@ -0,0 +1,78 @@
1
+ // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
+ /**
3
+ * Async write-path helper for hook-based memory plugins.
4
+ *
5
+ * When `cfg.writePathAsync` is true and we're not already the worker, we:
6
+ * 1. Drain the parent's stdin (the hook payload)
7
+ * 2. Emit the harness-specific approve/no-op response on stdout immediately
8
+ * 3. Spawn a detached clone of ourselves with env OV_HOOK_WORKER=1
9
+ * 4. Feed the drained payload to the worker's stdin, unref, and exit
10
+ *
11
+ * The worker re-enters the same script, maybeDetach returns false (because
12
+ * OV_HOOK_WORKER=1), and the hook's normal synchronous code path reads its
13
+ * own stdin and runs the OV HTTP work with nobody waiting.
14
+ *
15
+ * Call this BEFORE the hook reads stdin itself. When it returns true, the
16
+ * caller must `return` out of main() immediately.
17
+ */
18
+
19
+ import { spawn } from "node:child_process";
20
+
21
+ const WORKER_ENV = "OV_HOOK_WORKER";
22
+
23
+ export async function maybeDetach(cfg, { approve }) {
24
+ if (!cfg.writePathAsync) return false;
25
+ if (process.env[WORKER_ENV] === "1") return false;
26
+
27
+ // Drain parent stdin so we can forward to the worker.
28
+ let raw;
29
+ try {
30
+ const chunks = [];
31
+ for await (const chunk of process.stdin) chunks.push(chunk);
32
+ raw = Buffer.concat(chunks);
33
+ } catch {
34
+ // stdin read failed - let the synchronous path handle it.
35
+ return false;
36
+ }
37
+
38
+ let child;
39
+ try {
40
+ child = spawn(process.execPath, [process.argv[1]], {
41
+ detached: true,
42
+ stdio: ["pipe", "ignore", "ignore"],
43
+ env: { ...process.env, [WORKER_ENV]: "1" },
44
+ });
45
+ } catch {
46
+ // spawn failed - fall through to sync mode, but we've already drained
47
+ // stdin so hand the payload back via env var path below.
48
+ process.env.OPENVIKING_HOOK_STDIN_CACHE = raw.toString();
49
+ return false;
50
+ }
51
+
52
+ // Approve first, then write to the detached child. Ordering matters for
53
+ // harnesses that read stdout before waiting for the hook process to exit.
54
+ approve();
55
+
56
+ try {
57
+ child.stdin.write(raw);
58
+ child.stdin.end();
59
+ } catch { /* worker may have already exited; nothing useful to recover */ }
60
+ child.unref();
61
+ return true;
62
+ }
63
+
64
+ /**
65
+ * Fallback stdin reader used when the async path drained stdin but spawn
66
+ * failed; hooks should call this instead of reading stdin directly when
67
+ * possible so the sync fallback still works after a failed detach.
68
+ */
69
+ export async function readHookStdin() {
70
+ if (process.env.OPENVIKING_HOOK_STDIN_CACHE) {
71
+ const cached = process.env.OPENVIKING_HOOK_STDIN_CACHE;
72
+ delete process.env.OPENVIKING_HOOK_STDIN_CACHE;
73
+ return cached;
74
+ }
75
+ const chunks = [];
76
+ for await (const chunk of process.stdin) chunks.push(chunk);
77
+ return Buffer.concat(chunks).toString();
78
+ }
@@ -0,0 +1,122 @@
1
+ // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
+ import { enqueue } from "./pending-queue.mjs";
3
+
4
+ export const BATCH_LIMIT = 100;
5
+
6
+ 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;
10
+ }
11
+
12
+ function makeResult() {
13
+ return {
14
+ sent: 0,
15
+ queued: 0,
16
+ enqueueFailed: 0,
17
+ failed: 0,
18
+ retryable: false,
19
+ usedBatch: true,
20
+ lastError: null,
21
+ };
22
+ }
23
+
24
+ async function enqueueRemainder(sessionId, payloads, startIndex, result, retryable, lastError) {
25
+ result.retryable = retryable;
26
+ result.lastError = lastError ?? null;
27
+
28
+ if (!retryable) {
29
+ result.failed += Math.max(0, payloads.length - startIndex);
30
+ return result;
31
+ }
32
+
33
+ const baseCreatedAt = Date.now();
34
+ for (let i = startIndex; i < payloads.length; i++) {
35
+ const queued = await enqueue("addMessage", sessionId, payloads[i], {
36
+ createdAt: baseCreatedAt + (i - startIndex),
37
+ });
38
+ if (!queued.ok) {
39
+ // Stop at the first enqueue failure so queued entries stay a contiguous
40
+ // prefix: consumers mark the first sent+queued payloads as captured, so
41
+ // skipping a payload here and queueing a later one would silently drop it.
42
+ result.enqueueFailed += payloads.length - i;
43
+ return result;
44
+ }
45
+ result.queued++;
46
+ }
47
+ return result;
48
+ }
49
+
50
+ async function sendSerial(fetchJSON, sessionId, payloads, startIndex, opts, result) {
51
+ result.usedBatch = false;
52
+ const encodedSid = encodeURIComponent(sessionId);
53
+ for (let i = startIndex; i < payloads.length; i++) {
54
+ const res = await fetchJSON(`/api/v1/sessions/${encodedSid}/messages`, {
55
+ method: "POST",
56
+ body: JSON.stringify(payloads[i]),
57
+ });
58
+ if (res?.ok) {
59
+ result.sent++;
60
+ await opts.onSent?.(1);
61
+ continue;
62
+ }
63
+
64
+ const retryable = isRetryableSendFailure(res);
65
+ if (opts.enqueueOnRetryable) {
66
+ return enqueueRemainder(sessionId, payloads, i, result, retryable, res?.error ?? res);
67
+ }
68
+ result.retryable = retryable;
69
+ result.lastError = res?.error ?? res ?? null;
70
+ result.failed += payloads.length - i;
71
+ return result;
72
+ }
73
+ return result;
74
+ }
75
+
76
+ /**
77
+ * Send add-message payloads in server-sized batches with serial fallback.
78
+ *
79
+ * @param {Function} fetchJSON - (path, init) => { ok, status, result?, error? }
80
+ * @param {string} sessionId - OpenViking session id
81
+ * @param {Array<object>} payloads - sanitized add-message request bodies
82
+ * @param {object} opts
83
+ * @param {boolean} opts.enqueueOnRetryable - enqueue unsent payloads after a retryable failure
84
+ * @param {Function} opts.onSent - called with the number of messages durably sent after each success
85
+ * @returns {Promise<{sent:number,queued:number,enqueueFailed:number,failed:number,retryable:boolean,usedBatch:boolean,lastError:any}>}
86
+ */
87
+ export async function sendSessionMessages(fetchJSON, sessionId, payloads, opts = {}) {
88
+ const result = makeResult();
89
+ const messages = Array.isArray(payloads) ? payloads : [];
90
+ if (messages.length === 0) return result;
91
+
92
+ const encodedSid = encodeURIComponent(sessionId);
93
+ for (let start = 0; start < messages.length; start += BATCH_LIMIT) {
94
+ const chunk = messages.slice(start, start + BATCH_LIMIT);
95
+ const res = await fetchJSON(`/api/v1/sessions/${encodedSid}/messages/batch`, {
96
+ method: "POST",
97
+ body: JSON.stringify({ messages: chunk }),
98
+ });
99
+
100
+ if (res?.ok) {
101
+ result.sent += chunk.length;
102
+ await opts.onSent?.(chunk.length);
103
+ continue;
104
+ }
105
+
106
+ const status = Number(res?.status || 0);
107
+ if (status === 404 || status === 405) {
108
+ return sendSerial(fetchJSON, sessionId, messages, start, opts, result);
109
+ }
110
+
111
+ const retryable = isRetryableSendFailure(res);
112
+ if (opts.enqueueOnRetryable) {
113
+ return enqueueRemainder(sessionId, messages, start, result, retryable, res?.error ?? res);
114
+ }
115
+ result.retryable = retryable;
116
+ result.lastError = res?.error ?? res ?? null;
117
+ result.failed += messages.length - start;
118
+ return result;
119
+ }
120
+
121
+ return result;
122
+ }
@@ -201,11 +201,14 @@ export function createOpenVikingMcpProxy({
201
201
  return true;
202
202
  }
203
203
 
204
- function headersForRequest(includeSession = true, message = null) {
204
+ function headersForRequest(includeSession = true) {
205
205
  const headers = {
206
206
  "Content-Type": "application/json",
207
207
  "Accept": "application/json, text/event-stream",
208
- "MCP-Protocol-Version": message?.params?.protocolVersion || protocolVersion,
208
+ // Always the proxy's current version (default, then server-negotiated) —
209
+ // never the client's un-negotiated ask, which strict upstreams reject
210
+ // with HTTP 400 before initialize negotiation can run.
211
+ "MCP-Protocol-Version": protocolVersion,
209
212
  };
210
213
  if (includeSession && sessionId) headers["Mcp-Session-Id"] = sessionId;
211
214
  if (proxyConfig.apiKey) headers.Authorization = `Bearer ${proxyConfig.apiKey}`;
@@ -265,7 +268,7 @@ export function createOpenVikingMcpProxy({
265
268
  try {
266
269
  const res = await fetchImpl(proxyConfig.mcpUrl, {
267
270
  method: "POST",
268
- headers: headersForRequest(includeSession, message),
271
+ headers: headersForRequest(includeSession),
269
272
  body: JSON.stringify(message),
270
273
  signal: controller.signal,
271
274
  });
@@ -273,6 +276,10 @@ export function createOpenVikingMcpProxy({
273
276
  const messages = parseHttpBody(res.headers.get("content-type"), text);
274
277
  const nextSessionId = res.headers.get("mcp-session-id");
275
278
  if (nextSessionId) sessionId = nextSessionId;
279
+ if (message?.method === "initialize" && res.ok) {
280
+ const negotiated = messages.find((m) => typeof m?.result?.protocolVersion === "string")?.result.protocolVersion;
281
+ if (negotiated) protocolVersion = negotiated;
282
+ }
276
283
  if (!res.ok) {
277
284
  throw new HttpStatusError(res.status, res.statusText, text, messages);
278
285
  }
@@ -366,7 +373,7 @@ export function createOpenVikingMcpProxy({
366
373
  const expectsResponse = isRequest(message);
367
374
  if (message.method === "initialize") {
368
375
  initializeRequest = cloneMessage(message);
369
- protocolVersion = message.params?.protocolVersion || protocolVersion || DEFAULT_PROTOCOL_VERSION;
376
+ protocolVersion = DEFAULT_PROTOCOL_VERSION;
370
377
  sessionId = "";
371
378
  }
372
379
  if (message.method === "notifications/initialized") {
@@ -182,10 +182,12 @@ async function recoverStaleProcessing(dir) {
182
182
  * @param {string} type - "addMessage" or "commitSession"
183
183
  * @param {string} sessionId - OV session ID
184
184
  * @param {object} payload - the data that failed to send
185
+ * @param {object} options
186
+ * @param {number} options.createdAt - optional queue timestamp override
185
187
  */
186
- export async function enqueue(type, sessionId, payload) {
188
+ export async function enqueue(type, sessionId, payload, options = {}) {
187
189
  const dir = getPendingDir();
188
- const now = Date.now();
190
+ const now = Number.isFinite(options.createdAt) ? options.createdAt : Date.now();
189
191
  const dedupKey = makeDedupKey(type, sessionId, payload);
190
192
  const filename = pendingFilename(dedupKey, 0);
191
193
  const entry = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openviking/opencode-plugin",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "author": "@openviking",
5
5
  "description": "Unified OpenCode plugin for OpenViking repository retrieval and long-term memory",
6
6
  "type": "module",