open-memex 0.4.0 → 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/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # open-memex
2
2
 
3
+ > A shared organizational memory layer that helps people and AI capture, retain, and reuse institutional knowledge.
4
+
3
5
  [![npm version](https://img.shields.io/npm/v/open-memex.svg)](https://www.npmjs.com/package/open-memex)
4
6
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](./LICENSE)
5
7
 
package/README.zh-CN.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # open-memex
2
2
 
3
+ > 共享组织记忆层,帮助人与 AI 捕获、沉淀并复用组织知识。
4
+
3
5
  [![npm version](https://img.shields.io/npm/v/open-memex.svg)](https://www.npmjs.com/package/open-memex)
4
6
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](./LICENSE)
5
7
 
package/dist/cli.js CHANGED
@@ -223,11 +223,15 @@ Usage: open-memex migrate [--from <key>] [--to <key>] [--dry-run] [--on-conflict
223
223
 
224
224
  Flags:
225
225
  --from / --to scope keys (default: current project → personal)
226
- --dry-run preview without moving anything
226
+ --dry-run preview without moving anything (--to-v2 previews the legacy
227
+ files in place, including per-file conversion plans)
227
228
  --on-conflict newer (default), overwrite, or skip
228
229
  --to-v2 convert a legacy my-o-memory data dir to the v2 layout
229
230
 
230
231
  Always preview with --dry-run first; nothing moves without confirmation.
232
+ On Windows, if another program holds the legacy folder open, the backup
233
+ rename fails with an actionable message instead of a stack trace — close
234
+ the program and re-run.
231
235
 
232
236
  Examples:
233
237
  open-memex migrate --dry-run
@@ -376,14 +380,21 @@ function parseFlags(argv) {
376
380
  for (let i = 0; i < argv.length; i++) {
377
381
  const a = argv[i];
378
382
  if (a.startsWith("--")) {
379
- const key = a.slice(2);
383
+ const raw = a.slice(2);
384
+ const eq = raw.indexOf("=");
385
+ if (eq >= 0) {
386
+ // D44: accept --key=value as well as --key value (issue #7 — the
387
+ // = form was silently misparsed before, dropping the flag).
388
+ out[raw.slice(0, eq)] = raw.slice(eq + 1);
389
+ continue;
390
+ }
380
391
  const val = argv[i + 1];
381
392
  if (val !== undefined && !val.startsWith("--")) {
382
- out[key] = val;
393
+ out[raw] = val;
383
394
  i++;
384
395
  }
385
396
  else {
386
- out[key] = "true";
397
+ out[raw] = "true";
387
398
  }
388
399
  }
389
400
  }
@@ -486,7 +497,15 @@ async function main() {
486
497
  if (cmd === "migrate" && rest.includes("--to-v2")) {
487
498
  const flags = parseFlags(rest);
488
499
  const dryRun = flags["dry-run"] === "true";
489
- const stats = migrateV2({ dryRun });
500
+ let stats;
501
+ try {
502
+ stats = migrateV2({ dryRun });
503
+ }
504
+ catch (err) {
505
+ // D44: actionable message, not a raw syscall stack (issue #7).
506
+ console.error(`Error: ${err.message}`);
507
+ process.exit(1);
508
+ }
490
509
  console.log(`${dryRun ? "DRY RUN: " : ""}scanned ${stats.scanned} files: ` +
491
510
  `${stats.converted} to convert, ${stats.skippedV2} already v2`);
492
511
  for (const p of stats.plans) {
@@ -496,7 +515,9 @@ async function main() {
496
515
  console.log(` - ${c}`);
497
516
  }
498
517
  if (stats.legacyBackup) {
499
- console.log(`\nlegacy my-o-memory data dir merged; backup kept at:\n ${stats.legacyBackup}`);
518
+ console.log(dryRun
519
+ ? `\nDRY RUN: legacy my-o-memory data dir found — it would be merged and backed up at:\n ${stats.legacyBackup}`
520
+ : `\nlegacy my-o-memory data dir merged; backup kept at:\n ${stats.legacyBackup}`);
500
521
  }
501
522
  if (!dryRun && stats.converted > 0) {
502
523
  console.log(`\nindex schema will rebuild automatically on next run; ` +
@@ -83,23 +83,45 @@ export function planConversion(filePath, memoriesRoot) {
83
83
  return { fm, body, fromPath: filePath, toPath, changes };
84
84
  }
85
85
  /**
86
- * Merge a legacy `my-o-memory` data root into the current `open-memex` root.
87
- * The old dir is renamed to a dated backup — never deleted. Returns the
88
- * backup path, or null when no legacy dir exists.
86
+ * Locate a legacy `my-o-memory` data root next to the current root.
87
+ * Pure lookup — no disk writes.
89
88
  */
90
- function migrateLegacyDataRoot(dryRun) {
89
+ export function findLegacyDataRoot() {
91
90
  const { root } = paths();
92
91
  const legacy = path.join(path.dirname(root), "my-o-memory");
93
92
  if (!fs.existsSync(legacy) || !fs.statSync(legacy).isDirectory())
94
93
  return null;
94
+ return legacy;
95
+ }
96
+ /** Dated backup path for a legacy data root. Pure — no disk writes. */
97
+ export function legacyBackupPath(legacy) {
95
98
  const stamp = new Date().toISOString().slice(0, 10);
96
- const backup = `${legacy}.backup-${stamp}`;
97
- if (!dryRun) {
98
- const legacyMem = path.join(legacy, "memories");
99
- if (fs.existsSync(legacyMem)) {
100
- for (const entry of fs.readdirSync(legacyMem)) {
101
- const src = path.join(legacyMem, entry);
102
- const dst = path.join(root, "memories", entry);
99
+ return `${legacy}.backup-${stamp}`;
100
+ }
101
+ /**
102
+ * Merge a legacy `my-o-memory` data root into the current `open-memex` root.
103
+ * The old dir is renamed to a dated backup — never deleted.
104
+ * Failures throw an actionable Error (no raw syscall dump): on Windows the
105
+ * backup rename typically fails with EPERM when another program holds the
106
+ * folder open.
107
+ */
108
+ function mergeLegacyDataRoot(legacy, backup) {
109
+ const { memories } = paths();
110
+ const fail = (where, err) => {
111
+ const code = err?.code;
112
+ throw new Error(`could not ${where} (${legacy})` +
113
+ (code ? ` [${code}]` : "") +
114
+ `. Another program may be holding the folder open (e.g. a running MCP server, editor, or antivirus). ` +
115
+ `Your memories are safe — nothing was deleted. Close the program and re-run ` +
116
+ `\`open-memex migrate --to-v2\`, or rename the folder to ${backup} yourself and re-run.`);
117
+ };
118
+ const legacyMem = path.join(legacy, "memories");
119
+ if (fs.existsSync(legacyMem)) {
120
+ fs.mkdirSync(memories, { recursive: true });
121
+ for (const entry of fs.readdirSync(legacyMem)) {
122
+ const src = path.join(legacyMem, entry);
123
+ const dst = path.join(memories, entry);
124
+ try {
103
125
  if (!fs.existsSync(dst)) {
104
126
  fs.renameSync(src, dst);
105
127
  }
@@ -113,46 +135,73 @@ function migrateLegacyDataRoot(dryRun) {
113
135
  }
114
136
  }
115
137
  }
138
+ catch (err) {
139
+ fail(`move memories from the legacy data dir`, err);
140
+ }
116
141
  }
142
+ }
143
+ try {
117
144
  fs.renameSync(legacy, backup);
118
145
  }
119
- return backup;
146
+ catch (err) {
147
+ fail(`back up the legacy data dir`, err);
148
+ }
120
149
  }
121
- export function migrateV2(opts) {
122
- const { memories: memoriesRoot } = paths();
123
- const result = {
124
- scanned: 0,
125
- converted: 0,
126
- skippedV2: 0,
127
- plans: [],
128
- legacyBackup: null,
129
- };
130
- result.legacyBackup = migrateLegacyDataRoot(opts.dryRun);
131
- if (!fs.existsSync(memoriesRoot))
132
- return result;
133
- for (const entry of fs.readdirSync(memoriesRoot, { withFileTypes: true })) {
150
+ /** Plan v1 → v2 conversion for every `.md` file under `dir`. Read-only. */
151
+ function planDir(dir, memoriesRoot, result) {
152
+ if (!fs.existsSync(dir))
153
+ return;
154
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
134
155
  if (!entry.isDirectory())
135
156
  continue;
136
- const dir = path.join(memoriesRoot, entry.name);
137
- for (const name of fs.readdirSync(dir)) {
157
+ const sub = path.join(dir, entry.name);
158
+ for (const name of fs.readdirSync(sub)) {
138
159
  if (!name.endsWith(".md"))
139
160
  continue;
140
- const filePath = path.join(dir, name);
161
+ const filePath = path.join(sub, name);
141
162
  result.scanned++;
142
163
  const plan = planConversion(filePath, memoriesRoot);
143
164
  if (!plan) {
144
165
  result.skippedV2++;
145
166
  continue;
146
167
  }
147
- if (!opts.dryRun) {
148
- fs.mkdirSync(path.dirname(plan.toPath), { recursive: true });
149
- fs.writeFileSync(plan.toPath, serialize(plan.fm, plan.body), "utf8");
150
- if (plan.toPath !== plan.fromPath)
151
- fs.unlinkSync(plan.fromPath);
152
- }
153
168
  result.converted++;
154
169
  result.plans.push(plan);
155
170
  }
156
171
  }
172
+ }
173
+ /** Apply conversion plans to disk (real run only — never in dry-run). */
174
+ function applyPlans(result) {
175
+ for (const plan of result.plans) {
176
+ fs.mkdirSync(path.dirname(plan.toPath), { recursive: true });
177
+ fs.writeFileSync(plan.toPath, serialize(plan.fm, plan.body), "utf8");
178
+ if (plan.toPath !== plan.fromPath)
179
+ fs.unlinkSync(plan.fromPath);
180
+ }
181
+ }
182
+ export function migrateV2(opts) {
183
+ const { memories: memoriesRoot } = paths();
184
+ const result = {
185
+ scanned: 0,
186
+ converted: 0,
187
+ skippedV2: 0,
188
+ plans: [],
189
+ legacyBackup: null,
190
+ };
191
+ const legacy = findLegacyDataRoot();
192
+ if (legacy) {
193
+ result.legacyBackup = legacyBackupPath(legacy);
194
+ if (opts.dryRun) {
195
+ // D44: dry-run previews the legacy files in place — nothing is moved,
196
+ // so the preview actually shows what would convert (issue #7).
197
+ planDir(path.join(legacy, "memories"), memoriesRoot, result);
198
+ }
199
+ else {
200
+ mergeLegacyDataRoot(legacy, result.legacyBackup);
201
+ }
202
+ }
203
+ planDir(memoriesRoot, memoriesRoot, result);
204
+ if (!opts.dryRun)
205
+ applyPlans(result);
157
206
  return result;
158
207
  }
package/docs/V2-DESIGN.md CHANGED
@@ -867,6 +867,21 @@ requirement: personal data never touches third-party services). Benchmarks to tr
867
867
  MCP handshake instructions or the `init`-written instruction files — but
868
868
  opencode reads AGENTS.md natively, so the distilled snippet teaches the
869
869
  checkpoint habit wherever it lands. Approved 2026-09-29.*
870
+ - **D44** — `migrate --to-v2` hotfix (issue #7, 0.4.1): (1) `--dry-run` now
871
+ previews the legacy `my-o-memory` files in place — per-file conversion plans
872
+ without moving anything (previously it scanned only the new, still-empty
873
+ root and always reported "0 files", making the preview useless); (2) dry-run
874
+ no longer prints the false "legacy data dir merged" line; (3) the Windows
875
+ EPERM on the legacy-dir backup rename is caught and rethrown as an actionable
876
+ message (close the program holding the folder — e.g. a running MCP server —
877
+ and re-run; nothing was deleted), and the CLI prints it as `Error: …` with
878
+ exit 1 instead of a raw syscall stack; (4) `parseFlags` accepts `--key=value`
879
+ in addition to `--key value` (the `=` form was silently misparsed before,
880
+ dropping the flag — which can turn a `--dry-run` into a real run).
881
+ *Rationale: a preview that shows nothing is worse than no preview — the user
882
+ cannot confirm what the real run will do; and a destructive-path failure must
883
+ speak in user terms. Shipped as 0.4.1 hotfix on the stable line. Approved
884
+ 2026-09-29.*
870
885
  - **D41** — The type taxonomy is reconciled to 11 types with one-line definitions
871
886
  (§3.1): `fact` `preference` `decision` `constraint` `todo` `knowledge` `howto`
872
887
  `gotcha` `lesson` `observation` `reference`. Merged away: `warning`→`gotcha`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-memex",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,7 +1,7 @@
1
1
  // Quick smoke test — runs the pure-logic modules (no bun:sqlite dependency).
2
2
  // Usage: node --experimental-strip-types scripts\smoke-pure.ts
3
3
  import { parse, serialize, ulid, normalizeFrontmatter, msToRfc3339, timeToMs, parseRawFrontmatter, type Frontmatter } from "../src/store/markdown.ts";
4
- import { planConversion, isV2File } from "../src/store/v2migrate.ts";
4
+ import { planConversion, isV2File, migrateV2 } from "../src/store/v2migrate.ts";
5
5
  import { redact, findSecret } from "../src/redact.ts";
6
6
  import { detectKeywords } from "../src/capture/keywords.ts";
7
7
  import { DEFAULT_CONFIG } from "../src/config.ts";
@@ -314,6 +314,54 @@ const plan2 = planConversion(v1path, memRoot);
314
314
  ok("v2 on disk → null plan", plan2 === null);
315
315
  fs.rmSync(tmpRoot, { recursive: true, force: true });
316
316
 
317
+ console.log("== migrateV2 dry-run previews legacy files (issue #7) ==");
318
+ // legacy dir sits next to the new root (sibling), as on a real v1 upgrade
319
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "v2hotfix-"));
320
+ const newRoot = path.join(tmpHome, "open-memex");
321
+ const legacyRoot = path.join(tmpHome, "my-o-memory");
322
+ const legacyMem = path.join(legacyRoot, "memories", "user");
323
+ fs.mkdirSync(legacyMem, { recursive: true });
324
+ const v1legacy = `---\nid: 01HOTFIX1\nscope_key: user\nscope_kind: user\nproject_name: user\ntype: fact\npriority: 1\ncreated_at: 1758854400000\nupdated_at: 1758854400000\n---\n\nlegacy content\n`;
325
+ fs.writeFileSync(path.join(legacyMem, "01HOTFIX1.md"), v1legacy, "utf8");
326
+ process.env.MY_O_MEMORY_HOME = newRoot;
327
+
328
+ const dry = migrateV2({ dryRun: true });
329
+ ok("dry-run scans the legacy file", dry.scanned === 1 && dry.plans.length === 1, `scanned=${dry.scanned}`);
330
+ ok("dry-run yields a conversion plan", dry.plans.length === 1 && dry.plans[0].toPath.startsWith(newRoot), dry.plans[0]?.toPath);
331
+ ok("dry-run reports legacy backup target", typeof dry.legacyBackup === "string" && dry.legacyBackup.includes("backup-"), dry.legacyBackup);
332
+ ok("dry-run moves nothing", fs.existsSync(path.join(legacyMem, "01HOTFIX1.md")) && !fs.existsSync(path.join(newRoot, "memories", "personal", "01HOTFIX1.md")));
333
+ ok("dry-run does not rename legacy dir", fs.existsSync(legacyRoot) && !fs.existsSync(dry.legacyBackup!));
334
+
335
+ const real = migrateV2({ dryRun: false });
336
+ ok("real run converts", real.converted === 1, `converted=${real.converted}`);
337
+ ok("real run writes v2 file to new root", fs.existsSync(path.join(newRoot, "memories", "personal", "01HOTFIX1.md")));
338
+ ok("real run backs up legacy dir", !fs.existsSync(legacyRoot) && fs.existsSync(real.legacyBackup!));
339
+ const backContent = fs.readFileSync(path.join(newRoot, "memories", "personal", "01HOTFIX1.md"), "utf8");
340
+ ok("converted file has schema_version 2", backContent.includes("schema_version: 2"));
341
+
342
+ // backup rename failure → actionable Error, exit-1-worthy, no raw stack
343
+ // (same process/root: paths() is cached per process)
344
+ const legacyRoot2 = path.join(tmpHome, "my-o-memory");
345
+ const legacyMem2 = path.join(legacyRoot2, "memories", "user");
346
+ fs.mkdirSync(legacyMem2, { recursive: true });
347
+ fs.writeFileSync(path.join(legacyMem2, "01HOTFIX2.md"), v1legacy, "utf8");
348
+ // block the rename: pre-create today's dated backup as a non-empty dir
349
+ const blocked = path.join(tmpHome, "my-o-memory.backup-" + new Date().toISOString().slice(0, 10));
350
+ fs.mkdirSync(blocked, { recursive: true });
351
+ fs.writeFileSync(path.join(blocked, "sentinel"), "x", "utf8");
352
+ let boom: unknown = null;
353
+ try {
354
+ migrateV2({ dryRun: false });
355
+ } catch (e) {
356
+ boom = e;
357
+ }
358
+ ok("backup failure throws", boom instanceof Error, String(boom));
359
+ ok("backup failure message is actionable", boom instanceof Error && boom.message.includes("could not back up") && boom.message.includes("Your memories are safe"), boom instanceof Error ? boom.message.slice(0, 60) : "");
360
+ ok("failed backup keeps moved files safe in new root", fs.existsSync(path.join(newRoot, "memories", "user", "01HOTFIX2.md")));
361
+ ok("failed backup leaves legacy dir for retry", fs.existsSync(legacyRoot2));
362
+ delete process.env.MY_O_MEMORY_HOME;
363
+ fs.rmSync(tmpHome, { recursive: true, force: true });
364
+
317
365
  console.log("== lifecycle pure: contentHash / similarity ==");
318
366
  ok("hash deterministic + whitespace-insensitive", contentHash("hello world\n") === contentHash("hello world"));
319
367
  ok("hash differs on content", contentHash("hello world") !== contentHash("hello mars"));
package/src/cli.ts CHANGED
@@ -250,11 +250,15 @@ Usage: open-memex migrate [--from <key>] [--to <key>] [--dry-run] [--on-conflict
250
250
 
251
251
  Flags:
252
252
  --from / --to scope keys (default: current project → personal)
253
- --dry-run preview without moving anything
253
+ --dry-run preview without moving anything (--to-v2 previews the legacy
254
+ files in place, including per-file conversion plans)
254
255
  --on-conflict newer (default), overwrite, or skip
255
256
  --to-v2 convert a legacy my-o-memory data dir to the v2 layout
256
257
 
257
258
  Always preview with --dry-run first; nothing moves without confirmation.
259
+ On Windows, if another program holds the legacy folder open, the backup
260
+ rename fails with an actionable message instead of a stack trace — close
261
+ the program and re-run.
258
262
 
259
263
  Examples:
260
264
  open-memex migrate --dry-run
@@ -412,13 +416,20 @@ function parseFlags(argv: string[]): Record<string, string> {
412
416
  for (let i = 0; i < argv.length; i++) {
413
417
  const a = argv[i]!;
414
418
  if (a.startsWith("--")) {
415
- const key = a.slice(2);
419
+ const raw = a.slice(2);
420
+ const eq = raw.indexOf("=");
421
+ if (eq >= 0) {
422
+ // D44: accept --key=value as well as --key value (issue #7 — the
423
+ // = form was silently misparsed before, dropping the flag).
424
+ out[raw.slice(0, eq)] = raw.slice(eq + 1);
425
+ continue;
426
+ }
416
427
  const val = argv[i + 1];
417
428
  if (val !== undefined && !val.startsWith("--")) {
418
- out[key] = val;
429
+ out[raw] = val;
419
430
  i++;
420
431
  } else {
421
- out[key] = "true";
432
+ out[raw] = "true";
422
433
  }
423
434
  }
424
435
  }
@@ -548,7 +559,14 @@ async function main() {
548
559
  if (cmd === "migrate" && rest.includes("--to-v2")) {
549
560
  const flags = parseFlags(rest);
550
561
  const dryRun = flags["dry-run"] === "true";
551
- const stats = migrateV2({ dryRun });
562
+ let stats;
563
+ try {
564
+ stats = migrateV2({ dryRun });
565
+ } catch (err) {
566
+ // D44: actionable message, not a raw syscall stack (issue #7).
567
+ console.error(`Error: ${(err as Error).message}`);
568
+ process.exit(1);
569
+ }
552
570
  console.log(
553
571
  `${dryRun ? "DRY RUN: " : ""}scanned ${stats.scanned} files: ` +
554
572
  `${stats.converted} to convert, ${stats.skippedV2} already v2`,
@@ -562,7 +580,9 @@ async function main() {
562
580
  }
563
581
  if (stats.legacyBackup) {
564
582
  console.log(
565
- `\nlegacy my-o-memory data dir merged; backup kept at:\n ${stats.legacyBackup}`,
583
+ dryRun
584
+ ? `\nDRY RUN: legacy my-o-memory data dir found — it would be merged and backed up at:\n ${stats.legacyBackup}`
585
+ : `\nlegacy my-o-memory data dir merged; backup kept at:\n ${stats.legacyBackup}`,
566
586
  );
567
587
  }
568
588
  if (!dryRun && stats.converted > 0) {
@@ -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
  }