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 { 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, } 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"; type QueryDigestPage = { id?: string; title: string; kind: WikiPageSummary["kind"]; path: string; sourceIds: string[]; questions: string[]; contradictions: string[]; }; type QueryDigestClaim = { id?: string; pageId?: string; pageTitle: string; pageKind: WikiPageSummary["kind"]; pagePath: string; text: string; status?: string; confidence?: number; sourceIds?: string[]; freshnessLevel?: string; lastTouchedAt?: string; }; type QueryDigestBundle = { pages: QueryDigestPage[]; claims: QueryDigestClaim[]; }; export 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; }; export type WikiGetResult = { corpus: "wiki" | "memory"; path: string; title: string; kind: WikiPageSummary["kind"] | "memory"; content: string; fromLine: number; lineCount: number; id?: string; sourceType?: string; provenanceMode?: string; sourcePath?: string; provenanceLabel?: string; updatedAt?: string; }; export type QueryableWikiPage = WikiPageSummary & { raw: string; }; type QuerySearchOverrides = { searchBackend?: WikiSearchBackend; searchCorpus?: WikiSearchCorpus; }; async function listWikiMarkdownFiles(rootDir: string): Promise { 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 { const files = await listWikiMarkdownFiles(rootDir); return readQueryableWikiPagesByPaths(rootDir, files); } async function readQueryableWikiPagesByPaths( rootDir: string, files: string[], ): Promise { 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 { 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 matchingLine = raw .split(/\r?\n/) .find( (line) => normalizeLowercaseStringOrEmpty(line).includes(queryLower) && line.trim().length > 0, ); return ( matchingLine?.trim() || raw .split(/\r?\n/) .find((line) => line.trim().length > 0) ?.trim() || "" ); } function buildPageSearchText(page: QueryableWikiPage): string { return [ page.title, page.relativePath, page.id ?? "", page.sourceIds.join(" "), page.questions.join(" "), page.contradictions.join(" "), page.claims.map((claim) => claim.text).join(" "), page.claims.map((claim) => claim.id ?? "").join(" "), ] .filter(Boolean) .join("\n"); } function buildDigestPageSearchText(page: QueryDigestPage, claims: QueryDigestClaim[]): string { return [ page.title, page.path, page.id ?? "", page.sourceIds.join(" "), page.questions.join(" "), page.contradictions.join(" "), claims.map((claim) => claim.text).join(" "), claims.map((claim) => claim.id ?? "").join(" "), ] .filter(Boolean) .join("\n"); } function scoreDigestClaimMatch(claim: QueryDigestClaim, queryLower: string): number { let score = 0; if (normalizeLowercaseStringOrEmpty(claim.text).includes(queryLower)) { score += 25; } if (normalizeLowercaseStringOrEmpty(claim.id).includes(queryLower)) { score += 10; } if (typeof claim.confidence === "number") { score += Math.round(claim.confidence * 10); } switch (claim.freshnessLevel) { case "fresh": score += 8; break; case "aging": score += 4; break; case "stale": score -= 2; break; case "unknown": score -= 4; break; } score += isClaimContestedStatus(claim.status) ? -6 : 4; return score; } function buildDigestCandidatePaths(params: { digest: QueryDigestBundle; query: string; maxResults: number; }): string[] { const queryLower = normalizeLowercaseStringOrEmpty(params.query); const claimsByPage = new Map(); 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)) { return { path: page.path, score: 0 }; } let score = 1; const titleLower = normalizeLowercaseStringOrEmpty(page.title); const pathLower = normalizeLowercaseStringOrEmpty(page.path); const idLower = normalizeLowercaseStringOrEmpty(page.id); if (titleLower === queryLower) { score += 50; } else if (titleLower.includes(queryLower)) { score += 20; } if (pathLower.includes(queryLower)) { score += 10; } if (idLower.includes(queryLower)) { score += 20; } if ( page.sourceIds.some((sourceId) => normalizeLowercaseStringOrEmpty(sourceId).includes(queryLower), ) ) { score += 12; } const matchingClaims = claims .filter((claim) => { if (normalizeLowercaseStringOrEmpty(claim.text).includes(queryLower)) { return true; } return normalizeLowercaseStringOrEmpty(claim.id).includes(queryLower); }) .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); } 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): boolean { if (normalizeLowercaseStringOrEmpty(claim.text).includes(queryLower)) { return true; } return normalizeLowercaseStringOrEmpty(claim.id).includes(queryLower); } function rankClaimMatch(page: QueryableWikiPage, claim: WikiClaim, queryLower: string): number { let score = 0; if (normalizeLowercaseStringOrEmpty(claim.text).includes(queryLower)) { score += 25; } if (normalizeLowercaseStringOrEmpty(claim.id).includes(queryLower)) { score += 10; } if (typeof claim.confidence === "number") { score += Math.round(claim.confidence * 10); } const freshness = assessClaimFreshness({ page, claim }); switch (freshness.level) { case "fresh": score += 8; break; case "aging": score += 4; break; case "stale": score -= 2; break; case "unknown": score -= 4; break; } score += isClaimContestedStatus(claim.status) ? -6 : 4; return score; } function getMatchingClaims(page: QueryableWikiPage, queryLower: string): WikiClaim[] { return page.claims .filter((claim) => isClaimMatch(claim, queryLower)) .toSorted( (left, right) => rankClaimMatch(page, right, queryLower) - rankClaimMatch(page, left, queryLower), ); } 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): number { const queryLower = normalizeLowercaseStringOrEmpty(query); const titleLower = normalizeLowercaseStringOrEmpty(page.title); const pathLower = normalizeLowercaseStringOrEmpty(page.relativePath); const idLower = normalizeLowercaseStringOrEmpty(page.id); const metadataLower = normalizeLowercaseStringOrEmpty(buildPageSearchText(page)); const rawLower = normalizeLowercaseStringOrEmpty(page.raw); if ( !( titleLower.includes(queryLower) || pathLower.includes(queryLower) || idLower.includes(queryLower) || metadataLower.includes(queryLower) || rawLower.includes(queryLower) ) ) { return 0; } let score = 1; if (titleLower === queryLower) { score += 50; } else if (titleLower.includes(queryLower)) { score += 20; } if (pathLower.includes(queryLower)) { score += 10; } if (idLower.includes(queryLower)) { score += 20; } if ( page.sourceIds.some((sourceId) => normalizeLowercaseStringOrEmpty(sourceId).includes(queryLower), ) ) { score += 12; } const matchingClaims = getMatchingClaims(page, queryLower); if (matchingClaims.length > 0) { score += rankClaimMatch(page, matchingClaims[0], queryLower); score += Math.min(10, (matchingClaims.length - 1) * 2); } const bodyOccurrences = rawLower.split(queryLower).length - 1; score += Math.min(10, bodyOccurrences); 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 shouldSearchWiki(config: ResolvedMemoryWikiConfig): boolean { return config.search.corpus === "wiki" || config.search.corpus === "all"; } function shouldSearchSharedMemory( config: ResolvedMemoryWikiConfig, appConfig?: OpenClawConfig, ): boolean { return ( config.search.backend === "shared" && appConfig !== undefined && (config.search.corpus === "memory" || config.search.corpus === "all") ); } 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" >, ): 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 toWikiSearchResult(page: QueryableWikiPage, query: string): WikiSearchResult { return { corpus: "wiki", path: page.relativePath, title: page.title, kind: page.kind, score: scorePage(page, query), snippet: buildPageSnippet(page, query), ...(page.id ? { id: page.id } : {}), ...(page.sourceType ? { sourceType: page.sourceType } : {}), ...(page.provenanceMode ? { provenanceMode: page.provenanceMode } : {}), ...(page.sourcePath ? { sourcePath: page.sourcePath } : {}), ...(buildWikiProvenanceLabel(page) ? { provenanceLabel: buildWikiProvenanceLabel(page) } : {}), ...(page.updatedAt ? { updatedAt: page.updatedAt } : {}), }; } function toMemoryWikiSearchResult(result: MemorySearchResult): 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, ...(result.citation ? { citation: result.citation } : {}), }; } async function searchWikiCorpus(params: { rootDir: string; query: string; maxResults: number; }): Promise { const digest = await readQueryDigestBundle(params.rootDir); const candidatePaths = digest ? buildDigestCandidatePaths({ digest, query: params.query, maxResults: params.maxResults, }) : []; const seenPaths = new Set(); 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)) .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)) .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; query: string; maxResults?: number; searchBackend?: WikiSearchBackend; searchCorpus?: WikiSearchCorpus; }): Promise { const effectiveConfig = applySearchOverrides(params.config, params); await initializeMemoryWikiVault(effectiveConfig); const maxResults = Math.max(1, params.maxResults ?? 10); const wikiResults = shouldSearchWiki(effectiveConfig) ? await searchWikiCorpus({ rootDir: effectiveConfig.vault.path, query: params.query, maxResults, }) : []; const sharedMemoryManager = shouldSearchSharedMemory(effectiveConfig, params.appConfig) ? await resolveActiveMemoryManager({ appConfig: params.appConfig, agentId: params.agentId, agentSessionKey: params.agentSessionKey, }) : null; const memoryResults = sharedMemoryManager ? (await sharedMemoryManager.search(params.query, { maxResults })).map((result) => toMemoryWikiSearchResult(result), ) : []; return [...wikiResults, ...memoryResults] .toSorted((left, right) => { if (left.score !== right.score) { return right.score - left.score; } return left.title.localeCompare(right.title); }) .slice(0, maxResults); } export async function getMemoryWikiPage(params: { config: ResolvedMemoryWikiConfig; appConfig?: OpenClawConfig; agentId?: string; agentSessionKey?: string; lookup: string; fromLine?: number; lineCount?: number; searchBackend?: WikiSearchBackend; searchCorpus?: WikiSearchCorpus; }): Promise { const effectiveConfig = applySearchOverrides(params.config, params); 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 slice = lines.slice(fromLine - 1, fromLine - 1 + lineCount).join("\n"); return { corpus: "wiki", path: page.relativePath, title: page.title, kind: page.kind, content: slice, fromLine, lineCount, ...(page.id ? { id: page.id } : {}), ...(page.sourceType ? { sourceType: page.sourceType } : {}), ...(page.provenanceMode ? { provenanceMode: page.provenanceMode } : {}), ...(page.sourcePath ? { sourcePath: page.sourcePath } : {}), ...(buildWikiProvenanceLabel(page) ? { provenanceLabel: buildWikiProvenanceLabel(page) } : {}), ...(page.updatedAt ? { updatedAt: page.updatedAt } : {}), }; } } if (!shouldSearchSharedMemory(effectiveConfig, params.appConfig)) { return null; } const manager = await resolveActiveMemoryManager({ appConfig: params.appConfig, agentId: params.agentId, agentSessionKey: params.agentSessionKey, }); if (!manager) { return null; } for (const relPath of buildLookupCandidates(params.lookup)) { 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; }