@eleboucher/opencode-memini 0.6.8 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +51 -2
  2. package/memini.js +424 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -53,12 +53,23 @@ Pass options inline via the `[name, options]` form:
53
53
  | `recall_limit` | `MEMINI_RECALL_LIMIT` | `3` | max memories injected per turn |
54
54
  | `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 |
55
55
  | `recall_min_score` | `MEMINI_INJECT_RECALL_MIN_SCORE` | `0` | fused-score floor (>=) sent as `min_score` to `/v1/search` |
56
- | `timeout_ms` | `MEMINI_TIMEOUT_MS` | `30000` | per-request timeout |
56
+ | `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`) |
57
+ | `timeout_ms` | `MEMINI_TIMEOUT_MS` | `30000` | per-request timeout (recall past its budget keeps running in the background under this bound) |
57
58
  | `fallback_on_error` | `MEMINI_FALLBACK` | on | `false` surfaces errors instead of degrading silently |
58
- | — | `MEMINI_INJECT_LABELS` | — | comma-separated label toggles for each bullet: `tier`, `confidence`, `age` |
59
+ | — | `MEMINI_INJECT_LABELS` | — | comma-separated label toggles for each bullet: `tier`, `confidence`, `age`, `reason` |
59
60
  | — | `MEMINI_API_KEY` | — | bearer token, if memini needs auth (env only — secret; alias: `MEMINI_TOKEN`) |
60
61
  | — | `MEMINI_REQUIRE_HTTPS` | — | `1` refuses to send the token over plaintext HTTP |
61
62
 
63
+ opencode awaits `chat.message` before the model sees the message, so a slow or
64
+ unreachable memini would otherwise freeze the turn for the full `timeout_ms`.
65
+ Instead, recall races `recall_budget_ms`: if the search hasn't answered in time,
66
+ the turn proceeds without memories and the search keeps running in the
67
+ background — results that arrive late are injected on the session's next
68
+ message instead of being dropped. The plugin also pings `/healthz` once at
69
+ startup to warm the connection, so the first recall doesn't pay the
70
+ DNS/TLS cold-start. Set `recall_budget_ms: 0` to restore fully blocking
71
+ same-turn injection.
72
+
62
73
  Inline options win over the env vars. Secrets stay in the environment: set
63
74
  `MEMINI_API_KEY` (sent as `Authorization: Bearer …`), and optionally
64
75
  `MEMINI_REQUIRE_HTTPS=1` to refuse plaintext HTTP, in the shell that launches
@@ -71,6 +82,44 @@ stays correct even against a remote memini (the HTTP MCP wire below can't — a
71
82
  remote server has no access to your cwd). Set it to share one memory pool with
72
83
  your other agents.
73
84
 
85
+ If `$XDG_CONFIG_HOME/memini/config.json` (default `~/.config/memini/config.json`)
86
+ exists, the unset namespace is instead rendered from its `template` (default
87
+ `{tenant}/{project}/{agent}`): `{tenant}` from the `tenantRoots` entry whose
88
+ `path` contains the cwd, `{project}` from the git repo, `{agent}` from
89
+ `MEMINI_AGENT`; unresolved segments are dropped. The Hermes and Pi integrations
90
+ share this resolver, so one config file scopes them all identically.
91
+
92
+ ### Namespace resolution
93
+
94
+ In full, in order: a **per-project override** in
95
+ `$XDG_CONFIG_HOME/memini/overrides.json` > the `namespace` option /
96
+ `MEMINI_NAMESPACE` > the config template above > the git worktree basename.
97
+
98
+ The override wins over both deliberately. A globally exported `MEMINI_NAMESPACE`
99
+ — a shell rc, or a fish universal variable — pins every repo on the machine to
100
+ one namespace (as does a `namespace` option in a global
101
+ `~/.config/opencode/opencode.json`), and if either won, setting an override would
102
+ silently do nothing on exactly the machines that need one. The file is keyed by
103
+ git toplevel, so an override set at the top of a repo applies from any
104
+ subdirectory; it is the same file the Claude Code plugin writes and `memini
105
+ doctor` reads; and a malformed one degrades to automatic resolution rather than
106
+ breaking a turn.
107
+
108
+ ### The `memini_status` tool
109
+
110
+ The plugin registers one tool, `memini_status`: read-only, no arguments. It
111
+ reports the namespace in force and where it came from, what it would be _without_
112
+ the override and without the env pin, the connection settings (the API key
113
+ fingerprinted, never printed), and warnings — a global `MEMINI_NAMESPACE` pin, a
114
+ bearer token crossing plaintext HTTP, an override you forgot you set.
115
+
116
+ There is no `/memini:status` slash command: opencode's plugin contract registers
117
+ tools, not commands, and this plugin does not invent an API it does not have.
118
+ Setting or clearing an override is likewise not exposed here — declaring a tool
119
+ argument requires a zod schema, and this plugin ships dependency-free — so use
120
+ `/memini:namespace` from the Claude Code plugin, or edit `overrides.json`
121
+ directly; all harnesses read the same file.
122
+
74
123
  ### Tests
75
124
 
76
125
  ```bash
package/memini.js CHANGED
@@ -17,15 +17,19 @@
17
17
  */
18
18
 
19
19
  import { execSync } from "node:child_process";
20
- import { readFileSync } from "node:fs";
20
+ import { existsSync, readFileSync } from "node:fs";
21
21
  import { join, resolve, sep } from "node:path";
22
22
  import { homedir } from "node:os";
23
23
 
24
24
  const DEFAULT_BASE_URL = "http://localhost:8080";
25
25
  const DEFAULT_TIMEOUT_MS = 30000;
26
+ const DEFAULT_RECALL_BUDGET_MS = 2000;
26
27
  const DEFAULT_RECALL_LIMIT = 3;
27
28
  const DEFAULT_NAMESPACE = "opencode";
28
29
  const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
30
+ // Race sentinel: distinguishes "the recall budget expired" from any value the
31
+ // search itself could resolve to (including null on a degraded failure).
32
+ const BUDGET_EXPIRED = Symbol("memini-recall-budget-expired");
29
33
 
30
34
  function envBool(value, fallback) {
31
35
  if (value === undefined || value === null || value === "") return fallback;
@@ -136,25 +140,112 @@ function resolveConfigNamespace(cwd) {
136
140
  return ns || null;
137
141
  }
138
142
 
143
+ // --- Namespace override ---------------------------------------------------
144
+ //
145
+ // $XDG_CONFIG_HOME/memini/overrides.json (else ~/.config/memini/overrides.json)
146
+ // holds the per-project namespace a user set deliberately. It is a shared
147
+ // contract: the Claude Code plugin writes it, `memini doctor` reads it, and
148
+ // every harness must agree about which namespace is in force — an override that
149
+ // only some of them honor is worse than none at all.
150
+ //
151
+ // This plugin ships standalone and dependency-free from npm, so it cannot
152
+ // import @memini/client; the reader below is the whole contract (a JSON file
153
+ // plus a `git rev-parse`) and stays a copy, the same trade already made for
154
+ // createPlaintextBearerAuthGuard and the injection-budget helpers. Keep the
155
+ // contract identical when both sides change.
156
+
157
+ // overridesPath resolves the overrides file. Exported for testing / status.
158
+ export function overridesPath(env = process.env) {
159
+ const xdg = env.XDG_CONFIG_HOME;
160
+ const base = xdg && String(xdg).trim() ? String(xdg) : join(homedir(), ".config");
161
+ return join(base, "memini", "overrides.json");
162
+ }
163
+
164
+ // overrideKey is the key an override is stored under: the git toplevel when
165
+ // there is one, else the resolved directory. Keying on the repo root rather
166
+ // than the raw cwd means an override set at the top of a repo still applies
167
+ // when the agent is working three directories down.
168
+ export function overrideKey(cwd) {
169
+ const dir = cwd && String(cwd).trim() ? String(cwd) : process.cwd();
170
+ try {
171
+ const top = execSync("git rev-parse --show-toplevel", {
172
+ cwd: dir,
173
+ stdio: ["ignore", "pipe", "ignore"],
174
+ timeout: 500,
175
+ })
176
+ .toString()
177
+ .trim();
178
+ if (top) return resolve(top);
179
+ } catch {
180
+ // not a repo, or no git — fall through to the plain path
181
+ }
182
+ return resolve(dir);
183
+ }
184
+
185
+ // readOverride returns the override in effect for `cwd`, or null.
186
+ //
187
+ // The file is read BEFORE the key is computed, because the key costs a `git
188
+ // rev-parse` and this runs on every chat.message: nobody should pay for a git
189
+ // call to discover they have no overrides at all, which is the common case.
190
+ // Any error — missing file, hand-edited JSON, wrong shape — degrades to "no
191
+ // override" rather than throwing into opencode. Exported for testing.
192
+ export function readOverride(cwd, path) {
193
+ let file;
194
+ try {
195
+ file = JSON.parse(readFileSync(path || overridesPath(), "utf8"));
196
+ } catch {
197
+ return null;
198
+ }
199
+ const overrides = file && typeof file === "object" ? file.overrides : null;
200
+ if (!overrides || typeof overrides !== "object" || Object.keys(overrides).length === 0) return null;
201
+ const entry = overrides[overrideKey(cwd)];
202
+ if (!entry || typeof entry !== "object") return null;
203
+ const ns = typeof entry.namespace === "string" ? entry.namespace.trim() : "";
204
+ if (!ns) return null;
205
+ return { namespace: ns, setAt: typeof entry.setAt === "string" ? entry.setAt : "" };
206
+ }
207
+
139
208
  // resolveConfig merges env vars with the options object (options win), filling
140
209
  // in defaults. Exported for testing.
141
- export function resolveConfig(env, options, worktree) {
210
+ //
211
+ // Namespace precedence: project override > namespace option / MEMINI_NAMESPACE >
212
+ // config template > git worktree > default. The override sits ABOVE the env var
213
+ // (and above the inline option) on purpose: a globally exported MEMINI_NAMESPACE
214
+ // — a shell rc, or a fish universal variable — pins every repo on the machine to
215
+ // one namespace, and if the env won, setting an override would silently do
216
+ // nothing on exactly the machines that need one. The same argument applies to a
217
+ // `namespace` option in ~/.config/opencode/opencode.json, which pins every
218
+ // project the same way; and `memini doctor` reports the override as in force
219
+ // regardless, so anything else would make the two disagree.
220
+ //
221
+ // opts.ignoreOverride skips the override so the status tool can ask what the
222
+ // namespace would be without it — it lives in a file, so no amount of doctoring
223
+ // `env` would strip it. opts.overridesPath is for tests.
224
+ export function resolveConfig(env, options, worktree, opts = {}) {
142
225
  const e = env || {};
143
226
  const o = options || {};
144
- // An explicit namespace (option or MEMINI_NAMESPACE env) wins and is used
145
- // raw-trimmed: the server validates the header, and flattening "/" here would
146
- // split a tenant path like work/memini from the other integrations.
227
+ const dir = worktree || process.cwd();
228
+ const override = opts.ignoreOverride ? null : readOverride(dir, opts.overridesPath);
229
+ // An explicit namespace (option or MEMINI_NAMESPACE env) is used raw-trimmed:
230
+ // the server validates the header, and flattening "/" here would split a
231
+ // tenant path like work/memini from the other integrations. The override is
232
+ // written through @memini/client, which validates it, so it is used as-is too.
147
233
  const explicit = o.namespace || e.MEMINI_NAMESPACE;
148
234
  let namespace;
149
- if (explicit && String(explicit).trim()) {
235
+ let namespace_source;
236
+ if (override) {
237
+ namespace = override.namespace;
238
+ namespace_source = "override";
239
+ } else if (explicit && String(explicit).trim()) {
150
240
  namespace = String(explicit).trim();
241
+ namespace_source = o.namespace ? "option" : "env";
151
242
  } else {
152
243
  // Config present -> render the config template (tenant segments already
153
244
  // sanitized, "/" preserved); otherwise fall back to the legacy cwd chain.
154
- namespace =
155
- resolveConfigNamespace(worktree || process.cwd()) ||
156
- deriveNamespace(worktree) ||
157
- DEFAULT_NAMESPACE;
245
+ const fromConfig = resolveConfigNamespace(dir);
246
+ const fromWorktree = deriveNamespace(worktree);
247
+ namespace = fromConfig || fromWorktree || DEFAULT_NAMESPACE;
248
+ namespace_source = fromConfig ? "config" : fromWorktree ? "worktree" : "default";
158
249
  }
159
250
  // Number.isFinite guard: malformed env / option falls through to the next
160
251
  // source instead of NaN flowing into the request body.
@@ -165,6 +256,17 @@ export function resolveConfig(env, options, worktree) {
165
256
  }
166
257
  return DEFAULT_RECALL_LIMIT;
167
258
  })();
259
+ // How long chat.message waits for recall before letting the turn proceed
260
+ // without it; 0 disables the race (fully blocking recall). The ""-skip
261
+ // matters: Number("") === 0, so an empty env var would silently go blocking.
262
+ const recall_budget_ms = (() => {
263
+ for (const v of [o.recall_budget_ms, e.MEMINI_RECALL_BUDGET_MS, DEFAULT_RECALL_BUDGET_MS]) {
264
+ if (v === undefined || v === null || v === "") continue;
265
+ const n = Number(v);
266
+ if (Number.isFinite(n) && n >= 0) return n;
267
+ }
268
+ return DEFAULT_RECALL_BUDGET_MS;
269
+ })();
168
270
  // home: the caller's personal namespace, sent as X-Memini-Home. Same
169
271
  // env-only resolution style as namespace's MEMINI_NAMESPACE (option wins
170
272
  // over env), but no config-file/derivation fallback — unset means "no home
@@ -177,6 +279,11 @@ export function resolveConfig(env, options, worktree) {
177
279
  // per-segment-sanitized config/derived value); re-sanitizing here would
178
280
  // flatten tenant "/" separators.
179
281
  namespace: namespace || DEFAULT_NAMESPACE,
282
+ // Where the namespace came from, and the override itself when one is in
283
+ // force. Carried on the config so the status tool reports what the plugin
284
+ // actually does rather than a second, idealized resolution of its own.
285
+ namespace_source,
286
+ override,
180
287
  home,
181
288
  recall: o.recall !== undefined ? o.recall !== false : envBool(e.MEMINI_RECALL, true),
182
289
  capture: o.capture !== undefined ? o.capture !== false : envBool(e.MEMINI_CAPTURE, true),
@@ -189,6 +296,7 @@ export function resolveConfig(env, options, worktree) {
189
296
  o.recall_min_score !== undefined
190
297
  ? Number(o.recall_min_score) || 0
191
298
  : floatEnv("MEMINI_INJECT_RECALL_MIN_SCORE", 0),
299
+ recall_budget_ms,
192
300
  timeout_ms: Number(o.timeout_ms || e.MEMINI_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
193
301
  fallback_on_error:
194
302
  o.fallback_on_error !== undefined
@@ -384,6 +492,190 @@ export function truncate(value, max) {
384
492
  return value;
385
493
  }
386
494
 
495
+ // --- Status --------------------------------------------------------------
496
+ //
497
+ // "What is this plugin actually doing right now?" A list of values would not
498
+ // answer that. The case worth catching is MEMINI_NAMESPACE exported globally (a
499
+ // shell rc, or a fish universal variable), set once and forgotten, quietly
500
+ // collapsing every repo on the machine into one namespace: the value looks
501
+ // fine, only its provenance gives it away. So the namespace is resolved three
502
+ // times against progressively stripped inputs — as-is, without the override,
503
+ // and without the override AND the env/option pin — and all three are reported.
504
+
505
+ /**
506
+ * Render a secret as a recognizable-but-useless fingerprint: enough to tell two
507
+ * tokens apart, not enough to use. Short values are elided entirely rather than
508
+ * half-revealed. Mirrors packages/memini-client's redactValue. Exported for
509
+ * testing.
510
+ */
511
+ export function redactSecret(value) {
512
+ if (!value) return "";
513
+ return value.length <= 12 ? "***" : `${value.slice(0, 3)}…${value.slice(-4)}`;
514
+ }
515
+
516
+ /**
517
+ * Build the effective-settings report: the three namespace resolutions, the
518
+ * knobs with their provenance (secrets redacted), the paths, and the warnings.
519
+ * Exported for testing.
520
+ */
521
+ export function describeSettings(env, options, worktree) {
522
+ const e = env || {};
523
+ const o = options || {};
524
+ const dir = worktree || process.cwd();
525
+
526
+ const cfg = resolveConfig(e, o, worktree);
527
+ // Both counterfactuals must ignore the override explicitly: it lives in a
528
+ // file, so a resolution handed a stripped env would hand it straight back —
529
+ // and these are the two lines that exist to see past it.
530
+ const withoutOverride = resolveConfig(e, o, worktree, { ignoreOverride: true });
531
+ const envSansPin = { ...e };
532
+ delete envSansPin.MEMINI_NAMESPACE;
533
+ const derived = resolveConfig(
534
+ envSansPin,
535
+ { ...o, namespace: undefined },
536
+ worktree,
537
+ { ignoreOverride: true },
538
+ );
539
+
540
+ const secret = e.MEMINI_API_KEY || e.MEMINI_TOKEN || "";
541
+ const warnings = [];
542
+
543
+ if (cfg.override) {
544
+ warnings.push({
545
+ level: "note",
546
+ code: "override-active",
547
+ message:
548
+ `namespace is overridden to "${cfg.override.namespace}" for this project` +
549
+ (cfg.override.setAt ? ` (set ${cfg.override.setAt})` : "") +
550
+ `; without it this project would use "${withoutOverride.namespace}".`,
551
+ fix: `Remove the entry for ${overrideKey(dir)} from ${overridesPath(e)} to return to automatic resolution.`,
552
+ });
553
+ }
554
+
555
+ // The finding this whole report exists for.
556
+ const pin = String(e.MEMINI_NAMESPACE || "").trim();
557
+ if (pin && !cfg.override && derived.namespace && derived.namespace !== pin) {
558
+ warnings.push({
559
+ level: "warn",
560
+ code: "global-namespace-pin",
561
+ message:
562
+ `MEMINI_NAMESPACE is set to "${pin}", which pins EVERY project on this machine to one ` +
563
+ `namespace. This project would otherwise resolve to "${derived.namespace}". If it is ` +
564
+ `exported from a shell rc (or a fish universal variable), every repo you work in is ` +
565
+ `sharing one memory pool.`,
566
+ fix: "Unset MEMINI_NAMESPACE and let each repo resolve on its own, or set a per-project override instead.",
567
+ });
568
+ }
569
+
570
+ if (usesPlaintextBearerAuth(cfg.base_url, secret)) {
571
+ warnings.push({
572
+ level: "warn",
573
+ code: "plaintext-bearer",
574
+ message: plaintextBearerAuthMessage(cfg.base_url),
575
+ fix: "Use HTTPS, or tunnel over SSH. Set MEMINI_REQUIRE_HTTPS=1 to make this an error.",
576
+ });
577
+ }
578
+
579
+ if (!cfg.home) {
580
+ warnings.push({
581
+ level: "note",
582
+ code: "home-unset",
583
+ message: "MEMINI_HOME is unset: no personal leg merges into recall.",
584
+ fix: "Export MEMINI_HOME=personal/<you>.",
585
+ });
586
+ }
587
+
588
+ return {
589
+ project: overrideKey(dir),
590
+ worktree: dir,
591
+ namespace: {
592
+ effective: cfg.namespace,
593
+ source: cfg.namespace_source,
594
+ override: cfg.override,
595
+ withoutOverride,
596
+ derived,
597
+ home: cfg.home,
598
+ },
599
+ connection: {
600
+ base_url: cfg.base_url,
601
+ api_key: secret ? redactSecret(secret) : "",
602
+ require_https: e.MEMINI_REQUIRE_HTTPS === "1",
603
+ timeout_ms: cfg.timeout_ms,
604
+ },
605
+ memory: {
606
+ recall: cfg.recall,
607
+ capture: cfg.capture,
608
+ recall_limit: cfg.recall_limit,
609
+ recall_max_tokens: cfg.recall_max_tokens,
610
+ recall_min_score: cfg.recall_min_score,
611
+ recall_budget_ms: cfg.recall_budget_ms,
612
+ labels: [...labelsEnv()],
613
+ },
614
+ paths: { overrides: overridesPath(e) },
615
+ warnings,
616
+ };
617
+ }
618
+
619
+ const padTo = (s, n) => String(s).padEnd(n);
620
+
621
+ /** Render describeSettings() as the text block the tool hands back. */
622
+ export function renderStatus(report) {
623
+ const { namespace: ns, connection, memory, paths } = report;
624
+ const L = [];
625
+
626
+ L.push("memini — effective settings (opencode)");
627
+ L.push(`project: ${report.project}`);
628
+ L.push("");
629
+
630
+ L.push("NAMESPACE");
631
+ L.push(` ${padTo("effective", 26)} ${padTo(ns.effective, 30)} <- ${ns.source}`);
632
+ if (ns.override) {
633
+ L.push(
634
+ ` ${padTo("without the override", 26)} ${padTo(ns.withoutOverride.namespace, 30)} <- ${ns.withoutOverride.source}`,
635
+ );
636
+ }
637
+ if (ns.derived.namespace !== ns.effective) {
638
+ L.push(
639
+ ` ${padTo("git/cwd would give", 26)} ${padTo(ns.derived.namespace, 30)} <- ${ns.derived.source}`,
640
+ );
641
+ }
642
+ L.push(` ${padTo("home (personal)", 26)} ${ns.home || "(unset)"}`);
643
+ L.push("");
644
+
645
+ L.push("CONNECTION");
646
+ L.push(` ${padTo("base_url", 26)} ${connection.base_url}`);
647
+ L.push(` ${padTo("api_key", 26)} ${connection.api_key || "(unset)"}`);
648
+ L.push(` ${padTo("require_https", 26)} ${connection.require_https ? "1" : "0"}`);
649
+ L.push(` ${padTo("timeout_ms", 26)} ${connection.timeout_ms}`);
650
+ L.push("");
651
+
652
+ L.push("MEMORY");
653
+ L.push(` ${padTo("recall", 26)} ${memory.recall ? "on" : "off"}`);
654
+ L.push(` ${padTo("capture", 26)} ${memory.capture ? "on" : "off"}`);
655
+ L.push(` ${padTo("recall_limit", 26)} ${memory.recall_limit}`);
656
+ L.push(` ${padTo("recall_max_tokens", 26)} ${memory.recall_max_tokens || "uncapped"}`);
657
+ L.push(` ${padTo("recall_min_score", 26)} ${memory.recall_min_score}`);
658
+ L.push(` ${padTo("recall_budget_ms", 26)} ${memory.recall_budget_ms === 0 ? "0 (blocking)" : memory.recall_budget_ms}`);
659
+ L.push(` ${padTo("labels", 26)} ${memory.labels.length ? memory.labels.join(",") : "(none)"}`);
660
+ L.push("");
661
+
662
+ L.push("PATHS");
663
+ L.push(` ${padTo("overrides", 26)} ${paths.overrides}${existsSync(paths.overrides) ? "" : " (absent)"}`);
664
+ L.push("");
665
+
666
+ if (report.warnings.length) {
667
+ L.push("WARNINGS");
668
+ for (const w of report.warnings) {
669
+ L.push(` [${w.level === "warn" ? "!" : "i"}] ${w.code}: ${w.message}`);
670
+ if (w.fix) L.push(` fix: ${w.fix}`);
671
+ }
672
+ } else {
673
+ L.push("No problems detected.");
674
+ }
675
+
676
+ return L.join("\n");
677
+ }
678
+
387
679
  function createClient(cfg, log) {
388
680
  const baseUrl = String(cfg.base_url).replace(/\/+$/, "");
389
681
  const secret = process.env.MEMINI_API_KEY || process.env.MEMINI_TOKEN;
@@ -476,28 +768,46 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
476
768
 
477
769
  const cfg = resolveConfig(process.env, options, worktree || directory);
478
770
  const rest = createClient(cfg, log);
771
+ // Warm the connection (DNS/TCP/TLS) in opencode's embedded bun so a cold
772
+ // start doesn't eat the first recall budget. Silent: even a 404 warms the
773
+ // path, and an ingress that only routes /v1 legitimately has no /healthz.
774
+ if (cfg.recall || cfg.capture) {
775
+ try {
776
+ fetch(`${rest.baseUrl}/healthz`, { signal: AbortSignal.timeout(3000) }).catch(() => {});
777
+ } catch {
778
+ /* ignore */
779
+ }
780
+ }
479
781
  // Assistant message ids already captured, so repeated session.idle events for
480
782
  // the same turn don't write duplicates.
481
783
  const captured = new Set();
784
+ // boundedPut inserts key -> value and evicts the oldest entries, so a
785
+ // long-lived host can't grow a per-session map without limit.
786
+ const MAX_TRACKED_SESSIONS = 200;
787
+ const boundedPut = (map, key, value) => {
788
+ map.set(key, value);
789
+ while (map.size > MAX_TRACKED_SESSIONS) {
790
+ const oldest = map.keys().next().value;
791
+ if (oldest === undefined) break;
792
+ map.delete(oldest);
793
+ }
794
+ };
482
795
  // Memory ids each session has already been shown (mirrors the pi plugin):
483
796
  // the injected synthetic part is persisted into the session, so re-injecting
484
797
  // an unchanged match every turn stacks identical blocks in the context.
485
- // Bounded so long-lived hosts can't grow the map without limit.
486
798
  const injectedBySession = new Map();
487
- const MAX_TRACKED_SESSIONS = 200;
488
799
  const rememberInjected = (session, ids) => {
489
800
  let seen = injectedBySession.get(session);
490
801
  if (!seen) {
491
802
  seen = new Set();
492
- injectedBySession.set(session, seen);
493
- while (injectedBySession.size > MAX_TRACKED_SESSIONS) {
494
- const oldest = injectedBySession.keys().next().value;
495
- if (oldest === undefined) break;
496
- injectedBySession.delete(oldest);
497
- }
803
+ boundedPut(injectedBySession, session, seen);
498
804
  }
499
805
  for (const id of ids) if (id) seen.add(id);
500
806
  };
807
+ // Recall results that arrived after the injection budget expired, keyed by
808
+ // session and injected on that session's next chat.message. Latest-replace:
809
+ // a second late recall for the same session supersedes the first.
810
+ const pendingBySession = new Map();
501
811
 
502
812
  // opencode runs chat.message via an unguarded Effect.promise (a throw aborts the
503
813
  // turn) and dispatches event hooks fire-and-forget, so a hook must never reject:
@@ -511,6 +821,46 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
511
821
  };
512
822
 
513
823
  return {
824
+ // A tool, not a slash command, and deliberately so. opencode's plugin
825
+ // contract (Hooks in @opencode-ai/plugin) registers tools — `tool: { [id]:
826
+ // { description, args, execute } }` — but exposes no hook for registering a
827
+ // user-invocable command, so there is no `/memini:status` this plugin could
828
+ // offer without inventing one. `args` is empty: declaring a parameter means
829
+ // handing opencode a zod schema, and this plugin has no dependencies (a
830
+ // raw-JSON-Schema arg rides a compatibility path that older hosts feed
831
+ // straight to z.object() and throw on). A zero-arg tool is the shape every
832
+ // version accepts, and a read-only report needs no arguments anyway.
833
+ //
834
+ // Settings are recomputed per call rather than read off `cfg`, so an
835
+ // override set mid-session is visible here without restarting opencode —
836
+ // even though the hooks below still use the namespace they resolved at load.
837
+ tool: {
838
+ memini_status: {
839
+ description:
840
+ "Show the memini memory settings in force for this project: which namespace memories " +
841
+ "are written to and recalled from, where that namespace came from (a per-project " +
842
+ "override, MEMINI_NAMESPACE, the config file, or the git worktree), what it would be " +
843
+ "without each of those, and any misconfiguration worth flagging. Read-only; secrets " +
844
+ "are redacted. Call it when the user asks what memini is doing, why a memory cannot " +
845
+ "be recalled, or which namespace is in use.",
846
+ args: {},
847
+ execute: async () => {
848
+ try {
849
+ const report = describeSettings(process.env, options, worktree || directory);
850
+ return {
851
+ title: `memini: ${report.namespace.effective}`,
852
+ output: renderStatus(report),
853
+ metadata: { namespace: report.namespace.effective, source: report.namespace.source },
854
+ };
855
+ } catch (error) {
856
+ // A diagnostic that crashes the turn it was meant to diagnose is
857
+ // worse than no diagnostic.
858
+ return `memini status failed: ${String(error)}`;
859
+ }
860
+ },
861
+ },
862
+ },
863
+
514
864
  "chat.message": guard("chat.message", async (input, output) => {
515
865
  if (!cfg.recall) return;
516
866
  const query = extractPartsText(output && output.parts);
@@ -530,13 +880,58 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
530
880
  // the Claude Code plugin's pre-tool-use hook uses; client-side re-filter
531
881
  // is a belt-and-braces guard against score-normalization edge cases.
532
882
  if (cfg.recall_min_score > 0) body.min_score = cfg.recall_min_score;
533
- const result = await rest.postJson("/v1/search", body);
883
+ // opencode awaits this hook before the model sees the message, so the
884
+ // turn only waits recall_budget_ms for the search; the fetch itself keeps
885
+ // cfg.timeout_ms as its bound and runs on in the background. A slow or
886
+ // unreachable memini degrades to "no memories this turn" instead of a
887
+ // frozen turn, and late results carry over to the session's next message.
888
+ const fetchPromise = rest.postJson("/v1/search", body);
889
+ // Once the budget expires nothing awaits this promise, and with
890
+ // fallback_on_error off postJson rethrows — catch here or a late
891
+ // rejection surfaces as an unhandled rejection in the host.
892
+ const settled = fetchPromise.catch((error) => {
893
+ log.warn(`memini: ${String(error)}`);
894
+ return null;
895
+ });
896
+ let result;
897
+ if (cfg.recall_budget_ms > 0) {
898
+ let timer;
899
+ const budget = new Promise((resolve) => {
900
+ timer = setTimeout(() => resolve(BUDGET_EXPIRED), cfg.recall_budget_ms);
901
+ });
902
+ result = await Promise.race([settled, budget]);
903
+ clearTimeout(timer);
904
+ if (result === BUDGET_EXPIRED) {
905
+ log.warn(
906
+ `recall exceeded its ${cfg.recall_budget_ms}ms budget; late results will inject next turn`,
907
+ );
908
+ if (sessionID) {
909
+ settled.then((late) => {
910
+ const hits = Array.isArray(late && late.results) ? late.results : [];
911
+ if (hits.length) boundedPut(pendingBySession, sessionID, hits);
912
+ });
913
+ }
914
+ result = null;
915
+ }
916
+ } else {
917
+ result = await settled;
918
+ }
534
919
  // Client-side score floor: filter the raw hit list before formatting so
535
920
  // the bullet array only contains hits the operator asked for. Without
536
921
  // this, the server's default floor could leak low-quality hits in
537
922
  // regardless of cfg.recall_min_score.
538
923
  const floor = cfg.recall_min_score > 0 ? cfg.recall_min_score : 0;
539
924
  let rawHits = Array.isArray(result && result.results) ? result.results : [];
925
+ // Merge in results that arrived late on a previous turn: fresh hits
926
+ // first (they answer the current query), deduped by memory id.
927
+ if (sessionID) {
928
+ const pending = pendingBySession.get(sessionID);
929
+ if (pending && pending.length) {
930
+ pendingBySession.delete(sessionID);
931
+ const fresh = new Set(rawHits.map((r) => r?.memory?.id).filter(Boolean));
932
+ rawHits = rawHits.concat(pending.filter((r) => !fresh.has(r?.memory?.id)));
933
+ }
934
+ }
540
935
  // Suppress memories this session has already been shown — the injected
541
936
  // part persists in the session, so a repeat adds nothing but noise.
542
937
  if (sessionID) {
@@ -555,7 +950,16 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
555
950
  const fit = fitByTokens(hits, cfg.recall_max_tokens);
556
951
  if (fit.items.length === 0) return;
557
952
  if (sessionID) {
558
- rememberInjected(sessionID, filtered.map((r) => r?.memory?.id).filter(Boolean));
953
+ // Mark only the slice formatResults actually renders: with carryover
954
+ // merged in, `filtered` can exceed recall_limit, and marking unshown
955
+ // hits as seen would suppress them forever.
956
+ rememberInjected(
957
+ sessionID,
958
+ filtered
959
+ .slice(0, cfg.recall_limit || DEFAULT_RECALL_LIMIT)
960
+ .map((r) => r?.memory?.id)
961
+ .filter(Boolean),
962
+ );
559
963
  }
560
964
  const lines = [
561
965
  `Relevant long-term memory from memini (background context — prefer ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eleboucher/opencode-memini",
3
- "version": "0.6.8",
3
+ "version": "0.6.10",
4
4
  "description": "Automatic cross-session memory for opencode via memini — recall before each turn, capture after.",
5
5
  "keywords": [
6
6
  "memini",