open-memex 0.1.0 → 0.3.0-alpha

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,135 @@
1
+ // MCP server smoke test: handshake + tools/list + add/search/forget over stdio.
2
+ // Usage: node --experimental-strip-types scripts/smoke-mcp.ts
3
+ // Uses temp dirs for MY_O_MEMORY_HOME and the project cwd — no real data touched.
4
+ import { spawn } from "node:child_process";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-home-"));
11
+ const PROJ = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-proj-"));
12
+
13
+ const MCP_TS = fileURLToPath(new URL("../src/mcp.ts", import.meta.url));
14
+
15
+ const child = spawn(
16
+ "node",
17
+ ["--experimental-strip-types", MCP_TS],
18
+ {
19
+ cwd: PROJ,
20
+ env: { ...process.env, MY_O_MEMORY_HOME: HOME },
21
+ stdio: ["pipe", "pipe", "pipe"],
22
+ },
23
+ );
24
+
25
+ let buf = "";
26
+ let id = 0;
27
+ const pending = new Map<number, (v: any) => void>();
28
+ const stdoutLines: string[] = [];
29
+
30
+ child.stdout.on("data", (d) => {
31
+ buf += d.toString();
32
+ let idx;
33
+ while ((idx = buf.indexOf("\n")) >= 0) {
34
+ const line = buf.slice(0, idx).trim();
35
+ buf = buf.slice(idx + 1);
36
+ if (!line) continue;
37
+ stdoutLines.push(line);
38
+ let msg;
39
+ try {
40
+ msg = JSON.parse(line);
41
+ } catch {
42
+ console.error("NON-JSON on stdout:", line);
43
+ process.exitCode = 1;
44
+ continue;
45
+ }
46
+ if (msg.id !== undefined && pending.has(msg.id)) {
47
+ pending.get(msg.id)!(msg);
48
+ pending.delete(msg.id);
49
+ }
50
+ }
51
+ });
52
+ child.stderr.on("data", (d) => process.stderr.write("[srv:err] " + d.toString()));
53
+
54
+ function req(method: string, params?: any): Promise<any> {
55
+ const myId = ++id;
56
+ return new Promise((resolve) => {
57
+ pending.set(myId, resolve);
58
+ child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: myId, method, params }) + "\n");
59
+ });
60
+ }
61
+ function notify(method: string, params?: any) {
62
+ child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
63
+ }
64
+
65
+ const results: string[] = [];
66
+ function check(name: string, ok: boolean, detail = "") {
67
+ results.push(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
68
+ if (!ok) process.exitCode = 1;
69
+ }
70
+
71
+ try {
72
+ const init = await req("initialize", {
73
+ protocolVersion: "2024-11-05",
74
+ capabilities: {},
75
+ clientInfo: { name: "mcp-e2e", version: "0.0.1" },
76
+ });
77
+ check("initialize", !!init.result?.serverInfo, JSON.stringify(init.result?.serverInfo));
78
+ notify("notifications/initialized");
79
+
80
+ const tools = await req("tools/list", {});
81
+ const names = (tools.result?.tools ?? []).map((t: any) => t.name).sort();
82
+ check(
83
+ "tools/list has 5 memory tools",
84
+ JSON.stringify(names) ===
85
+ JSON.stringify(["memory_add", "memory_forget", "memory_list", "memory_search", "memory_supersede"]),
86
+ names.join(","),
87
+ );
88
+ const addSchema = tools.result.tools.find((t: any) => t.name === "memory_add").inputSchema;
89
+ check("memory_add schema has content+type+scope+tags", !!addSchema.properties?.content && !!addSchema.properties?.type, Object.keys(addSchema.properties ?? {}).join(","));
90
+
91
+ // D14 through MCP: a fake secret must be masked, not refused.
92
+ const add = await req("tools/call", {
93
+ name: "memory_add",
94
+ arguments: { content: "mcp e2e probe: deploy key sk-test-FAKESECRET1234567890abcdef", type: "fact" },
95
+ });
96
+ const addText: string = add.result?.content?.[0]?.text ?? "";
97
+ const savedId = (addText.match(/id=([A-Za-z0-9_]+)/) ?? [])[1];
98
+ check("memory_add saves (not refuses)", !add.result?.isError && !!savedId, addText.slice(0, 80));
99
+ check("memory_add notes masking (D14)", addText.includes("masked"), addText.slice(0, 120));
100
+
101
+ // The raw secret must not be on disk.
102
+ const mdFiles: string[] = [];
103
+ const walk = (d: string) => {
104
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
105
+ const p = path.join(d, e.name);
106
+ if (e.isDirectory()) walk(p);
107
+ else if (e.name.endsWith(".md")) mdFiles.push(p);
108
+ }
109
+ };
110
+ walk(HOME);
111
+ const leaked = mdFiles.filter((f) => fs.readFileSync(f, "utf8").includes("sk-test-FAKESECRET1234567890abcdef"));
112
+ check("secret not leaked to disk", leaked.length === 0, leaked.join(","));
113
+
114
+ const search = await req("tools/call", {
115
+ name: "memory_search",
116
+ arguments: { query: "mcp e2e probe" },
117
+ });
118
+ const searchText: string = search.result?.content?.[0]?.text ?? "";
119
+ check("memory_search finds it", searchText.includes(savedId), searchText.slice(0, 80));
120
+ check("memory_search output masked", !searchText.includes("sk-test-FAKESECRET1234567890abcdef"));
121
+
122
+ const list = await req("tools/call", { name: "memory_list", arguments: {} });
123
+ check("memory_list works", (list.result?.content?.[0]?.text ?? "").includes(savedId));
124
+
125
+ const forget = await req("tools/call", { name: "memory_forget", arguments: { id: savedId } });
126
+ check("memory_forget works", (forget.result?.content?.[0]?.text ?? "").includes("Deleted"));
127
+
128
+ const search2 = await req("tools/call", { name: "memory_search", arguments: { query: "mcp e2e probe" } });
129
+ check("memory gone after forget", !(search2.result?.content?.[0]?.text ?? "").includes(savedId));
130
+ } finally {
131
+ child.kill();
132
+ }
133
+
134
+ console.log(results.join("\n"));
135
+ console.log(process.exitCode ? "MCP E2E: FAILURES" : "MCP E2E: ALL PASS");
@@ -1,10 +1,13 @@
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
- import { parse, serialize, ulid, type Frontmatter } from "../src/store/markdown.ts";
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
5
  import { redact, findSecret } from "../src/redact.ts";
5
6
  import { detectKeywords } from "../src/capture/keywords.ts";
6
7
  import { DEFAULT_CONFIG } from "../src/config.ts";
7
- import { resolveProjectScope, resolveCwdScope, USER_SCOPE } from "../src/scope.ts";
8
+ import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE } from "../src/scope.ts";
9
+ import { cjkIndexText, cjkQueryExpr, hasCjk } from "../src/retrieve/cjk.ts";
10
+ import { contentHash, similarity, NEAR_DUP_THRESHOLD } from "../src/store/lifecycle.ts";
8
11
 
9
12
  let fails = 0;
10
13
  function ok(name: string, cond: boolean, info?: unknown) {
@@ -20,19 +23,28 @@ const a = ulid();
20
23
  const b = ulid();
21
24
  ok("length is 26", a.length === 26);
22
25
  ok("two calls differ", a !== b);
23
- ok("sortable", a < b || a === b);
26
+ // NOTE: same-ms monotonicity is NOT guaranteed — the 16-char suffix is
27
+ // random by design (timestamp prefix still gives rough ordering).
28
+ ok("time prefix is 10 chars", a.slice(0, 10).length === 10);
24
29
 
25
- console.log("== markdown round-trip ==");
30
+ console.log("== markdown round-trip (v2) ==");
26
31
  const fm: Frontmatter = {
27
32
  id: a,
33
+ schema_version: 2,
28
34
  scope_key: "project__test__abcdef123456",
29
- scope_kind: "project",
35
+ scope: "project",
36
+ visibility: "internal",
30
37
  project_name: "test",
31
- type: "note",
38
+ type: "fact",
39
+ role: "knowledge",
40
+ importance: "normal",
41
+ status: "active",
32
42
  tags: ["hello", "world"],
33
43
  source: "test",
34
- created_at: 1000,
35
- updated_at: 2000,
44
+ created_at: msToRfc3339(1000),
45
+ updated_at: msToRfc3339(2000),
46
+ supersedes: null,
47
+ superseded_by: null,
36
48
  };
37
49
  const body = "hello\n\nworld";
38
50
  const raw = serialize(fm, body);
@@ -54,6 +66,123 @@ ok("bare secret detected", r2.hadSecret === true);
54
66
  const r3 = redact("ghp_" + "a".repeat(36), DEFAULT_CONFIG.redactPatterns);
55
67
  ok("github PAT detected", r3.hadSecret === true);
56
68
 
69
+ console.log("== redact hardening ==");
70
+ import {
71
+ BUILTIN_SECRET_PATTERNS,
72
+ findBuiltinSecret,
73
+ findHighEntropySecret,
74
+ stripPrivate,
75
+ } from "../src/redact.ts";
76
+
77
+ // Every builtin pattern fires on a realistic sample, reported by id.
78
+ const builtinSamples: Array<[string, string]> = [
79
+ ["openai-key", "key=sk-1234567890abcdefghij1234"],
80
+ ["github-oauth-token", "tok=gho_12345678901234567890123456789012"],
81
+ ["github-fine-grained-pat", "tok=github_pat_1234567890123456789012345678901234567890"],
82
+ ["aws-access-key-id", "id=AKIA1234567890ABCDEF"],
83
+ ["slack-token", "tok=xoxb-123456789012-abcdefghij"],
84
+ ["google-api-key", "k=AIza12345678901234567890123456789012"],
85
+ ["npm-token", "t=npm_12345678901234567890123456789012"],
86
+ ["gitlab-pat", "t=glpat-12345678901234567890ab"],
87
+ ["stripe-webhook-secret", "s=whsec_12345678901234567890"],
88
+ ["private-key-block", "-----BEGIN RSA PRIVATE KEY-----\nQUJD\n-----END RSA PRIVATE KEY-----"],
89
+ ["private-key-truncated", "-----BEGIN RSA PRIVATE KEY-----"],
90
+ ["generic-secret-assignment", 'password = "hunter2hunter2hunter2"'],
91
+ ];
92
+ for (const [id, sample] of builtinSamples) {
93
+ const r = redact(`note: ${sample} end`, []);
94
+ ok(`builtin ${id} detected`, r.hadSecret === true && r.matchedPattern === id, r.matchedPattern);
95
+ }
96
+ // All builtin regexes compile.
97
+ ok(
98
+ "all builtins compile",
99
+ BUILTIN_SECRET_PATTERNS.every((p) => {
100
+ try {
101
+ new RegExp(p.source, p.flags ?? "");
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }),
107
+ );
108
+ // Non-secrets pass.
109
+ const benign = [
110
+ "remember that the meeting is at 3pm tomorrow",
111
+ "the api endpoint is https://api.example.com/v1/users/list",
112
+ "commit = a3f5c81234abcd1234abcd1234abcd1234abcd12",
113
+ "version = 1.2.3-alpha.4",
114
+ "note = aaaaaaaaaaaaaaaaaaaaaaaa",
115
+ "token: abc",
116
+ // sk- inside ordinary words must not trip the openai-key pattern
117
+ // (regression: these were refused as "openai-key" before the boundary guard).
118
+ "the task-management-system-workflow-2024 document is here",
119
+ "disk-encryption-key-management-system-v2 rollout notes",
120
+ "risk-assessment-score-2024-10 update",
121
+ ];
122
+ for (const b of benign) {
123
+ const r = redact(b, []);
124
+ ok(`benign passes: ${b.slice(0, 40)}`, r.hadSecret === false, r.matchedPattern);
125
+ }
126
+ // High-entropy heuristic.
127
+ const entropic = findHighEntropySecret('deploy_key = "aB3dE5fG7hJ9kL2mN4pQ6rS8tU0vW2xY4zA6bC8dE0"');
128
+ ok("high-entropy assignment caught", entropic === "high-entropy-secret:deploy_key", entropic);
129
+ ok("low-entropy passes", findHighEntropySecret('note = "aaaaaaaaaaaaaaaaaaaaaaaa"') === null);
130
+ ok("hex digest passes", findHighEntropySecret("commit = a3f5c81234abcd1234abcd1234abcd1234abcd12") === null);
131
+ // <private> handling.
132
+ ok(
133
+ "unclosed private redacts to end",
134
+ stripPrivate("hello <private>my secret") === "hello [REDACTED]",
135
+ );
136
+ ok(
137
+ "closed private still stripped",
138
+ stripPrivate("a <private>x</private> b") === "a [REDACTED] b",
139
+ );
140
+ const r4 = redact("token is <private>sk-1234567890abcdefghij1234</private> ok", []);
141
+ ok("wrapped secret does not trigger", r4.hadSecret === false);
142
+ // User patterns still work, reported verbatim.
143
+ const r5 = redact("foo CUSTOM123 bar", ["CUSTOM\\d+"]);
144
+ ok("user pattern detected", r5.hadSecret === true && r5.matchedPattern === "CUSTOM\\d+", r5.matchedPattern);
145
+
146
+ console.log("== redact masking (D14) ==");
147
+ // Secrets are masked in place (first 4 chars kept, rest → x), never refused.
148
+ const m1 = redact("deploy uses key=sk-1234567890abcdefghij1234 in prod", []);
149
+ ok("mask: detected", m1.hadSecret === true && m1.matchedPattern === "openai-key", m1.matchedPattern);
150
+ ok("mask: prefix kept", m1.content.includes("sk-1"), m1.content);
151
+ ok("mask: rest x'd", m1.content.includes("sk-1xxxxxxxxxxxxxxxxxxxxx"), m1.content);
152
+ ok("mask: full secret gone", !m1.content.includes("1234567890abcdefghij"));
153
+ ok("mask: surrounding text kept", m1.content.startsWith("deploy uses key=") && m1.content.includes(" in prod"));
154
+ // User patterns mask too.
155
+ ok("mask: user pattern", redact("foo CUSTOM123 bar", ["CUSTOM\\d+"]).content.includes("CUSTxxxxx"));
156
+ // High-entropy: value masked, name kept.
157
+ const r6 = redact('deploy_key = "aB3dE5fG7hJ9kL2mN4pQ6rS8tU0vW2xY4zA6bC8dE0"', []);
158
+ ok("mask: entropy value masked", r6.content.includes("aB3dxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"), r6.content);
159
+ ok("mask: entropy name kept", r6.content.includes("deploy_key ="));
160
+ // URLs are never masked, even when another secret is present in the same text.
161
+ const r7 = redact("see https://api.example.com/v1/users/list and key=sk-1234567890abcdefghij1234", []);
162
+ ok("mask: mixed url+secret detected", r7.hadSecret === true, r7.matchedPattern);
163
+ ok("mask: url intact", r7.content.includes("https://api.example.com/v1/users/list"), r7.content);
164
+ ok("mask: second secret also masked", !r7.content.includes("sk-1234567890"), r7.content);
165
+ // Bare high-entropy URL: the scheme colon must not turn the URL into a secret.
166
+ const r8 = redact("see https://aB3dEfGhIjKlMnOpQrStUvWx0123456789abcdef for details", []);
167
+ ok("mask: bare url untouched", r8.hadSecret === false && r8.content.includes("https://aB3dEfGhIjKlMnOpQrStUvWx0123456789abcdef"), r8.content);
168
+ // Private key: the WHOLE block is masked, body must not survive in readable form.
169
+ const pemBody = "MIIEpAIBAAKCAQEA7bXprGBcW2l5K3R8vN0mQw0F3xY2vBn5T6uI7oP8a9S0dF1gH";
170
+ const r9 = redact(`note: server key\n-----BEGIN RSA PRIVATE KEY-----\n${pemBody}\n-----END RSA PRIVATE KEY-----\nafter`, []);
171
+ ok("mask: pem detected", r9.hadSecret === true && r9.matchedPattern === "private-key-block", r9.matchedPattern);
172
+ ok("mask: pem body gone", !r9.content.includes(pemBody), r9.content);
173
+ ok("mask: pem surrounding kept", r9.content.includes("note: server key") && r9.content.includes("after"));
174
+ // Truncated key (no END marker): masked to end of text.
175
+ const r10 = redact(`note\n-----BEGIN RSA PRIVATE KEY-----\n${pemBody}\ntrailing prose`, []);
176
+ ok("mask: truncated pem detected", r10.hadSecret === true, r10.matchedPattern);
177
+ ok("mask: truncated pem body gone", !r10.content.includes(pemBody) && !r10.content.includes("trailing prose"), r10.content);
178
+ // Name-including patterns mask the value only; the name stays readable (D14).
179
+ const r11 = redact('api_key = "supersecretvalue123456"', []);
180
+ ok("mask: generic name kept", r11.content.includes("api_key ="), r11.content);
181
+ ok("mask: generic value masked", !r11.content.includes("supersecretvalue123456") && r11.content.includes("supexxxxxxxxxxxxxxxx"), r11.content);
182
+ const r12 = redact('aws_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCY1234567890"', []);
183
+ ok("mask: aws name kept", r12.content.includes("aws_secret_access_key ="), r12.content);
184
+ ok("mask: aws value masked", !r12.content.includes("wJalrXUtnFEMI"), r12.content);
185
+
57
186
  console.log("== keywords ==");
58
187
  const hits1 = detectKeywords("remember that this repo uses Bun, not Node", DEFAULT_CONFIG);
59
188
  ok("basic remember matched", hits1.length === 1);
@@ -74,7 +203,9 @@ ok("kind=project", s.kind === "project");
74
203
  ok("key starts with project__", s.key.startsWith("project__"));
75
204
  ok("hash length 12", /__[a-f0-9]{12}$/.test(s.key));
76
205
  console.log(" scope.key =", s.key);
77
- console.log(" user.key =", USER_SCOPE.key);
206
+ console.log(" personal.key =", PERSONAL_SCOPE.key);
207
+ ok("personal scope key", PERSONAL_SCOPE.key === "personal");
208
+ ok("personal scope kind", PERSONAL_SCOPE.kind === "personal");
78
209
 
79
210
  console.log("== scope (cwd-only legacy) ==");
80
211
  const cwdOnly = resolveCwdScope(process.cwd());
@@ -87,5 +218,115 @@ ok("cwd scope deterministic", cwdOnly.key === cwdOnly2.key);
87
218
  // (This repo does have an origin after `git push`.)
88
219
  console.log(" cwd.key =", cwdOnly.key);
89
220
 
221
+ console.log("== cjk ==");
222
+ ok("detects han", hasCjk("中文记忆"));
223
+ ok("detects mixed", hasCjk("open中文"));
224
+ ok("no cjk in latin", !hasCjk("hello world 123"));
225
+ ok(
226
+ "index bigrams",
227
+ cjkIndexText("中文记忆") === "中 文 记 忆 中文 文记 记忆",
228
+ cjkIndexText("中文记忆"),
229
+ );
230
+ ok("index single char", cjkIndexText("猫") === "猫", cjkIndexText("猫"));
231
+ ok(
232
+ "index skips latin",
233
+ cjkIndexText("open中文") === "中 文 中文",
234
+ cjkIndexText("open中文"),
235
+ );
236
+ ok("index empty", cjkIndexText("hello") === "");
237
+ ok(
238
+ "query bigrams",
239
+ cjkQueryExpr("中文记忆") === '"中文" OR "文记" OR "记忆"',
240
+ cjkQueryExpr("中文记忆"),
241
+ );
242
+ ok("query single char", cjkQueryExpr("猫") === '"猫"', cjkQueryExpr("猫"));
243
+ ok("query no cjk", cjkQueryExpr("hello") === "");
244
+
245
+ console.log("== v2 times ==");
246
+ ok("epoch 0 → rfc3339", msToRfc3339(0) === "1970-01-01T00:00:00Z", msToRfc3339(0));
247
+ ok("rfc3339 round-trip", timeToMs(msToRfc3339(1758854400000)) === 1758854400000);
248
+ ok("timeToMs accepts epoch", timeToMs(1000) === 1000);
249
+ ok("timeToMs accepts rfc3339", timeToMs("2026-09-26T12:00:00Z") === Date.parse("2026-09-26T12:00:00Z"));
250
+
251
+ console.log("== v1 → v2 normalize ==");
252
+ const v1raw = {
253
+ id: "01TESTV1",
254
+ scope_key: "user",
255
+ scope_kind: "user",
256
+ project_name: "user",
257
+ type: "instruction",
258
+ priority: 9,
259
+ tags: ["x"],
260
+ source: "test",
261
+ created_at: 1758854400000,
262
+ updated_at: 1758854400000,
263
+ } as Record<string, unknown>;
264
+ const nfm = normalizeFrontmatter(v1raw);
265
+ ok("scope user→personal", nfm.scope === "personal", nfm.scope);
266
+ ok("scope_key user→personal", nfm.scope_key === "personal", nfm.scope_key);
267
+ ok("visibility private", nfm.visibility === "private", nfm.visibility);
268
+ ok("type instruction→knowledge", nfm.type === "knowledge", nfm.type);
269
+ ok("role →instruction", nfm.role === "instruction", nfm.role);
270
+ ok("priority 9→high", nfm.importance === "high", nfm.importance);
271
+ ok("epoch→rfc3339", nfm.created_at === "2025-09-26T02:40:00Z", nfm.created_at);
272
+ ok("status active", nfm.status === "active");
273
+ ok("schema_version 2", nfm.schema_version === 2);
274
+
275
+ const v1raw2 = {
276
+ id: "01TESTV2",
277
+ scope_key: "project__x__abcdef123456",
278
+ scope_kind: "project",
279
+ project_name: "x",
280
+ type: "error-solution",
281
+ priority: 2,
282
+ tags: [],
283
+ created_at: 1000,
284
+ updated_at: 1000,
285
+ } as Record<string, unknown>;
286
+ const nfm2 = normalizeFrontmatter(v1raw2);
287
+ ok("project scope kept", nfm2.scope === "project");
288
+ ok("project visibility internal", nfm2.visibility === "internal");
289
+ ok("priority 2→low", nfm2.importance === "low");
290
+ ok("role default knowledge", nfm2.role === "knowledge");
291
+
292
+ console.log("== v1 → v2 planConversion ==");
293
+ import fs from "node:fs";
294
+ import path from "node:path";
295
+ import os from "node:os";
296
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "v2mig-"));
297
+ const memRoot = path.join(tmpRoot, "memories");
298
+ fs.mkdirSync(path.join(memRoot, "user"), { recursive: true });
299
+ const v1file = `---\nid: 01PLANTEST\nscope_key: user\nscope_kind: user\nproject_name: user\ntype: note\npriority: 5\ntags: []\nsource: test\ncreated_at: 1758854400000\nupdated_at: 1758854400000\n---\n\n记得买菜\n`;
300
+ const v1path = path.join(memRoot, "user", "01PLANTEST.md");
301
+ fs.writeFileSync(v1path, v1file, "utf8");
302
+ ok("v1 not v2 file", !isV2File(v1file));
303
+ const plan = planConversion(v1path, memRoot);
304
+ ok("plan exists", plan !== null);
305
+ ok("plan moves to personal/", plan?.toPath === path.join(memRoot, "personal", "01PLANTEST.md"), plan?.toPath);
306
+ ok("plan notes unknown type", plan?.changes.some((c) => c.includes('"note" → knowledge')) ?? false, plan?.changes);
307
+ ok("plan notes scope rename", plan?.changes.some((c) => c.includes("personal")) ?? false);
308
+ ok("plan fm scope personal", plan?.fm.scope === "personal");
309
+ const v2raw = serialize(plan!.fm, plan!.body);
310
+ ok("v2 marker detected", isV2File(v2raw));
311
+ ok("already-v2 → null plan", planConversion(v1path, memRoot) !== null); // file still v1 on disk
312
+ fs.writeFileSync(v1path, v2raw, "utf8");
313
+ const plan2 = planConversion(v1path, memRoot);
314
+ ok("v2 on disk → null plan", plan2 === null);
315
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
316
+
317
+ console.log("== lifecycle pure: contentHash / similarity ==");
318
+ ok("hash deterministic + whitespace-insensitive", contentHash("hello world\n") === contentHash("hello world"));
319
+ ok("hash differs on content", contentHash("hello world") !== contentHash("hello mars"));
320
+ ok("hash is sha256 hex", /^[0-9a-f]{64}$/.test(contentHash("x")));
321
+ ok("similarity identical latin = 1", similarity("apple banana", "apple banana") === 1);
322
+ ok("similarity identical CJK = 1", similarity("北京烤鸭好吃", "北京烤鸭好吃") === 1);
323
+ ok("similarity disjoint ≈ 0", similarity("apple banana", "car train") < 0.2);
324
+ const near = similarity(
325
+ "the meeting notes from tuesday about deployment",
326
+ "meeting notes from tuesday about deployment",
327
+ );
328
+ ok(`similarity near-dup ${near.toFixed(2)} >= ${NEAR_DUP_THRESHOLD}`, near >= NEAR_DUP_THRESHOLD);
329
+ ok("similarity empty → 0", similarity("", "anything") === 0);
330
+
90
331
  console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAILURES`);
91
332
  process.exit(fails === 0 ? 0 : 1);
@@ -3,32 +3,45 @@ import type { MyOMemoryConfig } from "../config.ts";
3
3
  export interface KeywordHit {
4
4
  content: string;
5
5
  pattern: string;
6
+ /** True when the hit came from a personal-scope pattern: force personal scope. */
7
+ personal: boolean;
6
8
  }
7
9
 
8
10
  /**
9
11
  * Scan user text for memory-capture keyword triggers.
10
12
  * Each pattern must have a single capture group whose value becomes the memory body.
13
+ * Personal patterns (cfg.keywordPersonalPatterns) force the personal scope.
14
+ *
15
+ * Personal patterns run first and claim their line: an explicit scope signal
16
+ * (e.g. "记住我对花粉过敏") must not also fire the generic 记住 into the project scope.
11
17
  */
12
18
  export function detectKeywords(userText: string, cfg: MyOMemoryConfig): KeywordHit[] {
13
19
  if (!cfg.keywordCaptureEnabled) return [];
14
20
  const hits: KeywordHit[] = [];
15
21
  const lines = userText.split(/\r?\n/);
16
- for (const src of cfg.keywordPatterns) {
17
- let re: RegExp;
18
- try {
19
- re = new RegExp(src, "i");
20
- } catch {
21
- continue;
22
- }
23
- for (const line of lines) {
24
- const m = line.match(re);
25
- if (!m || !m[1]) continue;
26
- const content = m[1].trim().replace(/[.!?]$/, "").trim();
27
- if (content.length >= 3 && content.length <= 2000) {
28
- hits.push({ content, pattern: src });
29
- break; // one hit per pattern per message
22
+ const claimed = new Set<number>();
23
+ const scan = (patterns: string[], personal: boolean, skipClaimed: boolean) => {
24
+ for (const src of patterns) {
25
+ let re: RegExp;
26
+ try {
27
+ re = new RegExp(src, "i");
28
+ } catch {
29
+ continue;
30
+ }
31
+ for (let i = 0; i < lines.length; i++) {
32
+ if (skipClaimed && claimed.has(i)) continue;
33
+ const m = lines[i].match(re);
34
+ if (!m || !m[1]) continue;
35
+ const content = m[1].trim().replace(/[.!?]$/, "").trim();
36
+ if (content.length >= 3 && content.length <= 2000) {
37
+ hits.push({ content, pattern: src, personal });
38
+ if (personal) claimed.add(i);
39
+ break; // one hit per pattern per message
40
+ }
30
41
  }
31
42
  }
32
- }
43
+ };
44
+ scan(cfg.keywordPersonalPatterns, true, false);
45
+ scan(cfg.keywordPatterns, false, true);
33
46
  return hits;
34
47
  }