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 +3 -2
- package/CHANGELOG.md +19 -1
- package/README.md +8 -1
- package/README.zh-CN.md +3 -1
- package/dist/doctor.js +143 -32
- package/dist/index.js +20 -112
- package/dist/init.js +80 -5
- package/dist/opencode-v2.js +43 -0
- package/dist/plugin-core.js +138 -0
- package/docs/V2-DESIGN.md +40 -0
- package/package.json +1 -1
- package/scripts/smoke-pure.ts +22 -1
- package/src/doctor.ts +140 -29
- package/src/index.ts +25 -112
- package/src/init.ts +83 -5
- package/src/opencode-v2.ts +119 -0
- package/src/plugin-core.ts +152 -0
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
|
|
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
|
|
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.
|
|
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
|
|
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"`
|
|
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
|
|
86
|
-
* silent — the entry was configured, the host never loaded it, and
|
|
87
|
-
* anywhere said why.
|
|
88
|
-
* 1. the entry's target
|
|
89
|
-
*
|
|
90
|
-
* 2. the plugin's import closure runtime-imports
|
|
91
|
-
* (
|
|
92
|
-
*
|
|
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
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
|
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
|
|
180
|
-
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
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: `${
|
|
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 {
|
|
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
|
-
|
|
13
|
-
|
|
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
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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}"]
|
|
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
|
-
|
|
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
|
-
|
|
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";
|