@eleboucher/opencode-memini 0.6.11 → 0.7.0

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.
Files changed (4) hide show
  1. package/README.md +58 -35
  2. package/memini-v2.js +384 -0
  3. package/memini.js +291 -274
  4. package/package.json +6 -2
package/memini.js CHANGED
@@ -12,14 +12,16 @@
12
12
  * X-Memini-Namespace header. Default endpoint http://localhost:8080.
13
13
  *
14
14
  * Config comes from the plugin options (the [name, options] form in
15
- * opencode.json), with env-var fallbacks; secrets like MEMINI_API_KEY come from
16
- * the environment. See the options/env table in ../README.md.
15
+ * opencode.json), with env-var fallbacks; secrets like MEMINI_API_KEY come
16
+ * from the environment. Namespace and behavioral settings (recall, capture,
17
+ * recall_limit, ...) are additionally cross-checked against the server via
18
+ * POST /v1/handshake — see effectiveConfig() below for the precedence. See
19
+ * the options/env table in ../README.md.
17
20
  */
18
21
 
19
22
  import { execSync } from "node:child_process";
20
- import { existsSync, readFileSync } from "node:fs";
21
- import { join, resolve, sep } from "node:path";
22
- import { homedir } from "node:os";
23
+ import { readFileSync } from "node:fs";
24
+ import { resolve } from "node:path";
23
25
 
24
26
  const DEFAULT_BASE_URL = "http://localhost:8080";
25
27
  const DEFAULT_TIMEOUT_MS = 30000;
@@ -31,11 +33,39 @@ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
31
33
  // search itself could resolve to (including null on a degraded failure).
32
34
  const BUDGET_EXPIRED = Symbol("memini-recall-budget-expired");
33
35
 
36
+ // The client identifies itself to /v1/handshake for logging/diagnostics only
37
+ // (api/openapi.yaml's HandshakeRequest.client). Version is read from this
38
+ // package's own package.json (always shipped alongside memini.js — npm
39
+ // includes it regardless of the "files" allowlist) so it never has to be kept
40
+ // in sync by hand; "0.0.0" degrades gracefully when running from a checkout
41
+ // that lacks one for some reason.
42
+ const CLIENT_NAME = "opencode-memini";
43
+ function readPluginVersion() {
44
+ try {
45
+ const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"));
46
+ return typeof pkg.version === "string" && pkg.version ? pkg.version : "0.0.0";
47
+ } catch {
48
+ return "0.0.0";
49
+ }
50
+ }
51
+ const CLIENT_VERSION = readPluginVersion();
52
+
53
+ // How long a memoized handshake stays trustworthy on a live plugin instance,
54
+ // and how long a single handshake call is allowed to block before falling
55
+ // back. Mirrors packages/memini-client's HANDSHAKE_TTL_MS / default timeout;
56
+ // this plugin ships standalone so it stays a copy, not an import.
57
+ export const HANDSHAKE_TTL_MS = 10 * 60 * 1000;
58
+ export const HANDSHAKE_TIMEOUT_MS = 2500;
59
+
34
60
  function envBool(value, fallback) {
35
61
  if (value === undefined || value === null || value === "") return fallback;
36
62
  return !/^(0|false|no|off)$/i.test(String(value).trim());
37
63
  }
38
64
 
65
+ function isSet(value) {
66
+ return value !== undefined && value !== null && String(value).trim() !== "";
67
+ }
68
+
39
69
  // sanitizeNamespace keeps the X-Memini-Namespace value header-safe (the server
40
70
  // sanitizes too, but the header should be clean): alnum, dot, dash, underscore;
41
71
  // collapse the rest to dashes and trim.
@@ -43,21 +73,30 @@ function sanitizeNamespace(s) {
43
73
  return String(s).trim().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
44
74
  }
45
75
 
76
+ // basenameOf is the raw (unsanitized) basename of a path — used for the
77
+ // cwd_basename fact sent to the server, which does its own sanitizing.
78
+ // deriveNamespace (below) is the sanitized, LOCAL-fallback-only variant.
79
+ function basenameOf(p) {
80
+ return String(p).replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "";
81
+ }
82
+
46
83
  // deriveNamespace scopes memory to the project: the basename of the git
47
- // worktree (the repo dir name), which is the same scheme memini auto-resolves
48
- // from a git repo. Use the same namespace across your other agents to share
49
- // memory. Returns "" when no path is given.
84
+ // worktree (the repo dir name). This is the LOCAL fallback only — used when
85
+ // neither an explicit namespace (option/MEMINI_NAMESPACE) nor a handshake
86
+ // result is available, see effectiveConfig(). Returns "" when no path is given.
50
87
  export function deriveNamespace(worktree) {
51
88
  if (typeof worktree !== "string" || !worktree.trim()) return "";
52
- const base = worktree.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "";
53
- return sanitizeNamespace(base);
89
+ return sanitizeNamespace(basenameOf(worktree));
54
90
  }
55
91
 
56
- // gitProject derives {project} the way the other integrations do when a config
57
- // file is present: git remote repo name > git toplevel basename > cwd basename.
58
- // Used whenever a config file is present (tenant-matched or not) — without a
59
- // config file the namespace stays the legacy cwd basename.
60
- function gitProject(cwd) {
92
+ // gitFacts best-effort gathers the git remote and toplevel for the handshake
93
+ // request body only never for local namespace derivation, which stays the
94
+ // plain worktree basename (deriveNamespace). Mirrors
95
+ // packages/memini-client's gatherFacts so every memini client sends the same
96
+ // shape of project facts; this plugin ships standalone so it stays a copy
97
+ // rather than an import. Never throws: no git, no repo, or a slow git all
98
+ // degrade to omitting the field.
99
+ function gitFacts(cwd) {
61
100
  const gitOut = (args) => {
62
101
  try {
63
102
  return execSync(`git ${args}`, { cwd, stdio: ["ignore", "pipe", "ignore"], timeout: 500 })
@@ -67,186 +106,62 @@ function gitProject(cwd) {
67
106
  return "";
68
107
  }
69
108
  };
109
+ const facts = {};
70
110
  const remote = gitOut("remote get-url origin");
71
- if (remote) {
72
- const cleaned = remote.replace(/\/+$/, "").replace(/\.git$/i, "");
73
- const scpMatch = cleaned.match(/^[^/:]+:[^/]/);
74
- const p = scpMatch ? cleaned.slice(scpMatch[0].indexOf(":") + 1) : cleaned;
75
- const name = sanitizeNamespace(p.split("/").filter(Boolean).pop() || "");
76
- if (name) return name;
77
- }
111
+ if (remote) facts.remote_url = remote;
78
112
  const toplevel = gitOut("rev-parse --show-toplevel");
79
- if (toplevel) return deriveNamespace(toplevel);
80
- return deriveNamespace(cwd);
81
- }
82
-
83
- // matchTenant returns the tenant name if cwd is under a configured tenant root,
84
- // else "". Each segment stays header-safe on its own so the tenant path keeps
85
- // its "/" separator (work/memini must not flatten to work-memini).
86
- function matchTenant(cwd, config) {
87
- if (!Array.isArray(config.tenantRoots)) return "";
88
- const resolvedCwd = resolve(cwd);
89
- for (const root of config.tenantRoots) {
90
- if (!root || typeof root !== "object") continue;
91
- let rootPath = root.path;
92
- // An empty/missing path would startsWith-match every cwd; skip it.
93
- if (typeof rootPath !== "string" || !rootPath) continue;
94
- if (rootPath === "~") rootPath = homedir();
95
- else if (rootPath.startsWith("~/")) rootPath = join(homedir(), rootPath.slice(2));
96
- rootPath = resolve(rootPath);
97
- if (resolvedCwd === rootPath || resolvedCwd.startsWith(rootPath + sep)) {
98
- const tenant = String(root.tenant || "")
99
- .replace(/[^A-Za-z0-9._-]+/g, "-")
100
- .replace(/^-+|-+$/g, "");
101
- if (tenant) return tenant;
102
- }
113
+ if (toplevel) {
114
+ facts.toplevel_path = toplevel;
115
+ facts.toplevel_basename = basenameOf(toplevel);
103
116
  }
104
- return "";
117
+ return facts;
105
118
  }
106
119
 
107
- // resolveConfigNamespace reads ~/.config/memini/config.json and renders the
108
- // config template over the resolved segments. Returns null only when no config
109
- // file exists (or it's unreadable/malformed), so the caller falls back to the
110
- // legacy deriveNamespace chain. When a config file is present, {project} is
111
- // always the git-derived name (repo name > toplevel > cwd basename), matching
112
- // pi/the shared resolver — even when cwd is under no tenant root.
113
- function resolveConfigNamespace(cwd) {
114
- let config;
115
- try {
116
- const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
117
- const configPath = join(xdg, "memini", "config.json");
118
- config = JSON.parse(readFileSync(configPath, "utf8"));
119
- } catch {
120
- return null; // no config file -> today's behavior, zero migration
121
- }
122
- if (!config || typeof config !== "object") return null;
123
- const tenant = matchTenant(cwd, config);
124
- const project = gitProject(cwd);
125
- const agent = (process.env.MEMINI_AGENT || "")
126
- .trim()
127
- .replace(/[^A-Za-z0-9._-]+/g, "-")
128
- .replace(/^-+|-+$/g, "");
129
- const template =
130
- typeof config.template === "string" && config.template
131
- ? config.template
132
- : "{tenant}/{project}/{agent}";
133
- const ns = template
134
- .replace(/\{tenant\}/g, tenant)
135
- .replace(/\{project\}/g, project)
136
- .replace(/\{agent\}/g, agent)
137
- .replace(/\{namespace\}/g, "")
138
- .replace(/\/{2,}/g, "/")
139
- .replace(/^\/+|\/+$/g, "");
140
- return ns || null;
141
- }
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 : "" };
120
+ // buildFacts assembles HandshakeRequest.project (api/openapi.yaml): the
121
+ // worktree basename (always present, the last-resort fallback), git
122
+ // remote/toplevel (best-effort), and MEMINI_NAMESPACE as env_namespace sent
123
+ // so a server-side pin can still beat it (the client cannot make that call
124
+ // itself without knowing whether a pin exists). Exported for testing.
125
+ export function buildFacts(dir, env) {
126
+ const e = env || {};
127
+ const facts = { cwd_basename: basenameOf(dir || process.cwd()), ...gitFacts(dir) };
128
+ const ns = String(e.MEMINI_NAMESPACE || "").trim();
129
+ if (ns) facts.env_namespace = ns;
130
+ return facts;
206
131
  }
207
132
 
208
133
  // resolveConfig merges env vars with the options object (options win), filling
209
134
  // in defaults. Exported for testing.
210
135
  //
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.
136
+ // Namespace here is LOCAL ONLY: the `namespace` option / MEMINI_NAMESPACE
137
+ // (raw-trimmed the server validates the header, and flattening "/" here
138
+ // would split a tenant path like work/memini in two), else the git worktree
139
+ // basename, else the built-in default. See effectiveConfig() below for how a
140
+ // handshake result is layered on top of this: a handshake can win the
141
+ // worktree/default tail, but never an explicit option or env value.
220
142
  //
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 = {}) {
143
+ // Each recall/capture knob also carries an `explicit` flag alongside its
144
+ // locally-resolved (option > env > built-in default) value, so
145
+ // effectiveConfig() knows whether a handshake's server settings are allowed
146
+ // to fill it in.
147
+ export function resolveConfig(env, options, worktree) {
225
148
  const e = env || {};
226
149
  const o = options || {};
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.
233
- const explicit = o.namespace || e.MEMINI_NAMESPACE;
150
+
234
151
  let namespace;
235
152
  let namespace_source;
236
- if (override) {
237
- namespace = override.namespace;
238
- namespace_source = "override";
239
- } else if (explicit && String(explicit).trim()) {
240
- namespace = String(explicit).trim();
241
- namespace_source = o.namespace ? "option" : "env";
153
+ if (o.namespace && String(o.namespace).trim()) {
154
+ namespace = String(o.namespace).trim();
155
+ namespace_source = "option";
156
+ } else if (e.MEMINI_NAMESPACE && String(e.MEMINI_NAMESPACE).trim()) {
157
+ namespace = String(e.MEMINI_NAMESPACE).trim();
158
+ namespace_source = "env";
242
159
  } else {
243
- // Config present -> render the config template (tenant segments already
244
- // sanitized, "/" preserved); otherwise fall back to the legacy cwd chain.
245
- const fromConfig = resolveConfigNamespace(dir);
246
- const fromWorktree = deriveNamespace(worktree);
247
- namespace = fromConfig || fromWorktree || DEFAULT_NAMESPACE;
248
- namespace_source = fromConfig ? "config" : fromWorktree ? "worktree" : "default";
160
+ const derived = deriveNamespace(worktree);
161
+ namespace = derived || DEFAULT_NAMESPACE;
162
+ namespace_source = derived ? "local-worktree" : "local-default";
249
163
  }
164
+
250
165
  // Number.isFinite guard: malformed env / option falls through to the next
251
166
  // source instead of NaN flowing into the request body.
252
167
  const recall_limit = (() => {
@@ -269,21 +184,18 @@ export function resolveConfig(env, options, worktree, opts = {}) {
269
184
  })();
270
185
  // home: the caller's personal namespace, sent as X-Memini-Home. Same
271
186
  // env-only resolution style as namespace's MEMINI_NAMESPACE (option wins
272
- // over env), but no config-file/derivation fallback — unset means "no home
273
- // leg", not a guess.
187
+ // over env), but no derivation fallback — unset means "no home leg", not a
188
+ // guess. Not layered from the server: it is a purely local, per-caller knob.
274
189
  const homeRaw = o.home !== undefined ? o.home : e.MEMINI_HOME;
275
190
  const home = homeRaw && String(homeRaw).trim() ? String(homeRaw).trim() : undefined;
191
+
276
192
  return {
277
- base_url: o.base_url || e.MEMINI_BASE_URL || e.MEMINI_URL || DEFAULT_BASE_URL,
278
- // namespace is already resolved above (explicit raw-trimmed, or a
279
- // per-segment-sanitized config/derived value); re-sanitizing here would
280
- // flatten tenant "/" separators.
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.
193
+ base_url: o.base_url || e.MEMINI_BASE_URL || DEFAULT_BASE_URL,
194
+ // namespace is already resolved above (explicit raw-trimmed, or the
195
+ // sanitized worktree/default fallback); re-sanitizing here would flatten
196
+ // a tenant "/" separator.
197
+ namespace,
285
198
  namespace_source,
286
- override,
287
199
  home,
288
200
  recall: o.recall !== undefined ? o.recall !== false : envBool(e.MEMINI_RECALL, true),
289
201
  capture: o.capture !== undefined ? o.capture !== false : envBool(e.MEMINI_CAPTURE, true),
@@ -302,6 +214,77 @@ export function resolveConfig(env, options, worktree, opts = {}) {
302
214
  o.fallback_on_error !== undefined
303
215
  ? o.fallback_on_error !== false
304
216
  : envBool(e.MEMINI_FALLBACK, true),
217
+ // Recorded so effectiveConfig() can tell "explicitly set to the built-in
218
+ // default" apart from "not set at all" — only the latter may be filled in
219
+ // from the server.
220
+ explicit: {
221
+ recall: o.recall !== undefined || isSet(e.MEMINI_RECALL),
222
+ capture: o.capture !== undefined || isSet(e.MEMINI_CAPTURE),
223
+ recall_limit: o.recall_limit !== undefined || isSet(e.MEMINI_RECALL_LIMIT),
224
+ recall_max_tokens: o.recall_max_tokens !== undefined || isSet(process.env.MEMINI_INJECT_RECALL_MAX_TOK),
225
+ recall_min_score: o.recall_min_score !== undefined || isSet(process.env.MEMINI_INJECT_RECALL_MIN_SCORE),
226
+ },
227
+ };
228
+ }
229
+
230
+ // effectiveConfig merges a (possibly null — handshake is fail-soft) handshake
231
+ // result into the locally-resolved `cfg` from resolveConfig():
232
+ //
233
+ // namespace: option/env (cfg.namespace_source already "option"/"env") beats
234
+ // the handshake's resolved namespace beats cfg's own local
235
+ // worktree/default fallback.
236
+ // recall/capture/recall_limit/recall_max_tokens/recall_min_score: option
237
+ // beats env (both already baked into cfg, tracked by cfg.explicit) beats
238
+ // the handshake's `settings` (ClientSettings — api/openapi.yaml) beats
239
+ // the built-in default already baked into cfg.
240
+ //
241
+ // hs may be null/undefined (network error, non-2xx, timeout — see
242
+ // createClient's handshake()) or shaped without the fields this cares about;
243
+ // every read below tolerates that and falls back to cfg. Exported for testing.
244
+ export function effectiveConfig(cfg, hs) {
245
+ let namespace = cfg.namespace;
246
+ let namespace_source = cfg.namespace_source;
247
+ if (namespace_source !== "option" && namespace_source !== "env" && hs && hs.namespace) {
248
+ namespace = hs.namespace;
249
+ namespace_source = `server:${hs.namespace_source}`;
250
+ }
251
+
252
+ const s = (hs && hs.settings) || {};
253
+ const explicit = cfg.explicit || {};
254
+
255
+ return {
256
+ ...cfg,
257
+ namespace,
258
+ namespace_source,
259
+ recall: explicit.recall || typeof s.recall !== "boolean" ? cfg.recall : s.recall,
260
+ capture: explicit.capture || typeof s.capture !== "boolean" ? cfg.capture : s.capture,
261
+ recall_limit:
262
+ explicit.recall_limit || !Number.isFinite(s.recall_limit) ? cfg.recall_limit : s.recall_limit,
263
+ recall_max_tokens:
264
+ explicit.recall_max_tokens || !Number.isFinite(s.inject_recall_max_tok)
265
+ ? cfg.recall_max_tokens
266
+ : s.inject_recall_max_tok,
267
+ recall_min_score:
268
+ explicit.recall_min_score || !Number.isFinite(s.inject_recall_min_score)
269
+ ? cfg.recall_min_score
270
+ : s.inject_recall_min_score,
271
+ };
272
+ }
273
+
274
+ // memoizeAsync wraps a zero-arg async fn so a long-lived plugin instance calls
275
+ // it at most once per ttlMs, returning the cached value in between — the
276
+ // shape MeminiPlugin uses to memoize the handshake per session. `now` is
277
+ // injectable so tests can drive expiry without a real 10-minute sleep.
278
+ // Exported for testing.
279
+ export function memoizeAsync(fn, ttlMs, now = Date.now) {
280
+ let cached = null; // { value, expiresAt }
281
+ return async () => {
282
+ const t = now();
283
+ if (!cached || t >= cached.expiresAt) {
284
+ const value = await fn();
285
+ cached = { value, expiresAt: t + ttlMs };
286
+ }
287
+ return cached.value;
305
288
  };
306
289
  }
307
290
 
@@ -498,9 +481,9 @@ export function truncate(value, max) {
498
481
  // answer that. The case worth catching is MEMINI_NAMESPACE exported globally (a
499
482
  // shell rc, or a fish universal variable), set once and forgotten, quietly
500
483
  // 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.
484
+ // fine, only its provenance gives it away. So the namespace is resolved twice
485
+ // against progressively stripped inputs — as-is and without the env/option
486
+ // pin — and both are reported.
504
487
 
505
488
  /**
506
489
  * Render a secret as a recognizable-but-useless fingerprint: enough to tell two
@@ -514,8 +497,13 @@ export function redactSecret(value) {
514
497
  }
515
498
 
516
499
  /**
517
- * Build the effective-settings report: the three namespace resolutions, the
518
- * knobs with their provenance (secrets redacted), the paths, and the warnings.
500
+ * Build the effective-settings report: the LOCAL namespace resolution (option
501
+ * / env / worktree / default — no network call, so this never blocks), the
502
+ * knobs with their provenance (secrets redacted), and the warnings. The
503
+ * `memini_status` tool overlays the live, handshake-aware values (see
504
+ * MeminiPlugin) on top of this report's namespace/memory sections before
505
+ * rendering, so what the user reads reflects what the plugin actually did on
506
+ * its last handshake — this function alone only ever reports the local view.
519
507
  * Exported for testing.
520
508
  */
521
509
  export function describeSettings(env, options, worktree) {
@@ -524,37 +512,18 @@ export function describeSettings(env, options, worktree) {
524
512
  const dir = worktree || process.cwd();
525
513
 
526
514
  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 });
515
+ // What this project would resolve to without MEMINI_NAMESPACE / the
516
+ // namespace option the line that turns "your namespace is X" into "and
517
+ // that's because of a global env pin" when the two disagree.
531
518
  const envSansPin = { ...e };
532
519
  delete envSansPin.MEMINI_NAMESPACE;
533
- const derived = resolveConfig(
534
- envSansPin,
535
- { ...o, namespace: undefined },
536
- worktree,
537
- { ignoreOverride: true },
538
- );
520
+ const derived = resolveConfig(envSansPin, { ...o, namespace: undefined }, worktree);
539
521
 
540
- const secret = e.MEMINI_API_KEY || e.MEMINI_TOKEN || "";
522
+ const secret = e.MEMINI_API_KEY || "";
541
523
  const warnings = [];
542
524
 
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
525
  const pin = String(e.MEMINI_NAMESPACE || "").trim();
557
- if (pin && !cfg.override && derived.namespace && derived.namespace !== pin) {
526
+ if (pin && derived.namespace && derived.namespace !== pin) {
558
527
  warnings.push({
559
528
  level: "warn",
560
529
  code: "global-namespace-pin",
@@ -563,7 +532,7 @@ export function describeSettings(env, options, worktree) {
563
532
  `namespace. This project would otherwise resolve to "${derived.namespace}". If it is ` +
564
533
  `exported from a shell rc (or a fish universal variable), every repo you work in is ` +
565
534
  `sharing one memory pool.`,
566
- fix: "Unset MEMINI_NAMESPACE and let each repo resolve on its own, or set a per-project override instead.",
535
+ fix: "Unset MEMINI_NAMESPACE and let each repo resolve on its own, or set the namespace option to scope one project deliberately.",
567
536
  });
568
537
  }
569
538
 
@@ -586,13 +555,11 @@ export function describeSettings(env, options, worktree) {
586
555
  }
587
556
 
588
557
  return {
589
- project: overrideKey(dir),
558
+ project: resolve(dir),
590
559
  worktree: dir,
591
560
  namespace: {
592
561
  effective: cfg.namespace,
593
562
  source: cfg.namespace_source,
594
- override: cfg.override,
595
- withoutOverride,
596
563
  derived,
597
564
  home: cfg.home,
598
565
  },
@@ -611,16 +578,15 @@ export function describeSettings(env, options, worktree) {
611
578
  recall_budget_ms: cfg.recall_budget_ms,
612
579
  labels: [...labelsEnv()],
613
580
  },
614
- paths: { overrides: overridesPath(e) },
615
581
  warnings,
616
582
  };
617
583
  }
618
584
 
619
585
  const padTo = (s, n) => String(s).padEnd(n);
620
586
 
621
- /** Render describeSettings() as the text block the tool hands back. */
587
+ /** Render describeSettings() (optionally overlaid with live values) as the text block the tool hands back. */
622
588
  export function renderStatus(report) {
623
- const { namespace: ns, connection, memory, paths } = report;
589
+ const { namespace: ns, connection, memory } = report;
624
590
  const L = [];
625
591
 
626
592
  L.push("memini — effective settings (opencode)");
@@ -629,11 +595,6 @@ export function renderStatus(report) {
629
595
 
630
596
  L.push("NAMESPACE");
631
597
  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
598
  if (ns.derived.namespace !== ns.effective) {
638
599
  L.push(
639
600
  ` ${padTo("git/cwd would give", 26)} ${padTo(ns.derived.namespace, 30)} <- ${ns.derived.source}`,
@@ -659,10 +620,6 @@ export function renderStatus(report) {
659
620
  L.push(` ${padTo("labels", 26)} ${memory.labels.length ? memory.labels.join(",") : "(none)"}`);
660
621
  L.push("");
661
622
 
662
- L.push("PATHS");
663
- L.push(` ${padTo("overrides", 26)} ${paths.overrides}${existsSync(paths.overrides) ? "" : " (absent)"}`);
664
- L.push("");
665
-
666
623
  if (report.warnings.length) {
667
624
  L.push("WARNINGS");
668
625
  for (const w of report.warnings) {
@@ -676,15 +633,16 @@ export function renderStatus(report) {
676
633
  return L.join("\n");
677
634
  }
678
635
 
679
- function createClient(cfg, log) {
636
+ export function createClient(cfg, log) {
680
637
  const baseUrl = String(cfg.base_url).replace(/\/+$/, "");
681
- const secret = process.env.MEMINI_API_KEY || process.env.MEMINI_TOKEN;
638
+ const secret = process.env.MEMINI_API_KEY;
682
639
  const guardPlaintextBearerAuth = createPlaintextBearerAuthGuard((m) => log.warn(m));
683
640
  if (process.env.MEMINI_REQUIRE_HTTPS === "1") guardPlaintextBearerAuth(baseUrl, secret);
684
641
 
685
- async function postJson(path, payload) {
642
+ async function postJson(path, payload, namespace) {
643
+ // Deliberate exception to the fail-soft try/catch below: a plaintext-bearer misconfiguration must raise, matching @memini/client's assertBearerTransportSafe.
686
644
  guardPlaintextBearerAuth(baseUrl, secret);
687
- const headers = { "Content-Type": "application/json", "X-Memini-Namespace": cfg.namespace };
645
+ const headers = { "Content-Type": "application/json", "X-Memini-Namespace": namespace };
688
646
  if (secret) headers.Authorization = `Bearer ${secret}`;
689
647
  if (cfg.home) headers["X-Memini-Home"] = cfg.home;
690
648
  try {
@@ -712,7 +670,42 @@ function createClient(cfg, log) {
712
670
  }
713
671
  }
714
672
 
715
- return { postJson, baseUrl };
673
+ // handshake calls POST /v1/handshake (api/openapi.yaml) — fail-soft ALWAYS:
674
+ // a network error, non-2xx, malformed JSON, or a ~2.5s timeout all return
675
+ // null, independent of cfg.fallback_on_error (that knob is specifically
676
+ // about degrading /v1/search and /v1/memories; degrading a handshake to
677
+ // local resolution is not optional — see effectiveConfig()). Not memoized
678
+ // here — the caller (MeminiPlugin) memoizes per plugin instance with a
679
+ // 10-minute TTL via memoizeAsync.
680
+ async function handshake(facts) {
681
+ // Same deliberate exception as postJson: fail-soft ALWAYS above, except this one raise, matching @memini/client's assertBearerTransportSafe.
682
+ guardPlaintextBearerAuth(baseUrl, secret);
683
+ const controller = new AbortController();
684
+ const timer = setTimeout(() => controller.abort(), HANDSHAKE_TIMEOUT_MS);
685
+ try {
686
+ const headers = { "Content-Type": "application/json" };
687
+ if (secret) headers.Authorization = `Bearer ${secret}`;
688
+ if (cfg.home) headers["X-Memini-Home"] = cfg.home;
689
+ const res = await fetch(`${baseUrl}/v1/handshake`, {
690
+ method: "POST",
691
+ headers,
692
+ body: JSON.stringify({ project: facts, client: { name: CLIENT_NAME, version: CLIENT_VERSION } }),
693
+ signal: controller.signal,
694
+ });
695
+ if (!res.ok) {
696
+ log.warn(`memini handshake failed: ${res.status}`);
697
+ return null;
698
+ }
699
+ return await res.json();
700
+ } catch (error) {
701
+ log.warn(`memini: handshake ${String(error)}`);
702
+ return null;
703
+ } finally {
704
+ clearTimeout(timer);
705
+ }
706
+ }
707
+
708
+ return { postJson, handshake, baseUrl };
716
709
  }
717
710
 
718
711
  // extractLastTurn returns the latest user and assistant text from the message
@@ -766,8 +759,19 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
766
759
  },
767
760
  };
768
761
 
769
- const cfg = resolveConfig(process.env, options, worktree || directory);
762
+ const dir = worktree || directory;
763
+ const cfg = resolveConfig(process.env, options, dir);
770
764
  const rest = createClient(cfg, log);
765
+
766
+ // The handshake is memoized on THIS plugin instance (opencode creates one
767
+ // per session) with a 10-minute TTL: long enough that a busy session isn't
768
+ // round-tripping the network on every message, short enough that an
769
+ // operator's pin/settings change is noticed within one long-lived session.
770
+ // A null handshake (fail-soft, see createClient's handshake()) just means
771
+ // effectiveConfig() falls all the way back to cfg's local resolution.
772
+ const getHandshake = memoizeAsync(() => rest.handshake(buildFacts(dir, process.env)), HANDSHAKE_TTL_MS);
773
+ const currentConfig = async () => effectiveConfig(cfg, await getHandshake());
774
+
771
775
  // Warm the connection (DNS/TCP/TLS) in opencode's embedded bun so a cold
772
776
  // start doesn't eat the first recall budget. Silent: even a 404 warms the
773
777
  // path, and an ingress that only routes /v1 legitimately has no /healthz.
@@ -831,17 +835,17 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
831
835
  // 400 on the unknown field: when a request carrying it fails and the retry
832
836
  // without it succeeds, stop sending it. The client-side filter stays.
833
837
  let serverExcludeIds = true;
834
- const searchExcluding = async (body, excludeIds) => {
838
+ const searchExcluding = async (body, excludeIds, namespace) => {
835
839
  if (!serverExcludeIds || excludeIds.length === 0) {
836
- return rest.postJson("/v1/search", body);
840
+ return rest.postJson("/v1/search", body, namespace);
837
841
  }
838
842
  try {
839
- const result = await rest.postJson("/v1/search", { ...body, exclude_ids: excludeIds });
843
+ const result = await rest.postJson("/v1/search", { ...body, exclude_ids: excludeIds }, namespace);
840
844
  if (result !== null) return result;
841
845
  } catch {
842
846
  // With fallback_on_error=false the 400 arrives as a throw, not null.
843
847
  }
844
- const retry = await rest.postJson("/v1/search", body);
848
+ const retry = await rest.postJson("/v1/search", body, namespace);
845
849
  if (retry !== null) {
846
850
  serverExcludeIds = false;
847
851
  log.warn("memini: server does not accept exclude_ids; using client-side dedupe only");
@@ -870,23 +874,30 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
870
874
  // raw-JSON-Schema arg rides a compatibility path that older hosts feed
871
875
  // straight to z.object() and throw on). A zero-arg tool is the shape every
872
876
  // version accepts, and a read-only report needs no arguments anyway.
873
- //
874
- // Settings are recomputed per call rather than read off `cfg`, so an
875
- // override set mid-session is visible here without restarting opencode —
876
- // even though the hooks below still use the namespace they resolved at load.
877
877
  tool: {
878
878
  memini_status: {
879
879
  description:
880
880
  "Show the memini memory settings in force for this project: which namespace memories " +
881
- "are written to and recalled from, where that namespace came from (a per-project " +
882
- "override, MEMINI_NAMESPACE, the config file, or the git worktree), what it would be " +
883
- "without each of those, and any misconfiguration worth flagging. Read-only; secrets " +
884
- "are redacted. Call it when the user asks what memini is doing, why a memory cannot " +
885
- "be recalled, or which namespace is in use.",
881
+ "are written to and recalled from, where that namespace came from (the namespace option, " +
882
+ "MEMINI_NAMESPACE, a server-resolved handshake, or the git worktree fallback), what it " +
883
+ "would be without the env/option pin, and any misconfiguration worth flagging. Read-only; " +
884
+ "secrets are redacted. Call it when the user asks what memini is doing, why a memory " +
885
+ "cannot be recalled, or which namespace is in use.",
886
886
  args: {},
887
887
  execute: async () => {
888
888
  try {
889
- const report = describeSettings(process.env, options, worktree || directory);
889
+ const report = describeSettings(process.env, options, dir);
890
+ // Overlay the live, handshake-aware values on top of the local
891
+ // report so what the tool reports matches what the hooks
892
+ // actually did on their last handshake.
893
+ const live = await currentConfig();
894
+ report.namespace.effective = live.namespace;
895
+ report.namespace.source = live.namespace_source;
896
+ report.memory.recall = live.recall;
897
+ report.memory.capture = live.capture;
898
+ report.memory.recall_limit = live.recall_limit;
899
+ report.memory.recall_max_tokens = live.recall_max_tokens;
900
+ report.memory.recall_min_score = live.recall_min_score;
890
901
  return {
891
902
  title: `memini: ${report.namespace.effective}`,
892
903
  output: renderStatus(report),
@@ -902,7 +913,8 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
902
913
  },
903
914
 
904
915
  "chat.message": guard("chat.message", async (input, output) => {
905
- if (!cfg.recall) return;
916
+ const live = await currentConfig();
917
+ if (!live.recall) return;
906
918
  const query = extractPartsText(output && output.parts);
907
919
  if (!query) return;
908
920
  // Borrow sessionID/messageID from the real parts when the hook input
@@ -911,7 +923,7 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
911
923
  const sibling = output.parts.find((p) => p && p.type === "text") || {};
912
924
  const sessionID = input.sessionID || sibling.sessionID;
913
925
  const messageID = input.messageID || sibling.messageID;
914
- const body = { query, limit: cfg.recall_limit };
926
+ const body = { query, limit: live.recall_limit };
915
927
  // Exclude this session's own captured turns: they're still in the live
916
928
  // context, so recalling them just echoes the conversation back a turn
917
929
  // behind. Captures from other (past) sessions are still recalled.
@@ -919,16 +931,16 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
919
931
  // min_score (fused-score floor) is optional and matches the wire knob
920
932
  // the Claude Code plugin's pre-tool-use hook uses; client-side re-filter
921
933
  // is a belt-and-braces guard against score-normalization edge cases.
922
- if (cfg.recall_min_score > 0) body.min_score = cfg.recall_min_score;
934
+ if (live.recall_min_score > 0) body.min_score = live.recall_min_score;
923
935
  // Already-shown ids go along as exclude_ids so a suppressed hit doesn't
924
936
  // waste a recall_limit slot.
925
937
  const excludeIds = sessionID ? [...(injectedBySession.get(sessionID) ?? [])] : [];
926
938
  // opencode awaits this hook before the model sees the message, so the
927
- // turn only waits recall_budget_ms for the search; the fetch itself keeps
939
+ // turn only waits live.recall_budget_ms for the search; the fetch itself keeps
928
940
  // cfg.timeout_ms as its bound and runs on in the background. A slow or
929
941
  // unreachable memini degrades to "no memories this turn" instead of a
930
942
  // frozen turn, and late results carry over to the session's next message.
931
- const fetchPromise = searchExcluding(body, excludeIds);
943
+ const fetchPromise = searchExcluding(body, excludeIds, live.namespace);
932
944
  // Once the budget expires nothing awaits this promise, and with
933
945
  // fallback_on_error off postJson rethrows — catch here or a late
934
946
  // rejection surfaces as an unhandled rejection in the host.
@@ -937,16 +949,16 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
937
949
  return null;
938
950
  });
939
951
  let result;
940
- if (cfg.recall_budget_ms > 0) {
952
+ if (live.recall_budget_ms > 0) {
941
953
  let timer;
942
954
  const budget = new Promise((resolve) => {
943
- timer = setTimeout(() => resolve(BUDGET_EXPIRED), cfg.recall_budget_ms);
955
+ timer = setTimeout(() => resolve(BUDGET_EXPIRED), live.recall_budget_ms);
944
956
  });
945
957
  result = await Promise.race([settled, budget]);
946
958
  clearTimeout(timer);
947
959
  if (result === BUDGET_EXPIRED) {
948
960
  log.warn(
949
- `recall exceeded its ${cfg.recall_budget_ms}ms budget; late results will inject next turn`,
961
+ `recall exceeded its ${live.recall_budget_ms}ms budget; late results will inject next turn`,
950
962
  );
951
963
  if (sessionID) {
952
964
  settled.then((late) => {
@@ -962,8 +974,8 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
962
974
  // Client-side score floor: filter the raw hit list before formatting so
963
975
  // the bullet array only contains hits the operator asked for. Without
964
976
  // this, the server's default floor could leak low-quality hits in
965
- // regardless of cfg.recall_min_score.
966
- const floor = cfg.recall_min_score > 0 ? cfg.recall_min_score : 0;
977
+ // regardless of live.recall_min_score.
978
+ const floor = live.recall_min_score > 0 ? live.recall_min_score : 0;
967
979
  let rawHits = Array.isArray(result && result.results) ? result.results : [];
968
980
  // Merge in results that arrived late on a previous turn: fresh hits
969
981
  // first (they answer the current query), deduped by memory id.
@@ -985,12 +997,12 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
985
997
  ? rawHits.filter((r) => (typeof r?.score === "number" ? r.score : 0) >= floor)
986
998
  : rawHits;
987
999
  const labels = labelsEnv();
988
- const hits = formatResults(filtered, cfg.recall_limit, labels);
1000
+ const hits = formatResults(filtered, live.recall_limit, labels);
989
1001
  if (hits.length === 0) return;
990
1002
  // Apply the token ceiling to the rendered bullet lines; with max=0
991
1003
  // (the default) fitByTokens returns the full list unchanged, so the
992
1004
  // behaviour matches the prior "no cap" code path for existing installs.
993
- const fit = fitByTokens(hits, cfg.recall_max_tokens);
1005
+ const fit = fitByTokens(hits, live.recall_max_tokens);
994
1006
  if (fit.items.length === 0) return;
995
1007
  if (sessionID) {
996
1008
  // Mark only the slice formatResults actually renders: with carryover
@@ -999,7 +1011,7 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
999
1011
  rememberInjected(
1000
1012
  sessionID,
1001
1013
  filtered
1002
- .slice(0, cfg.recall_limit || DEFAULT_RECALL_LIMIT)
1014
+ .slice(0, live.recall_limit || DEFAULT_RECALL_LIMIT)
1003
1015
  .map((r) => r?.memory?.id)
1004
1016
  .filter(Boolean),
1005
1017
  );
@@ -1028,7 +1040,8 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1028
1040
  }),
1029
1041
 
1030
1042
  event: guard("event", async ({ event }) => {
1031
- if (!cfg.capture || !event || event.type !== "session.idle") return;
1043
+ const live = await currentConfig();
1044
+ if (!live.capture || !event || event.type !== "session.idle") return;
1032
1045
  const sessionID = event.properties && event.properties.sessionID;
1033
1046
  if (!sessionID) return;
1034
1047
  const res = await client.session.messages({ path: { id: sessionID } });
@@ -1037,11 +1050,15 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1037
1050
  if (assistantID && captured.has(assistantID)) return;
1038
1051
  const metadata = { source: "opencode", session_id: sessionID, format: "turn" };
1039
1052
  if (lastAssistantFailed(res && res.data)) metadata.failed = true;
1040
- const stored = await rest.postJson("/v1/memories", {
1041
- content: `${userText.slice(0, 1000)}\n\n${assistantText.slice(0, 3000)}`,
1042
- tags: ["opencode"],
1043
- metadata,
1044
- });
1053
+ const stored = await rest.postJson(
1054
+ "/v1/memories",
1055
+ {
1056
+ content: `${userText.slice(0, 1000)}\n\n${assistantText.slice(0, 3000)}`,
1057
+ tags: ["opencode"],
1058
+ metadata,
1059
+ },
1060
+ live.namespace,
1061
+ );
1045
1062
  if (stored !== null && assistantID) rememberCaptured(assistantID);
1046
1063
  }),
1047
1064
  };