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.
- 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
|
@@ -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
package/scripts/smoke-pure.ts
CHANGED
|
@@ -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
|
|
98
|
-
* silent — the entry was configured, the host never loaded it, and
|
|
99
|
-
* anywhere said why.
|
|
100
|
-
* 1. the entry's target
|
|
101
|
-
*
|
|
102
|
-
* 2. the plugin's import closure runtime-imports
|
|
103
|
-
* (
|
|
104
|
-
*
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
139
|
-
const RUNTIME_SDK_REQUIRE = /require\(\s*["']@opencode
|
|
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
|
|
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
|
|
188
|
-
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
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: `${
|
|
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) };
|