open-memex 0.6.2-alpha.1 → 0.7.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -18,7 +18,7 @@ via `prepublishOnly` — Node refuses `--experimental-strip-types` for files und
18
18
 
19
19
  Tool logic is host-agnostic and lives in `src/tools/ops.ts` (plain functions + shared zod arg shapes + `TOOL_DESCRIPTIONS`). `src/tools/memory.ts` (opencode) and `src/mcp.ts` are thin adapters — when adding or changing a tool, change `ops.ts` once and both hosts pick it up.
20
20
 
21
- **Plugin code must not runtime-import `@opencode-ai/plugin`** (D58): it is a devDependency, and opencode does not resolve it for `file://` plugins loaded from a global npm install — the import fails and opencode silently skips the plugin (no error, no tools). Type-only imports are fine. `src/tools/memory.ts` carries a local stand-in for the SDK's `tool()` (a runtime identity) for exactly this reason.
21
+ **Plugin code must not runtime-import a host SDK package** (D58, extended by D60): `@opencode-ai/plugin` (v1) and `@opencode/plugin` (v2) are devDependencies, and neither host resolves them for plugins loaded from a global npm install — the import fails and the host silently skips the plugin (no error, no tools). Type-only imports are fine. `src/tools/memory.ts` carries a local stand-in for the v1 SDK's `tool()` (a runtime identity), and `src/opencode-v2.ts` duck-types the v2 context, for exactly this reason. The plugin entry is a dual export (`{ id, setup, server }`, D60): v1 ≥1.18.29 calls `server`, opencode 2 calls `setup`; both adapt `src/plugin-core.ts`.
22
22
 
23
23
  Consequences:
24
24
  - Imports **must** use explicit `.ts` extensions (`allowImportingTsExtensions: true`, `moduleResolution: "Bundler"`).
@@ -65,7 +65,8 @@ at init time — npx fallback when no durable bin is on PATH, D17),
65
65
  `open-memex config` prints the effective config, `open-memex capture --dry-run "text"`
66
66
  previews keyword capture without writing, `open-memex doctor` runs health checks
67
67
  (node version, config, scope resolution, storage writability, SQLite locking,
68
- opencode plugin entry (target exists + no host-SDK runtime imports, D59),
68
+ opencode plugin entries for both host generations (target exists + no
69
+ host-SDK runtime imports, D59/D60),
69
70
  VS Code MCP enablement, MCP handshake).
70
71
  `open-memex init` with no --client auto-detects and wires every installed editor
71
72
  (`--yes` skips, scripts never prompt); `open-memex config set <key> <value>` edits settings after install.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,23 @@ the `@alpha` tag are development snapshots and are not listed individually.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## One plugin, both opencode generations (D60)
13
+
14
+ - The native plugin now loads on **opencode 2** as well as opencode 1. `open-memex init --client opencode --global` writes both config spellings — the v1 `"plugin"` file entry and the v2 `"plugins"` directory entry — and each host reads its own. Previously an opencode 2 host silently skipped the plugin (a file entry is not a directory it can load), leaving no tools and no explanation.
15
+ - Tools register with code mode off (`options.codemode: false`): on opencode 2 an unflagged plugin tool is only reachable through a JS `execute` meta-tool, and models that call it by name get "Unknown tool" — with the opt-out, the five tools are ordinary function calls on both generations.
16
+ - On the opencode 1 side this raises the minimum host version to **1.18.29**: the dual entrypoint is a plain exported object, and older v1 hosts only invoke function exports.
17
+ - `open-memex doctor` extends the plugin patrol to both config keys: v2 entries are checked for a resolvable entry file and the same no-host-SDK-runtime-import rule, and when the installed opencode major version disagrees with what the config wires, doctor fails and names the fix.
18
+
19
+ ## [0.6.2] - 2026-10-03
20
+
21
+ ## Doctor patrols the plugin entry (D59)
22
+
23
+ - `open-memex doctor` gains an `opencode plugin` check: it reads the user-level opencode configs, attributes the open-memex plugin entry, and fails when the target file no longer exists (naming the `open-memex init --client opencode --global --force` re-point command) or when the plugin's import closure runtime-imports the host SDK. That second failure is exactly what silently killed the plugin in D58 — it now shows up in a check instead of surfacing as missing tools.
24
+
25
+ ## Notes
26
+
27
+ - 0.6.1 contained D56–D58 but was published before this check landed; 0.6.2 is the recommended upgrade for everyone on the 0.6.x line.
28
+
12
29
  ## [0.6.1] - 2026-10-03
13
30
 
14
31
  ## Question-shaped search, host parity said out loud (D56)
@@ -130,7 +147,8 @@ Install: `npm install -g open-memex`
130
147
 
131
148
  - First public release under the open-memex name (renamed from my-o-memory): local-first memory for AI coding agents — Markdown files as the source of truth, SQLite FTS5 index, opencode plugin + MCP server + CLI.
132
149
 
133
- [Unreleased]: https://github.com/stoneskin/open-memex/compare/v0.6.1...HEAD
150
+ [Unreleased]: https://github.com/stoneskin/open-memex/compare/v0.6.2...HEAD
151
+ [0.6.2]: https://github.com/stoneskin/open-memex/compare/v0.6.1...v0.6.2
134
152
  [0.6.1]: https://github.com/stoneskin/open-memex/compare/v0.6.0...v0.6.1
135
153
  [0.6.0]: https://github.com/stoneskin/open-memex/compare/v0.5.1...v0.6.0
136
154
  [0.5.1]: https://github.com/stoneskin/open-memex/compare/v0.5.0...v0.5.1
package/README.md CHANGED
@@ -247,6 +247,12 @@ project picks it up, no per-project init. You get the 5 core tools, keyword
247
247
  auto-capture, and first-turn context injection. (A config file with comments is
248
248
  left untouched — `init` prints the line to add by hand.)
249
249
 
250
+ Both opencode generations are supported from the same install: `init` writes
251
+ the opencode 1 spelling (`"plugin"`) and the opencode 2 spelling (`"plugins"`)
252
+ — each host reads its own key. Opencode 1 needs version **1.18.29 or newer**
253
+ for this. `open-memex doctor` tells you if the wiring and the installed host
254
+ version don't match.
255
+
250
256
  **opencode** (as a plain MCP consumer):
251
257
 
252
258
  ```sh
@@ -333,7 +339,8 @@ Everything `init` writes, in one place:
333
339
  macOS/Linux — your memory files and the search index. Nothing here is ever
334
340
  modified by `uninstall`.
335
341
  - **Editor wiring** (removed by `open-memex uninstall`):
336
- - opencode: a `"plugin"` entry merged into
342
+ - opencode: a `"plugin"` entry (opencode 1) and a `"plugins"` entry
343
+ (opencode 2) merged into
337
344
  `~/.config/opencode/opencode.json` (or `.jsonc`). Stale `my-o-memory`
338
345
  entries from before the rename are removed at the same time.
339
346
  - VS Code / Cursor: an `open-memex` server entry in the user-level or
package/README.zh-CN.md CHANGED
@@ -180,6 +180,8 @@ open-memex init --client opencode --global --yes
180
180
 
181
181
  把原生插件并入你的用户级 `~/.config/opencode/opencode.json`(如果你已有的是 `opencode.jsonc`,就并进那个)——一次设置,每个项目都生效,不用逐项目 init。你会得到 5 个核心工具、关键词自动捕获和第一轮上下文注入。(配置文件里有注释时它不会动,`init` 会打印要手动加的那一行。)
182
182
 
183
+ 同一套安装同时支持两个 opencode 世代:`init` 会同时写入 opencode 1 的写法(`"plugin"`)和 opencode 2 的写法(`"plugins"`),每个宿主只读自己的那把。opencode 1 要求 **1.18.29 或更新版本**。接线和已装宿主版本对不上,`open-memex doctor` 会报出来。
184
+
183
185
  **opencode**(作为普通 MCP 消费者):
184
186
 
185
187
  ```sh
@@ -238,7 +240,7 @@ open-memex uninstall --yes
238
240
 
239
241
  - **记忆数据**(首次使用时创建,不是 `init` 本身写的):Windows 上是 `%APPDATA%\open-memex\`,macOS/Linux 上是 `~/.local/share/open-memex/`——你的记忆文件和搜索索引。`uninstall` 永不修改这里的任何东西。
240
242
  - **编辑器接线**(由 `open-memex uninstall` 移除):
241
- - opencode:一条 `"plugin"` 条目,并入 `~/.config/opencode/opencode.json`(或 `.jsonc`)。改名之前遗留的 `my-o-memory` 旧条目会同时被清掉。
243
+ - opencode:一条 `"plugin"` 条目(opencode 1)和一条 `"plugins"` 条目(opencode 2),并入 `~/.config/opencode/opencode.json`(或 `.jsonc`)。改名之前遗留的 `my-o-memory` 旧条目会同时被清掉。
242
244
  - VS Code / Cursor:用户级或项目级 MCP 配置里的一条 `open-memex` 服务器条目,外加 Copilot 指令文件里的一段 open-memex 内容(默认是用户级 `~/.copilot/copilot-instructions.md`;`--instructions project` 改为写进仓库的 `.github/copilot-instructions.md`,适合团队全员都用 open-memex 的情况)。
243
245
  - Visual Studio:solution 旁边的 `.mcp.json`。
244
246
  - Agent Skill:VS Code(`~/.copilot/skills/`)、Cursor(`~/.cursor/skills/`)或 per-project MCP 模式下的 opencode(`~/.config/opencode/skills/`)各自一个 `skills/open-memex/` 文件夹。
package/dist/doctor.js CHANGED
@@ -82,14 +82,17 @@ function lockingCheck() {
82
82
  }
83
83
  }
84
84
  /**
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).
85
+ * D59/D60: patrol the opencode native-plugin entries. The D58 field failure
86
+ * was silent — the entry was configured, the host never loaded it, and
87
+ * nothing anywhere said why. Death modes to catch:
88
+ * 1. the entry's target does not exist (package moved, nvm rolled to a
89
+ * new version dir, checkout deleted);
90
+ * 2. the plugin's import closure runtime-imports a host SDK package
91
+ * (@opencode-ai/plugin on v1, @opencode/plugin on v2) — unresolvable
92
+ * from a global install, the host silently skips the plugin (D58);
93
+ * 3. (D60) the config wires only the other host generation: an
94
+ * opencode 2 host never reads the v1 `plugin` file entry (it demands
95
+ * a directory under `plugins`), and opencode 1 cannot read `plugins`.
93
96
  * Read-only; only inspects the user-level opencode configs.
94
97
  */
95
98
  function opencodePluginCheck() {
@@ -97,8 +100,18 @@ function opencodePluginCheck() {
97
100
  try {
98
101
  const jsonPath = opencodeGlobalConfigPath();
99
102
  const candidates = [jsonPath, jsonPath.replace(/\.json$/, ".jsonc")];
100
- const entries = [];
103
+ const v1Entries = [];
104
+ const v2Entries = [];
101
105
  let sawConfig = false;
106
+ const v2EntryPackage = (entry) => {
107
+ if (typeof entry === "string")
108
+ return entry;
109
+ if (entry !== null && typeof entry === "object" && "package" in entry) {
110
+ const p = entry.package;
111
+ return typeof p === "string" ? p : null;
112
+ }
113
+ return null;
114
+ };
102
115
  for (const f of candidates) {
103
116
  if (!fs.existsSync(f))
104
117
  continue;
@@ -106,17 +119,24 @@ function opencodePluginCheck() {
106
119
  const text = fs.readFileSync(f, "utf8");
107
120
  try {
108
121
  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);
122
+ if (Array.isArray(doc.plugin)) {
123
+ for (const e of doc.plugin)
124
+ if (typeof e === "string")
125
+ v1Entries.push(e);
126
+ }
127
+ if (Array.isArray(doc.plugins)) {
128
+ for (const e of doc.plugins) {
129
+ const p = v2EntryPackage(e);
130
+ if (p !== null)
131
+ v2Entries.push(p);
132
+ }
113
133
  }
114
134
  }
115
135
  catch {
116
136
  // JSONC (comments/trailing commas): fall back to scanning quoted
117
137
  // strings for plugin entries.
118
138
  for (const m of text.matchAll(/"(file:[^"]+|[^"]+\.(?:ts|js|mts|mjs))"/g)) {
119
- entries.push(m[1]);
139
+ v1Entries.push(m[1]);
120
140
  }
121
141
  }
122
142
  }
@@ -124,24 +144,71 @@ function opencodePluginCheck() {
124
144
  return { name, ok: true, detail: "no global opencode config found (skipped)" };
125
145
  }
126
146
  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*\)/;
147
+ // Host SDK packages per generation; a runtime import of either from a
148
+ // globally installed plugin cannot resolve (D58).
149
+ const RUNTIME_SDK_IMPORT = /^\s*import\s+(?!type\b)[^;]*?["']@opencode(?:-ai)?\/plugin(?:\/[^"']*)?["']/m;
150
+ const RUNTIME_SDK_REQUIRE = /require\(\s*["']@opencode(?:-ai)?\/plugin(?:\/[^"']*)?["']\s*\)/;
151
+ const toPath = (entry) => {
152
+ try {
153
+ return entry.startsWith("file:") ? fileURLToPath(entry) : entry;
154
+ }
155
+ catch {
156
+ return null;
157
+ }
158
+ };
129
159
  // An entry is ours when its path names the package, or when the target
130
160
  // file carries the plugin's "[open-memex]" log marker (source checkouts
131
161
  // can live at any path).
132
- const isOurEntry = (entry) => {
133
- if (entry.includes("open-memex"))
134
- return true;
162
+ const fileHasMarker = (target) => {
135
163
  try {
136
- const target = entry.startsWith("file:") ? fileURLToPath(entry) : entry;
137
- if (!fs.existsSync(target))
138
- return false;
139
164
  return fs.readFileSync(target, "utf8").slice(0, 65536).includes("[open-memex]");
140
165
  }
141
166
  catch {
142
167
  return false;
143
168
  }
144
169
  };
170
+ /** Resolve an OpenCode 2 directory entry to its entry file, if any. */
171
+ const resolveV2EntryFile = (target) => {
172
+ try {
173
+ if (!fs.existsSync(target))
174
+ return null;
175
+ if (fs.statSync(target).isFile())
176
+ return target;
177
+ for (const idx of ["index.ts", "index.js", "index.mts", "index.mjs"]) {
178
+ const p = path.join(target, idx);
179
+ if (fs.existsSync(p))
180
+ return p;
181
+ }
182
+ const pkgJson = path.join(target, "package.json");
183
+ if (fs.existsSync(pkgJson)) {
184
+ const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf8"));
185
+ if (typeof pkg.main === "string") {
186
+ const main = path.join(target, pkg.main);
187
+ if (fs.existsSync(main))
188
+ return main;
189
+ }
190
+ }
191
+ }
192
+ catch {
193
+ return null;
194
+ }
195
+ return null;
196
+ };
197
+ const isOursV1 = (entry) => {
198
+ if (entry.includes("open-memex"))
199
+ return true;
200
+ const target = toPath(entry);
201
+ return target !== null && fs.existsSync(target) && fileHasMarker(target);
202
+ };
203
+ const isOursV2 = (entry) => {
204
+ if (entry.includes("open-memex"))
205
+ return true;
206
+ const target = toPath(entry);
207
+ if (target === null)
208
+ return false;
209
+ const file = resolveV2EntryFile(target);
210
+ return file !== null && fileHasMarker(file);
211
+ };
145
212
  const scanSdkImports = (entryFile) => {
146
213
  // Walk the entry's relative-import closure looking for host-SDK
147
214
  // runtime imports (type-only imports are erased and fine).
@@ -176,16 +243,14 @@ function opencodePluginCheck() {
176
243
  }
177
244
  return offenders;
178
245
  };
179
- const ours = entries.filter(isOurEntry);
180
- if (ours.length === 0) {
246
+ const oursV1 = v1Entries.filter(isOursV1);
247
+ const oursV2 = v2Entries.filter(isOursV2);
248
+ if (oursV1.length === 0 && oursV2.length === 0) {
181
249
  return { name, ok: true, detail: "no global plugin entry (per-project or MCP wiring is not checked)" };
182
250
  }
183
- for (const entry of ours) {
184
- let target;
185
- try {
186
- target = entry.startsWith("file:") ? fileURLToPath(entry) : entry;
187
- }
188
- catch {
251
+ for (const entry of oursV1) {
252
+ const target = toPath(entry);
253
+ if (target === null) {
189
254
  problems.push(`entry "${entry}" is not a parseable file URL`);
190
255
  continue;
191
256
  }
@@ -194,16 +259,62 @@ function opencodePluginCheck() {
194
259
  continue;
195
260
  }
196
261
  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)`);
262
+ problems.push(`${f} runtime-imports a host SDK package — opencode silently skips plugins whose imports it cannot resolve from a global install (D58)`);
263
+ }
264
+ }
265
+ for (const entry of oursV2) {
266
+ // A bare package name ("open-memex" via `opencode plugin add`) is
267
+ // resolved by the host's plugin manager; there is no local path to
268
+ // verify.
269
+ const isPackageName = !entry.startsWith("file:") &&
270
+ !entry.startsWith(".") &&
271
+ !path.isAbsolute(entry) &&
272
+ !/^[a-zA-Z]:[\\/]/.test(entry);
273
+ if (isPackageName)
274
+ continue;
275
+ const target = toPath(entry);
276
+ if (target === null || !fs.existsSync(target)) {
277
+ 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`);
278
+ continue;
279
+ }
280
+ const entryFile = resolveV2EntryFile(target);
281
+ if (entryFile === null) {
282
+ problems.push(`opencode 2 entry "${entry}" has no loadable plugin file (looked for index.ts/index.js and package.json main)`);
283
+ continue;
284
+ }
285
+ for (const f of scanSdkImports(entryFile)) {
286
+ problems.push(`${f} runtime-imports a host SDK package — opencode silently skips plugins whose imports it cannot resolve from a global install (D58)`);
198
287
  }
199
288
  }
289
+ // D60: generation mismatch — the config wires only the generation the
290
+ // installed host cannot read. Best-effort: no binary, no opinion.
291
+ let hostMajor = null;
292
+ try {
293
+ const out = execFileSync("opencode", ["--version"], {
294
+ timeout: 3000,
295
+ encoding: "utf8",
296
+ stdio: ["ignore", "pipe", "ignore"],
297
+ });
298
+ const m = out.match(/(\d+)\.(\d+)\.(\d+)/);
299
+ if (m)
300
+ hostMajor = Number(m[1]);
301
+ }
302
+ catch {
303
+ hostMajor = null;
304
+ }
305
+ if (hostMajor !== null && hostMajor >= 2 && oursV1.length > 0 && oursV2.length === 0) {
306
+ 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`);
307
+ }
308
+ else if (hostMajor === 1 && oursV2.length > 0 && oursV1.length === 0) {
309
+ 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`);
310
+ }
200
311
  if (problems.length > 0) {
201
312
  return { name, ok: false, detail: problems.join("; ") };
202
313
  }
203
314
  return {
204
315
  name,
205
316
  ok: true,
206
- detail: `${ours.length} plugin ${ours.length === 1 ? "entry" : "entries"}: target exists, no host-SDK runtime imports`,
317
+ detail: `${oursV1.length + oursV2.length} plugin ${oursV1.length + oursV2.length === 1 ? "entry" : "entries"}: target exists, no host-SDK runtime imports`,
207
318
  };
208
319
  }
209
320
  catch (err) {
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 };
package/dist/init.js CHANGED
@@ -271,6 +271,15 @@ function opencodePluginUrl() {
271
271
  const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
272
272
  return pathToFileURL(path.join(pkgRoot, "src", "index.ts")).href;
273
273
  }
274
+ /**
275
+ * D60: the same plugin directory, in the form OpenCode 2 wants. v2 rejects
276
+ * file entries in `plugins` ("configured plugin path must be a directory")
277
+ * and loads `<dir>/index.ts` instead.
278
+ */
279
+ function opencodePluginDir() {
280
+ const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
281
+ return path.join(pkgRoot, "src");
282
+ }
274
283
  /**
275
284
  * D46: opencode's user-level config. opencode reads `~/.config/opencode/`
276
285
  * (`$XDG_CONFIG_HOME` when set) on every platform; a `plugin` entry there
@@ -297,6 +306,63 @@ export function mergePluginEntry(doc, url, force) {
297
306
  list.push(url);
298
307
  return "added";
299
308
  }
309
+ /** The package path an OpenCode 2 `plugins` entry refers to, if any. */
310
+ function v2EntryPackage(entry) {
311
+ if (typeof entry === "string")
312
+ return entry;
313
+ if (entry !== null && typeof entry === "object" && "package" in entry) {
314
+ const p = entry.package;
315
+ return typeof p === "string" ? p : null;
316
+ }
317
+ return null;
318
+ }
319
+ /**
320
+ * D60: pure merge of the open-memex directory into the `plugins` array
321
+ * (the OpenCode 2 spelling). An existing open-memex entry (any form)
322
+ * counts as present; with force, stale open-memex entries are replaced
323
+ * by `dir`. Same "added"/"kept" contract as mergePluginEntry.
324
+ */
325
+ export function mergeV2PluginEntry(doc, dir, force) {
326
+ let plugins = doc["plugins"];
327
+ if (!Array.isArray(plugins)) {
328
+ plugins = [];
329
+ doc["plugins"] = plugins;
330
+ }
331
+ const list = plugins;
332
+ const ours = (e) => (v2EntryPackage(e) ?? "").includes("open-memex");
333
+ if (!force && list.some(ours))
334
+ return "kept";
335
+ for (let i = list.length - 1; i >= 0; i--) {
336
+ const p = v2EntryPackage(list[i]);
337
+ if (p === dir) {
338
+ if (!force)
339
+ return "kept";
340
+ list.splice(i, 1);
341
+ }
342
+ else if (force && ours(list[i])) {
343
+ list.splice(i, 1);
344
+ }
345
+ }
346
+ list.push(dir);
347
+ return "added";
348
+ }
349
+ /**
350
+ * Pure removal of open-memex entries from the `plugins` array (OpenCode 2
351
+ * spelling — strings and `{package}` objects). Drops the key when empty.
352
+ */
353
+ export function removeV2PluginEntry(doc, match) {
354
+ const plugins = doc["plugins"];
355
+ if (!Array.isArray(plugins))
356
+ return "absent";
357
+ const kept = plugins.filter((p) => !(v2EntryPackage(p) ?? "").includes(match));
358
+ if (kept.length === plugins.length)
359
+ return "absent";
360
+ if (kept.length === 0)
361
+ delete doc["plugins"];
362
+ else
363
+ doc["plugins"] = kept;
364
+ return "removed";
365
+ }
300
366
  /**
301
367
  * D46: wire the native plugin at the opencode user level — the one-time
302
368
  * global setup. Merges into the existing config when it parses as JSON;
@@ -309,6 +375,7 @@ function writeOpencodeGlobalPlugin(force) {
309
375
  .map((f) => path.join(dir, f))
310
376
  .find((f) => fs.existsSync(f)) ?? path.join(dir, "opencode.json");
311
377
  const url = opencodePluginUrl();
378
+ const pluginDir = opencodePluginDir();
312
379
  let doc = {};
313
380
  if (fs.existsSync(file)) {
314
381
  try {
@@ -316,7 +383,8 @@ function writeOpencodeGlobalPlugin(force) {
316
383
  }
317
384
  catch {
318
385
  console.log(` ! ${file} has comments or invalid JSON — left untouched, fix it manually.`);
319
- console.log(` To enable open-memex everywhere, add "plugin": ["${url}"] to it.`);
386
+ console.log(` To enable open-memex everywhere, add "plugin": ["${url}"] (opencode 1)`);
387
+ console.log(` and "plugins": ["${pluginDir}"] (opencode 2) to it.`);
320
388
  console.log(` Also remove any "${LEGACY_PACKAGE_NAME}" entries from "plugin" (stale — package renamed).`);
321
389
  return null;
322
390
  }
@@ -325,8 +393,12 @@ function writeOpencodeGlobalPlugin(force) {
325
393
  // alongside the new one — it writes to the OLD data dir, silently splitting
326
394
  // memories in two. Drop it wherever init touches the plugin list.
327
395
  const staleRemoved = removePluginEntry(doc, LEGACY_PACKAGE_NAME) === "removed";
396
+ removeV2PluginEntry(doc, LEGACY_PACKAGE_NAME);
328
397
  const merged = mergePluginEntry(doc, url, force);
329
- if (merged === "kept" && !staleRemoved) {
398
+ // D60: write both key generations — opencode 1 reads `plugin`, opencode 2
399
+ // reads `plugins`; each host ignores the other's key.
400
+ const mergedV2 = mergeV2PluginEntry(doc, pluginDir, force);
401
+ if (merged === "kept" && mergedV2 === "kept" && !staleRemoved) {
330
402
  console.log(` = ${file} already loads the open-memex plugin — left as is (use --force to overwrite)`);
331
403
  return file;
332
404
  }
@@ -334,7 +406,7 @@ function writeOpencodeGlobalPlugin(force) {
334
406
  fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
335
407
  if (staleRemoved)
336
408
  console.log(` - ${file} (stale my-o-memory plugin entry removed — package renamed to open-memex)`);
337
- if (merged === "added")
409
+ if (merged === "added" || mergedV2 === "added")
338
410
  console.log(` + ${file} (native plugin — works in every project, no per-project init needed)`);
339
411
  return file;
340
412
  }
@@ -882,13 +954,16 @@ function removeOpencodePluginFile(file) {
882
954
  const doc = parseJsonConfig(fs.readFileSync(file, "utf8"));
883
955
  if (doc === null) {
884
956
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
885
- console.error(` Delete the open-memex URL from the "plugin" array.`);
957
+ console.error(` Delete the open-memex URL from the "plugin" array and the open-memex path from "plugins".`);
886
958
  return null;
887
959
  }
888
960
  const res = removePluginEntry(doc, "open-memex");
961
+ // D60: the v2 `plugins` entry goes too (strings and {package} objects).
962
+ const resV2 = removeV2PluginEntry(doc, "open-memex");
889
963
  // F30: also sweep the pre-rename leftover — see removeServerEntryFile.
890
964
  const stale = removePluginEntry(doc, LEGACY_PACKAGE_NAME);
891
- if (res === "removed" || stale === "removed") {
965
+ removeV2PluginEntry(doc, LEGACY_PACKAGE_NAME);
966
+ if (res === "removed" || resV2 === "removed" || stale === "removed") {
892
967
  fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
893
968
  console.log(` - ${file} (memory-plugin entries removed)`);
894
969
  return "removed";