feat(memory-wiki): allow per-call search corpus overrides

This commit is contained in:
Vincent Koc
2026-04-05 21:45:55 +01:00
parent a2a9fa7f6f
commit 64f889cd4b
5 changed files with 232 additions and 15 deletions

View File

@@ -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<T extends string>(
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<string> {
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<NodeJS.WriteStream, "write">;
}) {
@@ -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<NodeJS.WriteStream, "write">;
}) {
@@ -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("<query>", "Search query")
.option("--max-results <n>", "Maximum results", (value: string) => Number(value))
.option(
"--backend <backend>",
`Search backend (${WIKI_SEARCH_BACKENDS.join(", ")})`,
(value: string) => parseWikiSearchEnumOption(value, WIKI_SEARCH_BACKENDS, "backend"),
)
.option(
"--corpus <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("<lookup>", "Relative path or page id")
.option("--from <n>", "Start line", (value: string) => Number(value))
.option("--lines <n>", "Number of lines", (value: string) => Number(value))
.option(
"--backend <backend>",
`Search backend (${WIKI_SEARCH_BACKENDS.join(", ")})`,
(value: string) => parseWikiSearchEnumOption(value, WIKI_SEARCH_BACKENDS, "backend"),
)
.option(
"--corpus <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,
});
});

View File

@@ -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<string, unknown>, key: string): number |
return undefined;
}
function readEnumParam<T extends string>(
params: Record<string, unknown>,
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<OpenClawPluginApi["registerGatewayMethod"]>[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) {

View File

@@ -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();
});
});

View File

@@ -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<string[]> {
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<WikiSearchResult[]> {
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<WikiGetResult | null> {
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;
}

View File

@@ -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 {