From 6a559f0293f488f950e27d422d87782a9e6a76ce Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 7 Apr 2026 08:21:16 +0100 Subject: [PATCH] feat(memory-wiki): gate compiled digest prompts --- CHANGELOG.md | 1 + extensions/memory-wiki/README.md | 6 + extensions/memory-wiki/index.ts | 4 +- extensions/memory-wiki/openclaw.plugin.json | 13 ++ extensions/memory-wiki/src/config.test.ts | 4 + extensions/memory-wiki/src/config.ts | 14 ++ .../memory-wiki/src/prompt-section.test.ts | 88 ++++++++++- extensions/memory-wiki/src/prompt-section.ts | 145 +++++++++++++++++- 8 files changed, 269 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d76f588cb8f..9a78e3645eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/extensions/memory-wiki/README.md b/extensions/memory-wiki/README.md index bf7fcd05ac7..d2564ed7b3a 100644 --- a/extensions/memory-wiki/README.md +++ b/extensions/memory-wiki/README.md @@ -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: diff --git a/extensions/memory-wiki/index.ts b/extensions/memory-wiki/index.ts index 3c02de21241..f347e554cd2 100644 --- a/extensions/memory-wiki/index.ts +++ b/extensions/memory-wiki/index.ts @@ -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 }), ); diff --git a/extensions/memory-wiki/openclaw.plugin.json b/extensions/memory-wiki/openclaw.plugin.json index 7b6f38e1323..9c14e713fe0 100644 --- a/extensions/memory-wiki/openclaw.plugin.json +++ b/extensions/memory-wiki/openclaw.plugin.json @@ -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, diff --git a/extensions/memory-wiki/src/config.test.ts b/extensions/memory-wiki/src/config.test.ts index d0becd6eb31..bf0e1912fce 100644 --- a/extensions/memory-wiki/src/config.test.ts +++ b/extensions/memory-wiki/src/config.test.ts @@ -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); diff --git a/extensions/memory-wiki/src/config.ts b/extensions/memory-wiki/src/config.ts index 5c2fa6beead..a15dcf8c6f4 100644 --- a/extensions/memory-wiki/src/config.ts +++ b/extensions/memory-wiki/src/config.ts @@ -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, diff --git a/extensions/memory-wiki/src/prompt-section.test.ts b/extensions/memory-wiki/src/prompt-section.test.ts index fac0db906c8..58b82a254b4 100644 --- a/extensions/memory-wiki/src/prompt-section.test.ts +++ b/extensions/memory-wiki/src/prompt-section.test.ts @@ -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([]); + }); }); diff --git a/extensions/memory-wiki/src/prompt-section.ts b/extensions/memory-wiki/src/prompt-section.ts index e895aeea7b5..51029accd2f 100644 --- a/extensions/memory-wiki/src/prompt-section.ts +++ b/extensions/memory-wiki/src/prompt-section.ts @@ -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; + claimCount?: number; + contradictionClusters?: Array; + 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[] { 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 });