@eleboucher/opencode-memini 0.6.9 → 0.6.11

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 +145 -12
  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,27 +768,85 @@ 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
- // the same turn don't write duplicates.
782
+ // the same turn don't write duplicates. Repeats only concern recent turns,
783
+ // so cap the window instead of growing one entry per turn forever.
755
784
  const captured = new Set();
785
+ const MAX_CAPTURED = 200;
786
+ const rememberCaptured = (id) => {
787
+ captured.add(id);
788
+ while (captured.size > MAX_CAPTURED) {
789
+ const oldest = captured.values().next().value;
790
+ if (oldest === undefined) break;
791
+ captured.delete(oldest);
792
+ }
793
+ };
794
+ // boundedPut inserts key -> value and evicts the oldest entries, so a
795
+ // long-lived host can't grow a per-session map without limit.
796
+ const MAX_TRACKED_SESSIONS = 200;
797
+ const boundedPut = (map, key, value) => {
798
+ map.set(key, value);
799
+ while (map.size > MAX_TRACKED_SESSIONS) {
800
+ const oldest = map.keys().next().value;
801
+ if (oldest === undefined) break;
802
+ map.delete(oldest);
803
+ }
804
+ };
756
805
  // Memory ids each session has already been shown (mirrors the pi plugin):
757
806
  // the injected synthetic part is persisted into the session, so re-injecting
758
807
  // an unchanged match every turn stacks identical blocks in the context.
759
- // Bounded so long-lived hosts can't grow the map without limit.
808
+ // The inner cap keeps a stable session which never ages out of the outer
809
+ // map — from growing its Set for the process lifetime.
760
810
  const injectedBySession = new Map();
761
- const MAX_TRACKED_SESSIONS = 200;
811
+ const MAX_INJECTED_PER_SESSION = 200;
762
812
  const rememberInjected = (session, ids) => {
763
813
  let seen = injectedBySession.get(session);
764
814
  if (!seen) {
765
815
  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
- }
816
+ boundedPut(injectedBySession, session, seen);
772
817
  }
773
818
  for (const id of ids) if (id) seen.add(id);
819
+ while (seen.size > MAX_INJECTED_PER_SESSION) {
820
+ const oldest = seen.values().next().value;
821
+ if (oldest === undefined) break;
822
+ seen.delete(oldest);
823
+ }
824
+ };
825
+ // Recall results that arrived after the injection budget expired, keyed by
826
+ // session and injected on that session's next chat.message. Latest-replace:
827
+ // a second late recall for the same session supersedes the first.
828
+ const pendingBySession = new Map();
829
+ // /v1/search drops exclude_ids before ranking and the limit, so an
830
+ // already-shown hit frees its slot for the next-best match. Older servers
831
+ // 400 on the unknown field: when a request carrying it fails and the retry
832
+ // without it succeeds, stop sending it. The client-side filter stays.
833
+ let serverExcludeIds = true;
834
+ const searchExcluding = async (body, excludeIds) => {
835
+ if (!serverExcludeIds || excludeIds.length === 0) {
836
+ return rest.postJson("/v1/search", body);
837
+ }
838
+ try {
839
+ const result = await rest.postJson("/v1/search", { ...body, exclude_ids: excludeIds });
840
+ if (result !== null) return result;
841
+ } catch {
842
+ // With fallback_on_error=false the 400 arrives as a throw, not null.
843
+ }
844
+ const retry = await rest.postJson("/v1/search", body);
845
+ if (retry !== null) {
846
+ serverExcludeIds = false;
847
+ log.warn("memini: server does not accept exclude_ids; using client-side dedupe only");
848
+ }
849
+ return retry;
774
850
  };
775
851
 
776
852
  // opencode runs chat.message via an unguarded Effect.promise (a throw aborts the
@@ -844,13 +920,61 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
844
920
  // the Claude Code plugin's pre-tool-use hook uses; client-side re-filter
845
921
  // is a belt-and-braces guard against score-normalization edge cases.
846
922
  if (cfg.recall_min_score > 0) body.min_score = cfg.recall_min_score;
847
- const result = await rest.postJson("/v1/search", body);
923
+ // Already-shown ids go along as exclude_ids so a suppressed hit doesn't
924
+ // waste a recall_limit slot.
925
+ const excludeIds = sessionID ? [...(injectedBySession.get(sessionID) ?? [])] : [];
926
+ // opencode awaits this hook before the model sees the message, so the
927
+ // turn only waits recall_budget_ms for the search; the fetch itself keeps
928
+ // cfg.timeout_ms as its bound and runs on in the background. A slow or
929
+ // unreachable memini degrades to "no memories this turn" instead of a
930
+ // frozen turn, and late results carry over to the session's next message.
931
+ const fetchPromise = searchExcluding(body, excludeIds);
932
+ // Once the budget expires nothing awaits this promise, and with
933
+ // fallback_on_error off postJson rethrows — catch here or a late
934
+ // rejection surfaces as an unhandled rejection in the host.
935
+ const settled = fetchPromise.catch((error) => {
936
+ log.warn(`memini: ${String(error)}`);
937
+ return null;
938
+ });
939
+ let result;
940
+ if (cfg.recall_budget_ms > 0) {
941
+ let timer;
942
+ const budget = new Promise((resolve) => {
943
+ timer = setTimeout(() => resolve(BUDGET_EXPIRED), cfg.recall_budget_ms);
944
+ });
945
+ result = await Promise.race([settled, budget]);
946
+ clearTimeout(timer);
947
+ if (result === BUDGET_EXPIRED) {
948
+ log.warn(
949
+ `recall exceeded its ${cfg.recall_budget_ms}ms budget; late results will inject next turn`,
950
+ );
951
+ if (sessionID) {
952
+ settled.then((late) => {
953
+ const hits = Array.isArray(late && late.results) ? late.results : [];
954
+ if (hits.length) boundedPut(pendingBySession, sessionID, hits);
955
+ });
956
+ }
957
+ result = null;
958
+ }
959
+ } else {
960
+ result = await settled;
961
+ }
848
962
  // Client-side score floor: filter the raw hit list before formatting so
849
963
  // the bullet array only contains hits the operator asked for. Without
850
964
  // this, the server's default floor could leak low-quality hits in
851
965
  // regardless of cfg.recall_min_score.
852
966
  const floor = cfg.recall_min_score > 0 ? cfg.recall_min_score : 0;
853
967
  let rawHits = Array.isArray(result && result.results) ? result.results : [];
968
+ // Merge in results that arrived late on a previous turn: fresh hits
969
+ // first (they answer the current query), deduped by memory id.
970
+ if (sessionID) {
971
+ const pending = pendingBySession.get(sessionID);
972
+ if (pending && pending.length) {
973
+ pendingBySession.delete(sessionID);
974
+ const fresh = new Set(rawHits.map((r) => r?.memory?.id).filter(Boolean));
975
+ rawHits = rawHits.concat(pending.filter((r) => !fresh.has(r?.memory?.id)));
976
+ }
977
+ }
854
978
  // Suppress memories this session has already been shown — the injected
855
979
  // part persists in the session, so a repeat adds nothing but noise.
856
980
  if (sessionID) {
@@ -869,7 +993,16 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
869
993
  const fit = fitByTokens(hits, cfg.recall_max_tokens);
870
994
  if (fit.items.length === 0) return;
871
995
  if (sessionID) {
872
- rememberInjected(sessionID, filtered.map((r) => r?.memory?.id).filter(Boolean));
996
+ // Mark only the slice formatResults actually renders: with carryover
997
+ // merged in, `filtered` can exceed recall_limit, and marking unshown
998
+ // hits as seen would suppress them forever.
999
+ rememberInjected(
1000
+ sessionID,
1001
+ filtered
1002
+ .slice(0, cfg.recall_limit || DEFAULT_RECALL_LIMIT)
1003
+ .map((r) => r?.memory?.id)
1004
+ .filter(Boolean),
1005
+ );
873
1006
  }
874
1007
  const lines = [
875
1008
  `Relevant long-term memory from memini (background context — prefer ` +
@@ -909,7 +1042,7 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
909
1042
  tags: ["opencode"],
910
1043
  metadata,
911
1044
  });
912
- if (stored !== null && assistantID) captured.add(assistantID);
1045
+ if (stored !== null && assistantID) rememberCaptured(assistantID);
913
1046
  }),
914
1047
  };
915
1048
  };
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.11",
4
4
  "description": "Automatic cross-session memory for opencode via memini — recall before each turn, capture after.",
5
5
  "keywords": [
6
6
  "memini",