@openviking/opencode-plugin 0.1.6 → 0.2.0
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/INSTALL-ZH.md +50 -49
- package/INSTALL.md +50 -49
- package/README.md +40 -123
- package/index.mjs +20 -12
- package/lib/config.mjs +272 -0
- package/lib/mcp-config.mjs +21 -0
- package/lib/memory-recall.mjs +21 -172
- package/lib/memory-session.mjs +301 -478
- package/lib/repo-context.mjs +5 -5
- package/lib/session-inject.mjs +71 -0
- package/lib/shared/capture-utils.mjs +500 -0
- package/lib/shared/credentials.mjs +207 -0
- package/lib/shared/debug-log.mjs +58 -0
- package/lib/shared/mcp-proxy-core.mjs +451 -0
- package/lib/shared/pending-queue.mjs +439 -0
- package/lib/shared/profile-inject.mjs +294 -0
- package/lib/shared/recall-core.mjs +282 -0
- package/lib/shared/session-model.mjs +57 -0
- package/lib/shared/setup-wizard.mjs +111 -0
- package/lib/shared/uri-guard.mjs +56 -0
- package/lib/utils.mjs +33 -116
- package/lib/viking-uri-guard.mjs +9 -24
- package/package.json +8 -7
- package/scripts/setup.mjs +8 -0
- package/servers/mcp-proxy.mjs +67 -0
- package/servers/mcp-proxy.test.mjs +100 -0
- package/lib/code-tools.mjs +0 -134
- package/lib/memadd-local.mjs +0 -118
- package/lib/memory-tools.mjs +0 -410
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
|
|
2
|
+
/**
|
|
3
|
+
* Local pending queue for offline resilience.
|
|
4
|
+
*
|
|
5
|
+
* When the OpenViking server is temporarily unreachable, write operations
|
|
6
|
+
* (addMessage, commitSession) serialize their payloads to
|
|
7
|
+
* `~/.openviking/pending/` as JSON files. On the next session-start, the
|
|
8
|
+
* queue is replayed in small batches. This is a session-start-triggered retry
|
|
9
|
+
* path with maxRetries/TTL, not a long-running background worker.
|
|
10
|
+
*
|
|
11
|
+
* Each file contains: { type, sessionId, payload, createdAt, retries, dedupKey }
|
|
12
|
+
*
|
|
13
|
+
* Config (env vars):
|
|
14
|
+
* OPENVIKING_PENDING_DIR pending queue directory
|
|
15
|
+
* (default: ~/.openviking/pending)
|
|
16
|
+
* OPENVIKING_PENDING_MAX_RETRIES max retry attempts per item (default: 3)
|
|
17
|
+
* OPENVIKING_PENDING_TTL_DAYS max age in days before stale cleanup
|
|
18
|
+
* (default: 7)
|
|
19
|
+
* OPENVIKING_PENDING_REPLAY_LIMIT max items replayed per session-start
|
|
20
|
+
* (default: 50)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { mkdir, readdir, readFile, rename, writeFile, unlink, stat, chmod } from "node:fs/promises";
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
|
|
28
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
29
|
+
const DEFAULT_TTL_DAYS = 7;
|
|
30
|
+
const DEFAULT_REPLAY_LIMIT = 50;
|
|
31
|
+
const PROCESSING_STALE_MS = 10 * 60 * 1000;
|
|
32
|
+
const DEFAULT_PENDING_DIR = () => join(homedir(), ".openviking", "pending");
|
|
33
|
+
|
|
34
|
+
// Pending queue files may contain raw memory payload / transcript content.
|
|
35
|
+
// Use restrictive permissions explicitly so we don't depend on umask.
|
|
36
|
+
// - dir: 0o700 (owner: rwx; group/other: none)
|
|
37
|
+
// - file: 0o600 (owner: rw; group/other: none)
|
|
38
|
+
const PENDING_DIR_MODE = 0o700;
|
|
39
|
+
const PENDING_FILE_MODE = 0o600;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Ensure the pending directory exists with 0o700 permissions. If the
|
|
43
|
+
* directory was created by a previous version of this code (or by hand)
|
|
44
|
+
* with looser permissions, best-effort chmod it on first use.
|
|
45
|
+
*/
|
|
46
|
+
async function ensurePendingDir(dir) {
|
|
47
|
+
await mkdir(dir, { recursive: true, mode: PENDING_DIR_MODE });
|
|
48
|
+
try {
|
|
49
|
+
await chmod(dir, PENDING_DIR_MODE);
|
|
50
|
+
} catch {
|
|
51
|
+
// Best effort: chmod may fail on some platforms (e.g. Windows); not fatal.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getPendingDir() {
|
|
56
|
+
return process.env.OPENVIKING_PENDING_DIR || DEFAULT_PENDING_DIR();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getMaxRetries() {
|
|
60
|
+
const v = parseInt(process.env.OPENVIKING_PENDING_MAX_RETRIES || "", 10);
|
|
61
|
+
return Number.isFinite(v) && v >= 0 ? v : DEFAULT_MAX_RETRIES;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getTTLDays() {
|
|
65
|
+
const v = parseInt(process.env.OPENVIKING_PENDING_TTL_DAYS || "", 10);
|
|
66
|
+
return Number.isFinite(v) && v >= 0 ? v : DEFAULT_TTL_DAYS;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getReplayLimit() {
|
|
70
|
+
const v = parseInt(process.env.OPENVIKING_PENDING_REPLAY_LIMIT || "", 10);
|
|
71
|
+
return Number.isFinite(v) && v > 0 ? v : DEFAULT_REPLAY_LIMIT;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function stableStringify(value) {
|
|
75
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
76
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
77
|
+
const keys = Object.keys(value).sort();
|
|
78
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(",")}}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function makeDedupKey(type, sessionId, payload) {
|
|
82
|
+
return createHash("sha256")
|
|
83
|
+
.update(type)
|
|
84
|
+
.update("\n")
|
|
85
|
+
.update(sessionId)
|
|
86
|
+
.update("\n")
|
|
87
|
+
.update(stableStringify(payload))
|
|
88
|
+
.digest("hex");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function pendingFilename(dedupKey, retries = 0) {
|
|
92
|
+
return `${dedupKey}_${Math.max(0, Number(retries) || 0)}.json`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function retryFilename(filename, retries) {
|
|
96
|
+
const bare = filename.replace(/\.(json|processing)$/, "");
|
|
97
|
+
const nextBare = /_\d+$/.test(bare)
|
|
98
|
+
? bare.replace(/_\d+$/, `_${retries}`)
|
|
99
|
+
: `${bare}_${retries}`;
|
|
100
|
+
return `${nextBare}.json`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function processingFilename(filename) {
|
|
104
|
+
return filename.replace(/\.json$/, ".processing");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function pendingFromProcessingFilename(filename) {
|
|
108
|
+
return filename.replace(/\.processing$/, ".json");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isRetryableReplayFailure(res) {
|
|
112
|
+
if (!res || res.ok) return false;
|
|
113
|
+
const status = Number(res.status || 0);
|
|
114
|
+
return !status || status >= 500 || status === 408 || status === 429;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function readEntry(dir, filename) {
|
|
118
|
+
const raw = await readFile(join(dir, filename), "utf-8");
|
|
119
|
+
return JSON.parse(raw);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function findExistingByDedupKey(dir, dedupKey) {
|
|
123
|
+
let files;
|
|
124
|
+
try {
|
|
125
|
+
files = await readdir(dir);
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Fast path: filenames are `${dedupKey}_${retries}.json` (or
|
|
131
|
+
// .processing). `dedupKey` is a sha256 hex produced by makeDedupKey
|
|
132
|
+
// (no underscores), so `${dedupKey}_` is a unique prefix per key.
|
|
133
|
+
// Filter on prefix BEFORE opening the file to avoid readFile +
|
|
134
|
+
// JSON.parse on every entry in the directory on every enqueue call —
|
|
135
|
+
// the previous implementation was O(n²) in queue size. Worst case here
|
|
136
|
+
// is O(n) readdir entries, but only the (typically single) prefix
|
|
137
|
+
// match actually gets opened and parsed.
|
|
138
|
+
const prefix = `${dedupKey}_`;
|
|
139
|
+
|
|
140
|
+
for (const f of files) {
|
|
141
|
+
if (!f.startsWith(prefix)) continue;
|
|
142
|
+
if (!f.endsWith(".json") && !f.endsWith(".processing")) continue;
|
|
143
|
+
try {
|
|
144
|
+
const entry = await readEntry(dir, f);
|
|
145
|
+
if (entry?.dedupKey === dedupKey) return { filename: f, entry };
|
|
146
|
+
} catch {
|
|
147
|
+
// Corrupted file - ignore for dedup lookup.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function recoverStaleProcessing(dir) {
|
|
154
|
+
let files;
|
|
155
|
+
try {
|
|
156
|
+
files = await readdir(dir);
|
|
157
|
+
} catch {
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
let recovered = 0;
|
|
163
|
+
for (const f of files) {
|
|
164
|
+
if (!f.endsWith(".processing")) continue;
|
|
165
|
+
const from = join(dir, f);
|
|
166
|
+
try {
|
|
167
|
+
const s = await stat(from);
|
|
168
|
+
if (now - s.mtimeMs < PROCESSING_STALE_MS) continue;
|
|
169
|
+
const to = join(dir, pendingFromProcessingFilename(f));
|
|
170
|
+
await rename(from, to);
|
|
171
|
+
recovered++;
|
|
172
|
+
} catch {
|
|
173
|
+
// Best effort. A concurrent process may have already handled it.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return recovered;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Enqueue a failed operation to local disk.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} type - "addMessage" or "commitSession"
|
|
183
|
+
* @param {string} sessionId - OV session ID
|
|
184
|
+
* @param {object} payload - the data that failed to send
|
|
185
|
+
*/
|
|
186
|
+
export async function enqueue(type, sessionId, payload) {
|
|
187
|
+
const dir = getPendingDir();
|
|
188
|
+
const now = Date.now();
|
|
189
|
+
const dedupKey = makeDedupKey(type, sessionId, payload);
|
|
190
|
+
const filename = pendingFilename(dedupKey, 0);
|
|
191
|
+
const entry = {
|
|
192
|
+
type,
|
|
193
|
+
sessionId,
|
|
194
|
+
payload,
|
|
195
|
+
createdAt: now,
|
|
196
|
+
retries: 0,
|
|
197
|
+
dedupKey,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
await ensurePendingDir(dir);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
return { ok: false, error: err?.message || String(err), dedupKey };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const existing = await findExistingByDedupKey(dir, dedupKey);
|
|
207
|
+
if (existing) {
|
|
208
|
+
return { ok: true, path: existing.filename, deduped: true, dedupKey };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
await writeFile(join(dir, filename), JSON.stringify(entry), {
|
|
213
|
+
encoding: "utf-8",
|
|
214
|
+
flag: "wx",
|
|
215
|
+
mode: PENDING_FILE_MODE,
|
|
216
|
+
});
|
|
217
|
+
return { ok: true, path: filename, dedupKey };
|
|
218
|
+
} catch (err) {
|
|
219
|
+
if (err?.code !== "EEXIST") {
|
|
220
|
+
return { ok: false, error: err?.message || String(err), dedupKey };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const duplicate = await findExistingByDedupKey(dir, dedupKey);
|
|
225
|
+
if (duplicate) {
|
|
226
|
+
return { ok: true, path: duplicate.filename, deduped: true, dedupKey };
|
|
227
|
+
}
|
|
228
|
+
return { ok: false, error: `pending file exists but dedup entry was not readable: ${filename}`, dedupKey };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* List all pending queue entries.
|
|
233
|
+
* Returns array of { filename, entry } sorted by createdAt ascending.
|
|
234
|
+
*/
|
|
235
|
+
export async function listPending() {
|
|
236
|
+
const dir = getPendingDir();
|
|
237
|
+
let files;
|
|
238
|
+
try {
|
|
239
|
+
await recoverStaleProcessing(dir);
|
|
240
|
+
files = await readdir(dir);
|
|
241
|
+
} catch {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const entries = [];
|
|
246
|
+
for (const f of files) {
|
|
247
|
+
if (!f.endsWith(".json")) continue;
|
|
248
|
+
try {
|
|
249
|
+
const entry = await readEntry(dir, f);
|
|
250
|
+
entries.push({ filename: f, entry });
|
|
251
|
+
} catch {
|
|
252
|
+
// Corrupted file - skip.
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
entries.sort((a, b) => (a.entry.createdAt || 0) - (b.entry.createdAt || 0));
|
|
257
|
+
return entries;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Atomically claim a pending file for replay. Only the process that successfully
|
|
262
|
+
* renames the file may send the HTTP replay.
|
|
263
|
+
*/
|
|
264
|
+
export async function claimForReplay(filename) {
|
|
265
|
+
if (!filename.endsWith(".json")) return null;
|
|
266
|
+
const dir = getPendingDir();
|
|
267
|
+
const claimed = processingFilename(filename);
|
|
268
|
+
try {
|
|
269
|
+
await rename(join(dir, filename), join(dir, claimed));
|
|
270
|
+
return claimed;
|
|
271
|
+
} catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Remove a pending entry after successful replay.
|
|
278
|
+
*/
|
|
279
|
+
export async function dequeue(filename) {
|
|
280
|
+
const dir = getPendingDir();
|
|
281
|
+
try {
|
|
282
|
+
await unlink(join(dir, filename));
|
|
283
|
+
return true;
|
|
284
|
+
} catch {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Increment retry count on a pending entry. Returns false if max retries exceeded.
|
|
291
|
+
*/
|
|
292
|
+
export async function incrementRetry(filename, entry) {
|
|
293
|
+
const dir = getPendingDir();
|
|
294
|
+
const maxRetries = getMaxRetries();
|
|
295
|
+
entry.retries = (entry.retries || 0) + 1;
|
|
296
|
+
|
|
297
|
+
if (entry.retries > maxRetries) {
|
|
298
|
+
try {
|
|
299
|
+
await unlink(join(dir, filename));
|
|
300
|
+
} catch {
|
|
301
|
+
// Best effort.
|
|
302
|
+
}
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const newFilename = retryFilename(filename, entry.retries);
|
|
307
|
+
// Atomic write: write to a unique temp file in the same directory, then
|
|
308
|
+
// rename into place. Avoids leaving a half-written JSON if the process
|
|
309
|
+
// crashes mid-write.
|
|
310
|
+
const tmpFilename = `${newFilename}.tmp.${process.pid}.${Date.now()}`;
|
|
311
|
+
try {
|
|
312
|
+
await writeFile(join(dir, tmpFilename), JSON.stringify(entry), {
|
|
313
|
+
encoding: "utf-8",
|
|
314
|
+
flag: "wx",
|
|
315
|
+
mode: PENDING_FILE_MODE,
|
|
316
|
+
});
|
|
317
|
+
await rename(join(dir, tmpFilename), join(dir, newFilename));
|
|
318
|
+
await unlink(join(dir, filename)).catch(() => {});
|
|
319
|
+
return true;
|
|
320
|
+
} catch {
|
|
321
|
+
await unlink(join(dir, tmpFilename)).catch(() => {});
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Clean up stale entries older than TTL.
|
|
328
|
+
*/
|
|
329
|
+
export async function cleanStale() {
|
|
330
|
+
const ttlMs = getTTLDays() * 24 * 60 * 60 * 1000;
|
|
331
|
+
const now = Date.now();
|
|
332
|
+
const pending = await listPending();
|
|
333
|
+
let cleaned = 0;
|
|
334
|
+
|
|
335
|
+
for (const { filename, entry } of pending) {
|
|
336
|
+
const age = now - (entry.createdAt || 0);
|
|
337
|
+
if (age > ttlMs) {
|
|
338
|
+
await dequeue(filename);
|
|
339
|
+
cleaned++;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return cleaned;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Replay pending entries. Call this during session-start when the server is
|
|
347
|
+
* healthy. Each run processes at most OPENVIKING_PENDING_REPLAY_LIMIT items so
|
|
348
|
+
* a just-recovered server is not hit with an unbounded replay burst.
|
|
349
|
+
*
|
|
350
|
+
* @param {Function} fetchJSON - the configured fetchJSON from makeFetchJSON
|
|
351
|
+
* @param {Function} log - logger function
|
|
352
|
+
* @returns {{ replayed: number, failed: number, skipped: number, deferred: number }}
|
|
353
|
+
*/
|
|
354
|
+
export async function replayPending(fetchJSON, log) {
|
|
355
|
+
const pending = await listPending();
|
|
356
|
+
|
|
357
|
+
if (pending.length === 0) {
|
|
358
|
+
return { replayed: 0, failed: 0, skipped: 0, deferred: 0 };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const replayLimit = getReplayLimit();
|
|
362
|
+
log("pending-queue", { count: pending.length, replayLimit, action: "replay-start" });
|
|
363
|
+
|
|
364
|
+
let replayed = 0;
|
|
365
|
+
let failed = 0;
|
|
366
|
+
let skipped = 0;
|
|
367
|
+
let deferred = 0;
|
|
368
|
+
let processed = 0;
|
|
369
|
+
|
|
370
|
+
for (const { filename, entry } of pending) {
|
|
371
|
+
if (processed >= replayLimit) {
|
|
372
|
+
deferred++;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if ((entry.retries || 0) >= getMaxRetries()) {
|
|
377
|
+
await dequeue(filename);
|
|
378
|
+
skipped++;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const claimedFilename = await claimForReplay(filename);
|
|
383
|
+
if (!claimedFilename) {
|
|
384
|
+
skipped++;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
processed++;
|
|
388
|
+
|
|
389
|
+
let res;
|
|
390
|
+
try {
|
|
391
|
+
const encodedSid = encodeURIComponent(entry.sessionId);
|
|
392
|
+
if (entry.type === "addMessage") {
|
|
393
|
+
res = await fetchJSON(`/api/v1/sessions/${encodedSid}/messages`, {
|
|
394
|
+
method: "POST",
|
|
395
|
+
body: JSON.stringify(entry.payload),
|
|
396
|
+
});
|
|
397
|
+
} else if (entry.type === "commitSession") {
|
|
398
|
+
res = await fetchJSON(`/api/v1/sessions/${encodedSid}/commit`, {
|
|
399
|
+
method: "POST",
|
|
400
|
+
body: JSON.stringify(entry.payload || {}),
|
|
401
|
+
});
|
|
402
|
+
} else {
|
|
403
|
+
await dequeue(claimedFilename);
|
|
404
|
+
skipped++;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
} catch {
|
|
408
|
+
res = { ok: false };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (res?.ok) {
|
|
412
|
+
await dequeue(claimedFilename);
|
|
413
|
+
replayed++;
|
|
414
|
+
} else if (!isRetryableReplayFailure(res)) {
|
|
415
|
+
await dequeue(claimedFilename);
|
|
416
|
+
skipped++;
|
|
417
|
+
} else {
|
|
418
|
+
await incrementRetry(claimedFilename, entry);
|
|
419
|
+
failed++;
|
|
420
|
+
if (entry.type === "addMessage") {
|
|
421
|
+
deferred += Math.max(0, pending.length - processed);
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const cleaned = await cleanStale();
|
|
428
|
+
|
|
429
|
+
log("pending-queue", {
|
|
430
|
+
action: "replay-done",
|
|
431
|
+
replayed,
|
|
432
|
+
failed,
|
|
433
|
+
skipped,
|
|
434
|
+
deferred,
|
|
435
|
+
cleaned,
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
return { replayed, failed, skipped, deferred };
|
|
439
|
+
}
|