Files
openclaw/extensions/memory-wiki/src/query.ts
Peter Steinberger d8f2645c2c refactor(memory-wiki): move compiled cache to plugin state (#110167)
* feat(plugin-sdk): prepare memory prompts asynchronously

* refactor(memory-wiki): move compiled cache to plugin state

* fix(plugin-sdk): export memory prompt preparer type

* refactor(plugin-sdk): keep prompt preparer type private

* test(memory-wiki): isolate compiled cache state

* chore(memory-wiki): satisfy changed gates

* fix(memory): preserve prepared prompts across context engines

* docs(plugin-sdk): refresh API baseline

* fix(memory): revalidate prepared prompt ownership

* fix(codex): propagate sandboxed memory context

* test(memory-wiki): seed durable vault identity

* refactor(memory-wiki): keep cache lifecycle internal

* fix(memory-wiki): harden compiled cache publication

* style(memory-wiki): format cache lifecycle changes

* test(memory-wiki): support extension test lib target

* style(memory-wiki): remove redundant catch

* refactor(memory-wiki): keep vault generation helper private
2026-07-18 03:05:03 +01:00

1594 lines
47 KiB
TypeScript

// Memory Wiki plugin module implements query behavior.
import fs from "node:fs/promises";
import path from "node:path";
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 {
extractTranscriptIdentityFromSessionsMemoryHit,
loadCombinedSessionStoreForGateway,
resolveTranscriptStemToSessionKeys,
} from "openclaw/plugin-sdk/session-transcript-hit";
import {
createAgentToAgentPolicy,
createSessionVisibilityGuard,
resolveEffectiveSessionToolsVisibility,
} from "openclaw/plugin-sdk/session-visibility";
import {
normalizeLowercaseStringOrEmpty,
uniqueStrings,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import pMap, { pMapSkip } from "p-map";
import type { OpenClawConfig } from "../api.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 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;
};
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 dirPath = path.join(rootDir, relativeDir);
const entries = await fs
.readdir(dirPath, { withFileTypes: true, recursive: true })
.catch(() => []);
return entries
.filter(
(entry) => entry.isFile() && entry.name.endsWith(".md") && entry.name !== "index.md",
)
.map((entry) => {
const absPath = path.join(entry.parentPath ?? dirPath, entry.name);
return path.relative(rootDir, absPath).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[]> {
return await pMap(
files,
async (relativePath) => {
const absolutePath = path.join(rootDir, relativePath);
const raw = await fs.readFile(absolutePath, "utf8");
const summary = toWikiPageSummary({ absolutePath, relativePath, raw });
return summary ? { ...summary, raw } : pMapSkip;
},
{ concurrency: QUERY_PAGE_READ_CONCURRENCY, stopOnError: true },
);
}
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];
if (matchingClaim) {
return matchingClaim.text;
}
return buildSnippet(page.raw, query);
}
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 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.`,
);
}
}
const SESSION_MEMORY_PATH_PREFIXES = ["sessions/", "qmd/sessions/", "qmd/sessions-"] as const;
const SESSION_MEMORY_ROOT_PATHS = ["qmd/sessions"] as const;
// 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 (
SESSION_MEMORY_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix)) ||
SESSION_MEMORY_ROOT_PATHS.some((rootPath) => normalized === rootPath)
);
}
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 filterMemoryWikiSearchHitsBySessionVisibility(params: {
cfg: OpenClawConfig;
agentId: string | undefined;
requesterSessionKey: string | undefined;
sandboxed: boolean;
hits: MemorySearchResult[];
}): Promise<MemorySearchResult[]> {
if (!params.hits.some((hit) => hit.source === "sessions")) {
return params.hits;
}
const canReadSessionPath = await createSessionMemoryPathVisibilityChecker({
cfg: params.cfg,
agentId: params.agentId,
requesterSessionKey: params.requesterSessionKey,
sandboxed: params.sandboxed,
});
return filterMemoryWikiSearchHitsWithSessionVisibility({
canReadSessionPath,
hits: params.hits,
});
}
type SessionMemoryPathVisibilityChecker = (relPath: string) => boolean;
function filterSessionKeysByScopedAgent(params: {
cfg: OpenClawConfig;
keys: string[];
scopedAgentId: string | undefined;
}): string[] {
const scopedAgentId = normalizeLowercaseStringOrEmpty(params.scopedAgentId);
if (!scopedAgentId) {
return params.keys;
}
return params.keys.filter((key) => {
if (params.cfg.session?.scope === "global" && key.trim().toLowerCase() === "global") {
return true;
}
const ownerAgentId = resolveSessionAgentId({
sessionKey: key,
config: params.cfg,
});
return normalizeLowercaseStringOrEmpty(ownerAgentId) === scopedAgentId;
});
}
async function createSessionMemoryPathVisibilityChecker(params: {
cfg: OpenClawConfig;
agentId: string | undefined;
requesterSessionKey: string | undefined;
sandboxed: boolean;
}): Promise<SessionMemoryPathVisibilityChecker> {
const visibility = resolveEffectiveSessionToolsVisibility({
cfg: params.cfg,
sandboxed: params.sandboxed,
});
const a2aPolicy = createAgentToAgentPolicy(params.cfg);
const requesterAgentId = params.requesterSessionKey
? resolveSessionAgentId({
sessionKey: params.requesterSessionKey,
config: params.cfg,
})
: undefined;
const scopedAgentId = params.agentId?.trim() || requesterAgentId;
const guard = params.requesterSessionKey
? await createSessionVisibilityGuard({
action: "history",
requesterSessionKey: params.requesterSessionKey,
visibility,
a2aPolicy,
})
: null;
const { store: combinedSessionStore } = loadCombinedSessionStoreForGateway(
params.cfg,
scopedAgentId ? { agentId: scopedAgentId } : {},
);
return (relPath) => {
const identity = extractTranscriptIdentityFromSessionsMemoryHit(relPath);
if (!identity) {
return false;
}
const isQmdSessionPath = relPath.replace(/\\/g, "/").startsWith("qmd/");
const normalizedScopedAgentId = normalizeLowercaseStringOrEmpty(scopedAgentId);
const normalizedOwnerAgentId = normalizeLowercaseStringOrEmpty(identity.ownerAgentId);
if (
normalizedScopedAgentId &&
normalizedOwnerAgentId &&
normalizedOwnerAgentId !== normalizedScopedAgentId
) {
return false;
}
const archivedOwnerMatchesScope = Boolean(
identity.archived &&
((identity.ownerAgentId &&
(!normalizedScopedAgentId || normalizedOwnerAgentId === normalizedScopedAgentId)) ||
(isQmdSessionPath && scopedAgentId)),
);
const archivedOwnerAgentId = archivedOwnerMatchesScope
? (identity.ownerAgentId ?? scopedAgentId)
: undefined;
const liveKeys = identity.liveStem
? resolveTranscriptStemToSessionKeys({
store: combinedSessionStore,
stem: identity.liveStem,
allowQmdSlugFallback: false,
})
: [];
const keys = filterSessionKeysByScopedAgent({
cfg: params.cfg,
scopedAgentId,
keys:
liveKeys.length > 0
? liveKeys
: resolveTranscriptStemToSessionKeys({
store: combinedSessionStore,
stem: identity.stem,
allowQmdSlugFallback: isQmdSessionPath && !identity.archived,
...(archivedOwnerAgentId ? { archivedOwnerAgentId } : {}),
}),
});
if (!guard) {
return Boolean(scopedAgentId && keys.length > 0);
}
return keys.some((key) => guard.check(key).allowed);
};
}
function filterMemoryWikiSearchHitsWithSessionVisibility(params: {
canReadSessionPath: SessionMemoryPathVisibilityChecker;
hits: MemorySearchResult[];
}): MemorySearchResult[] {
const next: MemorySearchResult[] = [];
for (const hit of params.hits) {
if (hit.source !== "sessions") {
next.push(hit);
continue;
}
if (params.canReadSessionPath(hit.path)) {
next.push(hit);
}
}
return next;
}
function canReadSessionMemoryPath(params: {
canReadSessionPath: SessionMemoryPathVisibilityChecker;
relPath: string;
}): boolean {
// Reuses the search filter with a synthetic hit; update this if the filter needs more than path/source.
const filtered = filterMemoryWikiSearchHitsWithSessionVisibility({
canReadSessionPath: params.canReadSessionPath,
hits: [
{
path: params.relPath,
startLine: 1,
endLine: 1,
score: 0,
snippet: "",
source: "sessions",
},
],
});
return filtered.length > 0;
}
async function searchWikiCorpus(params: {
config: ResolvedMemoryWikiConfig;
query: string;
maxResults: number;
mode: WikiSearchMode;
}): 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
.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);
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(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
agentId?: string;
agentSessionKey?: string;
sandboxed?: boolean;
query: string;
maxResults?: number;
searchBackend?: WikiSearchBackend;
searchCorpus?: WikiSearchCorpus;
mode?: WikiSearchMode;
}): Promise<WikiSearchResult[]> {
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_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,
})
: [];
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 })
: [];
if (
params.appConfig &&
shouldEnforceSessionVisibility(params) &&
rawMemoryResults.some((hit) => hit.source === "sessions")
) {
rawMemoryResults = await filterMemoryWikiSearchHitsBySessionVisibility({
cfg: params.appConfig,
agentId: params.agentId,
requesterSessionKey: params.agentSessionKey,
sandboxed: params.sandboxed === true,
hits: rawMemoryResults,
});
}
const memoryResults = rawMemoryResults.map((result) => toMemoryWikiSearchResult(result, mode));
return mergeWikiSearchCorpusResults({
wikiResults,
memoryResults,
maxResults,
balanceCorpora: effectiveConfig.search.corpus === "all",
});
}
export async function getMemoryWikiPage(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
agentId?: string;
agentSessionKey?: string;
sandboxed?: boolean;
lookup: string;
fromLine?: number;
lineCount?: number;
searchBackend?: WikiSearchBackend;
searchCorpus?: WikiSearchCorpus;
}): Promise<WikiGetResult | null> {
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 digest = await readQueryDigestBundle(effectiveConfig);
const digestClaimPagePath = digest ? resolveDigestClaimLookup(digest, params.lookup) : null;
const digestLookupPage = digestClaimPagePath
? ((
await readQueryableWikiPagesByPaths(effectiveConfig.vault.path, [digestClaimPagePath])
)[0] ?? null)
: null;
const pages = digestLookupPage
? [digestLookupPage]
: await readQueryableWikiPages(effectiveConfig.vault.path);
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 canReadSessionPath =
params.appConfig &&
shouldEnforceSessionVisibility(params) &&
lookupCandidates.some((relPath) => isSessionMemoryPath(relPath))
? await createSessionMemoryPathVisibilityChecker({
cfg: params.appConfig,
agentId: params.agentId,
requesterSessionKey: params.agentSessionKey,
sandboxed: params.sandboxed === true,
})
: null;
for (const relPath of lookupCandidates) {
if (
canReadSessionPath &&
isSessionMemoryPath(relPath) &&
!canReadSessionMemoryPath({
canReadSessionPath,
relPath,
})
) {
continue;
}
try {
const result = await manager.readFile({
relPath,
from: fromLine,
lines: lineCount,
});
return {
corpus: "memory",
path: result.path,
title: buildMemorySearchTitle(result.path),
kind: "memory",
content: result.text,
fromLine,
lineCount,
};
} catch {
continue;
}
}
return null;
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */