feat(memory-wiki): add belief-layer digests and compat migration

This commit is contained in:
Vincent Koc
2026-04-07 08:01:49 +01:00
parent d5ed6d26e9
commit 947a43dae3
55 changed files with 1900 additions and 597 deletions

View File

@@ -18,6 +18,8 @@ Docs: https://docs.openclaw.ai
- Gateway/sessions: add persisted compaction checkpoints plus Sessions UI branch/restore actions so operators can inspect and recover pre-compaction session state. (#62146) Thanks @scoootscooob.
- Providers/Ollama: detect vision capability from the `/api/show` response and set image input on models that support it so Ollama vision models accept image attachments. (#62193) Thanks @BruceMacD.
- Memory/dreaming: ingest redacted session transcripts into the dreaming corpus with per-day session-corpus notes, cursor checkpointing, and promotion/doctor support. (#62227) Thanks @vignesh07.
- Plugins/memory: add a public memory-artifact export seam to the unified memory capability so companion plugins like `memory-wiki` can bridge the active memory plugin without reaching into `memory-core` internals. Thanks @vincentkoc.
- 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.
### Fixes

View File

@@ -248,7 +248,10 @@ OpenClaw resolves when it needs a context engine.
- **Memory plugins** (`plugins.slots.memory`) are separate from context engines.
Memory plugins provide search/retrieval; context engines control what the
model sees. They can work together — a context engine might use memory
plugin data during assembly.
plugin data during assembly. Plugin engines that want the active memory
plugin's legacy prompt guidance can pull it explicitly from
`openclaw/plugin-sdk/memory-host-core` via
`buildActiveMemoryPromptSection(...)`.
- **Session pruning** (trimming old tool results in-memory) still runs
regardless of which context engine is active.

View File

@@ -386,6 +386,7 @@ AI CLI backend such as `codex-cli`.
| Method | What it registers |
| ------------------------------------------ | ------------------------------------- |
| `api.registerContextEngine(id, factory)` | Context engine (one active at a time) |
| `api.registerMemoryCapability(capability)` | Unified memory capability |
| `api.registerMemoryPromptSection(builder)` | Memory prompt section builder |
| `api.registerMemoryFlushPlan(resolver)` | Memory flush plan resolver |
| `api.registerMemoryRuntime(runtime)` | Memory runtime adapter |
@@ -396,8 +397,13 @@ AI CLI backend such as `codex-cli`.
| ---------------------------------------------- | ---------------------------------------------- |
| `api.registerMemoryEmbeddingProvider(adapter)` | Memory embedding adapter for the active plugin |
- `registerMemoryCapability` is the preferred exclusive memory-plugin API.
- `registerMemoryCapability` may also expose `publicArtifacts.listArtifacts(...)`
so companion plugins can consume exported memory artifacts through
`openclaw/plugin-sdk/memory-host-core` instead of reaching into a specific
memory plugin's private layout.
- `registerMemoryPromptSection`, `registerMemoryFlushPlan`, and
`registerMemoryRuntime` are exclusive to memory plugins.
`registerMemoryRuntime` are legacy-compatible exclusive memory-plugin APIs.
- `registerMemoryEmbeddingProvider` lets the active memory plugin register one
or more embedding adapter ids (for example `openai`, `gemini`, or a custom
plugin-defined id).

View File

@@ -10,6 +10,7 @@ import {
} from "./src/flush-plan.js";
import { registerBuiltInMemoryEmbeddingProviders } from "./src/memory/provider-adapters.js";
import { buildPromptSection } from "./src/prompt-section.js";
import { listMemoryCorePublicArtifacts } from "./src/public-artifacts.js";
import { memoryRuntime } from "./src/runtime-provider.js";
import { createMemoryGetTool, createMemorySearchTool } from "./src/tools.js";
export {
@@ -29,9 +30,14 @@ export default definePluginEntry({
registerBuiltInMemoryEmbeddingProviders(api);
registerShortTermPromotionDreaming(api);
registerDreamingCommand(api);
api.registerMemoryPromptSection(buildPromptSection);
api.registerMemoryFlushPlan(buildMemoryFlushPlan);
api.registerMemoryRuntime(memoryRuntime);
api.registerMemoryCapability({
promptBuilder: buildPromptSection,
flushPlanResolver: buildMemoryFlushPlan,
runtime: memoryRuntime,
publicArtifacts: {
listArtifacts: listMemoryCorePublicArtifacts,
},
});
api.registerTool(
(ctx) =>

View File

@@ -1,9 +1,14 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core";
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core";
import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory-core";
import {
resolveMemoryCorePluginConfig,
resolveMemoryLightDreamingConfig,
resolveMemoryRemDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
import { describe, expect, it, vi } from "vitest";
import { registerMemoryDreamingPhases } from "./dreaming-phases.js";
import { __testing } from "./dreaming-phases.js";
import {
rankShortTermPromotionCandidates,
recordShortTermRecalls,
@@ -36,45 +41,55 @@ const LIGHT_DREAMING_TEST_CONFIG: OpenClawConfig = {
};
function createHarness(config: OpenClawConfig, workspaceDir?: string) {
let beforeAgentReply:
| ((
event: { cleanedBody: string },
ctx: { trigger?: string; workspaceDir?: string },
) => Promise<unknown>)
| undefined;
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const api = {
config: workspaceDir
? {
...config,
agents: {
...config.agents,
defaults: {
...config.agents?.defaults,
workspace: workspaceDir,
},
const resolvedConfig = workspaceDir
? {
...config,
agents: {
...config.agents,
defaults: {
...config.agents?.defaults,
workspace: workspaceDir,
},
}
: config,
pluginConfig: {},
logger,
registerHook: vi.fn(),
on: vi.fn((name: string, handler: unknown) => {
if (name === "before_agent_reply") {
beforeAgentReply = handler as typeof beforeAgentReply;
},
}
}),
} as unknown as OpenClawPluginApi;
registerMemoryDreamingPhases(api);
if (!beforeAgentReply) {
throw new Error("before_agent_reply hook not registered");
}
: config;
const pluginConfig = resolveMemoryCorePluginConfig(resolvedConfig) ?? {};
const beforeAgentReply = async (
event: { cleanedBody: string },
ctx: { trigger?: string; workspaceDir?: string },
) => {
const light = resolveMemoryLightDreamingConfig({ pluginConfig, cfg: resolvedConfig });
const lightResult = await __testing.runPhaseIfTriggered({
cleanedBody: event.cleanedBody,
trigger: ctx.trigger,
workspaceDir: ctx.workspaceDir,
cfg: resolvedConfig,
logger,
phase: "light",
eventText: __testing.constants.LIGHT_SLEEP_EVENT_TEXT,
config: light,
});
if (lightResult) {
return lightResult;
}
const rem = resolveMemoryRemDreamingConfig({ pluginConfig, cfg: resolvedConfig });
return await __testing.runPhaseIfTriggered({
cleanedBody: event.cleanedBody,
trigger: ctx.trigger,
workspaceDir: ctx.workspaceDir,
cfg: resolvedConfig,
logger,
phase: "rem",
eventText: __testing.constants.REM_SLEEP_EVENT_TEXT,
config: rem,
});
};
return { beforeAgentReply, logger };
}

View File

@@ -12,7 +12,6 @@ import {
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import {
formatMemoryDreamingDay,
resolveMemoryCorePluginConfig,
resolveMemoryDreamingWorkspaces,
resolveMemoryLightDreamingConfig,
resolveMemoryRemDreamingConfig,
@@ -30,61 +29,7 @@ import {
} from "./short-term-promotion.js";
type Logger = Pick<OpenClawPluginApi["logger"], "info" | "warn" | "error">;
type CronSchedule = { kind: "cron"; expr: string; tz?: string };
type CronPayload = { kind: "systemEvent"; text: string };
type ManagedCronJobCreate = {
name: string;
description: string;
enabled: boolean;
schedule: CronSchedule;
sessionTarget: "main";
wakeMode: "next-heartbeat";
payload: CronPayload;
};
type ManagedCronJobPatch = {
name?: string;
description?: string;
enabled?: boolean;
schedule?: CronSchedule;
sessionTarget?: "main";
wakeMode?: "next-heartbeat";
payload?: CronPayload;
};
type ManagedCronJobLike = {
id: string;
name?: string;
description?: string;
enabled?: boolean;
schedule?: {
kind?: string;
expr?: string;
tz?: string;
};
sessionTarget?: string;
wakeMode?: string;
payload?: {
kind?: string;
text?: string;
};
createdAtMs?: number;
};
type CronServiceLike = {
list: (opts?: { includeDisabled?: boolean }) => Promise<ManagedCronJobLike[]>;
add: (input: ManagedCronJobCreate) => Promise<unknown>;
update: (id: string, patch: ManagedCronJobPatch) => Promise<unknown>;
remove: (id: string) => Promise<{ removed?: boolean }>;
};
const LIGHT_SLEEP_CRON_NAME = "Memory Light Dreaming";
const LIGHT_SLEEP_CRON_TAG = "[managed-by=memory-core.dreaming.light]";
const LIGHT_SLEEP_EVENT_TEXT = "__openclaw_memory_core_light_sleep__";
const REM_SLEEP_CRON_NAME = "Memory REM Dreaming";
const REM_SLEEP_CRON_TAG = "[managed-by=memory-core.dreaming.rem]";
const REM_SLEEP_EVENT_TEXT = "__openclaw_memory_core_rem_sleep__";
const DAILY_MEMORY_FILENAME_RE = /^(\d{4}-\d{2}-\d{2})\.md$/;
const DAILY_INGESTION_STATE_RELATIVE_PATH = path.join("memory", ".dreams", "daily-ingestion.json");
@@ -121,193 +66,6 @@ const MANAGED_DAILY_DREAMING_BLOCKS = [
},
] as const;
function buildCronDescription(params: {
tag: string;
phase: "light" | "rem";
cron: string;
limit: number;
lookbackDays: number;
}): string {
return `${params.tag} Run ${params.phase} dreaming (cron=${params.cron}, limit=${params.limit}, lookbackDays=${params.lookbackDays}).`;
}
function buildManagedCronJob(params: {
name: string;
tag: string;
payloadText: string;
cron: string;
timezone?: string;
phase: "light" | "rem";
limit: number;
lookbackDays: number;
}): ManagedCronJobCreate {
return {
name: params.name,
description: buildCronDescription({
tag: params.tag,
phase: params.phase,
cron: params.cron,
limit: params.limit,
lookbackDays: params.lookbackDays,
}),
enabled: true,
schedule: {
kind: "cron",
expr: params.cron,
...(params.timezone ? { tz: params.timezone } : {}),
},
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: {
kind: "systemEvent",
text: params.payloadText,
},
};
}
function isManagedPhaseJob(
job: ManagedCronJobLike,
params: {
name: string;
tag: string;
payloadText: string;
},
): boolean {
const description = normalizeTrimmedString(job.description);
if (description?.includes(params.tag)) {
return true;
}
const name = normalizeTrimmedString(job.name);
const payloadText = normalizeTrimmedString(job.payload?.text);
return name === params.name && payloadText === params.payloadText;
}
function buildManagedPhasePatch(
job: ManagedCronJobLike,
desired: ManagedCronJobCreate,
): ManagedCronJobPatch | null {
const patch: ManagedCronJobPatch = {};
const scheduleKind = normalizeTrimmedString(job.schedule?.kind)?.toLowerCase();
const scheduleExpr = normalizeTrimmedString(job.schedule?.expr);
const scheduleTz = normalizeTrimmedString(job.schedule?.tz);
if (normalizeTrimmedString(job.name) !== desired.name) {
patch.name = desired.name;
}
if (normalizeTrimmedString(job.description) !== desired.description) {
patch.description = desired.description;
}
if (job.enabled !== true) {
patch.enabled = true;
}
if (
scheduleKind !== "cron" ||
scheduleExpr !== desired.schedule.expr ||
scheduleTz !== desired.schedule.tz
) {
patch.schedule = desired.schedule;
}
if (normalizeTrimmedString(job.sessionTarget)?.toLowerCase() !== "main") {
patch.sessionTarget = "main";
}
if (normalizeTrimmedString(job.wakeMode)?.toLowerCase() !== "next-heartbeat") {
patch.wakeMode = "next-heartbeat";
}
const payloadKind = normalizeTrimmedString(job.payload?.kind)?.toLowerCase();
const payloadText = normalizeTrimmedString(job.payload?.text);
if (payloadKind !== "systemevent" || payloadText !== desired.payload.text) {
patch.payload = desired.payload;
}
return Object.keys(patch).length > 0 ? patch : null;
}
function sortManagedJobs(managed: ManagedCronJobLike[]): ManagedCronJobLike[] {
return managed.toSorted((a, b) => {
const aCreated =
typeof a.createdAtMs === "number" && Number.isFinite(a.createdAtMs)
? a.createdAtMs
: Number.MAX_SAFE_INTEGER;
const bCreated =
typeof b.createdAtMs === "number" && Number.isFinite(b.createdAtMs)
? b.createdAtMs
: Number.MAX_SAFE_INTEGER;
if (aCreated !== bCreated) {
return aCreated - bCreated;
}
return a.id.localeCompare(b.id);
});
}
function resolveCronServiceFromStartupEvent(event: unknown): CronServiceLike | null {
const payload = asRecord(event);
if (!payload || payload.type !== "gateway" || payload.action !== "startup") {
return null;
}
const context = asRecord(payload.context);
const deps = asRecord(context?.deps);
const cronCandidate = context?.cron ?? deps?.cron;
if (!cronCandidate || typeof cronCandidate !== "object") {
return null;
}
const cron = cronCandidate as Partial<CronServiceLike>;
if (
typeof cron.list !== "function" ||
typeof cron.add !== "function" ||
typeof cron.update !== "function" ||
typeof cron.remove !== "function"
) {
return null;
}
return cron as CronServiceLike;
}
async function reconcileManagedPhaseCronJob(params: {
cron: CronServiceLike | null;
desired: ManagedCronJobCreate;
match: { name: string; tag: string; payloadText: string };
enabled: boolean;
logger: Logger;
}): Promise<void> {
const cron = params.cron;
if (!cron) {
return;
}
const allJobs = await cron.list({ includeDisabled: true });
const managed = allJobs.filter((job) => isManagedPhaseJob(job, params.match));
if (!params.enabled) {
for (const job of managed) {
try {
await cron.remove(job.id);
} catch (err) {
params.logger.warn(
`memory-core: failed to remove managed ${params.match.name} cron job ${job.id}: ${formatErrorMessage(err)}`,
);
}
}
return;
}
if (managed.length === 0) {
await cron.add(params.desired);
return;
}
const [primary, ...duplicates] = sortManagedJobs(managed);
for (const duplicate of duplicates) {
try {
await cron.remove(duplicate.id);
} catch (err) {
params.logger.warn(
`memory-core: failed to prune duplicate managed ${params.match.name} cron job ${duplicate.id}: ${formatErrorMessage(err)}`,
);
}
}
const patch = buildManagedPhasePatch(primary, params.desired);
if (patch) {
await cron.update(primary.id, patch);
}
}
function resolveWorkspaces(params: {
cfg?: OpenClawConfig;
fallbackWorkspaceDir?: string;
@@ -1817,94 +1575,18 @@ async function runPhaseIfTriggered(params: {
return { handled: true, reason: `memory-core: ${params.phase} dreaming processed` };
}
export function registerMemoryDreamingPhases(api: OpenClawPluginApi): void {
api.registerHook(
"gateway:startup",
async (event: unknown) => {
const cron = resolveCronServiceFromStartupEvent(event);
const pluginConfig = resolveMemoryCorePluginConfig(api.config) ?? api.pluginConfig;
const light = resolveMemoryLightDreamingConfig({ pluginConfig, cfg: api.config });
const rem = resolveMemoryRemDreamingConfig({ pluginConfig, cfg: api.config });
const lightDesired = buildManagedCronJob({
name: LIGHT_SLEEP_CRON_NAME,
tag: LIGHT_SLEEP_CRON_TAG,
payloadText: LIGHT_SLEEP_EVENT_TEXT,
cron: light.cron,
timezone: light.timezone,
phase: "light",
limit: light.limit,
lookbackDays: light.lookbackDays,
});
const remDesired = buildManagedCronJob({
name: REM_SLEEP_CRON_NAME,
tag: REM_SLEEP_CRON_TAG,
payloadText: REM_SLEEP_EVENT_TEXT,
cron: rem.cron,
timezone: rem.timezone,
phase: "rem",
limit: rem.limit,
lookbackDays: rem.lookbackDays,
});
try {
await reconcileManagedPhaseCronJob({
cron,
desired: lightDesired,
match: {
name: LIGHT_SLEEP_CRON_NAME,
tag: LIGHT_SLEEP_CRON_TAG,
payloadText: LIGHT_SLEEP_EVENT_TEXT,
},
enabled: light.enabled,
logger: api.logger,
});
await reconcileManagedPhaseCronJob({
cron,
desired: remDesired,
match: {
name: REM_SLEEP_CRON_NAME,
tag: REM_SLEEP_CRON_TAG,
payloadText: REM_SLEEP_EVENT_TEXT,
},
enabled: rem.enabled,
logger: api.logger,
});
} catch (err) {
api.logger.error(
`memory-core: dreaming startup reconciliation failed: ${formatErrorMessage(err)}`,
);
}
},
{ name: "memory-core-dreaming-phase-cron" },
);
api.on("before_agent_reply", async (event, ctx) => {
const pluginConfig = resolveMemoryCorePluginConfig(api.config) ?? api.pluginConfig;
const light = resolveMemoryLightDreamingConfig({ pluginConfig, cfg: api.config });
const lightResult = await runPhaseIfTriggered({
cleanedBody: event.cleanedBody,
trigger: ctx.trigger,
workspaceDir: ctx.workspaceDir,
cfg: api.config,
logger: api.logger,
subagent: light.enabled ? api.runtime?.subagent : undefined,
phase: "light",
eventText: LIGHT_SLEEP_EVENT_TEXT,
config: light,
});
if (lightResult) {
return lightResult;
}
const rem = resolveMemoryRemDreamingConfig({ pluginConfig, cfg: api.config });
return await runPhaseIfTriggered({
cleanedBody: event.cleanedBody,
trigger: ctx.trigger,
workspaceDir: ctx.workspaceDir,
cfg: api.config,
logger: api.logger,
subagent: rem.enabled ? api.runtime?.subagent : undefined,
phase: "rem",
eventText: REM_SLEEP_EVENT_TEXT,
config: rem,
});
});
/**
* @deprecated Unified dreaming registration lives in registerShortTermPromotionDreaming().
*/
export function registerMemoryDreamingPhases(_api: OpenClawPluginApi): void {
// LEGACY(memory-v1): kept as a no-op compatibility shim while the unified
// dreaming controller owns startup reconciliation and heartbeat triggers.
}
export const __testing = {
runPhaseIfTriggered,
constants: {
LIGHT_SLEEP_EVENT_TEXT,
REM_SLEEP_EVENT_TEXT,
},
};

View File

@@ -493,7 +493,7 @@ describe("short-term dreaming cron reconciliation", () => {
expect(harness.jobs.map((entry) => entry.id)).toEqual(["job-other"]);
});
it("prunes legacy light/rem dreaming cron jobs during reconciliation", async () => {
it("migrates legacy light/rem dreaming cron jobs during reconciliation", async () => {
const deepManagedJob: CronJobLike = {
id: "job-deep",
name: constants.MANAGED_DREAMING_CRON_NAME,
@@ -548,6 +548,46 @@ describe("short-term dreaming cron reconciliation", () => {
expect(result.status).toBe("updated");
expect(result.removed).toBe(2);
expect(harness.removeCalls).toEqual(["job-light", "job-rem"]);
expect(logger.info).toHaveBeenCalledWith(
"memory-core: migrated 2 legacy phase dreaming cron job(s) to the unified dreaming controller.",
);
});
it("migrates legacy phase jobs even when unified dreaming is disabled", async () => {
const legacyLightJob: CronJobLike = {
id: "job-light",
name: "Memory Light Dreaming",
description: "[managed-by=memory-core.dreaming.light] legacy",
enabled: true,
schedule: { kind: "cron", expr: "0 */6 * * *" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "__openclaw_memory_core_light_sleep__" },
createdAtMs: 8,
};
const harness = createCronHarness([legacyLightJob]);
const logger = createLogger();
const result = await reconcileShortTermDreamingCronJob({
cron: harness.cron,
config: {
enabled: false,
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
limit: constants.DEFAULT_DREAMING_LIMIT,
minScore: constants.DEFAULT_DREAMING_MIN_SCORE,
minRecallCount: constants.DEFAULT_DREAMING_MIN_RECALL_COUNT,
minUniqueQueries: constants.DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
recencyHalfLifeDays: constants.DEFAULT_DREAMING_RECENCY_HALF_LIFE_DAYS,
verboseLogging: false,
},
logger,
});
expect(result).toEqual({ status: "disabled", removed: 1 });
expect(harness.removeCalls).toEqual(["job-light"]);
expect(logger.info).toHaveBeenCalledWith(
"memory-core: completed legacy phase dreaming cron migration while unified dreaming is disabled (1 job(s) removed).",
);
});
it("does not overcount removed jobs when cron remove result is unknown", async () => {

View File

@@ -104,6 +104,8 @@ type ReconcileResult =
| { status: "updated"; removed: number }
| { status: "noop"; removed: number };
type LegacyPhaseMigrationMode = "enabled" | "disabled";
function formatRepairSummary(repair: {
rewroteStore: boolean;
removedInvalidEntries: number;
@@ -178,6 +180,39 @@ function compareOptionalStrings(a: string | undefined, b: string | undefined): b
return a === b;
}
async function migrateLegacyPhaseDreamingCronJobs(params: {
cron: CronServiceLike;
legacyJobs: ManagedCronJobLike[];
logger: Logger;
mode: LegacyPhaseMigrationMode;
}): Promise<number> {
let migrated = 0;
for (const job of params.legacyJobs) {
try {
const result = await params.cron.remove(job.id);
if (result.removed === true) {
migrated += 1;
}
} catch (err) {
params.logger.warn(
`memory-core: failed to migrate legacy phase dreaming cron job ${job.id}: ${formatErrorMessage(err)}`,
);
}
}
if (migrated > 0) {
if (params.mode === "enabled") {
params.logger.info(
`memory-core: migrated ${migrated} legacy phase dreaming cron job(s) to the unified dreaming controller.`,
);
} else {
params.logger.info(
`memory-core: completed legacy phase dreaming cron migration while unified dreaming is disabled (${migrated} job(s) removed).`,
);
}
}
return migrated;
}
function buildManagedDreamingPatch(
job: ManagedCronJobLike,
desired: ManagedCronJobCreate,
@@ -300,25 +335,13 @@ export async function reconcileShortTermDreamingCronJob(params: {
const managed = allJobs.filter(isManagedDreamingJob);
const legacyPhaseJobs = allJobs.filter(isLegacyPhaseDreamingJob);
let removedLegacy = 0;
for (const job of legacyPhaseJobs) {
try {
const result = await cron.remove(job.id);
if (result.removed === true) {
removedLegacy += 1;
}
} catch (err) {
params.logger.warn(
`memory-core: failed to remove legacy managed dreaming cron job ${job.id}: ${formatErrorMessage(err)}`,
);
}
}
if (removedLegacy > 0) {
params.logger.info(`memory-core: removed ${removedLegacy} legacy phase dreaming cron job(s).`);
}
if (!params.config.enabled) {
let removed = removedLegacy;
let removed = await migrateLegacyPhaseDreamingCronJobs({
cron,
legacyJobs: legacyPhaseJobs,
logger: params.logger,
mode: "disabled",
});
for (const job of managed) {
try {
const result = await cron.remove(job.id);
@@ -340,12 +363,23 @@ export async function reconcileShortTermDreamingCronJob(params: {
const desired = buildManagedDreamingCronJob(params.config);
if (managed.length === 0) {
await cron.add(desired);
const migratedLegacy = await migrateLegacyPhaseDreamingCronJobs({
cron,
legacyJobs: legacyPhaseJobs,
logger: params.logger,
mode: "enabled",
});
params.logger.info("memory-core: created managed dreaming cron job.");
return { status: "added", removed: removedLegacy };
return { status: "added", removed: migratedLegacy };
}
const [primary, ...duplicates] = sortManagedJobs(managed);
let removed = removedLegacy;
let removed = await migrateLegacyPhaseDreamingCronJobs({
cron,
legacyJobs: legacyPhaseJobs,
logger: params.logger,
mode: "enabled",
});
for (const duplicate of duplicates) {
try {
const result = await cron.remove(duplicate.id);

View File

@@ -0,0 +1,97 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
appendMemoryHostEvent,
resolveMemoryHostEventLogPath,
} from "openclaw/plugin-sdk/memory-core-host-events";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../api.js";
import { listMemoryCorePublicArtifacts } from "./public-artifacts.js";
describe("listMemoryCorePublicArtifacts", () => {
let fixtureRoot = "";
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-core-public-artifacts-"));
});
afterAll(async () => {
if (!fixtureRoot) {
return;
}
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
it("lists public workspace artifacts with stable kinds", async () => {
const workspaceDir = path.join(fixtureRoot, "workspace");
await fs.mkdir(path.join(workspaceDir, "memory", "dreaming"), { recursive: true });
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# Durable Memory\n", "utf8");
await fs.writeFile(
path.join(workspaceDir, "memory", "2026-04-06.md"),
"# Daily Note\n",
"utf8",
);
await fs.writeFile(
path.join(workspaceDir, "memory", "dreaming", "2026-04-06.md"),
"# Dream Report\n",
"utf8",
);
await appendMemoryHostEvent(workspaceDir, {
type: "memory.recall.recorded",
timestamp: "2026-04-06T12:00:00.000Z",
query: "alpha",
resultCount: 0,
results: [],
});
const cfg: OpenClawConfig = {
agents: {
list: [{ id: "main", default: true, workspace: workspaceDir }],
},
};
await expect(listMemoryCorePublicArtifacts({ cfg })).resolves.toEqual([
{
kind: "memory-root",
workspaceDir,
relativePath: "MEMORY.md",
absolutePath: path.join(workspaceDir, "MEMORY.md"),
agentIds: ["main"],
contentType: "markdown",
},
{
kind: "memory-root",
workspaceDir,
relativePath: "memory.md",
absolutePath: path.join(workspaceDir, "memory.md"),
agentIds: ["main"],
contentType: "markdown",
},
{
kind: "daily-note",
workspaceDir,
relativePath: "memory/2026-04-06.md",
absolutePath: path.join(workspaceDir, "memory", "2026-04-06.md"),
agentIds: ["main"],
contentType: "markdown",
},
{
kind: "dream-report",
workspaceDir,
relativePath: "memory/dreaming/2026-04-06.md",
absolutePath: path.join(workspaceDir, "memory", "dreaming", "2026-04-06.md"),
agentIds: ["main"],
contentType: "markdown",
},
{
kind: "event-log",
workspaceDir,
relativePath: "memory/.dreams/events.jsonl",
absolutePath: resolveMemoryHostEventLogPath(workspaceDir),
agentIds: ["main"],
contentType: "json",
},
]);
});
});

View File

@@ -0,0 +1,98 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveMemoryHostEventLogPath } from "openclaw/plugin-sdk/memory-core-host-events";
import { resolveMemoryDreamingWorkspaces } from "openclaw/plugin-sdk/memory-core-host-status";
import type { MemoryPluginPublicArtifact } from "openclaw/plugin-sdk/memory-host-core";
import type { OpenClawConfig } from "../api.js";
async function pathExists(inputPath: string): Promise<boolean> {
try {
await fs.access(inputPath);
return true;
} catch {
return false;
}
}
async function listMarkdownFilesRecursive(rootDir: string): Promise<string[]> {
const entries = await fs.readdir(rootDir, { withFileTypes: true }).catch(() => []);
const files: string[] = [];
for (const entry of entries) {
const fullPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
files.push(...(await listMarkdownFilesRecursive(fullPath)));
continue;
}
if (entry.isFile() && entry.name.endsWith(".md")) {
files.push(fullPath);
}
}
return files.toSorted((left, right) => left.localeCompare(right));
}
async function collectWorkspaceArtifacts(params: {
workspaceDir: string;
agentIds: string[];
}): Promise<MemoryPluginPublicArtifact[]> {
const artifacts: MemoryPluginPublicArtifact[] = [];
for (const relativePath of ["MEMORY.md", "memory.md"]) {
const absolutePath = path.join(params.workspaceDir, relativePath);
if (await pathExists(absolutePath)) {
artifacts.push({
kind: "memory-root",
workspaceDir: params.workspaceDir,
relativePath,
absolutePath,
agentIds: [...params.agentIds],
contentType: "markdown",
});
}
}
const memoryDir = path.join(params.workspaceDir, "memory");
for (const absolutePath of await listMarkdownFilesRecursive(memoryDir)) {
const relativePath = path.relative(params.workspaceDir, absolutePath).replace(/\\/g, "/");
artifacts.push({
kind: relativePath.startsWith("memory/dreaming/") ? "dream-report" : "daily-note",
workspaceDir: params.workspaceDir,
relativePath,
absolutePath,
agentIds: [...params.agentIds],
contentType: "markdown",
});
}
const eventLogPath = resolveMemoryHostEventLogPath(params.workspaceDir);
if (await pathExists(eventLogPath)) {
artifacts.push({
kind: "event-log",
workspaceDir: params.workspaceDir,
relativePath: path.relative(params.workspaceDir, eventLogPath).replace(/\\/g, "/"),
absolutePath: eventLogPath,
agentIds: [...params.agentIds],
contentType: "json",
});
}
const deduped = new Map<string, MemoryPluginPublicArtifact>();
for (const artifact of artifacts) {
deduped.set(`${artifact.workspaceDir}\0${artifact.relativePath}\0${artifact.kind}`, artifact);
}
return [...deduped.values()];
}
export async function listMemoryCorePublicArtifacts(params: {
cfg: OpenClawConfig;
}): Promise<MemoryPluginPublicArtifact[]> {
const workspaces = resolveMemoryDreamingWorkspaces(params.cfg);
const artifacts: MemoryPluginPublicArtifact[] = [];
for (const workspace of workspaces) {
artifacts.push(
...(await collectWorkspaceArtifacts({
workspaceDir: workspace.workspaceDir,
agentIds: workspace.agentIds,
})),
);
}
return artifacts;
}

View File

@@ -2,14 +2,14 @@
Persistent wiki compiler and Obsidian-friendly knowledge vault for **OpenClaw**.
This plugin is separate from the active memory plugin. `memory-core` still handles recall, promotion, and dreaming. `memory-wiki` compiles durable knowledge into a navigable markdown vault with deterministic indexes, provenance, and optional Obsidian CLI workflows.
This plugin is separate from the active memory plugin. The active memory plugin still handles recall, promotion, and dreaming. `memory-wiki` compiles durable knowledge into a navigable markdown vault with deterministic indexes, provenance, structured claim/evidence metadata, and optional Obsidian CLI workflows.
When the active memory plugin exposes shared recall, agents can use `memory_search` with `corpus=all` to search durable memory and the compiled wiki in one pass, then fall back to `wiki_search` / `wiki_get` when wiki-specific ranking or provenance matters.
## Modes
- `isolated`: own vault, own sources, no dependency on `memory-core`
- `bridge`: reads public `memory-core` artifacts and memory events through public seams
- `bridge`: reads public memory artifacts and memory events through public seams
- `unsafe-local`: explicit same-machine escape hatch for private local paths
Default mode is `isolated`.
@@ -36,7 +36,7 @@ Put config under `plugins.entries.memory-wiki.config`:
bridge: {
enabled: false,
readMemoryCore: true,
readMemoryArtifacts: true,
indexDreamReports: true,
indexDailyNotes: true,
indexMemoryRoot: true,
@@ -89,6 +89,8 @@ The plugin initializes a vault like this:
Generated content stays inside managed blocks. Human note blocks are preserved.
Key beliefs can live in structured `claims` frontmatter with per-claim evidence, confidence, and status. Compile also emits machine-readable digests under `.openclaw-wiki/cache/` so agent/runtime consumers do not have to scrape markdown pages.
When `render.createBacklinks` is enabled, compile adds deterministic `## Related` blocks to pages. Those blocks list source pages, pages that reference the current page, and nearby pages that share the same source ids.
When `render.createDashboards` is enabled, compile also maintains report dashboards under `reports/` for open questions, contradictions, low-confidence pages, and stale pages.
@@ -134,6 +136,8 @@ openclaw wiki obsidian daily
The plugin also registers a non-exclusive memory corpus supplement, so shared `memory_search` / `memory_get` flows can reach the wiki when the active memory plugin supports corpus selection.
`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.
## Gateway RPC
Read methods:
@@ -161,6 +165,7 @@ Write methods:
## Notes
- `unsafe-local` is intentionally experimental and non-portable.
- Bridge mode reads `memory-core` through public seams only.
- Bridge mode reads the active memory plugin through public seams only.
- Wiki pages are compiled artifacts, not the ultimate source of truth. Keep provenance attached to raw sources, memory artifacts, and daily notes.
- The compiled agent digests in `.openclaw-wiki/cache/agent-digest.json` and `.openclaw-wiki/cache/claims.jsonl` are the stable machine-facing view of the wiki.
- Obsidian CLI support requires the official `obsidian` CLI to be installed and available on `PATH`.

View File

@@ -0,0 +1 @@
export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/config-compat.js";

View File

@@ -28,8 +28,22 @@ export default definePluginEntry({
api.registerTool(createWikiStatusTool(config, api.config), { name: "wiki_status" });
api.registerTool(createWikiLintTool(config, api.config), { name: "wiki_lint" });
api.registerTool(createWikiApplyTool(config, api.config), { name: "wiki_apply" });
api.registerTool(createWikiSearchTool(config, api.config), { name: "wiki_search" });
api.registerTool(createWikiGetTool(config, api.config), { name: "wiki_get" });
api.registerTool(
(ctx) =>
createWikiSearchTool(config, api.config, {
agentId: ctx.agentId,
agentSessionKey: ctx.sessionKey,
}),
{ name: "wiki_search" },
);
api.registerTool(
(ctx) =>
createWikiGetTool(config, api.config, {
agentId: ctx.agentId,
agentSessionKey: ctx.sessionKey,
}),
{ name: "wiki_get" },
);
api.registerCli(
({ program }) => {
registerWikiCli(program, config, api.config);

View File

@@ -22,7 +22,11 @@
},
"bridge.enabled": {
"label": "Enable Bridge Mode",
"help": "Read public memory artifacts and events from the selected memory plugin."
"help": "Read public memory artifacts and events from the active memory plugin in bridge mode."
},
"bridge.readMemoryArtifacts": {
"label": "Read Memory Artifacts",
"help": "Enable bridge reads from the active memory plugin's public artifact export."
},
"unsafeLocal.allowPrivateMemoryCoreAccess": {
"label": "Allow Private Memory Access",
@@ -75,7 +79,7 @@
"enabled": {
"type": "boolean"
},
"readMemoryCore": {
"readMemoryArtifacts": {
"type": "boolean"
},
"indexDreamReports": {

View File

@@ -0,0 +1,11 @@
import { definePluginEntry } from "./api.js";
import { migrateMemoryWikiLegacyConfig } from "./src/config-compat.js";
export default definePluginEntry({
id: "memory-wiki",
name: "Memory Wiki Setup",
description: "Lightweight Memory Wiki setup hooks",
register(api) {
api.registerConfigMigration((config) => migrateMemoryWikiLegacyConfig(config));
},
});

View File

@@ -11,7 +11,7 @@ Use this skill when working inside a memory-wiki vault.
- Use `wiki_apply` for narrow synthesis filing and metadata updates when a tool-level mutation is enough.
- Run `wiki_lint` after meaningful wiki updates so contradictions, provenance gaps, and open questions get surfaced before you trust the vault.
- Use `openclaw wiki ingest`, `openclaw wiki compile`, and `openclaw wiki lint` as the default maintenance loop.
- In `bridge` mode, run `openclaw wiki bridge import` before relying on search results if you need the latest public memory-core artifacts pulled in.
- In `bridge` mode, run `openclaw wiki bridge import` before relying on search results if you need the latest public memory artifacts pulled in.
- In `unsafe-local` mode, use `openclaw wiki unsafe-local import` only when the user explicitly opted into private local path access.
- Keep generated sections inside managed markers. Do not overwrite human note blocks.
- Treat raw sources, memory artifacts, and daily notes as evidence. Do not let wiki pages become the only source of truth for new claims.

View File

@@ -18,6 +18,21 @@ describe("applyMemoryWikiMutation", () => {
title: "Alpha Synthesis",
body: "Alpha summary body.",
sourceIds: ["source.alpha", "source.beta"],
claims: [
{
id: "claim.alpha.postgres",
text: "Alpha uses PostgreSQL for production writes.",
status: "supported",
confidence: 0.86,
evidence: [
{
sourceId: "source.alpha",
lines: "12-18",
weight: 0.9,
},
],
},
],
contradictions: ["Needs a better primary source"],
questions: ["What changed after launch?"],
confidence: 0.7,
@@ -37,6 +52,21 @@ describe("applyMemoryWikiMutation", () => {
id: "synthesis.alpha-synthesis",
title: "Alpha Synthesis",
sourceIds: ["source.alpha", "source.beta"],
claims: [
{
id: "claim.alpha.postgres",
text: "Alpha uses PostgreSQL for production writes.",
status: "supported",
confidence: 0.86,
evidence: [
{
sourceId: "source.alpha",
lines: "12-18",
weight: 0.9,
},
],
},
],
contradictions: ["Needs a better primary source"],
questions: ["What changed after launch?"],
confidence: 0.7,
@@ -86,6 +116,14 @@ keep this note
op: "update_metadata",
lookup: "entity.alpha",
sourceIds: ["source.new"],
claims: [
{
id: "claim.alpha.status",
text: "Alpha is still active for existing tenants.",
status: "contested",
evidence: [{ sourceId: "source.new", lines: "4-9" }],
},
],
contradictions: ["Conflicts with source.beta"],
questions: ["Is Alpha still active?"],
confidence: null,
@@ -105,6 +143,14 @@ keep this note
id: "entity.alpha",
title: "Alpha",
sourceIds: ["source.new"],
claims: [
{
id: "claim.alpha.status",
text: "Alpha is still active for existing tenants.",
status: "contested",
evidence: [{ sourceId: "source.new", lines: "4-9" }],
},
],
contradictions: ["Conflicts with source.beta"],
questions: ["Is Alpha still active?"],
status: "review",

View File

@@ -11,6 +11,8 @@ import {
renderWikiMarkdown,
slugifyWikiSegment,
normalizeSourceIds,
normalizeWikiClaims,
type WikiClaim,
} from "./markdown.js";
import {
readQueryableWikiPages,
@@ -29,6 +31,7 @@ export type CreateSynthesisMemoryWikiMutation = {
title: string;
body: string;
sourceIds: string[];
claims?: WikiClaim[];
contradictions?: string[];
questions?: string[];
confidence?: number;
@@ -39,6 +42,7 @@ export type UpdateMetadataMemoryWikiMutation = {
op: "update_metadata";
lookup: string;
sourceIds?: string[];
claims?: WikiClaim[];
contradictions?: string[];
questions?: string[];
confidence?: number | null;
@@ -64,6 +68,7 @@ export function normalizeMemoryWikiMutationInput(rawParams: unknown): ApplyMemor
body?: string;
lookup?: string;
sourceIds?: string[];
claims?: WikiClaim[];
contradictions?: string[];
questions?: string[];
confidence?: number | null;
@@ -84,6 +89,7 @@ export function normalizeMemoryWikiMutationInput(rawParams: unknown): ApplyMemor
title: params.title,
body: params.body,
sourceIds: params.sourceIds,
...(Array.isArray(params.claims) ? { claims: normalizeWikiClaims(params.claims) } : {}),
...(params.contradictions ? { contradictions: params.contradictions } : {}),
...(params.questions ? { questions: params.questions } : {}),
...(typeof params.confidence === "number" ? { confidence: params.confidence } : {}),
@@ -97,6 +103,7 @@ export function normalizeMemoryWikiMutationInput(rawParams: unknown): ApplyMemor
op: "update_metadata",
lookup: params.lookup,
...(params.sourceIds ? { sourceIds: params.sourceIds } : {}),
...(Array.isArray(params.claims) ? { claims: normalizeWikiClaims(params.claims) } : {}),
...(params.contradictions ? { contradictions: params.contradictions } : {}),
...(params.questions ? { questions: params.questions } : {}),
...(params.confidence !== undefined ? { confidence: params.confidence } : {}),
@@ -190,6 +197,7 @@ async function applyCreateSynthesisMutation(params: {
id: pageId,
title: params.mutation.title,
sourceIds: normalizeSourceIds(params.mutation.sourceIds),
...(params.mutation.claims ? { claims: normalizeWikiClaims(params.mutation.claims) } : {}),
...(normalizeUniqueStrings(params.mutation.contradictions)
? { contradictions: normalizeUniqueStrings(params.mutation.contradictions) }
: {}),
@@ -222,6 +230,14 @@ function buildUpdatedFrontmatter(params: {
if (params.mutation.sourceIds) {
frontmatter.sourceIds = normalizeSourceIds(params.mutation.sourceIds);
}
if (params.mutation.claims) {
const claims = normalizeWikiClaims(params.mutation.claims);
if (claims.length > 0) {
frontmatter.claims = claims;
} else {
delete frontmatter.claims;
}
}
if (params.mutation.contradictions) {
const contradictions = normalizeUniqueStrings(params.mutation.contradictions) ?? [];
if (contradictions.length > 0) {

View File

@@ -1,8 +1,16 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { appendMemoryHostEvent } from "openclaw/plugin-sdk/memory-host-events";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { MemoryPluginPublicArtifact } from "openclaw/plugin-sdk/memory-host-core";
import {
appendMemoryHostEvent,
resolveMemoryHostEventLogPath,
} from "openclaw/plugin-sdk/memory-host-events";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
clearMemoryPluginState,
registerMemoryCapability,
} from "../../../src/plugins/memory-state.js";
import type { OpenClawConfig } from "../api.js";
import { syncMemoryWikiBridgeSources } from "./bridge.js";
import { createMemoryWikiTestHarness } from "./test-helpers.js";
@@ -24,6 +32,10 @@ describe("syncMemoryWikiBridgeSources", () => {
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
afterEach(() => {
clearMemoryPluginState();
});
function nextCaseRoot(name: string): string {
return path.join(fixtureRoot, `case-${caseId++}-${name}`);
}
@@ -34,7 +46,17 @@ describe("syncMemoryWikiBridgeSources", () => {
return workspaceDir;
}
it("imports public memory-core artifacts and stays idempotent across reruns", async () => {
function registerBridgeArtifacts(artifacts: MemoryPluginPublicArtifact[]) {
registerMemoryCapability("memory-core", {
publicArtifacts: {
async listArtifacts() {
return artifacts;
},
},
});
}
it("imports public memory artifacts and stays idempotent across reruns", async () => {
const workspaceDir = await createBridgeWorkspace("workspace");
const { rootDir: vaultDir, config } = await createVault({
rootDir: nextCaseRoot("vault"),
@@ -42,7 +64,7 @@ describe("syncMemoryWikiBridgeSources", () => {
vaultMode: "bridge",
bridge: {
enabled: true,
readMemoryCore: true,
readMemoryArtifacts: true,
indexMemoryRoot: true,
indexDailyNotes: true,
indexDreamReports: true,
@@ -62,16 +84,34 @@ describe("syncMemoryWikiBridgeSources", () => {
"# Dream Report\n",
"utf8",
);
registerBridgeArtifacts([
{
kind: "memory-root",
workspaceDir,
relativePath: "MEMORY.md",
absolutePath: path.join(workspaceDir, "MEMORY.md"),
agentIds: ["main"],
contentType: "markdown",
},
{
kind: "daily-note",
workspaceDir,
relativePath: "memory/2026-04-05.md",
absolutePath: path.join(workspaceDir, "memory", "2026-04-05.md"),
agentIds: ["main"],
contentType: "markdown",
},
{
kind: "dream-report",
workspaceDir,
relativePath: "memory/dreaming/2026-04-05.md",
absolutePath: path.join(workspaceDir, "memory", "dreaming", "2026-04-05.md"),
agentIds: ["main"],
contentType: "markdown",
},
]);
const appConfig: OpenClawConfig = {
plugins: {
entries: {
"memory-core": {
enabled: true,
config: {},
},
},
},
agents: {
list: [{ id: "main", default: true, workspace: workspaceDir }],
},
@@ -123,6 +163,41 @@ describe("syncMemoryWikiBridgeSources", () => {
});
});
it("returns a no-op result when bridge mode is enabled without exported memory artifacts", async () => {
const workspaceDir = await createBridgeWorkspace("no-memory-core");
const { config } = await createVault({
rootDir: nextCaseRoot("no-memory-core-vault"),
config: {
vaultMode: "bridge",
bridge: {
enabled: true,
readMemoryArtifacts: true,
indexMemoryRoot: true,
},
},
});
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# Durable Memory\n", "utf8");
const appConfig: OpenClawConfig = {
agents: {
list: [{ id: "main", default: true, workspace: workspaceDir }],
},
};
const result = await syncMemoryWikiBridgeSources({ config, appConfig });
expect(result).toMatchObject({
importedCount: 0,
updatedCount: 0,
skippedCount: 0,
removedCount: 0,
artifactCount: 0,
workspaces: 0,
pagePaths: [],
});
});
it("imports the public memory event journal when followMemoryEvents is enabled", async () => {
const workspaceDir = await createBridgeWorkspace("events-workspace");
const { rootDir: vaultDir, config } = await createVault({
@@ -150,16 +225,18 @@ describe("syncMemoryWikiBridgeSources", () => {
},
],
});
registerBridgeArtifacts([
{
kind: "event-log",
workspaceDir,
relativePath: "memory/.dreams/events.jsonl",
absolutePath: resolveMemoryHostEventLogPath(workspaceDir),
agentIds: ["main"],
contentType: "json",
},
]);
const appConfig: OpenClawConfig = {
plugins: {
entries: {
"memory-core": {
enabled: true,
config: {},
},
},
},
agents: {
list: [{ id: "main", default: true, workspace: workspaceDir }],
},
@@ -192,15 +269,17 @@ describe("syncMemoryWikiBridgeSources", () => {
});
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# Durable Memory\n", "utf8");
const appConfig: OpenClawConfig = {
plugins: {
entries: {
"memory-core": {
enabled: true,
config: {},
},
},
registerBridgeArtifacts([
{
kind: "memory-root",
workspaceDir,
relativePath: "MEMORY.md",
absolutePath: path.join(workspaceDir, "MEMORY.md"),
agentIds: ["main"],
contentType: "markdown",
},
]);
const appConfig: OpenClawConfig = {
agents: {
list: [{ id: "main", default: true, workspace: workspaceDir }],
},
@@ -211,6 +290,7 @@ describe("syncMemoryWikiBridgeSources", () => {
await expect(fs.stat(path.join(vaultDir, firstPagePath))).resolves.toBeTruthy();
await fs.rm(path.join(workspaceDir, "MEMORY.md"));
registerBridgeArtifacts([]);
const second = await syncMemoryWikiBridgeSources({ config, appConfig });
expect(second.artifactCount).toBe(0);

View File

@@ -1,17 +1,16 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveMemoryHostEventLogPath } from "openclaw/plugin-sdk/memory-host-events";
import {
resolveMemoryCorePluginConfig,
resolveMemoryDreamingWorkspaces,
} from "openclaw/plugin-sdk/memory-host-status";
listActiveMemoryPublicArtifacts,
type MemoryPluginPublicArtifact,
} from "openclaw/plugin-sdk/memory-host-core";
import type { OpenClawConfig } from "../api.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { appendMemoryWikiLog } from "./log.js";
import { renderMarkdownFence, renderWikiMarkdown, slugifyWikiSegment } from "./markdown.js";
import { writeImportedSourcePage } from "./source-page-shared.js";
import { pathExists, resolveArtifactKey } from "./source-path-shared.js";
import { resolveArtifactKey } from "./source-path-shared.js";
import {
pruneImportedSourceEntries,
readMemoryWikiSourceSyncState,
@@ -37,93 +36,44 @@ export type BridgeMemoryWikiResult = {
pagePaths: string[];
};
async function listMarkdownFilesRecursive(rootDir: string): Promise<string[]> {
const entries = await fs.readdir(rootDir, { withFileTypes: true }).catch(() => []);
const files: string[] = [];
for (const entry of entries) {
const fullPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
files.push(...(await listMarkdownFilesRecursive(fullPath)));
continue;
}
if (entry.isFile() && entry.name.endsWith(".md")) {
files.push(fullPath);
}
function shouldImportArtifact(
artifact: MemoryPluginPublicArtifact,
bridgeConfig: ResolvedMemoryWikiConfig["bridge"],
): boolean {
switch (artifact.kind) {
case "memory-root":
return bridgeConfig.indexMemoryRoot;
case "daily-note":
return bridgeConfig.indexDailyNotes;
case "dream-report":
return bridgeConfig.indexDreamReports;
case "event-log":
return bridgeConfig.followMemoryEvents;
default:
return false;
}
return files.toSorted((left, right) => left.localeCompare(right));
}
async function collectWorkspaceArtifacts(
workspaceDir: string,
async function collectBridgeArtifacts(
bridgeConfig: ResolvedMemoryWikiConfig["bridge"],
artifacts: MemoryPluginPublicArtifact[],
): Promise<BridgeArtifact[]> {
const artifacts: BridgeArtifact[] = [];
if (bridgeConfig.indexMemoryRoot) {
for (const relPath of ["MEMORY.md", "memory.md"]) {
const absolutePath = path.join(workspaceDir, relPath);
if (await pathExists(absolutePath)) {
const syncKey = await resolveArtifactKey(absolutePath);
artifacts.push({
syncKey,
artifactType: "markdown",
workspaceDir,
relativePath: relPath,
absolutePath,
});
}
}
}
if (bridgeConfig.indexDailyNotes) {
const memoryDir = path.join(workspaceDir, "memory");
const files = await listMarkdownFilesRecursive(memoryDir);
for (const absolutePath of files) {
const relativePath = path.relative(workspaceDir, absolutePath).replace(/\\/g, "/");
if (!relativePath.startsWith("memory/dreaming/")) {
const syncKey = await resolveArtifactKey(absolutePath);
artifacts.push({
syncKey,
artifactType: "markdown",
workspaceDir,
relativePath,
absolutePath,
});
}
}
}
if (bridgeConfig.indexDreamReports) {
const dreamingDir = path.join(workspaceDir, "memory", "dreaming");
const files = await listMarkdownFilesRecursive(dreamingDir);
for (const absolutePath of files) {
const relativePath = path.relative(workspaceDir, absolutePath).replace(/\\/g, "/");
const syncKey = await resolveArtifactKey(absolutePath);
artifacts.push({
syncKey,
artifactType: "markdown",
workspaceDir,
relativePath,
absolutePath,
});
}
}
if (bridgeConfig.followMemoryEvents) {
const eventLogPath = resolveMemoryHostEventLogPath(workspaceDir);
if (await pathExists(eventLogPath)) {
const syncKey = await resolveArtifactKey(eventLogPath);
artifacts.push({
syncKey,
artifactType: "memory-events",
workspaceDir,
relativePath: path.relative(workspaceDir, eventLogPath).replace(/\\/g, "/"),
absolutePath: eventLogPath,
});
}
}
const deduped = new Map<string, BridgeArtifact>();
const collected: BridgeArtifact[] = [];
for (const artifact of artifacts) {
if (!shouldImportArtifact(artifact, bridgeConfig)) {
continue;
}
const syncKey = await resolveArtifactKey(artifact.absolutePath);
collected.push({
syncKey,
artifactType: artifact.kind === "event-log" ? "memory-events" : "markdown",
workspaceDir: artifact.workspaceDir,
relativePath: artifact.relativePath,
absolutePath: artifact.absolutePath,
});
}
const deduped = new Map<string, BridgeArtifact>();
for (const artifact of collected) {
deduped.set(artifact.syncKey, artifact);
}
return [...deduped.values()];
@@ -253,7 +203,7 @@ export async function syncMemoryWikiBridgeSources(params: {
if (
params.config.vaultMode !== "bridge" ||
!params.config.bridge.enabled ||
!params.config.bridge.readMemoryCore ||
!params.config.bridge.readMemoryArtifacts ||
!params.appConfig
) {
return {
@@ -267,42 +217,32 @@ export async function syncMemoryWikiBridgeSources(params: {
};
}
const memoryPluginConfig = resolveMemoryCorePluginConfig(params.appConfig);
if (!memoryPluginConfig) {
return {
importedCount: 0,
updatedCount: 0,
skippedCount: 0,
removedCount: 0,
artifactCount: 0,
workspaces: 0,
pagePaths: [],
};
}
const workspaces = resolveMemoryDreamingWorkspaces(params.appConfig);
const publicArtifacts = await listActiveMemoryPublicArtifacts({ cfg: params.appConfig });
const state = await readMemoryWikiSourceSyncState(params.config.vault.path);
const results: Array<{ pagePath: string; changed: boolean; created: boolean }> = [];
let artifactCount = 0;
const activeKeys = new Set<string>();
for (const workspace of workspaces) {
const artifacts = await collectWorkspaceArtifacts(workspace.workspaceDir, params.config.bridge);
artifactCount += artifacts.length;
for (const artifact of artifacts) {
const stats = await fs.stat(artifact.absolutePath);
activeKeys.add(artifact.syncKey);
results.push(
await writeBridgeSourcePage({
config: params.config,
artifact,
agentIds: workspace.agentIds,
sourceUpdatedAtMs: stats.mtimeMs,
sourceSize: stats.size,
state,
}),
);
}
const artifacts = await collectBridgeArtifacts(params.config.bridge, publicArtifacts);
const agentIdsByWorkspace = new Map<string, string[]>();
for (const artifact of publicArtifacts) {
agentIdsByWorkspace.set(artifact.workspaceDir, artifact.agentIds);
}
artifactCount = artifacts.length;
for (const artifact of artifacts) {
const stats = await fs.stat(artifact.absolutePath);
activeKeys.add(artifact.syncKey);
results.push(
await writeBridgeSourcePage({
config: params.config,
artifact,
agentIds: agentIdsByWorkspace.get(artifact.workspaceDir) ?? [],
sourceUpdatedAtMs: stats.mtimeMs,
sourceSize: stats.size,
state,
}),
);
}
const workspaceCount = new Set(publicArtifacts.map((artifact) => artifact.workspaceDir)).size;
const removedCount = await pruneImportedSourceEntries({
vaultRoot: params.config.vault.path,
@@ -324,7 +264,7 @@ export async function syncMemoryWikiBridgeSources(params: {
timestamp: new Date().toISOString(),
details: {
sourceType: "memory-bridge",
workspaces: workspaces.length,
workspaces: workspaceCount,
artifactCount,
importedCount,
updatedCount,
@@ -340,7 +280,7 @@ export async function syncMemoryWikiBridgeSources(params: {
skippedCount,
removedCount,
artifactCount,
workspaces: workspaces.length,
workspaces: workspaceCount,
pagePaths,
};
}

View File

@@ -240,7 +240,9 @@ export async function runWikiStatus(params: {
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
const status = await resolveMemoryWikiStatus(params.config);
const status = await resolveMemoryWikiStatus(params.config, {
appConfig: params.appConfig,
});
writeOutput(
params.json ? JSON.stringify(status, null, 2) : renderMemoryWikiStatus(status),
params.stdout,
@@ -255,7 +257,11 @@ export async function runWikiDoctor(params: {
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
const report = buildMemoryWikiDoctorReport(await resolveMemoryWikiStatus(params.config));
const report = buildMemoryWikiDoctorReport(
await resolveMemoryWikiStatus(params.config, {
appConfig: params.appConfig,
}),
);
if (!report.healthy) {
process.exitCode = 1;
}
@@ -738,10 +744,10 @@ export function registerWikiCli(
const bridge = wiki
.command("bridge")
.description("Import public memory-core artifacts into the wiki vault");
.description("Import public memory artifacts into the wiki vault");
bridge
.command("import")
.description("Sync bridge-backed memory-core artifacts into wiki source pages")
.description("Sync bridge-backed memory artifacts into wiki source pages")
.option("--json", "Print JSON")
.action(async (opts: WikiBridgeImportCommandOptions) => {
await runWikiBridgeImport({ config, appConfig, json: opts.json });

View File

@@ -35,7 +35,19 @@ describe("compileMemoryWikiVault", () => {
await fs.writeFile(
path.join(rootDir, "sources", "alpha.md"),
renderWikiMarkdown({
frontmatter: { pageType: "source", id: "source.alpha", title: "Alpha" },
frontmatter: {
pageType: "source",
id: "source.alpha",
title: "Alpha",
claims: [
{
id: "claim.alpha.doc",
text: "Alpha is the canonical source page.",
status: "supported",
evidence: [{ sourceId: "source.alpha", lines: "1-3" }],
},
],
},
body: "# Alpha\n",
}),
"utf8",
@@ -44,12 +56,33 @@ describe("compileMemoryWikiVault", () => {
const result = await compileMemoryWikiVault(config);
expect(result.pageCounts.source).toBe(1);
expect(result.claimCount).toBe(1);
await expect(fs.readFile(path.join(rootDir, "index.md"), "utf8")).resolves.toContain(
"[Alpha](sources/alpha.md)",
);
await expect(fs.readFile(path.join(rootDir, "index.md"), "utf8")).resolves.toContain(
"- Claims: 1",
);
await expect(fs.readFile(path.join(rootDir, "sources", "index.md"), "utf8")).resolves.toContain(
"[Alpha](sources/alpha.md)",
);
const agentDigest = JSON.parse(
await fs.readFile(path.join(rootDir, ".openclaw-wiki", "cache", "agent-digest.json"), "utf8"),
) as {
claimCount: number;
pages: Array<{ path: string; claimCount: number; topClaims: Array<{ text: string }> }>;
};
expect(agentDigest.claimCount).toBe(1);
expect(agentDigest.pages).toContainEqual(
expect.objectContaining({
path: "sources/alpha.md",
claimCount: 1,
topClaims: [expect.objectContaining({ text: "Alpha is the canonical source page." })],
}),
);
await expect(
fs.readFile(path.join(rootDir, ".openclaw-wiki", "cache", "claims.jsonl"), "utf8"),
).resolves.toContain('"text":"Alpha is the canonical source page."');
});
it("renders obsidian-friendly links when configured", async () => {

View File

@@ -11,6 +11,7 @@ import {
parseWikiMarkdown,
renderWikiMarkdown,
toWikiPageSummary,
type WikiClaim,
type WikiPageKind,
type WikiPageSummary,
WIKI_RELATED_END_MARKER,
@@ -26,6 +27,8 @@ const COMPILE_PAGE_GROUPS: Array<{ kind: WikiPageKind; dir: string; heading: str
{ kind: "report", dir: "reports", heading: "Reports" },
];
const DASHBOARD_STALE_PAGE_DAYS = 30;
const AGENT_DIGEST_PATH = ".openclaw-wiki/cache/agent-digest.json";
const CLAIMS_DIGEST_PATH = ".openclaw-wiki/cache/claims.jsonl";
type DashboardPageDefinition = {
id: string;
@@ -152,6 +155,7 @@ export type CompileMemoryWikiResult = {
vaultRoot: string;
pageCounts: Record<WikiPageKind, number>;
pages: WikiPageSummary[];
claimCount: number;
updatedFiles: string[];
};
@@ -509,9 +513,11 @@ function buildRootIndexBody(params: {
pages: WikiPageSummary[];
counts: Record<WikiPageKind, number>;
}): string {
const claimCount = params.pages.reduce((total, page) => total + page.claims.length, 0);
const lines = [
`- Render mode: \`${params.config.vault.renderMode}\``,
`- Total pages: ${params.pages.length}`,
`- Claims: ${claimCount}`,
`- Sources: ${params.counts.source}`,
`- Entities: ${params.counts.entity}`,
`- Concepts: ${params.counts.concept}`,
@@ -545,6 +551,143 @@ function buildDirectoryIndexBody(params: {
});
}
type AgentDigestClaim = {
id?: string;
text: string;
status: string;
confidence?: number;
evidenceCount: number;
evidence: WikiClaim["evidence"];
updatedAt?: string;
};
type AgentDigestPage = {
id?: string;
title: string;
kind: WikiPageKind;
path: string;
sourceIds: string[];
questions: string[];
contradictions: string[];
confidence?: number;
updatedAt?: string;
claimCount: number;
topClaims: AgentDigestClaim[];
};
type AgentDigest = {
pageCounts: Record<WikiPageKind, number>;
claimCount: number;
pages: AgentDigestPage[];
};
function normalizeClaimStatus(claim: WikiClaim): string {
return claim.status?.trim() || "supported";
}
function sortClaims(claims: WikiClaim[]): WikiClaim[] {
return [...claims].toSorted((left, right) => {
const leftConfidence = left.confidence ?? -1;
const rightConfidence = right.confidence ?? -1;
if (leftConfidence !== rightConfidence) {
return rightConfidence - leftConfidence;
}
return left.text.localeCompare(right.text);
});
}
function buildAgentDigest(params: {
pages: WikiPageSummary[];
pageCounts: Record<WikiPageKind, number>;
}): AgentDigest {
const pages = [...params.pages]
.toSorted((left, right) => left.relativePath.localeCompare(right.relativePath))
.map((page) => ({
...(page.id ? { id: page.id } : {}),
title: page.title,
kind: page.kind,
path: page.relativePath,
sourceIds: [...page.sourceIds],
questions: [...page.questions],
contradictions: [...page.contradictions],
...(typeof page.confidence === "number" ? { confidence: page.confidence } : {}),
...(page.updatedAt ? { updatedAt: page.updatedAt } : {}),
claimCount: page.claims.length,
topClaims: sortClaims(page.claims)
.slice(0, 5)
.map((claim) => ({
...(claim.id ? { id: claim.id } : {}),
text: claim.text,
status: normalizeClaimStatus(claim),
...(typeof claim.confidence === "number" ? { confidence: claim.confidence } : {}),
evidenceCount: claim.evidence.length,
evidence: [...claim.evidence],
...(claim.updatedAt ? { updatedAt: claim.updatedAt } : {}),
})),
}));
return {
pageCounts: params.pageCounts,
claimCount: params.pages.reduce((total, page) => total + page.claims.length, 0),
pages,
};
}
function buildClaimsDigestLines(params: { pages: WikiPageSummary[] }): string[] {
return params.pages
.flatMap((page) =>
sortClaims(page.claims).map((claim) =>
JSON.stringify({
...(claim.id ? { id: claim.id } : {}),
pageId: page.id,
pageTitle: page.title,
pageKind: page.kind,
pagePath: page.relativePath,
text: claim.text,
status: normalizeClaimStatus(claim),
confidence: claim.confidence,
sourceIds: page.sourceIds,
evidence: claim.evidence,
updatedAt: claim.updatedAt ?? page.updatedAt,
}),
),
)
.toSorted((left, right) => left.localeCompare(right));
}
async function writeAgentDigestArtifacts(params: {
rootDir: string;
pages: WikiPageSummary[];
pageCounts: Record<WikiPageKind, number>;
}): Promise<string[]> {
const updatedFiles: string[] = [];
const agentDigestPath = path.join(params.rootDir, AGENT_DIGEST_PATH);
const claimsDigestPath = path.join(params.rootDir, CLAIMS_DIGEST_PATH);
const agentDigest = `${JSON.stringify(
buildAgentDigest({
pages: params.pages,
pageCounts: params.pageCounts,
}),
null,
2,
)}\n`;
const claimsDigest = withTrailingNewline(
buildClaimsDigestLines({ pages: params.pages }).join("\n"),
);
for (const [filePath, content] of [
[agentDigestPath, agentDigest],
[claimsDigestPath, claimsDigest],
] as const) {
const existing = await fs.readFile(filePath, "utf8").catch(() => "");
if (existing === content) {
continue;
}
await fs.writeFile(filePath, content, "utf8");
updatedFiles.push(filePath);
}
return updatedFiles;
}
export async function compileMemoryWikiVault(
config: ResolvedMemoryWikiConfig,
): Promise<CompileMemoryWikiResult> {
@@ -561,6 +704,12 @@ export async function compileMemoryWikiVault(
pages = await readPageSummaries(rootDir);
}
const counts = buildPageCounts(pages);
const digestUpdatedFiles = await writeAgentDigestArtifacts({
rootDir,
pages,
pageCounts: counts,
});
updatedFiles.push(...digestUpdatedFiles);
const rootIndexPath = path.join(rootDir, "index.md");
if (
@@ -605,6 +754,7 @@ export async function compileMemoryWikiVault(
vaultRoot: rootDir,
pageCounts: counts,
pages,
claimCount: pages.reduce((total, page) => total + page.claims.length, 0),
updatedFiles,
};
}

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../api.js";
import {
legacyConfigRules,
migrateMemoryWikiLegacyConfig,
normalizeCompatibilityConfig,
} from "./config-compat.js";
describe("memory-wiki config compatibility", () => {
it("detects the legacy bridge artifact toggle", () => {
expect(
legacyConfigRules[0]?.match({
readMemoryCore: true,
}),
).toBe(true);
});
it("migrates readMemoryCore to readMemoryArtifacts", () => {
const config = {
plugins: {
entries: {
"memory-wiki": {
config: {
bridge: {
enabled: true,
readMemoryCore: false,
},
},
},
},
},
} as OpenClawConfig;
const migration = migrateMemoryWikiLegacyConfig(config);
expect(migration?.changes).toEqual([
"Moved plugins.entries.memory-wiki.config.bridge.readMemoryCore → plugins.entries.memory-wiki.config.bridge.readMemoryArtifacts.",
]);
expect(
(
migration?.config.plugins?.entries?.["memory-wiki"] as {
config?: { bridge?: Record<string, unknown> };
}
).config?.bridge,
).toEqual({
enabled: true,
readMemoryArtifacts: false,
});
});
it("keeps the canonical bridge toggle when both keys are present", () => {
const config = {
plugins: {
entries: {
"memory-wiki": {
config: {
bridge: {
readMemoryCore: false,
readMemoryArtifacts: true,
},
},
},
},
},
} as OpenClawConfig;
const migration = normalizeCompatibilityConfig({ cfg: config });
expect(migration.changes).toEqual([
"Removed legacy plugins.entries.memory-wiki.config.bridge.readMemoryCore; kept explicit plugins.entries.memory-wiki.config.bridge.readMemoryArtifacts.",
]);
expect(
(
migration.config.plugins?.entries?.["memory-wiki"] as {
config?: { bridge?: Record<string, unknown> };
}
).config?.bridge,
).toEqual({
readMemoryArtifacts: true,
});
});
});

View File

@@ -0,0 +1,75 @@
import type { OpenClawConfig } from "../api.js";
type LegacyConfigRule = {
path: Array<string | number>;
message: string;
match: (value: unknown) => boolean;
};
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function hasLegacyBridgeArtifactToggle(value: unknown): boolean {
return Object.prototype.hasOwnProperty.call(asRecord(value) ?? {}, "readMemoryCore");
}
export const legacyConfigRules: LegacyConfigRule[] = [
{
path: ["plugins", "entries", "memory-wiki", "config", "bridge"],
message:
'plugins.entries.memory-wiki.config.bridge.readMemoryCore is legacy; use plugins.entries.memory-wiki.config.bridge.readMemoryArtifacts. Run "openclaw doctor --fix".',
match: hasLegacyBridgeArtifactToggle,
},
];
export function migrateMemoryWikiLegacyConfig(config: OpenClawConfig): {
config: OpenClawConfig;
changes: string[];
} | null {
const rawEntry = asRecord(config.plugins?.entries?.["memory-wiki"]);
const rawPluginConfig = asRecord(rawEntry?.config);
const rawBridge = asRecord(rawPluginConfig?.bridge);
if (!rawBridge || !hasLegacyBridgeArtifactToggle(rawBridge)) {
return null;
}
const nextConfig = structuredClone(config);
const nextPlugins = asRecord(nextConfig.plugins) ?? {};
nextConfig.plugins = nextPlugins;
const nextEntries = asRecord(nextPlugins.entries) ?? {};
nextPlugins.entries = nextEntries;
const nextEntry = asRecord(nextEntries["memory-wiki"]) ?? {};
nextEntries["memory-wiki"] = nextEntry;
const nextPluginConfig = asRecord(nextEntry.config) ?? {};
nextEntry.config = nextPluginConfig;
const nextBridge = asRecord(nextPluginConfig.bridge) ?? {};
nextPluginConfig.bridge = nextBridge;
const legacyValue = nextBridge.readMemoryCore;
const hasCanonical = Object.prototype.hasOwnProperty.call(nextBridge, "readMemoryArtifacts");
if (!hasCanonical) {
nextBridge.readMemoryArtifacts = legacyValue;
}
delete nextBridge.readMemoryCore;
return {
config: nextConfig,
changes: hasCanonical
? [
"Removed legacy plugins.entries.memory-wiki.config.bridge.readMemoryCore; kept explicit plugins.entries.memory-wiki.config.bridge.readMemoryArtifacts.",
]
: [
"Moved plugins.entries.memory-wiki.config.bridge.readMemoryCore → plugins.entries.memory-wiki.config.bridge.readMemoryArtifacts.",
],
};
}
export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): {
config: OpenClawConfig;
changes: string[];
} {
return migrateMemoryWikiLegacyConfig(cfg) ?? { config: cfg, changes: [] };
}

View File

@@ -46,6 +46,16 @@ describe("resolveMemoryWikiConfig", () => {
expect(config.vault.path).toBe("/Users/tester/vaults/wiki");
expect(config.vault.renderMode).toBe("obsidian");
});
it("normalizes the bridge artifact toggle", () => {
const canonical = resolveMemoryWikiConfig({
bridge: {
readMemoryArtifacts: false,
},
});
expect(canonical.bridge.readMemoryArtifacts).toBe(false);
});
});
describe("memory-wiki manifest config schema", () => {
@@ -63,6 +73,7 @@ describe("memory-wiki manifest config schema", () => {
},
bridge: {
enabled: true,
readMemoryArtifacts: true,
followMemoryEvents: true,
},
unsafeLocal: {

View File

@@ -26,7 +26,7 @@ export type MemoryWikiPluginConfig = {
};
bridge?: {
enabled?: boolean;
readMemoryCore?: boolean;
readMemoryArtifacts?: boolean;
indexDreamReports?: boolean;
indexDailyNotes?: boolean;
indexMemoryRoot?: boolean;
@@ -66,7 +66,7 @@ export type ResolvedMemoryWikiConfig = {
};
bridge: {
enabled: boolean;
readMemoryCore: boolean;
readMemoryArtifacts: boolean;
indexDreamReports: boolean;
indexDailyNotes: boolean;
indexMemoryRoot: boolean;
@@ -116,7 +116,7 @@ const MemoryWikiConfigSource = z.strictObject({
bridge: z
.strictObject({
enabled: z.boolean().optional(),
readMemoryCore: z.boolean().optional(),
readMemoryArtifacts: z.boolean().optional(),
indexDreamReports: z.boolean().optional(),
indexDailyNotes: z.boolean().optional(),
indexMemoryRoot: z.boolean().optional(),
@@ -216,7 +216,7 @@ export function resolveMemoryWikiConfig(
},
bridge: {
enabled: safeConfig.bridge?.enabled ?? false,
readMemoryCore: safeConfig.bridge?.readMemoryCore ?? true,
readMemoryArtifacts: safeConfig.bridge?.readMemoryArtifacts ?? true,
indexDreamReports: safeConfig.bridge?.indexDreamReports ?? true,
indexDailyNotes: safeConfig.bridge?.indexDailyNotes ?? true,
indexMemoryRoot: safeConfig.bridge?.indexMemoryRoot ?? true,

View File

@@ -11,6 +11,7 @@ export function createWikiCorpusSupplement(params: {
await searchMemoryWiki({
config: params.config,
appConfig: params.appConfig,
agentSessionKey: input.agentSessionKey,
query: input.query,
maxResults: input.maxResults,
searchBackend: "local",
@@ -25,6 +26,7 @@ export function createWikiCorpusSupplement(params: {
await getMemoryWikiPage({
config: params.config,
appConfig: params.appConfig,
agentSessionKey: input.agentSessionKey,
lookup: input.lookup,
fromLine: input.fromLine,
lineCount: input.lineCount,

View File

@@ -123,7 +123,9 @@ describe("memory-wiki gateway methods", () => {
});
expect(syncMemoryWikiImportedSources).toHaveBeenCalledWith({ config, appConfig: undefined });
expect(resolveMemoryWikiStatus).toHaveBeenCalledWith(config);
expect(resolveMemoryWikiStatus).toHaveBeenCalledWith(config, {
appConfig: undefined,
});
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({

View File

@@ -102,7 +102,12 @@ export function registerMemoryWikiGatewayMethods(params: {
async ({ respond }) => {
try {
await syncImportedSourcesIfNeeded(config, appConfig);
respond(true, await resolveMemoryWikiStatus(config));
respond(
true,
await resolveMemoryWikiStatus(config, {
appConfig,
}),
);
} catch (error) {
respondError(respond, error);
}
@@ -127,7 +132,9 @@ export function registerMemoryWikiGatewayMethods(params: {
async ({ respond }) => {
try {
await syncImportedSourcesIfNeeded(config, appConfig);
const status = await resolveMemoryWikiStatus(config);
const status = await resolveMemoryWikiStatus(config, {
appConfig,
});
respond(true, buildMemoryWikiDoctorReport(status));
} catch (error) {
respondError(respond, error);

View File

@@ -16,6 +16,24 @@ export type ParsedWikiMarkdown = {
body: string;
};
export type WikiClaimEvidence = {
sourceId?: string;
path?: string;
lines?: string;
weight?: number;
note?: string;
updatedAt?: string;
};
export type WikiClaim = {
id?: string;
text: string;
status?: string;
confidence?: number;
evidence: WikiClaimEvidence[];
updatedAt?: string;
};
export type WikiPageSummary = {
absolutePath: string;
relativePath: string;
@@ -25,6 +43,7 @@ export type WikiPageSummary = {
pageType?: string;
sourceIds: string[];
linkTargets: string[];
claims: WikiClaim[];
contradictions: string[];
questions: string[];
confidence?: number;
@@ -88,6 +107,71 @@ export function normalizeSourceIds(value: unknown): string[] {
return normalizeSingleOrTrimmedStringList(value);
}
function normalizeWikiClaimEvidence(value: unknown): WikiClaimEvidence | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const sourceId = normalizeOptionalString(record.sourceId);
const evidencePath = normalizeOptionalString(record.path);
const lines = normalizeOptionalString(record.lines);
const note = normalizeOptionalString(record.note);
const updatedAt = normalizeOptionalString(record.updatedAt);
const weight =
typeof record.weight === "number" && Number.isFinite(record.weight) ? record.weight : undefined;
if (!sourceId && !evidencePath && !lines && !note && weight === undefined && !updatedAt) {
return null;
}
return {
...(sourceId ? { sourceId } : {}),
...(evidencePath ? { path: evidencePath } : {}),
...(lines ? { lines } : {}),
...(weight !== undefined ? { weight } : {}),
...(note ? { note } : {}),
...(updatedAt ? { updatedAt } : {}),
};
}
export function normalizeWikiClaims(value: unknown): WikiClaim[] {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return [];
}
const record = entry as Record<string, unknown>;
const text = normalizeOptionalString(record.text);
if (!text) {
return [];
}
const evidence = Array.isArray(record.evidence)
? record.evidence.flatMap((candidate) => {
const normalized = normalizeWikiClaimEvidence(candidate);
return normalized ? [normalized] : [];
})
: [];
const confidence =
typeof record.confidence === "number" && Number.isFinite(record.confidence)
? record.confidence
: undefined;
return [
{
...(normalizeOptionalString(record.id) ? { id: normalizeOptionalString(record.id) } : {}),
text,
...(normalizeOptionalString(record.status)
? { status: normalizeOptionalString(record.status) }
: {}),
...(confidence !== undefined ? { confidence } : {}),
evidence,
...(normalizeOptionalString(record.updatedAt)
? { updatedAt: normalizeOptionalString(record.updatedAt) }
: {}),
},
];
});
}
export function extractWikiLinks(markdown: string): string[] {
const searchable = markdown.replace(RELATED_BLOCK_PATTERN, "");
const links: string[] = [];
@@ -174,6 +258,7 @@ export function toWikiPageSummary(params: {
pageType: normalizeOptionalString(parsed.frontmatter.pageType),
sourceIds: normalizeSourceIds(parsed.frontmatter.sourceIds),
linkTargets: extractWikiLinks(params.raw),
claims: normalizeWikiClaims(parsed.frontmatter.claims),
contradictions: normalizeSingleOrTrimmedStringList(parsed.frontmatter.contradictions),
questions: normalizeSingleOrTrimmedStringList(parsed.frontmatter.questions),
confidence:

View File

@@ -8,14 +8,24 @@ import { renderWikiMarkdown } from "./markdown.js";
import { getMemoryWikiPage, searchMemoryWiki } from "./query.js";
import { createMemoryWikiTestHarness } from "./test-helpers.js";
const { getActiveMemorySearchManagerMock } = vi.hoisted(() => ({
getActiveMemorySearchManagerMock: vi.fn(),
}));
const { getActiveMemorySearchManagerMock, resolveDefaultAgentIdMock, resolveSessionAgentIdMock } =
vi.hoisted(() => ({
getActiveMemorySearchManagerMock: vi.fn(),
resolveDefaultAgentIdMock: vi.fn(() => "main"),
resolveSessionAgentIdMock: vi.fn(({ sessionKey }: { sessionKey?: string }) =>
sessionKey === "agent:secondary:thread" ? "secondary" : "main",
),
}));
vi.mock("openclaw/plugin-sdk/memory-host-search", () => ({
getActiveMemorySearchManager: getActiveMemorySearchManagerMock,
}));
vi.mock("openclaw/plugin-sdk/memory-host-core", () => ({
resolveDefaultAgentId: resolveDefaultAgentIdMock,
resolveSessionAgentId: resolveSessionAgentIdMock,
}));
const { createVault } = createMemoryWikiTestHarness();
let suiteRoot = "";
let caseIndex = 0;
@@ -23,6 +33,8 @@ let caseIndex = 0;
beforeEach(() => {
getActiveMemorySearchManagerMock.mockReset();
getActiveMemorySearchManagerMock.mockResolvedValue({ manager: null, error: "unavailable" });
resolveDefaultAgentIdMock.mockClear();
resolveSessionAgentIdMock.mockClear();
});
beforeAll(async () => {
@@ -104,6 +116,42 @@ describe("searchMemoryWiki", () => {
expect(getActiveMemorySearchManagerMock).not.toHaveBeenCalled();
});
it("finds wiki pages by structured claim text and surfaces the claim as the snippet", async () => {
const { rootDir, config } = await createQueryVault({
initialize: true,
});
await fs.writeFile(
path.join(rootDir, "entities", "alpha.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "entity",
id: "entity.alpha",
title: "Alpha",
claims: [
{
id: "claim.alpha.postgres",
text: "Alpha uses PostgreSQL for production writes.",
status: "supported",
confidence: 0.91,
evidence: [{ sourceId: "source.alpha", lines: "12-18" }],
},
],
},
body: "# Alpha\n\nsummary without the query phrase\n",
}),
"utf8",
);
const results = await searchMemoryWiki({ config, query: "postgresql" });
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
corpus: "wiki",
path: "entities/alpha.md",
snippet: "Alpha uses PostgreSQL for production writes.",
});
});
it("surfaces bridge provenance for imported source pages", async () => {
const { rootDir, config } = await createQueryVault({
initialize: true,
@@ -179,6 +227,48 @@ describe("searchMemoryWiki", () => {
expect(results.some((result) => result.corpus === "wiki")).toBe(true);
expect(results.some((result) => result.corpus === "memory")).toBe(true);
expect(manager.search).toHaveBeenCalledWith("alpha", { maxResults: 5 });
expect(getActiveMemorySearchManagerMock).toHaveBeenCalledWith({
cfg: createAppConfig(),
agentId: "main",
});
});
it("uses the active session agent for shared memory search", async () => {
const { config } = await createQueryVault({
initialize: true,
config: {
search: { backend: "shared", corpus: "memory" },
},
});
const manager = createMemoryManager({
searchResults: [
{
path: "memory/2026-04-07.md",
startLine: 1,
endLine: 2,
score: 1,
snippet: "secondary agent memory",
source: "memory",
},
],
});
getActiveMemorySearchManagerMock.mockResolvedValue({ manager });
await searchMemoryWiki({
config,
appConfig: createAppConfig(),
agentSessionKey: "agent:secondary:thread",
query: "secondary",
});
expect(resolveSessionAgentIdMock).toHaveBeenCalledWith({
sessionKey: "agent:secondary:thread",
config: createAppConfig(),
});
expect(getActiveMemorySearchManagerMock).toHaveBeenCalledWith({
cfg: createAppConfig(),
agentId: "secondary",
});
});
it("allows per-call corpus overrides without changing config defaults", async () => {
@@ -369,6 +459,39 @@ describe("getMemoryWikiPage", () => {
});
});
it("uses the active session agent for shared memory reads", async () => {
const { config } = await createQueryVault({
initialize: true,
config: {
search: { backend: "shared", corpus: "memory" },
},
});
const manager = createMemoryManager({
readResult: {
path: "MEMORY.md",
text: "secondary memory line",
},
});
getActiveMemorySearchManagerMock.mockResolvedValue({ manager });
const result = await getMemoryWikiPage({
config,
appConfig: createAppConfig(),
agentSessionKey: "agent:secondary:thread",
lookup: "MEMORY.md",
});
expect(result?.corpus).toBe("memory");
expect(resolveSessionAgentIdMock).toHaveBeenCalledWith({
sessionKey: "agent:secondary:thread",
config: createAppConfig(),
});
expect(getActiveMemorySearchManagerMock).toHaveBeenCalledWith({
cfg: createAppConfig(),
agentId: "secondary",
});
});
it("allows per-call get overrides to bypass wiki and force memory fallback", async () => {
const { rootDir, config } = await createQueryVault({
initialize: true,

View File

@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/config-runtime";
import { resolveDefaultAgentId, resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core";
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-host-files";
import { getActiveMemorySearchManager } from "openclaw/plugin-sdk/memory-host-search";
import type { OpenClawConfig } from "../api.js";
@@ -99,17 +99,48 @@ function buildSnippet(raw: string, query: string): string {
);
}
function buildPageSearchText(page: QueryableWikiPage): string {
return [
page.title,
page.relativePath,
page.id ?? "",
page.sourceIds.join(" "),
page.questions.join(" "),
page.contradictions.join(" "),
page.claims.map((claim) => claim.text).join(" "),
page.claims.map((claim) => claim.id ?? "").join(" "),
]
.filter(Boolean)
.join("\n");
}
function buildPageSnippet(page: QueryableWikiPage, query: string): string {
const queryLower = query.toLowerCase();
const matchingClaim = page.claims.find((claim) => {
if (claim.text.toLowerCase().includes(queryLower)) {
return true;
}
return claim.id?.toLowerCase().includes(queryLower);
});
if (matchingClaim) {
return matchingClaim.text;
}
return buildSnippet(page.raw, query);
}
function scorePage(page: QueryableWikiPage, query: string): number {
const queryLower = query.toLowerCase();
const titleLower = page.title.toLowerCase();
const pathLower = page.relativePath.toLowerCase();
const idLower = page.id?.toLowerCase() ?? "";
const metadataLower = buildPageSearchText(page).toLowerCase();
const rawLower = page.raw.toLowerCase();
if (
!(
titleLower.includes(queryLower) ||
pathLower.includes(queryLower) ||
idLower.includes(queryLower) ||
metadataLower.includes(queryLower) ||
rawLower.includes(queryLower)
)
) {
@@ -126,10 +157,22 @@ function scorePage(page: QueryableWikiPage, query: string): number {
score += 10;
}
if (idLower.includes(queryLower)) {
score += 10;
score += 20;
}
if (page.sourceIds.some((sourceId) => sourceId.toLowerCase().includes(queryLower))) {
score += 12;
}
const matchingClaimCount = page.claims.filter((claim) => {
if (claim.text.toLowerCase().includes(queryLower)) {
return true;
}
return claim.id?.toLowerCase().includes(queryLower);
}).length;
if (matchingClaimCount > 0) {
score += 25 + Math.min(20, matchingClaimCount * 5);
}
const bodyOccurrences = rawLower.split(queryLower).length - 1;
score += Math.min(20, bodyOccurrences);
score += Math.min(10, bodyOccurrences);
return score;
}
@@ -159,14 +202,39 @@ function shouldSearchSharedMemory(
);
}
async function resolveActiveMemoryManager(appConfig?: OpenClawConfig) {
if (!appConfig) {
function resolveActiveMemoryAgentId(params: {
appConfig?: OpenClawConfig;
agentId?: string;
agentSessionKey?: string;
}): string | null {
if (!params.appConfig) {
return null;
}
if (params.agentId?.trim()) {
return params.agentId.trim();
}
if (params.agentSessionKey?.trim()) {
return resolveSessionAgentId({
sessionKey: params.agentSessionKey,
config: params.appConfig,
});
}
return resolveDefaultAgentId(params.appConfig);
}
async function resolveActiveMemoryManager(params: {
appConfig?: OpenClawConfig;
agentId?: string;
agentSessionKey?: string;
}) {
const agentId = resolveActiveMemoryAgentId(params);
if (!params.appConfig || !agentId) {
return null;
}
try {
const { manager } = await getActiveMemorySearchManager({
cfg: appConfig,
agentId: resolveDefaultAgentId(appConfig),
cfg: params.appConfig,
agentId,
});
return manager;
} catch {
@@ -224,7 +292,7 @@ function toWikiSearchResult(page: QueryableWikiPage, query: string): WikiSearchR
title: page.title,
kind: page.kind,
score: scorePage(page, query),
snippet: buildSnippet(page.raw, query),
snippet: buildPageSnippet(page, query),
...(page.id ? { id: page.id } : {}),
...(page.sourceType ? { sourceType: page.sourceType } : {}),
...(page.provenanceMode ? { provenanceMode: page.provenanceMode } : {}),
@@ -268,6 +336,8 @@ export function resolveQueryableWikiPageByLookup(
export async function searchMemoryWiki(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
agentId?: string;
agentSessionKey?: string;
query: string;
maxResults?: number;
searchBackend?: WikiSearchBackend;
@@ -284,7 +354,11 @@ export async function searchMemoryWiki(params: {
: [];
const sharedMemoryManager = shouldSearchSharedMemory(effectiveConfig, params.appConfig)
? await resolveActiveMemoryManager(params.appConfig)
? await resolveActiveMemoryManager({
appConfig: params.appConfig,
agentId: params.agentId,
agentSessionKey: params.agentSessionKey,
})
: null;
const memoryResults = sharedMemoryManager
? (await sharedMemoryManager.search(params.query, { maxResults })).map((result) =>
@@ -305,6 +379,8 @@ export async function searchMemoryWiki(params: {
export async function getMemoryWikiPage(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
agentId?: string;
agentSessionKey?: string;
lookup: string;
fromLine?: number;
lineCount?: number;
@@ -348,7 +424,11 @@ export async function getMemoryWikiPage(params: {
return null;
}
const manager = await resolveActiveMemoryManager(params.appConfig);
const manager = await resolveActiveMemoryManager({
appConfig: params.appConfig,
agentId: params.agentId,
agentSessionKey: params.agentSessionKey,
});
if (!manager) {
return null;
}

View File

@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../api.js";
import { resolveMemoryWikiConfig } from "./config.js";
import { renderWikiMarkdown } from "./markdown.js";
import {
@@ -59,6 +60,33 @@ describe("resolveMemoryWikiStatus", () => {
expect(status.warnings.map((warning) => warning.code)).toContain("unsafe-local-disabled");
});
it("warns when bridge mode has no exported memory artifacts", async () => {
const config = resolveMemoryWikiConfig(
{
vaultMode: "bridge",
bridge: {
enabled: true,
readMemoryArtifacts: true,
},
},
{ homedir: "/Users/tester" },
);
const status = await resolveMemoryWikiStatus(config, {
appConfig: {
agents: {
list: [{ id: "main", default: true, workspace: "/tmp/workspace" }],
},
} as OpenClawConfig,
listPublicArtifacts: async () => [],
pathExists: async () => true,
resolveCommand: async () => null,
});
expect(status.bridgePublicArtifactCount).toBe(0);
expect(status.warnings.map((warning) => warning.code)).toContain("bridge-artifacts-missing");
});
it("counts source provenance from the vault", async () => {
const { rootDir, config } = await createVault({
prefix: "memory-wiki-status-",
@@ -138,12 +166,13 @@ describe("renderMemoryWikiStatus", () => {
vaultExists: false,
bridge: {
enabled: false,
readMemoryCore: true,
readMemoryArtifacts: true,
indexDreamReports: true,
indexDailyNotes: true,
indexMemoryRoot: true,
followMemoryEvents: true,
},
bridgePublicArtifactCount: null,
obsidianCli: {
enabled: true,
requested: true,
@@ -204,4 +233,32 @@ describe("memory wiki doctor", () => {
expect(rendered).toContain("Suggested fixes:");
expect(rendered).toContain("openclaw wiki init");
});
it("suggests bridge fixes when no public artifacts are exported", async () => {
const config = resolveMemoryWikiConfig(
{
vaultMode: "bridge",
bridge: {
enabled: true,
readMemoryArtifacts: true,
},
},
{ homedir: "/Users/tester" },
);
const status = await resolveMemoryWikiStatus(config, {
appConfig: {
agents: {
list: [{ id: "main", default: true, workspace: "/tmp/workspace" }],
},
} as OpenClawConfig,
listPublicArtifacts: async () => [],
pathExists: async () => true,
resolveCommand: async () => null,
});
const report = buildMemoryWikiDoctorReport(status);
expect(report.fixes.map((fix) => fix.code)).toContain("bridge-artifacts-missing");
expect(renderMemoryWikiDoctor(report)).toContain("exports public artifacts");
});
});

View File

@@ -1,5 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { listActiveMemoryPublicArtifacts } from "openclaw/plugin-sdk/memory-host-core";
import type { OpenClawConfig } from "../api.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { inferWikiPageKind, toWikiPageSummary, type WikiPageKind } from "./markdown.js";
import { probeObsidianCli } from "./obsidian.js";
@@ -9,6 +11,7 @@ export type MemoryWikiStatusWarning = {
| "vault-missing"
| "obsidian-cli-missing"
| "bridge-disabled"
| "bridge-artifacts-missing"
| "unsafe-local-disabled"
| "unsafe-local-paths-missing"
| "unsafe-local-without-mode";
@@ -21,6 +24,7 @@ export type MemoryWikiStatus = {
vaultPath: string;
vaultExists: boolean;
bridge: ResolvedMemoryWikiConfig["bridge"];
bridgePublicArtifactCount: number | null;
obsidianCli: {
enabled: boolean;
requested: boolean;
@@ -55,7 +59,9 @@ export type MemoryWikiDoctorReport = {
};
type ResolveMemoryWikiStatusDeps = {
appConfig?: OpenClawConfig;
pathExists?: (inputPath: string) => Promise<boolean>;
listPublicArtifacts?: typeof listActiveMemoryPublicArtifacts;
resolveCommand?: (command: string) => Promise<string | null>;
};
@@ -135,6 +141,7 @@ async function collectVaultCounts(vaultPath: string): Promise<{
function buildWarnings(params: {
config: ResolvedMemoryWikiConfig;
bridgePublicArtifactCount: number | null;
vaultExists: boolean;
obsidianCommand: string | null;
}): MemoryWikiStatusWarning[] {
@@ -161,6 +168,18 @@ function buildWarnings(params: {
message: "vaultMode is `bridge` but bridge.enabled is false.",
});
}
if (
params.config.vaultMode === "bridge" &&
params.config.bridge.enabled &&
params.config.bridge.readMemoryArtifacts &&
params.bridgePublicArtifactCount === 0
) {
warnings.push({
code: "bridge-artifacts-missing",
message:
"Bridge mode is enabled but the active memory plugin is not exporting any public memory artifacts yet.",
});
}
if (
params.config.vaultMode === "unsafe-local" &&
!params.config.unsafeLocal.allowPrivateMemoryCoreAccess
@@ -198,6 +217,14 @@ export async function resolveMemoryWikiStatus(
): Promise<MemoryWikiStatus> {
const exists = deps?.pathExists ?? pathExists;
const vaultExists = await exists(config.vault.path);
const bridgePublicArtifactCount =
deps?.appConfig && config.vaultMode === "bridge" && config.bridge.enabled
? (
await (deps.listPublicArtifacts ?? listActiveMemoryPublicArtifacts)({
cfg: deps.appConfig,
})
).length
: null;
const obsidianProbe = await probeObsidianCli({ resolveCommand: deps?.resolveCommand });
const counts = vaultExists
? await collectVaultCounts(config.vault.path)
@@ -224,6 +251,7 @@ export async function resolveMemoryWikiStatus(
vaultPath: config.vault.path,
vaultExists,
bridge: config.bridge,
bridgePublicArtifactCount,
obsidianCli: {
enabled: config.obsidian.enabled,
requested: config.obsidian.enabled && config.obsidian.useOfficialCli,
@@ -236,7 +264,12 @@ export async function resolveMemoryWikiStatus(
},
pageCounts: counts.pageCounts,
sourceCounts: counts.sourceCounts,
warnings: buildWarnings({ config, vaultExists, obsidianCommand: obsidianProbe.command }),
warnings: buildWarnings({
config,
bridgePublicArtifactCount,
vaultExists,
obsidianCommand: obsidianProbe.command,
}),
};
}
@@ -250,11 +283,13 @@ export function buildMemoryWikiDoctorReport(status: MemoryWikiStatus): MemoryWik
? "Install the official Obsidian CLI or disable `obsidian.useOfficialCli`."
: warning.code === "bridge-disabled"
? "Enable `plugins.entries.memory-wiki.config.bridge.enabled` or switch vaultMode away from `bridge`."
: warning.code === "unsafe-local-disabled"
? "Enable `unsafeLocal.allowPrivateMemoryCoreAccess` or switch vaultMode away from `unsafe-local`."
: warning.code === "unsafe-local-paths-missing"
? "Add explicit `unsafeLocal.paths` entries before running unsafe-local imports."
: "Disable private memory-core access unless you explicitly want unsafe-local mode.",
: warning.code === "bridge-artifacts-missing"
? "Use a memory plugin that exports public artifacts, create/import memory artifacts first, or switch the wiki back to isolated mode."
: warning.code === "unsafe-local-disabled"
? "Enable `unsafeLocal.allowPrivateMemoryCoreAccess` or switch vaultMode away from `unsafe-local`."
: warning.code === "unsafe-local-paths-missing"
? "Add explicit `unsafeLocal.paths` entries before running unsafe-local imports."
: "Disable private memory-core access unless you explicitly want unsafe-local mode.",
}));
return {
healthy: status.warnings.length === 0,
@@ -270,7 +305,7 @@ export function renderMemoryWikiStatus(status: MemoryWikiStatus): string {
`Vault: ${status.vaultExists ? "ready" : "missing"} (${status.vaultPath})`,
`Render mode: ${status.renderMode}`,
`Obsidian CLI: ${status.obsidianCli.available ? "available" : "missing"}${status.obsidianCli.requested ? " (requested)" : ""}`,
`Bridge: ${status.bridge.enabled ? "enabled" : "disabled"}`,
`Bridge: ${status.bridge.enabled ? "enabled" : "disabled"}${typeof status.bridgePublicArtifactCount === "number" ? ` (${status.bridgePublicArtifactCount} exported artifact${status.bridgePublicArtifactCount === 1 ? "" : "s"})` : ""}`,
`Unsafe local: ${status.unsafeLocal.allowPrivateMemoryCoreAccess ? `enabled (${status.unsafeLocal.pathCount} paths)` : "disabled"}`,
`Pages: ${status.pageCounts.source} sources, ${status.pageCounts.entity} entities, ${status.pageCounts.concept} concepts, ${status.pageCounts.synthesis} syntheses, ${status.pageCounts.report} reports`,
`Source provenance: ${status.sourceCounts.native} native, ${status.sourceCounts.bridge} bridge, ${status.sourceCounts.bridgeEvents} bridge-events, ${status.sourceCounts.unsafeLocal} unsafe-local, ${status.sourceCounts.other} other`,

View File

@@ -36,6 +36,28 @@ const WikiGetSchema = Type.Object(
},
{ additionalProperties: false },
);
const WikiClaimEvidenceSchema = Type.Object(
{
sourceId: Type.Optional(Type.String({ minLength: 1 })),
path: Type.Optional(Type.String({ minLength: 1 })),
lines: Type.Optional(Type.String({ minLength: 1 })),
weight: Type.Optional(Type.Number({ minimum: 0 })),
note: Type.Optional(Type.String({ minLength: 1 })),
updatedAt: Type.Optional(Type.String({ minLength: 1 })),
},
{ additionalProperties: false },
);
const WikiClaimSchema = Type.Object(
{
id: Type.Optional(Type.String({ minLength: 1 })),
text: Type.String({ minLength: 1 }),
status: Type.Optional(Type.String({ minLength: 1 })),
confidence: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
evidence: Type.Optional(Type.Array(WikiClaimEvidenceSchema)),
updatedAt: Type.Optional(Type.String({ minLength: 1 })),
},
{ additionalProperties: false },
);
const WikiApplySchema = Type.Object(
{
op: Type.Union([Type.Literal("create_synthesis"), Type.Literal("update_metadata")]),
@@ -43,6 +65,7 @@ const WikiApplySchema = Type.Object(
body: Type.Optional(Type.String({ minLength: 1 })),
lookup: Type.Optional(Type.String({ minLength: 1 })),
sourceIds: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
claims: Type.Optional(Type.Array(WikiClaimSchema)),
contradictions: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
questions: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
confidence: Type.Optional(Type.Union([Type.Number({ minimum: 0, maximum: 1 }), Type.Null()])),
@@ -58,6 +81,11 @@ async function syncImportedSourcesIfNeeded(
await syncMemoryWikiImportedSources({ config, appConfig });
}
type WikiToolMemoryContext = {
agentId?: string;
agentSessionKey?: string;
};
export function createWikiStatusTool(
config: ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,
@@ -70,7 +98,9 @@ export function createWikiStatusTool(
parameters: WikiStatusSchema,
execute: async () => {
await syncImportedSourcesIfNeeded(config, appConfig);
const status = await resolveMemoryWikiStatus(config);
const status = await resolveMemoryWikiStatus(config, {
appConfig,
});
return {
content: [{ type: "text", text: renderMemoryWikiStatus(status) }],
details: status,
@@ -82,6 +112,7 @@ export function createWikiStatusTool(
export function createWikiSearchTool(
config: ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,
memoryContext: WikiToolMemoryContext = {},
): AnyAgentTool {
return {
name: "wiki_search",
@@ -100,6 +131,8 @@ export function createWikiSearchTool(
const results = await searchMemoryWiki({
config,
appConfig,
agentId: memoryContext.agentId,
agentSessionKey: memoryContext.agentSessionKey,
query: params.query,
maxResults: params.maxResults,
...(params.backend ? { searchBackend: params.backend } : {}),
@@ -193,6 +226,7 @@ export function createWikiApplyTool(
export function createWikiGetTool(
config: ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,
memoryContext: WikiToolMemoryContext = {},
): AnyAgentTool {
return {
name: "wiki_get",
@@ -212,6 +246,8 @@ export function createWikiGetTool(
const result = await getMemoryWikiPage({
config,
appConfig,
agentId: memoryContext.agentId,
agentSessionKey: memoryContext.agentSessionKey,
lookup: params.lookup,
fromLine: params.fromLine,
lineCount: params.lineCount,

View File

@@ -46,6 +46,8 @@ function buildAgentsMarkdown(): string {
- Treat generated blocks as plugin-owned.
- Preserve human notes outside managed markers.
- Prefer source-backed claims over wiki-to-wiki citation loops.
- Prefer structured \`claims\` with evidence over burying key beliefs only in prose.
- Use \`.openclaw-wiki/cache/agent-digest.json\` and \`claims.jsonl\` for machine reads; markdown pages are the human view.
`);
}
@@ -59,6 +61,11 @@ This vault is maintained by the OpenClaw memory-wiki plugin.
- Render mode: \`${config.vault.renderMode}\`
- Search corpus default: \`${config.search.corpus}\`
## Architecture
- Raw sources remain the evidence layer.
- Wiki pages are the human-readable synthesis layer.
- \`.openclaw-wiki/cache/agent-digest.json\` is the agent-facing compiled digest.
## Notes
<!-- openclaw:human:start -->
<!-- openclaw:human:end -->

View File

@@ -756,6 +756,7 @@ export async function runEmbeddedAttempt(
userTime,
userTimeFormat,
contextFiles,
includeMemorySection: !params.contextEngine || params.contextEngine.info.id === "legacy",
memoryCitationsMode: params.config?.memory?.citations,
promptContribution,
});

View File

@@ -1,5 +1,6 @@
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { clearMemoryPluginState, registerMemoryPromptSection } from "../../plugins/memory-state.js";
import {
applySystemPromptOverrideToSession,
buildEmbeddedSystemPrompt,
@@ -67,6 +68,10 @@ describe("applySystemPromptOverrideToSession", () => {
});
describe("buildEmbeddedSystemPrompt", () => {
afterEach(() => {
clearMemoryPluginState();
});
it("forwards provider prompt contributions into the embedded prompt", () => {
const prompt = buildEmbeddedSystemPrompt({
workspaceDir: "/tmp/openclaw",
@@ -89,4 +94,27 @@ describe("buildEmbeddedSystemPrompt", () => {
expect(prompt).toContain("## Embedded Stable\n\nStable provider guidance.");
});
it("can omit base memory guidance for non-legacy context engines", () => {
registerMemoryPromptSection(() => ["## Memory Recall", "Use memory carefully.", ""]);
const prompt = buildEmbeddedSystemPrompt({
workspaceDir: "/tmp/openclaw",
reasoningTagHint: false,
runtimeInfo: {
host: "local",
os: "darwin",
arch: "arm64",
node: process.version,
model: "gpt-5.4",
provider: "openai",
},
tools: [],
modelAliasLines: [],
userTimezone: "UTC",
includeMemorySection: false,
});
expect(prompt).not.toContain("## Memory Recall");
});
});

View File

@@ -51,6 +51,7 @@ export function buildEmbeddedSystemPrompt(params: {
userTime?: string;
userTimeFormat?: ResolvedTimeFormat;
contextFiles?: EmbeddedContextFile[];
includeMemorySection?: boolean;
memoryCitationsMode?: MemoryCitationsMode;
promptContribution?: ProviderSystemPromptContribution;
}): string {
@@ -80,6 +81,7 @@ export function buildEmbeddedSystemPrompt(params: {
userTime: params.userTime,
userTimeFormat: params.userTimeFormat,
contextFiles: params.contextFiles,
includeMemorySection: params.includeMemorySection,
memoryCitationsMode: params.memoryCitationsMode,
promptContribution: params.promptContribution,
});

View File

@@ -0,0 +1,24 @@
import { afterEach, describe, expect, it } from "vitest";
import { clearMemoryPluginState, registerMemoryPromptSection } from "../plugins/memory-state.js";
import { buildAgentSystemPrompt } from "./system-prompt.js";
describe("buildAgentSystemPrompt memory guidance", () => {
afterEach(() => {
clearMemoryPluginState();
});
it("can suppress base memory guidance so context engines own memory prompt assembly", () => {
registerMemoryPromptSection(() => ["## Memory Recall", "Use memory carefully.", ""]);
const promptWithMemory = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
});
const promptWithoutMemory = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
includeMemorySection: false,
});
expect(promptWithMemory).toContain("## Memory Recall");
expect(promptWithoutMemory).not.toContain("## Memory Recall");
});
});

View File

@@ -124,10 +124,11 @@ function buildSkillsSection(params: { skillsPrompt?: string; readToolName: strin
function buildMemorySection(params: {
isMinimal: boolean;
includeMemorySection?: boolean;
availableTools: Set<string>;
citationsMode?: MemoryCitationsMode;
}) {
if (params.isMinimal) {
if (params.isMinimal || params.includeMemorySection === false) {
return [];
}
return buildMemoryPromptSection({
@@ -354,6 +355,8 @@ export function buildAgentSystemPrompt(params: {
level: "minimal" | "extensive";
channel: string;
};
/** Whether to include the active memory plugin prompt guidance in the base system prompt. Defaults to true. */
includeMemorySection?: boolean;
memoryCitationsMode?: MemoryCitationsMode;
promptContribution?: ProviderSystemPromptContribution;
}) {
@@ -462,6 +465,7 @@ export function buildAgentSystemPrompt(params: {
});
const memorySection = buildMemorySection({
isMinimal,
includeMemorySection: params.includeMemorySection,
availableTools,
citationsMode: params.memoryCitationsMode,
});

View File

@@ -12,12 +12,19 @@ export { loadConfig } from "../config/config.js";
export { resolveStateDir } from "../config/paths.js";
export { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
export {
buildMemoryPromptSection as buildActiveMemoryPromptSection,
listActiveMemoryPublicArtifacts,
} from "../plugins/memory-state.js";
export { parseAgentSessionKey } from "../routing/session-key.js";
export type { OpenClawConfig } from "../config/config.js";
export type { MemoryCitationsMode } from "../config/types.memory.js";
export type {
MemoryFlushPlan,
MemoryFlushPlanResolver,
MemoryPluginCapability,
MemoryPluginPublicArtifact,
MemoryPluginPublicArtifactsProvider,
MemoryPluginRuntime,
MemoryPromptSectionBuilder,
} from "../plugins/memory-state.js";

View File

@@ -18,6 +18,11 @@ if (shouldWarnCompatImport) {
}
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
export type {
MemoryPluginCapability,
MemoryPluginPublicArtifact,
MemoryPluginPublicArtifactsProvider,
} from "../plugins/memory-state.js";
export { resolveControlCommandGate } from "../channels/command-gating.js";
export { delegateCompactionToRuntime } from "../context-engine/delegate.js";
export type { DiagnosticEventPayload } from "../infra/diagnostic-events.js";

View File

@@ -83,6 +83,11 @@ export type {
SpeechProviderPlugin,
} from "./plugin-entry.js";
export type { OpenClawPluginToolContext, OpenClawPluginToolFactory } from "../plugins/types.js";
export type {
MemoryPluginCapability,
MemoryPluginPublicArtifact,
MemoryPluginPublicArtifactsProvider,
} from "../plugins/memory-state.js";
export type {
PluginHookReplyDispatchContext,
PluginHookReplyDispatchEvent,

View File

@@ -78,6 +78,11 @@ export type {
export type { OpenClawConfig } from "../config/config.js";
/** @deprecated Use OpenClawConfig instead */
export type { OpenClawConfig as ClawdbotConfig } from "../config/config.js";
export type {
MemoryPluginCapability,
MemoryPluginPublicArtifact,
MemoryPluginPublicArtifactsProvider,
} from "../plugins/memory-state.js";
export type { CliBackendConfig } from "../config/types.js";
export * from "./image-generation.js";
export * from "./music-generation.js";

View File

@@ -24,6 +24,9 @@ export type {
MemoryCitationsMode,
MemoryFlushPlan,
MemoryFlushPlanResolver,
MemoryPluginCapability,
MemoryPluginPublicArtifact,
MemoryPluginPublicArtifactsProvider,
MemoryPluginRuntime,
MemoryPromptSectionBuilder,
OpenClawConfig,

View File

@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it } from "vitest";
import {
clearMemoryPluginState,
registerMemoryCapability,
registerMemoryPromptSection,
} from "../plugins/memory-state.js";
import {
buildActiveMemoryPromptSection,
listActiveMemoryPublicArtifacts,
} from "./memory-host-core.js";
describe("memory-host-core helpers", () => {
afterEach(() => {
clearMemoryPluginState();
});
it("exposes the active memory prompt guidance builder for context engines", () => {
registerMemoryPromptSection(({ citationsMode }) => [
"## Memory Recall",
`citations=${citationsMode ?? "default"}`,
"",
]);
expect(
buildActiveMemoryPromptSection({
availableTools: new Set(["memory_search"]),
citationsMode: "off",
}),
).toEqual(["## Memory Recall", "citations=off", ""]);
});
it("exposes active memory public artifacts for companion plugins", async () => {
registerMemoryCapability("memory-core", {
publicArtifacts: {
async listArtifacts() {
return [
{
kind: "memory-root",
workspaceDir: "/tmp/workspace",
relativePath: "MEMORY.md",
absolutePath: "/tmp/workspace/MEMORY.md",
agentIds: ["main"],
contentType: "markdown" as const,
},
];
},
},
});
await expect(listActiveMemoryPublicArtifacts({ cfg: {} as never })).resolves.toEqual([
{
kind: "memory-root",
workspaceDir: "/tmp/workspace",
relativePath: "MEMORY.md",
absolutePath: "/tmp/workspace/MEMORY.md",
agentIds: ["main"],
contentType: "markdown",
},
]);
});
});

View File

@@ -45,6 +45,7 @@ export type BuildPluginApiParams = {
| "onConversationBindingResolved"
| "registerCommand"
| "registerContextEngine"
| "registerMemoryCapability"
| "registerMemoryPromptSection"
| "registerMemoryPromptSupplement"
| "registerMemoryCorpusSupplement"
@@ -91,6 +92,7 @@ const noopOnConversationBindingResolved: OpenClawPluginApi["onConversationBindin
() => {};
const noopRegisterCommand: OpenClawPluginApi["registerCommand"] = () => {};
const noopRegisterContextEngine: OpenClawPluginApi["registerContextEngine"] = () => {};
const noopRegisterMemoryCapability: OpenClawPluginApi["registerMemoryCapability"] = () => {};
const noopRegisterMemoryPromptSection: OpenClawPluginApi["registerMemoryPromptSection"] = () => {};
const noopRegisterMemoryPromptSupplement: OpenClawPluginApi["registerMemoryPromptSupplement"] =
() => {};
@@ -152,6 +154,7 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi
handlers.onConversationBindingResolved ?? noopOnConversationBindingResolved,
registerCommand: handlers.registerCommand ?? noopRegisterCommand,
registerContextEngine: handlers.registerContextEngine ?? noopRegisterContextEngine,
registerMemoryCapability: handlers.registerMemoryCapability ?? noopRegisterMemoryCapability,
registerMemoryPromptSection:
handlers.registerMemoryPromptSection ?? noopRegisterMemoryPromptSection,
registerMemoryPromptSupplement:

View File

@@ -3,11 +3,15 @@ import {
_resetMemoryPluginState,
buildMemoryPromptSection,
clearMemoryPluginState,
getMemoryCapabilityRegistration,
getMemoryFlushPlanResolver,
getMemoryPromptSectionBuilder,
getMemoryRuntime,
hasMemoryRuntime,
listMemoryCorpusSupplements,
listMemoryPromptSupplements,
listActiveMemoryPublicArtifacts,
registerMemoryCapability,
registerMemoryCorpusSupplement,
registerMemoryFlushPlanResolver,
registerMemoryPromptSupplement,
@@ -48,6 +52,7 @@ function expectClearedMemoryState() {
function createMemoryStateSnapshot() {
return {
capability: getMemoryCapabilityRegistration(),
corpusSupplements: listMemoryCorpusSupplements(),
promptBuilder: getMemoryPromptSectionBuilder(),
promptSupplements: listMemoryPromptSupplements(),
@@ -97,6 +102,85 @@ describe("memory plugin state", () => {
]);
});
it("prefers the registered memory capability over legacy split state", async () => {
const runtime = createMemoryRuntime();
registerMemoryPromptSection(() => ["legacy prompt"]);
registerMemoryFlushPlanResolver(() => createMemoryFlushPlan("memory/legacy.md"));
registerMemoryRuntime({
async getMemorySearchManager() {
return { manager: null, error: "legacy" };
},
resolveMemoryBackendConfig() {
return { backend: "builtin" as const };
},
});
registerMemoryCapability("memory-core", {
promptBuilder: () => ["capability prompt"],
flushPlanResolver: () => createMemoryFlushPlan("memory/capability.md"),
runtime,
});
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual(["capability prompt"]);
expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/capability.md");
await expect(
getMemoryRuntime()?.getMemorySearchManager({
cfg: {} as never,
agentId: "main",
}),
).resolves.toEqual({ manager: null, error: "missing" });
expect(hasMemoryRuntime()).toBe(true);
expect(getMemoryCapabilityRegistration()).toMatchObject({
pluginId: "memory-core",
});
});
it("lists active public memory artifacts in deterministic order", async () => {
registerMemoryCapability("memory-core", {
publicArtifacts: {
async listArtifacts() {
return [
{
kind: "daily-note",
workspaceDir: "/tmp/workspace-b",
relativePath: "memory/2026-04-06.md",
absolutePath: "/tmp/workspace-b/memory/2026-04-06.md",
agentIds: ["beta"],
contentType: "markdown" as const,
},
{
kind: "memory-root",
workspaceDir: "/tmp/workspace-a",
relativePath: "MEMORY.md",
absolutePath: "/tmp/workspace-a/MEMORY.md",
agentIds: ["main"],
contentType: "markdown" as const,
},
];
},
},
});
await expect(listActiveMemoryPublicArtifacts({ cfg: {} as never })).resolves.toEqual([
{
kind: "memory-root",
workspaceDir: "/tmp/workspace-a",
relativePath: "MEMORY.md",
absolutePath: "/tmp/workspace-a/MEMORY.md",
agentIds: ["main"],
contentType: "markdown",
},
{
kind: "daily-note",
workspaceDir: "/tmp/workspace-b",
relativePath: "memory/2026-04-06.md",
absolutePath: "/tmp/workspace-b/memory/2026-04-06.md",
agentIds: ["beta"],
contentType: "markdown",
},
]);
});
it("passes citations mode through to the prompt builder", () => {
registerMemoryPromptSection(({ citationsMode }) => [
`citations: ${citationsMode ?? "default"}`,

View File

@@ -109,12 +109,46 @@ export type MemoryPluginRuntime = {
closeAllMemorySearchManagers?(): Promise<void>;
};
type MemoryPluginState = {
corpusSupplements: MemoryCorpusSupplementRegistration[];
export type MemoryPluginPublicArtifactContentType = "markdown" | "json" | "text";
export type MemoryPluginPublicArtifact = {
kind: string;
workspaceDir: string;
relativePath: string;
absolutePath: string;
agentIds: string[];
contentType: MemoryPluginPublicArtifactContentType;
};
export type MemoryPluginPublicArtifactsProvider = {
listArtifacts(params: { cfg: OpenClawConfig }): Promise<MemoryPluginPublicArtifact[]>;
};
export type MemoryPluginCapability = {
promptBuilder?: MemoryPromptSectionBuilder;
promptSupplements: MemoryPromptSupplementRegistration[];
flushPlanResolver?: MemoryFlushPlanResolver;
runtime?: MemoryPluginRuntime;
publicArtifacts?: MemoryPluginPublicArtifactsProvider;
};
export type MemoryPluginCapabilityRegistration = {
pluginId: string;
capability: MemoryPluginCapability;
};
type MemoryPluginState = {
capability?: MemoryPluginCapabilityRegistration;
corpusSupplements: MemoryCorpusSupplementRegistration[];
promptSupplements: MemoryPromptSupplementRegistration[];
// LEGACY(memory-v1): kept for external plugins still registering the older
// split memory surfaces. Prefer `registerMemoryCapability(...)`.
promptBuilder?: MemoryPromptSectionBuilder;
// LEGACY(memory-v1): remove after external memory plugins migrate to the
// unified capability registration path.
flushPlanResolver?: MemoryFlushPlanResolver;
// LEGACY(memory-v1): remove after external memory plugins migrate to the
// unified capability registration path.
runtime?: MemoryPluginRuntime;
};
const memoryPluginState: MemoryPluginState = {
@@ -133,10 +167,27 @@ export function registerMemoryCorpusSupplement(
memoryPluginState.corpusSupplements = next;
}
export function registerMemoryCapability(
pluginId: string,
capability: MemoryPluginCapability,
): void {
memoryPluginState.capability = { pluginId, capability: { ...capability } };
}
export function getMemoryCapabilityRegistration(): MemoryPluginCapabilityRegistration | undefined {
return memoryPluginState.capability
? {
pluginId: memoryPluginState.capability.pluginId,
capability: { ...memoryPluginState.capability.capability },
}
: undefined;
}
export function listMemoryCorpusSupplements(): MemoryCorpusSupplementRegistration[] {
return [...memoryPluginState.corpusSupplements];
}
/** @deprecated Use registerMemoryCapability(pluginId, { promptBuilder }) instead. */
export function registerMemoryPromptSection(builder: MemoryPromptSectionBuilder): void {
memoryPluginState.promptBuilder = builder;
}
@@ -156,7 +207,10 @@ export function buildMemoryPromptSection(params: {
availableTools: Set<string>;
citationsMode?: MemoryCitationsMode;
}): string[] {
const primary = memoryPluginState.promptBuilder?.(params) ?? [];
const primary =
memoryPluginState.capability?.capability.promptBuilder?.(params) ??
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))
@@ -165,13 +219,14 @@ export function buildMemoryPromptSection(params: {
}
export function getMemoryPromptSectionBuilder(): MemoryPromptSectionBuilder | undefined {
return memoryPluginState.promptBuilder;
return memoryPluginState.capability?.capability.promptBuilder ?? memoryPluginState.promptBuilder;
}
export function listMemoryPromptSupplements(): MemoryPromptSupplementRegistration[] {
return [...memoryPluginState.promptSupplements];
}
/** @deprecated Use registerMemoryCapability(pluginId, { flushPlanResolver }) instead. */
export function registerMemoryFlushPlanResolver(resolver: MemoryFlushPlanResolver): void {
memoryPluginState.flushPlanResolver = resolver;
}
@@ -180,26 +235,79 @@ export function resolveMemoryFlushPlan(params: {
cfg?: OpenClawConfig;
nowMs?: number;
}): MemoryFlushPlan | null {
return memoryPluginState.flushPlanResolver?.(params) ?? null;
return (
memoryPluginState.capability?.capability.flushPlanResolver?.(params) ??
memoryPluginState.flushPlanResolver?.(params) ??
null
);
}
export function getMemoryFlushPlanResolver(): MemoryFlushPlanResolver | undefined {
return memoryPluginState.flushPlanResolver;
return (
memoryPluginState.capability?.capability.flushPlanResolver ??
memoryPluginState.flushPlanResolver
);
}
/** @deprecated Use registerMemoryCapability(pluginId, { runtime }) instead. */
export function registerMemoryRuntime(runtime: MemoryPluginRuntime): void {
memoryPluginState.runtime = runtime;
}
export function getMemoryRuntime(): MemoryPluginRuntime | undefined {
return memoryPluginState.runtime;
return memoryPluginState.capability?.capability.runtime ?? memoryPluginState.runtime;
}
export function hasMemoryRuntime(): boolean {
return memoryPluginState.runtime !== undefined;
return getMemoryRuntime() !== undefined;
}
function cloneMemoryPublicArtifact(
artifact: MemoryPluginPublicArtifact,
): MemoryPluginPublicArtifact {
return {
...artifact,
agentIds: [...artifact.agentIds],
};
}
export async function listActiveMemoryPublicArtifacts(params: {
cfg: OpenClawConfig;
}): Promise<MemoryPluginPublicArtifact[]> {
const artifacts =
(await memoryPluginState.capability?.capability.publicArtifacts?.listArtifacts(params)) ?? [];
return artifacts.map(cloneMemoryPublicArtifact).toSorted((left, right) => {
const workspaceOrder = left.workspaceDir.localeCompare(right.workspaceDir);
if (workspaceOrder !== 0) {
return workspaceOrder;
}
const relativePathOrder = left.relativePath.localeCompare(right.relativePath);
if (relativePathOrder !== 0) {
return relativePathOrder;
}
const kindOrder = left.kind.localeCompare(right.kind);
if (kindOrder !== 0) {
return kindOrder;
}
const contentTypeOrder = left.contentType.localeCompare(right.contentType);
if (contentTypeOrder !== 0) {
return contentTypeOrder;
}
const agentOrder = left.agentIds.join("\0").localeCompare(right.agentIds.join("\0"));
if (agentOrder !== 0) {
return agentOrder;
}
return left.absolutePath.localeCompare(right.absolutePath);
});
}
export function restoreMemoryPluginState(state: MemoryPluginState): void {
memoryPluginState.capability = state.capability
? {
pluginId: state.capability.pluginId,
capability: { ...state.capability.capability },
}
: undefined;
memoryPluginState.corpusSupplements = [...state.corpusSupplements];
memoryPluginState.promptBuilder = state.promptBuilder;
memoryPluginState.promptSupplements = [...state.promptSupplements];
@@ -208,6 +316,7 @@ export function restoreMemoryPluginState(state: MemoryPluginState): void {
}
export function clearMemoryPluginState(): void {
memoryPluginState.capability = undefined;
memoryPluginState.corpusSupplements = [];
memoryPluginState.promptBuilder = undefined;
memoryPluginState.promptSupplements = [];

View File

@@ -5,7 +5,11 @@ import {
registerVirtualTestPlugin,
} from "../../test/helpers/plugins/contracts-testkit.js";
import { clearMemoryEmbeddingProviders } from "./memory-embedding-providers.js";
import { _resetMemoryPluginState, getMemoryRuntime } from "./memory-state.js";
import {
_resetMemoryPluginState,
getMemoryCapabilityRegistration,
getMemoryRuntime,
} from "./memory-state.js";
import { createPluginRecord } from "./status.test-helpers.js";
afterEach(() => {
@@ -92,4 +96,30 @@ describe("dual-kind memory registration gate", () => {
expect(getMemoryRuntime()).toBeDefined();
});
it("allows selected dual-kind plugins to register the unified memory capability", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "dual-plugin",
name: "Dual Plugin",
kind: ["memory", "context-engine"],
memorySlotSelected: true,
}),
register(api) {
api.registerMemoryCapability({
runtime: createStubMemoryRuntime(),
promptBuilder: () => ["memory capability"],
});
},
});
expect(getMemoryCapabilityRegistration()).toMatchObject({
pluginId: "dual-plugin",
});
expect(getMemoryRuntime()).toBeDefined();
});
});

View File

@@ -30,6 +30,7 @@ import {
registerMemoryEmbeddingProvider,
} from "./memory-embedding-providers.js";
import {
registerMemoryCapability,
registerMemoryCorpusSupplement,
registerMemoryFlushPlanResolver,
registerMemoryPromptSupplement,
@@ -1296,6 +1297,32 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
}
},
registerMemoryCapability: (capability) => {
if (!hasKind(record.kind, "memory")) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "only memory plugins can register a memory capability",
});
return;
}
if (
Array.isArray(record.kind) &&
record.kind.length > 1 &&
!record.memorySlotSelected
) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message:
"dual-kind plugin not selected for memory slot; skipping memory capability registration",
});
return;
}
registerMemoryCapability(record.id, capability);
},
registerMemoryPromptSection: (builder) => {
if (!hasKind(record.kind, "memory")) {
pushDiagnostic({

View File

@@ -2184,7 +2184,14 @@ export type OpenClawPluginApi = {
id: string,
factory: import("../context-engine/registry.js").ContextEngineFactory,
) => void;
/** Register the system prompt section builder for this memory plugin (exclusive slot). */
/** Register the active memory capability for this memory plugin (exclusive slot). */
registerMemoryCapability: (
capability: import("./memory-state.js").MemoryPluginCapability,
) => void;
/**
* Register the system prompt section builder for this memory plugin (exclusive slot).
* @deprecated Use registerMemoryCapability({ promptBuilder }) instead.
*/
registerMemoryPromptSection: (
builder: import("./memory-state.js").MemoryPromptSectionBuilder,
) => void;
@@ -2196,9 +2203,15 @@ export type OpenClawPluginApi = {
registerMemoryCorpusSupplement: (
supplement: import("./memory-state.js").MemoryCorpusSupplement,
) => void;
/** Register the pre-compaction flush plan resolver for this memory plugin (exclusive slot). */
/**
* Register the pre-compaction flush plan resolver for this memory plugin (exclusive slot).
* @deprecated Use registerMemoryCapability({ flushPlanResolver }) instead.
*/
registerMemoryFlushPlan: (resolver: import("./memory-state.js").MemoryFlushPlanResolver) => void;
/** Register the active memory runtime adapter for this memory plugin (exclusive slot). */
/**
* Register the active memory runtime adapter for this memory plugin (exclusive slot).
* @deprecated Use registerMemoryCapability({ runtime }) instead.
*/
registerMemoryRuntime: (runtime: import("./memory-state.js").MemoryPluginRuntime) => void;
/** Register a memory embedding provider adapter. Multiple adapters may coexist. */
registerMemoryEmbeddingProvider: (

View File

@@ -38,6 +38,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
onConversationBindingResolved() {},
registerCommand() {},
registerContextEngine() {},
registerMemoryCapability() {},
registerMemoryPromptSection() {},
registerMemoryPromptSupplement() {},
registerMemoryCorpusSupplement() {},