Files
OpenViking/examples/claude-code-memory-plugin/scripts/shared/recall-core.mjs
T
zhengxiao.wu 992215bfbf feat(plugins): recall account-shared skills and flag skill entries
The server context face already mixes both skill roots into per-prompt
recall, but the plugins' last-resort ranked find only searched
viking://~/memories and viking://~/skills, so on servers without the
context face a shared skill in viking://agent/skills could never be
recalled. Add it as a third source, and name each skill hit by its
directory rather than the .abstract.md it was indexed through, matching
the context face and the session-start catalog.

When an injected recall block carries a skill (a type="skills" entry, or
a [skill] line from the fallback), its header gains one line telling the
agent to read the skill's SKILL.md before following it. Turns without a
skill are unchanged.

The openclaw plugin's vendored recall-core copy is regenerated with it.

Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28
2026-09-18 15:31:19 +08:00

745 lines
28 KiB
JavaScript
Generated

// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { compressRecallContext } from "./recall-compress-core.mjs";
const PREFERENCE_QUERY_RE = /prefer|preference|favorite|favourite|like|偏好|喜欢|爱好|更倾向/i;
const TEMPORAL_QUERY_RE = /when|what time|date|day|month|year|yesterday|today|tomorrow|last|next|什么时候|何时|哪天|几月|几年|昨天|今天|明天/i;
const QUERY_TOKEN_RE = /[a-z0-9一-龥]{2,}/gi;
const STOPWORDS = new Set([
"what", "when", "where", "which", "who", "whom", "whose", "why", "how", "did", "does",
"is", "are", "was", "were", "the", "and", "for", "with", "from", "that", "this", "your", "you",
]);
const USER_RESERVED_DIRS = new Set(["memories", "skills"]);
const SOURCES = [
{ type: "memory", uri: "viking://~/memories", bucket: "memories" },
{ type: "skill", uri: "viking://~/skills", bucket: "skills" },
{ type: "skill", uri: "viking://agent/skills", bucket: "skills" },
];
const SKILL_ENTRY_HINT =
"Skill entries are OpenViking skills: read SKILL.md under the entry's URI before following one.";
const DEFAULT_CONTEXT_LIMIT = 10;
const DEFAULT_CONTEXT_MAX_TOKENS = 1600;
const DEFAULT_REWRITE_MAX_BULLETS = 6;
const CODING_QUOTA_WEIGHTS = {
events: 1,
entities: 2,
preferences: 1,
experiences: 1,
resources: 3,
skills: 2,
};
let userSpaceCache = "";
/**
* Is recall on for this config?
*
* The switch has four spellings in the wild: a boolean `autoRecall`, opencode's
* `{ enabled }` object, dsh and pi's `syncTurns`, and the global `enabled`
* that turns the whole plugin off. Reading it here rather than in each loader
* is what keeps it from drifting a fifth time — and any spelling that says off
* wins, so a config that disables recall under an older name still disables it.
*/
export function isRecallEnabled(cfg = {}) {
return isSwitchOn([cfg.enabled, cfg.autoRecall, cfg.recall, cfg.syncRecall]);
}
/** Any recognised spelling that says off wins; everything else means on. */
function isSwitchOn(values) {
for (const value of values) {
if (value === false) return false;
if (value && typeof value === "object" && !Array.isArray(value) && value.enabled === false) return false;
}
return true;
}
export function estimateTokens(text) {
return text ? Math.ceil(String(text).length / 4) : 0;
}
function scaleQuotas(limit, weights) {
const slots = Math.max(1, Math.floor(Number(limit) || DEFAULT_CONTEXT_LIMIT));
const order = Object.keys(weights);
const quotas = Object.fromEntries(order.map((key) => [key, 0]));
if (slots < order.length) {
for (const key of order) quotas[key] = 1;
return quotas;
}
for (const key of order) quotas[key] = 1;
const totalWeight = Object.values(weights).reduce((sum, weight) => sum + weight, 0);
const ideals = Object.fromEntries(
order.map((key) => [key, slots * weights[key] / totalWeight]),
);
while (order.reduce((sum, key) => sum + quotas[key], 0) < slots) {
const key = order.reduce((best, candidate) => (
ideals[candidate] - quotas[candidate] > ideals[best] - quotas[best]
? candidate
: best
));
quotas[key] += 1;
}
return quotas;
}
function legacyMemoryQuotas(limit) {
return {
...scaleQuotas(limit, { events: 10, entities: 10, preferences: 3 }),
experiences: 0,
};
}
function codingQuotas(limit) {
return scaleQuotas(limit, CODING_QUOTA_WEIGHTS);
}
export function buildRecallEndpointBody(cfg = {}) {
const limit = Math.max(Number(cfg.recallLimit || DEFAULT_CONTEXT_LIMIT), 1);
const body = {
query: "",
quotas: legacyMemoryQuotas(limit),
max_chars: Math.max(Number(cfg.recallMaxContentChars || 0) * limit, 1000),
min_score: Number.isFinite(Number(cfg.scoreThreshold)) ? Number(cfg.scoreThreshold) : 0.35,
render: true,
};
if (cfg.recallPeerScope === "actor") body.peer_scope = "actor";
return body;
}
/**
* Body for the server-side context face. The plugin declares intent (coding
* purpose, budget, session) and leaves the mechanics — quota ratios, tier
* degradation, cross-turn dedup — to the server's defaults.
*/
export function buildContextSearchBody(cfg = {}, options = {}) {
const rewriteMode = String(cfg.recallRewrite || "off").toLowerCase();
const limit = Math.max(1, Math.floor(Number(cfg.recallLimit || DEFAULT_CONTEXT_LIMIT)));
const maxTokens = Math.max(
64,
Math.floor(Number(cfg.recallMaxTokens || DEFAULT_CONTEXT_MAX_TOKENS)),
);
const body = {
query: "",
mode: "context",
purpose: "coding",
score_threshold: Number.isFinite(Number(cfg.scoreThreshold)) ? Number(cfg.scoreThreshold) : 0.35,
};
const limitConfigured = cfg.recallLimitConfigured === true;
const maxTokensConfigured = cfg.recallMaxTokensConfigured === true;
if (limitConfigured) body.quotas = codingQuotas(limit);
if (maxTokensConfigured) body.max_tokens = maxTokens;
if (cfg.recallPeerScope === "actor") body.peer_scope = "actor";
const sessionId = String(options.sessionId || "").trim();
if (sessionId) {
body.session_id = sessionId;
const queryExpansionConfigured = cfg.recallQueryExpansionConfigured === true;
if (queryExpansionConfigured) {
body.query_expansion = cfg.recallQueryExpansion === "off" ? "off" : "auto";
}
const dedupTurns = Number(cfg.recallDedupTurns);
const resolvedDedupTurns = Number.isFinite(dedupTurns)
? Math.max(0, Math.floor(dedupTurns))
: 5;
if (resolvedDedupTurns > 0) body.dedup_turns = resolvedDedupTurns;
}
const excludeUris = Array.isArray(options.excludeUris) ? options.excludeUris.slice(0, 200) : [];
if (excludeUris.length) body.exclude_uris = excludeUris;
if (rewriteMode === "server") body.rewrite = true;
else if (rewriteMode === "auto" && !options.localCompressorAvailable) body.rewrite = "auto";
const rewriteMaxBullets = Math.max(
1,
Math.floor(Number(cfg.recallCompressMaxBullets || DEFAULT_REWRITE_MAX_BULLETS)),
);
const rewriteMaxBulletsConfigured = cfg.recallCompressMaxBulletsConfigured === true;
if (body.rewrite !== undefined && rewriteMaxBulletsConfigured) {
body.rewrite_max_bullets = rewriteMaxBullets;
}
return body;
}
// The server pipeline is serial and each optional stage has its own fuse. A
// request is aborted client-side unless its deadline covers every stage it
// asked for, and aborting discards the whole response rather than just the
// stage that ran long.
//
// session_id -> query expansion (retrieval.recall_intent_timeout_s, 5s)
// always -> retrieval, body reads, budget planning
// rewrite -> digest (retrieval.recall_rewrite_timeout_s, 30s)
//
// Both budgets stay inside the 60s prompt-hook allowance, the rewrite one with
// a quarter to spare.
const EXPANSION_REQUEST_TIMEOUT_MS = 15000;
const SERVER_REWRITE_REQUEST_TIMEOUT_MS = 45000;
/**
* HTTP deadline for one context request, or undefined to keep the caller's own.
*
* Derived from the request body, because the body is what states which server
* stages will run: reading `cfg` alone cannot tell a bare retrieval from one
* that also spends the expansion or rewrite fuse.
*/
export function contextRequestTimeoutMs(cfg = {}, body = {}) {
const wantsRewrite = body.rewrite !== undefined;
// `query_expansion` defaults to "auto" server-side, so only an explicit "off"
// takes the expansion fuse back out of the budget.
const wantsExpansion = Boolean(body.session_id) && body.query_expansion !== "off";
const configured = Number(cfg.recallContextTimeoutMs);
if (Number.isFinite(configured) && configured > 0) return Math.max(1000, Math.floor(configured));
if (!wantsRewrite && !wantsExpansion) return undefined;
const floor = wantsRewrite ? SERVER_REWRITE_REQUEST_TIMEOUT_MS : EXPANSION_REQUEST_TIMEOUT_MS;
return Math.max(Number(cfg.timeoutMs) || 0, floor);
}
/**
* Strip the context-face fields a pre-context server rejects, converting the
* token budget back to v1's character budget.
*/
export function downgradeToRecallBody(contextBody = {}, cfg = {}) {
const body = buildRecallEndpointBody(cfg);
body.query = contextBody.query || "";
body.max_chars = Math.max(1000, Math.floor(Number(contextBody.max_tokens || 1600) * 4));
if (contextBody.peer_scope) body.peer_scope = contextBody.peer_scope;
return body;
}
function clampScore(v) {
if (typeof v !== "number" || Number.isNaN(v)) return 0;
return Math.max(0, Math.min(1, v));
}
function buildQueryProfile(query) {
const text = query.trim();
const allTokens = text.toLowerCase().match(QUERY_TOKEN_RE) || [];
return {
tokens: allTokens.filter((t) => !STOPWORDS.has(t)),
wantsPreference: PREFERENCE_QUERY_RE.test(text),
wantsTemporal: TEMPORAL_QUERY_RE.test(text),
};
}
function lexicalOverlapBoost(tokens, text) {
if (tokens.length === 0 || !text) return 0;
const haystack = ` ${text.toLowerCase()} `;
let matched = 0;
for (const token of tokens.slice(0, 8)) {
if (haystack.includes(token)) matched += 1;
}
return Math.min(0.2, (matched / Math.min(tokens.length, 4)) * 0.2);
}
function rankItem(item, profile) {
const base = clampScore(item.score);
const abstract = (item.abstract || item.overview || "").trim();
const cat = (item.category || "").toLowerCase();
const uri = (item.uri || "").toLowerCase();
const leafBoost = (item.level === 2 || uri.endsWith(".md")) ? 0.12 : 0;
const eventBoost = profile.wantsTemporal && (cat === "events" || uri.includes("/events/")) ? 0.1 : 0;
const prefBoost = profile.wantsPreference && (cat === "preferences" || uri.includes("/preferences/")) ? 0.08 : 0;
const overlapBoost = lexicalOverlapBoost(profile.tokens, `${item.uri} ${abstract}`);
return base + leafBoost + eventBoost + prefBoost + overlapBoost;
}
function isEventOrCaseItem(item) {
const cat = (item.category || "").toLowerCase();
const uri = (item.uri || "").toLowerCase();
return cat === "events" || cat === "cases" || uri.includes("/events/") || uri.includes("/cases/");
}
function dedupeItems(items) {
const seen = new Set();
const out = [];
for (const item of items) {
const key = isEventOrCaseItem(item)
? `uri:${item.uri}`
: ((item.abstract || item.overview || "").trim().toLowerCase() || `uri:${item.uri}`);
if (seen.has(key)) continue;
seen.add(key);
out.push(item);
}
return out;
}
async function resolveUserSpace(fetchJSON, actorPeerId = "") {
if (userSpaceCache) return userSpaceCache;
let fallbackSpace = "default";
const status = await fetchJSON("/api/v1/system/status");
if (status.ok && typeof status.result?.user === "string" && status.result.user.trim()) {
fallbackSpace = status.result.user.trim();
}
const lsRes = await fetchJSON(
`/api/v1/fs/ls?uri=${encodeURIComponent("viking://user")}&output=original`,
{},
{ actorPeerId },
);
if (lsRes.ok && Array.isArray(lsRes.result)) {
const spaces = lsRes.result
.filter((e) => e?.isDir)
.map((e) => (typeof e.name === "string" ? e.name.trim() : ""))
.filter((n) => n && !n.startsWith(".") && !USER_RESERVED_DIRS.has(n));
if (spaces.length > 0) {
if (spaces.includes(fallbackSpace)) { userSpaceCache = fallbackSpace; return fallbackSpace; }
if (spaces.includes("default")) { userSpaceCache = "default"; return "default"; }
if (spaces.length === 1) { userSpaceCache = spaces[0]; return spaces[0]; }
}
}
userSpaceCache = fallbackSpace;
return fallbackSpace;
}
async function resolveTargetUri(fetchJSON, targetUri, actorPeerId = "") {
const trimmed = targetUri.trim().replace(/\/+$/, "");
// viking://~ is the home alias: the server expands it to the caller's own user
// space, so it needs no client-side rewrite.
if (trimmed === "viking://~" || trimmed.startsWith("viking://~/")) return trimmed;
// Legacy compat: uid-less viking://user/<reserved> URIs may still sit in plugin
// configs. Newer servers reject them, so rewrite to an explicit-uid URI here.
const m = trimmed.match(/^viking:\/\/user(?:\/(.*))?$/);
if (!m) return trimmed;
const rawRest = (m[1] ?? "").trim();
if (!rawRest) return trimmed;
const parts = rawRest.split("/").filter(Boolean);
if (parts.length === 0) return trimmed;
if (!USER_RESERVED_DIRS.has(parts[0])) return trimmed;
const space = await resolveUserSpace(fetchJSON, actorPeerId);
return `viking://user/${space}/${parts.join("/")}`;
}
async function searchOneSource(fetchJSON, query, source, limit, actorPeerId = "") {
const resolvedUri = await resolveTargetUri(fetchJSON, source.uri, actorPeerId);
const body = { query, target_uri: resolvedUri, limit, score_threshold: 0 };
const res = await fetchJSON("/api/v1/search/find", {
method: "POST",
body: JSON.stringify(body),
}, { actorPeerId });
if (!res.ok) return [];
const items = res.result?.[source.bucket] || [];
return items.map((item) => ({
...item,
// A skill hit is its directory's .abstract.md; name the skill directory, as
// the context face and the session-start catalog do.
...(source.type === "skill" && typeof item.uri === "string"
? { uri: item.uri.replace(/\/\.(?:abstract|overview)\.md$/, "") }
: {}),
_sourceType: source.type,
}));
}
async function searchAllSources(fetchJSON, query, perSourceLimit, actorPeerId = "", log = () => {}) {
const results = await Promise.all(
SOURCES.map((src) => searchOneSource(fetchJSON, query, src, perSourceLimit, actorPeerId)),
);
const all = results.flat();
log("recall_search_summary", {
counts: SOURCES.map((src, i) => ({ type: src.type, uri: src.uri, count: results[i].length })),
total: all.length,
});
return all;
}
async function resolveItemContent(fetchJSON, item, cfg, actorPeerId = "") {
let content;
if (cfg.recallPreferAbstract && (item.abstract || item.overview || "").trim()) {
content = (item.abstract || item.overview).trim();
} else if (item.level === 2) {
try {
const res = await fetchJSON(
`/api/v1/content/read?uri=${encodeURIComponent(item.uri)}`,
{},
{ actorPeerId },
);
const body = res.ok && typeof res.result === "string" ? res.result.trim() : "";
content = body || (item.abstract || item.overview || "").trim() || item.uri;
} catch {
content = (item.abstract || item.overview || "").trim() || item.uri;
}
} else {
content = (item.abstract || item.overview || "").trim() || item.uri;
}
const maxChars = Math.max(50, Number(cfg.recallMaxContentChars || 500));
if (content.length > maxChars) content = `${content.slice(0, maxChars)}...`;
return content;
}
async function buildFallbackInjectionBlock(fetchJSON, items, cfg, actorPeerId = "", log = () => {}) {
const budgetTotal = Math.max(200, Number(cfg.recallTokenBudget || 2000));
let budgetRemaining = budgetTotal;
const lines = [
"<openviking-context>",
"Relevant context from OpenViking. Use the read MCP tool to expand URIs.",
];
if (items.some((item) => item._sourceType === "skill")) lines.push(SKILL_ENTRY_HINT);
let contentCount = 0;
let hintCount = 0;
for (const item of items) {
const score = (clampScore(item.score) * 100).toFixed(0);
const uriLine = `- [${item._sourceType} ${score}%] ${item.uri}`;
if (budgetRemaining > 0) {
const content = await resolveItemContent(fetchJSON, item, cfg, actorPeerId);
const contentLine = `- [${item._sourceType} ${score}%] ${content}`;
const lineTokens = estimateTokens(contentLine);
if (lineTokens > budgetRemaining && contentCount > 0) {
lines.push(uriLine);
hintCount++;
} else {
lines.push(contentLine);
budgetRemaining -= lineTokens;
contentCount++;
}
} else {
lines.push(uriLine);
hintCount++;
}
}
lines.push("</openviking-context>");
const budgetUsed = budgetTotal - budgetRemaining;
log("recall_injection_built", {
contentItems: contentCount,
hintItems: hintCount,
budgetUsed,
budgetTotal,
});
return { block: lines.join("\n"), contentCount, hintCount, budgetUsed, stage: "ranked" };
}
const LEGACY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
function stateFile(name) {
const override = String(process.env.OPENVIKING_STATE_DIR || "").trim();
return override ? join(override, name) : join(homedir(), ".openviking", "state", name);
}
async function readJsonFile(path) {
try { return JSON.parse(await readFile(path, "utf8")); } catch { return null; }
}
async function writeJsonFile(path, value) {
try {
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.tmp`;
await writeFile(tmp, JSON.stringify(value));
await rename(tmp, path);
} catch { /* best effort */ }
}
/**
* Hooks are one-shot processes, so "this server has no context face" has to be
* remembered on disk or every turn pays for a rejected request.
*/
export async function isContextFaceLegacy(path = stateFile("context-face.json"), now = Date.now()) {
const cached = await readJsonFile(path);
return Boolean(cached?.legacyUntil && Number(cached.legacyUntil) > now);
}
export async function markContextFaceLegacy(path = stateFile("context-face.json"), now = Date.now()) {
await writeJsonFile(path, { legacyUntil: now + LEGACY_CACHE_TTL_MS });
}
/**
* A `peer_scope` the server rejects is remembered the same way, but unlike the
* context face this one is not a silent capability probe: dropping the field
* widens recall from the caller's own peer to the whole user root, so doctor
* reads this file back and warns.
*/
export function peerScopeMemoPath() {
return stateFile("peer-scope.json");
}
export async function readPeerScopeDowngrade(path = peerScopeMemoPath(), now = Date.now()) {
const cached = await readJsonFile(path);
if (!cached?.legacyUntil || Number(cached.legacyUntil) <= now) return null;
return cached;
}
export async function markPeerScopeDowngrade(scope, status, path = peerScopeMemoPath(), now = Date.now()) {
await writeJsonFile(path, {
legacyUntil: now + LEGACY_CACHE_TTL_MS,
scope: String(scope || ""),
status: Number(status) || 0,
at: now,
});
}
function looksLikeUnknownField(res) {
const text = JSON.stringify(res?.error ?? res?.result ?? res?.detail ?? "").toLowerCase();
return text.includes("extra") || text.includes("mode") || text.includes("unexpected");
}
function wrapContext(body) {
return [
"<openviking-context>",
"Relevant memory from OpenViking. Use the search/read MCP tools to expand URIs.",
...(/\btype="skills"/.test(body) ? [SKILL_ENTRY_HINT] : []),
body,
"</openviking-context>",
].join("\n");
}
/**
* Server-assembled context: the context face when the deployment has it, else
* the deprecated /recall preset. Returns the injection block, "" when there was
* nothing relevant, or null when no server-side path was usable at all.
*/
export async function buildServerAssembledBlock(fetchJSON, cfg, query, options = {}) {
const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
const log = options.log || (() => {});
const block = await recallViaContextFace(fetchJSON, cfg, query, { ...options, actorPeerId }, log);
if (block !== null) return block;
return recallViaEndpoint(fetchJSON, cfg, query, actorPeerId, log);
}
/**
* Raw server-assembled context, or null when the deployment has no context face.
* Returns `{ rendered, entries, digest, stats }` — callers that need the entries
* (their own compression, their own envelope) use this instead of the block
* builders below.
*/
export async function fetchAssembledContext(fetchJSON, cfg, query, options = {}) {
const actorPeerId = options.actorPeerId || "";
const log = options.log || (() => {});
if (await isContextFaceLegacy(options.legacyCachePath)) return null;
const body = buildContextSearchBody(cfg, options);
body.query = query;
const res = await fetchJSON("/api/v1/search/search", {
method: "POST",
body: JSON.stringify(body),
}, { actorPeerId, timeoutMs: contextRequestTimeoutMs(cfg, body) });
if (!res.ok) {
const status = res.status || 0;
if ((status === 400 || status === 422) && looksLikeUnknownField(res)) {
await markContextFaceLegacy(options.legacyCachePath);
log("recall_context_face_unsupported", { status });
} else {
log("recall_context_face_error", { status });
}
return null;
}
const result = res.result || {};
const stats = result.stats || {};
log("recall_context_assembled", {
entries: Array.isArray(result.entries) ? result.entries.length : 0,
usedTokens: stats.used_tokens || 0,
tiers: stats.tier_counts || {},
rewrite: stats.rewrite || "off",
});
return {
rendered: String(result.rendered || "").trim(),
entries: Array.isArray(result.entries) ? result.entries : [],
digest: String(result.digest || "").trim(),
stats,
};
}
/**
* Entry field compatibility: the context face returns `category`/`text`, while
* the deprecated /recall v1 shape used `type` plus `content`/`summary`.
*/
export function normalizeContextEntry(entry = {}) {
return {
uri: String(entry.uri || "").trim(),
category: String(entry.category || entry.type || "memory").trim() || "memory",
detail: String(entry.detail || entry.mode || "").trim(),
score: Number(entry.score) || 0,
text: String(
entry.text || entry.content || entry.summary || entry.abstract || entry.uri || "",
).trim(),
};
}
async function recallViaContextFace(fetchJSON, cfg, query, options, log) {
const assembled = await fetchAssembledContext(fetchJSON, cfg, query, { ...options, log });
if (assembled === null) return null;
const { rendered, entries } = assembled;
let digest = assembled.digest;
const mode = String(cfg.recallRewrite || "off").toLowerCase();
if (String(assembled.stats?.rewrite || "").toLowerCase() === "no_relevant") {
log("recall_server_compression", { status: "empty" });
return "";
}
const wantsLocal = mode === "client" || (mode === "auto" && !digest);
if (wantsLocal && rendered && typeof options.runCompressor === "function") {
try {
const compression = await compressRecallContext({
query,
rendered,
entries,
cfg,
runCompressor: options.runCompressor,
cachePath: options.digestCachePath || stateFile("recall-digest.json"),
now: Date.now(),
});
log("recall_local_compression", { status: compression.status });
if (compression.status === "ok") digest = compression.context;
if (compression.status === "empty") return "";
} catch (err) {
log("recall_local_compression_failed", { error: String(err?.message || err) });
}
}
const injected = digest || rendered;
if (!injected) return "";
return wrapContext(injected);
}
async function recallViaEndpoint(fetchJSON, cfg, query, actorPeerId = "", log = () => {}) {
const body = buildRecallEndpointBody(cfg);
body.query = query;
const res = await postRecall(fetchJSON, body, { actorPeerId, log });
if (!res.ok) {
log("recall_endpoint_fallback", { status: res.status || 0 });
return null;
}
const rendered = String(res.result?.rendered || "").trim();
if (!rendered) return "";
return wrapContext(rendered);
}
export async function postRecall(fetchJSON, body, opts = {}) {
const actorPeerId = opts.actorPeerId || "";
const log = opts.log || (() => {});
const memoPath = opts.peerScopeMemoPath;
const request = { ...body };
// A remembered downgrade is still a downgrade: recall runs wider than the
// caller asked for, so the memo doubles as the doctor's evidence.
if (request.peer_scope && await readPeerScopeDowngrade(memoPath)) {
delete request.peer_scope;
}
const res = await fetchJSON("/api/v1/search/recall", {
method: "POST",
body: JSON.stringify(request),
}, { actorPeerId });
if (!request.peer_scope || (res.status !== 400 && res.status !== 422)) {
return res;
}
// Only an unknown-field rejection means "this server predates peer_scope".
// Retrying every other 400/422 without it silently widens the search from
// the caller's own peer to the whole user root.
if (!looksLikeUnknownField(res)) {
log("recall_peer_scope_error", { status: res.status || 0 });
return res;
}
const downgraded = { ...request };
delete downgraded.peer_scope;
await markPeerScopeDowngrade(String(request.peer_scope), res.status || 0, memoPath);
log("recall_peer_scope_downgrade", { status: res.status || 0 });
return fetchJSON("/api/v1/search/recall", {
method: "POST",
body: JSON.stringify(downgraded),
}, { actorPeerId });
}
/**
* Recall, plus whatever is still filed under the peer this workspace used
* before the identity rule changed.
*
* Under the default `peer_scope: "all"` this costs nothing: the server's own
* sweep of `{user_root}/peers` already reaches the old peer's memories. Under
* `"actor"` that sweep is off by definition, so the old peer is asked for
* separately — as itself, which is both cheaper and wider than a bare
* cross-peer read (that would need the user id, and reaches memories only).
*
* `stage` names the path the block came from, which is what a host needs to
* tell "the server had nothing" (`no_results`) from "the score threshold
* dropped everything" (`filtered_out`) when the block is empty, and a
* `server_assembled` block from a `ranked` one when it is not.
*/
export async function buildRecallBlockDetailed(fetchJSON, cfg, query, options = {}) {
const primary = await recallForPeer(fetchJSON, cfg, query, options);
const legacyPeerId = String(options.legacyPeerId || "").trim();
const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
if (cfg.recallPeerScope !== "actor" || !legacyPeerId || legacyPeerId === actorPeerId) return primary;
const log = options.log || (() => {});
const legacy = await recallForPeer(fetchJSON, cfg, query, { ...options, actorPeerId: legacyPeerId });
if (!legacy.block) return primary;
log("recall_legacy_peer_hit", { legacyPeerId });
return {
block: primary.block ? `${primary.block}\n${legacy.block}` : legacy.block,
contentCount: primary.contentCount + legacy.contentCount,
hintCount: primary.hintCount + legacy.hintCount,
budgetUsed: primary.budgetUsed + legacy.budgetUsed,
stage: primary.block ? primary.stage : legacy.stage,
};
}
/** The block alone, or null when nothing was injectable. */
export async function buildRecallBlock(fetchJSON, cfg, query, options = {}) {
const { block } = await buildRecallBlockDetailed(fetchJSON, cfg, query, options);
return block || null;
}
function emptyRecall(stage) {
return { block: "", contentCount: 0, hintCount: 0, budgetUsed: 0, stage };
}
async function recallForPeer(fetchJSON, cfg, query, options = {}) {
const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
const log = options.log || (() => {});
const trimmed = String(query || "").trim();
if (!trimmed) return emptyRecall("no_results");
// Assembly happens server-side when the deployment offers the context face;
// older servers fall through to /recall, then to raw find.
const serverBlock = await buildServerAssembledBlock(fetchJSON, cfg, trimmed, {
...options,
actorPeerId,
log,
});
if (serverBlock !== null) {
if (!serverBlock) return emptyRecall("no_results");
// The server rendered one budgeted unit, so nothing here degraded to a
// URI-only hint and the block's own estimate is what it cost.
return {
block: serverBlock,
contentCount: 1,
hintCount: 0,
budgetUsed: estimateTokens(serverBlock),
stage: "server_assembled",
};
}
const recallLimit = Math.max(1, Number(cfg.recallLimit || DEFAULT_CONTEXT_LIMIT));
const perSourceLimit = Math.max(recallLimit * 2, 8);
const raw = await searchAllSources(fetchJSON, trimmed, perSourceLimit, actorPeerId, log);
if (raw.length === 0) return emptyRecall("no_results");
const profile = buildQueryProfile(trimmed);
const scoreThreshold = Number.isFinite(Number(cfg.scoreThreshold)) ? Number(cfg.scoreThreshold) : 0.35;
const filtered = raw.filter((it) => clampScore(it.score) >= scoreThreshold);
filtered.sort((a, b) => rankItem(b, profile) - rankItem(a, profile));
const picked = dedupeItems(filtered).slice(0, recallLimit);
log("recall_picked", {
rawCount: raw.length,
filteredCount: filtered.length,
pickedCount: picked.length,
items: picked.map((it) => ({ type: it._sourceType, uri: it.uri, score: clampScore(it.score) })),
});
if (picked.length === 0) return emptyRecall("filtered_out");
return buildFallbackInjectionBlock(fetchJSON, picked, cfg, actorPeerId, log);
}