mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
feat(memory-wiki): add belief-layer digests and compat migration
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core";
|
||||
import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory-core";
|
||||
import {
|
||||
resolveMemoryCorePluginConfig,
|
||||
resolveMemoryLightDreamingConfig,
|
||||
resolveMemoryRemDreamingConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-status";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { registerMemoryDreamingPhases } from "./dreaming-phases.js";
|
||||
import { __testing } from "./dreaming-phases.js";
|
||||
import {
|
||||
rankShortTermPromotionCandidates,
|
||||
recordShortTermRecalls,
|
||||
@@ -36,45 +41,55 @@ const LIGHT_DREAMING_TEST_CONFIG: OpenClawConfig = {
|
||||
};
|
||||
|
||||
function createHarness(config: OpenClawConfig, workspaceDir?: string) {
|
||||
let beforeAgentReply:
|
||||
| ((
|
||||
event: { cleanedBody: string },
|
||||
ctx: { trigger?: string; workspaceDir?: string },
|
||||
) => Promise<unknown>)
|
||||
| undefined;
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const api = {
|
||||
config: workspaceDir
|
||||
? {
|
||||
...config,
|
||||
agents: {
|
||||
...config.agents,
|
||||
defaults: {
|
||||
...config.agents?.defaults,
|
||||
workspace: workspaceDir,
|
||||
},
|
||||
const resolvedConfig = workspaceDir
|
||||
? {
|
||||
...config,
|
||||
agents: {
|
||||
...config.agents,
|
||||
defaults: {
|
||||
...config.agents?.defaults,
|
||||
workspace: workspaceDir,
|
||||
},
|
||||
}
|
||||
: config,
|
||||
pluginConfig: {},
|
||||
logger,
|
||||
registerHook: vi.fn(),
|
||||
on: vi.fn((name: string, handler: unknown) => {
|
||||
if (name === "before_agent_reply") {
|
||||
beforeAgentReply = handler as typeof beforeAgentReply;
|
||||
},
|
||||
}
|
||||
}),
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
registerMemoryDreamingPhases(api);
|
||||
if (!beforeAgentReply) {
|
||||
throw new Error("before_agent_reply hook not registered");
|
||||
}
|
||||
: config;
|
||||
const pluginConfig = resolveMemoryCorePluginConfig(resolvedConfig) ?? {};
|
||||
const beforeAgentReply = async (
|
||||
event: { cleanedBody: string },
|
||||
ctx: { trigger?: string; workspaceDir?: string },
|
||||
) => {
|
||||
const light = resolveMemoryLightDreamingConfig({ pluginConfig, cfg: resolvedConfig });
|
||||
const lightResult = await __testing.runPhaseIfTriggered({
|
||||
cleanedBody: event.cleanedBody,
|
||||
trigger: ctx.trigger,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
cfg: resolvedConfig,
|
||||
logger,
|
||||
phase: "light",
|
||||
eventText: __testing.constants.LIGHT_SLEEP_EVENT_TEXT,
|
||||
config: light,
|
||||
});
|
||||
if (lightResult) {
|
||||
return lightResult;
|
||||
}
|
||||
const rem = resolveMemoryRemDreamingConfig({ pluginConfig, cfg: resolvedConfig });
|
||||
return await __testing.runPhaseIfTriggered({
|
||||
cleanedBody: event.cleanedBody,
|
||||
trigger: ctx.trigger,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
cfg: resolvedConfig,
|
||||
logger,
|
||||
phase: "rem",
|
||||
eventText: __testing.constants.REM_SLEEP_EVENT_TEXT,
|
||||
config: rem,
|
||||
});
|
||||
};
|
||||
return { beforeAgentReply, logger };
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
|
||||
import {
|
||||
formatMemoryDreamingDay,
|
||||
resolveMemoryCorePluginConfig,
|
||||
resolveMemoryDreamingWorkspaces,
|
||||
resolveMemoryLightDreamingConfig,
|
||||
resolveMemoryRemDreamingConfig,
|
||||
@@ -30,61 +29,7 @@ import {
|
||||
} from "./short-term-promotion.js";
|
||||
|
||||
type Logger = Pick<OpenClawPluginApi["logger"], "info" | "warn" | "error">;
|
||||
|
||||
type CronSchedule = { kind: "cron"; expr: string; tz?: string };
|
||||
type CronPayload = { kind: "systemEvent"; text: string };
|
||||
type ManagedCronJobCreate = {
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
schedule: CronSchedule;
|
||||
sessionTarget: "main";
|
||||
wakeMode: "next-heartbeat";
|
||||
payload: CronPayload;
|
||||
};
|
||||
|
||||
type ManagedCronJobPatch = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
schedule?: CronSchedule;
|
||||
sessionTarget?: "main";
|
||||
wakeMode?: "next-heartbeat";
|
||||
payload?: CronPayload;
|
||||
};
|
||||
|
||||
type ManagedCronJobLike = {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
schedule?: {
|
||||
kind?: string;
|
||||
expr?: string;
|
||||
tz?: string;
|
||||
};
|
||||
sessionTarget?: string;
|
||||
wakeMode?: string;
|
||||
payload?: {
|
||||
kind?: string;
|
||||
text?: string;
|
||||
};
|
||||
createdAtMs?: number;
|
||||
};
|
||||
|
||||
type CronServiceLike = {
|
||||
list: (opts?: { includeDisabled?: boolean }) => Promise<ManagedCronJobLike[]>;
|
||||
add: (input: ManagedCronJobCreate) => Promise<unknown>;
|
||||
update: (id: string, patch: ManagedCronJobPatch) => Promise<unknown>;
|
||||
remove: (id: string) => Promise<{ removed?: boolean }>;
|
||||
};
|
||||
|
||||
const LIGHT_SLEEP_CRON_NAME = "Memory Light Dreaming";
|
||||
const LIGHT_SLEEP_CRON_TAG = "[managed-by=memory-core.dreaming.light]";
|
||||
const LIGHT_SLEEP_EVENT_TEXT = "__openclaw_memory_core_light_sleep__";
|
||||
|
||||
const REM_SLEEP_CRON_NAME = "Memory REM Dreaming";
|
||||
const REM_SLEEP_CRON_TAG = "[managed-by=memory-core.dreaming.rem]";
|
||||
const REM_SLEEP_EVENT_TEXT = "__openclaw_memory_core_rem_sleep__";
|
||||
const DAILY_MEMORY_FILENAME_RE = /^(\d{4}-\d{2}-\d{2})\.md$/;
|
||||
const DAILY_INGESTION_STATE_RELATIVE_PATH = path.join("memory", ".dreams", "daily-ingestion.json");
|
||||
@@ -121,193 +66,6 @@ const MANAGED_DAILY_DREAMING_BLOCKS = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
function buildCronDescription(params: {
|
||||
tag: string;
|
||||
phase: "light" | "rem";
|
||||
cron: string;
|
||||
limit: number;
|
||||
lookbackDays: number;
|
||||
}): string {
|
||||
return `${params.tag} Run ${params.phase} dreaming (cron=${params.cron}, limit=${params.limit}, lookbackDays=${params.lookbackDays}).`;
|
||||
}
|
||||
|
||||
function buildManagedCronJob(params: {
|
||||
name: string;
|
||||
tag: string;
|
||||
payloadText: string;
|
||||
cron: string;
|
||||
timezone?: string;
|
||||
phase: "light" | "rem";
|
||||
limit: number;
|
||||
lookbackDays: number;
|
||||
}): ManagedCronJobCreate {
|
||||
return {
|
||||
name: params.name,
|
||||
description: buildCronDescription({
|
||||
tag: params.tag,
|
||||
phase: params.phase,
|
||||
cron: params.cron,
|
||||
limit: params.limit,
|
||||
lookbackDays: params.lookbackDays,
|
||||
}),
|
||||
enabled: true,
|
||||
schedule: {
|
||||
kind: "cron",
|
||||
expr: params.cron,
|
||||
...(params.timezone ? { tz: params.timezone } : {}),
|
||||
},
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: {
|
||||
kind: "systemEvent",
|
||||
text: params.payloadText,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isManagedPhaseJob(
|
||||
job: ManagedCronJobLike,
|
||||
params: {
|
||||
name: string;
|
||||
tag: string;
|
||||
payloadText: string;
|
||||
},
|
||||
): boolean {
|
||||
const description = normalizeTrimmedString(job.description);
|
||||
if (description?.includes(params.tag)) {
|
||||
return true;
|
||||
}
|
||||
const name = normalizeTrimmedString(job.name);
|
||||
const payloadText = normalizeTrimmedString(job.payload?.text);
|
||||
return name === params.name && payloadText === params.payloadText;
|
||||
}
|
||||
|
||||
function buildManagedPhasePatch(
|
||||
job: ManagedCronJobLike,
|
||||
desired: ManagedCronJobCreate,
|
||||
): ManagedCronJobPatch | null {
|
||||
const patch: ManagedCronJobPatch = {};
|
||||
const scheduleKind = normalizeTrimmedString(job.schedule?.kind)?.toLowerCase();
|
||||
const scheduleExpr = normalizeTrimmedString(job.schedule?.expr);
|
||||
const scheduleTz = normalizeTrimmedString(job.schedule?.tz);
|
||||
if (normalizeTrimmedString(job.name) !== desired.name) {
|
||||
patch.name = desired.name;
|
||||
}
|
||||
if (normalizeTrimmedString(job.description) !== desired.description) {
|
||||
patch.description = desired.description;
|
||||
}
|
||||
if (job.enabled !== true) {
|
||||
patch.enabled = true;
|
||||
}
|
||||
if (
|
||||
scheduleKind !== "cron" ||
|
||||
scheduleExpr !== desired.schedule.expr ||
|
||||
scheduleTz !== desired.schedule.tz
|
||||
) {
|
||||
patch.schedule = desired.schedule;
|
||||
}
|
||||
if (normalizeTrimmedString(job.sessionTarget)?.toLowerCase() !== "main") {
|
||||
patch.sessionTarget = "main";
|
||||
}
|
||||
if (normalizeTrimmedString(job.wakeMode)?.toLowerCase() !== "next-heartbeat") {
|
||||
patch.wakeMode = "next-heartbeat";
|
||||
}
|
||||
const payloadKind = normalizeTrimmedString(job.payload?.kind)?.toLowerCase();
|
||||
const payloadText = normalizeTrimmedString(job.payload?.text);
|
||||
if (payloadKind !== "systemevent" || payloadText !== desired.payload.text) {
|
||||
patch.payload = desired.payload;
|
||||
}
|
||||
return Object.keys(patch).length > 0 ? patch : null;
|
||||
}
|
||||
|
||||
function sortManagedJobs(managed: ManagedCronJobLike[]): ManagedCronJobLike[] {
|
||||
return managed.toSorted((a, b) => {
|
||||
const aCreated =
|
||||
typeof a.createdAtMs === "number" && Number.isFinite(a.createdAtMs)
|
||||
? a.createdAtMs
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const bCreated =
|
||||
typeof b.createdAtMs === "number" && Number.isFinite(b.createdAtMs)
|
||||
? b.createdAtMs
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
if (aCreated !== bCreated) {
|
||||
return aCreated - bCreated;
|
||||
}
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCronServiceFromStartupEvent(event: unknown): CronServiceLike | null {
|
||||
const payload = asRecord(event);
|
||||
if (!payload || payload.type !== "gateway" || payload.action !== "startup") {
|
||||
return null;
|
||||
}
|
||||
const context = asRecord(payload.context);
|
||||
const deps = asRecord(context?.deps);
|
||||
const cronCandidate = context?.cron ?? deps?.cron;
|
||||
if (!cronCandidate || typeof cronCandidate !== "object") {
|
||||
return null;
|
||||
}
|
||||
const cron = cronCandidate as Partial<CronServiceLike>;
|
||||
if (
|
||||
typeof cron.list !== "function" ||
|
||||
typeof cron.add !== "function" ||
|
||||
typeof cron.update !== "function" ||
|
||||
typeof cron.remove !== "function"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return cron as CronServiceLike;
|
||||
}
|
||||
|
||||
async function reconcileManagedPhaseCronJob(params: {
|
||||
cron: CronServiceLike | null;
|
||||
desired: ManagedCronJobCreate;
|
||||
match: { name: string; tag: string; payloadText: string };
|
||||
enabled: boolean;
|
||||
logger: Logger;
|
||||
}): Promise<void> {
|
||||
const cron = params.cron;
|
||||
if (!cron) {
|
||||
return;
|
||||
}
|
||||
const allJobs = await cron.list({ includeDisabled: true });
|
||||
const managed = allJobs.filter((job) => isManagedPhaseJob(job, params.match));
|
||||
if (!params.enabled) {
|
||||
for (const job of managed) {
|
||||
try {
|
||||
await cron.remove(job.id);
|
||||
} catch (err) {
|
||||
params.logger.warn(
|
||||
`memory-core: failed to remove managed ${params.match.name} cron job ${job.id}: ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (managed.length === 0) {
|
||||
await cron.add(params.desired);
|
||||
return;
|
||||
}
|
||||
|
||||
const [primary, ...duplicates] = sortManagedJobs(managed);
|
||||
for (const duplicate of duplicates) {
|
||||
try {
|
||||
await cron.remove(duplicate.id);
|
||||
} catch (err) {
|
||||
params.logger.warn(
|
||||
`memory-core: failed to prune duplicate managed ${params.match.name} cron job ${duplicate.id}: ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const patch = buildManagedPhasePatch(primary, params.desired);
|
||||
if (patch) {
|
||||
await cron.update(primary.id, patch);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWorkspaces(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
fallbackWorkspaceDir?: string;
|
||||
@@ -1817,94 +1575,18 @@ async function runPhaseIfTriggered(params: {
|
||||
return { handled: true, reason: `memory-core: ${params.phase} dreaming processed` };
|
||||
}
|
||||
|
||||
export function registerMemoryDreamingPhases(api: OpenClawPluginApi): void {
|
||||
api.registerHook(
|
||||
"gateway:startup",
|
||||
async (event: unknown) => {
|
||||
const cron = resolveCronServiceFromStartupEvent(event);
|
||||
const pluginConfig = resolveMemoryCorePluginConfig(api.config) ?? api.pluginConfig;
|
||||
const light = resolveMemoryLightDreamingConfig({ pluginConfig, cfg: api.config });
|
||||
const rem = resolveMemoryRemDreamingConfig({ pluginConfig, cfg: api.config });
|
||||
const lightDesired = buildManagedCronJob({
|
||||
name: LIGHT_SLEEP_CRON_NAME,
|
||||
tag: LIGHT_SLEEP_CRON_TAG,
|
||||
payloadText: LIGHT_SLEEP_EVENT_TEXT,
|
||||
cron: light.cron,
|
||||
timezone: light.timezone,
|
||||
phase: "light",
|
||||
limit: light.limit,
|
||||
lookbackDays: light.lookbackDays,
|
||||
});
|
||||
const remDesired = buildManagedCronJob({
|
||||
name: REM_SLEEP_CRON_NAME,
|
||||
tag: REM_SLEEP_CRON_TAG,
|
||||
payloadText: REM_SLEEP_EVENT_TEXT,
|
||||
cron: rem.cron,
|
||||
timezone: rem.timezone,
|
||||
phase: "rem",
|
||||
limit: rem.limit,
|
||||
lookbackDays: rem.lookbackDays,
|
||||
});
|
||||
try {
|
||||
await reconcileManagedPhaseCronJob({
|
||||
cron,
|
||||
desired: lightDesired,
|
||||
match: {
|
||||
name: LIGHT_SLEEP_CRON_NAME,
|
||||
tag: LIGHT_SLEEP_CRON_TAG,
|
||||
payloadText: LIGHT_SLEEP_EVENT_TEXT,
|
||||
},
|
||||
enabled: light.enabled,
|
||||
logger: api.logger,
|
||||
});
|
||||
await reconcileManagedPhaseCronJob({
|
||||
cron,
|
||||
desired: remDesired,
|
||||
match: {
|
||||
name: REM_SLEEP_CRON_NAME,
|
||||
tag: REM_SLEEP_CRON_TAG,
|
||||
payloadText: REM_SLEEP_EVENT_TEXT,
|
||||
},
|
||||
enabled: rem.enabled,
|
||||
logger: api.logger,
|
||||
});
|
||||
} catch (err) {
|
||||
api.logger.error(
|
||||
`memory-core: dreaming startup reconciliation failed: ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
{ name: "memory-core-dreaming-phase-cron" },
|
||||
);
|
||||
|
||||
api.on("before_agent_reply", async (event, ctx) => {
|
||||
const pluginConfig = resolveMemoryCorePluginConfig(api.config) ?? api.pluginConfig;
|
||||
const light = resolveMemoryLightDreamingConfig({ pluginConfig, cfg: api.config });
|
||||
const lightResult = await runPhaseIfTriggered({
|
||||
cleanedBody: event.cleanedBody,
|
||||
trigger: ctx.trigger,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
cfg: api.config,
|
||||
logger: api.logger,
|
||||
subagent: light.enabled ? api.runtime?.subagent : undefined,
|
||||
phase: "light",
|
||||
eventText: LIGHT_SLEEP_EVENT_TEXT,
|
||||
config: light,
|
||||
});
|
||||
if (lightResult) {
|
||||
return lightResult;
|
||||
}
|
||||
const rem = resolveMemoryRemDreamingConfig({ pluginConfig, cfg: api.config });
|
||||
return await runPhaseIfTriggered({
|
||||
cleanedBody: event.cleanedBody,
|
||||
trigger: ctx.trigger,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
cfg: api.config,
|
||||
logger: api.logger,
|
||||
subagent: rem.enabled ? api.runtime?.subagent : undefined,
|
||||
phase: "rem",
|
||||
eventText: REM_SLEEP_EVENT_TEXT,
|
||||
config: rem,
|
||||
});
|
||||
});
|
||||
/**
|
||||
* @deprecated Unified dreaming registration lives in registerShortTermPromotionDreaming().
|
||||
*/
|
||||
export function registerMemoryDreamingPhases(_api: OpenClawPluginApi): void {
|
||||
// LEGACY(memory-v1): kept as a no-op compatibility shim while the unified
|
||||
// dreaming controller owns startup reconciliation and heartbeat triggers.
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
runPhaseIfTriggered,
|
||||
constants: {
|
||||
LIGHT_SLEEP_EVENT_TEXT,
|
||||
REM_SLEEP_EVENT_TEXT,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -104,6 +104,8 @@ type ReconcileResult =
|
||||
| { status: "updated"; removed: number }
|
||||
| { status: "noop"; removed: number };
|
||||
|
||||
type LegacyPhaseMigrationMode = "enabled" | "disabled";
|
||||
|
||||
function formatRepairSummary(repair: {
|
||||
rewroteStore: boolean;
|
||||
removedInvalidEntries: number;
|
||||
@@ -178,6 +180,39 @@ function compareOptionalStrings(a: string | undefined, b: string | undefined): b
|
||||
return a === b;
|
||||
}
|
||||
|
||||
async function migrateLegacyPhaseDreamingCronJobs(params: {
|
||||
cron: CronServiceLike;
|
||||
legacyJobs: ManagedCronJobLike[];
|
||||
logger: Logger;
|
||||
mode: LegacyPhaseMigrationMode;
|
||||
}): Promise<number> {
|
||||
let migrated = 0;
|
||||
for (const job of params.legacyJobs) {
|
||||
try {
|
||||
const result = await params.cron.remove(job.id);
|
||||
if (result.removed === true) {
|
||||
migrated += 1;
|
||||
}
|
||||
} catch (err) {
|
||||
params.logger.warn(
|
||||
`memory-core: failed to migrate legacy phase dreaming cron job ${job.id}: ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (migrated > 0) {
|
||||
if (params.mode === "enabled") {
|
||||
params.logger.info(
|
||||
`memory-core: migrated ${migrated} legacy phase dreaming cron job(s) to the unified dreaming controller.`,
|
||||
);
|
||||
} else {
|
||||
params.logger.info(
|
||||
`memory-core: completed legacy phase dreaming cron migration while unified dreaming is disabled (${migrated} job(s) removed).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return migrated;
|
||||
}
|
||||
|
||||
function buildManagedDreamingPatch(
|
||||
job: ManagedCronJobLike,
|
||||
desired: ManagedCronJobCreate,
|
||||
@@ -300,25 +335,13 @@ export async function reconcileShortTermDreamingCronJob(params: {
|
||||
const managed = allJobs.filter(isManagedDreamingJob);
|
||||
const legacyPhaseJobs = allJobs.filter(isLegacyPhaseDreamingJob);
|
||||
|
||||
let removedLegacy = 0;
|
||||
for (const job of legacyPhaseJobs) {
|
||||
try {
|
||||
const result = await cron.remove(job.id);
|
||||
if (result.removed === true) {
|
||||
removedLegacy += 1;
|
||||
}
|
||||
} catch (err) {
|
||||
params.logger.warn(
|
||||
`memory-core: failed to remove legacy managed dreaming cron job ${job.id}: ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (removedLegacy > 0) {
|
||||
params.logger.info(`memory-core: removed ${removedLegacy} legacy phase dreaming cron job(s).`);
|
||||
}
|
||||
|
||||
if (!params.config.enabled) {
|
||||
let removed = removedLegacy;
|
||||
let removed = await migrateLegacyPhaseDreamingCronJobs({
|
||||
cron,
|
||||
legacyJobs: legacyPhaseJobs,
|
||||
logger: params.logger,
|
||||
mode: "disabled",
|
||||
});
|
||||
for (const job of managed) {
|
||||
try {
|
||||
const result = await cron.remove(job.id);
|
||||
@@ -340,12 +363,23 @@ export async function reconcileShortTermDreamingCronJob(params: {
|
||||
const desired = buildManagedDreamingCronJob(params.config);
|
||||
if (managed.length === 0) {
|
||||
await cron.add(desired);
|
||||
const migratedLegacy = await migrateLegacyPhaseDreamingCronJobs({
|
||||
cron,
|
||||
legacyJobs: legacyPhaseJobs,
|
||||
logger: params.logger,
|
||||
mode: "enabled",
|
||||
});
|
||||
params.logger.info("memory-core: created managed dreaming cron job.");
|
||||
return { status: "added", removed: removedLegacy };
|
||||
return { status: "added", removed: migratedLegacy };
|
||||
}
|
||||
|
||||
const [primary, ...duplicates] = sortManagedJobs(managed);
|
||||
let removed = removedLegacy;
|
||||
let removed = await migrateLegacyPhaseDreamingCronJobs({
|
||||
cron,
|
||||
legacyJobs: legacyPhaseJobs,
|
||||
logger: params.logger,
|
||||
mode: "enabled",
|
||||
});
|
||||
for (const duplicate of duplicates) {
|
||||
try {
|
||||
const result = await cron.remove(duplicate.id);
|
||||
|
||||
97
extensions/memory-core/src/public-artifacts.test.ts
Normal file
97
extensions/memory-core/src/public-artifacts.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
appendMemoryHostEvent,
|
||||
resolveMemoryHostEventLogPath,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-events";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
import { listMemoryCorePublicArtifacts } from "./public-artifacts.js";
|
||||
|
||||
describe("listMemoryCorePublicArtifacts", () => {
|
||||
let fixtureRoot = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-core-public-artifacts-"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!fixtureRoot) {
|
||||
return;
|
||||
}
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("lists public workspace artifacts with stable kinds", async () => {
|
||||
const workspaceDir = path.join(fixtureRoot, "workspace");
|
||||
await fs.mkdir(path.join(workspaceDir, "memory", "dreaming"), { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# Durable Memory\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, "memory", "2026-04-06.md"),
|
||||
"# Daily Note\n",
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, "memory", "dreaming", "2026-04-06.md"),
|
||||
"# Dream Report\n",
|
||||
"utf8",
|
||||
);
|
||||
await appendMemoryHostEvent(workspaceDir, {
|
||||
type: "memory.recall.recorded",
|
||||
timestamp: "2026-04-06T12:00:00.000Z",
|
||||
query: "alpha",
|
||||
resultCount: 0,
|
||||
results: [],
|
||||
});
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [{ id: "main", default: true, workspace: workspaceDir }],
|
||||
},
|
||||
};
|
||||
|
||||
await expect(listMemoryCorePublicArtifacts({ cfg })).resolves.toEqual([
|
||||
{
|
||||
kind: "memory-root",
|
||||
workspaceDir,
|
||||
relativePath: "MEMORY.md",
|
||||
absolutePath: path.join(workspaceDir, "MEMORY.md"),
|
||||
agentIds: ["main"],
|
||||
contentType: "markdown",
|
||||
},
|
||||
{
|
||||
kind: "memory-root",
|
||||
workspaceDir,
|
||||
relativePath: "memory.md",
|
||||
absolutePath: path.join(workspaceDir, "memory.md"),
|
||||
agentIds: ["main"],
|
||||
contentType: "markdown",
|
||||
},
|
||||
{
|
||||
kind: "daily-note",
|
||||
workspaceDir,
|
||||
relativePath: "memory/2026-04-06.md",
|
||||
absolutePath: path.join(workspaceDir, "memory", "2026-04-06.md"),
|
||||
agentIds: ["main"],
|
||||
contentType: "markdown",
|
||||
},
|
||||
{
|
||||
kind: "dream-report",
|
||||
workspaceDir,
|
||||
relativePath: "memory/dreaming/2026-04-06.md",
|
||||
absolutePath: path.join(workspaceDir, "memory", "dreaming", "2026-04-06.md"),
|
||||
agentIds: ["main"],
|
||||
contentType: "markdown",
|
||||
},
|
||||
{
|
||||
kind: "event-log",
|
||||
workspaceDir,
|
||||
relativePath: "memory/.dreams/events.jsonl",
|
||||
absolutePath: resolveMemoryHostEventLogPath(workspaceDir),
|
||||
agentIds: ["main"],
|
||||
contentType: "json",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
98
extensions/memory-core/src/public-artifacts.ts
Normal file
98
extensions/memory-core/src/public-artifacts.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolveMemoryHostEventLogPath } from "openclaw/plugin-sdk/memory-core-host-events";
|
||||
import { resolveMemoryDreamingWorkspaces } from "openclaw/plugin-sdk/memory-core-host-status";
|
||||
import type { MemoryPluginPublicArtifact } from "openclaw/plugin-sdk/memory-host-core";
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
|
||||
async function pathExists(inputPath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(inputPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function listMarkdownFilesRecursive(rootDir: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(rootDir, { withFileTypes: true }).catch(() => []);
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(rootDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await listMarkdownFilesRecursive(fullPath)));
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name.endsWith(".md")) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
async function collectWorkspaceArtifacts(params: {
|
||||
workspaceDir: string;
|
||||
agentIds: string[];
|
||||
}): Promise<MemoryPluginPublicArtifact[]> {
|
||||
const artifacts: MemoryPluginPublicArtifact[] = [];
|
||||
for (const relativePath of ["MEMORY.md", "memory.md"]) {
|
||||
const absolutePath = path.join(params.workspaceDir, relativePath);
|
||||
if (await pathExists(absolutePath)) {
|
||||
artifacts.push({
|
||||
kind: "memory-root",
|
||||
workspaceDir: params.workspaceDir,
|
||||
relativePath,
|
||||
absolutePath,
|
||||
agentIds: [...params.agentIds],
|
||||
contentType: "markdown",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const memoryDir = path.join(params.workspaceDir, "memory");
|
||||
for (const absolutePath of await listMarkdownFilesRecursive(memoryDir)) {
|
||||
const relativePath = path.relative(params.workspaceDir, absolutePath).replace(/\\/g, "/");
|
||||
artifacts.push({
|
||||
kind: relativePath.startsWith("memory/dreaming/") ? "dream-report" : "daily-note",
|
||||
workspaceDir: params.workspaceDir,
|
||||
relativePath,
|
||||
absolutePath,
|
||||
agentIds: [...params.agentIds],
|
||||
contentType: "markdown",
|
||||
});
|
||||
}
|
||||
|
||||
const eventLogPath = resolveMemoryHostEventLogPath(params.workspaceDir);
|
||||
if (await pathExists(eventLogPath)) {
|
||||
artifacts.push({
|
||||
kind: "event-log",
|
||||
workspaceDir: params.workspaceDir,
|
||||
relativePath: path.relative(params.workspaceDir, eventLogPath).replace(/\\/g, "/"),
|
||||
absolutePath: eventLogPath,
|
||||
agentIds: [...params.agentIds],
|
||||
contentType: "json",
|
||||
});
|
||||
}
|
||||
|
||||
const deduped = new Map<string, MemoryPluginPublicArtifact>();
|
||||
for (const artifact of artifacts) {
|
||||
deduped.set(`${artifact.workspaceDir}\0${artifact.relativePath}\0${artifact.kind}`, artifact);
|
||||
}
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
export async function listMemoryCorePublicArtifacts(params: {
|
||||
cfg: OpenClawConfig;
|
||||
}): Promise<MemoryPluginPublicArtifact[]> {
|
||||
const workspaces = resolveMemoryDreamingWorkspaces(params.cfg);
|
||||
const artifacts: MemoryPluginPublicArtifact[] = [];
|
||||
for (const workspace of workspaces) {
|
||||
artifacts.push(
|
||||
...(await collectWorkspaceArtifacts({
|
||||
workspaceDir: workspace.workspaceDir,
|
||||
agentIds: workspace.agentIds,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return artifacts;
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
1
extensions/memory-wiki/contract-api.ts
Normal file
1
extensions/memory-wiki/contract-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/config-compat.js";
|
||||
@@ -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);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
11
extensions/memory-wiki/setup-api.ts
Normal file
11
extensions/memory-wiki/setup-api.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { definePluginEntry } from "./api.js";
|
||||
import { migrateMemoryWikiLegacyConfig } from "./src/config-compat.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "memory-wiki",
|
||||
name: "Memory Wiki Setup",
|
||||
description: "Lightweight Memory Wiki setup hooks",
|
||||
register(api) {
|
||||
api.registerConfigMigration((config) => migrateMemoryWikiLegacyConfig(config));
|
||||
},
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolveMemoryHostEventLogPath } from "openclaw/plugin-sdk/memory-host-events";
|
||||
import {
|
||||
resolveMemoryCorePluginConfig,
|
||||
resolveMemoryDreamingWorkspaces,
|
||||
} from "openclaw/plugin-sdk/memory-host-status";
|
||||
listActiveMemoryPublicArtifacts,
|
||||
type MemoryPluginPublicArtifact,
|
||||
} from "openclaw/plugin-sdk/memory-host-core";
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
import type { ResolvedMemoryWikiConfig } from "./config.js";
|
||||
import { appendMemoryWikiLog } from "./log.js";
|
||||
import { renderMarkdownFence, renderWikiMarkdown, slugifyWikiSegment } from "./markdown.js";
|
||||
import { writeImportedSourcePage } from "./source-page-shared.js";
|
||||
import { pathExists, resolveArtifactKey } from "./source-path-shared.js";
|
||||
import { resolveArtifactKey } from "./source-path-shared.js";
|
||||
import {
|
||||
pruneImportedSourceEntries,
|
||||
readMemoryWikiSourceSyncState,
|
||||
@@ -37,93 +36,44 @@ export type BridgeMemoryWikiResult = {
|
||||
pagePaths: string[];
|
||||
};
|
||||
|
||||
async function listMarkdownFilesRecursive(rootDir: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(rootDir, { withFileTypes: true }).catch(() => []);
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(rootDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await listMarkdownFilesRecursive(fullPath)));
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name.endsWith(".md")) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
function shouldImportArtifact(
|
||||
artifact: MemoryPluginPublicArtifact,
|
||||
bridgeConfig: ResolvedMemoryWikiConfig["bridge"],
|
||||
): boolean {
|
||||
switch (artifact.kind) {
|
||||
case "memory-root":
|
||||
return bridgeConfig.indexMemoryRoot;
|
||||
case "daily-note":
|
||||
return bridgeConfig.indexDailyNotes;
|
||||
case "dream-report":
|
||||
return bridgeConfig.indexDreamReports;
|
||||
case "event-log":
|
||||
return bridgeConfig.followMemoryEvents;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return files.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
async function collectWorkspaceArtifacts(
|
||||
workspaceDir: string,
|
||||
async function collectBridgeArtifacts(
|
||||
bridgeConfig: ResolvedMemoryWikiConfig["bridge"],
|
||||
artifacts: MemoryPluginPublicArtifact[],
|
||||
): Promise<BridgeArtifact[]> {
|
||||
const artifacts: BridgeArtifact[] = [];
|
||||
if (bridgeConfig.indexMemoryRoot) {
|
||||
for (const relPath of ["MEMORY.md", "memory.md"]) {
|
||||
const absolutePath = path.join(workspaceDir, relPath);
|
||||
if (await pathExists(absolutePath)) {
|
||||
const syncKey = await resolveArtifactKey(absolutePath);
|
||||
artifacts.push({
|
||||
syncKey,
|
||||
artifactType: "markdown",
|
||||
workspaceDir,
|
||||
relativePath: relPath,
|
||||
absolutePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bridgeConfig.indexDailyNotes) {
|
||||
const memoryDir = path.join(workspaceDir, "memory");
|
||||
const files = await listMarkdownFilesRecursive(memoryDir);
|
||||
for (const absolutePath of files) {
|
||||
const relativePath = path.relative(workspaceDir, absolutePath).replace(/\\/g, "/");
|
||||
if (!relativePath.startsWith("memory/dreaming/")) {
|
||||
const syncKey = await resolveArtifactKey(absolutePath);
|
||||
artifacts.push({
|
||||
syncKey,
|
||||
artifactType: "markdown",
|
||||
workspaceDir,
|
||||
relativePath,
|
||||
absolutePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bridgeConfig.indexDreamReports) {
|
||||
const dreamingDir = path.join(workspaceDir, "memory", "dreaming");
|
||||
const files = await listMarkdownFilesRecursive(dreamingDir);
|
||||
for (const absolutePath of files) {
|
||||
const relativePath = path.relative(workspaceDir, absolutePath).replace(/\\/g, "/");
|
||||
const syncKey = await resolveArtifactKey(absolutePath);
|
||||
artifacts.push({
|
||||
syncKey,
|
||||
artifactType: "markdown",
|
||||
workspaceDir,
|
||||
relativePath,
|
||||
absolutePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (bridgeConfig.followMemoryEvents) {
|
||||
const eventLogPath = resolveMemoryHostEventLogPath(workspaceDir);
|
||||
if (await pathExists(eventLogPath)) {
|
||||
const syncKey = await resolveArtifactKey(eventLogPath);
|
||||
artifacts.push({
|
||||
syncKey,
|
||||
artifactType: "memory-events",
|
||||
workspaceDir,
|
||||
relativePath: path.relative(workspaceDir, eventLogPath).replace(/\\/g, "/"),
|
||||
absolutePath: eventLogPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const deduped = new Map<string, BridgeArtifact>();
|
||||
const collected: BridgeArtifact[] = [];
|
||||
for (const artifact of artifacts) {
|
||||
if (!shouldImportArtifact(artifact, bridgeConfig)) {
|
||||
continue;
|
||||
}
|
||||
const syncKey = await resolveArtifactKey(artifact.absolutePath);
|
||||
collected.push({
|
||||
syncKey,
|
||||
artifactType: artifact.kind === "event-log" ? "memory-events" : "markdown",
|
||||
workspaceDir: artifact.workspaceDir,
|
||||
relativePath: artifact.relativePath,
|
||||
absolutePath: artifact.absolutePath,
|
||||
});
|
||||
}
|
||||
const deduped = new Map<string, BridgeArtifact>();
|
||||
for (const artifact of collected) {
|
||||
deduped.set(artifact.syncKey, artifact);
|
||||
}
|
||||
return [...deduped.values()];
|
||||
@@ -253,7 +203,7 @@ export async function syncMemoryWikiBridgeSources(params: {
|
||||
if (
|
||||
params.config.vaultMode !== "bridge" ||
|
||||
!params.config.bridge.enabled ||
|
||||
!params.config.bridge.readMemoryCore ||
|
||||
!params.config.bridge.readMemoryArtifacts ||
|
||||
!params.appConfig
|
||||
) {
|
||||
return {
|
||||
@@ -267,42 +217,32 @@ export async function syncMemoryWikiBridgeSources(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const memoryPluginConfig = resolveMemoryCorePluginConfig(params.appConfig);
|
||||
if (!memoryPluginConfig) {
|
||||
return {
|
||||
importedCount: 0,
|
||||
updatedCount: 0,
|
||||
skippedCount: 0,
|
||||
removedCount: 0,
|
||||
artifactCount: 0,
|
||||
workspaces: 0,
|
||||
pagePaths: [],
|
||||
};
|
||||
}
|
||||
|
||||
const workspaces = resolveMemoryDreamingWorkspaces(params.appConfig);
|
||||
const publicArtifacts = await listActiveMemoryPublicArtifacts({ cfg: params.appConfig });
|
||||
const state = await readMemoryWikiSourceSyncState(params.config.vault.path);
|
||||
const results: Array<{ pagePath: string; changed: boolean; created: boolean }> = [];
|
||||
let artifactCount = 0;
|
||||
const activeKeys = new Set<string>();
|
||||
for (const workspace of workspaces) {
|
||||
const artifacts = await collectWorkspaceArtifacts(workspace.workspaceDir, params.config.bridge);
|
||||
artifactCount += artifacts.length;
|
||||
for (const artifact of artifacts) {
|
||||
const stats = await fs.stat(artifact.absolutePath);
|
||||
activeKeys.add(artifact.syncKey);
|
||||
results.push(
|
||||
await writeBridgeSourcePage({
|
||||
config: params.config,
|
||||
artifact,
|
||||
agentIds: workspace.agentIds,
|
||||
sourceUpdatedAtMs: stats.mtimeMs,
|
||||
sourceSize: stats.size,
|
||||
state,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const artifacts = await collectBridgeArtifacts(params.config.bridge, publicArtifacts);
|
||||
const agentIdsByWorkspace = new Map<string, string[]>();
|
||||
for (const artifact of publicArtifacts) {
|
||||
agentIdsByWorkspace.set(artifact.workspaceDir, artifact.agentIds);
|
||||
}
|
||||
artifactCount = artifacts.length;
|
||||
for (const artifact of artifacts) {
|
||||
const stats = await fs.stat(artifact.absolutePath);
|
||||
activeKeys.add(artifact.syncKey);
|
||||
results.push(
|
||||
await writeBridgeSourcePage({
|
||||
config: params.config,
|
||||
artifact,
|
||||
agentIds: agentIdsByWorkspace.get(artifact.workspaceDir) ?? [],
|
||||
sourceUpdatedAtMs: stats.mtimeMs,
|
||||
sourceSize: stats.size,
|
||||
state,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const workspaceCount = new Set(publicArtifacts.map((artifact) => artifact.workspaceDir)).size;
|
||||
|
||||
const removedCount = await pruneImportedSourceEntries({
|
||||
vaultRoot: params.config.vault.path,
|
||||
@@ -324,7 +264,7 @@ export async function syncMemoryWikiBridgeSources(params: {
|
||||
timestamp: new Date().toISOString(),
|
||||
details: {
|
||||
sourceType: "memory-bridge",
|
||||
workspaces: workspaces.length,
|
||||
workspaces: workspaceCount,
|
||||
artifactCount,
|
||||
importedCount,
|
||||
updatedCount,
|
||||
@@ -340,7 +280,7 @@ export async function syncMemoryWikiBridgeSources(params: {
|
||||
skippedCount,
|
||||
removedCount,
|
||||
artifactCount,
|
||||
workspaces: workspaces.length,
|
||||
workspaces: workspaceCount,
|
||||
pagePaths,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -240,7 +240,9 @@ export async function runWikiStatus(params: {
|
||||
stdout?: Pick<NodeJS.WriteStream, "write">;
|
||||
}) {
|
||||
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
|
||||
const status = await resolveMemoryWikiStatus(params.config);
|
||||
const status = await resolveMemoryWikiStatus(params.config, {
|
||||
appConfig: params.appConfig,
|
||||
});
|
||||
writeOutput(
|
||||
params.json ? JSON.stringify(status, null, 2) : renderMemoryWikiStatus(status),
|
||||
params.stdout,
|
||||
@@ -255,7 +257,11 @@ export async function runWikiDoctor(params: {
|
||||
stdout?: Pick<NodeJS.WriteStream, "write">;
|
||||
}) {
|
||||
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
|
||||
const report = buildMemoryWikiDoctorReport(await resolveMemoryWikiStatus(params.config));
|
||||
const report = buildMemoryWikiDoctorReport(
|
||||
await resolveMemoryWikiStatus(params.config, {
|
||||
appConfig: params.appConfig,
|
||||
}),
|
||||
);
|
||||
if (!report.healthy) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -738,10 +744,10 @@ export function registerWikiCli(
|
||||
|
||||
const bridge = wiki
|
||||
.command("bridge")
|
||||
.description("Import public memory-core artifacts into the wiki vault");
|
||||
.description("Import public memory artifacts into the wiki vault");
|
||||
bridge
|
||||
.command("import")
|
||||
.description("Sync bridge-backed memory-core artifacts into wiki source pages")
|
||||
.description("Sync bridge-backed memory artifacts into wiki source pages")
|
||||
.option("--json", "Print JSON")
|
||||
.action(async (opts: WikiBridgeImportCommandOptions) => {
|
||||
await runWikiBridgeImport({ config, appConfig, json: opts.json });
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
parseWikiMarkdown,
|
||||
renderWikiMarkdown,
|
||||
toWikiPageSummary,
|
||||
type WikiClaim,
|
||||
type WikiPageKind,
|
||||
type WikiPageSummary,
|
||||
WIKI_RELATED_END_MARKER,
|
||||
@@ -26,6 +27,8 @@ const COMPILE_PAGE_GROUPS: Array<{ kind: WikiPageKind; dir: string; heading: str
|
||||
{ kind: "report", dir: "reports", heading: "Reports" },
|
||||
];
|
||||
const DASHBOARD_STALE_PAGE_DAYS = 30;
|
||||
const AGENT_DIGEST_PATH = ".openclaw-wiki/cache/agent-digest.json";
|
||||
const CLAIMS_DIGEST_PATH = ".openclaw-wiki/cache/claims.jsonl";
|
||||
|
||||
type DashboardPageDefinition = {
|
||||
id: string;
|
||||
@@ -152,6 +155,7 @@ export type CompileMemoryWikiResult = {
|
||||
vaultRoot: string;
|
||||
pageCounts: Record<WikiPageKind, number>;
|
||||
pages: WikiPageSummary[];
|
||||
claimCount: number;
|
||||
updatedFiles: string[];
|
||||
};
|
||||
|
||||
@@ -509,9 +513,11 @@ function buildRootIndexBody(params: {
|
||||
pages: WikiPageSummary[];
|
||||
counts: Record<WikiPageKind, number>;
|
||||
}): string {
|
||||
const claimCount = params.pages.reduce((total, page) => total + page.claims.length, 0);
|
||||
const lines = [
|
||||
`- Render mode: \`${params.config.vault.renderMode}\``,
|
||||
`- Total pages: ${params.pages.length}`,
|
||||
`- Claims: ${claimCount}`,
|
||||
`- Sources: ${params.counts.source}`,
|
||||
`- Entities: ${params.counts.entity}`,
|
||||
`- Concepts: ${params.counts.concept}`,
|
||||
@@ -545,6 +551,143 @@ function buildDirectoryIndexBody(params: {
|
||||
});
|
||||
}
|
||||
|
||||
type AgentDigestClaim = {
|
||||
id?: string;
|
||||
text: string;
|
||||
status: string;
|
||||
confidence?: number;
|
||||
evidenceCount: number;
|
||||
evidence: WikiClaim["evidence"];
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
type AgentDigestPage = {
|
||||
id?: string;
|
||||
title: string;
|
||||
kind: WikiPageKind;
|
||||
path: string;
|
||||
sourceIds: string[];
|
||||
questions: string[];
|
||||
contradictions: string[];
|
||||
confidence?: number;
|
||||
updatedAt?: string;
|
||||
claimCount: number;
|
||||
topClaims: AgentDigestClaim[];
|
||||
};
|
||||
|
||||
type AgentDigest = {
|
||||
pageCounts: Record<WikiPageKind, number>;
|
||||
claimCount: number;
|
||||
pages: AgentDigestPage[];
|
||||
};
|
||||
|
||||
function normalizeClaimStatus(claim: WikiClaim): string {
|
||||
return claim.status?.trim() || "supported";
|
||||
}
|
||||
|
||||
function sortClaims(claims: WikiClaim[]): WikiClaim[] {
|
||||
return [...claims].toSorted((left, right) => {
|
||||
const leftConfidence = left.confidence ?? -1;
|
||||
const rightConfidence = right.confidence ?? -1;
|
||||
if (leftConfidence !== rightConfidence) {
|
||||
return rightConfidence - leftConfidence;
|
||||
}
|
||||
return left.text.localeCompare(right.text);
|
||||
});
|
||||
}
|
||||
|
||||
function buildAgentDigest(params: {
|
||||
pages: WikiPageSummary[];
|
||||
pageCounts: Record<WikiPageKind, number>;
|
||||
}): AgentDigest {
|
||||
const pages = [...params.pages]
|
||||
.toSorted((left, right) => left.relativePath.localeCompare(right.relativePath))
|
||||
.map((page) => ({
|
||||
...(page.id ? { id: page.id } : {}),
|
||||
title: page.title,
|
||||
kind: page.kind,
|
||||
path: page.relativePath,
|
||||
sourceIds: [...page.sourceIds],
|
||||
questions: [...page.questions],
|
||||
contradictions: [...page.contradictions],
|
||||
...(typeof page.confidence === "number" ? { confidence: page.confidence } : {}),
|
||||
...(page.updatedAt ? { updatedAt: page.updatedAt } : {}),
|
||||
claimCount: page.claims.length,
|
||||
topClaims: sortClaims(page.claims)
|
||||
.slice(0, 5)
|
||||
.map((claim) => ({
|
||||
...(claim.id ? { id: claim.id } : {}),
|
||||
text: claim.text,
|
||||
status: normalizeClaimStatus(claim),
|
||||
...(typeof claim.confidence === "number" ? { confidence: claim.confidence } : {}),
|
||||
evidenceCount: claim.evidence.length,
|
||||
evidence: [...claim.evidence],
|
||||
...(claim.updatedAt ? { updatedAt: claim.updatedAt } : {}),
|
||||
})),
|
||||
}));
|
||||
return {
|
||||
pageCounts: params.pageCounts,
|
||||
claimCount: params.pages.reduce((total, page) => total + page.claims.length, 0),
|
||||
pages,
|
||||
};
|
||||
}
|
||||
|
||||
function buildClaimsDigestLines(params: { pages: WikiPageSummary[] }): string[] {
|
||||
return params.pages
|
||||
.flatMap((page) =>
|
||||
sortClaims(page.claims).map((claim) =>
|
||||
JSON.stringify({
|
||||
...(claim.id ? { id: claim.id } : {}),
|
||||
pageId: page.id,
|
||||
pageTitle: page.title,
|
||||
pageKind: page.kind,
|
||||
pagePath: page.relativePath,
|
||||
text: claim.text,
|
||||
status: normalizeClaimStatus(claim),
|
||||
confidence: claim.confidence,
|
||||
sourceIds: page.sourceIds,
|
||||
evidence: claim.evidence,
|
||||
updatedAt: claim.updatedAt ?? page.updatedAt,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
async function writeAgentDigestArtifacts(params: {
|
||||
rootDir: string;
|
||||
pages: WikiPageSummary[];
|
||||
pageCounts: Record<WikiPageKind, number>;
|
||||
}): Promise<string[]> {
|
||||
const updatedFiles: string[] = [];
|
||||
const agentDigestPath = path.join(params.rootDir, AGENT_DIGEST_PATH);
|
||||
const claimsDigestPath = path.join(params.rootDir, CLAIMS_DIGEST_PATH);
|
||||
const agentDigest = `${JSON.stringify(
|
||||
buildAgentDigest({
|
||||
pages: params.pages,
|
||||
pageCounts: params.pageCounts,
|
||||
}),
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
const claimsDigest = withTrailingNewline(
|
||||
buildClaimsDigestLines({ pages: params.pages }).join("\n"),
|
||||
);
|
||||
|
||||
for (const [filePath, content] of [
|
||||
[agentDigestPath, agentDigest],
|
||||
[claimsDigestPath, claimsDigest],
|
||||
] as const) {
|
||||
const existing = await fs.readFile(filePath, "utf8").catch(() => "");
|
||||
if (existing === content) {
|
||||
continue;
|
||||
}
|
||||
await fs.writeFile(filePath, content, "utf8");
|
||||
updatedFiles.push(filePath);
|
||||
}
|
||||
return updatedFiles;
|
||||
}
|
||||
|
||||
export async function compileMemoryWikiVault(
|
||||
config: ResolvedMemoryWikiConfig,
|
||||
): Promise<CompileMemoryWikiResult> {
|
||||
@@ -561,6 +704,12 @@ export async function compileMemoryWikiVault(
|
||||
pages = await readPageSummaries(rootDir);
|
||||
}
|
||||
const counts = buildPageCounts(pages);
|
||||
const digestUpdatedFiles = await writeAgentDigestArtifacts({
|
||||
rootDir,
|
||||
pages,
|
||||
pageCounts: counts,
|
||||
});
|
||||
updatedFiles.push(...digestUpdatedFiles);
|
||||
|
||||
const rootIndexPath = path.join(rootDir, "index.md");
|
||||
if (
|
||||
@@ -605,6 +754,7 @@ export async function compileMemoryWikiVault(
|
||||
vaultRoot: rootDir,
|
||||
pageCounts: counts,
|
||||
pages,
|
||||
claimCount: pages.reduce((total, page) => total + page.claims.length, 0),
|
||||
updatedFiles,
|
||||
};
|
||||
}
|
||||
|
||||
82
extensions/memory-wiki/src/config-compat.test.ts
Normal file
82
extensions/memory-wiki/src/config-compat.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
import {
|
||||
legacyConfigRules,
|
||||
migrateMemoryWikiLegacyConfig,
|
||||
normalizeCompatibilityConfig,
|
||||
} from "./config-compat.js";
|
||||
|
||||
describe("memory-wiki config compatibility", () => {
|
||||
it("detects the legacy bridge artifact toggle", () => {
|
||||
expect(
|
||||
legacyConfigRules[0]?.match({
|
||||
readMemoryCore: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("migrates readMemoryCore to readMemoryArtifacts", () => {
|
||||
const config = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-wiki": {
|
||||
config: {
|
||||
bridge: {
|
||||
enabled: true,
|
||||
readMemoryCore: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const migration = migrateMemoryWikiLegacyConfig(config);
|
||||
|
||||
expect(migration?.changes).toEqual([
|
||||
"Moved plugins.entries.memory-wiki.config.bridge.readMemoryCore → plugins.entries.memory-wiki.config.bridge.readMemoryArtifacts.",
|
||||
]);
|
||||
expect(
|
||||
(
|
||||
migration?.config.plugins?.entries?.["memory-wiki"] as {
|
||||
config?: { bridge?: Record<string, unknown> };
|
||||
}
|
||||
).config?.bridge,
|
||||
).toEqual({
|
||||
enabled: true,
|
||||
readMemoryArtifacts: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the canonical bridge toggle when both keys are present", () => {
|
||||
const config = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-wiki": {
|
||||
config: {
|
||||
bridge: {
|
||||
readMemoryCore: false,
|
||||
readMemoryArtifacts: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const migration = normalizeCompatibilityConfig({ cfg: config });
|
||||
|
||||
expect(migration.changes).toEqual([
|
||||
"Removed legacy plugins.entries.memory-wiki.config.bridge.readMemoryCore; kept explicit plugins.entries.memory-wiki.config.bridge.readMemoryArtifacts.",
|
||||
]);
|
||||
expect(
|
||||
(
|
||||
migration.config.plugins?.entries?.["memory-wiki"] as {
|
||||
config?: { bridge?: Record<string, unknown> };
|
||||
}
|
||||
).config?.bridge,
|
||||
).toEqual({
|
||||
readMemoryArtifacts: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
75
extensions/memory-wiki/src/config-compat.ts
Normal file
75
extensions/memory-wiki/src/config-compat.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
|
||||
type LegacyConfigRule = {
|
||||
path: Array<string | number>;
|
||||
message: string;
|
||||
match: (value: unknown) => boolean;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function hasLegacyBridgeArtifactToggle(value: unknown): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(asRecord(value) ?? {}, "readMemoryCore");
|
||||
}
|
||||
|
||||
export const legacyConfigRules: LegacyConfigRule[] = [
|
||||
{
|
||||
path: ["plugins", "entries", "memory-wiki", "config", "bridge"],
|
||||
message:
|
||||
'plugins.entries.memory-wiki.config.bridge.readMemoryCore is legacy; use plugins.entries.memory-wiki.config.bridge.readMemoryArtifacts. Run "openclaw doctor --fix".',
|
||||
match: hasLegacyBridgeArtifactToggle,
|
||||
},
|
||||
];
|
||||
|
||||
export function migrateMemoryWikiLegacyConfig(config: OpenClawConfig): {
|
||||
config: OpenClawConfig;
|
||||
changes: string[];
|
||||
} | null {
|
||||
const rawEntry = asRecord(config.plugins?.entries?.["memory-wiki"]);
|
||||
const rawPluginConfig = asRecord(rawEntry?.config);
|
||||
const rawBridge = asRecord(rawPluginConfig?.bridge);
|
||||
if (!rawBridge || !hasLegacyBridgeArtifactToggle(rawBridge)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextConfig = structuredClone(config);
|
||||
const nextPlugins = asRecord(nextConfig.plugins) ?? {};
|
||||
nextConfig.plugins = nextPlugins;
|
||||
const nextEntries = asRecord(nextPlugins.entries) ?? {};
|
||||
nextPlugins.entries = nextEntries;
|
||||
const nextEntry = asRecord(nextEntries["memory-wiki"]) ?? {};
|
||||
nextEntries["memory-wiki"] = nextEntry;
|
||||
const nextPluginConfig = asRecord(nextEntry.config) ?? {};
|
||||
nextEntry.config = nextPluginConfig;
|
||||
const nextBridge = asRecord(nextPluginConfig.bridge) ?? {};
|
||||
nextPluginConfig.bridge = nextBridge;
|
||||
|
||||
const legacyValue = nextBridge.readMemoryCore;
|
||||
const hasCanonical = Object.prototype.hasOwnProperty.call(nextBridge, "readMemoryArtifacts");
|
||||
if (!hasCanonical) {
|
||||
nextBridge.readMemoryArtifacts = legacyValue;
|
||||
}
|
||||
delete nextBridge.readMemoryCore;
|
||||
|
||||
return {
|
||||
config: nextConfig,
|
||||
changes: hasCanonical
|
||||
? [
|
||||
"Removed legacy plugins.entries.memory-wiki.config.bridge.readMemoryCore; kept explicit plugins.entries.memory-wiki.config.bridge.readMemoryArtifacts.",
|
||||
]
|
||||
: [
|
||||
"Moved plugins.entries.memory-wiki.config.bridge.readMemoryCore → plugins.entries.memory-wiki.config.bridge.readMemoryArtifacts.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): {
|
||||
config: OpenClawConfig;
|
||||
changes: string[];
|
||||
} {
|
||||
return migrateMemoryWikiLegacyConfig(cfg) ?? { config: cfg, changes: [] };
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -16,6 +16,24 @@ export type ParsedWikiMarkdown = {
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type WikiClaimEvidence = {
|
||||
sourceId?: string;
|
||||
path?: string;
|
||||
lines?: string;
|
||||
weight?: number;
|
||||
note?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type WikiClaim = {
|
||||
id?: string;
|
||||
text: string;
|
||||
status?: string;
|
||||
confidence?: number;
|
||||
evidence: WikiClaimEvidence[];
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type WikiPageSummary = {
|
||||
absolutePath: string;
|
||||
relativePath: string;
|
||||
@@ -25,6 +43,7 @@ export type WikiPageSummary = {
|
||||
pageType?: string;
|
||||
sourceIds: string[];
|
||||
linkTargets: string[];
|
||||
claims: WikiClaim[];
|
||||
contradictions: string[];
|
||||
questions: string[];
|
||||
confidence?: number;
|
||||
@@ -88,6 +107,71 @@ export function normalizeSourceIds(value: unknown): string[] {
|
||||
return normalizeSingleOrTrimmedStringList(value);
|
||||
}
|
||||
|
||||
function normalizeWikiClaimEvidence(value: unknown): WikiClaimEvidence | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const sourceId = normalizeOptionalString(record.sourceId);
|
||||
const evidencePath = normalizeOptionalString(record.path);
|
||||
const lines = normalizeOptionalString(record.lines);
|
||||
const note = normalizeOptionalString(record.note);
|
||||
const updatedAt = normalizeOptionalString(record.updatedAt);
|
||||
const weight =
|
||||
typeof record.weight === "number" && Number.isFinite(record.weight) ? record.weight : undefined;
|
||||
if (!sourceId && !evidencePath && !lines && !note && weight === undefined && !updatedAt) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...(sourceId ? { sourceId } : {}),
|
||||
...(evidencePath ? { path: evidencePath } : {}),
|
||||
...(lines ? { lines } : {}),
|
||||
...(weight !== undefined ? { weight } : {}),
|
||||
...(note ? { note } : {}),
|
||||
...(updatedAt ? { updatedAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeWikiClaims(value: unknown): WikiClaim[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.flatMap((entry) => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return [];
|
||||
}
|
||||
const record = entry as Record<string, unknown>;
|
||||
const text = normalizeOptionalString(record.text);
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
const evidence = Array.isArray(record.evidence)
|
||||
? record.evidence.flatMap((candidate) => {
|
||||
const normalized = normalizeWikiClaimEvidence(candidate);
|
||||
return normalized ? [normalized] : [];
|
||||
})
|
||||
: [];
|
||||
const confidence =
|
||||
typeof record.confidence === "number" && Number.isFinite(record.confidence)
|
||||
? record.confidence
|
||||
: undefined;
|
||||
return [
|
||||
{
|
||||
...(normalizeOptionalString(record.id) ? { id: normalizeOptionalString(record.id) } : {}),
|
||||
text,
|
||||
...(normalizeOptionalString(record.status)
|
||||
? { status: normalizeOptionalString(record.status) }
|
||||
: {}),
|
||||
...(confidence !== undefined ? { confidence } : {}),
|
||||
evidence,
|
||||
...(normalizeOptionalString(record.updatedAt)
|
||||
? { updatedAt: normalizeOptionalString(record.updatedAt) }
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function extractWikiLinks(markdown: string): string[] {
|
||||
const searchable = markdown.replace(RELATED_BLOCK_PATTERN, "");
|
||||
const links: string[] = [];
|
||||
@@ -174,6 +258,7 @@ export function toWikiPageSummary(params: {
|
||||
pageType: normalizeOptionalString(parsed.frontmatter.pageType),
|
||||
sourceIds: normalizeSourceIds(parsed.frontmatter.sourceIds),
|
||||
linkTargets: extractWikiLinks(params.raw),
|
||||
claims: normalizeWikiClaims(parsed.frontmatter.claims),
|
||||
contradictions: normalizeSingleOrTrimmedStringList(parsed.frontmatter.contradictions),
|
||||
questions: normalizeSingleOrTrimmedStringList(parsed.frontmatter.questions),
|
||||
confidence:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { listActiveMemoryPublicArtifacts } from "openclaw/plugin-sdk/memory-host-core";
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
import type { ResolvedMemoryWikiConfig } from "./config.js";
|
||||
import { inferWikiPageKind, toWikiPageSummary, type WikiPageKind } from "./markdown.js";
|
||||
import { probeObsidianCli } from "./obsidian.js";
|
||||
@@ -9,6 +11,7 @@ export type MemoryWikiStatusWarning = {
|
||||
| "vault-missing"
|
||||
| "obsidian-cli-missing"
|
||||
| "bridge-disabled"
|
||||
| "bridge-artifacts-missing"
|
||||
| "unsafe-local-disabled"
|
||||
| "unsafe-local-paths-missing"
|
||||
| "unsafe-local-without-mode";
|
||||
@@ -21,6 +24,7 @@ export type MemoryWikiStatus = {
|
||||
vaultPath: string;
|
||||
vaultExists: boolean;
|
||||
bridge: ResolvedMemoryWikiConfig["bridge"];
|
||||
bridgePublicArtifactCount: number | null;
|
||||
obsidianCli: {
|
||||
enabled: boolean;
|
||||
requested: boolean;
|
||||
@@ -55,7 +59,9 @@ export type MemoryWikiDoctorReport = {
|
||||
};
|
||||
|
||||
type ResolveMemoryWikiStatusDeps = {
|
||||
appConfig?: OpenClawConfig;
|
||||
pathExists?: (inputPath: string) => Promise<boolean>;
|
||||
listPublicArtifacts?: typeof listActiveMemoryPublicArtifacts;
|
||||
resolveCommand?: (command: string) => Promise<string | null>;
|
||||
};
|
||||
|
||||
@@ -135,6 +141,7 @@ async function collectVaultCounts(vaultPath: string): Promise<{
|
||||
|
||||
function buildWarnings(params: {
|
||||
config: ResolvedMemoryWikiConfig;
|
||||
bridgePublicArtifactCount: number | null;
|
||||
vaultExists: boolean;
|
||||
obsidianCommand: string | null;
|
||||
}): MemoryWikiStatusWarning[] {
|
||||
@@ -161,6 +168,18 @@ function buildWarnings(params: {
|
||||
message: "vaultMode is `bridge` but bridge.enabled is false.",
|
||||
});
|
||||
}
|
||||
if (
|
||||
params.config.vaultMode === "bridge" &&
|
||||
params.config.bridge.enabled &&
|
||||
params.config.bridge.readMemoryArtifacts &&
|
||||
params.bridgePublicArtifactCount === 0
|
||||
) {
|
||||
warnings.push({
|
||||
code: "bridge-artifacts-missing",
|
||||
message:
|
||||
"Bridge mode is enabled but the active memory plugin is not exporting any public memory artifacts yet.",
|
||||
});
|
||||
}
|
||||
if (
|
||||
params.config.vaultMode === "unsafe-local" &&
|
||||
!params.config.unsafeLocal.allowPrivateMemoryCoreAccess
|
||||
@@ -198,6 +217,14 @@ export async function resolveMemoryWikiStatus(
|
||||
): Promise<MemoryWikiStatus> {
|
||||
const exists = deps?.pathExists ?? pathExists;
|
||||
const vaultExists = await exists(config.vault.path);
|
||||
const bridgePublicArtifactCount =
|
||||
deps?.appConfig && config.vaultMode === "bridge" && config.bridge.enabled
|
||||
? (
|
||||
await (deps.listPublicArtifacts ?? listActiveMemoryPublicArtifacts)({
|
||||
cfg: deps.appConfig,
|
||||
})
|
||||
).length
|
||||
: null;
|
||||
const obsidianProbe = await probeObsidianCli({ resolveCommand: deps?.resolveCommand });
|
||||
const counts = vaultExists
|
||||
? await collectVaultCounts(config.vault.path)
|
||||
@@ -224,6 +251,7 @@ export async function resolveMemoryWikiStatus(
|
||||
vaultPath: config.vault.path,
|
||||
vaultExists,
|
||||
bridge: config.bridge,
|
||||
bridgePublicArtifactCount,
|
||||
obsidianCli: {
|
||||
enabled: config.obsidian.enabled,
|
||||
requested: config.obsidian.enabled && config.obsidian.useOfficialCli,
|
||||
@@ -236,7 +264,12 @@ export async function resolveMemoryWikiStatus(
|
||||
},
|
||||
pageCounts: counts.pageCounts,
|
||||
sourceCounts: counts.sourceCounts,
|
||||
warnings: buildWarnings({ config, vaultExists, obsidianCommand: obsidianProbe.command }),
|
||||
warnings: buildWarnings({
|
||||
config,
|
||||
bridgePublicArtifactCount,
|
||||
vaultExists,
|
||||
obsidianCommand: obsidianProbe.command,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -250,11 +283,13 @@ export function buildMemoryWikiDoctorReport(status: MemoryWikiStatus): MemoryWik
|
||||
? "Install the official Obsidian CLI or disable `obsidian.useOfficialCli`."
|
||||
: warning.code === "bridge-disabled"
|
||||
? "Enable `plugins.entries.memory-wiki.config.bridge.enabled` or switch vaultMode away from `bridge`."
|
||||
: warning.code === "unsafe-local-disabled"
|
||||
? "Enable `unsafeLocal.allowPrivateMemoryCoreAccess` or switch vaultMode away from `unsafe-local`."
|
||||
: warning.code === "unsafe-local-paths-missing"
|
||||
? "Add explicit `unsafeLocal.paths` entries before running unsafe-local imports."
|
||||
: "Disable private memory-core access unless you explicitly want unsafe-local mode.",
|
||||
: warning.code === "bridge-artifacts-missing"
|
||||
? "Use a memory plugin that exports public artifacts, create/import memory artifacts first, or switch the wiki back to isolated mode."
|
||||
: warning.code === "unsafe-local-disabled"
|
||||
? "Enable `unsafeLocal.allowPrivateMemoryCoreAccess` or switch vaultMode away from `unsafe-local`."
|
||||
: warning.code === "unsafe-local-paths-missing"
|
||||
? "Add explicit `unsafeLocal.paths` entries before running unsafe-local imports."
|
||||
: "Disable private memory-core access unless you explicitly want unsafe-local mode.",
|
||||
}));
|
||||
return {
|
||||
healthy: status.warnings.length === 0,
|
||||
@@ -270,7 +305,7 @@ export function renderMemoryWikiStatus(status: MemoryWikiStatus): string {
|
||||
`Vault: ${status.vaultExists ? "ready" : "missing"} (${status.vaultPath})`,
|
||||
`Render mode: ${status.renderMode}`,
|
||||
`Obsidian CLI: ${status.obsidianCli.available ? "available" : "missing"}${status.obsidianCli.requested ? " (requested)" : ""}`,
|
||||
`Bridge: ${status.bridge.enabled ? "enabled" : "disabled"}`,
|
||||
`Bridge: ${status.bridge.enabled ? "enabled" : "disabled"}${typeof status.bridgePublicArtifactCount === "number" ? ` (${status.bridgePublicArtifactCount} exported artifact${status.bridgePublicArtifactCount === 1 ? "" : "s"})` : ""}`,
|
||||
`Unsafe local: ${status.unsafeLocal.allowPrivateMemoryCoreAccess ? `enabled (${status.unsafeLocal.pathCount} paths)` : "disabled"}`,
|
||||
`Pages: ${status.pageCounts.source} sources, ${status.pageCounts.entity} entities, ${status.pageCounts.concept} concepts, ${status.pageCounts.synthesis} syntheses, ${status.pageCounts.report} reports`,
|
||||
`Source provenance: ${status.sourceCounts.native} native, ${status.sourceCounts.bridge} bridge, ${status.sourceCounts.bridgeEvents} bridge-events, ${status.sourceCounts.unsafeLocal} unsafe-local, ${status.sourceCounts.other} other`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -46,6 +46,8 @@ function buildAgentsMarkdown(): string {
|
||||
- Treat generated blocks as plugin-owned.
|
||||
- Preserve human notes outside managed markers.
|
||||
- Prefer source-backed claims over wiki-to-wiki citation loops.
|
||||
- Prefer structured \`claims\` with evidence over burying key beliefs only in prose.
|
||||
- Use \`.openclaw-wiki/cache/agent-digest.json\` and \`claims.jsonl\` for machine reads; markdown pages are the human view.
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -59,6 +61,11 @@ This vault is maintained by the OpenClaw memory-wiki plugin.
|
||||
- Render mode: \`${config.vault.renderMode}\`
|
||||
- Search corpus default: \`${config.search.corpus}\`
|
||||
|
||||
## Architecture
|
||||
- Raw sources remain the evidence layer.
|
||||
- Wiki pages are the human-readable synthesis layer.
|
||||
- \`.openclaw-wiki/cache/agent-digest.json\` is the agent-facing compiled digest.
|
||||
|
||||
## Notes
|
||||
<!-- openclaw:human:start -->
|
||||
<!-- openclaw:human:end -->
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
24
src/agents/system-prompt.memory.test.ts
Normal file
24
src/agents/system-prompt.memory.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { clearMemoryPluginState, registerMemoryPromptSection } from "../plugins/memory-state.js";
|
||||
import { buildAgentSystemPrompt } from "./system-prompt.js";
|
||||
|
||||
describe("buildAgentSystemPrompt memory guidance", () => {
|
||||
afterEach(() => {
|
||||
clearMemoryPluginState();
|
||||
});
|
||||
|
||||
it("can suppress base memory guidance so context engines own memory prompt assembly", () => {
|
||||
registerMemoryPromptSection(() => ["## Memory Recall", "Use memory carefully.", ""]);
|
||||
|
||||
const promptWithMemory = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/openclaw",
|
||||
});
|
||||
const promptWithoutMemory = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/openclaw",
|
||||
includeMemorySection: false,
|
||||
});
|
||||
|
||||
expect(promptWithMemory).toContain("## Memory Recall");
|
||||
expect(promptWithoutMemory).not.toContain("## Memory Recall");
|
||||
});
|
||||
});
|
||||
@@ -124,10 +124,11 @@ function buildSkillsSection(params: { skillsPrompt?: string; readToolName: strin
|
||||
|
||||
function buildMemorySection(params: {
|
||||
isMinimal: boolean;
|
||||
includeMemorySection?: boolean;
|
||||
availableTools: Set<string>;
|
||||
citationsMode?: MemoryCitationsMode;
|
||||
}) {
|
||||
if (params.isMinimal) {
|
||||
if (params.isMinimal || params.includeMemorySection === false) {
|
||||
return [];
|
||||
}
|
||||
return buildMemoryPromptSection({
|
||||
@@ -354,6 +355,8 @@ export function buildAgentSystemPrompt(params: {
|
||||
level: "minimal" | "extensive";
|
||||
channel: string;
|
||||
};
|
||||
/** Whether to include the active memory plugin prompt guidance in the base system prompt. Defaults to true. */
|
||||
includeMemorySection?: boolean;
|
||||
memoryCitationsMode?: MemoryCitationsMode;
|
||||
promptContribution?: ProviderSystemPromptContribution;
|
||||
}) {
|
||||
@@ -462,6 +465,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
});
|
||||
const memorySection = buildMemorySection({
|
||||
isMinimal,
|
||||
includeMemorySection: params.includeMemorySection,
|
||||
availableTools,
|
||||
citationsMode: params.memoryCitationsMode,
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -24,6 +24,9 @@ export type {
|
||||
MemoryCitationsMode,
|
||||
MemoryFlushPlan,
|
||||
MemoryFlushPlanResolver,
|
||||
MemoryPluginCapability,
|
||||
MemoryPluginPublicArtifact,
|
||||
MemoryPluginPublicArtifactsProvider,
|
||||
MemoryPluginRuntime,
|
||||
MemoryPromptSectionBuilder,
|
||||
OpenClawConfig,
|
||||
|
||||
61
src/plugin-sdk/memory-host-core.test.ts
Normal file
61
src/plugin-sdk/memory-host-core.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearMemoryPluginState,
|
||||
registerMemoryCapability,
|
||||
registerMemoryPromptSection,
|
||||
} from "../plugins/memory-state.js";
|
||||
import {
|
||||
buildActiveMemoryPromptSection,
|
||||
listActiveMemoryPublicArtifacts,
|
||||
} from "./memory-host-core.js";
|
||||
|
||||
describe("memory-host-core helpers", () => {
|
||||
afterEach(() => {
|
||||
clearMemoryPluginState();
|
||||
});
|
||||
|
||||
it("exposes the active memory prompt guidance builder for context engines", () => {
|
||||
registerMemoryPromptSection(({ citationsMode }) => [
|
||||
"## Memory Recall",
|
||||
`citations=${citationsMode ?? "default"}`,
|
||||
"",
|
||||
]);
|
||||
|
||||
expect(
|
||||
buildActiveMemoryPromptSection({
|
||||
availableTools: new Set(["memory_search"]),
|
||||
citationsMode: "off",
|
||||
}),
|
||||
).toEqual(["## Memory Recall", "citations=off", ""]);
|
||||
});
|
||||
|
||||
it("exposes active memory public artifacts for companion plugins", async () => {
|
||||
registerMemoryCapability("memory-core", {
|
||||
publicArtifacts: {
|
||||
async listArtifacts() {
|
||||
return [
|
||||
{
|
||||
kind: "memory-root",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
relativePath: "MEMORY.md",
|
||||
absolutePath: "/tmp/workspace/MEMORY.md",
|
||||
agentIds: ["main"],
|
||||
contentType: "markdown" as const,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(listActiveMemoryPublicArtifacts({ cfg: {} as never })).resolves.toEqual([
|
||||
{
|
||||
kind: "memory-root",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
relativePath: "MEMORY.md",
|
||||
absolutePath: "/tmp/workspace/MEMORY.md",
|
||||
agentIds: ["main"],
|
||||
contentType: "markdown",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}`,
|
||||
|
||||
@@ -109,12 +109,46 @@ export type MemoryPluginRuntime = {
|
||||
closeAllMemorySearchManagers?(): Promise<void>;
|
||||
};
|
||||
|
||||
type MemoryPluginState = {
|
||||
corpusSupplements: MemoryCorpusSupplementRegistration[];
|
||||
export type MemoryPluginPublicArtifactContentType = "markdown" | "json" | "text";
|
||||
|
||||
export type MemoryPluginPublicArtifact = {
|
||||
kind: string;
|
||||
workspaceDir: string;
|
||||
relativePath: string;
|
||||
absolutePath: string;
|
||||
agentIds: string[];
|
||||
contentType: MemoryPluginPublicArtifactContentType;
|
||||
};
|
||||
|
||||
export type MemoryPluginPublicArtifactsProvider = {
|
||||
listArtifacts(params: { cfg: OpenClawConfig }): Promise<MemoryPluginPublicArtifact[]>;
|
||||
};
|
||||
|
||||
export type MemoryPluginCapability = {
|
||||
promptBuilder?: MemoryPromptSectionBuilder;
|
||||
promptSupplements: MemoryPromptSupplementRegistration[];
|
||||
flushPlanResolver?: MemoryFlushPlanResolver;
|
||||
runtime?: MemoryPluginRuntime;
|
||||
publicArtifacts?: MemoryPluginPublicArtifactsProvider;
|
||||
};
|
||||
|
||||
export type MemoryPluginCapabilityRegistration = {
|
||||
pluginId: string;
|
||||
capability: MemoryPluginCapability;
|
||||
};
|
||||
|
||||
type MemoryPluginState = {
|
||||
capability?: MemoryPluginCapabilityRegistration;
|
||||
corpusSupplements: MemoryCorpusSupplementRegistration[];
|
||||
promptSupplements: MemoryPromptSupplementRegistration[];
|
||||
// LEGACY(memory-v1): kept for external plugins still registering the older
|
||||
// split memory surfaces. Prefer `registerMemoryCapability(...)`.
|
||||
promptBuilder?: MemoryPromptSectionBuilder;
|
||||
// LEGACY(memory-v1): remove after external memory plugins migrate to the
|
||||
// unified capability registration path.
|
||||
flushPlanResolver?: MemoryFlushPlanResolver;
|
||||
// LEGACY(memory-v1): remove after external memory plugins migrate to the
|
||||
// unified capability registration path.
|
||||
runtime?: MemoryPluginRuntime;
|
||||
};
|
||||
|
||||
const memoryPluginState: MemoryPluginState = {
|
||||
@@ -133,10 +167,27 @@ export function registerMemoryCorpusSupplement(
|
||||
memoryPluginState.corpusSupplements = next;
|
||||
}
|
||||
|
||||
export function registerMemoryCapability(
|
||||
pluginId: string,
|
||||
capability: MemoryPluginCapability,
|
||||
): void {
|
||||
memoryPluginState.capability = { pluginId, capability: { ...capability } };
|
||||
}
|
||||
|
||||
export function getMemoryCapabilityRegistration(): MemoryPluginCapabilityRegistration | undefined {
|
||||
return memoryPluginState.capability
|
||||
? {
|
||||
pluginId: memoryPluginState.capability.pluginId,
|
||||
capability: { ...memoryPluginState.capability.capability },
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function listMemoryCorpusSupplements(): MemoryCorpusSupplementRegistration[] {
|
||||
return [...memoryPluginState.corpusSupplements];
|
||||
}
|
||||
|
||||
/** @deprecated Use registerMemoryCapability(pluginId, { promptBuilder }) instead. */
|
||||
export function registerMemoryPromptSection(builder: MemoryPromptSectionBuilder): void {
|
||||
memoryPluginState.promptBuilder = builder;
|
||||
}
|
||||
@@ -156,7 +207,10 @@ export function buildMemoryPromptSection(params: {
|
||||
availableTools: Set<string>;
|
||||
citationsMode?: MemoryCitationsMode;
|
||||
}): string[] {
|
||||
const primary = memoryPluginState.promptBuilder?.(params) ?? [];
|
||||
const primary =
|
||||
memoryPluginState.capability?.capability.promptBuilder?.(params) ??
|
||||
memoryPluginState.promptBuilder?.(params) ??
|
||||
[];
|
||||
const supplements = memoryPluginState.promptSupplements
|
||||
// Keep supplement order stable even if plugin registration order changes.
|
||||
.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId))
|
||||
@@ -165,13 +219,14 @@ export function buildMemoryPromptSection(params: {
|
||||
}
|
||||
|
||||
export function getMemoryPromptSectionBuilder(): MemoryPromptSectionBuilder | undefined {
|
||||
return memoryPluginState.promptBuilder;
|
||||
return memoryPluginState.capability?.capability.promptBuilder ?? memoryPluginState.promptBuilder;
|
||||
}
|
||||
|
||||
export function listMemoryPromptSupplements(): MemoryPromptSupplementRegistration[] {
|
||||
return [...memoryPluginState.promptSupplements];
|
||||
}
|
||||
|
||||
/** @deprecated Use registerMemoryCapability(pluginId, { flushPlanResolver }) instead. */
|
||||
export function registerMemoryFlushPlanResolver(resolver: MemoryFlushPlanResolver): void {
|
||||
memoryPluginState.flushPlanResolver = resolver;
|
||||
}
|
||||
@@ -180,26 +235,79 @@ export function resolveMemoryFlushPlan(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
nowMs?: number;
|
||||
}): MemoryFlushPlan | null {
|
||||
return memoryPluginState.flushPlanResolver?.(params) ?? null;
|
||||
return (
|
||||
memoryPluginState.capability?.capability.flushPlanResolver?.(params) ??
|
||||
memoryPluginState.flushPlanResolver?.(params) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function getMemoryFlushPlanResolver(): MemoryFlushPlanResolver | undefined {
|
||||
return memoryPluginState.flushPlanResolver;
|
||||
return (
|
||||
memoryPluginState.capability?.capability.flushPlanResolver ??
|
||||
memoryPluginState.flushPlanResolver
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated Use registerMemoryCapability(pluginId, { runtime }) instead. */
|
||||
export function registerMemoryRuntime(runtime: MemoryPluginRuntime): void {
|
||||
memoryPluginState.runtime = runtime;
|
||||
}
|
||||
|
||||
export function getMemoryRuntime(): MemoryPluginRuntime | undefined {
|
||||
return memoryPluginState.runtime;
|
||||
return memoryPluginState.capability?.capability.runtime ?? memoryPluginState.runtime;
|
||||
}
|
||||
|
||||
export function hasMemoryRuntime(): boolean {
|
||||
return memoryPluginState.runtime !== undefined;
|
||||
return getMemoryRuntime() !== undefined;
|
||||
}
|
||||
|
||||
function cloneMemoryPublicArtifact(
|
||||
artifact: MemoryPluginPublicArtifact,
|
||||
): MemoryPluginPublicArtifact {
|
||||
return {
|
||||
...artifact,
|
||||
agentIds: [...artifact.agentIds],
|
||||
};
|
||||
}
|
||||
|
||||
export async function listActiveMemoryPublicArtifacts(params: {
|
||||
cfg: OpenClawConfig;
|
||||
}): Promise<MemoryPluginPublicArtifact[]> {
|
||||
const artifacts =
|
||||
(await memoryPluginState.capability?.capability.publicArtifacts?.listArtifacts(params)) ?? [];
|
||||
return artifacts.map(cloneMemoryPublicArtifact).toSorted((left, right) => {
|
||||
const workspaceOrder = left.workspaceDir.localeCompare(right.workspaceDir);
|
||||
if (workspaceOrder !== 0) {
|
||||
return workspaceOrder;
|
||||
}
|
||||
const relativePathOrder = left.relativePath.localeCompare(right.relativePath);
|
||||
if (relativePathOrder !== 0) {
|
||||
return relativePathOrder;
|
||||
}
|
||||
const kindOrder = left.kind.localeCompare(right.kind);
|
||||
if (kindOrder !== 0) {
|
||||
return kindOrder;
|
||||
}
|
||||
const contentTypeOrder = left.contentType.localeCompare(right.contentType);
|
||||
if (contentTypeOrder !== 0) {
|
||||
return contentTypeOrder;
|
||||
}
|
||||
const agentOrder = left.agentIds.join("\0").localeCompare(right.agentIds.join("\0"));
|
||||
if (agentOrder !== 0) {
|
||||
return agentOrder;
|
||||
}
|
||||
return left.absolutePath.localeCompare(right.absolutePath);
|
||||
});
|
||||
}
|
||||
|
||||
export function restoreMemoryPluginState(state: MemoryPluginState): void {
|
||||
memoryPluginState.capability = state.capability
|
||||
? {
|
||||
pluginId: state.capability.pluginId,
|
||||
capability: { ...state.capability.capability },
|
||||
}
|
||||
: undefined;
|
||||
memoryPluginState.corpusSupplements = [...state.corpusSupplements];
|
||||
memoryPluginState.promptBuilder = state.promptBuilder;
|
||||
memoryPluginState.promptSupplements = [...state.promptSupplements];
|
||||
@@ -208,6 +316,7 @@ export function restoreMemoryPluginState(state: MemoryPluginState): void {
|
||||
}
|
||||
|
||||
export function clearMemoryPluginState(): void {
|
||||
memoryPluginState.capability = undefined;
|
||||
memoryPluginState.corpusSupplements = [];
|
||||
memoryPluginState.promptBuilder = undefined;
|
||||
memoryPluginState.promptSupplements = [];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -38,6 +38,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
|
||||
onConversationBindingResolved() {},
|
||||
registerCommand() {},
|
||||
registerContextEngine() {},
|
||||
registerMemoryCapability() {},
|
||||
registerMemoryPromptSection() {},
|
||||
registerMemoryPromptSupplement() {},
|
||||
registerMemoryCorpusSupplement() {},
|
||||
|
||||
Reference in New Issue
Block a user