@eleboucher/opencode-memini 0.7.7 → 0.7.9
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 +296 -101
- 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";
|
|
@@ -54,11 +55,16 @@ const CLIENT_VERSION = readPluginVersion();
|
|
|
54
55
|
|
|
55
56
|
// Auto-update: opencode never re-fetches cached npm plugins, so the plugin
|
|
56
57
|
// checks npm dist-tags once per process and self-updates (same major version
|
|
57
|
-
// only) so the running copy stays current.
|
|
58
|
+
// only) so the running copy stays current. opencode installs each plugin spec
|
|
59
|
+
// into its own isolated wrapper directory under
|
|
60
|
+
// ~/.cache/opencode/packages/<spec>/ (e.g. .../opencode-memini@latest/) — the
|
|
61
|
+
// wrapper holds a package.json listing the plugin as a dependency plus a
|
|
62
|
+
// node_modules/ tree — so the wrapper dir is where we rewrite the pin and
|
|
63
|
+
// re-run npm install.
|
|
58
64
|
const PACKAGE_NAME = "@eleboucher/opencode-memini";
|
|
59
65
|
const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`;
|
|
60
66
|
const NPM_FETCH_TIMEOUT = 5000;
|
|
61
|
-
const
|
|
67
|
+
const INSTALL_TIMEOUT_MS = 60000;
|
|
62
68
|
let autoUpdateChecked = false;
|
|
63
69
|
|
|
64
70
|
/**
|
|
@@ -86,23 +92,50 @@ export function compareVersions(a, b) {
|
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
95
|
+
* resolveInstallContextFrom walks up from `startPath` looking for the opencode
|
|
96
|
+
* plugin cache wrapper dir: the first ancestor whose child is a `node_modules`
|
|
97
|
+
* directory AND that also has a `package.json` sibling. Returns
|
|
98
|
+
* { installDir, packageJsonPath } or null. Pure (no `import.meta.url` read) so
|
|
99
|
+
* it can be driven from a synthetic on-disk layout in tests. Exported for
|
|
100
|
+
* testing.
|
|
92
101
|
*/
|
|
93
|
-
function
|
|
102
|
+
export function resolveInstallContextFrom(startPath) {
|
|
94
103
|
try {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
let dir = startPath;
|
|
105
|
+
for (let i = 0; i < 20; i++) {
|
|
106
|
+
const nodeModules = join(dir, "node_modules");
|
|
107
|
+
if (existsSync(nodeModules) && statSync(nodeModules).isDirectory()) {
|
|
108
|
+
const packageJsonPath = join(dir, "package.json");
|
|
109
|
+
if (existsSync(packageJsonPath)) {
|
|
110
|
+
return { installDir: dir, packageJsonPath };
|
|
111
|
+
}
|
|
112
|
+
return null; // node_modules found but no package.json — not a wrapper
|
|
113
|
+
}
|
|
114
|
+
const parent = dirname(dir);
|
|
115
|
+
if (parent === dir) break; // reached root
|
|
116
|
+
dir = parent;
|
|
101
117
|
}
|
|
102
118
|
} catch {}
|
|
103
119
|
return null;
|
|
104
120
|
}
|
|
105
121
|
|
|
122
|
+
/**
|
|
123
|
+
* resolveInstallContext finds the opencode plugin cache wrapper directory that
|
|
124
|
+
* holds this running plugin instance. opencode installs each npm plugin spec
|
|
125
|
+
* into its own isolated dir under ~/.cache/opencode/packages/<spec>/ (e.g.
|
|
126
|
+
* .../opencode-memini@latest/), containing a package.json listing the plugin as
|
|
127
|
+
* a dependency and a node_modules/ tree. The plugin's own file lives at
|
|
128
|
+
* <wrapper>/node_modules/@eleboucher/opencode-memini/memini.js, so the wrapper
|
|
129
|
+
* is the first ancestor whose child is a `node_modules` directory. Returns
|
|
130
|
+
* { installDir, packageJsonPath } or null. Exported for testing.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveInstallContext() {
|
|
133
|
+
try {
|
|
134
|
+
return resolveInstallContextFrom(dirname(fileURLToPath(import.meta.url)));
|
|
135
|
+
} catch {}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
106
139
|
/**
|
|
107
140
|
* fetchLatestVersion queries npm dist-tags with a timeout. Returns the version
|
|
108
141
|
* string or null on failure.
|
|
@@ -128,12 +161,19 @@ async function fetchLatestVersion() {
|
|
|
128
161
|
}
|
|
129
162
|
|
|
130
163
|
/**
|
|
131
|
-
* prepareCacheUpdate rewrites the
|
|
132
|
-
* removes the installed node_modules package, and cleans
|
|
133
|
-
* the installDir on success, null on failure.
|
|
164
|
+
* prepareCacheUpdate rewrites the wrapper package.json to pin the new version,
|
|
165
|
+
* removes the installed node_modules package, and cleans any lockfile so the
|
|
166
|
+
* next install re-fetches. Returns the installDir on success, null on failure.
|
|
167
|
+
*
|
|
168
|
+
* `ctx` is optional: when omitted, resolves from `import.meta.url` (the live
|
|
169
|
+
* path); tests pass a synthetic { installDir, packageJsonPath } so the full
|
|
170
|
+
* rewrite-and-clean flow can be exercised against a temp dir without touching
|
|
171
|
+
* the dev checkout. Exported for testing.
|
|
134
172
|
*/
|
|
135
|
-
function prepareCacheUpdate(newVersion, log) {
|
|
136
|
-
|
|
173
|
+
export function prepareCacheUpdate(newVersion, log, ctx) {
|
|
174
|
+
if (!ctx) {
|
|
175
|
+
ctx = resolveInstallContext();
|
|
176
|
+
}
|
|
137
177
|
if (!ctx) {
|
|
138
178
|
log.warn("auto-update: could not resolve install context");
|
|
139
179
|
return null;
|
|
@@ -150,7 +190,7 @@ function prepareCacheUpdate(newVersion, log) {
|
|
|
150
190
|
log.warn(`auto-update: failed to rewrite cache package.json: ${String(err)}`);
|
|
151
191
|
return null;
|
|
152
192
|
}
|
|
153
|
-
// Remove installed node_modules so
|
|
193
|
+
// Remove installed node_modules so the install re-fetches
|
|
154
194
|
try {
|
|
155
195
|
const pkgDir = join(ctx.installDir, "node_modules", "@eleboucher", "opencode-memini");
|
|
156
196
|
if (existsSync(pkgDir)) rmSync(pkgDir, { recursive: true, force: true });
|
|
@@ -158,39 +198,33 @@ function prepareCacheUpdate(newVersion, log) {
|
|
|
158
198
|
log.warn(`auto-update: failed to remove cached node_modules: ${String(err)}`);
|
|
159
199
|
return null;
|
|
160
200
|
}
|
|
161
|
-
// Clean
|
|
162
|
-
|
|
163
|
-
|
|
201
|
+
// Clean lockfiles: opencode's installer may write either package-lock.json
|
|
202
|
+
// (npm/arborist) or bun.lock depending on the bundler. Remove both if
|
|
203
|
+
// present; the install regenerates them.
|
|
204
|
+
for (const lockName of ["package-lock.json", "bun.lock"]) {
|
|
205
|
+
const lockPath = join(ctx.installDir, lockName);
|
|
206
|
+
if (!existsSync(lockPath)) continue;
|
|
164
207
|
try {
|
|
165
|
-
|
|
166
|
-
let modified = false;
|
|
167
|
-
if (lock.workspaces?.[""]?.dependencies?.[PACKAGE_NAME]) {
|
|
168
|
-
delete lock.workspaces[""].dependencies[PACKAGE_NAME];
|
|
169
|
-
modified = true;
|
|
170
|
-
}
|
|
171
|
-
if (lock.packages?.[PACKAGE_NAME]) {
|
|
172
|
-
delete lock.packages[PACKAGE_NAME];
|
|
173
|
-
modified = true;
|
|
174
|
-
}
|
|
175
|
-
if (modified) writeFileSync(lockPath, JSON.stringify(lock, null, 2));
|
|
208
|
+
rmSync(lockPath, { force: true });
|
|
176
209
|
} catch {
|
|
177
|
-
//
|
|
178
|
-
// will reconcile.
|
|
210
|
+
// A lock we can't remove isn't fatal — npm install reconciles it.
|
|
179
211
|
}
|
|
180
212
|
}
|
|
181
213
|
return ctx.installDir;
|
|
182
214
|
}
|
|
183
215
|
|
|
184
216
|
/**
|
|
185
|
-
*
|
|
186
|
-
* Returns true on success (exit code 0), false otherwise.
|
|
217
|
+
* runNpmInstall runs `npm install` in the given directory with a timeout.
|
|
218
|
+
* Returns true on success (exit code 0), false otherwise. opencode uses
|
|
219
|
+
* @npmcli/arborist under the hood, so `npm install` matches the lockfile
|
|
220
|
+
* format it produces; `bun install` would rewrite it. Exported for testing.
|
|
187
221
|
*/
|
|
188
|
-
function
|
|
222
|
+
export function runNpmInstall(installDir) {
|
|
189
223
|
try {
|
|
190
|
-
const result = spawnSync(
|
|
224
|
+
const result = spawnSync("npm", ["install"], {
|
|
191
225
|
cwd: installDir,
|
|
192
226
|
stdio: ["ignore", "pipe", "pipe"],
|
|
193
|
-
timeout:
|
|
227
|
+
timeout: INSTALL_TIMEOUT_MS,
|
|
194
228
|
});
|
|
195
229
|
return result.status === 0;
|
|
196
230
|
} catch {
|
|
@@ -337,6 +371,17 @@ export function resolveConfig(env, options, worktree) {
|
|
|
337
371
|
const homeRaw = o.home !== undefined ? o.home : e.MEMINI_HOME;
|
|
338
372
|
const home = homeRaw && String(homeRaw).trim() ? String(homeRaw).trim() : undefined;
|
|
339
373
|
|
|
374
|
+
// Windowed injection-cooldown knobs. 0 is MEANINGFUL (it disables that
|
|
375
|
+
// dimension; both 0 restores the legacy suppress-forever behavior), so a
|
|
376
|
+
// malformed option falls through to env/default rather than collapsing to 0.
|
|
377
|
+
const cooldownKnob = (optVal, envName, def) => {
|
|
378
|
+
if (optVal !== undefined) {
|
|
379
|
+
const n = Number(optVal);
|
|
380
|
+
if (Number.isFinite(n) && n >= 0) return n;
|
|
381
|
+
}
|
|
382
|
+
return intEnvFrom(e, envName, def);
|
|
383
|
+
};
|
|
384
|
+
|
|
340
385
|
return {
|
|
341
386
|
base_url: o.base_url || e.MEMINI_BASE_URL || DEFAULT_BASE_URL,
|
|
342
387
|
// namespace is already resolved above (explicit raw-trimmed, or the
|
|
@@ -363,6 +408,11 @@ export function resolveConfig(env, options, worktree) {
|
|
|
363
408
|
o.recall_min_score !== undefined
|
|
364
409
|
? Number(o.recall_min_score) || 0
|
|
365
410
|
: floatEnv("MEMINI_INJECT_RECALL_MIN_SCORE", 0),
|
|
411
|
+
// Windowed injection cooldown (option > env > server settings via
|
|
412
|
+
// effectiveConfig > built-in default, mirroring the server's own
|
|
413
|
+
// ClientSettings defaults: 30 min / 3 prompts). See injectedSuppressed.
|
|
414
|
+
inject_cooldown_ms: cooldownKnob(o.inject_cooldown_ms, "MEMINI_INJECT_COOLDOWN_MS", 1800000),
|
|
415
|
+
inject_cooldown_prompts: cooldownKnob(o.inject_cooldown_prompts, "MEMINI_INJECT_COOLDOWN_PROMPTS", 3),
|
|
366
416
|
recall_budget_ms,
|
|
367
417
|
timeout_ms: Number(o.timeout_ms || e.MEMINI_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
|
|
368
418
|
fallback_on_error:
|
|
@@ -379,6 +429,8 @@ export function resolveConfig(env, options, worktree) {
|
|
|
379
429
|
recall_limit: o.recall_limit !== undefined || isSet(e.MEMINI_RECALL_LIMIT),
|
|
380
430
|
recall_max_tokens: o.recall_max_tokens !== undefined || isSet(process.env.MEMINI_INJECT_RECALL_MAX_TOK),
|
|
381
431
|
recall_min_score: o.recall_min_score !== undefined || isSet(process.env.MEMINI_INJECT_RECALL_MIN_SCORE),
|
|
432
|
+
inject_cooldown_ms: o.inject_cooldown_ms !== undefined || isSet(e.MEMINI_INJECT_COOLDOWN_MS),
|
|
433
|
+
inject_cooldown_prompts: o.inject_cooldown_prompts !== undefined || isSet(e.MEMINI_INJECT_COOLDOWN_PROMPTS),
|
|
382
434
|
capture_user_max_chars: isSet(e.MEMINI_CAPTURE_USER_MAX_CHARS),
|
|
383
435
|
capture_assistant_max_chars: isSet(e.MEMINI_CAPTURE_ASSISTANT_MAX_CHARS),
|
|
384
436
|
},
|
|
@@ -427,6 +479,14 @@ export function effectiveConfig(cfg, hs) {
|
|
|
427
479
|
explicit.recall_min_score || !Number.isFinite(s.inject_recall_min_score)
|
|
428
480
|
? cfg.recall_min_score
|
|
429
481
|
: s.inject_recall_min_score,
|
|
482
|
+
inject_cooldown_ms:
|
|
483
|
+
explicit.inject_cooldown_ms || !Number.isFinite(s.inject_cooldown_ms)
|
|
484
|
+
? cfg.inject_cooldown_ms
|
|
485
|
+
: s.inject_cooldown_ms,
|
|
486
|
+
inject_cooldown_prompts:
|
|
487
|
+
explicit.inject_cooldown_prompts || !Number.isFinite(s.inject_cooldown_prompts)
|
|
488
|
+
? cfg.inject_cooldown_prompts
|
|
489
|
+
: s.inject_cooldown_prompts,
|
|
430
490
|
capture_user_max_chars:
|
|
431
491
|
explicit.capture_user_max_chars || !Number.isFinite(s.capture_user_max_chars)
|
|
432
492
|
? cfg.capture_user_max_chars
|
|
@@ -579,7 +639,7 @@ export function intEnvFrom(env, name, defaultValue) {
|
|
|
579
639
|
|
|
580
640
|
/**
|
|
581
641
|
* floatEnv parses a non-negative float env var and returns `default` when
|
|
582
|
-
* unset or malformed.
|
|
642
|
+
* unset or malformed.
|
|
583
643
|
*/
|
|
584
644
|
export function floatEnv(name, defaultValue) {
|
|
585
645
|
const raw = process.env[name];
|
|
@@ -642,6 +702,62 @@ export function fitByTokens(items, maxTokens) {
|
|
|
642
702
|
return { items: out, tokens: used, dropped };
|
|
643
703
|
}
|
|
644
704
|
|
|
705
|
+
// --- Injection-enforcement core (opencode copies) ---------------------------
|
|
706
|
+
//
|
|
707
|
+
// Ported from @memini/client's enforce core (packages/memini-client/src/
|
|
708
|
+
// enforce/identity.ts + seen.ts); semantics are pinned by the shared golden
|
|
709
|
+
// vectors (packages/memini-client/vectors/enforcement.json), replayed by
|
|
710
|
+
// memini.test.mjs. This plugin ships standalone (no build step), so these
|
|
711
|
+
// stay copies, not imports — the vector replay is what keeps them the same
|
|
712
|
+
// functions.
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* True for a well-formed server-minted content hash: 16 lowercase hex chars
|
|
716
|
+
* (the server's sha256(content||summary).slice(0,16) — the same recipe as the
|
|
717
|
+
* local fallback in injectedIdentity, so the two are interchangeable).
|
|
718
|
+
*/
|
|
719
|
+
export function isContentHash(s) {
|
|
720
|
+
return typeof s === "string" && /^[0-9a-f]{16}$/.test(s);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Content-identity hash for the injected-memory state: prefer the server-
|
|
725
|
+
* minted content_hash (read off the object itself or its nested `memory`),
|
|
726
|
+
* else hash the text a recall surface would render (content, falling back to
|
|
727
|
+
* summary) — so an in-place update still changes identity and re-injects even
|
|
728
|
+
* on servers without content_hash.
|
|
729
|
+
*/
|
|
730
|
+
export function injectedIdentity(m) {
|
|
731
|
+
const ch = m?.content_hash ?? m?.memory?.content_hash;
|
|
732
|
+
if (isContentHash(ch)) return ch;
|
|
733
|
+
const text = m?.content || m?.summary || "";
|
|
734
|
+
return createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* The shared windowed-cooldown predicate (enforce/seen.ts, core-exact):
|
|
739
|
+
*
|
|
740
|
+
* entry.h === "" → true sentinel/tool-read: forever
|
|
741
|
+
* identity && entry.h !== identity → false content changed: re-inject
|
|
742
|
+
* cooldownMs == 0 && prompts == 0 → true legacy forever-dedupe (#134)
|
|
743
|
+
* else suppressed within EITHER window; re-admit once BOTH lapse.
|
|
744
|
+
* counter == 0 leaves the prompt dimension inert (a host that never
|
|
745
|
+
* advances a counter degrades to time-only, not forever); negative deltas
|
|
746
|
+
* (clock skew / counter regression) clamp to suppressed.
|
|
747
|
+
*
|
|
748
|
+
* `identity` null is the id-only check (the exclude_ids view): the content-
|
|
749
|
+
* change bypass is skipped, so an entry is judged on the windows alone.
|
|
750
|
+
*/
|
|
751
|
+
export function injectedSuppressed(entry, identity, { now, counter, cooldownMs, cooldownPrompts }) {
|
|
752
|
+
if (!entry || typeof entry !== "object") return false;
|
|
753
|
+
if (entry.h === "") return true; // sentinel / tool-read: forever
|
|
754
|
+
if (identity && entry.h !== identity) return false; // content changed: re-inject
|
|
755
|
+
if (cooldownMs === 0 && cooldownPrompts === 0) return true; // legacy forever-dedupe
|
|
756
|
+
const promptDim = cooldownPrompts > 0 && counter > 0 && counter - entry.n < cooldownPrompts;
|
|
757
|
+
const timeDim = cooldownMs > 0 && now - entry.at < cooldownMs;
|
|
758
|
+
return promptDim || timeDim;
|
|
759
|
+
}
|
|
760
|
+
|
|
645
761
|
/**
|
|
646
762
|
* Truncate `s` to `max` CHARACTERS for a turn capture, marking the cut. `max <= 0`
|
|
647
763
|
* captures it whole. Mirrors @memini/client's truncateForCapture — this plugin
|
|
@@ -933,6 +1049,49 @@ export function createClient(cfg, log) {
|
|
|
933
1049
|
return { postJson, handshake, baseUrl };
|
|
934
1050
|
}
|
|
935
1051
|
|
|
1052
|
+
/**
|
|
1053
|
+
* POST /v1/search carrying the newer-than-server optional fields, retrying ONCE
|
|
1054
|
+
* with min_rank_score (and any exclude_ids) stripped when the first attempt
|
|
1055
|
+
* fails. An older server 400s an unknown field (returned as null under
|
|
1056
|
+
* fail-soft, or a throw with fallback_on_error off), so this degrades to the
|
|
1057
|
+
* client-side composite floor fallback instead of losing recall entirely.
|
|
1058
|
+
*
|
|
1059
|
+
* Returns {data, rankFloorStripped}. rankFloorStripped is true only when the
|
|
1060
|
+
* floor was sent and then dropped on the retry — the signal the caller uses to
|
|
1061
|
+
* decide whether to re-apply the composite floor client-side. A server that
|
|
1062
|
+
* accepted the floor is authoritative and its result set is NOT re-filtered.
|
|
1063
|
+
* exclude_ids rides only the first attempt and is stripped alongside the floor
|
|
1064
|
+
* on retry (matching _shared.mjs's combined strip); onExcludeIdsUnsupported,
|
|
1065
|
+
* when given, latches it off for the session. Unlike the Claude plugin's
|
|
1066
|
+
* pretool latch, the floor itself is never latched off: this integration tracks
|
|
1067
|
+
* no content hash, so a stateless per-call strip is the faithful port.
|
|
1068
|
+
*/
|
|
1069
|
+
export async function postSearchWithFloor(postJson, body, namespace, opts = {}) {
|
|
1070
|
+
const { excludeIds = [], onExcludeIdsUnsupported } = opts;
|
|
1071
|
+
const rankFloorInBody = body.min_rank_score !== undefined;
|
|
1072
|
+
const withExcludeIds = excludeIds.length > 0;
|
|
1073
|
+
if (!rankFloorInBody && !withExcludeIds) {
|
|
1074
|
+
return { data: await postJson("/v1/search", body, namespace), rankFloorStripped: false };
|
|
1075
|
+
}
|
|
1076
|
+
try {
|
|
1077
|
+
const first = await postJson(
|
|
1078
|
+
"/v1/search",
|
|
1079
|
+
withExcludeIds ? { ...body, exclude_ids: excludeIds } : body,
|
|
1080
|
+
namespace,
|
|
1081
|
+
);
|
|
1082
|
+
if (first !== null) return { data: first, rankFloorStripped: false };
|
|
1083
|
+
} catch {
|
|
1084
|
+
// With fallback_on_error=false the 400 arrives as a throw, not null.
|
|
1085
|
+
}
|
|
1086
|
+
const stripped = { ...body };
|
|
1087
|
+
delete stripped.min_rank_score;
|
|
1088
|
+
const retry = await postJson("/v1/search", stripped, namespace);
|
|
1089
|
+
if (retry !== null && withExcludeIds && typeof onExcludeIdsUnsupported === "function") {
|
|
1090
|
+
onExcludeIdsUnsupported();
|
|
1091
|
+
}
|
|
1092
|
+
return { data: retry, rankFloorStripped: rankFloorInBody };
|
|
1093
|
+
}
|
|
1094
|
+
|
|
936
1095
|
// extractLastTurn returns the latest user and assistant text from the message
|
|
937
1096
|
// list returned by client.session.messages ([{info, parts}, ...]), plus the id
|
|
938
1097
|
// of the assistant message (for dedup). Iterates in reverse to short-circuit.
|
|
@@ -1034,21 +1193,38 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1034
1193
|
// Memory ids each session has already been shown (mirrors the pi plugin):
|
|
1035
1194
|
// the injected synthetic part is persisted into the session, so re-injecting
|
|
1036
1195
|
// an unchanged match every turn stacks identical blocks in the context.
|
|
1037
|
-
//
|
|
1038
|
-
//
|
|
1039
|
-
|
|
1196
|
+
// Per session: the enforce core's { n, ids } shape — n is the prompt counter
|
|
1197
|
+
// (bumped once per chat.message) and ids maps memory id → { h, at, n }
|
|
1198
|
+
// (content identity, last-injected ms, counter at injection), judged by
|
|
1199
|
+
// injectedSuppressed against the inject_cooldown_ms / inject_cooldown_prompts
|
|
1200
|
+
// windows: suppressed within EITHER window, re-served once BOTH lapse, and
|
|
1201
|
+
// re-served immediately when the content changed (h mismatch). The inner cap
|
|
1202
|
+
// keeps a stable session — which never ages out of the outer map — from
|
|
1203
|
+
// growing its map for the process lifetime.
|
|
1204
|
+
const injectedBySession = new Map(); // session -> { n, ids: Map<id, {h, at, n}> }
|
|
1040
1205
|
const MAX_INJECTED_PER_SESSION = 200;
|
|
1041
|
-
const
|
|
1042
|
-
let
|
|
1043
|
-
if (!
|
|
1044
|
-
|
|
1045
|
-
boundedPut(injectedBySession, session,
|
|
1206
|
+
const sessionSeen = (session) => {
|
|
1207
|
+
let state = injectedBySession.get(session);
|
|
1208
|
+
if (!state) {
|
|
1209
|
+
state = { n: 0, ids: new Map() };
|
|
1210
|
+
boundedPut(injectedBySession, session, state);
|
|
1211
|
+
}
|
|
1212
|
+
return state;
|
|
1213
|
+
};
|
|
1214
|
+
const rememberInjected = (state, hits) => {
|
|
1215
|
+
const now = Date.now();
|
|
1216
|
+
for (const r of hits) {
|
|
1217
|
+
const id = r?.memory?.id;
|
|
1218
|
+
if (!id) continue;
|
|
1219
|
+
// delete+set refreshes both the stamp and the insertion order, so the
|
|
1220
|
+
// size cap below evicts the least-recently-shown id first.
|
|
1221
|
+
state.ids.delete(id);
|
|
1222
|
+
state.ids.set(id, { h: injectedIdentity(r?.memory), at: now, n: state.n });
|
|
1046
1223
|
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
const oldest = seen.values().next().value;
|
|
1224
|
+
while (state.ids.size > MAX_INJECTED_PER_SESSION) {
|
|
1225
|
+
const oldest = state.ids.keys().next().value;
|
|
1050
1226
|
if (oldest === undefined) break;
|
|
1051
|
-
|
|
1227
|
+
state.ids.delete(oldest);
|
|
1052
1228
|
}
|
|
1053
1229
|
};
|
|
1054
1230
|
// Recall results that arrived after the injection budget expired, keyed by
|
|
@@ -1060,23 +1236,18 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1060
1236
|
// 400 on the unknown field: when a request carrying it fails and the retry
|
|
1061
1237
|
// without it succeeds, stop sending it. The client-side filter stays.
|
|
1062
1238
|
let serverExcludeIds = true;
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
serverExcludeIds = false;
|
|
1076
|
-
log.warn("memini: server does not accept exclude_ids; using client-side dedupe only");
|
|
1077
|
-
}
|
|
1078
|
-
return retry;
|
|
1079
|
-
};
|
|
1239
|
+
// Delegate the search + one-shot compat retry to postSearchWithFloor, which
|
|
1240
|
+
// strips BOTH min_rank_score and exclude_ids on an older server's 400. Keep
|
|
1241
|
+
// the exclude_ids latch here (a closure the callback flips off); the floor is
|
|
1242
|
+
// not latched. Returns {data, rankFloorStripped}.
|
|
1243
|
+
const searchExcluding = (body, excludeIds, namespace) =>
|
|
1244
|
+
postSearchWithFloor(rest.postJson, body, namespace, {
|
|
1245
|
+
excludeIds: serverExcludeIds ? excludeIds : [],
|
|
1246
|
+
onExcludeIdsUnsupported: () => {
|
|
1247
|
+
serverExcludeIds = false;
|
|
1248
|
+
log.warn("memini: server does not accept exclude_ids; using client-side dedupe only");
|
|
1249
|
+
},
|
|
1250
|
+
});
|
|
1080
1251
|
|
|
1081
1252
|
// opencode runs chat.message via an unguarded Effect.promise (a throw aborts the
|
|
1082
1253
|
// turn) and dispatches event hooks fire-and-forget, so a hook must never reject:
|
|
@@ -1148,18 +1319,40 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1148
1319
|
const sibling = output.parts.find((p) => p && p.type === "text") || {};
|
|
1149
1320
|
const sessionID = input.sessionID || sibling.sessionID;
|
|
1150
1321
|
const messageID = input.messageID || sibling.messageID;
|
|
1322
|
+
// One chat.message == one user prompt: bump the session's prompt counter
|
|
1323
|
+
// before any recall — the cooldown's prompt dimension measures prompts-
|
|
1324
|
+
// since-injection even on turns that inject nothing.
|
|
1325
|
+
const seen = sessionID ? sessionSeen(sessionID) : null;
|
|
1326
|
+
if (seen) seen.n += 1;
|
|
1327
|
+
const cooldownOpts = () => ({
|
|
1328
|
+
now: Date.now(),
|
|
1329
|
+
counter: seen ? seen.n : 0,
|
|
1330
|
+
cooldownMs: live.inject_cooldown_ms,
|
|
1331
|
+
cooldownPrompts: live.inject_cooldown_prompts,
|
|
1332
|
+
});
|
|
1151
1333
|
const body = { query, limit: live.recall_limit };
|
|
1152
1334
|
// Exclude this session's own captured turns: they're still in the live
|
|
1153
1335
|
// context, so recalling them just echoes the conversation back a turn
|
|
1154
1336
|
// behind. Captures from other (past) sessions are still recalled.
|
|
1155
1337
|
if (sessionID) body.exclude_metadata = { session_id: sessionID };
|
|
1156
|
-
//
|
|
1157
|
-
//
|
|
1158
|
-
// is
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1338
|
+
// inject_recall_min_score floors the FINAL composite score server-side
|
|
1339
|
+
// via min_rank_score (not the fused-scale min_score), matching the Claude
|
|
1340
|
+
// Code plugin. A knob >= 1 is out of the server's range, so it clamps to a
|
|
1341
|
+
// client-only floor rather than 400ing every search.
|
|
1342
|
+
const rankFloorInRange = live.recall_min_score > 0 && live.recall_min_score < 1;
|
|
1343
|
+
if (rankFloorInRange) body.min_rank_score = live.recall_min_score;
|
|
1344
|
+
// Ids still IN COOLDOWN go along as exclude_ids so a suppressed hit
|
|
1345
|
+
// doesn't waste a recall_limit slot (id-only judgment — the wire cannot
|
|
1346
|
+
// know what content the server would serve); a LAPSED id is
|
|
1347
|
+
// intentionally absent so the server may re-serve it.
|
|
1348
|
+
const excludeIds = seen
|
|
1349
|
+
? (() => {
|
|
1350
|
+
const opts = cooldownOpts();
|
|
1351
|
+
return [...seen.ids.entries()]
|
|
1352
|
+
.filter(([, e]) => injectedSuppressed(e, null, opts))
|
|
1353
|
+
.map(([id]) => id);
|
|
1354
|
+
})()
|
|
1355
|
+
: [];
|
|
1163
1356
|
// opencode awaits this hook before the model sees the message, so the
|
|
1164
1357
|
// turn only waits live.recall_budget_ms for the search; the fetch itself keeps
|
|
1165
1358
|
// cfg.timeout_ms as its bound and runs on in the background. A slow or
|
|
@@ -1187,7 +1380,7 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1187
1380
|
);
|
|
1188
1381
|
if (sessionID) {
|
|
1189
1382
|
settled.then((late) => {
|
|
1190
|
-
const hits = Array.isArray(late && late.results) ? late.results : [];
|
|
1383
|
+
const hits = Array.isArray(late && late.data && late.data.results) ? late.data.results : [];
|
|
1191
1384
|
if (hits.length) boundedPut(pendingBySession, sessionID, hits);
|
|
1192
1385
|
});
|
|
1193
1386
|
}
|
|
@@ -1196,12 +1389,14 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1196
1389
|
} else {
|
|
1197
1390
|
result = await settled;
|
|
1198
1391
|
}
|
|
1199
|
-
|
|
1200
|
-
//
|
|
1201
|
-
//
|
|
1202
|
-
//
|
|
1203
|
-
|
|
1204
|
-
|
|
1392
|
+
const searchData = result && result.data ? result.data : null;
|
|
1393
|
+
// Client composite floor is a fallback ONLY: it runs when the knob was
|
|
1394
|
+
// clamped to client-only (>= 1) or the retry stripped min_rank_score for
|
|
1395
|
+
// an old server. A server that enforced the floor is authoritative and
|
|
1396
|
+
// its result set is not re-filtered here.
|
|
1397
|
+
const serverEnforcedFloor = rankFloorInRange && !(result && result.rankFloorStripped);
|
|
1398
|
+
const floor = live.recall_min_score > 0 && !serverEnforcedFloor ? live.recall_min_score : 0;
|
|
1399
|
+
let rawHits = Array.isArray(searchData && searchData.results) ? searchData.results : [];
|
|
1205
1400
|
// Merge in results that arrived late on a previous turn: fresh hits
|
|
1206
1401
|
// first (they answer the current query), deduped by memory id.
|
|
1207
1402
|
if (sessionID) {
|
|
@@ -1212,11 +1407,17 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1212
1407
|
rawHits = rawHits.concat(pending.filter((r) => !fresh.has(r?.memory?.id)));
|
|
1213
1408
|
}
|
|
1214
1409
|
}
|
|
1215
|
-
// Suppress memories this session
|
|
1216
|
-
//
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1410
|
+
// Suppress memories this session was already shown and that are still in
|
|
1411
|
+
// cooldown — judged PER HIT against its content identity, so an
|
|
1412
|
+
// in-window unchanged hit is dropped, a lapsed one passes through and
|
|
1413
|
+
// re-serves, and an UPDATED one (h mismatch) bypasses the window and
|
|
1414
|
+
// re-injects immediately.
|
|
1415
|
+
if (seen && seen.ids.size) {
|
|
1416
|
+
const opts = cooldownOpts();
|
|
1417
|
+
rawHits = rawHits.filter((r) => {
|
|
1418
|
+
const entry = seen.ids.get(r?.memory?.id);
|
|
1419
|
+
return !(entry && injectedSuppressed(entry, injectedIdentity(r?.memory), opts));
|
|
1420
|
+
});
|
|
1220
1421
|
}
|
|
1221
1422
|
const filtered = floor > 0
|
|
1222
1423
|
? rawHits.filter((r) => (typeof r?.score === "number" ? r.score : 0) >= floor)
|
|
@@ -1229,17 +1430,11 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1229
1430
|
// behaviour matches the prior "no cap" code path for existing installs.
|
|
1230
1431
|
const fit = fitByTokens(hits, live.recall_max_tokens);
|
|
1231
1432
|
if (fit.items.length === 0) return;
|
|
1232
|
-
if (
|
|
1433
|
+
if (seen) {
|
|
1233
1434
|
// Mark only the slice formatResults actually renders: with carryover
|
|
1234
1435
|
// merged in, `filtered` can exceed recall_limit, and marking unshown
|
|
1235
|
-
// hits as seen would suppress
|
|
1236
|
-
rememberInjected(
|
|
1237
|
-
sessionID,
|
|
1238
|
-
filtered
|
|
1239
|
-
.slice(0, live.recall_limit || DEFAULT_RECALL_LIMIT)
|
|
1240
|
-
.map((r) => r?.memory?.id)
|
|
1241
|
-
.filter(Boolean),
|
|
1242
|
-
);
|
|
1436
|
+
// hits as seen would suppress what was never injected.
|
|
1437
|
+
rememberInjected(seen, filtered.slice(0, live.recall_limit || DEFAULT_RECALL_LIMIT));
|
|
1243
1438
|
}
|
|
1244
1439
|
const lines = [
|
|
1245
1440
|
`Relevant long-term memory from memini (background context — prefer ` +
|
|
@@ -1249,8 +1444,8 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1249
1444
|
// /v1/search sets `degraded: "keyword_only"` (plus a `note`) when the
|
|
1250
1445
|
// query embed was unavailable and it fell back to keyword-only matching;
|
|
1251
1446
|
// both are already on `result`, so surfacing them is a one-line addition.
|
|
1252
|
-
if (
|
|
1253
|
-
lines.push(`[memini: ${
|
|
1447
|
+
if (searchData && searchData.degraded) {
|
|
1448
|
+
lines.push(`[memini: ${searchData.note || "semantic search unavailable — results are keyword-only and may be incomplete"}]`);
|
|
1254
1449
|
}
|
|
1255
1450
|
if (fit.dropped > 0) lines.push(`[... ${fit.dropped} item(s) truncated by token budget]`);
|
|
1256
1451
|
// opencode's part schema requires ids to start with `prt`.
|
|
@@ -1291,11 +1486,11 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
1291
1486
|
log.warn(`auto-update: updating ${CLIENT_VERSION} → ${latest}`);
|
|
1292
1487
|
const installDir = prepareCacheUpdate(latest, log);
|
|
1293
1488
|
if (!installDir) return;
|
|
1294
|
-
const ok =
|
|
1489
|
+
const ok = runNpmInstall(installDir);
|
|
1295
1490
|
if (ok) {
|
|
1296
1491
|
log.warn(`auto-update: installed v${latest} — restart opencode to apply`);
|
|
1297
1492
|
} else {
|
|
1298
|
-
log.warn(`auto-update:
|
|
1493
|
+
log.warn(`auto-update: npm install failed; will retry next session`);
|
|
1299
1494
|
}
|
|
1300
1495
|
} catch (err) {
|
|
1301
1496
|
log.warn(`auto-update: check failed: ${String(err)}`);
|