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/AGENTS.md +5 -0
- package/README.md +193 -21
- package/README.zh-CN.md +166 -17
- package/dist/cli.js +309 -23
- package/dist/config.js +2 -0
- package/dist/distill-agents.js +62 -0
- package/dist/export.js +157 -0
- package/dist/mcp.js +14 -0
- package/dist/providers/git.js +142 -0
- package/dist/store/v2migrate.js +83 -34
- package/docs/CURATOR.md +59 -0
- package/docs/TEST-PLAN.md +72 -0
- package/docs/V2-DESIGN.md +26 -2
- package/package.json +1 -1
- package/scripts/smoke-pure.ts +49 -1
- package/scripts/test-full.ts +345 -0
- package/src/cli.ts +321 -23
- package/src/config.ts +10 -0
- package/src/distill-agents.ts +85 -0
- package/src/export.ts +206 -0
- package/src/mcp.ts +16 -0
- package/src/providers/git.ts +191 -0
- package/src/store/sync.ts +1 -1
- package/src/store/v2migrate.ts +88 -34
package/scripts/smoke-pure.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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";
|
|
@@ -314,6 +314,54 @@ const plan2 = planConversion(v1path, memRoot);
|
|
|
314
314
|
ok("v2 on disk → null plan", plan2 === null);
|
|
315
315
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
316
316
|
|
|
317
|
+
console.log("== migrateV2 dry-run previews legacy files (issue #7) ==");
|
|
318
|
+
// legacy dir sits next to the new root (sibling), as on a real v1 upgrade
|
|
319
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "v2hotfix-"));
|
|
320
|
+
const newRoot = path.join(tmpHome, "open-memex");
|
|
321
|
+
const legacyRoot = path.join(tmpHome, "my-o-memory");
|
|
322
|
+
const legacyMem = path.join(legacyRoot, "memories", "user");
|
|
323
|
+
fs.mkdirSync(legacyMem, { recursive: true });
|
|
324
|
+
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`;
|
|
325
|
+
fs.writeFileSync(path.join(legacyMem, "01HOTFIX1.md"), v1legacy, "utf8");
|
|
326
|
+
process.env.MY_O_MEMORY_HOME = newRoot;
|
|
327
|
+
|
|
328
|
+
const dry = migrateV2({ dryRun: true });
|
|
329
|
+
ok("dry-run scans the legacy file", dry.scanned === 1 && dry.plans.length === 1, `scanned=${dry.scanned}`);
|
|
330
|
+
ok("dry-run yields a conversion plan", dry.plans.length === 1 && dry.plans[0].toPath.startsWith(newRoot), dry.plans[0]?.toPath);
|
|
331
|
+
ok("dry-run reports legacy backup target", typeof dry.legacyBackup === "string" && dry.legacyBackup.includes("backup-"), dry.legacyBackup);
|
|
332
|
+
ok("dry-run moves nothing", fs.existsSync(path.join(legacyMem, "01HOTFIX1.md")) && !fs.existsSync(path.join(newRoot, "memories", "personal", "01HOTFIX1.md")));
|
|
333
|
+
ok("dry-run does not rename legacy dir", fs.existsSync(legacyRoot) && !fs.existsSync(dry.legacyBackup!));
|
|
334
|
+
|
|
335
|
+
const real = migrateV2({ dryRun: false });
|
|
336
|
+
ok("real run converts", real.converted === 1, `converted=${real.converted}`);
|
|
337
|
+
ok("real run writes v2 file to new root", fs.existsSync(path.join(newRoot, "memories", "personal", "01HOTFIX1.md")));
|
|
338
|
+
ok("real run backs up legacy dir", !fs.existsSync(legacyRoot) && fs.existsSync(real.legacyBackup!));
|
|
339
|
+
const backContent = fs.readFileSync(path.join(newRoot, "memories", "personal", "01HOTFIX1.md"), "utf8");
|
|
340
|
+
ok("converted file has schema_version 2", backContent.includes("schema_version: 2"));
|
|
341
|
+
|
|
342
|
+
// backup rename failure → actionable Error, exit-1-worthy, no raw stack
|
|
343
|
+
// (same process/root: paths() is cached per process)
|
|
344
|
+
const legacyRoot2 = path.join(tmpHome, "my-o-memory");
|
|
345
|
+
const legacyMem2 = path.join(legacyRoot2, "memories", "user");
|
|
346
|
+
fs.mkdirSync(legacyMem2, { recursive: true });
|
|
347
|
+
fs.writeFileSync(path.join(legacyMem2, "01HOTFIX2.md"), v1legacy, "utf8");
|
|
348
|
+
// block the rename: pre-create today's dated backup as a non-empty dir
|
|
349
|
+
const blocked = path.join(tmpHome, "my-o-memory.backup-" + new Date().toISOString().slice(0, 10));
|
|
350
|
+
fs.mkdirSync(blocked, { recursive: true });
|
|
351
|
+
fs.writeFileSync(path.join(blocked, "sentinel"), "x", "utf8");
|
|
352
|
+
let boom: unknown = null;
|
|
353
|
+
try {
|
|
354
|
+
migrateV2({ dryRun: false });
|
|
355
|
+
} catch (e) {
|
|
356
|
+
boom = e;
|
|
357
|
+
}
|
|
358
|
+
ok("backup failure throws", boom instanceof Error, String(boom));
|
|
359
|
+
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) : "");
|
|
360
|
+
ok("failed backup keeps moved files safe in new root", fs.existsSync(path.join(newRoot, "memories", "user", "01HOTFIX2.md")));
|
|
361
|
+
ok("failed backup leaves legacy dir for retry", fs.existsSync(legacyRoot2));
|
|
362
|
+
delete process.env.MY_O_MEMORY_HOME;
|
|
363
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
364
|
+
|
|
317
365
|
console.log("== lifecycle pure: contentHash / similarity ==");
|
|
318
366
|
ok("hash deterministic + whitespace-insensitive", contentHash("hello world\n") === contentHash("hello world"));
|
|
319
367
|
ok("hash differs on content", contentHash("hello world") !== contentHash("hello mars"));
|
|
@@ -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);
|