@eleboucher/opencode-memini 0.7.2 → 0.7.4

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 CHANGED
@@ -83,6 +83,7 @@ Pass options inline via the `[name, options]` form:
83
83
  | `recall_budget_ms` | `MEMINI_RECALL_BUDGET_MS` | `2000` | how long a turn waits for recall before proceeding without it (`0` = wait for the full `timeout_ms`) |
84
84
  | `timeout_ms` | `MEMINI_TIMEOUT_MS` | `30000` | per-request timeout (recall past its budget keeps running in the background under this bound) |
85
85
  | `fallback_on_error` | `MEMINI_FALLBACK` | on | `false` surfaces errors instead of degrading silently |
86
+ | `auto_update` | `MEMINI_AUTO_UPDATE` | on | `false` disables npm auto-update checks (opencode never re-fetches cached plugins otherwise) |
86
87
  | — | `MEMINI_INJECT_LABELS` | — | comma-separated label toggles for each bullet: `tier`, `confidence`, `age`, `reason` |
87
88
  | — | `MEMINI_API_KEY` | — | bearer token, if memini needs auth (env only — secret) |
88
89
  | — | `MEMINI_REQUIRE_HTTPS` | — | `1` refuses to send the token over plaintext HTTP |
package/memini-v2.js CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  resolveConfig,
35
35
  buildFacts,
36
36
  effectiveConfig,
37
+ buildTurnCapture,
37
38
  memoizeAsync,
38
39
  extractPartsText,
39
40
  formatResults,
@@ -322,7 +323,7 @@ export async function setup(ctx) {
322
323
  const stored = await rest.postJson(
323
324
  "/v1/memories",
324
325
  {
325
- content: `${userText.slice(0, 1000)}\n\n${assistantText.slice(0, 3000)}`,
326
+ content: buildTurnCapture(userText, assistantText, live.capture_user_max_chars, live.capture_assistant_max_chars),
326
327
  tags: ["opencode"],
327
328
  metadata,
328
329
  },
package/memini.js CHANGED
@@ -19,9 +19,11 @@
19
19
  * the options/env table in ../README.md.
20
20
  */
21
21
 
22
- import { execSync } from "node:child_process";
23
- import { readFileSync } from "node:fs";
24
- import { resolve } from "node:path";
22
+ import { execSync, spawnSync } from "node:child_process";
23
+ import { readFileSync, existsSync, rmSync, writeFileSync, statSync } from "node:fs";
24
+ import { resolve, join, dirname } from "node:path";
25
+ import { homedir } from "node:os";
26
+ import { fileURLToPath } from "node:url";
25
27
 
26
28
  const DEFAULT_BASE_URL = "http://localhost:8080";
27
29
  const DEFAULT_TIMEOUT_MS = 30000;
@@ -50,6 +52,152 @@ function readPluginVersion() {
50
52
  }
51
53
  const CLIENT_VERSION = readPluginVersion();
52
54
 
55
+ // Auto-update: opencode never re-fetches cached npm plugins, so the plugin
56
+ // checks npm dist-tags once per process and self-updates (same major version
57
+ // only) so the running copy stays current.
58
+ const PACKAGE_NAME = "@eleboucher/opencode-memini";
59
+ const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`;
60
+ const NPM_FETCH_TIMEOUT = 5000;
61
+ const BUN_INSTALL_TIMEOUT_MS = 60000;
62
+ let autoUpdateChecked = false;
63
+
64
+ /**
65
+ * parseVersion extracts major.minor.patch from a semver string. Exported for
66
+ * testing.
67
+ */
68
+ export function parseVersion(version) {
69
+ const normalized = String(version).trim().replace(/^[~^=<>\s]+/, "");
70
+ const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)/);
71
+ if (!match) return null;
72
+ return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
73
+ }
74
+
75
+ /**
76
+ * compareVersions returns -1, 0, or 1. Exported for testing.
77
+ */
78
+ export function compareVersions(a, b) {
79
+ const va = parseVersion(a);
80
+ const vb = parseVersion(b);
81
+ if (!va || !vb) return 0;
82
+ if (va.major !== vb.major) return va.major < vb.major ? -1 : 1;
83
+ if (va.minor !== vb.minor) return va.minor < vb.minor ? -1 : 1;
84
+ if (va.patch !== vb.patch) return va.patch < vb.patch ? -1 : 1;
85
+ return 0;
86
+ }
87
+
88
+ /**
89
+ * resolveInstallContext finds the opencode plugin cache directory that holds
90
+ * this running plugin instance, by walking up from import.meta.url. Returns
91
+ * { installDir, packageJsonPath } or null.
92
+ */
93
+ function resolveInstallContext() {
94
+ try {
95
+ const pluginDir = dirname(fileURLToPath(import.meta.url));
96
+ const nodeModulesDir = dirname(pluginDir);
97
+ const installDir = dirname(nodeModulesDir);
98
+ const packageJsonPath = join(installDir, "package.json");
99
+ if (existsSync(packageJsonPath)) {
100
+ return { installDir, packageJsonPath };
101
+ }
102
+ } catch {}
103
+ return null;
104
+ }
105
+
106
+ /**
107
+ * fetchLatestVersion queries npm dist-tags with a timeout. Returns the version
108
+ * string or null on failure.
109
+ */
110
+ async function fetchLatestVersion() {
111
+ try {
112
+ const controller = new AbortController();
113
+ const timer = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT);
114
+ try {
115
+ const resp = await fetch(NPM_REGISTRY_URL, {
116
+ signal: controller.signal,
117
+ headers: { Accept: "application/json" },
118
+ });
119
+ if (!resp.ok) return null;
120
+ const data = await resp.json();
121
+ return data?.latest ?? null;
122
+ } finally {
123
+ clearTimeout(timer);
124
+ }
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * prepareCacheUpdate rewrites the cache package.json to pin the new version,
132
+ * removes the installed node_modules package, and cleans bun.lock. Returns
133
+ * the installDir on success, null on failure.
134
+ */
135
+ function prepareCacheUpdate(newVersion, log) {
136
+ const ctx = resolveInstallContext();
137
+ if (!ctx) {
138
+ log.warn("auto-update: could not resolve install context");
139
+ return null;
140
+ }
141
+ // Rewrite package.json with the new version pin
142
+ try {
143
+ const pkg = JSON.parse(readFileSync(ctx.packageJsonPath, "utf8"));
144
+ if (pkg.dependencies && pkg.dependencies[PACKAGE_NAME] === newVersion) {
145
+ return ctx.installDir; // already updated
146
+ }
147
+ pkg.dependencies = { ...pkg.dependencies, [PACKAGE_NAME]: newVersion };
148
+ writeFileSync(ctx.packageJsonPath, JSON.stringify(pkg, null, 2));
149
+ } catch (err) {
150
+ log.warn(`auto-update: failed to rewrite cache package.json: ${String(err)}`);
151
+ return null;
152
+ }
153
+ // Remove installed node_modules so bun install re-fetches
154
+ try {
155
+ const pkgDir = join(ctx.installDir, "node_modules", "@eleboucher", "opencode-memini");
156
+ if (existsSync(pkgDir)) rmSync(pkgDir, { recursive: true, force: true });
157
+ } catch (err) {
158
+ log.warn(`auto-update: failed to remove cached node_modules: ${String(err)}`);
159
+ return null;
160
+ }
161
+ // Clean bun.lock entry if it exists
162
+ const lockPath = join(ctx.installDir, "bun.lock");
163
+ if (existsSync(lockPath)) {
164
+ try {
165
+ const lock = JSON.parse(readFileSync(lockPath, "utf8"));
166
+ let modified = false;
167
+ if (lock.workspaces?.[""]?.dependencies?.[PACKAGE_NAME]) {
168
+ delete lock.workspaces[""].dependencies[PACKAGE_NAME];
169
+ modified = true;
170
+ }
171
+ if (lock.packages?.[PACKAGE_NAME]) {
172
+ delete lock.packages[PACKAGE_NAME];
173
+ modified = true;
174
+ }
175
+ if (modified) writeFileSync(lockPath, JSON.stringify(lock, null, 2));
176
+ } catch {
177
+ // bun.lock format varies; if we can't parse it, leave it — bun install
178
+ // will reconcile.
179
+ }
180
+ }
181
+ return ctx.installDir;
182
+ }
183
+
184
+ /**
185
+ * runBunInstall runs `bun install` in the given directory with a timeout.
186
+ * Returns true on success (exit code 0), false otherwise.
187
+ */
188
+ function runBunInstall(installDir) {
189
+ try {
190
+ const result = spawnSync(process.execPath, ["install"], {
191
+ cwd: installDir,
192
+ stdio: ["ignore", "pipe", "pipe"],
193
+ timeout: BUN_INSTALL_TIMEOUT_MS,
194
+ });
195
+ return result.status === 0;
196
+ } catch {
197
+ return false;
198
+ }
199
+ }
200
+
53
201
  // How long a memoized handshake stays trustworthy on a live plugin instance,
54
202
  // and how long a single handshake call is allowed to block before falling
55
203
  // back. Mirrors packages/memini-client's HANDSHAKE_TTL_MS / default timeout;
@@ -204,6 +352,13 @@ export function resolveConfig(env, options, worktree) {
204
352
  o.recall_max_tokens !== undefined
205
353
  ? Number(o.recall_max_tokens) || 0
206
354
  : intEnv("MEMINI_INJECT_RECALL_MAX_TOK", 0),
355
+ // Capture bounds: env override, else the built-in default; effectiveConfig
356
+ // fills in the server's value once a handshake is in hand, but only when
357
+ // the env var was not explicitly set (see `explicit` below). This plugin
358
+ // ships standalone (no build step), so it carries its own copy of the
359
+ // wire keys rather than importing @memini/client.
360
+ capture_user_max_chars: intEnvFrom(e, "MEMINI_CAPTURE_USER_MAX_CHARS", 1000),
361
+ capture_assistant_max_chars: intEnvFrom(e, "MEMINI_CAPTURE_ASSISTANT_MAX_CHARS", 3000),
207
362
  recall_min_score:
208
363
  o.recall_min_score !== undefined
209
364
  ? Number(o.recall_min_score) || 0
@@ -214,6 +369,7 @@ export function resolveConfig(env, options, worktree) {
214
369
  o.fallback_on_error !== undefined
215
370
  ? o.fallback_on_error !== false
216
371
  : envBool(e.MEMINI_FALLBACK, true),
372
+ auto_update: o.auto_update !== undefined ? o.auto_update !== false : envBool(e.MEMINI_AUTO_UPDATE, true),
217
373
  // Recorded so effectiveConfig() can tell "explicitly set to the built-in
218
374
  // default" apart from "not set at all" — only the latter may be filled in
219
375
  // from the server.
@@ -223,6 +379,8 @@ export function resolveConfig(env, options, worktree) {
223
379
  recall_limit: o.recall_limit !== undefined || isSet(e.MEMINI_RECALL_LIMIT),
224
380
  recall_max_tokens: o.recall_max_tokens !== undefined || isSet(process.env.MEMINI_INJECT_RECALL_MAX_TOK),
225
381
  recall_min_score: o.recall_min_score !== undefined || isSet(process.env.MEMINI_INJECT_RECALL_MIN_SCORE),
382
+ capture_user_max_chars: isSet(e.MEMINI_CAPTURE_USER_MAX_CHARS),
383
+ capture_assistant_max_chars: isSet(e.MEMINI_CAPTURE_ASSISTANT_MAX_CHARS),
226
384
  },
227
385
  };
228
386
  }
@@ -233,7 +391,8 @@ export function resolveConfig(env, options, worktree) {
233
391
  // namespace: option/env (cfg.namespace_source already "option"/"env") beats
234
392
  // the handshake's resolved namespace beats cfg's own local
235
393
  // worktree/default fallback.
236
- // recall/capture/recall_limit/recall_max_tokens/recall_min_score: option
394
+ // recall/capture/recall_limit/recall_max_tokens/recall_min_score/
395
+ // capture_user_max_chars/capture_assistant_max_chars: option
237
396
  // beats env (both already baked into cfg, tracked by cfg.explicit) beats
238
397
  // the handshake's `settings` (ClientSettings — api/openapi.yaml) beats
239
398
  // the built-in default already baked into cfg.
@@ -268,6 +427,14 @@ export function effectiveConfig(cfg, hs) {
268
427
  explicit.recall_min_score || !Number.isFinite(s.inject_recall_min_score)
269
428
  ? cfg.recall_min_score
270
429
  : s.inject_recall_min_score,
430
+ capture_user_max_chars:
431
+ explicit.capture_user_max_chars || !Number.isFinite(s.capture_user_max_chars)
432
+ ? cfg.capture_user_max_chars
433
+ : s.capture_user_max_chars,
434
+ capture_assistant_max_chars:
435
+ explicit.capture_assistant_max_chars || !Number.isFinite(s.capture_assistant_max_chars)
436
+ ? cfg.capture_assistant_max_chars
437
+ : s.capture_assistant_max_chars,
271
438
  };
272
439
  }
273
440
 
@@ -383,7 +550,17 @@ export function createPlaintextBearerAuthGuard(warn, env) {
383
550
  * input and shouldn't crash a hook.
384
551
  */
385
552
  export function intEnv(name, defaultValue) {
386
- const raw = process.env[name];
553
+ return intEnvFrom(process.env, name, defaultValue);
554
+ }
555
+
556
+ /**
557
+ * intEnv against an explicit env bag. resolveConfig and describeSettings are
558
+ * handed an `env` and read every other value off it; a knob resolved through
559
+ * intEnv (which closes over process.env) silently ignores that argument, so a
560
+ * caller inspecting a hypothetical environment gets the ambient one instead.
561
+ */
562
+ export function intEnvFrom(env, name, defaultValue) {
563
+ const raw = (env || {})[name];
387
564
  if (raw == null || raw === "") return defaultValue;
388
565
  const n = Number.parseInt(raw, 10);
389
566
  if (!Number.isFinite(n) || n < 0) return defaultValue;
@@ -455,6 +632,44 @@ export function fitByTokens(items, maxTokens) {
455
632
  return { items: out, tokens: used, dropped };
456
633
  }
457
634
 
635
+ /**
636
+ * Truncate `s` to `max` CHARACTERS for a turn capture, marking the cut. `max <= 0`
637
+ * captures it whole. Mirrors @memini/client's truncateForCapture — this plugin
638
+ * ships standalone (no build step), so it carries a copy rather than importing it.
639
+ *
640
+ * Distinct from truncate() below, and deliberately so: this one spreads into an
641
+ * array to iterate by code point, because `slice` indexes UTF-16 code units and
642
+ * would cut an emoji in half into a lone surrogate — invalid UTF-8 on the wire.
643
+ * It also treats 0 as uncapped rather than as "".
644
+ */
645
+ export function truncateForCapture(s, max) {
646
+ s = String(s);
647
+ // Anything not a positive finite number means "no cap": 0 (uncapped by
648
+ // contract), negatives, and also NaN/null/undefined/strings, since a server's
649
+ // settings value reaches here unvalidated. Failing open (store the text) is
650
+ // the only safe direction this close to the write.
651
+ if (typeof max !== "number" || !Number.isFinite(max) || max <= 0) return s;
652
+ const cap = Math.floor(max);
653
+ // UTF-16 length >= code-point count, so this proves it fits without counting.
654
+ if (s.length <= cap) return s;
655
+ // Walk to the cut rather than spreading the whole string, stepping by code
656
+ // point so a surrogate pair is never split.
657
+ let i = 0;
658
+ for (let n = 0; i < s.length && n < cap; n++) {
659
+ i += s.codePointAt(i) > 0xffff ? 2 : 1;
660
+ }
661
+ if (i >= s.length) return s;
662
+ return s.slice(0, i) + "\n[...truncated]";
663
+ }
664
+
665
+ /**
666
+ * Assemble a captured turn's stored body from its two sides, each under its own
667
+ * server-resolved bound. 0 on a side captures that side whole.
668
+ */
669
+ export function buildTurnCapture(userText, assistantText, userMax, assistantMax) {
670
+ return `${truncateForCapture(userText, userMax)}\n\n${truncateForCapture(assistantText, assistantMax)}`;
671
+ }
672
+
458
673
  /**
459
674
  * Truncate to `max` bytes, suffix with a marker. Same shape as the Claude
460
675
  * Code plugin's truncate helper.
@@ -1040,6 +1255,46 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1040
1255
  }),
1041
1256
 
1042
1257
  event: guard("event", async ({ event }) => {
1258
+ // Auto-update check: fires once per process on the first session.created
1259
+ if (
1260
+ !autoUpdateChecked &&
1261
+ event &&
1262
+ event.type === "session.created" &&
1263
+ !event.properties?.info?.parentID // skip sub-sessions
1264
+ ) {
1265
+ autoUpdateChecked = true;
1266
+ const live = await currentConfig();
1267
+ if (live.auto_update) {
1268
+ // Fire-and-forget — never blocks the event hook
1269
+ (async () => {
1270
+ try {
1271
+ const latest = await fetchLatestVersion();
1272
+ if (!latest) return;
1273
+ if (compareVersions(CLIENT_VERSION, latest) >= 0) return; // already up to date
1274
+ // Only auto-update within the same major version
1275
+ const cur = parseVersion(CLIENT_VERSION);
1276
+ const nxt = parseVersion(latest);
1277
+ if (!cur || !nxt || cur.major !== nxt.major) {
1278
+ log.warn(`auto-update: v${latest} available (major bump — update manually: pin @eleboucher/opencode-memini@${latest} in opencode.json)`);
1279
+ return;
1280
+ }
1281
+ log.warn(`auto-update: updating ${CLIENT_VERSION} → ${latest}`);
1282
+ const installDir = prepareCacheUpdate(latest, log);
1283
+ if (!installDir) return;
1284
+ const ok = runBunInstall(installDir);
1285
+ if (ok) {
1286
+ log.warn(`auto-update: installed v${latest} — restart opencode to apply`);
1287
+ } else {
1288
+ log.warn(`auto-update: bun install failed; will retry next session`);
1289
+ }
1290
+ } catch (err) {
1291
+ log.warn(`auto-update: check failed: ${String(err)}`);
1292
+ }
1293
+ })();
1294
+ }
1295
+ return;
1296
+ }
1297
+
1043
1298
  const live = await currentConfig();
1044
1299
  if (!live.capture || !event || event.type !== "session.idle") return;
1045
1300
  const sessionID = event.properties && event.properties.sessionID;
@@ -1053,7 +1308,7 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1053
1308
  const stored = await rest.postJson(
1054
1309
  "/v1/memories",
1055
1310
  {
1056
- content: `${userText.slice(0, 1000)}\n\n${assistantText.slice(0, 3000)}`,
1311
+ content: buildTurnCapture(userText, assistantText, live.capture_user_max_chars, live.capture_assistant_max_chars),
1057
1312
  tags: ["opencode"],
1058
1313
  metadata,
1059
1314
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eleboucher/opencode-memini",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "Automatic cross-session memory for opencode via memini — recall before each turn, capture after.",
5
5
  "keywords": [
6
6
  "memini",