open-memex
Version:
Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.
168 lines (167 loc) • 7.22 kB
JavaScript
/**
* 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.js";
import { readMemoryFile, writeMemoryFile, normalizeFrontmatter, } from "./store/markdown.js";
import { upsertFromFile } from "./store/sync.js";
import { contentHash } from "./store/lifecycle.js";
const EXPORT_FORMAT = "open-memex-export/1";
function fail(msg) {
throw new Error(`[open-memex] ${msg}`);
}
function checkTar() {
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 function exportMemories(opts) {
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 = [...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);
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).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 = [];
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() {
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")).version;
}
catch {
return "unknown";
}
}
export function importBundle(bundlePath, opts) {
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"));
if (manifest.format !== EXPORT_FORMAT) {
fail(`unsupported bundle format: ${manifest.format} (expected ${EXPORT_FORMAT})`);
}
const result = { 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 });
// 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);
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 });
}
}