@eleboucher/opencode-memini 0.6.12 → 0.7.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.
Files changed (4) hide show
  1. package/README.md +58 -35
  2. package/memini-v2.js +384 -0
  3. package/memini.js +291 -274
  4. package/package.json +6 -2
package/README.md CHANGED
@@ -33,6 +33,33 @@ for every project:
33
33
 
34
34
  opencode installs it from npm with Bun at startup.
35
35
 
36
+ ### opencode v2 (beta)
37
+
38
+ opencode's v2 preview (`opencode2`) uses a different plugin system: a `plugins`
39
+ (plural) config array and a `Plugin.define({ id, setup })` module — the v1
40
+ `{ id, server }` plugin above does not load under it. memini ships a v2 sibling
41
+ at the `/v2` subpath that wires the same recall / capture / `memini_status`
42
+ behaviour to the v2 `ctx.session.hook("request")`, `ctx.event.subscribe`, and
43
+ `ctx.tool.transform` API:
44
+
45
+ ```jsonc
46
+ {
47
+ "$schema": "https://opencode.ai/config.json",
48
+ "plugins": [["@eleboucher/opencode-memini/v2", { "namespace": "my-project" }]],
49
+ }
50
+ ```
51
+
52
+ The same options and env vars below apply. Recall injects into the request's
53
+ `system` prompt (rather than a synthetic message part).
54
+
55
+ > **Beta status.** The v2 plugin `ctx` is still gaining these hooks upstream.
56
+ > On a build where `ctx.session.hook` / `ctx.event.subscribe` / `ctx.tool.transform`
57
+ > are absent, the plugin logs which capability is unavailable and no-ops that
58
+ > part rather than crashing — it activates cleanly and lights up automatically
59
+ > once opencode exposes the hook. Track the plugin `ctx` in opencode's
60
+ > `core/src/plugin/host.ts`. Until then, stay on the v1 entry above with the
61
+ > stable `opencode` binary.
62
+
36
63
  ### Configure
37
64
 
38
65
  Pass options inline via the `[name, options]` form:
@@ -45,8 +72,8 @@ Pass options inline via the `[name, options]` form:
45
72
 
46
73
  | Option | Env var | Default | Purpose |
47
74
  | ------------------- | -------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
48
- | `base_url` | `MEMINI_BASE_URL` | `http://localhost:8080` | memini REST base URL (alias: `MEMINI_URL`) |
49
- | `namespace` | `MEMINI_NAMESPACE` | git repo basename | project the memory is scoped to (`X-Memini-Namespace`) |
75
+ | `base_url` | `MEMINI_BASE_URL` | `http://localhost:8080` | memini REST base URL |
76
+ | `namespace` | `MEMINI_NAMESPACE` | server handshake | project the memory is scoped to (`X-Memini-Namespace`) |
50
77
  | `home` | `MEMINI_HOME` | unset | caller's personal namespace, sent as `X-Memini-Home`; unset = no home leg |
51
78
  | `recall` | `MEMINI_RECALL` | on | `false` disables recall-before-turn |
52
79
  | `capture` | `MEMINI_CAPTURE` | on | `false` disables capture-after-turn |
@@ -57,7 +84,7 @@ Pass options inline via the `[name, options]` form:
57
84
  | `timeout_ms` | `MEMINI_TIMEOUT_MS` | `30000` | per-request timeout (recall past its budget keeps running in the background under this bound) |
58
85
  | `fallback_on_error` | `MEMINI_FALLBACK` | on | `false` surfaces errors instead of degrading silently |
59
86
  | — | `MEMINI_INJECT_LABELS` | — | comma-separated label toggles for each bullet: `tier`, `confidence`, `age`, `reason` |
60
- | — | `MEMINI_API_KEY` | — | bearer token, if memini needs auth (env only — secret; alias: `MEMINI_TOKEN`) |
87
+ | — | `MEMINI_API_KEY` | — | bearer token, if memini needs auth (env only — secret) |
61
88
  | — | `MEMINI_REQUIRE_HTTPS` | — | `1` refuses to send the token over plaintext HTTP |
62
89
 
63
90
  opencode awaits `chat.message` before the model sees the message, so a slow or
@@ -76,49 +103,45 @@ Inline options win over the env vars. Secrets stay in the environment: set
76
103
  opencode — not in `opencode.json`.
77
104
 
78
105
  Every option is optional, `namespace` included: `["@eleboucher/opencode-memini"]`
79
- runs with no config. Unset, the plugin derives the namespace from the git
80
- worktree basename and sends it as the `X-Memini-Namespace` header, so scoping
81
- stays correct even against a remote memini (the HTTP MCP wire below can't — a
82
- remote server has no access to your cwd). Set it to share one memory pool with
83
- your other agents.
84
-
85
- If `$XDG_CONFIG_HOME/memini/config.json` (default `~/.config/memini/config.json`)
86
- exists, the unset namespace is instead rendered from its `template` (default
87
- `{tenant}/{project}/{agent}`): `{tenant}` from the `tenantRoots` entry whose
88
- `path` contains the cwd, `{project}` from the git repo, `{agent}` from
89
- `MEMINI_AGENT`; unresolved segments are dropped. The Hermes and Pi integrations
90
- share this resolver, so one config file scopes them all identically.
106
+ runs with no config. On plugin load (and again every 10 minutes thereafter),
107
+ the plugin calls `POST /v1/handshake` with what it cheaply knows about the
108
+ project (the git remote/toplevel, when the worktree is a repo, plus the
109
+ worktree basename) and lets the server resolve the namespace and behavioral
110
+ settings (`recall`, `capture`, `recall_limit`, the recall-injection budget)
111
+ the same way every other memini client does. The call is fail-soft: any
112
+ error or a ~2.5s timeout falls back to purely local resolution below, so an
113
+ unreachable or older memini never breaks a turn.
91
114
 
92
115
  ### Namespace resolution
93
116
 
94
- In full, in order: a **per-project override** in
95
- `$XDG_CONFIG_HOME/memini/overrides.json` > the `namespace` option /
96
- `MEMINI_NAMESPACE` > the config template above > the git worktree basename.
117
+ In full, in order: the `namespace` option / `MEMINI_NAMESPACE` > the
118
+ server's handshake-resolved namespace > the git worktree basename > the
119
+ built-in default (`opencode`).
120
+
121
+ The option/env tier wins over the handshake outright and deliberately: a
122
+ `namespace` option in a global `~/.config/opencode/opencode.json`, or a
123
+ globally exported `MEMINI_NAMESPACE`, is this integration's own explicit pin
124
+ and is honored as such rather than second-guessed by the server. Absent
125
+ either, the server's resolution (which can draw on a pin, the git remote, or
126
+ an operator's per-key default) wins over this plugin's own git
127
+ worktree/default fallback, which only applies when the handshake itself is
128
+ unavailable.
97
129
 
98
- The override wins over both deliberately. A globally exported `MEMINI_NAMESPACE`
99
- a shell rc, or a fish universal variable — pins every repo on the machine to
100
- one namespace (as does a `namespace` option in a global
101
- `~/.config/opencode/opencode.json`), and if either won, setting an override would
102
- silently do nothing on exactly the machines that need one. The file is keyed by
103
- git toplevel, so an override set at the top of a repo applies from any
104
- subdirectory; it is the same file the Claude Code plugin writes and `memini
105
- doctor` reads; and a malformed one degrades to automatic resolution rather than
106
- breaking a turn.
130
+ Each recall/capture setting follows the same shape: the plugin option beats
131
+ `MEMINI_NAMESPACE`'s sibling env vars above beats the server's resolved
132
+ `ClientSettings` beats the built-in default baked into this plugin.
107
133
 
108
134
  ### The `memini_status` tool
109
135
 
110
136
  The plugin registers one tool, `memini_status`: read-only, no arguments. It
111
- reports the namespace in force and where it came from, what it would be _without_
112
- the override and without the env pin, the connection settings (the API key
113
- fingerprinted, never printed), and warnings a global `MEMINI_NAMESPACE` pin, a
114
- bearer token crossing plaintext HTTP, an override you forgot you set.
137
+ reports the namespace in force and where it came from (the namespace option,
138
+ `MEMINI_NAMESPACE`, the server's handshake, or the git worktree fallback),
139
+ what it would be without the env/option pin, the connection settings (the
140
+ API key fingerprinted, never printed), and warnings a global
141
+ `MEMINI_NAMESPACE` pin, a bearer token crossing plaintext HTTP.
115
142
 
116
143
  There is no `/memini:status` slash command: opencode's plugin contract registers
117
144
  tools, not commands, and this plugin does not invent an API it does not have.
118
- Setting or clearing an override is likewise not exposed here — declaring a tool
119
- argument requires a zod schema, and this plugin ships dependency-free — so use
120
- `/memini:namespace` from the Claude Code plugin, or edit `overrides.json`
121
- directly; all harnesses read the same file.
122
145
 
123
146
  ### Tests
124
147
 
package/memini-v2.js ADDED
@@ -0,0 +1,384 @@
1
+ /**
2
+ * memini memory plugin for opencode v2 (the Plugin.define / `setup` API).
3
+ *
4
+ * This is the v2 sibling of memini.js. It targets the documented v2 plugin
5
+ * contract (https://v2.opencode.ai — "Plugins"):
6
+ * - ctx.session.hook("request", …): recall memories relevant to the incoming
7
+ * turn and inject them into the model request (the `system` prompt) before
8
+ * dispatch. This is the v2 equivalent of v1's `chat.message`.
9
+ * - ctx.event.subscribe("session.idle"): once a session goes idle, capture the
10
+ * completed user/assistant turn into memini. The v2 equivalent of v1's
11
+ * `event` hook. Started detached (never awaited inside `setup`, per the docs)
12
+ * and torn down by the returned cleanup.
13
+ * - ctx.tool.transform(t => t.add(…)): register the read-only `memini_status`
14
+ * tool. The v2 equivalent of v1's `tool: { memini_status }`.
15
+ *
16
+ * Everything that is not opencode-contract-specific — config/namespace
17
+ * resolution, the handshake, formatting, token budgeting, status rendering, the
18
+ * REST client — is imported from memini.js so the two plugin generations stay
19
+ * byte-for-byte identical in behaviour. Only the host wiring differs.
20
+ *
21
+ * Dependency-free, like memini.js: `Plugin.define` is an identity function
22
+ * upstream (it returns its argument unchanged), so the module default is a plain
23
+ * `{ id, setup }` object. Importing @opencode-ai/plugin would add a runtime
24
+ * dependency this plugin has never needed and buys nothing.
25
+ *
26
+ * The v2 plugin API is beta and its ctx is still gaining hooks upstream. Every
27
+ * capability below is feature-detected: on a build where `ctx.session.hook`,
28
+ * `ctx.event.subscribe`, or `ctx.tool.transform` is absent, that capability logs
29
+ * once and no-ops rather than throwing and taking down plugin activation.
30
+ */
31
+
32
+ import {
33
+ HANDSHAKE_TTL_MS,
34
+ resolveConfig,
35
+ buildFacts,
36
+ effectiveConfig,
37
+ memoizeAsync,
38
+ extractPartsText,
39
+ formatResults,
40
+ fitByTokens,
41
+ labelsEnv,
42
+ extractLastTurn,
43
+ lastAssistantFailed,
44
+ describeSettings,
45
+ renderStatus,
46
+ createClient,
47
+ } from "./memini.js";
48
+
49
+ const INJECT_PREAMBLE =
50
+ "Relevant long-term memory from memini (background context — prefer " +
51
+ "current workspace state and the user's instructions):";
52
+
53
+ // messageText pulls the plain text out of one v2 request message, tolerating the
54
+ // shapes the beta may hand us: `content` as a string, `content` as an array of
55
+ // `{ type: "text", text }` parts, or a v1-style `parts` array.
56
+ function messageText(msg) {
57
+ if (!msg) return "";
58
+ if (typeof msg.content === "string") return msg.content.trim();
59
+ if (Array.isArray(msg.content)) {
60
+ return msg.content
61
+ .map((p) => (p && typeof p.text === "string" ? p.text : ""))
62
+ .join("\n")
63
+ .trim();
64
+ }
65
+ if (Array.isArray(msg.parts)) return extractPartsText(msg.parts);
66
+ return "";
67
+ }
68
+
69
+ // extractQueryFromRequest returns the latest user text from a request event's
70
+ // `messages`, falling back to the last non-empty message so a recall still fires
71
+ // on unusual message layouts. Exported for testing.
72
+ export function extractQueryFromRequest(event) {
73
+ const messages = Array.isArray(event && event.messages) ? event.messages : [];
74
+ for (let i = messages.length - 1; i >= 0; i--) {
75
+ const role = messages[i] && (messages[i].role || (messages[i].info && messages[i].info.role));
76
+ if (role === "user") {
77
+ const text = messageText(messages[i]);
78
+ if (text) return text;
79
+ }
80
+ }
81
+ for (let i = messages.length - 1; i >= 0; i--) {
82
+ const text = messageText(messages[i]);
83
+ if (text) return text;
84
+ }
85
+ return "";
86
+ }
87
+
88
+ // injectContext places the rendered memory block where the model will see it:
89
+ // the request event's `system` array first (transient, not persisted as a
90
+ // message part), falling back to prepending a system message. Returns true when
91
+ // it found a slot. Exported for testing.
92
+ export function injectContext(event, block) {
93
+ if (!event || !block) return false;
94
+ if (Array.isArray(event.system)) {
95
+ event.system.push(block);
96
+ return true;
97
+ }
98
+ if (Array.isArray(event.messages)) {
99
+ event.messages.unshift({ role: "system", content: block });
100
+ return true;
101
+ }
102
+ return false;
103
+ }
104
+
105
+ // fetchSessionMessages reads a session's message list through whichever server
106
+ // client method the running build exposes ([{info, parts}, …] is what
107
+ // extractLastTurn expects). The v2 ctx is "essentially a server client", but the
108
+ // exact accessor for GET /api/session/{id}/message isn't pinned in the beta, so
109
+ // try the plausible names and unwrap the common envelope shapes.
110
+ async function fetchSessionMessages(ctx, sessionID) {
111
+ const session = ctx && ctx.session;
112
+ if (!session) return [];
113
+ const unwrap = (res) =>
114
+ Array.isArray(res) ? res
115
+ : Array.isArray(res && res.data) ? res.data
116
+ : Array.isArray(res && res.messages) ? res.messages
117
+ : [];
118
+ const attempts = [
119
+ () => session.messages && session.messages({ path: { id: sessionID } }),
120
+ () => session.message && session.message({ path: { id: sessionID } }),
121
+ () => session.messages && session.messages(sessionID),
122
+ ];
123
+ for (const attempt of attempts) {
124
+ try {
125
+ const res = await attempt();
126
+ const arr = unwrap(res);
127
+ if (arr.length) return arr;
128
+ } catch {
129
+ /* try the next accessor shape */
130
+ }
131
+ }
132
+ return [];
133
+ }
134
+
135
+ export async function setup(ctx) {
136
+ const options = (ctx && ctx.options) || {};
137
+ // v2 setup(ctx) carries no worktree/directory the way v1's PluginInput did;
138
+ // opencode runs the plugin in the project root, so cwd is the project dir —
139
+ // the same input resolveConfig/deriveNamespace expect.
140
+ const dir = process.cwd();
141
+ const log = {
142
+ warn: (message) => {
143
+ // ctx is essentially a server client; mirror v1's structured logger and
144
+ // fall back to stderr.
145
+ try {
146
+ ctx?.app?.log?.({ body: { service: "memini", level: "warn", message } });
147
+ } catch {
148
+ /* ignore logging failures */
149
+ }
150
+ console.error(`[memini] ${message}`);
151
+ },
152
+ };
153
+
154
+ const cfg = resolveConfig(process.env, options, dir);
155
+ const rest = createClient(cfg, log);
156
+
157
+ // Handshake memoized per plugin instance (10-minute TTL), identical to the v1
158
+ // plugin: a null handshake (fail-soft) falls back to cfg's local resolution.
159
+ const getHandshake = memoizeAsync(
160
+ () => rest.handshake(buildFacts(dir, process.env)),
161
+ HANDSHAKE_TTL_MS,
162
+ );
163
+ const currentConfig = async () => effectiveConfig(cfg, await getHandshake());
164
+
165
+ // Assistant ids already captured, so repeated idle events for one turn don't
166
+ // write duplicates. Memory ids already injected per session, so an unchanged
167
+ // match isn't re-injected turn after turn. Both bounded for a long-lived host.
168
+ const captured = new Set();
169
+ const injectedBySession = new Map();
170
+ const MAX_TRACKED_SESSIONS = 200;
171
+ const rememberInjected = (session, ids) => {
172
+ let seen = injectedBySession.get(session);
173
+ if (!seen) {
174
+ seen = new Set();
175
+ injectedBySession.set(session, seen);
176
+ while (injectedBySession.size > MAX_TRACKED_SESSIONS) {
177
+ const oldest = injectedBySession.keys().next().value;
178
+ if (oldest === undefined) break;
179
+ injectedBySession.delete(oldest);
180
+ }
181
+ }
182
+ for (const id of ids) if (id) seen.add(id);
183
+ };
184
+
185
+ const cleanups = [];
186
+ const disposeReg = (reg) => {
187
+ if (typeof reg === "function") cleanups.push(reg);
188
+ else if (reg && typeof reg.dispose === "function") cleanups.push(() => reg.dispose());
189
+ };
190
+
191
+ // --- RECALL: ctx.session.hook("request") -------------------------------
192
+ //
193
+ // opencode awaits this hook immediately before model dispatch (a throw fails
194
+ // the turn — the doc's "a hook failure fails the operation it intercepts"), so
195
+ // the callback swallows its own errors and races the search against
196
+ // recall_budget_ms: if memini is slow, the turn proceeds without memory rather
197
+ // than freezing for the full timeout_ms.
198
+ if (ctx && ctx.session && typeof ctx.session.hook === "function") {
199
+ const reg = await ctx.session.hook("request", async (event) => {
200
+ try {
201
+ const live = await currentConfig();
202
+ if (!live.recall) return;
203
+ const query = extractQueryFromRequest(event);
204
+ if (!query) return;
205
+ const sessionID = event.sessionID || event.sessionId || (event.session && event.session.id) || "";
206
+
207
+ const body = { query, limit: live.recall_limit };
208
+ // Exclude this session's own captured turns: they're still in the live
209
+ // context, so recalling them just echoes the conversation back a turn
210
+ // behind. Past sessions still recall.
211
+ if (sessionID) body.exclude_metadata = { session_id: sessionID };
212
+ if (live.recall_min_score > 0) body.min_score = live.recall_min_score;
213
+
214
+ // Blocking, like v1's chat.message: opencode awaits this hook before
215
+ // dispatch. postJson is bounded by cfg.timeout_ms and fail-soft, so a
216
+ // slow/unreachable memini degrades to no memory this turn, never a throw.
217
+ const result = await rest.postJson("/v1/search", body, live.namespace);
218
+
219
+ const floor = live.recall_min_score > 0 ? live.recall_min_score : 0;
220
+ let rawHits = Array.isArray(result && result.results) ? result.results : [];
221
+ if (sessionID) {
222
+ const seen = injectedBySession.get(sessionID);
223
+ if (seen && seen.size) rawHits = rawHits.filter((r) => !seen.has(r && r.memory && r.memory.id));
224
+ }
225
+ const filtered =
226
+ floor > 0
227
+ ? rawHits.filter((r) => (typeof (r && r.score) === "number" ? r.score : 0) >= floor)
228
+ : rawHits;
229
+ const hits = formatResults(filtered, live.recall_limit, labelsEnv());
230
+ if (hits.length === 0) return;
231
+ const fit = fitByTokens(hits, live.recall_max_tokens);
232
+ if (fit.items.length === 0) return;
233
+
234
+ const lines = [INJECT_PREAMBLE, ...fit.items];
235
+ if (result && result.degraded) {
236
+ lines.push(
237
+ `[memini: ${result.note || "semantic search unavailable — results are keyword-only and may be incomplete"}]`,
238
+ );
239
+ }
240
+ if (fit.dropped > 0) lines.push(`[... ${fit.dropped} item(s) truncated by token budget]`);
241
+
242
+ if (injectContext(event, lines.join("\n")) && sessionID) {
243
+ rememberInjected(
244
+ sessionID,
245
+ filtered.map((r) => r && r.memory && r.memory.id).filter(Boolean),
246
+ );
247
+ }
248
+ } catch (error) {
249
+ log.warn(`request hook failed: ${String(error)}`);
250
+ }
251
+ });
252
+ disposeReg(reg);
253
+ } else if (cfg.recall) {
254
+ log.warn("recall unavailable: ctx.session.hook is not present on this opencode build");
255
+ }
256
+
257
+ // --- STATUS TOOL: ctx.tool.transform(t => t.add(...)) ------------------
258
+ if (ctx && ctx.tool && typeof ctx.tool.transform === "function") {
259
+ const reg = await ctx.tool.transform((tools) => {
260
+ tools.add({
261
+ name: "memini_status",
262
+ description:
263
+ "Show the memini memory settings in force for this project: which namespace memories " +
264
+ "are written to and recalled from, where that namespace came from (the namespace option, " +
265
+ "MEMINI_NAMESPACE, a server-resolved handshake, or the git worktree fallback), what it " +
266
+ "would be without the env/option pin, and any misconfiguration worth flagging. Read-only; " +
267
+ "secrets are redacted. Call it when the user asks what memini is doing, why a memory " +
268
+ "cannot be recalled, or which namespace is in use.",
269
+ jsonSchema: { type: "object", properties: {}, additionalProperties: false },
270
+ options: { codemode: false },
271
+ execute: async () => {
272
+ try {
273
+ const report = describeSettings(process.env, options, dir);
274
+ // Overlay the live, handshake-aware values so the tool reports what
275
+ // the hooks actually did on their last handshake.
276
+ const live = await currentConfig();
277
+ report.namespace.effective = live.namespace;
278
+ report.namespace.source = live.namespace_source;
279
+ report.memory.recall = live.recall;
280
+ report.memory.capture = live.capture;
281
+ report.memory.recall_limit = live.recall_limit;
282
+ report.memory.recall_max_tokens = live.recall_max_tokens;
283
+ report.memory.recall_min_score = live.recall_min_score;
284
+ const text = renderStatus(report);
285
+ return {
286
+ structured: {
287
+ namespace: report.namespace.effective,
288
+ source: report.namespace.source,
289
+ },
290
+ content: [{ type: "text", text }],
291
+ };
292
+ } catch (error) {
293
+ return { content: [{ type: "text", text: `memini status failed: ${String(error)}` }] };
294
+ }
295
+ },
296
+ });
297
+ });
298
+ disposeReg(reg);
299
+ } else {
300
+ log.warn("memini_status unavailable: ctx.tool.transform is not present on this opencode build");
301
+ }
302
+
303
+ // --- CAPTURE: ctx.event.subscribe("session.idle") ----------------------
304
+ //
305
+ // Detached: the docs say not to await an infinite stream inside setup. We spawn
306
+ // the consumer and hand back an AbortController-based cleanup.
307
+ const handleIdle = async (event) => {
308
+ const live = await currentConfig();
309
+ if (!live.capture) return;
310
+ // The stream may be narrower or broader than session.idle depending on how
311
+ // subscribe filters; guard on the type when present.
312
+ if (event && event.type && event.type !== "session.idle") return;
313
+ const sessionID =
314
+ (event && event.properties && event.properties.sessionID) || (event && event.sessionID);
315
+ if (!sessionID) return;
316
+ const messages = await fetchSessionMessages(ctx, sessionID);
317
+ const { userText, assistantText, assistantID } = extractLastTurn(messages);
318
+ if (!userText || !assistantText) return;
319
+ if (assistantID && captured.has(assistantID)) return;
320
+ const metadata = { source: "opencode", session_id: sessionID, format: "turn" };
321
+ if (lastAssistantFailed(messages)) metadata.failed = true;
322
+ const stored = await rest.postJson(
323
+ "/v1/memories",
324
+ {
325
+ content: `${userText.slice(0, 1000)}\n\n${assistantText.slice(0, 3000)}`,
326
+ tags: ["opencode"],
327
+ metadata,
328
+ },
329
+ live.namespace,
330
+ );
331
+ if (stored !== null && assistantID) captured.add(assistantID);
332
+ };
333
+
334
+ if (cfg.capture && ctx && ctx.event && typeof ctx.event.subscribe === "function") {
335
+ const controller = new AbortController();
336
+ const task = (async () => {
337
+ let stream;
338
+ try {
339
+ stream = ctx.event.subscribe("session.idle");
340
+ } catch (error) {
341
+ log.warn(`capture unavailable: ctx.event.subscribe threw: ${String(error)}`);
342
+ return;
343
+ }
344
+ if (!stream || typeof stream[Symbol.asyncIterator] !== "function") {
345
+ log.warn("capture unavailable: ctx.event.subscribe did not return an async iterable");
346
+ return;
347
+ }
348
+ try {
349
+ for await (const event of stream) {
350
+ if (controller.signal.aborted) break;
351
+ try {
352
+ await handleIdle(event);
353
+ } catch (error) {
354
+ log.warn(`event hook failed: ${String(error)}`);
355
+ }
356
+ }
357
+ } catch (error) {
358
+ if (!controller.signal.aborted) log.warn(`event stream failed: ${String(error)}`);
359
+ }
360
+ })();
361
+ cleanups.push(async () => {
362
+ controller.abort();
363
+ await task.catch(() => {});
364
+ });
365
+ } else if (cfg.capture) {
366
+ log.warn("capture unavailable: ctx.event.subscribe is not present on this opencode build");
367
+ }
368
+
369
+ // Cleanup: dispose hook/tool registrations and stop the capture consumer when
370
+ // the plugin is disabled, reloaded, or shut down.
371
+ return async () => {
372
+ for (const cleanup of cleanups) {
373
+ try {
374
+ await cleanup();
375
+ } catch {
376
+ /* ignore cleanup failures */
377
+ }
378
+ }
379
+ };
380
+ }
381
+
382
+ // Plugin.define is identity upstream, so a plain { id, setup } is the module
383
+ // default — no @opencode-ai/plugin dependency needed. See the file header.
384
+ export default { id: "memini", setup };