open-memex 0.4.0 → 0.5.0-alpha.2
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 +13 -7
- package/CONTRIBUTING.md +10 -10
- package/README.md +54 -8
- package/README.zh-CN.md +51 -7
- package/dist/cli.js +45 -15
- package/dist/init.js +299 -30
- package/dist/store/v2migrate.js +83 -34
- package/docs/TEST-PLAN.md +2 -0
- package/docs/V2-DESIGN.md +52 -0
- package/package.json +1 -1
- package/scripts/smoke-pure.ts +119 -1
- package/src/cli.ts +44 -15
- package/src/init.ts +327 -27
- package/src/store/v2migrate.ts +88 -34
package/dist/init.js
CHANGED
|
@@ -4,6 +4,7 @@ import fs from "node:fs";
|
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { createInterface } from "node:readline/promises";
|
|
7
|
+
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
7
8
|
import { DEFAULT_CONFIG, saveConfig } from "./config.js";
|
|
8
9
|
import { projectRoot } from "./paths.js";
|
|
9
10
|
const MARKER = "<!-- open-memex -->";
|
|
@@ -100,6 +101,72 @@ function writeMcpJson(root, client, force) {
|
|
|
100
101
|
const dir = client === "cursor" ? path.join(root, ".cursor") : path.join(root, ".vscode");
|
|
101
102
|
const file = path.join(dir, "mcp.json");
|
|
102
103
|
const sectionKey = client === "cursor" ? "mcpServers" : "servers";
|
|
104
|
+
const { entry, durable } = stdioServerEntry(client);
|
|
105
|
+
const written = writeServerEntryFile(file, sectionKey, entry, force, true);
|
|
106
|
+
if (written)
|
|
107
|
+
warnNonDurable(durable);
|
|
108
|
+
return written;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* D45: user-level MCP config path — `init --global` writes here so one init
|
|
112
|
+
* covers all projects. `platform` and `home` are parameters (default: current)
|
|
113
|
+
* so tests can cover all OS layouts without mocking.
|
|
114
|
+
*/
|
|
115
|
+
export function userMcpConfigPath(client, platform = process.platform, home = os.homedir()) {
|
|
116
|
+
if (client === "cursor")
|
|
117
|
+
return path.join(home, ".cursor", "mcp.json");
|
|
118
|
+
if (platform === "win32") {
|
|
119
|
+
const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
|
|
120
|
+
return path.join(appData, "Code", "User", "mcp.json");
|
|
121
|
+
}
|
|
122
|
+
if (platform === "darwin") {
|
|
123
|
+
return path.join(home, "Library", "Application Support", "Code", "User", "mcp.json");
|
|
124
|
+
}
|
|
125
|
+
const xdg = process.env.XDG_CONFIG_HOME || path.join(home, ".config");
|
|
126
|
+
return path.join(xdg, "Code", "User", "mcp.json");
|
|
127
|
+
}
|
|
128
|
+
/** The open-memex server entry, same shape init writes at project level. */
|
|
129
|
+
function stdioServerEntry(client) {
|
|
130
|
+
// D17: resolve the server command at init time — a one-shot npx leaves no bin behind.
|
|
131
|
+
const mc = resolveMcpCommand();
|
|
132
|
+
const entry = client === "cursor"
|
|
133
|
+
? { command: mc.command, args: mc.args }
|
|
134
|
+
: {
|
|
135
|
+
type: "stdio",
|
|
136
|
+
command: mc.command,
|
|
137
|
+
args: mc.args,
|
|
138
|
+
// VS Code substitutes ${workspaceFolder} per window, so the server
|
|
139
|
+
// starts with the open project as cwd and scope resolution just works.
|
|
140
|
+
cwd: "${workspaceFolder}",
|
|
141
|
+
};
|
|
142
|
+
return { entry, durable: mc.durable };
|
|
143
|
+
}
|
|
144
|
+
/** Warn when init had to fall back to an npx-based server command. */
|
|
145
|
+
function warnNonDurable(durable) {
|
|
146
|
+
if (durable)
|
|
147
|
+
return;
|
|
148
|
+
console.log(` ! no durable \`open-memex\` on PATH (one-shot npx?) — wrote an npx-based command.`);
|
|
149
|
+
console.log(` For faster startup: \`npm i -g ${ALPHA_TAG}\`, then re-run \`open-memex init --force\`.`);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Pure merge of one server entry into a parsed config doc.
|
|
153
|
+
* Returns "added" when the entry was written, "kept" when an entry already
|
|
154
|
+
* existed and force was not set. Mutates `doc`.
|
|
155
|
+
*/
|
|
156
|
+
export function mergeServerEntry(doc, sectionKey, entry, force) {
|
|
157
|
+
const section = (doc[sectionKey] ??= {});
|
|
158
|
+
if (section["open-memex"] && !force)
|
|
159
|
+
return "kept";
|
|
160
|
+
section["open-memex"] = entry;
|
|
161
|
+
return "added";
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Read (or create) a JSON config file, merge the open-memex server entry, write
|
|
165
|
+
* it back. Existing files are merged, never clobbered; invalid JSON is left
|
|
166
|
+
* untouched. `mkdir` controls whether parent dirs are created (user-level
|
|
167
|
+
* configs) or expected to exist via the project root.
|
|
168
|
+
*/
|
|
169
|
+
function writeServerEntryFile(file, sectionKey, entry, force, mkdir) {
|
|
103
170
|
let doc = {};
|
|
104
171
|
if (fs.existsSync(file)) {
|
|
105
172
|
try {
|
|
@@ -110,29 +177,93 @@ function writeMcpJson(root, client, force) {
|
|
|
110
177
|
return null;
|
|
111
178
|
}
|
|
112
179
|
}
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
180
|
+
const merged = mergeServerEntry(doc, sectionKey, entry, force);
|
|
181
|
+
if (merged === "kept") {
|
|
115
182
|
console.log(` = ${file} already configures open-memex — left as is (use --force to overwrite)`);
|
|
116
183
|
return file;
|
|
117
184
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
section["open-memex"] =
|
|
121
|
-
client === "cursor"
|
|
122
|
-
? { command: mc.command, args: mc.args }
|
|
123
|
-
: {
|
|
124
|
-
type: "stdio",
|
|
125
|
-
command: mc.command,
|
|
126
|
-
args: mc.args,
|
|
127
|
-
cwd: "${workspaceFolder}",
|
|
128
|
-
};
|
|
129
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
185
|
+
if (mkdir)
|
|
186
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
130
187
|
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
|
|
131
188
|
console.log(` + ${file}`);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
189
|
+
return file;
|
|
190
|
+
}
|
|
191
|
+
/** D45: `init --global` — one-time user-level wiring for VS Code / Cursor. */
|
|
192
|
+
function writeGlobalMcpJson(client, force) {
|
|
193
|
+
const file = userMcpConfigPath(client);
|
|
194
|
+
const sectionKey = client === "cursor" ? "mcpServers" : "servers";
|
|
195
|
+
const { entry, durable } = stdioServerEntry(client);
|
|
196
|
+
const written = writeServerEntryFile(file, sectionKey, entry, force, true);
|
|
197
|
+
if (written) {
|
|
198
|
+
warnNonDurable(durable);
|
|
199
|
+
const projFile = client === "cursor" ? ".cursor/mcp.json" : ".vscode/mcp.json";
|
|
200
|
+
console.log(` i user-level config — the open-memex MCP server now starts in every project.`);
|
|
201
|
+
console.log(` (a per-project ${projFile} still wins if a project defines its own)`);
|
|
202
|
+
}
|
|
203
|
+
return written;
|
|
204
|
+
}
|
|
205
|
+
/** Where this installed copy's opencode native plugin entry point lives. */
|
|
206
|
+
function opencodePluginUrl() {
|
|
207
|
+
// init.ts sits in <pkg>/src — the plugin entry is <pkg>/src/index.ts.
|
|
208
|
+
const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
209
|
+
return pathToFileURL(path.join(pkgRoot, "src", "index.ts")).href;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* D46: opencode's user-level config. opencode reads `~/.config/opencode/`
|
|
213
|
+
* (`$XDG_CONFIG_HOME` when set) on every platform; a `plugin` entry there
|
|
214
|
+
* loads open-memex in every project with no per-project init.
|
|
215
|
+
*/
|
|
216
|
+
export function opencodeGlobalConfigPath(home = os.homedir()) {
|
|
217
|
+
return path.join(opencodeConfigDir(home, process.env.XDG_CONFIG_HOME), "opencode.json");
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Pure merge of the open-memex plugin URL into a parsed opencode config doc.
|
|
221
|
+
* Creates the `plugin` array when missing; never duplicates the URL.
|
|
222
|
+
* Returns "added" when the doc changed, "kept" when already present.
|
|
223
|
+
*/
|
|
224
|
+
export function mergePluginEntry(doc, url, force) {
|
|
225
|
+
let plugins = doc["plugin"];
|
|
226
|
+
if (!Array.isArray(plugins)) {
|
|
227
|
+
plugins = [];
|
|
228
|
+
doc["plugin"] = plugins;
|
|
135
229
|
}
|
|
230
|
+
const list = plugins;
|
|
231
|
+
if (list.includes(url) && !force)
|
|
232
|
+
return "kept";
|
|
233
|
+
if (!list.includes(url))
|
|
234
|
+
list.push(url);
|
|
235
|
+
return "added";
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* D46: wire the native plugin at the opencode user level — the one-time
|
|
239
|
+
* global setup. Merges into the existing config when it parses as JSON;
|
|
240
|
+
* a file with comments (JSONC) is left untouched with a manual hint instead
|
|
241
|
+
* of being clobbered.
|
|
242
|
+
*/
|
|
243
|
+
function writeOpencodeGlobalPlugin(force) {
|
|
244
|
+
const dir = path.dirname(opencodeGlobalConfigPath());
|
|
245
|
+
const file = ["opencode.jsonc", "opencode.json"]
|
|
246
|
+
.map((f) => path.join(dir, f))
|
|
247
|
+
.find((f) => fs.existsSync(f)) ?? path.join(dir, "opencode.json");
|
|
248
|
+
const url = opencodePluginUrl();
|
|
249
|
+
let doc = {};
|
|
250
|
+
if (fs.existsSync(file)) {
|
|
251
|
+
try {
|
|
252
|
+
doc = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
console.log(` ! ${file} has comments or invalid JSON — left untouched, fix it manually.`);
|
|
256
|
+
console.log(` To enable open-memex everywhere, add "plugin": ["${url}"] to it.`);
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (mergePluginEntry(doc, url, force) === "kept") {
|
|
261
|
+
console.log(` = ${file} already loads the open-memex plugin — left as is (use --force to overwrite)`);
|
|
262
|
+
return file;
|
|
263
|
+
}
|
|
264
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
265
|
+
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
|
|
266
|
+
console.log(` + ${file} (native plugin — works in every project, no per-project init needed)`);
|
|
136
267
|
return file;
|
|
137
268
|
}
|
|
138
269
|
/** opencode MCP config: project-level opencode.jsonc, `type: "local"` + command array (v1 format). */
|
|
@@ -293,17 +424,133 @@ async function promptInstructionsScope() {
|
|
|
293
424
|
rl.close();
|
|
294
425
|
}
|
|
295
426
|
}
|
|
427
|
+
/** D45: ask whether the MCP server config should be project-level or user-level. */
|
|
428
|
+
async function promptConfigLevel() {
|
|
429
|
+
console.log("Where should the MCP server config live?");
|
|
430
|
+
console.log(" 1) this project only — .vscode/mcp.json (or .cursor/mcp.json)");
|
|
431
|
+
console.log(" 2) user-level — all projects, init once (VS Code / Cursor)");
|
|
432
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
433
|
+
try {
|
|
434
|
+
const ans = (await rl.question("Choice [1]: ")).trim();
|
|
435
|
+
return ans === "2";
|
|
436
|
+
}
|
|
437
|
+
finally {
|
|
438
|
+
rl.close();
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
function exists(p) {
|
|
442
|
+
try {
|
|
443
|
+
return fs.existsSync(p);
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/** True when `name` resolves on PATH (honors .cmd/.exe on Windows). */
|
|
450
|
+
function binOnPath(name, pathEnv, platform) {
|
|
451
|
+
const names = platform === "win32" ? [`${name}.cmd`, `${name}.exe`, name] : [name];
|
|
452
|
+
return pathEnv
|
|
453
|
+
.split(path.delimiter)
|
|
454
|
+
.filter((d) => d && !d.includes("_npx"))
|
|
455
|
+
.some((d) => names.some((n) => exists(path.join(d, n))));
|
|
456
|
+
}
|
|
457
|
+
function opencodeConfigDir(home, xdg) {
|
|
458
|
+
return path.join(xdg || path.join(home, ".config"), "opencode");
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* D46: detect which supported editors are installed. Used when `init` runs
|
|
462
|
+
* without --client — one init wires every detected editor (user-level where
|
|
463
|
+
* the editor supports it). Visual Studio is included only when the project
|
|
464
|
+
* has a solution file, since VS config is solution-scoped by design.
|
|
465
|
+
*/
|
|
466
|
+
export function detectInstalledClients(env = {}) {
|
|
467
|
+
const platform = env.platform ?? process.platform;
|
|
468
|
+
const pathEnv = env.pathEnv ?? process.env.PATH ?? "";
|
|
469
|
+
const home = env.home ?? os.homedir();
|
|
470
|
+
const found = [];
|
|
471
|
+
// VS Code: bin on PATH, well-known install location, or an existing
|
|
472
|
+
// user-level MCP config (a previous init counts as installed).
|
|
473
|
+
const vscodeInstallPaths = platform === "win32"
|
|
474
|
+
? [path.join(home, "AppData", "Local", "Programs", "Microsoft VS Code", "Code.exe")]
|
|
475
|
+
: platform === "darwin"
|
|
476
|
+
? ["/Applications/Visual Studio Code.app"]
|
|
477
|
+
: ["/usr/bin/code", "/usr/share/code/bin/code", "/snap/bin/code"];
|
|
478
|
+
if (binOnPath("code", pathEnv, platform) ||
|
|
479
|
+
vscodeInstallPaths.some(exists) ||
|
|
480
|
+
exists(userMcpConfigPath("vscode", platform, home))) {
|
|
481
|
+
found.push("vscode");
|
|
482
|
+
}
|
|
483
|
+
// Cursor: bin on PATH or its user config dir exists.
|
|
484
|
+
if (binOnPath("cursor", pathEnv, platform) || exists(path.join(home, ".cursor"))) {
|
|
485
|
+
found.push("cursor");
|
|
486
|
+
}
|
|
487
|
+
// opencode: bin on PATH or its global config dir exists.
|
|
488
|
+
const xdg = env.xdgConfigHome ?? process.env.XDG_CONFIG_HOME;
|
|
489
|
+
if (binOnPath("opencode", pathEnv, platform) || exists(opencodeConfigDir(home, xdg))) {
|
|
490
|
+
found.push("opencode");
|
|
491
|
+
}
|
|
492
|
+
// Visual Studio: solution-scoped — only when the project has a .sln.
|
|
493
|
+
let hasSln = false;
|
|
494
|
+
try {
|
|
495
|
+
hasSln = fs.readdirSync(env.root ?? projectRoot()).some((f) => f.toLowerCase().endsWith(".sln"));
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
hasSln = false;
|
|
499
|
+
}
|
|
500
|
+
if (hasSln)
|
|
501
|
+
found.push("visualstudio");
|
|
502
|
+
return found;
|
|
503
|
+
}
|
|
296
504
|
export async function initProject(opts) {
|
|
297
505
|
const interactive = !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
298
|
-
|
|
299
|
-
if (
|
|
506
|
+
const explicit = normalizeClient(opts.client ?? "");
|
|
507
|
+
if (explicit && !INIT_CLIENTS.includes(explicit)) {
|
|
300
508
|
console.error(`unknown client "${opts.client}" (${INIT_CLIENTS.join("|")})`);
|
|
301
509
|
process.exit(1);
|
|
302
510
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
511
|
+
// D46: no --client → auto-detect installed editors and wire them all
|
|
512
|
+
// (user-level where the editor supports it — init once). An explicit
|
|
513
|
+
// --client keeps the old single-editor behavior.
|
|
514
|
+
let clients;
|
|
515
|
+
let autoGlobal = false;
|
|
516
|
+
if (explicit) {
|
|
517
|
+
clients = [explicit];
|
|
518
|
+
}
|
|
519
|
+
else if (interactive) {
|
|
520
|
+
const detected = detectInstalledClients();
|
|
521
|
+
if (detected.length === 0) {
|
|
522
|
+
console.log(" - no supported editors detected — editor setup skipped");
|
|
523
|
+
clients = [];
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
console.log(`Detected editors: ${detected.join(", ")}`);
|
|
527
|
+
const all = await askBool("Wire up open-memex in all of them (one-time, user-level where supported)?", true);
|
|
528
|
+
if (all) {
|
|
529
|
+
clients = detected;
|
|
530
|
+
autoGlobal = true;
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
const one = await promptClient();
|
|
534
|
+
clients = one ? [one] : [];
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
clients = detectInstalledClients();
|
|
540
|
+
autoGlobal = true;
|
|
541
|
+
if (clients.length === 0) {
|
|
542
|
+
console.log(" - no supported editors detected — editor setup skipped");
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
console.log(`Detected editors: ${clients.join(", ")} — wiring all (use --client to pick one)`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
let global = !!opts.global || autoGlobal;
|
|
549
|
+
// The project-vs-user-level choice only applies to an explicit single
|
|
550
|
+
// client; auto mode is user-level by design (init once).
|
|
551
|
+
if (explicit && !global && interactive && (clients[0] === "vscode" || clients[0] === "cursor")) {
|
|
552
|
+
global = await promptConfigLevel();
|
|
553
|
+
}
|
|
307
554
|
let scope = "personal";
|
|
308
555
|
if (opts.instructions) {
|
|
309
556
|
if (opts.instructions !== "personal" && opts.instructions !== "project") {
|
|
@@ -332,17 +579,39 @@ export async function initProject(opts) {
|
|
|
332
579
|
}
|
|
333
580
|
const root = projectRoot();
|
|
334
581
|
console.log(`open-memex init — project root: ${root}`);
|
|
335
|
-
|
|
336
|
-
|
|
582
|
+
for (const client of clients) {
|
|
583
|
+
if (client === "vscode" || client === "cursor") {
|
|
584
|
+
if (global)
|
|
585
|
+
writeGlobalMcpJson(client, opts.force);
|
|
586
|
+
else
|
|
587
|
+
writeMcpJson(root, client, opts.force);
|
|
588
|
+
}
|
|
589
|
+
else if (client === "opencode") {
|
|
590
|
+
// D46: the native plugin is the one-time global setup; without --global
|
|
591
|
+
// (explicit single-client mode) keep the per-project plain-MCP config.
|
|
592
|
+
if (global)
|
|
593
|
+
writeOpencodeGlobalPlugin(opts.force);
|
|
594
|
+
else
|
|
595
|
+
writeMcpJson(root, client, opts.force);
|
|
596
|
+
}
|
|
597
|
+
else if (client === "visualstudio") {
|
|
598
|
+
if (explicit && global) {
|
|
599
|
+
console.log(` - visualstudio: --global not supported — VS uses solution-level .mcp.json by design.`);
|
|
600
|
+
}
|
|
601
|
+
else {
|
|
602
|
+
writeMcpJson(root, client, opts.force);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (clients.length === 0) {
|
|
607
|
+
console.log(" - editor setup skipped");
|
|
608
|
+
}
|
|
609
|
+
else if (clients.some((c) => c !== "opencode")) {
|
|
337
610
|
// copilot-instructions.md is VS Code/Cursor-shaped; opencode as a plain MCP
|
|
338
611
|
// consumer already gets the guidance from the tool descriptions (D16).
|
|
339
612
|
// D22: personal scope (default) writes to the Copilot user-level location
|
|
340
613
|
// so the repo stays clean for teammates without open-memex.
|
|
341
|
-
|
|
342
|
-
writeInstructions(root, scope, client);
|
|
343
|
-
}
|
|
344
|
-
else {
|
|
345
|
-
console.log(" - editor setup skipped");
|
|
614
|
+
writeInstructions(root, scope, clients.find((c) => c !== "opencode"));
|
|
346
615
|
}
|
|
347
616
|
console.log(`\nDone. Reload your editor window to start the open-memex MCP server.`);
|
|
348
617
|
}
|
package/dist/store/v2migrate.js
CHANGED
|
@@ -83,23 +83,45 @@ export function planConversion(filePath, memoriesRoot) {
|
|
|
83
83
|
return { fm, body, fromPath: filePath, toPath, changes };
|
|
84
84
|
}
|
|
85
85
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* backup path, or null when no legacy dir exists.
|
|
86
|
+
* Locate a legacy `my-o-memory` data root next to the current root.
|
|
87
|
+
* Pure lookup — no disk writes.
|
|
89
88
|
*/
|
|
90
|
-
function
|
|
89
|
+
export function findLegacyDataRoot() {
|
|
91
90
|
const { root } = paths();
|
|
92
91
|
const legacy = path.join(path.dirname(root), "my-o-memory");
|
|
93
92
|
if (!fs.existsSync(legacy) || !fs.statSync(legacy).isDirectory())
|
|
94
93
|
return null;
|
|
94
|
+
return legacy;
|
|
95
|
+
}
|
|
96
|
+
/** Dated backup path for a legacy data root. Pure — no disk writes. */
|
|
97
|
+
export function legacyBackupPath(legacy) {
|
|
95
98
|
const stamp = new Date().toISOString().slice(0, 10);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
99
|
+
return `${legacy}.backup-${stamp}`;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Merge a legacy `my-o-memory` data root into the current `open-memex` root.
|
|
103
|
+
* The old dir is renamed to a dated backup — never deleted.
|
|
104
|
+
* Failures throw an actionable Error (no raw syscall dump): on Windows the
|
|
105
|
+
* backup rename typically fails with EPERM when another program holds the
|
|
106
|
+
* folder open.
|
|
107
|
+
*/
|
|
108
|
+
function mergeLegacyDataRoot(legacy, backup) {
|
|
109
|
+
const { memories } = paths();
|
|
110
|
+
const fail = (where, err) => {
|
|
111
|
+
const code = err?.code;
|
|
112
|
+
throw new Error(`could not ${where} (${legacy})` +
|
|
113
|
+
(code ? ` [${code}]` : "") +
|
|
114
|
+
`. Another program may be holding the folder open (e.g. a running MCP server, editor, or antivirus). ` +
|
|
115
|
+
`Your memories are safe — nothing was deleted. Close the program and re-run ` +
|
|
116
|
+
`\`open-memex migrate --to-v2\`, or rename the folder to ${backup} yourself and re-run.`);
|
|
117
|
+
};
|
|
118
|
+
const legacyMem = path.join(legacy, "memories");
|
|
119
|
+
if (fs.existsSync(legacyMem)) {
|
|
120
|
+
fs.mkdirSync(memories, { recursive: true });
|
|
121
|
+
for (const entry of fs.readdirSync(legacyMem)) {
|
|
122
|
+
const src = path.join(legacyMem, entry);
|
|
123
|
+
const dst = path.join(memories, entry);
|
|
124
|
+
try {
|
|
103
125
|
if (!fs.existsSync(dst)) {
|
|
104
126
|
fs.renameSync(src, dst);
|
|
105
127
|
}
|
|
@@ -113,46 +135,73 @@ function migrateLegacyDataRoot(dryRun) {
|
|
|
113
135
|
}
|
|
114
136
|
}
|
|
115
137
|
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
fail(`move memories from the legacy data dir`, err);
|
|
140
|
+
}
|
|
116
141
|
}
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
117
144
|
fs.renameSync(legacy, backup);
|
|
118
145
|
}
|
|
119
|
-
|
|
146
|
+
catch (err) {
|
|
147
|
+
fail(`back up the legacy data dir`, err);
|
|
148
|
+
}
|
|
120
149
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
skippedV2: 0,
|
|
127
|
-
plans: [],
|
|
128
|
-
legacyBackup: null,
|
|
129
|
-
};
|
|
130
|
-
result.legacyBackup = migrateLegacyDataRoot(opts.dryRun);
|
|
131
|
-
if (!fs.existsSync(memoriesRoot))
|
|
132
|
-
return result;
|
|
133
|
-
for (const entry of fs.readdirSync(memoriesRoot, { withFileTypes: true })) {
|
|
150
|
+
/** Plan v1 → v2 conversion for every `.md` file under `dir`. Read-only. */
|
|
151
|
+
function planDir(dir, memoriesRoot, result) {
|
|
152
|
+
if (!fs.existsSync(dir))
|
|
153
|
+
return;
|
|
154
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
134
155
|
if (!entry.isDirectory())
|
|
135
156
|
continue;
|
|
136
|
-
const
|
|
137
|
-
for (const name of fs.readdirSync(
|
|
157
|
+
const sub = path.join(dir, entry.name);
|
|
158
|
+
for (const name of fs.readdirSync(sub)) {
|
|
138
159
|
if (!name.endsWith(".md"))
|
|
139
160
|
continue;
|
|
140
|
-
const filePath = path.join(
|
|
161
|
+
const filePath = path.join(sub, name);
|
|
141
162
|
result.scanned++;
|
|
142
163
|
const plan = planConversion(filePath, memoriesRoot);
|
|
143
164
|
if (!plan) {
|
|
144
165
|
result.skippedV2++;
|
|
145
166
|
continue;
|
|
146
167
|
}
|
|
147
|
-
if (!opts.dryRun) {
|
|
148
|
-
fs.mkdirSync(path.dirname(plan.toPath), { recursive: true });
|
|
149
|
-
fs.writeFileSync(plan.toPath, serialize(plan.fm, plan.body), "utf8");
|
|
150
|
-
if (plan.toPath !== plan.fromPath)
|
|
151
|
-
fs.unlinkSync(plan.fromPath);
|
|
152
|
-
}
|
|
153
168
|
result.converted++;
|
|
154
169
|
result.plans.push(plan);
|
|
155
170
|
}
|
|
156
171
|
}
|
|
172
|
+
}
|
|
173
|
+
/** Apply conversion plans to disk (real run only — never in dry-run). */
|
|
174
|
+
function applyPlans(result) {
|
|
175
|
+
for (const plan of result.plans) {
|
|
176
|
+
fs.mkdirSync(path.dirname(plan.toPath), { recursive: true });
|
|
177
|
+
fs.writeFileSync(plan.toPath, serialize(plan.fm, plan.body), "utf8");
|
|
178
|
+
if (plan.toPath !== plan.fromPath)
|
|
179
|
+
fs.unlinkSync(plan.fromPath);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
export function migrateV2(opts) {
|
|
183
|
+
const { memories: memoriesRoot } = paths();
|
|
184
|
+
const result = {
|
|
185
|
+
scanned: 0,
|
|
186
|
+
converted: 0,
|
|
187
|
+
skippedV2: 0,
|
|
188
|
+
plans: [],
|
|
189
|
+
legacyBackup: null,
|
|
190
|
+
};
|
|
191
|
+
const legacy = findLegacyDataRoot();
|
|
192
|
+
if (legacy) {
|
|
193
|
+
result.legacyBackup = legacyBackupPath(legacy);
|
|
194
|
+
if (opts.dryRun) {
|
|
195
|
+
// D44: dry-run previews the legacy files in place — nothing is moved,
|
|
196
|
+
// so the preview actually shows what would convert (issue #7).
|
|
197
|
+
planDir(path.join(legacy, "memories"), memoriesRoot, result);
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
mergeLegacyDataRoot(legacy, result.legacyBackup);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
planDir(memoriesRoot, memoriesRoot, result);
|
|
204
|
+
if (!opts.dryRun)
|
|
205
|
+
applyPlans(result);
|
|
157
206
|
return result;
|
|
158
207
|
}
|
package/docs/TEST-PLAN.md
CHANGED
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
|
|
34
34
|
- [ ] Cursor:`open-memex mcp --print-config cursor` → 贴到 Cursor MCP 配置 → 能连上
|
|
35
35
|
- [ ] opencode:`open-memex init --client opencode` → `opencode.jsonc` 生效
|
|
36
|
+
- [ ] `open-memex init --yes`(不带 --client)→ 自动检测已装编辑器并一次全接上
|
|
37
|
+
- [ ] opencode:`open-memex init --client opencode --global` → `~/.config/opencode/opencode.json` 的 `plugin` 数组合并(带注释的 jsonc 不动、只给手动提示)
|
|
36
38
|
- [ ] Claude Code:`open-memex mcp --print-config claude` 给出的 `claude mcp add` 命令能跑通
|
|
37
39
|
|
|
38
40
|
## D. 跨机迁移(export/import 真实场景)
|
package/docs/V2-DESIGN.md
CHANGED
|
@@ -867,6 +867,39 @@ requirement: personal data never touches third-party services). Benchmarks to tr
|
|
|
867
867
|
MCP handshake instructions or the `init`-written instruction files — but
|
|
868
868
|
opencode reads AGENTS.md natively, so the distilled snippet teaches the
|
|
869
869
|
checkpoint habit wherever it lands. Approved 2026-09-29.*
|
|
870
|
+
- **D44** — `migrate --to-v2` hotfix (issue #7, 0.4.1): (1) `--dry-run` now
|
|
871
|
+
previews the legacy `my-o-memory` files in place — per-file conversion plans
|
|
872
|
+
without moving anything (previously it scanned only the new, still-empty
|
|
873
|
+
root and always reported "0 files", making the preview useless); (2) dry-run
|
|
874
|
+
no longer prints the false "legacy data dir merged" line; (3) the Windows
|
|
875
|
+
EPERM on the legacy-dir backup rename is caught and rethrown as an actionable
|
|
876
|
+
message (close the program holding the folder — e.g. a running MCP server —
|
|
877
|
+
and re-run; nothing was deleted), and the CLI prints it as `Error: …` with
|
|
878
|
+
exit 1 instead of a raw syscall stack; (4) `parseFlags` accepts `--key=value`
|
|
879
|
+
in addition to `--key value` (the `=` form was silently misparsed before,
|
|
880
|
+
dropping the flag — which can turn a `--dry-run` into a real run).
|
|
881
|
+
*Rationale: a preview that shows nothing is worse than no preview — the user
|
|
882
|
+
cannot confirm what the real run will do; and a destructive-path failure must
|
|
883
|
+
speak in user terms. Shipped as 0.4.1 hotfix on the stable line. Approved
|
|
884
|
+
2026-09-29.*
|
|
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.*
|
|
870
903
|
- **D41** — The type taxonomy is reconciled to 11 types with one-line definitions
|
|
871
904
|
(§3.1): `fact` `preference` `decision` `constraint` `todo` `knowledge` `howto`
|
|
872
905
|
`gotcha` `lesson` `observation` `reference`. Merged away: `warning`→`gotcha`,
|
|
@@ -878,6 +911,25 @@ requirement: personal data never touches third-party services). Benchmarks to tr
|
|
|
878
911
|
is deliberately broader than `incident`: a postmortem's shape (timeline, root
|
|
879
912
|
cause, actions) is a template concern, not a type. Code
|
|
880
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.*
|
|
881
933
|
|
|
882
934
|
## Open Questions
|
|
883
935
|
|
package/package.json
CHANGED