feat(memory-wiki): gate compiled digest prompts

This commit is contained in:
Vincent Koc
2026-04-07 08:21:16 +01:00
parent 0d3cd4ac42
commit 6a559f0293
8 changed files with 269 additions and 6 deletions

View File

@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
- Memory/wiki: add structured claim/evidence fields plus compiled agent digest artifacts so `memory-wiki` behaves more like a persistent knowledge layer and less like markdown-only page storage. Thanks @vincentkoc.
- Memory/wiki: add claim-health linting, contradiction clustering, staleness-aware dashboards, and freshness-weighted wiki search so `memory-wiki` can act more like a maintained belief layer than a passive markdown dump. Thanks @vincentkoc.
- Memory/wiki: use compiled digest artifacts as the first-pass wiki index for search/get flows, and resolve claim ids back to owning pages so agents can retrieve knowledge by belief identity instead of only by file path. Thanks @vincentkoc.
- Memory/wiki: add an opt-in `context.includeCompiledDigestPrompt` flag so memory prompt supplements can append a compact compiled wiki snapshot for legacy prompt assembly and context engines that explicitly consume memory prompt sections. Thanks @vincentkoc.
### Fixes

View File

@@ -59,6 +59,10 @@ Put config under `plugins.entries.memory-wiki.config`:
corpus: "wiki", // or "memory" | "all"
},
context: {
includeCompiledDigestPrompt: false, // opt in to append a compact compiled digest snapshot to memory prompt sections
},
render: {
preserveHumanBlocks: true,
createBacklinks: true, // writes managed ## Related blocks with sources, backlinks, and related pages
@@ -138,6 +142,8 @@ The plugin also registers a non-exclusive memory corpus supplement, so shared `m
`wiki_apply` accepts structured `claims` payloads for synthesis and metadata updates, so the wiki can store claim-level evidence instead of only page-level prose.
When `context.includeCompiledDigestPrompt` is enabled, the memory prompt supplement also appends a compact snapshot from `.openclaw-wiki/cache/agent-digest.json`. Legacy prompt assembly sees that automatically, and non-legacy context engines can pick it up when they explicitly consume memory prompt supplements via `buildActiveMemoryPromptSection(...)`.
## Gateway RPC
Read methods:

View File

@@ -3,7 +3,7 @@ import { registerWikiCli } from "./src/cli.js";
import { memoryWikiConfigSchema, resolveMemoryWikiConfig } from "./src/config.js";
import { createWikiCorpusSupplement } from "./src/corpus-supplement.js";
import { registerMemoryWikiGatewayMethods } from "./src/gateway.js";
import { buildWikiPromptSection } from "./src/prompt-section.js";
import { createWikiPromptSectionBuilder } from "./src/prompt-section.js";
import {
createWikiApplyTool,
createWikiGetTool,
@@ -20,7 +20,7 @@ export default definePluginEntry({
register(api) {
const config = resolveMemoryWikiConfig(api.pluginConfig);
api.registerMemoryPromptSupplement(buildWikiPromptSection);
api.registerMemoryPromptSupplement(createWikiPromptSectionBuilder(config));
api.registerMemoryCorpusSupplement(
createWikiCorpusSupplement({ config, appConfig: api.config }),
);

View File

@@ -31,6 +31,10 @@
"unsafeLocal.allowPrivateMemoryCoreAccess": {
"label": "Allow Private Memory Access",
"help": "Experimental same-repo escape hatch for reading memory-core private paths."
},
"context.includeCompiledDigestPrompt": {
"label": "Include Compiled Digest In Prompt",
"help": "Append a compact compiled wiki digest snapshot to memory prompt sections for context engines and legacy prompt assembly."
}
},
"configSchema": {
@@ -141,6 +145,15 @@
}
}
},
"context": {
"type": "object",
"additionalProperties": false,
"properties": {
"includeCompiledDigestPrompt": {
"type": "boolean"
}
}
},
"render": {
"type": "object",
"additionalProperties": false,

View File

@@ -28,6 +28,7 @@ describe("resolveMemoryWikiConfig", () => {
expect(config.vault.path).toBe(resolveDefaultMemoryWikiVaultPath("/Users/tester"));
expect(config.search.backend).toBe(DEFAULT_WIKI_SEARCH_BACKEND);
expect(config.search.corpus).toBe(DEFAULT_WIKI_SEARCH_CORPUS);
expect(config.context.includeCompiledDigestPrompt).toBe(false);
});
it("expands ~/ paths and preserves explicit modes", () => {
@@ -84,6 +85,9 @@ describe("memory-wiki manifest config schema", () => {
backend: "shared",
corpus: "all",
},
context: {
includeCompiledDigestPrompt: true,
},
};
expect(validate(config)).toBe(true);

View File

@@ -45,6 +45,9 @@ export type MemoryWikiPluginConfig = {
backend?: WikiSearchBackend;
corpus?: WikiSearchCorpus;
};
context?: {
includeCompiledDigestPrompt?: boolean;
};
render?: {
preserveHumanBlocks?: boolean;
createBacklinks?: boolean;
@@ -85,6 +88,9 @@ export type ResolvedMemoryWikiConfig = {
backend: WikiSearchBackend;
corpus: WikiSearchCorpus;
};
context: {
includeCompiledDigestPrompt: boolean;
};
render: {
preserveHumanBlocks: boolean;
createBacklinks: boolean;
@@ -142,6 +148,11 @@ const MemoryWikiConfigSource = z.strictObject({
corpus: z.enum(WIKI_SEARCH_CORPORA).optional(),
})
.optional(),
context: z
.strictObject({
includeCompiledDigestPrompt: z.boolean().optional(),
})
.optional(),
render: z
.strictObject({
preserveHumanBlocks: z.boolean().optional(),
@@ -235,6 +246,9 @@ export function resolveMemoryWikiConfig(
backend: safeConfig.search?.backend ?? DEFAULT_WIKI_SEARCH_BACKEND,
corpus: safeConfig.search?.corpus ?? DEFAULT_WIKI_SEARCH_CORPUS,
},
context: {
includeCompiledDigestPrompt: safeConfig.context?.includeCompiledDigestPrompt ?? false,
},
render: {
preserveHumanBlocks: safeConfig.render?.preserveHumanBlocks ?? true,
createBacklinks: safeConfig.render?.createBacklinks ?? true,

View File

@@ -1,5 +1,21 @@
import { describe, expect, it } from "vitest";
import { buildWikiPromptSection } from "./prompt-section.js";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { resolveMemoryWikiConfig } from "./config.js";
import { buildWikiPromptSection, createWikiPromptSectionBuilder } from "./prompt-section.js";
let suiteRoot = "";
beforeAll(async () => {
suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-prompt-suite-"));
});
afterAll(async () => {
if (suiteRoot) {
await fs.rm(suiteRoot, { recursive: true, force: true });
}
});
describe("buildWikiPromptSection", () => {
it("prefers shared memory corpus guidance when memory tools are available", () => {
@@ -15,4 +31,72 @@ describe("buildWikiPromptSection", () => {
it("stays empty when no wiki or memory-adjacent tools are registered", () => {
expect(buildWikiPromptSection({ availableTools: new Set(["web_search"]) })).toEqual([]);
});
it("can append a compact compiled digest snapshot when enabled", async () => {
const rootDir = path.join(suiteRoot, "digest-enabled");
await fs.mkdir(path.join(rootDir, ".openclaw-wiki", "cache"), { recursive: true });
await fs.writeFile(
path.join(rootDir, ".openclaw-wiki", "cache", "agent-digest.json"),
JSON.stringify(
{
claimCount: 8,
contradictionClusters: [{ key: "claim.alpha.db" }],
pages: [
{
title: "Alpha",
kind: "entity",
claimCount: 3,
questions: ["Still active?"],
contradictions: ["Conflicts with source.beta"],
topClaims: [
{
text: "Alpha uses PostgreSQL for production writes.",
status: "supported",
confidence: 0.91,
freshnessLevel: "fresh",
},
],
},
],
},
null,
2,
),
"utf8",
);
const builder = createWikiPromptSectionBuilder(
resolveMemoryWikiConfig({
vault: { path: rootDir },
context: { includeCompiledDigestPrompt: true },
}),
);
const lines = builder({ availableTools: new Set(["web_search"]) });
expect(lines.join("\n")).toContain("## Compiled Wiki Snapshot");
expect(lines.join("\n")).toContain(
"Alpha: entity, 3 claims, 1 open questions, 1 contradiction notes",
);
expect(lines.join("\n")).toContain("Alpha uses PostgreSQL for production writes.");
});
it("keeps the digest snapshot disabled by default", async () => {
const rootDir = path.join(suiteRoot, "digest-disabled");
await fs.mkdir(path.join(rootDir, ".openclaw-wiki", "cache"), { recursive: true });
await fs.writeFile(
path.join(rootDir, ".openclaw-wiki", "cache", "agent-digest.json"),
JSON.stringify({
claimCount: 1,
pages: [{ title: "Alpha", kind: "entity", claimCount: 1, topClaims: [] }],
}),
"utf8",
);
const builder = createWikiPromptSectionBuilder(
resolveMemoryWikiConfig({
vault: { path: rootDir },
}),
);
expect(builder({ availableTools: new Set(["web_search"]) })).toEqual([]);
});
});

View File

@@ -1,6 +1,126 @@
import fs from "node:fs";
import path from "node:path";
import type { MemoryPromptSectionBuilder } from "openclaw/plugin-sdk/memory-host-core";
import { resolveMemoryWikiConfig, type ResolvedMemoryWikiConfig } from "./config.js";
export const buildWikiPromptSection: MemoryPromptSectionBuilder = ({ availableTools }) => {
const AGENT_DIGEST_PATH = ".openclaw-wiki/cache/agent-digest.json";
const DIGEST_MAX_PAGES = 4;
const DIGEST_MAX_CLAIMS_PER_PAGE = 2;
type PromptDigestClaim = {
text: string;
status?: string;
confidence?: number;
freshnessLevel?: string;
};
type PromptDigestPage = {
title: string;
kind: string;
claimCount: number;
questions?: string[];
contradictions?: string[];
topClaims?: PromptDigestClaim[];
};
type PromptDigest = {
pageCounts?: Record<string, number>;
claimCount?: number;
contradictionClusters?: Array<unknown>;
pages?: PromptDigestPage[];
};
function tryReadPromptDigest(config: ResolvedMemoryWikiConfig): PromptDigest | null {
const digestPath = path.join(config.vault.path, AGENT_DIGEST_PATH);
try {
const raw = fs.readFileSync(digestPath, "utf8");
const parsed = JSON.parse(raw) as PromptDigest;
if (!parsed || typeof parsed !== "object") {
return null;
}
return parsed;
} catch {
return null;
}
}
function rankPromptDigestPage(page: PromptDigestPage): number {
return (
(page.contradictions?.length ?? 0) * 6 +
(page.questions?.length ?? 0) * 4 +
Math.min(page.claimCount ?? 0, 6) * 2 +
Math.min(page.topClaims?.length ?? 0, 3)
);
}
function formatPromptClaim(claim: PromptDigestClaim): string {
const qualifiers = [
claim.status?.trim() ? `status ${claim.status.trim()}` : null,
typeof claim.confidence === "number" ? `confidence ${claim.confidence.toFixed(2)}` : null,
claim.freshnessLevel?.trim() ? `freshness ${claim.freshnessLevel.trim()}` : null,
].filter(Boolean);
if (qualifiers.length === 0) {
return claim.text;
}
return `${claim.text} (${qualifiers.join(", ")})`;
}
function buildDigestPromptSection(config: ResolvedMemoryWikiConfig): string[] {
if (!config.context.includeCompiledDigestPrompt) {
return [];
}
const digest = tryReadPromptDigest(config);
if (!digest?.pages?.length) {
return [];
}
const selectedPages = [...digest.pages]
.filter(
(page) =>
(page.claimCount ?? 0) > 0 ||
(page.questions?.length ?? 0) > 0 ||
(page.contradictions?.length ?? 0) > 0,
)
.toSorted((left, right) => {
const leftScore = rankPromptDigestPage(left);
const rightScore = rankPromptDigestPage(right);
if (leftScore !== rightScore) {
return rightScore - leftScore;
}
return left.title.localeCompare(right.title);
})
.slice(0, DIGEST_MAX_PAGES);
if (selectedPages.length === 0) {
return [];
}
const lines = [
"## Compiled Wiki Snapshot",
`Compiled wiki currently tracks ${digest.claimCount ?? 0} claims across ${selectedPages.length} high-signal pages.`,
];
if (Array.isArray(digest.contradictionClusters)) {
lines.push(`Contradiction clusters: ${digest.contradictionClusters.length}.`);
}
for (const page of selectedPages) {
const details = [
page.kind,
`${page.claimCount} claims`,
(page.questions?.length ?? 0) > 0 ? `${page.questions?.length} open questions` : null,
(page.contradictions?.length ?? 0) > 0
? `${page.contradictions?.length} contradiction notes`
: null,
].filter(Boolean);
lines.push(`- ${page.title}: ${details.join(", ")}`);
for (const claim of (page.topClaims ?? []).slice(0, DIGEST_MAX_CLAIMS_PER_PAGE)) {
lines.push(` - ${formatPromptClaim(claim)}`);
}
}
lines.push("");
return lines;
}
function buildWikiToolGuidance(availableTools: Set<string>): string[] {
const hasMemorySearch = availableTools.has("memory_search");
const hasMemoryGet = availableTools.has("memory_get");
const hasWikiSearch = availableTools.has("wiki_search");
@@ -59,4 +179,25 @@ export const buildWikiPromptSection: MemoryPromptSectionBuilder = ({ availableTo
}
lines.push("");
return lines;
};
}
export function createWikiPromptSectionBuilder(
config: ResolvedMemoryWikiConfig,
): MemoryPromptSectionBuilder {
return ({ availableTools }) => {
const digestLines = buildDigestPromptSection(config);
const toolGuidance = buildWikiToolGuidance(availableTools);
if (digestLines.length === 0 && toolGuidance.length === 0) {
return [];
}
return [...toolGuidance, ...digestLines];
};
}
export const buildWikiPromptSection: MemoryPromptSectionBuilder = ({ availableTools }) =>
createWikiPromptSectionBuilder(
resolveMemoryWikiConfig({
vault: { path: "" },
context: { includeCompiledDigestPrompt: false },
}),
)({ availableTools });