mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-15 11:11:09 +00:00
feat(memory-wiki): add wiki search and get surfaces
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<NodeJS.WriteStream, "write"> = 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<NodeJS.WriteStream, "write">;
|
||||
}) {
|
||||
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<NodeJS.WriteStream, "write">;
|
||||
}) {
|
||||
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("<query>", "Search query")
|
||||
.option("--max-results <n>", "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("<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("--json", "Print JSON")
|
||||
.action(async (lookup: string, opts: WikiGetCommandOptions) => {
|
||||
await runWikiGet({
|
||||
config,
|
||||
lookup,
|
||||
fromLine: opts.from,
|
||||
lineCount: opts.lines,
|
||||
json: opts.json,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
71
extensions/memory-wiki/src/query.test.ts
Normal file
71
extensions/memory-wiki/src/query.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
184
extensions/memory-wiki/src/query.ts
Normal file
184
extensions/memory-wiki/src/query.ts
Normal file
@@ -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<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));
|
||||
}
|
||||
|
||||
async function readQueryablePages(rootDir: string): Promise<QueryableWikiPage[]> {
|
||||
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<WikiSearchResult[]> {
|
||||
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<WikiGetResult | null> {
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user