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.
package/src/cli.ts CHANGED
@@ -1,20 +1,24 @@
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";
16
19
  import { paths } from "./paths.ts";
17
20
  import { redact } from "./redact.ts";
21
+ import { resolveMcpCommand } from "./init.ts";
18
22
  import fs from "node:fs";
19
23
 
20
24
  function usage(): never {
@@ -22,20 +26,46 @@ function usage(): never {
22
26
 
23
27
  Usage:
24
28
  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]
29
+ node --experimental-strip-types src/cli.ts list [--scope project|personal] [--type T] [--limit N]
30
+ node --experimental-strip-types src/cli.ts search "query" [--scope project|personal|both] [--type T] [--limit N]
31
+ node --experimental-strip-types src/cli.ts add "content" [--scope project|personal] [--type T] [--tag t1,t2]
32
+ node --experimental-strip-types src/cli.ts supersede <id> "new content" [--type T] [--tag t1,t2]
33
+ node --experimental-strip-types src/cli.ts status <id> active|deprecated|retracted|archived
28
34
  node --experimental-strip-types src/cli.ts forget <id>
29
35
  node --experimental-strip-types src/cli.ts reindex
30
36
  node --experimental-strip-types src/cli.ts scopes
31
37
  node --experimental-strip-types src/cli.ts migrate [--from <key>] [--to <key>]
32
38
  [--dry-run] [--on-conflict newer|overwrite|skip]
39
+ node --experimental-strip-types src/cli.ts migrate --to-v2 [--dry-run]
40
+ node --experimental-strip-types src/cli.ts mcp [--print-config vscode|cursor|claude|opencode|visualstudio]
41
+ node --experimental-strip-types src/cli.ts init [--client vscode|cursor|opencode|visualstudio] [--force] [--yes]
42
+ node --experimental-strip-types src/cli.ts config [set <key> <value>]
43
+ node --experimental-strip-types src/cli.ts capture --dry-run "text"
44
+ node --experimental-strip-types src/cli.ts doctor
45
+
46
+ One-command project setup: \`open-memex init\` (or \`npx open-memex@alpha init\`) writes
47
+ the MCP config for your editor (\`.vscode/mcp.json\`, \`.cursor/mcp.json\`,
48
+ \`opencode.jsonc\`, or Visual Studio's solution-level \`.mcp.json\`) plus
49
+ \`.github/copilot-instructions.md\` — no copy-paste needed.
50
+ Existing files are merged, never clobbered; re-running is safe. On a terminal it
51
+ asks which editor to set up and a couple of settings (keyword capture, first-turn
52
+ injection); \`--yes\` accepts all defaults, and non-terminal runs never prompt.
53
+ \`open-memex config set <key> <value>\` changes those settings after install.
54
+
55
+ Once installed globally (\`npm i -g open-memex@alpha\`) the \`open-memex\` command is
56
+ available directly: \`open-memex mcp\` starts the stdio MCP server (same five
57
+ memory_* tools as the opencode plugin); \`open-memex mcp --print-config <client>\`
58
+ prints a copy-paste MCP client config snippet.
33
59
 
34
60
  Scope defaults to \`project\` (derived from cwd's git remote or path).
61
+ \`user\` is accepted as a deprecated alias of \`personal\`.
35
62
  \`scopes\` lists every project scope dir with its file count — use it to find
36
63
  the \`--from\` key when migrating.
37
64
  \`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.`);
65
+ git remote after memories were already stored under the cwd-based key.
66
+ \`migrate --to-v2\` converts v1 memory files to the v2 format (§19):
67
+ user→personal scope rename, epoch→RFC 3339 times, priority→importance,
68
+ type: instruction→role split. Always preview with --dry-run first.`);
39
69
  process.exit(1);
40
70
  }
41
71
 
@@ -57,12 +87,234 @@ function parseFlags(argv: string[]): Record<string, string> {
57
87
  return out;
58
88
  }
59
89
 
90
+ /** CLI `--scope` flag → Scope. `user` is a deprecated alias of `personal`. */
91
+ function resolveCliScope(flags: Record<string, string>, project: Scope): Scope {
92
+ return flags.scope === "personal" || flags.scope === "user"
93
+ ? PERSONAL_SCOPE
94
+ : project;
95
+ }
96
+
97
+ /** Print a copy-paste MCP client config snippet. Requires a global install
98
+ * (`npm i -g open-memex@alpha`) so the `open-memex` command is on PATH. */
99
+ function printMcpConfig(client: string): never {
100
+ const c = client.toLowerCase();
101
+ // D17: resolve the server command the same way `init` does.
102
+ const mc = resolveMcpCommand();
103
+ if (c === "vscode") {
104
+ console.log(
105
+ JSON.stringify(
106
+ {
107
+ servers: {
108
+ "open-memex": {
109
+ type: "stdio",
110
+ command: mc.command,
111
+ args: mc.args,
112
+ cwd: "${workspaceFolder}",
113
+ },
114
+ },
115
+ },
116
+ null,
117
+ 2,
118
+ ),
119
+ );
120
+ } else if (c === "cursor") {
121
+ console.log(
122
+ JSON.stringify(
123
+ {
124
+ mcpServers: {
125
+ "open-memex": { command: mc.command, args: mc.args },
126
+ },
127
+ },
128
+ null,
129
+ 2,
130
+ ),
131
+ );
132
+ } else if (c === "claude") {
133
+ console.log(`claude mcp add open-memex -- ${mc.command} ${mc.args.join(" ")}`);
134
+ } else if (c === "opencode") {
135
+ // D16: opencode as a plain MCP consumer (alternative to the native plugin).
136
+ console.log(
137
+ JSON.stringify(
138
+ {
139
+ mcp: {
140
+ "open-memex": {
141
+ type: "local",
142
+ command: [mc.command, ...mc.args],
143
+ enabled: true,
144
+ },
145
+ },
146
+ },
147
+ null,
148
+ 2,
149
+ ),
150
+ );
151
+ } else if (c === "visualstudio" || c === "visual-studio") {
152
+ // D20: Visual Studio (Windows-only) reads solution-level `.mcp.json`.
153
+ console.log(
154
+ JSON.stringify(
155
+ {
156
+ servers: {
157
+ "open-memex": {
158
+ type: "stdio",
159
+ command: mc.command,
160
+ args: mc.args,
161
+ },
162
+ },
163
+ },
164
+ null,
165
+ 2,
166
+ ),
167
+ );
168
+ } else {
169
+ console.error(`unknown client "${client}" (vscode|cursor|claude|opencode|visualstudio)`);
170
+ process.exit(1);
171
+ }
172
+ if (!mc.durable) {
173
+ console.error(
174
+ `\n# note: no durable \`open-memex\` on PATH — snippet uses npx. \`npm i -g open-memex@alpha\` for faster startup.`,
175
+ );
176
+ }
177
+ process.exit(0);
178
+ }
179
+
60
180
  async function main() {
61
181
  const [cmd, ...rest] = process.argv.slice(2);
62
182
  if (!cmd) usage();
63
183
 
64
184
  const cfg = loadConfig();
65
185
  const project = resolveProjectScope(process.cwd());
186
+
187
+ // `migrate --to-v2` is a pure file operation (V2-DESIGN §19) — it runs
188
+ // before the index is opened so it also works where the DB driver
189
+ // isn't available.
190
+ if (cmd === "migrate" && rest.includes("--to-v2")) {
191
+ const flags = parseFlags(rest);
192
+ const dryRun = flags["dry-run"] === "true";
193
+ const stats = migrateV2({ dryRun });
194
+ console.log(
195
+ `${dryRun ? "DRY RUN: " : ""}scanned ${stats.scanned} files: ` +
196
+ `${stats.converted} to convert, ${stats.skippedV2} already v2`,
197
+ );
198
+ for (const p of stats.plans) {
199
+ console.log(`\n${p.fm.id}`);
200
+ console.log(
201
+ ` ${p.fromPath}${p.toPath !== p.fromPath ? `\n → ${p.toPath}` : ""}`,
202
+ );
203
+ for (const c of p.changes) console.log(` - ${c}`);
204
+ }
205
+ if (stats.legacyBackup) {
206
+ console.log(
207
+ `\nlegacy my-o-memory data dir merged; backup kept at:\n ${stats.legacyBackup}`,
208
+ );
209
+ }
210
+ if (!dryRun && stats.converted > 0) {
211
+ console.log(
212
+ `\nindex schema will rebuild automatically on next run; ` +
213
+ `run \`open-memex reindex\` to verify.`,
214
+ );
215
+ }
216
+ return;
217
+ }
218
+
219
+ // `init` is a pure file operation (§17 adoption path) — no DB needed.
220
+ // Interactive when on a TTY (asks editor + settings); --yes skips prompts.
221
+ if (cmd === "init") {
222
+ const flags = parseFlags(rest);
223
+ const { initProject } = await import("./init.ts");
224
+ await initProject({
225
+ client: flags["client"],
226
+ force: flags["force"] === "true",
227
+ yes: flags["yes"] === "true",
228
+ });
229
+ return;
230
+ }
231
+
232
+ // `doctor` runs environment health checks — no DB needed (it self-contains).
233
+ if (cmd === "doctor") {
234
+ const { runDoctor } = await import("./doctor.ts");
235
+ const ok = await runDoctor();
236
+ if (!ok) process.exitCode = 1;
237
+ return;
238
+ }
239
+
240
+ // `config` prints the effective configuration (defaults + file). No DB needed.
241
+ // `config set <key> <value>` persists a setting to the config file.
242
+ if (cmd === "config") {
243
+ if (rest[0] === "set") {
244
+ const [, key, ...valueParts] = rest;
245
+ const { SETTABLE_KEYS, saveConfig, configFilePath } = await import("./config.ts");
246
+ const validate = key ? SETTABLE_KEYS[key] : undefined;
247
+ if (!validate) {
248
+ console.error(
249
+ `unknown or unsettable key "${key ?? ""}". Settable keys: ${Object.keys(SETTABLE_KEYS).join(", ")}`,
250
+ );
251
+ process.exit(1);
252
+ }
253
+ const raw = valueParts.join(" ");
254
+ if (!raw) {
255
+ console.error(`usage: open-memex config set <key> <value>`);
256
+ process.exit(1);
257
+ }
258
+ let value: unknown = raw;
259
+ try {
260
+ value = JSON.parse(raw);
261
+ } catch {
262
+ /* keep as string */
263
+ }
264
+ try {
265
+ const saved = validate(value);
266
+ const file = saveConfig({ [key!]: saved });
267
+ console.log(`set ${key} = ${JSON.stringify(saved)} (${file})`);
268
+ } catch (err) {
269
+ console.error(`invalid value for ${key}: ${(err as Error).message}`);
270
+ process.exit(1);
271
+ }
272
+ return;
273
+ }
274
+ const cfg = loadConfig();
275
+ console.log(JSON.stringify(cfg, null, 2));
276
+ return;
277
+ }
278
+
279
+ // `capture --dry-run "text"` previews keyword capture without writing. No DB needed.
280
+ if (cmd === "capture") {
281
+ if (rest[0] !== "--dry-run") {
282
+ console.error(`usage: open-memex capture --dry-run "text"`);
283
+ process.exit(1);
284
+ }
285
+ const cfg = loadConfig();
286
+ const { detectKeywords } = await import("./capture/keywords.ts");
287
+ const text = rest.slice(1).join(" ");
288
+ const hits = detectKeywords(text, cfg);
289
+ if (hits.length === 0) {
290
+ console.log("no keyword triggers — nothing would be captured.");
291
+ return;
292
+ }
293
+ for (const h of hits) {
294
+ const r = redact(h.content, cfg.redactPatterns);
295
+ console.log(`- pattern: ${h.pattern}`);
296
+ console.log(` scope: ${h.personal ? "personal (forced by pattern)" : "current scope"}`);
297
+ console.log(` secret hit: ${r.hadSecret ? `yes (${r.matchedPattern}) — will be masked` : "no"}`);
298
+ console.log(
299
+ ` body: ${r.content.slice(0, 160)}${r.content.length > 160 ? "…" : ""}`,
300
+ );
301
+ }
302
+ return;
303
+ }
304
+
305
+ // `mcp` starts the stdio MCP server (same tools as the opencode plugin).
306
+ // Branched before db() — runMcpServer() does its own init, and stdout must
307
+ // stay clean for the MCP protocol.
308
+ if (cmd === "mcp") {
309
+ const flags = parseFlags(rest);
310
+ if (flags["print-config"]) {
311
+ printMcpConfig(flags["print-config"] === "true" ? "vscode" : flags["print-config"]);
312
+ }
313
+ const { runMcpServer } = await import("./mcp.ts");
314
+ await runMcpServer();
315
+ return;
316
+ }
317
+
66
318
  db();
67
319
 
68
320
  if (cmd === "where") {
@@ -71,22 +323,22 @@ async function main() {
71
323
  console.log(`memories: ${p.memories}`);
72
324
  console.log(`index: ${p.indexDb}`);
73
325
  console.log(`project: ${project.key}`);
74
- console.log(`user: ${USER_SCOPE.key}`);
326
+ console.log(`personal: ${PERSONAL_SCOPE.key}`);
75
327
  return;
76
328
  }
77
329
 
78
330
  if (cmd === "reindex") {
79
331
  const a = syncScope(project.key);
80
- const b = syncScope(USER_SCOPE.key);
332
+ const b = syncScope(PERSONAL_SCOPE.key);
81
333
  console.log(
82
- `reindexed. project: +${a.added} ~${a.updated} -${a.removed} (scanned ${a.scanned}), user: +${b.added} ~${b.updated} -${b.removed} (scanned ${b.scanned})`,
334
+ `reindexed. project: +${a.added} ~${a.updated} -${a.removed} (scanned ${a.scanned}), personal: +${b.added} ~${b.updated} -${b.removed} (scanned ${b.scanned})`,
83
335
  );
84
336
  return;
85
337
  }
86
338
 
87
339
  if (cmd === "list") {
88
340
  const flags = parseFlags(rest);
89
- const s = flags.scope === "user" ? USER_SCOPE : project;
341
+ const s = resolveCliScope(flags, project);
90
342
  syncScope(s.key);
91
343
  const hits = list(s.key, {
92
344
  type: flags.type,
@@ -97,7 +349,8 @@ async function main() {
97
349
  return;
98
350
  }
99
351
  for (const h of hits) {
100
- console.log(`[${h.type}] ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
352
+ const dep = h.status === "deprecated" ? " [deprecated]" : "";
353
+ console.log(`[${h.type}]${dep} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
101
354
  }
102
355
  return;
103
356
  }
@@ -107,13 +360,13 @@ async function main() {
107
360
  if (!query || query.startsWith("--")) usage();
108
361
  const flags = parseFlags(rest.slice(1));
109
362
  syncScope(project.key);
110
- syncScope(USER_SCOPE.key);
363
+ syncScope(PERSONAL_SCOPE.key);
111
364
  const keys =
112
- flags.scope === "user"
113
- ? [USER_SCOPE.key]
365
+ flags.scope === "personal" || flags.scope === "user"
366
+ ? [PERSONAL_SCOPE.key]
114
367
  : flags.scope === "project"
115
368
  ? [project.key]
116
- : [project.key, USER_SCOPE.key];
369
+ : [project.key, PERSONAL_SCOPE.key];
117
370
  const hits = search(query, {
118
371
  scopeKeys: keys,
119
372
  limit: flags.limit ? Number(flags.limit) : undefined,
@@ -124,8 +377,9 @@ async function main() {
124
377
  return;
125
378
  }
126
379
  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()}`);
380
+ const tag = h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project";
381
+ const dep = h.status === "deprecated" ? " [deprecated]" : "";
382
+ console.log(`[${tag}/${h.type}]${dep} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
129
383
  }
130
384
  return;
131
385
  }
@@ -134,19 +388,29 @@ async function main() {
134
388
  const content = rest[0];
135
389
  if (!content || content.startsWith("--")) usage();
136
390
  const flags = parseFlags(rest.slice(1));
137
- const s = flags.scope === "user" ? USER_SCOPE : project;
391
+ const s = resolveCliScope(flags, project);
138
392
  const { content: red, hadSecret, matchedPattern } = redact(content, cfg.redactPatterns);
139
- if (hadSecret) {
140
- console.error(`refused: content matched secret pattern (${matchedPattern}).`);
141
- process.exit(2);
393
+ // Dedup on write (§3.4): identical content is idempotent; near-duplicates
394
+ // warn but still save (use `supersede` when this replaces the old one).
395
+ // Secrets are masked (first 4 chars kept) and the write proceeds.
396
+ const dups = findDuplicates(s.key, red);
397
+ if (dups.exact) {
398
+ console.log(`already exists: id=${dups.exact.id} (identical content — not duplicated)`);
399
+ return;
142
400
  }
143
401
  const now = Date.now();
402
+ const rfc = msToRfc3339(now);
144
403
  const fm: Frontmatter = {
145
404
  id: ulid(),
405
+ schema_version: 2,
146
406
  scope_key: s.key,
147
- scope_kind: s.kind,
407
+ scope: s.kind === "project" ? "project" : "personal",
408
+ visibility: s.kind === "project" ? "internal" : "private",
148
409
  project_name: s.projectName,
149
- type: flags.type ?? "note",
410
+ type: flags.type ?? "fact",
411
+ role: "knowledge",
412
+ importance: "normal",
413
+ status: "active",
150
414
  tags: flags.tag
151
415
  ? flags.tag
152
416
  .split(",")
@@ -154,13 +418,70 @@ async function main() {
154
418
  .filter(Boolean)
155
419
  : [],
156
420
  source: "cli",
157
- created_at: now,
158
- updated_at: now,
421
+ created_at: rfc,
422
+ updated_at: rfc,
423
+ supersedes: null,
424
+ superseded_by: null,
159
425
  };
160
426
  const { filePath } = writeMemoryFile(fm, red);
161
427
  const mf = readMemoryFile(filePath);
162
428
  if (mf) upsertFromFile(mf);
163
- console.log(`saved ${fm.id} -> ${filePath}`);
429
+ console.log(`saved ${fm.id} [${s.kind}] -> ${filePath}`);
430
+ if (hadSecret) {
431
+ console.log(
432
+ `warning: content matched secret pattern (${matchedPattern}); saved with the secret masked (first 4 chars kept).`,
433
+ );
434
+ }
435
+ for (const n of dups.near) {
436
+ console.log(
437
+ `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.`,
438
+ );
439
+ }
440
+ return;
441
+ }
442
+
443
+ if (cmd === "supersede") {
444
+ const id = rest[0];
445
+ const content = rest[1];
446
+ if (!id || !content || content.startsWith("--")) usage();
447
+ const flags = parseFlags(rest.slice(2));
448
+ const { content: red, hadSecret, matchedPattern } = redact(content, cfg.redactPatterns);
449
+ try {
450
+ const { oldMf, newMf } = supersede(id, {
451
+ body: red,
452
+ type: flags.type,
453
+ tags: flags.tag
454
+ ? flags.tag.split(",").map((t) => t.trim()).filter(Boolean)
455
+ : undefined,
456
+ source: "cli",
457
+ });
458
+ upsertFromFile(oldMf);
459
+ upsertFromFile(newMf);
460
+ console.log(`superseded ${oldMf.fm.id} → ${newMf.fm.id}`);
461
+ if (hadSecret) {
462
+ console.log(
463
+ `warning: content matched secret pattern (${matchedPattern}); saved with the secret masked (first 4 chars kept).`,
464
+ );
465
+ }
466
+ } catch (e) {
467
+ console.error(`supersede failed: ${(e as Error).message}`);
468
+ process.exit(2);
469
+ }
470
+ return;
471
+ }
472
+
473
+ if (cmd === "status") {
474
+ const id = rest[0];
475
+ const st = rest[1];
476
+ if (!id || !st) usage();
477
+ try {
478
+ const mf = setStatus(id, st as "active" | "deprecated" | "retracted" | "archived");
479
+ upsertFromFile(mf);
480
+ console.log(`status ${id} → ${mf.fm.status}`);
481
+ } catch (e) {
482
+ console.error(`status failed: ${(e as Error).message}`);
483
+ process.exit(2);
484
+ }
164
485
  return;
165
486
  }
166
487
 
@@ -193,7 +514,7 @@ async function main() {
193
514
  const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")).length;
194
515
  const marker =
195
516
  name === project.key ? " <- current project"
196
- : name === USER_SCOPE.key ? " <- user"
517
+ : name === PERSONAL_SCOPE.key ? " <- personal"
197
518
  : "";
198
519
  entries.push({ key: name, files, marker });
199
520
  }
package/src/config.ts CHANGED
@@ -7,7 +7,10 @@ export interface MyOMemoryConfig {
7
7
  maxProfileItems: number;
8
8
  injectOnFirstTurn: boolean;
9
9
  keywordCaptureEnabled: boolean;
10
+ /** Patterns whose capture group 1 becomes the memory body; saved to the current scope. */
10
11
  keywordPatterns: string[];
12
+ /** Same shape, but hits are forced into the personal scope (e.g. "remember for me"). */
13
+ keywordPersonalPatterns: string[];
11
14
  redactPatterns: string[];
12
15
  logLevel: "debug" | "info" | "warn" | "error";
13
16
  }
@@ -18,21 +21,31 @@ export const DEFAULT_CONFIG: MyOMemoryConfig = {
18
21
  injectOnFirstTurn: true,
19
22
  keywordCaptureEnabled: true,
20
23
  keywordPatterns: [
21
- "^\\s*remember(?:\\s+that)?[:,]?\\s+(.+)$",
24
+ "^\\s*remember(?!\\s+for\\s+me)(?:\\s+that)?[:,]?\\s+(.+)$",
22
25
  "^\\s*(?:please\\s+)?(?:note|don'?t\\s+forget)(?:\\s+that)?[:,]?\\s+(.+)$",
23
26
  "^\\s*TIL[:,]?\\s+(.+)$",
24
27
  "^\\s*save\\s+(?:this|to\\s+memory)[:,]?\\s+(.+)$",
28
+ // Chinese equivalents
29
+ "^\\s*记住(?!(个人))[::,,]?\\s*(.+)$",
30
+ "^\\s*(?:请)?(?:记一下|记录一下)[::,,]?\\s*(.+)$",
31
+ "^\\s*别忘了[::,,]?\\s*(.+)$",
32
+ // First-person plural: team/project context, NOT personal
33
+ "^\\s*我们认为[::,,]?\\s*(.+)$",
34
+ "^\\s*我们决定[::,,]?\\s*(.+)$",
35
+ "^\\s*帮我们记(?:住|一下)?[::,,]?\\s*(.+)$",
25
36
  ],
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,}",
37
+ keywordPersonalPatterns: [
38
+ "^\\s*remember\\s+for\\s+me(?:\\s+that)?[:,]?\\s+(.+)$",
39
+ "^\\s*记住(个人)[::,,]?\\s*(.+)$",
40
+ // First-person singular: personal scope ("我" → 个人, "我们" → 项目)
41
+ "^\\s*记住我(?!们)[::,,]?\\s*(.+)$",
42
+ "^\\s*替我记(?:住|一下)?[::,,]?\\s*(.+)$",
43
+ "^\\s*我觉得[::,,]?\\s*(.+)$",
44
+ "^\\s*我喜欢[::,,]?\\s*(.+)$",
35
45
  ],
46
+ // Built-in provider patterns now live in src/redact.ts (always on).
47
+ // Add only your own extra patterns here.
48
+ redactPatterns: [],
36
49
  logLevel: "info",
37
50
  };
38
51
 
@@ -46,15 +59,80 @@ function stripJsonComments(raw: string): string {
46
59
  return out;
47
60
  }
48
61
 
49
- export function loadConfig(): MyOMemoryConfig {
62
+ /** Where `config set` / interactive `init` persist. Respects MY_O_MEMORY_CONFIG. */
63
+ export function configFilePath(): string {
64
+ return (
65
+ process.env.MY_O_MEMORY_CONFIG ??
66
+ path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc")
67
+ );
68
+ }
69
+
70
+ function toBool(v: unknown): boolean {
71
+ if (typeof v === "boolean") return v;
72
+ if (v === "true" || v === "1") return true;
73
+ if (v === "false" || v === "0") return false;
74
+ throw new Error("must be true/false");
75
+ }
76
+
77
+ function toNonNegInt(v: unknown): number {
78
+ const n = Number(v);
79
+ if (!Number.isInteger(n) || n < 0) throw new Error("must be a non-negative integer");
80
+ return n;
81
+ }
82
+
83
+ /** Keys users may change via `open-memex config set <key> <value>`, with validators. */
84
+ export const SETTABLE_KEYS: Record<string, (v: unknown) => unknown> = {
85
+ maxProjectMemories: toNonNegInt,
86
+ maxProfileItems: toNonNegInt,
87
+ injectOnFirstTurn: toBool,
88
+ keywordCaptureEnabled: toBool,
89
+ logLevel: (v) => {
90
+ if (v !== "info" && v !== "debug") throw new Error('must be "info" or "debug"');
91
+ return v;
92
+ },
93
+ };
94
+
95
+ /** Merge a patch into the config file (creates it if missing). Returns the file path. */
96
+ export function saveConfig(patch: Record<string, unknown>): string {
97
+ const file = configFilePath();
98
+ let cur: Record<string, unknown> = {};
99
+ if (fs.existsSync(file)) {
100
+ try {
101
+ cur = JSON.parse(stripJsonComments(fs.readFileSync(file, "utf8"))) as Record<
102
+ string,
103
+ unknown
104
+ >;
105
+ } catch {
106
+ console.error(`[open-memex] ${file} is not valid JSONC — it will be replaced`);
107
+ }
108
+ }
109
+ const next = { ...cur, ...patch };
110
+ fs.mkdirSync(path.dirname(file), { recursive: true });
111
+ fs.writeFileSync(
112
+ file,
113
+ "// Managed by `open-memex config set` / `open-memex init` — edit freely (JSONC).\n" +
114
+ JSON.stringify(next, null, 2) +
115
+ "\n",
116
+ );
117
+ return file;
118
+ }
119
+
120
+ /** Path of the config file in effect, or null when using built-in defaults. */
121
+ export function configSource(): string | null {
50
122
  const candidates = [
51
123
  process.env.MY_O_MEMORY_CONFIG,
52
124
  path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc"),
53
125
  path.join(os.homedir(), ".config", "opencode", "open-memex.json"),
54
126
  ].filter(Boolean) as string[];
55
-
56
127
  for (const p of candidates) {
57
- if (!fs.existsSync(p)) continue;
128
+ if (fs.existsSync(p)) return p;
129
+ }
130
+ return null;
131
+ }
132
+
133
+ export function loadConfig(): MyOMemoryConfig {
134
+ const p = configSource();
135
+ if (p) {
58
136
  try {
59
137
  const raw = fs.readFileSync(p, "utf8");
60
138
  const parsed = JSON.parse(stripJsonComments(raw)) as Partial<MyOMemoryConfig>;