@openviking/opencode-plugin 0.2.2 → 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
+ }
@@ -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.2",
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",