@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.
- package/README.md +12 -1
- package/memini.js +100 -10
- 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,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
|
|
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
|
-
|
|
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
|
-
|
|
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 ` +
|