@eleboucher/opencode-memini 0.7.7 → 0.7.8

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 +37 -17
  2. package/memini-v2.js +79 -22
  3. package/memini.js +222 -60
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -70,23 +70,25 @@ Pass options inline via the `[name, options]` form:
70
70
  }
71
71
  ```
72
72
 
73
- | Option | Env var | Default | Purpose |
74
- | ------------------- | -------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
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`) |
77
- | `home` | `MEMINI_HOME` | unset | caller's personal namespace, sent as `X-Memini-Home`; unset = no home leg |
78
- | `recall` | `MEMINI_RECALL` | on | `false` disables recall-before-turn |
79
- | `capture` | `MEMINI_CAPTURE` | on | `false` disables capture-after-turn |
80
- | `recall_limit` | `MEMINI_RECALL_LIMIT` | `3` | max memories injected per turn |
81
- | `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
- | `recall_min_score` | `MEMINI_INJECT_RECALL_MIN_SCORE` | `0` | fused-score floor (>=) sent as `min_score` to `/v1/search` |
83
- | `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`) |
84
- | `timeout_ms` | `MEMINI_TIMEOUT_MS` | `30000` | per-request timeout (recall past its budget keeps running in the background under this bound) |
85
- | `fallback_on_error` | `MEMINI_FALLBACK` | on | `false` surfaces errors instead of degrading silently |
86
- | `auto_update` | `MEMINI_AUTO_UPDATE` | on | `false` disables npm auto-update checks (opencode never re-fetches cached plugins otherwise) |
87
- | | `MEMINI_INJECT_LABELS` | | comma-separated label toggles for each bullet: `tier`, `confidence`, `age`, `reason` |
88
- | | `MEMINI_API_KEY` | | bearer token, if memini needs auth (env only secret) |
89
- | — | `MEMINI_REQUIRE_HTTPS` | — | `1` refuses to send the token over plaintext HTTP |
73
+ | Option | Env var | Default | Purpose |
74
+ | ------------------------- | -------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
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`) |
77
+ | `home` | `MEMINI_HOME` | unset | caller's personal namespace, sent as `X-Memini-Home`; unset = no home leg |
78
+ | `recall` | `MEMINI_RECALL` | on | `false` disables recall-before-turn |
79
+ | `capture` | `MEMINI_CAPTURE` | on | `false` disables capture-after-turn |
80
+ | `recall_limit` | `MEMINI_RECALL_LIMIT` | `3` | max memories injected per turn |
81
+ | `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
+ | `recall_min_score` | `MEMINI_INJECT_RECALL_MIN_SCORE` | `0` | fused-score floor (>=) sent as `min_score` to `/v1/search` |
83
+ | `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
+ | `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
+ | `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
+ | `fallback_on_error` | `MEMINI_FALLBACK` | on | `false` surfaces errors instead of degrading silently |
88
+ | `auto_update` | `MEMINI_AUTO_UPDATE` | on | `false` disables npm auto-update checks (opencode never re-fetches cached plugins otherwise) |
89
+ | — | `MEMINI_INJECT_LABELS` | — | comma-separated label toggles for each bullet: `tier`, `confidence`, `age`, `reason` |
90
+ | — | `MEMINI_API_KEY` | — | bearer token, if memini needs auth (env only — secret) |
91
+ | — | `MEMINI_REQUIRE_HTTPS` | — | `1` refuses to send the token over plaintext HTTP |
90
92
 
91
93
  opencode awaits `chat.message` before the model sees the message, so a slow or
92
94
  unreachable memini would otherwise freeze the turn for the full `timeout_ms`.
@@ -98,6 +100,24 @@ startup to warm the connection, so the first recall doesn't pay the
98
100
  DNS/TLS cold-start. Set `recall_budget_ms: 0` to restore fully blocking
99
101
  same-turn injection.
100
102
 
103
+ ### Repeat-injection cooldown
104
+
105
+ The two `inject_cooldown_*` knobs are the windowed **repeat-injection
106
+ cooldown** (shared with the Claude Code / hermes / openclaw integrations): an
107
+ already-injected memory is excluded from recall (server-side via
108
+ `exclude_ids`, with a client-side backstop) while it is inside _either_ window
109
+ — the time window (`inject_cooldown_ms`) or the prompt window
110
+ (`inject_cooldown_prompts`, one prompt = one user message; in the v2 plugin,
111
+ one `request`-hook fire) — and is **re-served once both have lapsed**. A
112
+ memory whose content was updated in place re-injects immediately (the
113
+ content-hash bypass), so a correction is never withheld for the window.
114
+
115
+ > **Behavior change (was: suppress forever).** Earlier versions suppressed an
116
+ > injected memory for the whole session. With the default windows (30 min /
117
+ > 3 prompts) a long session is now **re-reminded** of a still-relevant memory
118
+ > once enough of the conversation has moved past it. Set both knobs to `0` to
119
+ > restore the old suppress-for-the-whole-session behavior.
120
+
101
121
  Inline options win over the env vars. Secrets stay in the environment: set
102
122
  `MEMINI_API_KEY` (sent as `Authorization: Bearer …`), and optionally
103
123
  `MEMINI_REQUIRE_HTTPS=1` to refuse plaintext HTTP, in the shell that launches
package/memini-v2.js CHANGED
@@ -45,6 +45,9 @@ import {
45
45
  describeSettings,
46
46
  renderStatus,
47
47
  createClient,
48
+ injectedIdentity,
49
+ injectedSuppressed,
50
+ postSearchWithFloor,
48
51
  } from "./memini.js";
49
52
 
50
53
  const INJECT_PREAMBLE =
@@ -164,23 +167,45 @@ export async function setup(ctx) {
164
167
  const currentConfig = async () => effectiveConfig(cfg, await getHandshake());
165
168
 
166
169
  // Assistant ids already captured, so repeated idle events for one turn don't
167
- // write duplicates. Memory ids already injected per session, so an unchanged
168
- // match isn't re-injected turn after turn. Both bounded for a long-lived host.
170
+ // write duplicates. Memory ids already injected per session the enforce
171
+ // core's { n, ids } shape (n = prompt counter, bumped once per request-hook
172
+ // fire; ids maps memory id → { h, at, n }), judged by injectedSuppressed
173
+ // against the inject_cooldown_ms / inject_cooldown_prompts windows: an
174
+ // unchanged match is suppressed while inside EITHER window, re-served once
175
+ // BOTH lapse, and re-served immediately when its content changed (h
176
+ // mismatch). Both maps bounded for a long-lived host.
169
177
  const captured = new Set();
170
- const injectedBySession = new Map();
178
+ const injectedBySession = new Map(); // session -> { n, ids: Map<id, {h, at, n}> }
171
179
  const MAX_TRACKED_SESSIONS = 200;
172
- const rememberInjected = (session, ids) => {
173
- let seen = injectedBySession.get(session);
174
- if (!seen) {
175
- seen = new Set();
176
- injectedBySession.set(session, seen);
180
+ const MAX_INJECTED_PER_SESSION = 200;
181
+ const sessionSeen = (session) => {
182
+ let state = injectedBySession.get(session);
183
+ if (!state) {
184
+ state = { n: 0, ids: new Map() };
185
+ injectedBySession.set(session, state);
177
186
  while (injectedBySession.size > MAX_TRACKED_SESSIONS) {
178
187
  const oldest = injectedBySession.keys().next().value;
179
188
  if (oldest === undefined) break;
180
189
  injectedBySession.delete(oldest);
181
190
  }
182
191
  }
183
- for (const id of ids) if (id) seen.add(id);
192
+ return state;
193
+ };
194
+ const rememberInjected = (state, hits) => {
195
+ const now = Date.now();
196
+ for (const r of hits) {
197
+ const id = r?.memory?.id;
198
+ if (!id) continue;
199
+ // delete+set refreshes the stamp and the insertion order, so the size
200
+ // cap evicts the least-recently-shown id first.
201
+ state.ids.delete(id);
202
+ state.ids.set(id, { h: injectedIdentity(r?.memory), at: now, n: state.n });
203
+ }
204
+ while (state.ids.size > MAX_INJECTED_PER_SESSION) {
205
+ const oldest = state.ids.keys().next().value;
206
+ if (oldest === undefined) break;
207
+ state.ids.delete(oldest);
208
+ }
184
209
  };
185
210
 
186
211
  const cleanups = [];
@@ -204,24 +229,57 @@ export async function setup(ctx) {
204
229
  const query = extractQueryFromRequest(event);
205
230
  if (!query) return;
206
231
  const sessionID = event.sessionID || event.sessionId || (event.session && event.session.id) || "";
232
+ // One request-hook fire == one prompt for the cooldown's prompt
233
+ // dimension (the v2 beta's closest per-turn signal): bump before any
234
+ // gate, so the window measures prompts-since-injection even on turns
235
+ // that inject nothing.
236
+ const seen = sessionID ? sessionSeen(sessionID) : null;
237
+ if (seen) seen.n += 1;
238
+ const cooldownOpts = () => ({
239
+ now: Date.now(),
240
+ counter: seen ? seen.n : 0,
241
+ cooldownMs: live.inject_cooldown_ms,
242
+ cooldownPrompts: live.inject_cooldown_prompts,
243
+ });
207
244
 
208
245
  const body = { query, limit: live.recall_limit };
209
246
  // Exclude this session's own captured turns: they're still in the live
210
247
  // context, so recalling them just echoes the conversation back a turn
211
248
  // behind. Past sessions still recall.
212
249
  if (sessionID) body.exclude_metadata = { session_id: sessionID };
213
- if (live.recall_min_score > 0) body.min_score = live.recall_min_score;
250
+ // inject_recall_min_score floors the FINAL composite score server-side
251
+ // via min_rank_score (not the fused-scale min_score), matching the
252
+ // Claude Code plugin. A knob >= 1 is out of the server's range, so it
253
+ // clamps to a client-only floor rather than 400ing every search.
254
+ const rankFloorInRange = live.recall_min_score > 0 && live.recall_min_score < 1;
255
+ if (rankFloorInRange) body.min_rank_score = live.recall_min_score;
214
256
 
215
257
  // Blocking, like v1's chat.message: opencode awaits this hook before
216
- // dispatch. postJson is bounded by cfg.timeout_ms and fail-soft, so a
217
- // slow/unreachable memini degrades to no memory this turn, never a throw.
218
- const result = await rest.postJson("/v1/search", body, live.namespace);
258
+ // dispatch. postSearchWithFloor is bounded by cfg.timeout_ms and
259
+ // fail-soft, and on an older server's 400 it retries once with
260
+ // min_rank_score stripped (v2 sends no exclude_ids), so a slow or
261
+ // out-of-date memini degrades to no memory this turn, never a throw.
262
+ const { data: result, rankFloorStripped } = await postSearchWithFloor(
263
+ rest.postJson,
264
+ body,
265
+ live.namespace,
266
+ );
219
267
 
220
- const floor = live.recall_min_score > 0 ? live.recall_min_score : 0;
268
+ // Client composite floor is a fallback ONLY: it runs when the knob was
269
+ // clamped to client-only (>= 1) or the retry stripped min_rank_score. A
270
+ // server that enforced the floor is authoritative and not re-filtered.
271
+ const serverEnforcedFloor = rankFloorInRange && !rankFloorStripped;
272
+ const floor = live.recall_min_score > 0 && !serverEnforcedFloor ? live.recall_min_score : 0;
221
273
  let rawHits = Array.isArray(result && result.results) ? result.results : [];
222
- if (sessionID) {
223
- const seen = injectedBySession.get(sessionID);
224
- if (seen && seen.size) rawHits = rawHits.filter((r) => !seen.has(r && r.memory && r.memory.id));
274
+ // Windowed cooldown, judged PER HIT against its content identity: an
275
+ // in-window unchanged hit is dropped, a lapsed one re-serves, and an
276
+ // UPDATED one (h mismatch) bypasses the window and re-injects.
277
+ if (seen && seen.ids.size) {
278
+ const opts = cooldownOpts();
279
+ rawHits = rawHits.filter((r) => {
280
+ const entry = seen.ids.get(r && r.memory && r.memory.id);
281
+ return !(entry && injectedSuppressed(entry, injectedIdentity(r && r.memory), opts));
282
+ });
225
283
  }
226
284
  const filtered =
227
285
  floor > 0
@@ -240,11 +298,10 @@ export async function setup(ctx) {
240
298
  }
241
299
  if (fit.dropped > 0) lines.push(`[... ${fit.dropped} item(s) truncated by token budget]`);
242
300
 
243
- if (injectContext(event, lines.join("\n")) && sessionID) {
244
- rememberInjected(
245
- sessionID,
246
- filtered.map((r) => r && r.memory && r.memory.id).filter(Boolean),
247
- );
301
+ if (injectContext(event, lines.join("\n")) && seen) {
302
+ // Record only the slice formatResults actually renders, stamped with
303
+ // {h, at, n} so the windowed cooldown can judge re-admission later.
304
+ rememberInjected(seen, filtered.slice(0, live.recall_limit || 3));
248
305
  }
249
306
  } catch (error) {
250
307
  log.warn(`request hook failed: ${String(error)}`);
package/memini.js CHANGED
@@ -20,6 +20,7 @@
20
20
  */
21
21
 
22
22
  import { execSync, spawnSync } from "node:child_process";
23
+ import { createHash } from "node:crypto";
23
24
  import { readFileSync, existsSync, rmSync, writeFileSync, statSync } from "node:fs";
24
25
  import { resolve, join, dirname } from "node:path";
25
26
  import { homedir } from "node:os";
@@ -337,6 +338,17 @@ export function resolveConfig(env, options, worktree) {
337
338
  const homeRaw = o.home !== undefined ? o.home : e.MEMINI_HOME;
338
339
  const home = homeRaw && String(homeRaw).trim() ? String(homeRaw).trim() : undefined;
339
340
 
341
+ // Windowed injection-cooldown knobs. 0 is MEANINGFUL (it disables that
342
+ // dimension; both 0 restores the legacy suppress-forever behavior), so a
343
+ // malformed option falls through to env/default rather than collapsing to 0.
344
+ const cooldownKnob = (optVal, envName, def) => {
345
+ if (optVal !== undefined) {
346
+ const n = Number(optVal);
347
+ if (Number.isFinite(n) && n >= 0) return n;
348
+ }
349
+ return intEnvFrom(e, envName, def);
350
+ };
351
+
340
352
  return {
341
353
  base_url: o.base_url || e.MEMINI_BASE_URL || DEFAULT_BASE_URL,
342
354
  // namespace is already resolved above (explicit raw-trimmed, or the
@@ -363,6 +375,11 @@ export function resolveConfig(env, options, worktree) {
363
375
  o.recall_min_score !== undefined
364
376
  ? Number(o.recall_min_score) || 0
365
377
  : floatEnv("MEMINI_INJECT_RECALL_MIN_SCORE", 0),
378
+ // Windowed injection cooldown (option > env > server settings via
379
+ // effectiveConfig > built-in default, mirroring the server's own
380
+ // ClientSettings defaults: 30 min / 3 prompts). See injectedSuppressed.
381
+ inject_cooldown_ms: cooldownKnob(o.inject_cooldown_ms, "MEMINI_INJECT_COOLDOWN_MS", 1800000),
382
+ inject_cooldown_prompts: cooldownKnob(o.inject_cooldown_prompts, "MEMINI_INJECT_COOLDOWN_PROMPTS", 3),
366
383
  recall_budget_ms,
367
384
  timeout_ms: Number(o.timeout_ms || e.MEMINI_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
368
385
  fallback_on_error:
@@ -379,6 +396,8 @@ export function resolveConfig(env, options, worktree) {
379
396
  recall_limit: o.recall_limit !== undefined || isSet(e.MEMINI_RECALL_LIMIT),
380
397
  recall_max_tokens: o.recall_max_tokens !== undefined || isSet(process.env.MEMINI_INJECT_RECALL_MAX_TOK),
381
398
  recall_min_score: o.recall_min_score !== undefined || isSet(process.env.MEMINI_INJECT_RECALL_MIN_SCORE),
399
+ inject_cooldown_ms: o.inject_cooldown_ms !== undefined || isSet(e.MEMINI_INJECT_COOLDOWN_MS),
400
+ inject_cooldown_prompts: o.inject_cooldown_prompts !== undefined || isSet(e.MEMINI_INJECT_COOLDOWN_PROMPTS),
382
401
  capture_user_max_chars: isSet(e.MEMINI_CAPTURE_USER_MAX_CHARS),
383
402
  capture_assistant_max_chars: isSet(e.MEMINI_CAPTURE_ASSISTANT_MAX_CHARS),
384
403
  },
@@ -427,6 +446,14 @@ export function effectiveConfig(cfg, hs) {
427
446
  explicit.recall_min_score || !Number.isFinite(s.inject_recall_min_score)
428
447
  ? cfg.recall_min_score
429
448
  : s.inject_recall_min_score,
449
+ inject_cooldown_ms:
450
+ explicit.inject_cooldown_ms || !Number.isFinite(s.inject_cooldown_ms)
451
+ ? cfg.inject_cooldown_ms
452
+ : s.inject_cooldown_ms,
453
+ inject_cooldown_prompts:
454
+ explicit.inject_cooldown_prompts || !Number.isFinite(s.inject_cooldown_prompts)
455
+ ? cfg.inject_cooldown_prompts
456
+ : s.inject_cooldown_prompts,
430
457
  capture_user_max_chars:
431
458
  explicit.capture_user_max_chars || !Number.isFinite(s.capture_user_max_chars)
432
459
  ? cfg.capture_user_max_chars
@@ -579,7 +606,7 @@ export function intEnvFrom(env, name, defaultValue) {
579
606
 
580
607
  /**
581
608
  * floatEnv parses a non-negative float env var and returns `default` when
582
- * unset or malformed. Used for min_score.
609
+ * unset or malformed.
583
610
  */
584
611
  export function floatEnv(name, defaultValue) {
585
612
  const raw = process.env[name];
@@ -642,6 +669,62 @@ export function fitByTokens(items, maxTokens) {
642
669
  return { items: out, tokens: used, dropped };
643
670
  }
644
671
 
672
+ // --- Injection-enforcement core (opencode copies) ---------------------------
673
+ //
674
+ // Ported from @memini/client's enforce core (packages/memini-client/src/
675
+ // enforce/identity.ts + seen.ts); semantics are pinned by the shared golden
676
+ // vectors (packages/memini-client/vectors/enforcement.json), replayed by
677
+ // memini.test.mjs. This plugin ships standalone (no build step), so these
678
+ // stay copies, not imports — the vector replay is what keeps them the same
679
+ // functions.
680
+
681
+ /**
682
+ * True for a well-formed server-minted content hash: 16 lowercase hex chars
683
+ * (the server's sha256(content||summary).slice(0,16) — the same recipe as the
684
+ * local fallback in injectedIdentity, so the two are interchangeable).
685
+ */
686
+ export function isContentHash(s) {
687
+ return typeof s === "string" && /^[0-9a-f]{16}$/.test(s);
688
+ }
689
+
690
+ /**
691
+ * Content-identity hash for the injected-memory state: prefer the server-
692
+ * minted content_hash (read off the object itself or its nested `memory`),
693
+ * else hash the text a recall surface would render (content, falling back to
694
+ * summary) — so an in-place update still changes identity and re-injects even
695
+ * on servers without content_hash.
696
+ */
697
+ export function injectedIdentity(m) {
698
+ const ch = m?.content_hash ?? m?.memory?.content_hash;
699
+ if (isContentHash(ch)) return ch;
700
+ const text = m?.content || m?.summary || "";
701
+ return createHash("sha256").update(text).digest("hex").slice(0, 16);
702
+ }
703
+
704
+ /**
705
+ * The shared windowed-cooldown predicate (enforce/seen.ts, core-exact):
706
+ *
707
+ * entry.h === "" → true sentinel/tool-read: forever
708
+ * identity && entry.h !== identity → false content changed: re-inject
709
+ * cooldownMs == 0 && prompts == 0 → true legacy forever-dedupe (#134)
710
+ * else suppressed within EITHER window; re-admit once BOTH lapse.
711
+ * counter == 0 leaves the prompt dimension inert (a host that never
712
+ * advances a counter degrades to time-only, not forever); negative deltas
713
+ * (clock skew / counter regression) clamp to suppressed.
714
+ *
715
+ * `identity` null is the id-only check (the exclude_ids view): the content-
716
+ * change bypass is skipped, so an entry is judged on the windows alone.
717
+ */
718
+ export function injectedSuppressed(entry, identity, { now, counter, cooldownMs, cooldownPrompts }) {
719
+ if (!entry || typeof entry !== "object") return false;
720
+ if (entry.h === "") return true; // sentinel / tool-read: forever
721
+ if (identity && entry.h !== identity) return false; // content changed: re-inject
722
+ if (cooldownMs === 0 && cooldownPrompts === 0) return true; // legacy forever-dedupe
723
+ const promptDim = cooldownPrompts > 0 && counter > 0 && counter - entry.n < cooldownPrompts;
724
+ const timeDim = cooldownMs > 0 && now - entry.at < cooldownMs;
725
+ return promptDim || timeDim;
726
+ }
727
+
645
728
  /**
646
729
  * Truncate `s` to `max` CHARACTERS for a turn capture, marking the cut. `max <= 0`
647
730
  * captures it whole. Mirrors @memini/client's truncateForCapture — this plugin
@@ -933,6 +1016,49 @@ export function createClient(cfg, log) {
933
1016
  return { postJson, handshake, baseUrl };
934
1017
  }
935
1018
 
1019
+ /**
1020
+ * POST /v1/search carrying the newer-than-server optional fields, retrying ONCE
1021
+ * with min_rank_score (and any exclude_ids) stripped when the first attempt
1022
+ * fails. An older server 400s an unknown field (returned as null under
1023
+ * fail-soft, or a throw with fallback_on_error off), so this degrades to the
1024
+ * client-side composite floor fallback instead of losing recall entirely.
1025
+ *
1026
+ * Returns {data, rankFloorStripped}. rankFloorStripped is true only when the
1027
+ * floor was sent and then dropped on the retry — the signal the caller uses to
1028
+ * decide whether to re-apply the composite floor client-side. A server that
1029
+ * accepted the floor is authoritative and its result set is NOT re-filtered.
1030
+ * exclude_ids rides only the first attempt and is stripped alongside the floor
1031
+ * on retry (matching _shared.mjs's combined strip); onExcludeIdsUnsupported,
1032
+ * when given, latches it off for the session. Unlike the Claude plugin's
1033
+ * pretool latch, the floor itself is never latched off: this integration tracks
1034
+ * no content hash, so a stateless per-call strip is the faithful port.
1035
+ */
1036
+ export async function postSearchWithFloor(postJson, body, namespace, opts = {}) {
1037
+ const { excludeIds = [], onExcludeIdsUnsupported } = opts;
1038
+ const rankFloorInBody = body.min_rank_score !== undefined;
1039
+ const withExcludeIds = excludeIds.length > 0;
1040
+ if (!rankFloorInBody && !withExcludeIds) {
1041
+ return { data: await postJson("/v1/search", body, namespace), rankFloorStripped: false };
1042
+ }
1043
+ try {
1044
+ const first = await postJson(
1045
+ "/v1/search",
1046
+ withExcludeIds ? { ...body, exclude_ids: excludeIds } : body,
1047
+ namespace,
1048
+ );
1049
+ if (first !== null) return { data: first, rankFloorStripped: false };
1050
+ } catch {
1051
+ // With fallback_on_error=false the 400 arrives as a throw, not null.
1052
+ }
1053
+ const stripped = { ...body };
1054
+ delete stripped.min_rank_score;
1055
+ const retry = await postJson("/v1/search", stripped, namespace);
1056
+ if (retry !== null && withExcludeIds && typeof onExcludeIdsUnsupported === "function") {
1057
+ onExcludeIdsUnsupported();
1058
+ }
1059
+ return { data: retry, rankFloorStripped: rankFloorInBody };
1060
+ }
1061
+
936
1062
  // extractLastTurn returns the latest user and assistant text from the message
937
1063
  // list returned by client.session.messages ([{info, parts}, ...]), plus the id
938
1064
  // of the assistant message (for dedup). Iterates in reverse to short-circuit.
@@ -1034,21 +1160,38 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1034
1160
  // Memory ids each session has already been shown (mirrors the pi plugin):
1035
1161
  // the injected synthetic part is persisted into the session, so re-injecting
1036
1162
  // an unchanged match every turn stacks identical blocks in the context.
1037
- // The inner cap keeps a stable session which never ages out of the outer
1038
- // map from growing its Set for the process lifetime.
1039
- const injectedBySession = new Map();
1163
+ // Per session: the enforce core's { n, ids } shape n is the prompt counter
1164
+ // (bumped once per chat.message) and ids maps memory id → { h, at, n }
1165
+ // (content identity, last-injected ms, counter at injection), judged by
1166
+ // injectedSuppressed against the inject_cooldown_ms / inject_cooldown_prompts
1167
+ // windows: suppressed within EITHER window, re-served once BOTH lapse, and
1168
+ // re-served immediately when the content changed (h mismatch). The inner cap
1169
+ // keeps a stable session — which never ages out of the outer map — from
1170
+ // growing its map for the process lifetime.
1171
+ const injectedBySession = new Map(); // session -> { n, ids: Map<id, {h, at, n}> }
1040
1172
  const MAX_INJECTED_PER_SESSION = 200;
1041
- const rememberInjected = (session, ids) => {
1042
- let seen = injectedBySession.get(session);
1043
- if (!seen) {
1044
- seen = new Set();
1045
- boundedPut(injectedBySession, session, seen);
1173
+ const sessionSeen = (session) => {
1174
+ let state = injectedBySession.get(session);
1175
+ if (!state) {
1176
+ state = { n: 0, ids: new Map() };
1177
+ boundedPut(injectedBySession, session, state);
1046
1178
  }
1047
- for (const id of ids) if (id) seen.add(id);
1048
- while (seen.size > MAX_INJECTED_PER_SESSION) {
1049
- const oldest = seen.values().next().value;
1179
+ return state;
1180
+ };
1181
+ const rememberInjected = (state, hits) => {
1182
+ const now = Date.now();
1183
+ for (const r of hits) {
1184
+ const id = r?.memory?.id;
1185
+ if (!id) continue;
1186
+ // delete+set refreshes both the stamp and the insertion order, so the
1187
+ // size cap below evicts the least-recently-shown id first.
1188
+ state.ids.delete(id);
1189
+ state.ids.set(id, { h: injectedIdentity(r?.memory), at: now, n: state.n });
1190
+ }
1191
+ while (state.ids.size > MAX_INJECTED_PER_SESSION) {
1192
+ const oldest = state.ids.keys().next().value;
1050
1193
  if (oldest === undefined) break;
1051
- seen.delete(oldest);
1194
+ state.ids.delete(oldest);
1052
1195
  }
1053
1196
  };
1054
1197
  // Recall results that arrived after the injection budget expired, keyed by
@@ -1060,23 +1203,18 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1060
1203
  // 400 on the unknown field: when a request carrying it fails and the retry
1061
1204
  // without it succeeds, stop sending it. The client-side filter stays.
1062
1205
  let serverExcludeIds = true;
1063
- const searchExcluding = async (body, excludeIds, namespace) => {
1064
- if (!serverExcludeIds || excludeIds.length === 0) {
1065
- return rest.postJson("/v1/search", body, namespace);
1066
- }
1067
- try {
1068
- const result = await rest.postJson("/v1/search", { ...body, exclude_ids: excludeIds }, namespace);
1069
- if (result !== null) return result;
1070
- } catch {
1071
- // With fallback_on_error=false the 400 arrives as a throw, not null.
1072
- }
1073
- const retry = await rest.postJson("/v1/search", body, namespace);
1074
- if (retry !== null) {
1075
- serverExcludeIds = false;
1076
- log.warn("memini: server does not accept exclude_ids; using client-side dedupe only");
1077
- }
1078
- return retry;
1079
- };
1206
+ // Delegate the search + one-shot compat retry to postSearchWithFloor, which
1207
+ // strips BOTH min_rank_score and exclude_ids on an older server's 400. Keep
1208
+ // the exclude_ids latch here (a closure the callback flips off); the floor is
1209
+ // not latched. Returns {data, rankFloorStripped}.
1210
+ const searchExcluding = (body, excludeIds, namespace) =>
1211
+ postSearchWithFloor(rest.postJson, body, namespace, {
1212
+ excludeIds: serverExcludeIds ? excludeIds : [],
1213
+ onExcludeIdsUnsupported: () => {
1214
+ serverExcludeIds = false;
1215
+ log.warn("memini: server does not accept exclude_ids; using client-side dedupe only");
1216
+ },
1217
+ });
1080
1218
 
1081
1219
  // opencode runs chat.message via an unguarded Effect.promise (a throw aborts the
1082
1220
  // turn) and dispatches event hooks fire-and-forget, so a hook must never reject:
@@ -1148,18 +1286,40 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1148
1286
  const sibling = output.parts.find((p) => p && p.type === "text") || {};
1149
1287
  const sessionID = input.sessionID || sibling.sessionID;
1150
1288
  const messageID = input.messageID || sibling.messageID;
1289
+ // One chat.message == one user prompt: bump the session's prompt counter
1290
+ // before any recall — the cooldown's prompt dimension measures prompts-
1291
+ // since-injection even on turns that inject nothing.
1292
+ const seen = sessionID ? sessionSeen(sessionID) : null;
1293
+ if (seen) seen.n += 1;
1294
+ const cooldownOpts = () => ({
1295
+ now: Date.now(),
1296
+ counter: seen ? seen.n : 0,
1297
+ cooldownMs: live.inject_cooldown_ms,
1298
+ cooldownPrompts: live.inject_cooldown_prompts,
1299
+ });
1151
1300
  const body = { query, limit: live.recall_limit };
1152
1301
  // Exclude this session's own captured turns: they're still in the live
1153
1302
  // context, so recalling them just echoes the conversation back a turn
1154
1303
  // behind. Captures from other (past) sessions are still recalled.
1155
1304
  if (sessionID) body.exclude_metadata = { session_id: sessionID };
1156
- // min_score (fused-score floor) is optional and matches the wire knob
1157
- // the Claude Code plugin's pre-tool-use hook uses; client-side re-filter
1158
- // is a belt-and-braces guard against score-normalization edge cases.
1159
- if (live.recall_min_score > 0) body.min_score = live.recall_min_score;
1160
- // Already-shown ids go along as exclude_ids so a suppressed hit doesn't
1161
- // waste a recall_limit slot.
1162
- const excludeIds = sessionID ? [...(injectedBySession.get(sessionID) ?? [])] : [];
1305
+ // inject_recall_min_score floors the FINAL composite score server-side
1306
+ // via min_rank_score (not the fused-scale min_score), matching the Claude
1307
+ // Code plugin. A knob >= 1 is out of the server's range, so it clamps to a
1308
+ // client-only floor rather than 400ing every search.
1309
+ const rankFloorInRange = live.recall_min_score > 0 && live.recall_min_score < 1;
1310
+ if (rankFloorInRange) body.min_rank_score = live.recall_min_score;
1311
+ // Ids still IN COOLDOWN go along as exclude_ids so a suppressed hit
1312
+ // doesn't waste a recall_limit slot (id-only judgment — the wire cannot
1313
+ // know what content the server would serve); a LAPSED id is
1314
+ // intentionally absent so the server may re-serve it.
1315
+ const excludeIds = seen
1316
+ ? (() => {
1317
+ const opts = cooldownOpts();
1318
+ return [...seen.ids.entries()]
1319
+ .filter(([, e]) => injectedSuppressed(e, null, opts))
1320
+ .map(([id]) => id);
1321
+ })()
1322
+ : [];
1163
1323
  // opencode awaits this hook before the model sees the message, so the
1164
1324
  // turn only waits live.recall_budget_ms for the search; the fetch itself keeps
1165
1325
  // cfg.timeout_ms as its bound and runs on in the background. A slow or
@@ -1187,7 +1347,7 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1187
1347
  );
1188
1348
  if (sessionID) {
1189
1349
  settled.then((late) => {
1190
- const hits = Array.isArray(late && late.results) ? late.results : [];
1350
+ const hits = Array.isArray(late && late.data && late.data.results) ? late.data.results : [];
1191
1351
  if (hits.length) boundedPut(pendingBySession, sessionID, hits);
1192
1352
  });
1193
1353
  }
@@ -1196,12 +1356,14 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1196
1356
  } else {
1197
1357
  result = await settled;
1198
1358
  }
1199
- // Client-side score floor: filter the raw hit list before formatting so
1200
- // the bullet array only contains hits the operator asked for. Without
1201
- // this, the server's default floor could leak low-quality hits in
1202
- // regardless of live.recall_min_score.
1203
- const floor = live.recall_min_score > 0 ? live.recall_min_score : 0;
1204
- let rawHits = Array.isArray(result && result.results) ? result.results : [];
1359
+ const searchData = result && result.data ? result.data : null;
1360
+ // Client composite floor is a fallback ONLY: it runs when the knob was
1361
+ // clamped to client-only (>= 1) or the retry stripped min_rank_score for
1362
+ // an old server. A server that enforced the floor is authoritative and
1363
+ // its result set is not re-filtered here.
1364
+ const serverEnforcedFloor = rankFloorInRange && !(result && result.rankFloorStripped);
1365
+ const floor = live.recall_min_score > 0 && !serverEnforcedFloor ? live.recall_min_score : 0;
1366
+ let rawHits = Array.isArray(searchData && searchData.results) ? searchData.results : [];
1205
1367
  // Merge in results that arrived late on a previous turn: fresh hits
1206
1368
  // first (they answer the current query), deduped by memory id.
1207
1369
  if (sessionID) {
@@ -1212,11 +1374,17 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1212
1374
  rawHits = rawHits.concat(pending.filter((r) => !fresh.has(r?.memory?.id)));
1213
1375
  }
1214
1376
  }
1215
- // Suppress memories this session has already been shown the injected
1216
- // part persists in the session, so a repeat adds nothing but noise.
1217
- if (sessionID) {
1218
- const seen = injectedBySession.get(sessionID);
1219
- if (seen && seen.size) rawHits = rawHits.filter((r) => !seen.has(r?.memory?.id));
1377
+ // Suppress memories this session was already shown and that are still in
1378
+ // cooldown judged PER HIT against its content identity, so an
1379
+ // in-window unchanged hit is dropped, a lapsed one passes through and
1380
+ // re-serves, and an UPDATED one (h mismatch) bypasses the window and
1381
+ // re-injects immediately.
1382
+ if (seen && seen.ids.size) {
1383
+ const opts = cooldownOpts();
1384
+ rawHits = rawHits.filter((r) => {
1385
+ const entry = seen.ids.get(r?.memory?.id);
1386
+ return !(entry && injectedSuppressed(entry, injectedIdentity(r?.memory), opts));
1387
+ });
1220
1388
  }
1221
1389
  const filtered = floor > 0
1222
1390
  ? rawHits.filter((r) => (typeof r?.score === "number" ? r.score : 0) >= floor)
@@ -1229,17 +1397,11 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1229
1397
  // behaviour matches the prior "no cap" code path for existing installs.
1230
1398
  const fit = fitByTokens(hits, live.recall_max_tokens);
1231
1399
  if (fit.items.length === 0) return;
1232
- if (sessionID) {
1400
+ if (seen) {
1233
1401
  // Mark only the slice formatResults actually renders: with carryover
1234
1402
  // merged in, `filtered` can exceed recall_limit, and marking unshown
1235
- // hits as seen would suppress them forever.
1236
- rememberInjected(
1237
- sessionID,
1238
- filtered
1239
- .slice(0, live.recall_limit || DEFAULT_RECALL_LIMIT)
1240
- .map((r) => r?.memory?.id)
1241
- .filter(Boolean),
1242
- );
1403
+ // hits as seen would suppress what was never injected.
1404
+ rememberInjected(seen, filtered.slice(0, live.recall_limit || DEFAULT_RECALL_LIMIT));
1243
1405
  }
1244
1406
  const lines = [
1245
1407
  `Relevant long-term memory from memini (background context — prefer ` +
@@ -1249,8 +1411,8 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1249
1411
  // /v1/search sets `degraded: "keyword_only"` (plus a `note`) when the
1250
1412
  // query embed was unavailable and it fell back to keyword-only matching;
1251
1413
  // both are already on `result`, so surfacing them is a one-line addition.
1252
- if (result && result.degraded) {
1253
- lines.push(`[memini: ${result.note || "semantic search unavailable — results are keyword-only and may be incomplete"}]`);
1414
+ if (searchData && searchData.degraded) {
1415
+ lines.push(`[memini: ${searchData.note || "semantic search unavailable — results are keyword-only and may be incomplete"}]`);
1254
1416
  }
1255
1417
  if (fit.dropped > 0) lines.push(`[... ${fit.dropped} item(s) truncated by token budget]`);
1256
1418
  // opencode's part schema requires ids to start with `prt`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eleboucher/opencode-memini",
3
- "version": "0.7.7",
3
+ "version": "0.7.8",
4
4
  "description": "Automatic cross-session memory for opencode via memini — recall before each turn, capture after.",
5
5
  "keywords": [
6
6
  "memini",