@eleboucher/opencode-memini 0.6.12 → 0.7.1
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 +58 -35
- package/memini-v2.js +384 -0
- package/memini.js +291 -274
- 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
|
|
16
|
-
* the environment.
|
|
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 {
|
|
21
|
-
import {
|
|
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)
|
|
48
|
-
//
|
|
49
|
-
//
|
|
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
|
-
|
|
53
|
-
return sanitizeNamespace(base);
|
|
89
|
+
return sanitizeNamespace(basenameOf(worktree));
|
|
54
90
|
}
|
|
55
91
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
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)
|
|
80
|
-
|
|
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
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
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
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
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
|
-
|
|
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 (
|
|
237
|
-
namespace =
|
|
238
|
-
namespace_source = "
|
|
239
|
-
} else if (
|
|
240
|
-
namespace = String(
|
|
241
|
-
namespace_source =
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
273
|
-
//
|
|
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 ||
|
|
278
|
-
// namespace is already resolved above (explicit raw-trimmed, or
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
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
|
|
502
|
-
//
|
|
503
|
-
//
|
|
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
|
|
518
|
-
*
|
|
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
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
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 ||
|
|
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 &&
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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":
|
|
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
|
-
|
|
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
|
|
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 (
|
|
882
|
-
"
|
|
883
|
-
"without
|
|
884
|
-
"are redacted. Call it when the user asks what memini is doing, why a memory
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
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 (
|
|
952
|
+
if (live.recall_budget_ms > 0) {
|
|
941
953
|
let timer;
|
|
942
954
|
const budget = new Promise((resolve) => {
|
|
943
|
-
timer = setTimeout(() => resolve(BUDGET_EXPIRED),
|
|
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 ${
|
|
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
|
|
966
|
-
const floor =
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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(
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
};
|