open-memex 0.6.2 → 0.7.0-alpha.11

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.
Files changed (68) hide show
  1. package/AGENTS.md +25 -7
  2. package/CHANGELOG.md +103 -1
  3. package/README.md +54 -16
  4. package/README.zh-CN.md +30 -15
  5. package/dist/.build-stamp +1 -0
  6. package/dist/capture/keywords.js +41 -8
  7. package/dist/cli.js +259 -40
  8. package/dist/config.js +102 -18
  9. package/dist/distill-agents.js +9 -1
  10. package/dist/doctor.js +180 -79
  11. package/dist/export.js +12 -2
  12. package/dist/index.js +20 -112
  13. package/dist/init.js +103 -11
  14. package/dist/inventory-html.js +150 -0
  15. package/dist/inventory.js +176 -0
  16. package/dist/mcp.js +21 -5
  17. package/dist/opencode-v2.js +43 -0
  18. package/dist/paths.js +34 -2
  19. package/dist/plugin-core.js +138 -0
  20. package/dist/retrieve/display.js +161 -0
  21. package/dist/retrieve/inject.js +1 -1
  22. package/dist/retrieve/search.js +44 -5
  23. package/dist/review.js +9 -3
  24. package/dist/store/db.js +11 -9
  25. package/dist/store/lifecycle.js +20 -4
  26. package/dist/store/markdown.js +53 -7
  27. package/dist/store/migrate.js +14 -3
  28. package/dist/store/sync.js +5 -3
  29. package/dist/store/v2migrate.js +2 -2
  30. package/dist/submit.js +13 -5
  31. package/dist/tools/memory.js +28 -0
  32. package/dist/tools/ops.js +127 -16
  33. package/docs/CURATOR.md +7 -3
  34. package/docs/V2-DESIGN.md +335 -32
  35. package/docs/memory-visibility-design.md +130 -0
  36. package/package.json +2 -2
  37. package/scripts/build-stamp.mjs +63 -0
  38. package/scripts/smoke-mcp.ts +47 -1
  39. package/scripts/smoke-pure.ts +237 -3
  40. package/scripts/test-full.ts +362 -5
  41. package/skills/open-memex/SKILL.md +53 -1
  42. package/src/capture/keywords.ts +56 -6
  43. package/src/cli.ts +282 -39
  44. package/src/config.ts +104 -17
  45. package/src/distill-agents.ts +19 -0
  46. package/src/doctor.ts +181 -71
  47. package/src/export.ts +14 -2
  48. package/src/index.ts +25 -112
  49. package/src/init.ts +108 -11
  50. package/src/inventory-html.ts +191 -0
  51. package/src/inventory.ts +242 -0
  52. package/src/mcp.ts +21 -5
  53. package/src/opencode-v2.ts +119 -0
  54. package/src/paths.ts +35 -1
  55. package/src/plugin-core.ts +152 -0
  56. package/src/retrieve/display.ts +174 -0
  57. package/src/retrieve/inject.ts +1 -1
  58. package/src/retrieve/search.ts +63 -6
  59. package/src/review.ts +9 -2
  60. package/src/store/db.ts +11 -9
  61. package/src/store/lifecycle.ts +24 -3
  62. package/src/store/markdown.ts +60 -7
  63. package/src/store/migrate.ts +15 -2
  64. package/src/store/sync.ts +6 -3
  65. package/src/store/v2migrate.ts +2 -1
  66. package/src/submit.ts +12 -4
  67. package/src/tools/memory.ts +28 -0
  68. package/src/tools/ops.ts +147 -17
package/dist/doctor.js CHANGED
@@ -7,17 +7,25 @@ import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { spawn, execFileSync } from "node:child_process";
9
9
  import { fileURLToPath } from "node:url";
10
- import { loadConfig, configSource, DEFAULT_CONFIG } from "./config.js";
11
- import { paths } from "./paths.js";
10
+ import { loadConfig, configSource, DEFAULT_CONFIG, stripJsonComments } from "./config.js";
11
+ import { paths, homeOverride } from "./paths.js";
12
12
  import { resolveCwdScope } from "./scope.js";
13
13
  import { db, backendName } from "./store/db.js";
14
14
  import { LEGACY_PACKAGE_NAME, opencodeGlobalConfigPath, userMcpConfigPath } from "./init.js";
15
+ // D64: the MCP server exposes eleven tools; checking only the five the
16
+ // opencode plugin carries let a half-registered server pass as healthy.
15
17
  const EXPECTED_TOOLS = [
16
18
  "memory_add",
17
19
  "memory_search",
18
20
  "memory_list",
19
21
  "memory_supersede",
20
22
  "memory_forget",
23
+ "memory_status",
24
+ "memory_submit",
25
+ "memory_propose",
26
+ "memory_promote",
27
+ "memory_resolve",
28
+ "memory_pr_status",
21
29
  ];
22
30
  function nodeCheck() {
23
31
  const [major, minor] = process.versions.node.split(".").map(Number);
@@ -54,7 +62,13 @@ function storageCheck() {
54
62
  try {
55
63
  const p = paths();
56
64
  fs.accessSync(p.memories, fs.constants.W_OK);
57
- return { name: "storage", ok: true, detail: `${p.root} (writable)` };
65
+ // D62: when the root came from the legacy env var, say so (still ok —
66
+ // the fallback is supported, doctor just names the current one).
67
+ const via = homeOverride()?.via;
68
+ const note = via === "MY_O_MEMORY_HOME"
69
+ ? " (via legacy MY_O_MEMORY_HOME — OPEN_MEMEX_HOME is the current name)"
70
+ : "";
71
+ return { name: "storage", ok: true, detail: `${p.root} (writable)${note}` };
58
72
  }
59
73
  catch (err) {
60
74
  return { name: "storage", ok: false, detail: String(err) };
@@ -82,14 +96,17 @@ function lockingCheck() {
82
96
  }
83
97
  }
84
98
  /**
85
- * D59: patrol the opencode native-plugin entry. The D58 field failure was
86
- * silent — the entry was configured, the host never loaded it, and nothing
87
- * anywhere said why. Two death modes to catch:
88
- * 1. the entry's target file does not exist (package moved, nvm rolled
89
- * to a new version dir, checkout deleted);
90
- * 2. the plugin's import closure runtime-imports @opencode-ai/plugin
91
- * (or another host-only module the package does not depend on),
92
- * which is unresolvable from a global install (D58 regression).
99
+ * D59/D60: patrol the opencode native-plugin entries. The D58 field failure
100
+ * was silent — the entry was configured, the host never loaded it, and
101
+ * nothing anywhere said why. Death modes to catch:
102
+ * 1. the entry's target does not exist (package moved, nvm rolled to a
103
+ * new version dir, checkout deleted);
104
+ * 2. the plugin's import closure runtime-imports a host SDK package
105
+ * (@opencode-ai/plugin on v1, @opencode/plugin on v2) — unresolvable
106
+ * from a global install, the host silently skips the plugin (D58);
107
+ * 3. (D60) the config wires only the other host generation: an
108
+ * opencode 2 host never reads the v1 `plugin` file entry (it demands
109
+ * a directory under `plugins`), and opencode 1 cannot read `plugins`.
93
110
  * Read-only; only inspects the user-level opencode configs.
94
111
  */
95
112
  function opencodePluginCheck() {
@@ -97,8 +114,18 @@ function opencodePluginCheck() {
97
114
  try {
98
115
  const jsonPath = opencodeGlobalConfigPath();
99
116
  const candidates = [jsonPath, jsonPath.replace(/\.json$/, ".jsonc")];
100
- const entries = [];
117
+ const v1Entries = [];
118
+ const v2Entries = [];
101
119
  let sawConfig = false;
120
+ const v2EntryPackage = (entry) => {
121
+ if (typeof entry === "string")
122
+ return entry;
123
+ if (entry !== null && typeof entry === "object" && "package" in entry) {
124
+ const p = entry.package;
125
+ return typeof p === "string" ? p : null;
126
+ }
127
+ return null;
128
+ };
102
129
  for (const f of candidates) {
103
130
  if (!fs.existsSync(f))
104
131
  continue;
@@ -106,17 +133,24 @@ function opencodePluginCheck() {
106
133
  const text = fs.readFileSync(f, "utf8");
107
134
  try {
108
135
  const doc = JSON.parse(text);
109
- const list = Array.isArray(doc.plugin) ? doc.plugin : [];
110
- for (const e of list) {
111
- if (typeof e === "string")
112
- entries.push(e);
136
+ if (Array.isArray(doc.plugin)) {
137
+ for (const e of doc.plugin)
138
+ if (typeof e === "string")
139
+ v1Entries.push(e);
140
+ }
141
+ if (Array.isArray(doc.plugins)) {
142
+ for (const e of doc.plugins) {
143
+ const p = v2EntryPackage(e);
144
+ if (p !== null)
145
+ v2Entries.push(p);
146
+ }
113
147
  }
114
148
  }
115
149
  catch {
116
150
  // JSONC (comments/trailing commas): fall back to scanning quoted
117
151
  // strings for plugin entries.
118
152
  for (const m of text.matchAll(/"(file:[^"]+|[^"]+\.(?:ts|js|mts|mjs))"/g)) {
119
- entries.push(m[1]);
153
+ v1Entries.push(m[1]);
120
154
  }
121
155
  }
122
156
  }
@@ -124,24 +158,71 @@ function opencodePluginCheck() {
124
158
  return { name, ok: true, detail: "no global opencode config found (skipped)" };
125
159
  }
126
160
  const problems = [];
127
- const RUNTIME_SDK_IMPORT = /^\s*import\s+(?!type\b)[^;]*?["']@opencode-ai\/plugin(?:\/[^"']*)?["']/m;
128
- const RUNTIME_SDK_REQUIRE = /require\(\s*["']@opencode-ai\/plugin(?:\/[^"']*)?["']\s*\)/;
161
+ // Host SDK packages per generation; a runtime import of either from a
162
+ // globally installed plugin cannot resolve (D58).
163
+ const RUNTIME_SDK_IMPORT = /^\s*import\s+(?!type\b)[^;]*?["']@opencode(?:-ai)?\/plugin(?:\/[^"']*)?["']/m;
164
+ const RUNTIME_SDK_REQUIRE = /require\(\s*["']@opencode(?:-ai)?\/plugin(?:\/[^"']*)?["']\s*\)/;
165
+ const toPath = (entry) => {
166
+ try {
167
+ return entry.startsWith("file:") ? fileURLToPath(entry) : entry;
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ };
129
173
  // An entry is ours when its path names the package, or when the target
130
174
  // file carries the plugin's "[open-memex]" log marker (source checkouts
131
175
  // can live at any path).
132
- const isOurEntry = (entry) => {
133
- if (entry.includes("open-memex"))
134
- return true;
176
+ const fileHasMarker = (target) => {
135
177
  try {
136
- const target = entry.startsWith("file:") ? fileURLToPath(entry) : entry;
137
- if (!fs.existsSync(target))
138
- return false;
139
178
  return fs.readFileSync(target, "utf8").slice(0, 65536).includes("[open-memex]");
140
179
  }
141
180
  catch {
142
181
  return false;
143
182
  }
144
183
  };
184
+ /** Resolve an OpenCode 2 directory entry to its entry file, if any. */
185
+ const resolveV2EntryFile = (target) => {
186
+ try {
187
+ if (!fs.existsSync(target))
188
+ return null;
189
+ if (fs.statSync(target).isFile())
190
+ return target;
191
+ for (const idx of ["index.ts", "index.js", "index.mts", "index.mjs"]) {
192
+ const p = path.join(target, idx);
193
+ if (fs.existsSync(p))
194
+ return p;
195
+ }
196
+ const pkgJson = path.join(target, "package.json");
197
+ if (fs.existsSync(pkgJson)) {
198
+ const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf8"));
199
+ if (typeof pkg.main === "string") {
200
+ const main = path.join(target, pkg.main);
201
+ if (fs.existsSync(main))
202
+ return main;
203
+ }
204
+ }
205
+ }
206
+ catch {
207
+ return null;
208
+ }
209
+ return null;
210
+ };
211
+ const isOursV1 = (entry) => {
212
+ if (entry.includes("open-memex"))
213
+ return true;
214
+ const target = toPath(entry);
215
+ return target !== null && fs.existsSync(target) && fileHasMarker(target);
216
+ };
217
+ const isOursV2 = (entry) => {
218
+ if (entry.includes("open-memex"))
219
+ return true;
220
+ const target = toPath(entry);
221
+ if (target === null)
222
+ return false;
223
+ const file = resolveV2EntryFile(target);
224
+ return file !== null && fileHasMarker(file);
225
+ };
145
226
  const scanSdkImports = (entryFile) => {
146
227
  // Walk the entry's relative-import closure looking for host-SDK
147
228
  // runtime imports (type-only imports are erased and fine).
@@ -176,16 +257,14 @@ function opencodePluginCheck() {
176
257
  }
177
258
  return offenders;
178
259
  };
179
- const ours = entries.filter(isOurEntry);
180
- if (ours.length === 0) {
260
+ const oursV1 = v1Entries.filter(isOursV1);
261
+ const oursV2 = v2Entries.filter(isOursV2);
262
+ if (oursV1.length === 0 && oursV2.length === 0) {
181
263
  return { name, ok: true, detail: "no global plugin entry (per-project or MCP wiring is not checked)" };
182
264
  }
183
- for (const entry of ours) {
184
- let target;
185
- try {
186
- target = entry.startsWith("file:") ? fileURLToPath(entry) : entry;
187
- }
188
- catch {
265
+ for (const entry of oursV1) {
266
+ const target = toPath(entry);
267
+ if (target === null) {
189
268
  problems.push(`entry "${entry}" is not a parseable file URL`);
190
269
  continue;
191
270
  }
@@ -194,16 +273,62 @@ function opencodePluginCheck() {
194
273
  continue;
195
274
  }
196
275
  for (const f of scanSdkImports(target)) {
197
- problems.push(`${f} runtime-imports @opencode-ai/plugin — opencode silently skips plugins whose imports it cannot resolve from a global install (D58)`);
276
+ problems.push(`${f} runtime-imports a host SDK package — opencode silently skips plugins whose imports it cannot resolve from a global install (D58)`);
277
+ }
278
+ }
279
+ for (const entry of oursV2) {
280
+ // A bare package name ("open-memex" via `opencode plugin add`) is
281
+ // resolved by the host's plugin manager; there is no local path to
282
+ // verify.
283
+ const isPackageName = !entry.startsWith("file:") &&
284
+ !entry.startsWith(".") &&
285
+ !path.isAbsolute(entry) &&
286
+ !/^[a-zA-Z]:[\\/]/.test(entry);
287
+ if (isPackageName)
288
+ continue;
289
+ const target = toPath(entry);
290
+ if (target === null || !fs.existsSync(target)) {
291
+ problems.push(`opencode 2 entry "${entry}" does not resolve to an existing path — run \`open-memex init --client opencode --global --force\` to re-point it`);
292
+ continue;
293
+ }
294
+ const entryFile = resolveV2EntryFile(target);
295
+ if (entryFile === null) {
296
+ problems.push(`opencode 2 entry "${entry}" has no loadable plugin file (looked for index.ts/index.js and package.json main)`);
297
+ continue;
298
+ }
299
+ for (const f of scanSdkImports(entryFile)) {
300
+ problems.push(`${f} runtime-imports a host SDK package — opencode silently skips plugins whose imports it cannot resolve from a global install (D58)`);
198
301
  }
199
302
  }
303
+ // D60: generation mismatch — the config wires only the generation the
304
+ // installed host cannot read. Best-effort: no binary, no opinion.
305
+ let hostMajor = null;
306
+ try {
307
+ const out = execFileSync("opencode", ["--version"], {
308
+ timeout: 3000,
309
+ encoding: "utf8",
310
+ stdio: ["ignore", "pipe", "ignore"],
311
+ });
312
+ const m = out.match(/(\d+)\.(\d+)\.(\d+)/);
313
+ if (m)
314
+ hostMajor = Number(m[1]);
315
+ }
316
+ catch {
317
+ hostMajor = null;
318
+ }
319
+ if (hostMajor !== null && hostMajor >= 2 && oursV1.length > 0 && oursV2.length === 0) {
320
+ problems.push(`the installed opencode is v${hostMajor} but the config only has the opencode 1 "plugin" entry — v2 loads directories from "plugins" and will silently skip this plugin; run \`open-memex init --client opencode --global --force\` to add the v2 entry`);
321
+ }
322
+ else if (hostMajor === 1 && oursV2.length > 0 && oursV1.length === 0) {
323
+ problems.push(`the installed opencode is v1 but the config only has the opencode 2 "plugins" entry — v1 reads the "plugin" key and will not load this plugin; run \`open-memex init --client opencode --global --force\` to add the v1 entry`);
324
+ }
200
325
  if (problems.length > 0) {
201
326
  return { name, ok: false, detail: problems.join("; ") };
202
327
  }
203
328
  return {
204
329
  name,
205
330
  ok: true,
206
- detail: `${ours.length} plugin ${ours.length === 1 ? "entry" : "entries"}: target exists, no host-SDK runtime imports`,
331
+ detail: `${oursV1.length + oursV2.length} plugin ${oursV1.length + oursV2.length === 1 ? "entry" : "entries"}: target exists, no host-SDK runtime imports`,
207
332
  };
208
333
  }
209
334
  catch (err) {
@@ -290,7 +415,8 @@ function mcpCheck() {
290
415
  });
291
416
  }
292
417
  /** Parse JSON tolerating line/block comments plus trailing commas
293
- * (VS Code's settings.json is JSONC). Health-check grade, not a full parser. */
418
+ * (VS Code's settings.json is JSONC). Uses config.ts's string-aware
419
+ * stripper — one parser, not two (P0 review, 2026-10-03). */
294
420
  function parseLenientJson(raw) {
295
421
  try {
296
422
  return JSON.parse(raw);
@@ -298,47 +424,7 @@ function parseLenientJson(raw) {
298
424
  catch {
299
425
  /* fall through to comment stripping */
300
426
  }
301
- let out = "";
302
- let i = 0;
303
- let inStr = false;
304
- let esc = false;
305
- while (i < raw.length) {
306
- const c = raw[i];
307
- const n = raw[i + 1];
308
- if (inStr) {
309
- out += c;
310
- if (esc)
311
- esc = false;
312
- else if (c === "\\")
313
- esc = true;
314
- else if (c === '"')
315
- inStr = false;
316
- i++;
317
- continue;
318
- }
319
- if (c === '"') {
320
- inStr = true;
321
- out += c;
322
- i++;
323
- continue;
324
- }
325
- if (c === "/" && n === "/") {
326
- while (i < raw.length && raw[i] !== "\n")
327
- i++;
328
- continue;
329
- }
330
- if (c === "/" && n === "*") {
331
- i += 2;
332
- while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/"))
333
- i++;
334
- i += 2;
335
- continue;
336
- }
337
- out += c;
338
- i++;
339
- }
340
- out = out.replace(/,\s*([}\]])/g, "$1");
341
- return JSON.parse(out);
427
+ return JSON.parse(stripJsonComments(raw));
342
428
  }
343
429
  function vscodeSettingsPath() {
344
430
  const home = os.homedir();
@@ -448,13 +534,28 @@ export async function runDoctor() {
448
534
  function legacyCheck() {
449
535
  const name = "legacy my-o-memory";
450
536
  const found = [];
451
- const envHome = process.env.MY_O_MEMORY_HOME;
537
+ // D62: the override may arrive via either env var name; check the value
538
+ // in effect, not just the legacy one.
539
+ const envHome = homeOverride()?.dir;
452
540
  if (envHome && envHome.includes(LEGACY_PACKAGE_NAME))
453
- found.push(`MY_O_MEMORY_HOME points at a pre-rename dir (${envHome}) — unset it, then merge old data with \`open-memex migrate --to-v2\``);
541
+ found.push(`memory home points at a pre-rename dir (${envHome}) — unset it, then merge old data with \`open-memex migrate --to-v2\``);
454
542
  const root = paths().root;
455
543
  const legacyDir = path.join(path.dirname(root), LEGACY_PACKAGE_NAME);
456
544
  if (fs.existsSync(legacyDir) && fs.statSync(legacyDir).isDirectory())
457
545
  found.push(`legacy data dir ${legacyDir} — merge it with \`open-memex migrate --to-v2\``);
546
+ // D64: v1 stored personal memories under the scope dir "user"; v2 reads
547
+ // only "personal". Files left there are invisible to every read path —
548
+ // an upgrader silently sees an empty personal scope. Surface them here;
549
+ // the migration itself stays explicit (no silent moves).
550
+ try {
551
+ const userDir = path.join(root, "memories", "user");
552
+ if (fs.existsSync(userDir) &&
553
+ fs.readdirSync(userDir).some((f) => f.endsWith(".md")))
554
+ found.push("v1 personal memories under " + userDir + " are invisible to v2 reads — convert them with `open-memex migrate --to-v2` (it moves them to the personal scope; preview with --dry-run)");
555
+ }
556
+ catch {
557
+ /* unreadable — not this check's problem */
558
+ }
458
559
  for (const f of [opencodeGlobalConfigPath(), userMcpConfigPath("vscode"), userMcpConfigPath("cursor")]) {
459
560
  try {
460
561
  if (fs.existsSync(f) && fs.readFileSync(f, "utf8").includes(LEGACY_PACKAGE_NAME))
package/dist/export.js CHANGED
@@ -117,10 +117,20 @@ export function importBundle(bundlePath, opts) {
117
117
  if (manifest.format !== EXPORT_FORMAT) {
118
118
  fail(`unsupported bundle format: ${manifest.format} (expected ${EXPORT_FORMAT})`);
119
119
  }
120
- const result = { imported: 0, skippedIdentical: 0, skippedConflict: [] };
120
+ const result = { imported: 0, skippedIdentical: 0, skippedConflict: [], rejectedPaths: [] };
121
121
  const existingStmt = db().prepare(`SELECT content_hash FROM memories WHERE id = ?`);
122
+ // The manifest comes from whoever sent the bundle: entry.file must
123
+ // stay inside the staging dir (P0 review, 2026-10-03 — "../" entries
124
+ // otherwise read arbitrary .md files into the store). realpath also
125
+ // catches symlinks planted by the tarball.
126
+ const stageRoot = fs.realpathSync(stage);
122
127
  for (const entry of manifest.memories ?? []) {
123
- const src = path.join(stage, entry.file);
128
+ const joined = path.resolve(path.join(stage, entry.file));
129
+ const src = fs.existsSync(joined) ? fs.realpathSync(joined) : joined;
130
+ if (!src.startsWith(stageRoot + path.sep)) {
131
+ result.rejectedPaths.push(entry.file);
132
+ continue;
133
+ }
124
134
  const mf = readMemoryFile(src);
125
135
  if (!mf)
126
136
  continue;
package/dist/index.js CHANGED
@@ -1,127 +1,35 @@
1
- import { loadConfig } from "./config.js";
2
- import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE } from "./scope.js";
3
- import { db } from "./store/db.js";
4
- import { syncScope, upsertFromFile } from "./store/sync.js";
5
- import { scopeHasFiles } from "./store/migrate.js";
6
- import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, } from "./store/markdown.js";
7
- import { buildContextBlock } from "./retrieve/inject.js";
8
- import { detectKeywords } from "./capture/keywords.js";
9
- import { findDuplicates } from "./store/lifecycle.js";
10
- import { redact } from "./redact.js";
1
+ import { PLUGIN_ID, bootstrapPlugin, captureFromText, injectOnce, } from "./plugin-core.js";
11
2
  import { makeTools } from "./tools/memory.js";
12
- const plugin = async ({ worktree, directory }) => {
13
- const cfg = loadConfig();
3
+ import { v2Plugin } from "./opencode-v2.js";
4
+ /**
5
+ * One entrypoint, both opencode generations (D60):
6
+ * - OpenCode 2 structurally decodes the default export as `{ id, setup }`
7
+ * and ignores `server` (see src/opencode-v2.ts).
8
+ * - OpenCode 1 (≥1.18.29) reads `server` off the same default export and
9
+ * ignores `setup` — the function below is the v1 plugin exactly as it
10
+ * behaved before dual support.
11
+ * Neither host runtime-imports an SDK package from this file: v1's SDK is
12
+ * type-only (D58), and v2's decode is structural.
13
+ */
14
+ const server = async ({ worktree, directory }) => {
14
15
  const roots = worktree || directory || process.cwd();
15
- const scope = resolveProjectScope(roots);
16
- // Init DB and one-shot sync of markdown -> index on plugin load.
17
- try {
18
- db();
19
- syncScope(scope.key);
20
- syncScope(PERSONAL_SCOPE.key);
21
- if (cfg.logLevel === "debug") {
22
- console.log(`[open-memex] loaded. scope=${scope.key}`);
23
- }
24
- // If a git remote was added *after* memories were first captured, the
25
- // scope key changes (cwd-hash -> origin-hash). Detect the legacy dir on
26
- // disk and tell the user how to migrate. We do NOT auto-migrate: two
27
- // different repos at the same cwd would collide.
28
- const cwdScope = resolveCwdScope(roots);
29
- if (cwdScope.key !== scope.key && scopeHasFiles(cwdScope.key)) {
30
- console.warn(`[open-memex] found memories under legacy scope key "${cwdScope.key}". ` +
31
- `Current project scope is "${scope.key}". ` +
32
- `To move them: npm run cli -- migrate --from ${cwdScope.key}`);
33
- }
34
- }
35
- catch (err) {
36
- console.error("[open-memex] init failed:", err);
37
- }
38
- const tools = makeTools(() => scope, cfg);
39
- const injectedSessions = new Set();
16
+ const state = bootstrapPlugin(roots);
17
+ const tools = makeTools(() => state.scope, state.cfg);
40
18
  return {
41
19
  tool: tools,
42
20
  async "chat.message"(_input, output) {
43
- if (!cfg.keywordCaptureEnabled)
44
- return;
45
21
  const parts = (output.parts ?? []);
46
22
  const text = parts
47
23
  .map((p) => (p?.type === "text" ? p.text ?? "" : ""))
48
24
  .filter(Boolean)
49
25
  .join("\n");
50
- if (!text)
51
- return;
52
- const hits = detectKeywords(text, cfg);
53
- for (const h of hits) {
54
- const { content, hadSecret, matchedPattern } = redact(h.content, cfg.redactPatterns);
55
- // Secrets are masked (first 4 chars kept) and the capture proceeds;
56
- // skip only when nothing usable remains.
57
- if (content.length === 0)
58
- continue;
59
- if (hadSecret && cfg.logLevel === "debug") {
60
- console.log(`[open-memex] keyword capture masked secret (${matchedPattern})`);
61
- }
62
- // Personal patterns ("remember for me" / "记住(个人)") force the personal scope.
63
- const target = h.personal ? PERSONAL_SCOPE : scope;
64
- // Dedup (§3.4): skip exact duplicates captured before.
65
- if (findDuplicates(target.key, content).exact)
66
- continue;
67
- const now = Date.now();
68
- const rfc = msToRfc3339(now);
69
- const fm = {
70
- id: ulid(),
71
- schema_version: 2,
72
- scope_key: target.key,
73
- scope: target.kind === "project" ? "project" : "personal",
74
- visibility: target.kind === "project" ? "internal" : "private",
75
- project_name: target.projectName,
76
- type: "fact",
77
- role: "knowledge",
78
- importance: "normal",
79
- status: "active",
80
- tags: ["keyword"],
81
- source: "keyword",
82
- created_at: rfc,
83
- updated_at: rfc,
84
- supersedes: null,
85
- superseded_by: null,
86
- review_state: "draft",
87
- proposed_by: null,
88
- approved_by: null,
89
- derived_from: null,
90
- review_note: null,
91
- review_history: [],
92
- };
93
- try {
94
- const { filePath } = writeMemoryFile(fm, content);
95
- const mf = readMemoryFile(filePath);
96
- if (mf)
97
- upsertFromFile(mf);
98
- // Capture feedback: always visible (not debug-only) — the user said
99
- // "记住…", they should see that it landed. The opencode plugin API
100
- // offers no toast channel, so the plugin log is the feedback surface.
101
- const preview = content.length > 60 ? content.slice(0, 60) + "…" : content;
102
- console.log(`[open-memex] remembered → ${target.kind} scope: "${preview}"`);
103
- }
104
- catch (err) {
105
- console.error("[open-memex] keyword capture failed:", err);
106
- }
107
- }
26
+ captureFromText(text, state);
108
27
  },
109
28
  async "experimental.chat.system.transform"(input, output) {
110
- if (!cfg.injectOnFirstTurn)
111
- return;
112
- const sid = input.sessionID ?? "";
113
- if (injectedSessions.has(sid))
114
- return;
115
- injectedSessions.add(sid);
116
- try {
117
- const block = buildContextBlock(scope, cfg);
118
- if (block)
119
- output.system.push(block);
120
- }
121
- catch (err) {
122
- console.error("[open-memex] context injection failed:", err);
123
- }
29
+ injectOnce(input.sessionID ?? "", state, (block) => {
30
+ output.system.push(block);
31
+ });
124
32
  },
125
33
  };
126
34
  };
127
- export default plugin;
35
+ export default { id: PLUGIN_ID, setup: v2Plugin.setup, server };