@openviking/dsh-memory-plugin 0.3.0 → 0.3.2
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 +75 -16
- package/config.mjs +6 -2
- package/index.mjs +15 -3
- package/package.json +6 -5
- package/runtime.mjs +108 -9
- package/servers/mcp-proxy.mjs +4 -0
- package/shared/capture-utils.mjs +69 -6
- package/shared/input-filters.mjs +219 -0
- package/shared/mcp-proxy-config.mjs +18 -8
- package/shared/mcp-proxy-core.mjs +12 -0
- package/shared/pending-queue.mjs +41 -5
- package/shared/recall-compress-core.mjs +17 -6
- package/shared/recall-core.mjs +65 -0
- package/shared/workspace-identity.mjs +334 -0
- package/shared/workspace-peer.mjs +126 -8
- package/skills/openviking-memory/SKILL.md +19 -0
- package/skills.mjs +1 -4
- package/shared/plugin-config.mjs +0 -90
- package/shared/setup-wizard.mjs +0 -114
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
|
|
2
|
+
/**
|
|
3
|
+
* Ordered, operator-configured regex filters for memory plugin input.
|
|
4
|
+
*
|
|
5
|
+
* Rules are sed-style strings, applied in order to a single piece of text:
|
|
6
|
+
* s/^\s*ultrathink\s+//i substitute (add `g` to replace every match)
|
|
7
|
+
* d|^\s*[/!]| drop the text when the pattern matches
|
|
8
|
+
* k/^\?ov\b/ keep the text only when the pattern matches
|
|
9
|
+
* user:d/^\s*\/clear\b/ apply to one role only
|
|
10
|
+
*
|
|
11
|
+
* A malformed rule becomes an entry in `errors` and is skipped rather than
|
|
12
|
+
* throwing, so a typo in a config file cannot take a hook down.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const OPS = new Set(["s", "d", "k"]);
|
|
16
|
+
const SCOPES = ["user", "assistant"];
|
|
17
|
+
const ALLOWED_FLAGS = "imsug";
|
|
18
|
+
|
|
19
|
+
export const INPUT_FILTER_KNOBS = [
|
|
20
|
+
{
|
|
21
|
+
key: "recallQueryFilters",
|
|
22
|
+
env: "OPENVIKING_RECALL_QUERY_FILTERS",
|
|
23
|
+
label: "recall query filters",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
key: "captureFilters",
|
|
27
|
+
env: "OPENVIKING_CAPTURE_FILTERS",
|
|
28
|
+
label: "capture filters",
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read one delimited field. `\` always consumes the next character, and only
|
|
34
|
+
* `\<delim>` is un-escaped, so `\d` / `\b` / `\x2c` reach RegExp verbatim.
|
|
35
|
+
* Returns the index of the closing delimiter, or -1 when it is missing.
|
|
36
|
+
*/
|
|
37
|
+
function scanField(source, start, delim) {
|
|
38
|
+
let out = "";
|
|
39
|
+
let i = start;
|
|
40
|
+
while (i < source.length) {
|
|
41
|
+
const c = source[i];
|
|
42
|
+
if (c === "\\") {
|
|
43
|
+
const next = source[i + 1];
|
|
44
|
+
if (next === undefined) {
|
|
45
|
+
out += "\\";
|
|
46
|
+
i += 1;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
out += next === delim ? next : "\\" + next;
|
|
50
|
+
i += 2;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (c === delim) return { value: out, end: i };
|
|
54
|
+
out += c;
|
|
55
|
+
i += 1;
|
|
56
|
+
}
|
|
57
|
+
return { value: out, end: -1 };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseInputFilterRule(source) {
|
|
61
|
+
if (typeof source !== "string") {
|
|
62
|
+
return { source: String(source ?? ""), error: "rule must be a string" };
|
|
63
|
+
}
|
|
64
|
+
const raw = source.trim();
|
|
65
|
+
if (!raw) return { source: raw, error: "rule is empty" };
|
|
66
|
+
|
|
67
|
+
let scope = "";
|
|
68
|
+
let rest = raw;
|
|
69
|
+
for (const candidate of SCOPES) {
|
|
70
|
+
if (rest.startsWith(`${candidate}:`)) {
|
|
71
|
+
scope = candidate;
|
|
72
|
+
rest = rest.slice(candidate.length + 1);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const op = rest[0] || "";
|
|
78
|
+
if (!OPS.has(op)) {
|
|
79
|
+
return {
|
|
80
|
+
source: raw,
|
|
81
|
+
error: `unknown operation "${op}" — expected s (substitute), d (drop) or k (keep)`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const delim = rest[1];
|
|
85
|
+
if (delim === undefined) {
|
|
86
|
+
return { source: raw, error: `missing delimiter after "${op}"` };
|
|
87
|
+
}
|
|
88
|
+
if (/[A-Za-z0-9\s\\]/.test(delim)) {
|
|
89
|
+
return {
|
|
90
|
+
source: raw,
|
|
91
|
+
error: `invalid delimiter ${JSON.stringify(delim)} — use punctuation such as / | # or :`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const first = scanField(rest, 2, delim);
|
|
96
|
+
const pattern = first.value;
|
|
97
|
+
let replacement = null;
|
|
98
|
+
let flagsRaw = "";
|
|
99
|
+
if (op === "s") {
|
|
100
|
+
if (first.end < 0) {
|
|
101
|
+
return { source: raw, error: `missing ${JSON.stringify(delim)} after the pattern` };
|
|
102
|
+
}
|
|
103
|
+
const second = scanField(rest, first.end + 1, delim);
|
|
104
|
+
if (second.end < 0) {
|
|
105
|
+
return {
|
|
106
|
+
source: raw,
|
|
107
|
+
error: `missing ${JSON.stringify(delim)} after the replacement — write s${delim}pattern${delim}replacement${delim}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
replacement = second.value;
|
|
111
|
+
flagsRaw = rest.slice(second.end + 1);
|
|
112
|
+
} else {
|
|
113
|
+
flagsRaw = first.end < 0 ? "" : rest.slice(first.end + 1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!pattern) return { source: raw, error: "pattern is empty" };
|
|
117
|
+
|
|
118
|
+
const flags = new Set();
|
|
119
|
+
for (const flag of flagsRaw) {
|
|
120
|
+
if (!ALLOWED_FLAGS.includes(flag)) {
|
|
121
|
+
return { source: raw, error: `unknown flag "${flag}" — allowed flags are i, m, s, u, g` };
|
|
122
|
+
}
|
|
123
|
+
if (flags.has(flag)) return { source: raw, error: `duplicate flag "${flag}"` };
|
|
124
|
+
flags.add(flag);
|
|
125
|
+
}
|
|
126
|
+
// d/k decide with .test(), which advances lastIndex on a global regex and
|
|
127
|
+
// would then alternate between calls. `g` means nothing there, so drop it.
|
|
128
|
+
if (op !== "s") flags.delete("g");
|
|
129
|
+
|
|
130
|
+
let re;
|
|
131
|
+
try {
|
|
132
|
+
re = new RegExp(pattern, [...flags].join(""));
|
|
133
|
+
} catch (err) {
|
|
134
|
+
// V8 already says "Invalid regular expression: <pattern>: <why>"; keep that
|
|
135
|
+
// detail without stuttering the prefix the doctors key off.
|
|
136
|
+
const message = err?.message || String(err);
|
|
137
|
+
return {
|
|
138
|
+
source: raw,
|
|
139
|
+
error: /^invalid regular expression/i.test(message)
|
|
140
|
+
? `invalid${message.slice("Invalid".length)}`
|
|
141
|
+
: `invalid regular expression: ${message}`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { op, scope, re, replacement, source: raw };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function compileInputFilters(rules) {
|
|
149
|
+
const compiled = [];
|
|
150
|
+
const errors = [];
|
|
151
|
+
const list = Array.isArray(rules) ? rules : [];
|
|
152
|
+
list.forEach((entry, index) => {
|
|
153
|
+
if (typeof entry !== "string") {
|
|
154
|
+
errors.push({ index, source: String(entry ?? ""), message: "rule must be a string" });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (!entry.trim()) return;
|
|
158
|
+
const parsed = parseInputFilterRule(entry);
|
|
159
|
+
if (parsed.error) errors.push({ index, source: parsed.source, message: parsed.error });
|
|
160
|
+
else compiled.push({ ...parsed, index });
|
|
161
|
+
});
|
|
162
|
+
return { rules: compiled, errors };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Run compiled rules over `text`.
|
|
167
|
+
*
|
|
168
|
+
* `substituteOnly` skips the d/k operations, so a caller that already took one
|
|
169
|
+
* drop decision for a turn can rewrite its individual pieces without taking a
|
|
170
|
+
* second, possibly contradictory one.
|
|
171
|
+
*/
|
|
172
|
+
export function applyInputFilters(text, compiled, { role = "", substituteOnly = false } = {}) {
|
|
173
|
+
const original = typeof text === "string" ? text : String(text ?? "");
|
|
174
|
+
let current = original;
|
|
175
|
+
for (const rule of Array.isArray(compiled) ? compiled : []) {
|
|
176
|
+
if (rule.scope && rule.scope !== role) continue;
|
|
177
|
+
if (rule.op === "s") {
|
|
178
|
+
current = current.replace(rule.re, rule.replacement);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (substituteOnly) continue;
|
|
182
|
+
const matched = rule.re.test(current);
|
|
183
|
+
if ((rule.op === "d" && matched) || (rule.op === "k" && !matched)) {
|
|
184
|
+
return { text: "", changed: false, dropped: true, ruleIndex: rule.index, op: rule.op };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const finalText = current.trim();
|
|
188
|
+
return {
|
|
189
|
+
text: finalText,
|
|
190
|
+
changed: finalText !== original,
|
|
191
|
+
dropped: false,
|
|
192
|
+
ruleIndex: -1,
|
|
193
|
+
op: "",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Doctor-facing summary of both filter knobs on a resolved plugin config. */
|
|
198
|
+
export function describeInputFilters(cfg = {}) {
|
|
199
|
+
return INPUT_FILTER_KNOBS.map(({ key, env, label }) => {
|
|
200
|
+
const configured = Array.isArray(cfg?.[key]) ? cfg[key] : [];
|
|
201
|
+
const compiled = compileInputFilters(configured);
|
|
202
|
+
const ops = { s: 0, d: 0, k: 0 };
|
|
203
|
+
for (const rule of compiled.rules) ops[rule.op] += 1;
|
|
204
|
+
const bits = [];
|
|
205
|
+
if (ops.s) bits.push(`${ops.s} substitute`);
|
|
206
|
+
if (ops.d) bits.push(`${ops.d} drop`);
|
|
207
|
+
if (ops.k) bits.push(`${ops.k} keep-only`);
|
|
208
|
+
const active = compiled.rules.length;
|
|
209
|
+
return {
|
|
210
|
+
key,
|
|
211
|
+
env,
|
|
212
|
+
label,
|
|
213
|
+
total: configured.length,
|
|
214
|
+
active,
|
|
215
|
+
summary: `${active} rule${active === 1 ? "" : "s"}${bits.length ? ` (${bits.join(", ")})` : ""}`,
|
|
216
|
+
errors: compiled.errors,
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
}
|
|
@@ -45,24 +45,34 @@ export function defaultCredentialPaths(env = process.env) {
|
|
|
45
45
|
* Resolve the actor peer header for a long-lived MCP proxy process.
|
|
46
46
|
*
|
|
47
47
|
* MCP servers may start in the plugin directory rather than the active
|
|
48
|
-
* workspace, so their process cwd is not a reliable peer identity
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
48
|
+
* workspace, so their process cwd is not a reliable peer identity — and unlike
|
|
49
|
+
* a hook, a long-lived proxy cannot re-derive one per turn. Broad recall
|
|
50
|
+
* intentionally spans the authenticated user's peer workspaces and therefore
|
|
51
|
+
* leaves the actor header unset. Actor-scoped recall needs an explicit peer so
|
|
52
|
+
* isolation never depends on the proxy's launch directory.
|
|
53
|
+
*
|
|
54
|
+
* A missing one degrades to broad recall with a warning rather than refusing to
|
|
55
|
+
* start: the proxy is what carries every memory tool, and taking those away
|
|
56
|
+
* because a scope preference cannot be honoured costs the user far more than
|
|
57
|
+
* the wider search does. Only the harness whose parent process injects the peer
|
|
58
|
+
* (dsh, `mcp.mjs:27`) can satisfy this without the user setting it.
|
|
52
59
|
*/
|
|
53
60
|
export function resolveMcpActorPeerId({
|
|
54
61
|
peerId = "",
|
|
55
62
|
recallPeerScope = "all",
|
|
63
|
+
onWarn = null,
|
|
56
64
|
} = {}) {
|
|
57
65
|
if (recallPeerScope !== "actor") return "";
|
|
58
66
|
|
|
59
67
|
const explicitPeerId = String(peerId || "").trim();
|
|
60
68
|
if (explicitPeerId) return explicitPeerId;
|
|
61
69
|
|
|
62
|
-
|
|
63
|
-
"
|
|
64
|
-
+ "Set actor_peer_id in ovcli.conf or OPENVIKING_PEER_ID in the MCP environment."
|
|
65
|
-
);
|
|
70
|
+
const message = "OpenViking MCP: actor-scoped recall needs an explicit peer id, which an MCP proxy cannot "
|
|
71
|
+
+ "derive from its launch directory. Falling back to broad recall across every peer under this user. "
|
|
72
|
+
+ "Set actor_peer_id in ovcli.conf, or OPENVIKING_PEER_ID in the MCP server environment, to scope it.";
|
|
73
|
+
if (typeof onWarn === "function") onWarn(message);
|
|
74
|
+
else process.stderr.write(`${message}\n`);
|
|
75
|
+
return "";
|
|
66
76
|
}
|
|
67
77
|
|
|
68
78
|
function uniq(values) {
|
|
@@ -292,6 +292,18 @@ export function createOpenVikingMcpProxy({
|
|
|
292
292
|
);
|
|
293
293
|
}
|
|
294
294
|
const msg = err instanceof Error ? err.message : String(err);
|
|
295
|
+
if (err && err.name === "AbortError") {
|
|
296
|
+
// Client-side timeout, not an outage: the server may be healthy and
|
|
297
|
+
// still computing (rerank-inclusive find/search can legitimately take
|
|
298
|
+
// longer than the default budget). Keep -32001 for genuine
|
|
299
|
+
// connection failures so the two are not misdiagnosed as each other.
|
|
300
|
+
return errorResponse(
|
|
301
|
+
id,
|
|
302
|
+
-32004,
|
|
303
|
+
`OpenViking MCP request timed out after ${proxyConfig.timeoutMs}ms (${proxyConfig.mcpUrl}). The server may still be processing (rerank can be slow) — check /health or raise OPENVIKING_TIMEOUT_MS.`,
|
|
304
|
+
{ timeoutMs: proxyConfig.timeoutMs, mcpUrl: proxyConfig.mcpUrl, cause: msg },
|
|
305
|
+
);
|
|
306
|
+
}
|
|
295
307
|
return errorResponse(
|
|
296
308
|
id,
|
|
297
309
|
-32001,
|
package/shared/pending-queue.mjs
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
*
|
|
5
5
|
* When the OpenViking server is temporarily unreachable, write operations
|
|
6
6
|
* (addMessage, commitSession) serialize their payloads to
|
|
7
|
-
* `~/.openviking/pending/` as JSON files.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* `~/.openviking/pending/` as JSON files. The queue is replayed in small
|
|
8
|
+
* batches, either at session-start (consuming retry budgets, with
|
|
9
|
+
* maxRetries/TTL) or by a long-running drainer that passes
|
|
10
|
+
* `consumeRetries: false` so transient failures stay retryable.
|
|
10
11
|
*
|
|
11
12
|
* Each file contains: { type, sessionId, payload, createdAt, retries, dedupKey }
|
|
12
13
|
*
|
|
@@ -274,6 +275,23 @@ export async function claimForReplay(filename) {
|
|
|
274
275
|
}
|
|
275
276
|
}
|
|
276
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Release a claimed file back to the queue without consuming a retry. The
|
|
280
|
+
* entry keeps its original filename, retry count, and createdAt position, so a
|
|
281
|
+
* later run retries it as if this attempt never happened.
|
|
282
|
+
*/
|
|
283
|
+
export async function releaseClaim(claimedFilename) {
|
|
284
|
+
if (!claimedFilename.endsWith(".processing")) return null;
|
|
285
|
+
const dir = getPendingDir();
|
|
286
|
+
const restored = pendingFromProcessingFilename(claimedFilename);
|
|
287
|
+
try {
|
|
288
|
+
await rename(join(dir, claimedFilename), join(dir, restored));
|
|
289
|
+
return restored;
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
277
295
|
/**
|
|
278
296
|
* Remove a pending entry after successful replay.
|
|
279
297
|
*/
|
|
@@ -350,9 +368,16 @@ export async function cleanStale() {
|
|
|
350
368
|
*
|
|
351
369
|
* @param {Function} fetchJSON - the configured fetchJSON from makeFetchJSON
|
|
352
370
|
* @param {Function} log - logger function
|
|
371
|
+
* @param {object} [options]
|
|
372
|
+
* @param {boolean} [options.consumeRetries=true] - when false, retryable
|
|
373
|
+
* failures release their claim instead of incrementing the retry count, so
|
|
374
|
+
* a background drainer can keep retrying a transient failure without
|
|
375
|
+
* burning the session-start retry budget. Exhausted and non-retryable
|
|
376
|
+
* entries are deleted exactly as in the default mode.
|
|
353
377
|
* @returns {{ replayed: number, failed: number, skipped: number, deferred: number }}
|
|
354
378
|
*/
|
|
355
|
-
export async function replayPending(fetchJSON, log) {
|
|
379
|
+
export async function replayPending(fetchJSON, log, options = {}) {
|
|
380
|
+
const consumeRetries = options.consumeRetries !== false;
|
|
356
381
|
const pending = await listPending();
|
|
357
382
|
|
|
358
383
|
if (pending.length === 0) {
|
|
@@ -427,7 +452,18 @@ export async function replayPending(fetchJSON, log) {
|
|
|
427
452
|
await dequeue(claimedFilename);
|
|
428
453
|
skipped++;
|
|
429
454
|
} else {
|
|
430
|
-
|
|
455
|
+
if (consumeRetries) {
|
|
456
|
+
await incrementRetry(claimedFilename, entry);
|
|
457
|
+
} else {
|
|
458
|
+
const released = await releaseClaim(claimedFilename);
|
|
459
|
+
log("pending-queue", {
|
|
460
|
+
action: released ? "replay-deferred" : "release-failed",
|
|
461
|
+
sessionId: entry.sessionId,
|
|
462
|
+
type: entry.type,
|
|
463
|
+
status: res?.result?.status || res?.status,
|
|
464
|
+
retries: entry.retries || 0,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
431
467
|
failed++;
|
|
432
468
|
if (entry.type === "addMessage") {
|
|
433
469
|
deferred += Math.max(0, pending.length - processed);
|
|
@@ -86,10 +86,16 @@ export function repairDigestUris(digest, validUris = []) {
|
|
|
86
86
|
lines.push(line);
|
|
87
87
|
continue;
|
|
88
88
|
}
|
|
89
|
+
let tokenized = line;
|
|
90
|
+
for (const uri of [...valid].sort((a, b) => b.length - a.length)) {
|
|
91
|
+
if (/\s/.test(uri)) tokenized = tokenized.split(uri).join(encodeURI(uri));
|
|
92
|
+
}
|
|
89
93
|
let dropped = false;
|
|
90
|
-
const repaired =
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
const repaired = tokenized.replace(/viking:\/\/[^\s<>"')\]]+/g, (uri) => {
|
|
95
|
+
let decoded = uri;
|
|
96
|
+
try { decoded = decodeURI(uri); } catch { /* keep the original candidate */ }
|
|
97
|
+
if (validSet.has(decoded)) return decoded;
|
|
98
|
+
const nearest = nearestUri(decoded, valid);
|
|
93
99
|
if (nearest) return nearest;
|
|
94
100
|
dropped = true;
|
|
95
101
|
return uri;
|
|
@@ -149,6 +155,7 @@ async function writeCache(path, value) {
|
|
|
149
155
|
export async function compressRecallContext({
|
|
150
156
|
query,
|
|
151
157
|
rendered,
|
|
158
|
+
shortContext = rendered,
|
|
152
159
|
entries = [],
|
|
153
160
|
cfg = {},
|
|
154
161
|
runCompressor,
|
|
@@ -158,7 +165,9 @@ export async function compressRecallContext({
|
|
|
158
165
|
const input = String(rendered || "").trim();
|
|
159
166
|
if (!input) return { status: COMPRESS_EMPTY, context: "" };
|
|
160
167
|
const minChars = Math.max(0, Number(cfg.recallCompressMinInputChars ?? 1500));
|
|
161
|
-
if (input.length < minChars)
|
|
168
|
+
if (input.length < minChars) {
|
|
169
|
+
return { status: COMPRESS_OK, context: String(shortContext ?? input).trim() };
|
|
170
|
+
}
|
|
162
171
|
|
|
163
172
|
const maxInputChars = Math.max(1000, Number(cfg.recallCompressMaxInputChars || 18000));
|
|
164
173
|
const maxBullets = Math.max(1, Number(cfg.recallCompressMaxBullets || 6));
|
|
@@ -171,7 +180,8 @@ export async function compressRecallContext({
|
|
|
171
180
|
});
|
|
172
181
|
const cached = await readCache(cachePath);
|
|
173
182
|
if (cached?.key === key && typeof cached.digest === "string") {
|
|
174
|
-
|
|
183
|
+
const digest = normalizeCompressedContext(cached.digest, 4000, maxBullets);
|
|
184
|
+
if (digest) return { status: COMPRESS_OK, context: digest };
|
|
175
185
|
}
|
|
176
186
|
|
|
177
187
|
const prompt = buildRecallCompressionPrompt({
|
|
@@ -185,9 +195,10 @@ export async function compressRecallContext({
|
|
|
185
195
|
if (!normalized) return { status: COMPRESS_EMPTY, context: "" };
|
|
186
196
|
|
|
187
197
|
const validUris = entries.map((entry) => entry?.uri).filter(Boolean);
|
|
188
|
-
const
|
|
198
|
+
const repaired = repairDigestUris(normalized, validUris.length
|
|
189
199
|
? validUris
|
|
190
200
|
: (input.match(/viking:\/\/[^\s<>"']+/g) || []));
|
|
201
|
+
const digest = normalizeCompressedContext(repaired, 4000, maxBullets);
|
|
191
202
|
if (!digest) return { status: COMPRESS_FAILED, context: "" };
|
|
192
203
|
|
|
193
204
|
await writeCache(cachePath, { key, digest, updatedAt: now || 0 });
|
package/shared/recall-core.mjs
CHANGED
|
@@ -417,6 +417,31 @@ export async function markContextFaceLegacy(path = stateFile("context-face.json"
|
|
|
417
417
|
await writeJsonFile(path, { legacyUntil: now + LEGACY_CACHE_TTL_MS });
|
|
418
418
|
}
|
|
419
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
|
+
|
|
420
445
|
function looksLikeUnknownField(res) {
|
|
421
446
|
const text = JSON.stringify(res?.error ?? res?.result ?? res?.detail ?? "").toLowerCase();
|
|
422
447
|
return text.includes("extra") || text.includes("mode") || text.includes("unexpected");
|
|
@@ -558,7 +583,15 @@ async function recallViaEndpoint(fetchJSON, cfg, query, actorPeerId = "", log =
|
|
|
558
583
|
export async function postRecall(fetchJSON, body, opts = {}) {
|
|
559
584
|
const actorPeerId = opts.actorPeerId || "";
|
|
560
585
|
const log = opts.log || (() => {});
|
|
586
|
+
const memoPath = opts.peerScopeMemoPath;
|
|
561
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
|
+
|
|
562
595
|
const res = await fetchJSON("/api/v1/search/recall", {
|
|
563
596
|
method: "POST",
|
|
564
597
|
body: JSON.stringify(request),
|
|
@@ -566,9 +599,17 @@ export async function postRecall(fetchJSON, body, opts = {}) {
|
|
|
566
599
|
if (!request.peer_scope || (res.status !== 400 && res.status !== 422)) {
|
|
567
600
|
return res;
|
|
568
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
|
+
}
|
|
569
609
|
|
|
570
610
|
const downgraded = { ...request };
|
|
571
611
|
delete downgraded.peer_scope;
|
|
612
|
+
await markPeerScopeDowngrade(String(request.peer_scope), res.status || 0, memoPath);
|
|
572
613
|
log("recall_peer_scope_downgrade", { status: res.status || 0 });
|
|
573
614
|
return fetchJSON("/api/v1/search/recall", {
|
|
574
615
|
method: "POST",
|
|
@@ -576,7 +617,31 @@ export async function postRecall(fetchJSON, body, opts = {}) {
|
|
|
576
617
|
}, { actorPeerId });
|
|
577
618
|
}
|
|
578
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
|
+
*/
|
|
579
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 = {}) {
|
|
580
645
|
const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
|
|
581
646
|
const log = options.log || (() => {});
|
|
582
647
|
const trimmed = String(query || "").trim();
|