Files
openclaw/extensions/memory-wiki/src/query.ts
Peter Steinberger 31fc15b8dd fix(memory-wiki): prevent oversized wiki content from crowding out model context (#126779)
* fix(memory-wiki): bound search snippets before model context

* fix(memory-wiki): bound compiled prompt digest context
2026-08-20 11:27:40 -07:00

1487 lines
45 KiB
TypeScript

// Memory Wiki plugin module implements query behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { filterMemorySearchHitsBySessionVisibility } from "@openclaw/memory-core/api.js";
import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime";
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import { resolveDefaultAgentId, resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core";
import { getActiveMemorySearchManager } from "openclaw/plugin-sdk/memory-host-search";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry";
import {
normalizeLowercaseStringOrEmpty,
uniqueStrings,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import type { OpenClawConfig } from "../api.js";
import { walkMemoryWikiDirectory } from "./bounded-walk.js";
import { assessClaimFreshness, isClaimContestedStatus } from "./claim-health.js";
import {
loadMemoryWikiCompiledCache,
type MemoryWikiCompiledClaim,
type MemoryWikiCompiledDigestPage,
} from "./compiled-cache.js";
import type { ResolvedMemoryWikiConfig, WikiSearchBackend, WikiSearchCorpus } from "./config.js";
import {
parseWikiMarkdown,
toWikiPageSummary,
type WikiClaim,
type WikiPageSummary,
} from "./markdown.js";
import { initializeMemoryWikiVault } from "./vault.js";
const QUERY_DIRS = ["entities", "concepts", "sources", "syntheses", "reports"] as const;
const QUERY_PAGE_READ_CONCURRENCY = 16;
const WIKI_SNIPPET_MAX_CHARS = 700;
const RELATED_BLOCK_PATTERN =
/<!-- openclaw:wiki:related:start -->[\s\S]*?<!-- openclaw:wiki:related:end -->/g;
const MARKDOWN_FRONTMATTER_PATTERN = /^\s*---\r?\n[\s\S]*?\r?\n---\r?\n?/;
const ROUTE_QUESTION_STOP_WORDS = new Set([
"a",
"about",
"am",
"an",
"are",
"ask",
"asking",
"be",
"been",
"being",
"can",
"could",
"did",
"do",
"does",
"for",
"help",
"how",
"i",
"in",
"is",
"know",
"knows",
"me",
"my",
"need",
"needs",
"of",
"on",
"or",
"our",
"question",
"questions",
"should",
"the",
"to",
"us",
"we",
"what",
"when",
"where",
"who",
"whom",
"whose",
"why",
"with",
"would",
]);
function normalizePositiveInteger(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value)
? Math.max(1, Math.floor(value))
: fallback;
}
export const WIKI_SEARCH_MODES = [
"auto",
"find-person",
"route-question",
"source-evidence",
"raw-claim",
] as const;
export type WikiSearchMode = (typeof WIKI_SEARCH_MODES)[number];
type QueryDigestPage = MemoryWikiCompiledDigestPage;
type QueryDigestClaim = MemoryWikiCompiledClaim;
type QueryDigestBundle = {
pages: QueryDigestPage[];
claims: QueryDigestClaim[];
};
type WikiSearchResult = {
corpus: "wiki" | "memory";
path: string;
title: string;
kind: WikiPageSummary["kind"] | "memory";
score: number;
snippet: string;
id?: string;
startLine?: number;
endLine?: number;
citation?: string;
memorySource?: MemorySearchResult["source"];
sourceType?: string;
provenanceMode?: string;
sourcePath?: string;
provenanceLabel?: string;
updatedAt?: string;
searchMode?: WikiSearchMode;
entityType?: string;
canonicalId?: string;
aliases?: string[];
privacyTier?: string;
matchedClaimId?: string;
matchedClaimStatus?: string;
matchedClaimConfidence?: number;
evidenceKinds?: string[];
evidenceSourceIds?: string[];
};
type WikiGetResult = {
corpus: "wiki" | "memory";
path: string;
title: string;
kind: WikiPageSummary["kind"] | "memory";
content: string;
fromLine: number;
lineCount: number;
totalLines?: number;
truncated?: boolean;
id?: string;
sourceType?: string;
provenanceMode?: string;
sourcePath?: string;
provenanceLabel?: string;
updatedAt?: string;
};
export type QueryableWikiPage = WikiPageSummary & {
raw: string;
};
type QuerySearchOverrides = {
searchBackend?: WikiSearchBackend;
searchCorpus?: WikiSearchCorpus;
};
type ConversationRecallContext = NonNullable<OpenClawPluginToolContext["conversationRecall"]>;
function sortWikiSearchResults(results: WikiSearchResult[]): WikiSearchResult[] {
return results.toSorted((left, right) => {
if (left.score !== right.score) {
return right.score - left.score;
}
return left.title.localeCompare(right.title);
});
}
function mergeWikiSearchCorpusResults(params: {
wikiResults: WikiSearchResult[];
memoryResults: WikiSearchResult[];
maxResults: number;
balanceCorpora: boolean;
}): WikiSearchResult[] {
const wikiResults = sortWikiSearchResults(params.wikiResults);
const memoryResults = sortWikiSearchResults(params.memoryResults);
if (!params.balanceCorpora || wikiResults.length === 0 || memoryResults.length === 0) {
return sortWikiSearchResults([...wikiResults, ...memoryResults]).slice(0, params.maxResults);
}
const perCorpusCap = Math.ceil(params.maxResults / 2);
const selectedWiki = wikiResults.slice(0, perCorpusCap);
const selectedMemory = memoryResults.slice(0, perCorpusCap);
const selected = [...selectedWiki, ...selectedMemory];
if (selected.length < params.maxResults) {
selected.push(
...sortWikiSearchResults([
...wikiResults.slice(selectedWiki.length),
...memoryResults.slice(selectedMemory.length),
]).slice(0, params.maxResults - selected.length),
);
}
return sortWikiSearchResults(selected).slice(0, params.maxResults);
}
async function listWikiMarkdownFiles(rootDir: string): Promise<string[]> {
const files = (
await Promise.all(
QUERY_DIRS.map(async (relativeDir) => {
const entries = await walkMemoryWikiDirectory(rootDir, relativeDir);
return entries
.filter(
(entry) =>
entry.kind === "file" &&
entry.relativePath.endsWith(".md") &&
path.basename(entry.relativePath) !== "index.md",
)
.map((entry) => entry.relativePath.split(path.sep).join("/"));
}),
)
).flat();
return files.toSorted((left, right) => left.localeCompare(right));
}
export async function readQueryableWikiPages(rootDir: string): Promise<QueryableWikiPage[]> {
const files = await listWikiMarkdownFiles(rootDir);
return readQueryableWikiPagesByPaths(rootDir, files);
}
async function readQueryableWikiPagesByPaths(
rootDir: string,
files: string[],
): Promise<QueryableWikiPage[]> {
const { results } = await runTasksWithConcurrency({
tasks: files.map((relativePath) => async () => {
const absolutePath = path.join(rootDir, relativePath);
const raw = await fs.readFile(absolutePath, "utf8");
const summary = toWikiPageSummary({ absolutePath, relativePath, raw });
return summary ? { ...summary, raw } : null;
}),
limit: QUERY_PAGE_READ_CONCURRENCY,
errorMode: "stop",
throwOnError: true,
});
return results.filter((page): page is QueryableWikiPage => page !== null);
}
async function readQueryDigestBundle(
config: ResolvedMemoryWikiConfig,
): Promise<QueryDigestBundle | null> {
const snapshot = await loadMemoryWikiCompiledCache(config);
return snapshot ? { pages: snapshot.digest.pages, claims: snapshot.claims } : null;
}
function buildSnippet(raw: string, query: string): string {
const queryLower = normalizeLowercaseStringOrEmpty(query);
const queryTokens = buildQueryTokens(queryLower);
const searchable = buildSnippetSearchText(raw);
const lines = searchable.split(/\r?\n/).filter((line) => line.trim().length > 0);
const matchingLine =
lines.find((line) =>
lineMatchesQuery(normalizeLowercaseStringOrEmpty(line), queryLower, queryTokens),
) ??
lines
.map((line) => ({
line,
hits: queryTokens.filter((token) => normalizeLowercaseStringOrEmpty(line).includes(token))
.length,
}))
.toSorted((left, right) => right.hits - left.hits)
.find((candidate) => candidate.hits > 0)?.line;
return matchingLine?.trim() || lines.find((line) => line.trim() !== "---")?.trim() || "";
}
function buildPageSearchText(page: QueryableWikiPage): string {
return [
page.title,
page.relativePath,
page.id ?? "",
page.pageType ?? "",
page.entityType ?? "",
page.canonicalId ?? "",
page.aliases.join(" "),
page.sourceIds.join(" "),
page.questions.join(" "),
page.contradictions.join(" "),
page.privacyTier ?? "",
page.bestUsedFor.join(" "),
page.notEnoughFor.join(" "),
page.personCard?.canonicalId ?? "",
page.personCard?.handles.join(" ") ?? "",
page.personCard?.socials.join(" ") ?? "",
page.personCard?.emails.join(" ") ?? "",
page.personCard?.timezone ?? "",
page.personCard?.lane ?? "",
page.personCard?.askFor.join(" ") ?? "",
page.personCard?.avoidAskingFor.join(" ") ?? "",
page.personCard?.bestUsedFor.join(" ") ?? "",
page.personCard?.notEnoughFor.join(" ") ?? "",
page.relationships
.flatMap((relationship) => [
relationship.targetId ?? "",
relationship.targetPath ?? "",
relationship.targetTitle ?? "",
relationship.kind ?? "",
relationship.evidenceKind ?? "",
relationship.note ?? "",
])
.join(" "),
page.claims.map((claim) => claim.text).join(" "),
page.claims.map((claim) => claim.id ?? "").join(" "),
page.claims
.flatMap((claim) =>
claim.evidence.flatMap((evidence) => [
evidence.kind ?? "",
evidence.sourceId ?? "",
evidence.path ?? "",
evidence.lines ?? "",
evidence.note ?? "",
evidence.privacyTier ?? "",
]),
)
.join(" "),
]
.filter(Boolean)
.join("\n");
}
function stripGeneratedRelatedBlock(raw: string): string {
return raw.replace(RELATED_BLOCK_PATTERN, "");
}
function buildSnippetSearchText(raw: string): string {
return stripGeneratedRelatedBlock(raw).replace(MARKDOWN_FRONTMATTER_PATTERN, "");
}
function buildQueryTokens(queryLower: string): string[] {
return [
...new Set(
queryLower
.split(/[^a-z0-9@._-]+/i)
.map((token) => token.trim())
.filter((token) => token.length >= 2),
),
];
}
function buildRouteQuestionTokens(queryLower: string): string[] {
const tokens = buildQueryTokens(queryLower);
const routedTokens = tokens.filter((token) => !ROUTE_QUESTION_STOP_WORDS.has(token));
return routedTokens.length > 0 ? routedTokens : tokens;
}
function lineMatchesQuery(lineLower: string, queryLower: string, queryTokens: string[]): boolean {
if (queryLower.length > 0 && lineLower.includes(queryLower)) {
return true;
}
return queryTokens.length > 0 && queryTokens.every((token) => lineLower.includes(token));
}
function buildDigestPageSearchText(page: QueryDigestPage, claims: QueryDigestClaim[]): string {
return [
page.title,
page.path,
page.id ?? "",
page.pageType ?? "",
page.entityType ?? "",
page.canonicalId ?? "",
page.aliases?.join(" ") ?? "",
page.sourceIds.join(" "),
page.questions.join(" "),
page.contradictions.join(" "),
page.privacyTier ?? "",
page.bestUsedFor?.join(" ") ?? "",
page.notEnoughFor?.join(" ") ?? "",
page.personCard?.canonicalId ?? "",
page.personCard?.handles.join(" ") ?? "",
page.personCard?.socials.join(" ") ?? "",
page.personCard?.emails.join(" ") ?? "",
page.personCard?.timezone ?? "",
page.personCard?.lane ?? "",
page.personCard?.askFor.join(" ") ?? "",
page.personCard?.avoidAskingFor.join(" ") ?? "",
page.personCard?.bestUsedFor.join(" ") ?? "",
page.personCard?.notEnoughFor.join(" ") ?? "",
page.topRelationships
?.flatMap((relationship) => [
relationship.targetId ?? "",
relationship.targetPath ?? "",
relationship.targetTitle ?? "",
relationship.kind ?? "",
relationship.evidenceKind ?? "",
relationship.note ?? "",
])
.join(" ") ?? "",
claims.map((claim) => claim.text).join(" "),
claims.map((claim) => claim.id ?? "").join(" "),
claims.map((claim) => claim.evidenceKinds?.join(" ") ?? "").join(" "),
claims.map((claim) => claim.privacyTiers?.join(" ") ?? "").join(" "),
]
.filter(Boolean)
.join("\n");
}
function isClaimTextOrIdMatch(
claim: Pick<QueryDigestClaim, "id" | "text"> | Pick<WikiClaim, "id" | "text">,
queryLower: string,
queryTokens: readonly string[] = buildQueryTokens(queryLower),
): boolean {
const textLower = normalizeLowercaseStringOrEmpty(claim.text);
if (lineMatchesQuery(textLower, queryLower, [...queryTokens])) {
return true;
}
return lineMatchesQuery(normalizeLowercaseStringOrEmpty(claim.id), queryLower, [...queryTokens]);
}
function scoreClaimMatch(params: {
text: string;
id?: string;
confidence?: number;
status?: string;
freshnessLevel?: string;
queryLower: string;
queryTokens?: readonly string[];
}): number {
let score = 0;
if (normalizeLowercaseStringOrEmpty(params.text).includes(params.queryLower)) {
score += 25;
} else if (
params.queryTokens?.length &&
params.queryTokens.every((token) =>
normalizeLowercaseStringOrEmpty(params.text).includes(token),
)
) {
score += 18;
}
if (normalizeLowercaseStringOrEmpty(params.id).includes(params.queryLower)) {
score += 10;
}
if (typeof params.confidence === "number") {
score += Math.round(params.confidence * 10);
}
switch (params.freshnessLevel) {
case "fresh":
score += 8;
break;
case "aging":
score += 4;
break;
case "stale":
score -= 2;
break;
case "unknown":
score -= 4;
break;
case undefined:
break;
}
score += isClaimContestedStatus(params.status) ? -6 : 4;
return score;
}
function scoreDigestClaimMatch(claim: QueryDigestClaim, queryLower: string): number {
return scoreClaimMatch({
text: claim.text,
id: claim.id,
confidence: claim.confidence,
status: claim.status,
freshnessLevel: claim.freshnessLevel,
queryLower,
queryTokens: buildQueryTokens(queryLower),
});
}
function scoreWikiMetadataMatch(params: {
title: string;
path: string;
id?: string;
sourceIds: readonly string[];
queryLower: string;
}): number {
let score = 0;
const titleLower = normalizeLowercaseStringOrEmpty(params.title);
const pathLower = normalizeLowercaseStringOrEmpty(params.path);
const idLower = normalizeLowercaseStringOrEmpty(params.id);
if (titleLower === params.queryLower) {
score += 50;
} else if (titleLower.includes(params.queryLower)) {
score += 20;
}
if (pathLower.includes(params.queryLower)) {
score += 10;
}
if (idLower.includes(params.queryLower)) {
score += 20;
}
if (
params.sourceIds.some((sourceId) =>
normalizeLowercaseStringOrEmpty(sourceId).includes(params.queryLower),
)
) {
score += 12;
}
return score;
}
function hasQueryMatch(
value: string | undefined,
queryLower: string,
queryTokens: readonly string[],
) {
const normalized = normalizeLowercaseStringOrEmpty(value);
return lineMatchesQuery(normalized, queryLower, [...queryTokens]);
}
function hasAnyQueryMatch(
values: readonly (string | undefined)[],
queryLower: string,
queryTokens: readonly string[],
) {
return values.some((value) => hasQueryMatch(value, queryLower, queryTokens));
}
function buildPageRouteQuestionFields(page: QueryableWikiPage): string[] {
return [
page.personCard?.lane,
...(page.personCard?.askFor ?? []),
...(page.personCard?.avoidAskingFor ?? []),
...page.bestUsedFor,
...page.notEnoughFor,
...(page.personCard?.bestUsedFor ?? []),
...(page.personCard?.notEnoughFor ?? []),
...page.relationships.flatMap((relationship) => [
relationship.kind,
relationship.targetTitle,
relationship.note,
]),
].filter((value): value is string => Boolean(value));
}
function buildDigestRouteQuestionFields(page: QueryDigestPage): string[] {
return [
page.personCard?.lane,
...(page.personCard?.askFor ?? []),
...(page.personCard?.avoidAskingFor ?? []),
...(page.bestUsedFor ?? []),
...(page.notEnoughFor ?? []),
...(page.personCard?.bestUsedFor ?? []),
...(page.personCard?.notEnoughFor ?? []),
...(page.topRelationships?.flatMap((relationship) => [
relationship.kind,
relationship.targetTitle,
relationship.note,
]) ?? []),
].filter((value): value is string => Boolean(value));
}
function hasRouteQuestionMatch(values: readonly string[], queryLower: string): boolean {
return hasAnyQueryMatch(values, queryLower, buildRouteQuestionTokens(queryLower));
}
function isPersonLikeSummary(
page: Pick<WikiPageSummary, "entityType" | "pageType" | "personCard">,
): boolean {
const entityType = normalizeLowercaseStringOrEmpty(page.entityType);
const pageType = normalizeLowercaseStringOrEmpty(page.pageType);
return (
Boolean(page.personCard) ||
entityType === "person" ||
entityType === "maintainer" ||
pageType === "person" ||
pageType === "maintainer"
);
}
function scorePageSearchModeBoost(params: {
page: QueryableWikiPage;
matchingClaims: readonly WikiClaim[];
queryLower: string;
queryTokens: readonly string[];
mode: WikiSearchMode;
}): number {
const { page, queryLower, queryTokens } = params;
switch (params.mode) {
case "auto":
return 0;
case "find-person": {
let score = isPersonLikeSummary(page) ? 24 : -4;
if (
hasAnyQueryMatch(
[
page.canonicalId,
...page.aliases,
page.personCard?.canonicalId,
...(page.personCard?.handles ?? []),
...(page.personCard?.emails ?? []),
...(page.personCard?.socials ?? []),
],
queryLower,
queryTokens,
)
) {
score += 24;
}
return score;
}
case "route-question": {
let score = isPersonLikeSummary(page) ? 14 : 0;
if (hasRouteQuestionMatch(buildPageRouteQuestionFields(page), queryLower)) {
score += 32;
}
score += Math.min(8, page.relationships.length * 2);
return score;
}
case "source-evidence": {
let score = page.kind === "source" ? 22 : 0;
if (
hasAnyQueryMatch(
[
page.sourcePath,
...page.sourceIds,
...page.claims.flatMap((claim) =>
claim.evidence.flatMap((evidence) => [
evidence.kind,
evidence.sourceId,
evidence.path,
evidence.lines,
evidence.note,
]),
),
],
queryLower,
queryTokens,
)
) {
score += 30;
}
return score;
}
case "raw-claim":
return params.matchingClaims.length > 0 ? 42 : 0;
}
return 0;
}
function scoreDigestSearchModeBoost(params: {
page: QueryDigestPage;
claims: readonly QueryDigestClaim[];
matchingClaims: readonly QueryDigestClaim[];
queryLower: string;
queryTokens: readonly string[];
mode: WikiSearchMode;
}): number {
const { page, queryLower, queryTokens } = params;
switch (params.mode) {
case "auto":
return 0;
case "find-person": {
let score = isPersonLikeSummary(page) ? 24 : -4;
if (
hasAnyQueryMatch(
[
page.canonicalId,
...(page.aliases ?? []),
page.personCard?.canonicalId,
...(page.personCard?.handles ?? []),
...(page.personCard?.emails ?? []),
...(page.personCard?.socials ?? []),
],
queryLower,
queryTokens,
)
) {
score += 24;
}
return score;
}
case "route-question": {
let score = isPersonLikeSummary(page) ? 14 : 0;
if (hasRouteQuestionMatch(buildDigestRouteQuestionFields(page), queryLower)) {
score += 32;
}
score += Math.min(8, (page.relationshipCount ?? 0) * 2);
return score;
}
case "source-evidence": {
let score = page.kind === "source" ? 22 : 0;
if (
hasAnyQueryMatch(
[
...page.sourceIds,
...params.claims.flatMap((claim) => [
...(claim.sourceIds ?? []),
...(claim.evidenceKinds ?? []),
...(claim.privacyTiers ?? []),
]),
],
queryLower,
queryTokens,
)
) {
score += 30;
}
return score;
}
case "raw-claim":
return params.matchingClaims.length > 0 ? 42 : 0;
}
return 0;
}
function buildDigestCandidatePaths(params: {
digest: QueryDigestBundle;
query: string;
maxResults: number;
mode: WikiSearchMode;
}): string[] {
const queryLower = normalizeLowercaseStringOrEmpty(params.query);
const queryTokens = buildQueryTokens(queryLower);
const claimsByPage = new Map<string, QueryDigestClaim[]>();
for (const claim of params.digest.claims) {
const current = claimsByPage.get(claim.pagePath) ?? [];
current.push(claim);
claimsByPage.set(claim.pagePath, current);
}
return params.digest.pages
.map((page) => {
const claims = claimsByPage.get(page.path) ?? [];
const metadataLower = normalizeLowercaseStringOrEmpty(
buildDigestPageSearchText(page, claims),
);
if (
!metadataLower.includes(queryLower) &&
!(
params.mode === "route-question" &&
hasRouteQuestionMatch(buildDigestRouteQuestionFields(page), queryLower)
)
) {
return { path: page.path, score: 0 };
}
let score =
1 +
scoreWikiMetadataMatch({
title: page.title,
path: page.path,
id: page.id,
sourceIds: page.sourceIds,
queryLower,
});
const matchingClaims = claims
.filter((claim) => isClaimTextOrIdMatch(claim, queryLower, queryTokens))
.toSorted(
(left, right) =>
scoreDigestClaimMatch(right, queryLower) - scoreDigestClaimMatch(left, queryLower),
);
const [bestMatchingClaim] = matchingClaims;
if (bestMatchingClaim) {
score += scoreDigestClaimMatch(bestMatchingClaim, queryLower);
score += Math.min(10, (matchingClaims.length - 1) * 2);
}
score += scoreDigestSearchModeBoost({
page,
claims,
matchingClaims,
queryLower,
queryTokens,
mode: params.mode,
});
return { path: page.path, score };
})
.filter((candidate) => candidate.score > 0)
.toSorted((left, right) => {
if (left.score !== right.score) {
return right.score - left.score;
}
return left.path.localeCompare(right.path);
})
.slice(0, Math.max(params.maxResults * 4, 20))
.map((candidate) => candidate.path);
}
function isClaimMatch(
claim: WikiClaim,
queryLower: string,
queryTokens: readonly string[],
): boolean {
return isClaimTextOrIdMatch(claim, queryLower, queryTokens);
}
function rankClaimMatch(
page: QueryableWikiPage,
claim: WikiClaim,
queryLower: string,
queryTokens: readonly string[],
): number {
const freshness = assessClaimFreshness({ page, claim });
return scoreClaimMatch({
text: claim.text,
id: claim.id,
confidence: claim.confidence,
status: claim.status,
freshnessLevel: freshness.level,
queryLower,
queryTokens,
});
}
function getMatchingClaims(page: QueryableWikiPage, queryLower: string): WikiClaim[] {
const queryTokens = buildQueryTokens(queryLower);
return page.claims
.filter((claim) => isClaimMatch(claim, queryLower, queryTokens))
.toSorted(
(left, right) =>
rankClaimMatch(page, right, queryLower, queryTokens) -
rankClaimMatch(page, left, queryLower, queryTokens),
);
}
function buildPageSnippet(page: QueryableWikiPage, query: string): string {
const queryLower = normalizeLowercaseStringOrEmpty(query);
const matchingClaim = getMatchingClaims(page, queryLower)[0];
return truncateUtf16Safe(
matchingClaim?.text ?? buildSnippet(page.raw, query),
WIKI_SNIPPET_MAX_CHARS,
);
}
function scorePage(page: QueryableWikiPage, query: string, mode: WikiSearchMode): number {
const queryLower = normalizeLowercaseStringOrEmpty(query);
const queryTokens = buildQueryTokens(queryLower);
const titleLower = normalizeLowercaseStringOrEmpty(page.title);
const pathLower = normalizeLowercaseStringOrEmpty(page.relativePath);
const idLower = normalizeLowercaseStringOrEmpty(page.id);
const metadataLower = normalizeLowercaseStringOrEmpty(buildPageSearchText(page));
const rawLower = normalizeLowercaseStringOrEmpty(stripGeneratedRelatedBlock(page.raw));
const combinedLower = [titleLower, pathLower, idLower, metadataLower, rawLower].join("\n");
const hasExactMatch =
titleLower.includes(queryLower) ||
pathLower.includes(queryLower) ||
idLower.includes(queryLower) ||
metadataLower.includes(queryLower) ||
rawLower.includes(queryLower);
const hasAllTokens =
queryTokens.length > 0 && queryTokens.every((token) => combinedLower.includes(token));
const hasModeMatch =
mode === "route-question" &&
hasRouteQuestionMatch(buildPageRouteQuestionFields(page), queryLower);
if (!hasExactMatch && !hasAllTokens && !hasModeMatch) {
return 0;
}
let score =
1 +
scoreWikiMetadataMatch({
title: page.title,
path: page.relativePath,
id: page.id,
sourceIds: page.sourceIds,
queryLower,
});
const matchingClaims = getMatchingClaims(page, queryLower);
const [bestMatchingClaim] = matchingClaims;
if (bestMatchingClaim) {
score += rankClaimMatch(page, bestMatchingClaim, queryLower, queryTokens);
score += Math.min(10, (matchingClaims.length - 1) * 2);
}
score += scorePageSearchModeBoost({
page,
matchingClaims,
queryLower,
queryTokens,
mode,
});
const bodyOccurrences = rawLower.split(queryLower).length - 1;
score += Math.min(10, bodyOccurrences);
for (const token of queryTokens) {
if (titleLower.includes(token)) {
score += 8;
}
if (pathLower.includes(token) || idLower.includes(token)) {
score += 6;
}
if (metadataLower.includes(token)) {
score += 4;
}
if (rawLower.includes(token)) {
score += 1;
}
}
return score;
}
function normalizeLookupKey(value: string): string {
const normalized = value.trim().replace(/\\/g, "/");
return normalized.endsWith(".md") ? normalized : normalized.replace(/\/+$/, "");
}
function buildLookupCandidates(lookup: string): string[] {
const normalized = normalizeLookupKey(lookup);
const withExtension = normalized.endsWith(".md") ? normalized : `${normalized}.md`;
return uniqueStrings([normalized, withExtension]);
}
function shouldEnforceSessionVisibility(params: {
agentId?: string;
agentSessionKey?: string;
sandboxed?: boolean;
}): boolean {
return (
params.sandboxed === true ||
Boolean(params.agentSessionKey?.trim()) ||
Boolean(params.agentId?.trim())
);
}
function isBridgeCompiledPage(page: QueryableWikiPage): boolean {
return (
page.sourceType === "memory-bridge" ||
page.sourceType === "memory-bridge-events" ||
page.bridgeAgentIds.length > 0
);
}
function createWikiPageVisibilityFilter(params: {
appConfig?: OpenClawConfig;
agentId?: string;
agentSessionKey?: string;
sandboxed?: boolean;
}): (page: QueryableWikiPage) => boolean {
if (params.sandboxed !== true) {
return () => true;
}
const sessionKey = params.agentSessionKey?.trim();
const scopedAgentId = normalizeLowercaseStringOrEmpty(
params.agentId?.trim() ||
(params.appConfig && sessionKey
? resolveSessionAgentId({ sessionKey, config: params.appConfig })
: undefined),
);
return (page) =>
!isBridgeCompiledPage(page) ||
(scopedAgentId.length > 0 &&
page.bridgeAgentIds.some(
(agentId) => normalizeLowercaseStringOrEmpty(agentId) === scopedAgentId,
));
}
function shouldSearchSharedMemoryCorpus(config: ResolvedMemoryWikiConfig): boolean {
return config.search.corpus === "memory" || config.search.corpus === "all";
}
function shouldUseSharedMemory(config: ResolvedMemoryWikiConfig): boolean {
return config.search.backend === "shared" && shouldSearchSharedMemoryCorpus(config);
}
function assertSessionVisibilityAppConfig(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
agentId?: string;
agentSessionKey?: string;
sandboxed?: boolean;
operation: string;
}): void {
if (
shouldUseSharedMemory(params.config) &&
shouldEnforceSessionVisibility(params) &&
!params.appConfig
) {
throw new Error(
`${params.operation} requires appConfig to enforce session visibility for session-bound shared memory calls.`,
);
}
}
// Keep these path shapes aligned with source: "sessions" hits in session-search-visibility and session-transcript-hit.
function isSessionMemoryPath(relPath: string): boolean {
const normalized = relPath.replace(/\\/g, "/");
return normalized.startsWith("sessions/");
}
function shouldSearchWiki(config: ResolvedMemoryWikiConfig): boolean {
return config.search.corpus === "wiki" || config.search.corpus === "all";
}
function shouldSearchSharedMemory(
config: ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,
): boolean {
return shouldUseSharedMemory(config) && appConfig !== undefined;
}
function resolveActiveMemoryAgentId(params: {
appConfig?: OpenClawConfig;
agentId?: string;
agentSessionKey?: string;
}): string | null {
if (!params.appConfig) {
return null;
}
if (params.agentId?.trim()) {
return params.agentId.trim();
}
if (params.agentSessionKey?.trim()) {
return resolveSessionAgentId({
sessionKey: params.agentSessionKey,
config: params.appConfig,
});
}
return resolveDefaultAgentId(params.appConfig);
}
async function resolveActiveMemoryManager(params: {
appConfig?: OpenClawConfig;
agentId?: string;
agentSessionKey?: string;
}) {
const agentId = resolveActiveMemoryAgentId(params);
if (!params.appConfig || !agentId) {
return null;
}
try {
const { manager } = await getActiveMemorySearchManager({
cfg: params.appConfig,
agentId,
});
return manager;
} catch {
return null;
}
}
// Registered managers come from the active memory plugin; nothing enforces
// the MemorySearchManager contract at runtime, so a partial manager would
// otherwise surface as "... is not a function" from inside the bundle.
function buildMemoryManagerContractError(method: "search" | "readFile"): Error {
return new Error(
`The active memory plugin's search manager does not implement ${method}() from the MemorySearchManager contract. ` +
`Set search.backend to "local" for wiki-only access, or use a memory plugin that implements the contract.`,
);
}
function buildMemorySearchTitle(resultPath: string): string {
const basename = path.basename(resultPath, path.extname(resultPath));
return basename.length > 0 ? basename : resultPath;
}
function applySearchOverrides(
config: ResolvedMemoryWikiConfig,
overrides?: QuerySearchOverrides,
): ResolvedMemoryWikiConfig {
if (!overrides?.searchBackend && !overrides?.searchCorpus) {
return config;
}
return {
...config,
search: {
backend: overrides.searchBackend ?? config.search.backend,
corpus: overrides.searchCorpus ?? config.search.corpus,
},
};
}
function buildWikiProvenanceLabel(
page: Pick<
WikiPageSummary,
| "sourceType"
| "provenanceMode"
| "bridgeRelativePath"
| "unsafeLocalRelativePath"
| "relativePath"
| "entityType"
| "canonicalId"
| "aliases"
| "privacyTier"
>,
): string | undefined {
if (page.sourceType === "memory-bridge-events") {
return `bridge events: ${page.bridgeRelativePath ?? page.relativePath}`;
}
if (page.sourceType === "memory-bridge") {
return `bridge: ${page.bridgeRelativePath ?? page.relativePath}`;
}
if (page.provenanceMode === "unsafe-local" || page.sourceType === "memory-unsafe-local") {
return `unsafe-local: ${page.unsafeLocalRelativePath ?? page.relativePath}`;
}
return undefined;
}
function buildWikiResultMetadata(
page: Pick<
WikiPageSummary,
| "id"
| "sourceType"
| "provenanceMode"
| "sourcePath"
| "updatedAt"
| "bridgeRelativePath"
| "unsafeLocalRelativePath"
| "relativePath"
| "entityType"
| "canonicalId"
| "aliases"
| "privacyTier"
>,
): Partial<
Pick<
WikiSearchResult,
| "id"
| "sourceType"
| "provenanceMode"
| "sourcePath"
| "provenanceLabel"
| "updatedAt"
| "entityType"
| "canonicalId"
| "aliases"
| "privacyTier"
>
> {
const provenanceLabel = buildWikiProvenanceLabel(page);
return {
...(page.id ? { id: page.id } : {}),
...(page.sourceType ? { sourceType: page.sourceType } : {}),
...(page.provenanceMode ? { provenanceMode: page.provenanceMode } : {}),
...(page.sourcePath ? { sourcePath: page.sourcePath } : {}),
...(provenanceLabel ? { provenanceLabel } : {}),
...(page.updatedAt ? { updatedAt: page.updatedAt } : {}),
...("entityType" in page && page.entityType ? { entityType: page.entityType } : {}),
...("canonicalId" in page && page.canonicalId ? { canonicalId: page.canonicalId } : {}),
...("aliases" in page && page.aliases.length > 0 ? { aliases: [...page.aliases] } : {}),
...("privacyTier" in page && page.privacyTier ? { privacyTier: page.privacyTier } : {}),
};
}
function buildClaimResultMetadata(claim: WikiClaim | undefined): Partial<WikiSearchResult> {
if (!claim) {
return {};
}
return {
...(claim.id ? { matchedClaimId: claim.id } : {}),
...(claim.status ? { matchedClaimStatus: claim.status } : {}),
...(typeof claim.confidence === "number" ? { matchedClaimConfidence: claim.confidence } : {}),
evidenceKinds: uniqueStrings(claim.evidence.flatMap((evidence) => evidence.kind ?? [])),
evidenceSourceIds: uniqueStrings(claim.evidence.flatMap((evidence) => evidence.sourceId ?? [])),
};
}
function toWikiSearchResult(
page: QueryableWikiPage,
query: string,
mode: WikiSearchMode,
): WikiSearchResult {
const queryLower = normalizeLowercaseStringOrEmpty(query);
const matchingClaim = getMatchingClaims(page, queryLower)[0];
return {
corpus: "wiki",
path: page.relativePath,
title: page.title,
kind: page.kind,
score: scorePage(page, query, mode),
snippet: buildPageSnippet(page, query),
searchMode: mode,
...buildWikiResultMetadata(page),
...buildClaimResultMetadata(matchingClaim),
};
}
function toMemoryWikiSearchResult(
result: MemorySearchResult,
mode: WikiSearchMode,
): WikiSearchResult {
return {
corpus: "memory",
path: result.path,
title: buildMemorySearchTitle(result.path),
kind: "memory",
score: result.score,
snippet: result.snippet,
startLine: result.startLine,
endLine: result.endLine,
memorySource: result.source,
searchMode: mode,
...(result.citation ? { citation: result.citation } : {}),
};
}
async function searchWikiCorpus(params: {
config: ResolvedMemoryWikiConfig;
query: string;
maxResults: number;
mode: WikiSearchMode;
canReadPage: (page: QueryableWikiPage) => boolean;
}): Promise<WikiSearchResult[]> {
const digest = await readQueryDigestBundle(params.config);
const rootDir = params.config.vault.path;
const candidatePaths = digest
? buildDigestCandidatePaths({
digest,
query: params.query,
maxResults: params.maxResults,
mode: params.mode,
})
: [];
const seenPaths = new Set<string>();
const candidatePages =
candidatePaths.length > 0
? await readQueryableWikiPagesByPaths(rootDir, candidatePaths)
: await readQueryableWikiPages(rootDir);
for (const page of candidatePages) {
seenPaths.add(page.relativePath);
}
const results = candidatePages
.filter(params.canReadPage)
.map((page) => toWikiSearchResult(page, params.query, params.mode))
.filter((page) => page.score > 0);
if (candidatePaths.length === 0 || results.length >= params.maxResults) {
return results;
}
const remainingPaths = (await listWikiMarkdownFiles(rootDir)).filter(
(relativePath) => !seenPaths.has(relativePath),
);
const remainingPages = (await readQueryableWikiPagesByPaths(rootDir, remainingPaths)).filter(
params.canReadPage,
);
return [
...results,
...remainingPages
.map((page) => toWikiSearchResult(page, params.query, params.mode))
.filter((page) => page.score > 0),
];
}
function resolveDigestClaimLookup(digest: QueryDigestBundle, lookup: string): string | null {
const trimmed = lookup.trim();
const claimId = trimmed.replace(/^claim:/i, "");
const match = digest.claims.find((claim) => claim.id === claimId);
return match?.pagePath ?? null;
}
export function resolveQueryableWikiPageByLookup(
pages: QueryableWikiPage[],
lookup: string,
): QueryableWikiPage | null {
const key = normalizeLookupKey(lookup);
const withExtension = key.endsWith(".md") ? key : `${key}.md`;
return (
pages.find((page) => page.relativePath === key) ??
pages.find((page) => page.relativePath === withExtension) ??
pages.find((page) => page.relativePath.replace(/\.md$/i, "") === key) ??
pages.find((page) => path.basename(page.relativePath, ".md") === key) ??
pages.find((page) => page.id === key) ??
null
);
}
export async function searchMemoryWiki(input: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
agentId?: string;
agentSessionKey?: string;
sandboxed?: boolean;
conversationRecall?: ConversationRecallContext;
query: string;
maxResults?: number;
searchBackend?: WikiSearchBackend;
searchCorpus?: WikiSearchCorpus;
mode?: WikiSearchMode;
}): Promise<WikiSearchResult[]> {
const agentId = resolveActiveMemoryAgentId(input);
const params = agentId ? { ...input, agentId } : input;
const protectedSessionRecall = params.conversationRecall?.corpus === "sessions";
// Recall scope is runtime-owned; model corpus/backend overrides cannot widen it.
const effectiveConfig = applySearchOverrides(
params.config,
protectedSessionRecall
? { searchBackend: params.config.search.backend, searchCorpus: "memory" }
: params,
);
assertSessionVisibilityAppConfig({
config: effectiveConfig,
appConfig: params.appConfig,
...(params.agentId ? { agentId: params.agentId } : {}),
agentSessionKey: params.agentSessionKey,
sandboxed: params.sandboxed,
operation: "wiki_search",
});
await initializeMemoryWikiVault(effectiveConfig);
const maxResults = normalizePositiveInteger(params.maxResults, 10);
const mode = params.mode ?? "auto";
const wikiResults = shouldSearchWiki(effectiveConfig)
? await searchWikiCorpus({
config: effectiveConfig,
query: params.query,
maxResults,
mode,
canReadPage: createWikiPageVisibilityFilter(params),
})
: [];
const sharedMemoryManager = shouldSearchSharedMemory(effectiveConfig, params.appConfig)
? await resolveActiveMemoryManager({
appConfig: params.appConfig,
agentId: params.agentId,
agentSessionKey: params.agentSessionKey,
})
: null;
if (sharedMemoryManager && typeof sharedMemoryManager.search !== "function") {
throw buildMemoryManagerContractError("search");
}
let rawMemoryResults = sharedMemoryManager
? await sharedMemoryManager.search(params.query, {
maxResults,
...(protectedSessionRecall
? { sources: ["sessions" as const], sessionKey: params.agentSessionKey }
: {}),
})
: [];
if (
params.appConfig &&
shouldEnforceSessionVisibility(params) &&
(params.conversationRecall || rawMemoryResults.some((hit) => hit.source === "sessions"))
) {
rawMemoryResults = await filterMemorySearchHitsBySessionVisibility({
cfg: params.appConfig,
agentId: params.agentId,
requesterSessionKey: params.agentSessionKey,
sandboxed: params.sandboxed === true,
hits: rawMemoryResults,
conversationRecall: params.conversationRecall,
trustedAgentScope: !params.agentSessionKey && Boolean(params.agentId?.trim()),
});
}
const memoryResults = rawMemoryResults.map((result) => toMemoryWikiSearchResult(result, mode));
return mergeWikiSearchCorpusResults({
wikiResults,
memoryResults,
maxResults,
balanceCorpora: effectiveConfig.search.corpus === "all",
});
}
export async function getMemoryWikiPage(input: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
agentId?: string;
agentSessionKey?: string;
sandboxed?: boolean;
conversationRecall?: ConversationRecallContext;
lookup: string;
fromLine?: number;
lineCount?: number;
searchBackend?: WikiSearchBackend;
searchCorpus?: WikiSearchCorpus;
}): Promise<WikiGetResult | null> {
const agentId = resolveActiveMemoryAgentId(input);
const params = agentId ? { ...input, agentId } : input;
const effectiveConfig = applySearchOverrides(params.config, params);
assertSessionVisibilityAppConfig({
config: effectiveConfig,
appConfig: params.appConfig,
...(params.agentId ? { agentId: params.agentId } : {}),
agentSessionKey: params.agentSessionKey,
sandboxed: params.sandboxed,
operation: "wiki_get",
});
await initializeMemoryWikiVault(effectiveConfig);
const fromLine = normalizePositiveInteger(params.fromLine, 1);
const lineCount = normalizePositiveInteger(params.lineCount, 200);
if (shouldSearchWiki(effectiveConfig)) {
const canReadPage = createWikiPageVisibilityFilter(params);
const digest = await readQueryDigestBundle(effectiveConfig);
const digestClaimPagePath = digest ? resolveDigestClaimLookup(digest, params.lookup) : null;
const digestLookupPage = digestClaimPagePath
? ((
await readQueryableWikiPagesByPaths(effectiveConfig.vault.path, [digestClaimPagePath])
).find(canReadPage) ?? null)
: null;
const pages = digestLookupPage
? [digestLookupPage]
: (await readQueryableWikiPages(effectiveConfig.vault.path)).filter(canReadPage);
const page = digestLookupPage ?? resolveQueryableWikiPageByLookup(pages, params.lookup);
if (page) {
const parsed = parseWikiMarkdown(page.raw);
const lines = parsed.body.split(/\r?\n/);
const totalLines = lines.length;
const slice = lines.slice(fromLine - 1, fromLine - 1 + lineCount).join("\n");
const truncated = fromLine - 1 + lineCount < totalLines;
return {
corpus: "wiki",
path: page.relativePath,
title: page.title,
kind: page.kind,
content: slice,
fromLine,
lineCount,
totalLines,
truncated,
...buildWikiResultMetadata(page),
};
}
}
if (!shouldSearchSharedMemory(effectiveConfig, params.appConfig)) {
return null;
}
const manager = await resolveActiveMemoryManager({
appConfig: params.appConfig,
agentId: params.agentId,
agentSessionKey: params.agentSessionKey,
});
if (!manager) {
return null;
}
if (typeof manager.readFile !== "function") {
throw buildMemoryManagerContractError("readFile");
}
const lookupCandidates = buildLookupCandidates(params.lookup);
const visibleSessionPaths =
params.appConfig &&
shouldEnforceSessionVisibility(params) &&
lookupCandidates.some((relPath) => isSessionMemoryPath(relPath))
? new Set(
(
await filterMemorySearchHitsBySessionVisibility({
cfg: params.appConfig,
agentId: params.agentId,
requesterSessionKey: params.agentSessionKey,
sandboxed: params.sandboxed === true,
conversationRecall: params.conversationRecall,
trustedAgentScope: !params.agentSessionKey && Boolean(params.agentId?.trim()),
hits: lookupCandidates
.filter((relPath) => isSessionMemoryPath(relPath))
.map((relPath) => ({
path: relPath,
startLine: 1,
endLine: 1,
score: 0,
snippet: "",
source: "sessions" as const,
})),
})
).map((hit) => hit.path),
)
: null;
for (const relPath of lookupCandidates) {
// Raw session candidates still need visibility checks; memory readers accept Markdown only.
if (
!relPath.endsWith(".md") ||
(visibleSessionPaths && isSessionMemoryPath(relPath) && !visibleSessionPaths.has(relPath))
) {
continue;
}
const result = await manager.readFile({
relPath,
from: fromLine,
lines: lineCount,
});
if (result.status === "not_found") {
continue;
}
return {
corpus: "memory",
path: result.path,
title: buildMemorySearchTitle(result.path),
kind: "memory",
content: result.text,
fromLine,
lineCount,
};
}
return null;
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */