Skip to content

Commit 9ce4abf

Browse files
committed
feat(memory-wiki): add agent lint tool and issue categories
1 parent a213a58 commit 9ce4abf

7 files changed

Lines changed: 159 additions & 4 deletions

File tree

extensions/memory-wiki/index.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@ describe("memory-wiki plugin", () => {
2424

2525
await plugin.register(api);
2626

27-
expect(registerTool).toHaveBeenCalledTimes(3);
27+
expect(registerTool).toHaveBeenCalledTimes(4);
2828
expect(registerTool.mock.calls.map((call) => call[1]?.name)).toEqual([
2929
"wiki_status",
30+
"wiki_lint",
3031
"wiki_search",
3132
"wiki_get",
3233
]);

extensions/memory-wiki/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { definePluginEntry } from "./api.js";
22
import { registerWikiCli } from "./src/cli.js";
33
import { memoryWikiConfigSchema, resolveMemoryWikiConfig } from "./src/config.js";
4-
import { createWikiGetTool, createWikiSearchTool, createWikiStatusTool } from "./src/tool.js";
4+
import {
5+
createWikiGetTool,
6+
createWikiLintTool,
7+
createWikiSearchTool,
8+
createWikiStatusTool,
9+
} from "./src/tool.js";
510

611
export default definePluginEntry({
712
id: "memory-wiki",
@@ -12,6 +17,7 @@ export default definePluginEntry({
1217
const config = resolveMemoryWikiConfig(api.pluginConfig);
1318

1419
api.registerTool(createWikiStatusTool(config, api.config), { name: "wiki_status" });
20+
api.registerTool(createWikiLintTool(config, api.config), { name: "wiki_lint" });
1521
api.registerTool(createWikiSearchTool(config, api.config), { name: "wiki_search" });
1622
api.registerTool(createWikiGetTool(config, api.config), { name: "wiki_get" });
1723
api.registerCli(

extensions/memory-wiki/skills/wiki-maintainer/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Use this skill when working inside a memory-wiki vault.
77

88
- Prefer `wiki_status` first when you need to understand the vault mode, path, or Obsidian CLI availability.
99
- Use `wiki_search` to discover candidate pages, then `wiki_get` to inspect the exact page before editing or citing it.
10+
- Run `wiki_lint` after meaningful wiki updates so contradictions, provenance gaps, and open questions get surfaced before you trust the vault.
1011
- Use `openclaw wiki ingest`, `openclaw wiki compile`, and `openclaw wiki lint` as the default maintenance loop.
1112
- In `bridge` mode, run `openclaw wiki bridge import` before relying on search results if you need the latest public memory-core artifacts pulled in.
1213
- In `unsafe-local` mode, use `openclaw wiki unsafe-local import` only when the user explicitly opted into private local path access.

extensions/memory-wiki/src/lint.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ afterEach(async () => {
1414
});
1515

1616
describe("lintMemoryWikiVault", () => {
17-
it("detects duplicate ids and missing sourceIds", async () => {
17+
it("detects duplicate ids, provenance gaps, contradictions, and open questions", async () => {
1818
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-lint-"));
1919
tempDirs.push(rootDir);
2020
const config = resolveMemoryWikiConfig(
@@ -28,6 +28,9 @@ describe("lintMemoryWikiVault", () => {
2828
pageType: "entity",
2929
id: "entity.alpha",
3030
title: "Alpha",
31+
contradictions: ["Conflicts with source.beta"],
32+
questions: ["Is Alpha still active?"],
33+
confidence: 0.2,
3134
},
3235
body: "# Alpha\n\n[[missing-page]]\n",
3336
});
@@ -40,6 +43,13 @@ describe("lintMemoryWikiVault", () => {
4043
expect(result.issues.map((issue) => issue.code)).toContain("duplicate-id");
4144
expect(result.issues.map((issue) => issue.code)).toContain("missing-source-ids");
4245
expect(result.issues.map((issue) => issue.code)).toContain("broken-wikilink");
46+
expect(result.issues.map((issue) => issue.code)).toContain("contradiction-present");
47+
expect(result.issues.map((issue) => issue.code)).toContain("open-question");
48+
expect(result.issues.map((issue) => issue.code)).toContain("low-confidence");
49+
expect(result.issuesByCategory.contradictions).toHaveLength(2);
50+
expect(result.issuesByCategory["open-questions"]).toHaveLength(2);
4351
await expect(fs.readFile(result.reportPath, "utf8")).resolves.toContain("### Errors");
52+
await expect(fs.readFile(result.reportPath, "utf8")).resolves.toContain("### Contradictions");
53+
await expect(fs.readFile(result.reportPath, "utf8")).resolves.toContain("### Open Questions");
4454
});
4555
});

extensions/memory-wiki/src/lint.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,18 @@ import { renderWikiMarkdown, toWikiPageSummary, type WikiPageSummary } from "./m
1111

1212
export type MemoryWikiLintIssue = {
1313
severity: "error" | "warning";
14+
category: "structure" | "provenance" | "links" | "contradictions" | "open-questions" | "quality";
1415
code:
1516
| "missing-id"
1617
| "duplicate-id"
1718
| "missing-page-type"
1819
| "page-type-mismatch"
1920
| "missing-title"
2021
| "missing-source-ids"
21-
| "broken-wikilink";
22+
| "broken-wikilink"
23+
| "contradiction-present"
24+
| "open-question"
25+
| "low-confidence";
2226
path: string;
2327
message: string;
2428
};
@@ -27,6 +31,7 @@ export type LintMemoryWikiResult = {
2731
vaultRoot: string;
2832
issueCount: number;
2933
issues: MemoryWikiLintIssue[];
34+
issuesByCategory: Record<MemoryWikiLintIssue["category"], MemoryWikiLintIssue[]>;
3035
reportPath: string;
3136
};
3237

@@ -48,6 +53,7 @@ function collectBrokenLinkIssues(pages: WikiPageSummary[]): MemoryWikiLintIssue[
4853
if (!validTargets.has(linkTarget)) {
4954
issues.push({
5055
severity: "warning",
56+
category: "links",
5157
code: "broken-wikilink",
5258
path: page.relativePath,
5359
message: `Broken wikilink target \`${linkTarget}\`.`,
@@ -66,6 +72,7 @@ function collectPageIssues(pages: WikiPageSummary[]): MemoryWikiLintIssue[] {
6672
if (!page.id) {
6773
issues.push({
6874
severity: "error",
75+
category: "structure",
6976
code: "missing-id",
7077
path: page.relativePath,
7178
message: "Missing `id` frontmatter.",
@@ -79,13 +86,15 @@ function collectPageIssues(pages: WikiPageSummary[]): MemoryWikiLintIssue[] {
7986
if (!page.pageType) {
8087
issues.push({
8188
severity: "error",
89+
category: "structure",
8290
code: "missing-page-type",
8391
path: page.relativePath,
8492
message: "Missing `pageType` frontmatter.",
8593
});
8694
} else if (page.pageType !== toExpectedPageType(page)) {
8795
issues.push({
8896
severity: "error",
97+
category: "structure",
8998
code: "page-type-mismatch",
9099
path: page.relativePath,
91100
message: `Expected pageType \`${toExpectedPageType(page)}\`, found \`${page.pageType}\`.`,
@@ -95,6 +104,7 @@ function collectPageIssues(pages: WikiPageSummary[]): MemoryWikiLintIssue[] {
95104
if (!page.title.trim()) {
96105
issues.push({
97106
severity: "error",
107+
category: "structure",
98108
code: "missing-title",
99109
path: page.relativePath,
100110
message: "Missing page title.",
@@ -104,18 +114,50 @@ function collectPageIssues(pages: WikiPageSummary[]): MemoryWikiLintIssue[] {
104114
if (page.kind !== "source" && page.kind !== "report" && page.sourceIds.length === 0) {
105115
issues.push({
106116
severity: "warning",
117+
category: "provenance",
107118
code: "missing-source-ids",
108119
path: page.relativePath,
109120
message: "Non-source page is missing `sourceIds` provenance.",
110121
});
111122
}
123+
124+
if (page.contradictions.length > 0) {
125+
issues.push({
126+
severity: "warning",
127+
category: "contradictions",
128+
code: "contradiction-present",
129+
path: page.relativePath,
130+
message: `Page lists ${page.contradictions.length} contradiction${page.contradictions.length === 1 ? "" : "s"} to resolve.`,
131+
});
132+
}
133+
134+
if (page.questions.length > 0) {
135+
issues.push({
136+
severity: "warning",
137+
category: "open-questions",
138+
code: "open-question",
139+
path: page.relativePath,
140+
message: `Page lists ${page.questions.length} open question${page.questions.length === 1 ? "" : "s"}.`,
141+
});
142+
}
143+
144+
if (typeof page.confidence === "number" && page.confidence < 0.5) {
145+
issues.push({
146+
severity: "warning",
147+
category: "quality",
148+
code: "low-confidence",
149+
path: page.relativePath,
150+
message: `Page confidence is low (${page.confidence.toFixed(2)}).`,
151+
});
152+
}
112153
}
113154

114155
for (const [id, matches] of pagesById.entries()) {
115156
if (matches.length > 1) {
116157
for (const match of matches) {
117158
issues.push({
118159
severity: "error",
160+
category: "structure",
119161
code: "duplicate-id",
120162
path: match.relativePath,
121163
message: `Duplicate page id \`${id}\`.`,
@@ -128,13 +170,27 @@ function collectPageIssues(pages: WikiPageSummary[]): MemoryWikiLintIssue[] {
128170
return issues.toSorted((left, right) => left.path.localeCompare(right.path));
129171
}
130172

173+
function buildIssuesByCategory(
174+
issues: MemoryWikiLintIssue[],
175+
): Record<MemoryWikiLintIssue["category"], MemoryWikiLintIssue[]> {
176+
return {
177+
structure: issues.filter((issue) => issue.category === "structure"),
178+
provenance: issues.filter((issue) => issue.category === "provenance"),
179+
links: issues.filter((issue) => issue.category === "links"),
180+
contradictions: issues.filter((issue) => issue.category === "contradictions"),
181+
"open-questions": issues.filter((issue) => issue.category === "open-questions"),
182+
quality: issues.filter((issue) => issue.category === "quality"),
183+
};
184+
}
185+
131186
function buildLintReportBody(issues: MemoryWikiLintIssue[]): string {
132187
if (issues.length === 0) {
133188
return "No issues found.";
134189
}
135190

136191
const errors = issues.filter((issue) => issue.severity === "error");
137192
const warnings = issues.filter((issue) => issue.severity === "warning");
193+
const byCategory = buildIssuesByCategory(issues);
138194
const lines = [`- Errors: ${errors.length}`, `- Warnings: ${warnings.length}`];
139195

140196
if (errors.length > 0) {
@@ -151,6 +207,27 @@ function buildLintReportBody(issues: MemoryWikiLintIssue[]): string {
151207
}
152208
}
153209

210+
if (byCategory.contradictions.length > 0) {
211+
lines.push("", "### Contradictions");
212+
for (const issue of byCategory.contradictions) {
213+
lines.push(`- \`${issue.path}\`: ${issue.message}`);
214+
}
215+
}
216+
217+
if (byCategory["open-questions"].length > 0) {
218+
lines.push("", "### Open Questions");
219+
for (const issue of byCategory["open-questions"]) {
220+
lines.push(`- \`${issue.path}\`: ${issue.message}`);
221+
}
222+
}
223+
224+
if (byCategory.provenance.length > 0 || byCategory.quality.length > 0) {
225+
lines.push("", "### Quality Follow-Up");
226+
for (const issue of [...byCategory.provenance, ...byCategory.quality]) {
227+
lines.push(`- \`${issue.path}\`: ${issue.message}`);
228+
}
229+
}
230+
154231
return lines.join("\n");
155232
}
156233

@@ -183,6 +260,7 @@ export async function lintMemoryWikiVault(
183260
): Promise<LintMemoryWikiResult> {
184261
const compileResult = await compileMemoryWikiVault(config);
185262
const issues = collectPageIssues(compileResult.pages);
263+
const issuesByCategory = buildIssuesByCategory(issues);
186264
const reportPath = await writeLintReport(config.vault.path, issues);
187265

188266
await appendMemoryWikiLog(config.vault.path, {
@@ -198,6 +276,7 @@ export async function lintMemoryWikiVault(
198276
vaultRoot: config.vault.path,
199277
issueCount: issues.length,
200278
issues,
279+
issuesByCategory,
201280
reportPath,
202281
};
203282
}

extensions/memory-wiki/src/markdown.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export type WikiPageSummary = {
1919
pageType?: string;
2020
sourceIds: string[];
2121
linkTargets: string[];
22+
contradictions: string[];
23+
questions: string[];
24+
confidence?: number;
2225
};
2326

2427
const FRONTMATTER_PATTERN = /^---\n([\s\S]*?)\n---\n?/;
@@ -72,6 +75,16 @@ export function normalizeSourceIds(value: unknown): string[] {
7275
return [];
7376
}
7477

78+
function normalizeStringList(value: unknown): string[] {
79+
if (Array.isArray(value)) {
80+
return value.flatMap((item) => (typeof item === "string" && item.trim() ? [item.trim()] : []));
81+
}
82+
if (typeof value === "string" && value.trim()) {
83+
return [value.trim()];
84+
}
85+
return [];
86+
}
87+
7588
export function extractWikiLinks(markdown: string): string[] {
7689
const links: string[] = [];
7790
for (const match of markdown.matchAll(OBSIDIAN_LINK_PATTERN)) {
@@ -153,5 +166,12 @@ export function toWikiPageSummary(params: {
153166
: undefined,
154167
sourceIds: normalizeSourceIds(parsed.frontmatter.sourceIds),
155168
linkTargets: extractWikiLinks(params.raw),
169+
contradictions: normalizeStringList(parsed.frontmatter.contradictions),
170+
questions: normalizeStringList(parsed.frontmatter.questions),
171+
confidence:
172+
typeof parsed.frontmatter.confidence === "number" &&
173+
Number.isFinite(parsed.frontmatter.confidence)
174+
? parsed.frontmatter.confidence
175+
: undefined,
156176
};
157177
}

extensions/memory-wiki/src/tool.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { Type } from "@sinclair/typebox";
22
import type { AnyAgentTool, OpenClawConfig } from "../api.js";
33
import type { ResolvedMemoryWikiConfig } from "./config.js";
4+
import { lintMemoryWikiVault } from "./lint.js";
45
import { getMemoryWikiPage, searchMemoryWiki } from "./query.js";
56
import { syncMemoryWikiImportedSources } from "./source-sync.js";
67
import { renderMemoryWikiStatus, resolveMemoryWikiStatus } from "./status.js";
78

89
const WikiStatusSchema = Type.Object({}, { additionalProperties: false });
10+
const WikiLintSchema = Type.Object({}, { additionalProperties: false });
911
const WikiSearchSchema = Type.Object(
1012
{
1113
query: Type.String({ minLength: 1 }),
@@ -84,6 +86,42 @@ export function createWikiSearchTool(
8486
};
8587
}
8688

89+
export function createWikiLintTool(
90+
config: ResolvedMemoryWikiConfig,
91+
appConfig?: OpenClawConfig,
92+
): AnyAgentTool {
93+
return {
94+
name: "wiki_lint",
95+
label: "Wiki Lint",
96+
description:
97+
"Lint the wiki vault and surface structural issues, provenance gaps, contradictions, and open questions.",
98+
parameters: WikiLintSchema,
99+
execute: async () => {
100+
await syncImportedSourcesIfNeeded(config, appConfig);
101+
const result = await lintMemoryWikiVault(config);
102+
const contradictions = result.issuesByCategory.contradictions.length;
103+
const openQuestions = result.issuesByCategory["open-questions"].length;
104+
const provenance = result.issuesByCategory.provenance.length;
105+
const errors = result.issues.filter((issue) => issue.severity === "error").length;
106+
const warnings = result.issues.filter((issue) => issue.severity === "warning").length;
107+
const summary =
108+
result.issueCount === 0
109+
? "No wiki lint issues."
110+
: [
111+
`Issues: ${result.issueCount} total (${errors} errors, ${warnings} warnings)`,
112+
`Contradictions: ${contradictions}`,
113+
`Open questions: ${openQuestions}`,
114+
`Provenance gaps: ${provenance}`,
115+
`Report: ${result.reportPath}`,
116+
].join("\n");
117+
return {
118+
content: [{ type: "text", text: summary }],
119+
details: result,
120+
};
121+
},
122+
};
123+
}
124+
87125
export function createWikiGetTool(
88126
config: ResolvedMemoryWikiConfig,
89127
appConfig?: OpenClawConfig,

0 commit comments

Comments
 (0)