@eleboucher/opencode-memini 0.6.7 → 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.
- package/README.md +39 -1
- package/memini.js +324 -10
- 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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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);
|