@eleboucher/opencode-memini 0.7.18 → 0.7.20

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 (3) hide show
  1. package/README.md +49 -11
  2. package/memini-v2.js +265 -104
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -38,27 +38,65 @@ opencode installs it from npm with Bun at startup.
38
38
  opencode's v2 preview (`opencode2`) uses a different plugin system: a `plugins`
39
39
  (plural) config array and a `Plugin.define({ id, setup })` module — the v1
40
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:
41
+ that wires the same recall / capture / `memini_status` behaviour to v2's
42
+ session hooks, public event stream, and tool transforms.
43
+
44
+ The v2 loader treats a `plugins` entry as either an npm package specifier or a
45
+ path. It cannot install this package's `/v2` export subpath as an npm package,
46
+ so v2 ships under its own package name:
44
47
 
45
48
  ```jsonc
46
49
  {
47
50
  "$schema": "https://opencode.ai/config.json",
48
- "plugins": [["@eleboucher/opencode-memini/v2", { "namespace": "my-project" }]],
51
+ "plugins": ["@eleboucher/opencode-memini-v2"],
52
+ }
53
+ ```
54
+
55
+ A bare entry resolves to the `latest` dist-tag, so that is where this package
56
+ is published. `opencode2 plugin add @eleboucher/opencode-memini-v2` writes the
57
+ entry for you, using whatever specifier you pass it.
58
+
59
+ > **Why a second package rather than one with two entrypoints?** opencode
60
+ > resolves a plugin through `exports["./server"]` before falling back to
61
+ > `main`, and it uses that same `server` kind for v1-style plugins. Adding a
62
+ > `./server` key to `@eleboucher/opencode-memini` would therefore hand the v2
63
+ > module to v1 hosts, which cannot load it.
64
+
65
+ To run from a checkout, use the local entrypoint. opencode does not install
66
+ dependencies for path-loaded plugins; this one has none, so nothing else is
67
+ needed:
68
+
69
+ ```jsonc
70
+ {
71
+ "plugins": ["/absolute/path/to/memini/integrations/opencode/plugin/memini-v2.js"],
72
+ }
73
+ ```
74
+
75
+ For options, use the documented object form:
76
+
77
+ ```jsonc
78
+ {
79
+ "plugins": [
80
+ {
81
+ "package": "@eleboucher/opencode-memini-v2",
82
+ "options": { "namespace": "my-project" },
83
+ },
84
+ ],
49
85
  }
50
86
  ```
51
87
 
52
88
  The same options and env vars below apply. Recall injects into the request's
53
89
  `system` prompt (rather than a synthetic message part).
54
90
 
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.
91
+ > **Beta status.** Verified against `opencode2 v0.0.0-next-16502` and re-checked
92
+ > through `v0.0.0-beta-18050`: the working request hook is `context` (the docs
93
+ > call it `request`); user/assistant turns are captured from
94
+ > `session.inbox.enqueued`, `session.text.ended`, and `session.execution.*`
95
+ > because these builds emit no `session.idle` and expose no session-message list
96
+ > method. `next-17444` renamed the first event from `session.input.admitted` and
97
+ > moved its text from `input.data` to `item.payload`; the plugin reads both
98
+ > shapes, and registers both hook names so it remains compatible with the
99
+ > documented spelling.
62
100
 
63
101
  The v2 plugin sends diagnostics through the structured `ctx.app.log` logger when
64
102
  available. Logging is best-effort and remains silent when that logger is absent
package/memini-v2.js CHANGED
@@ -1,17 +1,43 @@
1
1
  /**
2
2
  * memini memory plugin for opencode v2 (the Plugin.define / `setup` API).
3
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 }`.
4
+ * This is the v2 sibling of memini.js. It targets the v2 plugin contract as
5
+ * verified against opencode2 v0.0.0-next-16502 and re-checked through
6
+ * v0.0.0-beta-18050 (@opencode-ai/plugin `next` / `beta`), which differs from
7
+ * the docs at https://opencode.ai/v2/docs/build/plugins in several places
8
+ * every shape below is feature-detected and was confirmed against the live
9
+ * runtime:
10
+ *
11
+ * - RECALL ctx.session.hook("context", …): the docs call this hook
12
+ * "request", but this build fires "context" (unknown names register without
13
+ * error and simply never fire). The event is
14
+ * { sessionID, agent, model, system, messages, tools }, where `system` is an
15
+ * array of { type: "text", text } parts and `messages` are AI-SDK-shaped
16
+ * ({ id, role, content: [{ type: "text", text } …] }). The hook fires once
17
+ * per model DISPATCH — i.e. again on every tool-loop continuation step — so
18
+ * recall is deduplicated per TURN (last user message id): the search runs
19
+ * once per user message and the rendered block is re-injected from cache on
20
+ * continuation dispatches of the same turn. This is the v2 equivalent of
21
+ * v1's `chat.message`.
22
+ * - CAPTURE — ctx.event.subscribe() (no type argument; the whole public
23
+ * server event stream, across every project the service hosts). There is no
24
+ * `session.idle` in v2: a turn's boundary is the
25
+ * `session.execution.succeeded` / `.failed` / `.interrupted` event. There is
26
+ * also no message-listing method on ctx.session (create/get/prompt/command/
27
+ * synthetic/generate/interrupt only; session.get returns SessionInfo with
28
+ * no messages). Capture is therefore event-driven: the user text comes from
29
+ * `session.inbox.enqueued` (data.item.payload.text) and the assistant text
30
+ * from `session.text.ended` parts (keyed by assistantMessageID + ordinal),
31
+ * flushed to memini when the execution terminal event arrives. v1's
32
+ * failed-turn marking maps to execution.failed / execution.interrupted.
33
+ * Builds up to next-16502 named that event `session.input.admitted` and
34
+ * nested the same Prompt.fields under `data.input.data`; both shapes are
35
+ * read, so one plugin covers either build.
36
+ * - STATUS TOOL — ctx.tool.transform(t => t.add(…)): `add` takes ONE
37
+ * structural Tool.Info object ({ name, input, description, execute,
38
+ * options? }) — not the docs' three-argument add(name, def, options). The
39
+ * input schema field is `input` (raw JSON Schema is fine) and the result is
40
+ * { content: [{ type: "text", text }] }.
15
41
  *
16
42
  * Everything that is not opencode-contract-specific — config/namespace
17
43
  * resolution, the handshake, formatting, token budgeting, status rendering, the
@@ -26,7 +52,16 @@
26
52
  * The v2 plugin API is beta and its ctx is still gaining hooks upstream. Every
27
53
  * capability below is feature-detected: on a build where `ctx.session.hook`,
28
54
  * `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.
55
+ * once and no-ops rather than throwing and taking down plugin activation. Note
56
+ * that ctx.app has no `log` method on current builds (it is { name, version,
57
+ * channel }), so diagnostics stay silent — they are best-effort by design.
58
+ *
59
+ * KNOWN LIMITATION (beta): the v2 service hosts every project in ONE process
60
+ * with ONE plugin instance, and hooks/events are service-global. The namespace
61
+ * is resolved from the plugin process's cwd (typically the service's start
62
+ * directory), so sessions from other projects would recall/capture against the
63
+ * same namespace. Pin `MEMINI_NAMESPACE` if needed and re-check the upstream
64
+ * ctx as it gains per-location routing.
30
65
  */
31
66
 
32
67
  import {
@@ -40,8 +75,6 @@ import {
40
75
  formatResults,
41
76
  fitByTokens,
42
77
  labelsEnv,
43
- extractLastTurn,
44
- lastAssistantFailed,
45
78
  describeSettings,
46
79
  renderStatus,
47
80
  createClient,
@@ -55,6 +88,25 @@ const INJECT_PREAMBLE =
55
88
  "Relevant long-term memory from memini (background context — prefer " +
56
89
  "current workspace state and the user's instructions):";
57
90
  const BUDGET_EXPIRED = Symbol("memini-recall-budget-expired");
91
+ // OpenCode activates a plugin per location while event.subscribe() is global.
92
+ // Assistant message IDs are globally unique, so this prevents duplicate writes.
93
+ const capturedAssistantIDs = new Set();
94
+ const MAX_CAPTURED_ASSISTANT_IDS = 1000;
95
+
96
+ function claimCapturedAssistant(id) {
97
+ if (capturedAssistantIDs.has(id)) return false;
98
+ capturedAssistantIDs.add(id);
99
+ while (capturedAssistantIDs.size > MAX_CAPTURED_ASSISTANT_IDS) {
100
+ const oldest = capturedAssistantIDs.keys().next().value;
101
+ if (oldest === undefined) break;
102
+ capturedAssistantIDs.delete(oldest);
103
+ }
104
+ return true;
105
+ }
106
+
107
+ export function resetForTests() {
108
+ capturedAssistantIDs.clear();
109
+ }
58
110
 
59
111
  // messageText pulls the plain text out of one v2 request message, tolerating the
60
112
  // shapes the beta may hand us: `content` as a string, `content` as an array of
@@ -72,18 +124,16 @@ function messageText(msg) {
72
124
  return "";
73
125
  }
74
126
 
75
- // extractQueryFromRequest returns the latest user text from a request event's
127
+ // extractQueryFromRequest returns the latest user text from a context event's
76
128
  // `messages`, falling back to the last non-empty message so a recall still fires
77
129
  // on unusual message layouts. Exported for testing.
78
130
  export function extractQueryFromRequest(event) {
79
- const messages = Array.isArray(event && event.messages) ? event.messages : [];
80
- for (let i = messages.length - 1; i >= 0; i--) {
81
- const role = messages[i] && (messages[i].role || (messages[i].info && messages[i].info.role));
82
- if (role === "user") {
83
- const text = messageText(messages[i]);
84
- if (text) return text;
85
- }
131
+ const lastUser = lastUserMessage(event);
132
+ if (lastUser) {
133
+ const text = messageText(lastUser);
134
+ if (text) return text;
86
135
  }
136
+ const messages = Array.isArray(event && event.messages) ? event.messages : [];
87
137
  for (let i = messages.length - 1; i >= 0; i--) {
88
138
  const text = messageText(messages[i]);
89
139
  if (text) return text;
@@ -91,14 +141,34 @@ export function extractQueryFromRequest(event) {
91
141
  return "";
92
142
  }
93
143
 
144
+ // lastUserMessage returns the most recent user-role message, or null.
145
+ function lastUserMessage(event) {
146
+ const messages = Array.isArray(event && event.messages) ? event.messages : [];
147
+ for (let i = messages.length - 1; i >= 0; i--) {
148
+ const role = messages[i] && (messages[i].role || (messages[i].info && messages[i].info.role));
149
+ if (role === "user") return messages[i];
150
+ }
151
+ return null;
152
+ }
153
+
154
+ // turnKey identifies a user TURN (as opposed to one model dispatch): the
155
+ // context hook re-fires on every tool-loop continuation with the same last
156
+ // user message. Exported for testing.
157
+ export function turnKey(event, query) {
158
+ const sessionID = event && event.sessionID ? String(event.sessionID) : "";
159
+ const lastUser = lastUserMessage(event);
160
+ const id = lastUser && (lastUser.id || (lastUser.info && lastUser.info.id));
161
+ return `${sessionID}|${id || query}`;
162
+ }
163
+
94
164
  // injectContext places the rendered memory block where the model will see it:
95
- // the request event's `system` array first (transient, not persisted as a
96
- // message part), falling back to prepending a system message. Returns true when
97
- // it found a slot. Exported for testing.
165
+ // the event's `system` array first (an array of { type: "text", text } parts,
166
+ // transient and not persisted as a message), falling back to prepending a
167
+ // system message. Returns true when it found a slot. Exported for testing.
98
168
  export function injectContext(event, block) {
99
169
  if (!event || !block) return false;
100
170
  if (Array.isArray(event.system)) {
101
- event.system.push(block);
171
+ event.system.push({ type: "text", text: block });
102
172
  return true;
103
173
  }
104
174
  if (Array.isArray(event.messages)) {
@@ -108,34 +178,11 @@ export function injectContext(event, block) {
108
178
  return false;
109
179
  }
110
180
 
111
- // fetchSessionMessages reads a session's message list through whichever server
112
- // client method the running build exposes ([{info, parts}, …] is what
113
- // extractLastTurn expects). The v2 ctx is "essentially a server client", but the
114
- // exact accessor for GET /api/session/{id}/message isn't pinned in the beta, so
115
- // try the plausible names and unwrap the common envelope shapes.
116
- async function fetchSessionMessages(ctx, sessionID) {
117
- const session = ctx && ctx.session;
118
- if (!session) return [];
119
- const unwrap = (res) =>
120
- Array.isArray(res) ? res
121
- : Array.isArray(res && res.data) ? res.data
122
- : Array.isArray(res && res.messages) ? res.messages
123
- : [];
124
- const attempts = [
125
- () => session.messages && session.messages({ path: { id: sessionID } }),
126
- () => session.message && session.message({ path: { id: sessionID } }),
127
- () => session.messages && session.messages(sessionID),
128
- ];
129
- for (const attempt of attempts) {
130
- try {
131
- const res = await attempt();
132
- const arr = unwrap(res);
133
- if (arr.length) return arr;
134
- } catch {
135
- /* try the next accessor shape */
136
- }
137
- }
138
- return [];
181
+ function contextAlreadyInjected(event) {
182
+ return Array.isArray(event && event.system) && event.system.some((part) => {
183
+ const text = typeof part === "string" ? part : part && part.text;
184
+ return typeof text === "string" && text.startsWith(INJECT_PREAMBLE);
185
+ });
139
186
  }
140
187
 
141
188
  export async function setup(ctx) {
@@ -177,7 +224,6 @@ export async function setup(ctx) {
177
224
  // unchanged match is suppressed while inside EITHER window, re-served once
178
225
  // BOTH lapse, and re-served immediately when its content changed (h
179
226
  // mismatch). Both maps bounded for a long-lived host.
180
- const captured = new Set();
181
227
  const injectedBySession = new Map(); // session -> { n, ids: Map<id, {h, at, n}> }
182
228
  const MAX_TRACKED_SESSIONS = 200;
183
229
  const MAX_INJECTED_PER_SESSION = 200;
@@ -217,27 +263,49 @@ export async function setup(ctx) {
217
263
  else if (reg && typeof reg.dispose === "function") cleanups.push(() => reg.dispose());
218
264
  };
219
265
 
220
- // --- RECALL: ctx.session.hook("request") -------------------------------
266
+ // --- RECALL: ctx.session.hook("context") --------------------------------
267
+ //
268
+ // opencode awaits this hook immediately before model dispatch (the doc's "a
269
+ // hook failure fails the operation it intercepts"), so the callback swallows
270
+ // its own errors and races the search against recall_budget_ms: if memini is
271
+ // slow, the turn proceeds without memory rather than freezing for the full
272
+ // timeout_ms.
221
273
  //
222
- // opencode awaits this hook immediately before model dispatch (a throw fails
223
- // the turn the doc's "a hook failure fails the operation it intercepts"), so
224
- // the callback swallows its own errors and races the search against
225
- // recall_budget_ms: if memini is slow, the turn proceeds without memory rather
226
- // than freezing for the full timeout_ms.
274
+ // The build verified against fires "context"; the docs name the same hook
275
+ // "request". Unknown names register without error and never fire, so both
276
+ // are registered: on any given build only the implemented one runs, and a
277
+ // build that ever fires both for one dispatch is neutralized by the
278
+ // per-turn dedup plus the injected-event WeakSet below.
227
279
  if (ctx && ctx.session && typeof ctx.session.hook === "function") {
228
- const reg = await ctx.session.hook("request", async (event) => {
280
+ // Events whose system/messages this hook generation already injected
281
+ // guards against a build that fires both names for one dispatch.
282
+ const injectedEvents = new WeakSet();
283
+ const handleRequest = async (event) => {
229
284
  try {
230
285
  const live = await currentConfig();
231
286
  if (!live.recall) return;
232
287
  const query = extractQueryFromRequest(event);
233
288
  if (!query) return;
234
289
  const sessionID = event.sessionID || event.sessionId || (event.session && event.session.id) || "";
235
- // One request-hook fire == one prompt for the cooldown's prompt
236
- // dimension (the v2 beta's closest per-turn signal): bump before any
237
- // gate, so the window measures prompts-since-injection even on turns
238
- // that inject nothing.
239
290
  const seen = sessionID ? sessionSeen(sessionID) : null;
240
- if (seen) seen.n += 1;
291
+
292
+ // One USER TURN == one prompt for the cooldown's prompt dimension. The
293
+ // hook re-fires per tool-loop step of the same turn, so dedupe on the
294
+ // last user message: a repeat turnKey skips the search and the counter,
295
+ // but re-injects this turn's cached block into the new dispatch's
296
+ // system (each dispatch rebuilds its request from scratch).
297
+ const key = turnKey(event, query);
298
+ if (seen && seen.lastTurnKey === key) {
299
+ if (seen.block && !contextAlreadyInjected(event) && !injectedEvents.has(event) && injectContext(event, seen.block)) {
300
+ injectedEvents.add(event);
301
+ }
302
+ return;
303
+ }
304
+ if (seen) {
305
+ seen.lastTurnKey = key;
306
+ seen.block = undefined;
307
+ seen.n += 1;
308
+ }
241
309
  const cooldownOpts = () => ({
242
310
  now: Date.now(),
243
311
  counter: seen ? seen.n : 0,
@@ -323,21 +391,35 @@ export async function setup(ctx) {
323
391
  }
324
392
  if (fit.dropped > 0) lines.push(`[... ${fit.dropped} item(s) truncated by token budget]`);
325
393
 
326
- if (injectContext(event, lines.join("\n")) && seen) {
327
- // Record only the slice formatResults actually renders, stamped with
328
- // {h, at, n} so the windowed cooldown can judge re-admission later.
329
- rememberInjected(seen, filtered.slice(0, live.recall_limit || 3));
394
+ const block = lines.join("\n");
395
+ if (!contextAlreadyInjected(event) && injectContext(event, block)) {
396
+ injectedEvents.add(event);
397
+ if (seen) {
398
+ // Cache for re-injection on this turn's continuation dispatches,
399
+ // and record only the slice formatResults actually renders,
400
+ // stamped {h, at, n} so the windowed cooldown can judge later.
401
+ seen.block = block;
402
+ rememberInjected(seen, filtered.slice(0, live.recall_limit || 3));
403
+ }
330
404
  }
331
405
  } catch (error) {
332
406
  log.warn(`request hook failed: ${String(error)}`);
333
407
  }
334
- });
335
- disposeReg(reg);
408
+ };
409
+ for (const name of ["context", "request"]) {
410
+ const reg = await ctx.session.hook(name, handleRequest);
411
+ disposeReg(reg);
412
+ }
336
413
  } else if (cfg.recall) {
337
414
  log.warn("recall unavailable: ctx.session.hook is not present on this opencode build");
338
415
  }
339
416
 
340
- // --- STATUS TOOL: ctx.tool.transform(t => t.add(...)) ------------------
417
+ // --- STATUS TOOL: ctx.tool.transform(t => t.add(info)) ------------------
418
+ //
419
+ // `add` takes ONE structural Tool.Info: { name, input, description, execute,
420
+ // options? } (verified against next-16502; the docs' three-argument
421
+ // add(name, def, options) is wrong for this build). `input` is a raw JSON
422
+ // Schema; the result is { content: [{ type: "text", text }] }.
341
423
  if (ctx && ctx.tool && typeof ctx.tool.transform === "function") {
342
424
  const reg = await ctx.tool.transform((tools) => {
343
425
  tools.add({
@@ -349,7 +431,7 @@ export async function setup(ctx) {
349
431
  "would be without the env/option pin, and any misconfiguration worth flagging. Read-only; " +
350
432
  "secrets are redacted. Call it when the user asks what memini is doing, why a memory " +
351
433
  "cannot be recalled, or which namespace is in use.",
352
- jsonSchema: { type: "object", properties: {}, additionalProperties: false },
434
+ input: { type: "object", properties: {}, additionalProperties: false },
353
435
  options: { codemode: false },
354
436
  execute: async () => {
355
437
  try {
@@ -365,13 +447,7 @@ export async function setup(ctx) {
365
447
  report.memory.recall_max_tokens = live.recall_max_tokens;
366
448
  report.memory.recall_min_score = live.recall_min_score;
367
449
  const text = renderStatus(report);
368
- return {
369
- structured: {
370
- namespace: report.namespace.effective,
371
- source: report.namespace.source,
372
- },
373
- content: [{ type: "text", text }],
374
- };
450
+ return { content: [{ type: "text", text }] };
375
451
  } catch (error) {
376
452
  return { content: [{ type: "text", text: `memini status failed: ${String(error)}` }] };
377
453
  }
@@ -383,45 +459,122 @@ export async function setup(ctx) {
383
459
  log.warn("memini_status unavailable: ctx.tool.transform is not present on this opencode build");
384
460
  }
385
461
 
386
- // --- CAPTURE: ctx.event.subscribe("session.idle") ----------------------
462
+ // --- CAPTURE: ctx.event.subscribe() -------------------------------------
463
+ //
464
+ // Detached: the docs say not to await an infinite stream inside setup. We
465
+ // spawn the consumer and hand back an AbortController-based cleanup.
387
466
  //
388
- // Detached: the docs say not to await an infinite stream inside setup. We spawn
389
- // the consumer and hand back an AbortController-based cleanup.
390
- const handleIdle = async (event) => {
467
+ // There is no `session.idle` and no message list accessor in v2, so a turn
468
+ // is reconstructed from the public event stream:
469
+ // session.inbox.enqueued -> user text (data.item.payload.text;
470
+ // session.input.admitted / data.input.data
471
+ // on builds up to next-16502)
472
+ // session.text.ended -> assistant part (data: sessionID,
473
+ // assistantMessageID, ordinal, text)
474
+ // session.execution.succeeded -> flush (failed / interrupted flush
475
+ // with metadata.failed = true)
476
+ // An execution event with no pending user text (compaction, synthetic
477
+ // input, a turn that started before this plugin generation loaded) captures
478
+ // nothing — same as v1's skip on an empty turn.
479
+ const MAX_TRACKED_CAPTURE = 200;
480
+ const pendingBySession = new Map(); // sessionID -> { userText, texts: Map<aid, Map<ordinal, text>>, lastAid }
481
+ const pendingState = (sessionID) => {
482
+ let state = pendingBySession.get(sessionID);
483
+ if (!state) {
484
+ state = { userText: "", texts: new Map(), lastAid: "" };
485
+ pendingBySession.set(sessionID, state);
486
+ while (pendingBySession.size > MAX_TRACKED_CAPTURE) {
487
+ const oldest = pendingBySession.keys().next().value;
488
+ if (oldest === undefined) break;
489
+ pendingBySession.delete(oldest);
490
+ }
491
+ }
492
+ return state;
493
+ };
494
+ const flushTurn = async (sessionID, failed) => {
391
495
  const live = await currentConfig();
392
496
  if (!live.capture) return;
393
- // The stream may be narrower or broader than session.idle depending on how
394
- // subscribe filters; guard on the type when present.
395
- if (event && event.type && event.type !== "session.idle") return;
396
- const sessionID =
397
- (event && event.properties && event.properties.sessionID) || (event && event.sessionID);
398
- if (!sessionID) return;
399
- const ancestry = await resolveSessionAncestry(ctx.session, sessionID);
400
- if (ancestry.session_type !== "root" && !live.capture_child_sessions) return;
401
- const messages = await fetchSessionMessages(ctx, sessionID);
402
- const { userText, assistantText, assistantID } = extractLastTurn(messages);
403
- if (!userText || !assistantText) return;
404
- if (assistantID && captured.has(assistantID)) return;
497
+ const pending = pendingBySession.get(sessionID);
498
+ pendingBySession.delete(sessionID);
499
+ if (!pending || !pending.userText || !pending.lastAid) return;
500
+ const parts = pending.texts.get(pending.lastAid);
501
+ const assistantText = parts
502
+ ? [...parts.entries()].sort((a, b) => a[0] - b[0]).map(([, text]) => text).join("\n").trim()
503
+ : "";
504
+ if (!assistantText) return;
505
+ const assistantID = pending.lastAid;
506
+ if (!claimCapturedAssistant(assistantID)) return;
507
+ const ancestry = await resolveSessionAncestry(
508
+ {
509
+ get: (input) => {
510
+ const id = typeof input === "string" ? input : input?.sessionID || input?.path?.id;
511
+ return ctx.session.get({ sessionID: id });
512
+ },
513
+ },
514
+ sessionID,
515
+ );
516
+ if (ancestry.session_type !== "root" && !live.capture_child_sessions) {
517
+ capturedAssistantIDs.delete(assistantID);
518
+ return;
519
+ }
405
520
  const metadata = { source: "opencode", session_id: sessionID, format: "turn", ...ancestry };
406
- if (lastAssistantFailed(messages)) metadata.failed = true;
521
+ if (failed) metadata.failed = true;
407
522
  const stored = await rest.postJson(
408
523
  "/v1/memories",
409
524
  {
410
- content: buildTurnCapture(userText, assistantText, live.capture_user_max_chars, live.capture_assistant_max_chars),
525
+ content: buildTurnCapture(pending.userText, assistantText, live.capture_user_max_chars, live.capture_assistant_max_chars),
411
526
  tags: ["opencode"],
412
527
  metadata,
413
528
  },
414
529
  live.namespace,
415
530
  );
416
- if (stored !== null && assistantID) captured.add(assistantID);
531
+ if (stored === null) capturedAssistantIDs.delete(assistantID);
532
+ };
533
+ const handleEvent = async (event) => {
534
+ const type = event && event.type;
535
+ const data = (event && event.data) || {};
536
+ const sessionID = data.sessionID || (data.properties && data.properties.sessionID);
537
+ if (!sessionID) return;
538
+ if (type === "session.inbox.enqueued" || type === "session.input.admitted") {
539
+ // next-17444 renamed the event and its payload: session.input.admitted
540
+ // { input: { type, data } } became session.inbox.enqueued
541
+ // { item: { type, payload } }. Both carry Prompt.fields, so `text` is
542
+ // read the same way once the wrapper is unwrapped. Synthetic and
543
+ // compaction items are skipped, as the "user" check always did.
544
+ const item = data.item || data.input;
545
+ const payload = item && item.type === "user" ? item.payload || item.data : null;
546
+ const text = payload && payload.text;
547
+ const pending = pendingState(sessionID);
548
+ pending.userText = typeof text === "string" ? text : "";
549
+ pending.texts = new Map();
550
+ pending.lastAid = "";
551
+ return;
552
+ }
553
+ if (type === "session.text.ended") {
554
+ if (!data.assistantMessageID || typeof data.text !== "string") return;
555
+ const pending = pendingState(sessionID);
556
+ let parts = pending.texts.get(data.assistantMessageID);
557
+ if (!parts) {
558
+ parts = new Map();
559
+ pending.texts.set(data.assistantMessageID, parts);
560
+ }
561
+ parts.set(data.ordinal ?? parts.size, data.text);
562
+ pending.lastAid = data.assistantMessageID;
563
+ return;
564
+ }
565
+ if (type === "session.execution.succeeded") return flushTurn(sessionID, false);
566
+ if (type === "session.execution.failed" || type === "session.execution.interrupted") {
567
+ return flushTurn(sessionID, true);
568
+ }
417
569
  };
418
570
 
419
571
  if (cfg.capture && ctx && ctx.event && typeof ctx.event.subscribe === "function") {
420
572
  const controller = new AbortController();
573
+ let iterator;
421
574
  const task = (async () => {
422
575
  let stream;
423
576
  try {
424
- stream = ctx.event.subscribe("session.idle");
577
+ stream = ctx.event.subscribe();
425
578
  } catch (error) {
426
579
  log.warn(`capture unavailable: ctx.event.subscribe threw: ${String(error)}`);
427
580
  return;
@@ -431,10 +584,13 @@ export async function setup(ctx) {
431
584
  return;
432
585
  }
433
586
  try {
434
- for await (const event of stream) {
587
+ iterator = stream[Symbol.asyncIterator]();
588
+ for (;;) {
435
589
  if (controller.signal.aborted) break;
590
+ const { value: event, done } = await iterator.next();
591
+ if (done) break;
436
592
  try {
437
- await handleIdle(event);
593
+ await handleEvent(event);
438
594
  } catch (error) {
439
595
  log.warn(`event hook failed: ${String(error)}`);
440
596
  }
@@ -445,6 +601,11 @@ export async function setup(ctx) {
445
601
  })();
446
602
  cleanups.push(async () => {
447
603
  controller.abort();
604
+ try {
605
+ if (iterator && typeof iterator.return === "function") await iterator.return();
606
+ } catch {
607
+ /* closing the event stream is best-effort */
608
+ }
448
609
  await task.catch(() => {});
449
610
  });
450
611
  } else if (cfg.capture) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eleboucher/opencode-memini",
3
- "version": "0.7.18",
3
+ "version": "0.7.20",
4
4
  "description": "Automatic cross-session memory for opencode via memini — recall before each turn, capture after.",
5
5
  "keywords": [
6
6
  "memini",