open-memex 0.4.0-alpha.7 → 0.4.0

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.
@@ -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
+ }
@@ -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,13 @@ 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.*
861
870
  - **D41** — The type taxonomy is reconciled to 11 types with one-line definitions
862
871
  (§3.1): `fact` `preference` `decision` `constraint` `todo` `knowledge` `howto`
863
872
  `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.0",
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",
@@ -0,0 +1,345 @@
1
+ // Full feature test — exercises every CLI command and every MCP tool.
2
+ // Usage: node --experimental-strip-types scripts/test-full.ts
3
+ // Isolated: uses temp HOME + MY_O_MEMORY_HOME + temp git repos. Touches nothing real.
4
+ //
5
+ // NOTE on flakiness: better-sqlite3 11.x intermittently crashes at process
6
+ // exit on Node 24 (RemoveEnvironmentCleanupHook assertion, exit 134) — a
7
+ // known pre-existing issue. The work itself always completes; only the exit
8
+ // code/output flush is affected. Id-generating calls retry until an id is
9
+ // parsed, and crash-prone commands retry up to 5 times.
10
+ import { execFileSync, spawn } from "node:child_process";
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const REPO = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
17
+ const CLI = path.join(REPO, "dist", "cli.js");
18
+ const MCP_TS = path.join(REPO, "src", "mcp.ts");
19
+
20
+ const T = fs.mkdtempSync(path.join(os.tmpdir(), "om-full-"));
21
+ const TESTENV = {
22
+ ...process.env,
23
+ HOME: path.join(T, "home"),
24
+ MY_O_MEMORY_HOME: path.join(T, "data"),
25
+ };
26
+ fs.mkdirSync(TESTENV.HOME, { recursive: true });
27
+
28
+ let pass = 0, fail = 0;
29
+ const failures: string[] = [];
30
+ function ok(name: string, cond: boolean, info?: string) {
31
+ if (cond) { pass++; console.log(` ok ${name}`); }
32
+ else { fail++; failures.push(name); console.log(` FAIL ${name}${info ? " — " + info : ""}`); }
33
+ }
34
+ function isCrash(err: string): boolean {
35
+ return /RemoveEnvironmentCleanupHook|Assertion failed/.test(err);
36
+ }
37
+ // Base runner: transparently retries the known better-sqlite3 exit crash
38
+ // (work completes; only exit code/output flush is affected).
39
+ function cliRaw(args: string[], cwd: string, env: NodeJS.ProcessEnv): { out: string; err: string; code: number } {
40
+ let last = { out: "", err: "", code: 1 };
41
+ for (let i = 0; i < 5; i++) {
42
+ try {
43
+ const out = execFileSync("node", [CLI, ...args], { cwd, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
44
+ return { out, err: "", code: 0 };
45
+ } catch (e: any) {
46
+ last = { out: e.stdout ?? "", err: e.stderr ?? "", code: e.status ?? 1 };
47
+ if (!isCrash(last.err)) break; // real error, not the flaky crash
48
+ }
49
+ }
50
+ return last;
51
+ }
52
+ function cli(args: string[], cwd: string): { out: string; err: string; code: number } {
53
+ return cliRaw(args, cwd, TESTENV);
54
+ }
55
+ function git(args: string[], cwd: string) {
56
+ execFileSync("git", args, { cwd, env: TESTENV, stdio: "ignore" });
57
+ }
58
+ function mkproj(name: string): string {
59
+ const p = path.join(T, name);
60
+ fs.mkdirSync(p, { recursive: true });
61
+ git(["init", "-q"], p);
62
+ git(["config", "user.email", "test@example.com"], p);
63
+ git(["config", "user.name", "Test"], p);
64
+ fs.writeFileSync(path.join(p, "README.md"), "# test\n");
65
+ git(["add", "."], p);
66
+ git(["commit", "-qm", "init"], p);
67
+ return p;
68
+ }
69
+ const grabId = (out: string) => (out.match(/saved ([A-Z0-9]{26})/) || [])[1] ?? "";
70
+ const grabArrowId = (out: string) => (out.match(/→\s*([A-Z0-9]{26})/) || [])[1] ?? "";
71
+ function tarRead(tarfile: string, member: string): string {
72
+ try {
73
+ return execFileSync("tar", ["-xzOf", tarfile, member], { env: TESTENV, encoding: "utf8" });
74
+ } catch { return ""; }
75
+ }
76
+ function cliRetry(args: string[], cwd: string): { out: string; err: string; code: number } {
77
+ return cli(args, cwd); // retry is built into cliRaw
78
+ }
79
+ // add is the id factory — the known better-sqlite3 exit crash can eat the
80
+ // output, so retry until we actually get an id back.
81
+ function addMem(args: string[], cwd: string): string {
82
+ for (let i = 0; i < 5; i++) {
83
+ const id = grabId(cli(["add", ...args], cwd).out);
84
+ if (id) return id;
85
+ }
86
+ return "";
87
+ }
88
+ function proposeMem(id: string, cwd: string): string {
89
+ for (let i = 0; i < 5; i++) {
90
+ const d = grabArrowId(cli(["propose", id, "--to", "project"], cwd).out);
91
+ if (d) return d;
92
+ }
93
+ return "";
94
+ }
95
+ // Separate data dir = separate machine (the real export/import scenario).
96
+ const TESTENV2 = { ...TESTENV, MY_O_MEMORY_HOME: path.join(T, "data2") };
97
+ function cli2(args: string[], cwd: string): { out: string; err: string; code: number } {
98
+ return cliRaw(args, cwd, TESTENV2);
99
+ }
100
+
101
+ console.log("== setup ==");
102
+ const PROJ = mkproj("proj");
103
+ const REMOTE = path.join(T, "remote.git");
104
+ execFileSync("git", ["init", "-q", "--bare", REMOTE], { env: TESTENV });
105
+ git(["remote", "add", "origin", REMOTE], PROJ);
106
+ ok("proj + bare remote ready", fs.existsSync(path.join(PROJ, ".git")));
107
+
108
+ // ---------- 1. where / scopes / config ----------
109
+ console.log("== where / scopes / config ==");
110
+ let r = cli(["where"], PROJ);
111
+ ok("where shows project scope", /project__/.test(r.out), r.out.slice(0, 120));
112
+ r = cli(["scopes"], PROJ);
113
+ ok("scopes empty → graceful message", /no scopes with memories yet/.test(r.out), r.out.slice(0, 120));
114
+ r = cli(["config", "set", "sync.autoPull", "true"], PROJ);
115
+ r = cli(["config"], PROJ);
116
+ ok("config set/get dotted key", /autoPull/.test(r.out) && /true/.test(r.out), r.out.slice(0, 200));
117
+ cli(["config", "set", "sync.autoPull", "false"], PROJ);
118
+
119
+ // ---------- 2. add / list / search ----------
120
+ console.log("== add / list / search ==");
121
+ const idFact = addMem(["fulltest fact about deploys", "--type", "fact", "--tag", "t1"], PROJ);
122
+ const idDecision = addMem(["fulltest decision to use ff merges", "--type", "decision", "--tag", "t2"], PROJ);
123
+ const idPersonal = addMem(["fulltest personal pref", "--scope", "personal"], PROJ);
124
+ ok("add returns ids", !!idFact && !!idDecision && !!idPersonal);
125
+ r = cli(["list"], PROJ);
126
+ ok("list default = project only", r.out.includes(idFact) && r.out.includes(idDecision) && !r.out.includes(idPersonal));
127
+ r = cli(["list", "--scope", "personal"], PROJ);
128
+ ok("list --scope personal", r.out.includes(idPersonal) && !r.out.includes(idFact));
129
+ r = cli(["scopes"], PROJ);
130
+ ok("scopes lists populated scopes", /personal/.test(r.out) && /project__/.test(r.out), r.out.slice(0, 160));
131
+ r = cli(["list", "--type", "decision"], PROJ);
132
+ ok("list --type filter", r.out.includes(idDecision) && !r.out.includes(idFact));
133
+ r = cli(["search", "ff merges"], PROJ);
134
+ ok("search finds decision", r.out.includes(idDecision), r.out.slice(0, 150));
135
+
136
+ // ---------- 3. supersede / status / forget ----------
137
+ console.log("== supersede / status / forget ==");
138
+ r = cli(["supersede", idFact, "fulltest fact about deploys v2"], PROJ);
139
+ const idV2 = grabArrowId(r.out);
140
+ ok("supersede creates new version", !!idV2 && idV2 !== idFact, r.out.slice(0, 150));
141
+ const idDep = addMem(["fulltest to deprecate", "--type", "fact"], PROJ);
142
+ r = cli(["status", idDep, "deprecated"], PROJ);
143
+ ok("status deprecated", r.code === 0, (r.err || r.out).slice(0, 150));
144
+ r = cli(["status", idFact, "deprecated"], PROJ);
145
+ ok("status on superseded chain-member is refused", r.code !== 0 && /chain-managed|supersede/i.test(r.err + r.out), (r.err || r.out).slice(0, 120));
146
+ r = cli(["forget", idDecision], PROJ);
147
+ ok("forget deletes", r.code === 0 && /delet/i.test(r.out), r.out.slice(0, 120));
148
+ r = cli(["list"], PROJ);
149
+ ok("forgotten id gone from list", !r.out.includes(idDecision));
150
+
151
+ // ---------- 4. propose / submit / promote / sync-status ----------
152
+ console.log("== review workflow: propose / submit / promote ==");
153
+ const idProp = addMem(["fulltest proposal candidate", "--scope", "personal"], PROJ);
154
+ const idDraft = proposeMem(idProp, PROJ);
155
+ ok("propose stages outbox draft", !!idDraft);
156
+ r = cli(["sync-status"], PROJ);
157
+ ok("sync-status shows outbox draft", /outbox/.test(r.out) && r.out.includes(idDraft));
158
+ r = cli(["submit", idDraft], PROJ);
159
+ ok("submit commits locally", r.code === 0, (r.err || r.out).slice(0, 150));
160
+ const log = execFileSync("git", ["log", "--oneline", "-1"], { cwd: PROJ, env: TESTENV, encoding: "utf8" });
161
+ ok("submit created a git commit", /submit|mem/i.test(log), log.trim());
162
+ r = cli(["promote", idDraft, "--note", "fulltest approval"], PROJ);
163
+ ok("promote → approved", r.code === 0, (r.err || r.out).slice(0, 150));
164
+ r = cli(["promote", idDraft, "--note", "fulltest publish"], PROJ);
165
+ ok("promote → published", r.code === 0, (r.err || r.out).slice(0, 150));
166
+ const idRej = addMem(["fulltest reject candidate", "--scope", "personal"], PROJ);
167
+ const idRejDraft = proposeMem(idRej, PROJ);
168
+ r = cliRetry(["submit", idRejDraft], PROJ);
169
+ ok("reject-path submit", r.code === 0, (r.err || r.out).slice(0, 120));
170
+ r = cli(["promote", idRejDraft, "--reject", "--note", "fulltest rejection"], PROJ);
171
+ ok("promote --reject with note", r.code === 0, (r.err || r.out).slice(0, 150));
172
+
173
+ // ---------- 5. export / import ----------
174
+ console.log("== export / import ==");
175
+ const expFile = path.join(T, "bundle.tar.gz");
176
+ r = cliRetry(["export", "-o", expFile], PROJ);
177
+ ok("export creates bundle", r.code === 0 && fs.existsSync(expFile), (r.err || r.out).slice(0, 120));
178
+ const man = JSON.parse(tarRead(expFile, "manifest.json") || "{}");
179
+ ok("manifest format valid", man.format === "open-memex-export/1" && Array.isArray(man.memories) && man.memories.length > 0, JSON.stringify(man).slice(0, 120));
180
+ r = cliRetry(["export", "--scope", "personal", "--all", "-o", path.join(T, "p.tar.gz")], PROJ);
181
+ const manP = JSON.parse(tarRead(path.join(T, "p.tar.gz"), "manifest.json") || "{}");
182
+ ok("export --all includes private", manP.include_private === true && manP.memories.length >= 2);
183
+ const PROJ2 = mkproj("proj2");
184
+ function cli2Retry(args: string[], cwd: string): { out: string; err: string; code: number } {
185
+ return cli2(args, cwd); // retry is built into cliRaw
186
+ }
187
+ r = cli2Retry(["import", expFile, "--dry-run"], PROJ2);
188
+ ok("import --dry-run", /DRY RUN/.test(r.out), r.out.slice(0, 120));
189
+ r = cli2Retry(["import", expFile], PROJ2);
190
+ ok("import real run", /imported [1-9]/.test(r.out), r.out.slice(0, 120));
191
+ r = cli2(["list"], PROJ2);
192
+ ok("imported memories visible in target project", /fulltest/.test(r.out), r.out.slice(0, 160));
193
+ r = cli2Retry(["import", expFile], PROJ2);
194
+ ok("import identical → skipped", /skipped [1-9]+ identical/.test(r.out), r.out.slice(0, 120));
195
+ // conflict: craft bundle with same id, different content
196
+ const cid = man.memories[0].id;
197
+ const cstage = path.join(T, "cstage");
198
+ fs.mkdirSync(path.join(cstage, "memories", "project"), { recursive: true });
199
+ fs.writeFileSync(path.join(cstage, "memories", "project", cid + ".md"),
200
+ `---\nid: ${cid}\nscope: project\nscope_key: project__old__x\nvisibility: internal\ntype: fact\nstatus: active\ncreated_at: 2026-01-01T00:00:00Z\nupdated_at: 2026-01-01T00:00:00Z\n---\n\nCONFLICT CONTENT\n`);
201
+ fs.writeFileSync(path.join(cstage, "manifest.json"), JSON.stringify({ format: "open-memex-export/1", exported_at: "2026-01-01T00:00:00Z", open_memex_version: "t", include_private: false, filters: {}, memories: [{ id: cid, scope: "project", file: `memories/project/${cid}.md` }] }));
202
+ const cfile = path.join(T, "conflict.tar.gz");
203
+ execFileSync("tar", ["-czf", cfile, "-C", cstage, "manifest.json", "memories"], { env: TESTENV });
204
+ r = cli2Retry(["import", cfile], PROJ2);
205
+ ok("import conflict reported, never overwritten", /conflict/.test(r.out) && /imported 0/.test(r.out), r.out.slice(0, 160));
206
+
207
+ // ---------- 6. distill-agents ----------
208
+ console.log("== distill-agents ==");
209
+ addMem(["fulltest distill decision: always fast-forward", "--type", "decision"], PROJ);
210
+ addMem(["fulltest distill gotcha: sqlite crashes on exit", "--type", "gotcha"], PROJ);
211
+ r = cliRetry(["distill-agents"], PROJ);
212
+ ok("distill-agents proposes snippet", /## Learned/.test(r.out), r.out.slice(0, 120));
213
+ r = cliRetry(["distill-agents", "--type", "decision", "-o", path.join(T, "snip.md")], PROJ);
214
+ ok("distill-agents -o writes file", fs.existsSync(path.join(T, "snip.md")));
215
+
216
+ // ---------- 7. pull / push ----------
217
+ console.log("== pull / push ==");
218
+ r = cliRetry(["push"], PROJ);
219
+ ok("push explicit", r.code === 0, (r.err || r.out).slice(0, 150));
220
+ const PROJ3 = path.join(T, "proj3");
221
+ execFileSync("git", ["clone", "-q", REMOTE, PROJ3], { env: TESTENV });
222
+ git(["config", "user.email", "test@example.com"], PROJ3);
223
+ git(["config", "user.name", "Test"], PROJ3);
224
+ const idRemote = addMem(["fulltest remote memory", "--type", "fact"], PROJ3);
225
+ cliRetry(["submit", idRemote], PROJ3);
226
+ cliRetry(["push"], PROJ3);
227
+ r = cliRetry(["pull"], PROJ);
228
+ ok("pull fast-forward", r.code === 0 && /up.to.date|fast-forward|pulled/i.test(r.out + r.err), (r.out + r.err).slice(0, 150));
229
+ // diverged: commit on both sides
230
+ fs.writeFileSync(path.join(PROJ, "div1.txt"), "a");
231
+ git(["add", "."], PROJ); git(["commit", "-qm", "div1"], PROJ);
232
+ fs.writeFileSync(path.join(PROJ3, "div2.txt"), "b");
233
+ git(["add", "."], PROJ3); git(["commit", "-qm", "div2"], PROJ3);
234
+ cli(["push"], PROJ3);
235
+ r = cli(["pull"], PROJ);
236
+ ok("pull diverged → clear failure", r.code !== 0 && /diverg|behind|ahead/i.test(r.out + r.err), (r.out + r.err).slice(0, 160));
237
+
238
+ // ---------- 8. migrate / reindex ----------
239
+ console.log("== migrate / reindex ==");
240
+ r = cli(["migrate", "--dry-run"], PROJ);
241
+ ok("migrate bare → graceful no-op message", /no --from given/.test(r.out + r.err), (r.out + r.err).slice(0, 150));
242
+ const idMig = addMem(["fulltest migrate me", "--scope", "personal"], PROJ);
243
+ r = cli(["migrate", "--from", "personal", "--to", "project", "--dry-run"], PROJ);
244
+ ok("migrate personal→project dry-run", r.code === 0, (r.err || r.out).slice(0, 150));
245
+ const dbPath = path.join(TESTENV.MY_O_MEMORY_HOME as string, "index.db");
246
+ fs.rmSync(dbPath);
247
+ const beforeReindex = Date.now();
248
+ cli(["reindex"], PROJ); // exit code unreliable (known sqlite exit crash); verify by artifact
249
+ let dbOk = false;
250
+ try { dbOk = fs.existsSync(dbPath) && fs.statSync(dbPath).mtimeMs >= beforeReindex - 1000; } catch { /* no */ }
251
+ ok("reindex rebuilds", dbOk, `db exists: ${fs.existsSync(dbPath)}`);
252
+ r = cli(["list", "--scope", "personal"], PROJ);
253
+ ok("list works after reindex", r.out.includes(idMig) || r.out.includes(idPersonal));
254
+
255
+ // ---------- 9. capture / doctor / mcp --print-config / init ----------
256
+ console.log("== capture / doctor / mcp / init ==");
257
+ r = cli(["capture", "--dry-run", "remember: we deploy on Fridays and use ff merges"], PROJ);
258
+ ok("capture --dry-run", r.code === 0 && /deploy|friday/i.test(r.out), r.out.slice(0, 150));
259
+ for (const c of ["vscode", "cursor", "claude", "opencode", "visualstudio"]) {
260
+ r = cli(["mcp", "--print-config", c], PROJ);
261
+ ok(`mcp --print-config ${c}`, r.code === 0 && r.out.trim().length > 10, r.err.slice(0, 100));
262
+ }
263
+ r = cli(["init", "--client", "vscode", "--yes"], PROJ);
264
+ ok("init --client vscode --yes", r.code === 0 && fs.existsSync(path.join(PROJ, ".vscode", "mcp.json")), (r.err || r.out).slice(0, 150));
265
+ r = cliRetry(["doctor"], PROJ);
266
+ ok("doctor", r.code === 0 && /All checks passed/.test(r.out), (r.err || r.out).slice(0, 200));
267
+
268
+ // ---------- 10. MCP: all 11 tools + session-start instructions ----------
269
+ console.log("== MCP tools ==");
270
+ {
271
+ const child = spawn("node", ["--experimental-strip-types", MCP_TS], {
272
+ cwd: PROJ, env: TESTENV, stdio: ["pipe", "pipe", "pipe"],
273
+ });
274
+ let buf = "";
275
+ let id = 0;
276
+ const pending = new Map<number, (v: any) => void>();
277
+ child.stdout.on("data", (d: Buffer) => {
278
+ buf += d.toString();
279
+ let idx: number;
280
+ while ((idx = buf.indexOf("\n")) >= 0) {
281
+ const line = buf.slice(0, idx).trim();
282
+ buf = buf.slice(idx + 1);
283
+ if (!line) continue;
284
+ try {
285
+ const msg = JSON.parse(line);
286
+ if (msg.id !== undefined && pending.has(msg.id)) { pending.get(msg.id)!(msg); pending.delete(msg.id); }
287
+ } catch { /* ignore */ }
288
+ }
289
+ });
290
+ const req = (method: string, params: any) => new Promise<any>((resolve) => {
291
+ const i = ++id;
292
+ pending.set(i, resolve);
293
+ child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: i, method, params }) + "\n");
294
+ setTimeout(() => { if (pending.has(i)) { pending.delete(i); resolve({ __timeout: true }); } }, 30000);
295
+ });
296
+ const call = async (tool: string, args: any) => {
297
+ const resp = await req("tools/call", { name: tool, arguments: args });
298
+ const text = (resp.result?.content || []).map((c: any) => c.text || "").join("\n");
299
+ return { resp, text };
300
+ };
301
+
302
+ const init = await req("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "t", version: "1" } });
303
+ const instructions: string = init.result?.instructions || "";
304
+ ok("MCP initialize returns instructions", instructions.length > 200, instructions.slice(0, 100));
305
+ ok("instructions mention sync/memory_status", /memory_status|sync/i.test(instructions));
306
+ await req("notifications/initialized", {});
307
+ const tools = await req("tools/list", {});
308
+ const names: string[] = (tools.result?.tools || []).map((t: any) => t.name);
309
+ const expected = ["memory_add","memory_forget","memory_list","memory_pr_status","memory_promote","memory_propose","memory_resolve","memory_search","memory_status","memory_submit","memory_supersede"];
310
+ ok("11 MCP tools listed", expected.every((n) => names.includes(n)), names.join(","));
311
+
312
+ let m = await call("memory_add", { content: "mcptest fact via MCP", type: "fact" });
313
+ const mid = (m.text.match(/([A-Z0-9]{26})/) || [])[1] || "";
314
+ ok("memory_add", !!mid, m.text.slice(0, 120));
315
+ m = await call("memory_search", { query: "mcptest fact" });
316
+ ok("memory_search", m.text.includes(mid), m.text.slice(0, 120));
317
+ m = await call("memory_list", { limit: 5 });
318
+ ok("memory_list", m.text.includes(mid) || /fact/.test(m.text), m.text.slice(0, 120));
319
+ m = await call("memory_status", {});
320
+ ok("memory_status", /project|outbox|sync/i.test(m.text), m.text.slice(0, 120));
321
+ m = await call("memory_supersede", { id: mid, content: "mcptest fact via MCP v2" });
322
+ const mid2 = (m.text.match(/with ([A-Z0-9]{26})/) || [])[1] || "";
323
+ ok("memory_supersede", !!mid2 && mid2 !== mid, m.text.slice(0, 120));
324
+ // propose → submit → promote via MCP
325
+ const pid = ((await call("memory_add", { content: "mcptest proposal", scope: "personal" })).text.match(/([A-Z0-9]{26})/) || [])[1] || "";
326
+ m = await call("memory_propose", { ids: [pid] });
327
+ const pdraft = (m.text.match(/→\s*([A-Z0-9]{26})/) || m.text.match(/([A-Z0-9]{26})/) || [])[1] || "";
328
+ ok("memory_propose", !!pdraft && pdraft !== pid, m.text.slice(0, 120));
329
+ m = await call("memory_submit", { ids: [pdraft] });
330
+ ok("memory_submit", !/error/i.test(m.text) || /commit|submit/i.test(m.text), m.text.slice(0, 120));
331
+ m = await call("memory_promote", { id: pdraft, note: "mcptest approve" });
332
+ ok("memory_promote", !/__timeout/.test(JSON.stringify(m.resp)), m.text.slice(0, 120));
333
+ m = await call("memory_resolve", {});
334
+ ok("memory_resolve (list)", !/__timeout/.test(JSON.stringify(m.resp)), m.text.slice(0, 120));
335
+ m = await call("memory_pr_status", {});
336
+ ok("memory_pr_status (report)", !/__timeout/.test(JSON.stringify(m.resp)), m.text.slice(0, 120));
337
+ m = await call("memory_forget", { id: mid2 });
338
+ ok("memory_forget", /delet|forget/i.test(m.text), m.text.slice(0, 120));
339
+ child.kill();
340
+ }
341
+
342
+ console.log("\n================ SUMMARY ================");
343
+ console.log(`pass: ${pass}, fail: ${fail}`);
344
+ if (failures.length) { console.log("failed tests:"); for (const f of failures) console.log(" - " + f); }
345
+ process.exit(fail ? 1 : 0);