diff --git a/extensions/memory-wiki/src/cli.ts b/extensions/memory-wiki/src/cli.ts index 20b9302e4c1..27b9050c973 100644 --- a/extensions/memory-wiki/src/cli.ts +++ b/extensions/memory-wiki/src/cli.ts @@ -3,8 +3,13 @@ import type { Command } from "commander"; import type { OpenClawConfig } from "../api.js"; import { applyMemoryWikiMutation } from "./apply.js"; import { compileMemoryWikiVault } from "./compile.js"; -import type { MemoryWikiPluginConfig, ResolvedMemoryWikiConfig } from "./config.js"; -import { resolveMemoryWikiConfig } from "./config.js"; +import { + resolveMemoryWikiConfig, + WIKI_SEARCH_BACKENDS, + WIKI_SEARCH_CORPORA, + type MemoryWikiPluginConfig, + type ResolvedMemoryWikiConfig, +} from "./config.js"; import { ingestMemoryWikiSource } from "./ingest.js"; import { lintMemoryWikiVault } from "./lint.js"; import { @@ -52,12 +57,16 @@ type WikiIngestCommandOptions = { type WikiSearchCommandOptions = { json?: boolean; maxResults?: number; + backend?: ResolvedMemoryWikiConfig["search"]["backend"]; + corpus?: ResolvedMemoryWikiConfig["search"]["corpus"]; }; type WikiGetCommandOptions = { json?: boolean; from?: number; lines?: number; + backend?: ResolvedMemoryWikiConfig["search"]["backend"]; + corpus?: ResolvedMemoryWikiConfig["search"]["corpus"]; }; type WikiApplySynthesisCommandOptions = { @@ -133,6 +142,17 @@ function normalizeCliStringList(values?: string[]): string[] | undefined { return normalized.length > 0 ? normalized : undefined; } +function parseWikiSearchEnumOption( + value: string, + allowed: readonly T[], + label: string, +): T { + if ((allowed as readonly string[]).includes(value)) { + return value as T; + } + throw new Error(`Invalid ${label}: ${value}. Expected one of: ${allowed.join(", ")}`); +} + async function resolveWikiApplyBody(params: { body?: string; bodyFile?: string }): Promise { if (params.body?.trim()) { return params.body; @@ -243,6 +263,8 @@ export async function runWikiSearch(params: { appConfig?: OpenClawConfig; query: string; maxResults?: number; + searchBackend?: ResolvedMemoryWikiConfig["search"]["backend"]; + searchCorpus?: ResolvedMemoryWikiConfig["search"]["corpus"]; json?: boolean; stdout?: Pick; }) { @@ -252,6 +274,8 @@ export async function runWikiSearch(params: { appConfig: params.appConfig, query: params.query, maxResults: params.maxResults, + searchBackend: params.searchBackend, + searchCorpus: params.searchCorpus, }); const summary = params.json ? JSON.stringify(results, null, 2) @@ -273,6 +297,8 @@ export async function runWikiGet(params: { lookup: string; fromLine?: number; lineCount?: number; + searchBackend?: ResolvedMemoryWikiConfig["search"]["backend"]; + searchCorpus?: ResolvedMemoryWikiConfig["search"]["corpus"]; json?: boolean; stdout?: Pick; }) { @@ -283,6 +309,8 @@ export async function runWikiGet(params: { lookup: params.lookup, fromLine: params.fromLine, lineCount: params.lineCount, + searchBackend: params.searchBackend, + searchCorpus: params.searchCorpus, }); const summary = params.json ? JSON.stringify(result, null, 2) @@ -545,6 +573,16 @@ export function registerWikiCli( .description("Search wiki pages and, when configured, the active memory corpus") .argument("", "Search query") .option("--max-results ", "Maximum results", (value: string) => Number(value)) + .option( + "--backend ", + `Search backend (${WIKI_SEARCH_BACKENDS.join(", ")})`, + (value: string) => parseWikiSearchEnumOption(value, WIKI_SEARCH_BACKENDS, "backend"), + ) + .option( + "--corpus ", + `Search corpus (${WIKI_SEARCH_CORPORA.join(", ")})`, + (value: string) => parseWikiSearchEnumOption(value, WIKI_SEARCH_CORPORA, "corpus"), + ) .option("--json", "Print JSON") .action(async (query: string, opts: WikiSearchCommandOptions) => { await runWikiSearch({ @@ -552,6 +590,8 @@ export function registerWikiCli( appConfig, query, maxResults: opts.maxResults, + searchBackend: opts.backend, + searchCorpus: opts.corpus, json: opts.json, }); }); @@ -562,6 +602,16 @@ export function registerWikiCli( .argument("", "Relative path or page id") .option("--from ", "Start line", (value: string) => Number(value)) .option("--lines ", "Number of lines", (value: string) => Number(value)) + .option( + "--backend ", + `Search backend (${WIKI_SEARCH_BACKENDS.join(", ")})`, + (value: string) => parseWikiSearchEnumOption(value, WIKI_SEARCH_BACKENDS, "backend"), + ) + .option( + "--corpus ", + `Search corpus (${WIKI_SEARCH_CORPORA.join(", ")})`, + (value: string) => parseWikiSearchEnumOption(value, WIKI_SEARCH_CORPORA, "corpus"), + ) .option("--json", "Print JSON") .action(async (lookup: string, opts: WikiGetCommandOptions) => { await runWikiGet({ @@ -570,6 +620,8 @@ export function registerWikiCli( lookup, fromLine: opts.from, lineCount: opts.lines, + searchBackend: opts.backend, + searchCorpus: opts.corpus, json: opts.json, }); }); diff --git a/extensions/memory-wiki/src/gateway.ts b/extensions/memory-wiki/src/gateway.ts index 4fd3ef58e97..e30059e4f34 100644 --- a/extensions/memory-wiki/src/gateway.ts +++ b/extensions/memory-wiki/src/gateway.ts @@ -1,7 +1,11 @@ import type { OpenClawConfig, OpenClawPluginApi } from "../api.js"; import { applyMemoryWikiMutation, normalizeMemoryWikiMutationInput } from "./apply.js"; import { compileMemoryWikiVault } from "./compile.js"; -import type { ResolvedMemoryWikiConfig } from "./config.js"; +import { + WIKI_SEARCH_BACKENDS, + WIKI_SEARCH_CORPORA, + type ResolvedMemoryWikiConfig, +} from "./config.js"; import { ingestMemoryWikiSource } from "./ingest.js"; import { lintMemoryWikiVault } from "./lint.js"; import { @@ -48,6 +52,21 @@ function readNumberParam(params: Record, key: string): number | return undefined; } +function readEnumParam( + params: Record, + key: string, + allowed: readonly T[], +): T | undefined { + const value = readStringParam(params, key); + if (!value) { + return undefined; + } + if ((allowed as readonly string[]).includes(value)) { + return value as T; + } + throw new Error(`${key} must be one of: ${allowed.join(", ")}.`); +} + function respondError( respond: Parameters[1] extends ( ctx: infer T, @@ -203,6 +222,8 @@ export function registerMemoryWikiGatewayMethods(params: { await syncImportedSourcesIfNeeded(config, appConfig); const query = readStringParam(requestParams, "query", { required: true }); const maxResults = readNumberParam(requestParams, "maxResults"); + const searchBackend = readEnumParam(requestParams, "backend", WIKI_SEARCH_BACKENDS); + const searchCorpus = readEnumParam(requestParams, "corpus", WIKI_SEARCH_CORPORA); respond( true, await searchMemoryWiki({ @@ -210,6 +231,8 @@ export function registerMemoryWikiGatewayMethods(params: { appConfig, query, maxResults, + searchBackend, + searchCorpus, }), ); } catch (error) { @@ -246,6 +269,8 @@ export function registerMemoryWikiGatewayMethods(params: { const lookup = readStringParam(requestParams, "lookup", { required: true }); const fromLine = readNumberParam(requestParams, "fromLine"); const lineCount = readNumberParam(requestParams, "lineCount"); + const searchBackend = readEnumParam(requestParams, "backend", WIKI_SEARCH_BACKENDS); + const searchCorpus = readEnumParam(requestParams, "corpus", WIKI_SEARCH_CORPORA); respond( true, await getMemoryWikiPage({ @@ -254,6 +279,8 @@ export function registerMemoryWikiGatewayMethods(params: { lookup, fromLine, lineCount, + searchBackend, + searchCorpus, }), ); } catch (error) { diff --git a/extensions/memory-wiki/src/query.test.ts b/extensions/memory-wiki/src/query.test.ts index 915ebc118eb..a5612fb17c2 100644 --- a/extensions/memory-wiki/src/query.test.ts +++ b/extensions/memory-wiki/src/query.test.ts @@ -173,6 +173,51 @@ describe("searchMemoryWiki", () => { expect(manager.search).toHaveBeenCalledWith("alpha", { maxResults: 5 }); }); + it("allows per-call corpus overrides without changing config defaults", async () => { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-query-")); + tempDirs.push(rootDir); + const config = resolveMemoryWikiConfig( + { + vault: { path: rootDir }, + search: { backend: "shared", corpus: "wiki" }, + }, + { homedir: "/Users/tester" }, + ); + await initializeMemoryWikiVault(config); + await fs.writeFile( + path.join(rootDir, "sources", "alpha.md"), + renderWikiMarkdown({ + frontmatter: { pageType: "source", id: "source.alpha", title: "Alpha Source" }, + body: "# Alpha Source\n\nalpha body text\n", + }), + "utf8", + ); + const manager = createMemoryManager({ + searchResults: [ + { + path: "MEMORY.md", + startLine: 10, + endLine: 12, + score: 99, + snippet: "memory-only alpha", + source: "memory", + }, + ], + }); + getActiveMemorySearchManagerMock.mockResolvedValue({ manager }); + + const memoryOnly = await searchMemoryWiki({ + config, + appConfig: createAppConfig(), + query: "alpha", + searchCorpus: "memory", + }); + + expect(memoryOnly).toHaveLength(1); + expect(memoryOnly[0]?.corpus).toBe("memory"); + expect(manager.search).toHaveBeenCalledWith("alpha", { maxResults: 10 }); + }); + it("keeps memory search disabled when the backend is local", async () => { const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-query-")); tempDirs.push(rootDir); @@ -335,4 +380,43 @@ describe("getMemoryWikiPage", () => { lines: 2, }); }); + + it("allows per-call get overrides to bypass wiki and force memory fallback", async () => { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-query-")); + tempDirs.push(rootDir); + const config = resolveMemoryWikiConfig( + { + vault: { path: rootDir }, + search: { backend: "shared", corpus: "wiki" }, + }, + { homedir: "/Users/tester" }, + ); + await initializeMemoryWikiVault(config); + await fs.writeFile( + path.join(rootDir, "sources", "MEMORY.md"), + renderWikiMarkdown({ + frontmatter: { pageType: "source", id: "source.memory.shadow", title: "Shadow Memory" }, + body: "# Shadow Memory\n\nwiki copy\n", + }), + "utf8", + ); + const manager = createMemoryManager({ + readResult: { + path: "MEMORY.md", + text: "forced memory read", + }, + }); + getActiveMemorySearchManagerMock.mockResolvedValue({ manager }); + + const result = await getMemoryWikiPage({ + config, + appConfig: createAppConfig(), + lookup: "MEMORY.md", + searchCorpus: "memory", + }); + + expect(result?.corpus).toBe("memory"); + expect(result?.content).toBe("forced memory read"); + expect(manager.readFile).toHaveBeenCalled(); + }); }); diff --git a/extensions/memory-wiki/src/query.ts b/extensions/memory-wiki/src/query.ts index a2abe9e3684..e46d5a5a3e6 100644 --- a/extensions/memory-wiki/src/query.ts +++ b/extensions/memory-wiki/src/query.ts @@ -4,7 +4,7 @@ import { resolveDefaultAgentId } from "openclaw/plugin-sdk/config-runtime"; import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-host-files"; import { getActiveMemorySearchManager } from "openclaw/plugin-sdk/memory-host-search"; import type { OpenClawConfig } from "../api.js"; -import type { ResolvedMemoryWikiConfig } from "./config.js"; +import type { ResolvedMemoryWikiConfig, WikiSearchBackend, WikiSearchCorpus } from "./config.js"; import { parseWikiMarkdown, toWikiPageSummary, type WikiPageSummary } from "./markdown.js"; import { initializeMemoryWikiVault } from "./vault.js"; @@ -49,6 +49,11 @@ export type QueryableWikiPage = WikiPageSummary & { raw: string; }; +type QuerySearchOverrides = { + searchBackend?: WikiSearchBackend; + searchCorpus?: WikiSearchCorpus; +}; + async function listWikiMarkdownFiles(rootDir: string): Promise { const files = ( await Promise.all( @@ -174,6 +179,22 @@ function buildMemorySearchTitle(resultPath: string): string { 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, @@ -249,17 +270,20 @@ export async function searchMemoryWiki(params: { appConfig?: OpenClawConfig; query: string; maxResults?: number; + searchBackend?: WikiSearchBackend; + searchCorpus?: WikiSearchCorpus; }): Promise { - await initializeMemoryWikiVault(params.config); + const effectiveConfig = applySearchOverrides(params.config, params); + await initializeMemoryWikiVault(effectiveConfig); const maxResults = Math.max(1, params.maxResults ?? 10); - const wikiResults = shouldSearchWiki(params.config) - ? (await readQueryableWikiPages(params.config.vault.path)) + const wikiResults = shouldSearchWiki(effectiveConfig) + ? (await readQueryableWikiPages(effectiveConfig.vault.path)) .map((page) => toWikiSearchResult(page, params.query)) .filter((page) => page.score > 0) : []; - const sharedMemoryManager = shouldSearchSharedMemory(params.config, params.appConfig) + const sharedMemoryManager = shouldSearchSharedMemory(effectiveConfig, params.appConfig) ? await resolveActiveMemoryManager(params.appConfig) : null; const memoryResults = sharedMemoryManager @@ -284,13 +308,16 @@ export async function getMemoryWikiPage(params: { lookup: string; fromLine?: number; lineCount?: number; + searchBackend?: WikiSearchBackend; + searchCorpus?: WikiSearchCorpus; }): Promise { - await initializeMemoryWikiVault(params.config); + 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(params.config)) { - const pages = await readQueryableWikiPages(params.config.vault.path); + if (shouldSearchWiki(effectiveConfig)) { + const pages = await readQueryableWikiPages(effectiveConfig.vault.path); const page = resolveQueryableWikiPageByLookup(pages, params.lookup); if (page) { const parsed = parseWikiMarkdown(page.raw); @@ -317,7 +344,7 @@ export async function getMemoryWikiPage(params: { } } - if (!shouldSearchSharedMemory(params.config, params.appConfig)) { + if (!shouldSearchSharedMemory(effectiveConfig, params.appConfig)) { return null; } diff --git a/extensions/memory-wiki/src/tool.ts b/extensions/memory-wiki/src/tool.ts index bad94ac7f71..0b8fa7c9a78 100644 --- a/extensions/memory-wiki/src/tool.ts +++ b/extensions/memory-wiki/src/tool.ts @@ -1,7 +1,11 @@ import { Type } from "@sinclair/typebox"; import type { AnyAgentTool, OpenClawConfig } from "../api.js"; import { applyMemoryWikiMutation, normalizeMemoryWikiMutationInput } from "./apply.js"; -import type { ResolvedMemoryWikiConfig } from "./config.js"; +import { + WIKI_SEARCH_BACKENDS, + WIKI_SEARCH_CORPORA, + type ResolvedMemoryWikiConfig, +} from "./config.js"; import { lintMemoryWikiVault } from "./lint.js"; import { getMemoryWikiPage, searchMemoryWiki } from "./query.js"; import { syncMemoryWikiImportedSources } from "./source-sync.js"; @@ -9,10 +13,16 @@ import { renderMemoryWikiStatus, resolveMemoryWikiStatus } from "./status.js"; const WikiStatusSchema = Type.Object({}, { additionalProperties: false }); const WikiLintSchema = Type.Object({}, { additionalProperties: false }); +const WikiSearchBackendSchema = Type.Union( + WIKI_SEARCH_BACKENDS.map((value) => Type.Literal(value)), +); +const WikiSearchCorpusSchema = Type.Union(WIKI_SEARCH_CORPORA.map((value) => Type.Literal(value))); const WikiSearchSchema = Type.Object( { query: Type.String({ minLength: 1 }), maxResults: Type.Optional(Type.Number({ minimum: 1 })), + backend: Type.Optional(WikiSearchBackendSchema), + corpus: Type.Optional(WikiSearchCorpusSchema), }, { additionalProperties: false }, ); @@ -21,6 +31,8 @@ const WikiGetSchema = Type.Object( lookup: Type.String({ minLength: 1 }), fromLine: Type.Optional(Type.Number({ minimum: 1 })), lineCount: Type.Optional(Type.Number({ minimum: 1 })), + backend: Type.Optional(WikiSearchBackendSchema), + corpus: Type.Optional(WikiSearchCorpusSchema), }, { additionalProperties: false }, ); @@ -78,13 +90,20 @@ export function createWikiSearchTool( "Search wiki pages and, when shared search is enabled, the active memory corpus by title, path, id, or body text.", parameters: WikiSearchSchema, execute: async (_toolCallId, rawParams) => { - const params = rawParams as { query: string; maxResults?: number }; + const params = rawParams as { + query: string; + maxResults?: number; + backend?: ResolvedMemoryWikiConfig["search"]["backend"]; + corpus?: ResolvedMemoryWikiConfig["search"]["corpus"]; + }; await syncImportedSourcesIfNeeded(config, appConfig); const results = await searchMemoryWiki({ config, appConfig, query: params.query, maxResults: params.maxResults, + ...(params.backend ? { searchBackend: params.backend } : {}), + ...(params.corpus ? { searchCorpus: params.corpus } : {}), }); const text = results.length === 0 @@ -182,7 +201,13 @@ export function createWikiGetTool( "Read a wiki page by id or relative path, or fall back to the active memory corpus when shared search is enabled.", parameters: WikiGetSchema, execute: async (_toolCallId, rawParams) => { - const params = rawParams as { lookup: string; fromLine?: number; lineCount?: number }; + const params = rawParams as { + lookup: string; + fromLine?: number; + lineCount?: number; + backend?: ResolvedMemoryWikiConfig["search"]["backend"]; + corpus?: ResolvedMemoryWikiConfig["search"]["corpus"]; + }; await syncImportedSourcesIfNeeded(config, appConfig); const result = await getMemoryWikiPage({ config, @@ -190,6 +215,8 @@ export function createWikiGetTool( lookup: params.lookup, fromLine: params.fromLine, lineCount: params.lineCount, + ...(params.backend ? { searchBackend: params.backend } : {}), + ...(params.corpus ? { searchCorpus: params.corpus } : {}), }); if (!result) { return {