Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 15 additions & 20 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { spawn } from "node:child_process";
const isCliMode = () => process.env.OPENCLAW_CLI === "1";

// Import core components
import { MemoryStore, normalizeStoragePath, validateStoragePath, type MemoryEntry } from "./src/store.js";
import { MemoryStore, normalizeStoragePath, type MemoryEntry } from "./src/store.js";
import {
createEmbedder,
getEffectiveVectorDimensions,
Expand Down Expand Up @@ -1991,25 +1991,20 @@ interface PluginSingletonState {

let _singletonState: PluginSingletonState | null = null;

function _initPluginState(api: OpenClawPluginApi): PluginSingletonState {
const config = parsePluginConfig(api.pluginConfig);
let resolvedDbPath = normalizeStoragePath(api.resolvePath(config.dbPath || getDefaultDbPath()));

try {
resolvedDbPath = validateStoragePath(resolvedDbPath);
} catch (err) {
api.logger.warn(
`memory-lancedb-pro: storage path issue — ${String(err)}\n` +
` The plugin will still attempt to start, but writes may fail.`,
);
}

const vectorDim = getEffectiveVectorDimensions(
config.embedding.model || "text-embedding-3-small",
config.embedding.dimensions,
config.embedding.requestDimensions,
);
const store = new MemoryStore({ dbPath: resolvedDbPath, vectorDim });
function _initPluginState(api: OpenClawPluginApi): PluginSingletonState {
const config = parsePluginConfig(api.pluginConfig);
let resolvedDbPath = normalizeStoragePath(api.resolvePath(config.dbPath || getDefaultDbPath()));

const vectorDim = getEffectiveVectorDimensions(
config.embedding.model || "text-embedding-3-small",
config.embedding.dimensions,
config.embedding.requestDimensions,
);
const store = new MemoryStore({
dbPath: resolvedDbPath,
vectorDim,
onStoragePathWarning: (message) => api.logger.warn(message),
});
const embedder = createEmbedder({
provider: "openai-compatible",
apiKey: config.embedding.apiKey,
Expand Down
88 changes: 88 additions & 0 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ import {
unlinkSync,
rmdirSync,
} from "node:fs";
import {
access as accessAsync,
lstat as lstatAsync,
mkdir as mkdirAsync,
realpath as realpathAsync,
} from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { buildSmartMetadata, isMemoryActiveAt, parseSmartMetadata, stringifySmartMetadata } from "./smart-metadata.js";
Expand Down Expand Up @@ -43,6 +49,7 @@ export interface MemorySearchResult {
export interface StoreConfig {
dbPath: string;
vectorDim: number;
onStoragePathWarning?: (message: string) => void;
}

export interface MetadataPatch {
Expand Down Expand Up @@ -235,6 +242,78 @@ export function validateStoragePath(dbPath: string): string {
return resolvedPath;
}

/**
* Async variant of {@link validateStoragePath}. Use this on runtime paths so
* slow filesystems do not block OpenClaw's event loop during startup.
*/
export async function validateStoragePathAsync(dbPath: string): Promise<string> {
let resolvedPath = normalizeStoragePath(dbPath);

// Resolve symlinks (including dangling symlinks)
try {
const stats = await lstatAsync(dbPath);
if (stats.isSymbolicLink()) {
try {
resolvedPath = await realpathAsync(dbPath);
} catch (err: any) {
throw new Error(
`dbPath "${dbPath}" is a symlink whose target does not exist.\n` +
` Fix: Create the target directory, or update the symlink to point to a valid path.\n` +
` Details: ${err.code || ""} ${err.message}`,
);
}
}
} catch (err: any) {
// Missing path is OK (it will be created below)
if (err?.code === "ENOENT") {
// no-op
} else if (
typeof err?.message === "string" &&
err.message.includes("symlink whose target does not exist")
) {
throw err;
} else {
// Other lstat failures — continue with original path
}
}

// Create directory if it doesn't exist
let pathExists = false;
try {
await accessAsync(resolvedPath, constants.F_OK);
pathExists = true;
} catch {
pathExists = false;
}

if (!pathExists) {
try {
await mkdirAsync(resolvedPath, { recursive: true });
} catch (err: any) {
throw new Error(
`Failed to create dbPath directory "${resolvedPath}".\n` +
` Fix: Ensure the parent directory "${dirname(resolvedPath)}" exists and is writable,\n` +
` or create it manually: mkdir -p "${resolvedPath}"\n` +
` Details: ${err.code || ""} ${err.message}`,
);
}
}

// Check write permissions
try {
await accessAsync(resolvedPath, constants.W_OK);
} catch (err: any) {
throw new Error(
`dbPath directory "${resolvedPath}" is not writable.\n` +
` Fix: Check permissions with: ls -la "${dirname(resolvedPath)}"\n` +
` Or grant write access: chmod u+w "${resolvedPath}"\n` +
` Details: ${err.code || ""} ${err.message}`,
);
}

return resolvedPath;
}

// ============================================================================
// Memory Store
// ============================================================================
Expand Down Expand Up @@ -420,6 +499,15 @@ export class MemoryStore {
}

private async doInitialize(): Promise<void> {
try {
this.config.dbPath = await validateStoragePathAsync(this.config.dbPath);
} catch (err) {
this.config.onStoragePathWarning?.(
`memory-lancedb-pro: storage path issue — ${String(err)}\n` +
` The plugin will still attempt to start, but writes may fail.`,
);
}

const lancedb = await loadLanceDB();

let db: LanceDB.Connection;
Expand Down
11 changes: 9 additions & 2 deletions test/plugin-manifest-regression.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { mkdtempSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import http from "node:http";
import Module from "node:module";
import { tmpdir } from "node:os";
Expand Down Expand Up @@ -150,9 +150,10 @@ const services = [];
const embeddingRequests = [];

try {
const startupDbPath = path.join(workDir, "db");
const api = createMockApi(
{
dbPath: path.join(workDir, "db"),
dbPath: startupDbPath,
autoRecall: false,
embedding: {
provider: "openai-compatible",
Expand All @@ -165,7 +166,13 @@ try {
{ services },
);
resetRegistration();
assert.equal(existsSync(startupDbPath), false, "test dbPath should start missing");
plugin.register(api);
assert.equal(
existsSync(startupDbPath),
false,
"plugin registration should not synchronously create or validate dbPath",
);
assert.equal(services.length, 1, "plugin should register its background service");
assert.equal(typeof api.hooks.agent_end, "function", "autoCapture should remain enabled by default");
assert.equal(typeof api.hooks["command:new"], "function", "selfImprovement command:new hook should be registered by default (#391)");
Expand Down
16 changes: 15 additions & 1 deletion test/storage-path-normalization.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import { pathToFileURL } from "node:url";
import jitiFactory from "jiti";

const jiti = jitiFactory(import.meta.url, { interopDefault: true });
const { MemoryStore, normalizeStoragePath, validateStoragePath } = jiti("../src/store.ts");
const {
MemoryStore,
normalizeStoragePath,
validateStoragePath,
validateStoragePathAsync,
} = jiti("../src/store.ts");

describe("storage path normalization", () => {
it("converts Windows drive-letter file URLs to native paths", () => {
Expand Down Expand Up @@ -49,4 +54,13 @@ describe("storage path normalization", () => {
rmSync(dir, { recursive: true, force: true });
}
});

it("validates local file URLs asynchronously using native filesystem paths", async () => {
const dir = mkdtempSync(join(tmpdir(), "memory-lancedb-pro-file-url-async-"));
try {
assert.equal(await validateStoragePathAsync(pathToFileURL(dir).href), dir);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
Loading