@eleboucher/opencode-memini 0.6.8 → 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 +51 -2
- package/memini.js +424 -20
- 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
|
-
| — | `MEMINI_INJECT_LABELS` | — | comma-separated label toggles for each bullet: `tier`, `confidence`, `age`
|
|
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
|
|
@@ -71,6 +82,44 @@ stays correct even against a remote memini (the HTTP MCP wire below can't — a
|
|
|
71
82
|
remote server has no access to your cwd). Set it to share one memory pool with
|
|
72
83
|
your other agents.
|
|
73
84
|
|
|
85
|
+
If `$XDG_CONFIG_HOME/memini/config.json` (default `~/.config/memini/config.json`)
|
|
86
|
+
exists, the unset namespace is instead rendered from its `template` (default
|
|
87
|
+
`{tenant}/{project}/{agent}`): `{tenant}` from the `tenantRoots` entry whose
|
|
88
|
+
`path` contains the cwd, `{project}` from the git repo, `{agent}` from
|
|
89
|
+
`MEMINI_AGENT`; unresolved segments are dropped. The Hermes and Pi integrations
|
|
90
|
+
share this resolver, so one config file scopes them all identically.
|
|
91
|
+
|
|
92
|
+
### Namespace resolution
|
|
93
|
+
|
|
94
|
+
In full, in order: a **per-project override** in
|
|
95
|
+
`$XDG_CONFIG_HOME/memini/overrides.json` > the `namespace` option /
|
|
96
|
+
`MEMINI_NAMESPACE` > the config template above > the git worktree basename.
|
|
97
|
+
|
|
98
|
+
The override wins over both deliberately. A globally exported `MEMINI_NAMESPACE`
|
|
99
|
+
— a shell rc, or a fish universal variable — pins every repo on the machine to
|
|
100
|
+
one namespace (as does a `namespace` option in a global
|
|
101
|
+
`~/.config/opencode/opencode.json`), and if either won, setting an override would
|
|
102
|
+
silently do nothing on exactly the machines that need one. The file is keyed by
|
|
103
|
+
git toplevel, so an override set at the top of a repo applies from any
|
|
104
|
+
subdirectory; it is the same file the Claude Code plugin writes and `memini
|
|
105
|
+
doctor` reads; and a malformed one degrades to automatic resolution rather than
|
|
106
|
+
breaking a turn.
|
|
107
|
+
|
|
108
|
+
### The `memini_status` tool
|
|
109
|
+
|
|
110
|
+
The plugin registers one tool, `memini_status`: read-only, no arguments. It
|
|
111
|
+
reports the namespace in force and where it came from, what it would be _without_
|
|
112
|
+
the override and without the env pin, the connection settings (the API key
|
|
113
|
+
fingerprinted, never printed), and warnings — a global `MEMINI_NAMESPACE` pin, a
|
|
114
|
+
bearer token crossing plaintext HTTP, an override you forgot you set.
|
|
115
|
+
|
|
116
|
+
There is no `/memini:status` slash command: opencode's plugin contract registers
|
|
117
|
+
tools, not commands, and this plugin does not invent an API it does not have.
|
|
118
|
+
Setting or clearing an override is likewise not exposed here — declaring a tool
|
|
119
|
+
argument requires a zod schema, and this plugin ships dependency-free — so use
|
|
120
|
+
`/memini:namespace` from the Claude Code plugin, or edit `overrides.json`
|
|
121
|
+
directly; all harnesses read the same file.
|
|
122
|
+
|
|
74
123
|
### Tests
|
|
75
124
|
|
|
76
125
|
```bash
|
package/memini.js
CHANGED
|
@@ -17,15 +17,19 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { execSync } from "node:child_process";
|
|
20
|
-
import { readFileSync } from "node:fs";
|
|
20
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
21
21
|
import { join, resolve, sep } from "node:path";
|
|
22
22
|
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;
|
|
@@ -136,25 +140,112 @@ function resolveConfigNamespace(cwd) {
|
|
|
136
140
|
return ns || null;
|
|
137
141
|
}
|
|
138
142
|
|
|
143
|
+
// --- Namespace override ---------------------------------------------------
|
|
144
|
+
//
|
|
145
|
+
// $XDG_CONFIG_HOME/memini/overrides.json (else ~/.config/memini/overrides.json)
|
|
146
|
+
// holds the per-project namespace a user set deliberately. It is a shared
|
|
147
|
+
// contract: the Claude Code plugin writes it, `memini doctor` reads it, and
|
|
148
|
+
// every harness must agree about which namespace is in force — an override that
|
|
149
|
+
// only some of them honor is worse than none at all.
|
|
150
|
+
//
|
|
151
|
+
// This plugin ships standalone and dependency-free from npm, so it cannot
|
|
152
|
+
// import @memini/client; the reader below is the whole contract (a JSON file
|
|
153
|
+
// plus a `git rev-parse`) and stays a copy, the same trade already made for
|
|
154
|
+
// createPlaintextBearerAuthGuard and the injection-budget helpers. Keep the
|
|
155
|
+
// contract identical when both sides change.
|
|
156
|
+
|
|
157
|
+
// overridesPath resolves the overrides file. Exported for testing / status.
|
|
158
|
+
export function overridesPath(env = process.env) {
|
|
159
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
160
|
+
const base = xdg && String(xdg).trim() ? String(xdg) : join(homedir(), ".config");
|
|
161
|
+
return join(base, "memini", "overrides.json");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// overrideKey is the key an override is stored under: the git toplevel when
|
|
165
|
+
// there is one, else the resolved directory. Keying on the repo root rather
|
|
166
|
+
// than the raw cwd means an override set at the top of a repo still applies
|
|
167
|
+
// when the agent is working three directories down.
|
|
168
|
+
export function overrideKey(cwd) {
|
|
169
|
+
const dir = cwd && String(cwd).trim() ? String(cwd) : process.cwd();
|
|
170
|
+
try {
|
|
171
|
+
const top = execSync("git rev-parse --show-toplevel", {
|
|
172
|
+
cwd: dir,
|
|
173
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
174
|
+
timeout: 500,
|
|
175
|
+
})
|
|
176
|
+
.toString()
|
|
177
|
+
.trim();
|
|
178
|
+
if (top) return resolve(top);
|
|
179
|
+
} catch {
|
|
180
|
+
// not a repo, or no git — fall through to the plain path
|
|
181
|
+
}
|
|
182
|
+
return resolve(dir);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// readOverride returns the override in effect for `cwd`, or null.
|
|
186
|
+
//
|
|
187
|
+
// The file is read BEFORE the key is computed, because the key costs a `git
|
|
188
|
+
// rev-parse` and this runs on every chat.message: nobody should pay for a git
|
|
189
|
+
// call to discover they have no overrides at all, which is the common case.
|
|
190
|
+
// Any error — missing file, hand-edited JSON, wrong shape — degrades to "no
|
|
191
|
+
// override" rather than throwing into opencode. Exported for testing.
|
|
192
|
+
export function readOverride(cwd, path) {
|
|
193
|
+
let file;
|
|
194
|
+
try {
|
|
195
|
+
file = JSON.parse(readFileSync(path || overridesPath(), "utf8"));
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
const overrides = file && typeof file === "object" ? file.overrides : null;
|
|
200
|
+
if (!overrides || typeof overrides !== "object" || Object.keys(overrides).length === 0) return null;
|
|
201
|
+
const entry = overrides[overrideKey(cwd)];
|
|
202
|
+
if (!entry || typeof entry !== "object") return null;
|
|
203
|
+
const ns = typeof entry.namespace === "string" ? entry.namespace.trim() : "";
|
|
204
|
+
if (!ns) return null;
|
|
205
|
+
return { namespace: ns, setAt: typeof entry.setAt === "string" ? entry.setAt : "" };
|
|
206
|
+
}
|
|
207
|
+
|
|
139
208
|
// resolveConfig merges env vars with the options object (options win), filling
|
|
140
209
|
// in defaults. Exported for testing.
|
|
141
|
-
|
|
210
|
+
//
|
|
211
|
+
// Namespace precedence: project override > namespace option / MEMINI_NAMESPACE >
|
|
212
|
+
// config template > git worktree > default. The override sits ABOVE the env var
|
|
213
|
+
// (and above the inline option) on purpose: a globally exported MEMINI_NAMESPACE
|
|
214
|
+
// — a shell rc, or a fish universal variable — pins every repo on the machine to
|
|
215
|
+
// one namespace, and if the env won, setting an override would silently do
|
|
216
|
+
// nothing on exactly the machines that need one. The same argument applies to a
|
|
217
|
+
// `namespace` option in ~/.config/opencode/opencode.json, which pins every
|
|
218
|
+
// project the same way; and `memini doctor` reports the override as in force
|
|
219
|
+
// regardless, so anything else would make the two disagree.
|
|
220
|
+
//
|
|
221
|
+
// opts.ignoreOverride skips the override so the status tool can ask what the
|
|
222
|
+
// namespace would be without it — it lives in a file, so no amount of doctoring
|
|
223
|
+
// `env` would strip it. opts.overridesPath is for tests.
|
|
224
|
+
export function resolveConfig(env, options, worktree, opts = {}) {
|
|
142
225
|
const e = env || {};
|
|
143
226
|
const o = options || {};
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
//
|
|
227
|
+
const dir = worktree || process.cwd();
|
|
228
|
+
const override = opts.ignoreOverride ? null : readOverride(dir, opts.overridesPath);
|
|
229
|
+
// An explicit namespace (option or MEMINI_NAMESPACE env) is used raw-trimmed:
|
|
230
|
+
// the server validates the header, and flattening "/" here would split a
|
|
231
|
+
// tenant path like work/memini from the other integrations. The override is
|
|
232
|
+
// written through @memini/client, which validates it, so it is used as-is too.
|
|
147
233
|
const explicit = o.namespace || e.MEMINI_NAMESPACE;
|
|
148
234
|
let namespace;
|
|
149
|
-
|
|
235
|
+
let namespace_source;
|
|
236
|
+
if (override) {
|
|
237
|
+
namespace = override.namespace;
|
|
238
|
+
namespace_source = "override";
|
|
239
|
+
} else if (explicit && String(explicit).trim()) {
|
|
150
240
|
namespace = String(explicit).trim();
|
|
241
|
+
namespace_source = o.namespace ? "option" : "env";
|
|
151
242
|
} else {
|
|
152
243
|
// Config present -> render the config template (tenant segments already
|
|
153
244
|
// sanitized, "/" preserved); otherwise fall back to the legacy cwd chain.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
245
|
+
const fromConfig = resolveConfigNamespace(dir);
|
|
246
|
+
const fromWorktree = deriveNamespace(worktree);
|
|
247
|
+
namespace = fromConfig || fromWorktree || DEFAULT_NAMESPACE;
|
|
248
|
+
namespace_source = fromConfig ? "config" : fromWorktree ? "worktree" : "default";
|
|
158
249
|
}
|
|
159
250
|
// Number.isFinite guard: malformed env / option falls through to the next
|
|
160
251
|
// source instead of NaN flowing into the request body.
|
|
@@ -165,6 +256,17 @@ export function resolveConfig(env, options, worktree) {
|
|
|
165
256
|
}
|
|
166
257
|
return DEFAULT_RECALL_LIMIT;
|
|
167
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
|
+
})();
|
|
168
270
|
// home: the caller's personal namespace, sent as X-Memini-Home. Same
|
|
169
271
|
// env-only resolution style as namespace's MEMINI_NAMESPACE (option wins
|
|
170
272
|
// over env), but no config-file/derivation fallback — unset means "no home
|
|
@@ -177,6 +279,11 @@ export function resolveConfig(env, options, worktree) {
|
|
|
177
279
|
// per-segment-sanitized config/derived value); re-sanitizing here would
|
|
178
280
|
// flatten tenant "/" separators.
|
|
179
281
|
namespace: namespace || DEFAULT_NAMESPACE,
|
|
282
|
+
// Where the namespace came from, and the override itself when one is in
|
|
283
|
+
// force. Carried on the config so the status tool reports what the plugin
|
|
284
|
+
// actually does rather than a second, idealized resolution of its own.
|
|
285
|
+
namespace_source,
|
|
286
|
+
override,
|
|
180
287
|
home,
|
|
181
288
|
recall: o.recall !== undefined ? o.recall !== false : envBool(e.MEMINI_RECALL, true),
|
|
182
289
|
capture: o.capture !== undefined ? o.capture !== false : envBool(e.MEMINI_CAPTURE, true),
|
|
@@ -189,6 +296,7 @@ export function resolveConfig(env, options, worktree) {
|
|
|
189
296
|
o.recall_min_score !== undefined
|
|
190
297
|
? Number(o.recall_min_score) || 0
|
|
191
298
|
: floatEnv("MEMINI_INJECT_RECALL_MIN_SCORE", 0),
|
|
299
|
+
recall_budget_ms,
|
|
192
300
|
timeout_ms: Number(o.timeout_ms || e.MEMINI_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
|
|
193
301
|
fallback_on_error:
|
|
194
302
|
o.fallback_on_error !== undefined
|
|
@@ -384,6 +492,190 @@ export function truncate(value, max) {
|
|
|
384
492
|
return value;
|
|
385
493
|
}
|
|
386
494
|
|
|
495
|
+
// --- Status --------------------------------------------------------------
|
|
496
|
+
//
|
|
497
|
+
// "What is this plugin actually doing right now?" A list of values would not
|
|
498
|
+
// answer that. The case worth catching is MEMINI_NAMESPACE exported globally (a
|
|
499
|
+
// shell rc, or a fish universal variable), set once and forgotten, quietly
|
|
500
|
+
// collapsing every repo on the machine into one namespace: the value looks
|
|
501
|
+
// fine, only its provenance gives it away. So the namespace is resolved three
|
|
502
|
+
// times against progressively stripped inputs — as-is, without the override,
|
|
503
|
+
// and without the override AND the env/option pin — and all three are reported.
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Render a secret as a recognizable-but-useless fingerprint: enough to tell two
|
|
507
|
+
* tokens apart, not enough to use. Short values are elided entirely rather than
|
|
508
|
+
* half-revealed. Mirrors packages/memini-client's redactValue. Exported for
|
|
509
|
+
* testing.
|
|
510
|
+
*/
|
|
511
|
+
export function redactSecret(value) {
|
|
512
|
+
if (!value) return "";
|
|
513
|
+
return value.length <= 12 ? "***" : `${value.slice(0, 3)}…${value.slice(-4)}`;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Build the effective-settings report: the three namespace resolutions, the
|
|
518
|
+
* knobs with their provenance (secrets redacted), the paths, and the warnings.
|
|
519
|
+
* Exported for testing.
|
|
520
|
+
*/
|
|
521
|
+
export function describeSettings(env, options, worktree) {
|
|
522
|
+
const e = env || {};
|
|
523
|
+
const o = options || {};
|
|
524
|
+
const dir = worktree || process.cwd();
|
|
525
|
+
|
|
526
|
+
const cfg = resolveConfig(e, o, worktree);
|
|
527
|
+
// Both counterfactuals must ignore the override explicitly: it lives in a
|
|
528
|
+
// file, so a resolution handed a stripped env would hand it straight back —
|
|
529
|
+
// and these are the two lines that exist to see past it.
|
|
530
|
+
const withoutOverride = resolveConfig(e, o, worktree, { ignoreOverride: true });
|
|
531
|
+
const envSansPin = { ...e };
|
|
532
|
+
delete envSansPin.MEMINI_NAMESPACE;
|
|
533
|
+
const derived = resolveConfig(
|
|
534
|
+
envSansPin,
|
|
535
|
+
{ ...o, namespace: undefined },
|
|
536
|
+
worktree,
|
|
537
|
+
{ ignoreOverride: true },
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
const secret = e.MEMINI_API_KEY || e.MEMINI_TOKEN || "";
|
|
541
|
+
const warnings = [];
|
|
542
|
+
|
|
543
|
+
if (cfg.override) {
|
|
544
|
+
warnings.push({
|
|
545
|
+
level: "note",
|
|
546
|
+
code: "override-active",
|
|
547
|
+
message:
|
|
548
|
+
`namespace is overridden to "${cfg.override.namespace}" for this project` +
|
|
549
|
+
(cfg.override.setAt ? ` (set ${cfg.override.setAt})` : "") +
|
|
550
|
+
`; without it this project would use "${withoutOverride.namespace}".`,
|
|
551
|
+
fix: `Remove the entry for ${overrideKey(dir)} from ${overridesPath(e)} to return to automatic resolution.`,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// The finding this whole report exists for.
|
|
556
|
+
const pin = String(e.MEMINI_NAMESPACE || "").trim();
|
|
557
|
+
if (pin && !cfg.override && derived.namespace && derived.namespace !== pin) {
|
|
558
|
+
warnings.push({
|
|
559
|
+
level: "warn",
|
|
560
|
+
code: "global-namespace-pin",
|
|
561
|
+
message:
|
|
562
|
+
`MEMINI_NAMESPACE is set to "${pin}", which pins EVERY project on this machine to one ` +
|
|
563
|
+
`namespace. This project would otherwise resolve to "${derived.namespace}". If it is ` +
|
|
564
|
+
`exported from a shell rc (or a fish universal variable), every repo you work in is ` +
|
|
565
|
+
`sharing one memory pool.`,
|
|
566
|
+
fix: "Unset MEMINI_NAMESPACE and let each repo resolve on its own, or set a per-project override instead.",
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (usesPlaintextBearerAuth(cfg.base_url, secret)) {
|
|
571
|
+
warnings.push({
|
|
572
|
+
level: "warn",
|
|
573
|
+
code: "plaintext-bearer",
|
|
574
|
+
message: plaintextBearerAuthMessage(cfg.base_url),
|
|
575
|
+
fix: "Use HTTPS, or tunnel over SSH. Set MEMINI_REQUIRE_HTTPS=1 to make this an error.",
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (!cfg.home) {
|
|
580
|
+
warnings.push({
|
|
581
|
+
level: "note",
|
|
582
|
+
code: "home-unset",
|
|
583
|
+
message: "MEMINI_HOME is unset: no personal leg merges into recall.",
|
|
584
|
+
fix: "Export MEMINI_HOME=personal/<you>.",
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return {
|
|
589
|
+
project: overrideKey(dir),
|
|
590
|
+
worktree: dir,
|
|
591
|
+
namespace: {
|
|
592
|
+
effective: cfg.namespace,
|
|
593
|
+
source: cfg.namespace_source,
|
|
594
|
+
override: cfg.override,
|
|
595
|
+
withoutOverride,
|
|
596
|
+
derived,
|
|
597
|
+
home: cfg.home,
|
|
598
|
+
},
|
|
599
|
+
connection: {
|
|
600
|
+
base_url: cfg.base_url,
|
|
601
|
+
api_key: secret ? redactSecret(secret) : "",
|
|
602
|
+
require_https: e.MEMINI_REQUIRE_HTTPS === "1",
|
|
603
|
+
timeout_ms: cfg.timeout_ms,
|
|
604
|
+
},
|
|
605
|
+
memory: {
|
|
606
|
+
recall: cfg.recall,
|
|
607
|
+
capture: cfg.capture,
|
|
608
|
+
recall_limit: cfg.recall_limit,
|
|
609
|
+
recall_max_tokens: cfg.recall_max_tokens,
|
|
610
|
+
recall_min_score: cfg.recall_min_score,
|
|
611
|
+
recall_budget_ms: cfg.recall_budget_ms,
|
|
612
|
+
labels: [...labelsEnv()],
|
|
613
|
+
},
|
|
614
|
+
paths: { overrides: overridesPath(e) },
|
|
615
|
+
warnings,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const padTo = (s, n) => String(s).padEnd(n);
|
|
620
|
+
|
|
621
|
+
/** Render describeSettings() as the text block the tool hands back. */
|
|
622
|
+
export function renderStatus(report) {
|
|
623
|
+
const { namespace: ns, connection, memory, paths } = report;
|
|
624
|
+
const L = [];
|
|
625
|
+
|
|
626
|
+
L.push("memini — effective settings (opencode)");
|
|
627
|
+
L.push(`project: ${report.project}`);
|
|
628
|
+
L.push("");
|
|
629
|
+
|
|
630
|
+
L.push("NAMESPACE");
|
|
631
|
+
L.push(` ${padTo("effective", 26)} ${padTo(ns.effective, 30)} <- ${ns.source}`);
|
|
632
|
+
if (ns.override) {
|
|
633
|
+
L.push(
|
|
634
|
+
` ${padTo("without the override", 26)} ${padTo(ns.withoutOverride.namespace, 30)} <- ${ns.withoutOverride.source}`,
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
if (ns.derived.namespace !== ns.effective) {
|
|
638
|
+
L.push(
|
|
639
|
+
` ${padTo("git/cwd would give", 26)} ${padTo(ns.derived.namespace, 30)} <- ${ns.derived.source}`,
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
L.push(` ${padTo("home (personal)", 26)} ${ns.home || "(unset)"}`);
|
|
643
|
+
L.push("");
|
|
644
|
+
|
|
645
|
+
L.push("CONNECTION");
|
|
646
|
+
L.push(` ${padTo("base_url", 26)} ${connection.base_url}`);
|
|
647
|
+
L.push(` ${padTo("api_key", 26)} ${connection.api_key || "(unset)"}`);
|
|
648
|
+
L.push(` ${padTo("require_https", 26)} ${connection.require_https ? "1" : "0"}`);
|
|
649
|
+
L.push(` ${padTo("timeout_ms", 26)} ${connection.timeout_ms}`);
|
|
650
|
+
L.push("");
|
|
651
|
+
|
|
652
|
+
L.push("MEMORY");
|
|
653
|
+
L.push(` ${padTo("recall", 26)} ${memory.recall ? "on" : "off"}`);
|
|
654
|
+
L.push(` ${padTo("capture", 26)} ${memory.capture ? "on" : "off"}`);
|
|
655
|
+
L.push(` ${padTo("recall_limit", 26)} ${memory.recall_limit}`);
|
|
656
|
+
L.push(` ${padTo("recall_max_tokens", 26)} ${memory.recall_max_tokens || "uncapped"}`);
|
|
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}`);
|
|
659
|
+
L.push(` ${padTo("labels", 26)} ${memory.labels.length ? memory.labels.join(",") : "(none)"}`);
|
|
660
|
+
L.push("");
|
|
661
|
+
|
|
662
|
+
L.push("PATHS");
|
|
663
|
+
L.push(` ${padTo("overrides", 26)} ${paths.overrides}${existsSync(paths.overrides) ? "" : " (absent)"}`);
|
|
664
|
+
L.push("");
|
|
665
|
+
|
|
666
|
+
if (report.warnings.length) {
|
|
667
|
+
L.push("WARNINGS");
|
|
668
|
+
for (const w of report.warnings) {
|
|
669
|
+
L.push(` [${w.level === "warn" ? "!" : "i"}] ${w.code}: ${w.message}`);
|
|
670
|
+
if (w.fix) L.push(` fix: ${w.fix}`);
|
|
671
|
+
}
|
|
672
|
+
} else {
|
|
673
|
+
L.push("No problems detected.");
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return L.join("\n");
|
|
677
|
+
}
|
|
678
|
+
|
|
387
679
|
function createClient(cfg, log) {
|
|
388
680
|
const baseUrl = String(cfg.base_url).replace(/\/+$/, "");
|
|
389
681
|
const secret = process.env.MEMINI_API_KEY || process.env.MEMINI_TOKEN;
|
|
@@ -476,28 +768,46 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
476
768
|
|
|
477
769
|
const cfg = resolveConfig(process.env, options, worktree || directory);
|
|
478
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
|
+
}
|
|
479
781
|
// Assistant message ids already captured, so repeated session.idle events for
|
|
480
782
|
// the same turn don't write duplicates.
|
|
481
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
|
+
};
|
|
482
795
|
// Memory ids each session has already been shown (mirrors the pi plugin):
|
|
483
796
|
// the injected synthetic part is persisted into the session, so re-injecting
|
|
484
797
|
// an unchanged match every turn stacks identical blocks in the context.
|
|
485
|
-
// Bounded so long-lived hosts can't grow the map without limit.
|
|
486
798
|
const injectedBySession = new Map();
|
|
487
|
-
const MAX_TRACKED_SESSIONS = 200;
|
|
488
799
|
const rememberInjected = (session, ids) => {
|
|
489
800
|
let seen = injectedBySession.get(session);
|
|
490
801
|
if (!seen) {
|
|
491
802
|
seen = new Set();
|
|
492
|
-
injectedBySession
|
|
493
|
-
while (injectedBySession.size > MAX_TRACKED_SESSIONS) {
|
|
494
|
-
const oldest = injectedBySession.keys().next().value;
|
|
495
|
-
if (oldest === undefined) break;
|
|
496
|
-
injectedBySession.delete(oldest);
|
|
497
|
-
}
|
|
803
|
+
boundedPut(injectedBySession, session, seen);
|
|
498
804
|
}
|
|
499
805
|
for (const id of ids) if (id) seen.add(id);
|
|
500
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();
|
|
501
811
|
|
|
502
812
|
// opencode runs chat.message via an unguarded Effect.promise (a throw aborts the
|
|
503
813
|
// turn) and dispatches event hooks fire-and-forget, so a hook must never reject:
|
|
@@ -511,6 +821,46 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
511
821
|
};
|
|
512
822
|
|
|
513
823
|
return {
|
|
824
|
+
// A tool, not a slash command, and deliberately so. opencode's plugin
|
|
825
|
+
// contract (Hooks in @opencode-ai/plugin) registers tools — `tool: { [id]:
|
|
826
|
+
// { description, args, execute } }` — but exposes no hook for registering a
|
|
827
|
+
// user-invocable command, so there is no `/memini:status` this plugin could
|
|
828
|
+
// offer without inventing one. `args` is empty: declaring a parameter means
|
|
829
|
+
// handing opencode a zod schema, and this plugin has no dependencies (a
|
|
830
|
+
// raw-JSON-Schema arg rides a compatibility path that older hosts feed
|
|
831
|
+
// straight to z.object() and throw on). A zero-arg tool is the shape every
|
|
832
|
+
// version accepts, and a read-only report needs no arguments anyway.
|
|
833
|
+
//
|
|
834
|
+
// Settings are recomputed per call rather than read off `cfg`, so an
|
|
835
|
+
// override set mid-session is visible here without restarting opencode —
|
|
836
|
+
// even though the hooks below still use the namespace they resolved at load.
|
|
837
|
+
tool: {
|
|
838
|
+
memini_status: {
|
|
839
|
+
description:
|
|
840
|
+
"Show the memini memory settings in force for this project: which namespace memories " +
|
|
841
|
+
"are written to and recalled from, where that namespace came from (a per-project " +
|
|
842
|
+
"override, MEMINI_NAMESPACE, the config file, or the git worktree), what it would be " +
|
|
843
|
+
"without each of those, and any misconfiguration worth flagging. Read-only; secrets " +
|
|
844
|
+
"are redacted. Call it when the user asks what memini is doing, why a memory cannot " +
|
|
845
|
+
"be recalled, or which namespace is in use.",
|
|
846
|
+
args: {},
|
|
847
|
+
execute: async () => {
|
|
848
|
+
try {
|
|
849
|
+
const report = describeSettings(process.env, options, worktree || directory);
|
|
850
|
+
return {
|
|
851
|
+
title: `memini: ${report.namespace.effective}`,
|
|
852
|
+
output: renderStatus(report),
|
|
853
|
+
metadata: { namespace: report.namespace.effective, source: report.namespace.source },
|
|
854
|
+
};
|
|
855
|
+
} catch (error) {
|
|
856
|
+
// A diagnostic that crashes the turn it was meant to diagnose is
|
|
857
|
+
// worse than no diagnostic.
|
|
858
|
+
return `memini status failed: ${String(error)}`;
|
|
859
|
+
}
|
|
860
|
+
},
|
|
861
|
+
},
|
|
862
|
+
},
|
|
863
|
+
|
|
514
864
|
"chat.message": guard("chat.message", async (input, output) => {
|
|
515
865
|
if (!cfg.recall) return;
|
|
516
866
|
const query = extractPartsText(output && output.parts);
|
|
@@ -530,13 +880,58 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
530
880
|
// the Claude Code plugin's pre-tool-use hook uses; client-side re-filter
|
|
531
881
|
// is a belt-and-braces guard against score-normalization edge cases.
|
|
532
882
|
if (cfg.recall_min_score > 0) body.min_score = cfg.recall_min_score;
|
|
533
|
-
|
|
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
|
+
}
|
|
534
919
|
// Client-side score floor: filter the raw hit list before formatting so
|
|
535
920
|
// the bullet array only contains hits the operator asked for. Without
|
|
536
921
|
// this, the server's default floor could leak low-quality hits in
|
|
537
922
|
// regardless of cfg.recall_min_score.
|
|
538
923
|
const floor = cfg.recall_min_score > 0 ? cfg.recall_min_score : 0;
|
|
539
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
|
+
}
|
|
540
935
|
// Suppress memories this session has already been shown — the injected
|
|
541
936
|
// part persists in the session, so a repeat adds nothing but noise.
|
|
542
937
|
if (sessionID) {
|
|
@@ -555,7 +950,16 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
555
950
|
const fit = fitByTokens(hits, cfg.recall_max_tokens);
|
|
556
951
|
if (fit.items.length === 0) return;
|
|
557
952
|
if (sessionID) {
|
|
558
|
-
|
|
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
|
+
);
|
|
559
963
|
}
|
|
560
964
|
const lines = [
|
|
561
965
|
`Relevant long-term memory from memini (background context — prefer ` +
|