@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.
package/lib/memory-session.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
379
|
-
}
|
|
374
|
+
if (toSend.length === 0) return 0
|
|
380
375
|
|
|
381
|
-
|
|
376
|
+
let added = 0
|
|
382
377
|
const health = await fetchJSON(config, "/health", {}, { timeoutMs: 5000 })
|
|
383
378
|
if (!health.ok) {
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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
|
|
204
|
+
function headersForRequest(includeSession = true) {
|
|
205
205
|
const headers = {
|
|
206
206
|
"Content-Type": "application/json",
|
|
207
207
|
"Accept": "application/json, text/event-stream",
|
|
208
|
-
|
|
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
|
|
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 =
|
|
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 = {
|