@eleboucher/opencode-memini 0.7.8 → 0.7.10

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 (2) hide show
  1. package/memini.js +110 -55
  2. package/package.json +1 -1
package/memini.js CHANGED
@@ -55,11 +55,16 @@ const CLIENT_VERSION = readPluginVersion();
55
55
 
56
56
  // Auto-update: opencode never re-fetches cached npm plugins, so the plugin
57
57
  // checks npm dist-tags once per process and self-updates (same major version
58
- // only) so the running copy stays current.
58
+ // only) so the running copy stays current. opencode installs each plugin spec
59
+ // into its own isolated wrapper directory under
60
+ // ~/.cache/opencode/packages/<spec>/ (e.g. .../opencode-memini@latest/) — the
61
+ // wrapper holds a package.json listing the plugin as a dependency plus a
62
+ // node_modules/ tree — so the wrapper dir is where we rewrite the pin and
63
+ // re-run npm install.
59
64
  const PACKAGE_NAME = "@eleboucher/opencode-memini";
60
65
  const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`;
61
66
  const NPM_FETCH_TIMEOUT = 5000;
62
- const BUN_INSTALL_TIMEOUT_MS = 60000;
67
+ const INSTALL_TIMEOUT_MS = 60000;
63
68
  let autoUpdateChecked = false;
64
69
 
65
70
  /**
@@ -87,23 +92,45 @@ export function compareVersions(a, b) {
87
92
  }
88
93
 
89
94
  /**
90
- * resolveInstallContext finds the opencode plugin cache directory that holds
91
- * this running plugin instance, by walking up from import.meta.url. Returns
92
- * { installDir, packageJsonPath } or null.
95
+ * resolveInstallContextFrom walks up from `startPath` looking for the opencode
96
+ * plugin cache wrapper dir: the first ancestor whose child is a `node_modules`
97
+ * directory AND that also has a `package.json` sibling. Returns
98
+ * { installDir, packageJsonPath } or null. Pure (no `import.meta.url` read) so
99
+ * it can be driven from a synthetic on-disk layout in tests. Exported for
100
+ * testing.
93
101
  */
94
- function resolveInstallContext() {
102
+ export function resolveInstallContextFrom(startPath) {
95
103
  try {
96
- const pluginDir = dirname(fileURLToPath(import.meta.url));
97
- const nodeModulesDir = dirname(pluginDir);
98
- const installDir = dirname(nodeModulesDir);
99
- const packageJsonPath = join(installDir, "package.json");
100
- if (existsSync(packageJsonPath)) {
101
- return { installDir, packageJsonPath };
104
+ let dir = startPath;
105
+ for (let i = 0; i < 20; i++) {
106
+ const nodeModules = join(dir, "node_modules");
107
+ if (existsSync(nodeModules) && statSync(nodeModules).isDirectory()) {
108
+ const packageJsonPath = join(dir, "package.json");
109
+ if (existsSync(packageJsonPath)) {
110
+ return { installDir: dir, packageJsonPath };
111
+ }
112
+ return null; // node_modules found but no package.json — not a wrapper
113
+ }
114
+ const parent = dirname(dir);
115
+ if (parent === dir) break; // reached root
116
+ dir = parent;
102
117
  }
103
118
  } catch {}
104
119
  return null;
105
120
  }
106
121
 
122
+ /**
123
+ * resolveInstallContext finds the opencode plugin cache wrapper directory
124
+ * holding this running plugin instance, by walking up from import.meta.url.
125
+ * Exported for testing.
126
+ */
127
+ export function resolveInstallContext() {
128
+ try {
129
+ return resolveInstallContextFrom(dirname(fileURLToPath(import.meta.url)));
130
+ } catch {}
131
+ return null;
132
+ }
133
+
107
134
  /**
108
135
  * fetchLatestVersion queries npm dist-tags with a timeout. Returns the version
109
136
  * string or null on failure.
@@ -129,14 +156,21 @@ async function fetchLatestVersion() {
129
156
  }
130
157
 
131
158
  /**
132
- * prepareCacheUpdate rewrites the cache package.json to pin the new version,
133
- * removes the installed node_modules package, and cleans bun.lock. Returns
134
- * the installDir on success, null on failure.
159
+ * prepareCacheUpdate rewrites the wrapper package.json to pin the new version,
160
+ * removes the installed node_modules package, and cleans any lockfile so the
161
+ * next install re-fetches. Returns the installDir on success, null on failure.
162
+ *
163
+ * `ctx` is optional: when omitted, resolves from `import.meta.url` (the live
164
+ * path); tests pass a synthetic { installDir, packageJsonPath } so the full
165
+ * rewrite-and-clean flow can be exercised against a temp dir without touching
166
+ * the dev checkout. Exported for testing.
135
167
  */
136
- function prepareCacheUpdate(newVersion, log) {
137
- const ctx = resolveInstallContext();
168
+ export function prepareCacheUpdate(newVersion, log, ctx) {
169
+ if (!ctx) {
170
+ ctx = resolveInstallContext();
171
+ }
138
172
  if (!ctx) {
139
- log.warn("auto-update: could not resolve install context");
173
+ log.error("auto-update: could not resolve install context");
140
174
  return null;
141
175
  }
142
176
  // Rewrite package.json with the new version pin
@@ -148,50 +182,44 @@ function prepareCacheUpdate(newVersion, log) {
148
182
  pkg.dependencies = { ...pkg.dependencies, [PACKAGE_NAME]: newVersion };
149
183
  writeFileSync(ctx.packageJsonPath, JSON.stringify(pkg, null, 2));
150
184
  } catch (err) {
151
- log.warn(`auto-update: failed to rewrite cache package.json: ${String(err)}`);
185
+ log.error(`auto-update: failed to rewrite cache package.json: ${String(err)}`);
152
186
  return null;
153
187
  }
154
- // Remove installed node_modules so bun install re-fetches
188
+ // Remove installed node_modules so the install re-fetches
155
189
  try {
156
190
  const pkgDir = join(ctx.installDir, "node_modules", "@eleboucher", "opencode-memini");
157
191
  if (existsSync(pkgDir)) rmSync(pkgDir, { recursive: true, force: true });
158
192
  } catch (err) {
159
- log.warn(`auto-update: failed to remove cached node_modules: ${String(err)}`);
193
+ log.error(`auto-update: failed to remove cached node_modules: ${String(err)}`);
160
194
  return null;
161
195
  }
162
- // Clean bun.lock entry if it exists
163
- const lockPath = join(ctx.installDir, "bun.lock");
164
- if (existsSync(lockPath)) {
196
+ // Clean lockfiles: opencode's installer may write either package-lock.json
197
+ // (npm/arborist) or bun.lock depending on the bundler. Remove both if
198
+ // present; the install regenerates them.
199
+ for (const lockName of ["package-lock.json", "bun.lock"]) {
200
+ const lockPath = join(ctx.installDir, lockName);
201
+ if (!existsSync(lockPath)) continue;
165
202
  try {
166
- const lock = JSON.parse(readFileSync(lockPath, "utf8"));
167
- let modified = false;
168
- if (lock.workspaces?.[""]?.dependencies?.[PACKAGE_NAME]) {
169
- delete lock.workspaces[""].dependencies[PACKAGE_NAME];
170
- modified = true;
171
- }
172
- if (lock.packages?.[PACKAGE_NAME]) {
173
- delete lock.packages[PACKAGE_NAME];
174
- modified = true;
175
- }
176
- if (modified) writeFileSync(lockPath, JSON.stringify(lock, null, 2));
203
+ rmSync(lockPath, { force: true });
177
204
  } catch {
178
- // bun.lock format varies; if we can't parse it, leave it bun install
179
- // will reconcile.
205
+ // A lock we can't remove isn't fatalnpm install reconciles it.
180
206
  }
181
207
  }
182
208
  return ctx.installDir;
183
209
  }
184
210
 
185
211
  /**
186
- * runBunInstall runs `bun install` in the given directory with a timeout.
187
- * Returns true on success (exit code 0), false otherwise.
212
+ * runNpmInstall runs `npm install` in the given directory with a timeout.
213
+ * Returns true on success (exit code 0), false otherwise. opencode uses
214
+ * @npmcli/arborist under the hood, so `npm install` matches the lockfile
215
+ * format it produces; `bun install` would rewrite it. Exported for testing.
188
216
  */
189
- function runBunInstall(installDir) {
217
+ export function runNpmInstall(installDir) {
190
218
  try {
191
- const result = spawnSync(process.execPath, ["install"], {
219
+ const result = spawnSync("npm", ["install"], {
192
220
  cwd: installDir,
193
221
  stdio: ["ignore", "pipe", "pipe"],
194
- timeout: BUN_INSTALL_TIMEOUT_MS,
222
+ timeout: INSTALL_TIMEOUT_MS,
195
223
  });
196
224
  return result.status === 0;
197
225
  } catch {
@@ -1098,15 +1126,42 @@ export function lastAssistantFailed(messages) {
1098
1126
  }
1099
1127
 
1100
1128
  export const MeminiPlugin = async ({ client, worktree, directory }, options) => {
1129
+ // Structured logger is primary; console.error is the fallback when it
1130
+ // throws (absent client.app.log). Direct call so a missing app.log reaches
1131
+ // the catch. Symbols match oh-my-opencode-slim: [ok]/[x]/[!]/[i].
1132
+ const GREEN = "\x1b[32m";
1133
+ const RED = "\x1b[31m";
1134
+ const YELLOW = "\x1b[33m";
1135
+ const BLUE = "\x1b[34m";
1136
+ const RESET = "\x1b[0m";
1101
1137
  const log = {
1138
+ error: (message) => {
1139
+ try {
1140
+ client.app.log({ body: { service: "memini", level: "error", message } });
1141
+ } catch {
1142
+ console.error(`${RED}[x]${RESET} [memini] ${message}`);
1143
+ }
1144
+ },
1102
1145
  warn: (message) => {
1103
- // client.app.log is opencode's structured logger; fall back to stderr.
1104
1146
  try {
1105
- client?.app?.log?.({ body: { service: "memini", level: "warn", message } });
1147
+ client.app.log({ body: { service: "memini", level: "warn", message } });
1148
+ } catch {
1149
+ console.error(`${YELLOW}[!]${RESET} [memini] ${message}`);
1150
+ }
1151
+ },
1152
+ info: (message) => {
1153
+ try {
1154
+ client.app.log({ body: { service: "memini", level: "info", message } });
1155
+ } catch {
1156
+ console.error(`${BLUE}[i]${RESET} [memini] ${message}`);
1157
+ }
1158
+ },
1159
+ success: (message) => {
1160
+ try {
1161
+ client.app.log({ body: { service: "memini", level: "info", message } });
1106
1162
  } catch {
1107
- /* ignore logging failures */
1163
+ console.error(`${GREEN}[ok]${RESET} [memini] ${message}`);
1108
1164
  }
1109
- console.error(`[memini] ${message}`);
1110
1165
  },
1111
1166
  };
1112
1167
 
@@ -1212,7 +1267,7 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1212
1267
  excludeIds: serverExcludeIds ? excludeIds : [],
1213
1268
  onExcludeIdsUnsupported: () => {
1214
1269
  serverExcludeIds = false;
1215
- log.warn("memini: server does not accept exclude_ids; using client-side dedupe only");
1270
+ log.info("memini: server does not accept exclude_ids; using client-side dedupe only");
1216
1271
  },
1217
1272
  });
1218
1273
 
@@ -1223,7 +1278,7 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1223
1278
  try {
1224
1279
  return await fn(...args);
1225
1280
  } catch (error) {
1226
- log.warn(`${name} hook failed: ${String(error)}`);
1281
+ log.error(`${name} hook failed: ${String(error)}`);
1227
1282
  }
1228
1283
  };
1229
1284
 
@@ -1342,7 +1397,7 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1342
1397
  result = await Promise.race([settled, budget]);
1343
1398
  clearTimeout(timer);
1344
1399
  if (result === BUDGET_EXPIRED) {
1345
- log.warn(
1400
+ log.info(
1346
1401
  `recall exceeded its ${live.recall_budget_ms}ms budget; late results will inject next turn`,
1347
1402
  );
1348
1403
  if (sessionID) {
@@ -1447,20 +1502,20 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
1447
1502
  const cur = parseVersion(CLIENT_VERSION);
1448
1503
  const nxt = parseVersion(latest);
1449
1504
  if (!cur || !nxt || cur.major !== nxt.major) {
1450
- log.warn(`auto-update: v${latest} available (major bump — update manually: pin @eleboucher/opencode-memini@${latest} in opencode.json)`);
1505
+ log.info(`auto-update: v${latest} available (major bump — update manually: pin @eleboucher/opencode-memini@${latest} in opencode.json)`);
1451
1506
  return;
1452
1507
  }
1453
- log.warn(`auto-update: updating ${CLIENT_VERSION} → ${latest}`);
1508
+ log.info(`auto-update: updating ${CLIENT_VERSION} → ${latest}`);
1454
1509
  const installDir = prepareCacheUpdate(latest, log);
1455
1510
  if (!installDir) return;
1456
- const ok = runBunInstall(installDir);
1511
+ const ok = runNpmInstall(installDir);
1457
1512
  if (ok) {
1458
- log.warn(`auto-update: installed v${latest} — restart opencode to apply`);
1513
+ log.success(`auto-update: installed v${latest} — restart opencode to apply`);
1459
1514
  } else {
1460
- log.warn(`auto-update: bun install failed; will retry next session`);
1515
+ log.error(`auto-update: npm install failed; will retry next session`);
1461
1516
  }
1462
1517
  } catch (err) {
1463
- log.warn(`auto-update: check failed: ${String(err)}`);
1518
+ log.error(`auto-update: check failed: ${String(err)}`);
1464
1519
  }
1465
1520
  })();
1466
1521
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eleboucher/opencode-memini",
3
- "version": "0.7.8",
3
+ "version": "0.7.10",
4
4
  "description": "Automatic cross-session memory for opencode via memini — recall before each turn, capture after.",
5
5
  "keywords": [
6
6
  "memini",