mirror of https://github.com/openclaw/openclaw.git
feat(memory-wiki): add agent lint tool and issue categories
This commit is contained in:
parent
a213a580d5
commit
9ce4abfe55
|
|
@ -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",
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue