open-memex 0.6.2 → 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.
@@ -0,0 +1,43 @@
1
+ import { z } from "zod";
2
+ import { PLUGIN_ID, bootstrapPlugin, captureFromText, injectOnce, } from "./plugin-core.js";
3
+ import { makeTools } from "./tools/memory.js";
4
+ /** Register the five memory tools on a v2 host. */
5
+ async function registerTools(ctx, state) {
6
+ const tools = makeTools(() => state.scope, state.cfg);
7
+ await ctx.tool.transform((editor) => {
8
+ for (const [name, t] of Object.entries(tools)) {
9
+ editor.add({
10
+ name,
11
+ description: t.description,
12
+ input: z.toJSONSchema(z.object(t.args)),
13
+ options: { codemode: false },
14
+ execute: async (input) => {
15
+ const r = await t.execute(input, undefined);
16
+ const content = typeof r === "string" ? r : (r.output ?? JSON.stringify(r));
17
+ return { content };
18
+ },
19
+ });
20
+ }
21
+ });
22
+ }
23
+ export const v2Plugin = {
24
+ id: PLUGIN_ID,
25
+ async setup(ctx) {
26
+ const root = ctx.location?.directory ?? ctx.location?.project?.directory ?? process.cwd();
27
+ const state = bootstrapPlugin(root);
28
+ const registrations = [];
29
+ await registerTools(ctx, state);
30
+ registrations.push(await ctx.session.hook("prompt", (ev) => {
31
+ captureFromText(ev.prompt?.text ?? "", state);
32
+ }));
33
+ registrations.push(await ctx.session.hook("context", (ev) => {
34
+ injectOnce(ev.sessionID ?? "", state, (block) => {
35
+ ev.system.push({ type: "text", text: block });
36
+ });
37
+ }));
38
+ return () => {
39
+ for (const r of registrations)
40
+ void r.dispose?.();
41
+ };
42
+ },
43
+ };
@@ -0,0 +1,138 @@
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";
11
+ /**
12
+ * Host-agnostic plugin core (D60). Both opencode host generations adapt to
13
+ * this module: the v1 plugin (src/index.ts `server`) and the v2 plugin
14
+ * (src/opencode-v2.ts `setup`). Host-specific glue — tool registration and
15
+ * hook shapes — stays in the adapters; everything memory-shaped lives here.
16
+ */
17
+ export const PLUGIN_ID = "open-memex";
18
+ /**
19
+ * Resolve the project scope for a host working directory and bring the
20
+ * index up to date. Never throws: a broken store must not keep the host
21
+ * from starting — failures land in the host's log instead.
22
+ */
23
+ export function bootstrapPlugin(root) {
24
+ const cfg = loadConfig();
25
+ const scope = resolveProjectScope(root);
26
+ try {
27
+ db();
28
+ syncScope(scope.key);
29
+ syncScope(PERSONAL_SCOPE.key);
30
+ if (cfg.logLevel === "debug") {
31
+ console.log(`[open-memex] loaded. scope=${scope.key}`);
32
+ }
33
+ // If a git remote was added *after* memories were first captured, the
34
+ // scope key changes (cwd-hash -> origin-hash). Detect the legacy dir on
35
+ // disk and tell the user how to migrate. We do NOT auto-migrate: two
36
+ // different repos at the same cwd would collide.
37
+ const cwdScope = resolveCwdScope(root);
38
+ if (cwdScope.key !== scope.key && scopeHasFiles(cwdScope.key)) {
39
+ console.warn(`[open-memex] found memories under legacy scope key "${cwdScope.key}". ` +
40
+ `Current project scope is "${scope.key}". ` +
41
+ `To move them: npm run cli -- migrate --from ${cwdScope.key}`);
42
+ }
43
+ }
44
+ catch (err) {
45
+ console.error("[open-memex] init failed:", err);
46
+ }
47
+ return { cfg, scope, injectedSessions: new Set() };
48
+ }
49
+ /**
50
+ * Keyword capture (§ capture): scan one user message for "remember …"
51
+ * triggers and save each hit. Shared by the v1 `chat.message` hook and
52
+ * the v2 session `prompt` hook — both hand us the message text.
53
+ */
54
+ export function captureFromText(text, state) {
55
+ const { cfg, scope } = state;
56
+ if (!cfg.keywordCaptureEnabled || !text)
57
+ return;
58
+ const hits = detectKeywords(text, cfg);
59
+ for (const h of hits) {
60
+ const { content, hadSecret, matchedPattern } = redact(h.content, cfg.redactPatterns);
61
+ // Secrets are masked (first 4 chars kept) and the capture proceeds;
62
+ // skip only when nothing usable remains.
63
+ if (content.length === 0)
64
+ continue;
65
+ if (hadSecret && cfg.logLevel === "debug") {
66
+ console.log(`[open-memex] keyword capture masked secret (${matchedPattern})`);
67
+ }
68
+ // Personal patterns ("remember for me" / "记住(个人)") force the personal scope.
69
+ const target = h.personal ? PERSONAL_SCOPE : scope;
70
+ // Dedup (§3.4): skip exact duplicates captured before.
71
+ if (findDuplicates(target.key, content).exact)
72
+ continue;
73
+ const now = Date.now();
74
+ const rfc = msToRfc3339(now);
75
+ const fm = {
76
+ id: ulid(),
77
+ schema_version: 2,
78
+ scope_key: target.key,
79
+ scope: target.kind === "project" ? "project" : "personal",
80
+ visibility: target.kind === "project" ? "internal" : "private",
81
+ project_name: target.projectName,
82
+ type: "fact",
83
+ role: "knowledge",
84
+ importance: "normal",
85
+ status: "active",
86
+ tags: ["keyword"],
87
+ source: "keyword",
88
+ created_at: rfc,
89
+ updated_at: rfc,
90
+ supersedes: null,
91
+ superseded_by: null,
92
+ review_state: "draft",
93
+ proposed_by: null,
94
+ approved_by: null,
95
+ derived_from: null,
96
+ review_note: null,
97
+ review_history: [],
98
+ };
99
+ try {
100
+ const { filePath } = writeMemoryFile(fm, content);
101
+ const mf = readMemoryFile(filePath);
102
+ if (mf)
103
+ upsertFromFile(mf);
104
+ // Capture feedback: always visible (not debug-only) — the user said
105
+ // "记住…", they should see that it landed. The opencode plugin API
106
+ // offers no toast channel, so the plugin log is the feedback surface.
107
+ const preview = content.length > 60 ? content.slice(0, 60) + "…" : content;
108
+ console.log(`[open-memex] remembered → ${target.kind} scope: "${preview}"`);
109
+ }
110
+ catch (err) {
111
+ console.error("[open-memex] keyword capture failed:", err);
112
+ }
113
+ }
114
+ }
115
+ /**
116
+ * First-turn context injection: exactly once per session. The host adapter
117
+ * supplies `push` (v1: output.system.push; v2: the session context hook's
118
+ * system array). Returns true when a block was pushed.
119
+ */
120
+ export function injectOnce(sessionID, state, push) {
121
+ const { cfg, scope } = state;
122
+ if (!cfg.injectOnFirstTurn)
123
+ return false;
124
+ if (state.injectedSessions.has(sessionID))
125
+ return false;
126
+ state.injectedSessions.add(sessionID);
127
+ try {
128
+ const block = buildContextBlock(scope, cfg);
129
+ if (block) {
130
+ push(block);
131
+ return true;
132
+ }
133
+ }
134
+ catch (err) {
135
+ console.error("[open-memex] context injection failed:", err);
136
+ }
137
+ return false;
138
+ }
package/docs/V2-DESIGN.md CHANGED
@@ -1210,6 +1210,46 @@ requirement: personal data never touches third-party services). Benchmarks to tr
1210
1210
  `@opencode-ai/plugin`, the exact D58 death. It checks only what can be
1211
1211
  attributed; per-project and MCP wiring are out of scope.
1212
1212
 
1213
+ - **D60** — one plugin serves both opencode generations (0.7.0-alpha.1).
1214
+ Opencode 2 changes the plugin contract end to end: config reads
1215
+ `plugins` (directories or `{package}` objects) instead of v1's
1216
+ `plugin` file entries, a v1-style file entry only earns a log warning
1217
+ ("configured plugin path must be a directory") and is silently skipped
1218
+ — the D58 death in a new shape; and the plugin value itself is a
1219
+ structurally decoded `{ id, setup(ctx) }` rather than the v1 server
1220
+ factory. A single `open-memex init --client opencode --global` now
1221
+ writes both keys (`plugin` file URL and `plugins` directory): each
1222
+ host loads from its own key — verified: opencode 1.18.34 ignores the
1223
+ `plugins` array with a config-normalization warning, and opencode 2
1224
+ warns per `plugin` file entry but loads the directory entry — so users on either generation get a
1225
+ working plugin from the same install. `src/index.ts` default-exports
1226
+ `{ id, setup, server }`: v2 calls `setup`, v1 calls `server`, the
1227
+ shared logic (bootstrap, keyword capture, one-time injection) lives in
1228
+ `src/plugin-core.ts`. Cost of the bridge on the v1 side: v1 hosts
1229
+ older than 1.18.29 only invoke function default exports and would
1230
+ skip this object; 1.18.29+ accepts it — the README states the new
1231
+ minimum. The v2 adapter (src/opencode-v2.ts) obeys D58's rule — no
1232
+ runtime `@opencode/plugin` import (a devDependency again); the
1233
+ context is duck-typed from the 2.0 surface, tool arg shapes compile
1234
+ from the same zod shapes via `z.toJSONSchema`, and hooks map
1235
+ one-to-one (session `prompt` → keyword capture, session `context` →
1236
+ first-turn injection). One registration subtlety cost real debugging
1237
+ time and is worth recording: on 2.0.1 a plugin tool added without
1238
+ options is exposed to the model only in code mode — callable through
1239
+ an `execute` meta-tool that runs model-written JS
1240
+ (`tools.memory_search(...)`) — and a model that instead calls the
1241
+ tool by name gets a host-side "Unknown tool" error, so whether the
1242
+ memory tools work at all depends on the model's calling style. Each
1243
+ tool therefore registers with `options: { codemode: false }`, which
1244
+ restores plain function calling (verified: direct calls execute
1245
+ first try). The D59 doctor check extends to both keys:
1246
+ v2 entries are checked for an existing, resolvable entry file and the
1247
+ closure scan now covers `@opencode/plugin` too, plus a generation
1248
+ advisory — when the installed host and the config point at different
1249
+ generations, doctor fails and names the fix (`init --force`; package-
1250
+ name entries installed via `opencode plugin add` are recorded but not
1251
+ path-checked). Field-verified on real hosts 1.18.34 and 2.0.1.
1252
+
1213
1253
  ## Open Questions
1214
1254
 
1215
1255
  _All resolved — see D10 (rename), D11 (type/role split), D12 (explicit pull)._
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-memex",
3
- "version": "0.6.2",
3
+ "version": "0.7.0-alpha.1",
4
4
  "description": "Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -9,7 +9,7 @@ import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE } from "../src/sco
9
9
  import { cjkIndexText, cjkQueryExpr, hasCjk } from "../src/retrieve/cjk.ts";
10
10
  import { toFtsQuery } from "../src/retrieve/query.ts";
11
11
  import { contentHash, similarity, NEAR_DUP_THRESHOLD } from "../src/store/lifecycle.ts";
12
- import { userMcpConfigPath, mergeServerEntry, detectInstalledClients, mergePluginEntry, opencodeGlobalConfigPath, printManualEntryHint, parseJsonConfig, removeServerEntry, removePluginEntry, removeInstructionsSection, LEGACY_PACKAGE_NAME, shouldInstallSkill } from "../src/init.ts";
12
+ import { userMcpConfigPath, mergeServerEntry, detectInstalledClients, mergePluginEntry, mergeV2PluginEntry, removeV2PluginEntry, opencodeGlobalConfigPath, printManualEntryHint, parseJsonConfig, removeServerEntry, removePluginEntry, removeInstructionsSection, LEGACY_PACKAGE_NAME, shouldInstallSkill } from "../src/init.ts";
13
13
 
14
14
  let fails = 0;
15
15
  function ok(name: string, cond: boolean, info?: unknown) {
@@ -504,6 +504,27 @@ ok("plugin remove absent", removePluginEntry({ plugin: ["other"] }, "open-memex"
504
504
  const lpdoc: Record<string, unknown> = { plugin: ["file:///g/my-o-memory/src/index.ts", "file:///g/open-memex/src/index.ts"] };
505
505
  ok("plugin remove drops legacy my-o-memory entry", removePluginEntry(lpdoc, LEGACY_PACKAGE_NAME) === "removed"
506
506
  && JSON.stringify(lpdoc["plugin"]) === JSON.stringify(["file:///g/open-memex/src/index.ts"]));
507
+ // D60: the OpenCode 2 `plugins` array (directory strings and {package} objects).
508
+ const vdoc: Record<string, unknown> = {};
509
+ ok("v2 merge creates array", mergeV2PluginEntry(vdoc, "/x/open-memex/src", false) === "added"
510
+ && JSON.stringify(vdoc["plugins"]) === JSON.stringify(["/x/open-memex/src"]));
511
+ ok("v2 merge dup kept", mergeV2PluginEntry(vdoc, "/x/open-memex/src", false) === "kept");
512
+ const vdoc2: Record<string, unknown> = { plugins: [{ package: "other-plugin", options: {} }, "/old/open-memex/src"] };
513
+ ok("v2 merge kept with existing open-memex entry (no force)", mergeV2PluginEntry(vdoc2, "/new/open-memex/src", false) === "kept"
514
+ && (vdoc2["plugins"] as unknown[]).length === 2);
515
+ ok("v2 merge force replaces stale open-memex dir, keeps others", mergeV2PluginEntry(vdoc2, "/new/open-memex/src", true) === "added"
516
+ && (vdoc2["plugins"] as unknown[]).length === 2
517
+ && JSON.stringify(vdoc2["plugins"]) === JSON.stringify([{ package: "other-plugin", options: {} }, "/new/open-memex/src"]));
518
+ ok("v2 remove drops string + object entries", removeV2PluginEntry(vdoc2, "open-memex") === "removed"
519
+ && JSON.stringify(vdoc2["plugins"]) === JSON.stringify([{ package: "other-plugin", options: {} }]));
520
+ ok("v2 remove absent", removeV2PluginEntry(vdoc2, "open-memex") === "absent");
521
+ const vdoc3: Record<string, unknown> = { plugins: ["/x/open-memex/src"] };
522
+ ok("v2 remove prunes empty array", removeV2PluginEntry(vdoc3, "open-memex") === "removed" && !("plugins" in vdoc3));
523
+ // D60: the plugin entrypoint default-exports both host generations.
524
+ const entryModule = (await import("../src/index.ts")) as { default: { id?: unknown; setup?: unknown; server?: unknown } };
525
+ ok("entry default export has id/setup/server", entryModule.default.id === "open-memex"
526
+ && typeof entryModule.default.setup === "function"
527
+ && typeof entryModule.default.server === "function");
507
528
  // removeInstructionsSection: cuts MARKER..end, "" when nothing remains.
508
529
  ok("instructions remove keeps prior content",
509
530
  removeInstructionsSection("# mine\n\n<!-- open-memex -->\n# OpenMemex memory\n") === "# mine\n");
package/src/doctor.ts CHANGED
@@ -94,14 +94,17 @@ function lockingCheck(): Check {
94
94
  }
95
95
 
96
96
  /**
97
- * D59: patrol the opencode native-plugin entry. The D58 field failure was
98
- * silent — the entry was configured, the host never loaded it, and nothing
99
- * anywhere said why. Two death modes to catch:
100
- * 1. the entry's target file does not exist (package moved, nvm rolled
101
- * to a new version dir, checkout deleted);
102
- * 2. the plugin's import closure runtime-imports @opencode-ai/plugin
103
- * (or another host-only module the package does not depend on),
104
- * which is unresolvable from a global install (D58 regression).
97
+ * D59/D60: patrol the opencode native-plugin entries. The D58 field failure
98
+ * was silent — the entry was configured, the host never loaded it, and
99
+ * nothing anywhere said why. Death modes to catch:
100
+ * 1. the entry's target does not exist (package moved, nvm rolled to a
101
+ * new version dir, checkout deleted);
102
+ * 2. the plugin's import closure runtime-imports a host SDK package
103
+ * (@opencode-ai/plugin on v1, @opencode/plugin on v2) — unresolvable
104
+ * from a global install, the host silently skips the plugin (D58);
105
+ * 3. (D60) the config wires only the other host generation: an
106
+ * opencode 2 host never reads the v1 `plugin` file entry (it demands
107
+ * a directory under `plugins`), and opencode 1 cannot read `plugins`.
105
108
  * Read-only; only inspects the user-level opencode configs.
106
109
  */
107
110
  function opencodePluginCheck(): Check {
@@ -109,23 +112,37 @@ function opencodePluginCheck(): Check {
109
112
  try {
110
113
  const jsonPath = opencodeGlobalConfigPath();
111
114
  const candidates = [jsonPath, jsonPath.replace(/\.json$/, ".jsonc")];
112
- const entries: string[] = [];
115
+ const v1Entries: string[] = [];
116
+ const v2Entries: string[] = [];
113
117
  let sawConfig = false;
118
+ const v2EntryPackage = (entry: unknown): string | null => {
119
+ if (typeof entry === "string") return entry;
120
+ if (entry !== null && typeof entry === "object" && "package" in entry) {
121
+ const p = (entry as { package?: unknown }).package;
122
+ return typeof p === "string" ? p : null;
123
+ }
124
+ return null;
125
+ };
114
126
  for (const f of candidates) {
115
127
  if (!fs.existsSync(f)) continue;
116
128
  sawConfig = true;
117
129
  const text = fs.readFileSync(f, "utf8");
118
130
  try {
119
- const doc = JSON.parse(text) as { plugin?: unknown };
120
- const list = Array.isArray(doc.plugin) ? doc.plugin : [];
121
- for (const e of list) {
122
- if (typeof e === "string") entries.push(e);
131
+ const doc = JSON.parse(text) as { plugin?: unknown; plugins?: unknown };
132
+ if (Array.isArray(doc.plugin)) {
133
+ for (const e of doc.plugin) if (typeof e === "string") v1Entries.push(e);
134
+ }
135
+ if (Array.isArray(doc.plugins)) {
136
+ for (const e of doc.plugins) {
137
+ const p = v2EntryPackage(e);
138
+ if (p !== null) v2Entries.push(p);
139
+ }
123
140
  }
124
141
  } catch {
125
142
  // JSONC (comments/trailing commas): fall back to scanning quoted
126
143
  // strings for plugin entries.
127
144
  for (const m of text.matchAll(/"(file:[^"]+|[^"]+\.(?:ts|js|mts|mjs))"/g)) {
128
- entries.push(m[1]!);
145
+ v1Entries.push(m[1]!);
129
146
  }
130
147
  }
131
148
  }
@@ -134,23 +151,63 @@ function opencodePluginCheck(): Check {
134
151
  }
135
152
 
136
153
  const problems: string[] = [];
154
+ // Host SDK packages per generation; a runtime import of either from a
155
+ // globally installed plugin cannot resolve (D58).
137
156
  const RUNTIME_SDK_IMPORT =
138
- /^\s*import\s+(?!type\b)[^;]*?["']@opencode-ai\/plugin(?:\/[^"']*)?["']/m;
139
- const RUNTIME_SDK_REQUIRE = /require\(\s*["']@opencode-ai\/plugin(?:\/[^"']*)?["']\s*\)/;
157
+ /^\s*import\s+(?!type\b)[^;]*?["']@opencode(?:-ai)?\/plugin(?:\/[^"']*)?["']/m;
158
+ const RUNTIME_SDK_REQUIRE = /require\(\s*["']@opencode(?:-ai)?\/plugin(?:\/[^"']*)?["']\s*\)/;
140
159
 
160
+ const toPath = (entry: string): string | null => {
161
+ try {
162
+ return entry.startsWith("file:") ? fileURLToPath(entry) : entry;
163
+ } catch {
164
+ return null;
165
+ }
166
+ };
141
167
  // An entry is ours when its path names the package, or when the target
142
168
  // file carries the plugin's "[open-memex]" log marker (source checkouts
143
169
  // can live at any path).
144
- const isOurEntry = (entry: string): boolean => {
145
- if (entry.includes("open-memex")) return true;
170
+ const fileHasMarker = (target: string): boolean => {
146
171
  try {
147
- const target = entry.startsWith("file:") ? fileURLToPath(entry) : entry;
148
- if (!fs.existsSync(target)) return false;
149
172
  return fs.readFileSync(target, "utf8").slice(0, 65536).includes("[open-memex]");
150
173
  } catch {
151
174
  return false;
152
175
  }
153
176
  };
177
+ /** Resolve an OpenCode 2 directory entry to its entry file, if any. */
178
+ const resolveV2EntryFile = (target: string): string | null => {
179
+ try {
180
+ if (!fs.existsSync(target)) return null;
181
+ if (fs.statSync(target).isFile()) return target;
182
+ for (const idx of ["index.ts", "index.js", "index.mts", "index.mjs"]) {
183
+ const p = path.join(target, idx);
184
+ if (fs.existsSync(p)) return p;
185
+ }
186
+ const pkgJson = path.join(target, "package.json");
187
+ if (fs.existsSync(pkgJson)) {
188
+ const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf8")) as { main?: unknown };
189
+ if (typeof pkg.main === "string") {
190
+ const main = path.join(target, pkg.main);
191
+ if (fs.existsSync(main)) return main;
192
+ }
193
+ }
194
+ } catch {
195
+ return null;
196
+ }
197
+ return null;
198
+ };
199
+ const isOursV1 = (entry: string): boolean => {
200
+ if (entry.includes("open-memex")) return true;
201
+ const target = toPath(entry);
202
+ return target !== null && fs.existsSync(target) && fileHasMarker(target);
203
+ };
204
+ const isOursV2 = (entry: string): boolean => {
205
+ if (entry.includes("open-memex")) return true;
206
+ const target = toPath(entry);
207
+ if (target === null) return false;
208
+ const file = resolveV2EntryFile(target);
209
+ return file !== null && fileHasMarker(file);
210
+ };
154
211
  const scanSdkImports = (entryFile: string): string[] => {
155
212
  // Walk the entry's relative-import closure looking for host-SDK
156
213
  // runtime imports (type-only imports are erased and fine).
@@ -184,15 +241,14 @@ function opencodePluginCheck(): Check {
184
241
  return offenders;
185
242
  };
186
243
 
187
- const ours = entries.filter(isOurEntry);
188
- if (ours.length === 0) {
244
+ const oursV1 = v1Entries.filter(isOursV1);
245
+ const oursV2 = v2Entries.filter(isOursV2);
246
+ if (oursV1.length === 0 && oursV2.length === 0) {
189
247
  return { name, ok: true, detail: "no global plugin entry (per-project or MCP wiring is not checked)" };
190
248
  }
191
- for (const entry of ours) {
192
- let target: string;
193
- try {
194
- target = entry.startsWith("file:") ? fileURLToPath(entry) : entry;
195
- } catch {
249
+ for (const entry of oursV1) {
250
+ const target = toPath(entry);
251
+ if (target === null) {
196
252
  problems.push(`entry "${entry}" is not a parseable file URL`);
197
253
  continue;
198
254
  }
@@ -204,17 +260,72 @@ function opencodePluginCheck(): Check {
204
260
  }
205
261
  for (const f of scanSdkImports(target)) {
206
262
  problems.push(
207
- `${f} runtime-imports @opencode-ai/plugin — opencode silently skips plugins whose imports it cannot resolve from a global install (D58)`,
263
+ `${f} runtime-imports a host SDK package — opencode silently skips plugins whose imports it cannot resolve from a global install (D58)`,
264
+ );
265
+ }
266
+ }
267
+ for (const entry of oursV2) {
268
+ // A bare package name ("open-memex" via `opencode plugin add`) is
269
+ // resolved by the host's plugin manager; there is no local path to
270
+ // verify.
271
+ const isPackageName =
272
+ !entry.startsWith("file:") &&
273
+ !entry.startsWith(".") &&
274
+ !path.isAbsolute(entry) &&
275
+ !/^[a-zA-Z]:[\\/]/.test(entry);
276
+ if (isPackageName) continue;
277
+ const target = toPath(entry);
278
+ if (target === null || !fs.existsSync(target)) {
279
+ problems.push(
280
+ `opencode 2 entry "${entry}" does not resolve to an existing path — run \`open-memex init --client opencode --global --force\` to re-point it`,
281
+ );
282
+ continue;
283
+ }
284
+ const entryFile = resolveV2EntryFile(target);
285
+ if (entryFile === null) {
286
+ problems.push(
287
+ `opencode 2 entry "${entry}" has no loadable plugin file (looked for index.ts/index.js and package.json main)`,
208
288
  );
289
+ continue;
209
290
  }
291
+ for (const f of scanSdkImports(entryFile)) {
292
+ problems.push(
293
+ `${f} runtime-imports a host SDK package — opencode silently skips plugins whose imports it cannot resolve from a global install (D58)`,
294
+ );
295
+ }
296
+ }
297
+
298
+ // D60: generation mismatch — the config wires only the generation the
299
+ // installed host cannot read. Best-effort: no binary, no opinion.
300
+ let hostMajor: number | null = null;
301
+ try {
302
+ const out = execFileSync("opencode", ["--version"], {
303
+ timeout: 3000,
304
+ encoding: "utf8",
305
+ stdio: ["ignore", "pipe", "ignore"],
306
+ });
307
+ const m = out.match(/(\d+)\.(\d+)\.(\d+)/);
308
+ if (m) hostMajor = Number(m[1]);
309
+ } catch {
310
+ hostMajor = null;
210
311
  }
312
+ if (hostMajor !== null && hostMajor >= 2 && oursV1.length > 0 && oursV2.length === 0) {
313
+ problems.push(
314
+ `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`,
315
+ );
316
+ } else if (hostMajor === 1 && oursV2.length > 0 && oursV1.length === 0) {
317
+ problems.push(
318
+ `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`,
319
+ );
320
+ }
321
+
211
322
  if (problems.length > 0) {
212
323
  return { name, ok: false, detail: problems.join("; ") };
213
324
  }
214
325
  return {
215
326
  name,
216
327
  ok: true,
217
- detail: `${ours.length} plugin ${ours.length === 1 ? "entry" : "entries"}: target exists, no host-SDK runtime imports`,
328
+ detail: `${oursV1.length + oursV2.length} plugin ${oursV1.length + oursV2.length === 1 ? "entry" : "entries"}: target exists, no host-SDK runtime imports`,
218
329
  };
219
330
  } catch (err) {
220
331
  return { name, ok: false, detail: String(err) };