Skip to content

Commit b98f363

Browse files
ngutmanBunsDev
andauthored
fix(memory): bootstrap lancedb runtime on demand (openclaw#53111)
Bootstrap LanceDB into plugin runtime state on first use for packaged/global installs, keep @lancedb/lancedb plugin-local, and add regression coverage for bundled, cached, retry, and Nix fail-fast runtime paths. Co-authored-by: Val Alexander <[email protected]>
1 parent fd5496d commit b98f363

File tree

6 files changed

+459
-23
lines changed

6 files changed

+459
-23
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai
4040
- ClawHub/macOS: read the local ClawHub login from the macOS Application Support path and still honor XDG config on macOS, so skill browsing uses the logged-in token on both default and XDG-style setups. Fixes #52949. Thanks @scoootscooob.
4141
- Discord/commands: return an explicit unauthorized reply for privileged native slash commands instead of falling through to Discord's misleading generic completion when auth gates reject the sender. Fixes #53041. Thanks @scoootscooob.
4242
- Models/OpenAI Codex OAuth: bootstrap the env-configured HTTP/HTTPS proxy dispatcher on the stored-credential refresh path before token renewal runs, so expired Codex OAuth profiles can refresh successfully in proxy-required environments instead of locking users out after the first token expiry.
43+
- Plugins/memory-lancedb: bootstrap LanceDB into plugin runtime state on first use when the bundled npm install does not already have it, so `plugins.slots.memory="memory-lancedb"` works again after global npm installs without moving LanceDB into OpenClaw core dependencies. Fixes #26100.
4344

4445
## 2026.3.22
4546

extensions/memory-lancedb/index.test.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -148,14 +148,7 @@ describe("memory plugin e2e", () => {
148148
const toArray = vi.fn(async () => []);
149149
const limit = vi.fn(() => ({ toArray }));
150150
const vectorSearch = vi.fn(() => ({ limit }));
151-
152-
vi.resetModules();
153-
vi.doMock("openai", () => ({
154-
default: class MockOpenAI {
155-
embeddings = { create: embeddingsCreate };
156-
},
157-
}));
158-
vi.doMock("@lancedb/lancedb", () => ({
151+
const loadLanceDbModule = vi.fn(async () => ({
159152
connect: vi.fn(async () => ({
160153
tableNames: vi.fn(async () => ["memories"]),
161154
openTable: vi.fn(async () => ({
@@ -167,6 +160,16 @@ describe("memory plugin e2e", () => {
167160
})),
168161
}));
169162

163+
vi.resetModules();
164+
vi.doMock("openai", () => ({
165+
default: class MockOpenAI {
166+
embeddings = { create: embeddingsCreate };
167+
},
168+
}));
169+
vi.doMock("./lancedb-runtime.js", () => ({
170+
loadLanceDbModule,
171+
}));
172+
170173
try {
171174
const { default: memoryPlugin } = await import("./index.js");
172175
// oxlint-disable-next-line typescript/no-explicit-any
@@ -214,14 +217,15 @@ describe("memory plugin e2e", () => {
214217
}
215218
await recallTool.execute("test-call-dims", { query: "hello dimensions" });
216219

220+
expect(loadLanceDbModule).toHaveBeenCalledTimes(1);
217221
expect(embeddingsCreate).toHaveBeenCalledWith({
218222
model: "text-embedding-3-small",
219223
input: "hello dimensions",
220224
dimensions: 1024,
221225
});
222226
} finally {
223227
vi.doUnmock("openai");
224-
vi.doUnmock("@lancedb/lancedb");
228+
vi.doUnmock("./lancedb-runtime.js");
225229
vi.resetModules();
226230
}
227231
});

extensions/memory-lancedb/index.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,12 @@ import {
1818
memoryConfigSchema,
1919
vectorDimsForModel,
2020
} from "./config.js";
21+
import { loadLanceDbModule } from "./lancedb-runtime.js";
2122

2223
// ============================================================================
2324
// Types
2425
// ============================================================================
2526

26-
let lancedbImportPromise: Promise<typeof import("@lancedb/lancedb")> | null = null;
27-
const loadLanceDB = async (): Promise<typeof import("@lancedb/lancedb")> => {
28-
if (!lancedbImportPromise) {
29-
lancedbImportPromise = import("@lancedb/lancedb");
30-
}
31-
try {
32-
return await lancedbImportPromise;
33-
} catch (err) {
34-
// Common on macOS today: upstream package may not ship darwin native bindings.
35-
throw new Error(`memory-lancedb: failed to load LanceDB. ${String(err)}`, { cause: err });
36-
}
37-
};
38-
3927
type MemoryEntry = {
4028
id: string;
4129
text: string;
@@ -79,7 +67,7 @@ class MemoryDB {
7967
}
8068

8169
private async doInitialize(): Promise<void> {
82-
const lancedb = await loadLanceDB();
70+
const lancedb = await loadLanceDbModule();
8371
this.db = await lancedb.connect(this.dbPath);
8472
const tables = await this.db.tableNames();
8573

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { createLanceDbRuntimeLoader, type LanceDbRuntimeLogger } from "./lancedb-runtime.js";
3+
4+
const TEST_RUNTIME_MANIFEST = {
5+
name: "openclaw-memory-lancedb-runtime",
6+
private: true as const,
7+
type: "module" as const,
8+
dependencies: {
9+
"@lancedb/lancedb": "^0.27.1",
10+
},
11+
};
12+
13+
type LanceDbModule = typeof import("@lancedb/lancedb");
14+
type RuntimeManifest = {
15+
name: string;
16+
private: true;
17+
type: "module";
18+
dependencies: Record<string, string>;
19+
};
20+
21+
function createMockModule(): LanceDbModule {
22+
return {
23+
connect: vi.fn(),
24+
} as unknown as LanceDbModule;
25+
}
26+
27+
function createLoader(
28+
overrides: {
29+
env?: NodeJS.ProcessEnv;
30+
importBundled?: () => Promise<LanceDbModule>;
31+
importResolved?: (resolvedPath: string) => Promise<LanceDbModule>;
32+
resolveRuntimeEntry?: (params: {
33+
runtimeDir: string;
34+
manifest: RuntimeManifest;
35+
}) => string | null;
36+
installRuntime?: (params: {
37+
runtimeDir: string;
38+
manifest: RuntimeManifest;
39+
env: NodeJS.ProcessEnv;
40+
logger?: LanceDbRuntimeLogger;
41+
}) => Promise<string>;
42+
} = {},
43+
) {
44+
return createLanceDbRuntimeLoader({
45+
env: overrides.env ?? ({} as NodeJS.ProcessEnv),
46+
resolveStateDir: () => "/tmp/openclaw-state",
47+
runtimeManifest: TEST_RUNTIME_MANIFEST,
48+
importBundled:
49+
overrides.importBundled ??
50+
(async () => {
51+
throw new Error("Cannot find package '@lancedb/lancedb'");
52+
}),
53+
importResolved: overrides.importResolved ?? (async () => createMockModule()),
54+
resolveRuntimeEntry: overrides.resolveRuntimeEntry ?? (() => null),
55+
installRuntime:
56+
overrides.installRuntime ??
57+
(async ({ runtimeDir }: { runtimeDir: string }) =>
58+
`${runtimeDir}/node_modules/@lancedb/lancedb/index.js`),
59+
});
60+
}
61+
62+
describe("lancedb runtime loader", () => {
63+
it("uses the bundled module when it is already available", async () => {
64+
const bundledModule = createMockModule();
65+
const importBundled = vi.fn(async () => bundledModule);
66+
const importResolved = vi.fn(async () => createMockModule());
67+
const resolveRuntimeEntry = vi.fn(() => null);
68+
const installRuntime = vi.fn(async () => "/tmp/openclaw-state/plugin-runtimes/lancedb.js");
69+
const loader = createLoader({
70+
importBundled,
71+
importResolved,
72+
resolveRuntimeEntry,
73+
installRuntime,
74+
});
75+
76+
await expect(loader.load()).resolves.toBe(bundledModule);
77+
78+
expect(resolveRuntimeEntry).not.toHaveBeenCalled();
79+
expect(installRuntime).not.toHaveBeenCalled();
80+
expect(importResolved).not.toHaveBeenCalled();
81+
});
82+
83+
it("reuses an existing user runtime install before attempting a reinstall", async () => {
84+
const runtimeModule = createMockModule();
85+
const importResolved = vi.fn(async () => runtimeModule);
86+
const resolveRuntimeEntry = vi.fn(
87+
() => "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/runtime-entry.js",
88+
);
89+
const installRuntime = vi.fn(
90+
async () => "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/runtime-entry.js",
91+
);
92+
const loader = createLoader({
93+
importResolved,
94+
resolveRuntimeEntry,
95+
installRuntime,
96+
});
97+
98+
await expect(loader.load()).resolves.toBe(runtimeModule);
99+
100+
expect(resolveRuntimeEntry).toHaveBeenCalledWith(
101+
expect.objectContaining({
102+
runtimeDir: "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb",
103+
}),
104+
);
105+
expect(installRuntime).not.toHaveBeenCalled();
106+
});
107+
108+
it("installs LanceDB into user state when the bundled runtime is unavailable", async () => {
109+
const runtimeModule = createMockModule();
110+
const logger: LanceDbRuntimeLogger = {
111+
warn: vi.fn(),
112+
info: vi.fn(),
113+
};
114+
const importResolved = vi.fn(async () => runtimeModule);
115+
const resolveRuntimeEntry = vi.fn(() => null);
116+
const installRuntime = vi.fn(
117+
async ({ runtimeDir }: { runtimeDir: string }) =>
118+
`${runtimeDir}/node_modules/@lancedb/lancedb/index.js`,
119+
);
120+
const loader = createLoader({
121+
importResolved,
122+
resolveRuntimeEntry,
123+
installRuntime,
124+
});
125+
126+
await expect(loader.load(logger)).resolves.toBe(runtimeModule);
127+
128+
expect(installRuntime).toHaveBeenCalledWith(
129+
expect.objectContaining({
130+
runtimeDir: "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb",
131+
manifest: TEST_RUNTIME_MANIFEST,
132+
}),
133+
);
134+
expect(logger.warn).toHaveBeenCalledWith(
135+
expect.stringContaining(
136+
"installing runtime deps under /tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb",
137+
),
138+
);
139+
});
140+
141+
it("fails fast in nix mode instead of attempting auto-install", async () => {
142+
const installRuntime = vi.fn(
143+
async ({ runtimeDir }: { runtimeDir: string }) =>
144+
`${runtimeDir}/node_modules/@lancedb/lancedb/index.js`,
145+
);
146+
const loader = createLoader({
147+
env: { OPENCLAW_NIX_MODE: "1" } as NodeJS.ProcessEnv,
148+
installRuntime,
149+
});
150+
151+
await expect(loader.load()).rejects.toThrow(
152+
"memory-lancedb: failed to load LanceDB and Nix mode disables auto-install.",
153+
);
154+
expect(installRuntime).not.toHaveBeenCalled();
155+
});
156+
157+
it("clears the cached failure so later calls can retry the install", async () => {
158+
const runtimeModule = createMockModule();
159+
const installRuntime = vi
160+
.fn()
161+
.mockRejectedValueOnce(new Error("network down"))
162+
.mockResolvedValueOnce(
163+
"/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb/node_modules/@lancedb/lancedb/index.js",
164+
);
165+
const importResolved = vi.fn(async () => runtimeModule);
166+
const loader = createLoader({
167+
installRuntime,
168+
importResolved,
169+
});
170+
171+
await expect(loader.load()).rejects.toThrow("network down");
172+
await expect(loader.load()).resolves.toBe(runtimeModule);
173+
174+
expect(installRuntime).toHaveBeenCalledTimes(2);
175+
});
176+
});

0 commit comments

Comments
 (0)