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