open-memex 0.5.0-alpha.4 → 0.5.0-alpha.5

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
@@ -64,6 +64,13 @@ previews keyword capture without writing, `open-memex doctor` runs health checks
64
64
  (node version, config, scope resolution, storage writability, MCP handshake).
65
65
  `open-memex init` with no --client auto-detects and wires every installed editor
66
66
  (`--yes` skips, scripts never prompt); `open-memex config set <key> <value>` edits settings after install.
67
+ `open-memex uninstall [--client vscode|cursor|opencode|visualstudio] [--global] [--yes]`
68
+ reverses init — removes the MCP server entry / opencode plugin line / Copilot
69
+ instructions section; memory data never touched (D48); no --client → auto-detect
70
+ with an interactive confirm, explicit --client never prompts; `--yes` only skips
71
+ that confirm; `--global` limits cleanup to user-level. Empty/whitespace-only
72
+ config files parse as `{}` and are safely populated (D49); non-JSON (JSONC)
73
+ files are left untouched with a printed manual snippet (D47).
67
74
  The published `open-memex` bin points at `dist/cli.js` (compiled at publish time).
68
75
  From a source checkout, `npm run cli` / `npm run mcp` still run `src/` directly
69
76
  with type-stripping — no build step needed for development.
package/dist/init.js CHANGED
@@ -170,6 +170,29 @@ export function printManualEntryHint(sectionKey, entry) {
170
170
  const pretty = JSON.stringify({ "open-memex": entry }, null, 2).replace(/\n/g, "\n ");
171
171
  console.error(` ${pretty}`);
172
172
  }
173
+ /**
174
+ * Parse a JSON config file's raw text for merging. An empty or
175
+ * whitespace-only file counts as `{}` — there is nothing to preserve, so
176
+ * writers can safely populate it (an empty mcp.json is a normal first-run
177
+ * state, e.g. created by the editor, not a corrupt file). Returns null when
178
+ * the content is genuinely unparseable (e.g. JSONC comments) or not an
179
+ * object, in which case the caller leaves the file untouched and prints the
180
+ * manual step.
181
+ */
182
+ export function parseJsonConfig(raw) {
183
+ if (raw.trim() === "")
184
+ return {};
185
+ let doc;
186
+ try {
187
+ doc = JSON.parse(raw);
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ if (!doc || typeof doc !== "object" || Array.isArray(doc))
193
+ return null;
194
+ return doc;
195
+ }
173
196
  /**
174
197
  * Read (or create) a JSON config file, merge the open-memex server entry, write
175
198
  * it back. Existing files are merged, never clobbered; invalid JSON is left
@@ -179,14 +202,13 @@ export function printManualEntryHint(sectionKey, entry) {
179
202
  function writeServerEntryFile(file, sectionKey, entry, force, mkdir) {
180
203
  let doc = {};
181
204
  if (fs.existsSync(file)) {
182
- try {
183
- doc = JSON.parse(fs.readFileSync(file, "utf8"));
184
- }
185
- catch {
205
+ const parsed = parseJsonConfig(fs.readFileSync(file, "utf8"));
206
+ if (parsed === null) {
186
207
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
187
208
  printManualEntryHint(sectionKey, entry);
188
209
  return null;
189
210
  }
211
+ doc = parsed;
190
212
  }
191
213
  const merged = mergeServerEntry(doc, sectionKey, entry, force);
192
214
  if (merged === "kept") {
@@ -282,10 +304,8 @@ function writeOpencodeMcpJson(root, force) {
282
304
  const file = path.join(root, "opencode.jsonc");
283
305
  let doc = {};
284
306
  if (fs.existsSync(file)) {
285
- try {
286
- doc = JSON.parse(fs.readFileSync(file, "utf8"));
287
- }
288
- catch {
307
+ const parsed = parseJsonConfig(fs.readFileSync(file, "utf8"));
308
+ if (parsed === null) {
289
309
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
290
310
  const mc = resolveMcpCommand();
291
311
  printManualEntryHint("mcp", {
@@ -295,6 +315,7 @@ function writeOpencodeMcpJson(root, force) {
295
315
  });
296
316
  return null;
297
317
  }
318
+ doc = parsed;
298
319
  }
299
320
  const section = (doc["mcp"] ??= {});
300
321
  if (section["open-memex"] && !force) {
@@ -323,10 +344,8 @@ function writeVisualStudioMcpJson(root, force) {
323
344
  const file = path.join(root, ".mcp.json");
324
345
  let doc = {};
325
346
  if (fs.existsSync(file)) {
326
- try {
327
- doc = JSON.parse(fs.readFileSync(file, "utf8"));
328
- }
329
- catch {
347
+ const parsed = parseJsonConfig(fs.readFileSync(file, "utf8"));
348
+ if (parsed === null) {
330
349
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
331
350
  const mc = resolveMcpCommand();
332
351
  printManualEntryHint("servers", {
@@ -696,11 +715,8 @@ export function removeInstructionsSection(text) {
696
715
  function removeServerEntryFile(file, sectionKey) {
697
716
  if (!fs.existsSync(file))
698
717
  return "absent";
699
- let doc;
700
- try {
701
- doc = JSON.parse(fs.readFileSync(file, "utf8"));
702
- }
703
- catch {
718
+ const doc = parseJsonConfig(fs.readFileSync(file, "utf8"));
719
+ if (doc === null) {
704
720
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
705
721
  console.error(` Delete the "open-memex" key under "${sectionKey}".`);
706
722
  return null;
@@ -716,11 +732,8 @@ function removeServerEntryFile(file, sectionKey) {
716
732
  function removeOpencodePluginFile(file) {
717
733
  if (!fs.existsSync(file))
718
734
  return "absent";
719
- let doc;
720
- try {
721
- doc = JSON.parse(fs.readFileSync(file, "utf8"));
722
- }
723
- catch {
735
+ const doc = parseJsonConfig(fs.readFileSync(file, "utf8"));
736
+ if (doc === null) {
724
737
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
725
738
  console.error(` Delete the open-memex URL from the "plugin" array.`);
726
739
  return null;
package/docs/V2-DESIGN.md CHANGED
@@ -954,6 +954,19 @@ requirement: personal data never touches third-party services). Benchmarks to tr
954
954
  with the manual step, same D47 treatment as the init write path.
955
955
  *Rationale: Stone asked "有 uninstall 吗" while testing init on Windows —
956
956
  every write deserves an undo. Approved 2026-09-29.*
957
+ - **D49** — empty config files are no longer treated as corrupt (0.5.0-alpha.5).
958
+ Stone's real Windows run: his user-level `mcp.json` existed but was empty,
959
+ and `init` refused it with "not valid JSON — left untouched", because
960
+ `JSON.parse("")` throws. New `parseJsonConfig` helper: empty or
961
+ whitespace-only content parses as `{}` — there is nothing to preserve, so
962
+ writers safely populate it; genuinely unparseable content (JSONC comments)
963
+ or non-objects still return null and keep the D47 leave-untouched + manual
964
+ hint behavior. Applied to all five config file touch points: the three init
965
+ writers (vscode/cursor MCP, opencode `opencode.jsonc`, Visual Studio
966
+ `.mcp.json`) and the two uninstall removers (empty file = nothing to remove).
967
+ *Rationale: an empty file is the safest write target, not a corrupt file;
968
+ refusing it sent the user down a manual path for no reason. Triggered by
969
+ Stone's report 2026-09-29.*
957
970
 
958
971
  ## Open Questions
959
972
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-memex",
3
- "version": "0.5.0-alpha.4",
3
+ "version": "0.5.0-alpha.5",
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",
@@ -8,7 +8,7 @@ import { DEFAULT_CONFIG } from "../src/config.ts";
8
8
  import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE } from "../src/scope.ts";
9
9
  import { cjkIndexText, cjkQueryExpr, hasCjk } from "../src/retrieve/cjk.ts";
10
10
  import { contentHash, similarity, NEAR_DUP_THRESHOLD } from "../src/store/lifecycle.ts";
11
- import { userMcpConfigPath, mergeServerEntry, detectInstalledClients, mergePluginEntry, opencodeGlobalConfigPath, printManualEntryHint, removeServerEntry, removePluginEntry, removeInstructionsSection } from "../src/init.ts";
11
+ import { userMcpConfigPath, mergeServerEntry, detectInstalledClients, mergePluginEntry, opencodeGlobalConfigPath, printManualEntryHint, parseJsonConfig, removeServerEntry, removePluginEntry, removeInstructionsSection } from "../src/init.ts";
12
12
 
13
13
  let fails = 0;
14
14
  function ok(name: string, cond: boolean, info?: unknown) {
@@ -431,6 +431,15 @@ console.error = origErr;
431
431
  const hintText = hintLines.join("\n");
432
432
  ok("manual hint names the section", hintText.includes('"servers"'));
433
433
  ok("manual hint contains the entry", hintText.includes('"open-memex"') && hintText.includes("stdio"));
434
+ // parseJsonConfig: empty/whitespace-only files are empty docs (safe to populate);
435
+ // genuinely unparseable content (JSONC comments) or non-objects yield null.
436
+ ok("empty file → {}", JSON.stringify(parseJsonConfig("")) === "{}");
437
+ ok("whitespace-only file → {}", JSON.stringify(parseJsonConfig(" \n\t ")) === "{}");
438
+ ok("valid JSON parses", (parseJsonConfig('{"servers":{}}') as Record<string, unknown>)["servers"] !== undefined);
439
+ ok("JSONC comments → null", parseJsonConfig('{\n // a comment\n}') === null);
440
+ ok("trailing garbage → null", parseJsonConfig('{} trailing') === null);
441
+ ok("array → null", parseJsonConfig("[1,2]") === null);
442
+ ok("scalar → null", parseJsonConfig("42") === null);
434
443
  // removeServerEntry: pure removal semantics.
435
444
  const rdoc: Record<string, unknown> = { servers: { "open-memex": { command: "x" }, other: { command: "y" } }, untouched: 1 };
436
445
  ok("remove deletes the entry", removeServerEntry(rdoc, "servers") === "removed"
package/src/init.ts CHANGED
@@ -192,6 +192,27 @@ export function printManualEntryHint(sectionKey: string, entry: Record<string, u
192
192
  console.error(` ${pretty}`);
193
193
  }
194
194
 
195
+ /**
196
+ * Parse a JSON config file's raw text for merging. An empty or
197
+ * whitespace-only file counts as `{}` — there is nothing to preserve, so
198
+ * writers can safely populate it (an empty mcp.json is a normal first-run
199
+ * state, e.g. created by the editor, not a corrupt file). Returns null when
200
+ * the content is genuinely unparseable (e.g. JSONC comments) or not an
201
+ * object, in which case the caller leaves the file untouched and prints the
202
+ * manual step.
203
+ */
204
+ export function parseJsonConfig(raw: string): Record<string, unknown> | null {
205
+ if (raw.trim() === "") return {};
206
+ let doc: unknown;
207
+ try {
208
+ doc = JSON.parse(raw);
209
+ } catch {
210
+ return null;
211
+ }
212
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
213
+ return doc as Record<string, unknown>;
214
+ }
215
+
195
216
  /**
196
217
  * Read (or create) a JSON config file, merge the open-memex server entry, write
197
218
  * it back. Existing files are merged, never clobbered; invalid JSON is left
@@ -207,13 +228,13 @@ function writeServerEntryFile(
207
228
  ): string | null {
208
229
  let doc: Record<string, unknown> = {};
209
230
  if (fs.existsSync(file)) {
210
- try {
211
- doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
212
- } catch {
231
+ const parsed = parseJsonConfig(fs.readFileSync(file, "utf8"));
232
+ if (parsed === null) {
213
233
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
214
234
  printManualEntryHint(sectionKey, entry);
215
235
  return null;
216
236
  }
237
+ doc = parsed;
217
238
  }
218
239
  const merged = mergeServerEntry(doc, sectionKey, entry, force);
219
240
  if (merged === "kept") {
@@ -316,9 +337,8 @@ function writeOpencodeMcpJson(root: string, force: boolean): string | null {
316
337
  const file = path.join(root, "opencode.jsonc");
317
338
  let doc: Record<string, unknown> = {};
318
339
  if (fs.existsSync(file)) {
319
- try {
320
- doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
321
- } catch {
340
+ const parsed = parseJsonConfig(fs.readFileSync(file, "utf8"));
341
+ if (parsed === null) {
322
342
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
323
343
  const mc = resolveMcpCommand();
324
344
  printManualEntryHint("mcp", {
@@ -328,6 +348,7 @@ function writeOpencodeMcpJson(root: string, force: boolean): string | null {
328
348
  });
329
349
  return null;
330
350
  }
351
+ doc = parsed;
331
352
  }
332
353
  const section = ((doc["mcp"] ??= {}) as Record<string, unknown>);
333
354
  if (section["open-memex"] && !force) {
@@ -357,9 +378,8 @@ function writeVisualStudioMcpJson(root: string, force: boolean): string | null {
357
378
  const file = path.join(root, ".mcp.json");
358
379
  let doc: Record<string, unknown> = {};
359
380
  if (fs.existsSync(file)) {
360
- try {
361
- doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
362
- } catch {
381
+ const parsed = parseJsonConfig(fs.readFileSync(file, "utf8"));
382
+ if (parsed === null) {
363
383
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
364
384
  const mc = resolveMcpCommand();
365
385
  printManualEntryHint("servers", {
@@ -761,10 +781,8 @@ export function removeInstructionsSection(text: string): string {
761
781
  */
762
782
  function removeServerEntryFile(file: string, sectionKey: string): "removed" | "absent" | null {
763
783
  if (!fs.existsSync(file)) return "absent";
764
- let doc: Record<string, unknown>;
765
- try {
766
- doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
767
- } catch {
784
+ const doc = parseJsonConfig(fs.readFileSync(file, "utf8"));
785
+ if (doc === null) {
768
786
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
769
787
  console.error(` Delete the "open-memex" key under "${sectionKey}".`);
770
788
  return null;
@@ -780,10 +798,8 @@ function removeServerEntryFile(file: string, sectionKey: string): "removed" | "a
780
798
  /** Remove the open-memex plugin URL from one opencode user-level config file. */
781
799
  function removeOpencodePluginFile(file: string): "removed" | "absent" | null {
782
800
  if (!fs.existsSync(file)) return "absent";
783
- let doc: Record<string, unknown>;
784
- try {
785
- doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
786
- } catch {
801
+ const doc = parseJsonConfig(fs.readFileSync(file, "utf8"));
802
+ if (doc === null) {
787
803
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
788
804
  console.error(` Delete the open-memex URL from the "plugin" array.`);
789
805
  return null;