From 82710f2adde82e47ac7afe55df19cbdf6af0f462 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 5 Apr 2026 20:44:05 +0100 Subject: [PATCH] feat(memory-wiki): add wiki search and get surfaces --- extensions/memory-wiki/index.test.ts | 8 +- extensions/memory-wiki/index.ts | 4 +- .../skills/wiki-maintainer/SKILL.md | 1 + extensions/memory-wiki/src/cli.ts | 91 +++++++++ extensions/memory-wiki/src/query.test.ts | 71 +++++++ extensions/memory-wiki/src/query.ts | 184 ++++++++++++++++++ extensions/memory-wiki/src/tool.ts | 74 +++++++ 7 files changed, 430 insertions(+), 3 deletions(-) create mode 100644 extensions/memory-wiki/src/query.test.ts create mode 100644 extensions/memory-wiki/src/query.ts diff --git a/extensions/memory-wiki/index.test.ts b/extensions/memory-wiki/index.test.ts index 6bf65a4bd6d..3b9e6bd03aa 100644 --- a/extensions/memory-wiki/index.test.ts +++ b/extensions/memory-wiki/index.test.ts @@ -24,8 +24,12 @@ describe("memory-wiki plugin", () => { await plugin.register(api); - expect(registerTool).toHaveBeenCalledTimes(1); - expect(registerTool.mock.calls[0]?.[1]).toMatchObject({ name: "wiki_status" }); + expect(registerTool).toHaveBeenCalledTimes(3); + expect(registerTool.mock.calls.map((call) => call[1]?.name)).toEqual([ + "wiki_status", + "wiki_search", + "wiki_get", + ]); expect(registerCli).toHaveBeenCalledTimes(1); expect(registerCli.mock.calls[0]?.[1]).toMatchObject({ descriptors: [ diff --git a/extensions/memory-wiki/index.ts b/extensions/memory-wiki/index.ts index 00a8ca22197..f7c832050e2 100644 --- a/extensions/memory-wiki/index.ts +++ b/extensions/memory-wiki/index.ts @@ -1,7 +1,7 @@ import { definePluginEntry } from "./api.js"; import { registerWikiCli } from "./src/cli.js"; import { memoryWikiConfigSchema, resolveMemoryWikiConfig } from "./src/config.js"; -import { createWikiStatusTool } from "./src/tool.js"; +import { createWikiGetTool, createWikiSearchTool, createWikiStatusTool } from "./src/tool.js"; export default definePluginEntry({ id: "memory-wiki", @@ -12,6 +12,8 @@ export default definePluginEntry({ const config = resolveMemoryWikiConfig(api.pluginConfig); api.registerTool(createWikiStatusTool(config), { name: "wiki_status" }); + api.registerTool(createWikiSearchTool(config), { name: "wiki_search" }); + api.registerTool(createWikiGetTool(config), { name: "wiki_get" }); api.registerCli( ({ program }) => { registerWikiCli(program, config); diff --git a/extensions/memory-wiki/skills/wiki-maintainer/SKILL.md b/extensions/memory-wiki/skills/wiki-maintainer/SKILL.md index b75c11d6f90..8c1cdc97c82 100644 --- a/extensions/memory-wiki/skills/wiki-maintainer/SKILL.md +++ b/extensions/memory-wiki/skills/wiki-maintainer/SKILL.md @@ -6,6 +6,7 @@ description: Maintain the OpenClaw memory wiki vault with deterministic pages, m Use this skill when working inside a memory-wiki vault. - Prefer `wiki_status` first when you need to understand the vault mode, path, or Obsidian CLI availability. +- Use `wiki_search` to discover candidate pages, then `wiki_get` to inspect the exact page before editing or citing it. - Use `openclaw wiki ingest`, `openclaw wiki compile`, and `openclaw wiki lint` as the default maintenance loop. - Keep generated sections inside managed markers. Do not overwrite human note blocks. - Treat raw sources, memory artifacts, and daily notes as evidence. Do not let wiki pages become the only source of truth for new claims. diff --git a/extensions/memory-wiki/src/cli.ts b/extensions/memory-wiki/src/cli.ts index 198eec6b999..9089fa2ccd4 100644 --- a/extensions/memory-wiki/src/cli.ts +++ b/extensions/memory-wiki/src/cli.ts @@ -4,6 +4,7 @@ import type { MemoryWikiPluginConfig, ResolvedMemoryWikiConfig } from "./config. import { resolveMemoryWikiConfig } from "./config.js"; import { ingestMemoryWikiSource } from "./ingest.js"; import { lintMemoryWikiVault } from "./lint.js"; +import { getMemoryWikiPage, searchMemoryWiki } from "./query.js"; import { renderMemoryWikiStatus, resolveMemoryWikiStatus } from "./status.js"; import { initializeMemoryWikiVault } from "./vault.js"; @@ -28,6 +29,17 @@ type WikiIngestCommandOptions = { title?: string; }; +type WikiSearchCommandOptions = { + json?: boolean; + maxResults?: number; +}; + +type WikiGetCommandOptions = { + json?: boolean; + from?: number; + lines?: number; +}; + function writeOutput(output: string, writer: Pick = process.stdout) { writer.write(output.endsWith("\n") ? output : `${output}\n`); } @@ -103,6 +115,53 @@ export async function runWikiIngest(params: { return result; } +export async function runWikiSearch(params: { + config: ResolvedMemoryWikiConfig; + query: string; + maxResults?: number; + json?: boolean; + stdout?: Pick; +}) { + const results = await searchMemoryWiki({ + config: params.config, + query: params.query, + maxResults: params.maxResults, + }); + const summary = params.json + ? JSON.stringify(results, null, 2) + : results.length === 0 + ? "No wiki results." + : results + .map( + (result, index) => + `${index + 1}. ${result.title} (${result.kind})\nPath: ${result.path}\nSnippet: ${result.snippet}`, + ) + .join("\n\n"); + writeOutput(summary, params.stdout); + return results; +} + +export async function runWikiGet(params: { + config: ResolvedMemoryWikiConfig; + lookup: string; + fromLine?: number; + lineCount?: number; + json?: boolean; + stdout?: Pick; +}) { + const result = await getMemoryWikiPage({ + config: params.config, + lookup: params.lookup, + fromLine: params.fromLine, + lineCount: params.lineCount, + }); + const summary = params.json + ? JSON.stringify(result, null, 2) + : (result?.content ?? `Wiki page not found: ${params.lookup}`); + writeOutput(summary, params.stdout); + return result; +} + export function registerWikiCli(program: Command, pluginConfig?: MemoryWikiPluginConfig) { const config = resolveMemoryWikiConfig(pluginConfig); const wiki = program.command("wiki").description("Inspect and initialize the memory wiki vault"); @@ -148,4 +207,36 @@ export function registerWikiCli(program: Command, pluginConfig?: MemoryWikiPlugi .action(async (inputPath: string, opts: WikiIngestCommandOptions) => { await runWikiIngest({ config, inputPath, title: opts.title, json: opts.json }); }); + + wiki + .command("search") + .description("Search wiki pages") + .argument("", "Search query") + .option("--max-results ", "Maximum results", (value: string) => Number(value)) + .option("--json", "Print JSON") + .action(async (query: string, opts: WikiSearchCommandOptions) => { + await runWikiSearch({ + config, + query, + maxResults: opts.maxResults, + json: opts.json, + }); + }); + + wiki + .command("get") + .description("Read a wiki page by id or relative path") + .argument("", "Relative path or page id") + .option("--from ", "Start line", (value: string) => Number(value)) + .option("--lines ", "Number of lines", (value: string) => Number(value)) + .option("--json", "Print JSON") + .action(async (lookup: string, opts: WikiGetCommandOptions) => { + await runWikiGet({ + config, + lookup, + fromLine: opts.from, + lineCount: opts.lines, + json: opts.json, + }); + }); } diff --git a/extensions/memory-wiki/src/query.test.ts b/extensions/memory-wiki/src/query.test.ts new file mode 100644 index 00000000000..7b212fee1ac --- /dev/null +++ b/extensions/memory-wiki/src/query.test.ts @@ -0,0 +1,71 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolveMemoryWikiConfig } from "./config.js"; +import { renderWikiMarkdown } from "./markdown.js"; +import { getMemoryWikiPage, searchMemoryWiki } from "./query.js"; +import { initializeMemoryWikiVault } from "./vault.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("searchMemoryWiki", () => { + it("finds pages by title and body", async () => { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-query-")); + tempDirs.push(rootDir); + const config = resolveMemoryWikiConfig( + { vault: { path: rootDir } }, + { 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 results = await searchMemoryWiki({ config, query: "alpha" }); + + expect(results).toHaveLength(1); + expect(results[0]?.path).toBe("sources/alpha.md"); + }); +}); + +describe("getMemoryWikiPage", () => { + it("reads pages by relative path and slices line ranges", async () => { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-query-")); + tempDirs.push(rootDir); + const config = resolveMemoryWikiConfig( + { vault: { path: rootDir } }, + { 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\nline one\nline two\nline three\n", + }), + "utf8", + ); + + const result = await getMemoryWikiPage({ + config, + lookup: "sources/alpha.md", + fromLine: 4, + lineCount: 2, + }); + + expect(result?.path).toBe("sources/alpha.md"); + expect(result?.content).toContain("line one"); + expect(result?.content).toContain("line two"); + expect(result?.content).not.toContain("line three"); + }); +}); diff --git a/extensions/memory-wiki/src/query.ts b/extensions/memory-wiki/src/query.ts new file mode 100644 index 00000000000..ba266b34f3a --- /dev/null +++ b/extensions/memory-wiki/src/query.ts @@ -0,0 +1,184 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { ResolvedMemoryWikiConfig } from "./config.js"; +import { parseWikiMarkdown, toWikiPageSummary, type WikiPageSummary } from "./markdown.js"; +import { initializeMemoryWikiVault } from "./vault.js"; + +const QUERY_DIRS = ["entities", "concepts", "sources", "syntheses", "reports"] as const; + +export type WikiSearchResult = { + path: string; + title: string; + kind: WikiPageSummary["kind"]; + score: number; + snippet: string; + id?: string; +}; + +export type WikiGetResult = { + path: string; + title: string; + kind: WikiPageSummary["kind"]; + content: string; + fromLine: number; + lineCount: number; + id?: string; +}; + +type QueryableWikiPage = WikiPageSummary & { + raw: string; +}; + +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)); +} + +async function readQueryablePages(rootDir: string): Promise { + const files = await listWikiMarkdownFiles(rootDir); + 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 buildSnippet(raw: string, query: string): string { + const queryLower = query.toLowerCase(); + const matchingLine = raw + .split(/\r?\n/) + .find((line) => line.toLowerCase().includes(queryLower) && line.trim().length > 0); + return ( + matchingLine?.trim() || + raw + .split(/\r?\n/) + .find((line) => line.trim().length > 0) + ?.trim() || + "" + ); +} + +function scorePage(page: QueryableWikiPage, query: string): number { + const queryLower = query.toLowerCase(); + const titleLower = page.title.toLowerCase(); + const pathLower = page.relativePath.toLowerCase(); + const idLower = page.id?.toLowerCase() ?? ""; + const rawLower = page.raw.toLowerCase(); + if ( + !( + titleLower.includes(queryLower) || + pathLower.includes(queryLower) || + idLower.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 += 10; + } + const bodyOccurrences = rawLower.split(queryLower).length - 1; + score += Math.min(20, bodyOccurrences); + return score; +} + +function normalizeLookupKey(value: string): string { + const normalized = value.trim().replace(/\\/g, "/"); + return normalized.endsWith(".md") ? normalized : normalized.replace(/\/+$/, ""); +} + +function resolvePageByLookup(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; + query: string; + maxResults?: number; +}): Promise { + await initializeMemoryWikiVault(params.config); + const pages = await readQueryablePages(params.config.vault.path); + const maxResults = Math.max(1, params.maxResults ?? 10); + return pages + .map((page) => ({ + path: page.relativePath, + title: page.title, + kind: page.kind, + score: scorePage(page, params.query), + snippet: buildSnippet(page.raw, params.query), + ...(page.id ? { id: page.id } : {}), + })) + .filter((page) => page.score > 0) + .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; + lookup: string; + fromLine?: number; + lineCount?: number; +}): Promise { + await initializeMemoryWikiVault(params.config); + const pages = await readQueryablePages(params.config.vault.path); + const page = resolvePageByLookup(pages, params.lookup); + if (!page) { + return null; + } + + const parsed = parseWikiMarkdown(page.raw); + const lines = parsed.body.split(/\r?\n/); + const fromLine = Math.max(1, params.fromLine ?? 1); + const lineCount = Math.max(1, params.lineCount ?? 200); + const slice = lines.slice(fromLine - 1, fromLine - 1 + lineCount).join("\n"); + + return { + path: page.relativePath, + title: page.title, + kind: page.kind, + content: slice, + fromLine, + lineCount, + ...(page.id ? { id: page.id } : {}), + }; +} diff --git a/extensions/memory-wiki/src/tool.ts b/extensions/memory-wiki/src/tool.ts index 1fc54eb520e..c6d84402cb6 100644 --- a/extensions/memory-wiki/src/tool.ts +++ b/extensions/memory-wiki/src/tool.ts @@ -1,9 +1,25 @@ import { Type } from "@sinclair/typebox"; import type { AnyAgentTool } from "../api.js"; import type { ResolvedMemoryWikiConfig } from "./config.js"; +import { getMemoryWikiPage, searchMemoryWiki } from "./query.js"; import { renderMemoryWikiStatus, resolveMemoryWikiStatus } from "./status.js"; const WikiStatusSchema = Type.Object({}, { additionalProperties: false }); +const WikiSearchSchema = Type.Object( + { + query: Type.String({ minLength: 1 }), + maxResults: Type.Optional(Type.Number({ minimum: 1 })), + }, + { additionalProperties: false }, +); +const WikiGetSchema = Type.Object( + { + lookup: Type.String({ minLength: 1 }), + fromLine: Type.Optional(Type.Number({ minimum: 1 })), + lineCount: Type.Optional(Type.Number({ minimum: 1 })), + }, + { additionalProperties: false }, +); export function createWikiStatusTool(config: ResolvedMemoryWikiConfig): AnyAgentTool { return { @@ -21,3 +37,61 @@ export function createWikiStatusTool(config: ResolvedMemoryWikiConfig): AnyAgent }, }; } + +export function createWikiSearchTool(config: ResolvedMemoryWikiConfig): AnyAgentTool { + return { + name: "wiki_search", + label: "Wiki Search", + description: "Search wiki pages by title, path, id, or body text.", + parameters: WikiSearchSchema, + execute: async (_toolCallId, rawParams) => { + const params = rawParams as { query: string; maxResults?: number }; + const results = await searchMemoryWiki({ + config, + query: params.query, + maxResults: params.maxResults, + }); + const text = + results.length === 0 + ? "No wiki results." + : results + .map( + (result, index) => + `${index + 1}. ${result.title} (${result.kind})\nPath: ${result.path}\nSnippet: ${result.snippet}`, + ) + .join("\n\n"); + return { + content: [{ type: "text", text }], + details: { results }, + }; + }, + }; +} + +export function createWikiGetTool(config: ResolvedMemoryWikiConfig): AnyAgentTool { + return { + name: "wiki_get", + label: "Wiki Get", + description: "Read a wiki page by id or relative path.", + parameters: WikiGetSchema, + execute: async (_toolCallId, rawParams) => { + const params = rawParams as { lookup: string; fromLine?: number; lineCount?: number }; + const result = await getMemoryWikiPage({ + config, + lookup: params.lookup, + fromLine: params.fromLine, + lineCount: params.lineCount, + }); + if (!result) { + return { + content: [{ type: "text", text: `Wiki page not found: ${params.lookup}` }], + details: { found: false }, + }; + } + return { + content: [{ type: "text", text: result.content }], + details: { found: true, ...result }, + }; + }, + }; +}