@eleboucher/opencode-memini 0.6.8 → 0.6.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.
Files changed (3) hide show
  1. package/README.md +39 -1
  2. package/memini.js +324 -10
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -55,7 +55,7 @@ Pass options inline via the `[name, options]` form:
55
55
  | `recall_min_score` | `MEMINI_INJECT_RECALL_MIN_SCORE` | `0` | fused-score floor (>=) sent as `min_score` to `/v1/search` |
56
56
  | `timeout_ms` | `MEMINI_TIMEOUT_MS` | `30000` | per-request timeout |
57
57
  | `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` |
58
+ | — | `MEMINI_INJECT_LABELS` | — | comma-separated label toggles for each bullet: `tier`, `confidence`, `age`, `reason` |
59
59
  | — | `MEMINI_API_KEY` | — | bearer token, if memini needs auth (env only — secret; alias: `MEMINI_TOKEN`) |
60
60
  | — | `MEMINI_REQUIRE_HTTPS` | — | `1` refuses to send the token over plaintext HTTP |
61
61
 
@@ -71,6 +71,44 @@ stays correct even against a remote memini (the HTTP MCP wire below can't — a
71
71
  remote server has no access to your cwd). Set it to share one memory pool with
72
72
  your other agents.
73
73
 
74
+ If `$XDG_CONFIG_HOME/memini/config.json` (default `~/.config/memini/config.json`)
75
+ exists, the unset namespace is instead rendered from its `template` (default
76
+ `{tenant}/{project}/{agent}`): `{tenant}` from the `tenantRoots` entry whose
77
+ `path` contains the cwd, `{project}` from the git repo, `{agent}` from
78
+ `MEMINI_AGENT`; unresolved segments are dropped. The Hermes and Pi integrations
79
+ share this resolver, so one config file scopes them all identically.
80
+
81
+ ### Namespace resolution
82
+
83
+ In full, in order: a **per-project override** in
84
+ `$XDG_CONFIG_HOME/memini/overrides.json` > the `namespace` option /
85
+ `MEMINI_NAMESPACE` > the config template above > the git worktree basename.
86
+
87
+ The override wins over both deliberately. A globally exported `MEMINI_NAMESPACE`
88
+ — a shell rc, or a fish universal variable — pins every repo on the machine to
89
+ one namespace (as does a `namespace` option in a global
90
+ `~/.config/opencode/opencode.json`), and if either won, setting an override would
91
+ silently do nothing on exactly the machines that need one. The file is keyed by
92
+ git toplevel, so an override set at the top of a repo applies from any
93
+ subdirectory; it is the same file the Claude Code plugin writes and `memini
94
+ doctor` reads; and a malformed one degrades to automatic resolution rather than
95
+ breaking a turn.
96
+
97
+ ### The `memini_status` tool
98
+
99
+ The plugin registers one tool, `memini_status`: read-only, no arguments. It
100
+ reports the namespace in force and where it came from, what it would be _without_
101
+ the override and without the env pin, the connection settings (the API key
102
+ fingerprinted, never printed), and warnings — a global `MEMINI_NAMESPACE` pin, a
103
+ bearer token crossing plaintext HTTP, an override you forgot you set.
104
+
105
+ There is no `/memini:status` slash command: opencode's plugin contract registers
106
+ tools, not commands, and this plugin does not invent an API it does not have.
107
+ Setting or clearing an override is likewise not exposed here — declaring a tool
108
+ argument requires a zod schema, and this plugin ships dependency-free — so use
109
+ `/memini:namespace` from the Claude Code plugin, or edit `overrides.json`
110
+ directly; all harnesses read the same file.
111
+
74
112
  ### Tests
75
113
 
76
114
  ```bash
package/memini.js CHANGED
@@ -17,7 +17,7 @@
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
 
@@ -136,25 +136,112 @@ function resolveConfigNamespace(cwd) {
136
136
  return ns || null;
137
137
  }
138
138
 
139
+ // --- Namespace override ---------------------------------------------------
140
+ //
141
+ // $XDG_CONFIG_HOME/memini/overrides.json (else ~/.config/memini/overrides.json)
142
+ // holds the per-project namespace a user set deliberately. It is a shared
143
+ // contract: the Claude Code plugin writes it, `memini doctor` reads it, and
144
+ // every harness must agree about which namespace is in force — an override that
145
+ // only some of them honor is worse than none at all.
146
+ //
147
+ // This plugin ships standalone and dependency-free from npm, so it cannot
148
+ // import @memini/client; the reader below is the whole contract (a JSON file
149
+ // plus a `git rev-parse`) and stays a copy, the same trade already made for
150
+ // createPlaintextBearerAuthGuard and the injection-budget helpers. Keep the
151
+ // contract identical when both sides change.
152
+
153
+ // overridesPath resolves the overrides file. Exported for testing / status.
154
+ export function overridesPath(env = process.env) {
155
+ const xdg = env.XDG_CONFIG_HOME;
156
+ const base = xdg && String(xdg).trim() ? String(xdg) : join(homedir(), ".config");
157
+ return join(base, "memini", "overrides.json");
158
+ }
159
+
160
+ // overrideKey is the key an override is stored under: the git toplevel when
161
+ // there is one, else the resolved directory. Keying on the repo root rather
162
+ // than the raw cwd means an override set at the top of a repo still applies
163
+ // when the agent is working three directories down.
164
+ export function overrideKey(cwd) {
165
+ const dir = cwd && String(cwd).trim() ? String(cwd) : process.cwd();
166
+ try {
167
+ const top = execSync("git rev-parse --show-toplevel", {
168
+ cwd: dir,
169
+ stdio: ["ignore", "pipe", "ignore"],
170
+ timeout: 500,
171
+ })
172
+ .toString()
173
+ .trim();
174
+ if (top) return resolve(top);
175
+ } catch {
176
+ // not a repo, or no git — fall through to the plain path
177
+ }
178
+ return resolve(dir);
179
+ }
180
+
181
+ // readOverride returns the override in effect for `cwd`, or null.
182
+ //
183
+ // The file is read BEFORE the key is computed, because the key costs a `git
184
+ // rev-parse` and this runs on every chat.message: nobody should pay for a git
185
+ // call to discover they have no overrides at all, which is the common case.
186
+ // Any error — missing file, hand-edited JSON, wrong shape — degrades to "no
187
+ // override" rather than throwing into opencode. Exported for testing.
188
+ export function readOverride(cwd, path) {
189
+ let file;
190
+ try {
191
+ file = JSON.parse(readFileSync(path || overridesPath(), "utf8"));
192
+ } catch {
193
+ return null;
194
+ }
195
+ const overrides = file && typeof file === "object" ? file.overrides : null;
196
+ if (!overrides || typeof overrides !== "object" || Object.keys(overrides).length === 0) return null;
197
+ const entry = overrides[overrideKey(cwd)];
198
+ if (!entry || typeof entry !== "object") return null;
199
+ const ns = typeof entry.namespace === "string" ? entry.namespace.trim() : "";
200
+ if (!ns) return null;
201
+ return { namespace: ns, setAt: typeof entry.setAt === "string" ? entry.setAt : "" };
202
+ }
203
+
139
204
  // resolveConfig merges env vars with the options object (options win), filling
140
205
  // in defaults. Exported for testing.
141
- export function resolveConfig(env, options, worktree) {
206
+ //
207
+ // Namespace precedence: project override > namespace option / MEMINI_NAMESPACE >
208
+ // config template > git worktree > default. The override sits ABOVE the env var
209
+ // (and above the inline option) on purpose: a globally exported MEMINI_NAMESPACE
210
+ // — a shell rc, or a fish universal variable — pins every repo on the machine to
211
+ // one namespace, and if the env won, setting an override would silently do
212
+ // nothing on exactly the machines that need one. The same argument applies to a
213
+ // `namespace` option in ~/.config/opencode/opencode.json, which pins every
214
+ // project the same way; and `memini doctor` reports the override as in force
215
+ // regardless, so anything else would make the two disagree.
216
+ //
217
+ // opts.ignoreOverride skips the override so the status tool can ask what the
218
+ // namespace would be without it — it lives in a file, so no amount of doctoring
219
+ // `env` would strip it. opts.overridesPath is for tests.
220
+ export function resolveConfig(env, options, worktree, opts = {}) {
142
221
  const e = env || {};
143
222
  const o = options || {};
144
- // An explicit namespace (option or MEMINI_NAMESPACE env) wins and is used
145
- // raw-trimmed: the server validates the header, and flattening "/" here would
146
- // split a tenant path like work/memini from the other integrations.
223
+ const dir = worktree || process.cwd();
224
+ const override = opts.ignoreOverride ? null : readOverride(dir, opts.overridesPath);
225
+ // An explicit namespace (option or MEMINI_NAMESPACE env) is used raw-trimmed:
226
+ // the server validates the header, and flattening "/" here would split a
227
+ // tenant path like work/memini from the other integrations. The override is
228
+ // written through @memini/client, which validates it, so it is used as-is too.
147
229
  const explicit = o.namespace || e.MEMINI_NAMESPACE;
148
230
  let namespace;
149
- if (explicit && String(explicit).trim()) {
231
+ let namespace_source;
232
+ if (override) {
233
+ namespace = override.namespace;
234
+ namespace_source = "override";
235
+ } else if (explicit && String(explicit).trim()) {
150
236
  namespace = String(explicit).trim();
237
+ namespace_source = o.namespace ? "option" : "env";
151
238
  } else {
152
239
  // Config present -> render the config template (tenant segments already
153
240
  // sanitized, "/" preserved); otherwise fall back to the legacy cwd chain.
154
- namespace =
155
- resolveConfigNamespace(worktree || process.cwd()) ||
156
- deriveNamespace(worktree) ||
157
- DEFAULT_NAMESPACE;
241
+ const fromConfig = resolveConfigNamespace(dir);
242
+ const fromWorktree = deriveNamespace(worktree);
243
+ namespace = fromConfig || fromWorktree || DEFAULT_NAMESPACE;
244
+ namespace_source = fromConfig ? "config" : fromWorktree ? "worktree" : "default";
158
245
  }
159
246
  // Number.isFinite guard: malformed env / option falls through to the next
160
247
  // source instead of NaN flowing into the request body.
@@ -177,6 +264,11 @@ export function resolveConfig(env, options, worktree) {
177
264
  // per-segment-sanitized config/derived value); re-sanitizing here would
178
265
  // flatten tenant "/" separators.
179
266
  namespace: namespace || DEFAULT_NAMESPACE,
267
+ // Where the namespace came from, and the override itself when one is in
268
+ // force. Carried on the config so the status tool reports what the plugin
269
+ // actually does rather than a second, idealized resolution of its own.
270
+ namespace_source,
271
+ override,
180
272
  home,
181
273
  recall: o.recall !== undefined ? o.recall !== false : envBool(e.MEMINI_RECALL, true),
182
274
  capture: o.capture !== undefined ? o.capture !== false : envBool(e.MEMINI_CAPTURE, true),
@@ -384,6 +476,188 @@ export function truncate(value, max) {
384
476
  return value;
385
477
  }
386
478
 
479
+ // --- Status --------------------------------------------------------------
480
+ //
481
+ // "What is this plugin actually doing right now?" A list of values would not
482
+ // answer that. The case worth catching is MEMINI_NAMESPACE exported globally (a
483
+ // shell rc, or a fish universal variable), set once and forgotten, quietly
484
+ // collapsing every repo on the machine into one namespace: the value looks
485
+ // fine, only its provenance gives it away. So the namespace is resolved three
486
+ // times against progressively stripped inputs — as-is, without the override,
487
+ // and without the override AND the env/option pin — and all three are reported.
488
+
489
+ /**
490
+ * Render a secret as a recognizable-but-useless fingerprint: enough to tell two
491
+ * tokens apart, not enough to use. Short values are elided entirely rather than
492
+ * half-revealed. Mirrors packages/memini-client's redactValue. Exported for
493
+ * testing.
494
+ */
495
+ export function redactSecret(value) {
496
+ if (!value) return "";
497
+ return value.length <= 12 ? "***" : `${value.slice(0, 3)}…${value.slice(-4)}`;
498
+ }
499
+
500
+ /**
501
+ * Build the effective-settings report: the three namespace resolutions, the
502
+ * knobs with their provenance (secrets redacted), the paths, and the warnings.
503
+ * Exported for testing.
504
+ */
505
+ export function describeSettings(env, options, worktree) {
506
+ const e = env || {};
507
+ const o = options || {};
508
+ const dir = worktree || process.cwd();
509
+
510
+ const cfg = resolveConfig(e, o, worktree);
511
+ // Both counterfactuals must ignore the override explicitly: it lives in a
512
+ // file, so a resolution handed a stripped env would hand it straight back —
513
+ // and these are the two lines that exist to see past it.
514
+ const withoutOverride = resolveConfig(e, o, worktree, { ignoreOverride: true });
515
+ const envSansPin = { ...e };
516
+ delete envSansPin.MEMINI_NAMESPACE;
517
+ const derived = resolveConfig(
518
+ envSansPin,
519
+ { ...o, namespace: undefined },
520
+ worktree,
521
+ { ignoreOverride: true },
522
+ );
523
+
524
+ const secret = e.MEMINI_API_KEY || e.MEMINI_TOKEN || "";
525
+ const warnings = [];
526
+
527
+ if (cfg.override) {
528
+ warnings.push({
529
+ level: "note",
530
+ code: "override-active",
531
+ message:
532
+ `namespace is overridden to "${cfg.override.namespace}" for this project` +
533
+ (cfg.override.setAt ? ` (set ${cfg.override.setAt})` : "") +
534
+ `; without it this project would use "${withoutOverride.namespace}".`,
535
+ fix: `Remove the entry for ${overrideKey(dir)} from ${overridesPath(e)} to return to automatic resolution.`,
536
+ });
537
+ }
538
+
539
+ // The finding this whole report exists for.
540
+ const pin = String(e.MEMINI_NAMESPACE || "").trim();
541
+ if (pin && !cfg.override && derived.namespace && derived.namespace !== pin) {
542
+ warnings.push({
543
+ level: "warn",
544
+ code: "global-namespace-pin",
545
+ message:
546
+ `MEMINI_NAMESPACE is set to "${pin}", which pins EVERY project on this machine to one ` +
547
+ `namespace. This project would otherwise resolve to "${derived.namespace}". If it is ` +
548
+ `exported from a shell rc (or a fish universal variable), every repo you work in is ` +
549
+ `sharing one memory pool.`,
550
+ fix: "Unset MEMINI_NAMESPACE and let each repo resolve on its own, or set a per-project override instead.",
551
+ });
552
+ }
553
+
554
+ if (usesPlaintextBearerAuth(cfg.base_url, secret)) {
555
+ warnings.push({
556
+ level: "warn",
557
+ code: "plaintext-bearer",
558
+ message: plaintextBearerAuthMessage(cfg.base_url),
559
+ fix: "Use HTTPS, or tunnel over SSH. Set MEMINI_REQUIRE_HTTPS=1 to make this an error.",
560
+ });
561
+ }
562
+
563
+ if (!cfg.home) {
564
+ warnings.push({
565
+ level: "note",
566
+ code: "home-unset",
567
+ message: "MEMINI_HOME is unset: no personal leg merges into recall.",
568
+ fix: "Export MEMINI_HOME=personal/<you>.",
569
+ });
570
+ }
571
+
572
+ return {
573
+ project: overrideKey(dir),
574
+ worktree: dir,
575
+ namespace: {
576
+ effective: cfg.namespace,
577
+ source: cfg.namespace_source,
578
+ override: cfg.override,
579
+ withoutOverride,
580
+ derived,
581
+ home: cfg.home,
582
+ },
583
+ connection: {
584
+ base_url: cfg.base_url,
585
+ api_key: secret ? redactSecret(secret) : "",
586
+ require_https: e.MEMINI_REQUIRE_HTTPS === "1",
587
+ timeout_ms: cfg.timeout_ms,
588
+ },
589
+ memory: {
590
+ recall: cfg.recall,
591
+ capture: cfg.capture,
592
+ recall_limit: cfg.recall_limit,
593
+ recall_max_tokens: cfg.recall_max_tokens,
594
+ recall_min_score: cfg.recall_min_score,
595
+ labels: [...labelsEnv()],
596
+ },
597
+ paths: { overrides: overridesPath(e) },
598
+ warnings,
599
+ };
600
+ }
601
+
602
+ const padTo = (s, n) => String(s).padEnd(n);
603
+
604
+ /** Render describeSettings() as the text block the tool hands back. */
605
+ export function renderStatus(report) {
606
+ const { namespace: ns, connection, memory, paths } = report;
607
+ const L = [];
608
+
609
+ L.push("memini — effective settings (opencode)");
610
+ L.push(`project: ${report.project}`);
611
+ L.push("");
612
+
613
+ L.push("NAMESPACE");
614
+ L.push(` ${padTo("effective", 26)} ${padTo(ns.effective, 30)} <- ${ns.source}`);
615
+ if (ns.override) {
616
+ L.push(
617
+ ` ${padTo("without the override", 26)} ${padTo(ns.withoutOverride.namespace, 30)} <- ${ns.withoutOverride.source}`,
618
+ );
619
+ }
620
+ if (ns.derived.namespace !== ns.effective) {
621
+ L.push(
622
+ ` ${padTo("git/cwd would give", 26)} ${padTo(ns.derived.namespace, 30)} <- ${ns.derived.source}`,
623
+ );
624
+ }
625
+ L.push(` ${padTo("home (personal)", 26)} ${ns.home || "(unset)"}`);
626
+ L.push("");
627
+
628
+ L.push("CONNECTION");
629
+ L.push(` ${padTo("base_url", 26)} ${connection.base_url}`);
630
+ L.push(` ${padTo("api_key", 26)} ${connection.api_key || "(unset)"}`);
631
+ L.push(` ${padTo("require_https", 26)} ${connection.require_https ? "1" : "0"}`);
632
+ L.push(` ${padTo("timeout_ms", 26)} ${connection.timeout_ms}`);
633
+ L.push("");
634
+
635
+ L.push("MEMORY");
636
+ L.push(` ${padTo("recall", 26)} ${memory.recall ? "on" : "off"}`);
637
+ L.push(` ${padTo("capture", 26)} ${memory.capture ? "on" : "off"}`);
638
+ L.push(` ${padTo("recall_limit", 26)} ${memory.recall_limit}`);
639
+ L.push(` ${padTo("recall_max_tokens", 26)} ${memory.recall_max_tokens || "uncapped"}`);
640
+ L.push(` ${padTo("recall_min_score", 26)} ${memory.recall_min_score}`);
641
+ L.push(` ${padTo("labels", 26)} ${memory.labels.length ? memory.labels.join(",") : "(none)"}`);
642
+ L.push("");
643
+
644
+ L.push("PATHS");
645
+ L.push(` ${padTo("overrides", 26)} ${paths.overrides}${existsSync(paths.overrides) ? "" : " (absent)"}`);
646
+ L.push("");
647
+
648
+ if (report.warnings.length) {
649
+ L.push("WARNINGS");
650
+ for (const w of report.warnings) {
651
+ L.push(` [${w.level === "warn" ? "!" : "i"}] ${w.code}: ${w.message}`);
652
+ if (w.fix) L.push(` fix: ${w.fix}`);
653
+ }
654
+ } else {
655
+ L.push("No problems detected.");
656
+ }
657
+
658
+ return L.join("\n");
659
+ }
660
+
387
661
  function createClient(cfg, log) {
388
662
  const baseUrl = String(cfg.base_url).replace(/\/+$/, "");
389
663
  const secret = process.env.MEMINI_API_KEY || process.env.MEMINI_TOKEN;
@@ -511,6 +785,46 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
511
785
  };
512
786
 
513
787
  return {
788
+ // A tool, not a slash command, and deliberately so. opencode's plugin
789
+ // contract (Hooks in @opencode-ai/plugin) registers tools — `tool: { [id]:
790
+ // { description, args, execute } }` — but exposes no hook for registering a
791
+ // user-invocable command, so there is no `/memini:status` this plugin could
792
+ // offer without inventing one. `args` is empty: declaring a parameter means
793
+ // handing opencode a zod schema, and this plugin has no dependencies (a
794
+ // raw-JSON-Schema arg rides a compatibility path that older hosts feed
795
+ // straight to z.object() and throw on). A zero-arg tool is the shape every
796
+ // version accepts, and a read-only report needs no arguments anyway.
797
+ //
798
+ // Settings are recomputed per call rather than read off `cfg`, so an
799
+ // override set mid-session is visible here without restarting opencode —
800
+ // even though the hooks below still use the namespace they resolved at load.
801
+ tool: {
802
+ memini_status: {
803
+ description:
804
+ "Show the memini memory settings in force for this project: which namespace memories " +
805
+ "are written to and recalled from, where that namespace came from (a per-project " +
806
+ "override, MEMINI_NAMESPACE, the config file, or the git worktree), what it would be " +
807
+ "without each of those, and any misconfiguration worth flagging. Read-only; secrets " +
808
+ "are redacted. Call it when the user asks what memini is doing, why a memory cannot " +
809
+ "be recalled, or which namespace is in use.",
810
+ args: {},
811
+ execute: async () => {
812
+ try {
813
+ const report = describeSettings(process.env, options, worktree || directory);
814
+ return {
815
+ title: `memini: ${report.namespace.effective}`,
816
+ output: renderStatus(report),
817
+ metadata: { namespace: report.namespace.effective, source: report.namespace.source },
818
+ };
819
+ } catch (error) {
820
+ // A diagnostic that crashes the turn it was meant to diagnose is
821
+ // worse than no diagnostic.
822
+ return `memini status failed: ${String(error)}`;
823
+ }
824
+ },
825
+ },
826
+ },
827
+
514
828
  "chat.message": guard("chat.message", async (input, output) => {
515
829
  if (!cfg.recall) return;
516
830
  const query = extractPartsText(output && output.parts);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eleboucher/opencode-memini",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "description": "Automatic cross-session memory for opencode via memini — recall before each turn, capture after.",
5
5
  "keywords": [
6
6
  "memini",