@eleboucher/opencode-memini 0.7.7 → 0.7.9

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 +296 -101
  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";
@@ -54,11 +55,16 @@ const CLIENT_VERSION = readPluginVersion();
54
55
 
55
56
  // Auto-update: opencode never re-fetches cached npm plugins, so the plugin
56
57
  // checks npm dist-tags once per process and self-updates (same major version
57
- // only) so the running copy stays current.
58
+ // only) so the running copy stays current. opencode installs each plugin spec
59
+ // into its own isolated wrapper directory under
60
+ // ~/.cache/opencode/packages/<spec>/ (e.g. .../opencode-memini@latest/) — the
61
+ // wrapper holds a package.json listing the plugin as a dependency plus a
62
+ // node_modules/ tree — so the wrapper dir is where we rewrite the pin and
63
+ // re-run npm install.
58
64
  const PACKAGE_NAME = "@eleboucher/opencode-memini";
59
65
  const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`;
60
66
  const NPM_FETCH_TIMEOUT = 5000;
61
- const BUN_INSTALL_TIMEOUT_MS = 60000;
67
+ const INSTALL_TIMEOUT_MS = 60000;
62
68
  let autoUpdateChecked = false;
63
69
 
64
70
  /**
@@ -86,23 +92,50 @@ export function compareVersions(a, b) {
86
92
  }
87
93
 
88
94
  /**
89
- * resolveInstallContext finds the opencode plugin cache directory that holds
90
- * this running plugin instance, by walking up from import.meta.url. Returns
91
- * { installDir, packageJsonPath } or null.
95
+ * resolveInstallContextFrom walks up from `startPath` looking for the opencode
96
+ * plugin cache wrapper dir: the first ancestor whose child is a `node_modules`
97
+ * directory AND that also has a `package.json` sibling. Returns
98
+ * { installDir, packageJsonPath } or null. Pure (no `import.meta.url` read) so
99
+ * it can be driven from a synthetic on-disk layout in tests. Exported for
100
+ * testing.
92
101
  */
93
- function resolveInstallContext() {
102
+ export function resolveInstallContextFrom(startPath) {
94
103
  try {
95
- const pluginDir = dirname(fileURLToPath(import.meta.url));
96
- const nodeModulesDir = dirname(pluginDir);
97
- const installDir = dirname(nodeModulesDir);
98
- const packageJsonPath = join(installDir, "package.json");
99
- if (existsSync(packageJsonPath)) {
100
- return { installDir, packageJsonPath };
104
+ let dir = startPath;
105
+ for (let i = 0; i < 20; i++) {
106
+ const nodeModules = join(dir, "node_modules");
107
+ if (existsSync(nodeModules) && statSync(nodeModules).isDirectory()) {
108
+ const packageJsonPath = join(dir, "package.json");
109
+ if (existsSync(packageJsonPath)) {
110
+ return { installDir: dir, packageJsonPath };
111
+ }
112
+ return null; // node_modules found but no package.json — not a wrapper
113
+ }
114
+ const parent = dirname(dir);
115
+ if (parent === dir) break; // reached root
116
+ dir = parent;
101
117
  }
102
118
  } catch {}
103
119
  return null;
104
120
  }
105
121
 
122
+ /**
123
+ * resolveInstallContext finds the opencode plugin cache wrapper directory that
124
+ * holds this running plugin instance. opencode installs each npm plugin spec
125
+ * into its own isolated dir under ~/.cache/opencode/packages/<spec>/ (e.g.
126
+ * .../opencode-memini@latest/), containing a package.json listing the plugin as
127
+ * a dependency and a node_modules/ tree. The plugin's own file lives at
128
+ * <wrapper>/node_modules/@eleboucher/opencode-memini/memini.js, so the wrapper
129
+ * is the first ancestor whose child is a `node_modules` directory. Returns
130
+ * { installDir, packageJsonPath } or null. Exported for testing.
131
+ */
132
+ export function resolveInstallContext() {
133
+ try {
134
+ return resolveInstallContextFrom(dirname(fileURLToPath(import.meta.url)));
135
+ } catch {}
136
+ return null;
137
+ }
138
+
106
139
  /**
107
140
  * fetchLatestVersion queries npm dist-tags with a timeout. Returns the version
108
141
  * string or null on failure.
@@ -128,12 +161,19 @@ async function fetchLatestVersion() {
128
161
  }
129
162
 
130
163
  /**
131
- * prepareCacheUpdate rewrites the cache package.json to pin the new version,
132
- * removes the installed node_modules package, and cleans bun.lock. Returns
133
- * the installDir on success, null on failure.
164
+ * prepareCacheUpdate rewrites the wrapper package.json to pin the new version,
165
+ * removes the installed node_modules package, and cleans any lockfile so the
166
+ * next install re-fetches. Returns the installDir on success, null on failure.
167
+ *
168
+ * `ctx` is optional: when omitted, resolves from `import.meta.url` (the live
169
+ * path); tests pass a synthetic { installDir, packageJsonPath } so the full
170
+ * rewrite-and-clean flow can be exercised against a temp dir without touching
171
+ * the dev checkout. Exported for testing.
134
172
  */
135
- function prepareCacheUpdate(newVersion, log) {
136
- const ctx = resolveInstallContext();
173
+ export function prepareCacheUpdate(newVersion, log, ctx) {
174
+ if (!ctx) {
175
+ ctx = resolveInstallContext();
176
+ }
137
177
  if (!ctx) {
138
178
  log.warn("auto-update: could not resolve install context");
139
179
  return null;
@@ -150,7 +190,7 @@ function prepareCacheUpdate(newVersion, log) {
150
190
  log.warn(`auto-update: failed to rewrite cache package.json: ${String(err)}`);
151
191
  return null;
152
192
  }
153
- // Remove installed node_modules so bun install re-fetches
193
+ // Remove installed node_modules so the install re-fetches
154
194
  try {
155
195
  const pkgDir = join(ctx.installDir, "node_modules", "@eleboucher", "opencode-memini");
156
196
  if (existsSync(pkgDir)) rmSync(pkgDir, { recursive: true, force: true });
@@ -158,39 +198,33 @@ function prepareCacheUpdate(newVersion, log) {
158
198
  log.warn(`auto-update: failed to remove cached node_modules: ${String(err)}`);
159
199
  return null;
160
200
  }
161
- // Clean bun.lock entry if it exists
162
- const lockPath = join(ctx.installDir, "bun.lock");
163
- if (existsSync(lockPath)) {
201
+ // Clean lockfiles: opencode's installer may write either package-lock.json
202
+ // (npm/arborist) or bun.lock depending on the bundler. Remove both if
203
+ // present; the install regenerates them.
204
+ for (const lockName of ["package-lock.json", "bun.lock"]) {
205
+ const lockPath = join(ctx.installDir, lockName);
206
+ if (!existsSync(lockPath)) continue;
164
207
  try {
165
- const lock = JSON.parse(readFileSync(lockPath, "utf8"));
166
- let modified = false;
167
- if (lock.workspaces?.[""]?.dependencies?.[PACKAGE_NAME]) {
168
- delete lock.workspaces[""].dependencies[PACKAGE_NAME];
169
- modified = true;
170
- }
171
- if (lock.packages?.[PACKAGE_NAME]) {
172
- delete lock.packages[PACKAGE_NAME];
173
- modified = true;
174
- }
175
- if (modified) writeFileSync(lockPath, JSON.stringify(lock, null, 2));
208
+ rmSync(lockPath, { force: true });
176
209
  } catch {
177
- // bun.lock format varies; if we can't parse it, leave it bun install
178
- // will reconcile.
210
+ // A lock we can't remove isn't fatalnpm install reconciles it.
179
211
  }
180
212
  }
181
213
  return ctx.installDir;
182
214
  }
183
215
 
184
216
  /**
185
- * runBunInstall runs `bun install` in the given directory with a timeout.
186
- * Returns true on success (exit code 0), false otherwise.
217
+ * runNpmInstall runs `npm install` in the given directory with a timeout.
218
+ * Returns true on success (exit code 0), false otherwise. opencode uses
219
+ * @npmcli/arborist under the hood, so `npm install` matches the lockfile
220
+ * format it produces; `bun install` would rewrite it. Exported for testing.
187
221
  */
188
- function runBunInstall(installDir) {
222
+ export function runNpmInstall(installDir) {
189
223
  try {
190
- const result = spawnSync(process.execPath, ["install"], {
224
+ const result = spawnSync("npm", ["install"], {
191
225
  cwd: installDir,
192
226
  stdio: ["ignore", "pipe", "pipe"],
193
- timeout: BUN_INSTALL_TIMEOUT_MS,
227
+ timeout: INSTALL_TIMEOUT_MS,
194
228
  });
195
229
  return result.status === 0;
196
230
  } catch {
@@ -337,6 +371,17 @@ export function resolveConfig(env, options, worktree) {
337
371
  const homeRaw = o.home !== undefined ? o.home : e.MEMINI_HOME;
338
372
  const home = homeRaw && String(homeRaw).trim() ? String(homeRaw).trim() : undefined;
339
373
 
374
+ // Windowed injection-cooldown knobs. 0 is MEANINGFUL (it disables that
375
+ // dimension; both 0 restores the legacy suppress-forever behavior), so a
376
+ // malformed option falls through to env/default rather than collapsing to 0.
377
+ const cooldownKnob = (optVal, envName, def) => {
378
+ if (optVal !== undefined) {
379
+ const n = Number(optVal);
380
+ if (Number.isFinite(n) && n >= 0) return n;
381
+ }
382
+ return intEnvFrom(e, envName, def);
383
+ };
384
+
340
385
  return {
341
386
  base_url: o.base_url || e.MEMINI_BASE_URL || DEFAULT_BASE_URL,
342
387
  // namespace is already resolved above (explicit raw-trimmed, or the
@@ -363,6 +408,11 @@ export function resolveConfig(env, options, worktree) {
363
408
  o.recall_min_score !== undefined
364
409
  ? Number(o.recall_min_score) || 0
365
410
  : floatEnv("MEMINI_INJECT_RECALL_MIN_SCORE", 0),
411
+ // Windowed injection cooldown (option > env > server settings via
412
+ // effectiveConfig > built-in default, mirroring the server's own
413
+ // ClientSettings defaults: 30 min / 3 prompts). See injectedSuppressed.
414
+ inject_cooldown_ms: cooldownKnob(o.inject_cooldown_ms, "MEMINI_INJECT_COOLDOWN_MS", 1800000),
415
+ inject_cooldown_prompts: cooldownKnob(o.inject_cooldown_prompts, "MEMINI_INJECT_COOLDOWN_PROMPTS", 3),
366
416
  recall_budget_ms,
367
417
  timeout_ms: Number(o.timeout_ms || e.MEMINI_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
368
418
  fallback_on_error:
@@ -379,6 +429,8 @@ export function resolveConfig(env, options, worktree) {
379
429
  recall_limit: o.recall_limit !== undefined || isSet(e.MEMINI_RECALL_LIMIT),
380
430
  recall_max_tokens: o.recall_max_tokens !== undefined || isSet(process.env.MEMINI_INJECT_RECALL_MAX_TOK),
381
431
  recall_min_score: o.recall_min_score !== undefined || isSet(process.env.MEMINI_INJECT_RECALL_MIN_SCORE),
432
+ inject_cooldown_ms: o.inject_cooldown_ms !== undefined || isSet(e.MEMINI_INJECT_COOLDOWN_MS),
433
+ inject_cooldown_prompts: o.inject_cooldown_prompts !== undefined || isSet(e.MEMINI_INJECT_COOLDOWN_PROMPTS),
382
434
  capture_user_max_chars: isSet(e.MEMINI_CAPTURE_USER_MAX_CHARS),
383
435
  capture_assistant_max_chars: isSet(e.MEMINI_CAPTURE_ASSISTANT_MAX_CHARS),
384
436
  },
@@ -427,6 +479,14 @@ export function effectiveConfig(cfg, hs) {
427
479
  explicit.recall_min_score || !Number.isFinite(s.inject_recall_min_score)
428
480
  ? cfg.recall_min_score
429
481
  : s.inject_recall_min_score,
482
+ inject_cooldown_ms:
483
+ explicit.inject_cooldown_ms || !Number.isFinite(s.inject_cooldown_ms)
484
+ ? cfg.inject_cooldown_ms
485
+ : s.inject_cooldown_ms,
486
+ inject_cooldown_prompts:
487
+ explicit.inject_cooldown_prompts || !Number.isFinite(s.inject_cooldown_prompts)
488
+ ? cfg.inject_cooldown_prompts
489
+ : s.inject_cooldown_prompts,
430
490
  capture_user_max_chars:
431
491
  explicit.capture_user_max_chars || !Number.isFinite(s.capture_user_max_chars)
432
492
  ? cfg.capture_user_max_chars
@@ -579,7 +639,7 @@ export function intEnvFrom(env, name, defaultValue) {
579
639
 
580
640
  /**
581
641
  * floatEnv parses a non-negative float env var and returns `default` when
582
- * unset or malformed. Used for min_score.
642
+ * unset or malformed.
583
643
  */
584
644
  export function floatEnv(name, defaultValue) {
585
645
  const raw = process.env[name];
@@ -642,6 +702,62 @@ export function fitByTokens(items, maxTokens) {
642
702
  return { items: out, tokens: used, dropped };
643
703
  }
644
704
 
705
+ // --- Injection-enforcement core (opencode copies) ---------------------------
706
+ //
707
+ // Ported from @memini/client's enforce core (packages/memini-client/src/
708
+ // enforce/identity.ts + seen.ts); semantics are pinned by the shared golden
709
+ // vectors (packages/memini-client/vectors/enforcement.json), replayed by
710
+ // memini.test.mjs. This plugin ships standalone (no build step), so these
711
+ // stay copies, not imports — the vector replay is what keeps them the same
712
+ // functions.
713
+
714
+ /**
715
+ * True for a well-formed server-minted content hash: 16 lowercase hex chars
716
+ * (the server's sha256(content||summary).slice(0,16) — the same recipe as the
717
+ * local fallback in injectedIdentity, so the two are interchangeable).
718
+ */
719
+ export function isContentHash(s) {
720
+ return typeof s === "string" && /^[0-9a-f]{16}$/.test(s);
721
+ }
722
+
723
+ /**
724
+ * Content-identity hash for the injected-memory state: prefer the server-
725
+ * minted content_hash (read off the object itself or its nested `memory`),
726
+ * else hash the text a recall surface would render (content, falling back to
727
+ * summary) — so an in-place update still changes identity and re-injects even
728
+ * on servers without content_hash.
729
+ */
730
+ export function injectedIdentity(m) {
731
+ const ch = m?.content_hash ?? m?.memory?.content_hash;
732
+ if (isContentHash(ch)) return ch;
733
+ const text = m?.content || m?.summary || "";
734
+ return createHash("sha256").update(text).digest("hex").slice(0, 16);
735
+ }
736
+
737
+ /**
738
+ * The shared windowed-cooldown predicate (enforce/seen.ts, core-exact):
739
+ *
740
+ * entry.h === "" → true sentinel/tool-read: forever
741
+ * identity && entry.h !== identity → false content changed: re-inject
742
+ * cooldownMs == 0 && prompts == 0 → true legacy forever-dedupe (#134)
743
+ * else suppressed within EITHER window; re-admit once BOTH lapse.
744
+ * counter == 0 leaves the prompt dimension inert (a host that never
745
+ * advances a counter degrades to time-only, not forever); negative deltas
746
+ * (clock skew / counter regression) clamp to suppressed.
747
+ *
748
+ * `identity` null is the id-only check (the exclude_ids view): the content-
749
+ * change bypass is skipped, so an entry is judged on the windows alone.
750
+ */
751
+ export function injectedSuppressed(entry, identity, { now, counter, cooldownMs, cooldownPrompts }) {
752
+ if (!entry || typeof entry !== "object") return false;
753
+ if (entry.h === "") return true; // sentinel / tool-read: forever
754
+ if (identity && entry.h !== identity) return false; // content changed: re-inject
755
+ if (cooldownMs === 0 && cooldownPrompts === 0) return true; // legacy forever-dedupe
756
+ const promptDim = cooldownPrompts > 0 && counter > 0 && counter - entry.n < cooldownPrompts;
757
+ const timeDim = cooldownMs > 0 && now - entry.at < cooldownMs;
758
+ return promptDim || timeDim;
759
+ }
760
+
645
761
  /**
646
762
  * Truncate `s` to `max` CHARACTERS for a turn capture, marking the cut. `max <= 0`
647
763
  * captures it whole. Mirrors @memini/client's truncateForCapture — this plugin
@@ -933,6 +1049,49 @@ export function createClient(cfg, log) {
933
1049
  return { postJson, handshake, baseUrl };
934
1050
  }
935
1051
 
1052
+ /**
1053
+ * POST /v1/search carrying the newer-than-server optional fields, retrying ONCE
1054
+ * with min_rank_score (and any exclude_ids) stripped when the first attempt
1055
+ * fails. An older server 400s an unknown field (returned as null under
1056
+ * fail-soft, or a throw with fallback_on_error off), so this degrades to the
1057
+ * client-side composite floor fallback instead of losing recall entirely.
1058
+ *
1059
+ * Returns {data, rankFloorStripped}. rankFloorStripped is true only when the
1060
+ * floor was sent and then dropped on the retry — the signal the caller uses to
1061
+ * decide whether to re-apply the composite floor client-side. A server that
1062
+ * accepted the floor is authoritative and its result set is NOT re-filtered.
1063
+ * exclude_ids rides only the first attempt and is stripped alongside the floor
1064
+ * on retry (matching _shared.mjs's combined strip); onExcludeIdsUnsupported,
1065
+ * when given, latches it off for the session. Unlike the Claude plugin's
1066
+ * pretool latch, the floor itself is never latched off: this integration tracks
1067
+ * no content hash, so a stateless per-call strip is the faithful port.
1068
+ */
1069
+ export async function postSearchWithFloor(postJson, body, namespace, opts = {}) {
1070
+ const { excludeIds = [], onExcludeIdsUnsupported } = opts;
1071
+ const rankFloorInBody = body.min_rank_score !== undefined;
1072
+ const withExcludeIds = excludeIds.length > 0;
1073
+ if (!rankFloorInBody && !withExcludeIds) {
1074
+ return { data: await postJson("/v1/search", body, namespace), rankFloorStripped: false };
1075
+ }
1076
+ try {
1077
+ const first = await postJson(
1078
+ "/v1/search",
1079
+ withExcludeIds ? { ...body, exclude_ids: excludeIds } : body,
1080
+ namespace,
1081
+ );
1082
+ if (first !== null) return { data: first, rankFloorStripped: false };
1083
+ } catch {
1084
+ // With fallback_on_error=false the 400 arrives as a throw, not null.
1085
+ }
1086
+ const stripped = { ...body };
1087
+ delete stripped.min_rank_score;
1088
+ const retry = await postJson("/v1/search", stripped, namespace);
1089
+ if (retry !== null && withExcludeIds && typeof onExcludeIdsUnsupported === "function") {
1090
+ onExcludeIdsUnsupported();
1091
+ }
1092
+ return { data: retry, rankFloorStripped: rankFloorInBody };
1093
+ }
1094
+
936
1095
  // extractLastTurn returns the latest user and assistant text from the message
937
1096
  // list returned by client.session.messages ([{info, parts}, ...]), plus the id
938
1097
  // of the assistant message (for dedup). Iterates in reverse to short-circuit.
@@ -1034,21 +1193,38 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1034
1193
  // Memory ids each session has already been shown (mirrors the pi plugin):
1035
1194
  // the injected synthetic part is persisted into the session, so re-injecting
1036
1195
  // 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();
1196
+ // Per session: the enforce core's { n, ids } shape n is the prompt counter
1197
+ // (bumped once per chat.message) and ids maps memory id → { h, at, n }
1198
+ // (content identity, last-injected ms, counter at injection), judged by
1199
+ // injectedSuppressed against the inject_cooldown_ms / inject_cooldown_prompts
1200
+ // windows: suppressed within EITHER window, re-served once BOTH lapse, and
1201
+ // re-served immediately when the content changed (h mismatch). The inner cap
1202
+ // keeps a stable session — which never ages out of the outer map — from
1203
+ // growing its map for the process lifetime.
1204
+ const injectedBySession = new Map(); // session -> { n, ids: Map<id, {h, at, n}> }
1040
1205
  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);
1206
+ const sessionSeen = (session) => {
1207
+ let state = injectedBySession.get(session);
1208
+ if (!state) {
1209
+ state = { n: 0, ids: new Map() };
1210
+ boundedPut(injectedBySession, session, state);
1211
+ }
1212
+ return state;
1213
+ };
1214
+ const rememberInjected = (state, hits) => {
1215
+ const now = Date.now();
1216
+ for (const r of hits) {
1217
+ const id = r?.memory?.id;
1218
+ if (!id) continue;
1219
+ // delete+set refreshes both the stamp and the insertion order, so the
1220
+ // size cap below evicts the least-recently-shown id first.
1221
+ state.ids.delete(id);
1222
+ state.ids.set(id, { h: injectedIdentity(r?.memory), at: now, n: state.n });
1046
1223
  }
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;
1224
+ while (state.ids.size > MAX_INJECTED_PER_SESSION) {
1225
+ const oldest = state.ids.keys().next().value;
1050
1226
  if (oldest === undefined) break;
1051
- seen.delete(oldest);
1227
+ state.ids.delete(oldest);
1052
1228
  }
1053
1229
  };
1054
1230
  // Recall results that arrived after the injection budget expired, keyed by
@@ -1060,23 +1236,18 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1060
1236
  // 400 on the unknown field: when a request carrying it fails and the retry
1061
1237
  // without it succeeds, stop sending it. The client-side filter stays.
1062
1238
  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
- };
1239
+ // Delegate the search + one-shot compat retry to postSearchWithFloor, which
1240
+ // strips BOTH min_rank_score and exclude_ids on an older server's 400. Keep
1241
+ // the exclude_ids latch here (a closure the callback flips off); the floor is
1242
+ // not latched. Returns {data, rankFloorStripped}.
1243
+ const searchExcluding = (body, excludeIds, namespace) =>
1244
+ postSearchWithFloor(rest.postJson, body, namespace, {
1245
+ excludeIds: serverExcludeIds ? excludeIds : [],
1246
+ onExcludeIdsUnsupported: () => {
1247
+ serverExcludeIds = false;
1248
+ log.warn("memini: server does not accept exclude_ids; using client-side dedupe only");
1249
+ },
1250
+ });
1080
1251
 
1081
1252
  // opencode runs chat.message via an unguarded Effect.promise (a throw aborts the
1082
1253
  // turn) and dispatches event hooks fire-and-forget, so a hook must never reject:
@@ -1148,18 +1319,40 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1148
1319
  const sibling = output.parts.find((p) => p && p.type === "text") || {};
1149
1320
  const sessionID = input.sessionID || sibling.sessionID;
1150
1321
  const messageID = input.messageID || sibling.messageID;
1322
+ // One chat.message == one user prompt: bump the session's prompt counter
1323
+ // before any recall — the cooldown's prompt dimension measures prompts-
1324
+ // since-injection even on turns that inject nothing.
1325
+ const seen = sessionID ? sessionSeen(sessionID) : null;
1326
+ if (seen) seen.n += 1;
1327
+ const cooldownOpts = () => ({
1328
+ now: Date.now(),
1329
+ counter: seen ? seen.n : 0,
1330
+ cooldownMs: live.inject_cooldown_ms,
1331
+ cooldownPrompts: live.inject_cooldown_prompts,
1332
+ });
1151
1333
  const body = { query, limit: live.recall_limit };
1152
1334
  // Exclude this session's own captured turns: they're still in the live
1153
1335
  // context, so recalling them just echoes the conversation back a turn
1154
1336
  // behind. Captures from other (past) sessions are still recalled.
1155
1337
  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) ?? [])] : [];
1338
+ // inject_recall_min_score floors the FINAL composite score server-side
1339
+ // via min_rank_score (not the fused-scale min_score), matching the Claude
1340
+ // Code plugin. A knob >= 1 is out of the server's range, so it clamps to a
1341
+ // client-only floor rather than 400ing every search.
1342
+ const rankFloorInRange = live.recall_min_score > 0 && live.recall_min_score < 1;
1343
+ if (rankFloorInRange) body.min_rank_score = live.recall_min_score;
1344
+ // Ids still IN COOLDOWN go along as exclude_ids so a suppressed hit
1345
+ // doesn't waste a recall_limit slot (id-only judgment — the wire cannot
1346
+ // know what content the server would serve); a LAPSED id is
1347
+ // intentionally absent so the server may re-serve it.
1348
+ const excludeIds = seen
1349
+ ? (() => {
1350
+ const opts = cooldownOpts();
1351
+ return [...seen.ids.entries()]
1352
+ .filter(([, e]) => injectedSuppressed(e, null, opts))
1353
+ .map(([id]) => id);
1354
+ })()
1355
+ : [];
1163
1356
  // opencode awaits this hook before the model sees the message, so the
1164
1357
  // turn only waits live.recall_budget_ms for the search; the fetch itself keeps
1165
1358
  // cfg.timeout_ms as its bound and runs on in the background. A slow or
@@ -1187,7 +1380,7 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1187
1380
  );
1188
1381
  if (sessionID) {
1189
1382
  settled.then((late) => {
1190
- const hits = Array.isArray(late && late.results) ? late.results : [];
1383
+ const hits = Array.isArray(late && late.data && late.data.results) ? late.data.results : [];
1191
1384
  if (hits.length) boundedPut(pendingBySession, sessionID, hits);
1192
1385
  });
1193
1386
  }
@@ -1196,12 +1389,14 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1196
1389
  } else {
1197
1390
  result = await settled;
1198
1391
  }
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 : [];
1392
+ const searchData = result && result.data ? result.data : null;
1393
+ // Client composite floor is a fallback ONLY: it runs when the knob was
1394
+ // clamped to client-only (>= 1) or the retry stripped min_rank_score for
1395
+ // an old server. A server that enforced the floor is authoritative and
1396
+ // its result set is not re-filtered here.
1397
+ const serverEnforcedFloor = rankFloorInRange && !(result && result.rankFloorStripped);
1398
+ const floor = live.recall_min_score > 0 && !serverEnforcedFloor ? live.recall_min_score : 0;
1399
+ let rawHits = Array.isArray(searchData && searchData.results) ? searchData.results : [];
1205
1400
  // Merge in results that arrived late on a previous turn: fresh hits
1206
1401
  // first (they answer the current query), deduped by memory id.
1207
1402
  if (sessionID) {
@@ -1212,11 +1407,17 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1212
1407
  rawHits = rawHits.concat(pending.filter((r) => !fresh.has(r?.memory?.id)));
1213
1408
  }
1214
1409
  }
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));
1410
+ // Suppress memories this session was already shown and that are still in
1411
+ // cooldown judged PER HIT against its content identity, so an
1412
+ // in-window unchanged hit is dropped, a lapsed one passes through and
1413
+ // re-serves, and an UPDATED one (h mismatch) bypasses the window and
1414
+ // re-injects immediately.
1415
+ if (seen && seen.ids.size) {
1416
+ const opts = cooldownOpts();
1417
+ rawHits = rawHits.filter((r) => {
1418
+ const entry = seen.ids.get(r?.memory?.id);
1419
+ return !(entry && injectedSuppressed(entry, injectedIdentity(r?.memory), opts));
1420
+ });
1220
1421
  }
1221
1422
  const filtered = floor > 0
1222
1423
  ? rawHits.filter((r) => (typeof r?.score === "number" ? r.score : 0) >= floor)
@@ -1229,17 +1430,11 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1229
1430
  // behaviour matches the prior "no cap" code path for existing installs.
1230
1431
  const fit = fitByTokens(hits, live.recall_max_tokens);
1231
1432
  if (fit.items.length === 0) return;
1232
- if (sessionID) {
1433
+ if (seen) {
1233
1434
  // Mark only the slice formatResults actually renders: with carryover
1234
1435
  // 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
- );
1436
+ // hits as seen would suppress what was never injected.
1437
+ rememberInjected(seen, filtered.slice(0, live.recall_limit || DEFAULT_RECALL_LIMIT));
1243
1438
  }
1244
1439
  const lines = [
1245
1440
  `Relevant long-term memory from memini (background context — prefer ` +
@@ -1249,8 +1444,8 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1249
1444
  // /v1/search sets `degraded: "keyword_only"` (plus a `note`) when the
1250
1445
  // query embed was unavailable and it fell back to keyword-only matching;
1251
1446
  // 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"}]`);
1447
+ if (searchData && searchData.degraded) {
1448
+ lines.push(`[memini: ${searchData.note || "semantic search unavailable — results are keyword-only and may be incomplete"}]`);
1254
1449
  }
1255
1450
  if (fit.dropped > 0) lines.push(`[... ${fit.dropped} item(s) truncated by token budget]`);
1256
1451
  // opencode's part schema requires ids to start with `prt`.
@@ -1291,11 +1486,11 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1291
1486
  log.warn(`auto-update: updating ${CLIENT_VERSION} → ${latest}`);
1292
1487
  const installDir = prepareCacheUpdate(latest, log);
1293
1488
  if (!installDir) return;
1294
- const ok = runBunInstall(installDir);
1489
+ const ok = runNpmInstall(installDir);
1295
1490
  if (ok) {
1296
1491
  log.warn(`auto-update: installed v${latest} — restart opencode to apply`);
1297
1492
  } else {
1298
- log.warn(`auto-update: bun install failed; will retry next session`);
1493
+ log.warn(`auto-update: npm install failed; will retry next session`);
1299
1494
  }
1300
1495
  } catch (err) {
1301
1496
  log.warn(`auto-update: check failed: ${String(err)}`);
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.9",
4
4
  "description": "Automatic cross-session memory for opencode via memini — recall before each turn, capture after.",
5
5
  "keywords": [
6
6
  "memini",