feat(memory-wiki): add agent lint tool and issue categories

This commit is contained in:
Vincent Koc 2026-04-05 21:09:31 +01:00
parent a213a580d5
commit 9ce4abfe55
7 changed files with 159 additions and 4 deletions

View File

@ -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",
]);

View File

@ -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(

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.
- 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.

View File

@ -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");
});
});

View File

@ -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<MemoryWikiLintIssue["category"], MemoryWikiLintIssue[]>;
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<MemoryWikiLintIssue["category"], MemoryWikiLintIssue[]> {
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<LintMemoryWikiResult> {
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,
};
}

View File

@ -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,
};
}

View File

@ -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,