UNPKG

open-memex

Version:

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

121 lines (120 loc) • 5.07 kB
import fs from "node:fs"; import path from "node:path"; import { memoriesDirFor, memoriesDirPath } from "../paths.js"; import { db } from "./db.js"; import { iterMemoryFiles, readMemoryFile, serialize, timeToMs, atomicWriteTextSync, } from "./markdown.js"; import { syncScope } from "./sync.js"; import { PERSONAL_SCOPE } from "../scope.js"; /** True if this scope has at least one `.md` file on disk. Does not create * the directory if it doesn't already exist. */ export function scopeHasFiles(scopeKey) { const dir = memoriesDirPath(scopeKey); if (!fs.existsSync(dir)) return false; return fs.readdirSync(dir).some((f) => f.endsWith(".md")); } /** * Move all memories from `fromKey` to `toKey`. Rewrites each file's * `scope_key` (and optionally `project_name`) frontmatter. On id collision * in the destination, `onConflict` decides: * - "newer" (default): keep the file with the greater `updated_at` * - "overwrite": always take the source * - "skip": always keep the destination * * After moving files, clears any lingering FTS rows for `fromKey` and * re-runs `syncScope(toKey)` so the index reflects reality. Removes the * empty source directory when possible. */ export function migrateScope(fromKey, toKey, opts = {}) { const log = opts.logger ?? (() => { }); const strategy = opts.onConflict ?? "newer"; const dryRun = !!opts.dryRun; const stats = { moved: 0, skipped: 0, conflicts: 0, dryRun }; if (fromKey === toKey) { throw new Error("migrate: --from and --to are identical"); } const fromDir = memoriesDirPath(fromKey); const toDir = memoriesDirFor(toKey); if (!fs.existsSync(fromDir)) { log(`no source directory: ${fromDir}`); return stats; } for (const srcPath of iterMemoryFiles(fromKey)) { const mf = readMemoryFile(srcPath); if (!mf) { log(`skip unparseable: ${srcPath}`); stats.skipped++; continue; } const dstPath = path.join(toDir, path.basename(srcPath)); let action = "write"; if (fs.existsSync(dstPath)) { stats.conflicts++; const dst = readMemoryFile(dstPath); if (!dst) { log(`conflict but destination unreadable, overwriting: ${dstPath}`); } else if (strategy === "skip") { action = "skip"; log(`conflict: kept destination (--on-conflict=skip): ${dstPath}`); } else if (strategy === "overwrite") { log(`conflict: overwriting destination (--on-conflict=overwrite): ${dstPath}`); } else if (timeToMs(dst.fm.updated_at) >= timeToMs(mf.fm.updated_at)) { // Compare parsed instants, not RFC-3339 strings: lexicographic // order breaks around fractional seconds ("…:00Z" > "…:00.005Z"), // letting the older file win. action = "skip"; log(`conflict: kept destination (newer updated_at): ${dstPath}`); } else { log(`conflict: replaced destination (source newer): ${dstPath}`); } } if (action === "skip") { stats.skipped++; continue; } // Re-key AND re-scope: scope/visibility must follow the destination // (P0 review, 2026-10-03). Rewriting only scope_key produced files // submit rejects ("scope is personal") or personal memories carrying // visibility: internal into exports. const toPersonal = toKey === PERSONAL_SCOPE.key; const newFm = { ...mf.fm, scope_key: toKey, scope: toPersonal ? "personal" : "project", visibility: toPersonal ? "private" : "internal", project_name: opts.toProjectName ?? mf.fm.project_name, }; const raw = serialize(newFm, mf.body); if (!dryRun) { atomicWriteTextSync(dstPath, raw); // Only unlink source if it's a different path (paths differ because // scope dir differs; belt-and-suspenders check). if (path.resolve(srcPath) !== path.resolve(dstPath)) { fs.unlinkSync(srcPath); } } log(`${dryRun ? "would move" : "moved"}: ${srcPath} -> ${dstPath}`); stats.moved++; } if (!dryRun) { // Best-effort: remove now-empty source dir. try { if (fs.existsSync(fromDir) && fs.readdirSync(fromDir).length === 0) { fs.rmdirSync(fromDir); } } catch { /* ignore */ } // Drop any remaining FTS/memories rows for the old scope, then rebuild // the destination index from disk. `syncScope` also handles removals for // the destination scope if a conflict-skip left stale rows behind. db().prepare(`DELETE FROM memories WHERE scope_key = ?`).run(fromKey); syncScope(toKey); } return stats; }