open-memex 0.4.1 → 0.5.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/docs/V2-DESIGN.md CHANGED
@@ -882,6 +882,24 @@ requirement: personal data never touches third-party services). Benchmarks to tr
882
882
  cannot confirm what the real run will do; and a destructive-path failure must
883
883
  speak in user terms. Shipped as 0.4.1 hotfix on the stable line. Approved
884
884
  2026-09-29.*
885
+ - **D45** — `init --global` (0.5.0-alpha.1): one-time user-level MCP wiring for
886
+ VS Code / Cursor. Writes the open-memex server entry to the editor's
887
+ user-level `mcp.json` (`%APPDATA%\Code\User\mcp.json` on Windows,
888
+ `~/Library/Application Support/Code/User/mcp.json` on macOS,
889
+ `~/.config/Code/User/mcp.json` on Linux; `~/.cursor/mcp.json` for Cursor)
890
+ instead of the project's `.vscode/mcp.json` — init once, the server starts
891
+ in every project. The entry keeps `cwd: "${workspaceFolder}"` so VS Code
892
+ substitutes it per window and project-scope resolution keeps working;
893
+ a per-project config still wins when present. Merge semantics are shared
894
+ with project-level init (merge, never clobber; `--force` overwrites).
895
+ Interactive `init` now asks per-project vs user-level for vscode/cursor
896
+ (default: per-project, preserving old behavior); `--global` skips the
897
+ question. For opencode, `--global` is a no-op that prints the native-plugin
898
+ one-liner (already global, and strictly more capable than plain-MCP mode);
899
+ Visual Studio stays solution-level by design. *Rationale: per-project init
900
+ is a paper cut that compounds — the data layer already needs zero per-project
901
+ setup (scope is derived from cwd), so the editor wiring should be able to
902
+ match. Approved 2026-09-29.*
885
903
  - **D41** — The type taxonomy is reconciled to 11 types with one-line definitions
886
904
  (§3.1): `fact` `preference` `decision` `constraint` `todo` `knowledge` `howto`
887
905
  `gotcha` `lesson` `observation` `reference`. Merged away: `warning`→`gotcha`,
@@ -893,6 +911,49 @@ requirement: personal data never touches third-party services). Benchmarks to tr
893
911
  is deliberately broader than `incident`: a postmortem's shape (timeline, root
894
912
  cause, actions) is a template concern, not a type. Code
895
913
  `MEMORY_TYPE_TAXONOMY` updated to match. Approved 2026-09-28.*
914
+ - **D46** — `init` with no `--client` auto-detects installed editors and wires
915
+ them all (0.5.0-alpha.2). Detection: VS Code via `code` on `PATH`, well-known
916
+ install locations, or an existing user-level `mcp.json`; Cursor via `cursor`
917
+ on `PATH` or `~/.cursor`; opencode via `opencode` on `PATH` or its global
918
+ config dir; Visual Studio only when the project has a `.sln` (solution-scoped
919
+ by design). Auto mode always wires user-level where the editor supports it —
920
+ VS Code / Cursor MCP entry (D45), and for opencode the native plugin entry is
921
+ now *actually merged* into `~/.config/opencode/opencode.json` (replacing D45's
922
+ hint-only `--global`), so one init covers every editor and every project.
923
+ A config file with comments (JSONC) is never rewritten — init prints the
924
+ manual one-liner instead. Interactive `init` shows the detected editors and
925
+ confirms wiring all of them (declining falls back to the single-editor
926
+ prompt); non-interactive (`--yes`) wires all detected with no prompts.
927
+ An explicit `--client` keeps the old single-editor behavior, including the
928
+ per-project default for vscode/cursor. *Rationale: Stone's two hats — he
929
+ writes code in several editors himself, and new users / pilot colleagues
930
+ should not have to learn `--client` to get started. The help text already
931
+ promised "default: auto-detect"; D46 makes the code keep that promise.
932
+ Approved 2026-09-29.*
933
+ - **D47** — when `init` refuses to touch a config file that isn't valid JSON
934
+ (usually JSONC with comments — VS Code / Cursor / opencode all accept them),
935
+ it now prints the exact snippet to add by hand: the section key plus the
936
+ `"open-memex"` entry, pretty-printed. Covers all three MCP-writing paths —
937
+ vscode/cursor (user + project level), opencode project-level `opencode.jsonc`,
938
+ and Visual Studio `.mcp.json` — matching the manual hint the opencode
939
+ `--global` plugin path already printed (D46). The file is still never
940
+ rewritten; the hint just makes "fix it manually" actionable. Triggered by
941
+ Stone's real Windows run: his user-level `mcp.json` had comments, init
942
+ correctly left it alone, but the old message gave him nothing to paste.
943
+ *Rationale: a refusal without a remedy is a dead end; the entry shape is
944
+ already computed, so printing it costs nothing. Approved 2026-09-29.*
945
+ - **D48** — new `open-memex uninstall` command, the reverse of `init`
946
+ (0.5.0-alpha.4). Removes the MCP server entry / opencode native plugin line /
947
+ Copilot instructions section that `init` wrote; memory data is never touched.
948
+ Client resolution mirrors `init` (explicit `--client`, else auto-detect with
949
+ an interactive confirm). Without `--global` it cleans both project-level and
950
+ user-level files — init may have written either, and a leftover entry at the
951
+ other level would be a surprise; `--global` restricts to user-level. Empty
952
+ sections/arrays are pruned; an instructions file that only held the
953
+ open-memex section is deleted. Non-JSON (JSONC) configs are left untouched
954
+ with the manual step, same D47 treatment as the init write path.
955
+ *Rationale: Stone asked "有 uninstall 吗" while testing init on Windows —
956
+ every write deserves an undo. Approved 2026-09-29.*
896
957
 
897
958
  ## Open Questions
898
959
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-memex",
3
- "version": "0.4.1",
3
+ "version": "0.5.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",
@@ -8,6 +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
12
 
12
13
  let fails = 0;
13
14
  function ok(name: string, cond: boolean, info?: unknown) {
@@ -376,5 +377,108 @@ const near = similarity(
376
377
  ok(`similarity near-dup ${near.toFixed(2)} >= ${NEAR_DUP_THRESHOLD}`, near >= NEAR_DUP_THRESHOLD);
377
378
  ok("similarity empty → 0", similarity("", "anything") === 0);
378
379
 
380
+ console.log("== init --global: userMcpConfigPath / mergeServerEntry ==");
381
+ // D45: user-level MCP config locations, per platform (platform param is injectable).
382
+ ok("vscode win32", userMcpConfigPath("vscode", "win32").endsWith(path.join("Code", "User", "mcp.json")));
383
+ ok("vscode darwin", userMcpConfigPath("vscode", "darwin").includes(path.join("Library", "Application Support", "Code", "User", "mcp.json")));
384
+ ok("vscode linux", userMcpConfigPath("vscode", "linux").endsWith(path.join(".config", "Code", "User", "mcp.json")));
385
+ ok("cursor is ~/.cursor/mcp.json on every platform",
386
+ (["win32", "darwin", "linux"] as const).every((p) =>
387
+ userMcpConfigPath("cursor", p).endsWith(path.join(".cursor", "mcp.json"))));
388
+ // mergeServerEntry: pure merge semantics.
389
+ const doc1: Record<string, unknown> = {};
390
+ ok("merge into empty doc adds", mergeServerEntry(doc1, "servers", { command: "x" }, false) === "added");
391
+ ok("entry landed under section", (doc1["servers"] as Record<string, unknown>)["open-memex"] !== undefined);
392
+ ok("existing entry kept without force", mergeServerEntry(doc1, "servers", { command: "y" }, false) === "kept");
393
+ ok("kept entry untouched", ((doc1["servers"] as Record<string, unknown>)["open-memex"] as Record<string, unknown>)["command"] === "x");
394
+ ok("force overwrites", mergeServerEntry(doc1, "servers", { command: "y" }, true) === "added");
395
+ ok("forced entry applied", ((doc1["servers"] as Record<string, unknown>)["open-memex"] as Record<string, unknown>)["command"] === "y");
396
+ const doc2: Record<string, unknown> = { servers: { other: { command: "z" } } };
397
+ ok("merge preserves sibling entries", mergeServerEntry(doc2, "servers", { command: "x" }, false) === "added"
398
+ && (doc2["servers"] as Record<string, unknown>)["other"] !== undefined);
399
+
400
+ console.log("== init D46: auto-detect + opencode global plugin ==");
401
+ // opencodeGlobalConfigPath: user-level location, XDG-aware.
402
+ const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "memex-smoke-"));
403
+ ok("opencode global config under ~/.config/opencode",
404
+ opencodeGlobalConfigPath(fakeHome).endsWith(path.join(".config", "opencode", "opencode.json")));
405
+ ok("opencode global config honors XDG_CONFIG_HOME", (() => {
406
+ const prev = process.env.XDG_CONFIG_HOME;
407
+ process.env.XDG_CONFIG_HOME = path.join(fakeHome, "xdg");
408
+ try {
409
+ return opencodeGlobalConfigPath(fakeHome).startsWith(path.join(fakeHome, "xdg"));
410
+ } finally {
411
+ if (prev === undefined) delete process.env.XDG_CONFIG_HOME;
412
+ else process.env.XDG_CONFIG_HOME = prev;
413
+ }
414
+ })());
415
+ // mergePluginEntry: pure merge semantics.
416
+ const pdoc: Record<string, unknown> = {};
417
+ ok("plugin merge creates array", mergePluginEntry(pdoc, "file:///x", false) === "added"
418
+ && JSON.stringify(pdoc["plugin"]) === JSON.stringify(["file:///x"]));
419
+ ok("plugin merge dup kept", mergePluginEntry(pdoc, "file:///x", false) === "kept");
420
+ ok("plugin merge force on dup adds nothing twice", mergePluginEntry(pdoc, "file:///x", true) === "added"
421
+ && (pdoc["plugin"] as unknown[]).length === 1);
422
+ const pdoc2: Record<string, unknown> = { plugin: ["file:///other"], theme: "dark" };
423
+ ok("plugin merge preserves siblings", mergePluginEntry(pdoc2, "file:///x", false) === "added"
424
+ && (pdoc2["plugin"] as unknown[]).length === 2 && pdoc2["theme"] === "dark");
425
+ // printManualEntryHint: the invalid-JSON bail-out must show the exact snippet to add by hand.
426
+ const hintLines: string[] = [];
427
+ const origErr = console.error;
428
+ console.error = (msg?: unknown) => { hintLines.push(String(msg)); };
429
+ printManualEntryHint("servers", { type: "stdio", command: "open-memex", args: ["mcp"] });
430
+ console.error = origErr;
431
+ const hintText = hintLines.join("\n");
432
+ ok("manual hint names the section", hintText.includes('"servers"'));
433
+ ok("manual hint contains the entry", hintText.includes('"open-memex"') && hintText.includes("stdio"));
434
+ // removeServerEntry: pure removal semantics.
435
+ const rdoc: Record<string, unknown> = { servers: { "open-memex": { command: "x" }, other: { command: "y" } }, untouched: 1 };
436
+ ok("remove deletes the entry", removeServerEntry(rdoc, "servers") === "removed"
437
+ && !("open-memex" in (rdoc["servers"] as Record<string, unknown>))
438
+ && "other" in (rdoc["servers"] as Record<string, unknown>));
439
+ ok("remove absent when entry already gone", removeServerEntry(rdoc, "servers") === "absent"
440
+ && "servers" in rdoc); // sibling kept, section kept
441
+ const rdoc2: Record<string, unknown> = { servers: { "open-memex": { command: "x" } } };
442
+ ok("remove prunes empty section", removeServerEntry(rdoc2, "servers") === "removed" && !("servers" in rdoc2));
443
+ ok("remove absent when no entry", removeServerEntry({ servers: {} }, "servers") === "absent");
444
+ ok("remove absent when no section", removeServerEntry({}, "servers") === "absent");
445
+ // removePluginEntry: removes any open-memex URL, prunes empty array.
446
+ const rpdoc: Record<string, unknown> = { plugin: ["file:///x/open-memex/src/index.ts", "other-plugin"], theme: "dark" };
447
+ ok("plugin remove by substring", removePluginEntry(rpdoc, "open-memex") === "removed"
448
+ && JSON.stringify(rpdoc["plugin"]) === JSON.stringify(["other-plugin"]));
449
+ const rpdoc3: Record<string, unknown> = { plugin: ["file:///open-memex/y"] };
450
+ ok("plugin remove prunes empty array", removePluginEntry(rpdoc3, "open-memex") === "removed" && !("plugin" in rpdoc3));
451
+ ok("plugin remove absent", removePluginEntry({ plugin: ["other"] }, "open-memex") === "absent");
452
+ // removeInstructionsSection: cuts MARKER..end, "" when nothing remains.
453
+ ok("instructions remove keeps prior content",
454
+ removeInstructionsSection("# mine\n\n<!-- open-memex -->\n# OpenMemex memory\n") === "# mine\n");
455
+ ok("instructions remove returns empty when wholesale",
456
+ removeInstructionsSection("<!-- open-memex -->\n# OpenMemex memory\n") === "");
457
+ ok("instructions remove no-op without marker",
458
+ removeInstructionsSection("# mine\n") === "# mine\n");
459
+ // detectInstalledClients with a fully fake env.
460
+ const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "memex-bin-"));
461
+ fs.writeFileSync(path.join(binDir, "code"), "#!/bin/sh\n");
462
+ const detHome = fs.mkdtempSync(path.join(os.tmpdir(), "memex-home-"));
463
+ fs.mkdirSync(path.join(detHome, ".cursor"), { recursive: true });
464
+ fs.mkdirSync(path.join(detHome, ".config", "opencode"), { recursive: true });
465
+ const detRoot = fs.mkdtempSync(path.join(os.tmpdir(), "memex-root-"));
466
+ const detEnv = { pathEnv: binDir, home: detHome, platform: "linux" as const, root: detRoot, xdgConfigHome: path.join(detHome, ".config") };
467
+ const det = detectInstalledClients(detEnv);
468
+ ok("detects vscode via PATH", det.includes("vscode"));
469
+ ok("detects cursor via ~/.cursor", det.includes("cursor"));
470
+ ok("detects opencode via config dir", det.includes("opencode"));
471
+ ok("no visualstudio without .sln", !det.includes("visualstudio"));
472
+ fs.writeFileSync(path.join(detRoot, "app.sln"), "");
473
+ ok("visualstudio detected with .sln", detectInstalledClients(detEnv).includes("visualstudio"));
474
+ const emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), "memex-empty-"));
475
+ const detEmpty = detectInstalledClients({ pathEnv: "", home: emptyHome, platform: "linux", root: emptyHome, xdgConfigHome: path.join(emptyHome, ".config") });
476
+ ok("empty env detects nothing", detEmpty.length === 0);
477
+ const winBin = fs.mkdtempSync(path.join(os.tmpdir(), "memex-winbin-"));
478
+ fs.writeFileSync(path.join(winBin, "code.cmd"), "@echo off\n");
479
+ ok("win32 detects vscode via code.cmd",
480
+ detectInstalledClients({ pathEnv: winBin, home: emptyHome, platform: "win32", root: emptyHome, xdgConfigHome: path.join(emptyHome, ".config") }).includes("vscode"));
481
+ for (const d of [fakeHome, binDir, detHome, detRoot, emptyHome, winBin]) fs.rmSync(d, { recursive: true, force: true });
482
+
379
483
  console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAILURES`);
380
484
  process.exit(fails === 0 ? 0 : 1);
package/src/cli.ts CHANGED
@@ -279,18 +279,40 @@ Examples:
279
279
  agent memory instructions. Existing files are merged, never clobbered.
280
280
 
281
281
  Usage: open-memex init [--client vscode|cursor|opencode|visualstudio]
282
- [--instructions personal|project] [--force] [--yes]
282
+ [--instructions personal|project] [--global] [--force] [--yes]
283
283
 
284
284
  Flags:
285
- --client editor to configure (default: auto-detect)
285
+ --client editor to configure (default: auto-detect all installed editors)
286
286
  --instructions personal (default, ~/.copilot/copilot-instructions.md) or project
287
+ --global write the MCP server entry to the editor's user-level config
288
+ (VS Code / Cursor) — init once, works in every project.
289
+ For opencode, --global wires the native plugin at user level
290
+ (~/.config/opencode/opencode.json) — no per-project init needed.
287
291
  --force overwrite existing config
288
292
  --yes accept all defaults, never prompt
289
293
 
290
294
  Examples:
291
295
  open-memex init
296
+ open-memex init --client vscode --global --yes
292
297
  open-memex init --client cursor --yes`,
293
298
 
299
+ uninstall: `Remove the editor wiring that \`init\` wrote: the MCP server entry,
300
+ the opencode native plugin line, and the Copilot instructions section.
301
+ Your memories are never touched.
302
+
303
+ Usage: open-memex uninstall [--client vscode|cursor|opencode|visualstudio]
304
+ [--global] [--yes]
305
+
306
+ Flags:
307
+ --client editor to unwire (default: auto-detect all installed editors)
308
+ --global only the user-level config; without it, both project-level and
309
+ user-level wiring are removed (init may have written either)
310
+ --yes accept all defaults, never prompt
311
+
312
+ Examples:
313
+ open-memex uninstall
314
+ open-memex uninstall --client vscode --yes`,
315
+
294
316
  config: `Show config, or set a key.
295
317
 
296
318
  Usage: open-memex config [set <key> <value>]
@@ -346,6 +368,8 @@ Usage:
346
368
  open-memex mcp [--print-config vscode|cursor|claude|opencode|visualstudio]
347
369
  open-memex init [--client vscode|cursor|opencode|visualstudio]
348
370
  [--instructions personal|project] [--force] [--yes]
371
+ open-memex uninstall [--client vscode|cursor|opencode|visualstudio]
372
+ [--global] [--yes]
349
373
  open-memex config [set <key> <value>]
350
374
  open-memex capture --dry-run "text"
351
375
  open-memex doctor
@@ -353,16 +377,19 @@ Usage:
353
377
  Every command has its own help with description and examples:
354
378
  open-memex <command> --help (or -h)
355
379
 
356
- One-command project setup: \`open-memex init\` (or \`npx open-memex@alpha init\`) writes
357
- the MCP config for your editor (\`.vscode/mcp.json\`, \`.cursor/mcp.json\`,
358
- \`opencode.jsonc\`, or Visual Studio's solution-level \`.mcp.json\`) — no copy-paste
359
- needed. The Copilot memory instructions default to your user-level
380
+ One-command project setup: \`open-memex init\` (or \`npx open-memex@alpha init\`) detects
381
+ your installed editors and wires them all — user-level where the editor supports it
382
+ (VS Code / Cursor MCP config, opencode native plugin), so one init covers every project;
383
+ Visual Studio is included when the project has a solution file (its \`.mcp.json\`
384
+ stays solution-level by design). \`--client\` picks a single editor instead, and a
385
+ single-editor opencode init writes the per-project plain-MCP \`opencode.jsonc\`.
386
+ The Copilot memory instructions default to your user-level
360
387
  \`~/.copilot/copilot-instructions.md\` (all projects, never checked into a repo);
361
388
  \`--instructions project\` writes \`.github/copilot-instructions.md\` instead for
362
389
  teams where everyone uses open-memex.
363
390
  Existing files are merged, never clobbered; re-running is safe. On a terminal it
364
- asks which editor to set up and a couple of settings (keyword capture, first-turn
365
- injection); \`--yes\` accepts all defaults, and non-terminal runs never prompt.
391
+ confirms the detected editors and asks a couple of settings (keyword capture,
392
+ first-turn injection); \`--yes\` accepts all defaults, and non-terminal runs never prompt.
366
393
  \`open-memex config set <key> <value>\` changes those settings after install.
367
394
 
368
395
  Once installed globally (\`npm i -g open-memex@alpha\`) the \`open-memex\` command is
@@ -595,7 +622,7 @@ async function main() {
595
622
  }
596
623
 
597
624
  // `init` is a pure file operation (§17 adoption path) — no DB needed.
598
- // Interactive when on a TTY (asks editor + settings); --yes skips prompts.
625
+ // Interactive when on a TTY (confirms detected editors + settings); --yes skips prompts.
599
626
  if (cmd === "init") {
600
627
  const flags = parseFlags(rest);
601
628
  const { initProject } = await import("./init.ts");
@@ -604,6 +631,20 @@ async function main() {
604
631
  force: flags["force"] === "true",
605
632
  yes: flags["yes"] === "true",
606
633
  instructions: flags["instructions"],
634
+ global: flags["global"] === "true",
635
+ });
636
+ return;
637
+ }
638
+
639
+ // `uninstall` reverses `init` — removes the editor wiring; never touches data.
640
+ // No DB needed (pure file operation, like init).
641
+ if (cmd === "uninstall") {
642
+ const flags = parseFlags(rest);
643
+ const { uninstallProject } = await import("./init.ts");
644
+ await uninstallProject({
645
+ client: flags["client"],
646
+ global: flags["global"] === "true",
647
+ yes: flags["yes"] === "true",
607
648
  });
608
649
  return;
609
650
  }