open-memex 0.7.0-alpha.2 → 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
@@ -102,7 +102,7 @@ The published `open-memex` bin points at `dist/cli.js` (compiled at publish time
102
102
  From a source checkout, `npm run cli` / `npm run mcp` still run `src/` directly
103
103
  with type-stripping — no build step needed for development.
104
104
 
105
- There is **no `npm test`** and no CI. Verification loop is: `npm run typecheck` + `smoke-pure.ts` + (if touching sqlite) `npm run cli -- reindex` against a scratch `MY_O_MEMORY_HOME`.
105
+ There is **no `npm test`** and no CI. Verification loop is: `npm run typecheck` + `smoke-pure.ts` + (if touching sqlite) `npm run cli -- reindex` against a scratch `OPEN_MEMEX_HOME`.
106
106
 
107
107
  To load the plugin in opencode locally, `~/.config/opencode/opencode.jsonc` must have:
108
108
  ```
@@ -115,7 +115,7 @@ Restart opencode after any change — the plugin is not hot-reloaded.
115
115
  Storage root:
116
116
  - Windows: `%APPDATA%\open-memex\`
117
117
  - Linux/macOS: `$XDG_DATA_HOME/open-memex/` (fallback `~/.local/share/open-memex/`)
118
- - Override with `MY_O_MEMORY_HOME` (use this for tests to avoid clobbering real data).
118
+ - Override with `OPEN_MEMEX_HOME` (use this for tests to avoid clobbering real data). The pre-rename `MY_O_MEMORY_HOME` is still honored as a fallback (D62).
119
119
 
120
120
  Layout: `memories/<scope_key>/<id>.md` (YAML frontmatter + body) + `index.db` (SQLite FTS5).
121
121
 
@@ -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,21 @@ 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
+
23
+ ## Environment variable names (D62)
24
+
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.
26
+
12
27
  ## Proactive capture: checkpoint proposals and aliases (D61)
13
28
 
14
29
  - At task checkpoints the agent now proposes instead of waiting to be asked: when a task wraps up, it offers 1–3 short candidate memories distilled from the session (decisions and their reasons, conventions, gotchas, approaches tried and abandoned) and saves only the ones you approve — approved inferences are marked `source: "inference"`. One memorability rubric and one search discipline (break the question into concepts, try several phrasings, check both scopes) are written into every guidance surface — the bundled skill, the AGENTS.md memory-hygiene footer, the MCP handshake, and the tool descriptions — so behavior is consistent across editors. Nothing is auto-drafted in the background; that posture waits on memory visibility.
package/README.md CHANGED
@@ -578,8 +578,9 @@ branches and PRs like any other file.
578
578
 
579
579
  Settings live in `~/.config/opencode/open-memex.jsonc` — the `opencode` in the
580
580
  path is historical; this one file is shared by every client. Override the config
581
- path with `MY_O_MEMORY_CONFIG` and the storage root with `MY_O_MEMORY_HOME`
582
- (legacy environment names from before the rename, still honored).
581
+ path with `OPEN_MEMEX_CONFIG` and the storage root with `OPEN_MEMEX_HOME`
582
+ (the pre-rename `MY_O_MEMORY_CONFIG` / `MY_O_MEMORY_HOME` names are still
583
+ honored as fallbacks).
583
584
 
584
585
  Defaults:
585
586
 
package/README.zh-CN.md CHANGED
@@ -391,7 +391,7 @@ npm install -g open-memex@latest # 或 @alpha
391
391
 
392
392
  ## 配置
393
393
 
394
- 设置放在 `~/.config/opencode/open-memex.jsonc`——路径里的 `opencode` 是历史名;这一个文件由所有客户端共享。用 `MY_O_MEMORY_CONFIG` 覆盖配置路径,用 `MY_O_MEMORY_HOME` 覆盖存储根目录(改名之前留下的旧环境变量名,仍然有效)。
394
+ 设置放在 `~/.config/opencode/open-memex.jsonc`——路径里的 `opencode` 是历史名;这一个文件由所有客户端共享。用 `OPEN_MEMEX_CONFIG` 覆盖配置路径,用 `OPEN_MEMEX_HOME` 覆盖存储根目录(改名之前的旧变量名 `MY_O_MEMORY_CONFIG` / `MY_O_MEMORY_HOME` 仍然有效,作为回退)。
395
395
 
396
396
  默认值:
397
397
 
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,18 +37,73 @@ 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
- /** Where `config set` / interactive `init` persist. Respects MY_O_MEMORY_CONFIG. */
100
+ /**
101
+ * Where `config set` / interactive `init` persist. Respects OPEN_MEMEX_CONFIG
102
+ * (D62); MY_O_MEMORY_CONFIG is the pre-rename fallback, still honored.
103
+ */
50
104
  export function configFilePath() {
51
- return (process.env.MY_O_MEMORY_CONFIG ??
105
+ return (process.env.OPEN_MEMEX_CONFIG ??
106
+ process.env.MY_O_MEMORY_CONFIG ??
52
107
  path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc"));
53
108
  }
54
109
  function toBool(v) {
@@ -111,6 +166,7 @@ export function saveConfig(patch) {
111
166
  /** Path of the config file in effect, or null when using built-in defaults. */
112
167
  export function configSource() {
113
168
  const candidates = [
169
+ process.env.OPEN_MEMEX_CONFIG,
114
170
  process.env.MY_O_MEMORY_CONFIG,
115
171
  path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc"),
116
172
  path.join(os.homedir(), ".config", "opencode", "open-memex.json"),
@@ -127,7 +183,19 @@ export function loadConfig() {
127
183
  try {
128
184
  const raw = fs.readFileSync(p, "utf8");
129
185
  const parsed = JSON.parse(stripJsonComments(raw));
130
- 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;
131
199
  }
132
200
  catch (err) {
133
201
  console.error(`[open-memex] failed to parse ${p}:`, err);
package/dist/doctor.js CHANGED
@@ -7,8 +7,8 @@ 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";
11
- import { paths } from "./paths.js";
10
+ import { loadConfig, configSource, DEFAULT_CONFIG, stripJsonComments } from "./config.js";
11
+ import { paths, homeOverride } from "./paths.js";
12
12
  import { resolveCwdScope } from "./scope.js";
13
13
  import { db, backendName } from "./store/db.js";
14
14
  import { LEGACY_PACKAGE_NAME, opencodeGlobalConfigPath, userMcpConfigPath } from "./init.js";
@@ -54,7 +54,13 @@ function storageCheck() {
54
54
  try {
55
55
  const p = paths();
56
56
  fs.accessSync(p.memories, fs.constants.W_OK);
57
- return { name: "storage", ok: true, detail: `${p.root} (writable)` };
57
+ // D62: when the root came from the legacy env var, say so (still ok —
58
+ // the fallback is supported, doctor just names the current one).
59
+ const via = homeOverride()?.via;
60
+ const note = via === "MY_O_MEMORY_HOME"
61
+ ? " (via legacy MY_O_MEMORY_HOME — OPEN_MEMEX_HOME is the current name)"
62
+ : "";
63
+ return { name: "storage", ok: true, detail: `${p.root} (writable)${note}` };
58
64
  }
59
65
  catch (err) {
60
66
  return { name: "storage", ok: false, detail: String(err) };
@@ -401,7 +407,8 @@ function mcpCheck() {
401
407
  });
402
408
  }
403
409
  /** Parse JSON tolerating line/block comments plus trailing commas
404
- * (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). */
405
412
  function parseLenientJson(raw) {
406
413
  try {
407
414
  return JSON.parse(raw);
@@ -409,47 +416,7 @@ function parseLenientJson(raw) {
409
416
  catch {
410
417
  /* fall through to comment stripping */
411
418
  }
412
- let out = "";
413
- let i = 0;
414
- let inStr = false;
415
- let esc = false;
416
- while (i < raw.length) {
417
- const c = raw[i];
418
- const n = raw[i + 1];
419
- if (inStr) {
420
- out += c;
421
- if (esc)
422
- esc = false;
423
- else if (c === "\\")
424
- esc = true;
425
- else if (c === '"')
426
- inStr = false;
427
- i++;
428
- continue;
429
- }
430
- if (c === '"') {
431
- inStr = true;
432
- out += c;
433
- i++;
434
- continue;
435
- }
436
- if (c === "/" && n === "/") {
437
- while (i < raw.length && raw[i] !== "\n")
438
- i++;
439
- continue;
440
- }
441
- if (c === "/" && n === "*") {
442
- i += 2;
443
- while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/"))
444
- i++;
445
- i += 2;
446
- continue;
447
- }
448
- out += c;
449
- i++;
450
- }
451
- out = out.replace(/,\s*([}\]])/g, "$1");
452
- return JSON.parse(out);
419
+ return JSON.parse(stripJsonComments(raw));
453
420
  }
454
421
  function vscodeSettingsPath() {
455
422
  const home = os.homedir();
@@ -559,9 +526,11 @@ export async function runDoctor() {
559
526
  function legacyCheck() {
560
527
  const name = "legacy my-o-memory";
561
528
  const found = [];
562
- const envHome = process.env.MY_O_MEMORY_HOME;
529
+ // D62: the override may arrive via either env var name; check the value
530
+ // in effect, not just the legacy one.
531
+ const envHome = homeOverride()?.dir;
563
532
  if (envHome && envHome.includes(LEGACY_PACKAGE_NAME))
564
- found.push(`MY_O_MEMORY_HOME points at a pre-rename dir (${envHome}) — unset it, then merge old data with \`open-memex migrate --to-v2\``);
533
+ found.push(`memory home points at a pre-rename dir (${envHome}) — unset it, then merge old data with \`open-memex migrate --to-v2\``);
565
534
  const root = paths().root;
566
535
  const legacyDir = path.join(path.dirname(root), LEGACY_PACKAGE_NAME);
567
536
  if (fs.existsSync(legacyDir) && fs.statSync(legacyDir).isDirectory())
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.
package/dist/paths.js CHANGED
@@ -2,9 +2,23 @@ import path from "node:path";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import { execFileSync } from "node:child_process";
5
- function dataRoot() {
5
+ /**
6
+ * D62: the data-root override env var. `OPEN_MEMEX_HOME` is the current
7
+ * name; `MY_O_MEMORY_HOME` (pre-rename package name) is still honored as a
8
+ * fallback so existing setups and scripts keep working. New name wins
9
+ * when both are set.
10
+ */
11
+ export function homeOverride() {
12
+ if (process.env.OPEN_MEMEX_HOME)
13
+ return { dir: path.resolve(process.env.OPEN_MEMEX_HOME), via: "OPEN_MEMEX_HOME" };
6
14
  if (process.env.MY_O_MEMORY_HOME)
7
- return path.resolve(process.env.MY_O_MEMORY_HOME);
15
+ return { dir: path.resolve(process.env.MY_O_MEMORY_HOME), via: "MY_O_MEMORY_HOME" };
16
+ return null;
17
+ }
18
+ function dataRoot() {
19
+ const override = homeOverride();
20
+ if (override)
21
+ return override.dir;
8
22
  if (process.platform === "win32") {
9
23
  const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
10
24
  return path.join(appData, "open-memex");
@@ -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
@@ -1304,13 +1304,42 @@ requirement: personal data never touches third-party services). Benchmarks to tr
1304
1304
  distillation over host transcripts stays its own group; it will
1305
1305
  reuse this rubric.
1306
1306
 
1307
+ - **D62 — environment variable names grow current-name aliases.** The
1308
+ data-root and config-path overrides predate the rename and kept the
1309
+ old package's name (`MY_O_MEMORY_HOME`, `MY_O_MEMORY_CONFIG`) — a
1310
+ stale-looking name is a trap for users and agents scripting against
1311
+ the current one. `OPEN_MEMEX_HOME` / `OPEN_MEMEX_CONFIG` are now the
1312
+ preferred names; the legacy names still work as fallbacks (new name
1313
+ wins when both are set), so existing setups and scripts never break.
1314
+ The data root itself does not move — only the variable names change.
1315
+ `doctor`'s storage check notes when the root arrived via the legacy
1316
+ variable, and its pre-rename-dir leftovers check reads whichever
1317
+ variable is in effect.
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
+
1307
1337
  ## Open Questions
1308
1338
 
1309
1339
  _All resolved — see D10 (rename), D11 (type/role split), D12 (explicit pull)._
1340
+ - **Checkpoint proactivity for capture (open, 2026-10-02):** D53 retired checkpoint *polling* — the server pushes draft counts instead. But agent proactivity at task checkpoints genuinely helps knowledge capture (§3.5 distillation), so the open question is the degree: proactive enough to catch distillable moments, without nagging the user or burning tokens at every checkpoint. Candidate directions (server-pushed hints in tool results, client-side heuristics, or a middle setting) to be evaluated in a later version; default posture TBD. **Update (2026-10-03): decided in D61 — propose posture** (agent proposes 1–3 candidates at task checkpoints, saves only on approval; auto-draft deferred until memory visibility ships, see the MEMORY_VISIBILITY question below).
1310
1341
 
1311
- - **Checkpoint proactivity for capture (open, 2026-10-02):** D53 retired checkpoint *polling* — the server pushes draft counts instead. But agent proactivity at task checkpoints genuinely helps knowledge capture (§3.5 distillation), so the open question is the degree: proactive enough to catch distillable moments, without nagging the user or burning tokens at every checkpoint. Candidate directions (server-pushed hints in tool results, client-side heuristics, or a middle setting) to be evaluated in a later version; default posture TBD.
1312
-
1313
- - **Retrieval robustness: phrasing, synonyms, extra words (open, 2026-10-03):** keyword search matches word forms, not meaning — the asker phrases it differently, adds a word, or uses a synonym and the memory is missed. The current FTS5 setup (porter stemming + pre-tokenized CJK bigrams) already covers morphology and substrings; it cannot bridge semantics. Layered candidate directions, cheapest first: (1) query construction — never feed a raw sentence to FTS; extract content terms, drop stopwords, OR them and let bm25 rank, so extra words hurt ranking instead of killing the match; (2) agent-side query expansion as an explicit protocol in the skill/instructions (decompose the question into concepts, generate 2–3 synonym variants per concept, run several searches before giving up — the LLM is the synonym dictionary, no embedding model needed); (3) capture-time aliasing — when a memory is written, the agent attaches keyword/synonym aliases so future phrasings can hit it. Embeddings are the design's reserved optional capability: real semantic matching, but they require a design decision and a local model to distribute, so they stay deferred unless (1)–(3) prove insufficient. Note (3) pairs naturally with the proactive-capture question below: the same capture pass that proposes a memory can attach its aliases. **Status (2026-10-03): layer (1) shipped (D56, 0.6.1)** — query construction now filters EN/CJK function words, dedupes, and caps terms. Layers (2) agent-side expansion and (3) capture-time aliasing remain open; embeddings stay deferred.
1342
+ - **Retrieval robustness: phrasing, synonyms, extra words (open, 2026-10-03):** keyword search matches word forms, not meaning — the asker phrases it differently, adds a word, or uses a synonym and the memory is missed. The current FTS5 setup (porter stemming + pre-tokenized CJK bigrams) already covers morphology and substrings; it cannot bridge semantics. Layered candidate directions, cheapest first: (1) query construction — never feed a raw sentence to FTS; extract content terms, drop stopwords, OR them and let bm25 rank, so extra words hurt ranking instead of killing the match; (2) agent-side query expansion as an explicit protocol in the skill/instructions (decompose the question into concepts, generate 2–3 synonym variants per concept, run several searches before giving up — the LLM is the synonym dictionary, no embedding model needed); (3) capture-time aliasing — when a memory is written, the agent attaches keyword/synonym aliases so future phrasings can hit it. Embeddings are the design's reserved optional capability: real semantic matching, but they require a design decision and a local model to distribute, so they stay deferred unless (1)–(3) prove insufficient. Note (3) pairs naturally with the proactive-capture question below: the same capture pass that proposes a memory can attach its aliases. **Status (2026-10-03): layer (1) shipped (D56, 0.6.1)** — query construction now filters EN/CJK function words, dedupes, and caps terms. Layers (2) agent-side expansion and (3) capture-time aliasing remain open; embeddings stay deferred. **Update (2026-10-03): layers (2) and (3) shipped in D61** — agent-side expansion as guidance text on every surface, capture-time aliases indexed in their own FTS column behind the `captureAliases` flag. Embeddings stay deferred.
1314
1343
 
1315
1344
  - **Proactive capture: agent judgment of what is worth remembering (open, 2026-10-03):** companion to the checkpoint-proactivity question above — that one asks *when* to prompt; this one asks *what qualifies*. Agent hosts with good long-term memory (e.g. Muse) get their results from explicit memorability criteria plus checkpoint consolidation, not from silent background magic; open-memex can adopt both halves. Candidate criteria to teach via the skill/instructions: decisions **and their rationale**, preferences, conventions, gotchas, and abandoned paths ("we tried X, it failed because…") are worth proposing; one-off task details and anything re-derivable from the code are not. Two postures to evaluate: propose-at-checkpoint (agent asks "worth saving?", user vetoes, then `memory_add`) versus auto-draft (agent writes outbox drafts unasked and surfaces them in a batch at the next checkpoint — drafts are git-invisible and deletable, so the cost of a wrong guess is low). Hard boundaries either way: project scope never auto-submits, and no cross-session background consolidation is possible — the plugin lives inside sessions, so consolidation happens at in-session checkpoints and wrap-up passes only. This question also decides how much aliasing (retrieval question above) the capture pass should generate.
1316
1345
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-memex",
3
- "version": "0.7.0-alpha.2",
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",
@@ -12,6 +12,42 @@ import { contentHash, similarity, NEAR_DUP_THRESHOLD } from "../src/store/lifecy
12
12
  import { userMcpConfigPath, mergeServerEntry, detectInstalledClients, mergePluginEntry, mergeV2PluginEntry, removeV2PluginEntry, opencodeGlobalConfigPath, printManualEntryHint, parseJsonConfig, removeServerEntry, removePluginEntry, removeInstructionsSection, LEGACY_PACKAGE_NAME, shouldInstallSkill } from "../src/init.ts";
13
13
  import { z } from "zod";
14
14
  import { memoryAddArgs } from "../src/tools/ops.ts";
15
+ import { dataRootPath, homeOverride } from "../src/paths.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
+ }
15
51
 
16
52
  let fails = 0;
17
53
  function ok(name: string, cond: boolean, info?: unknown) {
@@ -423,6 +459,38 @@ ok("failed backup leaves legacy dir for retry", fs.existsSync(legacyRoot2));
423
459
  delete process.env.MY_O_MEMORY_HOME;
424
460
  fs.rmSync(tmpHome, { recursive: true, force: true });
425
461
 
462
+ console.log("== env var names (D62) ==");
463
+ {
464
+ const saved = {
465
+ n: process.env.OPEN_MEMEX_HOME,
466
+ l: process.env.MY_O_MEMORY_HOME,
467
+ nc: process.env.OPEN_MEMEX_CONFIG,
468
+ lc: process.env.MY_O_MEMORY_CONFIG,
469
+ };
470
+ const restore = () => {
471
+ const set = (k: string, v: string | undefined) => (v === undefined ? delete process.env[k] : (process.env[k] = v));
472
+ set("OPEN_MEMEX_HOME", saved.n);
473
+ set("MY_O_MEMORY_HOME", saved.l);
474
+ set("OPEN_MEMEX_CONFIG", saved.nc);
475
+ set("MY_O_MEMORY_CONFIG", saved.lc);
476
+ };
477
+ try {
478
+ delete process.env.OPEN_MEMEX_HOME;
479
+ delete process.env.MY_O_MEMORY_HOME;
480
+ ok("no override -> homeOverride null", homeOverride() === null);
481
+ process.env.MY_O_MEMORY_HOME = "/tmp/legacy-home";
482
+ ok("legacy fallback honored", dataRootPath().endsWith("legacy-home") && homeOverride()?.via === "MY_O_MEMORY_HOME");
483
+ process.env.OPEN_MEMEX_HOME = "/tmp/new-home";
484
+ ok("new name wins over legacy", dataRootPath().endsWith("new-home") && homeOverride()?.via === "OPEN_MEMEX_HOME");
485
+ process.env.MY_O_MEMORY_CONFIG = "/tmp/legacy.jsonc";
486
+ ok("config legacy fallback", configFilePath().endsWith("legacy.jsonc"));
487
+ process.env.OPEN_MEMEX_CONFIG = "/tmp/new.jsonc";
488
+ ok("config new name wins", configFilePath().endsWith("new.jsonc"));
489
+ } finally {
490
+ restore();
491
+ }
492
+ }
493
+
426
494
  console.log("== lifecycle pure: contentHash / similarity ==");
427
495
  ok("hash deterministic + whitespace-insensitive", contentHash("hello world\n") === contentHash("hello world"));
428
496
  ok("hash differs on content", contentHash("hello world") !== contentHash("hello mars"));
@@ -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,19 +74,68 @@ 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
 
87
- /** Where `config set` / interactive `init` persist. Respects MY_O_MEMORY_CONFIG. */
132
+ /**
133
+ * Where `config set` / interactive `init` persist. Respects OPEN_MEMEX_CONFIG
134
+ * (D62); MY_O_MEMORY_CONFIG is the pre-rename fallback, still honored.
135
+ */
88
136
  export function configFilePath(): string {
89
137
  return (
138
+ process.env.OPEN_MEMEX_CONFIG ??
90
139
  process.env.MY_O_MEMORY_CONFIG ??
91
140
  path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc")
92
141
  );
@@ -156,6 +205,7 @@ export function saveConfig(patch: Record<string, unknown>): string {
156
205
  /** Path of the config file in effect, or null when using built-in defaults. */
157
206
  export function configSource(): string | null {
158
207
  const candidates = [
208
+ process.env.OPEN_MEMEX_CONFIG,
159
209
  process.env.MY_O_MEMORY_CONFIG,
160
210
  path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc"),
161
211
  path.join(os.homedir(), ".config", "opencode", "open-memex.json"),
@@ -172,7 +222,20 @@ export function loadConfig(): MyOMemoryConfig {
172
222
  try {
173
223
  const raw = fs.readFileSync(p, "utf8");
174
224
  const parsed = JSON.parse(stripJsonComments(raw)) as Partial<MyOMemoryConfig>;
175
- 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;
176
239
  } catch (err) {
177
240
  console.error(`[open-memex] failed to parse ${p}:`, err);
178
241
  }
package/src/doctor.ts CHANGED
@@ -7,8 +7,8 @@ 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";
11
- import { paths } from "./paths.ts";
10
+ import { loadConfig, configSource, DEFAULT_CONFIG, stripJsonComments } from "./config.ts";
11
+ import { paths, homeOverride } from "./paths.ts";
12
12
  import { resolveCwdScope } from "./scope.ts";
13
13
  import { db, backendName } from "./store/db.ts";
14
14
  import { LEGACY_PACKAGE_NAME, opencodeGlobalConfigPath, userMcpConfigPath } from "./init.ts";
@@ -66,7 +66,14 @@ function storageCheck(): Check {
66
66
  try {
67
67
  const p = paths();
68
68
  fs.accessSync(p.memories, fs.constants.W_OK);
69
- return { name: "storage", ok: true, detail: `${p.root} (writable)` };
69
+ // D62: when the root came from the legacy env var, say so (still ok —
70
+ // the fallback is supported, doctor just names the current one).
71
+ const via = homeOverride()?.via;
72
+ const note =
73
+ via === "MY_O_MEMORY_HOME"
74
+ ? " (via legacy MY_O_MEMORY_HOME — OPEN_MEMEX_HOME is the current name)"
75
+ : "";
76
+ return { name: "storage", ok: true, detail: `${p.root} (writable)${note}` };
70
77
  } catch (err) {
71
78
  return { name: "storage", ok: false, detail: String(err) };
72
79
  }
@@ -422,49 +429,15 @@ function mcpCheck(): Promise<Check> {
422
429
  }
423
430
 
424
431
  /** Parse JSON tolerating line/block comments plus trailing commas
425
- * (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). */
426
434
  function parseLenientJson(raw: string): Record<string, unknown> {
427
435
  try {
428
436
  return JSON.parse(raw) as Record<string, unknown>;
429
437
  } catch {
430
438
  /* fall through to comment stripping */
431
439
  }
432
- let out = "";
433
- let i = 0;
434
- let inStr = false;
435
- let esc = false;
436
- while (i < raw.length) {
437
- const c = raw[i];
438
- const n = raw[i + 1];
439
- if (inStr) {
440
- out += c;
441
- if (esc) esc = false;
442
- else if (c === "\\") esc = true;
443
- else if (c === '"') inStr = false;
444
- i++;
445
- continue;
446
- }
447
- if (c === '"') {
448
- inStr = true;
449
- out += c;
450
- i++;
451
- continue;
452
- }
453
- if (c === "/" && n === "/") {
454
- while (i < raw.length && raw[i] !== "\n") i++;
455
- continue;
456
- }
457
- if (c === "/" && n === "*") {
458
- i += 2;
459
- while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
460
- i += 2;
461
- continue;
462
- }
463
- out += c;
464
- i++;
465
- }
466
- out = out.replace(/,\s*([}\]])/g, "$1");
467
- return JSON.parse(out) as Record<string, unknown>;
440
+ return JSON.parse(stripJsonComments(raw)) as Record<string, unknown>;
468
441
  }
469
442
 
470
443
  function vscodeSettingsPath(): string | null {
@@ -579,10 +552,12 @@ export async function runDoctor(): Promise<boolean> {
579
552
  function legacyCheck(): Check {
580
553
  const name = "legacy my-o-memory";
581
554
  const found: string[] = [];
582
- const envHome = process.env.MY_O_MEMORY_HOME;
555
+ // D62: the override may arrive via either env var name; check the value
556
+ // in effect, not just the legacy one.
557
+ const envHome = homeOverride()?.dir;
583
558
  if (envHome && envHome.includes(LEGACY_PACKAGE_NAME))
584
559
  found.push(
585
- `MY_O_MEMORY_HOME points at a pre-rename dir (${envHome}) — unset it, then merge old data with \`open-memex migrate --to-v2\``,
560
+ `memory home points at a pre-rename dir (${envHome}) — unset it, then merge old data with \`open-memex migrate --to-v2\``,
586
561
  );
587
562
  const root = paths().root;
588
563
  const legacyDir = path.join(path.dirname(root), LEGACY_PACKAGE_NAME);
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.
package/src/paths.ts CHANGED
@@ -3,8 +3,23 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import { execFileSync } from "node:child_process";
5
5
 
6
+ /**
7
+ * D62: the data-root override env var. `OPEN_MEMEX_HOME` is the current
8
+ * name; `MY_O_MEMORY_HOME` (pre-rename package name) is still honored as a
9
+ * fallback so existing setups and scripts keep working. New name wins
10
+ * when both are set.
11
+ */
12
+ export function homeOverride(): { dir: string; via: "OPEN_MEMEX_HOME" | "MY_O_MEMORY_HOME" } | null {
13
+ if (process.env.OPEN_MEMEX_HOME)
14
+ return { dir: path.resolve(process.env.OPEN_MEMEX_HOME), via: "OPEN_MEMEX_HOME" };
15
+ if (process.env.MY_O_MEMORY_HOME)
16
+ return { dir: path.resolve(process.env.MY_O_MEMORY_HOME), via: "MY_O_MEMORY_HOME" };
17
+ return null;
18
+ }
19
+
6
20
  function dataRoot(): string {
7
- if (process.env.MY_O_MEMORY_HOME) return path.resolve(process.env.MY_O_MEMORY_HOME);
21
+ const override = homeOverride();
22
+ if (override) return override.dir;
8
23
  if (process.platform === "win32") {
9
24
  const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
10
25
  return path.join(appData, "open-memex");
@@ -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);