mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 12:30:43 +00:00
* fix(memory): enforce wiki session visibility Co-authored-by: zsx <git@zsxsoft.com> * fix(memory): cover wiki visibility follow-ups # Conflicts: # CHANGELOG.md * fix(memory): tighten wiki session visibility reads * docs(changelog): add memory wiki visibility entry --------- Co-authored-by: zsx <git@zsxsoft.com> Co-authored-by: Devin Robison <drobison@nvidia.com> Co-authored-by: Devin Robison <drobison00@users.noreply.github.com>
1552 lines
45 KiB
TypeScript
1552 lines
45 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { resolveDefaultAgentId, resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core";
|
|
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-host-files";
|
|
import { getActiveMemorySearchManager } from "openclaw/plugin-sdk/memory-host-search";
|
|
import {
|
|
extractTranscriptStemFromSessionsMemoryHit,
|
|
loadCombinedSessionStoreForGateway,
|
|
resolveTranscriptStemToSessionKeys,
|
|
} from "openclaw/plugin-sdk/session-transcript-hit";
|
|
import {
|
|
createAgentToAgentPolicy,
|
|
createSessionVisibilityGuard,
|
|
resolveEffectiveSessionToolsVisibility,
|
|
} from "openclaw/plugin-sdk/session-visibility";
|
|
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
|
import type { OpenClawConfig } from "../api.js";
|
|
import { assessClaimFreshness, isClaimContestedStatus } from "./claim-health.js";
|
|
import type { ResolvedMemoryWikiConfig, WikiSearchBackend, WikiSearchCorpus } from "./config.js";
|
|
import {
|
|
parseWikiMarkdown,
|
|
toWikiPageSummary,
|
|
type WikiClaim,
|
|
type WikiPageSummary,
|
|
type WikiRelationship,
|
|
} from "./markdown.js";
|
|
import { initializeMemoryWikiVault } from "./vault.js";
|
|
|
|
const QUERY_DIRS = ["entities", "concepts", "sources", "syntheses", "reports"] as const;
|
|
const AGENT_DIGEST_PATH = ".openclaw-wiki/cache/agent-digest.json";
|
|
const CLAIMS_DIGEST_PATH = ".openclaw-wiki/cache/claims.jsonl";
|
|
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",
|
|
]);
|
|
|
|
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 = {
|
|
id?: string;
|
|
title: string;
|
|
kind: WikiPageSummary["kind"];
|
|
path: string;
|
|
pageType?: string;
|
|
entityType?: string;
|
|
canonicalId?: string;
|
|
aliases?: string[];
|
|
sourceIds: string[];
|
|
questions: string[];
|
|
contradictions: string[];
|
|
privacyTier?: string;
|
|
personCard?: WikiPageSummary["personCard"];
|
|
bestUsedFor?: string[];
|
|
notEnoughFor?: string[];
|
|
relationshipCount?: number;
|
|
topRelationships?: WikiRelationship[];
|
|
};
|
|
|
|
type QueryDigestClaim = {
|
|
id?: string;
|
|
pageId?: string;
|
|
pageTitle: string;
|
|
pageKind: WikiPageSummary["kind"];
|
|
pagePath: string;
|
|
pageType?: string;
|
|
entityType?: string;
|
|
canonicalId?: string;
|
|
aliases?: string[];
|
|
text: string;
|
|
status?: string;
|
|
confidence?: number;
|
|
sourceIds?: string[];
|
|
evidenceKinds?: string[];
|
|
privacyTiers?: string[];
|
|
freshnessLevel?: string;
|
|
lastTouchedAt?: string;
|
|
};
|
|
|
|
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 }).catch(() => []);
|
|
return entries
|
|
.filter(
|
|
(entry) => entry.isFile() && entry.name.endsWith(".md") && entry.name !== "index.md",
|
|
)
|
|
.map((entry) => path.join(relativeDir, entry.name));
|
|
}),
|
|
)
|
|
).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 pages = await Promise.all(
|
|
files.map(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 } : null;
|
|
}),
|
|
);
|
|
return pages.flatMap((page) => (page ? [page] : []));
|
|
}
|
|
|
|
function parseClaimsDigest(raw: string): QueryDigestClaim[] {
|
|
return raw.split(/\r?\n/).flatMap((line) => {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) {
|
|
return [];
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(trimmed) as QueryDigestClaim;
|
|
if (!parsed || typeof parsed !== "object" || typeof parsed.pagePath !== "string") {
|
|
return [];
|
|
}
|
|
return [parsed];
|
|
} catch {
|
|
return [];
|
|
}
|
|
});
|
|
}
|
|
|
|
async function readQueryDigestBundle(rootDir: string): Promise<QueryDigestBundle | null> {
|
|
const [agentDigestRaw, claimsDigestRaw] = await Promise.all([
|
|
fs.readFile(path.join(rootDir, AGENT_DIGEST_PATH), "utf8").catch(() => null),
|
|
fs.readFile(path.join(rootDir, CLAIMS_DIGEST_PATH), "utf8").catch(() => null),
|
|
]);
|
|
if (!agentDigestRaw && !claimsDigestRaw) {
|
|
return null;
|
|
}
|
|
|
|
const pages = (() => {
|
|
if (!agentDigestRaw) {
|
|
return [];
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(agentDigestRaw) as { pages?: QueryDigestPage[] };
|
|
return Array.isArray(parsed.pages) ? parsed.pages : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
})();
|
|
const claims = claimsDigestRaw ? parseClaimsDigest(claimsDigestRaw) : [];
|
|
|
|
if (pages.length === 0 && claims.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return { pages, claims };
|
|
}
|
|
|
|
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),
|
|
);
|
|
if (matchingClaims.length > 0) {
|
|
score += scoreDigestClaimMatch(matchingClaims[0], 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);
|
|
if (matchingClaims.length > 0) {
|
|
score += rankClaimMatch(page, matchingClaims[0], 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 [...new Set([normalized, withExtension])];
|
|
}
|
|
|
|
function shouldEnforceSessionVisibility(params: {
|
|
agentSessionKey?: string;
|
|
sandboxed?: boolean;
|
|
}): boolean {
|
|
return params.sandboxed === true || Boolean(params.agentSessionKey?.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;
|
|
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.
|
|
export 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;
|
|
}
|
|
}
|
|
|
|
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: [...new Set(claim.evidence.flatMap((evidence) => evidence.kind ?? []))],
|
|
evidenceSourceIds: [...new Set(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;
|
|
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,
|
|
requesterSessionKey: params.requesterSessionKey,
|
|
sandboxed: params.sandboxed,
|
|
});
|
|
return filterMemoryWikiSearchHitsWithSessionVisibility({
|
|
canReadSessionPath,
|
|
hits: params.hits,
|
|
});
|
|
}
|
|
|
|
type SessionMemoryPathVisibilityChecker = (relPath: string) => boolean;
|
|
|
|
async function createSessionMemoryPathVisibilityChecker(params: {
|
|
cfg: OpenClawConfig;
|
|
requesterSessionKey: string | undefined;
|
|
sandboxed: boolean;
|
|
}): Promise<SessionMemoryPathVisibilityChecker> {
|
|
const visibility = resolveEffectiveSessionToolsVisibility({
|
|
cfg: params.cfg,
|
|
sandboxed: params.sandboxed,
|
|
});
|
|
const a2aPolicy = createAgentToAgentPolicy(params.cfg);
|
|
const guard = params.requesterSessionKey
|
|
? await createSessionVisibilityGuard({
|
|
action: "history",
|
|
requesterSessionKey: params.requesterSessionKey,
|
|
visibility,
|
|
a2aPolicy,
|
|
})
|
|
: null;
|
|
if (!guard) {
|
|
return () => false;
|
|
}
|
|
|
|
const { store: combinedSessionStore } = loadCombinedSessionStoreForGateway(params.cfg);
|
|
return (relPath) => {
|
|
const stem = extractTranscriptStemFromSessionsMemoryHit(relPath);
|
|
if (!stem) {
|
|
return false;
|
|
}
|
|
const keys = resolveTranscriptStemToSessionKeys({
|
|
store: combinedSessionStore,
|
|
stem,
|
|
});
|
|
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: {
|
|
rootDir: string;
|
|
query: string;
|
|
maxResults: number;
|
|
mode: WikiSearchMode;
|
|
}): Promise<WikiSearchResult[]> {
|
|
const digest = await readQueryDigestBundle(params.rootDir);
|
|
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(params.rootDir, candidatePaths)
|
|
: await readQueryableWikiPages(params.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(params.rootDir)).filter(
|
|
(relativePath) => !seenPaths.has(relativePath),
|
|
);
|
|
const remainingPages = await readQueryableWikiPagesByPaths(params.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,
|
|
agentSessionKey: params.agentSessionKey,
|
|
sandboxed: params.sandboxed,
|
|
operation: "wiki_search",
|
|
});
|
|
await initializeMemoryWikiVault(effectiveConfig);
|
|
const maxResults = Math.max(1, params.maxResults ?? 10);
|
|
const mode = params.mode ?? "auto";
|
|
|
|
const wikiResults = shouldSearchWiki(effectiveConfig)
|
|
? await searchWikiCorpus({
|
|
rootDir: effectiveConfig.vault.path,
|
|
query: params.query,
|
|
maxResults,
|
|
mode,
|
|
})
|
|
: [];
|
|
|
|
const sharedMemoryManager = shouldSearchSharedMemory(effectiveConfig, params.appConfig)
|
|
? await resolveActiveMemoryManager({
|
|
appConfig: params.appConfig,
|
|
agentId: params.agentId,
|
|
agentSessionKey: params.agentSessionKey,
|
|
})
|
|
: null;
|
|
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,
|
|
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,
|
|
agentSessionKey: params.agentSessionKey,
|
|
sandboxed: params.sandboxed,
|
|
operation: "wiki_get",
|
|
});
|
|
await initializeMemoryWikiVault(effectiveConfig);
|
|
const fromLine = Math.max(1, params.fromLine ?? 1);
|
|
const lineCount = Math.max(1, params.lineCount ?? 200);
|
|
|
|
if (shouldSearchWiki(effectiveConfig)) {
|
|
const digest = await readQueryDigestBundle(effectiveConfig.vault.path);
|
|
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;
|
|
}
|
|
|
|
const lookupCandidates = buildLookupCandidates(params.lookup);
|
|
const canReadSessionPath =
|
|
params.appConfig &&
|
|
shouldEnforceSessionVisibility(params) &&
|
|
lookupCandidates.some((relPath) => isSessionMemoryPath(relPath))
|
|
? await createSessionMemoryPathVisibilityChecker({
|
|
cfg: params.appConfig,
|
|
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;
|
|
}
|