@eleboucher/opencode-memini 0.7.11 → 0.7.13

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/README.md CHANGED
@@ -15,8 +15,8 @@ What it wires (two hooks):
15
15
  excludes this session's own captured turns (already in the live context), so
16
16
  they aren't echoed back as memory a turn behind; past sessions still recall.
17
17
  - **`event` (`session.idle`)** — once the session goes idle, stores the
18
- completed user/assistant turn back into memini (episodic, tagged with the
19
- session id) so it can be recalled later.
18
+ completed user/assistant turn back into memini (tagged with the session id) so
19
+ it can be recalled later.
20
20
 
21
21
  ### Install
22
22
 
@@ -60,6 +60,10 @@ The same options and env vars below apply. Recall injects into the request's
60
60
  > `core/src/plugin/host.ts`. Until then, stay on the v1 entry above with the
61
61
  > stable `opencode` binary.
62
62
 
63
+ The v2 plugin sends diagnostics through the structured `ctx.app.log` logger when
64
+ available. Logging is best-effort and remains silent when that logger is absent
65
+ or unavailable.
66
+
63
67
  ### Configure
64
68
 
65
69
  Pass options inline via the `[name, options]` form:
@@ -77,13 +81,14 @@ Pass options inline via the `[name, options]` form:
77
81
  | `home` | `MEMINI_HOME` | unset | caller's personal namespace, sent as `X-Memini-Home`; unset = no home leg |
78
82
  | `recall` | `MEMINI_RECALL` | on | `false` disables recall-before-turn |
79
83
  | `capture` | `MEMINI_CAPTURE` | on | `false` disables capture-after-turn |
84
+ | `capture_child_sessions` | `MEMINI_CAPTURE_CHILD_SESSIONS` | off | capture child sessions; off skips children and unknown ancestry (fail-closed), on records ancestry metadata |
80
85
  | `recall_limit` | `MEMINI_RECALL_LIMIT` | `3` | max memories injected per turn |
81
86
  | `recall_max_tokens` | `MEMINI_INJECT_RECALL_MAX_TOK` | `0` | hard ceiling on the recall-block tokens (`0` = unbounded); the tail is dropped with a `[… N item(s) truncated by token budget]` footer |
82
87
  | `recall_min_score` | `MEMINI_INJECT_RECALL_MIN_SCORE` | `0` | fused-score floor (>=) sent as `min_score` to `/v1/search` |
83
88
  | `inject_cooldown_ms` | `MEMINI_INJECT_COOLDOWN_MS` | `1800000` | repeat-injection cooldown, **time** window (ms): an already-injected memory is held back this long before it may re-serve; `0` disables the time dimension |
84
89
  | `inject_cooldown_prompts` | `MEMINI_INJECT_COOLDOWN_PROMPTS` | `3` | repeat-injection cooldown, **prompt** window (counted per user message); `0` disables the prompt dimension; both cooldown knobs `0` = suppress for the whole session |
85
90
  | `recall_budget_ms` | `MEMINI_RECALL_BUDGET_MS` | `2000` | how long a turn waits for recall before proceeding without it (`0` = wait for the full `timeout_ms`) |
86
- | `timeout_ms` | `MEMINI_TIMEOUT_MS` | `30000` | per-request timeout (recall past its budget keeps running in the background under this bound) |
91
+ | `timeout_ms` | `MEMINI_TIMEOUT_MS` | `30000` | per-request timeout for memini requests |
87
92
  | `fallback_on_error` | `MEMINI_FALLBACK` | on | `false` surfaces errors instead of degrading silently |
88
93
  | `auto_update` | `MEMINI_AUTO_UPDATE` | on | `false` disables npm auto-update checks (opencode never re-fetches cached plugins otherwise) |
89
94
  | — | `MEMINI_INJECT_LABELS` | — | comma-separated label toggles for each bullet: `tier`, `confidence`, `age`, `reason` |
@@ -93,13 +98,20 @@ Pass options inline via the `[name, options]` form:
93
98
  opencode awaits `chat.message` before the model sees the message, so a slow or
94
99
  unreachable memini would otherwise freeze the turn for the full `timeout_ms`.
95
100
  Instead, recall races `recall_budget_ms`: if the search hasn't answered in time,
96
- the turn proceeds without memories and the search keeps running in the
97
- background results that arrive late are injected on the session's next
98
- message instead of being dropped. The plugin also pings `/healthz` once at
99
- startup to warm the connection, so the first recall doesn't pay the
101
+ the turn proceeds without memories and eventual results are discarded. Errors
102
+ from the background request are still logged. The plugin also pings `/healthz`
103
+ once at startup to warm the connection, so the first recall doesn't pay the
100
104
  DNS/TLS cold-start. Set `recall_budget_ms: 0` to restore fully blocking
101
105
  same-turn injection.
102
106
 
107
+ Automatic captures leave tier selection to the server. Before retrieving
108
+ messages, the plugin resolves session ancestry. Root sessions are captured by
109
+ default; child sessions and sessions whose ancestry cannot be resolved are
110
+ skipped by default. Set `capture_child_sessions` (or
111
+ `MEMINI_CAPTURE_CHILD_SESSIONS=1`) to opt in; captures then include
112
+ `metadata.session_type` (`root`, `child`, or `unknown`) and child captures also
113
+ include `metadata.parent_session_id`.
114
+
103
115
  ### Repeat-injection cooldown
104
116
 
105
117
  The two `inject_cooldown_*` knobs are the windowed **repeat-injection
package/memini-v2.js CHANGED
@@ -48,11 +48,13 @@ import {
48
48
  injectedIdentity,
49
49
  injectedSuppressed,
50
50
  postSearchWithFloor,
51
+ resolveSessionAncestry,
51
52
  } from "./memini.js";
52
53
 
53
54
  const INJECT_PREAMBLE =
54
55
  "Relevant long-term memory from memini (background context — prefer " +
55
56
  "current workspace state and the user's instructions):";
57
+ const BUDGET_EXPIRED = Symbol("memini-recall-budget-expired");
56
58
 
57
59
  // messageText pulls the plain text out of one v2 request message, tolerating the
58
60
  // shapes the beta may hand us: `content` as a string, `content` as an array of
@@ -142,16 +144,17 @@ export async function setup(ctx) {
142
144
  // opencode runs the plugin in the project root, so cwd is the project dir —
143
145
  // the same input resolveConfig/deriveNamespace expect.
144
146
  const dir = process.cwd();
147
+ const emitLog = async (level, message) => {
148
+ try {
149
+ if (typeof ctx?.app?.log !== "function") return;
150
+ await ctx.app.log({ body: { service: "memini", level, message } });
151
+ } catch {
152
+ // Logging is best-effort and must never affect plugin behavior.
153
+ }
154
+ };
145
155
  const log = {
146
156
  warn: (message) => {
147
- // ctx is essentially a server client; mirror v1's structured logger and
148
- // fall back to stderr.
149
- try {
150
- ctx?.app?.log?.({ body: { service: "memini", level: "warn", message } });
151
- } catch {
152
- /* ignore logging failures */
153
- }
154
- console.error(`[memini] ${message}`);
157
+ void emitLog("warn", message);
155
158
  },
156
159
  };
157
160
 
@@ -259,11 +262,33 @@ export async function setup(ctx) {
259
262
  // fail-soft, and on an older server's 400 it retries once with
260
263
  // min_rank_score stripped (v2 sends no exclude_ids), so a slow or
261
264
  // out-of-date memini degrades to no memory this turn, never a throw.
262
- const { data: result, rankFloorStripped } = await postSearchWithFloor(
265
+ const searchPromise = postSearchWithFloor(
263
266
  rest.postJson,
264
267
  body,
265
268
  live.namespace,
266
269
  );
270
+ // Keep the rejection handler attached even after the request budget
271
+ // expires: late results are discarded, but late errors remain visible.
272
+ const settled = searchPromise.catch((error) => {
273
+ log.warn(`memini: ${String(error)}`);
274
+ return { data: null, rankFloorStripped: false };
275
+ });
276
+ let search;
277
+ if (live.recall_budget_ms > 0) {
278
+ let timer;
279
+ const budget = new Promise((resolve) => {
280
+ timer = setTimeout(() => resolve(BUDGET_EXPIRED), live.recall_budget_ms);
281
+ });
282
+ search = await Promise.race([settled, budget]);
283
+ clearTimeout(timer);
284
+ if (search === BUDGET_EXPIRED) {
285
+ log.warn(`recall exceeded its ${live.recall_budget_ms}ms budget; late results discarded`);
286
+ return;
287
+ }
288
+ } else {
289
+ search = await settled;
290
+ }
291
+ const { data: result, rankFloorStripped } = search;
267
292
 
268
293
  // Client composite floor is a fallback ONLY: it runs when the knob was
269
294
  // clamped to client-only (>= 1) or the retry stripped min_rank_score. A
@@ -371,11 +396,13 @@ export async function setup(ctx) {
371
396
  const sessionID =
372
397
  (event && event.properties && event.properties.sessionID) || (event && event.sessionID);
373
398
  if (!sessionID) return;
399
+ const ancestry = await resolveSessionAncestry(ctx.session, sessionID);
400
+ if (ancestry.session_type !== "root" && !live.capture_child_sessions) return;
374
401
  const messages = await fetchSessionMessages(ctx, sessionID);
375
402
  const { userText, assistantText, assistantID } = extractLastTurn(messages);
376
403
  if (!userText || !assistantText) return;
377
404
  if (assistantID && captured.has(assistantID)) return;
378
- const metadata = { source: "opencode", session_id: sessionID, format: "turn" };
405
+ const metadata = { source: "opencode", session_id: sessionID, format: "turn", ...ancestry };
379
406
  if (lastAssistantFailed(messages)) metadata.failed = true;
380
407
  const stored = await rest.postJson(
381
408
  "/v1/memories",
package/memini.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * - chat.message: recall memories relevant to the incoming user message and
7
7
  * inject them as a synthetic context part before the turn runs.
8
8
  * - event (session.idle): capture the completed user/assistant turn into
9
- * memini as episodic memory once the session goes idle.
9
+ * memini once the session goes idle.
10
10
  *
11
11
  * Talks to memini over REST (/v1/search, /v1/memories), scoped by the
12
12
  * X-Memini-Namespace header. Default endpoint http://localhost:8080.
@@ -365,6 +365,9 @@ export function resolveConfig(env, options, worktree) {
365
365
  // guess. Not layered from the server: it is a purely local, per-caller knob.
366
366
  const homeRaw = o.home !== undefined ? o.home : e.MEMINI_HOME;
367
367
  const home = homeRaw && String(homeRaw).trim() ? String(homeRaw).trim() : undefined;
368
+ const capture_child_sessions = o.capture_child_sessions !== undefined
369
+ ? envBool(o.capture_child_sessions, false)
370
+ : envBool(e.MEMINI_CAPTURE_CHILD_SESSIONS, false);
368
371
 
369
372
  // Windowed injection-cooldown knobs. 0 is MEANINGFUL (it disables that
370
373
  // dimension; both 0 restores the legacy suppress-forever behavior), so a
@@ -387,6 +390,7 @@ export function resolveConfig(env, options, worktree) {
387
390
  home,
388
391
  recall: o.recall !== undefined ? o.recall !== false : envBool(e.MEMINI_RECALL, true),
389
392
  capture: o.capture !== undefined ? o.capture !== false : envBool(e.MEMINI_CAPTURE, true),
393
+ capture_child_sessions,
390
394
  recall_limit,
391
395
  recall_max_tokens:
392
396
  o.recall_max_tokens !== undefined
@@ -791,6 +795,33 @@ export function buildTurnCapture(userText, assistantText, userMax, assistantMax)
791
795
  return `${truncateForCapture(userText, userMax)}\n\n${truncateForCapture(assistantText, assistantMax)}`;
792
796
  }
793
797
 
798
+ // Resolve ancestry before reading messages. Hosts have exposed several session
799
+ // accessors over time; an unavailable accessor is distinguishable from a root
800
+ // session so the default capture policy can fail closed.
801
+ export async function resolveSessionAncestry(session, sessionID) {
802
+ if (!session || !sessionID) return { session_type: "unknown" };
803
+ const attempts = [
804
+ () => session.get && session.get({ path: { id: sessionID } }),
805
+ () => session.info && session.info({ path: { id: sessionID } }),
806
+ () => session.get && session.get(sessionID),
807
+ ];
808
+ for (const attempt of attempts) {
809
+ try {
810
+ const response = await attempt();
811
+ if (response?.error) continue;
812
+ const info = response?.data || response?.session || response;
813
+ if (!info || typeof info !== "object" || Array.isArray(info) || info.error) continue;
814
+ const recordID = info.id || info.sessionID || info.sessionId;
815
+ if (!recordID || String(recordID) !== String(sessionID)) continue;
816
+ const parent = info.parentID || info.parentId || info.parent_session_id || info.parentSessionId;
817
+ return parent ? { session_type: "child", parent_session_id: parent } : { session_type: "root" };
818
+ } catch {
819
+ // Try the next host shape.
820
+ }
821
+ }
822
+ return { session_type: "unknown" };
823
+ }
824
+
794
825
  /**
795
826
  * Truncate to `max` bytes, suffix with a marker. Same shape as the Claude
796
827
  * Code plugin's truncate helper.
@@ -1249,10 +1280,6 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1249
1280
  state.ids.delete(oldest);
1250
1281
  }
1251
1282
  };
1252
- // Recall results that arrived after the injection budget expired, keyed by
1253
- // session and injected on that session's next chat.message. Latest-replace:
1254
- // a second late recall for the same session supersedes the first.
1255
- const pendingBySession = new Map();
1256
1283
  // /v1/search drops exclude_ids before ranking and the limit, so an
1257
1284
  // already-shown hit frees its slot for the next-best match. Older servers
1258
1285
  // 400 on the unknown field: when a request carrying it fails and the retry
@@ -1377,9 +1404,9 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1377
1404
  : [];
1378
1405
  // opencode awaits this hook before the model sees the message, so the
1379
1406
  // turn only waits live.recall_budget_ms for the search; the fetch itself keeps
1380
- // cfg.timeout_ms as its bound and runs on in the background. A slow or
1407
+ // cfg.timeout_ms as its bound and runs in the background. A slow or
1381
1408
  // unreachable memini degrades to "no memories this turn" instead of a
1382
- // frozen turn, and late results carry over to the session's next message.
1409
+ // frozen turn; results arriving after the budget are discarded.
1383
1410
  const fetchPromise = searchExcluding(body, excludeIds, live.namespace);
1384
1411
  // Once the budget expires nothing awaits this promise, and with
1385
1412
  // fallback_on_error off postJson rethrows — catch here or a late
@@ -1397,15 +1424,7 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1397
1424
  result = await Promise.race([settled, budget]);
1398
1425
  clearTimeout(timer);
1399
1426
  if (result === BUDGET_EXPIRED) {
1400
- log.info(
1401
- `recall exceeded its ${live.recall_budget_ms}ms budget; late results will inject next turn`,
1402
- );
1403
- if (sessionID) {
1404
- settled.then((late) => {
1405
- const hits = Array.isArray(late && late.data && late.data.results) ? late.data.results : [];
1406
- if (hits.length) boundedPut(pendingBySession, sessionID, hits);
1407
- });
1408
- }
1427
+ log.info(`recall exceeded its ${live.recall_budget_ms}ms budget; late results discarded`);
1409
1428
  result = null;
1410
1429
  }
1411
1430
  } else {
@@ -1419,16 +1438,6 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1419
1438
  const serverEnforcedFloor = rankFloorInRange && !(result && result.rankFloorStripped);
1420
1439
  const floor = live.recall_min_score > 0 && !serverEnforcedFloor ? live.recall_min_score : 0;
1421
1440
  let rawHits = Array.isArray(searchData && searchData.results) ? searchData.results : [];
1422
- // Merge in results that arrived late on a previous turn: fresh hits
1423
- // first (they answer the current query), deduped by memory id.
1424
- if (sessionID) {
1425
- const pending = pendingBySession.get(sessionID);
1426
- if (pending && pending.length) {
1427
- pendingBySession.delete(sessionID);
1428
- const fresh = new Set(rawHits.map((r) => r?.memory?.id).filter(Boolean));
1429
- rawHits = rawHits.concat(pending.filter((r) => !fresh.has(r?.memory?.id)));
1430
- }
1431
- }
1432
1441
  // Suppress memories this session was already shown and that are still in
1433
1442
  // cooldown — judged PER HIT against its content identity, so an
1434
1443
  // in-window unchanged hit is dropped, a lapsed one passes through and
@@ -1453,8 +1462,7 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1453
1462
  const fit = fitByTokens(hits, live.recall_max_tokens);
1454
1463
  if (fit.items.length === 0) return;
1455
1464
  if (seen) {
1456
- // Mark only the slice formatResults actually renders: with carryover
1457
- // merged in, `filtered` can exceed recall_limit, and marking unshown
1465
+ // Mark only the slice formatResults actually renders; marking unshown
1458
1466
  // hits as seen would suppress what was never injected.
1459
1467
  rememberInjected(seen, filtered.slice(0, live.recall_limit || DEFAULT_RECALL_LIMIT));
1460
1468
  }
@@ -1526,11 +1534,13 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1526
1534
  if (!live.capture || !event || event.type !== "session.idle") return;
1527
1535
  const sessionID = event.properties && event.properties.sessionID;
1528
1536
  if (!sessionID) return;
1537
+ const ancestry = await resolveSessionAncestry(client.session, sessionID);
1538
+ if (ancestry.session_type !== "root" && !live.capture_child_sessions) return;
1529
1539
  const res = await client.session.messages({ path: { id: sessionID } });
1530
1540
  const { userText, assistantText, assistantID } = extractLastTurn(res && res.data);
1531
1541
  if (!userText || !assistantText) return;
1532
1542
  if (assistantID && captured.has(assistantID)) return;
1533
- const metadata = { source: "opencode", session_id: sessionID, format: "turn" };
1543
+ const metadata = { source: "opencode", session_id: sessionID, format: "turn", ...ancestry };
1534
1544
  if (lastAssistantFailed(res && res.data)) metadata.failed = true;
1535
1545
  const stored = await rest.postJson(
1536
1546
  "/v1/memories",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eleboucher/opencode-memini",
3
- "version": "0.7.11",
3
+ "version": "0.7.13",
4
4
  "description": "Automatic cross-session memory for opencode via memini — recall before each turn, capture after.",
5
5
  "keywords": [
6
6
  "memini",