UNPKG

open-memex

Version:

Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.

208 lines (207 loc) • 7.85 kB
import fs from "node:fs"; import path from "node:path"; import { paths } from "../paths.js"; import { parseRawFrontmatter, normalizeFrontmatter, isTaxonomyType, serialize, atomicWriteTextSync, } from "./markdown.js"; /** * v1 → v2 file migration (V2-DESIGN §19). Explicit only — run via * `open-memex migrate --to-v2`. Converts frontmatter in place: * * - scope rename: v1 `user` → v2 `personal` (scope, scope_key, and the * storage dir `memories/user/` → `memories/personal/`) * - times: epoch ms → RFC 3339 * - `priority: N` → `importance: low|normal|high` * - `type: instruction` → `type: knowledge` + `role: instruction` * - unknown v1 types → `knowledge` (v2 taxonomy, §3.1) * - adds `schema_version: 2`, `visibility`, `role`, `status` * * Legacy `my-o-memory` data roots (§19/D13: `.my-o-memory/` → * `.open-memex/`, `~/.my-o-memory/` → `~/.open-memex/`) are merged into the * current root; the old dir is kept as a dated backup. */ const V2_MARKER = /^schema_version:\s*2(\s|$)/m; export function isV2File(raw) { return V2_MARKER.test(raw); } /** * Plan the v1 → v2 conversion of one file. Returns null when the file is * already v2 (nothing to do). Pure — no disk writes. */ export function planConversion(filePath, memoriesRoot) { const raw = fs.readFileSync(filePath, "utf8"); if (isV2File(raw)) return null; const parsed = parseRawFrontmatter(raw); if (!parsed) return null; const { rawFm, body } = parsed; const fm = normalizeFrontmatter(rawFm); const changes = []; // scope rename if (rawFm.scope_kind === "user" || rawFm.scope_key === "user") { changes.push("scope user → personal"); } else if (!rawFm.scope) { changes.push(`scope → ${fm.scope} (default)`); } // storage dir move follows scope_key const toPath = path.join(memoriesRoot, fm.scope_key, path.basename(filePath)); if (toPath !== filePath) { changes.push(`file moved: ${path.basename(path.dirname(filePath))}/ → ${fm.scope_key}/`); } // visibility default if (!rawFm.visibility) { changes.push(`visibility → ${fm.visibility} (default)`); } // type taxonomy const rawType = rawFm.type; if (rawType === "instruction") { changes.push("type instruction → knowledge, role → instruction (D11)"); } else if (typeof rawType === "string" && rawType && !isTaxonomyType(rawType)) { changes.push(`type "${rawType}" → knowledge (not in v2 taxonomy)`); fm.type = "knowledge"; } else if (!rawType) { changes.push(`type → ${fm.type} (default)`); } // priority → importance if (typeof rawFm.priority === "number") { changes.push(`priority ${rawFm.priority} → importance ${fm.importance}`); } else if (!rawFm.importance) { changes.push(`importance → ${fm.importance} (default)`); } // times if (typeof rawFm.created_at === "number") { changes.push("created_at/updated_at: epoch ms → RFC 3339"); } // status default if (!rawFm.status) { changes.push(`status → ${fm.status} (default)`); } changes.push("schema_version → 2"); return { fm, body, fromPath: filePath, toPath, changes }; } /** * Locate a legacy `my-o-memory` data root next to the current root. * Pure lookup — no disk writes. */ export function findLegacyDataRoot() { const { root } = paths(); const legacy = path.join(path.dirname(root), "my-o-memory"); if (!fs.existsSync(legacy) || !fs.statSync(legacy).isDirectory()) return null; return legacy; } /** Dated backup path for a legacy data root. Pure — no disk writes. */ export function legacyBackupPath(legacy) { const stamp = new Date().toISOString().slice(0, 10); return `${legacy}.backup-${stamp}`; } /** * Merge a legacy `my-o-memory` data root into the current `open-memex` root. * The old dir is renamed to a dated backup — never deleted. * Failures throw an actionable Error (no raw syscall dump): on Windows the * backup rename typically fails with EPERM when another program holds the * folder open. */ function mergeLegacyDataRoot(legacy, backup) { const { memories } = paths(); const fail = (where, err) => { const code = err?.code; throw new Error(`could not ${where} (${legacy})` + (code ? ` [${code}]` : "") + `. Another program may be holding the folder open (e.g. a running MCP server, editor, or antivirus). ` + `Your memories are safe — nothing was deleted. Close the program and re-run ` + `\`open-memex migrate --to-v2\`, or rename the folder to ${backup} yourself and re-run.`); }; const legacyMem = path.join(legacy, "memories"); if (fs.existsSync(legacyMem)) { fs.mkdirSync(memories, { recursive: true }); for (const entry of fs.readdirSync(legacyMem)) { const src = path.join(legacyMem, entry); const dst = path.join(memories, entry); try { if (!fs.existsSync(dst)) { fs.renameSync(src, dst); } else { // merge file-by-file, never overwrite for (const name of fs.readdirSync(src)) { const s = path.join(src, name); const d = path.join(dst, name); if (!fs.existsSync(d)) fs.renameSync(s, d); } } } catch (err) { fail(`move memories from the legacy data dir`, err); } } } try { fs.renameSync(legacy, backup); } catch (err) { fail(`back up the legacy data dir`, err); } } /** Plan v1 → v2 conversion for every `.md` file under `dir`. Read-only. */ function planDir(dir, memoriesRoot, result) { if (!fs.existsSync(dir)) return; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const sub = path.join(dir, entry.name); for (const name of fs.readdirSync(sub)) { if (!name.endsWith(".md")) continue; const filePath = path.join(sub, name); result.scanned++; const plan = planConversion(filePath, memoriesRoot); if (!plan) { result.skippedV2++; continue; } result.converted++; result.plans.push(plan); } } } /** Apply conversion plans to disk (real run only — never in dry-run). */ function applyPlans(result) { for (const plan of result.plans) { fs.mkdirSync(path.dirname(plan.toPath), { recursive: true }); atomicWriteTextSync(plan.toPath, serialize(plan.fm, plan.body)); if (plan.toPath !== plan.fromPath) fs.unlinkSync(plan.fromPath); } } export function migrateV2(opts) { const { memories: memoriesRoot } = paths(); const result = { scanned: 0, converted: 0, skippedV2: 0, plans: [], legacyBackup: null, }; const legacy = findLegacyDataRoot(); if (legacy) { result.legacyBackup = legacyBackupPath(legacy); if (opts.dryRun) { // D44: dry-run previews the legacy files in place — nothing is moved, // so the preview actually shows what would convert (issue #7). planDir(path.join(legacy, "memories"), memoriesRoot, result); } else { mergeLegacyDataRoot(legacy, result.legacyBackup); } } planDir(memoriesRoot, memoriesRoot, result); if (!opts.dryRun) applyPlans(result); return result; }