open-memex 0.7.0-alpha.3 → 0.7.0-alpha.4

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/AGENTS.md CHANGED
@@ -136,6 +136,7 @@ Every write path (tool, keyword hook, CLI `add`) must:
136
136
  2. If `hadSecret` → the matched secret is **masked in place** (first 4 characters kept, the rest replaced with `x`) and the write proceeds; never save the unmasked original. `<private>…</private>` spans are stripped to `[REDACTED]` instead (design D14).
137
137
  3. Check `findDuplicates` (design §3.4): identical content is idempotent (return existing id); near-duplicates (similarity ≥ 0.8) warn but save — suggest `supersede` when the new content replaces the old.
138
138
  4. `writeMemoryFile` first, then `readMemoryFile` + `upsertFromFile` to keep FTS in sync.
139
+ 5. Deletion reports honestly (D63): only ENOENT counts as "deleted"; any other unlink failure keeps the index row and returns failure — the file is the source of truth and would otherwise resurrect the memory on next sync.
139
140
 
140
141
  Capture-time aliases (D61): `memory_add`/`supersede` may carry up to 4
141
142
  `aliases` (alternate phrasings) into frontmatter; they index in their own FTS
package/CHANGELOG.md CHANGED
@@ -9,6 +9,17 @@ the `@alpha` tag are development snapshots and are not listed individually.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## Correctness fixes from the whole-project review (D63)
13
+
14
+ Six silent-wrong-answer bugs found by an external three-lens review, each fixed with a regression test:
15
+
16
+ - `init --client visualstudio` no longer wipes other servers from an existing `.mcp.json` (the parsed config was dropped instead of merged).
17
+ - `memory_forget` / `forget` tell the truth: when the memory file can't be deleted (locked, in use), they report failure and keep the index entry, instead of claiming success while the file — the source of truth — brings the memory back on next sync.
18
+ - Config parsing is string-aware: a redact pattern like `src/**/secrets` is no longer eaten as a block comment and silently disabled. `doctor` and the config loader now share one parser.
19
+ - `loadConfig` enforces the `memoryDir` invariant on hand-edited configs; an escaping value (`../../shared`) falls back to the default with a warning instead of writing project memories outside the repo.
20
+ - `migrate` re-scopes as well as re-keys: migrated files get the destination's `scope`/`visibility`, so personal→project migrations are submittable and project→personal memories stay private in exports. Conflict resolution compares parsed timestamps, not RFC-3339 strings.
21
+ - `import` rejects bundle entries whose paths escape the bundle (`../` or symlinks) instead of reading arbitrary local files into the store; rejected paths are printed.
22
+
12
23
  ## Environment variable names (D62)
13
24
 
14
25
  - The override variables now answer to `OPEN_MEMEX_HOME` (storage root) and `OPEN_MEMEX_CONFIG` (config path). The pre-rename `MY_O_MEMORY_HOME` / `MY_O_MEMORY_CONFIG` names keep working as fallbacks — nothing to migrate, the data root itself does not move. `doctor` notes when the storage root came from the legacy variable.
package/dist/cli.js CHANGED
@@ -954,12 +954,18 @@ async function main() {
954
954
  process.exit(1);
955
955
  }
956
956
  // Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
957
+ // Only ENOENT = success; anything else means the file survives and
958
+ // would be re-indexed — fail loudly (P0 review, 2026-10-03).
957
959
  if (row.file_path) {
958
960
  try {
959
961
  fs.unlinkSync(row.file_path);
960
962
  }
961
- catch {
962
- /* already gone */
963
+ catch (e) {
964
+ const code = e.code;
965
+ if (code !== "ENOENT") {
966
+ console.error(`could not delete ${row.file_path} (${code ?? "error"}) — memory not forgotten`);
967
+ process.exit(1);
968
+ }
963
969
  }
964
970
  }
965
971
  deleteFromIndex(id);
@@ -1176,6 +1182,9 @@ async function main() {
1176
1182
  syncScope(PERSONAL_SCOPE.key, "cli");
1177
1183
  }
1178
1184
  console.log(`${dryRun ? "DRY RUN: " : ""}imported ${r.imported}, skipped ${r.skippedIdentical} identical`);
1185
+ for (const rp of r.rejectedPaths) {
1186
+ console.log(` rejected: ${rp} (path outside the bundle)`);
1187
+ }
1179
1188
  for (const c of r.skippedConflict) {
1180
1189
  console.log(` conflict (kept existing): ${c.id} from ${c.file}`);
1181
1190
  }
package/dist/config.js CHANGED
@@ -37,13 +37,64 @@ export const DEFAULT_CONFIG = {
37
37
  memoryDir: ".ai/open-memex",
38
38
  sync: { autoPull: false },
39
39
  };
40
- function stripJsonComments(raw) {
41
- // Line comments
42
- let out = raw.replace(/^\s*\/\/.*$/gm, "");
43
- // Block comments (non-greedy)
44
- out = out.replace(/\/\*[\s\S]*?\*\//g, "");
45
- // Trailing commas before closing brackets
46
- out = out.replace(/,(\s*[}\]])/g, "$1");
40
+ /**
41
+ * Strip JSONC comments and trailing commas with a string-aware scanner.
42
+ * The previous regex version was not string-aware: a config value like
43
+ * "src/**\/secrets" had its `/**…*\/` eaten as a block comment, silently
44
+ * corrupting the user's redact pattern (P0 review, 2026-10-03). Shared
45
+ * with doctor.ts — one parser, not two.
46
+ */
47
+ export function stripJsonComments(raw) {
48
+ let out = "";
49
+ let i = 0;
50
+ let inStr = false;
51
+ let esc = false;
52
+ while (i < raw.length) {
53
+ const c = raw[i];
54
+ const n = raw[i + 1];
55
+ if (inStr) {
56
+ out += c;
57
+ if (esc)
58
+ esc = false;
59
+ else if (c === "\\")
60
+ esc = true;
61
+ else if (c === '"')
62
+ inStr = false;
63
+ i++;
64
+ continue;
65
+ }
66
+ if (c === '"') {
67
+ inStr = true;
68
+ out += c;
69
+ i++;
70
+ continue;
71
+ }
72
+ if (c === "/" && n === "/") {
73
+ while (i < raw.length && raw[i] !== "\n")
74
+ i++;
75
+ continue;
76
+ }
77
+ if (c === "/" && n === "*") {
78
+ i += 2;
79
+ while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/"))
80
+ i++;
81
+ i += 2;
82
+ continue;
83
+ }
84
+ if (c === ",") {
85
+ // Trailing comma: drop it when the next significant char closes a
86
+ // container. Whitespace/comments between are tolerated.
87
+ let j = i + 1;
88
+ while (j < raw.length && /\s/.test(raw[j]))
89
+ j++;
90
+ if (raw[j] === "}" || raw[j] === "]") {
91
+ i++;
92
+ continue;
93
+ }
94
+ }
95
+ out += c;
96
+ i++;
97
+ }
47
98
  return out;
48
99
  }
49
100
  /**
@@ -132,7 +183,19 @@ export function loadConfig() {
132
183
  try {
133
184
  const raw = fs.readFileSync(p, "utf8");
134
185
  const parsed = JSON.parse(stripJsonComments(raw));
135
- return { ...DEFAULT_CONFIG, ...parsed };
186
+ const merged = { ...DEFAULT_CONFIG, ...parsed };
187
+ // The config file is hand-editable, so the load path must enforce
188
+ // the same invariants `config set` does (P0 review, 2026-10-03):
189
+ // an unvalidated memoryDir like "../../shared" would put
190
+ // committed project memories outside the repo.
191
+ try {
192
+ merged.memoryDir = toRelativeDir(merged.memoryDir);
193
+ }
194
+ catch {
195
+ console.error(`[open-memex] invalid memoryDir "${merged.memoryDir}" in ${p} — using default "${DEFAULT_CONFIG.memoryDir}"`);
196
+ merged.memoryDir = DEFAULT_CONFIG.memoryDir;
197
+ }
198
+ return merged;
136
199
  }
137
200
  catch (err) {
138
201
  console.error(`[open-memex] failed to parse ${p}:`, err);
package/dist/doctor.js CHANGED
@@ -7,7 +7,7 @@ import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { spawn, execFileSync } from "node:child_process";
9
9
  import { fileURLToPath } from "node:url";
10
- import { loadConfig, configSource, DEFAULT_CONFIG } from "./config.js";
10
+ import { loadConfig, configSource, DEFAULT_CONFIG, stripJsonComments } from "./config.js";
11
11
  import { paths, homeOverride } from "./paths.js";
12
12
  import { resolveCwdScope } from "./scope.js";
13
13
  import { db, backendName } from "./store/db.js";
@@ -407,7 +407,8 @@ function mcpCheck() {
407
407
  });
408
408
  }
409
409
  /** Parse JSON tolerating line/block comments plus trailing commas
410
- * (VS Code's settings.json is JSONC). Health-check grade, not a full parser. */
410
+ * (VS Code's settings.json is JSONC). Uses config.ts's string-aware
411
+ * stripper — one parser, not two (P0 review, 2026-10-03). */
411
412
  function parseLenientJson(raw) {
412
413
  try {
413
414
  return JSON.parse(raw);
@@ -415,47 +416,7 @@ function parseLenientJson(raw) {
415
416
  catch {
416
417
  /* fall through to comment stripping */
417
418
  }
418
- let out = "";
419
- let i = 0;
420
- let inStr = false;
421
- let esc = false;
422
- while (i < raw.length) {
423
- const c = raw[i];
424
- const n = raw[i + 1];
425
- if (inStr) {
426
- out += c;
427
- if (esc)
428
- esc = false;
429
- else if (c === "\\")
430
- esc = true;
431
- else if (c === '"')
432
- inStr = false;
433
- i++;
434
- continue;
435
- }
436
- if (c === '"') {
437
- inStr = true;
438
- out += c;
439
- i++;
440
- continue;
441
- }
442
- if (c === "/" && n === "/") {
443
- while (i < raw.length && raw[i] !== "\n")
444
- i++;
445
- continue;
446
- }
447
- if (c === "/" && n === "*") {
448
- i += 2;
449
- while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/"))
450
- i++;
451
- i += 2;
452
- continue;
453
- }
454
- out += c;
455
- i++;
456
- }
457
- out = out.replace(/,\s*([}\]])/g, "$1");
458
- return JSON.parse(out);
419
+ return JSON.parse(stripJsonComments(raw));
459
420
  }
460
421
  function vscodeSettingsPath() {
461
422
  const home = os.homedir();
package/dist/export.js CHANGED
@@ -117,10 +117,20 @@ export function importBundle(bundlePath, opts) {
117
117
  if (manifest.format !== EXPORT_FORMAT) {
118
118
  fail(`unsupported bundle format: ${manifest.format} (expected ${EXPORT_FORMAT})`);
119
119
  }
120
- const result = { imported: 0, skippedIdentical: 0, skippedConflict: [] };
120
+ const result = { imported: 0, skippedIdentical: 0, skippedConflict: [], rejectedPaths: [] };
121
121
  const existingStmt = db().prepare(`SELECT content_hash FROM memories WHERE id = ?`);
122
+ // The manifest comes from whoever sent the bundle: entry.file must
123
+ // stay inside the staging dir (P0 review, 2026-10-03 — "../" entries
124
+ // otherwise read arbitrary .md files into the store). realpath also
125
+ // catches symlinks planted by the tarball.
126
+ const stageRoot = fs.realpathSync(stage);
122
127
  for (const entry of manifest.memories ?? []) {
123
- const src = path.join(stage, entry.file);
128
+ const joined = path.resolve(path.join(stage, entry.file));
129
+ const src = fs.existsSync(joined) ? fs.realpathSync(joined) : joined;
130
+ if (!src.startsWith(stageRoot + path.sep)) {
131
+ result.rejectedPaths.push(entry.file);
132
+ continue;
133
+ }
124
134
  const mf = readMemoryFile(src);
125
135
  if (!mf)
126
136
  continue;
package/dist/init.js CHANGED
@@ -470,6 +470,7 @@ function writeVisualStudioMcpJson(root, force) {
470
470
  });
471
471
  return null;
472
472
  }
473
+ doc = parsed;
473
474
  }
474
475
  const section = (doc["servers"] ??= {});
475
476
  // F30: drop the pre-rename server entry — it points at the OLD data dir.
@@ -2,8 +2,9 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { memoriesDirFor, memoriesDirPath } from "../paths.js";
4
4
  import { db } from "./db.js";
5
- import { iterMemoryFiles, readMemoryFile, serialize, } from "./markdown.js";
5
+ import { iterMemoryFiles, readMemoryFile, serialize, timeToMs, } from "./markdown.js";
6
6
  import { syncScope } from "./sync.js";
7
+ import { PERSONAL_SCOPE } from "../scope.js";
7
8
  /** True if this scope has at least one `.md` file on disk. Does not create
8
9
  * the directory if it doesn't already exist. */
9
10
  export function scopeHasFiles(scopeKey) {
@@ -60,7 +61,10 @@ export function migrateScope(fromKey, toKey, opts = {}) {
60
61
  else if (strategy === "overwrite") {
61
62
  log(`conflict: overwriting destination (--on-conflict=overwrite): ${dstPath}`);
62
63
  }
63
- else if (dst.fm.updated_at >= mf.fm.updated_at) {
64
+ else if (timeToMs(dst.fm.updated_at) >= timeToMs(mf.fm.updated_at)) {
65
+ // Compare parsed instants, not RFC-3339 strings: lexicographic
66
+ // order breaks around fractional seconds ("…:00Z" > "…:00.005Z"),
67
+ // letting the older file win.
64
68
  action = "skip";
65
69
  log(`conflict: kept destination (newer updated_at): ${dstPath}`);
66
70
  }
@@ -72,9 +76,16 @@ export function migrateScope(fromKey, toKey, opts = {}) {
72
76
  stats.skipped++;
73
77
  continue;
74
78
  }
79
+ // Re-key AND re-scope: scope/visibility must follow the destination
80
+ // (P0 review, 2026-10-03). Rewriting only scope_key produced files
81
+ // submit rejects ("scope is personal") or personal memories carrying
82
+ // visibility: internal into exports.
83
+ const toPersonal = toKey === PERSONAL_SCOPE.key;
75
84
  const newFm = {
76
85
  ...mf.fm,
77
86
  scope_key: toKey,
87
+ scope: toPersonal ? "personal" : "project",
88
+ visibility: toPersonal ? "private" : "internal",
78
89
  project_name: opts.toProjectName ?? mf.fm.project_name,
79
90
  };
80
91
  const raw = serialize(newFm, mf.body);
package/dist/tools/ops.js CHANGED
@@ -273,12 +273,21 @@ export async function forgetMemory(args) {
273
273
  return { title: "memory: not found", output: `No memory with id ${args.id}.` };
274
274
  }
275
275
  // Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
276
+ // Only ENOENT counts as "deleted": on any other unlink failure the file
277
+ // is still the source of truth and would be re-indexed on next sync, so
278
+ // report failure instead of lying (P0 review, 2026-10-03).
276
279
  if (row.file_path) {
277
280
  try {
278
281
  fs.unlinkSync(row.file_path);
279
282
  }
280
- catch {
281
- /* already gone */
283
+ catch (e) {
284
+ const code = e.code;
285
+ if (code !== "ENOENT") {
286
+ return {
287
+ title: "memory: forget failed",
288
+ output: `Could not delete ${row.file_path} (${code ?? "error"}) — the memory was NOT forgotten and is still in the index. Close anything holding the file and retry.`,
289
+ };
290
+ }
282
291
  }
283
292
  }
284
293
  deleteFromIndex(args.id);
package/docs/V2-DESIGN.md CHANGED
@@ -1316,6 +1316,24 @@ requirement: personal data never touches third-party services). Benchmarks to tr
1316
1316
  variable, and its pre-rename-dir leftovers check reads whichever
1317
1317
  variable is in effect.
1318
1318
 
1319
+ - **D63 — correctness fixes from a whole-project external review.** Six
1320
+ verified silent-wrong-answer bugs, fixed as one batch: (1) VS init
1321
+ dropped the parsed `.mcp.json` instead of merging it, wiping other
1322
+ servers; (2) forget reported success when unlink failed — only ENOENT
1323
+ is success now, both in the tool op and the CLI; (3) the config
1324
+ loader's regex comment-stripper was not string-aware and corrupted
1325
+ redact patterns (`src/**/secrets` → `srcsecrets`) — replaced with a
1326
+ string-aware scanner now shared with doctor (one parser, not two);
1327
+ (4) `loadConfig` enforces the `memoryDir` no-escape invariant on
1328
+ hand-edited files, falling back to the default with a warning;
1329
+ (5) `migrate` rewrites `scope`/`visibility` with the key, and
1330
+ conflict resolution compares parsed timestamps (lexicographic
1331
+ RFC-3339 order breaks at fractional seconds); (6) bundle import
1332
+ enforces staging-dir containment (resolve + realpath), rejecting
1333
+ `../` and symlink escapes. Rule going forward: any behavior that can
1334
+ fail must fail loudly in its return value; "already gone" is the only
1335
+ swallowed delete error.
1336
+
1319
1337
  ## Open Questions
1320
1338
 
1321
1339
  _All resolved — see D10 (rename), D11 (type/role split), D12 (explicit pull)._
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-memex",
3
- "version": "0.7.0-alpha.3",
3
+ "version": "0.7.0-alpha.4",
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",
@@ -13,7 +13,41 @@ import { userMcpConfigPath, mergeServerEntry, detectInstalledClients, mergePlugi
13
13
  import { z } from "zod";
14
14
  import { memoryAddArgs } from "../src/tools/ops.ts";
15
15
  import { dataRootPath, homeOverride } from "../src/paths.ts";
16
- import { configFilePath } from "../src/config.ts";
16
+ import { configFilePath, loadConfig, stripJsonComments } from "../src/config.ts";
17
+
18
+ console.log("== jsonc parsing + config invariants (P0) ==");
19
+ {
20
+ // The pre-fix regex stripper ate "src/**/secrets" as a block comment,
21
+ // silently disabling the user's redact rule.
22
+ const doc = JSON.parse(stripJsonComments(`{
23
+ // a line comment with a "quote
24
+ "redactPatterns": ["src/**/secrets", "a /* not a comment */ b"],
25
+ /* a real block comment */
26
+ "memoryDir": ".ai/open-memex",
27
+ }`)) as { redactPatterns: string[] };
28
+ ok("redact pattern with ** survives comment stripping",
29
+ doc.redactPatterns[0] === "src/**/secrets" && doc.redactPatterns[1] === "a /* not a comment */ b",
30
+ JSON.stringify(doc.redactPatterns));
31
+ }
32
+ {
33
+ // loadConfig must enforce the memoryDir invariant on hand-edited files.
34
+ const saved = process.env.OPEN_MEMEX_CONFIG;
35
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "om-cfg-"));
36
+ const file = path.join(dir, "c.jsonc");
37
+ fs.writeFileSync(file, JSON.stringify({ memoryDir: "../../shared" }));
38
+ const errs: unknown[] = [];
39
+ const origErr = console.error;
40
+ console.error = (...a: unknown[]) => errs.push(a);
41
+ try {
42
+ process.env.OPEN_MEMEX_CONFIG = file;
43
+ ok("escaping memoryDir falls back to default", loadConfig().memoryDir === ".ai/open-memex");
44
+ } finally {
45
+ console.error = origErr;
46
+ if (saved === undefined) delete process.env.OPEN_MEMEX_CONFIG;
47
+ else process.env.OPEN_MEMEX_CONFIG = saved;
48
+ fs.rmSync(dir, { recursive: true, force: true });
49
+ }
50
+ }
17
51
 
18
52
  let fails = 0;
19
53
  function ok(name: string, cond: boolean, info?: unknown) {
@@ -176,6 +176,40 @@ ok("forget deletes", r.code === 0 && /delet/i.test(r.out), r.out.slice(0, 120));
176
176
  r = cli(["list"], PROJ);
177
177
  ok("forgotten id gone from list", !r.out.includes(idDecision));
178
178
 
179
+ // P0: forget must not report success when the file cannot be deleted —
180
+ // the file is the source of truth and would be re-indexed on next sync.
181
+ // (Simulated by putting a directory where the file belongs: unlink fails
182
+ // with EISDIR even for root, unlike permission bits.)
183
+ {
184
+ const idF = addMem(["fulltest forget failure"], PROJ);
185
+ const memRoot = path.join(TESTENV.MY_O_MEMORY_HOME as string, "memories");
186
+ let memFile = "";
187
+ const findFile = (d: string) => {
188
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
189
+ const p2 = path.join(d, e.name);
190
+ if (e.isDirectory()) findFile(p2);
191
+ else if (e.name === idF + ".md") memFile = p2;
192
+ }
193
+ };
194
+ findFile(memRoot);
195
+ ok("forget-failure setup found the file", !!memFile);
196
+ const backup = fs.readFileSync(memFile, "utf8");
197
+ fs.rmSync(memFile);
198
+ fs.mkdirSync(memFile);
199
+ try {
200
+ r = cli(["forget", idF], PROJ);
201
+ ok("forget reports failure when unlink fails",
202
+ r.code !== 0 && /could not delete/i.test(r.err + r.out),
203
+ (r.err || r.out).slice(0, 150));
204
+ } finally {
205
+ fs.rmSync(memFile, { recursive: true });
206
+ fs.writeFileSync(memFile, backup);
207
+ }
208
+ r = cli(["list"], PROJ);
209
+ ok("memory survives failed forget", r.out.includes(idF), r.out.slice(0, 150));
210
+ r = cli(["forget", idF], PROJ);
211
+ ok("forget succeeds once the file is deletable again", r.code === 0, (r.err || r.out).slice(0, 150));
212
+ }
179
213
  // ---------- 4. propose / submit / promote / sync-status ----------
180
214
  console.log("== review workflow: propose / submit / promote ==");
181
215
  const idProp = addMem(["fulltest proposal candidate", "--scope", "personal"], PROJ);
@@ -231,6 +265,26 @@ const cfile = path.join(T, "conflict.tar.gz");
231
265
  execFileSync("tar", ["-czf", cfile, "-C", cstage, "manifest.json", "memories"], { env: TESTENV });
232
266
  r = cli2Retry(["import", cfile], PROJ2);
233
267
  ok("import conflict reported, never overwritten", /conflict/.test(r.out) && /imported 0/.test(r.out), r.out.slice(0, 160));
268
+ // P0: a bundle manifest pointing outside the bundle must be rejected —
269
+ // it otherwise imports arbitrary local .md files into the store. The
270
+ // secret is planted next to the import staging dir (os.tmpdir()), which
271
+ // is where "../" from a manifest entry lands.
272
+ {
273
+ const secretName = `outside-secret-${process.pid}.md`;
274
+ const secretFile = path.join(os.tmpdir(), secretName);
275
+ const secretId = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
276
+ fs.writeFileSync(secretFile, `---\nid: ${secretId}\nscope: personal\nscope_key: personal\nvisibility: private\ntype: fact\nstatus: active\ncreated_at: 2026-01-01T00:00:00Z\nupdated_at: 2026-01-01T00:00:00Z\n---\n\nfulltest traversal secret\n`);
277
+ const tstage = path.join(T, "tstage");
278
+ fs.mkdirSync(tstage, { recursive: true });
279
+ fs.writeFileSync(path.join(tstage, "manifest.json"), JSON.stringify({ format: "open-memex-export/1", exported_at: "2026-01-01T00:00:00Z", open_memex_version: "t", include_private: true, filters: {}, memories: [{ id: secretId, scope: "personal", file: `../${secretName}` }] }));
280
+ const tfile = path.join(T, "traversal.tar.gz");
281
+ execFileSync("tar", ["-czf", tfile, "-C", tstage, "manifest.json"], { env: TESTENV });
282
+ r = cli2Retry(["import", tfile], PROJ2);
283
+ ok("import rejects path outside bundle", /imported 0/.test(r.out) && /rejected/.test(r.out), r.out.slice(0, 160));
284
+ r = cli2(["search", "traversal", "--scope", "personal"], PROJ2);
285
+ ok("traversed file never landed in store", !/traversal secret/.test(r.out), r.out.slice(0, 160));
286
+ fs.rmSync(secretFile, { force: true });
287
+ }
234
288
 
235
289
  // ---------- 6. distill-agents ----------
236
290
  console.log("== distill-agents ==");
@@ -279,6 +333,24 @@ try { dbOk = fs.existsSync(dbPath) && fs.statSync(dbPath).mtimeMs >= beforeReind
279
333
  ok("reindex rebuilds", dbOk, `db exists: ${fs.existsSync(dbPath)}`);
280
334
  r = cli(["list", "--scope", "personal"], PROJ);
281
335
  ok("list works after reindex", r.out.includes(idMig) || r.out.includes(idPersonal));
336
+ // P0: a real migrate must re-scope the file, not just re-key it — a
337
+ // personal memory migrated to the project key must become submittable
338
+ // project memory (scope: project in its frontmatter).
339
+ r = cli(["migrate", "--from", "personal", "--to", "project"], PROJ);
340
+ ok("migrate personal→project runs", r.code === 0, (r.err || r.out).slice(0, 150));
341
+ {
342
+ const memRoot = path.join(TESTENV.MY_O_MEMORY_HOME as string, "memories");
343
+ let moved = "";
344
+ const walk = (d: string) => {
345
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
346
+ const p = path.join(d, e.name);
347
+ if (e.isDirectory()) walk(p);
348
+ else if (e.name === idMig + ".md") moved = fs.readFileSync(p, "utf8");
349
+ }
350
+ };
351
+ walk(memRoot);
352
+ ok("migrated file is now scope: project", /^scope: project$/m.test(moved), moved.slice(0, 200));
353
+ }
282
354
 
283
355
  // ---------- 9. capture / doctor / mcp --print-config / init ----------
284
356
  console.log("== capture / doctor / mcp / init ==");
@@ -290,6 +362,17 @@ for (const c of ["vscode", "cursor", "claude", "opencode", "visualstudio"]) {
290
362
  }
291
363
  r = cli(["init", "--client", "vscode", "--yes"], PROJ);
292
364
  ok("init --client vscode --yes", r.code === 0 && fs.existsSync(path.join(PROJ, ".vscode", "mcp.json")), (r.err || r.out).slice(0, 150));
365
+ // P0: VS init must merge into an existing .mcp.json, not replace it —
366
+ // the parsed doc used to be dropped, wiping other MCP servers.
367
+ {
368
+ const mcpFile = path.join(PROJ, ".mcp.json");
369
+ fs.writeFileSync(mcpFile, JSON.stringify({ servers: { "other-server": { type: "stdio", command: "other" } } }));
370
+ r = cli(["init", "--client", "visualstudio", "--yes"], PROJ);
371
+ const after = JSON.parse(fs.readFileSync(mcpFile, "utf8")) as { servers?: Record<string, unknown> };
372
+ ok("VS init preserves other servers in .mcp.json",
373
+ r.code === 0 && !!after.servers?.["other-server"] && !!after.servers?.["open-memex"],
374
+ JSON.stringify(after).slice(0, 160));
375
+ }
293
376
  r = cliRetry(["doctor"], PROJ);
294
377
  ok("doctor", r.code === 0 && /All checks passed/.test(r.out), (r.err || r.out).slice(0, 200));
295
378
 
package/src/cli.ts CHANGED
@@ -1059,11 +1059,17 @@ async function main() {
1059
1059
  process.exit(1);
1060
1060
  }
1061
1061
  // Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
1062
+ // Only ENOENT = success; anything else means the file survives and
1063
+ // would be re-indexed — fail loudly (P0 review, 2026-10-03).
1062
1064
  if (row.file_path) {
1063
1065
  try {
1064
1066
  fs.unlinkSync(row.file_path);
1065
- } catch {
1066
- /* already gone */
1067
+ } catch (e) {
1068
+ const code = (e as NodeJS.ErrnoException).code;
1069
+ if (code !== "ENOENT") {
1070
+ console.error(`could not delete ${row.file_path} (${code ?? "error"}) — memory not forgotten`);
1071
+ process.exit(1);
1072
+ }
1067
1073
  }
1068
1074
  }
1069
1075
  deleteFromIndex(id);
@@ -1281,6 +1287,9 @@ function positionalArgs(argv: string[]): string[] {
1281
1287
  console.log(
1282
1288
  `${dryRun ? "DRY RUN: " : ""}imported ${r.imported}, skipped ${r.skippedIdentical} identical`,
1283
1289
  );
1290
+ for (const rp of r.rejectedPaths) {
1291
+ console.log(` rejected: ${rp} (path outside the bundle)`);
1292
+ }
1284
1293
  for (const c of r.skippedConflict) {
1285
1294
  console.log(` conflict (kept existing): ${c.id} from ${c.file}`);
1286
1295
  }
package/src/config.ts CHANGED
@@ -74,13 +74,58 @@ export const DEFAULT_CONFIG: MyOMemoryConfig = {
74
74
  sync: { autoPull: false },
75
75
  };
76
76
 
77
- function stripJsonComments(raw: string): string {
78
- // Line comments
79
- let out = raw.replace(/^\s*\/\/.*$/gm, "");
80
- // Block comments (non-greedy)
81
- out = out.replace(/\/\*[\s\S]*?\*\//g, "");
82
- // Trailing commas before closing brackets
83
- out = out.replace(/,(\s*[}\]])/g, "$1");
77
+ /**
78
+ * Strip JSONC comments and trailing commas with a string-aware scanner.
79
+ * The previous regex version was not string-aware: a config value like
80
+ * "src/**\/secrets" had its `/**…*\/` eaten as a block comment, silently
81
+ * corrupting the user's redact pattern (P0 review, 2026-10-03). Shared
82
+ * with doctor.ts — one parser, not two.
83
+ */
84
+ export function stripJsonComments(raw: string): string {
85
+ let out = "";
86
+ let i = 0;
87
+ let inStr = false;
88
+ let esc = false;
89
+ while (i < raw.length) {
90
+ const c = raw[i]!;
91
+ const n = raw[i + 1];
92
+ if (inStr) {
93
+ out += c;
94
+ if (esc) esc = false;
95
+ else if (c === "\\") esc = true;
96
+ else if (c === '"') inStr = false;
97
+ i++;
98
+ continue;
99
+ }
100
+ if (c === '"') {
101
+ inStr = true;
102
+ out += c;
103
+ i++;
104
+ continue;
105
+ }
106
+ if (c === "/" && n === "/") {
107
+ while (i < raw.length && raw[i] !== "\n") i++;
108
+ continue;
109
+ }
110
+ if (c === "/" && n === "*") {
111
+ i += 2;
112
+ while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
113
+ i += 2;
114
+ continue;
115
+ }
116
+ if (c === ",") {
117
+ // Trailing comma: drop it when the next significant char closes a
118
+ // container. Whitespace/comments between are tolerated.
119
+ let j = i + 1;
120
+ while (j < raw.length && /\s/.test(raw[j]!)) j++;
121
+ if (raw[j] === "}" || raw[j] === "]") {
122
+ i++;
123
+ continue;
124
+ }
125
+ }
126
+ out += c;
127
+ i++;
128
+ }
84
129
  return out;
85
130
  }
86
131
 
@@ -177,7 +222,20 @@ export function loadConfig(): MyOMemoryConfig {
177
222
  try {
178
223
  const raw = fs.readFileSync(p, "utf8");
179
224
  const parsed = JSON.parse(stripJsonComments(raw)) as Partial<MyOMemoryConfig>;
180
- return { ...DEFAULT_CONFIG, ...parsed };
225
+ const merged = { ...DEFAULT_CONFIG, ...parsed };
226
+ // The config file is hand-editable, so the load path must enforce
227
+ // the same invariants `config set` does (P0 review, 2026-10-03):
228
+ // an unvalidated memoryDir like "../../shared" would put
229
+ // committed project memories outside the repo.
230
+ try {
231
+ merged.memoryDir = toRelativeDir(merged.memoryDir);
232
+ } catch {
233
+ console.error(
234
+ `[open-memex] invalid memoryDir "${merged.memoryDir}" in ${p} — using default "${DEFAULT_CONFIG.memoryDir}"`,
235
+ );
236
+ merged.memoryDir = DEFAULT_CONFIG.memoryDir;
237
+ }
238
+ return merged;
181
239
  } catch (err) {
182
240
  console.error(`[open-memex] failed to parse ${p}:`, err);
183
241
  }
package/src/doctor.ts CHANGED
@@ -7,7 +7,7 @@ import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { spawn, execFileSync } from "node:child_process";
9
9
  import { fileURLToPath } from "node:url";
10
- import { loadConfig, configSource, DEFAULT_CONFIG } from "./config.ts";
10
+ import { loadConfig, configSource, DEFAULT_CONFIG, stripJsonComments } from "./config.ts";
11
11
  import { paths, homeOverride } from "./paths.ts";
12
12
  import { resolveCwdScope } from "./scope.ts";
13
13
  import { db, backendName } from "./store/db.ts";
@@ -429,49 +429,15 @@ function mcpCheck(): Promise<Check> {
429
429
  }
430
430
 
431
431
  /** Parse JSON tolerating line/block comments plus trailing commas
432
- * (VS Code's settings.json is JSONC). Health-check grade, not a full parser. */
432
+ * (VS Code's settings.json is JSONC). Uses config.ts's string-aware
433
+ * stripper — one parser, not two (P0 review, 2026-10-03). */
433
434
  function parseLenientJson(raw: string): Record<string, unknown> {
434
435
  try {
435
436
  return JSON.parse(raw) as Record<string, unknown>;
436
437
  } catch {
437
438
  /* fall through to comment stripping */
438
439
  }
439
- let out = "";
440
- let i = 0;
441
- let inStr = false;
442
- let esc = false;
443
- while (i < raw.length) {
444
- const c = raw[i];
445
- const n = raw[i + 1];
446
- if (inStr) {
447
- out += c;
448
- if (esc) esc = false;
449
- else if (c === "\\") esc = true;
450
- else if (c === '"') inStr = false;
451
- i++;
452
- continue;
453
- }
454
- if (c === '"') {
455
- inStr = true;
456
- out += c;
457
- i++;
458
- continue;
459
- }
460
- if (c === "/" && n === "/") {
461
- while (i < raw.length && raw[i] !== "\n") i++;
462
- continue;
463
- }
464
- if (c === "/" && n === "*") {
465
- i += 2;
466
- while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
467
- i += 2;
468
- continue;
469
- }
470
- out += c;
471
- i++;
472
- }
473
- out = out.replace(/,\s*([}\]])/g, "$1");
474
- return JSON.parse(out) as Record<string, unknown>;
440
+ return JSON.parse(stripJsonComments(raw)) as Record<string, unknown>;
475
441
  }
476
442
 
477
443
  function vscodeSettingsPath(): string | null {
package/src/export.ts CHANGED
@@ -152,6 +152,8 @@ export interface ImportResult {
152
152
  imported: number;
153
153
  skippedIdentical: number;
154
154
  skippedConflict: { id: string; file: string }[];
155
+ /** Manifest entries refused for pointing outside the bundle. */
156
+ rejectedPaths: string[];
155
157
  }
156
158
 
157
159
  export function importBundle(bundlePath: string, opts: ImportOptions): ImportResult {
@@ -172,10 +174,20 @@ export function importBundle(bundlePath: string, opts: ImportOptions): ImportRes
172
174
  fail(`unsupported bundle format: ${manifest.format} (expected ${EXPORT_FORMAT})`);
173
175
  }
174
176
 
175
- const result: ImportResult = { imported: 0, skippedIdentical: 0, skippedConflict: [] };
177
+ const result: ImportResult = { imported: 0, skippedIdentical: 0, skippedConflict: [], rejectedPaths: [] };
176
178
  const existingStmt = db().prepare(`SELECT content_hash FROM memories WHERE id = ?`);
179
+ // The manifest comes from whoever sent the bundle: entry.file must
180
+ // stay inside the staging dir (P0 review, 2026-10-03 — "../" entries
181
+ // otherwise read arbitrary .md files into the store). realpath also
182
+ // catches symlinks planted by the tarball.
183
+ const stageRoot = fs.realpathSync(stage);
177
184
  for (const entry of manifest.memories ?? []) {
178
- const src = path.join(stage, entry.file);
185
+ const joined = path.resolve(path.join(stage, entry.file));
186
+ const src = fs.existsSync(joined) ? fs.realpathSync(joined) : joined;
187
+ if (!src.startsWith(stageRoot + path.sep)) {
188
+ result.rejectedPaths.push(entry.file);
189
+ continue;
190
+ }
179
191
  const mf = readMemoryFile(src);
180
192
  if (!mf) continue;
181
193
  const fm = normalizeFrontmatter({ ...mf.fm } as unknown as Record<string, unknown>);
package/src/init.ts CHANGED
@@ -508,6 +508,7 @@ function writeVisualStudioMcpJson(root: string, force: boolean): string | null {
508
508
  });
509
509
  return null;
510
510
  }
511
+ doc = parsed;
511
512
  }
512
513
  const section = ((doc["servers"] ??= {}) as Record<string, unknown>);
513
514
  // F30: drop the pre-rename server entry — it points at the OLD data dir.
@@ -6,9 +6,11 @@ import {
6
6
  iterMemoryFiles,
7
7
  readMemoryFile,
8
8
  serialize,
9
+ timeToMs,
9
10
  type Frontmatter,
10
11
  } from "./markdown.ts";
11
12
  import { syncScope } from "./sync.ts";
13
+ import { PERSONAL_SCOPE } from "../scope.ts";
12
14
 
13
15
  export type ConflictStrategy = "newer" | "overwrite" | "skip";
14
16
 
@@ -90,7 +92,10 @@ export function migrateScope(
90
92
  log(`conflict: kept destination (--on-conflict=skip): ${dstPath}`);
91
93
  } else if (strategy === "overwrite") {
92
94
  log(`conflict: overwriting destination (--on-conflict=overwrite): ${dstPath}`);
93
- } else if (dst.fm.updated_at >= mf.fm.updated_at) {
95
+ } else if (timeToMs(dst.fm.updated_at) >= timeToMs(mf.fm.updated_at)) {
96
+ // Compare parsed instants, not RFC-3339 strings: lexicographic
97
+ // order breaks around fractional seconds ("…:00Z" > "…:00.005Z"),
98
+ // letting the older file win.
94
99
  action = "skip";
95
100
  log(`conflict: kept destination (newer updated_at): ${dstPath}`);
96
101
  } else {
@@ -103,9 +108,16 @@ export function migrateScope(
103
108
  continue;
104
109
  }
105
110
 
111
+ // Re-key AND re-scope: scope/visibility must follow the destination
112
+ // (P0 review, 2026-10-03). Rewriting only scope_key produced files
113
+ // submit rejects ("scope is personal") or personal memories carrying
114
+ // visibility: internal into exports.
115
+ const toPersonal = toKey === PERSONAL_SCOPE.key;
106
116
  const newFm: Frontmatter = {
107
117
  ...mf.fm,
108
118
  scope_key: toKey,
119
+ scope: toPersonal ? "personal" : "project",
120
+ visibility: toPersonal ? "private" : "internal",
109
121
  project_name: opts.toProjectName ?? mf.fm.project_name,
110
122
  };
111
123
  const raw = serialize(newFm, mf.body);
package/src/tools/ops.ts CHANGED
@@ -388,11 +388,20 @@ export async function forgetMemory(args: MemoryForgetArgs): Promise<ToolResult>
388
388
  return { title: "memory: not found", output: `No memory with id ${args.id}.` };
389
389
  }
390
390
  // Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
391
+ // Only ENOENT counts as "deleted": on any other unlink failure the file
392
+ // is still the source of truth and would be re-indexed on next sync, so
393
+ // report failure instead of lying (P0 review, 2026-10-03).
391
394
  if (row.file_path) {
392
395
  try {
393
396
  fs.unlinkSync(row.file_path);
394
- } catch {
395
- /* already gone */
397
+ } catch (e) {
398
+ const code = (e as NodeJS.ErrnoException).code;
399
+ if (code !== "ENOENT") {
400
+ return {
401
+ title: "memory: forget failed",
402
+ output: `Could not delete ${row.file_path} (${code ?? "error"}) — the memory was NOT forgotten and is still in the index. Close anything holding the file and retry.`,
403
+ };
404
+ }
396
405
  }
397
406
  }
398
407
  deleteFromIndex(args.id);