@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.
- package/README.md +12 -1
- package/memini.js +145 -12
- 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
|
-
| `
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
1045
|
+
if (stored !== null && assistantID) rememberCaptured(assistantID);
|
|
913
1046
|
}),
|
|
914
1047
|
};
|
|
915
1048
|
};
|