open-memex 0.4.0-alpha.7 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/export.ts ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Export / import: portable memory archives (§9, D40).
3
+ *
4
+ * `export` bundles selected memories (markdown source of truth + manifest)
5
+ * into a single .tar.gz for moving to another machine or another app.
6
+ * `import` restores a bundle: personal memories go to the personal dir,
7
+ * project memories are re-keyed to the current project and land in the
8
+ * outbox as drafts (submit moves them into the repo).
9
+ *
10
+ * D40: export excludes `visibility: private` by default; `--all` / `-a`
11
+ * includes everything — the full-migration escape hatch.
12
+ */
13
+ import fs from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { execFileSync } from "node:child_process";
17
+ import { db } from "./store/db.ts";
18
+ import {
19
+ readMemoryFile,
20
+ writeMemoryFile,
21
+ normalizeFrontmatter,
22
+ } from "./store/markdown.ts";
23
+ import { upsertFromFile } from "./store/sync.ts";
24
+ import { contentHash } from "./store/lifecycle.ts";
25
+
26
+ const EXPORT_FORMAT = "open-memex-export/1";
27
+
28
+ function fail(msg: string): never {
29
+ throw new Error(`[open-memex] ${msg}`);
30
+ }
31
+
32
+ function checkTar(): void {
33
+ try {
34
+ execFileSync("tar", ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
35
+ } catch {
36
+ fail("the `tar` command is required for export/import but was not found on PATH");
37
+ }
38
+ }
39
+
40
+ export interface ExportOptions {
41
+ /** scope keys to include (resolved by the caller) */
42
+ scopeKeys: string[];
43
+ type?: string;
44
+ tag?: string;
45
+ /** D40: include visibility:private memories */
46
+ includePrivate?: boolean;
47
+ outFile?: string;
48
+ }
49
+
50
+ export interface ExportResult {
51
+ file: string;
52
+ exported: number;
53
+ skippedPrivate: number;
54
+ includePrivate: boolean;
55
+ }
56
+
57
+ interface ExportRow {
58
+ id: string;
59
+ scope_key: string;
60
+ scope: string;
61
+ visibility: string;
62
+ type: string;
63
+ file_path: string;
64
+ }
65
+
66
+ export function exportMemories(opts: ExportOptions): ExportResult {
67
+ checkTar();
68
+ const includePrivate = opts.includePrivate ?? false;
69
+
70
+ let sql = `SELECT id, scope_key, scope, visibility, type, file_path FROM memories WHERE scope_key IN (${opts.scopeKeys.map(() => "?").join(",")})`;
71
+ const params: unknown[] = [...opts.scopeKeys];
72
+ if (!includePrivate) sql += ` AND visibility != 'private'`;
73
+ if (opts.type) {
74
+ sql += ` AND type = ?`;
75
+ params.push(opts.type);
76
+ }
77
+ if (opts.tag) {
78
+ sql += ` AND (',' || tags || ',' LIKE ?)`;
79
+ params.push(`%,${opts.tag},%`);
80
+ }
81
+ sql += ` ORDER BY updated_at DESC`;
82
+ const rows = db().prepare(sql).all(...(params as any[])) as ExportRow[];
83
+
84
+ const skippedPrivate = includePrivate
85
+ ? 0
86
+ : (db().prepare(
87
+ `SELECT COUNT(*) AS n FROM memories WHERE scope_key IN (${opts.scopeKeys.map(() => "?").join(",")}) AND visibility = 'private'`,
88
+ ).get(...(opts.scopeKeys as any[])) as { n: number }).n;
89
+
90
+ const stage = fs.mkdtempSync(path.join(os.tmpdir(), "open-memex-export-"));
91
+ try {
92
+ const memDir = path.join(stage, "memories");
93
+ fs.mkdirSync(memDir, { recursive: true });
94
+ const manifestEntries: Array<{ id: string; scope: string; file: string }> = [];
95
+ let exported = 0;
96
+ for (const r of rows) {
97
+ const mf = readMemoryFile(r.file_path);
98
+ if (!mf) continue; // stale index row — skip, don't fail the export
99
+ const rel = path.join(r.scope, `${r.id}.md`);
100
+ const dest = path.join(memDir, rel);
101
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
102
+ fs.copyFileSync(r.file_path, dest);
103
+ manifestEntries.push({ id: r.id, scope: r.scope, file: path.join("memories", rel) });
104
+ exported++;
105
+ }
106
+ const manifest = {
107
+ format: EXPORT_FORMAT,
108
+ exported_at: new Date().toISOString(),
109
+ open_memex_version: readPackageVersion(),
110
+ include_private: includePrivate,
111
+ filters: {
112
+ scope_keys: opts.scopeKeys,
113
+ type: opts.type ?? null,
114
+ tag: opts.tag ?? null,
115
+ },
116
+ memories: manifestEntries,
117
+ };
118
+ fs.writeFileSync(path.join(stage, "manifest.json"), JSON.stringify(manifest, null, 2), "utf8");
119
+
120
+ const stamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15);
121
+ const outFile = opts.outFile ?? path.resolve(`open-memex-export-${stamp}.tar.gz`);
122
+ execFileSync("tar", ["-czf", outFile, "-C", stage, "manifest.json", "memories"], {
123
+ stdio: ["ignore", "pipe", "pipe"],
124
+ });
125
+ return { file: outFile, exported, skippedPrivate, includePrivate };
126
+ } finally {
127
+ fs.rmSync(stage, { recursive: true, force: true });
128
+ }
129
+ }
130
+
131
+ function readPackageVersion(): string {
132
+ try {
133
+ const here = new URL(import.meta.url);
134
+ const pkg = path.join(path.dirname(here.pathname), "..", "package.json");
135
+ return (JSON.parse(fs.readFileSync(pkg, "utf8")) as { version: string }).version;
136
+ } catch {
137
+ return "unknown";
138
+ }
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Import
143
+ // ---------------------------------------------------------------------------
144
+
145
+ export interface ImportOptions {
146
+ /** current project scope key — project memories are re-keyed to it */
147
+ projectScopeKey: string;
148
+ dryRun?: boolean;
149
+ }
150
+
151
+ export interface ImportResult {
152
+ imported: number;
153
+ skippedIdentical: number;
154
+ skippedConflict: { id: string; file: string }[];
155
+ }
156
+
157
+ export function importBundle(bundlePath: string, opts: ImportOptions): ImportResult {
158
+ checkTar();
159
+ if (!fs.existsSync(bundlePath)) fail(`bundle not found: ${bundlePath}`);
160
+ const stage = fs.mkdtempSync(path.join(os.tmpdir(), "open-memex-import-"));
161
+ try {
162
+ execFileSync("tar", ["-xzf", path.resolve(bundlePath), "-C", stage], {
163
+ stdio: ["ignore", "pipe", "pipe"],
164
+ });
165
+ const manifestPath = path.join(stage, "manifest.json");
166
+ if (!fs.existsSync(manifestPath)) fail("not an open-memex export bundle (manifest.json missing)");
167
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
168
+ format: string;
169
+ memories: Array<{ id: string; scope: string; file: string }>;
170
+ };
171
+ if (manifest.format !== EXPORT_FORMAT) {
172
+ fail(`unsupported bundle format: ${manifest.format} (expected ${EXPORT_FORMAT})`);
173
+ }
174
+
175
+ const result: ImportResult = { imported: 0, skippedIdentical: 0, skippedConflict: [] };
176
+ const existingStmt = db().prepare(`SELECT content_hash FROM memories WHERE id = ?`);
177
+ for (const entry of manifest.memories ?? []) {
178
+ const src = path.join(stage, entry.file);
179
+ const mf = readMemoryFile(src);
180
+ if (!mf) continue;
181
+ const fm = normalizeFrontmatter({ ...mf.fm } as unknown as Record<string, unknown>);
182
+ // Re-key to this machine: personal stays personal; project memories
183
+ // adopt the current project's scope key (keys embed a path hash).
184
+ if (fm.scope === "project") fm.scope_key = opts.projectScopeKey;
185
+ else if (fm.scope === "personal") fm.scope_key = "personal";
186
+
187
+ const existing = existingStmt.get(fm.id) as { content_hash: string } | undefined;
188
+ if (existing) {
189
+ if (existing.content_hash === contentHash(mf.body)) result.skippedIdentical++;
190
+ else result.skippedConflict.push({ id: fm.id, file: entry.file });
191
+ continue;
192
+ }
193
+ if (opts.dryRun) {
194
+ result.imported++;
195
+ continue;
196
+ }
197
+ const { filePath } = writeMemoryFile(fm, mf.body);
198
+ const written = readMemoryFile(filePath);
199
+ if (written) upsertFromFile(written);
200
+ result.imported++;
201
+ }
202
+ return result;
203
+ } finally {
204
+ fs.rmSync(stage, { recursive: true, force: true });
205
+ }
206
+ }
package/src/mcp.ts CHANGED
@@ -137,6 +137,22 @@ export async function runMcpServer() {
137
137
  syncScope(PERSONAL_SCOPE.key, "session");
138
138
  console.error(`[open-memex] MCP server up. scope=${scope.key}`);
139
139
 
140
+ // D12: pulls are explicit by default — session start never touches the
141
+ // network. With sync.autoPull, one best-effort pull; a failure never
142
+ // blocks the session, it just logs and continues.
143
+ if (cfg.sync?.autoPull) {
144
+ try {
145
+ const { GitProvider } = await import("./providers/git.ts");
146
+ const r = new GitProvider().pull(process.cwd());
147
+ syncScope(scope.key, "pull");
148
+ console.error(
149
+ `[open-memex] auto-pull: ${r.branch} ${r.fastForwarded ? "fast-forwarded" : "already up to date"}`,
150
+ );
151
+ } catch (e) {
152
+ console.error(`[open-memex] auto-pull skipped: ${(e as Error).message}`);
153
+ }
154
+ }
155
+
140
156
  // D26: re-sync on every request, not just at startup. The in-repo dir
141
157
  // follows the current git branch, so a branch switch mid-session would
142
158
  // otherwise leave the index pointing at files that no longer exist.
@@ -0,0 +1,191 @@
1
+ /**
2
+ * GitProvider — the git transport for repo-synced shared scopes (§9, D12).
3
+ *
4
+ * Git is a transport, not the product boundary: this provider moves the
5
+ * in-repo memory dir (`.ai/open-memex/`) between the local checkout and the
6
+ * remote. It never touches the appdata outbox, never force-pushes, never
7
+ * auto-merges a divergence — those are human decisions.
8
+ *
9
+ * Hard rules (from §9 / D12 / D36):
10
+ * - pull is explicit (`open-memex pull`); pull = fetch + fast-forward only.
11
+ * - push is explicit (`open-memex push`); the tool never pushes on its own.
12
+ * - a failed pull/push fails with a clear message and leaves no broken state.
13
+ */
14
+ import { execFileSync } from "node:child_process";
15
+
16
+ function fail(msg: string): never {
17
+ throw new Error(`[open-memex] ${msg}`);
18
+ }
19
+
20
+ /** Run git, raising a readable error. `timeoutMs` guards network calls. */
21
+ function git(root: string, args: string[], timeoutMs = 0): string {
22
+ try {
23
+ return execFileSync("git", args, {
24
+ cwd: root,
25
+ encoding: "utf8",
26
+ stdio: ["ignore", "pipe", "pipe"],
27
+ ...(timeoutMs > 0 ? { timeout: timeoutMs } : {}),
28
+ }).trim();
29
+ } catch (e) {
30
+ const err = e as { stderr?: string; message?: string; code?: string };
31
+ if (err.code === "ETIMEDOUT") fail(`git ${args.join(" ")} timed out — remote unreachable?`);
32
+ const detail = (err.stderr ?? err.message ?? "").trim().split("\n")[0];
33
+ fail(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
34
+ }
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Provider capability model (§2) — every provider declares what it can do.
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export interface MemoryProviderCapabilities {
42
+ read: boolean;
43
+ write: boolean;
44
+ delete: boolean;
45
+ /** version / audit trail */
46
+ history: boolean;
47
+ sync: "none" | "pull" | "push" | "bidirectional";
48
+ }
49
+
50
+ export interface MemoryProvider {
51
+ readonly name: string;
52
+ readonly capabilities: MemoryProviderCapabilities;
53
+ }
54
+
55
+ export interface PullReceipt {
56
+ at: string; // RFC 3339
57
+ branch: string;
58
+ remote: string;
59
+ before: string; // HEAD sha before the pull
60
+ after: string; // HEAD sha after the pull
61
+ fastForwarded: boolean;
62
+ }
63
+
64
+ export interface PushReceipt {
65
+ at: string; // RFC 3339
66
+ branch: string;
67
+ remote: string;
68
+ head: string; // sha pushed
69
+ }
70
+
71
+ export interface GitSyncStatus {
72
+ branch: string;
73
+ upstream: string | null;
74
+ remote: string | null;
75
+ ahead: number;
76
+ behind: number;
77
+ }
78
+
79
+ const FETCH_TIMEOUT_MS = 30_000;
80
+
81
+ export class GitProvider implements MemoryProvider {
82
+ readonly name = "git";
83
+ readonly capabilities: MemoryProviderCapabilities = {
84
+ read: true,
85
+ write: true,
86
+ delete: false,
87
+ history: true,
88
+ sync: "bidirectional",
89
+ };
90
+
91
+ /** Current branch + upstream, or a clear failure when git can't answer. */
92
+ status(root: string): GitSyncStatus {
93
+ git(root, ["rev-parse", "--git-dir"]);
94
+ const branch =
95
+ git(root, ["branch", "--show-current"]) ||
96
+ git(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
97
+ let upstream: string | null = null;
98
+ try {
99
+ upstream = git(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
100
+ } catch {
101
+ upstream = null;
102
+ }
103
+ let ahead = 0;
104
+ let behind = 0;
105
+ if (upstream) {
106
+ const counts = git(root, ["rev-list", "--left-right", "--count", `HEAD...${upstream}`]);
107
+ const [a, b] = counts.split(/\s+/).map((n) => parseInt(n, 10));
108
+ ahead = Number.isFinite(a) ? a : 0;
109
+ behind = Number.isFinite(b) ? b : 0;
110
+ }
111
+ const remote = upstream ? upstream.split("/")[0] : null;
112
+ return { branch, upstream, remote, ahead, behind };
113
+ }
114
+
115
+ /**
116
+ * Explicit pull: fetch + fast-forward only (§9).
117
+ * Diverged branches are NOT merged — the user resolves them by hand.
118
+ */
119
+ pull(root: string): PullReceipt {
120
+ const st = this.status(root);
121
+ if (!st.upstream || !st.remote) {
122
+ fail(
123
+ `branch ${st.branch} has no upstream — set one with ` +
124
+ `\`git push -u <remote> ${st.branch}\`, then pull again`,
125
+ );
126
+ }
127
+ git(root, ["fetch", st.remote], FETCH_TIMEOUT_MS);
128
+ const before = git(root, ["rev-parse", "HEAD"]);
129
+ const remoteSha = git(root, ["rev-parse", st.upstream]);
130
+ if (before === remoteSha) {
131
+ return {
132
+ at: new Date().toISOString(),
133
+ branch: st.branch,
134
+ remote: st.remote,
135
+ before,
136
+ after: before,
137
+ fastForwarded: false,
138
+ };
139
+ }
140
+ // Fast-forward is possible iff HEAD is an ancestor of the upstream.
141
+ let ffPossible = false;
142
+ try {
143
+ git(root, ["merge-base", "--is-ancestor", "HEAD", st.upstream]);
144
+ ffPossible = true;
145
+ } catch {
146
+ ffPossible = false;
147
+ }
148
+ if (!ffPossible) {
149
+ fail(
150
+ `branch ${st.branch} has diverged from ${st.upstream} — ` +
151
+ `open-memex never force-merges; resolve it by hand ` +
152
+ `(rebase or merge), then pull again`,
153
+ );
154
+ }
155
+ git(root, ["merge", "--ff-only", st.upstream]);
156
+ const after = git(root, ["rev-parse", "HEAD"]);
157
+ return {
158
+ at: new Date().toISOString(),
159
+ branch: st.branch,
160
+ remote: st.remote,
161
+ before,
162
+ after,
163
+ fastForwarded: true,
164
+ };
165
+ }
166
+
167
+ /** Explicit push of the current branch. Never called automatically. */
168
+ push(root: string): PushReceipt {
169
+ const st = this.status(root);
170
+ if (!st.remote) {
171
+ // No upstream yet: push explicitly sets it (-u), still user-invoked.
172
+ const remotes = git(root, ["remote"]);
173
+ const remote = remotes.split("\n").map((r) => r.trim()).filter(Boolean)[0];
174
+ if (!remote) fail("no git remote configured — add one before pushing");
175
+ git(root, ["push", "-u", remote, st.branch], FETCH_TIMEOUT_MS);
176
+ return {
177
+ at: new Date().toISOString(),
178
+ branch: st.branch,
179
+ remote,
180
+ head: git(root, ["rev-parse", "HEAD"]),
181
+ };
182
+ }
183
+ git(root, ["push", st.remote, st.branch], FETCH_TIMEOUT_MS);
184
+ return {
185
+ at: new Date().toISOString(),
186
+ branch: st.branch,
187
+ remote: st.remote,
188
+ head: git(root, ["rev-parse", "HEAD"]),
189
+ };
190
+ }
191
+ }
package/src/store/sync.ts CHANGED
@@ -153,7 +153,7 @@ export function syncScope(scopeKey: string, kind: SyncKind = "auto"): SyncStats
153
153
  // ---------------------------------------------------------------------------
154
154
 
155
155
  /** What triggered the sync — shown in sync-status so the user can see it. */
156
- export type SyncKind = "session" | "request" | "cli" | "submit" | "auto";
156
+ export type SyncKind = "session" | "request" | "cli" | "submit" | "pull" | "push" | "auto";
157
157
 
158
158
  export interface SyncStateEntry {
159
159
  lastSyncAt: string; // RFC 3339
@@ -118,23 +118,48 @@ export interface MigrateV2Result {
118
118
  }
119
119
 
120
120
  /**
121
- * Merge a legacy `my-o-memory` data root into the current `open-memex` root.
122
- * The old dir is renamed to a dated backup — never deleted. Returns the
123
- * backup path, or null when no legacy dir exists.
121
+ * Locate a legacy `my-o-memory` data root next to the current root.
122
+ * Pure lookup — no disk writes.
124
123
  */
125
- function migrateLegacyDataRoot(dryRun: boolean): string | null {
124
+ export function findLegacyDataRoot(): string | null {
126
125
  const { root } = paths();
127
126
  const legacy = path.join(path.dirname(root), "my-o-memory");
128
127
  if (!fs.existsSync(legacy) || !fs.statSync(legacy).isDirectory()) return null;
128
+ return legacy;
129
+ }
129
130
 
131
+ /** Dated backup path for a legacy data root. Pure — no disk writes. */
132
+ export function legacyBackupPath(legacy: string): string {
130
133
  const stamp = new Date().toISOString().slice(0, 10);
131
- const backup = `${legacy}.backup-${stamp}`;
132
- if (!dryRun) {
133
- const legacyMem = path.join(legacy, "memories");
134
- if (fs.existsSync(legacyMem)) {
135
- for (const entry of fs.readdirSync(legacyMem)) {
136
- const src = path.join(legacyMem, entry);
137
- const dst = path.join(root, "memories", entry);
134
+ return `${legacy}.backup-${stamp}`;
135
+ }
136
+
137
+ /**
138
+ * Merge a legacy `my-o-memory` data root into the current `open-memex` root.
139
+ * The old dir is renamed to a dated backup — never deleted.
140
+ * Failures throw an actionable Error (no raw syscall dump): on Windows the
141
+ * backup rename typically fails with EPERM when another program holds the
142
+ * folder open.
143
+ */
144
+ function mergeLegacyDataRoot(legacy: string, backup: string): void {
145
+ const { memories } = paths();
146
+ const fail = (where: string, err: unknown): never => {
147
+ const code = (err as NodeJS.ErrnoException)?.code;
148
+ throw new Error(
149
+ `could not ${where} (${legacy})` +
150
+ (code ? ` [${code}]` : "") +
151
+ `. Another program may be holding the folder open (e.g. a running MCP server, editor, or antivirus). ` +
152
+ `Your memories are safe — nothing was deleted. Close the program and re-run ` +
153
+ `\`open-memex migrate --to-v2\`, or rename the folder to ${backup} yourself and re-run.`,
154
+ );
155
+ };
156
+ const legacyMem = path.join(legacy, "memories");
157
+ if (fs.existsSync(legacyMem)) {
158
+ fs.mkdirSync(memories, { recursive: true });
159
+ for (const entry of fs.readdirSync(legacyMem)) {
160
+ const src = path.join(legacyMem, entry);
161
+ const dst = path.join(memories, entry);
162
+ try {
138
163
  if (!fs.existsSync(dst)) {
139
164
  fs.renameSync(src, dst);
140
165
  } else {
@@ -145,46 +170,75 @@ function migrateLegacyDataRoot(dryRun: boolean): string | null {
145
170
  if (!fs.existsSync(d)) fs.renameSync(s, d);
146
171
  }
147
172
  }
173
+ } catch (err) {
174
+ fail(`move memories from the legacy data dir`, err);
148
175
  }
149
176
  }
177
+ }
178
+ try {
150
179
  fs.renameSync(legacy, backup);
180
+ } catch (err) {
181
+ fail(`back up the legacy data dir`, err);
151
182
  }
152
- return backup;
153
183
  }
154
184
 
155
- export function migrateV2(opts: { dryRun: boolean }): MigrateV2Result {
156
- const { memories: memoriesRoot } = paths();
157
- const result: MigrateV2Result = {
158
- scanned: 0,
159
- converted: 0,
160
- skippedV2: 0,
161
- plans: [],
162
- legacyBackup: null,
163
- };
164
-
165
- result.legacyBackup = migrateLegacyDataRoot(opts.dryRun);
166
-
167
- if (!fs.existsSync(memoriesRoot)) return result;
168
- for (const entry of fs.readdirSync(memoriesRoot, { withFileTypes: true })) {
185
+ /** Plan v1 → v2 conversion for every `.md` file under `dir`. Read-only. */
186
+ function planDir(
187
+ dir: string,
188
+ memoriesRoot: string,
189
+ result: MigrateV2Result,
190
+ ): void {
191
+ if (!fs.existsSync(dir)) return;
192
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
169
193
  if (!entry.isDirectory()) continue;
170
- const dir = path.join(memoriesRoot, entry.name);
171
- for (const name of fs.readdirSync(dir)) {
194
+ const sub = path.join(dir, entry.name);
195
+ for (const name of fs.readdirSync(sub)) {
172
196
  if (!name.endsWith(".md")) continue;
173
- const filePath = path.join(dir, name);
197
+ const filePath = path.join(sub, name);
174
198
  result.scanned++;
175
199
  const plan = planConversion(filePath, memoriesRoot);
176
200
  if (!plan) {
177
201
  result.skippedV2++;
178
202
  continue;
179
203
  }
180
- if (!opts.dryRun) {
181
- fs.mkdirSync(path.dirname(plan.toPath), { recursive: true });
182
- fs.writeFileSync(plan.toPath, serialize(plan.fm, plan.body), "utf8");
183
- if (plan.toPath !== plan.fromPath) fs.unlinkSync(plan.fromPath);
184
- }
185
204
  result.converted++;
186
205
  result.plans.push(plan);
187
206
  }
188
207
  }
208
+ }
209
+
210
+ /** Apply conversion plans to disk (real run only — never in dry-run). */
211
+ function applyPlans(result: MigrateV2Result): void {
212
+ for (const plan of result.plans) {
213
+ fs.mkdirSync(path.dirname(plan.toPath), { recursive: true });
214
+ fs.writeFileSync(plan.toPath, serialize(plan.fm, plan.body), "utf8");
215
+ if (plan.toPath !== plan.fromPath) fs.unlinkSync(plan.fromPath);
216
+ }
217
+ }
218
+
219
+ export function migrateV2(opts: { dryRun: boolean }): MigrateV2Result {
220
+ const { memories: memoriesRoot } = paths();
221
+ const result: MigrateV2Result = {
222
+ scanned: 0,
223
+ converted: 0,
224
+ skippedV2: 0,
225
+ plans: [],
226
+ legacyBackup: null,
227
+ };
228
+
229
+ const legacy = findLegacyDataRoot();
230
+ if (legacy) {
231
+ result.legacyBackup = legacyBackupPath(legacy);
232
+ if (opts.dryRun) {
233
+ // D44: dry-run previews the legacy files in place — nothing is moved,
234
+ // so the preview actually shows what would convert (issue #7).
235
+ planDir(path.join(legacy, "memories"), memoriesRoot, result);
236
+ } else {
237
+ mergeLegacyDataRoot(legacy, result.legacyBackup);
238
+ }
239
+ }
240
+
241
+ planDir(memoriesRoot, memoriesRoot, result);
242
+ if (!opts.dryRun) applyPlans(result);
189
243
  return result;
190
244
  }