feat(memory-wiki): add prompt supplement integration

This commit is contained in:
Vincent Koc
2026-04-05 21:40:56 +01:00
parent 00372508b5
commit c11e7a7420
15 changed files with 154 additions and 16 deletions

View File

@@ -1,2 +1,2 @@
97509287d728c8f5d1736f7ea07521451ada4b9d7ef56555dbe860a89e1b6e08 plugin-sdk-api-baseline.json
a22b3d427953cc8394b28c87ef7a992d2eb4f2c9f6a76fa58b33079e2306661b plugin-sdk-api-baseline.jsonl
73fa1674a0b450c3ef489ea6d16983c81285ffe0c896072bfae2b4b24d0cf15e plugin-sdk-api-baseline.json
4e83b129be7c697489a1b33f891c31852be90d077483599bf6f4c6f7cfc2eb79 plugin-sdk-api-baseline.jsonl

View File

@@ -308,14 +308,15 @@ methods:
### Infrastructure
| Method | What it registers |
| ---------------------------------------------- | --------------------- |
| `api.registerHook(events, handler, opts?)` | Event hook |
| `api.registerHttpRoute(params)` | Gateway HTTP endpoint |
| `api.registerGatewayMethod(name, handler)` | Gateway RPC method |
| `api.registerCli(registrar, opts?)` | CLI subcommand |
| `api.registerService(service)` | Background service |
| `api.registerInteractiveHandler(registration)` | Interactive handler |
| Method | What it registers |
| ---------------------------------------------- | --------------------------------------- |
| `api.registerHook(events, handler, opts?)` | Event hook |
| `api.registerHttpRoute(params)` | Gateway HTTP endpoint |
| `api.registerGatewayMethod(name, handler)` | Gateway RPC method |
| `api.registerCli(registrar, opts?)` | CLI subcommand |
| `api.registerService(service)` | Background service |
| `api.registerInteractiveHandler(registration)` | Interactive handler |
| `api.registerMemoryPromptSupplement(builder)` | Additive memory-adjacent prompt section |
Reserved core admin namespaces (`config.*`, `exec.approvals.*`, `wizard.*`,
`update.*`) always stay `operator.admin`, even if a plugin tries to assign a

View File

@@ -62,6 +62,7 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
registerCommand() {},
registerContextEngine() {},
registerMemoryPromptSection() {},
registerMemoryPromptSupplement() {},
registerMemoryFlushPlan() {},
registerMemoryRuntime() {},
registerMemoryEmbeddingProvider() {},

View File

@@ -6,6 +6,7 @@ import plugin from "./index.js";
function createApi() {
const registerCli = vi.fn();
const registerGatewayMethod = vi.fn();
const registerMemoryPromptSupplement = vi.fn();
const registerTool = vi.fn();
const api = createTestPluginApi({
id: "memory-wiki",
@@ -15,17 +16,31 @@ function createApi() {
runtime: {} as OpenClawPluginApi["runtime"],
registerCli,
registerGatewayMethod,
registerMemoryPromptSupplement,
registerTool,
}) as OpenClawPluginApi;
return { api, registerCli, registerGatewayMethod, registerTool };
return {
api,
registerCli,
registerGatewayMethod,
registerMemoryPromptSupplement,
registerTool,
};
}
describe("memory-wiki plugin", () => {
it("registers gateway methods, tools, and wiki cli surface", async () => {
const { api, registerCli, registerGatewayMethod, registerTool } = createApi();
it("registers prompt supplement, gateway methods, tools, and wiki cli surface", async () => {
const {
api,
registerCli,
registerGatewayMethod,
registerMemoryPromptSupplement,
registerTool,
} = createApi();
await plugin.register(api);
expect(registerMemoryPromptSupplement).toHaveBeenCalledTimes(1);
expect(registerGatewayMethod.mock.calls.map((call) => call[0])).toEqual([
"wiki.status",
"wiki.init",

View File

@@ -2,6 +2,7 @@ import { definePluginEntry } from "./api.js";
import { registerWikiCli } from "./src/cli.js";
import { memoryWikiConfigSchema, resolveMemoryWikiConfig } from "./src/config.js";
import { registerMemoryWikiGatewayMethods } from "./src/gateway.js";
import { buildWikiPromptSection } from "./src/prompt-section.js";
import {
createWikiApplyTool,
createWikiGetTool,
@@ -18,6 +19,7 @@ export default definePluginEntry({
register(api) {
const config = resolveMemoryWikiConfig(api.pluginConfig);
api.registerMemoryPromptSupplement(buildWikiPromptSection);
registerMemoryWikiGatewayMethods({ api, config, appConfig: api.config });
api.registerTool(createWikiStatusTool(config, api.config), { name: "wiki_status" });
api.registerTool(createWikiLintTool(config, api.config), { name: "wiki_lint" });

View File

@@ -0,0 +1,42 @@
import type { MemoryPromptSectionBuilder } from "openclaw/plugin-sdk/memory-host-core";
export const buildWikiPromptSection: MemoryPromptSectionBuilder = ({ availableTools }) => {
const hasWikiSearch = availableTools.has("wiki_search");
const hasWikiGet = availableTools.has("wiki_get");
const hasWikiApply = availableTools.has("wiki_apply");
const hasWikiLint = availableTools.has("wiki_lint");
if (!hasWikiSearch && !hasWikiGet && !hasWikiApply && !hasWikiLint) {
return [];
}
const lines = [
"## Compiled Wiki",
"Use the wiki when the answer depends on accumulated project knowledge, prior syntheses, entity pages, or source-backed notes that should survive beyond one conversation.",
];
if (hasWikiSearch && hasWikiGet) {
lines.push(
"Workflow: `wiki_search` first, then `wiki_get` for the exact page or imported memory file you need. Shared search may return `corpus=memory` results when active-memory bridging is enabled.",
);
} else if (hasWikiSearch) {
lines.push(
"Use `wiki_search` before answering from stored knowledge. Shared search may return `corpus=memory` results when active-memory bridging is enabled.",
);
} else if (hasWikiGet) {
lines.push(
"Use `wiki_get` to inspect specific wiki pages or imported memory files by path/id.",
);
}
if (hasWikiApply) {
lines.push(
"Use `wiki_apply` for narrow synthesis filing and metadata repair instead of rewriting managed markdown blocks by hand.",
);
}
if (hasWikiLint) {
lines.push("After meaningful wiki updates, run `wiki_lint` before trusting the vault.");
}
lines.push("");
return lines;
};

View File

@@ -41,6 +41,7 @@ export type BuildPluginApiParams = {
| "registerCommand"
| "registerContextEngine"
| "registerMemoryPromptSection"
| "registerMemoryPromptSupplement"
| "registerMemoryFlushPlan"
| "registerMemoryRuntime"
| "registerMemoryEmbeddingProvider"
@@ -78,6 +79,8 @@ const noopOnConversationBindingResolved: OpenClawPluginApi["onConversationBindin
const noopRegisterCommand: OpenClawPluginApi["registerCommand"] = () => {};
const noopRegisterContextEngine: OpenClawPluginApi["registerContextEngine"] = () => {};
const noopRegisterMemoryPromptSection: OpenClawPluginApi["registerMemoryPromptSection"] = () => {};
const noopRegisterMemoryPromptSupplement: OpenClawPluginApi["registerMemoryPromptSupplement"] =
() => {};
const noopRegisterMemoryFlushPlan: OpenClawPluginApi["registerMemoryFlushPlan"] = () => {};
const noopRegisterMemoryRuntime: OpenClawPluginApi["registerMemoryRuntime"] = () => {};
const noopRegisterMemoryEmbeddingProvider: OpenClawPluginApi["registerMemoryEmbeddingProvider"] =
@@ -129,6 +132,8 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi
registerContextEngine: handlers.registerContextEngine ?? noopRegisterContextEngine,
registerMemoryPromptSection:
handlers.registerMemoryPromptSection ?? noopRegisterMemoryPromptSection,
registerMemoryPromptSupplement:
handlers.registerMemoryPromptSupplement ?? noopRegisterMemoryPromptSupplement,
registerMemoryFlushPlan: handlers.registerMemoryFlushPlan ?? noopRegisterMemoryFlushPlan,
registerMemoryRuntime: handlers.registerMemoryRuntime ?? noopRegisterMemoryRuntime,
registerMemoryEmbeddingProvider:

View File

@@ -9,6 +9,7 @@ import {
buildMemoryPromptSection,
getMemoryRuntime,
registerMemoryFlushPlanResolver,
registerMemoryPromptSupplement,
registerMemoryPromptSection,
registerMemoryRuntime,
resolveMemoryFlushPlan,
@@ -156,6 +157,7 @@ describe("clearPluginLoaderCache", () => {
create: async () => ({ provider: null }),
});
registerMemoryPromptSection(() => ["stale memory section"]);
registerMemoryPromptSupplement("memory-wiki", () => ["stale wiki supplement"]);
registerMemoryFlushPlanResolver(() => ({
softThresholdTokens: 1,
forceFlushTranscriptBytes: 2,
@@ -174,6 +176,7 @@ describe("clearPluginLoaderCache", () => {
});
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([
"stale memory section",
"stale wiki supplement",
]);
expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/stale.md");
expect(getMemoryRuntime()).toBeDefined();

View File

@@ -29,6 +29,7 @@ import {
buildMemoryPromptSection,
getMemoryRuntime,
registerMemoryFlushPlanResolver,
registerMemoryPromptSupplement,
registerMemoryPromptSection,
registerMemoryRuntime,
resolveMemoryFlushPlan,
@@ -1382,6 +1383,7 @@ module.exports = { id: "throws-after-import", register() {} };`,
create: async () => ({ provider: null }),
});
registerMemoryPromptSection(() => ["active memory section"]);
registerMemoryPromptSupplement("memory-wiki", () => ["active wiki supplement"]);
registerMemoryFlushPlanResolver(() => ({
softThresholdTokens: 1,
forceFlushTranscriptBytes: 2,
@@ -1448,6 +1450,7 @@ module.exports = { id: "throws-after-import", register() {} };`,
expect(scoped.plugins.find((entry) => entry.id === "snapshot-memory")?.status).toBe("loaded");
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([
"active memory section",
"active wiki supplement",
]);
expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/active.md");
expect(getMemoryRuntime()).toBe(activeRuntime);
@@ -1468,6 +1471,7 @@ module.exports = { id: "throws-after-import", register() {} };`,
create: async () => ({ provider: null }),
});
api.registerMemoryPromptSection(() => ["stale failure section"]);
api.registerMemoryPromptSupplement(() => ["stale failure supplement"]);
api.registerMemoryFlushPlan(() => ({
softThresholdTokens: 10,
forceFlushTranscriptBytes: 20,

View File

@@ -39,6 +39,7 @@ import {
getMemoryFlushPlanResolver,
getMemoryPromptSectionBuilder,
getMemoryRuntime,
listMemoryPromptSupplements,
restoreMemoryPluginState,
} from "./memory-state.js";
import { isPathInside, safeStatSync } from "./path-safety.js";
@@ -132,6 +133,7 @@ type CachedPluginState = {
memoryEmbeddingProviders: ReturnType<typeof listRegisteredMemoryEmbeddingProviders>;
memoryFlushPlanResolver: ReturnType<typeof getMemoryFlushPlanResolver>;
memoryPromptBuilder: ReturnType<typeof getMemoryPromptSectionBuilder>;
memoryPromptSupplements: ReturnType<typeof listMemoryPromptSupplements>;
memoryRuntime: ReturnType<typeof getMemoryRuntime>;
};
@@ -993,6 +995,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
restoreRegisteredMemoryEmbeddingProviders(cached.memoryEmbeddingProviders);
restoreMemoryPluginState({
promptBuilder: cached.memoryPromptBuilder,
promptSupplements: cached.memoryPromptSupplements,
flushPlanResolver: cached.memoryFlushPlanResolver,
runtime: cached.memoryRuntime,
});
@@ -1557,6 +1560,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
const previousMemoryEmbeddingProviders = listRegisteredMemoryEmbeddingProviders();
const previousMemoryFlushPlanResolver = getMemoryFlushPlanResolver();
const previousMemoryPromptBuilder = getMemoryPromptSectionBuilder();
const previousMemoryPromptSupplements = listMemoryPromptSupplements();
const previousMemoryRuntime = getMemoryRuntime();
try {
@@ -1574,6 +1578,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
restoreRegisteredMemoryEmbeddingProviders(previousMemoryEmbeddingProviders);
restoreMemoryPluginState({
promptBuilder: previousMemoryPromptBuilder,
promptSupplements: previousMemoryPromptSupplements,
flushPlanResolver: previousMemoryFlushPlanResolver,
runtime: previousMemoryRuntime,
});
@@ -1584,6 +1589,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
restoreRegisteredMemoryEmbeddingProviders(previousMemoryEmbeddingProviders);
restoreMemoryPluginState({
promptBuilder: previousMemoryPromptBuilder,
promptSupplements: previousMemoryPromptSupplements,
flushPlanResolver: previousMemoryFlushPlanResolver,
runtime: previousMemoryRuntime,
});
@@ -1639,6 +1645,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
memoryEmbeddingProviders: listRegisteredMemoryEmbeddingProviders(),
memoryFlushPlanResolver: getMemoryFlushPlanResolver(),
memoryPromptBuilder: getMemoryPromptSectionBuilder(),
memoryPromptSupplements: listMemoryPromptSupplements(),
memoryRuntime: getMemoryRuntime(),
});
}

View File

@@ -6,7 +6,9 @@ import {
getMemoryFlushPlanResolver,
getMemoryPromptSectionBuilder,
getMemoryRuntime,
listMemoryPromptSupplements,
registerMemoryFlushPlanResolver,
registerMemoryPromptSupplement,
registerMemoryPromptSection,
registerMemoryRuntime,
resolveMemoryFlushPlan,
@@ -44,6 +46,7 @@ function expectClearedMemoryState() {
function createMemoryStateSnapshot() {
return {
promptBuilder: getMemoryPromptSectionBuilder(),
promptSupplements: listMemoryPromptSupplements(),
flushPlanResolver: getMemoryFlushPlanResolver(),
runtime: getMemoryRuntime(),
};
@@ -103,6 +106,18 @@ describe("memory plugin state", () => {
).toEqual(["citations: off"]);
});
it("appends prompt supplements in plugin-id order", () => {
registerMemoryPromptSection(() => ["primary"]);
registerMemoryPromptSupplement("memory-wiki", () => ["wiki"]);
registerMemoryPromptSupplement("alpha-helper", () => ["alpha"]);
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([
"primary",
"alpha",
"wiki",
]);
});
it("uses the registered flush plan resolver", () => {
registerMemoryFlushPlanResolver(() => ({
softThresholdTokens: 1,
@@ -137,13 +152,17 @@ describe("memory plugin state", () => {
relativePath: "memory/first.md",
runtime,
});
registerMemoryPromptSupplement("memory-wiki", () => ["wiki supplement"]);
const snapshot = createMemoryStateSnapshot();
_resetMemoryPluginState();
expectClearedMemoryState();
restoreMemoryPluginState(snapshot);
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual(["first"]);
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([
"first",
"wiki supplement",
]);
expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/first.md");
expect(getMemoryRuntime()).toBe(runtime);
});

View File

@@ -7,6 +7,11 @@ export type MemoryPromptSectionBuilder = (params: {
citationsMode?: MemoryCitationsMode;
}) => string[];
export type MemoryPromptSupplementRegistration = {
pluginId: string;
builder: MemoryPromptSectionBuilder;
};
export type MemoryFlushPlan = {
softThresholdTokens: number;
forceFlushTranscriptBytes: number;
@@ -54,27 +59,50 @@ export type MemoryPluginRuntime = {
type MemoryPluginState = {
promptBuilder?: MemoryPromptSectionBuilder;
promptSupplements: MemoryPromptSupplementRegistration[];
flushPlanResolver?: MemoryFlushPlanResolver;
runtime?: MemoryPluginRuntime;
};
const memoryPluginState: MemoryPluginState = {};
const memoryPluginState: MemoryPluginState = {
promptSupplements: [],
};
export function registerMemoryPromptSection(builder: MemoryPromptSectionBuilder): void {
memoryPluginState.promptBuilder = builder;
}
export function registerMemoryPromptSupplement(
pluginId: string,
builder: MemoryPromptSectionBuilder,
): void {
const next = memoryPluginState.promptSupplements.filter(
(registration) => registration.pluginId !== pluginId,
);
next.push({ pluginId, builder });
memoryPluginState.promptSupplements = next;
}
export function buildMemoryPromptSection(params: {
availableTools: Set<string>;
citationsMode?: MemoryCitationsMode;
}): string[] {
return memoryPluginState.promptBuilder?.(params) ?? [];
const primary = memoryPluginState.promptBuilder?.(params) ?? [];
const supplements = memoryPluginState.promptSupplements
// Keep supplement order stable even if plugin registration order changes.
.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId))
.flatMap((registration) => registration.builder(params));
return [...primary, ...supplements];
}
export function getMemoryPromptSectionBuilder(): MemoryPromptSectionBuilder | undefined {
return memoryPluginState.promptBuilder;
}
export function listMemoryPromptSupplements(): MemoryPromptSupplementRegistration[] {
return [...memoryPluginState.promptSupplements];
}
export function registerMemoryFlushPlanResolver(resolver: MemoryFlushPlanResolver): void {
memoryPluginState.flushPlanResolver = resolver;
}
@@ -104,12 +132,14 @@ export function hasMemoryRuntime(): boolean {
export function restoreMemoryPluginState(state: MemoryPluginState): void {
memoryPluginState.promptBuilder = state.promptBuilder;
memoryPluginState.promptSupplements = [...state.promptSupplements];
memoryPluginState.flushPlanResolver = state.flushPlanResolver;
memoryPluginState.runtime = state.runtime;
}
export function clearMemoryPluginState(): void {
memoryPluginState.promptBuilder = undefined;
memoryPluginState.promptSupplements = [];
memoryPluginState.flushPlanResolver = undefined;
memoryPluginState.runtime = undefined;
}

View File

@@ -25,6 +25,7 @@ import {
} from "./memory-embedding-providers.js";
import {
registerMemoryFlushPlanResolver,
registerMemoryPromptSupplement,
registerMemoryPromptSection,
registerMemoryRuntime,
} from "./memory-state.js";
@@ -1116,6 +1117,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
}
registerMemoryPromptSection(builder);
},
registerMemoryPromptSupplement: (builder) => {
registerMemoryPromptSupplement(record.id, builder);
},
registerMemoryFlushPlan: (resolver) => {
if (!hasKind(record.kind, "memory")) {
pushDiagnostic({

View File

@@ -2082,6 +2082,10 @@ export type OpenClawPluginApi = {
registerMemoryPromptSection: (
builder: import("./memory-state.js").MemoryPromptSectionBuilder,
) => void;
/** Register an additive memory-adjacent prompt section (non-exclusive). */
registerMemoryPromptSupplement: (
builder: import("./memory-state.js").MemoryPromptSectionBuilder,
) => void;
/** Register the pre-compaction flush plan resolver for this memory plugin (exclusive slot). */
registerMemoryFlushPlan: (resolver: import("./memory-state.js").MemoryFlushPlanResolver) => void;
/** Register the active memory runtime adapter for this memory plugin (exclusive slot). */

View File

@@ -33,6 +33,7 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi
registerCommand() {},
registerContextEngine() {},
registerMemoryPromptSection() {},
registerMemoryPromptSupplement() {},
registerMemoryFlushPlan() {},
registerMemoryRuntime() {},
registerMemoryEmbeddingProvider() {},