feat(memory-lancedb): add cloud storage support to memory-lancedb (#63502)

* feat(memory-lancedb): add cloud storage support to memory-lancedb

- Pass storageOptions to LanceDB connection

# Conflicts:
#	extensions/memory-lancedb/index.ts

# Conflicts:
#	extensions/memory-lancedb/config.ts

* support env var

* make storageOptions sensitive
This commit is contained in:
Xin Sun
2026-04-15 16:07:49 +08:00
committed by GitHub
parent 94d5c3dd6b
commit df918c4de5
4 changed files with 136 additions and 4 deletions

View File

@@ -15,6 +15,7 @@ export type MemoryConfig = {
autoCapture?: boolean;
autoRecall?: boolean;
captureMaxChars?: number;
storageOptions?: Record<string, string>;
};
export const MEMORY_CATEGORIES = ["preference", "fact", "decision", "entity", "other"] as const;
@@ -98,7 +99,7 @@ export const memoryConfigSchema = {
const cfg = value as Record<string, unknown>;
assertAllowedKeys(
cfg,
["embedding", "dreaming", "dbPath", "autoCapture", "autoRecall", "captureMaxChars"],
["embedding", "dreaming", "dbPath", "autoCapture", "autoRecall", "captureMaxChars", "storageOptions"],
"memory config",
);
@@ -128,6 +129,23 @@ export const memoryConfigSchema = {
throw new Error("dreaming config must be an object");
})();
// Parse storageOptions (object with string values)
let storageOptions: Record<string, string> | undefined;
const storageOpts = cfg.storageOptions as Record<string, unknown> | undefined;
if (storageOpts !== undefined && storageOpts !== null) {
if (!storageOpts || typeof storageOpts !== "object" || Array.isArray(storageOpts)) {
throw new Error("storageOptions must be an object");
}
storageOptions = {};
// Validate all values are strings
for (const [key, value] of Object.entries(storageOpts)) {
if (typeof value !== "string") {
throw new Error(`storageOptions.${key} must be a string`);
}
storageOptions[key] = resolveEnvVars(value);
}
}
return {
embedding: {
provider: "openai",
@@ -142,6 +160,7 @@ export const memoryConfigSchema = {
autoCapture: cfg.autoCapture === true,
autoRecall: cfg.autoRecall !== false,
captureMaxChars: captureMaxChars ?? DEFAULT_CAPTURE_MAX_CHARS,
...(storageOptions ? { storageOptions } : {}),
};
},
uiHints: {
@@ -172,6 +191,7 @@ export const memoryConfigSchema = {
label: "Database Path",
placeholder: "~/.openclaw/memory/lancedb",
advanced: true,
help: "Local filesystem path or cloud storage URI (s3://, gs://) for LanceDB database",
},
autoCapture: {
label: "Auto-Capture",
@@ -187,5 +207,11 @@ export const memoryConfigSchema = {
advanced: true,
placeholder: String(DEFAULT_CAPTURE_MAX_CHARS),
},
storageOptions: {
label: "Storage Options",
sensitive: true,
advanced: true,
help: "Storage configuration options (access_key, secret_key, endpoint, etc.); supports ${ENV_VAR} values",
},
},
};

View File

@@ -31,6 +31,7 @@ type MemoryPluginTestConfig = {
captureMaxChars?: number;
autoCapture?: boolean;
autoRecall?: boolean;
storageOptions?: Record<string, string>;
};
const TEST_RUNTIME_MANIFEST = {
@@ -279,6 +280,95 @@ describe("memory plugin e2e", () => {
}
});
test("config schema accepts storageOptions with string values", async () => {
const { default: memoryPlugin } = await import("./index.js");
const config = memoryPlugin.configSchema?.parse?.({
embedding: {
apiKey: OPENAI_API_KEY,
model: "text-embedding-3-small",
},
dbPath: getDbPath(),
storageOptions: {
region: "us-west-2",
access_key: "test-key",
secret_key: "test-secret",
},
}) as MemoryPluginTestConfig | undefined;
expect(config?.storageOptions).toEqual({
region: "us-west-2",
access_key: "test-key",
secret_key: "test-secret",
});
});
test("config schema resolves env vars in storageOptions", async () => {
const { default: memoryPlugin } = await import("./index.js");
process.env.TEST_MEMORY_STORAGE_ACCESS_KEY = "env-access";
process.env.TEST_MEMORY_STORAGE_SECRET_KEY = "env-secret";
try {
const config = memoryPlugin.configSchema?.parse?.({
embedding: {
apiKey: OPENAI_API_KEY,
model: "text-embedding-3-small",
},
dbPath: getDbPath(),
storageOptions: {
region: "us-west-2",
access_key: "${TEST_MEMORY_STORAGE_ACCESS_KEY}",
secret_key: "${TEST_MEMORY_STORAGE_SECRET_KEY}",
},
}) as MemoryPluginTestConfig | undefined;
expect(config?.storageOptions).toEqual({
region: "us-west-2",
access_key: "env-access",
secret_key: "env-secret",
});
} finally {
delete process.env.TEST_MEMORY_STORAGE_ACCESS_KEY;
delete process.env.TEST_MEMORY_STORAGE_SECRET_KEY;
}
});
test("config schema rejects missing env vars in storageOptions", async () => {
const { default: memoryPlugin } = await import("./index.js");
delete process.env.TEST_MEMORY_STORAGE_MISSING;
expect(() => {
memoryPlugin.configSchema?.parse?.({
embedding: {
apiKey: OPENAI_API_KEY,
model: "text-embedding-3-small",
},
dbPath: getDbPath(),
storageOptions: {
secret_key: "${TEST_MEMORY_STORAGE_MISSING}",
},
});
}).toThrow("Environment variable TEST_MEMORY_STORAGE_MISSING is not set");
});
test("config schema rejects storageOptions with non-string values", async () => {
const { default: memoryPlugin } = await import("./index.js");
expect(() => {
memoryPlugin.configSchema?.parse?.({
embedding: {
apiKey: OPENAI_API_KEY,
model: "text-embedding-3-small",
},
dbPath: getDbPath(),
storageOptions: {
region: "us-west-2",
timeout: 30, // number, should fail
},
});
}).toThrow("storageOptions.timeout must be a string");
});
test("shouldCapture applies real capture rules", async () => {
expect(shouldCapture("I prefer dark mode")).toBe(true);
expect(shouldCapture("Remember that my name is John")).toBe(true);

View File

@@ -56,6 +56,7 @@ class MemoryDB {
constructor(
private readonly dbPath: string,
private readonly vectorDim: number,
private readonly storageOptions?: Record<string, string>,
) {}
private async ensureInitialized(): Promise<void> {
@@ -72,7 +73,10 @@ class MemoryDB {
private async doInitialize(): Promise<void> {
const lancedb = await loadLanceDbModule();
this.db = await lancedb.connect(this.dbPath);
const connectionOptions: LanceDB.ConnectionOptions = this.storageOptions
? { storageOptions: this.storageOptions }
: {};
this.db = await lancedb.connect(this.dbPath, connectionOptions);
const tables = await this.db.tableNames();
if (tables.includes(TABLE_NAME)) {
@@ -291,11 +295,12 @@ export default definePluginEntry({
register(api: OpenClawPluginApi) {
const cfg = memoryConfigSchema.parse(api.pluginConfig);
const resolvedDbPath = api.resolvePath(cfg.dbPath!);
const dbPath = cfg.dbPath!;
const resolvedDbPath = dbPath.includes("://") ? dbPath : api.resolvePath(dbPath);
const { model, dimensions, apiKey, baseUrl } = cfg.embedding;
const vectorDim = dimensions ?? vectorDimsForModel(model);
const db = new MemoryDB(resolvedDbPath, vectorDim);
const db = new MemoryDB(resolvedDbPath, vectorDim, cfg.storageOptions);
const embeddings = new Embeddings(apiKey, model, baseUrl, dimensions);
api.logger.info(`memory-lancedb: plugin registered (db: ${resolvedDbPath}, lazy init)`);

View File

@@ -47,6 +47,11 @@
"help": "Maximum message length eligible for auto-capture",
"advanced": true,
"placeholder": "500"
},
"storageOptions": {
"label": "Storage Options",
"advanced": true,
"help": "Storage configuration options (access_key, secret_key, endpoint, etc.); supports ${ENV_VAR} values"
}
},
"configSchema": {
@@ -88,6 +93,12 @@
"type": "number",
"minimum": 100,
"maximum": 10000
},
"storageOptions": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": ["embedding"]