diff --git a/cli.ts b/cli.ts index 878ee77d..60baab94 100644 --- a/cli.ts +++ b/cli.ts @@ -3,7 +3,7 @@ */ import type { Command } from "commander"; -import { readFileSync } from "node:fs"; +import { readFileSync, type Dirent } from "node:fs"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; @@ -482,6 +482,250 @@ async function sleep(ms: number): Promise { // CLI Command Implementations // ============================================================================ +export async function runImportMarkdown( + ctx: { embedder?: import("./src/embedder.js").Embedder; store: MemoryStore }, + workspaceGlob: string | undefined, + options: { + dryRun?: boolean; + scope?: string; + openclawHome?: string; + dedup?: boolean; + minTextLength?: string; + importance?: string; + } + ): Promise<{ imported: number; skipped: number; foundFiles: number }> { + const openclawHome = options.openclawHome + ? path.resolve(options.openclawHome) + : path.join(homedir(), ".openclaw"); + + const workspaceDir = path.join(openclawHome, "workspace"); + let imported = 0; + let skipped = 0; + let foundFiles = 0; + + if (!ctx.embedder) { + // [FIXED P1] Throw instead of process.exit(1) so CLI handler can catch it + throw new Error( + "import-markdown requires an embedder. Use via plugin CLI or ensure embedder is configured.", + ); + } + + // Infer workspace scope from openclaw.json agents list + // (flat memory/ files have no per-file metadata, so we derive scope from config) + const fsPromises = await import("node:fs/promises"); + let workspaceScope = ""; // empty = no scope override for nested workspaces + try { + const configPath = path.join(openclawHome, "openclaw.json"); + const configContent = await fsPromises.readFile(configPath, "utf-8"); + const config = JSON.parse(configContent); + const agentsList: Array<{ id?: string; workspace?: string }> = config?.agents?.list ?? []; + const matched = agentsList.find((a) => { + if (!a.workspace) return false; + return path.normalize(a.workspace) === workspaceDir; + }); + if (matched?.id) { + workspaceScope = matched.id; + } + } catch { /* use default */ } + + // Scan workspace directories + let workspaceEntries: Dirent[]; + try { + workspaceEntries = await fsPromises.readdir(workspaceDir, { withFileTypes: true }); + } catch { + // [FIXED P1] Throw instead of process.exit(1) so CLI handler can catch it + throw new Error(`Failed to read workspace directory: ${workspaceDir}`); + } + + // Collect all markdown files to scan + const mdFiles: Array<{ filePath: string; scope: string }> = []; + + for (const entry of workspaceEntries) { + if (!entry.isDirectory()) continue; + if (workspaceGlob && !entry.name.includes(workspaceGlob)) continue; + + const workspacePath = path.join(workspaceDir, entry.name); + + // MEMORY.md + const memoryMd = path.join(workspacePath, "MEMORY.md"); + try { + await fsPromises.stat(memoryMd); + mdFiles.push({ filePath: memoryMd, scope: entry.name }); + } catch { /* not found */ } + + // memory/ directory + const memoryDir = path.join(workspacePath, "memory"); + try { + const stats = await fsPromises.stat(memoryDir); + if (stats.isDirectory()) { + const files = await fsPromises.readdir(memoryDir); + for (const f of files) { + if (f.endsWith(".md") && /^\d{4}-\d{2}-\d{2}/.test(f)) { + mdFiles.push({ filePath: path.join(memoryDir, f), scope: entry.name }); + } + } + } + } catch { /* not found */ } + } + + // Also scan nested agent workspaces under workspace/agents//. + // This handles the structure used by session-recovery and other OpenClaw + // components: workspace/agents//MEMORY.md and workspace/agents//memory/. + // We scan one additional level deeper than the top-level workspace scan. + async function scanAgentMd( + agentPath: string, + agentId: string, + mdFiles: Array<{ filePath: string; scope: string }>, + fsP: typeof import("node:fs/promises") + ): Promise { + // workspace/agents//MEMORY.md + const agentMemoryMd = path.join(agentPath, "MEMORY.md"); + try { + await fsP.stat(agentMemoryMd); + mdFiles.push({ filePath: agentMemoryMd, scope: agentId }); + } catch { /* not found */ } + + // workspace/agents//memory/ date files + const agentMemoryDir = path.join(agentPath, "memory"); + try { + const stats = await fsP.stat(agentMemoryDir); + if (stats.isDirectory()) { + const files = await fsP.readdir(agentMemoryDir); + for (const f of files) { + if (f.endsWith(".md") && /^\d{4}-\d{2}-\d{2}/.test(f)) { + mdFiles.push({ filePath: path.join(agentMemoryDir, f), scope: agentId }); + } + } + } + } catch { /* not found */ } + } + + const agentsDir = path.join(workspaceDir, "agents"); + try { + const agentEntries = await fsPromises.readdir(agentsDir, { withFileTypes: true }); + if (workspaceGlob) { + // 有明確目標:只掃描符合的那一個 agent workspace + const matchedAgent = agentEntries.find(e => e.isDirectory() && e.name === workspaceGlob); + if (matchedAgent) { + const agentPath = path.join(agentsDir, matchedAgent.name); + await scanAgentMd(agentPath, matchedAgent.name, mdFiles, fsPromises); + } + } else { + // 無指定:掃描全部 agent workspaces + for (const agentEntry of agentEntries) { + if (!agentEntry.isDirectory()) continue; + const agentPath = path.join(agentsDir, agentEntry.name); + await scanAgentMd(agentPath, agentEntry.name, mdFiles, fsPromises); + } + } + } catch { /* no agents/ directory */ } + + // Also scan the flat `workspace/memory/` directory directly under workspace root + // (not inside any workspace subdirectory — supports James's actual structure). + // This scan runs regardless of whether nested workspace mdFiles were found, + // so flat memory is always reachable even when all nested workspaces are empty. + // Skip if a specific workspace was requested (workspaceGlob), to avoid importing + // root flat memory when the user meant to import only one workspace. + if (!workspaceGlob) { + const flatMemoryDir = path.join(workspaceDir, "memory"); + try { + const stats = await fsPromises.stat(flatMemoryDir); + if (stats.isDirectory()) { + const files = await fsPromises.readdir(flatMemoryDir); + for (const f of files) { + if (f.endsWith(".md") && /^\d{4}-\d{2}-\d{2}/.test(f)) { + mdFiles.push({ filePath: path.join(flatMemoryDir, f), scope: workspaceScope || "global" }); + } + } + } + } catch { /* not found */ } + } + + if (mdFiles.length === 0) { + return { imported: 0, skipped: 0, foundFiles: 0 }; + } + + // NaN-safe parsing with bounds — invalid input falls back to defaults instead of + // silently passing NaN (e.g. "--min-text-length abc" would otherwise make every + // length check behave unexpectedly). + const minTextLength = clampInt(parseInt(options.minTextLength ?? "5", 10), 1, 10000); + const importanceDefault = Number.isFinite(parseFloat(options.importance ?? "0.7")) + ? Math.max(0, Math.min(1, parseFloat(options.importance ?? "0.7"))) + : 0.7; + const dedupEnabled = !!options.dedup; + + // Parse each file for memory entries (lines starting with "- ") + for (const { filePath, scope: discoveredScope } of mdFiles) { + foundFiles++; + let content = await fsPromises.readFile(filePath, "utf-8"); + // Strip UTF-8 BOM (e.g. from Windows Notepad-saved files) + content = content.replace(/^\uFEFF/, ""); + // Normalize line endings: handle both CRLF (\r\n) and LF (\n) + const lines = content.split(/\r?\n/); + + for (const line of lines) { + // Skip non-memory lines + // Supports: "- text", "* text", "+ text" (standard Markdown bullet formats) + if (!/^[-*+]\s/.test(line)) continue; + const text = line.slice(2).trim(); + if (text.length < minTextLength) { skipped++; continue; } + + // Use --scope if provided, otherwise fall back to per-file discovered scope. + // This prevents cross-workspace leakage: without --scope, each workspace + // writes to its own scope instead of collapsing everything into "global". + const effectiveScope = options.scope || discoveredScope; + + // ── Deduplication check (scope-aware exact match) ─────────────────── + // Run even in dry-run so --dry-run --dedup reports accurate counts + if (dedupEnabled) { + try { + const existing = await ctx.store.bm25Search(text, 5, [effectiveScope]); + if (existing.length > 0 && existing[0].entry.text === text) { + skipped++; + if (!options.dryRun) { + console.log(` [skip] already imported: ${text.slice(0, 60)}${text.length > 60 ? "..." : ""}`); + } + continue; + } + } catch (err) { + // [FIXED P2] Log warning so dedup failure is visible instead of silent + console.warn(` [import-markdown] dedup check failed (${err}), proceeding with import: ${text.slice(0, 60)}...`); + } + } + + if (options.dryRun) { + console.log(` [dry-run] would import: ${text.slice(0, 80)}${text.length > 80 ? "..." : ""}`); + imported++; + continue; + } + + try { + const vector = await ctx.embedder!.embedPassage(text); + await ctx.store.store({ + text, + vector, + importance: importanceDefault, + category: "other", + scope: effectiveScope, + metadata: JSON.stringify({ importedFrom: filePath, sourceScope: discoveredScope }), + }); + imported++; + } catch (err) { + console.warn(` Failed to import: ${text.slice(0, 60)}... — ${err}`); + skipped++; + } + } + } + + if (options.dryRun) { + console.log(`\nDRY RUN — found ${foundFiles} files, ${imported} entries would be imported, ${skipped} skipped${dedupEnabled ? " [dedup enabled]" : ""}`); + } else { + console.log(`\nImport complete: ${imported} imported, ${skipped} skipped (scanned ${foundFiles} files)${dedupEnabled ? " [dedup enabled]" : ""}`); + } + return { imported, skipped, foundFiles }; + } + + export function registerMemoryCLI(program: Command, context: CLIContext): void { let lastSearchDiagnostics: ReturnType = null; @@ -1162,6 +1406,47 @@ export function registerMemoryCLI(program: Command, context: CLIContext): void { } }); + /** + * import-markdown: Import memories from Markdown memory files into the plugin store. + * Targets MEMORY.md and memory/YYYY-MM-DD.md files found in OpenClaw workspaces. + */ + memory + .command("import-markdown [workspace-glob]") + .description("Import memories from Markdown files (MEMORY.md, memory/YYYY-MM-DD.md) into the plugin store") + .option("--dry-run", "Show what would be imported without importing") + .option("--scope ", "Import into specific scope (default: auto-discovered from workspace)") + .option( + "--openclaw-home ", + "OpenClaw home directory (default: ~/.openclaw)", + ) + .option( + "--dedup", + "Skip entries already in store (scope-aware exact match, requires store.bm25Search)", + ) + .option( + "--min-text-length ", + "Minimum text length to import (default: 5)", + "5", + ) + .option( + "--importance ", + "Importance score for imported entries, 0.0-1.0 (default: 0.7)", + "0.7", + ) + .action(async (workspaceGlob, options) => { + // [FIXED P1] Wrap with try/catch — runImportMarkdown now throws instead of process.exit(1) + try { + const result = await runImportMarkdown(context, workspaceGlob, options); + if (result.foundFiles === 0) { + console.log("No Markdown memory files found."); + } + // Summary is printed inside runImportMarkdown (removed duplicate output) + } catch (err) { + console.error(`import-markdown failed: ${err}`); + process.exit(1); + } + }); + // Re-embed an existing LanceDB into the current target DB (A/B testing) memory .command("reembed") diff --git a/package-lock.json b/package-lock.json index fcbf1b04..5b6709d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "memory-lancedb-pro", - "version": "1.1.0-beta.9", + "version": "1.1.0-beta.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "memory-lancedb-pro", - "version": "1.1.0-beta.9", + "version": "1.1.0-beta.10", "license": "MIT", "dependencies": { "@lancedb/lancedb": "^0.26.2", @@ -19,9 +19,452 @@ "devDependencies": { "commander": "^14.0.0", "jiti": "^2.6.0", + "tsx": "^4.21.0", "typescript": "^5.9.3" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@lancedb/lancedb": { "version": "0.26.2", "resolved": "https://registry.npmjs.org/@lancedb/lancedb/-/lancedb-0.26.2.tgz", @@ -223,6 +666,7 @@ "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-18.1.0.tgz", "integrity": "sha512-v/ShMp57iBnBp4lDgV8Jx3d3Q5/Hac25FWmQ98eMahUiHPXcvwIMKJD0hBIgclm/FCG+LwPkAKtkRO1O/W0YGg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@swc/helpers": "^0.5.11", "@types/command-line-args": "^5.2.3", @@ -354,6 +798,48 @@ "node": ">=20" } }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "node_modules/find-replace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", @@ -372,6 +858,34 @@ "integrity": "sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA==", "license": "Apache-2.0" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -461,6 +975,16 @@ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "license": "Apache-2.0" }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -516,6 +1040,26 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index fd80aacf..42798305 100644 --- a/package.json +++ b/package.json @@ -38,13 +38,14 @@ ] }, "scripts": { - "test": "node test/embedder-error-hints.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node test/plugin-manifest-regression.mjs && node --test test/session-summary-before-reset.test.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs && node test/is-latest-auto-supersede.test.mjs", + "test": "node test/embedder-error-hints.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node test/plugin-manifest-regression.mjs && node --test test/session-summary-before-reset.test.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs && node --test test/is-latest-auto-supersede.test.mjs && node --test test/import-markdown/import-markdown.test.mjs", "test:openclaw-host": "node test/openclaw-host-functional.mjs", "version": "node scripts/sync-plugin-version.mjs openclaw.plugin.json package.json && git add openclaw.plugin.json" }, "devDependencies": { "commander": "^14.0.0", "jiti": "^2.6.0", + "tsx": "^4.21.0", "typescript": "^5.9.3" } } diff --git a/test/import-markdown/import-markdown.test.mjs b/test/import-markdown/import-markdown.test.mjs new file mode 100644 index 00000000..88d82ca2 --- /dev/null +++ b/test/import-markdown/import-markdown.test.mjs @@ -0,0 +1,326 @@ +/** + * import-markdown.test.mjs + * Integration tests for the import-markdown CLI command. + * Tests: BOM handling, CRLF normalization, bullet formats, dedup logic, + * minTextLength, importance, and dry-run mode. + * + * Run: node --test test/import-markdown/import-markdown.test.mjs + */ +import { describe, it, before, after, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import jitiFactory from "jiti"; +const jiti = jitiFactory(import.meta.url, { interopDefault: true }); + +// ────────────────────────────────────────────────────────────────────────────── Mock implementations ────────────────────────────────────────────────────────────────────────────── + +let storedRecords = []; + +const mockEmbedder = { + embedQuery: async (text) => { + // Return a deterministic 384-dim fake vector + const dim = 384; + const vec = []; + let seed = hashString(text); + for (let i = 0; i < dim; i++) { + seed = (seed * 1664525 + 1013904223) & 0xffffffff; + vec.push((seed >>> 8) / 16777215 - 1); + } + return vec; + }, + embedPassage: async (text) => { + // Same deterministic vector as embedQuery for test consistency + const dim = 384; + const vec = []; + let seed = hashString(text); + for (let i = 0; i < dim; i++) { + seed = (seed * 1664525 + 1013904223) & 0xffffffff; + vec.push((seed >>> 8) / 16777215 - 1); + } + return vec; + }, +}; + +const mockStore = { + get storedRecords() { + return storedRecords; + }, + async store(entry) { + storedRecords.push({ ...entry }); + }, + async bm25Search(query, limit = 1, scopeFilter = []) { + const q = query.toLowerCase(); + return storedRecords + .filter((r) => { + if (scopeFilter.length > 0 && !scopeFilter.includes(r.scope)) return false; + return r.text.toLowerCase().includes(q); + }) + .slice(0, limit) + .map((r) => ({ entry: r, score: r.text.toLowerCase() === q ? 1.0 : 0.8 })); + }, + reset() { + storedRecords.length = 0; // Mutate in place to preserve the array reference + }, +}; + +function hashString(s) { + let h = 5381; + for (let i = 0; i < s.length; i++) { + h = ((h << 5) + h) + s.charCodeAt(i); + h = h & 0xffffffff; + } + return h; +} + +// ────────────────────────────────────────────────────────────────────────────── Test helpers ────────────────────────────────────────────────────────────────────────────── + +import { writeFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +let testWorkspaceDir; + +// Module-level: shared between before() hook and runImportMarkdown() +let importMarkdown; + +async function setupWorkspace(name) { + // Files must be created at: /workspace// + // because runImportMarkdown looks for path.join(openclawHome, "workspace") + const wsDir = join(testWorkspaceDir, "workspace", name); + await mkdir(wsDir, { recursive: true }); + return wsDir; +} + +// ────────────────────────────────────────────────────────────────────────────── Setup / Teardown ────────────────────────────────────────────────────────────────────────────── + +before(async () => { + testWorkspaceDir = join(tmpdir(), "import-markdown-test-" + Date.now()); + await mkdir(testWorkspaceDir, { recursive: true }); +}); + +afterEach(() => { + mockStore.reset(); +}); + +after(async () => { + // Cleanup handled by OS (tmpdir cleanup) +}); + +// ────────────────────────────────────────────────────────────────────────────── Tests ────────────────────────────────────────────────────────────────────────────── + +describe("import-markdown CLI", () => { + before(async () => { + // Lazy-import via jiti to handle TypeScript compilation + const mod = jiti("../../cli.ts"); + importMarkdown = mod.runImportMarkdown ?? null; + }); + + describe("BOM handling", () => { + it("strips UTF-8 BOM from file content", async () => { + const wsDir = await setupWorkspace("bom-test"); + // UTF-8 BOM (\ufeff) followed by a valid bullet line; BOM-only line should be skipped + await writeFile(join(wsDir, "MEMORY.md"), "\ufeff- BOM line\n- Real bullet\n", "utf-8"); + + const ctx = { embedder: mockEmbedder, store: mockStore }; + const { imported } = await runImportMarkdown(ctx, { + openclawHome: testWorkspaceDir, + workspaceGlob: "bom-test", + }); + + assert.ok(imported >= 1, `expected imported >= 1, got ${imported}`); + }); + }); + + describe("CRLF normalization", () => { + it("handles Windows CRLF line endings", async () => { + const wsDir = await setupWorkspace("crlf-test"); + await writeFile(join(wsDir, "MEMORY.md"), "- Line one\r\n- Line two\r\n", "utf-8"); + + const ctx = { embedder: mockEmbedder, store: mockStore }; + const { imported } = await runImportMarkdown(ctx, { + openclawHome: testWorkspaceDir, + workspaceGlob: "crlf-test", + }); + + assert.strictEqual(imported, 2); + }); + }); + + describe("Bullet format support", () => { + it("imports dash, star, and plus bullet formats", async () => { + const wsDir = await setupWorkspace("bullet-formats"); + await writeFile(join(wsDir, "MEMORY.md"), + "- Dash bullet\n* Star bullet\n+ Plus bullet\n", "utf-8"); + + const ctx = { embedder: mockEmbedder, store: mockStore }; + const { imported, skipped } = await runImportMarkdown(ctx, { + openclawHome: testWorkspaceDir, + workspaceGlob: "bullet-formats", + }); + + assert.strictEqual(imported, 3); + assert.strictEqual(skipped, 0); + }); + }); + + describe("minTextLength option", () => { + it("skips lines shorter than minTextLength", async () => { + const wsDir = await setupWorkspace("min-len-test"); + // Lines: "短"=1 char, "中文字"=3 chars, "長文字行"=4 chars, "合格的文字"=5 chars + await writeFile(join(wsDir, "MEMORY.md"), + "- 短\n- 中文字\n- 長文字行\n- 合格的文字\n", "utf-8"); + + const ctx = { embedder: mockEmbedder, store: mockStore }; + const { imported, skipped } = await runImportMarkdown(ctx, { + openclawHome: testWorkspaceDir, + workspaceGlob: "min-len-test", + minTextLength: 5, + }); + + assert.strictEqual(imported, 1); // "合格的文字" (5 chars) + assert.strictEqual(skipped, 3); // "短", "中文字", "長文字行" + }); + }); + + describe("importance option", () => { + it("uses custom importance value", async () => { + const wsDir = await setupWorkspace("importance-test"); + await writeFile(join(wsDir, "MEMORY.md"), "- Test content line\n", "utf-8"); + + const ctx = { embedder: mockEmbedder, store: mockStore }; + await runImportMarkdown(ctx, { + openclawHome: testWorkspaceDir, + workspaceGlob: "importance-test", + importance: 0.9, + }); + + assert.strictEqual(mockStore.storedRecords[0].importance, 0.9); + }); + }); + + describe("dedup logic", () => { + it("skips already-imported entries in same scope when dedup is enabled", async () => { + const wsDir = await setupWorkspace("dedup-test"); + await writeFile(join(wsDir, "MEMORY.md"), "- Duplicate content line\n", "utf-8"); + + const ctx = { embedder: mockEmbedder, store: mockStore }; + + // First import (no dedup) + await runImportMarkdown(ctx, { + openclawHome: testWorkspaceDir, + workspaceGlob: "dedup-test", + dedup: false, + }); + assert.strictEqual(mockStore.storedRecords.length, 1); + + // Second import WITH dedup — should skip the duplicate + const { imported, skipped } = await runImportMarkdown(ctx, { + openclawHome: testWorkspaceDir, + workspaceGlob: "dedup-test", + dedup: true, + }); + + assert.strictEqual(imported, 0); + assert.strictEqual(skipped, 1); + assert.strictEqual(mockStore.storedRecords.length, 1); // Still only 1 + }); + + it("imports same text into different scope even with dedup enabled", async () => { + const wsDir = await setupWorkspace("dedup-scope-test"); + await writeFile(join(wsDir, "MEMORY.md"), "- Same content line\n", "utf-8"); + + const ctx = { embedder: mockEmbedder, store: mockStore }; + + // First import to scope-A + await runImportMarkdown(ctx, { + openclawHome: testWorkspaceDir, + workspaceGlob: "dedup-scope-test", + scope: "scope-A", + dedup: false, + }); + assert.strictEqual(mockStore.storedRecords.length, 1); + + // Second import to scope-B — should NOT skip (different scope) + const { imported } = await runImportMarkdown(ctx, { + openclawHome: testWorkspaceDir, + workspaceGlob: "dedup-scope-test", + scope: "scope-B", + dedup: true, + }); + + assert.strictEqual(imported, 1); + assert.strictEqual(mockStore.storedRecords.length, 2); // Two entries, different scopes + }); + }); + + describe("dry-run mode", () => { + it("does not write to store in dry-run mode", async () => { + const wsDir = await setupWorkspace("dryrun-test"); + await writeFile(join(wsDir, "MEMORY.md"), "- Dry run test line\n", "utf-8"); + + const ctx = { embedder: mockEmbedder, store: mockStore }; + const { imported } = await runImportMarkdown(ctx, { + openclawHome: testWorkspaceDir, + workspaceGlob: "dryrun-test", + dryRun: true, + }); + + assert.strictEqual(imported, 1); + assert.strictEqual(mockStore.storedRecords.length, 0); // No actual write + }); + }); + + describe("continue on error", () => { + it("continues processing after a store failure", async () => { + const wsDir = await setupWorkspace("error-test"); + await writeFile(join(wsDir, "MEMORY.md"), + "- First line\n- Second line\n- Third line\n", "utf-8"); + + let callCount = 0; + const errorStore = { + async store(entry) { + callCount++; + if (callCount === 2) throw new Error("Simulated failure"); + storedRecords.push({ ...entry }); // Use outer storedRecords directly + }, + async bm25Search(...args) { + return mockStore.bm25Search(...args); + }, + }; + + const ctx = { embedder: mockEmbedder, store: errorStore }; + const { imported, skipped } = await runImportMarkdown(ctx, { + openclawHome: testWorkspaceDir, + workspaceGlob: "error-test", + }); + + // Second call threw, but first and third should have succeeded + assert.ok(imported >= 2, `expected imported >= 2, got ${imported}`); + assert.ok(skipped >= 0); + }); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── Test runner helper ────────────────────────────────────────────────────────────────────────────── + +/** + * Thin adapter: delegates to the production runImportMarkdown exported from ../../cli.ts. + * Keeps existing test call signatures working while ensuring tests always exercise the + * real implementation (no duplicate logic drift). + */ +async function runImportMarkdown(context, options = {}) { + if (typeof importMarkdown === "function") { + return importMarkdown( + context, + options.workspaceGlob ?? null, + { + dryRun: !!options.dryRun, + scope: options.scope, + openclawHome: options.openclawHome, + dedup: !!options.dedup, + minTextLength: String(options.minTextLength ?? 5), + importance: String(options.importance ?? 0.7), + }, + ); + } + throw new Error(`importMarkdown not set (got ${typeof importMarkdown})`); +}