diff --git a/extensions/memory-wiki/index.test.ts b/extensions/memory-wiki/index.test.ts index 02a7a88e046..668064ff8be 100644 --- a/extensions/memory-wiki/index.test.ts +++ b/extensions/memory-wiki/index.test.ts @@ -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", ]); diff --git a/extensions/memory-wiki/index.ts b/extensions/memory-wiki/index.ts index d1a02c22d8e..3b0390105f2 100644 --- a/extensions/memory-wiki/index.ts +++ b/extensions/memory-wiki/index.ts @@ -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( diff --git a/extensions/memory-wiki/skills/wiki-maintainer/SKILL.md b/extensions/memory-wiki/skills/wiki-maintainer/SKILL.md index f612f55d91c..c72f1be048e 100644 --- a/extensions/memory-wiki/skills/wiki-maintainer/SKILL.md +++ b/extensions/memory-wiki/skills/wiki-maintainer/SKILL.md @@ -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. diff --git a/extensions/memory-wiki/src/apply.test.ts b/extensions/memory-wiki/src/apply.test.ts new file mode 100644 index 00000000000..c3db80119fb --- /dev/null +++ b/extensions/memory-wiki/src/apply.test.ts @@ -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(""); + expect(parsed.body).toContain("Alpha summary body."); + expect(parsed.body).toContain("## Notes"); + expect(parsed.body).toContain(""); + 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 + +keep this note + +`, + }), + "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(""); + await expect( + fs.readFile(path.join(rootDir, "entities", "index.md"), "utf8"), + ).resolves.toContain("[Alpha](entities/alpha.md)"); + }); +}); diff --git a/extensions/memory-wiki/src/apply.ts b/extensions/memory-wiki/src/apply.ts new file mode 100644 index 00000000000..6d743dd4d7c --- /dev/null +++ b/extensions/memory-wiki/src/apply.ts @@ -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 = ""; +const GENERATED_END = ""; +const HUMAN_START = ""; +const 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; + body: string; +}): Promise { + 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 { + 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; + mutation: UpdateMetadataMemoryWikiMutation; +}): Record { + const frontmatter: Record = { + ...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 { + 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, + }; +} diff --git a/extensions/memory-wiki/src/query.ts b/extensions/memory-wiki/src/query.ts index ba266b34f3a..33727c37140 100644 --- a/extensions/memory-wiki/src/query.ts +++ b/extensions/memory-wiki/src/query.ts @@ -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 { return files.toSorted((left, right) => left.localeCompare(right)); } -async function readQueryablePages(rootDir: string): Promise { +export async function readQueryableWikiPages(rootDir: string): Promise { 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 { 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 { 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; } diff --git a/extensions/memory-wiki/src/tool.ts b/extensions/memory-wiki/src/tool.ts index 888b9d6f92c..e22b95deb00 100644 --- a/extensions/memory-wiki/src/tool.ts +++ b/extensions/memory-wiki/src/tool.ts @@ -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,