@eleboucher/opencode-memini 0.7.5 → 0.7.8
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 +37 -17
- package/memini-v2.js +79 -22
- package/memini.js +236 -64
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -70,23 +70,25 @@ Pass options inline via the `[name, options]` form:
|
|
|
70
70
|
}
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
| Option
|
|
74
|
-
|
|
|
75
|
-
| `base_url`
|
|
76
|
-
| `namespace`
|
|
77
|
-
| `home`
|
|
78
|
-
| `recall`
|
|
79
|
-
| `capture`
|
|
80
|
-
| `recall_limit`
|
|
81
|
-
| `recall_max_tokens`
|
|
82
|
-
| `recall_min_score`
|
|
83
|
-
| `
|
|
84
|
-
| `
|
|
85
|
-
| `
|
|
86
|
-
| `
|
|
87
|
-
|
|
|
88
|
-
|
|
|
89
|
-
| —
|
|
73
|
+
| Option | Env var | Default | Purpose |
|
|
74
|
+
| ------------------------- | -------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
75
|
+
| `base_url` | `MEMINI_BASE_URL` | `http://localhost:8080` | memini REST base URL |
|
|
76
|
+
| `namespace` | `MEMINI_NAMESPACE` | server handshake | project the memory is scoped to (`X-Memini-Namespace`) |
|
|
77
|
+
| `home` | `MEMINI_HOME` | unset | caller's personal namespace, sent as `X-Memini-Home`; unset = no home leg |
|
|
78
|
+
| `recall` | `MEMINI_RECALL` | on | `false` disables recall-before-turn |
|
|
79
|
+
| `capture` | `MEMINI_CAPTURE` | on | `false` disables capture-after-turn |
|
|
80
|
+
| `recall_limit` | `MEMINI_RECALL_LIMIT` | `3` | max memories injected per turn |
|
|
81
|
+
| `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 |
|
|
82
|
+
| `recall_min_score` | `MEMINI_INJECT_RECALL_MIN_SCORE` | `0` | fused-score floor (>=) sent as `min_score` to `/v1/search` |
|
|
83
|
+
| `inject_cooldown_ms` | `MEMINI_INJECT_COOLDOWN_MS` | `1800000` | repeat-injection cooldown, **time** window (ms): an already-injected memory is held back this long before it may re-serve; `0` disables the time dimension |
|
|
84
|
+
| `inject_cooldown_prompts` | `MEMINI_INJECT_COOLDOWN_PROMPTS` | `3` | repeat-injection cooldown, **prompt** window (counted per user message); `0` disables the prompt dimension; both cooldown knobs `0` = suppress for the whole session |
|
|
85
|
+
| `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`) |
|
|
86
|
+
| `timeout_ms` | `MEMINI_TIMEOUT_MS` | `30000` | per-request timeout (recall past its budget keeps running in the background under this bound) |
|
|
87
|
+
| `fallback_on_error` | `MEMINI_FALLBACK` | on | `false` surfaces errors instead of degrading silently |
|
|
88
|
+
| `auto_update` | `MEMINI_AUTO_UPDATE` | on | `false` disables npm auto-update checks (opencode never re-fetches cached plugins otherwise) |
|
|
89
|
+
| — | `MEMINI_INJECT_LABELS` | — | comma-separated label toggles for each bullet: `tier`, `confidence`, `age`, `reason` |
|
|
90
|
+
| — | `MEMINI_API_KEY` | — | bearer token, if memini needs auth (env only — secret) |
|
|
91
|
+
| — | `MEMINI_REQUIRE_HTTPS` | — | `1` refuses to send the token over plaintext HTTP |
|
|
90
92
|
|
|
91
93
|
opencode awaits `chat.message` before the model sees the message, so a slow or
|
|
92
94
|
unreachable memini would otherwise freeze the turn for the full `timeout_ms`.
|
|
@@ -98,6 +100,24 @@ startup to warm the connection, so the first recall doesn't pay the
|
|
|
98
100
|
DNS/TLS cold-start. Set `recall_budget_ms: 0` to restore fully blocking
|
|
99
101
|
same-turn injection.
|
|
100
102
|
|
|
103
|
+
### Repeat-injection cooldown
|
|
104
|
+
|
|
105
|
+
The two `inject_cooldown_*` knobs are the windowed **repeat-injection
|
|
106
|
+
cooldown** (shared with the Claude Code / hermes / openclaw integrations): an
|
|
107
|
+
already-injected memory is excluded from recall (server-side via
|
|
108
|
+
`exclude_ids`, with a client-side backstop) while it is inside _either_ window
|
|
109
|
+
— the time window (`inject_cooldown_ms`) or the prompt window
|
|
110
|
+
(`inject_cooldown_prompts`, one prompt = one user message; in the v2 plugin,
|
|
111
|
+
one `request`-hook fire) — and is **re-served once both have lapsed**. A
|
|
112
|
+
memory whose content was updated in place re-injects immediately (the
|
|
113
|
+
content-hash bypass), so a correction is never withheld for the window.
|
|
114
|
+
|
|
115
|
+
> **Behavior change (was: suppress forever).** Earlier versions suppressed an
|
|
116
|
+
> injected memory for the whole session. With the default windows (30 min /
|
|
117
|
+
> 3 prompts) a long session is now **re-reminded** of a still-relevant memory
|
|
118
|
+
> once enough of the conversation has moved past it. Set both knobs to `0` to
|
|
119
|
+
> restore the old suppress-for-the-whole-session behavior.
|
|
120
|
+
|
|
101
121
|
Inline options win over the env vars. Secrets stay in the environment: set
|
|
102
122
|
`MEMINI_API_KEY` (sent as `Authorization: Bearer …`), and optionally
|
|
103
123
|
`MEMINI_REQUIRE_HTTPS=1` to refuse plaintext HTTP, in the shell that launches
|
package/memini-v2.js
CHANGED
|
@@ -45,6 +45,9 @@ import {
|
|
|
45
45
|
describeSettings,
|
|
46
46
|
renderStatus,
|
|
47
47
|
createClient,
|
|
48
|
+
injectedIdentity,
|
|
49
|
+
injectedSuppressed,
|
|
50
|
+
postSearchWithFloor,
|
|
48
51
|
} from "./memini.js";
|
|
49
52
|
|
|
50
53
|
const INJECT_PREAMBLE =
|
|
@@ -164,23 +167,45 @@ export async function setup(ctx) {
|
|
|
164
167
|
const currentConfig = async () => effectiveConfig(cfg, await getHandshake());
|
|
165
168
|
|
|
166
169
|
// Assistant ids already captured, so repeated idle events for one turn don't
|
|
167
|
-
// write duplicates. Memory ids already injected per session
|
|
168
|
-
//
|
|
170
|
+
// write duplicates. Memory ids already injected per session — the enforce
|
|
171
|
+
// core's { n, ids } shape (n = prompt counter, bumped once per request-hook
|
|
172
|
+
// fire; ids maps memory id → { h, at, n }), judged by injectedSuppressed
|
|
173
|
+
// against the inject_cooldown_ms / inject_cooldown_prompts windows: an
|
|
174
|
+
// unchanged match is suppressed while inside EITHER window, re-served once
|
|
175
|
+
// BOTH lapse, and re-served immediately when its content changed (h
|
|
176
|
+
// mismatch). Both maps bounded for a long-lived host.
|
|
169
177
|
const captured = new Set();
|
|
170
|
-
const injectedBySession = new Map();
|
|
178
|
+
const injectedBySession = new Map(); // session -> { n, ids: Map<id, {h, at, n}> }
|
|
171
179
|
const MAX_TRACKED_SESSIONS = 200;
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
180
|
+
const MAX_INJECTED_PER_SESSION = 200;
|
|
181
|
+
const sessionSeen = (session) => {
|
|
182
|
+
let state = injectedBySession.get(session);
|
|
183
|
+
if (!state) {
|
|
184
|
+
state = { n: 0, ids: new Map() };
|
|
185
|
+
injectedBySession.set(session, state);
|
|
177
186
|
while (injectedBySession.size > MAX_TRACKED_SESSIONS) {
|
|
178
187
|
const oldest = injectedBySession.keys().next().value;
|
|
179
188
|
if (oldest === undefined) break;
|
|
180
189
|
injectedBySession.delete(oldest);
|
|
181
190
|
}
|
|
182
191
|
}
|
|
183
|
-
|
|
192
|
+
return state;
|
|
193
|
+
};
|
|
194
|
+
const rememberInjected = (state, hits) => {
|
|
195
|
+
const now = Date.now();
|
|
196
|
+
for (const r of hits) {
|
|
197
|
+
const id = r?.memory?.id;
|
|
198
|
+
if (!id) continue;
|
|
199
|
+
// delete+set refreshes the stamp and the insertion order, so the size
|
|
200
|
+
// cap evicts the least-recently-shown id first.
|
|
201
|
+
state.ids.delete(id);
|
|
202
|
+
state.ids.set(id, { h: injectedIdentity(r?.memory), at: now, n: state.n });
|
|
203
|
+
}
|
|
204
|
+
while (state.ids.size > MAX_INJECTED_PER_SESSION) {
|
|
205
|
+
const oldest = state.ids.keys().next().value;
|
|
206
|
+
if (oldest === undefined) break;
|
|
207
|
+
state.ids.delete(oldest);
|
|
208
|
+
}
|
|
184
209
|
};
|
|
185
210
|
|
|
186
211
|
const cleanups = [];
|
|
@@ -204,24 +229,57 @@ export async function setup(ctx) {
|
|
|
204
229
|
const query = extractQueryFromRequest(event);
|
|
205
230
|
if (!query) return;
|
|
206
231
|
const sessionID = event.sessionID || event.sessionId || (event.session && event.session.id) || "";
|
|
232
|
+
// One request-hook fire == one prompt for the cooldown's prompt
|
|
233
|
+
// dimension (the v2 beta's closest per-turn signal): bump before any
|
|
234
|
+
// gate, so the window measures prompts-since-injection even on turns
|
|
235
|
+
// that inject nothing.
|
|
236
|
+
const seen = sessionID ? sessionSeen(sessionID) : null;
|
|
237
|
+
if (seen) seen.n += 1;
|
|
238
|
+
const cooldownOpts = () => ({
|
|
239
|
+
now: Date.now(),
|
|
240
|
+
counter: seen ? seen.n : 0,
|
|
241
|
+
cooldownMs: live.inject_cooldown_ms,
|
|
242
|
+
cooldownPrompts: live.inject_cooldown_prompts,
|
|
243
|
+
});
|
|
207
244
|
|
|
208
245
|
const body = { query, limit: live.recall_limit };
|
|
209
246
|
// Exclude this session's own captured turns: they're still in the live
|
|
210
247
|
// context, so recalling them just echoes the conversation back a turn
|
|
211
248
|
// behind. Past sessions still recall.
|
|
212
249
|
if (sessionID) body.exclude_metadata = { session_id: sessionID };
|
|
213
|
-
|
|
250
|
+
// inject_recall_min_score floors the FINAL composite score server-side
|
|
251
|
+
// via min_rank_score (not the fused-scale min_score), matching the
|
|
252
|
+
// Claude Code plugin. A knob >= 1 is out of the server's range, so it
|
|
253
|
+
// clamps to a client-only floor rather than 400ing every search.
|
|
254
|
+
const rankFloorInRange = live.recall_min_score > 0 && live.recall_min_score < 1;
|
|
255
|
+
if (rankFloorInRange) body.min_rank_score = live.recall_min_score;
|
|
214
256
|
|
|
215
257
|
// Blocking, like v1's chat.message: opencode awaits this hook before
|
|
216
|
-
// dispatch.
|
|
217
|
-
//
|
|
218
|
-
|
|
258
|
+
// dispatch. postSearchWithFloor is bounded by cfg.timeout_ms and
|
|
259
|
+
// fail-soft, and on an older server's 400 it retries once with
|
|
260
|
+
// min_rank_score stripped (v2 sends no exclude_ids), so a slow or
|
|
261
|
+
// out-of-date memini degrades to no memory this turn, never a throw.
|
|
262
|
+
const { data: result, rankFloorStripped } = await postSearchWithFloor(
|
|
263
|
+
rest.postJson,
|
|
264
|
+
body,
|
|
265
|
+
live.namespace,
|
|
266
|
+
);
|
|
219
267
|
|
|
220
|
-
|
|
268
|
+
// Client composite floor is a fallback ONLY: it runs when the knob was
|
|
269
|
+
// clamped to client-only (>= 1) or the retry stripped min_rank_score. A
|
|
270
|
+
// server that enforced the floor is authoritative and not re-filtered.
|
|
271
|
+
const serverEnforcedFloor = rankFloorInRange && !rankFloorStripped;
|
|
272
|
+
const floor = live.recall_min_score > 0 && !serverEnforcedFloor ? live.recall_min_score : 0;
|
|
221
273
|
let rawHits = Array.isArray(result && result.results) ? result.results : [];
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
274
|
+
// Windowed cooldown, judged PER HIT against its content identity: an
|
|
275
|
+
// in-window unchanged hit is dropped, a lapsed one re-serves, and an
|
|
276
|
+
// UPDATED one (h mismatch) bypasses the window and re-injects.
|
|
277
|
+
if (seen && seen.ids.size) {
|
|
278
|
+
const opts = cooldownOpts();
|
|
279
|
+
rawHits = rawHits.filter((r) => {
|
|
280
|
+
const entry = seen.ids.get(r && r.memory && r.memory.id);
|
|
281
|
+
return !(entry && injectedSuppressed(entry, injectedIdentity(r && r.memory), opts));
|
|
282
|
+
});
|
|
225
283
|
}
|
|
226
284
|
const filtered =
|
|
227
285
|
floor > 0
|
|
@@ -240,11 +298,10 @@ export async function setup(ctx) {
|
|
|
240
298
|
}
|
|
241
299
|
if (fit.dropped > 0) lines.push(`[... ${fit.dropped} item(s) truncated by token budget]`);
|
|
242
300
|
|
|
243
|
-
if (injectContext(event, lines.join("\n")) &&
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
);
|
|
301
|
+
if (injectContext(event, lines.join("\n")) && seen) {
|
|
302
|
+
// Record only the slice formatResults actually renders, stamped with
|
|
303
|
+
// {h, at, n} so the windowed cooldown can judge re-admission later.
|
|
304
|
+
rememberInjected(seen, filtered.slice(0, live.recall_limit || 3));
|
|
248
305
|
}
|
|
249
306
|
} catch (error) {
|
|
250
307
|
log.warn(`request hook failed: ${String(error)}`);
|
package/memini.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { execSync, spawnSync } from "node:child_process";
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
23
24
|
import { readFileSync, existsSync, rmSync, writeFileSync, statSync } from "node:fs";
|
|
24
25
|
import { resolve, join, dirname } from "node:path";
|
|
25
26
|
import { homedir } from "node:os";
|
|
@@ -337,6 +338,17 @@ export function resolveConfig(env, options, worktree) {
|
|
|
337
338
|
const homeRaw = o.home !== undefined ? o.home : e.MEMINI_HOME;
|
|
338
339
|
const home = homeRaw && String(homeRaw).trim() ? String(homeRaw).trim() : undefined;
|
|
339
340
|
|
|
341
|
+
// Windowed injection-cooldown knobs. 0 is MEANINGFUL (it disables that
|
|
342
|
+
// dimension; both 0 restores the legacy suppress-forever behavior), so a
|
|
343
|
+
// malformed option falls through to env/default rather than collapsing to 0.
|
|
344
|
+
const cooldownKnob = (optVal, envName, def) => {
|
|
345
|
+
if (optVal !== undefined) {
|
|
346
|
+
const n = Number(optVal);
|
|
347
|
+
if (Number.isFinite(n) && n >= 0) return n;
|
|
348
|
+
}
|
|
349
|
+
return intEnvFrom(e, envName, def);
|
|
350
|
+
};
|
|
351
|
+
|
|
340
352
|
return {
|
|
341
353
|
base_url: o.base_url || e.MEMINI_BASE_URL || DEFAULT_BASE_URL,
|
|
342
354
|
// namespace is already resolved above (explicit raw-trimmed, or the
|
|
@@ -363,6 +375,11 @@ export function resolveConfig(env, options, worktree) {
|
|
|
363
375
|
o.recall_min_score !== undefined
|
|
364
376
|
? Number(o.recall_min_score) || 0
|
|
365
377
|
: floatEnv("MEMINI_INJECT_RECALL_MIN_SCORE", 0),
|
|
378
|
+
// Windowed injection cooldown (option > env > server settings via
|
|
379
|
+
// effectiveConfig > built-in default, mirroring the server's own
|
|
380
|
+
// ClientSettings defaults: 30 min / 3 prompts). See injectedSuppressed.
|
|
381
|
+
inject_cooldown_ms: cooldownKnob(o.inject_cooldown_ms, "MEMINI_INJECT_COOLDOWN_MS", 1800000),
|
|
382
|
+
inject_cooldown_prompts: cooldownKnob(o.inject_cooldown_prompts, "MEMINI_INJECT_COOLDOWN_PROMPTS", 3),
|
|
366
383
|
recall_budget_ms,
|
|
367
384
|
timeout_ms: Number(o.timeout_ms || e.MEMINI_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
|
|
368
385
|
fallback_on_error:
|
|
@@ -379,6 +396,8 @@ export function resolveConfig(env, options, worktree) {
|
|
|
379
396
|
recall_limit: o.recall_limit !== undefined || isSet(e.MEMINI_RECALL_LIMIT),
|
|
380
397
|
recall_max_tokens: o.recall_max_tokens !== undefined || isSet(process.env.MEMINI_INJECT_RECALL_MAX_TOK),
|
|
381
398
|
recall_min_score: o.recall_min_score !== undefined || isSet(process.env.MEMINI_INJECT_RECALL_MIN_SCORE),
|
|
399
|
+
inject_cooldown_ms: o.inject_cooldown_ms !== undefined || isSet(e.MEMINI_INJECT_COOLDOWN_MS),
|
|
400
|
+
inject_cooldown_prompts: o.inject_cooldown_prompts !== undefined || isSet(e.MEMINI_INJECT_COOLDOWN_PROMPTS),
|
|
382
401
|
capture_user_max_chars: isSet(e.MEMINI_CAPTURE_USER_MAX_CHARS),
|
|
383
402
|
capture_assistant_max_chars: isSet(e.MEMINI_CAPTURE_ASSISTANT_MAX_CHARS),
|
|
384
403
|
},
|
|
@@ -427,6 +446,14 @@ export function effectiveConfig(cfg, hs) {
|
|
|
427
446
|
explicit.recall_min_score || !Number.isFinite(s.inject_recall_min_score)
|
|
428
447
|
? cfg.recall_min_score
|
|
429
448
|
: s.inject_recall_min_score,
|
|
449
|
+
inject_cooldown_ms:
|
|
450
|
+
explicit.inject_cooldown_ms || !Number.isFinite(s.inject_cooldown_ms)
|
|
451
|
+
? cfg.inject_cooldown_ms
|
|
452
|
+
: s.inject_cooldown_ms,
|
|
453
|
+
inject_cooldown_prompts:
|
|
454
|
+
explicit.inject_cooldown_prompts || !Number.isFinite(s.inject_cooldown_prompts)
|
|
455
|
+
? cfg.inject_cooldown_prompts
|
|
456
|
+
: s.inject_cooldown_prompts,
|
|
430
457
|
capture_user_max_chars:
|
|
431
458
|
explicit.capture_user_max_chars || !Number.isFinite(s.capture_user_max_chars)
|
|
432
459
|
? cfg.capture_user_max_chars
|
|
@@ -443,15 +470,25 @@ export function effectiveConfig(cfg, hs) {
|
|
|
443
470
|
// shape MeminiPlugin uses to memoize the handshake per session. `now` is
|
|
444
471
|
// injectable so tests can drive expiry without a real 10-minute sleep.
|
|
445
472
|
// Exported for testing.
|
|
473
|
+
//
|
|
474
|
+
// Caches the promise, not the resolved value, so concurrent callers share
|
|
475
|
+
// one in-flight call. Clears on rejection so a transient failure doesn't
|
|
476
|
+
// poison the TTL window.
|
|
446
477
|
export function memoizeAsync(fn, ttlMs, now = Date.now) {
|
|
447
|
-
let cached = null; // {
|
|
478
|
+
let cached = null; // { promise, expiresAt }
|
|
448
479
|
return async () => {
|
|
449
480
|
const t = now();
|
|
450
481
|
if (!cached || t >= cached.expiresAt) {
|
|
451
|
-
const
|
|
452
|
-
cached = {
|
|
482
|
+
const promise = fn();
|
|
483
|
+
cached = { promise, expiresAt: t + ttlMs };
|
|
484
|
+
promise.then(
|
|
485
|
+
() => {},
|
|
486
|
+
() => {
|
|
487
|
+
if (cached && cached.promise === promise) cached = null;
|
|
488
|
+
},
|
|
489
|
+
);
|
|
453
490
|
}
|
|
454
|
-
return cached.
|
|
491
|
+
return cached.promise;
|
|
455
492
|
};
|
|
456
493
|
}
|
|
457
494
|
|
|
@@ -569,7 +606,7 @@ export function intEnvFrom(env, name, defaultValue) {
|
|
|
569
606
|
|
|
570
607
|
/**
|
|
571
608
|
* floatEnv parses a non-negative float env var and returns `default` when
|
|
572
|
-
* unset or malformed.
|
|
609
|
+
* unset or malformed.
|
|
573
610
|
*/
|
|
574
611
|
export function floatEnv(name, defaultValue) {
|
|
575
612
|
const raw = process.env[name];
|
|
@@ -632,6 +669,62 @@ export function fitByTokens(items, maxTokens) {
|
|
|
632
669
|
return { items: out, tokens: used, dropped };
|
|
633
670
|
}
|
|
634
671
|
|
|
672
|
+
// --- Injection-enforcement core (opencode copies) ---------------------------
|
|
673
|
+
//
|
|
674
|
+
// Ported from @memini/client's enforce core (packages/memini-client/src/
|
|
675
|
+
// enforce/identity.ts + seen.ts); semantics are pinned by the shared golden
|
|
676
|
+
// vectors (packages/memini-client/vectors/enforcement.json), replayed by
|
|
677
|
+
// memini.test.mjs. This plugin ships standalone (no build step), so these
|
|
678
|
+
// stay copies, not imports — the vector replay is what keeps them the same
|
|
679
|
+
// functions.
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* True for a well-formed server-minted content hash: 16 lowercase hex chars
|
|
683
|
+
* (the server's sha256(content||summary).slice(0,16) — the same recipe as the
|
|
684
|
+
* local fallback in injectedIdentity, so the two are interchangeable).
|
|
685
|
+
*/
|
|
686
|
+
export function isContentHash(s) {
|
|
687
|
+
return typeof s === "string" && /^[0-9a-f]{16}$/.test(s);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Content-identity hash for the injected-memory state: prefer the server-
|
|
692
|
+
* minted content_hash (read off the object itself or its nested `memory`),
|
|
693
|
+
* else hash the text a recall surface would render (content, falling back to
|
|
694
|
+
* summary) — so an in-place update still changes identity and re-injects even
|
|
695
|
+
* on servers without content_hash.
|
|
696
|
+
*/
|
|
697
|
+
export function injectedIdentity(m) {
|
|
698
|
+
const ch = m?.content_hash ?? m?.memory?.content_hash;
|
|
699
|
+
if (isContentHash(ch)) return ch;
|
|
700
|
+
const text = m?.content || m?.summary || "";
|
|
701
|
+
return createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* The shared windowed-cooldown predicate (enforce/seen.ts, core-exact):
|
|
706
|
+
*
|
|
707
|
+
* entry.h === "" → true sentinel/tool-read: forever
|
|
708
|
+
* identity && entry.h !== identity → false content changed: re-inject
|
|
709
|
+
* cooldownMs == 0 && prompts == 0 → true legacy forever-dedupe (#134)
|
|
710
|
+
* else suppressed within EITHER window; re-admit once BOTH lapse.
|
|
711
|
+
* counter == 0 leaves the prompt dimension inert (a host that never
|
|
712
|
+
* advances a counter degrades to time-only, not forever); negative deltas
|
|
713
|
+
* (clock skew / counter regression) clamp to suppressed.
|
|
714
|
+
*
|
|
715
|
+
* `identity` null is the id-only check (the exclude_ids view): the content-
|
|
716
|
+
* change bypass is skipped, so an entry is judged on the windows alone.
|
|
717
|
+
*/
|
|
718
|
+
export function injectedSuppressed(entry, identity, { now, counter, cooldownMs, cooldownPrompts }) {
|
|
719
|
+
if (!entry || typeof entry !== "object") return false;
|
|
720
|
+
if (entry.h === "") return true; // sentinel / tool-read: forever
|
|
721
|
+
if (identity && entry.h !== identity) return false; // content changed: re-inject
|
|
722
|
+
if (cooldownMs === 0 && cooldownPrompts === 0) return true; // legacy forever-dedupe
|
|
723
|
+
const promptDim = cooldownPrompts > 0 && counter > 0 && counter - entry.n < cooldownPrompts;
|
|
724
|
+
const timeDim = cooldownMs > 0 && now - entry.at < cooldownMs;
|
|
725
|
+
return promptDim || timeDim;
|
|
726
|
+
}
|
|
727
|
+
|
|
635
728
|
/**
|
|
636
729
|
* Truncate `s` to `max` CHARACTERS for a turn capture, marking the cut. `max <= 0`
|
|
637
730
|
* captures it whole. Mirrors @memini/client's truncateForCapture — this plugin
|
|
@@ -923,6 +1016,49 @@ export function createClient(cfg, log) {
|
|
|
923
1016
|
return { postJson, handshake, baseUrl };
|
|
924
1017
|
}
|
|
925
1018
|
|
|
1019
|
+
/**
|
|
1020
|
+
* POST /v1/search carrying the newer-than-server optional fields, retrying ONCE
|
|
1021
|
+
* with min_rank_score (and any exclude_ids) stripped when the first attempt
|
|
1022
|
+
* fails. An older server 400s an unknown field (returned as null under
|
|
1023
|
+
* fail-soft, or a throw with fallback_on_error off), so this degrades to the
|
|
1024
|
+
* client-side composite floor fallback instead of losing recall entirely.
|
|
1025
|
+
*
|
|
1026
|
+
* Returns {data, rankFloorStripped}. rankFloorStripped is true only when the
|
|
1027
|
+
* floor was sent and then dropped on the retry — the signal the caller uses to
|
|
1028
|
+
* decide whether to re-apply the composite floor client-side. A server that
|
|
1029
|
+
* accepted the floor is authoritative and its result set is NOT re-filtered.
|
|
1030
|
+
* exclude_ids rides only the first attempt and is stripped alongside the floor
|
|
1031
|
+
* on retry (matching _shared.mjs's combined strip); onExcludeIdsUnsupported,
|
|
1032
|
+
* when given, latches it off for the session. Unlike the Claude plugin's
|
|
1033
|
+
* pretool latch, the floor itself is never latched off: this integration tracks
|
|
1034
|
+
* no content hash, so a stateless per-call strip is the faithful port.
|
|
1035
|
+
*/
|
|
1036
|
+
export async function postSearchWithFloor(postJson, body, namespace, opts = {}) {
|
|
1037
|
+
const { excludeIds = [], onExcludeIdsUnsupported } = opts;
|
|
1038
|
+
const rankFloorInBody = body.min_rank_score !== undefined;
|
|
1039
|
+
const withExcludeIds = excludeIds.length > 0;
|
|
1040
|
+
if (!rankFloorInBody && !withExcludeIds) {
|
|
1041
|
+
return { data: await postJson("/v1/search", body, namespace), rankFloorStripped: false };
|
|
1042
|
+
}
|
|
1043
|
+
try {
|
|
1044
|
+
const first = await postJson(
|
|
1045
|
+
"/v1/search",
|
|
1046
|
+
withExcludeIds ? { ...body, exclude_ids: excludeIds } : body,
|
|
1047
|
+
namespace,
|
|
1048
|
+
);
|
|
1049
|
+
if (first !== null) return { data: first, rankFloorStripped: false };
|
|
1050
|
+
} catch {
|
|
1051
|
+
// With fallback_on_error=false the 400 arrives as a throw, not null.
|
|
1052
|
+
}
|
|
1053
|
+
const stripped = { ...body };
|
|
1054
|
+
delete stripped.min_rank_score;
|
|
1055
|
+
const retry = await postJson("/v1/search", stripped, namespace);
|
|
1056
|
+
if (retry !== null && withExcludeIds && typeof onExcludeIdsUnsupported === "function") {
|
|
1057
|
+
onExcludeIdsUnsupported();
|
|
1058
|
+
}
|
|
1059
|
+
return { data: retry, rankFloorStripped: rankFloorInBody };
|
|
1060
|
+
}
|
|
1061
|
+
|
|
926
1062
|
// extractLastTurn returns the latest user and assistant text from the message
|
|
927
1063
|
// list returned by client.session.messages ([{info, parts}, ...]), plus the id
|
|
928
1064
|
// of the assistant message (for dedup). Iterates in reverse to short-circuit.
|
|
@@ -1024,21 +1160,38 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1024
1160
|
// Memory ids each session has already been shown (mirrors the pi plugin):
|
|
1025
1161
|
// the injected synthetic part is persisted into the session, so re-injecting
|
|
1026
1162
|
// an unchanged match every turn stacks identical blocks in the context.
|
|
1027
|
-
//
|
|
1028
|
-
//
|
|
1029
|
-
|
|
1163
|
+
// Per session: the enforce core's { n, ids } shape — n is the prompt counter
|
|
1164
|
+
// (bumped once per chat.message) and ids maps memory id → { h, at, n }
|
|
1165
|
+
// (content identity, last-injected ms, counter at injection), judged by
|
|
1166
|
+
// injectedSuppressed against the inject_cooldown_ms / inject_cooldown_prompts
|
|
1167
|
+
// windows: suppressed within EITHER window, re-served once BOTH lapse, and
|
|
1168
|
+
// re-served immediately when the content changed (h mismatch). The inner cap
|
|
1169
|
+
// keeps a stable session — which never ages out of the outer map — from
|
|
1170
|
+
// growing its map for the process lifetime.
|
|
1171
|
+
const injectedBySession = new Map(); // session -> { n, ids: Map<id, {h, at, n}> }
|
|
1030
1172
|
const MAX_INJECTED_PER_SESSION = 200;
|
|
1031
|
-
const
|
|
1032
|
-
let
|
|
1033
|
-
if (!
|
|
1034
|
-
|
|
1035
|
-
boundedPut(injectedBySession, session,
|
|
1173
|
+
const sessionSeen = (session) => {
|
|
1174
|
+
let state = injectedBySession.get(session);
|
|
1175
|
+
if (!state) {
|
|
1176
|
+
state = { n: 0, ids: new Map() };
|
|
1177
|
+
boundedPut(injectedBySession, session, state);
|
|
1178
|
+
}
|
|
1179
|
+
return state;
|
|
1180
|
+
};
|
|
1181
|
+
const rememberInjected = (state, hits) => {
|
|
1182
|
+
const now = Date.now();
|
|
1183
|
+
for (const r of hits) {
|
|
1184
|
+
const id = r?.memory?.id;
|
|
1185
|
+
if (!id) continue;
|
|
1186
|
+
// delete+set refreshes both the stamp and the insertion order, so the
|
|
1187
|
+
// size cap below evicts the least-recently-shown id first.
|
|
1188
|
+
state.ids.delete(id);
|
|
1189
|
+
state.ids.set(id, { h: injectedIdentity(r?.memory), at: now, n: state.n });
|
|
1036
1190
|
}
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
const oldest = seen.values().next().value;
|
|
1191
|
+
while (state.ids.size > MAX_INJECTED_PER_SESSION) {
|
|
1192
|
+
const oldest = state.ids.keys().next().value;
|
|
1040
1193
|
if (oldest === undefined) break;
|
|
1041
|
-
|
|
1194
|
+
state.ids.delete(oldest);
|
|
1042
1195
|
}
|
|
1043
1196
|
};
|
|
1044
1197
|
// Recall results that arrived after the injection budget expired, keyed by
|
|
@@ -1050,23 +1203,18 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1050
1203
|
// 400 on the unknown field: when a request carrying it fails and the retry
|
|
1051
1204
|
// without it succeeds, stop sending it. The client-side filter stays.
|
|
1052
1205
|
let serverExcludeIds = true;
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
serverExcludeIds = false;
|
|
1066
|
-
log.warn("memini: server does not accept exclude_ids; using client-side dedupe only");
|
|
1067
|
-
}
|
|
1068
|
-
return retry;
|
|
1069
|
-
};
|
|
1206
|
+
// Delegate the search + one-shot compat retry to postSearchWithFloor, which
|
|
1207
|
+
// strips BOTH min_rank_score and exclude_ids on an older server's 400. Keep
|
|
1208
|
+
// the exclude_ids latch here (a closure the callback flips off); the floor is
|
|
1209
|
+
// not latched. Returns {data, rankFloorStripped}.
|
|
1210
|
+
const searchExcluding = (body, excludeIds, namespace) =>
|
|
1211
|
+
postSearchWithFloor(rest.postJson, body, namespace, {
|
|
1212
|
+
excludeIds: serverExcludeIds ? excludeIds : [],
|
|
1213
|
+
onExcludeIdsUnsupported: () => {
|
|
1214
|
+
serverExcludeIds = false;
|
|
1215
|
+
log.warn("memini: server does not accept exclude_ids; using client-side dedupe only");
|
|
1216
|
+
},
|
|
1217
|
+
});
|
|
1070
1218
|
|
|
1071
1219
|
// opencode runs chat.message via an unguarded Effect.promise (a throw aborts the
|
|
1072
1220
|
// turn) and dispatches event hooks fire-and-forget, so a hook must never reject:
|
|
@@ -1138,18 +1286,40 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1138
1286
|
const sibling = output.parts.find((p) => p && p.type === "text") || {};
|
|
1139
1287
|
const sessionID = input.sessionID || sibling.sessionID;
|
|
1140
1288
|
const messageID = input.messageID || sibling.messageID;
|
|
1289
|
+
// One chat.message == one user prompt: bump the session's prompt counter
|
|
1290
|
+
// before any recall — the cooldown's prompt dimension measures prompts-
|
|
1291
|
+
// since-injection even on turns that inject nothing.
|
|
1292
|
+
const seen = sessionID ? sessionSeen(sessionID) : null;
|
|
1293
|
+
if (seen) seen.n += 1;
|
|
1294
|
+
const cooldownOpts = () => ({
|
|
1295
|
+
now: Date.now(),
|
|
1296
|
+
counter: seen ? seen.n : 0,
|
|
1297
|
+
cooldownMs: live.inject_cooldown_ms,
|
|
1298
|
+
cooldownPrompts: live.inject_cooldown_prompts,
|
|
1299
|
+
});
|
|
1141
1300
|
const body = { query, limit: live.recall_limit };
|
|
1142
1301
|
// Exclude this session's own captured turns: they're still in the live
|
|
1143
1302
|
// context, so recalling them just echoes the conversation back a turn
|
|
1144
1303
|
// behind. Captures from other (past) sessions are still recalled.
|
|
1145
1304
|
if (sessionID) body.exclude_metadata = { session_id: sessionID };
|
|
1146
|
-
//
|
|
1147
|
-
//
|
|
1148
|
-
// is
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1305
|
+
// inject_recall_min_score floors the FINAL composite score server-side
|
|
1306
|
+
// via min_rank_score (not the fused-scale min_score), matching the Claude
|
|
1307
|
+
// Code plugin. A knob >= 1 is out of the server's range, so it clamps to a
|
|
1308
|
+
// client-only floor rather than 400ing every search.
|
|
1309
|
+
const rankFloorInRange = live.recall_min_score > 0 && live.recall_min_score < 1;
|
|
1310
|
+
if (rankFloorInRange) body.min_rank_score = live.recall_min_score;
|
|
1311
|
+
// Ids still IN COOLDOWN go along as exclude_ids so a suppressed hit
|
|
1312
|
+
// doesn't waste a recall_limit slot (id-only judgment — the wire cannot
|
|
1313
|
+
// know what content the server would serve); a LAPSED id is
|
|
1314
|
+
// intentionally absent so the server may re-serve it.
|
|
1315
|
+
const excludeIds = seen
|
|
1316
|
+
? (() => {
|
|
1317
|
+
const opts = cooldownOpts();
|
|
1318
|
+
return [...seen.ids.entries()]
|
|
1319
|
+
.filter(([, e]) => injectedSuppressed(e, null, opts))
|
|
1320
|
+
.map(([id]) => id);
|
|
1321
|
+
})()
|
|
1322
|
+
: [];
|
|
1153
1323
|
// opencode awaits this hook before the model sees the message, so the
|
|
1154
1324
|
// turn only waits live.recall_budget_ms for the search; the fetch itself keeps
|
|
1155
1325
|
// cfg.timeout_ms as its bound and runs on in the background. A slow or
|
|
@@ -1177,7 +1347,7 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1177
1347
|
);
|
|
1178
1348
|
if (sessionID) {
|
|
1179
1349
|
settled.then((late) => {
|
|
1180
|
-
const hits = Array.isArray(late && late.results) ? late.results : [];
|
|
1350
|
+
const hits = Array.isArray(late && late.data && late.data.results) ? late.data.results : [];
|
|
1181
1351
|
if (hits.length) boundedPut(pendingBySession, sessionID, hits);
|
|
1182
1352
|
});
|
|
1183
1353
|
}
|
|
@@ -1186,12 +1356,14 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1186
1356
|
} else {
|
|
1187
1357
|
result = await settled;
|
|
1188
1358
|
}
|
|
1189
|
-
|
|
1190
|
-
//
|
|
1191
|
-
//
|
|
1192
|
-
//
|
|
1193
|
-
|
|
1194
|
-
|
|
1359
|
+
const searchData = result && result.data ? result.data : null;
|
|
1360
|
+
// Client composite floor is a fallback ONLY: it runs when the knob was
|
|
1361
|
+
// clamped to client-only (>= 1) or the retry stripped min_rank_score for
|
|
1362
|
+
// an old server. A server that enforced the floor is authoritative and
|
|
1363
|
+
// its result set is not re-filtered here.
|
|
1364
|
+
const serverEnforcedFloor = rankFloorInRange && !(result && result.rankFloorStripped);
|
|
1365
|
+
const floor = live.recall_min_score > 0 && !serverEnforcedFloor ? live.recall_min_score : 0;
|
|
1366
|
+
let rawHits = Array.isArray(searchData && searchData.results) ? searchData.results : [];
|
|
1195
1367
|
// Merge in results that arrived late on a previous turn: fresh hits
|
|
1196
1368
|
// first (they answer the current query), deduped by memory id.
|
|
1197
1369
|
if (sessionID) {
|
|
@@ -1202,11 +1374,17 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1202
1374
|
rawHits = rawHits.concat(pending.filter((r) => !fresh.has(r?.memory?.id)));
|
|
1203
1375
|
}
|
|
1204
1376
|
}
|
|
1205
|
-
// Suppress memories this session
|
|
1206
|
-
//
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1377
|
+
// Suppress memories this session was already shown and that are still in
|
|
1378
|
+
// cooldown — judged PER HIT against its content identity, so an
|
|
1379
|
+
// in-window unchanged hit is dropped, a lapsed one passes through and
|
|
1380
|
+
// re-serves, and an UPDATED one (h mismatch) bypasses the window and
|
|
1381
|
+
// re-injects immediately.
|
|
1382
|
+
if (seen && seen.ids.size) {
|
|
1383
|
+
const opts = cooldownOpts();
|
|
1384
|
+
rawHits = rawHits.filter((r) => {
|
|
1385
|
+
const entry = seen.ids.get(r?.memory?.id);
|
|
1386
|
+
return !(entry && injectedSuppressed(entry, injectedIdentity(r?.memory), opts));
|
|
1387
|
+
});
|
|
1210
1388
|
}
|
|
1211
1389
|
const filtered = floor > 0
|
|
1212
1390
|
? rawHits.filter((r) => (typeof r?.score === "number" ? r.score : 0) >= floor)
|
|
@@ -1219,17 +1397,11 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1219
1397
|
// behaviour matches the prior "no cap" code path for existing installs.
|
|
1220
1398
|
const fit = fitByTokens(hits, live.recall_max_tokens);
|
|
1221
1399
|
if (fit.items.length === 0) return;
|
|
1222
|
-
if (
|
|
1400
|
+
if (seen) {
|
|
1223
1401
|
// Mark only the slice formatResults actually renders: with carryover
|
|
1224
1402
|
// merged in, `filtered` can exceed recall_limit, and marking unshown
|
|
1225
|
-
// hits as seen would suppress
|
|
1226
|
-
rememberInjected(
|
|
1227
|
-
sessionID,
|
|
1228
|
-
filtered
|
|
1229
|
-
.slice(0, live.recall_limit || DEFAULT_RECALL_LIMIT)
|
|
1230
|
-
.map((r) => r?.memory?.id)
|
|
1231
|
-
.filter(Boolean),
|
|
1232
|
-
);
|
|
1403
|
+
// hits as seen would suppress what was never injected.
|
|
1404
|
+
rememberInjected(seen, filtered.slice(0, live.recall_limit || DEFAULT_RECALL_LIMIT));
|
|
1233
1405
|
}
|
|
1234
1406
|
const lines = [
|
|
1235
1407
|
`Relevant long-term memory from memini (background context — prefer ` +
|
|
@@ -1239,8 +1411,8 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1239
1411
|
// /v1/search sets `degraded: "keyword_only"` (plus a `note`) when the
|
|
1240
1412
|
// query embed was unavailable and it fell back to keyword-only matching;
|
|
1241
1413
|
// both are already on `result`, so surfacing them is a one-line addition.
|
|
1242
|
-
if (
|
|
1243
|
-
lines.push(`[memini: ${
|
|
1414
|
+
if (searchData && searchData.degraded) {
|
|
1415
|
+
lines.push(`[memini: ${searchData.note || "semantic search unavailable — results are keyword-only and may be incomplete"}]`);
|
|
1244
1416
|
}
|
|
1245
1417
|
if (fit.dropped > 0) lines.push(`[... ${fit.dropped} item(s) truncated by token budget]`);
|
|
1246
1418
|
// opencode's part schema requires ids to start with `prt`.
|