@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.
Files changed (3) hide show
  1. package/README.md +14 -11
  2. package/memini.js +179 -14
  3. 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 | 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
- | `timeout_ms` | `MEMINI_TIMEOUT_MS` | `30000` | per-request timeout |
54
- | `fallback_on_error` | `MEMINI_FALLBACK` | on | `false` surfaces errors instead of degrading silently |
55
- | | `MEMINI_API_KEY` | | bearer token, if memini needs auth (env only — secret) |
56
- | | `MEMINI_REQUIRE_HTTPS` | | `1` refuses to send the token over plaintext HTTP |
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: Number(o.recall_limit || e.MEMINI_RECALL_LIMIT || DEFAULT_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 renders memini search hits as a compact bullet list. Exported
81
- // for testing.
82
- export function formatResults(results, limit) {
83
- if (!Array.isArray(results) || results.length === 0) return "";
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.slice(0, 300)}`;
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
- const block = formatResults(result && result.results, cfg.recall_limit);
225
- if (!block) return;
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eleboucher/opencode-memini",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "description": "Automatic cross-session memory for opencode via memini — recall before each turn, capture after.",
5
5
  "keywords": [
6
6
  "memini",