@openviking/dsh-memory-plugin 0.3.0 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,43 +19,52 @@
19
19
  * Resolution stays env → ovcli.conf plugin.<harness> → ovcli.conf plugin →
20
20
  * ov.conf harness section (legacy) → defaults.
21
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.
22
+ * Every harness resolves through `resolveSettings()` below, and assembles its
23
+ * whole configuration through `buildPluginConfig()`, so one `plugin` section
24
+ * configures all of them and `ov config switch` moves behaviour along with
25
+ * credentials. The knobs themselves are declared once in `config-schema.mjs`;
26
+ * this module decides which files supply them and what is derived from them.
28
27
  */
29
28
 
30
- import { readFileSync } from "node:fs";
31
29
  import { homedir } from "node:os";
32
- import { join, resolve as resolvePath } from "node:path";
30
+ import { join } from "node:path";
33
31
 
34
- const DEFAULT_OVCLI_CONF_PATH = join(homedir(), ".openviking", "ovcli.conf");
32
+ import { HARNESS_KEYS, KNOBS, harnessKey, resolveKnobs } from "./config-schema.mjs";
33
+ import {
34
+ buildUserAgent,
35
+ loadCredentialFiles,
36
+ readManifestVersion,
37
+ resolveConnection,
38
+ } from "./credentials.mjs";
39
+ import {
40
+ announcedOverrides,
41
+ loadWorkspaceLayers,
42
+ mergeConfigLayers,
43
+ normalizeWorkspaceConfig,
44
+ projectWorkspaceSettings,
45
+ } from "./workspace-config.mjs";
46
+ import { findWorkspaceRoot, resolveWorkspaceIdentity } from "./workspace-identity.mjs";
47
+ import { resolveEffectivePeerId, resolvePluginPeerId } from "./workspace-peer.mjs";
48
+ import { readEntry } from "./workspace-registry.mjs";
35
49
 
36
- export const HARNESS_KEYS = {
37
- claudeCode: "claude_code",
38
- codex: "codex",
39
- };
50
+ const OVCLI_LAYER = "ovcli.conf";
51
+ const OV_CONF_LAYER = "ov.conf";
40
52
 
41
- function tryLoadJson(path) {
42
- try {
43
- return JSON.parse(readFileSync(path, "utf-8"));
44
- } catch {
45
- return null;
46
- }
47
- }
53
+ export { HARNESS_KEYS, harnessKey };
48
54
 
49
55
  /**
50
56
  * Merge the shared plugin section with the harness-specific override.
51
57
  * Returns a flat settings object; unknown keys pass through untouched so a
52
58
  * harness can add its own knobs without touching this module.
59
+ *
60
+ * `cliFile` is ovcli.conf already parsed. Callers that have it hand it over so
61
+ * the file is read once per build and, more importantly, so the `plugin`
62
+ * section comes out of the same file the credential chain read — an
63
+ * `OPENVIKING_CONFIG_FILE` that is ovcli-shaped is ovcli.conf, and only
64
+ * `loadCredentialFiles` knows that.
53
65
  */
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);
66
+ export function loadPluginSettings(harness, env = process.env, options = {}) {
67
+ const file = options.cliFile || loadCredentialFiles(env).cliFile;
59
68
  const plugin = file && typeof file.plugin === "object" && file.plugin ? file.plugin : {};
60
69
 
61
70
  const shared = {};
@@ -63,11 +72,212 @@ export function loadPluginSettings(harness, env = process.env) {
63
72
  if (value && typeof value === "object" && !Array.isArray(value)) continue;
64
73
  shared[key] = value;
65
74
  }
66
- const scoped = harness && plugin[harness] && typeof plugin[harness] === "object"
67
- ? plugin[harness]
68
- : {};
75
+ // Both spellings of the harness name find the same override, so copying
76
+ // `claude-code` out of the installer is not a silent no-op.
77
+ const key = harnessKey(harness);
78
+ const scoped = {};
79
+ for (const candidate of [key, key.replace(/_/g, "-")]) {
80
+ const value = candidate && plugin[candidate];
81
+ if (value && typeof value === "object" && !Array.isArray(value)) Object.assign(scoped, value);
82
+ }
83
+
84
+ const settings = { ...shared, ...scoped };
85
+ const cwd = String(options.cwd || "").trim();
86
+ if (!cwd) return settings;
87
+ return { ...settings, ...resolveWorkspaceSettings(cwd, env, options).settings };
88
+ }
89
+
90
+ /**
91
+ * Every knob for one harness, resolved through the whole layer stack.
92
+ *
93
+ * `legacy` is that harness's section in ov.conf, which is where tuning used to
94
+ * live: it stays the lowest configured layer so an existing deployment keeps
95
+ * working, and everything above it comes from files a client may edit.
96
+ *
97
+ * Returns the settings, the names some layer actually supplied (what the
98
+ * `*Configured` flags report), and the raw ovcli.conf merge — a couple of
99
+ * callers still need to know a value came from ovcli.conf rather than ov.conf.
100
+ */
101
+ export function resolveSettings(harness, options = {}) {
102
+ const { env = process.env, cwd = "", legacy = {}, clientVersion = "", cliFile = null } = options;
103
+ const key = harnessKey(harness);
104
+ const plugin = loadPluginSettings(key, env, { cwd, clientVersion, cliFile });
105
+ const { settings, configured, sources } = resolveKnobs({
106
+ harness: key,
107
+ layers: [
108
+ { name: OV_CONF_LAYER, data: legacy },
109
+ { name: OVCLI_LAYER, data: plugin },
110
+ ],
111
+ env,
112
+ });
113
+ return { settings, configured, sources, plugin };
114
+ }
69
115
 
70
- return { ...shared, ...scoped };
116
+ /**
117
+ * The workspace layers for one cwd, flattened into harness knobs.
118
+ *
119
+ * Takes the cwd explicitly because a hook's `loadConfig()` runs at module top
120
+ * level, before the payload on stdin has said which directory the session is
121
+ * actually in — the caller resolves this again once it knows.
122
+ */
123
+ export function resolveWorkspaceSettings(cwd, env = process.env, { clientVersion = "" } = {}) {
124
+ const empty = { settings: {}, root: "", provenance: {}, warnings: [], announced: [] };
125
+ try {
126
+ const { root } = findWorkspaceRoot(cwd, env);
127
+ if (!root) return empty;
128
+
129
+ const { layers, warnings } = loadWorkspaceLayers(root, { clientVersion });
130
+ // The identity is what makes the registry's negative evidence work: without
131
+ // it a directory reused by a different repository inherits the old peer.
132
+ const identity = resolveWorkspaceIdentity({ cwd, env });
133
+ const registry = readEntry(root, { identity, env });
134
+ warnings.push(...registry.warnings);
135
+ if (registry.entry?.settings) layers.push({ layer: "registry", data: registry.entry.settings });
136
+ if (registry.entry?.peer) layers.push({ layer: "registry", data: { peer: registry.entry.peer } });
137
+ if (!layers.length) return { ...empty, root, warnings };
138
+
139
+ const { value, provenance } = mergeConfigLayers(layers, warnings);
140
+ normalizeWorkspaceConfig(value, warnings);
141
+ return {
142
+ settings: projectWorkspaceSettings(value),
143
+ root,
144
+ provenance,
145
+ warnings,
146
+ announced: announcedOverrides(provenance),
147
+ value,
148
+ };
149
+ } catch {
150
+ // A hook must never die over a config file. An unreadable layer is no layer.
151
+ return empty;
152
+ }
153
+ }
154
+
155
+ const SEND_ONLY_WHEN_CONFIGURED = KNOBS.filter((knob) => knob.sendOnlyWhenConfigured);
156
+
157
+ function str(value) {
158
+ return typeof value === "string" && value.trim() ? value.trim() : "";
159
+ }
160
+
161
+ /** The ov.conf block named after this harness, the lowest layer of every chain. */
162
+ function ovConfSection(ovFile, key) {
163
+ const section = key ? ovFile?.[key] : null;
164
+ return section && typeof section === "object" && !Array.isArray(section) ? section : {};
165
+ }
166
+
167
+ /**
168
+ * One harness's whole configuration: the files, the knobs, the peer, and the
169
+ * handful of fields derived from them.
170
+ *
171
+ * Every loader used to write this sequence out by hand and each wrote a
172
+ * slightly different subset, which is how ov.conf's `<harness>.authMode` came
173
+ * to apply on three harnesses only, and why two of the four knobs the server
174
+ * defaults for itself were reported as configured on two harnesses and nowhere
175
+ * else. What is left in a loader after this call is what only that harness
176
+ * knows.
177
+ *
178
+ * The connection — server, key, identity, auth mode — is not a knob: it comes
179
+ * from `resolveConnection`, the same call an MCP proxy's connection comes
180
+ * from, and the `plugin` keys and ov.conf block that name it are read there.
181
+ *
182
+ * `legacy` replaces the ov.conf block this harness would otherwise get for its
183
+ * knobs (dsh's host hands it one of its own); `hostInput` is what an embedding
184
+ * host named for this process, which is the more specific answer and outranks
185
+ * every file; `logFile` is the log basename this harness has always used, and
186
+ * a harness that never had a default log path does not get one invented for
187
+ * it; `rootKeyFallback` is for the harnesses whose chain predates the ovcli.conf
188
+ * pinning and has always ended at `server.root_api_key`.
189
+ */
190
+ export function buildPluginConfig(harness, {
191
+ cwd = "",
192
+ env = process.env,
193
+ legacy = null,
194
+ manifestUrl = "",
195
+ version = "",
196
+ hostInput = {},
197
+ logFile = "",
198
+ rootKeyFallback = false,
199
+ deriveEffectivePeer = false,
200
+ } = {}) {
201
+ const name = String(harness || "");
202
+ const key = harnessKey(name);
203
+ const workspaceCwd = str(cwd) || process.cwd();
204
+ const files = loadCredentialFiles(env);
205
+ // The version answers two questions: what the User-Agent reports, and whether
206
+ // a workspace file asking for a newer client gets to say so.
207
+ const clientVersion = str(version) || (manifestUrl ? readManifestVersion(manifestUrl) : "");
208
+ const { settings, configured, sources } = resolveSettings(key, {
209
+ env,
210
+ cwd: workspaceCwd,
211
+ legacy: legacy || ovConfSection(files.ovFile, key),
212
+ cliFile: files.cliFile,
213
+ clientVersion,
214
+ });
215
+ const connection = resolveConnection(key, { env, files, hostInput, rootKeyFallback });
216
+ const { baseUrl, account, user } = connection;
217
+ const timeoutMs = settings.timeoutMs;
218
+ const peerId = resolvePluginPeerId({
219
+ settings,
220
+ configured,
221
+ sources,
222
+ credentials: connection,
223
+ hostInput: hostInput?.peerId,
224
+ env,
225
+ });
226
+
227
+ const config = {
228
+ ...settings,
229
+ harness: name,
230
+ clientVersion,
231
+ userAgent: buildUserAgent(name, clientVersion),
232
+
233
+ baseUrl,
234
+ mcpUrl: connection.mcpUrl,
235
+ apiKey: connection.apiKey,
236
+ account,
237
+ user,
238
+ authMode: connection.authMode,
239
+ sendIdentityHeaders: connection.sendIdentityHeaders,
240
+
241
+ credentialSource: connection.credentialSource,
242
+ apiKeySource: connection.apiKeySource,
243
+ credentialPath: connection.credentialPath,
244
+ hasApiKey: connection.hasApiKey,
245
+ configPath: connection.cliPath || connection.ovPath || null,
246
+ cliConfigPath: connection.cliPath,
247
+ ovConfigPath: connection.ovPath,
248
+ cliPath: connection.cliPath,
249
+ ovPath: connection.ovPath,
250
+
251
+ peerId,
252
+ explicitPeerId: peerId,
253
+
254
+ // A write gets a longer budget than a read, and a digest has to finish
255
+ // inside the recall request it belongs to, so both fall back to a knob.
256
+ captureTimeoutMs: settings.captureTimeoutMs || Math.max(timeoutMs * 2, 30000),
257
+ recallCompressTimeoutMs: settings.recallCompressTimeoutMs
258
+ || Math.max(1000, settings.recallTimeoutMs - 10000),
259
+ debugLogPath: settings.debugLogPath
260
+ || (logFile ? join(homedir(), ".openviking", "logs", logFile) : ""),
261
+
262
+ // The spellings these fields carried before every harness shared a loader.
263
+ endpoint: baseUrl,
264
+ accountId: account,
265
+ userId: user,
266
+ requestTimeoutMs: timeoutMs,
267
+ };
268
+ for (const knob of SEND_ONLY_WHEN_CONFIGURED) {
269
+ config[`${knob.name}Configured`] = configured.has(knob.name);
270
+ }
271
+
272
+ // Only for the harnesses that resolve the peer once at load. The rest derive
273
+ // it per session, against the directory the payload names, and an MCP proxy
274
+ // must not derive one from wherever it happened to be launched.
275
+ if (deriveEffectivePeer) {
276
+ const effectivePeer = resolveEffectivePeerId({ cfg: config, cwd: workspaceCwd, env });
277
+ config.effectivePeer = effectivePeer;
278
+ config.legacyPeerId = effectivePeer.legacyPeerId;
279
+ }
280
+ return config;
71
281
  }
72
282
 
73
283
  const REWRITE_MODES = new Set(["off", "client", "server", "auto"]);
@@ -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 = line.replace(/viking:\/\/[^\s<>"')\]]+/g, (uri) => {
91
- if (validSet.has(uri)) return uri;
92
- const nearest = nearestUri(uri, valid);
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) return { status: COMPRESS_OK, context: input };
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
- return { status: COMPRESS_OK, context: cached.digest };
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 digest = repairDigestUris(normalized, validUris.length
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 });
@@ -31,6 +31,28 @@ const CODING_QUOTA_WEIGHTS = {
31
31
 
32
32
  let userSpaceCache = "";
33
33
 
34
+ /**
35
+ * Is recall on for this config?
36
+ *
37
+ * The switch has four spellings in the wild: a boolean `autoRecall`, opencode's
38
+ * `{ enabled }` object, dsh and pi's `syncTurns`, and the global `enabled`
39
+ * that turns the whole plugin off. Reading it here rather than in each loader
40
+ * is what keeps it from drifting a fifth time — and any spelling that says off
41
+ * wins, so a config that disables recall under an older name still disables it.
42
+ */
43
+ export function isRecallEnabled(cfg = {}) {
44
+ return isSwitchOn([cfg.enabled, cfg.autoRecall, cfg.recall, cfg.syncRecall]);
45
+ }
46
+
47
+ /** Any recognised spelling that says off wins; everything else means on. */
48
+ function isSwitchOn(values) {
49
+ for (const value of values) {
50
+ if (value === false) return false;
51
+ if (value && typeof value === "object" && !Array.isArray(value) && value.enabled === false) return false;
52
+ }
53
+ return true;
54
+ }
55
+
34
56
  export function estimateTokens(text) {
35
57
  return text ? Math.ceil(String(text).length / 4) : 0;
36
58
  }
@@ -338,9 +360,8 @@ async function resolveItemContent(fetchJSON, item, cfg, actorPeerId = "") {
338
360
  }
339
361
 
340
362
  async function buildFallbackInjectionBlock(fetchJSON, items, cfg, actorPeerId = "", log = () => {}) {
341
- if (items.length === 0) return null;
342
-
343
- let budgetRemaining = Math.max(200, Number(cfg.recallTokenBudget || 2000));
363
+ const budgetTotal = Math.max(200, Number(cfg.recallTokenBudget || 2000));
364
+ let budgetRemaining = budgetTotal;
344
365
  const lines = [
345
366
  "<openviking-context>",
346
367
  "Relevant context from OpenViking. Use the read MCP tool to expand URIs.",
@@ -373,15 +394,15 @@ async function buildFallbackInjectionBlock(fetchJSON, items, cfg, actorPeerId =
373
394
 
374
395
  lines.push("</openviking-context>");
375
396
 
376
- const budgetUsed = Math.max(200, Number(cfg.recallTokenBudget || 2000)) - budgetRemaining;
397
+ const budgetUsed = budgetTotal - budgetRemaining;
377
398
  log("recall_injection_built", {
378
399
  contentItems: contentCount,
379
400
  hintItems: hintCount,
380
401
  budgetUsed,
381
- budgetTotal: Math.max(200, Number(cfg.recallTokenBudget || 2000)),
402
+ budgetTotal,
382
403
  });
383
404
 
384
- return lines.join("\n");
405
+ return { block: lines.join("\n"), contentCount, hintCount, budgetUsed, stage: "ranked" };
385
406
  }
386
407
 
387
408
  const LEGACY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
@@ -417,6 +438,31 @@ export async function markContextFaceLegacy(path = stateFile("context-face.json"
417
438
  await writeJsonFile(path, { legacyUntil: now + LEGACY_CACHE_TTL_MS });
418
439
  }
419
440
 
441
+ /**
442
+ * A `peer_scope` the server rejects is remembered the same way, but unlike the
443
+ * context face this one is not a silent capability probe: dropping the field
444
+ * widens recall from the caller's own peer to the whole user root, so doctor
445
+ * reads this file back and warns.
446
+ */
447
+ export function peerScopeMemoPath() {
448
+ return stateFile("peer-scope.json");
449
+ }
450
+
451
+ export async function readPeerScopeDowngrade(path = peerScopeMemoPath(), now = Date.now()) {
452
+ const cached = await readJsonFile(path);
453
+ if (!cached?.legacyUntil || Number(cached.legacyUntil) <= now) return null;
454
+ return cached;
455
+ }
456
+
457
+ export async function markPeerScopeDowngrade(scope, status, path = peerScopeMemoPath(), now = Date.now()) {
458
+ await writeJsonFile(path, {
459
+ legacyUntil: now + LEGACY_CACHE_TTL_MS,
460
+ scope: String(scope || ""),
461
+ status: Number(status) || 0,
462
+ at: now,
463
+ });
464
+ }
465
+
420
466
  function looksLikeUnknownField(res) {
421
467
  const text = JSON.stringify(res?.error ?? res?.result ?? res?.detail ?? "").toLowerCase();
422
468
  return text.includes("extra") || text.includes("mode") || text.includes("unexpected");
@@ -558,7 +604,15 @@ async function recallViaEndpoint(fetchJSON, cfg, query, actorPeerId = "", log =
558
604
  export async function postRecall(fetchJSON, body, opts = {}) {
559
605
  const actorPeerId = opts.actorPeerId || "";
560
606
  const log = opts.log || (() => {});
607
+ const memoPath = opts.peerScopeMemoPath;
561
608
  const request = { ...body };
609
+
610
+ // A remembered downgrade is still a downgrade: recall runs wider than the
611
+ // caller asked for, so the memo doubles as the doctor's evidence.
612
+ if (request.peer_scope && await readPeerScopeDowngrade(memoPath)) {
613
+ delete request.peer_scope;
614
+ }
615
+
562
616
  const res = await fetchJSON("/api/v1/search/recall", {
563
617
  method: "POST",
564
618
  body: JSON.stringify(request),
@@ -566,9 +620,17 @@ export async function postRecall(fetchJSON, body, opts = {}) {
566
620
  if (!request.peer_scope || (res.status !== 400 && res.status !== 422)) {
567
621
  return res;
568
622
  }
623
+ // Only an unknown-field rejection means "this server predates peer_scope".
624
+ // Retrying every other 400/422 without it silently widens the search from
625
+ // the caller's own peer to the whole user root.
626
+ if (!looksLikeUnknownField(res)) {
627
+ log("recall_peer_scope_error", { status: res.status || 0 });
628
+ return res;
629
+ }
569
630
 
570
631
  const downgraded = { ...request };
571
632
  delete downgraded.peer_scope;
633
+ await markPeerScopeDowngrade(String(request.peer_scope), res.status || 0, memoPath);
572
634
  log("recall_peer_scope_downgrade", { status: res.status || 0 });
573
635
  return fetchJSON("/api/v1/search/recall", {
574
636
  method: "POST",
@@ -576,11 +638,56 @@ export async function postRecall(fetchJSON, body, opts = {}) {
576
638
  }, { actorPeerId });
577
639
  }
578
640
 
641
+ /**
642
+ * Recall, plus whatever is still filed under the peer this workspace used
643
+ * before the identity rule changed.
644
+ *
645
+ * Under the default `peer_scope: "all"` this costs nothing: the server's own
646
+ * sweep of `{user_root}/peers` already reaches the old peer's memories. Under
647
+ * `"actor"` that sweep is off by definition, so the old peer is asked for
648
+ * separately — as itself, which is both cheaper and wider than a bare
649
+ * cross-peer read (that would need the user id, and reaches memories only).
650
+ *
651
+ * `stage` names the path the block came from, which is what a host needs to
652
+ * tell "the server had nothing" (`no_results`) from "the score threshold
653
+ * dropped everything" (`filtered_out`) when the block is empty, and a
654
+ * `server_assembled` block from a `ranked` one when it is not.
655
+ */
656
+ export async function buildRecallBlockDetailed(fetchJSON, cfg, query, options = {}) {
657
+ const primary = await recallForPeer(fetchJSON, cfg, query, options);
658
+
659
+ const legacyPeerId = String(options.legacyPeerId || "").trim();
660
+ const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
661
+ if (cfg.recallPeerScope !== "actor" || !legacyPeerId || legacyPeerId === actorPeerId) return primary;
662
+
663
+ const log = options.log || (() => {});
664
+ const legacy = await recallForPeer(fetchJSON, cfg, query, { ...options, actorPeerId: legacyPeerId });
665
+ if (!legacy.block) return primary;
666
+ log("recall_legacy_peer_hit", { legacyPeerId });
667
+ return {
668
+ block: primary.block ? `${primary.block}\n${legacy.block}` : legacy.block,
669
+ contentCount: primary.contentCount + legacy.contentCount,
670
+ hintCount: primary.hintCount + legacy.hintCount,
671
+ budgetUsed: primary.budgetUsed + legacy.budgetUsed,
672
+ stage: primary.block ? primary.stage : legacy.stage,
673
+ };
674
+ }
675
+
676
+ /** The block alone, or null when nothing was injectable. */
579
677
  export async function buildRecallBlock(fetchJSON, cfg, query, options = {}) {
678
+ const { block } = await buildRecallBlockDetailed(fetchJSON, cfg, query, options);
679
+ return block || null;
680
+ }
681
+
682
+ function emptyRecall(stage) {
683
+ return { block: "", contentCount: 0, hintCount: 0, budgetUsed: 0, stage };
684
+ }
685
+
686
+ async function recallForPeer(fetchJSON, cfg, query, options = {}) {
580
687
  const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
581
688
  const log = options.log || (() => {});
582
689
  const trimmed = String(query || "").trim();
583
- if (!trimmed) return null;
690
+ if (!trimmed) return emptyRecall("no_results");
584
691
 
585
692
  // Assembly happens server-side when the deployment offers the context face;
586
693
  // older servers fall through to /recall, then to raw find.
@@ -589,12 +696,23 @@ export async function buildRecallBlock(fetchJSON, cfg, query, options = {}) {
589
696
  actorPeerId,
590
697
  log,
591
698
  });
592
- if (serverBlock !== null) return serverBlock || null;
699
+ if (serverBlock !== null) {
700
+ if (!serverBlock) return emptyRecall("no_results");
701
+ // The server rendered one budgeted unit, so nothing here degraded to a
702
+ // URI-only hint and the block's own estimate is what it cost.
703
+ return {
704
+ block: serverBlock,
705
+ contentCount: 1,
706
+ hintCount: 0,
707
+ budgetUsed: estimateTokens(serverBlock),
708
+ stage: "server_assembled",
709
+ };
710
+ }
593
711
 
594
712
  const recallLimit = Math.max(1, Number(cfg.recallLimit || DEFAULT_CONTEXT_LIMIT));
595
713
  const perSourceLimit = Math.max(recallLimit * 2, 8);
596
714
  const raw = await searchAllSources(fetchJSON, trimmed, perSourceLimit, actorPeerId, log);
597
- if (raw.length === 0) return null;
715
+ if (raw.length === 0) return emptyRecall("no_results");
598
716
 
599
717
  const profile = buildQueryProfile(trimmed);
600
718
  const scoreThreshold = Number.isFinite(Number(cfg.scoreThreshold)) ? Number(cfg.scoreThreshold) : 0.35;
@@ -608,6 +726,6 @@ export async function buildRecallBlock(fetchJSON, cfg, query, options = {}) {
608
726
  items: picked.map((it) => ({ type: it._sourceType, uri: it.uri, score: clampScore(it.score) })),
609
727
  });
610
728
 
611
- if (picked.length === 0) return null;
729
+ if (picked.length === 0) return emptyRecall("filtered_out");
612
730
  return buildFallbackInjectionBlock(fetchJSON, picked, cfg, actorPeerId, log);
613
731
  }