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/scripts/smoke-pure.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// Quick smoke test — runs the pure-logic modules (no bun:sqlite dependency).
|
|
2
2
|
// Usage: node --experimental-strip-types scripts\smoke-pure.ts
|
|
3
3
|
import { parse, serialize, ulid, normalizeFrontmatter, msToRfc3339, timeToMs, parseRawFrontmatter, type Frontmatter } from "../src/store/markdown.ts";
|
|
4
|
-
import { planConversion, isV2File } from "../src/store/v2migrate.ts";
|
|
4
|
+
import { planConversion, isV2File, migrateV2 } from "../src/store/v2migrate.ts";
|
|
5
5
|
import { redact, findSecret } from "../src/redact.ts";
|
|
6
6
|
import { detectKeywords } from "../src/capture/keywords.ts";
|
|
7
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 } from "../src/init.ts";
|
|
11
12
|
|
|
12
13
|
let fails = 0;
|
|
13
14
|
function ok(name: string, cond: boolean, info?: unknown) {
|
|
@@ -314,6 +315,54 @@ const plan2 = planConversion(v1path, memRoot);
|
|
|
314
315
|
ok("v2 on disk → null plan", plan2 === null);
|
|
315
316
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
316
317
|
|
|
318
|
+
console.log("== migrateV2 dry-run previews legacy files (issue #7) ==");
|
|
319
|
+
// legacy dir sits next to the new root (sibling), as on a real v1 upgrade
|
|
320
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "v2hotfix-"));
|
|
321
|
+
const newRoot = path.join(tmpHome, "open-memex");
|
|
322
|
+
const legacyRoot = path.join(tmpHome, "my-o-memory");
|
|
323
|
+
const legacyMem = path.join(legacyRoot, "memories", "user");
|
|
324
|
+
fs.mkdirSync(legacyMem, { recursive: true });
|
|
325
|
+
const v1legacy = `---\nid: 01HOTFIX1\nscope_key: user\nscope_kind: user\nproject_name: user\ntype: fact\npriority: 1\ncreated_at: 1758854400000\nupdated_at: 1758854400000\n---\n\nlegacy content\n`;
|
|
326
|
+
fs.writeFileSync(path.join(legacyMem, "01HOTFIX1.md"), v1legacy, "utf8");
|
|
327
|
+
process.env.MY_O_MEMORY_HOME = newRoot;
|
|
328
|
+
|
|
329
|
+
const dry = migrateV2({ dryRun: true });
|
|
330
|
+
ok("dry-run scans the legacy file", dry.scanned === 1 && dry.plans.length === 1, `scanned=${dry.scanned}`);
|
|
331
|
+
ok("dry-run yields a conversion plan", dry.plans.length === 1 && dry.plans[0].toPath.startsWith(newRoot), dry.plans[0]?.toPath);
|
|
332
|
+
ok("dry-run reports legacy backup target", typeof dry.legacyBackup === "string" && dry.legacyBackup.includes("backup-"), dry.legacyBackup);
|
|
333
|
+
ok("dry-run moves nothing", fs.existsSync(path.join(legacyMem, "01HOTFIX1.md")) && !fs.existsSync(path.join(newRoot, "memories", "personal", "01HOTFIX1.md")));
|
|
334
|
+
ok("dry-run does not rename legacy dir", fs.existsSync(legacyRoot) && !fs.existsSync(dry.legacyBackup!));
|
|
335
|
+
|
|
336
|
+
const real = migrateV2({ dryRun: false });
|
|
337
|
+
ok("real run converts", real.converted === 1, `converted=${real.converted}`);
|
|
338
|
+
ok("real run writes v2 file to new root", fs.existsSync(path.join(newRoot, "memories", "personal", "01HOTFIX1.md")));
|
|
339
|
+
ok("real run backs up legacy dir", !fs.existsSync(legacyRoot) && fs.existsSync(real.legacyBackup!));
|
|
340
|
+
const backContent = fs.readFileSync(path.join(newRoot, "memories", "personal", "01HOTFIX1.md"), "utf8");
|
|
341
|
+
ok("converted file has schema_version 2", backContent.includes("schema_version: 2"));
|
|
342
|
+
|
|
343
|
+
// backup rename failure → actionable Error, exit-1-worthy, no raw stack
|
|
344
|
+
// (same process/root: paths() is cached per process)
|
|
345
|
+
const legacyRoot2 = path.join(tmpHome, "my-o-memory");
|
|
346
|
+
const legacyMem2 = path.join(legacyRoot2, "memories", "user");
|
|
347
|
+
fs.mkdirSync(legacyMem2, { recursive: true });
|
|
348
|
+
fs.writeFileSync(path.join(legacyMem2, "01HOTFIX2.md"), v1legacy, "utf8");
|
|
349
|
+
// block the rename: pre-create today's dated backup as a non-empty dir
|
|
350
|
+
const blocked = path.join(tmpHome, "my-o-memory.backup-" + new Date().toISOString().slice(0, 10));
|
|
351
|
+
fs.mkdirSync(blocked, { recursive: true });
|
|
352
|
+
fs.writeFileSync(path.join(blocked, "sentinel"), "x", "utf8");
|
|
353
|
+
let boom: unknown = null;
|
|
354
|
+
try {
|
|
355
|
+
migrateV2({ dryRun: false });
|
|
356
|
+
} catch (e) {
|
|
357
|
+
boom = e;
|
|
358
|
+
}
|
|
359
|
+
ok("backup failure throws", boom instanceof Error, String(boom));
|
|
360
|
+
ok("backup failure message is actionable", boom instanceof Error && boom.message.includes("could not back up") && boom.message.includes("Your memories are safe"), boom instanceof Error ? boom.message.slice(0, 60) : "");
|
|
361
|
+
ok("failed backup keeps moved files safe in new root", fs.existsSync(path.join(newRoot, "memories", "user", "01HOTFIX2.md")));
|
|
362
|
+
ok("failed backup leaves legacy dir for retry", fs.existsSync(legacyRoot2));
|
|
363
|
+
delete process.env.MY_O_MEMORY_HOME;
|
|
364
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
365
|
+
|
|
317
366
|
console.log("== lifecycle pure: contentHash / similarity ==");
|
|
318
367
|
ok("hash deterministic + whitespace-insensitive", contentHash("hello world\n") === contentHash("hello world"));
|
|
319
368
|
ok("hash differs on content", contentHash("hello world") !== contentHash("hello mars"));
|
|
@@ -328,5 +377,74 @@ const near = similarity(
|
|
|
328
377
|
ok(`similarity near-dup ${near.toFixed(2)} >= ${NEAR_DUP_THRESHOLD}`, near >= NEAR_DUP_THRESHOLD);
|
|
329
378
|
ok("similarity empty → 0", similarity("", "anything") === 0);
|
|
330
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
|
+
// detectInstalledClients with a fully fake env.
|
|
426
|
+
const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "memex-bin-"));
|
|
427
|
+
fs.writeFileSync(path.join(binDir, "code"), "#!/bin/sh\n");
|
|
428
|
+
const detHome = fs.mkdtempSync(path.join(os.tmpdir(), "memex-home-"));
|
|
429
|
+
fs.mkdirSync(path.join(detHome, ".cursor"), { recursive: true });
|
|
430
|
+
fs.mkdirSync(path.join(detHome, ".config", "opencode"), { recursive: true });
|
|
431
|
+
const detRoot = fs.mkdtempSync(path.join(os.tmpdir(), "memex-root-"));
|
|
432
|
+
const detEnv = { pathEnv: binDir, home: detHome, platform: "linux" as const, root: detRoot, xdgConfigHome: path.join(detHome, ".config") };
|
|
433
|
+
const det = detectInstalledClients(detEnv);
|
|
434
|
+
ok("detects vscode via PATH", det.includes("vscode"));
|
|
435
|
+
ok("detects cursor via ~/.cursor", det.includes("cursor"));
|
|
436
|
+
ok("detects opencode via config dir", det.includes("opencode"));
|
|
437
|
+
ok("no visualstudio without .sln", !det.includes("visualstudio"));
|
|
438
|
+
fs.writeFileSync(path.join(detRoot, "app.sln"), "");
|
|
439
|
+
ok("visualstudio detected with .sln", detectInstalledClients(detEnv).includes("visualstudio"));
|
|
440
|
+
const emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), "memex-empty-"));
|
|
441
|
+
const detEmpty = detectInstalledClients({ pathEnv: "", home: emptyHome, platform: "linux", root: emptyHome, xdgConfigHome: path.join(emptyHome, ".config") });
|
|
442
|
+
ok("empty env detects nothing", detEmpty.length === 0);
|
|
443
|
+
const winBin = fs.mkdtempSync(path.join(os.tmpdir(), "memex-winbin-"));
|
|
444
|
+
fs.writeFileSync(path.join(winBin, "code.cmd"), "@echo off\n");
|
|
445
|
+
ok("win32 detects vscode via code.cmd",
|
|
446
|
+
detectInstalledClients({ pathEnv: winBin, home: emptyHome, platform: "win32", root: emptyHome, xdgConfigHome: path.join(emptyHome, ".config") }).includes("vscode"));
|
|
447
|
+
for (const d of [fakeHome, binDir, detHome, detRoot, emptyHome, winBin]) fs.rmSync(d, { recursive: true, force: true });
|
|
448
|
+
|
|
331
449
|
console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAILURES`);
|
|
332
450
|
process.exit(fails === 0 ? 0 : 1);
|
package/src/cli.ts
CHANGED
|
@@ -250,11 +250,15 @@ Usage: open-memex migrate [--from <key>] [--to <key>] [--dry-run] [--on-conflict
|
|
|
250
250
|
|
|
251
251
|
Flags:
|
|
252
252
|
--from / --to scope keys (default: current project → personal)
|
|
253
|
-
--dry-run preview without moving anything
|
|
253
|
+
--dry-run preview without moving anything (--to-v2 previews the legacy
|
|
254
|
+
files in place, including per-file conversion plans)
|
|
254
255
|
--on-conflict newer (default), overwrite, or skip
|
|
255
256
|
--to-v2 convert a legacy my-o-memory data dir to the v2 layout
|
|
256
257
|
|
|
257
258
|
Always preview with --dry-run first; nothing moves without confirmation.
|
|
259
|
+
On Windows, if another program holds the legacy folder open, the backup
|
|
260
|
+
rename fails with an actionable message instead of a stack trace — close
|
|
261
|
+
the program and re-run.
|
|
258
262
|
|
|
259
263
|
Examples:
|
|
260
264
|
open-memex migrate --dry-run
|
|
@@ -275,16 +279,21 @@ Examples:
|
|
|
275
279
|
agent memory instructions. Existing files are merged, never clobbered.
|
|
276
280
|
|
|
277
281
|
Usage: open-memex init [--client vscode|cursor|opencode|visualstudio]
|
|
278
|
-
[--instructions personal|project] [--force] [--yes]
|
|
282
|
+
[--instructions personal|project] [--global] [--force] [--yes]
|
|
279
283
|
|
|
280
284
|
Flags:
|
|
281
|
-
--client editor to configure (default: auto-detect)
|
|
285
|
+
--client editor to configure (default: auto-detect all installed editors)
|
|
282
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.
|
|
283
291
|
--force overwrite existing config
|
|
284
292
|
--yes accept all defaults, never prompt
|
|
285
293
|
|
|
286
294
|
Examples:
|
|
287
295
|
open-memex init
|
|
296
|
+
open-memex init --client vscode --global --yes
|
|
288
297
|
open-memex init --client cursor --yes`,
|
|
289
298
|
|
|
290
299
|
config: `Show config, or set a key.
|
|
@@ -349,16 +358,19 @@ Usage:
|
|
|
349
358
|
Every command has its own help with description and examples:
|
|
350
359
|
open-memex <command> --help (or -h)
|
|
351
360
|
|
|
352
|
-
One-command project setup: \`open-memex init\` (or \`npx open-memex@alpha init\`)
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
361
|
+
One-command project setup: \`open-memex init\` (or \`npx open-memex@alpha init\`) detects
|
|
362
|
+
your installed editors and wires them all — user-level where the editor supports it
|
|
363
|
+
(VS Code / Cursor MCP config, opencode native plugin), so one init covers every project;
|
|
364
|
+
Visual Studio is included when the project has a solution file (its \`.mcp.json\`
|
|
365
|
+
stays solution-level by design). \`--client\` picks a single editor instead, and a
|
|
366
|
+
single-editor opencode init writes the per-project plain-MCP \`opencode.jsonc\`.
|
|
367
|
+
The Copilot memory instructions default to your user-level
|
|
356
368
|
\`~/.copilot/copilot-instructions.md\` (all projects, never checked into a repo);
|
|
357
369
|
\`--instructions project\` writes \`.github/copilot-instructions.md\` instead for
|
|
358
370
|
teams where everyone uses open-memex.
|
|
359
371
|
Existing files are merged, never clobbered; re-running is safe. On a terminal it
|
|
360
|
-
|
|
361
|
-
injection); \`--yes\` accepts all defaults, and non-terminal runs never prompt.
|
|
372
|
+
confirms the detected editors and asks a couple of settings (keyword capture,
|
|
373
|
+
first-turn injection); \`--yes\` accepts all defaults, and non-terminal runs never prompt.
|
|
362
374
|
\`open-memex config set <key> <value>\` changes those settings after install.
|
|
363
375
|
|
|
364
376
|
Once installed globally (\`npm i -g open-memex@alpha\`) the \`open-memex\` command is
|
|
@@ -412,13 +424,20 @@ function parseFlags(argv: string[]): Record<string, string> {
|
|
|
412
424
|
for (let i = 0; i < argv.length; i++) {
|
|
413
425
|
const a = argv[i]!;
|
|
414
426
|
if (a.startsWith("--")) {
|
|
415
|
-
const
|
|
427
|
+
const raw = a.slice(2);
|
|
428
|
+
const eq = raw.indexOf("=");
|
|
429
|
+
if (eq >= 0) {
|
|
430
|
+
// D44: accept --key=value as well as --key value (issue #7 — the
|
|
431
|
+
// = form was silently misparsed before, dropping the flag).
|
|
432
|
+
out[raw.slice(0, eq)] = raw.slice(eq + 1);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
416
435
|
const val = argv[i + 1];
|
|
417
436
|
if (val !== undefined && !val.startsWith("--")) {
|
|
418
|
-
out[
|
|
437
|
+
out[raw] = val;
|
|
419
438
|
i++;
|
|
420
439
|
} else {
|
|
421
|
-
out[
|
|
440
|
+
out[raw] = "true";
|
|
422
441
|
}
|
|
423
442
|
}
|
|
424
443
|
}
|
|
@@ -548,7 +567,14 @@ async function main() {
|
|
|
548
567
|
if (cmd === "migrate" && rest.includes("--to-v2")) {
|
|
549
568
|
const flags = parseFlags(rest);
|
|
550
569
|
const dryRun = flags["dry-run"] === "true";
|
|
551
|
-
|
|
570
|
+
let stats;
|
|
571
|
+
try {
|
|
572
|
+
stats = migrateV2({ dryRun });
|
|
573
|
+
} catch (err) {
|
|
574
|
+
// D44: actionable message, not a raw syscall stack (issue #7).
|
|
575
|
+
console.error(`Error: ${(err as Error).message}`);
|
|
576
|
+
process.exit(1);
|
|
577
|
+
}
|
|
552
578
|
console.log(
|
|
553
579
|
`${dryRun ? "DRY RUN: " : ""}scanned ${stats.scanned} files: ` +
|
|
554
580
|
`${stats.converted} to convert, ${stats.skippedV2} already v2`,
|
|
@@ -562,7 +588,9 @@ async function main() {
|
|
|
562
588
|
}
|
|
563
589
|
if (stats.legacyBackup) {
|
|
564
590
|
console.log(
|
|
565
|
-
|
|
591
|
+
dryRun
|
|
592
|
+
? `\nDRY RUN: legacy my-o-memory data dir found — it would be merged and backed up at:\n ${stats.legacyBackup}`
|
|
593
|
+
: `\nlegacy my-o-memory data dir merged; backup kept at:\n ${stats.legacyBackup}`,
|
|
566
594
|
);
|
|
567
595
|
}
|
|
568
596
|
if (!dryRun && stats.converted > 0) {
|
|
@@ -575,7 +603,7 @@ async function main() {
|
|
|
575
603
|
}
|
|
576
604
|
|
|
577
605
|
// `init` is a pure file operation (§17 adoption path) — no DB needed.
|
|
578
|
-
// Interactive when on a TTY (
|
|
606
|
+
// Interactive when on a TTY (confirms detected editors + settings); --yes skips prompts.
|
|
579
607
|
if (cmd === "init") {
|
|
580
608
|
const flags = parseFlags(rest);
|
|
581
609
|
const { initProject } = await import("./init.ts");
|
|
@@ -584,6 +612,7 @@ async function main() {
|
|
|
584
612
|
force: flags["force"] === "true",
|
|
585
613
|
yes: flags["yes"] === "true",
|
|
586
614
|
instructions: flags["instructions"],
|
|
615
|
+
global: flags["global"] === "true",
|
|
587
616
|
});
|
|
588
617
|
return;
|
|
589
618
|
}
|