@eleboucher/opencode-memini 0.4.6 → 0.4.7
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 +14 -11
- package/memini.js +179 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -43,17 +43,20 @@ Pass options inline via the `[name, options]` form:
|
|
|
43
43
|
}
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
| Option | Env var
|
|
47
|
-
| ------------------- |
|
|
48
|
-
| `base_url` | `MEMINI_BASE_URL`
|
|
49
|
-
| `namespace` | `MEMINI_NAMESPACE`
|
|
50
|
-
| `recall` | `MEMINI_RECALL`
|
|
51
|
-
| `capture` | `MEMINI_CAPTURE`
|
|
52
|
-
| `recall_limit` | `MEMINI_RECALL_LIMIT`
|
|
53
|
-
| `
|
|
54
|
-
| `
|
|
55
|
-
|
|
|
56
|
-
|
|
|
46
|
+
| Option | Env var | Default | Purpose |
|
|
47
|
+
| ------------------- | -------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
48
|
+
| `base_url` | `MEMINI_BASE_URL` | `http://localhost:8080` | memini REST base URL |
|
|
49
|
+
| `namespace` | `MEMINI_NAMESPACE` | git repo basename | tenant the memory is scoped to (`X-Memini-Namespace`) |
|
|
50
|
+
| `recall` | `MEMINI_RECALL` | on | `false` disables recall-before-turn |
|
|
51
|
+
| `capture` | `MEMINI_CAPTURE` | on | `false` disables capture-after-turn |
|
|
52
|
+
| `recall_limit` | `MEMINI_RECALL_LIMIT` | `5` | max memories injected per turn |
|
|
53
|
+
| `recall_max_tokens` | `MEMINI_INJECT_RECALL_MAX_TOK` | `0` | hard ceiling on the recall-block tokens (`0` = unbounded); the tail is dropped with a `[… N item(s) truncated by token budget]` footer |
|
|
54
|
+
| `recall_min_score` | `MEMINI_INJECT_RECALL_MIN_SCORE` | `0` | fused-score floor (>=) sent as `min_score` to `/v1/search` |
|
|
55
|
+
| `timeout_ms` | `MEMINI_TIMEOUT_MS` | `30000` | per-request timeout |
|
|
56
|
+
| `fallback_on_error` | `MEMINI_FALLBACK` | on | `false` surfaces errors instead of degrading silently |
|
|
57
|
+
| — | `MEMINI_INJECT_LABELS` | — | comma-separated label toggles for each bullet: `tier`, `confidence`, `age` |
|
|
58
|
+
| — | `MEMINI_API_KEY` | — | bearer token, if memini needs auth (env only — secret) |
|
|
59
|
+
| — | `MEMINI_REQUIRE_HTTPS` | — | `1` refuses to send the token over plaintext HTTP |
|
|
57
60
|
|
|
58
61
|
Inline options win over the env vars. Secrets stay in the environment: set
|
|
59
62
|
`MEMINI_API_KEY` (sent as `Authorization: Bearer …`), and optionally
|
package/memini.js
CHANGED
|
@@ -51,12 +51,29 @@ export function resolveConfig(env, options, worktree) {
|
|
|
51
51
|
const o = options || {};
|
|
52
52
|
const namespace =
|
|
53
53
|
o.namespace || e.MEMINI_NAMESPACE || deriveNamespace(worktree) || DEFAULT_NAMESPACE;
|
|
54
|
+
// Number.isFinite guard: malformed env / option falls through to the next
|
|
55
|
+
// source instead of NaN flowing into the request body.
|
|
56
|
+
const recall_limit = (() => {
|
|
57
|
+
for (const v of [o.recall_limit, e.MEMINI_RECALL_LIMIT, DEFAULT_RECALL_LIMIT]) {
|
|
58
|
+
const n = Number(v);
|
|
59
|
+
if (Number.isFinite(n) && n >= 0) return n;
|
|
60
|
+
}
|
|
61
|
+
return DEFAULT_RECALL_LIMIT;
|
|
62
|
+
})();
|
|
54
63
|
return {
|
|
55
64
|
base_url: o.base_url || e.MEMINI_BASE_URL || DEFAULT_BASE_URL,
|
|
56
65
|
namespace: sanitizeNamespace(namespace) || DEFAULT_NAMESPACE,
|
|
57
66
|
recall: o.recall !== undefined ? o.recall !== false : envBool(e.MEMINI_RECALL, true),
|
|
58
67
|
capture: o.capture !== undefined ? o.capture !== false : envBool(e.MEMINI_CAPTURE, true),
|
|
59
|
-
recall_limit
|
|
68
|
+
recall_limit,
|
|
69
|
+
recall_max_tokens:
|
|
70
|
+
o.recall_max_tokens !== undefined
|
|
71
|
+
? Number(o.recall_max_tokens) || 0
|
|
72
|
+
: intEnv("MEMINI_INJECT_RECALL_MAX_TOK", 0),
|
|
73
|
+
recall_min_score:
|
|
74
|
+
o.recall_min_score !== undefined
|
|
75
|
+
? Number(o.recall_min_score) || 0
|
|
76
|
+
: floatEnv("MEMINI_INJECT_RECALL_MIN_SCORE", 0),
|
|
60
77
|
timeout_ms: Number(o.timeout_ms || e.MEMINI_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
|
|
61
78
|
fallback_on_error:
|
|
62
79
|
o.fallback_on_error !== undefined
|
|
@@ -77,20 +94,40 @@ export function extractPartsText(parts) {
|
|
|
77
94
|
.trim();
|
|
78
95
|
}
|
|
79
96
|
|
|
80
|
-
// formatResults
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
97
|
+
// formatResults returns an array of bullet lines; the caller passes it to
|
|
98
|
+
// fitByTokens to apply a token ceiling, then joins + appends a footer.
|
|
99
|
+
//
|
|
100
|
+
// `labels` (optional) toggles the rich prefix: empty -> "- (tier) text" (the
|
|
101
|
+
// prior format, kept identical so snapshots don't break); non-empty ->
|
|
102
|
+
// "[tier · conf · age] text", same shape as the Claude Code plugin's
|
|
103
|
+
// formatMemory in plugin/scripts/session-start.mjs. Exported for testing.
|
|
104
|
+
export function formatResults(results, limit, labels) {
|
|
105
|
+
if (!Array.isArray(results) || results.length === 0) return [];
|
|
106
|
+
const useLabels = labels && labels.size > 0 ? labels : null;
|
|
84
107
|
return results
|
|
85
108
|
.slice(0, limit || DEFAULT_RECALL_LIMIT)
|
|
86
109
|
.map((result, index) => {
|
|
87
110
|
const mem = (result && result.memory) || {};
|
|
88
|
-
const text = String(mem.summary || mem.content || `Memory ${index + 1}`).trim();
|
|
111
|
+
const text = truncate(String(mem.summary || mem.content || `Memory ${index + 1}`).trim(), 300);
|
|
112
|
+
if (!text) return null;
|
|
89
113
|
const tier = String(mem.tier || "memory").trim();
|
|
90
|
-
return `- (${tier}) ${text
|
|
114
|
+
if (!useLabels) return `- (${tier}) ${text}`;
|
|
115
|
+
const tagParts = [];
|
|
116
|
+
if (useLabels.has("tier") && tier) tagParts.push(tier);
|
|
117
|
+
if (useLabels.has("confidence") && typeof mem.confidence === "number") {
|
|
118
|
+
tagParts.push(`conf=${mem.confidence.toFixed(2)}`);
|
|
119
|
+
}
|
|
120
|
+
if (useLabels.has("age") && mem.created_at) {
|
|
121
|
+
const ageMs = Date.now() - new Date(mem.created_at).getTime();
|
|
122
|
+
if (Number.isFinite(ageMs) && ageMs >= 0) {
|
|
123
|
+
const days = Math.floor(ageMs / 86400000);
|
|
124
|
+
tagParts.push(days === 0 ? "today" : `${days}d`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (tagParts.length === 0) return `- (${tier}) ${text}`;
|
|
128
|
+
return `[${tagParts.join(" · ")}] ${text}`;
|
|
91
129
|
})
|
|
92
|
-
.filter(Boolean)
|
|
93
|
-
.join("\n");
|
|
130
|
+
.filter(Boolean);
|
|
94
131
|
}
|
|
95
132
|
|
|
96
133
|
function normalizedHostname(hostname) {
|
|
@@ -127,6 +164,111 @@ export function createPlaintextBearerAuthGuard(warn, env) {
|
|
|
127
164
|
};
|
|
128
165
|
}
|
|
129
166
|
|
|
167
|
+
// --- Injection budget ----------------------------------------------------
|
|
168
|
+
//
|
|
169
|
+
// Near-verbatim copies of plugin/scripts/_shared.mjs. The opencode plugin
|
|
170
|
+
// ships standalone on npm so it can't import across the tree; copy matches
|
|
171
|
+
// the precedent set by createPlaintextBearerAuthGuard above. Keep contracts
|
|
172
|
+
// identical when both sides change.
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* intEnv parses a positive integer env var (>= 0) and returns `default` when
|
|
176
|
+
* unset or malformed. A negative value also falls back — env values are user
|
|
177
|
+
* input and shouldn't crash a hook.
|
|
178
|
+
*/
|
|
179
|
+
export function intEnv(name, defaultValue) {
|
|
180
|
+
const raw = process.env[name];
|
|
181
|
+
if (raw == null || raw === "") return defaultValue;
|
|
182
|
+
const n = Number.parseInt(raw, 10);
|
|
183
|
+
if (!Number.isFinite(n) || n < 0) return defaultValue;
|
|
184
|
+
return n;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* floatEnv parses a non-negative float env var and returns `default` when
|
|
189
|
+
* unset or malformed. Used for min_score.
|
|
190
|
+
*/
|
|
191
|
+
export function floatEnv(name, defaultValue) {
|
|
192
|
+
const raw = process.env[name];
|
|
193
|
+
if (raw == null || raw === "") return defaultValue;
|
|
194
|
+
const n = Number.parseFloat(raw);
|
|
195
|
+
if (!Number.isFinite(n) || n < 0) return defaultValue;
|
|
196
|
+
return n;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* labelsEnv parses MEMINI_INJECT_LABELS into a Set of enabled labels.
|
|
201
|
+
* Recognized: "tier", "confidence", "age", "reason". Empty/unset returns an
|
|
202
|
+
* empty Set — the format helpers then skip every label.
|
|
203
|
+
*/
|
|
204
|
+
export function labelsEnv(name = "MEMINI_INJECT_LABELS") {
|
|
205
|
+
const raw = process.env[name];
|
|
206
|
+
if (!raw) return new Set();
|
|
207
|
+
return new Set(
|
|
208
|
+
raw
|
|
209
|
+
.split(/[|,]/)
|
|
210
|
+
.map((s) => s.trim().toLowerCase())
|
|
211
|
+
.filter(Boolean),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* approxTokens is a cheap token estimator. ~0.75 tokens/word for English-ish
|
|
217
|
+
* content, with a floor of 1 so a single non-empty line never reports 0.
|
|
218
|
+
*/
|
|
219
|
+
export function approxTokens(text) {
|
|
220
|
+
if (!text) return 0;
|
|
221
|
+
const words = String(text).trim().split(/\s+/).filter(Boolean).length;
|
|
222
|
+
return Math.max(1, Math.ceil((words * 4) / 3));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* fitByTokens trims a list of pre-formatted strings to fit under `maxTokens`,
|
|
227
|
+
* keeping the head (the most-relevant entries first). Returns the trimmed
|
|
228
|
+
* list and the running token total, so callers can render a "[… truncated]"
|
|
229
|
+
* footer when items were dropped.
|
|
230
|
+
*/
|
|
231
|
+
export function fitByTokens(items, maxTokens) {
|
|
232
|
+
if (!Array.isArray(items) || items.length === 0) return { items: [], tokens: 0, dropped: 0 };
|
|
233
|
+
if (!Number.isFinite(maxTokens) || maxTokens <= 0) {
|
|
234
|
+
const tokens = items.reduce((sum, s) => sum + approxTokens(s), 0);
|
|
235
|
+
return { items: items.slice(), tokens, dropped: 0 };
|
|
236
|
+
}
|
|
237
|
+
const out = [];
|
|
238
|
+
let used = 0;
|
|
239
|
+
let dropped = 0;
|
|
240
|
+
for (const s of items) {
|
|
241
|
+
const t = approxTokens(s);
|
|
242
|
+
if (used + t > maxTokens) {
|
|
243
|
+
dropped++;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
out.push(s);
|
|
247
|
+
used += t;
|
|
248
|
+
}
|
|
249
|
+
return { items: out, tokens: used, dropped };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Truncate to `max` bytes, suffix with a marker. Same shape as the Claude
|
|
254
|
+
* Code plugin's truncate helper.
|
|
255
|
+
*/
|
|
256
|
+
export function truncate(value, max) {
|
|
257
|
+
if (typeof value === "string") {
|
|
258
|
+
return value.length > max ? value.slice(0, max) + "\n[...truncated]" : value;
|
|
259
|
+
}
|
|
260
|
+
if (value && typeof value === "object") {
|
|
261
|
+
let str;
|
|
262
|
+
try {
|
|
263
|
+
str = JSON.stringify(value);
|
|
264
|
+
} catch {
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
267
|
+
return str.length > max ? str.slice(0, max) + "...[truncated]" : str;
|
|
268
|
+
}
|
|
269
|
+
return value;
|
|
270
|
+
}
|
|
271
|
+
|
|
130
272
|
function createClient(cfg, log) {
|
|
131
273
|
const baseUrl = String(cfg.base_url).replace(/\/+$/, "");
|
|
132
274
|
const secret = process.env.MEMINI_API_KEY;
|
|
@@ -220,9 +362,34 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
220
362
|
// context, so recalling them just echoes the conversation back a turn
|
|
221
363
|
// behind. Captures from other (past) sessions are still recalled.
|
|
222
364
|
if (sessionID) body.exclude_metadata = { session_id: sessionID };
|
|
365
|
+
// min_score (fused-score floor) is optional and matches the wire knob
|
|
366
|
+
// the Claude Code plugin's pre-tool-use hook uses; client-side re-filter
|
|
367
|
+
// is a belt-and-braces guard against score-normalization edge cases.
|
|
368
|
+
if (cfg.recall_min_score > 0) body.min_score = cfg.recall_min_score;
|
|
223
369
|
const result = await rest.postJson("/v1/search", body);
|
|
224
|
-
|
|
225
|
-
|
|
370
|
+
// Client-side score floor: filter the raw hit list before formatting so
|
|
371
|
+
// the bullet array only contains hits the operator asked for. Without
|
|
372
|
+
// this, the server's default floor could leak low-quality hits in
|
|
373
|
+
// regardless of cfg.recall_min_score.
|
|
374
|
+
const floor = cfg.recall_min_score > 0 ? cfg.recall_min_score : 0;
|
|
375
|
+
const rawHits = Array.isArray(result && result.results) ? result.results : [];
|
|
376
|
+
const filtered = floor > 0
|
|
377
|
+
? rawHits.filter((r) => (typeof r?.score === "number" ? r.score : 0) >= floor)
|
|
378
|
+
: rawHits;
|
|
379
|
+
const labels = labelsEnv();
|
|
380
|
+
const hits = formatResults(filtered, cfg.recall_limit, labels);
|
|
381
|
+
if (hits.length === 0) return;
|
|
382
|
+
// Apply the token ceiling to the rendered bullet lines; with max=0
|
|
383
|
+
// (the default) fitByTokens returns the full list unchanged, so the
|
|
384
|
+
// behaviour matches the prior "no cap" code path for existing installs.
|
|
385
|
+
const fit = fitByTokens(hits, cfg.recall_max_tokens);
|
|
386
|
+
if (fit.items.length === 0) return;
|
|
387
|
+
const lines = [
|
|
388
|
+
`Relevant long-term memory from memini (background context — prefer ` +
|
|
389
|
+
`current workspace state and the user's instructions):`,
|
|
390
|
+
...fit.items,
|
|
391
|
+
];
|
|
392
|
+
if (fit.dropped > 0) lines.push(`[... ${fit.dropped} item(s) truncated by token budget]`);
|
|
226
393
|
// opencode's part schema requires ids to start with `prt`.
|
|
227
394
|
output.parts.unshift({
|
|
228
395
|
id: `prt_${crypto.randomUUID()}`,
|
|
@@ -230,9 +397,7 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
230
397
|
messageID,
|
|
231
398
|
type: "text",
|
|
232
399
|
synthetic: true,
|
|
233
|
-
text:
|
|
234
|
-
`Relevant long-term memory from memini (background context — prefer ` +
|
|
235
|
-
`current workspace state and the user's instructions):\n${block}`,
|
|
400
|
+
text: lines.join("\n"),
|
|
236
401
|
});
|
|
237
402
|
},
|
|
238
403
|
|