// Memory Core tests cover index plugin behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { OpenClawPluginApi, OpenClawPluginCommandDefinition } from "openclaw/plugin-sdk/core"; import type { MemoryPluginRuntime } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { buildMemoryFlushPlan } from "./src/flush-plan.js"; import type { MemoryCoreRuntimeHost } from "./src/memory/runtime-host.js"; import { buildPromptSection } from "./src/prompt-section.js"; const closeMemorySearchManagerMock = vi.hoisted(() => vi.fn(async () => {})); const getMemorySearchManagerMock = vi.hoisted(() => vi.fn(async () => null)); const authorizeSearchHitsMock = vi.hoisted(() => vi.fn(async ({ hits }) => hits)); const createMemoryRuntimeMock = vi.hoisted(() => vi.fn((_host: MemoryCoreRuntimeHost = {}) => ({ authorizeSearchHits: authorizeSearchHitsMock, closeAllMemorySearchManagers: vi.fn(async () => {}), closeMemorySearchManager: closeMemorySearchManagerMock, getMemorySearchManager: getMemorySearchManagerMock, })), ); vi.mock("./src/runtime-provider.js", () => ({ createMemoryRuntime: createMemoryRuntimeMock, memoryRuntime: { closeAllMemorySearchManagers: vi.fn(async () => {}), closeMemorySearchManager: closeMemorySearchManagerMock, getMemorySearchManager: getMemorySearchManagerMock, }, })); import plugin from "./index.js"; const hostRuntime = { llm: { acquireLocalService: async () => undefined, }, state: { openKeyedStore: vi.fn(() => ({ lookup: vi.fn(), register: vi.fn(), delete: vi.fn(), list: vi.fn(), })), }, } as unknown as OpenClawPluginApi["runtime"]; function registerMemoryCoreRuntime(): MemoryPluginRuntime { let runtime: MemoryPluginRuntime | undefined; plugin.register( createTestPluginApi({ runtime: hostRuntime, registerMemoryCapability(capability) { runtime = capability.runtime; }, }), ); if (!runtime) { throw new Error("expected memory-core to register a memory runtime"); } return runtime; } describe("buildPromptSection", () => { it("returns empty when no memory tools are available", () => { expect(buildPromptSection({ availableTools: new Set() })).toStrictEqual([]); }); it("describes the two-step flow when both memory tools are available", () => { const result = buildPromptSection({ availableTools: new Set(["memory_search", "memory_get"]), }); expect(result[0]).toBe("## Memory Recall"); expect(result[1]).toContain("run memory_search"); expect(result[1]).toContain("then use memory_get"); expect(result[1]).toContain("indexed session transcripts"); expect(result).toContain( "Citations: include Source: when it helps the user verify memory snippets.", ); expect(result.at(-1)).toBe(""); }); it("limits the guidance to memory_search when only search is available", () => { const result = buildPromptSection({ availableTools: new Set(["memory_search"]) }); expect(result[0]).toBe("## Memory Recall"); expect(result[1]).toContain("run memory_search"); expect(result[1]).toContain("indexed session transcripts"); expect(result[1]).not.toContain("then use memory_get"); }); it("limits the guidance to memory_get when only get is available", () => { const result = buildPromptSection({ availableTools: new Set(["memory_get"]) }); expect(result[0]).toBe("## Memory Recall"); expect(result[1]).toContain("run memory_get"); expect(result[1]).not.toContain("run memory_search"); }); it("includes citations-off instruction when citationsMode is off", () => { const result = buildPromptSection({ availableTools: new Set(["memory_search"]), citationsMode: "off", }); expect(result).toContain( "Citations are disabled: do not mention file paths or line numbers in replies unless the user explicitly asks.", ); }); }); describe("memory-core plugin runtime registration", () => { beforeEach(() => { vi.clearAllMocks(); }); it("registers the dreaming runtime slash command", () => { let command: OpenClawPluginCommandDefinition | undefined; plugin.register( createTestPluginApi({ runtime: hostRuntime, registerCommand(definition) { command = definition; }, }), ); expect(command?.name).toBe("dreaming"); expect(command?.acceptsArgs).toBe(true); expect(command?.exposeSenderIsOwner).toBe(true); expect(command?.description).toContain("Enable or disable"); }); it("registers the standing-intent tool and deterministic prompt hook", () => { const toolNames: string[] = []; const hooks: string[] = []; const subagentRun = vi.fn(); plugin.register( createTestPluginApi({ runtime: { ...hostRuntime, subagent: { run: subagentRun } } as never, registerTool(_factory, options?: Parameters[1]) { toolNames.push(...(options?.names ?? [])); }, on(hookName) { hooks.push(hookName); }, }), ); expect(toolNames).toContain("intent"); expect(hooks).toContain("before_prompt_build"); expect(subagentRun).not.toHaveBeenCalled(); }); it("scopes both reply hooks to scheduled turns across three registrations", () => { for (let cycle = 1; cycle <= 3; cycle += 1) { const replyHookTriggers: unknown[] = []; plugin.register( createTestPluginApi({ runtime: hostRuntime, on(hookName, _handler, options) { if (hookName === "before_agent_reply") { replyHookTriggers.push(options?.eligibleTriggers); } }, }), ); expect(replyHookTriggers, `cycle ${cycle}`).toEqual([ ["heartbeat", "cron"], ["heartbeat", "cron"], ]); } }); it("hides intent create, list, and cancel from non-owner turns", () => { let intentFactory: | ((ctx: { config?: OpenClawConfig; senderIsOwner?: boolean }) => unknown) | undefined; plugin.register( createTestPluginApi({ config: {}, runtime: hostRuntime, registerTool(factory, options) { if (options?.names?.includes("intent") && typeof factory === "function") { intentFactory = factory as typeof intentFactory; } }, }), ); if (!intentFactory) { throw new Error("expected standing-intent tool factory"); } expect(intentFactory({ config: {}, senderIsOwner: false })).toBeNull(); expect(intentFactory({ config: {} })).toBeNull(); expect(intentFactory({ config: {}, senderIsOwner: true })).toMatchObject({ name: "intent" }); }); it("keeps memory manager initialization demand-driven", () => { plugin.register( createTestPluginApi({ runtime: hostRuntime, }), ); expect(createMemoryRuntimeMock).not.toHaveBeenCalled(); expect(getMemorySearchManagerMock).not.toHaveBeenCalled(); }); it("wires scoped memory search cleanup through the lazy runtime", async () => { const runtime = registerMemoryCoreRuntime(); const cfg = {} as OpenClawConfig; await runtime.closeMemorySearchManager?.({ cfg, agentId: "main" }); expect(closeMemorySearchManagerMock).toHaveBeenCalledWith({ cfg, agentId: "main" }); }); it("binds the host local-service hook to the registered memory runtime", async () => { const runtime = registerMemoryCoreRuntime(); const cfg = {} as OpenClawConfig; await runtime.getMemorySearchManager({ cfg, agentId: "main" }); expect(createMemoryRuntimeMock).toHaveBeenCalledWith({ acquireLocalService: expect.any(Function), openKeyedStore: expect.any(Function), }); }); it("defers nested host runtime access until the injected operation runs", async () => { const acquireLocalService = vi.fn(async () => undefined); const openKeyedStore = vi.fn(() => ({})); const llmGetter = vi.fn(() => ({ acquireLocalService })); const stateGetter = vi.fn(() => ({ openKeyedStore })); const host = Object.defineProperties( {}, { llm: { configurable: true, enumerable: true, get: llmGetter }, state: { configurable: true, enumerable: true, get: stateGetter }, }, ) as OpenClawPluginApi["runtime"]; let runtime: MemoryPluginRuntime | undefined; plugin.register( createTestPluginApi({ runtime: host, registerMemoryCapability(capability) { runtime = capability.runtime; }, }), ); expect(llmGetter).not.toHaveBeenCalled(); expect(stateGetter).not.toHaveBeenCalled(); await runtime?.getMemorySearchManager({ cfg: {}, agentId: "main" }); const injectedHost = createMemoryRuntimeMock.mock.calls.at(-1)?.[0]; if (!injectedHost?.acquireLocalService || !injectedHost.openKeyedStore) { throw new Error("expected memory-core host operations"); } const target = { providerId: "local", baseUrl: "http://127.0.0.1:11434" }; await injectedHost.acquireLocalService(target); const storeOptions = { namespace: "lazy-host", maxEntries: 1 }; injectedHost.openKeyedStore(storeOptions); expect(llmGetter).toHaveBeenCalledOnce(); expect(acquireLocalService).toHaveBeenCalledWith(target); expect(stateGetter).toHaveBeenCalledOnce(); expect(openKeyedStore).toHaveBeenCalledWith(storeOptions); }); it("forwards search-hit authorization through the registered memory runtime", async () => { const runtime = registerMemoryCoreRuntime(); const cfg = {} as OpenClawConfig; const hits = [ { source: "sessions" as const, path: "sessions/private.jsonl", startLine: 1, endLine: 1, score: 1, snippet: "private", }, ]; await expect( runtime.authorizeSearchHits?.({ cfg, agentId: "main", requesterSessionKey: "agent:main:voice:15550001234", sandboxed: false, hits, }), ).resolves.toEqual(hits); expect(authorizeSearchHitsMock).toHaveBeenCalledWith({ cfg, agentId: "main", requesterSessionKey: "agent:main:voice:15550001234", sandboxed: false, hits, }); expect(createMemoryRuntimeMock).toHaveBeenCalledWith({ acquireLocalService: expect.any(Function), openKeyedStore: expect.any(Function), }); }); it("binds the host SQLite state hook to tools and CLI runtime", async () => { const runtime = registerMemoryCoreRuntime(); const cfg = {} as OpenClawConfig; await runtime.getMemorySearchManager({ cfg, agentId: "main" }); const host = createMemoryRuntimeMock.mock.calls.at(-1)?.[0]; const storeOptions = { namespace: "cli-status-regression", maxEntries: 1 }; host?.openKeyedStore?.(storeOptions); expect(hostRuntime.state.openKeyedStore).toHaveBeenCalledWith(storeOptions); }); }); describe("buildMemoryFlushPlan", () => { const cfg = { agents: { defaults: { userTimezone: "America/New_York", timeFormat: "12", }, }, } as OpenClawConfig; it("replaces YYYY-MM-DD using user timezone and appends current time", () => { const plan = buildMemoryFlushPlan({ cfg, nowMs: Date.UTC(2026, 1, 16, 15, 0, 0), }); expect(plan?.prompt).toContain("memory/2026-02-16.md"); expect(plan?.prompt).toContain( "Current time: Monday, February 16th, 2026 - 10:00 AM (America/New_York)", ); expect(plan?.prompt).toContain("Reference UTC: 2026-02-16 15:00 UTC"); expect(plan?.relativePath).toBe("memory/2026-02-16.md"); }); it("appends one current time line to the built-in prompt", () => { const plan = buildMemoryFlushPlan({ cfg, nowMs: Date.UTC(2026, 1, 16, 15, 0, 0), }); expect((plan?.prompt.match(/Current time:/g) ?? []).length).toBe(1); }); it("defaults to safe prompts and gating values", () => { const plan = buildMemoryFlushPlan(); expect(plan?.softThresholdTokens).toBe(4000); expect(plan?.forceFlushTranscriptBytes).toBe(2 * 1024 * 1024); expect(plan?.prompt).toContain("memory/"); expect(plan?.prompt).toContain("MEMORY.md"); expect(plan?.systemPrompt).toContain("MEMORY.md"); }); it("respects disable flag", () => { expect( buildMemoryFlushPlan({ cfg: { agents: { defaults: { compaction: { memoryFlush: { enabled: false } } }, }, }, }), ).toBeNull(); }); it("carries configured memory flush model override", () => { const plan = buildMemoryFlushPlan({ cfg: { agents: { defaults: { compaction: { memoryFlush: { model: "ollama/qwen3:8b", }, }, }, }, }, }); expect(plan?.model).toBe("ollama/qwen3:8b"); }); it("falls back to defaults when numeric values are invalid", () => { const plan = buildMemoryFlushPlan({ cfg: { agents: { defaults: { compaction: { memoryFlush: { softThresholdTokens: -100, }, }, }, }, }, }); expect(plan?.softThresholdTokens).toBe(4000); expect(plan?.forceFlushTranscriptBytes).toBe(2 * 1024 * 1024); }); it("parses forceFlushTranscriptBytes from byte-size strings", () => { const plan = buildMemoryFlushPlan({ cfg: { agents: { defaults: { compaction: { memoryFlush: { forceFlushTranscriptBytes: "3mb", }, }, }, }, }, }); expect(plan?.forceFlushTranscriptBytes).toBe(3 * 1024 * 1024); }); it("keeps overwrite guards in the default prompt", () => { const prompt = buildMemoryFlushPlan()?.prompt; expect(prompt).toMatch(/APPEND/i); expect(prompt).toContain("do not overwrite"); expect(prompt).toContain("timestamped variant"); expect(prompt).toMatch(/memory\/\d{4}-\d{2}-\d{2}\.md/); }); });