claude-mem-lite 3.96.1 → 3.97.0

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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.96.1",
13
+ "version": "3.97.0",
14
14
  "source": "./",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.96.1",
3
+ "version": "3.97.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/hook-context.mjs CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  } from './utils.mjs';
23
23
  import {
24
24
  STALE_SESSION_MS,
25
- FALLBACK_OBS_WINDOW_MS,
25
+ RELATED_OBS_WINDOW_MS,
26
26
  RUNTIME_DIR,
27
27
  effectiveQuiet,
28
28
  isQuietHooks,
@@ -459,7 +459,7 @@ export function buildSessionContextLines(
459
459
  let fallbackObs = [];
460
460
  if (observations.length < 3) {
461
461
  const fbOneDayAgo = now.getTime() - STALE_SESSION_MS;
462
- const fbSevenDaysAgo = now.getTime() - FALLBACK_OBS_WINDOW_MS;
462
+ const fbSevenDaysAgo = now.getTime() - RELATED_OBS_WINDOW_MS;
463
463
  fallbackObs = db
464
464
  .prepare(
465
465
  `
package/hook-shared.mjs CHANGED
@@ -54,18 +54,27 @@ export const SESSION_EXPIRY_MS = 12 * 60 * 60 * 1000; // 12h
54
54
  export const STALE_SESSION_MS = 24 * 60 * 60 * 1000; // 24h
55
55
  export const STALE_LOCK_MS = 30000; // 30s
56
56
 
57
+ // Backstop for cleanStaleLockFiles(): a lock whose recorded pid is ALIVE is kept until it
58
+ // reaches this age, not STALE_LOCK_MS. Deliberately LONGER than proc-lock.mjs's own 5-min
59
+ // steal window, so the sweeper is never the more aggressive of the two — whatever it
60
+ // removes, the lock protocol itself would already have let the next caller steal. Its only
61
+ // job is to garbage-collect a leaked file whose pid was recycled onto an unrelated live
62
+ // process, which would otherwise pin the file forever. (A20260905-R5-P1-1)
63
+ export const ABANDONED_LOCK_MS = 10 * 60 * 1000; // 10 min
64
+
57
65
  // The background-maintenance mutex, defined HERE next to the sweeper policy it has to
58
- // escape. cleanStaleLockFiles() below unlinks any `*.lock` older than STALE_LOCK_MS
59
- // WITHOUT checking whether the holder is alive — right for the episode lock's millisecond
60
- // critical section, fatal for a maintenance pass that runs for seconds to minutes. The
61
- // name therefore ends in `.proclock`, and `tests/auto-maintain-proc-lock.test.mjs` asserts
62
- // that against THIS constant rather than a re-typed copy: the first version of that test
63
- // built its own path from a literal, so renaming the lock left it green with the hazard
66
+ // escape. cleanStaleLockFiles() sweeps every `*.lock` in RUNTIME_DIR; until
67
+ // A20260905-R5-P1-1 it did so on AGE ALONE once past STALE_LOCK_MS — right for the episode
68
+ // lock's millisecond critical section, fatal for a maintenance pass that runs for seconds
69
+ // to minutes. The sweeper now spares a live holder, but this mutex keeps the `.proclock`
70
+ // name: not being swept at all is a stronger guarantee than being spared by a liveness
71
+ // probe, and pid checks are meaningless across a shared homedir. `tests/auto-maintain-proc-lock.test.mjs`
72
+ // asserts that against THIS constant rather than a re-typed copy: the first version of that
73
+ // test built its own path from a literal, so renaming the lock left it green with the hazard
64
74
  // back. proc-lock's own staleness policy (age OR provably-dead pid) is the correct one.
65
75
  export const AUTO_MAINTAIN_LOCK = 'auto-maintain.proclock';
66
76
  export const DEDUP_WINDOW_MS = 5 * 60 * 1000; // 5 min (title dedup)
67
77
  export const RELATED_OBS_WINDOW_MS = 7 * DAY_MS; // 7 days
68
- export const FALLBACK_OBS_WINDOW_MS = RELATED_OBS_WINDOW_MS; // same window
69
78
  // Candidate rows the SessionStart Key Context surface considers (hook-context.mjs
70
79
  // keyObs; each of the two sections then renders at most 5). The user-prompt
71
80
  // exclude-set does NOT mirror this query — it reads the ids actually rendered
package/hook-update.mjs CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  rmSync,
17
17
  renameSync,
18
18
  chmodSync,
19
+ realpathSync,
19
20
  } from 'node:fs';
20
21
  import { join, dirname, resolve } from 'node:path';
21
22
  import { pathToFileURL } from 'node:url';
@@ -1294,6 +1295,19 @@ export function clearCacheHookResidue() {
1294
1295
  // ── Plugin Cache Pruning ──────────────────────────────────
1295
1296
  const PLUGIN_CACHE_KEEP = 3;
1296
1297
 
1298
+ /**
1299
+ * Same-directory test that survives trailing slashes, `..` segments and symlinks.
1300
+ * realpathSync throws on a path that no longer exists → fall back to lexical resolve.
1301
+ */
1302
+ function isSameDir(a, b) {
1303
+ if (!a || !b) return false;
1304
+ try {
1305
+ return realpathSync(a) === realpathSync(b);
1306
+ } catch {
1307
+ return resolve(a) === resolve(b);
1308
+ }
1309
+ }
1310
+
1297
1311
  export function prunePluginCache() {
1298
1312
  const cacheBase = join(homedir(), '.claude', 'plugins', 'cache', 'sdsrss', 'claude-mem-lite');
1299
1313
  if (!existsSync(cacheBase)) return 0;
@@ -1304,11 +1318,20 @@ export function prunePluginCache() {
1304
1318
 
1305
1319
  if (entries.length <= PLUGIN_CACHE_KEEP) return 0;
1306
1320
 
1321
+ // A20260905-R5-Q1: "not in the newest 3" is not the same question as "not in use".
1322
+ // CLAUDE_PLUGIN_ROOT is the version dir THIS process was launched from, and after a
1323
+ // marketplace rollback (a bad release withdrawn while >=3 newer dirs are already cached)
1324
+ // it is not among the newest 3 — so keep-latest-3 rm -rf'd the tree the running hooks and
1325
+ // MCP server import from. scripts/setup.sh step 8 carries the same guard for the same
1326
+ // reason; the two prune the same directory and must agree.
1327
+ const runningRoot = process.env.CLAUDE_PLUGIN_ROOT;
1307
1328
  const toRemove = entries.slice(PLUGIN_CACHE_KEEP);
1308
1329
  let removed = 0;
1309
1330
  for (const ver of toRemove) {
1331
+ const dir = join(cacheBase, ver);
1332
+ if (isSameDir(dir, runningRoot)) continue;
1310
1333
  try {
1311
- rmSync(join(cacheBase, ver), { recursive: true, force: true });
1334
+ rmSync(dir, { recursive: true, force: true });
1312
1335
  removed++;
1313
1336
  } catch {}
1314
1337
  }
package/hook.mjs CHANGED
@@ -70,6 +70,7 @@ import {
70
70
  SESSION_EXPIRY_MS,
71
71
  STALE_SESSION_MS,
72
72
  STALE_LOCK_MS,
73
+ ABANDONED_LOCK_MS,
73
74
  AUTO_MAINTAIN_LOCK,
74
75
  STALE_EPISODE_BUFFER_AGE_MS,
75
76
  HANDOFF_EXPIRY_CLEAR,
@@ -1945,14 +1946,15 @@ function scheduleSessionStartAutoMaintain(project) {
1945
1946
  if (!process.env.CLAUDE_MEM_SKIP_MAINTAIN) spawnBackground('auto-maintain', project);
1946
1947
  }
1947
1948
 
1948
- // The maintenance mutex deliberately does NOT end in `.lock`: cleanStaleLockFiles()
1949
- // below unlinks every `*.lock` in RUNTIME_DIR whose age exceeds STALE_LOCK_MS (30s)
1950
- // WITHOUT consulting the holder's pid — a policy written for the episode lock, whose
1951
- // critical section is milliseconds. A maintenance pass is seconds to minutes (VACUUM INTO
1952
- // snapshot, purge, decay, dedup over the whole DB), so that sweeper would strip this lock
1953
- // mid-pass and hand the exclusion straight back to the race it exists to close.
1954
- // proc-lock brings its own staleness policy (age OR provably-dead pid), which is the
1955
- // correct one here.
1949
+ // The maintenance mutex deliberately does NOT end in `.lock`, so cleanStaleLockFiles()
1950
+ // below never sees it at all. That sweeper used to unlink every `*.lock` past
1951
+ // STALE_LOCK_MS (30s) without consulting the holder's pid — a policy written for the
1952
+ // episode lock, whose critical section is milliseconds and a maintenance pass is seconds
1953
+ // to minutes (VACUUM INTO snapshot, purge, decay, dedup over the whole DB). It now spares a
1954
+ // live holder (A20260905-R5-P1-1), but this escape stays: not being swept is a stronger
1955
+ // guarantee than being spared by a pid probe, and pids are meaningless across a shared
1956
+ // homedir. proc-lock brings its own staleness policy (age OR provably-dead pid), which is
1957
+ // the correct one here.
1956
1958
  // Generous upper bound on one pass; a crashed holder is normally reclaimed sooner via the
1957
1959
  // dead-pid check, so this only matters for a holder killed on another host.
1958
1960
  const AUTO_MAINTAIN_LOCK_STALE_MS = 10 * 60 * 1000;
@@ -2078,8 +2080,29 @@ function saveHandoffAndFastSummary(
2078
2080
  }
2079
2081
  }
2080
2082
 
2083
+ /**
2084
+ * Sweep abandoned `*.lock` files out of RUNTIME_DIR on SessionStart.
2085
+ *
2086
+ * "Stale" has to mean ABANDONED, not merely old. Until A20260905-R5-P1-1 this swept on AGE
2087
+ * ALONE (pid was consulted only for locks YOUNGER than STALE_LOCK_MS, i.e. exactly the ones
2088
+ * it was going to keep anyway), so a lock older than 30s was unlinked no matter who held it.
2089
+ * `runtime/install.lock` — lib/proc-lock.mjs, taken by `install.mjs repair`,
2090
+ * `install.mjs rebuild-binding`, hook-update.installExtractedRelease and scripts/launch.mjs —
2091
+ * guards a critical section that routinely runs 30s–2min (npm install in staging is capped at
2092
+ * 60s, npm rebuild in smoke at 120s). Any parallel Claude Code window starting up during that
2093
+ * span deleted the live holder's lock; the next installer then acquired it and began renaming
2094
+ * files into the same install dir, which is the torn mixed-version install (server vN + hook
2095
+ * vN+1) proc-lock.mjs's header exists to prevent.
2096
+ *
2097
+ * Policy now: a recorded pid that is ALIVE (or alive-but-not-ours, EPERM) is spared until
2098
+ * ABANDONED_LOCK_MS; a provably-dead pid (ESRCH) is swept at any age; a lock with no usable
2099
+ * pid falls back to STALE_LOCK_MS, as before.
2100
+ *
2101
+ * Liveness of the two real lock families does not depend on this sweeper, so tightening it
2102
+ * cannot wedge either: hook-episode.acquireLock() preempts a >30s episode lock itself at
2103
+ * acquire time, and proc-lock.acquireLock() steals on age OR dead pid at 5 min.
2104
+ */
2081
2105
  function cleanStaleLockFiles() {
2082
- // Clean stale lock files in runtime dir
2083
2106
  try {
2084
2107
  for (const f of readdirSync(RUNTIME_DIR)) {
2085
2108
  if (!f.endsWith('.lock')) continue;
@@ -2089,12 +2112,16 @@ function cleanStaleLockFiles() {
2089
2112
  const info = JSON.parse(raw);
2090
2113
  const age = Date.now() - (info.ts || 0);
2091
2114
  let stale = age > STALE_LOCK_MS;
2092
- if (!stale && info.pid) {
2115
+ if (info.pid) {
2116
+ let alive = false;
2093
2117
  try {
2094
2118
  process.kill(info.pid, 0);
2119
+ alive = true;
2095
2120
  } catch (killErr) {
2096
- stale = killErr.code === 'ESRCH';
2121
+ // EPERM = the process exists but belongs to another user — still a live holder.
2122
+ alive = killErr.code === 'EPERM';
2097
2123
  }
2124
+ stale = alive ? age > ABANDONED_LOCK_MS : true;
2098
2125
  }
2099
2126
  if (stale) unlinkSync(lp);
2100
2127
  } catch {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.96.1",
3
+ "version": "3.97.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.96.1",
9
+ "version": "3.97.0",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.30.0",
12
12
  "better-sqlite3": "^12.11.1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.96.1",
3
+ "version": "3.97.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -14,7 +14,9 @@
14
14
  // dir) or a missing bare dependency like better-sqlite3 (e.url is undefined and
15
15
  // the importer named in the message is under the install dir) — run
16
16
  // `install.mjs repair` (rate-limited via a 6h marker file under runtime/) and
17
- // retry the import once. If repair is unavailable or fails, degrade quietly:
17
+ // retry the import once. That repair runs at SESSION-START ONLY; every other
18
+ // event records the breakage and defers (A20260905-R5-Q2, see attemptHeal).
19
+ // If repair is unavailable, deferred or fails, degrade quietly:
18
20
  // these are best-effort memory hooks, so a broken/missing dependency emits one
19
21
  // clean recovery line and exits 0 rather than dumping a Node stack trace on
20
22
  // every fire. On any other (foreign) exception, re-throw so Node's default
@@ -272,6 +274,37 @@ function clearBreakage() {
272
274
  }
273
275
  }
274
276
 
277
+ // Hot-path counterpart of attemptHeal (A20260905-R5-Q2).
278
+ //
279
+ // attemptHeal() runs `install.mjs repair` SYNCHRONOUSLY with a 300s timeout. The events
280
+ // this launcher fires on cannot host that: hooks/hooks.json gives PreToolUse 3s,
281
+ // PostToolUse 3s, UserPromptSubmit 2s, Stop and PreCompact 5s — only SessionStart's 15s is
282
+ // in the same order of magnitude as an npm run. Worse than being killed: recordHealAttempt()
283
+ // arms the 6h cooldown BEFORE the spawn, deliberately, as concurrent-fire rate limiting (see
284
+ // clearHealMarker below). So a repair the host killed at 2s still bought six hours of
285
+ // "Self-heal skipped" — including for the SessionStart fire that had the budget to finish it.
286
+ // The hot path now records the breakage for `doctor` and gets out of the way.
287
+ //
288
+ // This is the same rule healNativeBindingIfBroken() below already follows, for the same
289
+ // reason ("never on the per-tool hot path, where an npm run would stall the user's edit").
290
+ //
291
+ // Do NOT "fix" the cooldown by moving recordHealAttempt() after the spawn instead: that is
292
+ // the mutual-exclusion between concurrent fires, and the R5 report's first suggestion.
293
+ //
294
+ // Known gap this does NOT close, because it was already open: if the missing module sits on
295
+ // a DIFFERENT entry's import chain and session-start's own entry imports cleanly, nothing
296
+ // heals — a clean session-start clears the breakage marker without repairing. Narrow in
297
+ // practice (hook.mjs imports most of lib/), and closing it needs a detached, stdio-ignored
298
+ // spawn like the native-binding path, not this one.
299
+ function deferHealToSessionStart(reason) {
300
+ process.stderr.write(
301
+ `[claude-mem-lite] Broken install (${reason}) — self-heal deferred to the next SessionStart ` +
302
+ `(this hook has a 2-5s budget; repair needs minutes).\n` +
303
+ `[claude-mem-lite] Manual recovery: ${CLI_REPAIR}\n`,
304
+ );
305
+ return false;
306
+ }
307
+
275
308
  async function attemptHeal(reason) {
276
309
  if (recentHealAttempt()) {
277
310
  process.stderr.write(
@@ -298,6 +331,64 @@ async function attemptHeal(reason) {
298
331
  return result.status === 0;
299
332
  }
300
333
 
334
+ // Session-start heal driven by the BREAKAGE MARKER rather than by our own failed import
335
+ // (A20260905-R5-Q2, second half).
336
+ //
337
+ // Since the heal moved off the hot path, a hot-path fire that hits a missing module records
338
+ // the breakage and defers. But this launcher fronts several entries — hook.mjs plus
339
+ // pre-tool-recall.js, pre-skill-bridge.js, post-tool-recall.js, user-prompt-search.js — and
340
+ // the missing module may sit on one of THEIR import chains and not on hook.mjs's. Then
341
+ // session-start's own entry imports cleanly, the catch below never fires, and before this
342
+ // function existed the clean fire simply cleared the marker: nothing ever repaired it. (That
343
+ // gap predates the hot-path gate — a clean session-start always cleared the marker — but the
344
+ // gate is what makes it the ONLY remaining route, so it is closed here.)
345
+ //
346
+ // DETACHED with stdio ignored, exactly like healNativeBindingIfBroken() below and for the
347
+ // same two reasons: the fire is capped at 15s while `install.mjs repair` can take minutes,
348
+ // and install.mjs logs to STDOUT while SessionStart stdout is a JSON envelope Claude Code
349
+ // parses. That is also why this cannot reuse attemptHeal(), whose spawn is synchronous and
350
+ // inherits stdio — correct where the entry failed and nothing has been written yet, wrong
351
+ // here, where runEntry() has already emitted the envelope.
352
+ //
353
+ // Marker bookkeeping, and why it differs from the native-binding path: `install.mjs repair`
354
+ // does not know about `hook-launcher-broken` (only doctor reads it, only this file writes
355
+ // it), so no child can clear it on our behalf. Clearing it here after spawning keeps
356
+ // `doctor` honest — a repair that did not take is re-recorded by the very next failing fire
357
+ // — while the 6h cooldown, not the marker, is what bounds repair spawns to one per window.
358
+ // Within that window the marker is deliberately left in place so doctor still reports the
359
+ // unrepaired breakage.
360
+ function healRecordedBreakage() {
361
+ try {
362
+ if (!existsSync(BROKEN_MARKER)) {
363
+ // No fire has recorded a degraded exit since the last session-start → as healthy as
364
+ // this launcher can tell. Drop a stale cooldown so a LATER unrelated break heals
365
+ // immediately rather than waiting out a window earned by an old fault. (#6/#9)
366
+ clearHealMarker();
367
+ return;
368
+ }
369
+ if (recentHealAttempt()) return; // on cooldown — keep the marker, doctor should see it
370
+ const installer = join(INSTALL_DIR, 'install.mjs');
371
+ if (!existsSync(installer)) {
372
+ process.stderr.write(
373
+ `[claude-mem-lite] A hook fire degraded to exit 0 and install.mjs is missing — ${TARBALL_FALLBACK}\n`,
374
+ );
375
+ return;
376
+ }
377
+ recordHealAttempt();
378
+ process.stderr.write(
379
+ '[claude-mem-lite] A previous hook fire degraded to exit 0 — repairing in the background\n',
380
+ );
381
+ const child = spawn(process.execPath, [installer, 'repair'], {
382
+ detached: true,
383
+ stdio: 'ignore',
384
+ });
385
+ child.unref();
386
+ clearBreakage();
387
+ } catch {
388
+ /* best-effort — a heal failure must never stop the fire */
389
+ }
390
+ }
391
+
301
392
  // Defense-in-depth for plugin-mode version drift: the plugin-cache MCP server
302
393
  // (kept current by Claude Code) migrates the shared DB schema forward, while
303
394
  // this data-dir code (the standalone CLI + these hooks) is only advanced by the
@@ -398,9 +489,12 @@ if (IS_SESSION_START) {
398
489
 
399
490
  try {
400
491
  await runEntry();
401
- // A clean session-start fire confirms the install is healthy clear any stale
402
- // breakage marker. Gated to session-start so the per-tool hot path pays nothing.
403
- if (IS_SESSION_START) clearBreakage();
492
+ // Our entry imported cleanly, but that is NOT the same as "the install is whole": this
493
+ // launcher fronts five entries and the missing module may be on another one's chain. So
494
+ // act on the recorded breakage instead of just clearing it — background repair if there is
495
+ // one to do, clear either way. Gated to session-start so the hot path pays nothing.
496
+ // (Was an unconditional clearBreakage(); A20260905-R5-Q2.)
497
+ if (IS_SESSION_START) healRecordedBreakage();
404
498
  // After the entry too: the fire that DISCOVERS the breakage is the one that
405
499
  // records it, so a pre-entry-only check would leave the whole session dead and
406
500
  // heal one session late.
@@ -408,7 +502,7 @@ try {
408
502
  } catch (e) {
409
503
  if (!isLocalModuleErr(e)) throw e;
410
504
  const reason = describeFailure(e);
411
- const healed = await attemptHeal(reason);
505
+ const healed = IS_SESSION_START ? await attemptHeal(reason) : deferHealToSessionStart(reason);
412
506
  if (!healed) {
413
507
  // Broken/missing dependency we can't repair right now (repair failed, or
414
508
  // was skipped within the 6h cooldown). attemptHeal already wrote actionable
package/scripts/setup.sh CHANGED
@@ -248,10 +248,25 @@ if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" ]]; then
248
248
  done < <(for _d in "${_all_dirs[@]}"; do [[ -d "$_d" ]] && echo "${_d##*/}"; done | sort -t. -k1,1nr -k2,2nr -k3,3nr | tail -n +4)
249
249
  unset _all_dirs _d
250
250
  if [[ ${#OLD_VERS[@]} -gt 0 ]]; then
251
+ PRUNED=0
251
252
  for ver in "${OLD_VERS[@]}"; do
253
+ # A20260905-R5-Q1: "not in the newest 3" is not the same question as "not in use".
254
+ # CLAUDE_PLUGIN_ROOT is the version dir this session is RUNNING from, and after a
255
+ # marketplace rollback (a bad release withdrawn while >=3 newer dirs sit in the
256
+ # cache) it falls outside the newest 3 — so this loop deleted the tree every hook
257
+ # and the MCP server import from, mid-session. -ef compares device+inode, so it is
258
+ # not fooled by a trailing slash, a `..` segment or a symlinked cache dir.
259
+ # hook-update.mjs prunePluginCache() carries the same guard; the two prune the same
260
+ # directory and must agree.
261
+ if [[ "$CACHE_DIR/$ver" -ef "$CLAUDE_PLUGIN_ROOT" ]]; then
262
+ continue
263
+ fi
252
264
  rm -rf "${CACHE_DIR:?}/$ver" 2>/dev/null || true
265
+ PRUNED=$((PRUNED + 1))
253
266
  done
254
- log_ok "Plugin cache pruned: removed ${#OLD_VERS[@]} old version(s)"
267
+ if [[ $PRUNED -gt 0 ]]; then
268
+ log_ok "Plugin cache pruned: removed $PRUNED old version(s)"
269
+ fi
255
270
  fi
256
271
  fi
257
272
  fi