open-memex 0.4.0-alpha.7 → 0.4.1

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/dist/export.js ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Export / import: portable memory archives (§9, D40).
3
+ *
4
+ * `export` bundles selected memories (markdown source of truth + manifest)
5
+ * into a single .tar.gz for moving to another machine or another app.
6
+ * `import` restores a bundle: personal memories go to the personal dir,
7
+ * project memories are re-keyed to the current project and land in the
8
+ * outbox as drafts (submit moves them into the repo).
9
+ *
10
+ * D40: export excludes `visibility: private` by default; `--all` / `-a`
11
+ * includes everything — the full-migration escape hatch.
12
+ */
13
+ import fs from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { execFileSync } from "node:child_process";
17
+ import { db } from "./store/db.js";
18
+ import { readMemoryFile, writeMemoryFile, normalizeFrontmatter, } from "./store/markdown.js";
19
+ import { upsertFromFile } from "./store/sync.js";
20
+ import { contentHash } from "./store/lifecycle.js";
21
+ const EXPORT_FORMAT = "open-memex-export/1";
22
+ function fail(msg) {
23
+ throw new Error(`[open-memex] ${msg}`);
24
+ }
25
+ function checkTar() {
26
+ try {
27
+ execFileSync("tar", ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
28
+ }
29
+ catch {
30
+ fail("the `tar` command is required for export/import but was not found on PATH");
31
+ }
32
+ }
33
+ export function exportMemories(opts) {
34
+ checkTar();
35
+ const includePrivate = opts.includePrivate ?? false;
36
+ let sql = `SELECT id, scope_key, scope, visibility, type, file_path FROM memories WHERE scope_key IN (${opts.scopeKeys.map(() => "?").join(",")})`;
37
+ const params = [...opts.scopeKeys];
38
+ if (!includePrivate)
39
+ sql += ` AND visibility != 'private'`;
40
+ if (opts.type) {
41
+ sql += ` AND type = ?`;
42
+ params.push(opts.type);
43
+ }
44
+ if (opts.tag) {
45
+ sql += ` AND (',' || tags || ',' LIKE ?)`;
46
+ params.push(`%,${opts.tag},%`);
47
+ }
48
+ sql += ` ORDER BY updated_at DESC`;
49
+ const rows = db().prepare(sql).all(...params);
50
+ const skippedPrivate = includePrivate
51
+ ? 0
52
+ : db().prepare(`SELECT COUNT(*) AS n FROM memories WHERE scope_key IN (${opts.scopeKeys.map(() => "?").join(",")}) AND visibility = 'private'`).get(...opts.scopeKeys).n;
53
+ const stage = fs.mkdtempSync(path.join(os.tmpdir(), "open-memex-export-"));
54
+ try {
55
+ const memDir = path.join(stage, "memories");
56
+ fs.mkdirSync(memDir, { recursive: true });
57
+ const manifestEntries = [];
58
+ let exported = 0;
59
+ for (const r of rows) {
60
+ const mf = readMemoryFile(r.file_path);
61
+ if (!mf)
62
+ continue; // stale index row — skip, don't fail the export
63
+ const rel = path.join(r.scope, `${r.id}.md`);
64
+ const dest = path.join(memDir, rel);
65
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
66
+ fs.copyFileSync(r.file_path, dest);
67
+ manifestEntries.push({ id: r.id, scope: r.scope, file: path.join("memories", rel) });
68
+ exported++;
69
+ }
70
+ const manifest = {
71
+ format: EXPORT_FORMAT,
72
+ exported_at: new Date().toISOString(),
73
+ open_memex_version: readPackageVersion(),
74
+ include_private: includePrivate,
75
+ filters: {
76
+ scope_keys: opts.scopeKeys,
77
+ type: opts.type ?? null,
78
+ tag: opts.tag ?? null,
79
+ },
80
+ memories: manifestEntries,
81
+ };
82
+ fs.writeFileSync(path.join(stage, "manifest.json"), JSON.stringify(manifest, null, 2), "utf8");
83
+ const stamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15);
84
+ const outFile = opts.outFile ?? path.resolve(`open-memex-export-${stamp}.tar.gz`);
85
+ execFileSync("tar", ["-czf", outFile, "-C", stage, "manifest.json", "memories"], {
86
+ stdio: ["ignore", "pipe", "pipe"],
87
+ });
88
+ return { file: outFile, exported, skippedPrivate, includePrivate };
89
+ }
90
+ finally {
91
+ fs.rmSync(stage, { recursive: true, force: true });
92
+ }
93
+ }
94
+ function readPackageVersion() {
95
+ try {
96
+ const here = new URL(import.meta.url);
97
+ const pkg = path.join(path.dirname(here.pathname), "..", "package.json");
98
+ return JSON.parse(fs.readFileSync(pkg, "utf8")).version;
99
+ }
100
+ catch {
101
+ return "unknown";
102
+ }
103
+ }
104
+ export function importBundle(bundlePath, opts) {
105
+ checkTar();
106
+ if (!fs.existsSync(bundlePath))
107
+ fail(`bundle not found: ${bundlePath}`);
108
+ const stage = fs.mkdtempSync(path.join(os.tmpdir(), "open-memex-import-"));
109
+ try {
110
+ execFileSync("tar", ["-xzf", path.resolve(bundlePath), "-C", stage], {
111
+ stdio: ["ignore", "pipe", "pipe"],
112
+ });
113
+ const manifestPath = path.join(stage, "manifest.json");
114
+ if (!fs.existsSync(manifestPath))
115
+ fail("not an open-memex export bundle (manifest.json missing)");
116
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
117
+ if (manifest.format !== EXPORT_FORMAT) {
118
+ fail(`unsupported bundle format: ${manifest.format} (expected ${EXPORT_FORMAT})`);
119
+ }
120
+ const result = { imported: 0, skippedIdentical: 0, skippedConflict: [] };
121
+ const existingStmt = db().prepare(`SELECT content_hash FROM memories WHERE id = ?`);
122
+ for (const entry of manifest.memories ?? []) {
123
+ const src = path.join(stage, entry.file);
124
+ const mf = readMemoryFile(src);
125
+ if (!mf)
126
+ continue;
127
+ const fm = normalizeFrontmatter({ ...mf.fm });
128
+ // Re-key to this machine: personal stays personal; project memories
129
+ // adopt the current project's scope key (keys embed a path hash).
130
+ if (fm.scope === "project")
131
+ fm.scope_key = opts.projectScopeKey;
132
+ else if (fm.scope === "personal")
133
+ fm.scope_key = "personal";
134
+ const existing = existingStmt.get(fm.id);
135
+ if (existing) {
136
+ if (existing.content_hash === contentHash(mf.body))
137
+ result.skippedIdentical++;
138
+ else
139
+ result.skippedConflict.push({ id: fm.id, file: entry.file });
140
+ continue;
141
+ }
142
+ if (opts.dryRun) {
143
+ result.imported++;
144
+ continue;
145
+ }
146
+ const { filePath } = writeMemoryFile(fm, mf.body);
147
+ const written = readMemoryFile(filePath);
148
+ if (written)
149
+ upsertFromFile(written);
150
+ result.imported++;
151
+ }
152
+ return result;
153
+ }
154
+ finally {
155
+ fs.rmSync(stage, { recursive: true, force: true });
156
+ }
157
+ }
package/dist/mcp.js CHANGED
@@ -104,6 +104,20 @@ export async function runMcpServer() {
104
104
  syncScope(scope.key, "session");
105
105
  syncScope(PERSONAL_SCOPE.key, "session");
106
106
  console.error(`[open-memex] MCP server up. scope=${scope.key}`);
107
+ // D12: pulls are explicit by default — session start never touches the
108
+ // network. With sync.autoPull, one best-effort pull; a failure never
109
+ // blocks the session, it just logs and continues.
110
+ if (cfg.sync?.autoPull) {
111
+ try {
112
+ const { GitProvider } = await import("./providers/git.js");
113
+ const r = new GitProvider().pull(process.cwd());
114
+ syncScope(scope.key, "pull");
115
+ console.error(`[open-memex] auto-pull: ${r.branch} ${r.fastForwarded ? "fast-forwarded" : "already up to date"}`);
116
+ }
117
+ catch (e) {
118
+ console.error(`[open-memex] auto-pull skipped: ${e.message}`);
119
+ }
120
+ }
107
121
  // D26: re-sync on every request, not just at startup. The in-repo dir
108
122
  // follows the current git branch, so a branch switch mid-session would
109
123
  // otherwise leave the index pointing at files that no longer exist.
@@ -0,0 +1,142 @@
1
+ /**
2
+ * GitProvider — the git transport for repo-synced shared scopes (§9, D12).
3
+ *
4
+ * Git is a transport, not the product boundary: this provider moves the
5
+ * in-repo memory dir (`.ai/open-memex/`) between the local checkout and the
6
+ * remote. It never touches the appdata outbox, never force-pushes, never
7
+ * auto-merges a divergence — those are human decisions.
8
+ *
9
+ * Hard rules (from §9 / D12 / D36):
10
+ * - pull is explicit (`open-memex pull`); pull = fetch + fast-forward only.
11
+ * - push is explicit (`open-memex push`); the tool never pushes on its own.
12
+ * - a failed pull/push fails with a clear message and leaves no broken state.
13
+ */
14
+ import { execFileSync } from "node:child_process";
15
+ function fail(msg) {
16
+ throw new Error(`[open-memex] ${msg}`);
17
+ }
18
+ /** Run git, raising a readable error. `timeoutMs` guards network calls. */
19
+ function git(root, args, timeoutMs = 0) {
20
+ try {
21
+ return execFileSync("git", args, {
22
+ cwd: root,
23
+ encoding: "utf8",
24
+ stdio: ["ignore", "pipe", "pipe"],
25
+ ...(timeoutMs > 0 ? { timeout: timeoutMs } : {}),
26
+ }).trim();
27
+ }
28
+ catch (e) {
29
+ const err = e;
30
+ if (err.code === "ETIMEDOUT")
31
+ fail(`git ${args.join(" ")} timed out — remote unreachable?`);
32
+ const detail = (err.stderr ?? err.message ?? "").trim().split("\n")[0];
33
+ fail(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
34
+ }
35
+ }
36
+ const FETCH_TIMEOUT_MS = 30_000;
37
+ export class GitProvider {
38
+ name = "git";
39
+ capabilities = {
40
+ read: true,
41
+ write: true,
42
+ delete: false,
43
+ history: true,
44
+ sync: "bidirectional",
45
+ };
46
+ /** Current branch + upstream, or a clear failure when git can't answer. */
47
+ status(root) {
48
+ git(root, ["rev-parse", "--git-dir"]);
49
+ const branch = git(root, ["branch", "--show-current"]) ||
50
+ git(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
51
+ let upstream = null;
52
+ try {
53
+ upstream = git(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
54
+ }
55
+ catch {
56
+ upstream = null;
57
+ }
58
+ let ahead = 0;
59
+ let behind = 0;
60
+ if (upstream) {
61
+ const counts = git(root, ["rev-list", "--left-right", "--count", `HEAD...${upstream}`]);
62
+ const [a, b] = counts.split(/\s+/).map((n) => parseInt(n, 10));
63
+ ahead = Number.isFinite(a) ? a : 0;
64
+ behind = Number.isFinite(b) ? b : 0;
65
+ }
66
+ const remote = upstream ? upstream.split("/")[0] : null;
67
+ return { branch, upstream, remote, ahead, behind };
68
+ }
69
+ /**
70
+ * Explicit pull: fetch + fast-forward only (§9).
71
+ * Diverged branches are NOT merged — the user resolves them by hand.
72
+ */
73
+ pull(root) {
74
+ const st = this.status(root);
75
+ if (!st.upstream || !st.remote) {
76
+ fail(`branch ${st.branch} has no upstream — set one with ` +
77
+ `\`git push -u <remote> ${st.branch}\`, then pull again`);
78
+ }
79
+ git(root, ["fetch", st.remote], FETCH_TIMEOUT_MS);
80
+ const before = git(root, ["rev-parse", "HEAD"]);
81
+ const remoteSha = git(root, ["rev-parse", st.upstream]);
82
+ if (before === remoteSha) {
83
+ return {
84
+ at: new Date().toISOString(),
85
+ branch: st.branch,
86
+ remote: st.remote,
87
+ before,
88
+ after: before,
89
+ fastForwarded: false,
90
+ };
91
+ }
92
+ // Fast-forward is possible iff HEAD is an ancestor of the upstream.
93
+ let ffPossible = false;
94
+ try {
95
+ git(root, ["merge-base", "--is-ancestor", "HEAD", st.upstream]);
96
+ ffPossible = true;
97
+ }
98
+ catch {
99
+ ffPossible = false;
100
+ }
101
+ if (!ffPossible) {
102
+ fail(`branch ${st.branch} has diverged from ${st.upstream} — ` +
103
+ `open-memex never force-merges; resolve it by hand ` +
104
+ `(rebase or merge), then pull again`);
105
+ }
106
+ git(root, ["merge", "--ff-only", st.upstream]);
107
+ const after = git(root, ["rev-parse", "HEAD"]);
108
+ return {
109
+ at: new Date().toISOString(),
110
+ branch: st.branch,
111
+ remote: st.remote,
112
+ before,
113
+ after,
114
+ fastForwarded: true,
115
+ };
116
+ }
117
+ /** Explicit push of the current branch. Never called automatically. */
118
+ push(root) {
119
+ const st = this.status(root);
120
+ if (!st.remote) {
121
+ // No upstream yet: push explicitly sets it (-u), still user-invoked.
122
+ const remotes = git(root, ["remote"]);
123
+ const remote = remotes.split("\n").map((r) => r.trim()).filter(Boolean)[0];
124
+ if (!remote)
125
+ fail("no git remote configured — add one before pushing");
126
+ git(root, ["push", "-u", remote, st.branch], FETCH_TIMEOUT_MS);
127
+ return {
128
+ at: new Date().toISOString(),
129
+ branch: st.branch,
130
+ remote,
131
+ head: git(root, ["rev-parse", "HEAD"]),
132
+ };
133
+ }
134
+ git(root, ["push", st.remote, st.branch], FETCH_TIMEOUT_MS);
135
+ return {
136
+ at: new Date().toISOString(),
137
+ branch: st.branch,
138
+ remote: st.remote,
139
+ head: git(root, ["rev-parse", "HEAD"]),
140
+ };
141
+ }
142
+ }
@@ -83,23 +83,45 @@ export function planConversion(filePath, memoriesRoot) {
83
83
  return { fm, body, fromPath: filePath, toPath, changes };
84
84
  }
85
85
  /**
86
- * Merge a legacy `my-o-memory` data root into the current `open-memex` root.
87
- * The old dir is renamed to a dated backup — never deleted. Returns the
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 migrateLegacyDataRoot(dryRun) {
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
- const backup = `${legacy}.backup-${stamp}`;
97
- if (!dryRun) {
98
- const legacyMem = path.join(legacy, "memories");
99
- if (fs.existsSync(legacyMem)) {
100
- for (const entry of fs.readdirSync(legacyMem)) {
101
- const src = path.join(legacyMem, entry);
102
- const dst = path.join(root, "memories", entry);
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
- return backup;
146
+ catch (err) {
147
+ fail(`back up the legacy data dir`, err);
148
+ }
120
149
  }
121
- export function migrateV2(opts) {
122
- const { memories: memoriesRoot } = paths();
123
- const result = {
124
- scanned: 0,
125
- converted: 0,
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 dir = path.join(memoriesRoot, entry.name);
137
- for (const name of fs.readdirSync(dir)) {
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(dir, name);
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
  }
@@ -0,0 +1,59 @@
1
+ # Curator Convention
2
+
3
+ The **curator** is the human who tends a project's shared memory. It is a
4
+ documented convention, not a permission system — anyone on the team can act
5
+ as curator; the tool records *who* did *what* (the audit trail), it doesn't
6
+ decide who is *allowed* to.
7
+
8
+ ## What the curator does
9
+
10
+ 1. **Triage proposals.** `open-memex propose` puts memories up for review.
11
+ The curator reads them, then `open-memex promote <id>` to approve or
12
+ `open-memex promote <id> --reject` to send back, with `--note` saying why.
13
+ 2. **Resolve conflicts.** `open-memex resolve` lists file-level and semantic
14
+ conflicts. The curator merges or picks a winner — Core never silently
15
+ resolves a semantic conflict; both sides stay `active` until a human
16
+ decides.
17
+ 3. **Keep the garden.** Deprecate what's stale (`open-memex status <id>
18
+ deprecated`), supersede what's been replaced, forget what's noise.
19
+ Shared memory rots without pruning.
20
+ 4. **Watch the pipeline.** `open-memex sync-status` shows the outbox, review
21
+ states, and uncommitted files; `open-memex pr-status --apply` maps the
22
+ GitHub PR state back onto `review_state`.
23
+
24
+ ## Admission bar
25
+
26
+ Approve a memory when it is:
27
+
28
+ - **True** — you believe it, or it cites something verifiable.
29
+ - **Durable** — it will still matter in a month. Chat logs are not memories.
30
+ - **Scoped right** — project knowledge in project scope; personal stuff stays
31
+ personal (personal memories are never the curator's business).
32
+ - **Well-typed** — `type` says what it IS (`decision`, `gotcha`, `lesson`…),
33
+ `tags` say what it's ABOUT.
34
+
35
+ Send back (don't silently fix) when it's vague, duplicated, or belongs in
36
+ `docs/` as formal documentation instead — memory is the fast-changing long
37
+ tail, `docs/` is the slow-changing core.
38
+
39
+ ## What the curator does NOT do
40
+
41
+ - **Never rewrite someone else's memory in place.** Propose a superseding
42
+ memory instead — the chain (`supersedes` / `superseded_by`) is the audit
43
+ trail.
44
+ - **Never approve their own proposals silently in team mode.** That's what
45
+ `--local-approve` is for — solo projects only.
46
+ - **Never pull rank with the tool.** If the team disagrees with a call, the
47
+ disagreement itself is worth a memory.
48
+
49
+ ## Cadence
50
+
51
+ There is no required cadence. A workable default: triage proposals at the
52
+ end of each work chunk (the same checkpoint where §3.5 distillation runs),
53
+ and do a pruning pass when `sync-status` starts feeling noisy.
54
+
55
+ ## Solo mode
56
+
57
+ No team, no curator needed. `open-memex propose --local-approve` records
58
+ `approved_by: self` and skips the PR. You are the curator, the proposer,
59
+ and the gardener — the same hygiene rules apply, just faster.
@@ -0,0 +1,72 @@
1
+ # OpenMemex 测试计划(v0.4.0-alpha.10)
2
+
3
+ > 自动化部分:`node --experimental-strip-types scripts/test-full.ts`
4
+ > 62 项全过(26 个 CLI 命令 + 11 个 MCP tool),隔离环境运行,不碰真实数据。
5
+ > 下面是机器/账号相关的部分,需要 Stone 在真机上过一遍。
6
+
7
+ ## A. Windows 真机 + VS Code Copilot
8
+
9
+ - [ ] `npm i -g open-memex@alpha` 全局安装,`open-memex --version` 显示正确版本
10
+ - [ ] 在一个真实项目目录跑 `open-memex init`(不加 `--yes`,走一遍交互)
11
+ - 确认 `.vscode/mcp.json` 生成,`~/.copilot/copilot-instructions.md` 合并写入(不覆盖已有内容)
12
+ - [ ] 重启 VS Code,Copilot Chat 里问 "what do you remember about this project?"
13
+ - 预期:MCP 连接成功,能调用 memory_search
14
+ - [ ] `open-memex add "windows 真机测试" --type fact`,再让 Copilot 搜出来
15
+ - [ ] 中文路径项目、中文记忆内容各试一条(CJK 索引)
16
+
17
+ ## B. 真实 GitHub PR 全流程(review 工作流)
18
+
19
+ 在一个真实 repo 里:
20
+
21
+ - [ ] `open-memex add "PR流程测试" --scope personal` → `propose --to project` → `sync-status` 看到 outbox draft
22
+ - [ ] `open-memex submit <id>`(留在当前分支,本地 commit)
23
+ - [ ] 手动 `git push` + 开 PR
24
+ - [ ] 在 PR 里点 Approve → 回来跑 `open-memex pr-status`(先看 report),再 `pr-status --apply`
25
+ - 预期:memory 变成 approved,`approved_by` 是 reviewer
26
+ - [ ] 找一条让 reviewer 点 "Request changes" → `pr-status --apply`
27
+ - 预期:只给 suggestion,**不**自动 reject(D32)
28
+ - [ ] Merge PR → `pr-status --apply`
29
+ - 预期:memory 变成 published
30
+ - [ ] `open-memex resolve` 无冲突时输出 "(no conflicted memory files)"
31
+
32
+ ## C. 其他编辑器 MCP 集成
33
+
34
+ - [ ] Cursor:`open-memex mcp --print-config cursor` → 贴到 Cursor MCP 配置 → 能连上
35
+ - [ ] opencode:`open-memex init --client opencode` → `opencode.jsonc` 生效
36
+ - [ ] Claude Code:`open-memex mcp --print-config claude` 给出的 `claude mcp add` 命令能跑通
37
+
38
+ ## D. 跨机迁移(export/import 真实场景)
39
+
40
+ - [ ] 本机:`open-memex export --all -o migration.tar.gz`(含 private 的全量)
41
+ - [ ] 本机:`open-memex export -o share.tar.gz`(默认排除 private)→ 解包检查 manifest,确认没有 visibility:private 的条目
42
+ - [ ] 另一台机器:`open-memex import migration.tar.gz --dry-run` 先看预览,再正式 import
43
+ - 预期:project memory re-key 到新机器的 project scope,进 outbox 当 draft;personal 进 personal
44
+ - [ ] 同一个 bundle 导两次 → 第二次 "skipped N identical"
45
+
46
+ ## E. Agent 会话行为(D42 / §3.5)
47
+
48
+ - [ ] 新开一个 agent 会话(MCP 已接),看 initialize 返回的 instructions 里有没有 session-start 同步指引
49
+ - [ ] 对 agent 说 "sync memory" / "同步记忆"
50
+ - 预期:agent 走 memory_status → 摘要 → 问你要同步哪条(而不是直接翻 appdata)
51
+ - [ ] 长对话中 agent 是否在检查点提议蒸馏(§3.5),提议后是否等你批准才保存(D42)
52
+
53
+ ## F. 冲突解决(3-way merge)
54
+
55
+ - [ ] 两台机器(或两个 clone)同时改同一条 project memory,各自 submit + push,一边 pull 制造 diverged
56
+ - 预期:`open-memex pull` 明确报错退出,不自动 merge
57
+ - [ ] 手动 merge 后 `open-memex resolve <id>` 看 3-way 展示(base/outbox/repo),手动解决
58
+
59
+ ## G. 同事 pilot(1–2 人,Stone 私下选)
60
+
61
+ - [ ] 对方 `npx open-memex@alpha init` 走通
62
+ - [ ] 对方能 propose → 你这边能看到 PR → promote 流程走通
63
+ - [ ] 收集反馈:哪里卡、哪里不符合直觉
64
+
65
+ ## H. 已知问题观察
66
+
67
+ - [ ] better-sqlite3 在 Node 24 退出时偶发 crash(exit 134):注意是否丢数据(预期:不丢,只影响退出码)
68
+ - [ ] `memory_list` 默认只列 project scope 是否符合预期(Stone 已定保持现状)
69
+
70
+ ---
71
+
72
+ 测试中发现的 bug 直接记到 GitHub issue;改完后更新本文档的复选框。
package/docs/V2-DESIGN.md CHANGED
@@ -534,8 +534,10 @@ requirement: personal data never touches third-party services). Benchmarks to tr
534
534
  | atlaso-labs/codex | Codex marketplace | long-term memory plugin for Codex (hooks + MCP + cloud-sync upsell) | **direct comparable** for a future Codex plugin; their cloud upsell vs our local-first |
535
535
 
536
536
  (Star counts / funding as of Sep 2026 — re-verify before quoting publicly.)
537
- - **Phase 3 — Org layer.** Org memory repo · curator convention · `examples/remote-server/` ·
538
- distill-to-AGENTS.md assist · export/import archive command for user portability.
537
+ - **Phase 3 — Org layer.** Org memory repo · `examples/remote-server/` ·
538
+ curator convention ✅ `docs/CURATOR.md` (2026-09-29, pulled forward) ·
539
+ distill-to-AGENTS.md assist ✅ `open-memex distill-agents` (2026-09-29, pulled forward) ·
540
+ export/import archive command ✅ `open-memex export` / `import` (2026-09-29, pulled forward, D40).
539
541
  - **Phase 4 — Future, signal-gated.** Cloud `RemoteProvider` customization only on: multi-private-repo
540
542
  sharing needs, fine-grained ACL, audit/compliance mandates · optional API-backed exporters/providers
541
543
  for enterprise knowledge systems.
@@ -858,6 +860,28 @@ requirement: personal data never touches third-party services). Benchmarks to tr
858
860
  saved via `memory_add` with the new optional `source` param set to
859
861
  `"inference"` (default `"tool"`). *Rationale: closes the 0.4.0 TODO from D41 —
860
862
  the design's capture loop now reaches the agent. Implemented 2026-09-29.*
863
+ - **D43** — `distill-agents` output gains a "Memory hygiene (open-memex)"
864
+ footer carrying the §3.5 checkpoint guidance (propose 1–3 distilled captures
865
+ at checkpoints; save nothing without approval; prefer `reference` over
866
+ copying). *Rationale: double insurance for opencode users, who never see the
867
+ MCP handshake instructions or the `init`-written instruction files — but
868
+ opencode reads AGENTS.md natively, so the distilled snippet teaches the
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.*
861
885
  - **D41** — The type taxonomy is reconciled to 11 types with one-line definitions
862
886
  (§3.1): `fact` `preference` `decision` `constraint` `todo` `knowledge` `howto`
863
887
  `gotcha` `lesson` `observation` `reference`. Merged away: `warning`→`gotcha`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-memex",
3
- "version": "0.4.0-alpha.7",
3
+ "version": "0.4.1",
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",