@eleboucher/opencode-memini 0.6.10 → 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 (2) hide show
  1. package/memini.js +46 -3
  2. package/package.json +1 -1
package/memini.js CHANGED
@@ -779,8 +779,18 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
779
779
  }
780
780
  }
781
781
  // Assistant message ids already captured, so repeated session.idle events for
782
- // 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.
783
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
+ };
784
794
  // boundedPut inserts key -> value and evicts the oldest entries, so a
785
795
  // long-lived host can't grow a per-session map without limit.
786
796
  const MAX_TRACKED_SESSIONS = 200;
@@ -795,7 +805,10 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
795
805
  // Memory ids each session has already been shown (mirrors the pi plugin):
796
806
  // the injected synthetic part is persisted into the session, so re-injecting
797
807
  // an unchanged match every turn stacks identical blocks in the context.
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.
798
810
  const injectedBySession = new Map();
811
+ const MAX_INJECTED_PER_SESSION = 200;
799
812
  const rememberInjected = (session, ids) => {
800
813
  let seen = injectedBySession.get(session);
801
814
  if (!seen) {
@@ -803,11 +816,38 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
803
816
  boundedPut(injectedBySession, session, seen);
804
817
  }
805
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
+ }
806
824
  };
807
825
  // Recall results that arrived after the injection budget expired, keyed by
808
826
  // session and injected on that session's next chat.message. Latest-replace:
809
827
  // a second late recall for the same session supersedes the first.
810
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;
850
+ };
811
851
 
812
852
  // opencode runs chat.message via an unguarded Effect.promise (a throw aborts the
813
853
  // turn) and dispatches event hooks fire-and-forget, so a hook must never reject:
@@ -880,12 +920,15 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
880
920
  // the Claude Code plugin's pre-tool-use hook uses; client-side re-filter
881
921
  // is a belt-and-braces guard against score-normalization edge cases.
882
922
  if (cfg.recall_min_score > 0) body.min_score = cfg.recall_min_score;
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) ?? [])] : [];
883
926
  // opencode awaits this hook before the model sees the message, so the
884
927
  // turn only waits recall_budget_ms for the search; the fetch itself keeps
885
928
  // cfg.timeout_ms as its bound and runs on in the background. A slow or
886
929
  // unreachable memini degrades to "no memories this turn" instead of a
887
930
  // frozen turn, and late results carry over to the session's next message.
888
- const fetchPromise = rest.postJson("/v1/search", body);
931
+ const fetchPromise = searchExcluding(body, excludeIds);
889
932
  // Once the budget expires nothing awaits this promise, and with
890
933
  // fallback_on_error off postJson rethrows — catch here or a late
891
934
  // rejection surfaces as an unhandled rejection in the host.
@@ -999,7 +1042,7 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
999
1042
  tags: ["opencode"],
1000
1043
  metadata,
1001
1044
  });
1002
- if (stored !== null && assistantID) captured.add(assistantID);
1045
+ if (stored !== null && assistantID) rememberCaptured(assistantID);
1003
1046
  }),
1004
1047
  };
1005
1048
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eleboucher/opencode-memini",
3
- "version": "0.6.10",
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",