feat(memory-wiki): add wiki apply mutation tool

This commit is contained in:
Vincent Koc
2026-04-05 21:14:25 +01:00
parent 9ce4abfe55
commit d624ec3a0b
7 changed files with 496 additions and 7 deletions

View File

@@ -24,10 +24,11 @@ describe("memory-wiki plugin", () => {
await plugin.register(api);
expect(registerTool).toHaveBeenCalledTimes(4);
expect(registerTool).toHaveBeenCalledTimes(5);
expect(registerTool.mock.calls.map((call) => call[1]?.name)).toEqual([
"wiki_status",
"wiki_lint",
"wiki_apply",
"wiki_search",
"wiki_get",
]);

View File

@@ -2,6 +2,7 @@ import { definePluginEntry } from "./api.js";
import { registerWikiCli } from "./src/cli.js";
import { memoryWikiConfigSchema, resolveMemoryWikiConfig } from "./src/config.js";
import {
createWikiApplyTool,
createWikiGetTool,
createWikiLintTool,
createWikiSearchTool,
@@ -18,6 +19,7 @@ export default definePluginEntry({
api.registerTool(createWikiStatusTool(config, api.config), { name: "wiki_status" });
api.registerTool(createWikiLintTool(config, api.config), { name: "wiki_lint" });
api.registerTool(createWikiApplyTool(config, api.config), { name: "wiki_apply" });
api.registerTool(createWikiSearchTool(config, api.config), { name: "wiki_search" });
api.registerTool(createWikiGetTool(config, api.config), { name: "wiki_get" });
api.registerCli(

View File

@@ -7,6 +7,7 @@ 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 `wiki_apply` for narrow synthesis filing and metadata updates when a tool-level mutation is enough.
- Run `wiki_lint` after meaningful wiki updates so contradictions, provenance gaps, and open questions get surfaced before you trust the vault.
- Use `openclaw wiki ingest`, `openclaw wiki compile`, and `openclaw wiki lint` as the default maintenance loop.
- In `bridge` mode, run `openclaw wiki bridge import` before relying on search results if you need the latest public memory-core artifacts pulled in.

View File

@@ -0,0 +1,133 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { applyMemoryWikiMutation } from "./apply.js";
import { resolveMemoryWikiConfig } from "./config.js";
import { parseWikiMarkdown, renderWikiMarkdown } from "./markdown.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("applyMemoryWikiMutation", () => {
it("creates synthesis pages with managed summary blocks and refreshed indexes", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-apply-"));
tempDirs.push(rootDir);
const config = resolveMemoryWikiConfig(
{ vault: { path: rootDir } },
{ homedir: "/Users/tester" },
);
const result = await applyMemoryWikiMutation({
config,
mutation: {
op: "create_synthesis",
title: "Alpha Synthesis",
body: "Alpha summary body.",
sourceIds: ["source.alpha", "source.beta"],
contradictions: ["Needs a better primary source"],
questions: ["What changed after launch?"],
confidence: 0.7,
},
});
expect(result.changed).toBe(true);
expect(result.pagePath).toBe("syntheses/alpha-synthesis.md");
expect(result.pageId).toBe("synthesis.alpha-synthesis");
expect(result.compile.pageCounts.synthesis).toBe(1);
const page = await fs.readFile(path.join(rootDir, result.pagePath), "utf8");
const parsed = parseWikiMarkdown(page);
expect(parsed.frontmatter).toMatchObject({
pageType: "synthesis",
id: "synthesis.alpha-synthesis",
title: "Alpha Synthesis",
sourceIds: ["source.alpha", "source.beta"],
contradictions: ["Needs a better primary source"],
questions: ["What changed after launch?"],
confidence: 0.7,
status: "active",
});
expect(parsed.body).toContain("## Summary");
expect(parsed.body).toContain("<!-- openclaw:wiki:generated:start -->");
expect(parsed.body).toContain("Alpha summary body.");
expect(parsed.body).toContain("## Notes");
expect(parsed.body).toContain("<!-- openclaw:human:start -->");
await expect(fs.readFile(path.join(rootDir, "index.md"), "utf8")).resolves.toContain(
"[Alpha Synthesis](syntheses/alpha-synthesis.md)",
);
});
it("updates page metadata without overwriting existing human notes", async () => {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-apply-"));
tempDirs.push(rootDir);
const config = resolveMemoryWikiConfig(
{ vault: { path: rootDir } },
{ homedir: "/Users/tester" },
);
await initializeMemoryWikiVault(config);
const targetPath = path.join(rootDir, "entities", "alpha.md");
await fs.writeFile(
targetPath,
renderWikiMarkdown({
frontmatter: {
pageType: "entity",
id: "entity.alpha",
title: "Alpha",
sourceIds: ["source.old"],
confidence: 0.3,
},
body: `# Alpha
## Notes
<!-- openclaw:human:start -->
keep this note
<!-- openclaw:human:end -->
`,
}),
"utf8",
);
const result = await applyMemoryWikiMutation({
config,
mutation: {
op: "update_metadata",
lookup: "entity.alpha",
sourceIds: ["source.new"],
contradictions: ["Conflicts with source.beta"],
questions: ["Is Alpha still active?"],
confidence: null,
status: "review",
},
});
expect(result.changed).toBe(true);
expect(result.pagePath).toBe("entities/alpha.md");
expect(result.compile.pageCounts.entity).toBe(1);
const updated = await fs.readFile(targetPath, "utf8");
const parsed = parseWikiMarkdown(updated);
expect(parsed.frontmatter).toMatchObject({
pageType: "entity",
id: "entity.alpha",
title: "Alpha",
sourceIds: ["source.new"],
contradictions: ["Conflicts with source.beta"],
questions: ["Is Alpha still active?"],
status: "review",
});
expect(parsed.frontmatter).not.toHaveProperty("confidence");
expect(parsed.body).toContain("keep this note");
expect(parsed.body).toContain("<!-- openclaw:human:start -->");
await expect(
fs.readFile(path.join(rootDir, "entities", "index.md"), "utf8"),
).resolves.toContain("[Alpha](entities/alpha.md)");
});
});

View File

@@ -0,0 +1,255 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
replaceManagedMarkdownBlock,
withTrailingNewline,
} from "openclaw/plugin-sdk/memory-host-markdown";
import { compileMemoryWikiVault, type CompileMemoryWikiResult } from "./compile.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import {
parseWikiMarkdown,
renderWikiMarkdown,
slugifyWikiSegment,
normalizeSourceIds,
} from "./markdown.js";
import {
readQueryableWikiPages,
resolveQueryableWikiPageByLookup,
type QueryableWikiPage,
} from "./query.js";
import { initializeMemoryWikiVault } from "./vault.js";
const GENERATED_START = "<!-- openclaw:wiki:generated:start -->";
const GENERATED_END = "<!-- openclaw:wiki:generated:end -->";
const HUMAN_START = "<!-- openclaw:human:start -->";
const HUMAN_END = "<!-- openclaw:human:end -->";
export type CreateSynthesisMemoryWikiMutation = {
op: "create_synthesis";
title: string;
body: string;
sourceIds: string[];
contradictions?: string[];
questions?: string[];
confidence?: number;
status?: string;
};
export type UpdateMetadataMemoryWikiMutation = {
op: "update_metadata";
lookup: string;
sourceIds?: string[];
contradictions?: string[];
questions?: string[];
confidence?: number | null;
status?: string;
};
export type ApplyMemoryWikiMutation =
| CreateSynthesisMemoryWikiMutation
| UpdateMetadataMemoryWikiMutation;
export type ApplyMemoryWikiMutationResult = {
changed: boolean;
operation: ApplyMemoryWikiMutation["op"];
pagePath: string;
pageId?: string;
compile: CompileMemoryWikiResult;
};
function normalizeUniqueStrings(values: string[] | undefined): string[] | undefined {
if (!values) {
return undefined;
}
const normalized = values
.map((value) => value.trim())
.filter(Boolean)
.filter((value, index, all) => all.indexOf(value) === index);
return normalized;
}
function ensureHumanNotesBlock(body: string): string {
if (body.includes(HUMAN_START) && body.includes(HUMAN_END)) {
return body;
}
const trimmed = body.trimEnd();
const prefix = trimmed.length > 0 ? `${trimmed}\n\n` : "";
return `${prefix}## Notes\n${HUMAN_START}\n${HUMAN_END}\n`;
}
function buildSynthesisBody(params: {
title: string;
originalBody?: string;
generatedBody: string;
}): string {
const base = params.originalBody?.trim().length
? params.originalBody
: `# ${params.title}\n\n## Notes\n${HUMAN_START}\n${HUMAN_END}\n`;
const withGenerated = replaceManagedMarkdownBlock({
original: base,
heading: "## Summary",
startMarker: GENERATED_START,
endMarker: GENERATED_END,
body: params.generatedBody,
});
return ensureHumanNotesBlock(withGenerated);
}
async function writeWikiPage(params: {
absolutePath: string;
frontmatter: Record<string, unknown>;
body: string;
}): Promise<boolean> {
const rendered = withTrailingNewline(
renderWikiMarkdown({
frontmatter: params.frontmatter,
body: params.body,
}),
);
const existing = await fs.readFile(params.absolutePath, "utf8").catch(() => "");
if (existing === rendered) {
return false;
}
await fs.mkdir(path.dirname(params.absolutePath), { recursive: true });
await fs.writeFile(params.absolutePath, rendered, "utf8");
return true;
}
async function resolveWritablePage(params: {
config: ResolvedMemoryWikiConfig;
lookup: string;
}): Promise<QueryableWikiPage | null> {
const pages = await readQueryableWikiPages(params.config.vault.path);
return resolveQueryableWikiPageByLookup(pages, params.lookup);
}
async function applyCreateSynthesisMutation(params: {
config: ResolvedMemoryWikiConfig;
mutation: CreateSynthesisMemoryWikiMutation;
}): Promise<{ changed: boolean; pagePath: string; pageId: string }> {
const slug = slugifyWikiSegment(params.mutation.title);
const pagePath = path.join("syntheses", `${slug}.md`).replace(/\\/g, "/");
const absolutePath = path.join(params.config.vault.path, pagePath);
const existing = await fs.readFile(absolutePath, "utf8").catch(() => "");
const parsed = parseWikiMarkdown(existing);
const pageId =
(typeof parsed.frontmatter.id === "string" && parsed.frontmatter.id.trim()) ||
`synthesis.${slug}`;
const changed = await writeWikiPage({
absolutePath,
frontmatter: {
...parsed.frontmatter,
pageType: "synthesis",
id: pageId,
title: params.mutation.title,
sourceIds: normalizeSourceIds(params.mutation.sourceIds),
...(normalizeUniqueStrings(params.mutation.contradictions)
? { contradictions: normalizeUniqueStrings(params.mutation.contradictions) }
: {}),
...(normalizeUniqueStrings(params.mutation.questions)
? { questions: normalizeUniqueStrings(params.mutation.questions) }
: {}),
...(typeof params.mutation.confidence === "number"
? { confidence: params.mutation.confidence }
: {}),
status: params.mutation.status?.trim() || "active",
updatedAt: new Date().toISOString(),
},
body: buildSynthesisBody({
title: params.mutation.title,
originalBody: parsed.body,
generatedBody: params.mutation.body.trim(),
}),
});
return { changed, pagePath, pageId };
}
function buildUpdatedFrontmatter(params: {
original: Record<string, unknown>;
mutation: UpdateMetadataMemoryWikiMutation;
}): Record<string, unknown> {
const frontmatter: Record<string, unknown> = {
...params.original,
updatedAt: new Date().toISOString(),
};
if (params.mutation.sourceIds) {
frontmatter.sourceIds = normalizeSourceIds(params.mutation.sourceIds);
}
if (params.mutation.contradictions) {
const contradictions = normalizeUniqueStrings(params.mutation.contradictions) ?? [];
if (contradictions.length > 0) {
frontmatter.contradictions = contradictions;
} else {
delete frontmatter.contradictions;
}
}
if (params.mutation.questions) {
const questions = normalizeUniqueStrings(params.mutation.questions) ?? [];
if (questions.length > 0) {
frontmatter.questions = questions;
} else {
delete frontmatter.questions;
}
}
if (params.mutation.confidence === null) {
delete frontmatter.confidence;
} else if (typeof params.mutation.confidence === "number") {
frontmatter.confidence = params.mutation.confidence;
}
if (params.mutation.status?.trim()) {
frontmatter.status = params.mutation.status.trim();
}
return frontmatter;
}
async function applyUpdateMetadataMutation(params: {
config: ResolvedMemoryWikiConfig;
mutation: UpdateMetadataMemoryWikiMutation;
}): Promise<{ changed: boolean; pagePath: string; pageId?: string }> {
const page = await resolveWritablePage({
config: params.config,
lookup: params.mutation.lookup,
});
if (!page) {
throw new Error(`Wiki page not found: ${params.mutation.lookup}`);
}
const parsed = parseWikiMarkdown(page.raw);
const changed = await writeWikiPage({
absolutePath: page.absolutePath,
frontmatter: buildUpdatedFrontmatter({
original: parsed.frontmatter,
mutation: params.mutation,
}),
body: parsed.body,
});
return {
changed,
pagePath: page.relativePath,
...(page.id ? { pageId: page.id } : {}),
};
}
export async function applyMemoryWikiMutation(params: {
config: ResolvedMemoryWikiConfig;
mutation: ApplyMemoryWikiMutation;
}): Promise<ApplyMemoryWikiMutationResult> {
await initializeMemoryWikiVault(params.config);
const result =
params.mutation.op === "create_synthesis"
? await applyCreateSynthesisMutation({
config: params.config,
mutation: params.mutation,
})
: await applyUpdateMetadataMutation({
config: params.config,
mutation: params.mutation,
});
const compile = await compileMemoryWikiVault(params.config);
return {
changed: result.changed,
operation: params.mutation.op,
pagePath: result.pagePath,
...(result.pageId ? { pageId: result.pageId } : {}),
compile,
};
}

View File

@@ -25,7 +25,7 @@ export type WikiGetResult = {
id?: string;
};
type QueryableWikiPage = WikiPageSummary & {
export type QueryableWikiPage = WikiPageSummary & {
raw: string;
};
@@ -46,7 +46,7 @@ async function listWikiMarkdownFiles(rootDir: string): Promise<string[]> {
return files.toSorted((left, right) => left.localeCompare(right));
}
async function readQueryablePages(rootDir: string): Promise<QueryableWikiPage[]> {
export async function readQueryableWikiPages(rootDir: string): Promise<QueryableWikiPage[]> {
const files = await listWikiMarkdownFiles(rootDir);
const pages = await Promise.all(
files.map(async (relativePath) => {
@@ -113,7 +113,10 @@ function normalizeLookupKey(value: string): string {
return normalized.endsWith(".md") ? normalized : normalized.replace(/\/+$/, "");
}
function resolvePageByLookup(pages: QueryableWikiPage[], lookup: string): QueryableWikiPage | null {
export function resolveQueryableWikiPageByLookup(
pages: QueryableWikiPage[],
lookup: string,
): QueryableWikiPage | null {
const key = normalizeLookupKey(lookup);
const withExtension = key.endsWith(".md") ? key : `${key}.md`;
return (
@@ -132,7 +135,7 @@ export async function searchMemoryWiki(params: {
maxResults?: number;
}): Promise<WikiSearchResult[]> {
await initializeMemoryWikiVault(params.config);
const pages = await readQueryablePages(params.config.vault.path);
const pages = await readQueryableWikiPages(params.config.vault.path);
const maxResults = Math.max(1, params.maxResults ?? 10);
return pages
.map((page) => ({
@@ -160,8 +163,8 @@ export async function getMemoryWikiPage(params: {
lineCount?: number;
}): Promise<WikiGetResult | null> {
await initializeMemoryWikiVault(params.config);
const pages = await readQueryablePages(params.config.vault.path);
const page = resolvePageByLookup(pages, params.lookup);
const pages = await readQueryableWikiPages(params.config.vault.path);
const page = resolveQueryableWikiPageByLookup(pages, params.lookup);
if (!page) {
return null;
}

View File

@@ -1,5 +1,6 @@
import { Type } from "@sinclair/typebox";
import type { AnyAgentTool, OpenClawConfig } from "../api.js";
import { applyMemoryWikiMutation, type ApplyMemoryWikiMutation } from "./apply.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { lintMemoryWikiVault } from "./lint.js";
import { getMemoryWikiPage, searchMemoryWiki } from "./query.js";
@@ -23,6 +24,20 @@ const WikiGetSchema = Type.Object(
},
{ additionalProperties: false },
);
const WikiApplySchema = Type.Object(
{
op: Type.Union([Type.Literal("create_synthesis"), Type.Literal("update_metadata")]),
title: Type.Optional(Type.String({ minLength: 1 })),
body: Type.Optional(Type.String({ minLength: 1 })),
lookup: Type.Optional(Type.String({ minLength: 1 })),
sourceIds: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
contradictions: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
questions: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
confidence: Type.Optional(Type.Union([Type.Number({ minimum: 0, maximum: 1 }), Type.Null()])),
status: Type.Optional(Type.String({ minLength: 1 })),
},
{ additionalProperties: false },
);
async function syncImportedSourcesIfNeeded(
config: ResolvedMemoryWikiConfig,
@@ -31,6 +46,53 @@ async function syncImportedSourcesIfNeeded(
await syncMemoryWikiImportedSources({ config, appConfig });
}
function normalizeWikiApplyMutation(rawParams: unknown): ApplyMemoryWikiMutation {
const params = rawParams as {
op: ApplyMemoryWikiMutation["op"];
title?: string;
body?: string;
lookup?: string;
sourceIds?: string[];
contradictions?: string[];
questions?: string[];
confidence?: number | null;
status?: string;
};
if (params.op === "create_synthesis") {
if (!params.title?.trim()) {
throw new Error("wiki_apply requires title for create_synthesis.");
}
if (!params.body?.trim()) {
throw new Error("wiki_apply requires body for create_synthesis.");
}
if (!params.sourceIds || params.sourceIds.length === 0) {
throw new Error("wiki_apply requires at least one sourceId for create_synthesis.");
}
return {
op: "create_synthesis",
title: params.title,
body: params.body,
sourceIds: params.sourceIds,
...(params.contradictions ? { contradictions: params.contradictions } : {}),
...(params.questions ? { questions: params.questions } : {}),
...(typeof params.confidence === "number" ? { confidence: params.confidence } : {}),
...(params.status ? { status: params.status } : {}),
};
}
if (!params.lookup?.trim()) {
throw new Error("wiki_apply requires lookup for update_metadata.");
}
return {
op: "update_metadata",
lookup: params.lookup,
...(params.sourceIds ? { sourceIds: params.sourceIds } : {}),
...(params.contradictions ? { contradictions: params.contradictions } : {}),
...(params.questions ? { questions: params.questions } : {}),
...(params.confidence !== undefined ? { confidence: params.confidence } : {}),
...(params.status ? { status: params.status } : {}),
};
}
export function createWikiStatusTool(
config: ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,
@@ -122,6 +184,38 @@ export function createWikiLintTool(
};
}
export function createWikiApplyTool(
config: ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,
): AnyAgentTool {
return {
name: "wiki_apply",
label: "Wiki Apply",
description:
"Apply narrow wiki mutations for syntheses and page metadata without freeform markdown surgery.",
parameters: WikiApplySchema,
execute: async (_toolCallId, rawParams) => {
const mutation = normalizeWikiApplyMutation(rawParams);
await syncImportedSourcesIfNeeded(config, appConfig);
const result = await applyMemoryWikiMutation({ config, mutation });
const action = result.changed ? "Updated" : "No changes for";
const compileSummary =
result.compile.updatedFiles.length > 0
? `Refreshed ${result.compile.updatedFiles.length} index file${result.compile.updatedFiles.length === 1 ? "" : "s"}.`
: "Indexes unchanged.";
return {
content: [
{
type: "text",
text: `${action} ${result.pagePath} via ${result.operation}. ${compileSummary}`,
},
],
details: result,
};
},
};
}
export function createWikiGetTool(
config: ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,