Files
OpenViking/examples/claude-code-memory-plugin/scripts/auto-recall.mjs
T
Yu ZhangClaude Sonnet 4.6 noreply@anthropic.comqin-ctx
e2a604167d fix(plugin): remove misleading recall top score (#4053)
* fix(plugin): report server recall top score

Derive the status snapshot's top score from server-assembled context so /ov no longer reports 0.00 for scored recalls.

Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com

* test(plugin): remove redundant recall state tests

* fix(plugin): remove misleading recall top score

---------

Co-authored-by: qin-ctx <qinhaojie.exe@bytedance.com>
2026-08-18 12:07:59 +08:00

451 lines
16 KiB
JavaScript

#!/usr/bin/env node
/**
* Auto-Recall Hook Script for Claude Code (UserPromptSubmit).
*
* Searches OpenViking for relevant context and injects an
* <openviking-context> block. High-score items within the token budget
* include resolved content; remaining items degrade to URI + score.
*
* Ranking: ported from openclaw-plugin/memory-ranking.ts (query profile
* + boosts). Content resolution + budget: ported from
* openclaw-plugin/index.ts resolveMemoryContent / buildMemoryLinesWithBudget,
* modified so items beyond the budget are degraded (URI-only) instead of
* dropped.
*/
import { isPluginEnabled, loadConfig } from "./config.mjs";
import { createLogger } from "./debug-log.mjs";
import { deriveOvSessionId, isBypassed, makeFetchJSON } from "./lib/ov-session.mjs";
import { writeJsonState } from "./lib/state.mjs";
import { createHostCompressor } from "./lib/host-compressor.mjs";
import { getEffectivePeerId } from "./lib/workspace-peer.mjs";
import { buildServerAssembledBlock } from "./shared/recall-core.mjs";
if (!isPluginEnabled()) {
process.stdout.write(JSON.stringify({ decision: "approve" }) + "\n");
process.exit(0);
}
const cfg = loadConfig();
const { log, logError } = createLogger("auto-recall");
const fetchJSON = makeFetchJSON(cfg);
function output(obj) {
process.stdout.write(JSON.stringify(obj) + "\n");
}
function approve(msg) {
const out = { decision: "approve" };
if (msg) out.hookSpecificOutput = { hookEventName: "UserPromptSubmit", additionalContext: msg };
output(out);
}
// ---------------------------------------------------------------------------
// Ranking (ported from openclaw-plugin/memory-ranking.ts)
// ---------------------------------------------------------------------------
function clampScore(v) {
if (typeof v !== "number" || Number.isNaN(v)) return 0;
return Math.max(0, Math.min(1, v));
}
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",
]);
function buildQueryProfile(query) {
const text = query.trim();
const allTokens = text.toLowerCase().match(QUERY_TOKEN_RE) || [];
const tokens = allTokens.filter(t => !STOPWORDS.has(t));
return {
tokens,
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;
}
/**
* events/cases specialization (ported from openclaw-plugin/memory-ranking.ts
* isEventOrCaseMemory): dedupe by URI instead of abstract.
*/
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;
}
// ---------------------------------------------------------------------------
// User URI space resolution
// ---------------------------------------------------------------------------
const USER_RESERVED_DIRS = new Set(["memories", "skills"]);
let _userSpaceCache = "";
async function resolveUserSpace(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(targetUri, actorPeerId = "") {
const trimmed = targetUri.trim().replace(/\/+$/, "");
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(actorPeerId);
return `viking://user/${space}/${parts.join("/")}`;
}
// ---------------------------------------------------------------------------
// Multi-source search (scoped sources only — resources excluded to prevent
// cross-namespace leakage; use MCP search(scope="resources") explicitly)
// ---------------------------------------------------------------------------
const SOURCES = [
{ type: "memory", uri: "viking://user/memories", bucket: "memories" },
{ type: "skill", uri: "viking://user/skills", bucket: "skills" },
];
async function searchOneSource(query, source, limit, actorPeerId = "") {
const resolvedUri = await resolveTargetUri(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, _sourceType: source.type }));
}
async function searchAllSources(query, perSourceLimit, actorPeerId = "") {
const results = await Promise.all(SOURCES.map(src => searchOneSource(query, src, perSourceLimit, actorPeerId)));
const all = results.flat();
log("search_summary", {
counts: SOURCES.map((src, i) => ({ type: src.type, uri: src.uri, count: results[i].length })),
total: all.length,
});
return all;
}
// ---------------------------------------------------------------------------
// Content resolution + budget formatting
// Ported from openclaw-plugin/index.ts resolveMemoryContent (line 1822-1850)
// and buildMemoryLinesWithBudget (line 1878-1907).
// Key difference: items beyond token budget are degraded to URI+score
// instead of being dropped entirely.
// ---------------------------------------------------------------------------
/** chars/4 heuristic (openclaw-plugin/index.ts:1812) */
function estimateTokens(text) {
return text ? Math.ceil(text.length / 4) : 0;
}
/**
* Resolve display content for a single item.
* Ported from openclaw-plugin/index.ts:1822 resolveMemoryContent.
*/
async function resolveItemContent(item, 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;
}
if (content.length > cfg.recallMaxContentChars) {
content = content.slice(0, cfg.recallMaxContentChars) + "...";
}
return content;
}
/**
* Build the injection block with token budget.
* Front items (within budget) get full content lines.
* Remaining items (beyond budget) degrade to URI + score only.
*/
async function buildInjectionBlock(items, actorPeerId = "") {
if (items.length === 0) return null;
let budgetRemaining = cfg.recallTokenBudget;
const lines = [
"<openviking-context>",
"Relevant context from OpenViking. Use the read MCP tool to expand URIs.",
];
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(item, actorPeerId);
const contentLine = `- [${item._sourceType} ${score}%] ${content}`;
const lineTokens = estimateTokens(contentLine);
// First item always included even if over budget (openclaw spec §6.2)
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 = cfg.recallTokenBudget - budgetRemaining;
log("injection_built", {
contentItems: contentCount,
hintItems: hintCount,
budgetUsed,
budgetTotal: cfg.recallTokenBudget,
});
return { block: lines.join("\n"), contentCount, hintCount, budgetUsed };
}
async function recallViaServerAssembly(query, actorPeerId = "", sessionId = "") {
const runCompressor = await createHostCompressor(cfg, log);
return buildServerAssembledBlock(fetchJSON, cfg, query, {
actorPeerId,
sessionId,
log,
runCompressor,
localCompressorAvailable: Boolean(runCompressor),
});
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const t0 = Date.now();
// Snapshot state for the statusline. Always written, even on early-exit
// branches, so the indicator reflects the latest turn rather than stale
// data from the previous run.
const writeRecallState = (extra) => writeJsonState("last-recall.json", {
server_url: cfg.baseUrl,
latency_ms: Date.now() - t0,
...extra,
});
if (!cfg.autoRecall) {
log("skip", { reason: "autoRecall disabled" });
writeRecallState({ count: 0, reason: "disabled" });
approve();
return;
}
let input;
try {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
input = JSON.parse(Buffer.concat(chunks).toString());
} catch {
log("skip", { reason: "invalid stdin" });
writeRecallState({ count: 0, reason: "bad_stdin" });
approve();
return;
}
const userPrompt = (input.prompt || "").trim();
const sessionId = input.session_id;
const cwd = input.cwd;
const effectivePeer = getEffectivePeerId(cfg, { sessionId, cwd });
log("start", {
query: userPrompt.slice(0, 200),
queryLength: userPrompt.length,
config: {
recallLimit: cfg.recallLimit,
scoreThreshold: cfg.scoreThreshold,
recallMaxContentChars: cfg.recallMaxContentChars,
recallTokenBudget: cfg.recallTokenBudget,
peerSource: effectivePeer.source,
recallPeerScope: cfg.recallPeerScope,
},
});
if (isBypassed(cfg, { sessionId, cwd })) {
log("skip", { reason: "bypass_session_pattern" });
writeRecallState({ count: 0, reason: "bypass", cc_session_id: sessionId });
approve();
return;
}
if (!userPrompt || userPrompt.length < cfg.minQueryLength) {
log("skip", { reason: "query too short or empty" });
writeRecallState({ count: 0, reason: "short_query", cc_session_id: sessionId });
approve();
return;
}
const health = await fetchJSON("/health");
if (!health.ok) {
logError("health_check", "server unreachable");
writeRecallState({ count: 0, reason: "offline", cc_session_id: sessionId });
approve();
return;
}
// The OV session id is what unlocks server-side query expansion and the
// cross-turn dedup ledger; it must match the id auto-capture writes to.
const ovSessionId = sessionId && sessionId !== "unknown" ? deriveOvSessionId(sessionId) : "";
const endpointBlock = await recallViaServerAssembly(
userPrompt,
effectivePeer.peerId,
ovSessionId,
);
if (endpointBlock !== null) {
if (!endpointBlock) {
log("skip", { reason: "recall_endpoint_no_results" });
writeRecallState({ count: 0, reason: "no_results", cc_session_id: sessionId });
approve();
return;
}
writeRecallState({
count: new Set(endpointBlock.match(/viking:\/\/[^\s<>"')\]]+/g) || []).size,
content_items: 1,
hint_items: 0,
tokens_used: estimateTokens(endpointBlock),
tokens_budget: cfg.recallTokenBudget,
cc_session_id: sessionId,
reason: "ok",
});
approve(endpointBlock);
return;
}
const perSourceLimit = Math.max(cfg.recallLimit * 2, 8);
const raw = await searchAllSources(userPrompt, perSourceLimit, effectivePeer.peerId);
if (raw.length === 0) {
log("skip", { reason: "no results" });
writeRecallState({ count: 0, reason: "no_results", cc_session_id: sessionId });
approve();
return;
}
const profile = buildQueryProfile(userPrompt);
const filtered = raw.filter(it => clampScore(it.score) >= cfg.scoreThreshold);
filtered.sort((a, b) => rankItem(b, profile) - rankItem(a, profile));
const deduped = dedupeItems(filtered);
const picked = deduped.slice(0, cfg.recallLimit);
log("picked", {
rawCount: raw.length,
filteredCount: filtered.length,
dedupedCount: deduped.length,
pickedCount: picked.length,
items: picked.map(it => ({ type: it._sourceType, uri: it.uri, score: clampScore(it.score) })),
});
if (picked.length === 0) {
writeRecallState({ count: 0, reason: "filtered_out", cc_session_id: sessionId });
approve();
return;
}
const built = await buildInjectionBlock(picked, effectivePeer.peerId);
writeRecallState({
count: picked.length,
content_items: built?.contentCount ?? 0,
hint_items: built?.hintCount ?? 0,
tokens_used: built?.budgetUsed ?? 0,
tokens_budget: cfg.recallTokenBudget,
cc_session_id: sessionId,
reason: "ok",
});
approve(built?.block);
}
main().catch((err) => { logError("uncaught", err); approve(); });