fix(memory-lancedb): expose public memory artifacts

This commit is contained in:
brokemac79
2026-05-21 19:11:24 +01:00
committed by Peter Steinberger
parent 6657b493e2
commit aac1abeaff
5 changed files with 369 additions and 92 deletions

View File

@@ -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<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[] = [];
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<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;
return await listMemoryHostPublicArtifacts(params);
}

View File

@@ -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<T>(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<string, unknown> = {}) {
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<unknown> }
| 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<string, unknown>) => ({

View File

@@ -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

View File

@@ -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);

View File

@@ -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<boolean> {
try {
await fs.access(filePath);
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));
}
export async function listMemoryWorkspacePublicArtifacts(params: {
workspaceDir: string;
agentIds: string[];
}): Promise<MemoryPluginPublicArtifact[]> {
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<string, MemoryPluginPublicArtifact>();
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<MemoryPluginPublicArtifact[]> {
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;
}