@eleboucher/opencode-memini 0.6.9 → 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 +12 -1
  2. package/memini.js +100 -10
  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
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
package/memini.js CHANGED
@@ -23,9 +23,13 @@ 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;
@@ -252,6 +256,17 @@ export function resolveConfig(env, options, worktree, opts = {}) {
252
256
  }
253
257
  return DEFAULT_RECALL_LIMIT;
254
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
+ })();
255
270
  // home: the caller's personal namespace, sent as X-Memini-Home. Same
256
271
  // env-only resolution style as namespace's MEMINI_NAMESPACE (option wins
257
272
  // over env), but no config-file/derivation fallback — unset means "no home
@@ -281,6 +296,7 @@ export function resolveConfig(env, options, worktree, opts = {}) {
281
296
  o.recall_min_score !== undefined
282
297
  ? Number(o.recall_min_score) || 0
283
298
  : floatEnv("MEMINI_INJECT_RECALL_MIN_SCORE", 0),
299
+ recall_budget_ms,
284
300
  timeout_ms: Number(o.timeout_ms || e.MEMINI_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
285
301
  fallback_on_error:
286
302
  o.fallback_on_error !== undefined
@@ -592,6 +608,7 @@ export function describeSettings(env, options, worktree) {
592
608
  recall_limit: cfg.recall_limit,
593
609
  recall_max_tokens: cfg.recall_max_tokens,
594
610
  recall_min_score: cfg.recall_min_score,
611
+ recall_budget_ms: cfg.recall_budget_ms,
595
612
  labels: [...labelsEnv()],
596
613
  },
597
614
  paths: { overrides: overridesPath(e) },
@@ -638,6 +655,7 @@ export function renderStatus(report) {
638
655
  L.push(` ${padTo("recall_limit", 26)} ${memory.recall_limit}`);
639
656
  L.push(` ${padTo("recall_max_tokens", 26)} ${memory.recall_max_tokens || "uncapped"}`);
640
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}`);
641
659
  L.push(` ${padTo("labels", 26)} ${memory.labels.length ? memory.labels.join(",") : "(none)"}`);
642
660
  L.push("");
643
661
 
@@ -750,28 +768,46 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
750
768
 
751
769
  const cfg = resolveConfig(process.env, options, worktree || directory);
752
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
+ }
753
781
  // Assistant message ids already captured, so repeated session.idle events for
754
782
  // the same turn don't write duplicates.
755
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
+ };
756
795
  // Memory ids each session has already been shown (mirrors the pi plugin):
757
796
  // the injected synthetic part is persisted into the session, so re-injecting
758
797
  // an unchanged match every turn stacks identical blocks in the context.
759
- // Bounded so long-lived hosts can't grow the map without limit.
760
798
  const injectedBySession = new Map();
761
- const MAX_TRACKED_SESSIONS = 200;
762
799
  const rememberInjected = (session, ids) => {
763
800
  let seen = injectedBySession.get(session);
764
801
  if (!seen) {
765
802
  seen = new Set();
766
- injectedBySession.set(session, seen);
767
- while (injectedBySession.size > MAX_TRACKED_SESSIONS) {
768
- const oldest = injectedBySession.keys().next().value;
769
- if (oldest === undefined) break;
770
- injectedBySession.delete(oldest);
771
- }
803
+ boundedPut(injectedBySession, session, seen);
772
804
  }
773
805
  for (const id of ids) if (id) seen.add(id);
774
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();
775
811
 
776
812
  // opencode runs chat.message via an unguarded Effect.promise (a throw aborts the
777
813
  // turn) and dispatches event hooks fire-and-forget, so a hook must never reject:
@@ -844,13 +880,58 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
844
880
  // the Claude Code plugin's pre-tool-use hook uses; client-side re-filter
845
881
  // is a belt-and-braces guard against score-normalization edge cases.
846
882
  if (cfg.recall_min_score > 0) body.min_score = cfg.recall_min_score;
847
- 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
+ }
848
919
  // Client-side score floor: filter the raw hit list before formatting so
849
920
  // the bullet array only contains hits the operator asked for. Without
850
921
  // this, the server's default floor could leak low-quality hits in
851
922
  // regardless of cfg.recall_min_score.
852
923
  const floor = cfg.recall_min_score > 0 ? cfg.recall_min_score : 0;
853
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
+ }
854
935
  // Suppress memories this session has already been shown — the injected
855
936
  // part persists in the session, so a repeat adds nothing but noise.
856
937
  if (sessionID) {
@@ -869,7 +950,16 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
869
950
  const fit = fitByTokens(hits, cfg.recall_max_tokens);
870
951
  if (fit.items.length === 0) return;
871
952
  if (sessionID) {
872
- 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
+ );
873
963
  }
874
964
  const lines = [
875
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.9",
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",