@eleboucher/opencode-memini 0.7.11 → 0.7.12

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
 
@@ -77,13 +77,14 @@ Pass options inline via the `[name, options]` form:
77
77
  | `home` | `MEMINI_HOME` | unset | caller's personal namespace, sent as `X-Memini-Home`; unset = no home leg |
78
78
  | `recall` | `MEMINI_RECALL` | on | `false` disables recall-before-turn |
79
79
  | `capture` | `MEMINI_CAPTURE` | on | `false` disables capture-after-turn |
80
+ | `capture_child_sessions` | `MEMINI_CAPTURE_CHILD_SESSIONS` | off | capture child sessions; off skips children and unknown ancestry (fail-closed), on records ancestry metadata |
80
81
  | `recall_limit` | `MEMINI_RECALL_LIMIT` | `3` | max memories injected per turn |
81
82
  | `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
83
  | `recall_min_score` | `MEMINI_INJECT_RECALL_MIN_SCORE` | `0` | fused-score floor (>=) sent as `min_score` to `/v1/search` |
83
84
  | `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
85
  | `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
86
  | `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) |
87
+ | `timeout_ms` | `MEMINI_TIMEOUT_MS` | `30000` | per-request timeout for memini requests |
87
88
  | `fallback_on_error` | `MEMINI_FALLBACK` | on | `false` surfaces errors instead of degrading silently |
88
89
  | `auto_update` | `MEMINI_AUTO_UPDATE` | on | `false` disables npm auto-update checks (opencode never re-fetches cached plugins otherwise) |
89
90
  | — | `MEMINI_INJECT_LABELS` | — | comma-separated label toggles for each bullet: `tier`, `confidence`, `age`, `reason` |
@@ -93,13 +94,20 @@ Pass options inline via the `[name, options]` form:
93
94
  opencode awaits `chat.message` before the model sees the message, so a slow or
94
95
  unreachable memini would otherwise freeze the turn for the full `timeout_ms`.
95
96
  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
97
+ the turn proceeds without memories and eventual results are discarded. Errors
98
+ from the background request are still logged. The plugin also pings `/healthz`
99
+ once at startup to warm the connection, so the first recall doesn't pay the
100
100
  DNS/TLS cold-start. Set `recall_budget_ms: 0` to restore fully blocking
101
101
  same-turn injection.
102
102
 
103
+ Automatic captures leave tier selection to the server. Before retrieving
104
+ messages, the plugin resolves session ancestry. Root sessions are captured by
105
+ default; child sessions and sessions whose ancestry cannot be resolved are
106
+ skipped by default. Set `capture_child_sessions` (or
107
+ `MEMINI_CAPTURE_CHILD_SESSIONS=1`) to opt in; captures then include
108
+ `metadata.session_type` (`root`, `child`, or `unknown`) and child captures also
109
+ include `metadata.parent_session_id`.
110
+
103
111
  ### Repeat-injection cooldown
104
112
 
105
113
  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
@@ -259,11 +261,33 @@ export async function setup(ctx) {
259
261
  // fail-soft, and on an older server's 400 it retries once with
260
262
  // min_rank_score stripped (v2 sends no exclude_ids), so a slow or
261
263
  // out-of-date memini degrades to no memory this turn, never a throw.
262
- const { data: result, rankFloorStripped } = await postSearchWithFloor(
264
+ const searchPromise = postSearchWithFloor(
263
265
  rest.postJson,
264
266
  body,
265
267
  live.namespace,
266
268
  );
269
+ // Keep the rejection handler attached even after the request budget
270
+ // expires: late results are discarded, but late errors remain visible.
271
+ const settled = searchPromise.catch((error) => {
272
+ log.warn(`memini: ${String(error)}`);
273
+ return { data: null, rankFloorStripped: false };
274
+ });
275
+ let search;
276
+ if (live.recall_budget_ms > 0) {
277
+ let timer;
278
+ const budget = new Promise((resolve) => {
279
+ timer = setTimeout(() => resolve(BUDGET_EXPIRED), live.recall_budget_ms);
280
+ });
281
+ search = await Promise.race([settled, budget]);
282
+ clearTimeout(timer);
283
+ if (search === BUDGET_EXPIRED) {
284
+ log.warn(`recall exceeded its ${live.recall_budget_ms}ms budget; late results discarded`);
285
+ return;
286
+ }
287
+ } else {
288
+ search = await settled;
289
+ }
290
+ const { data: result, rankFloorStripped } = search;
267
291
 
268
292
  // Client composite floor is a fallback ONLY: it runs when the knob was
269
293
  // clamped to client-only (>= 1) or the retry stripped min_rank_score. A
@@ -371,11 +395,13 @@ export async function setup(ctx) {
371
395
  const sessionID =
372
396
  (event && event.properties && event.properties.sessionID) || (event && event.sessionID);
373
397
  if (!sessionID) return;
398
+ const ancestry = await resolveSessionAncestry(ctx.session, sessionID);
399
+ if (ancestry.session_type !== "root" && !live.capture_child_sessions) return;
374
400
  const messages = await fetchSessionMessages(ctx, sessionID);
375
401
  const { userText, assistantText, assistantID } = extractLastTurn(messages);
376
402
  if (!userText || !assistantText) return;
377
403
  if (assistantID && captured.has(assistantID)) return;
378
- const metadata = { source: "opencode", session_id: sessionID, format: "turn" };
404
+ const metadata = { source: "opencode", session_id: sessionID, format: "turn", ...ancestry };
379
405
  if (lastAssistantFailed(messages)) metadata.failed = true;
380
406
  const stored = await rest.postJson(
381
407
  "/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.12",
4
4
  "description": "Automatic cross-session memory for opencode via memini — recall before each turn, capture after.",
5
5
  "keywords": [
6
6
  "memini",