import fs from "node:fs/promises"; import path from "node:path"; import { parseArgs } from "node:util"; import type { MemoryRecord } from "../../src/core/record/l1-writer.js"; import { listLocalProfiles } from "../../src/core/profile/profile-sync.js"; import { createBM25Encoder } from "../../src/core/store/bm25-local.js"; import { VectorStore, type L0RecordRow, type L1RecordRow } from "../../src/core/store/sqlite.js"; import { TcvdbMemoryStore } from "../../src/core/store/tcvdb.js"; import type { L0Record, ProfileRecord, ProfileSyncRecord, StoreInitResult } from "../../src/core/store/types.js"; import { readManifest } from "../../src/utils/manifest.js"; import { rewriteMigrationManifest as rewriteMigrationManifestDefault, type RewriteMigrationManifestResult, } from "./manifest-write.js"; import { writeMigrationPluginConfig as writeMigrationPluginConfigDefault, } from "./config-write.js"; export const DEFAULT_MIGRATION_PLUGIN_ID = "memory-tencentdb"; export const ALL_MIGRATION_LAYERS = ["l0", "l1", "l2", "l3"] as const; const TAG = "[memory-tdai][migrate]"; function log(message: string): void { process.stderr.write(`${TAG} ${message}\n`); } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } export type MigrationLayer = (typeof ALL_MIGRATION_LAYERS)[number]; export type Bm25Language = "zh" | "en"; export interface ResolvedMigrationCliOptions { pluginDataDir: string; sqlitePath: string; openclawConfigPath: string; pluginId: string; layers: MigrationLayer[]; applyConfig: boolean; configBackup: boolean; rewriteManifest: boolean; failIfTargetNonempty: boolean; verifyCounts: boolean; dryRun: boolean; yes: boolean; bm25Enabled: boolean; bm25Language: Bm25Language; summaryJsonPath?: string; jobId?: string; tcvdb: { url: string; username: string; apiKey: string; database: string; alias: string; embeddingModel: string; timeout: number; caPemPath?: string; }; } export interface MigrationPreflightSummary { pluginId: string; dryRun: boolean; layers: MigrationLayer[]; paths: { pluginDataDir: string; sqlitePath: string; openclawConfigPath: string; }; source: { l0Count: number; l1Count: number; profileCount: number; manifestExists: boolean; manifestStoreType: "sqlite" | "tcvdb" | null; }; target: { url: string; username: string; database: string; alias: string; embeddingModel: string; timeout: number; bm25Enabled: boolean; bm25Language: Bm25Language; }; options: { applyConfig: boolean; configBackup: boolean; rewriteManifest: boolean; failIfTargetNonempty: boolean; verifyCounts: boolean; yes: boolean; }; migration?: { l0Migrated: number; l1Migrated: number; profileMigrated: number; targetL0Count: number; targetL1Count: number; targetProfileCount: number; configWritten: boolean; manifestWritten: boolean; manifestBackupPath?: string; }; } export const DEFAULT_MIGRATION_PAGE_SIZE = 50; export interface MigrationTargetStore { init(providerInfo?: unknown): Promise | StoreInitResult; isDegraded(): boolean; close(): void; upsertL1(record: MemoryRecord, embedding?: Float32Array): Promise | boolean; upsertL0(record: L0Record, embedding?: Float32Array): Promise | boolean; upsertL1Batch?(records: MemoryRecord[]): Promise; upsertL0Batch?(records: L0Record[]): Promise; countL1(): Promise | number; countL0(): Promise | number; pullProfiles?(): Promise; syncProfiles?(records: ProfileSyncRecord[]): Promise; } export interface RunMigrationCliDeps { createTargetStore?: (options: ResolvedMigrationCliOptions) => MigrationTargetStore; writeMigrationPluginConfig?: typeof writeMigrationPluginConfigDefault; rewriteMigrationManifest?: (params: { dataDir: string; tcvdbUrl: string; tcvdbDatabase: string; tcvdbAlias?: string; }) => Promise; verifyDelayMs?: number; } function getRequiredString(values: Record, key: string): string { const value = values[key]; if (typeof value !== "string" || value.trim() === "") { throw new Error(`Missing required option --${key}`); } return value.trim(); } function getOptionalString(values: Record, key: string): string | undefined { const value = values[key]; if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed === "" ? undefined : trimmed; } function resolveBooleanOption( values: Record, key: string, defaultValue: boolean, ): boolean { const value = values[key]; return typeof value === "boolean" ? value : defaultValue; } function resolveTcvdbApiKey(values: Record): string { const directApiKey = getOptionalString(values, "tcvdb-api-key"); const apiKeyEnvName = getOptionalString(values, "tcvdb-api-key-env"); if (directApiKey && apiKeyEnvName) { throw new Error("Provide either --tcvdb-api-key or --tcvdb-api-key-env, not both"); } if (directApiKey) { return directApiKey; } if (!apiKeyEnvName) { throw new Error("Missing required TCVDB API key input: use --tcvdb-api-key or --tcvdb-api-key-env"); } const envValue = process.env[apiKeyEnvName]?.trim(); if (!envValue) { throw new Error(`Environment variable ${apiKeyEnvName} is empty or not set`); } return envValue; } function parseLayers(rawLayers: string | undefined): MigrationLayer[] { if (!rawLayers) return [...ALL_MIGRATION_LAYERS]; const values = rawLayers .split(",") .map((layer) => layer.trim()) .filter(Boolean); if (values.length === 0) { throw new Error("--layers must include at least one layer"); } const uniqueLayers = [...new Set(values)]; const invalid = uniqueLayers.filter( (layer): layer is string => !ALL_MIGRATION_LAYERS.includes(layer as MigrationLayer), ); if (invalid.length > 0) { throw new Error(`Unsupported layer(s): ${invalid.join(", ")}`); } return uniqueLayers as MigrationLayer[]; } function parseBm25Language(rawLanguage: string | undefined): Bm25Language { if (!rawLanguage) return "zh"; if (rawLanguage === "zh" || rawLanguage === "en") { return rawLanguage; } throw new Error(`Unsupported --bm25-language value: ${rawLanguage}`); } function parseTimeout(rawValue: string | undefined): number { if (!rawValue) return 10000; const timeout = Number(rawValue); if (!Number.isFinite(timeout) || timeout <= 0) { throw new Error(`Invalid --tcvdb-timeout-ms value: ${rawValue}`); } return timeout; } /** * Build a "nothing to migrate" summary — used when source data dir or sqlite * file doesn't exist (e.g. fresh deployment that hasn't captured any data yet). */ function buildEmptySummary(options: ResolvedMigrationCliOptions): MigrationPreflightSummary { return { pluginId: options.pluginId, dryRun: options.dryRun, layers: options.layers, paths: { pluginDataDir: options.pluginDataDir, sqlitePath: options.sqlitePath, openclawConfigPath: options.openclawConfigPath, }, source: { l0Count: 0, l1Count: 0, profileCount: 0, manifestExists: false, manifestStoreType: null, }, target: { url: options.tcvdb.url, username: options.tcvdb.username, database: options.tcvdb.database, alias: options.tcvdb.alias, embeddingModel: options.tcvdb.embeddingModel, timeout: options.tcvdb.timeout, bm25Enabled: options.bm25Enabled, bm25Language: options.bm25Language, }, options: { applyConfig: options.applyConfig, configBackup: options.configBackup, rewriteManifest: options.rewriteManifest, failIfTargetNonempty: options.failIfTargetNonempty, verifyCounts: options.verifyCounts, yes: options.yes, }, }; } async function ensureReadablePath(filePath: string, label: string): Promise { try { await fs.access(filePath); } catch { throw new Error(`${label} does not exist or is not accessible: ${filePath}`); } } async function ensureReadableDirectory(dirPath: string, label: string): Promise { const stat = await fs.stat(dirPath).catch(() => null); if (!stat?.isDirectory()) { throw new Error(`${label} is not a directory: ${dirPath}`); } } function safeParseMetadata(raw: string): Record { if (!raw) return {}; try { const parsed = JSON.parse(raw); return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : {}; } catch { return {}; } } function compactTimestamps(row: L1RecordRow): string[] { return [...new Set([row.timestamp_str, row.timestamp_start, row.timestamp_end].filter(Boolean))]; } function mapL1RowToMemoryRecord(row: L1RecordRow): MemoryRecord { const timestamps = compactTimestamps(row); const fallbackIso = row.updated_time || row.created_time || row.timestamp_end || row.timestamp_str || new Date(0).toISOString(); return { id: row.record_id, content: row.content, type: row.type as MemoryRecord["type"], priority: row.priority, scene_name: row.scene_name, source_message_ids: [], metadata: safeParseMetadata(row.metadata_json), timestamps, createdAt: row.created_time || fallbackIso, updatedAt: row.updated_time || row.created_time || fallbackIso, sessionKey: row.session_key || "", sessionId: row.session_id || "", }; } function mapL0RowToRecord(row: L0RecordRow): L0Record { return { id: row.record_id, sessionKey: row.session_key, sessionId: row.session_id || "", role: row.role, messageText: row.message_text, recordedAt: row.recorded_at || "", timestamp: row.timestamp ?? 0, }; } function createTargetStoreDefault(options: ResolvedMigrationCliOptions): MigrationTargetStore { const bm25Encoder = createBM25Encoder( { enabled: options.bm25Enabled, language: options.bm25Language, }, ); return new TcvdbMemoryStore({ url: options.tcvdb.url, username: options.tcvdb.username, apiKey: options.tcvdb.apiKey, database: options.tcvdb.database, embeddingModel: options.tcvdb.embeddingModel, timeout: options.tcvdb.timeout, caPemPath: options.tcvdb.caPemPath, bm25Encoder, }); } async function ensureTargetIsEmpty( options: ResolvedMigrationCliOptions, targetStore: MigrationTargetStore, ): Promise { if (!options.failIfTargetNonempty) return; const [existingL1, existingL0, existingProfiles] = await Promise.all([ Promise.resolve(targetStore.countL1()), Promise.resolve(targetStore.countL0()), targetStore.pullProfiles ? targetStore.pullProfiles().then((records) => records.length) : Promise.resolve(0), ]); if (existingL1 > 0 || existingL0 > 0 || existingProfiles > 0) { throw new Error( `Target store is not empty (L1=${existingL1}, L0=${existingL0}, profiles=${existingProfiles})`, ); } } async function migrateL1Records(sourceStore: VectorStore, targetStore: MigrationTargetStore, pageSize: number): Promise { let migrated = 0; let cursor = ""; const useBatch = typeof targetStore.upsertL1Batch === "function"; // eslint-disable-next-line no-constant-condition while (true) { const rows = sourceStore.queryL1RecordsCursor(cursor, pageSize); if (rows.length === 0) break; const records = rows.map(mapL1RowToMemoryRecord); if (useBatch) { const count = await targetStore.upsertL1Batch!(records); if (count === 0) { throw new Error(`Failed to batch migrate L1 records (cursor=${cursor}, page=${rows.length})`); } } else { for (const record of records) { const ok = await Promise.resolve(targetStore.upsertL1(record)); if (!ok) throw new Error(`Failed to migrate L1 record ${record.id}`); } } migrated += rows.length; cursor = rows[rows.length - 1].record_id; log(`L1: 已迁移 ${migrated} 条...`); if (rows.length < pageSize) break; } log(`L1: 迁移完成,共 ${migrated} 条`); return migrated; } async function migrateL0Records(sourceStore: VectorStore, targetStore: MigrationTargetStore, pageSize: number): Promise { let migrated = 0; let cursor = ""; const useBatch = typeof targetStore.upsertL0Batch === "function"; // eslint-disable-next-line no-constant-condition while (true) { const rows = sourceStore.queryL0RecordsCursor(cursor, pageSize); if (rows.length === 0) break; const records = rows.map(mapL0RowToRecord); if (useBatch) { const count = await targetStore.upsertL0Batch!(records); if (count === 0) { throw new Error(`Failed to batch migrate L0 records (cursor=${cursor}, page=${rows.length})`); } } else { for (const record of records) { const ok = await Promise.resolve(targetStore.upsertL0(record)); if (!ok) throw new Error(`Failed to migrate L0 record ${record.id}`); } } migrated += rows.length; cursor = rows[rows.length - 1].record_id; log(`L0: 已迁移 ${migrated} 条...`); if (rows.length < pageSize) break; } log(`L0: 迁移完成,共 ${migrated} 条`); return migrated; } async function migrateProfiles( pluginDataDir: string, targetStore: MigrationTargetStore, ): Promise { const profiles = await listLocalProfiles(pluginDataDir); if (profiles.length === 0) { log("Profiles: 无本地 profile,跳过"); return 0; } if (!targetStore.syncProfiles) { throw new Error("Target store does not support profile sync"); } log(`Profiles: 发现 ${profiles.length} 个本地 profile,开始同步...`); await targetStore.syncProfiles(profiles); log(`Profiles: 同步完成,共 ${profiles.length} 个`); return profiles.length; } async function verifyMigratedCounts( summary: MigrationPreflightSummary, targetStore: MigrationTargetStore, delayMs: number, ): Promise<{ l1Count: number; l0Count: number; profileCount: number }> { if (delayMs > 0) { log(`等待 ${Math.round(delayMs / 1000)} 秒让远端数据落盘...`); await sleep(delayMs); } log("开始校验迁移数量..."); const [l1Count, l0Count, profileCount] = await Promise.all([ Promise.resolve(targetStore.countL1()), Promise.resolve(targetStore.countL0()), targetStore.pullProfiles ? targetStore.pullProfiles().then((records) => records.length) : Promise.resolve(0), ]); log(`校验结果: L1=${l1Count}/${summary.source.l1Count}, L0=${l0Count}/${summary.source.l0Count}, Profiles=${profileCount}/${summary.source.profileCount}`); if (l1Count !== summary.source.l1Count) { throw new Error(`L1 count verification failed: source=${summary.source.l1Count}, target=${l1Count}`); } if (l0Count !== summary.source.l0Count) { throw new Error(`L0 count verification failed: source=${summary.source.l0Count}, target=${l0Count}`); } if (profileCount !== summary.source.profileCount) { throw new Error( `Profile count verification failed: source=${summary.source.profileCount}, target=${profileCount}`, ); } log("校验通过"); return { l1Count, l0Count, profileCount }; } async function writeSummaryJson( summaryJsonPath: string | undefined, summary: MigrationPreflightSummary, ): Promise { if (!summaryJsonPath) return; await fs.mkdir(path.dirname(summaryJsonPath), { recursive: true }); await fs.writeFile(summaryJsonPath, `${JSON.stringify(summary, null, 2)}\n`, "utf-8"); } function printUsageAndExit(): never { const text = ` SQLite → 腾讯云向量数据库迁移工具 Usage: migrate-sqlite-to-tcvdb [options] Required: --plugin-data-dir 插件数据目录路径 --openclaw-config-path openclaw.json 配置文件路径 --tcvdb-url TCVDB 服务地址 --tcvdb-username TCVDB 用户名 --tcvdb-database TCVDB 数据库名 --tcvdb-embedding-model Embedding 模型名称 --tcvdb-api-key TCVDB API 密钥(明文,与 --tcvdb-api-key-env 二选一) --tcvdb-api-key-env 包含 API 密钥的环境变量名 Optional: --sqlite-path SQLite 数据库路径(默认: /vectors.db) --plugin-id 写入配置时使用的插件 ID(默认: memory-tencentdb) --layers 要迁移的层,逗号分隔(默认: l0,l1,l2,l3) --tcvdb-alias 用户自定义别名 --tcvdb-timeout-ms 请求超时(默认: 10000) --tcvdb-ca-pem CA 证书 PEM 文件路径(HTTPS 连接时使用) --bm25-language BM25 分词语言(默认: zh) --summary-json-path 将迁移摘要写入此文件 --job-id 迁移任务 ID(用于追踪) Flags: --dry-run 仅预览,不执行写入 --yes 跳过交互确认 --no-apply-config 不自动更新 openclaw.json --no-config-backup 写入配置前不备份 --no-rewrite-manifest 不更新 manifest.json --no-fail-if-target-nonempty 目标库非空时不中止 --no-verify-counts 迁移后不校验记录数 --no-bm25-enabled 禁用 BM25 稀疏向量 -h, --help 显示此帮助信息 Examples: # 预检模式 migrate-sqlite-to-tcvdb \\ --plugin-data-dir ~/.openclaw/memory-tdai \\ --openclaw-config-path ~/.openclaw/openclaw.json \\ --tcvdb-url http://127.0.0.1:80 --tcvdb-username root \\ --tcvdb-api-key-env TCVDB_API_KEY \\ --tcvdb-database agent_memory_prod \\ --tcvdb-embedding-model bge-large-zh \\ --dry-run # 正式迁移 migrate-sqlite-to-tcvdb \\ --plugin-data-dir ~/.openclaw/memory-tdai \\ --openclaw-config-path ~/.openclaw/openclaw.json \\ --tcvdb-url http://127.0.0.1:80 --tcvdb-username root \\ --tcvdb-api-key-env TCVDB_API_KEY \\ --tcvdb-database agent_memory_prod \\ --tcvdb-embedding-model bge-large-zh \\ --yes `.trimStart(); process.stdout.write(text); process.exit(0); } export function resolveMigrationCliOptions(argv: string[]): ResolvedMigrationCliOptions { const { values } = parseArgs({ args: argv, strict: true, allowPositionals: false, allowNegative: true, options: { help: { type: "boolean", short: "h" }, "plugin-data-dir": { type: "string" }, "sqlite-path": { type: "string" }, "openclaw-config-path": { type: "string" }, "plugin-id": { type: "string" }, layers: { type: "string" }, "apply-config": { type: "boolean" }, "config-backup": { type: "boolean" }, "rewrite-manifest": { type: "boolean" }, "fail-if-target-nonempty": { type: "boolean" }, "verify-counts": { type: "boolean" }, "dry-run": { type: "boolean" }, yes: { type: "boolean" }, "bm25-enabled": { type: "boolean" }, "bm25-language": { type: "string" }, "summary-json-path": { type: "string" }, "job-id": { type: "string" }, "tcvdb-url": { type: "string" }, "tcvdb-username": { type: "string" }, "tcvdb-api-key": { type: "string" }, "tcvdb-api-key-env": { type: "string" }, "tcvdb-database": { type: "string" }, "tcvdb-alias": { type: "string" }, "tcvdb-embedding-model": { type: "string" }, "tcvdb-timeout-ms": { type: "string" }, "tcvdb-ca-pem": { type: "string" }, }, }); if (values.help) { printUsageAndExit(); } const pluginDataDir = path.resolve(getRequiredString(values, "plugin-data-dir")); const sqlitePath = path.resolve( getOptionalString(values, "sqlite-path") ?? path.join(pluginDataDir, "vectors.db"), ); const summaryJsonPath = getOptionalString(values, "summary-json-path"); return { pluginDataDir, sqlitePath, openclawConfigPath: path.resolve(getRequiredString(values, "openclaw-config-path")), pluginId: getOptionalString(values, "plugin-id") ?? DEFAULT_MIGRATION_PLUGIN_ID, layers: parseLayers(getOptionalString(values, "layers")), applyConfig: resolveBooleanOption(values, "apply-config", true), configBackup: resolveBooleanOption(values, "config-backup", true), rewriteManifest: resolveBooleanOption(values, "rewrite-manifest", true), failIfTargetNonempty: resolveBooleanOption(values, "fail-if-target-nonempty", true), verifyCounts: resolveBooleanOption(values, "verify-counts", true), dryRun: resolveBooleanOption(values, "dry-run", false), yes: resolveBooleanOption(values, "yes", false), bm25Enabled: resolveBooleanOption(values, "bm25-enabled", true), bm25Language: parseBm25Language(getOptionalString(values, "bm25-language")), summaryJsonPath: summaryJsonPath ? path.resolve(summaryJsonPath) : undefined, jobId: getOptionalString(values, "job-id"), tcvdb: { url: getRequiredString(values, "tcvdb-url"), username: getRequiredString(values, "tcvdb-username"), apiKey: resolveTcvdbApiKey(values), database: getRequiredString(values, "tcvdb-database"), alias: getOptionalString(values, "tcvdb-alias") ?? "", embeddingModel: getRequiredString(values, "tcvdb-embedding-model"), timeout: parseTimeout(getOptionalString(values, "tcvdb-timeout-ms")), caPemPath: getOptionalString(values, "tcvdb-ca-pem"), }, }; } export async function collectMigrationPreflight( options: ResolvedMigrationCliOptions, ): Promise { // ── 优雅处理"数据目录 / sqlite 不存在"的场景 ── // 如果源数据路径不存在(全新部署、尚未有任何 capture),不报错—— // 返回"全零"summary,让调用方判断是否跳过迁移。 const dirExists = await fs.stat(options.pluginDataDir).then(s => s.isDirectory()).catch(() => false); if (!dirExists) { log(`plugin data directory 不存在,无需迁移: ${options.pluginDataDir}`); return buildEmptySummary(options); } const sqliteExists = await fs.access(options.sqlitePath).then(() => true).catch(() => false); if (!sqliteExists) { log(`sqlite database 不存在,无需迁移: ${options.sqlitePath}`); return buildEmptySummary(options); } await ensureReadablePath(options.openclawConfigPath, "OpenClaw config file"); const store = new VectorStore(options.sqlitePath, 0); const initResult = store.init(); if (store.isDegraded()) { store.close(); throw new Error(`Failed to open sqlite store for migration preflight: ${initResult.reason ?? "unknown error"}`); } try { const [profiles, manifest] = await Promise.all([ listLocalProfiles(options.pluginDataDir), Promise.resolve(readManifest(options.pluginDataDir)), ]); return { pluginId: options.pluginId, dryRun: options.dryRun, layers: options.layers, paths: { pluginDataDir: options.pluginDataDir, sqlitePath: options.sqlitePath, openclawConfigPath: options.openclawConfigPath, }, source: { l0Count: store.countL0(), l1Count: store.countL1(), profileCount: profiles.length, manifestExists: manifest !== null, manifestStoreType: manifest?.store.type ?? null, }, target: { url: options.tcvdb.url, username: options.tcvdb.username, database: options.tcvdb.database, alias: options.tcvdb.alias, embeddingModel: options.tcvdb.embeddingModel, timeout: options.tcvdb.timeout, bm25Enabled: options.bm25Enabled, bm25Language: options.bm25Language, }, options: { applyConfig: options.applyConfig, configBackup: options.configBackup, rewriteManifest: options.rewriteManifest, failIfTargetNonempty: options.failIfTargetNonempty, verifyCounts: options.verifyCounts, yes: options.yes, }, }; } finally { store.close(); } } export async function runMigrationCli( argv: string[], deps: RunMigrationCliDeps = {}, ): Promise { const options = resolveMigrationCliOptions(argv); log("开始预检..."); const summary = await collectMigrationPreflight(options); log(`预检完成: 源数据 L1=${summary.source.l1Count}, L0=${summary.source.l0Count}, Profiles=${summary.source.profileCount}`); log(`目标: ${summary.target.url} / ${summary.target.database}`); const hasSourceData = summary.source.l0Count > 0 || summary.source.l1Count > 0 || summary.source.profileCount > 0; if (!hasSourceData) { log("源数据为空,跳过数据迁移。"); } if (options.dryRun) { log("预检模式 (dry-run),不执行写入"); await writeSummaryJson(options.summaryJsonPath, summary); return summary; } const createTargetStore = deps.createTargetStore ?? createTargetStoreDefault; const writeMigrationPluginConfig = deps.writeMigrationPluginConfig ?? writeMigrationPluginConfigDefault; const rewriteMigrationManifest = deps.rewriteMigrationManifest ?? (async (params) => await rewriteMigrationManifestDefault(params)); const migration = { l1Migrated: 0, l0Migrated: 0, profileMigrated: 0, targetL1Count: 0, targetL0Count: 0, targetProfileCount: 0, configWritten: false, manifestWritten: false, manifestBackupPath: undefined as string | undefined, }; if (hasSourceData) { log("打开 SQLite 源库..."); const sourceStore = new VectorStore(options.sqlitePath, 0); const sourceInitResult = sourceStore.init(); if (sourceStore.isDegraded()) { sourceStore.close(); throw new Error(`Failed to reopen sqlite source store: ${sourceInitResult.reason ?? "unknown error"}`); } log("初始化目标库..."); const targetStore = createTargetStore(options); try { await Promise.resolve(targetStore.init()); if (targetStore.isDegraded()) { throw new Error("Target store entered degraded mode during initialization"); } await ensureTargetIsEmpty(options, targetStore); const pageSize = DEFAULT_MIGRATION_PAGE_SIZE; log(`分页大小: ${pageSize} 条/批`); migration.l1Migrated = options.layers.includes("l1") ? await migrateL1Records(sourceStore, targetStore, pageSize) : 0; migration.l0Migrated = options.layers.includes("l0") ? await migrateL0Records(sourceStore, targetStore, pageSize) : 0; migration.profileMigrated = options.layers.includes("l2") || options.layers.includes("l3") ? await migrateProfiles(options.pluginDataDir, targetStore) : 0; const verifyDelayMs = deps.verifyDelayMs ?? 10_000; const verifiedCounts = options.verifyCounts ? await verifyMigratedCounts(summary, targetStore, verifyDelayMs) : { l1Count: await Promise.resolve(targetStore.countL1()), l0Count: await Promise.resolve(targetStore.countL0()), profileCount: targetStore.pullProfiles ? (await targetStore.pullProfiles()).length : 0, }; migration.targetL1Count = verifiedCounts.l1Count; migration.targetL0Count = verifiedCounts.l0Count; migration.targetProfileCount = verifiedCounts.profileCount; } finally { sourceStore.close(); targetStore.close(); } } if (options.applyConfig) { log(`写入配置到 ${options.openclawConfigPath} ...`); await writeMigrationPluginConfig({ configPath: options.openclawConfigPath, pluginId: options.pluginId, tcvdb: { url: options.tcvdb.url, username: options.tcvdb.username, apiKey: options.tcvdb.apiKey, database: options.tcvdb.database, alias: options.tcvdb.alias, embeddingModel: options.tcvdb.embeddingModel, timeout: options.tcvdb.timeout, }, bm25: { enabled: options.bm25Enabled, language: options.bm25Language, }, }); migration.configWritten = true; log("配置写入完成"); } if (options.rewriteManifest) { log("更新 manifest..."); const manifestResult = await rewriteMigrationManifest({ dataDir: options.pluginDataDir, tcvdbUrl: options.tcvdb.url, tcvdbDatabase: options.tcvdb.database, tcvdbAlias: options.tcvdb.alias || undefined, }); migration.manifestWritten = manifestResult.created || manifestResult.updated; migration.manifestBackupPath = manifestResult.backupPath; log(`Manifest ${manifestResult.created ? "创建" : "更新"}完成${manifestResult.backupPath ? `,备份: ${manifestResult.backupPath}` : ""}`); } summary.migration = { l1Migrated: migration.l1Migrated, l0Migrated: migration.l0Migrated, profileMigrated: migration.profileMigrated, targetL1Count: migration.targetL1Count, targetL0Count: migration.targetL0Count, targetProfileCount: migration.targetProfileCount, configWritten: migration.configWritten, manifestWritten: migration.manifestWritten, manifestBackupPath: migration.manifestBackupPath, }; await writeSummaryJson(options.summaryJsonPath, summary); log("迁移全部完成!"); return summary; }