diff --git a/CHANGELOG.md b/CHANGELOG.md index 783e565e5be..d14cd97307a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/concepts/context-engine.md b/docs/concepts/context-engine.md index d24359c84ce..6778d561001 100644 --- a/docs/concepts/context-engine.md +++ b/docs/concepts/context-engine.md @@ -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. diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index 0ccaff88751..cf8e0044f5d 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -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). diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index f533d654bbc..3c3da4b5aec 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -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) => diff --git a/extensions/memory-core/src/dreaming-phases.test.ts b/extensions/memory-core/src/dreaming-phases.test.ts index 045fabd5e58..5080754c6d3 100644 --- a/extensions/memory-core/src/dreaming-phases.test.ts +++ b/extensions/memory-core/src/dreaming-phases.test.ts @@ -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) - | 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 }; } diff --git a/extensions/memory-core/src/dreaming-phases.ts b/extensions/memory-core/src/dreaming-phases.ts index 79444002acd..8690086fc2b 100644 --- a/extensions/memory-core/src/dreaming-phases.ts +++ b/extensions/memory-core/src/dreaming-phases.ts @@ -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; - -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; - add: (input: ManagedCronJobCreate) => Promise; - update: (id: string, patch: ManagedCronJobPatch) => Promise; - 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; - 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 { - 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, + }, +}; diff --git a/extensions/memory-core/src/dreaming.test.ts b/extensions/memory-core/src/dreaming.test.ts index cc082223a11..96065a5b977 100644 --- a/extensions/memory-core/src/dreaming.test.ts +++ b/extensions/memory-core/src/dreaming.test.ts @@ -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 () => { diff --git a/extensions/memory-core/src/dreaming.ts b/extensions/memory-core/src/dreaming.ts index 8628ab0c6b3..e7b83149f63 100644 --- a/extensions/memory-core/src/dreaming.ts +++ b/extensions/memory-core/src/dreaming.ts @@ -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 { + 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); diff --git a/extensions/memory-core/src/public-artifacts.test.ts b/extensions/memory-core/src/public-artifacts.test.ts new file mode 100644 index 00000000000..fd6a631fae0 --- /dev/null +++ b/extensions/memory-core/src/public-artifacts.test.ts @@ -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", + }, + ]); + }); +}); diff --git a/extensions/memory-core/src/public-artifacts.ts b/extensions/memory-core/src/public-artifacts.ts new file mode 100644 index 00000000000..4dfc3d5fa2e --- /dev/null +++ b/extensions/memory-core/src/public-artifacts.ts @@ -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 { + try { + await fs.access(inputPath); + return true; + } catch { + return false; + } +} + +async function listMarkdownFilesRecursive(rootDir: string): Promise { + 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 { + 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(); + 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 { + 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; +} diff --git a/extensions/memory-wiki/README.md b/extensions/memory-wiki/README.md index 2d89f748808..bf7fcd05ac7 100644 --- a/extensions/memory-wiki/README.md +++ b/extensions/memory-wiki/README.md @@ -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`. diff --git a/extensions/memory-wiki/contract-api.ts b/extensions/memory-wiki/contract-api.ts new file mode 100644 index 00000000000..db610ee157d --- /dev/null +++ b/extensions/memory-wiki/contract-api.ts @@ -0,0 +1 @@ +export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/config-compat.js"; diff --git a/extensions/memory-wiki/index.ts b/extensions/memory-wiki/index.ts index 723b56e59cd..3c02de21241 100644 --- a/extensions/memory-wiki/index.ts +++ b/extensions/memory-wiki/index.ts @@ -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); diff --git a/extensions/memory-wiki/openclaw.plugin.json b/extensions/memory-wiki/openclaw.plugin.json index a4728993196..7b6f38e1323 100644 --- a/extensions/memory-wiki/openclaw.plugin.json +++ b/extensions/memory-wiki/openclaw.plugin.json @@ -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": { diff --git a/extensions/memory-wiki/setup-api.ts b/extensions/memory-wiki/setup-api.ts new file mode 100644 index 00000000000..29c2839eef7 --- /dev/null +++ b/extensions/memory-wiki/setup-api.ts @@ -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)); + }, +}); diff --git a/extensions/memory-wiki/skills/wiki-maintainer/SKILL.md b/extensions/memory-wiki/skills/wiki-maintainer/SKILL.md index 5f81d1d590f..ec95bd195bc 100644 --- a/extensions/memory-wiki/skills/wiki-maintainer/SKILL.md +++ b/extensions/memory-wiki/skills/wiki-maintainer/SKILL.md @@ -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. diff --git a/extensions/memory-wiki/src/apply.test.ts b/extensions/memory-wiki/src/apply.test.ts index 4c8dff3aa84..72557b6e555 100644 --- a/extensions/memory-wiki/src/apply.test.ts +++ b/extensions/memory-wiki/src/apply.test.ts @@ -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", diff --git a/extensions/memory-wiki/src/apply.ts b/extensions/memory-wiki/src/apply.ts index df33c7eee86..bcba405f67e 100644 --- a/extensions/memory-wiki/src/apply.ts +++ b/extensions/memory-wiki/src/apply.ts @@ -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) { diff --git a/extensions/memory-wiki/src/bridge.test.ts b/extensions/memory-wiki/src/bridge.test.ts index dba7c36b3d7..f6e3b888c86 100644 --- a/extensions/memory-wiki/src/bridge.test.ts +++ b/extensions/memory-wiki/src/bridge.test.ts @@ -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); diff --git a/extensions/memory-wiki/src/bridge.ts b/extensions/memory-wiki/src/bridge.ts index 116ca541568..e1c40e8e964 100644 --- a/extensions/memory-wiki/src/bridge.ts +++ b/extensions/memory-wiki/src/bridge.ts @@ -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 { - 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 { - 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(); + 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(); + 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(); - 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(); + 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, }; } diff --git a/extensions/memory-wiki/src/cli.ts b/extensions/memory-wiki/src/cli.ts index 2ce7070c282..be6ce8cd203 100644 --- a/extensions/memory-wiki/src/cli.ts +++ b/extensions/memory-wiki/src/cli.ts @@ -240,7 +240,9 @@ export async function runWikiStatus(params: { stdout?: Pick; }) { 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; }) { 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 }); diff --git a/extensions/memory-wiki/src/compile.test.ts b/extensions/memory-wiki/src/compile.test.ts index 42a6ed1b888..9fc969f10f9 100644 --- a/extensions/memory-wiki/src/compile.test.ts +++ b/extensions/memory-wiki/src/compile.test.ts @@ -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 () => { diff --git a/extensions/memory-wiki/src/compile.ts b/extensions/memory-wiki/src/compile.ts index d143e6796d2..de945f73de3 100644 --- a/extensions/memory-wiki/src/compile.ts +++ b/extensions/memory-wiki/src/compile.ts @@ -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; pages: WikiPageSummary[]; + claimCount: number; updatedFiles: string[]; }; @@ -509,9 +513,11 @@ function buildRootIndexBody(params: { pages: WikiPageSummary[]; counts: Record; }): 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; + 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; +}): 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; +}): Promise { + 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 { @@ -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, }; } diff --git a/extensions/memory-wiki/src/config-compat.test.ts b/extensions/memory-wiki/src/config-compat.test.ts new file mode 100644 index 00000000000..d58c3ce86c7 --- /dev/null +++ b/extensions/memory-wiki/src/config-compat.test.ts @@ -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 }; + } + ).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 }; + } + ).config?.bridge, + ).toEqual({ + readMemoryArtifacts: true, + }); + }); +}); diff --git a/extensions/memory-wiki/src/config-compat.ts b/extensions/memory-wiki/src/config-compat.ts new file mode 100644 index 00000000000..6657a50c4e6 --- /dev/null +++ b/extensions/memory-wiki/src/config-compat.ts @@ -0,0 +1,75 @@ +import type { OpenClawConfig } from "../api.js"; + +type LegacyConfigRule = { + path: Array; + message: string; + match: (value: unknown) => boolean; +}; + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : 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: [] }; +} diff --git a/extensions/memory-wiki/src/config.test.ts b/extensions/memory-wiki/src/config.test.ts index 791c3e33516..d0becd6eb31 100644 --- a/extensions/memory-wiki/src/config.test.ts +++ b/extensions/memory-wiki/src/config.test.ts @@ -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: { diff --git a/extensions/memory-wiki/src/config.ts b/extensions/memory-wiki/src/config.ts index 4344b1b0c1d..5c2fa6beead 100644 --- a/extensions/memory-wiki/src/config.ts +++ b/extensions/memory-wiki/src/config.ts @@ -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, diff --git a/extensions/memory-wiki/src/corpus-supplement.ts b/extensions/memory-wiki/src/corpus-supplement.ts index e9cd380cd38..bb84e9c2d02 100644 --- a/extensions/memory-wiki/src/corpus-supplement.ts +++ b/extensions/memory-wiki/src/corpus-supplement.ts @@ -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, diff --git a/extensions/memory-wiki/src/gateway.test.ts b/extensions/memory-wiki/src/gateway.test.ts index b1fc65a52b9..b05d0209348 100644 --- a/extensions/memory-wiki/src/gateway.test.ts +++ b/extensions/memory-wiki/src/gateway.test.ts @@ -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({ diff --git a/extensions/memory-wiki/src/gateway.ts b/extensions/memory-wiki/src/gateway.ts index 5526a4401ce..52a785d5b8f 100644 --- a/extensions/memory-wiki/src/gateway.ts +++ b/extensions/memory-wiki/src/gateway.ts @@ -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); diff --git a/extensions/memory-wiki/src/markdown.ts b/extensions/memory-wiki/src/markdown.ts index 583851d31e9..29f2a706315 100644 --- a/extensions/memory-wiki/src/markdown.ts +++ b/extensions/memory-wiki/src/markdown.ts @@ -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; + 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; + 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: diff --git a/extensions/memory-wiki/src/query.test.ts b/extensions/memory-wiki/src/query.test.ts index c22184ac71b..84dc6cb0cc0 100644 --- a/extensions/memory-wiki/src/query.test.ts +++ b/extensions/memory-wiki/src/query.test.ts @@ -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, diff --git a/extensions/memory-wiki/src/query.ts b/extensions/memory-wiki/src/query.ts index e46d5a5a3e6..ae93225fd37 100644 --- a/extensions/memory-wiki/src/query.ts +++ b/extensions/memory-wiki/src/query.ts @@ -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; } diff --git a/extensions/memory-wiki/src/status.test.ts b/extensions/memory-wiki/src/status.test.ts index 07dff999a30..ab192a21ede 100644 --- a/extensions/memory-wiki/src/status.test.ts +++ b/extensions/memory-wiki/src/status.test.ts @@ -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"); + }); }); diff --git a/extensions/memory-wiki/src/status.ts b/extensions/memory-wiki/src/status.ts index 18c7d2a052d..211679d1e37 100644 --- a/extensions/memory-wiki/src/status.ts +++ b/extensions/memory-wiki/src/status.ts @@ -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; + listPublicArtifacts?: typeof listActiveMemoryPublicArtifacts; resolveCommand?: (command: string) => Promise; }; @@ -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 { 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`, diff --git a/extensions/memory-wiki/src/tool.ts b/extensions/memory-wiki/src/tool.ts index 0b8fa7c9a78..f49b6dad458 100644 --- a/extensions/memory-wiki/src/tool.ts +++ b/extensions/memory-wiki/src/tool.ts @@ -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, diff --git a/extensions/memory-wiki/src/vault.ts b/extensions/memory-wiki/src/vault.ts index 90ff60b9085..dffc7a1562f 100644 --- a/extensions/memory-wiki/src/vault.ts +++ b/extensions/memory-wiki/src/vault.ts @@ -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 diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index ad6061b94bd..0611dc8fcfe 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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, }); diff --git a/src/agents/pi-embedded-runner/system-prompt.test.ts b/src/agents/pi-embedded-runner/system-prompt.test.ts index 9aea35abcc7..be73a98306a 100644 --- a/src/agents/pi-embedded-runner/system-prompt.test.ts +++ b/src/agents/pi-embedded-runner/system-prompt.test.ts @@ -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"); + }); }); diff --git a/src/agents/pi-embedded-runner/system-prompt.ts b/src/agents/pi-embedded-runner/system-prompt.ts index e10e2102119..d7d9acd18fa 100644 --- a/src/agents/pi-embedded-runner/system-prompt.ts +++ b/src/agents/pi-embedded-runner/system-prompt.ts @@ -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, }); diff --git a/src/agents/system-prompt.memory.test.ts b/src/agents/system-prompt.memory.test.ts new file mode 100644 index 00000000000..e0fc6120537 --- /dev/null +++ b/src/agents/system-prompt.memory.test.ts @@ -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"); + }); +}); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 322593db1bf..12f54dfc1cc 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -124,10 +124,11 @@ function buildSkillsSection(params: { skillsPrompt?: string; readToolName: strin function buildMemorySection(params: { isMinimal: boolean; + includeMemorySection?: boolean; availableTools: Set; 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, }); diff --git a/src/memory-host-sdk/runtime-core.ts b/src/memory-host-sdk/runtime-core.ts index b18782c0ce9..d57fe65c17d 100644 --- a/src/memory-host-sdk/runtime-core.ts +++ b/src/memory-host-sdk/runtime-core.ts @@ -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"; diff --git a/src/plugin-sdk/compat.ts b/src/plugin-sdk/compat.ts index aa1e5599d85..c351863859d 100644 --- a/src/plugin-sdk/compat.ts +++ b/src/plugin-sdk/compat.ts @@ -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"; diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 2dbc5a7feec..e5c8a65224c 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -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, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index a72655db28e..891f603d030 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -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"; diff --git a/src/plugin-sdk/memory-core.ts b/src/plugin-sdk/memory-core.ts index 1d9537f9904..452563d328c 100644 --- a/src/plugin-sdk/memory-core.ts +++ b/src/plugin-sdk/memory-core.ts @@ -24,6 +24,9 @@ export type { MemoryCitationsMode, MemoryFlushPlan, MemoryFlushPlanResolver, + MemoryPluginCapability, + MemoryPluginPublicArtifact, + MemoryPluginPublicArtifactsProvider, MemoryPluginRuntime, MemoryPromptSectionBuilder, OpenClawConfig, diff --git a/src/plugin-sdk/memory-host-core.test.ts b/src/plugin-sdk/memory-host-core.test.ts new file mode 100644 index 00000000000..d5b07d8fed9 --- /dev/null +++ b/src/plugin-sdk/memory-host-core.test.ts @@ -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", + }, + ]); + }); +}); diff --git a/src/plugins/api-builder.ts b/src/plugins/api-builder.ts index 798d854f02f..f64cbb5599c 100644 --- a/src/plugins/api-builder.ts +++ b/src/plugins/api-builder.ts @@ -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: diff --git a/src/plugins/memory-state.test.ts b/src/plugins/memory-state.test.ts index d1b048f9c43..c1c37353d20 100644 --- a/src/plugins/memory-state.test.ts +++ b/src/plugins/memory-state.test.ts @@ -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"}`, diff --git a/src/plugins/memory-state.ts b/src/plugins/memory-state.ts index d58dc1d8545..8c8379de97c 100644 --- a/src/plugins/memory-state.ts +++ b/src/plugins/memory-state.ts @@ -109,12 +109,46 @@ export type MemoryPluginRuntime = { closeAllMemorySearchManagers?(): Promise; }; -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; +}; + +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; 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 { + 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 = []; diff --git a/src/plugins/registry.dual-kind-memory-gate.test.ts b/src/plugins/registry.dual-kind-memory-gate.test.ts index 5269664c5a1..14e92a5368a 100644 --- a/src/plugins/registry.dual-kind-memory-gate.test.ts +++ b/src/plugins/registry.dual-kind-memory-gate.test.ts @@ -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(); + }); }); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 3ca621ded7c..bb938e865ab 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -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({ diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 51f641aaa22..64f9b6eaf36 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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: ( diff --git a/test/helpers/plugins/plugin-api.ts b/test/helpers/plugins/plugin-api.ts index c77645fa36a..a43728bfee9 100644 --- a/test/helpers/plugins/plugin-api.ts +++ b/test/helpers/plugins/plugin-api.ts @@ -38,6 +38,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi onConversationBindingResolved() {}, registerCommand() {}, registerContextEngine() {}, + registerMemoryCapability() {}, registerMemoryPromptSection() {}, registerMemoryPromptSupplement() {}, registerMemoryCorpusSupplement() {},