@openviking/opencode-plugin 0.2.3 → 2026.9.9-1

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.
@@ -1,4 +1,10 @@
1
1
  // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+
6
+ import { compressRecallContext } from "./recall-compress-core.mjs";
7
+
2
8
  const PREFERENCE_QUERY_RE = /prefer|preference|favorite|favourite|like|偏好|喜欢|爱好|更倾向/i;
3
9
  const TEMPORAL_QUERY_RE = /when|what time|date|day|month|year|yesterday|today|tomorrow|last|next|什么时候|何时|哪天|几月|几年|昨天|今天|明天/i;
4
10
  const QUERY_TOKEN_RE = /[a-z0-9一-龥]{2,}/gi;
@@ -8,9 +14,20 @@ const STOPWORDS = new Set([
8
14
  ]);
9
15
  const USER_RESERVED_DIRS = new Set(["memories", "skills"]);
10
16
  const SOURCES = [
11
- { type: "memory", uri: "viking://user/memories", bucket: "memories" },
12
- { type: "skill", uri: "viking://user/skills", bucket: "skills" },
17
+ { type: "memory", uri: "viking://~/memories", bucket: "memories" },
18
+ { type: "skill", uri: "viking://~/skills", bucket: "skills" },
13
19
  ];
20
+ const DEFAULT_CONTEXT_LIMIT = 10;
21
+ const DEFAULT_CONTEXT_MAX_TOKENS = 1600;
22
+ const DEFAULT_REWRITE_MAX_BULLETS = 6;
23
+ const CODING_QUOTA_WEIGHTS = {
24
+ events: 1,
25
+ entities: 2,
26
+ preferences: 1,
27
+ experiences: 1,
28
+ resources: 3,
29
+ skills: 2,
30
+ };
14
31
 
15
32
  let userSpaceCache = "";
16
33
 
@@ -18,16 +35,47 @@ export function estimateTokens(text) {
18
35
  return text ? Math.ceil(String(text).length / 4) : 0;
19
36
  }
20
37
 
38
+ function scaleQuotas(limit, weights) {
39
+ const slots = Math.max(1, Math.floor(Number(limit) || DEFAULT_CONTEXT_LIMIT));
40
+ const order = Object.keys(weights);
41
+ const quotas = Object.fromEntries(order.map((key) => [key, 0]));
42
+ if (slots < order.length) {
43
+ for (const key of order) quotas[key] = 1;
44
+ return quotas;
45
+ }
46
+
47
+ for (const key of order) quotas[key] = 1;
48
+ const totalWeight = Object.values(weights).reduce((sum, weight) => sum + weight, 0);
49
+ const ideals = Object.fromEntries(
50
+ order.map((key) => [key, slots * weights[key] / totalWeight]),
51
+ );
52
+ while (order.reduce((sum, key) => sum + quotas[key], 0) < slots) {
53
+ const key = order.reduce((best, candidate) => (
54
+ ideals[candidate] - quotas[candidate] > ideals[best] - quotas[best]
55
+ ? candidate
56
+ : best
57
+ ));
58
+ quotas[key] += 1;
59
+ }
60
+ return quotas;
61
+ }
62
+
63
+ function legacyMemoryQuotas(limit) {
64
+ return {
65
+ ...scaleQuotas(limit, { events: 10, entities: 10, preferences: 3 }),
66
+ experiences: 0,
67
+ };
68
+ }
69
+
70
+ function codingQuotas(limit) {
71
+ return scaleQuotas(limit, CODING_QUOTA_WEIGHTS);
72
+ }
73
+
21
74
  export function buildRecallEndpointBody(cfg = {}) {
22
- const limit = Math.max(Number(cfg.recallLimit || 0), 1);
75
+ const limit = Math.max(Number(cfg.recallLimit || DEFAULT_CONTEXT_LIMIT), 1);
23
76
  const body = {
24
77
  query: "",
25
- quotas: {
26
- events: limit,
27
- entities: limit,
28
- preferences: Math.max(1, Math.min(limit, 3)),
29
- experiences: 0,
30
- },
78
+ quotas: legacyMemoryQuotas(limit),
31
79
  max_chars: Math.max(Number(cfg.recallMaxContentChars || 0) * limit, 1000),
32
80
  min_score: Number.isFinite(Number(cfg.scoreThreshold)) ? Number(cfg.scoreThreshold) : 0.35,
33
81
  render: true,
@@ -36,6 +84,105 @@ export function buildRecallEndpointBody(cfg = {}) {
36
84
  return body;
37
85
  }
38
86
 
87
+ /**
88
+ * Body for the server-side context face. The plugin declares intent (coding
89
+ * purpose, budget, session) and leaves the mechanics — quota ratios, tier
90
+ * degradation, cross-turn dedup — to the server's defaults.
91
+ */
92
+ export function buildContextSearchBody(cfg = {}, options = {}) {
93
+ const rewriteMode = String(cfg.recallRewrite || "off").toLowerCase();
94
+ const limit = Math.max(1, Math.floor(Number(cfg.recallLimit || DEFAULT_CONTEXT_LIMIT)));
95
+ const maxTokens = Math.max(
96
+ 64,
97
+ Math.floor(Number(cfg.recallMaxTokens || DEFAULT_CONTEXT_MAX_TOKENS)),
98
+ );
99
+ const body = {
100
+ query: "",
101
+ mode: "context",
102
+ purpose: "coding",
103
+ score_threshold: Number.isFinite(Number(cfg.scoreThreshold)) ? Number(cfg.scoreThreshold) : 0.35,
104
+ };
105
+ const limitConfigured = cfg.recallLimitConfigured === true;
106
+ const maxTokensConfigured = cfg.recallMaxTokensConfigured === true;
107
+ if (limitConfigured) body.quotas = codingQuotas(limit);
108
+ if (maxTokensConfigured) body.max_tokens = maxTokens;
109
+ if (cfg.recallPeerScope === "actor") body.peer_scope = "actor";
110
+
111
+ const sessionId = String(options.sessionId || "").trim();
112
+ if (sessionId) {
113
+ body.session_id = sessionId;
114
+ const queryExpansionConfigured = cfg.recallQueryExpansionConfigured === true;
115
+ if (queryExpansionConfigured) {
116
+ body.query_expansion = cfg.recallQueryExpansion === "off" ? "off" : "auto";
117
+ }
118
+ const dedupTurns = Number(cfg.recallDedupTurns);
119
+ const resolvedDedupTurns = Number.isFinite(dedupTurns)
120
+ ? Math.max(0, Math.floor(dedupTurns))
121
+ : 5;
122
+ if (resolvedDedupTurns > 0) body.dedup_turns = resolvedDedupTurns;
123
+ }
124
+
125
+ const excludeUris = Array.isArray(options.excludeUris) ? options.excludeUris.slice(0, 200) : [];
126
+ if (excludeUris.length) body.exclude_uris = excludeUris;
127
+
128
+ if (rewriteMode === "server") body.rewrite = true;
129
+ else if (rewriteMode === "auto" && !options.localCompressorAvailable) body.rewrite = "auto";
130
+ const rewriteMaxBullets = Math.max(
131
+ 1,
132
+ Math.floor(Number(cfg.recallCompressMaxBullets || DEFAULT_REWRITE_MAX_BULLETS)),
133
+ );
134
+ const rewriteMaxBulletsConfigured = cfg.recallCompressMaxBulletsConfigured === true;
135
+ if (body.rewrite !== undefined && rewriteMaxBulletsConfigured) {
136
+ body.rewrite_max_bullets = rewriteMaxBullets;
137
+ }
138
+ return body;
139
+ }
140
+
141
+ // The server pipeline is serial and each optional stage has its own fuse. A
142
+ // request is aborted client-side unless its deadline covers every stage it
143
+ // asked for, and aborting discards the whole response rather than just the
144
+ // stage that ran long.
145
+ //
146
+ // session_id -> query expansion (retrieval.recall_intent_timeout_s, 5s)
147
+ // always -> retrieval, body reads, budget planning
148
+ // rewrite -> digest (retrieval.recall_rewrite_timeout_s, 30s)
149
+ //
150
+ // Both budgets stay inside the 60s prompt-hook allowance, the rewrite one with
151
+ // a quarter to spare.
152
+ const EXPANSION_REQUEST_TIMEOUT_MS = 15000;
153
+ const SERVER_REWRITE_REQUEST_TIMEOUT_MS = 45000;
154
+
155
+ /**
156
+ * HTTP deadline for one context request, or undefined to keep the caller's own.
157
+ *
158
+ * Derived from the request body, because the body is what states which server
159
+ * stages will run: reading `cfg` alone cannot tell a bare retrieval from one
160
+ * that also spends the expansion or rewrite fuse.
161
+ */
162
+ export function contextRequestTimeoutMs(cfg = {}, body = {}) {
163
+ const wantsRewrite = body.rewrite !== undefined;
164
+ // `query_expansion` defaults to "auto" server-side, so only an explicit "off"
165
+ // takes the expansion fuse back out of the budget.
166
+ const wantsExpansion = Boolean(body.session_id) && body.query_expansion !== "off";
167
+ const configured = Number(cfg.recallContextTimeoutMs);
168
+ if (Number.isFinite(configured) && configured > 0) return Math.max(1000, Math.floor(configured));
169
+ if (!wantsRewrite && !wantsExpansion) return undefined;
170
+ const floor = wantsRewrite ? SERVER_REWRITE_REQUEST_TIMEOUT_MS : EXPANSION_REQUEST_TIMEOUT_MS;
171
+ return Math.max(Number(cfg.timeoutMs) || 0, floor);
172
+ }
173
+
174
+ /**
175
+ * Strip the context-face fields a pre-context server rejects, converting the
176
+ * token budget back to v1's character budget.
177
+ */
178
+ export function downgradeToRecallBody(contextBody = {}, cfg = {}) {
179
+ const body = buildRecallEndpointBody(cfg);
180
+ body.query = contextBody.query || "";
181
+ body.max_chars = Math.max(1000, Math.floor(Number(contextBody.max_tokens || 1600) * 4));
182
+ if (contextBody.peer_scope) body.peer_scope = contextBody.peer_scope;
183
+ return body;
184
+ }
185
+
39
186
  function clampScore(v) {
40
187
  if (typeof v !== "number" || Number.isNaN(v)) return 0;
41
188
  return Math.max(0, Math.min(1, v));
@@ -124,6 +271,11 @@ async function resolveUserSpace(fetchJSON, actorPeerId = "") {
124
271
 
125
272
  async function resolveTargetUri(fetchJSON, targetUri, actorPeerId = "") {
126
273
  const trimmed = targetUri.trim().replace(/\/+$/, "");
274
+ // viking://~ is the home alias: the server expands it to the caller's own user
275
+ // space, so it needs no client-side rewrite.
276
+ if (trimmed === "viking://~" || trimmed.startsWith("viking://~/")) return trimmed;
277
+ // Legacy compat: uid-less viking://user/<reserved> URIs may still sit in plugin
278
+ // configs. Newer servers reject them, so rewrite to an explicit-uid URI here.
127
279
  const m = trimmed.match(/^viking:\/\/user(?:\/(.*))?$/);
128
280
  if (!m) return trimmed;
129
281
  const rawRest = (m[1] ?? "").trim();
@@ -232,6 +384,189 @@ async function buildFallbackInjectionBlock(fetchJSON, items, cfg, actorPeerId =
232
384
  return lines.join("\n");
233
385
  }
234
386
 
387
+ const LEGACY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
388
+
389
+ function stateFile(name) {
390
+ const override = String(process.env.OPENVIKING_STATE_DIR || "").trim();
391
+ return override ? join(override, name) : join(homedir(), ".openviking", "state", name);
392
+ }
393
+
394
+ async function readJsonFile(path) {
395
+ try { return JSON.parse(await readFile(path, "utf8")); } catch { return null; }
396
+ }
397
+
398
+ async function writeJsonFile(path, value) {
399
+ try {
400
+ await mkdir(dirname(path), { recursive: true });
401
+ const tmp = `${path}.tmp`;
402
+ await writeFile(tmp, JSON.stringify(value));
403
+ await rename(tmp, path);
404
+ } catch { /* best effort */ }
405
+ }
406
+
407
+ /**
408
+ * Hooks are one-shot processes, so "this server has no context face" has to be
409
+ * remembered on disk or every turn pays for a rejected request.
410
+ */
411
+ export async function isContextFaceLegacy(path = stateFile("context-face.json"), now = Date.now()) {
412
+ const cached = await readJsonFile(path);
413
+ return Boolean(cached?.legacyUntil && Number(cached.legacyUntil) > now);
414
+ }
415
+
416
+ export async function markContextFaceLegacy(path = stateFile("context-face.json"), now = Date.now()) {
417
+ await writeJsonFile(path, { legacyUntil: now + LEGACY_CACHE_TTL_MS });
418
+ }
419
+
420
+ /**
421
+ * A `peer_scope` the server rejects is remembered the same way, but unlike the
422
+ * context face this one is not a silent capability probe: dropping the field
423
+ * widens recall from the caller's own peer to the whole user root, so doctor
424
+ * reads this file back and warns.
425
+ */
426
+ export function peerScopeMemoPath() {
427
+ return stateFile("peer-scope.json");
428
+ }
429
+
430
+ export async function readPeerScopeDowngrade(path = peerScopeMemoPath(), now = Date.now()) {
431
+ const cached = await readJsonFile(path);
432
+ if (!cached?.legacyUntil || Number(cached.legacyUntil) <= now) return null;
433
+ return cached;
434
+ }
435
+
436
+ export async function markPeerScopeDowngrade(scope, status, path = peerScopeMemoPath(), now = Date.now()) {
437
+ await writeJsonFile(path, {
438
+ legacyUntil: now + LEGACY_CACHE_TTL_MS,
439
+ scope: String(scope || ""),
440
+ status: Number(status) || 0,
441
+ at: now,
442
+ });
443
+ }
444
+
445
+ function looksLikeUnknownField(res) {
446
+ const text = JSON.stringify(res?.error ?? res?.result ?? res?.detail ?? "").toLowerCase();
447
+ return text.includes("extra") || text.includes("mode") || text.includes("unexpected");
448
+ }
449
+
450
+ function wrapContext(body) {
451
+ return [
452
+ "<openviking-context>",
453
+ "Relevant memory from OpenViking. Use the search/read MCP tools to expand URIs.",
454
+ body,
455
+ "</openviking-context>",
456
+ ].join("\n");
457
+ }
458
+
459
+ /**
460
+ * Server-assembled context: the context face when the deployment has it, else
461
+ * the deprecated /recall preset. Returns the injection block, "" when there was
462
+ * nothing relevant, or null when no server-side path was usable at all.
463
+ */
464
+ export async function buildServerAssembledBlock(fetchJSON, cfg, query, options = {}) {
465
+ const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
466
+ const log = options.log || (() => {});
467
+
468
+ const block = await recallViaContextFace(fetchJSON, cfg, query, { ...options, actorPeerId }, log);
469
+ if (block !== null) return block;
470
+ return recallViaEndpoint(fetchJSON, cfg, query, actorPeerId, log);
471
+ }
472
+
473
+ /**
474
+ * Raw server-assembled context, or null when the deployment has no context face.
475
+ * Returns `{ rendered, entries, digest, stats }` — callers that need the entries
476
+ * (their own compression, their own envelope) use this instead of the block
477
+ * builders below.
478
+ */
479
+ export async function fetchAssembledContext(fetchJSON, cfg, query, options = {}) {
480
+ const actorPeerId = options.actorPeerId || "";
481
+ const log = options.log || (() => {});
482
+ if (await isContextFaceLegacy(options.legacyCachePath)) return null;
483
+
484
+ const body = buildContextSearchBody(cfg, options);
485
+ body.query = query;
486
+ const res = await fetchJSON("/api/v1/search/search", {
487
+ method: "POST",
488
+ body: JSON.stringify(body),
489
+ }, { actorPeerId, timeoutMs: contextRequestTimeoutMs(cfg, body) });
490
+
491
+ if (!res.ok) {
492
+ const status = res.status || 0;
493
+ if ((status === 400 || status === 422) && looksLikeUnknownField(res)) {
494
+ await markContextFaceLegacy(options.legacyCachePath);
495
+ log("recall_context_face_unsupported", { status });
496
+ } else {
497
+ log("recall_context_face_error", { status });
498
+ }
499
+ return null;
500
+ }
501
+
502
+ const result = res.result || {};
503
+ const stats = result.stats || {};
504
+ log("recall_context_assembled", {
505
+ entries: Array.isArray(result.entries) ? result.entries.length : 0,
506
+ usedTokens: stats.used_tokens || 0,
507
+ tiers: stats.tier_counts || {},
508
+ rewrite: stats.rewrite || "off",
509
+ });
510
+ return {
511
+ rendered: String(result.rendered || "").trim(),
512
+ entries: Array.isArray(result.entries) ? result.entries : [],
513
+ digest: String(result.digest || "").trim(),
514
+ stats,
515
+ };
516
+ }
517
+
518
+ /**
519
+ * Entry field compatibility: the context face returns `category`/`text`, while
520
+ * the deprecated /recall v1 shape used `type` plus `content`/`summary`.
521
+ */
522
+ export function normalizeContextEntry(entry = {}) {
523
+ return {
524
+ uri: String(entry.uri || "").trim(),
525
+ category: String(entry.category || entry.type || "memory").trim() || "memory",
526
+ detail: String(entry.detail || entry.mode || "").trim(),
527
+ score: Number(entry.score) || 0,
528
+ text: String(
529
+ entry.text || entry.content || entry.summary || entry.abstract || entry.uri || "",
530
+ ).trim(),
531
+ };
532
+ }
533
+
534
+ async function recallViaContextFace(fetchJSON, cfg, query, options, log) {
535
+ const assembled = await fetchAssembledContext(fetchJSON, cfg, query, { ...options, log });
536
+ if (assembled === null) return null;
537
+
538
+ const { rendered, entries } = assembled;
539
+ let digest = assembled.digest;
540
+ const mode = String(cfg.recallRewrite || "off").toLowerCase();
541
+ if (String(assembled.stats?.rewrite || "").toLowerCase() === "no_relevant") {
542
+ log("recall_server_compression", { status: "empty" });
543
+ return "";
544
+ }
545
+ const wantsLocal = mode === "client" || (mode === "auto" && !digest);
546
+ if (wantsLocal && rendered && typeof options.runCompressor === "function") {
547
+ try {
548
+ const compression = await compressRecallContext({
549
+ query,
550
+ rendered,
551
+ entries,
552
+ cfg,
553
+ runCompressor: options.runCompressor,
554
+ cachePath: options.digestCachePath || stateFile("recall-digest.json"),
555
+ now: Date.now(),
556
+ });
557
+ log("recall_local_compression", { status: compression.status });
558
+ if (compression.status === "ok") digest = compression.context;
559
+ if (compression.status === "empty") return "";
560
+ } catch (err) {
561
+ log("recall_local_compression_failed", { error: String(err?.message || err) });
562
+ }
563
+ }
564
+
565
+ const injected = digest || rendered;
566
+ if (!injected) return "";
567
+ return wrapContext(injected);
568
+ }
569
+
235
570
  async function recallViaEndpoint(fetchJSON, cfg, query, actorPeerId = "", log = () => {}) {
236
571
  const body = buildRecallEndpointBody(cfg);
237
572
  body.query = query;
@@ -242,18 +577,21 @@ async function recallViaEndpoint(fetchJSON, cfg, query, actorPeerId = "", log =
242
577
  }
243
578
  const rendered = String(res.result?.rendered || "").trim();
244
579
  if (!rendered) return "";
245
- return [
246
- "<openviking-context>",
247
- "Relevant memory from OpenViking. Use the recall/read MCP tools to expand URIs.",
248
- rendered,
249
- "</openviking-context>",
250
- ].join("\n");
580
+ return wrapContext(rendered);
251
581
  }
252
582
 
253
583
  export async function postRecall(fetchJSON, body, opts = {}) {
254
584
  const actorPeerId = opts.actorPeerId || "";
255
585
  const log = opts.log || (() => {});
586
+ const memoPath = opts.peerScopeMemoPath;
256
587
  const request = { ...body };
588
+
589
+ // A remembered downgrade is still a downgrade: recall runs wider than the
590
+ // caller asked for, so the memo doubles as the doctor's evidence.
591
+ if (request.peer_scope && await readPeerScopeDowngrade(memoPath)) {
592
+ delete request.peer_scope;
593
+ }
594
+
257
595
  const res = await fetchJSON("/api/v1/search/recall", {
258
596
  method: "POST",
259
597
  body: JSON.stringify(request),
@@ -261,9 +599,17 @@ export async function postRecall(fetchJSON, body, opts = {}) {
261
599
  if (!request.peer_scope || (res.status !== 400 && res.status !== 422)) {
262
600
  return res;
263
601
  }
602
+ // Only an unknown-field rejection means "this server predates peer_scope".
603
+ // Retrying every other 400/422 without it silently widens the search from
604
+ // the caller's own peer to the whole user root.
605
+ if (!looksLikeUnknownField(res)) {
606
+ log("recall_peer_scope_error", { status: res.status || 0 });
607
+ return res;
608
+ }
264
609
 
265
610
  const downgraded = { ...request };
266
611
  delete downgraded.peer_scope;
612
+ await markPeerScopeDowngrade(String(request.peer_scope), res.status || 0, memoPath);
267
613
  log("recall_peer_scope_downgrade", { status: res.status || 0 });
268
614
  return fetchJSON("/api/v1/search/recall", {
269
615
  method: "POST",
@@ -271,16 +617,46 @@ export async function postRecall(fetchJSON, body, opts = {}) {
271
617
  }, { actorPeerId });
272
618
  }
273
619
 
620
+ /**
621
+ * Recall, plus whatever is still filed under the peer this workspace used
622
+ * before the identity rule changed.
623
+ *
624
+ * Under the default `peer_scope: "all"` this costs nothing: the server's own
625
+ * sweep of `{user_root}/peers` already reaches the old peer's memories. Under
626
+ * `"actor"` that sweep is off by definition, so the old peer is asked for
627
+ * separately — as itself, which is both cheaper and wider than a bare
628
+ * cross-peer read (that would need the user id, and reaches memories only).
629
+ */
274
630
  export async function buildRecallBlock(fetchJSON, cfg, query, options = {}) {
631
+ const primary = await recallForPeer(fetchJSON, cfg, query, options);
632
+
633
+ const legacyPeerId = String(options.legacyPeerId || "").trim();
634
+ const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
635
+ if (cfg.recallPeerScope !== "actor" || !legacyPeerId || legacyPeerId === actorPeerId) return primary;
636
+
637
+ const log = options.log || (() => {});
638
+ const legacy = await recallForPeer(fetchJSON, cfg, query, { ...options, actorPeerId: legacyPeerId });
639
+ if (!legacy) return primary;
640
+ log("recall_legacy_peer_hit", { legacyPeerId });
641
+ return primary ? `${primary}\n${legacy}` : legacy;
642
+ }
643
+
644
+ async function recallForPeer(fetchJSON, cfg, query, options = {}) {
275
645
  const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
276
646
  const log = options.log || (() => {});
277
647
  const trimmed = String(query || "").trim();
278
648
  if (!trimmed) return null;
279
649
 
280
- const endpointBlock = await recallViaEndpoint(fetchJSON, cfg, trimmed, actorPeerId, log);
281
- if (endpointBlock !== null) return endpointBlock || null;
650
+ // Assembly happens server-side when the deployment offers the context face;
651
+ // older servers fall through to /recall, then to raw find.
652
+ const serverBlock = await buildServerAssembledBlock(fetchJSON, cfg, trimmed, {
653
+ ...options,
654
+ actorPeerId,
655
+ log,
656
+ });
657
+ if (serverBlock !== null) return serverBlock || null;
282
658
 
283
- const recallLimit = Math.max(1, Number(cfg.recallLimit || 6));
659
+ const recallLimit = Math.max(1, Number(cfg.recallLimit || DEFAULT_CONTEXT_LIMIT));
284
660
  const perSourceLimit = Math.max(recallLimit * 2, 8);
285
661
  const raw = await searchAllSources(fetchJSON, trimmed, perSourceLimit, actorPeerId, log);
286
662
  if (raw.length === 0) return null;
@@ -0,0 +1,9 @@
1
+ // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
+ export function isRetryableFailure(result) {
3
+ if (!result || result.ok) return false;
4
+ const status = Number(result.status || 0);
5
+ if (!status || status === 408 || status === 429 || status >= 500) {
6
+ return true;
7
+ }
8
+ return status === 409 && result.error?.details?.retryable === true;
9
+ }
@@ -39,14 +39,17 @@ export async function runSetupWizard({
39
39
  output = process.stdout,
40
40
  env = process.env,
41
41
  } = {}) {
42
- const { cliPath } = loadCredentialFiles(env);
43
- const current = readJsonSafe(cliPath);
42
+ // cliPath is empty until the file exists; first-run writes go to the candidate
43
+ // path (OPENVIKING_CLI_CONFIG_FILE, else ~/.openviking/ovcli.conf).
44
+ const { cliPath, cliPathCandidate } = loadCredentialFiles(env);
45
+ const targetPath = cliPath || cliPathCandidate;
46
+ const current = readJsonSafe(targetPath);
44
47
  const rl = createInterface({ input, output });
45
48
  const say = (line = "") => output.write(`${line}\n`);
46
49
 
47
50
  try {
48
51
  say("OpenViking memory plugin setup");
49
- say(`Config file: ${cliPath}`);
52
+ say(`Config file: ${targetPath}`);
50
53
  say("");
51
54
  say("Current values:");
52
55
  say(` url: ${current.url || "(not set)"}`);
@@ -89,22 +92,22 @@ export async function runSetupWizard({
89
92
  const confirm = (await rl.question("Write config? [Y/n] ")).trim().toLowerCase();
90
93
  if (confirm === "n" || confirm === "no") {
91
94
  say("Aborted; nothing written.");
92
- return { written: false, path: cliPath };
95
+ return { written: false, path: targetPath };
93
96
  }
94
97
 
95
- mkdirSync(dirname(cliPath), { recursive: true });
96
- if (existsSync(cliPath)) {
97
- copyFileSync(cliPath, `${cliPath}.bak.${Date.now()}`);
98
+ mkdirSync(dirname(targetPath), { recursive: true });
99
+ if (existsSync(targetPath)) {
100
+ copyFileSync(targetPath, `${targetPath}.bak.${Date.now()}`);
98
101
  }
99
- writeFileSync(cliPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
102
+ writeFileSync(targetPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
100
103
  try {
101
- chmodSync(cliPath, 0o600);
104
+ chmodSync(targetPath, 0o600);
102
105
  } catch {
103
106
  /* best effort on platforms without chmod semantics */
104
107
  }
105
- say(`Written: ${cliPath}`);
108
+ say(`Written: ${targetPath}`);
106
109
  say("The stdio MCP proxy and hooks pick this up on the next harness start.");
107
- return { written: true, path: cliPath };
110
+ return { written: true, path: targetPath };
108
111
  } finally {
109
112
  rl.close();
110
113
  }
@@ -14,30 +14,52 @@ export function normalizeToolName(value) {
14
14
  return String(value || "").trim().toLowerCase();
15
15
  }
16
16
 
17
- export function findVikingUri(args = {}, keys = DEFAULT_URI_KEYS) {
17
+ // Arguments that carry file CONTENT rather than a location. The sweep below
18
+ // looks past the known path keys so an unusual one (`paths`, a nested target)
19
+ // is still caught, but text a tool is asked to WRITE is not a path: a local
20
+ // `write` whose body merely mentions viking://user/default/ was denied, and no
21
+ // file was created. Skipped by name at any depth.
22
+ const DEFAULT_CONTENT_KEYS = [
23
+ "content",
24
+ "contents",
25
+ "text",
26
+ "body",
27
+ "old_string",
28
+ "oldString",
29
+ "new_string",
30
+ "newString",
31
+ "old_str",
32
+ "new_str",
33
+ "file_text",
34
+ "insert_line",
35
+ "replacement",
36
+ ];
37
+
38
+ export function findVikingUri(args = {}, keys = DEFAULT_URI_KEYS, contentKeys = DEFAULT_CONTENT_KEYS) {
18
39
  if (!args || typeof args !== "object") return null;
19
40
  for (const key of keys) {
20
41
  const uri = findVikingUriInValue(args[key]);
21
42
  if (uri) return uri;
22
43
  }
23
- return findVikingUriInValue(args);
44
+ return findVikingUriInValue(args, new Set(contentKeys));
24
45
  }
25
46
 
26
- export function findVikingUriInValue(value) {
47
+ export function findVikingUriInValue(value, skipKeys) {
27
48
  if (typeof value === "string") {
28
49
  const match = value.match(/\bviking:\/\/[^\s"'`<>)]*/i);
29
50
  return match?.[0] || null;
30
51
  }
31
52
  if (Array.isArray(value)) {
32
53
  for (const item of value) {
33
- const uri = findVikingUriInValue(item);
54
+ const uri = findVikingUriInValue(item, skipKeys);
34
55
  if (uri) return uri;
35
56
  }
36
57
  return null;
37
58
  }
38
59
  if (value && typeof value === "object") {
39
- for (const item of Object.values(value)) {
40
- const uri = findVikingUriInValue(item);
60
+ for (const [key, item] of Object.entries(value)) {
61
+ if (skipKeys?.has(key)) continue;
62
+ const uri = findVikingUriInValue(item, skipKeys);
41
63
  if (uri) return uri;
42
64
  }
43
65
  }