From aac1abeafff4d1303779c8a1e95504346dff4a0e Mon Sep 17 00:00:00 2001 From: brokemac79 Date: Thu, 21 May 2026 19:11:24 +0100 Subject: [PATCH] fix(memory-lancedb): expose public memory artifacts --- .../memory-core/src/public-artifacts.ts | 95 +--------- extensions/memory-lancedb/index.test.ts | 176 +++++++++++++++++- extensions/memory-lancedb/index.ts | 11 ++ src/plugin-sdk/memory-host-core.test.ts | 76 ++++++++ src/plugin-sdk/memory-host-core.ts | 103 ++++++++++ 5 files changed, 369 insertions(+), 92 deletions(-) diff --git a/extensions/memory-core/src/public-artifacts.ts b/extensions/memory-core/src/public-artifacts.ts index e77c8f3789c..ba4defabc28 100644 --- a/extensions/memory-core/src/public-artifacts.ts +++ b/extensions/memory-core/src/public-artifacts.ts @@ -1,96 +1,11 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { resolveMemoryDreamingWorkspaces } from "openclaw/plugin-sdk/memory-core-host-status"; -import type { MemoryPluginPublicArtifact } from "openclaw/plugin-sdk/memory-host-core"; -import { resolveMemoryHostEventLogPath } from "openclaw/plugin-sdk/memory-host-events"; -import { pathExists } from "openclaw/plugin-sdk/security-runtime"; +import { + listMemoryHostPublicArtifacts, + type MemoryPluginPublicArtifact, +} from "openclaw/plugin-sdk/memory-host-core"; import type { OpenClawConfig } from "../api.js"; -async function listMarkdownFilesRecursive(rootDir: string): Promise { - const entries = await fs.readdir(rootDir, { withFileTypes: true }).catch(() => []); - const files: string[] = []; - for (const entry of entries) { - const fullPath = path.join(rootDir, entry.name); - if (entry.isDirectory()) { - files.push(...(await listMarkdownFilesRecursive(fullPath))); - continue; - } - if (entry.isFile() && entry.name.endsWith(".md")) { - files.push(fullPath); - } - } - return files.toSorted((left, right) => left.localeCompare(right)); -} - -async function collectWorkspaceArtifacts(params: { - workspaceDir: string; - agentIds: string[]; -}): Promise { - const artifacts: MemoryPluginPublicArtifact[] = []; - const workspaceEntries = new Set( - (await fs.readdir(params.workspaceDir, { withFileTypes: true }).catch(() => [])) - .filter((entry) => entry.isFile()) - .map((entry) => entry.name), - ); - for (const relativePath of ["MEMORY.md"]) { - if (!workspaceEntries.has(relativePath)) { - continue; - } - const absolutePath = path.join(params.workspaceDir, relativePath); - artifacts.push({ - kind: "memory-root", - workspaceDir: params.workspaceDir, - relativePath, - absolutePath, - agentIds: [...params.agentIds], - contentType: "markdown", - }); - } - - const memoryDir = path.join(params.workspaceDir, "memory"); - for (const absolutePath of await listMarkdownFilesRecursive(memoryDir)) { - const relativePath = path.relative(params.workspaceDir, absolutePath).replace(/\\/g, "/"); - artifacts.push({ - kind: relativePath.startsWith("memory/dreaming/") ? "dream-report" : "daily-note", - workspaceDir: params.workspaceDir, - relativePath, - absolutePath, - agentIds: [...params.agentIds], - contentType: "markdown", - }); - } - - const eventLogPath = resolveMemoryHostEventLogPath(params.workspaceDir); - if (await pathExists(eventLogPath)) { - artifacts.push({ - kind: "event-log", - workspaceDir: params.workspaceDir, - relativePath: path.relative(params.workspaceDir, eventLogPath).replace(/\\/g, "/"), - absolutePath: eventLogPath, - agentIds: [...params.agentIds], - contentType: "json", - }); - } - - const deduped = new Map(); - for (const artifact of artifacts) { - deduped.set(`${artifact.workspaceDir}\0${artifact.relativePath}\0${artifact.kind}`, artifact); - } - return [...deduped.values()]; -} - export async function listMemoryCorePublicArtifacts(params: { cfg: OpenClawConfig; }): Promise { - const workspaces = resolveMemoryDreamingWorkspaces(params.cfg); - const artifacts: MemoryPluginPublicArtifact[] = []; - for (const workspace of workspaces) { - artifacts.push( - ...(await collectWorkspaceArtifacts({ - workspaceDir: workspace.workspaceDir, - agentIds: workspace.agentIds, - })), - ); - } - return artifacts; + return await listMemoryHostPublicArtifacts(params); } diff --git a/extensions/memory-lancedb/index.test.ts b/extensions/memory-lancedb/index.test.ts index dd6d18ab343..2c07e93e532 100644 --- a/extensions/memory-lancedb/index.test.ts +++ b/extensions/memory-lancedb/index.test.ts @@ -9,7 +9,16 @@ */ import { Buffer } from "node:buffer"; -import { describe, test, expect, vi } from "vitest"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { + clearMemoryPluginState, + getMemoryCapabilityRegistration, + listActiveMemoryPublicArtifacts, + registerMemoryCapability, + type MemoryPluginCapability, +} from "openclaw/plugin-sdk/memory-host-core"; +import { afterEach, describe, test, expect, vi } from "vitest"; import memoryPlugin, { detectCategory, formatRelevantMemoriesContext, @@ -164,7 +173,11 @@ async function withMockedOpenAiMemoryPlugin(params: { } describe("memory plugin e2e", () => { - const { getDbPath } = installTmpDirHarness({ prefix: "openclaw-memory-test-" }); + const { getDbPath, getTmpDir } = installTmpDirHarness({ prefix: "openclaw-memory-test-" }); + + afterEach(() => { + clearMemoryPluginState(); + }); function parseConfig(overrides: Record = {}) { return memoryPlugin.configSchema?.parse?.({ @@ -340,6 +353,165 @@ describe("memory plugin e2e", () => { expectHookNotRegistered(on, "before_agent_start"); }); + test("registers memory public artifact provider for memory-wiki bridge parity", async () => { + const workspaceDir = path.join(getTmpDir(), "workspace-public-artifacts"); + await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true }); + await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# Durable Memory\n", "utf8"); + await fs.writeFile(path.join(workspaceDir, "memory", "2026-05-18.md"), "# Daily\n", "utf8"); + const registerMemoryCapability = vi.fn(); + const mockApi = { + id: "memory-lancedb", + name: "Memory (LanceDB)", + source: "test", + config: {}, + pluginConfig: { + embedding: { + apiKey: OPENAI_API_KEY, + model: "text-embedding-3-small", + }, + dbPath: getDbPath(), + autoCapture: false, + autoRecall: false, + }, + runtime: {}, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + registerMemoryCapability, + registerTool: vi.fn(), + registerCli: vi.fn(), + registerService: vi.fn(), + on: vi.fn(), + resolvePath: (filePath: string) => filePath, + }; + + memoryPlugin.register(mockApi as any); + const capability = firstObjectArg( + registerMemoryCapability as unknown as MockCallSource, + "memory capability", + ); + const publicArtifacts = capability.publicArtifacts as + | { listArtifacts?: (params: { cfg: unknown }) => Promise } + | undefined; + expect(publicArtifacts?.listArtifacts).toBeTypeOf("function"); + + await expect( + publicArtifacts?.listArtifacts?.({ + cfg: { + agents: { + list: [{ id: "main", default: true, workspace: workspaceDir }], + }, + }, + }), + ).resolves.toEqual([ + { + kind: "memory-root", + workspaceDir, + relativePath: "MEMORY.md", + absolutePath: path.join(workspaceDir, "MEMORY.md"), + agentIds: ["main"], + contentType: "markdown", + }, + { + kind: "daily-note", + workspaceDir, + relativePath: "memory/2026-05-18.md", + absolutePath: path.join(workspaceDir, "memory", "2026-05-18.md"), + agentIds: ["main"], + contentType: "markdown", + }, + ]); + }); + + test("preserves memory-core sidecar capability when registering public artifacts", async () => { + const workspaceDir = path.join(getTmpDir(), "workspace-sidecar-public-artifacts"); + await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true }); + await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# Durable Memory\n", "utf8"); + await fs.writeFile(path.join(workspaceDir, "memory", "2026-05-18.md"), "# Daily\n", "utf8"); + const runtime = { + async getMemorySearchManager() { + return { manager: null, error: "test" }; + }, + resolveMemoryBackendConfig() { + return { backend: "builtin" as const }; + }, + }; + const flushPlanResolver = vi.fn(() => ({ + softThresholdTokens: 1, + forceFlushTranscriptBytes: 2, + reserveTokensFloor: 3, + prompt: "flush", + systemPrompt: "flush", + relativePath: "memory/sidecar.md", + })); + registerMemoryCapability("memory-core", { + flushPlanResolver, + runtime, + }); + const registerMemoryCapabilityForPlugin = vi.fn((capability: MemoryPluginCapability) => { + registerMemoryCapability("memory-lancedb", capability); + }); + const mockApi = { + id: "memory-lancedb", + name: "Memory (LanceDB)", + source: "test", + config: {}, + pluginConfig: { + embedding: { + apiKey: OPENAI_API_KEY, + model: "text-embedding-3-small", + }, + dbPath: getDbPath(), + autoCapture: false, + autoRecall: false, + }, + runtime: {}, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + registerMemoryCapability: registerMemoryCapabilityForPlugin, + registerTool: vi.fn(), + registerCli: vi.fn(), + registerService: vi.fn(), + on: vi.fn(), + resolvePath: (filePath: string) => filePath, + }; + + memoryPlugin.register(mockApi as any); + + expect(registerMemoryCapabilityForPlugin).toHaveBeenCalledOnce(); + expect( + getMemoryCapabilityRegistration()?.capability.flushPlanResolver?.({})?.relativePath, + ).toBe("memory/sidecar.md"); + expect(getMemoryCapabilityRegistration()?.capability.runtime).toBe(runtime); + await expect( + listActiveMemoryPublicArtifacts({ + cfg: { + agents: { + list: [{ id: "main", default: true, workspace: workspaceDir }], + }, + }, + }), + ).resolves.toMatchObject([ + { + kind: "memory-root", + workspaceDir, + relativePath: "MEMORY.md", + }, + { + kind: "daily-note", + workspaceDir, + relativePath: "memory/2026-05-18.md", + }, + ]); + }); + test("uses provider adapter auth when embedding apiKey is omitted", async () => { const embedQuery = vi.fn(async () => [0.1, 0.2, 0.3]); const createProvider = vi.fn(async (options: Record) => ({ diff --git a/extensions/memory-lancedb/index.ts b/extensions/memory-lancedb/index.ts index 6dcb7627212..53c8ca9c48f 100644 --- a/extensions/memory-lancedb/index.ts +++ b/extensions/memory-lancedb/index.ts @@ -11,6 +11,7 @@ import { randomUUID } from "node:crypto"; import type * as LanceDB from "@lancedb/lancedb"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { MemoryEmbeddingProvider } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; +import { getMemoryCapabilityRegistration } from "openclaw/plugin-sdk/memory-host-core"; import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime"; import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -694,6 +695,16 @@ export default definePluginEntry({ }; api.logger.info(`memory-lancedb: plugin registered (db: ${resolvedDbPath}, lazy init)`); + const existingMemoryCapability = getMemoryCapabilityRegistration()?.capability; + api.registerMemoryCapability?.({ + ...existingMemoryCapability, + publicArtifacts: { + async listArtifacts(params) { + const { listMemoryHostPublicArtifacts } = await loadMemoryHostCoreModule(); + return await listMemoryHostPublicArtifacts(params); + }, + }, + }); // ======================================================================== // Tools diff --git a/src/plugin-sdk/memory-host-core.test.ts b/src/plugin-sdk/memory-host-core.test.ts index 6908edf7064..fa5d363a36e 100644 --- a/src/plugin-sdk/memory-host-core.test.ts +++ b/src/plugin-sdk/memory-host-core.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { clearMemoryPluginState, @@ -7,8 +10,10 @@ import { import * as memoryCoreAlias from "./memory-core.js"; import { buildActiveMemoryPromptSection, + listMemoryHostPublicArtifacts, listActiveMemoryPublicArtifacts, } from "./memory-host-core.js"; +import { appendMemoryHostEvent, resolveMemoryHostEventLogPath } from "./memory-host-events.js"; describe("memory-host-core helpers", () => { afterEach(() => { @@ -60,6 +65,77 @@ describe("memory-host-core helpers", () => { ]); }); + it("lists shared public artifacts from memory workspaces", async () => { + const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-host-public-artifacts-")); + try { + 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-05-18.md"), + "# Daily Note\n", + "utf8", + ); + await fs.writeFile( + path.join(workspaceDir, "memory", "dreaming", "2026-05-18.md"), + "# Dream Report\n", + "utf8", + ); + await appendMemoryHostEvent(workspaceDir, { + type: "memory.recall.recorded", + timestamp: "2026-05-18T12:00:00.000Z", + query: "bridge", + resultCount: 0, + results: [], + }); + + await expect( + listMemoryHostPublicArtifacts({ + cfg: { + agents: { + list: [{ id: "main", default: true, workspace: workspaceDir }], + }, + }, + }), + ).resolves.toEqual([ + { + kind: "memory-root", + workspaceDir, + relativePath: "MEMORY.md", + absolutePath: path.join(workspaceDir, "MEMORY.md"), + agentIds: ["main"], + contentType: "markdown", + }, + { + kind: "daily-note", + workspaceDir, + relativePath: "memory/2026-05-18.md", + absolutePath: path.join(workspaceDir, "memory", "2026-05-18.md"), + agentIds: ["main"], + contentType: "markdown", + }, + { + kind: "dream-report", + workspaceDir, + relativePath: "memory/dreaming/2026-05-18.md", + absolutePath: path.join(workspaceDir, "memory", "dreaming", "2026-05-18.md"), + agentIds: ["main"], + contentType: "markdown", + }, + { + kind: "event-log", + workspaceDir, + relativePath: "memory/.dreams/events.jsonl", + absolutePath: resolveMemoryHostEventLogPath(workspaceDir), + agentIds: ["main"], + contentType: "json", + }, + ]); + } finally { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } + }); + it("keeps the deprecated memory-core alias wired to memory-host-core", () => { expect(memoryCoreAlias.buildActiveMemoryPromptSection).toBe(buildActiveMemoryPromptSection); expect(memoryCoreAlias.listActiveMemoryPublicArtifacts).toBe(listActiveMemoryPublicArtifacts); diff --git a/src/plugin-sdk/memory-host-core.ts b/src/plugin-sdk/memory-host-core.ts index 38676c4c919..d064225a8bb 100644 --- a/src/plugin-sdk/memory-host-core.ts +++ b/src/plugin-sdk/memory-host-core.ts @@ -1 +1,104 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { OpenClawConfig } from "../config/config.js"; +import type { MemoryPluginPublicArtifact } from "../plugins/memory-state.js"; +import { resolveMemoryDreamingWorkspaces } from "./memory-core-host-status.js"; +import { resolveMemoryHostEventLogPath } from "./memory-host-events.js"; + export * from "./memory-core-host-runtime-core.js"; + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function listMarkdownFilesRecursive(rootDir: string): Promise { + const entries = await fs.readdir(rootDir, { withFileTypes: true }).catch(() => []); + const files: string[] = []; + for (const entry of entries) { + const fullPath = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + files.push(...(await listMarkdownFilesRecursive(fullPath))); + continue; + } + if (entry.isFile() && entry.name.endsWith(".md")) { + files.push(fullPath); + } + } + return files.toSorted((left, right) => left.localeCompare(right)); +} + +export async function listMemoryWorkspacePublicArtifacts(params: { + workspaceDir: string; + agentIds: string[]; +}): Promise { + const artifacts: MemoryPluginPublicArtifact[] = []; + const workspaceEntries = new Set( + (await fs.readdir(params.workspaceDir, { withFileTypes: true }).catch(() => [])) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name), + ); + + if (workspaceEntries.has("MEMORY.md")) { + const absolutePath = path.join(params.workspaceDir, "MEMORY.md"); + artifacts.push({ + kind: "memory-root", + workspaceDir: params.workspaceDir, + relativePath: "MEMORY.md", + absolutePath, + agentIds: [...params.agentIds], + contentType: "markdown", + }); + } + + const memoryDir = path.join(params.workspaceDir, "memory"); + for (const absolutePath of await listMarkdownFilesRecursive(memoryDir)) { + const relativePath = path.relative(params.workspaceDir, absolutePath).replace(/\\/g, "/"); + artifacts.push({ + kind: relativePath.startsWith("memory/dreaming/") ? "dream-report" : "daily-note", + workspaceDir: params.workspaceDir, + relativePath, + absolutePath, + agentIds: [...params.agentIds], + contentType: "markdown", + }); + } + + const eventLogPath = resolveMemoryHostEventLogPath(params.workspaceDir); + if (await pathExists(eventLogPath)) { + artifacts.push({ + kind: "event-log", + workspaceDir: params.workspaceDir, + relativePath: path.relative(params.workspaceDir, eventLogPath).replace(/\\/g, "/"), + absolutePath: eventLogPath, + agentIds: [...params.agentIds], + contentType: "json", + }); + } + + const deduped = new Map(); + for (const artifact of artifacts) { + deduped.set(`${artifact.workspaceDir}\0${artifact.relativePath}\0${artifact.kind}`, artifact); + } + return [...deduped.values()]; +} + +export async function listMemoryHostPublicArtifacts(params: { + cfg: OpenClawConfig; +}): Promise { + const workspaces = resolveMemoryDreamingWorkspaces(params.cfg); + const artifacts: MemoryPluginPublicArtifact[] = []; + for (const workspace of workspaces) { + artifacts.push( + ...(await listMemoryWorkspacePublicArtifacts({ + workspaceDir: workspace.workspaceDir, + agentIds: workspace.agentIds, + })), + ); + } + return artifacts; +}