mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
feat(memory-wiki): gate compiled digest prompts
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user