@eleboucher/opencode-memini 0.7.1 → 0.7.3
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 +1 -0
- package/memini-v2.js +2 -1
- package/memini.js +261 -6
- package/package.json +1 -1
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
1311
|
+
content: buildTurnCapture(userText, assistantText, live.capture_user_max_chars, live.capture_assistant_max_chars),
|
|
1057
1312
|
tags: ["opencode"],
|
|
1058
1313
|
metadata,
|
|
1059
1314
|
},
|