From 9ce4abfe558e7f1935fdd3e8d04d3ddc670095aa Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 5 Apr 2026 21:09:31 +0100 Subject: [PATCH] feat(memory-wiki): add agent lint tool and issue categories --- extensions/memory-wiki/index.test.ts | 3 +- extensions/memory-wiki/index.ts | 8 +- .../skills/wiki-maintainer/SKILL.md | 1 + extensions/memory-wiki/src/lint.test.ts | 12 ++- extensions/memory-wiki/src/lint.ts | 81 ++++++++++++++++++- extensions/memory-wiki/src/markdown.ts | 20 +++++ extensions/memory-wiki/src/tool.ts | 38 +++++++++ 7 files changed, 159 insertions(+), 4 deletions(-) diff --git a/extensions/memory-wiki/index.test.ts b/extensions/memory-wiki/index.test.ts index 3b9e6bd03aa..02a7a88e046 100644 --- a/extensions/memory-wiki/index.test.ts +++ b/extensions/memory-wiki/index.test.ts @@ -24,9 +24,10 @@ describe("memory-wiki plugin", () => { await plugin.register(api); - expect(registerTool).toHaveBeenCalledTimes(3); + expect(registerTool).toHaveBeenCalledTimes(4); expect(registerTool.mock.calls.map((call) => call[1]?.name)).toEqual([ "wiki_status", + "wiki_lint", "wiki_search", "wiki_get", ]); diff --git a/extensions/memory-wiki/index.ts b/extensions/memory-wiki/index.ts index 0dbf7d2be8e..d1a02c22d8e 100644 --- a/extensions/memory-wiki/index.ts +++ b/extensions/memory-wiki/index.ts @@ -1,7 +1,12 @@ import { definePluginEntry } from "./api.js"; import { registerWikiCli } from "./src/cli.js"; import { memoryWikiConfigSchema, resolveMemoryWikiConfig } from "./src/config.js"; -import { createWikiGetTool, createWikiSearchTool, createWikiStatusTool } from "./src/tool.js"; +import { + createWikiGetTool, + createWikiLintTool, + createWikiSearchTool, + createWikiStatusTool, +} from "./src/tool.js"; export default definePluginEntry({ id: "memory-wiki", @@ -12,6 +17,7 @@ export default definePluginEntry({ const config = resolveMemoryWikiConfig(api.pluginConfig); api.registerTool(createWikiStatusTool(config, api.config), { name: "wiki_status" }); + api.registerTool(createWikiLintTool(config, api.config), { name: "wiki_lint" }); 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 ae8c18f33e6..f612f55d91c 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. +- 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. - In `unsafe-local` mode, use `openclaw wiki unsafe-local import` only when the user explicitly opted into private local path access. diff --git a/extensions/memory-wiki/src/lint.test.ts b/extensions/memory-wiki/src/lint.test.ts index 48694c04e1a..a61bb2c60b7 100644 --- a/extensions/memory-wiki/src/lint.test.ts +++ b/extensions/memory-wiki/src/lint.test.ts @@ -14,7 +14,7 @@ afterEach(async () => { }); describe("lintMemoryWikiVault", () => { - it("detects duplicate ids and missing sourceIds", async () => { + it("detects duplicate ids, provenance gaps, contradictions, and open questions", async () => { const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-lint-")); tempDirs.push(rootDir); const config = resolveMemoryWikiConfig( @@ -28,6 +28,9 @@ describe("lintMemoryWikiVault", () => { pageType: "entity", id: "entity.alpha", title: "Alpha", + contradictions: ["Conflicts with source.beta"], + questions: ["Is Alpha still active?"], + confidence: 0.2, }, body: "# Alpha\n\n[[missing-page]]\n", }); @@ -40,6 +43,13 @@ describe("lintMemoryWikiVault", () => { expect(result.issues.map((issue) => issue.code)).toContain("duplicate-id"); expect(result.issues.map((issue) => issue.code)).toContain("missing-source-ids"); expect(result.issues.map((issue) => issue.code)).toContain("broken-wikilink"); + expect(result.issues.map((issue) => issue.code)).toContain("contradiction-present"); + expect(result.issues.map((issue) => issue.code)).toContain("open-question"); + expect(result.issues.map((issue) => issue.code)).toContain("low-confidence"); + expect(result.issuesByCategory.contradictions).toHaveLength(2); + expect(result.issuesByCategory["open-questions"]).toHaveLength(2); await expect(fs.readFile(result.reportPath, "utf8")).resolves.toContain("### Errors"); + await expect(fs.readFile(result.reportPath, "utf8")).resolves.toContain("### Contradictions"); + await expect(fs.readFile(result.reportPath, "utf8")).resolves.toContain("### Open Questions"); }); }); diff --git a/extensions/memory-wiki/src/lint.ts b/extensions/memory-wiki/src/lint.ts index 69b2b56fc34..88cf0beafe0 100644 --- a/extensions/memory-wiki/src/lint.ts +++ b/extensions/memory-wiki/src/lint.ts @@ -11,6 +11,7 @@ import { renderWikiMarkdown, toWikiPageSummary, type WikiPageSummary } from "./m export type MemoryWikiLintIssue = { severity: "error" | "warning"; + category: "structure" | "provenance" | "links" | "contradictions" | "open-questions" | "quality"; code: | "missing-id" | "duplicate-id" @@ -18,7 +19,10 @@ export type MemoryWikiLintIssue = { | "page-type-mismatch" | "missing-title" | "missing-source-ids" - | "broken-wikilink"; + | "broken-wikilink" + | "contradiction-present" + | "open-question" + | "low-confidence"; path: string; message: string; }; @@ -27,6 +31,7 @@ export type LintMemoryWikiResult = { vaultRoot: string; issueCount: number; issues: MemoryWikiLintIssue[]; + issuesByCategory: Record; reportPath: string; }; @@ -48,6 +53,7 @@ function collectBrokenLinkIssues(pages: WikiPageSummary[]): MemoryWikiLintIssue[ if (!validTargets.has(linkTarget)) { issues.push({ severity: "warning", + category: "links", code: "broken-wikilink", path: page.relativePath, message: `Broken wikilink target \`${linkTarget}\`.`, @@ -66,6 +72,7 @@ function collectPageIssues(pages: WikiPageSummary[]): MemoryWikiLintIssue[] { if (!page.id) { issues.push({ severity: "error", + category: "structure", code: "missing-id", path: page.relativePath, message: "Missing `id` frontmatter.", @@ -79,6 +86,7 @@ function collectPageIssues(pages: WikiPageSummary[]): MemoryWikiLintIssue[] { if (!page.pageType) { issues.push({ severity: "error", + category: "structure", code: "missing-page-type", path: page.relativePath, message: "Missing `pageType` frontmatter.", @@ -86,6 +94,7 @@ function collectPageIssues(pages: WikiPageSummary[]): MemoryWikiLintIssue[] { } else if (page.pageType !== toExpectedPageType(page)) { issues.push({ severity: "error", + category: "structure", code: "page-type-mismatch", path: page.relativePath, message: `Expected pageType \`${toExpectedPageType(page)}\`, found \`${page.pageType}\`.`, @@ -95,6 +104,7 @@ function collectPageIssues(pages: WikiPageSummary[]): MemoryWikiLintIssue[] { if (!page.title.trim()) { issues.push({ severity: "error", + category: "structure", code: "missing-title", path: page.relativePath, message: "Missing page title.", @@ -104,11 +114,42 @@ function collectPageIssues(pages: WikiPageSummary[]): MemoryWikiLintIssue[] { if (page.kind !== "source" && page.kind !== "report" && page.sourceIds.length === 0) { issues.push({ severity: "warning", + category: "provenance", code: "missing-source-ids", path: page.relativePath, message: "Non-source page is missing `sourceIds` provenance.", }); } + + if (page.contradictions.length > 0) { + issues.push({ + severity: "warning", + category: "contradictions", + code: "contradiction-present", + path: page.relativePath, + message: `Page lists ${page.contradictions.length} contradiction${page.contradictions.length === 1 ? "" : "s"} to resolve.`, + }); + } + + if (page.questions.length > 0) { + issues.push({ + severity: "warning", + category: "open-questions", + code: "open-question", + path: page.relativePath, + message: `Page lists ${page.questions.length} open question${page.questions.length === 1 ? "" : "s"}.`, + }); + } + + if (typeof page.confidence === "number" && page.confidence < 0.5) { + issues.push({ + severity: "warning", + category: "quality", + code: "low-confidence", + path: page.relativePath, + message: `Page confidence is low (${page.confidence.toFixed(2)}).`, + }); + } } for (const [id, matches] of pagesById.entries()) { @@ -116,6 +157,7 @@ function collectPageIssues(pages: WikiPageSummary[]): MemoryWikiLintIssue[] { for (const match of matches) { issues.push({ severity: "error", + category: "structure", code: "duplicate-id", path: match.relativePath, message: `Duplicate page id \`${id}\`.`, @@ -128,6 +170,19 @@ function collectPageIssues(pages: WikiPageSummary[]): MemoryWikiLintIssue[] { return issues.toSorted((left, right) => left.path.localeCompare(right.path)); } +function buildIssuesByCategory( + issues: MemoryWikiLintIssue[], +): Record { + return { + structure: issues.filter((issue) => issue.category === "structure"), + provenance: issues.filter((issue) => issue.category === "provenance"), + links: issues.filter((issue) => issue.category === "links"), + contradictions: issues.filter((issue) => issue.category === "contradictions"), + "open-questions": issues.filter((issue) => issue.category === "open-questions"), + quality: issues.filter((issue) => issue.category === "quality"), + }; +} + function buildLintReportBody(issues: MemoryWikiLintIssue[]): string { if (issues.length === 0) { return "No issues found."; @@ -135,6 +190,7 @@ function buildLintReportBody(issues: MemoryWikiLintIssue[]): string { const errors = issues.filter((issue) => issue.severity === "error"); const warnings = issues.filter((issue) => issue.severity === "warning"); + const byCategory = buildIssuesByCategory(issues); const lines = [`- Errors: ${errors.length}`, `- Warnings: ${warnings.length}`]; if (errors.length > 0) { @@ -151,6 +207,27 @@ function buildLintReportBody(issues: MemoryWikiLintIssue[]): string { } } + if (byCategory.contradictions.length > 0) { + lines.push("", "### Contradictions"); + for (const issue of byCategory.contradictions) { + lines.push(`- \`${issue.path}\`: ${issue.message}`); + } + } + + if (byCategory["open-questions"].length > 0) { + lines.push("", "### Open Questions"); + for (const issue of byCategory["open-questions"]) { + lines.push(`- \`${issue.path}\`: ${issue.message}`); + } + } + + if (byCategory.provenance.length > 0 || byCategory.quality.length > 0) { + lines.push("", "### Quality Follow-Up"); + for (const issue of [...byCategory.provenance, ...byCategory.quality]) { + lines.push(`- \`${issue.path}\`: ${issue.message}`); + } + } + return lines.join("\n"); } @@ -183,6 +260,7 @@ export async function lintMemoryWikiVault( ): Promise { const compileResult = await compileMemoryWikiVault(config); const issues = collectPageIssues(compileResult.pages); + const issuesByCategory = buildIssuesByCategory(issues); const reportPath = await writeLintReport(config.vault.path, issues); await appendMemoryWikiLog(config.vault.path, { @@ -198,6 +276,7 @@ export async function lintMemoryWikiVault( vaultRoot: config.vault.path, issueCount: issues.length, issues, + issuesByCategory, reportPath, }; } diff --git a/extensions/memory-wiki/src/markdown.ts b/extensions/memory-wiki/src/markdown.ts index 544c9325798..59accbd4580 100644 --- a/extensions/memory-wiki/src/markdown.ts +++ b/extensions/memory-wiki/src/markdown.ts @@ -19,6 +19,9 @@ export type WikiPageSummary = { pageType?: string; sourceIds: string[]; linkTargets: string[]; + contradictions: string[]; + questions: string[]; + confidence?: number; }; const FRONTMATTER_PATTERN = /^---\n([\s\S]*?)\n---\n?/; @@ -72,6 +75,16 @@ export function normalizeSourceIds(value: unknown): string[] { return []; } +function normalizeStringList(value: unknown): string[] { + if (Array.isArray(value)) { + return value.flatMap((item) => (typeof item === "string" && item.trim() ? [item.trim()] : [])); + } + if (typeof value === "string" && value.trim()) { + return [value.trim()]; + } + return []; +} + export function extractWikiLinks(markdown: string): string[] { const links: string[] = []; for (const match of markdown.matchAll(OBSIDIAN_LINK_PATTERN)) { @@ -153,5 +166,12 @@ export function toWikiPageSummary(params: { : undefined, sourceIds: normalizeSourceIds(parsed.frontmatter.sourceIds), linkTargets: extractWikiLinks(params.raw), + contradictions: normalizeStringList(parsed.frontmatter.contradictions), + questions: normalizeStringList(parsed.frontmatter.questions), + confidence: + typeof parsed.frontmatter.confidence === "number" && + Number.isFinite(parsed.frontmatter.confidence) + ? parsed.frontmatter.confidence + : undefined, }; } diff --git a/extensions/memory-wiki/src/tool.ts b/extensions/memory-wiki/src/tool.ts index f9f5a16c891..888b9d6f92c 100644 --- a/extensions/memory-wiki/src/tool.ts +++ b/extensions/memory-wiki/src/tool.ts @@ -1,11 +1,13 @@ import { Type } from "@sinclair/typebox"; import type { AnyAgentTool, OpenClawConfig } from "../api.js"; import type { ResolvedMemoryWikiConfig } from "./config.js"; +import { lintMemoryWikiVault } from "./lint.js"; import { getMemoryWikiPage, searchMemoryWiki } from "./query.js"; import { syncMemoryWikiImportedSources } from "./source-sync.js"; import { renderMemoryWikiStatus, resolveMemoryWikiStatus } from "./status.js"; const WikiStatusSchema = Type.Object({}, { additionalProperties: false }); +const WikiLintSchema = Type.Object({}, { additionalProperties: false }); const WikiSearchSchema = Type.Object( { query: Type.String({ minLength: 1 }), @@ -84,6 +86,42 @@ export function createWikiSearchTool( }; } +export function createWikiLintTool( + config: ResolvedMemoryWikiConfig, + appConfig?: OpenClawConfig, +): AnyAgentTool { + return { + name: "wiki_lint", + label: "Wiki Lint", + description: + "Lint the wiki vault and surface structural issues, provenance gaps, contradictions, and open questions.", + parameters: WikiLintSchema, + execute: async () => { + await syncImportedSourcesIfNeeded(config, appConfig); + const result = await lintMemoryWikiVault(config); + const contradictions = result.issuesByCategory.contradictions.length; + const openQuestions = result.issuesByCategory["open-questions"].length; + const provenance = result.issuesByCategory.provenance.length; + const errors = result.issues.filter((issue) => issue.severity === "error").length; + const warnings = result.issues.filter((issue) => issue.severity === "warning").length; + const summary = + result.issueCount === 0 + ? "No wiki lint issues." + : [ + `Issues: ${result.issueCount} total (${errors} errors, ${warnings} warnings)`, + `Contradictions: ${contradictions}`, + `Open questions: ${openQuestions}`, + `Provenance gaps: ${provenance}`, + `Report: ${result.reportPath}`, + ].join("\n"); + return { + content: [{ type: "text", text: summary }], + details: result, + }; + }, + }; +} + export function createWikiGetTool( config: ResolvedMemoryWikiConfig, appConfig?: OpenClawConfig,