UNPKG

open-memex

Version:

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

219 lines (200 loc) • 8.25 kB
/** * Export / import: portable memory archives (§9, D40). * * `export` bundles selected memories (markdown source of truth + manifest) * into a single .tar.gz for moving to another machine or another app. * `import` restores a bundle: personal memories go to the personal dir, * project memories are re-keyed to the current project and land in the * outbox as drafts (submit moves them into the repo). * * D40: export excludes `visibility: private` by default; `--all` / `-a` * includes everything — the full-migration escape hatch. */ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; import { db } from "./store/db.ts"; import { readMemoryFile, writeMemoryFile, normalizeFrontmatter, } from "./store/markdown.ts"; import { upsertFromFile } from "./store/sync.ts"; import { contentHash } from "./store/lifecycle.ts"; const EXPORT_FORMAT = "open-memex-export/1"; function fail(msg: string): never { throw new Error(`[open-memex] ${msg}`); } function checkTar(): void { try { execFileSync("tar", ["--version"], { stdio: ["ignore", "pipe", "ignore"] }); } catch { fail("the `tar` command is required for export/import but was not found on PATH"); } } export interface ExportOptions { /** scope keys to include (resolved by the caller) */ scopeKeys: string[]; type?: string; tag?: string; /** D40: include visibility:private memories */ includePrivate?: boolean; outFile?: string; } export interface ExportResult { file: string; exported: number; skippedPrivate: number; includePrivate: boolean; } interface ExportRow { id: string; scope_key: string; scope: string; visibility: string; type: string; file_path: string; } export function exportMemories(opts: ExportOptions): ExportResult { checkTar(); const includePrivate = opts.includePrivate ?? false; let sql = `SELECT id, scope_key, scope, visibility, type, file_path FROM memories WHERE scope_key IN (${opts.scopeKeys.map(() => "?").join(",")})`; const params: unknown[] = [...opts.scopeKeys]; if (!includePrivate) sql += ` AND visibility != 'private'`; if (opts.type) { sql += ` AND type = ?`; params.push(opts.type); } if (opts.tag) { sql += ` AND (',' || tags || ',' LIKE ?)`; params.push(`%,${opts.tag},%`); } sql += ` ORDER BY updated_at DESC`; const rows = db().prepare(sql).all(...(params as any[])) as ExportRow[]; const skippedPrivate = includePrivate ? 0 : (db().prepare( `SELECT COUNT(*) AS n FROM memories WHERE scope_key IN (${opts.scopeKeys.map(() => "?").join(",")}) AND visibility = 'private'`, ).get(...(opts.scopeKeys as any[])) as { n: number }).n; const stage = fs.mkdtempSync(path.join(os.tmpdir(), "open-memex-export-")); try { const memDir = path.join(stage, "memories"); fs.mkdirSync(memDir, { recursive: true }); const manifestEntries: Array<{ id: string; scope: string; file: string }> = []; let exported = 0; for (const r of rows) { const mf = readMemoryFile(r.file_path); if (!mf) continue; // stale index row — skip, don't fail the export const rel = path.join(r.scope, `${r.id}.md`); const dest = path.join(memDir, rel); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(r.file_path, dest); manifestEntries.push({ id: r.id, scope: r.scope, file: path.join("memories", rel) }); exported++; } const manifest = { format: EXPORT_FORMAT, exported_at: new Date().toISOString(), open_memex_version: readPackageVersion(), include_private: includePrivate, filters: { scope_keys: opts.scopeKeys, type: opts.type ?? null, tag: opts.tag ?? null, }, memories: manifestEntries, }; fs.writeFileSync(path.join(stage, "manifest.json"), JSON.stringify(manifest, null, 2), "utf8"); const stamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15); const outFile = opts.outFile ?? path.resolve(`open-memex-export-${stamp}.tar.gz`); execFileSync("tar", ["-czf", outFile, "-C", stage, "manifest.json", "memories"], { stdio: ["ignore", "pipe", "pipe"], }); return { file: outFile, exported, skippedPrivate, includePrivate }; } finally { fs.rmSync(stage, { recursive: true, force: true }); } } function readPackageVersion(): string { try { const here = new URL(import.meta.url); const pkg = path.join(path.dirname(here.pathname), "..", "package.json"); return (JSON.parse(fs.readFileSync(pkg, "utf8")) as { version: string }).version; } catch { return "unknown"; } } // --------------------------------------------------------------------------- // Import // --------------------------------------------------------------------------- export interface ImportOptions { /** current project scope key — project memories are re-keyed to it */ projectScopeKey: string; dryRun?: boolean; } export interface ImportResult { imported: number; skippedIdentical: number; skippedConflict: { id: string; file: string }[]; /** Manifest entries refused for pointing outside the bundle. */ rejectedPaths: string[]; } export function importBundle(bundlePath: string, opts: ImportOptions): ImportResult { checkTar(); if (!fs.existsSync(bundlePath)) fail(`bundle not found: ${bundlePath}`); const stage = fs.mkdtempSync(path.join(os.tmpdir(), "open-memex-import-")); try { execFileSync("tar", ["-xzf", path.resolve(bundlePath), "-C", stage], { stdio: ["ignore", "pipe", "pipe"], }); const manifestPath = path.join(stage, "manifest.json"); if (!fs.existsSync(manifestPath)) fail("not an open-memex export bundle (manifest.json missing)"); const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { format: string; memories: Array<{ id: string; scope: string; file: string }>; }; if (manifest.format !== EXPORT_FORMAT) { fail(`unsupported bundle format: ${manifest.format} (expected ${EXPORT_FORMAT})`); } const result: ImportResult = { imported: 0, skippedIdentical: 0, skippedConflict: [], rejectedPaths: [] }; const existingStmt = db().prepare(`SELECT content_hash FROM memories WHERE id = ?`); // The manifest comes from whoever sent the bundle: entry.file must // stay inside the staging dir (P0 review, 2026-10-03 — "../" entries // otherwise read arbitrary .md files into the store). realpath also // catches symlinks planted by the tarball. const stageRoot = fs.realpathSync(stage); for (const entry of manifest.memories ?? []) { const joined = path.resolve(path.join(stage, entry.file)); const src = fs.existsSync(joined) ? fs.realpathSync(joined) : joined; if (!src.startsWith(stageRoot + path.sep)) { result.rejectedPaths.push(entry.file); continue; } const mf = readMemoryFile(src); if (!mf) continue; const fm = normalizeFrontmatter({ ...mf.fm } as unknown as Record<string, unknown>); // Re-key to this machine: personal stays personal; project memories // adopt the current project's scope key (keys embed a path hash). if (fm.scope === "project") fm.scope_key = opts.projectScopeKey; else if (fm.scope === "personal") fm.scope_key = "personal"; const existing = existingStmt.get(fm.id) as { content_hash: string } | undefined; if (existing) { if (existing.content_hash === contentHash(mf.body)) result.skippedIdentical++; else result.skippedConflict.push({ id: fm.id, file: entry.file }); continue; } if (opts.dryRun) { result.imported++; continue; } const { filePath } = writeMemoryFile(fm, mf.body); const written = readMemoryFile(filePath); if (written) upsertFromFile(written); result.imported++; } return result; } finally { fs.rmSync(stage, { recursive: true, force: true }); } }