diff --git a/apps/memos-local-openclaw/index.ts b/apps/memos-local-openclaw/index.ts index d84d94dcd..557a9d00f 100644 --- a/apps/memos-local-openclaw/index.ts +++ b/apps/memos-local-openclaw/index.ts @@ -22,6 +22,7 @@ import { SkillInstaller } from "./src/skill/installer"; import { Summarizer } from "./src/ingest/providers"; import { MEMORY_GUIDE_SKILL_MD } from "./src/skill/bundled-memory-guide"; import { Telemetry } from "./src/telemetry"; +import { ensureBetterSqlite3Available } from "./src/runtime/sqlite-bootstrap"; /** Remove near-duplicate hits based on summary word overlap (>70%). Keeps first (highest-scored) hit. */ @@ -75,86 +76,7 @@ const memosLocalPlugin = { configSchema: pluginConfigSchema, register(api: OpenClawPluginApi) { - // ─── Ensure better-sqlite3 native module is available ─── - const pluginDir = path.dirname(new URL(import.meta.url).pathname); - let sqliteReady = false; - - function trySqliteLoad(): boolean { - try { - const resolved = require.resolve("better-sqlite3", { paths: [pluginDir] }); - if (!resolved.startsWith(pluginDir)) { - api.logger.warn(`memos-local: better-sqlite3 resolved outside plugin dir: ${resolved}`); - return false; - } - require(resolved); - return true; - } catch { - return false; - } - } - - sqliteReady = trySqliteLoad(); - - if (!sqliteReady) { - api.logger.warn(`memos-local: better-sqlite3 not found in ${pluginDir}, attempting auto-rebuild ...`); - - try { - const { spawnSync } = require("child_process"); - const rebuildResult = spawnSync("npm", ["rebuild", "better-sqlite3"], { - cwd: pluginDir, - stdio: "pipe", - shell: true, - timeout: 120_000, - }); - - const stdout = rebuildResult.stdout?.toString() || ""; - const stderr = rebuildResult.stderr?.toString() || ""; - if (stdout) api.logger.info(`memos-local: rebuild stdout: ${stdout.slice(0, 500)}`); - if (stderr) api.logger.warn(`memos-local: rebuild stderr: ${stderr.slice(0, 500)}`); - - if (rebuildResult.status === 0) { - Object.keys(require.cache) - .filter(k => k.includes("better-sqlite3") || k.includes("better_sqlite3")) - .forEach(k => delete require.cache[k]); - sqliteReady = trySqliteLoad(); - if (sqliteReady) { - api.logger.info("memos-local: better-sqlite3 auto-rebuild succeeded!"); - } else { - api.logger.warn("memos-local: rebuild exited 0 but module still not loadable from plugin dir"); - } - } else { - api.logger.warn(`memos-local: rebuild exited with code ${rebuildResult.status}`); - } - } catch (rebuildErr) { - api.logger.warn(`memos-local: auto-rebuild error: ${rebuildErr}`); - } - - if (!sqliteReady) { - const msg = [ - "", - "╔══════════════════════════════════════════════════════════════╗", - "║ MemOS Local Memory — better-sqlite3 native module missing ║", - "╠══════════════════════════════════════════════════════════════╣", - "║ ║", - "║ Auto-rebuild failed. Run these commands manually: ║", - "║ ║", - `║ cd ${pluginDir}`, - "║ npm rebuild better-sqlite3 ║", - "║ openclaw gateway stop && openclaw gateway start ║", - "║ ║", - "║ If rebuild fails, install build tools first: ║", - "║ macOS: xcode-select --install ║", - "║ Linux: sudo apt install build-essential python3 ║", - "║ ║", - "╚══════════════════════════════════════════════════════════════╝", - "", - ].join("\n"); - api.logger.warn(msg); - throw new Error( - `better-sqlite3 native module not found. Auto-rebuild failed. Fix: cd ${pluginDir} && npm rebuild better-sqlite3` - ); - } - } + const pluginDir = ensureBetterSqlite3Available(api, import.meta.url); const pluginCfg = (api.pluginConfig ?? {}) as Record; const stateDir = api.resolvePath("~/.openclaw"); @@ -178,7 +100,7 @@ const memosLocalPlugin = { let pluginVersion = "0.0.0"; try { - const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf-8")); + const pkg = JSON.parse(fs.readFileSync(path.join(pluginDir, "package.json"), "utf-8")); pluginVersion = pkg.version ?? pluginVersion; } catch {} const telemetry = new Telemetry(ctx.config.telemetry ?? {}, stateDir, pluginVersion, ctx.log); diff --git a/apps/memos-local-openclaw/src/runtime/sqlite-bootstrap.ts b/apps/memos-local-openclaw/src/runtime/sqlite-bootstrap.ts new file mode 100644 index 000000000..471e976d2 --- /dev/null +++ b/apps/memos-local-openclaw/src/runtime/sqlite-bootstrap.ts @@ -0,0 +1,226 @@ +import { spawnSync } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import { createRequire } from "module"; +import { fileURLToPath } from "url"; + +type Logger = { + info: (message: string) => void; + warn: (message: string) => void; +}; + +type BootstrapApi = { + logger: Logger; +}; + +type RebuildResult = { + status: number | null; + stdout?: string | Buffer | null; + stderr?: string | Buffer | null; +}; + +export type BetterSqliteRuntime = { + resolveFromPluginDir: (pluginDir: string) => string; + load: (resolvedPath: string) => void; + rebuild: (pluginDir: string) => RebuildResult; + clearCache: () => void; +}; + +function fileUrlToPathWithWindowsFallback( + importMetaUrl: string, + platform: NodeJS.Platform, +): string { + const nativePath = fileURLToPath(importMetaUrl); + if (platform !== "win32" || process.platform === "win32" || !/^\/[A-Za-z]:/.test(nativePath)) { + return nativePath; + } + + const parsedUrl = new URL(importMetaUrl); + if (parsedUrl.protocol !== "file:") { + return nativePath; + } + + if (parsedUrl.hostname) { + const uncPath = decodeURIComponent(parsedUrl.pathname || "").replace(/\//g, "\\"); + return `\\\\${parsedUrl.hostname}${uncPath}`; + } + + let pathname = decodeURIComponent(parsedUrl.pathname || ""); + if (/^\/[A-Za-z]:/.test(pathname)) { + pathname = pathname.slice(1); + } + + return pathname.replace(/\//g, "\\"); +} + +export function getPluginDirFromImportMeta( + importMetaUrl: string, + platform: NodeJS.Platform = process.platform, +): string { + const pathApi = platform === "win32" ? path.win32 : path.posix; + return pathApi.dirname(fileUrlToPathWithWindowsFallback(importMetaUrl, platform)); +} + +function canonicalizePath(fsPath: string, platform: NodeJS.Platform): string { + const pathApi = platform === "win32" ? path.win32 : path.posix; + + let resolved = pathApi.resolve(fsPath); + if (platform === process.platform) { + try { + resolved = fs.realpathSync.native?.(fsPath) ?? fs.realpathSync(fsPath); + } catch { + resolved = pathApi.resolve(fsPath); + } + } + + if (platform === "win32") { + return resolved.replace(/\//g, "\\").toLowerCase(); + } + + return resolved; +} + +export function isPathWithinDir( + candidatePath: string, + baseDir: string, + platform: NodeJS.Platform = process.platform, +): boolean { + const pathApi = platform === "win32" ? path.win32 : path.posix; + const candidate = canonicalizePath(candidatePath, platform); + const base = canonicalizePath(baseDir, platform); + const relative = pathApi.relative(base, candidate); + + return ( + relative === "" || + (!(relative === ".." || relative.startsWith(`..${pathApi.sep}`)) && !pathApi.isAbsolute(relative)) + ); +} + +function createBetterSqliteRuntime(importMetaUrl: string): BetterSqliteRuntime { + const runtimeRequire = createRequire(importMetaUrl); + + return { + resolveFromPluginDir(pluginDir: string): string { + return runtimeRequire.resolve("better-sqlite3", { paths: [pluginDir] }); + }, + load(resolvedPath: string): void { + runtimeRequire(resolvedPath); + }, + rebuild(pluginDir: string): RebuildResult { + return spawnSync("npm", ["rebuild", "better-sqlite3"], { + cwd: pluginDir, + stdio: "pipe", + shell: true, + timeout: 120_000, + }); + }, + clearCache(): void { + Object.keys(runtimeRequire.cache) + .filter((cacheKey) => cacheKey.includes("better-sqlite3") || cacheKey.includes("better_sqlite3")) + .forEach((cacheKey) => delete runtimeRequire.cache[cacheKey]); + }, + }; +} + +function tryLoadBetterSqlite( + api: BootstrapApi, + pluginDir: string, + platform: NodeJS.Platform, + runtime: BetterSqliteRuntime, +): boolean { + try { + const resolved = runtime.resolveFromPluginDir(pluginDir); + if (!isPathWithinDir(resolved, pluginDir, platform)) { + api.logger.warn(`memos-local: better-sqlite3 resolved outside plugin dir: ${resolved}`); + return false; + } + + runtime.load(resolved); + return true; + } catch { + return false; + } +} + +function formatOutputSnippet(output: string | Buffer | null | undefined): string { + if (!output) { + return ""; + } + + return output.toString().slice(0, 500); +} + +export function ensureBetterSqlite3Available( + api: BootstrapApi, + importMetaUrl: string, + options: { + platform?: NodeJS.Platform; + runtime?: BetterSqliteRuntime; + } = {}, +): string { + const platform = options.platform ?? process.platform; + const pluginDir = getPluginDirFromImportMeta(importMetaUrl, platform); + const runtime = options.runtime ?? createBetterSqliteRuntime(importMetaUrl); + + let sqliteReady = tryLoadBetterSqlite(api, pluginDir, platform, runtime); + if (sqliteReady) { + return pluginDir; + } + + api.logger.warn(`memos-local: better-sqlite3 not found in ${pluginDir}, attempting auto-rebuild ...`); + + try { + const rebuildResult = runtime.rebuild(pluginDir); + const stdout = formatOutputSnippet(rebuildResult.stdout); + const stderr = formatOutputSnippet(rebuildResult.stderr); + + if (stdout) { + api.logger.info(`memos-local: rebuild stdout: ${stdout}`); + } + if (stderr) { + api.logger.warn(`memos-local: rebuild stderr: ${stderr}`); + } + + if (rebuildResult.status === 0) { + runtime.clearCache(); + sqliteReady = tryLoadBetterSqlite(api, pluginDir, platform, runtime); + if (sqliteReady) { + api.logger.info("memos-local: better-sqlite3 auto-rebuild succeeded!"); + } else { + api.logger.warn("memos-local: rebuild exited 0 but module still not loadable from plugin dir"); + } + } else { + api.logger.warn(`memos-local: rebuild exited with code ${rebuildResult.status}`); + } + } catch (rebuildErr) { + api.logger.warn(`memos-local: auto-rebuild error: ${rebuildErr}`); + } + + if (sqliteReady) { + return pluginDir; + } + + const msg = [ + "", + "╔══════════════════════════════════════════════════════════════╗", + "║ MemOS Local Memory — better-sqlite3 native module missing ║", + "╠══════════════════════════════════════════════════════════════╣", + "║ ║", + "║ Auto-rebuild failed. Run these commands manually: ║", + "║ ║", + `║ cd ${pluginDir}`, + "║ npm rebuild better-sqlite3 ║", + "║ openclaw gateway stop && openclaw gateway start ║", + "║ ║", + "║ If rebuild fails, install build tools first: ║", + "║ macOS: xcode-select --install ║", + "║ Linux: sudo apt install build-essential python3 ║", + "║ ║", + "╚══════════════════════════════════════════════════════════════╝", + "", + ].join("\n"); + api.logger.warn(msg); + throw new Error( + `better-sqlite3 native module not found. Auto-rebuild failed. Fix: cd ${pluginDir} && npm rebuild better-sqlite3`, + ); +} diff --git a/apps/memos-local-openclaw/tests/sqlite-bootstrap.test.ts b/apps/memos-local-openclaw/tests/sqlite-bootstrap.test.ts new file mode 100644 index 000000000..3b1295278 --- /dev/null +++ b/apps/memos-local-openclaw/tests/sqlite-bootstrap.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from "vitest"; +import { + ensureBetterSqlite3Available, + getPluginDirFromImportMeta, + isPathWithinDir, + type BetterSqliteRuntime, +} from "../src/runtime/sqlite-bootstrap"; + +function makeApi() { + const info = vi.fn(); + const warn = vi.fn(); + + return { + api: { logger: { info, warn } }, + info, + warn, + }; +} + +describe("sqlite bootstrap path handling", () => { + it("converts import.meta.url into a real Windows filesystem path", () => { + const pluginDir = getPluginDirFromImportMeta( + "file:///C:/Users/nowcoder/.openclaw/extensions/memos-local-openclaw-plugin/index.ts", + "win32", + ); + + expect(pluginDir).toBe("C:\\Users\\nowcoder\\.openclaw\\extensions\\memos-local-openclaw-plugin"); + }); + + it("keeps compatibility with Node runtimes that only expose the one-argument fileURLToPath", () => { + const pluginDir = getPluginDirFromImportMeta( + "file:///C:/Program%20Files/OpenClaw/plugins/memos-local-openclaw-plugin/index.ts", + "win32", + ); + + expect(pluginDir).toBe("C:\\Program Files\\OpenClaw\\plugins\\memos-local-openclaw-plugin"); + }); + + it("treats Windows paths with slash and case differences as the same directory", () => { + expect( + isPathWithinDir( + "c:/users/nowcoder/.openclaw/extensions/memos-local-openclaw-plugin/node_modules/better-sqlite3/lib/index.js", + "C:\\Users\\nowcoder\\.openclaw\\extensions\\memos-local-openclaw-plugin", + "win32", + ), + ).toBe(true); + }); + + it("rejects sibling directories that only share a string prefix", () => { + expect( + isPathWithinDir( + "C:/Users/nowcoder/.openclaw/extensions/memos-local-openclaw-plugin-bad/node_modules/better-sqlite3/index.js", + "C:\\Users\\nowcoder\\.openclaw\\extensions\\memos-local-openclaw-plugin", + "win32", + ), + ).toBe(false); + }); + + it("allows child directories whose names start with two dots", () => { + expect( + isPathWithinDir( + "C:/Users/nowcoder/.openclaw/extensions/memos-local-openclaw-plugin/..cache/better-sqlite3/index.js", + "C:\\Users\\nowcoder\\.openclaw\\extensions\\memos-local-openclaw-plugin", + "win32", + ), + ).toBe(true); + }); +}); + +describe("ensureBetterSqlite3Available", () => { + it("does not rebuild when better-sqlite3 resolves inside the plugin dir on Windows", () => { + const { api } = makeApi(); + const runtime: BetterSqliteRuntime = { + resolveFromPluginDir: vi + .fn() + .mockReturnValue( + "C:/Users/nowcoder/.openclaw/extensions/memos-local-openclaw-plugin/node_modules/better-sqlite3/lib/index.js", + ), + load: vi.fn(), + rebuild: vi.fn(), + clearCache: vi.fn(), + }; + + const pluginDir = ensureBetterSqlite3Available( + api, + "file:///C:/Users/nowcoder/.openclaw/extensions/memos-local-openclaw-plugin/index.ts", + { platform: "win32", runtime }, + ); + + expect(pluginDir).toBe("C:\\Users\\nowcoder\\.openclaw\\extensions\\memos-local-openclaw-plugin"); + expect(runtime.load).toHaveBeenCalledTimes(1); + expect(runtime.rebuild).not.toHaveBeenCalled(); + }); + + it("rebuilds once when the initial resolution falls outside the plugin dir", () => { + const { api, info, warn } = makeApi(); + const runtime: BetterSqliteRuntime = { + resolveFromPluginDir: vi + .fn() + .mockReturnValueOnce("C:/Users/nowcoder/.openclaw/extensions/shared/node_modules/better-sqlite3/index.js") + .mockReturnValueOnce( + "C:/Users/nowcoder/.openclaw/extensions/memos-local-openclaw-plugin/node_modules/better-sqlite3/index.js", + ), + load: vi.fn(), + rebuild: vi.fn().mockReturnValue({ status: 0, stdout: "rebuilt", stderr: "" }), + clearCache: vi.fn(), + }; + + const pluginDir = ensureBetterSqlite3Available( + api, + "file:///C:/Users/nowcoder/.openclaw/extensions/memos-local-openclaw-plugin/index.ts", + { platform: "win32", runtime }, + ); + + expect(pluginDir).toBe("C:\\Users\\nowcoder\\.openclaw\\extensions\\memos-local-openclaw-plugin"); + expect(runtime.rebuild).toHaveBeenCalledWith(pluginDir); + expect(runtime.clearCache).toHaveBeenCalledTimes(1); + expect(runtime.load).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("better-sqlite3 resolved outside plugin dir"), + ); + expect(info).toHaveBeenCalledWith(expect.stringContaining("auto-rebuild succeeded")); + }); +});