open-memex 0.1.0 → 0.2.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.
@@ -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,82 @@ 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-----"],
89
+ ["generic-secret-assignment", 'password = "hunter2hunter2hunter2"'],
90
+ ];
91
+ for (const [id, sample] of builtinSamples) {
92
+ const r = redact(`note: ${sample} end`, []);
93
+ ok(`builtin ${id} detected`, r.hadSecret === true && r.matchedPattern === id, r.matchedPattern);
94
+ }
95
+ // All builtin regexes compile.
96
+ ok(
97
+ "all builtins compile",
98
+ BUILTIN_SECRET_PATTERNS.every((p) => {
99
+ try {
100
+ new RegExp(p.source, p.flags ?? "");
101
+ return true;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }),
106
+ );
107
+ // Non-secrets pass.
108
+ const benign = [
109
+ "remember that the meeting is at 3pm tomorrow",
110
+ "the api endpoint is https://api.example.com/v1/users/list",
111
+ "commit = a3f5c81234abcd1234abcd1234abcd1234abcd12",
112
+ "version = 1.2.3-alpha.4",
113
+ "note = aaaaaaaaaaaaaaaaaaaaaaaa",
114
+ "token: abc",
115
+ // sk- inside ordinary words must not trip the openai-key pattern
116
+ // (regression: these were refused as "openai-key" before the boundary guard).
117
+ "the task-management-system-workflow-2024 document is here",
118
+ "disk-encryption-key-management-system-v2 rollout notes",
119
+ "risk-assessment-score-2024-10 update",
120
+ ];
121
+ for (const b of benign) {
122
+ const r = redact(b, []);
123
+ ok(`benign passes: ${b.slice(0, 40)}`, r.hadSecret === false, r.matchedPattern);
124
+ }
125
+ // High-entropy heuristic.
126
+ const entropic = findHighEntropySecret('deploy_key = "aB3dE5fG7hJ9kL2mN4pQ6rS8tU0vW2xY4zA6bC8dE0"');
127
+ ok("high-entropy assignment caught", entropic === "high-entropy-secret:deploy_key", entropic);
128
+ ok("low-entropy passes", findHighEntropySecret('note = "aaaaaaaaaaaaaaaaaaaaaaaa"') === null);
129
+ ok("hex digest passes", findHighEntropySecret("commit = a3f5c81234abcd1234abcd1234abcd1234abcd12") === null);
130
+ // <private> handling.
131
+ ok(
132
+ "unclosed private redacts to end",
133
+ stripPrivate("hello <private>my secret") === "hello [REDACTED]",
134
+ );
135
+ ok(
136
+ "closed private still stripped",
137
+ stripPrivate("a <private>x</private> b") === "a [REDACTED] b",
138
+ );
139
+ const r4 = redact("token is <private>sk-1234567890abcdefghij1234</private> ok", []);
140
+ ok("wrapped secret does not trigger", r4.hadSecret === false);
141
+ // User patterns still work, reported verbatim.
142
+ const r5 = redact("foo CUSTOM123 bar", ["CUSTOM\\d+"]);
143
+ ok("user pattern detected", r5.hadSecret === true && r5.matchedPattern === "CUSTOM\\d+", r5.matchedPattern);
144
+
57
145
  console.log("== keywords ==");
58
146
  const hits1 = detectKeywords("remember that this repo uses Bun, not Node", DEFAULT_CONFIG);
59
147
  ok("basic remember matched", hits1.length === 1);
@@ -74,7 +162,9 @@ ok("kind=project", s.kind === "project");
74
162
  ok("key starts with project__", s.key.startsWith("project__"));
75
163
  ok("hash length 12", /__[a-f0-9]{12}$/.test(s.key));
76
164
  console.log(" scope.key =", s.key);
77
- console.log(" user.key =", USER_SCOPE.key);
165
+ console.log(" personal.key =", PERSONAL_SCOPE.key);
166
+ ok("personal scope key", PERSONAL_SCOPE.key === "personal");
167
+ ok("personal scope kind", PERSONAL_SCOPE.kind === "personal");
78
168
 
79
169
  console.log("== scope (cwd-only legacy) ==");
80
170
  const cwdOnly = resolveCwdScope(process.cwd());
@@ -87,5 +177,115 @@ ok("cwd scope deterministic", cwdOnly.key === cwdOnly2.key);
87
177
  // (This repo does have an origin after `git push`.)
88
178
  console.log(" cwd.key =", cwdOnly.key);
89
179
 
180
+ console.log("== cjk ==");
181
+ ok("detects han", hasCjk("中文记忆"));
182
+ ok("detects mixed", hasCjk("open中文"));
183
+ ok("no cjk in latin", !hasCjk("hello world 123"));
184
+ ok(
185
+ "index bigrams",
186
+ cjkIndexText("中文记忆") === "中 文 记 忆 中文 文记 记忆",
187
+ cjkIndexText("中文记忆"),
188
+ );
189
+ ok("index single char", cjkIndexText("猫") === "猫", cjkIndexText("猫"));
190
+ ok(
191
+ "index skips latin",
192
+ cjkIndexText("open中文") === "中 文 中文",
193
+ cjkIndexText("open中文"),
194
+ );
195
+ ok("index empty", cjkIndexText("hello") === "");
196
+ ok(
197
+ "query bigrams",
198
+ cjkQueryExpr("中文记忆") === '"中文" OR "文记" OR "记忆"',
199
+ cjkQueryExpr("中文记忆"),
200
+ );
201
+ ok("query single char", cjkQueryExpr("猫") === '"猫"', cjkQueryExpr("猫"));
202
+ ok("query no cjk", cjkQueryExpr("hello") === "");
203
+
204
+ console.log("== v2 times ==");
205
+ ok("epoch 0 → rfc3339", msToRfc3339(0) === "1970-01-01T00:00:00Z", msToRfc3339(0));
206
+ ok("rfc3339 round-trip", timeToMs(msToRfc3339(1758854400000)) === 1758854400000);
207
+ ok("timeToMs accepts epoch", timeToMs(1000) === 1000);
208
+ ok("timeToMs accepts rfc3339", timeToMs("2026-09-26T12:00:00Z") === Date.parse("2026-09-26T12:00:00Z"));
209
+
210
+ console.log("== v1 → v2 normalize ==");
211
+ const v1raw = {
212
+ id: "01TESTV1",
213
+ scope_key: "user",
214
+ scope_kind: "user",
215
+ project_name: "user",
216
+ type: "instruction",
217
+ priority: 9,
218
+ tags: ["x"],
219
+ source: "test",
220
+ created_at: 1758854400000,
221
+ updated_at: 1758854400000,
222
+ } as Record<string, unknown>;
223
+ const nfm = normalizeFrontmatter(v1raw);
224
+ ok("scope user→personal", nfm.scope === "personal", nfm.scope);
225
+ ok("scope_key user→personal", nfm.scope_key === "personal", nfm.scope_key);
226
+ ok("visibility private", nfm.visibility === "private", nfm.visibility);
227
+ ok("type instruction→knowledge", nfm.type === "knowledge", nfm.type);
228
+ ok("role →instruction", nfm.role === "instruction", nfm.role);
229
+ ok("priority 9→high", nfm.importance === "high", nfm.importance);
230
+ ok("epoch→rfc3339", nfm.created_at === "2025-09-26T02:40:00Z", nfm.created_at);
231
+ ok("status active", nfm.status === "active");
232
+ ok("schema_version 2", nfm.schema_version === 2);
233
+
234
+ const v1raw2 = {
235
+ id: "01TESTV2",
236
+ scope_key: "project__x__abcdef123456",
237
+ scope_kind: "project",
238
+ project_name: "x",
239
+ type: "error-solution",
240
+ priority: 2,
241
+ tags: [],
242
+ created_at: 1000,
243
+ updated_at: 1000,
244
+ } as Record<string, unknown>;
245
+ const nfm2 = normalizeFrontmatter(v1raw2);
246
+ ok("project scope kept", nfm2.scope === "project");
247
+ ok("project visibility internal", nfm2.visibility === "internal");
248
+ ok("priority 2→low", nfm2.importance === "low");
249
+ ok("role default knowledge", nfm2.role === "knowledge");
250
+
251
+ console.log("== v1 → v2 planConversion ==");
252
+ import fs from "node:fs";
253
+ import path from "node:path";
254
+ import os from "node:os";
255
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "v2mig-"));
256
+ const memRoot = path.join(tmpRoot, "memories");
257
+ fs.mkdirSync(path.join(memRoot, "user"), { recursive: true });
258
+ 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`;
259
+ const v1path = path.join(memRoot, "user", "01PLANTEST.md");
260
+ fs.writeFileSync(v1path, v1file, "utf8");
261
+ ok("v1 not v2 file", !isV2File(v1file));
262
+ const plan = planConversion(v1path, memRoot);
263
+ ok("plan exists", plan !== null);
264
+ ok("plan moves to personal/", plan?.toPath === path.join(memRoot, "personal", "01PLANTEST.md"), plan?.toPath);
265
+ ok("plan notes unknown type", plan?.changes.some((c) => c.includes('"note" → knowledge')) ?? false, plan?.changes);
266
+ ok("plan notes scope rename", plan?.changes.some((c) => c.includes("personal")) ?? false);
267
+ ok("plan fm scope personal", plan?.fm.scope === "personal");
268
+ const v2raw = serialize(plan!.fm, plan!.body);
269
+ ok("v2 marker detected", isV2File(v2raw));
270
+ ok("already-v2 → null plan", planConversion(v1path, memRoot) !== null); // file still v1 on disk
271
+ fs.writeFileSync(v1path, v2raw, "utf8");
272
+ const plan2 = planConversion(v1path, memRoot);
273
+ ok("v2 on disk → null plan", plan2 === null);
274
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
275
+
276
+ console.log("== lifecycle pure: contentHash / similarity ==");
277
+ ok("hash deterministic + whitespace-insensitive", contentHash("hello world\n") === contentHash("hello world"));
278
+ ok("hash differs on content", contentHash("hello world") !== contentHash("hello mars"));
279
+ ok("hash is sha256 hex", /^[0-9a-f]{64}$/.test(contentHash("x")));
280
+ ok("similarity identical latin = 1", similarity("apple banana", "apple banana") === 1);
281
+ ok("similarity identical CJK = 1", similarity("北京烤鸭好吃", "北京烤鸭好吃") === 1);
282
+ ok("similarity disjoint ≈ 0", similarity("apple banana", "car train") < 0.2);
283
+ const near = similarity(
284
+ "the meeting notes from tuesday about deployment",
285
+ "meeting notes from tuesday about deployment",
286
+ );
287
+ ok(`similarity near-dup ${near.toFixed(2)} >= ${NEAR_DUP_THRESHOLD}`, near >= NEAR_DUP_THRESHOLD);
288
+ ok("similarity empty → 0", similarity("", "anything") === 0);
289
+
90
290
  console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAILURES`);
91
291
  process.exit(fails === 0 ? 0 : 1);
package/src/cli.ts CHANGED
@@ -1,15 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  // Run with: node --experimental-strip-types src/cli.ts <command>
3
- import { resolveProjectScope, resolveCwdScope, USER_SCOPE } from "./scope.ts";
3
+ import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE, type Scope } from "./scope.ts";
4
4
  import { db, closeDb } from "./store/db.ts";
5
5
  import { syncScope, upsertFromFile, deleteFromIndex } from "./store/sync.ts";
6
6
  import { migrateScope, scopeHasFiles, type ConflictStrategy } from "./store/migrate.ts";
7
+ import { migrateV2 } from "./store/v2migrate.ts";
8
+ import { findDuplicates, supersede, setStatus } from "./store/lifecycle.ts";
7
9
  import { search, list } from "./retrieve/search.ts";
8
10
  import {
9
11
  writeMemoryFile,
10
12
  readMemoryFile,
11
13
  deleteMemoryFile,
12
14
  ulid,
15
+ msToRfc3339,
13
16
  type Frontmatter,
14
17
  } from "./store/markdown.ts";
15
18
  import { loadConfig } from "./config.ts";
@@ -22,20 +25,27 @@ function usage(): never {
22
25
 
23
26
  Usage:
24
27
  node --experimental-strip-types src/cli.ts where
25
- node --experimental-strip-types src/cli.ts list [--scope project|user] [--type T] [--limit N]
26
- node --experimental-strip-types src/cli.ts search "query" [--scope project|user|both] [--type T] [--limit N]
27
- node --experimental-strip-types src/cli.ts add "content" [--scope project|user] [--type T] [--tag t1,t2]
28
+ node --experimental-strip-types src/cli.ts list [--scope project|personal] [--type T] [--limit N]
29
+ node --experimental-strip-types src/cli.ts search "query" [--scope project|personal|both] [--type T] [--limit N]
30
+ node --experimental-strip-types src/cli.ts add "content" [--scope project|personal] [--type T] [--tag t1,t2]
31
+ node --experimental-strip-types src/cli.ts supersede <id> "new content" [--type T] [--tag t1,t2]
32
+ node --experimental-strip-types src/cli.ts status <id> active|deprecated|retracted|archived
28
33
  node --experimental-strip-types src/cli.ts forget <id>
29
34
  node --experimental-strip-types src/cli.ts reindex
30
35
  node --experimental-strip-types src/cli.ts scopes
31
36
  node --experimental-strip-types src/cli.ts migrate [--from <key>] [--to <key>]
32
37
  [--dry-run] [--on-conflict newer|overwrite|skip]
38
+ node --experimental-strip-types src/cli.ts migrate --to-v2 [--dry-run]
33
39
 
34
40
  Scope defaults to \`project\` (derived from cwd's git remote or path).
41
+ \`user\` is accepted as a deprecated alias of \`personal\`.
35
42
  \`scopes\` lists every project scope dir with its file count — use it to find
36
43
  the \`--from\` key when migrating.
37
44
  \`migrate\` moves memories between scope keys — useful when a repo gains a
38
- git remote after memories were already stored under the cwd-based key.`);
45
+ git remote after memories were already stored under the cwd-based key.
46
+ \`migrate --to-v2\` converts v1 memory files to the v2 format (§19):
47
+ user→personal scope rename, epoch→RFC 3339 times, priority→importance,
48
+ type: instruction→role split. Always preview with --dry-run first.`);
39
49
  process.exit(1);
40
50
  }
41
51
 
@@ -57,12 +67,52 @@ function parseFlags(argv: string[]): Record<string, string> {
57
67
  return out;
58
68
  }
59
69
 
70
+ /** CLI `--scope` flag → Scope. `user` is a deprecated alias of `personal`. */
71
+ function resolveCliScope(flags: Record<string, string>, project: Scope): Scope {
72
+ return flags.scope === "personal" || flags.scope === "user"
73
+ ? PERSONAL_SCOPE
74
+ : project;
75
+ }
76
+
60
77
  async function main() {
61
78
  const [cmd, ...rest] = process.argv.slice(2);
62
79
  if (!cmd) usage();
63
80
 
64
81
  const cfg = loadConfig();
65
82
  const project = resolveProjectScope(process.cwd());
83
+
84
+ // `migrate --to-v2` is a pure file operation (V2-DESIGN §19) — it runs
85
+ // before the index is opened so it also works where the DB driver
86
+ // isn't available.
87
+ if (cmd === "migrate" && rest.includes("--to-v2")) {
88
+ const flags = parseFlags(rest);
89
+ const dryRun = flags["dry-run"] === "true";
90
+ const stats = migrateV2({ dryRun });
91
+ console.log(
92
+ `${dryRun ? "DRY RUN: " : ""}scanned ${stats.scanned} files: ` +
93
+ `${stats.converted} to convert, ${stats.skippedV2} already v2`,
94
+ );
95
+ for (const p of stats.plans) {
96
+ console.log(`\n${p.fm.id}`);
97
+ console.log(
98
+ ` ${p.fromPath}${p.toPath !== p.fromPath ? `\n → ${p.toPath}` : ""}`,
99
+ );
100
+ for (const c of p.changes) console.log(` - ${c}`);
101
+ }
102
+ if (stats.legacyBackup) {
103
+ console.log(
104
+ `\nlegacy my-o-memory data dir merged; backup kept at:\n ${stats.legacyBackup}`,
105
+ );
106
+ }
107
+ if (!dryRun && stats.converted > 0) {
108
+ console.log(
109
+ `\nindex schema will rebuild automatically on next run; ` +
110
+ `run \`open-memex reindex\` to verify.`,
111
+ );
112
+ }
113
+ return;
114
+ }
115
+
66
116
  db();
67
117
 
68
118
  if (cmd === "where") {
@@ -71,22 +121,22 @@ async function main() {
71
121
  console.log(`memories: ${p.memories}`);
72
122
  console.log(`index: ${p.indexDb}`);
73
123
  console.log(`project: ${project.key}`);
74
- console.log(`user: ${USER_SCOPE.key}`);
124
+ console.log(`personal: ${PERSONAL_SCOPE.key}`);
75
125
  return;
76
126
  }
77
127
 
78
128
  if (cmd === "reindex") {
79
129
  const a = syncScope(project.key);
80
- const b = syncScope(USER_SCOPE.key);
130
+ const b = syncScope(PERSONAL_SCOPE.key);
81
131
  console.log(
82
- `reindexed. project: +${a.added} ~${a.updated} -${a.removed} (scanned ${a.scanned}), user: +${b.added} ~${b.updated} -${b.removed} (scanned ${b.scanned})`,
132
+ `reindexed. project: +${a.added} ~${a.updated} -${a.removed} (scanned ${a.scanned}), personal: +${b.added} ~${b.updated} -${b.removed} (scanned ${b.scanned})`,
83
133
  );
84
134
  return;
85
135
  }
86
136
 
87
137
  if (cmd === "list") {
88
138
  const flags = parseFlags(rest);
89
- const s = flags.scope === "user" ? USER_SCOPE : project;
139
+ const s = resolveCliScope(flags, project);
90
140
  syncScope(s.key);
91
141
  const hits = list(s.key, {
92
142
  type: flags.type,
@@ -97,7 +147,8 @@ async function main() {
97
147
  return;
98
148
  }
99
149
  for (const h of hits) {
100
- console.log(`[${h.type}] ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
150
+ const dep = h.status === "deprecated" ? " [deprecated]" : "";
151
+ console.log(`[${h.type}]${dep} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
101
152
  }
102
153
  return;
103
154
  }
@@ -107,13 +158,13 @@ async function main() {
107
158
  if (!query || query.startsWith("--")) usage();
108
159
  const flags = parseFlags(rest.slice(1));
109
160
  syncScope(project.key);
110
- syncScope(USER_SCOPE.key);
161
+ syncScope(PERSONAL_SCOPE.key);
111
162
  const keys =
112
- flags.scope === "user"
113
- ? [USER_SCOPE.key]
163
+ flags.scope === "personal" || flags.scope === "user"
164
+ ? [PERSONAL_SCOPE.key]
114
165
  : flags.scope === "project"
115
166
  ? [project.key]
116
- : [project.key, USER_SCOPE.key];
167
+ : [project.key, PERSONAL_SCOPE.key];
117
168
  const hits = search(query, {
118
169
  scopeKeys: keys,
119
170
  limit: flags.limit ? Number(flags.limit) : undefined,
@@ -124,8 +175,9 @@ async function main() {
124
175
  return;
125
176
  }
126
177
  for (const h of hits) {
127
- const tag = h.scope_key === USER_SCOPE.key ? "user" : "project";
128
- console.log(`[${tag}/${h.type}] ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
178
+ const tag = h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project";
179
+ const dep = h.status === "deprecated" ? " [deprecated]" : "";
180
+ console.log(`[${tag}/${h.type}]${dep} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
129
181
  }
130
182
  return;
131
183
  }
@@ -134,19 +186,32 @@ async function main() {
134
186
  const content = rest[0];
135
187
  if (!content || content.startsWith("--")) usage();
136
188
  const flags = parseFlags(rest.slice(1));
137
- const s = flags.scope === "user" ? USER_SCOPE : project;
189
+ const s = resolveCliScope(flags, project);
138
190
  const { content: red, hadSecret, matchedPattern } = redact(content, cfg.redactPatterns);
139
191
  if (hadSecret) {
140
192
  console.error(`refused: content matched secret pattern (${matchedPattern}).`);
141
193
  process.exit(2);
142
194
  }
195
+ // Dedup on write (§3.4): identical content is idempotent; near-duplicates
196
+ // warn but still save (use `supersede` when this replaces the old one).
197
+ const dups = findDuplicates(s.key, red);
198
+ if (dups.exact) {
199
+ console.log(`already exists: id=${dups.exact.id} (identical content — not duplicated)`);
200
+ return;
201
+ }
143
202
  const now = Date.now();
203
+ const rfc = msToRfc3339(now);
144
204
  const fm: Frontmatter = {
145
205
  id: ulid(),
206
+ schema_version: 2,
146
207
  scope_key: s.key,
147
- scope_kind: s.kind,
208
+ scope: s.kind === "project" ? "project" : "personal",
209
+ visibility: s.kind === "project" ? "internal" : "private",
148
210
  project_name: s.projectName,
149
- type: flags.type ?? "note",
211
+ type: flags.type ?? "fact",
212
+ role: "knowledge",
213
+ importance: "normal",
214
+ status: "active",
150
215
  tags: flags.tag
151
216
  ? flags.tag
152
217
  .split(",")
@@ -154,13 +219,64 @@ async function main() {
154
219
  .filter(Boolean)
155
220
  : [],
156
221
  source: "cli",
157
- created_at: now,
158
- updated_at: now,
222
+ created_at: rfc,
223
+ updated_at: rfc,
224
+ supersedes: null,
225
+ superseded_by: null,
159
226
  };
160
227
  const { filePath } = writeMemoryFile(fm, red);
161
228
  const mf = readMemoryFile(filePath);
162
229
  if (mf) upsertFromFile(mf);
163
230
  console.log(`saved ${fm.id} -> ${filePath}`);
231
+ for (const n of dups.near) {
232
+ console.log(
233
+ `warning: similar memory exists (score ${n.score.toFixed(2)}): id=${n.id}\n ${n.snippet}\n use \`open-memex supersede ${n.id} "new content"\` if this replaces it.`,
234
+ );
235
+ }
236
+ return;
237
+ }
238
+
239
+ if (cmd === "supersede") {
240
+ const id = rest[0];
241
+ const content = rest[1];
242
+ if (!id || !content || content.startsWith("--")) usage();
243
+ const flags = parseFlags(rest.slice(2));
244
+ const { content: red, hadSecret, matchedPattern } = redact(content, cfg.redactPatterns);
245
+ if (hadSecret) {
246
+ console.error(`refused: content matched secret pattern (${matchedPattern}).`);
247
+ process.exit(2);
248
+ }
249
+ try {
250
+ const { oldMf, newMf } = supersede(id, {
251
+ body: red,
252
+ type: flags.type,
253
+ tags: flags.tag
254
+ ? flags.tag.split(",").map((t) => t.trim()).filter(Boolean)
255
+ : undefined,
256
+ source: "cli",
257
+ });
258
+ upsertFromFile(oldMf);
259
+ upsertFromFile(newMf);
260
+ console.log(`superseded ${oldMf.fm.id} → ${newMf.fm.id}`);
261
+ } catch (e) {
262
+ console.error(`supersede failed: ${(e as Error).message}`);
263
+ process.exit(2);
264
+ }
265
+ return;
266
+ }
267
+
268
+ if (cmd === "status") {
269
+ const id = rest[0];
270
+ const st = rest[1];
271
+ if (!id || !st) usage();
272
+ try {
273
+ const mf = setStatus(id, st as "active" | "deprecated" | "retracted" | "archived");
274
+ upsertFromFile(mf);
275
+ console.log(`status ${id} → ${mf.fm.status}`);
276
+ } catch (e) {
277
+ console.error(`status failed: ${(e as Error).message}`);
278
+ process.exit(2);
279
+ }
164
280
  return;
165
281
  }
166
282
 
@@ -193,7 +309,7 @@ async function main() {
193
309
  const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")).length;
194
310
  const marker =
195
311
  name === project.key ? " <- current project"
196
- : name === USER_SCOPE.key ? " <- user"
312
+ : name === PERSONAL_SCOPE.key ? " <- personal"
197
313
  : "";
198
314
  entries.push({ key: name, files, marker });
199
315
  }
package/src/config.ts CHANGED
@@ -23,16 +23,9 @@ export const DEFAULT_CONFIG: MyOMemoryConfig = {
23
23
  "^\\s*TIL[:,]?\\s+(.+)$",
24
24
  "^\\s*save\\s+(?:this|to\\s+memory)[:,]?\\s+(.+)$",
25
25
  ],
26
- redactPatterns: [
27
- "sk-[A-Za-z0-9_-]{20,}",
28
- "sm_[A-Za-z0-9_-]{20,}",
29
- "ghp_[A-Za-z0-9]{30,}",
30
- "gho_[A-Za-z0-9]{30,}",
31
- "github_pat_[A-Za-z0-9_]{40,}",
32
- "AKIA[0-9A-Z]{16}",
33
- "xox[baprs]-[A-Za-z0-9-]{10,}",
34
- "AIza[0-9A-Za-z_-]{30,}",
35
- ],
26
+ // Built-in provider patterns now live in src/redact.ts (always on).
27
+ // Add only your own extra patterns here.
28
+ redactPatterns: [],
36
29
  logLevel: "info",
37
30
  };
38
31
 
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin";
2
2
  import { loadConfig } from "./config.ts";
3
- import { resolveProjectScope, resolveCwdScope, USER_SCOPE, type Scope } from "./scope.ts";
3
+ import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE, type Scope } from "./scope.ts";
4
4
  import { db } from "./store/db.ts";
5
5
  import { syncScope, upsertFromFile } from "./store/sync.ts";
6
6
  import { scopeHasFiles } from "./store/migrate.ts";
@@ -8,10 +8,12 @@ import {
8
8
  writeMemoryFile,
9
9
  readMemoryFile,
10
10
  ulid,
11
+ msToRfc3339,
11
12
  type Frontmatter,
12
13
  } from "./store/markdown.ts";
13
14
  import { buildContextBlock } from "./retrieve/inject.ts";
14
15
  import { detectKeywords } from "./capture/keywords.ts";
16
+ import { findDuplicates } from "./store/lifecycle.ts";
15
17
  import { redact } from "./redact.ts";
16
18
  import { makeTools } from "./tools/memory.ts";
17
19
 
@@ -24,7 +26,7 @@ const plugin: Plugin = async ({ worktree, directory }) => {
24
26
  try {
25
27
  db();
26
28
  syncScope(scope.key);
27
- syncScope(USER_SCOPE.key);
29
+ syncScope(PERSONAL_SCOPE.key);
28
30
  if (cfg.logLevel === "debug") {
29
31
  console.log(`[open-memex] loaded. scope=${scope.key}`);
30
32
  }
@@ -63,17 +65,27 @@ const plugin: Plugin = async ({ worktree, directory }) => {
63
65
  for (const h of hits) {
64
66
  const { content, hadSecret } = redact(h.content, cfg.redactPatterns);
65
67
  if (hadSecret || content.length === 0) continue;
68
+ // Dedup (§3.4): skip exact duplicates captured before.
69
+ if (findDuplicates(scope.key, content).exact) continue;
66
70
  const now = Date.now();
71
+ const rfc = msToRfc3339(now);
67
72
  const fm: Frontmatter = {
68
73
  id: ulid(),
74
+ schema_version: 2,
69
75
  scope_key: scope.key,
70
- scope_kind: scope.kind,
76
+ scope: scope.kind === "project" ? "project" : "personal",
77
+ visibility: scope.kind === "project" ? "internal" : "private",
71
78
  project_name: scope.projectName,
72
- type: "note",
79
+ type: "fact",
80
+ role: "knowledge",
81
+ importance: "normal",
82
+ status: "active",
73
83
  tags: ["keyword"],
74
84
  source: "keyword",
75
- created_at: now,
76
- updated_at: now,
85
+ created_at: rfc,
86
+ updated_at: rfc,
87
+ supersedes: null,
88
+ superseded_by: null,
77
89
  };
78
90
  try {
79
91
  const { filePath } = writeMemoryFile(fm, content);