claude-mem-lite 6.23.2 → 6.24.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.
package/hook-update.mjs CHANGED
@@ -1,6 +1,7 @@
1
- // claude-mem-lite: Auto-update via GitHub Releases
2
- // Checks for new versions on SessionStart, downloads and installs automatically.
3
- // Skips in dev mode (symlinked installs). Silent on network failure.
1
+ // claude-mem-lite: update check and installer via GitHub Releases
2
+ // The SessionStart check (hook.mjs, allowInstall:false) only records and announces a newer
3
+ // release; `self-update` and `repair` install one, signature-verified. Skips a dev install
4
+ // (a git checkout or symlinks) and under CLAUDE_MEM_SKIP_UPDATE. Silent on network failure.
4
5
 
5
6
  import { isOurMcpRegistration } from './lib/mcp-ownership.mjs';
6
7
  import { execSync, execFileSync } from 'node:child_process';
@@ -20,13 +21,14 @@ import {
20
21
  } from 'node:fs';
21
22
  import { join, dirname, resolve } from 'node:path';
22
23
  import { pathToFileURL } from 'node:url';
23
- import { tmpdir, homedir } from 'node:os';
24
+ import { tmpdir } from 'node:os';
24
25
  // lib/data-paths.mjs, NOT schema.mjs: this module is what install.mjs::repair() imports to
25
26
  // reach the signature-verified release path, and schema.mjs statically imports
26
27
  // better-sqlite3 — so importing two path constants from there made the verified repair
27
28
  // unreachable on exactly the broken-install state it exists to repair (2026-09-08).
28
29
  // tests/repair-path-no-native-dep.test.mjs fails on any package edge reachable from here.
29
- import { DB_DIR, CODE_DIR } from './lib/data-paths.mjs';
30
+ import { DB_DIR, CODE_DIR, claudeConfigDir, claudeStatePath } from './lib/data-paths.mjs';
31
+ import { workerDirGone } from './lib/worker-data-dir.mjs';
30
32
  import { debugCatch, debugLog } from './utils.mjs';
31
33
  import { NATIVE_BINDING_SOURCE_BUILD_CMD } from './lib/binding-probe.mjs';
32
34
  // Local manifest is fallback only — the active manifest is loaded from the
@@ -40,7 +42,7 @@ import {
40
42
  // version check AND the release download — dies instantly behind a proxy, and
41
43
  // because checkForUpdate is silent on network failure the plugin then reports
42
44
  // itself permanently up to date. Same tunnel the OpenRouter call site uses.
43
- import { httpConnectProxyFor, getViaConnectProxy } from './lib/proxy-fetch.mjs';
45
+ import { httpConnectProxyFor, getViaConnectProxy, redactProxyUrl } from './lib/proxy-fetch.mjs';
44
46
  import { acquireLock } from './lib/proc-lock.mjs';
45
47
  import { atomicWriteFileSync } from './lib/atomic-write.mjs';
46
48
  import { verifyReleaseFiles, verifyManifestSignature } from './lib/release-digest.mjs';
@@ -67,12 +69,17 @@ const NPM_INSTALL_CMD = 'npm install --omit=dev --no-audit --no-fund';
67
69
 
68
70
  // ── Main Entry ─────────────────────────────────────────────
69
71
  export async function checkForUpdate(options = {}) {
72
+ // A failed lookup returns null by default, which a background caller wants (silent on
73
+ // network failure). `self-update` printed that null as "Already up to date", so a caller
74
+ // that reports to a person asks for the failure instead. (D#251)
75
+ const failed = (rateLimited) =>
76
+ options.reportFailure ? { updateAvailable: false, updated: false, checkFailed: true, rateLimited } : null;
70
77
  try {
71
78
  const pluginMode = isPluginMode();
72
79
  const force = Boolean(options.force);
73
80
  const allowInstall = options.allowInstall ?? !pluginMode;
74
81
 
75
- if (isDevMode() || process.env.CLAUDE_MEM_SKIP_UPDATE) return null;
82
+ if (updateCheckDisabledReason()) return null;
76
83
 
77
84
  const state = readState();
78
85
  if (!force && !shouldCheck(state)) {
@@ -91,15 +98,25 @@ export async function checkForUpdate(options = {}) {
91
98
  return null;
92
99
  }
93
100
 
94
- const latest = await fetchLatestRelease();
101
+ const lookup = await lookupLatestRelease();
102
+ const latest = lookup.release;
95
103
  if (!latest) {
96
- // Re-read from disk: a 403 inside fetchWithTimeout just persisted rateLimited:true.
104
+ // Re-read from disk: a 403 inside fetchJson just persisted rateLimited:true.
97
105
  // Spreading the stale in-memory `state` (captured above with rateLimited:false) would
98
106
  // clobber that flag back to false, so shouldCheck never honors the backoff and the
99
107
  // rate-limit mechanism is dead. Re-reading preserves the freshly-written flag.
108
+ // lookupError / lookupFailingSince are doctor's evidence: lastCheck alone advanced on a
109
+ // failed lookup too, so doctor printed a fresh "last check" in green while every lookup
110
+ // behind a proxy failed from 2026-08-19 to 2026-10-07 (D#255). A success clears both.
100
111
  const fresh = readState();
101
- saveState({ ...fresh, lastCheck: new Date().toISOString() });
102
- return null;
112
+ const now = new Date().toISOString();
113
+ saveState({
114
+ ...fresh,
115
+ lastCheck: now,
116
+ lookupError: lookup.error,
117
+ lookupFailingSince: fresh.lookupFailingSince || now,
118
+ });
119
+ return failed(lookup.rateLimited);
103
120
  }
104
121
 
105
122
  const currentVersion = getCurrentVersion();
@@ -139,6 +156,8 @@ export async function checkForUpdate(options = {}) {
139
156
  updateAvailable: false,
140
157
  rateLimited: false,
141
158
  lastError: null,
159
+ lookupError: null,
160
+ lookupFailingSince: null,
142
161
  });
143
162
  return null;
144
163
  } catch (err) {
@@ -147,7 +166,7 @@ export async function checkForUpdate(options = {}) {
147
166
  const s = readState();
148
167
  saveState({ ...s, lastCheck: new Date().toISOString(), lastError: err.message });
149
168
  } catch {}
150
- return null;
169
+ return failed(false);
151
170
  }
152
171
  }
153
172
 
@@ -160,7 +179,7 @@ export async function checkForUpdate(options = {}) {
160
179
  // Banner string from cached update-state (≤24h stale), or null. No network I/O.
161
180
  export function getCachedUpdateBanner() {
162
181
  try {
163
- if (isDevMode() || process.env.CLAUDE_MEM_SKIP_UPDATE) return null;
182
+ if (updateCheckDisabledReason()) return null;
164
183
  const state = readState();
165
184
  const running = pendingCachedUpdate(state);
166
185
  if (running) {
@@ -193,7 +212,7 @@ export function pendingCachedUpdate(state) {
193
212
  // Caller spawns the refresh in the background so this session doesn't wait.
194
213
  export function isUpdateCheckDue() {
195
214
  try {
196
- if (isDevMode() || process.env.CLAUDE_MEM_SKIP_UPDATE) return false;
215
+ if (updateCheckDisabledReason()) return false;
197
216
  return shouldCheck(readState());
198
217
  } catch {
199
218
  return false;
@@ -203,7 +222,7 @@ export function isUpdateCheckDue() {
203
222
  // D#187. `CLAUDE_PLUGIN_ROOT` is set in every hook and MCP process Claude Code
204
223
  // spawns, so inside those the env var answers "am I a plugin install?" perfectly —
205
224
  // which is why v3.84.1's fix, which reads it, was enough THERE. It is not set in a
206
- // plain terminal, and a plugin-only user typing `claude-mem-lite update` therefore
225
+ // plain terminal, and a plugin-only user typing `claude-mem-lite self-update` therefore
207
226
  // fell off both plugin paths at once: getCurrentVersion() returned '0.0.0' (so every
208
227
  // release compares as newer, forever), and isPluginMode() was false, so allowInstall
209
228
  // defaulted to true and downloadAndInstall laid a full managed tree into
@@ -252,6 +271,19 @@ function isPluginMode() {
252
271
  // never a command that would overwrite its working tree. Re-implementing the check at the
253
272
  // call site would make it the second copy of a predicate this file has already had to get
254
273
  // right twice (whole-dir symlink, then per-file drift) — the twin-drift class.
274
+ /**
275
+ * Why the update check will not look at all, or null when it will. checkForUpdate returns
276
+ * null both for "nothing newer" and for "never looked", so a caller that reports to a
277
+ * person asks this first: `self-update` used to print "Already up to date" under
278
+ * CLAUDE_MEM_SKIP_UPDATE without a single request (D#251).
279
+ * @returns {'dev-install'|'CLAUDE_MEM_SKIP_UPDATE'|null}
280
+ */
281
+ export function updateCheckDisabledReason() {
282
+ if (isDevMode()) return 'dev-install';
283
+ if (process.env.CLAUDE_MEM_SKIP_UPDATE) return 'CLAUDE_MEM_SKIP_UPDATE';
284
+ return null;
285
+ }
286
+
255
287
  export function isDevMode() {
256
288
  try {
257
289
  // A dev checkout always carries a .git dir. This catches a whole-directory
@@ -286,57 +318,93 @@ function shouldCheck(state) {
286
318
  // ── GitHub API ─────────────────────────────────────────────
287
319
  // Try releases/latest first, fallback to tags (some repos only use tags)
288
320
  export async function fetchLatestRelease() {
321
+ return (await lookupLatestRelease()).release;
322
+ }
323
+
324
+ // One lookup's outcome, returned rather than kept in module state: a module flag let a second
325
+ // lookup running at the same time read the first one's 403 (D#260). `rateLimited` is THIS
326
+ // lookup's refusal; the persisted flag is not that, since only a successful lookup clears it
327
+ // (pre-tag review of cd9f1ab7). `error` says why there is no release, for doctor (D#255).
328
+ async function lookupLatestRelease() {
289
329
  const headers = {
290
330
  Accept: 'application/vnd.github+json',
291
331
  'User-Agent': 'claude-mem-lite-updater/1.0',
292
332
  };
333
+ const fail = (rateLimited, error) => ({ release: null, rateLimited, error });
293
334
 
294
335
  // Attempt 1: GitHub Releases API
295
- const result = await fetchWithTimeout(
296
- `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`,
297
- headers,
298
- );
299
- if (result === 'rate-limited') return null;
336
+ const result = await fetchJson(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, headers);
337
+ if (result.rateLimited) return fail(true, result.error);
300
338
  // Guard tag_name: a 200-OK with a malformed body ({} / {tag_name:null}) would throw
301
339
  // `Cannot read properties of undefined (reading 'replace')`. Caught upstream, but it
302
340
  // poisons lastError and blocks the tags fallback below — fall through instead.
303
- if (result && typeof result.tag_name === 'string') {
341
+ const rel = result.body;
342
+ if (rel && typeof rel.tag_name === 'string') {
304
343
  return {
305
- version: result.tag_name.replace(/^v/, ''),
306
- tarballUrl: result.tarball_url,
307
- releaseUrl: result.html_url,
308
- assets: Array.isArray(result.assets) ? result.assets : [],
344
+ release: {
345
+ version: rel.tag_name.replace(/^v/, ''),
346
+ tarballUrl: rel.tarball_url,
347
+ releaseUrl: rel.html_url,
348
+ assets: Array.isArray(rel.assets) ? rel.assets : [],
349
+ },
350
+ rateLimited: false,
351
+ error: null,
309
352
  };
310
353
  }
311
354
 
312
355
  // Attempt 2: Tags API fallback (for repos without formal releases)
313
- const tags = await fetchWithTimeout(`https://api.github.com/repos/${GITHUB_REPO}/tags?per_page=1`, headers);
314
- if (tags === 'rate-limited') return null;
315
- if (Array.isArray(tags) && tags.length > 0 && typeof tags[0]?.name === 'string') {
316
- const tag = tags[0];
356
+ const tags = await fetchJson(`https://api.github.com/repos/${GITHUB_REPO}/tags?per_page=1`, headers);
357
+ if (tags.rateLimited) return fail(true, tags.error);
358
+ const tagList = tags.body;
359
+ if (Array.isArray(tagList) && tagList.length > 0 && typeof tagList[0]?.name === 'string') {
360
+ const tag = tagList[0];
317
361
  return {
318
- version: tag.name.replace(/^v/, ''),
319
- tarballUrl: `https://api.github.com/repos/${GITHUB_REPO}/tarball/${tag.name}`,
320
- releaseUrl: `https://github.com/${GITHUB_REPO}/releases/tag/${tag.name}`,
321
- assets: [],
362
+ release: {
363
+ version: tag.name.replace(/^v/, ''),
364
+ tarballUrl: `https://api.github.com/repos/${GITHUB_REPO}/tarball/${tag.name}`,
365
+ releaseUrl: `https://github.com/${GITHUB_REPO}/releases/tag/${tag.name}`,
366
+ assets: [],
367
+ },
368
+ rateLimited: false,
369
+ error: null,
322
370
  };
323
371
  }
324
372
 
325
- return null;
373
+ const relErr = result.error || 'no release in the response';
374
+ const tagErr = tags.error || 'no tag in the response';
375
+ return fail(false, relErr === tagErr ? relErr : `${relErr}; tags: ${tagErr}`);
376
+ }
377
+
378
+ // Why a request produced no usable body, in words doctor can print: the status, or the
379
+ // transport error's code (native fetch wraps it as `fetch failed` with the code on `cause`).
380
+ // The proxy is named redacted — HTTP(S)_PROXY may carry user:pass@.
381
+ // Total: it runs inside fetchJson's catch, and a throw there would reject the lookup the
382
+ // background check relies on never rejecting.
383
+ function describeLookupError(e, proxy) {
384
+ try {
385
+ const what =
386
+ e?.name === 'AbortError'
387
+ ? `timed out after ${FETCH_TIMEOUT_MS / 1000} s`
388
+ : String(e?.cause?.code || e?.code || e?.message || e || 'unknown error');
389
+ return `${what.slice(0, 160)}${proxy ? ` via proxy ${redactProxyUrl(proxy)}` : ''}`;
390
+ } catch {
391
+ return 'unknown error';
392
+ }
326
393
  }
327
394
 
328
- async function fetchWithTimeout(url, headers) {
395
+ async function fetchJson(url, headers) {
329
396
  const controller = new AbortController();
330
397
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
398
+ let proxy = null;
331
399
  try {
332
400
  // Proxy configured → CONNECT tunnel; otherwise native fetch, byte-for-byte
333
401
  // the previous behaviour. Both shapes expose { status, ok, json() }, and the
334
402
  // tunnel REJECTS on transport failure exactly as a failed fetch does, so the
335
- // catch below still returns null and the caller still stays silent.
403
+ // catch below still yields no body and the caller still stays silent.
336
404
  // The AbortController above governs only the fetch branch; the tunnel takes
337
405
  // the same budget as an explicit argument and bounds the whole call with it
338
406
  // (redirect chain included). (pre-tag review NOTE 7)
339
- const proxy = httpConnectProxyFor(url);
407
+ proxy = httpConnectProxyFor(url);
340
408
  const res = proxy
341
409
  ? await getViaConnectProxy(proxy, url, { headers, timeout: FETCH_TIMEOUT_MS })
342
410
  : await fetch(url, { signal: controller.signal, headers });
@@ -347,12 +415,15 @@ async function fetchWithTimeout(url, headers) {
347
415
  const state = readState();
348
416
  saveState({ ...state, rateLimited: true });
349
417
  debugLog('DEBUG', 'hook-update', 'GitHub API rate limited; will retry on the 6h rate-limit cadence');
350
- return 'rate-limited';
418
+ return {
419
+ rateLimited: true,
420
+ error: describeLookupError({ message: `HTTP ${res.status} (rate limited)` }, proxy),
421
+ };
351
422
  }
352
- if (!res.ok) return null;
353
- return await res.json();
354
- } catch {
355
- return null;
423
+ if (!res.ok) return { error: describeLookupError({ message: `HTTP ${res.status}` }, proxy) };
424
+ return { body: await res.json() };
425
+ } catch (e) {
426
+ return { error: describeLookupError(e, proxy) };
356
427
  } finally {
357
428
  clearTimeout(timeout);
358
429
  }
@@ -412,7 +483,7 @@ export function getCurrentVersion() {
412
483
  }
413
484
  // D#187: no env var in a plain terminal, so ask the filesystem which plugin
414
485
  // version this machine actually runs. Without this a plugin-only user's
415
- // `claude-mem-lite update` reads 0.0.0 and every release compares as newer.
486
+ // `claude-mem-lite self-update` reads 0.0.0 and every release compares as newer.
416
487
  const active = installShape()?.activePluginVersion;
417
488
  if (active) {
418
489
  try {
@@ -1030,7 +1101,7 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
1030
1101
  // guard rejects. Failure is non-fatal — a stale entry is noisy, not broken, and must
1031
1102
  // never roll back an otherwise-good update.
1032
1103
  try {
1033
- const settingsPath = join(homedir(), '.claude', 'settings.json');
1104
+ const settingsPath = join(claudeConfigDir(), 'settings.json');
1034
1105
  if (existsSync(settingsPath)) {
1035
1106
  const { pruneDanglingMemHooks } = await import('./lib/hook-prune.mjs');
1036
1107
  const before = JSON.parse(readFileSync(settingsPath, 'utf8'));
@@ -1059,7 +1130,7 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
1059
1130
  // server was deleted on every plugin update that re-synced a direct install.
1060
1131
  try {
1061
1132
  if (isPluginMode()) {
1062
- const claudeJsonPath = join(homedir(), '.claude.json');
1133
+ const claudeJsonPath = claudeStatePath();
1063
1134
  const cfg = JSON.parse(readFileSync(claudeJsonPath, 'utf8'));
1064
1135
  let changed = false;
1065
1136
  for (const k of ['mem', 'mem-lite']) {
@@ -1154,7 +1225,7 @@ export async function syncDataDirFromCache(opts = {}) {
1154
1225
  let sourceDir = opts.sourceDir || null;
1155
1226
  if (!sourceDir) {
1156
1227
  const cacheBase =
1157
- opts.cacheBase || join(homedir(), '.claude', 'plugins', 'cache', 'sdsrss', 'claude-mem-lite');
1228
+ opts.cacheBase || join(claudeConfigDir(), 'plugins', 'cache', 'sdsrss', 'claude-mem-lite');
1158
1229
  if (!existsSync(cacheBase)) return { synced: false, reason: 'no-cache' };
1159
1230
  const versions = readdirSync(cacheBase)
1160
1231
  .filter((n) => /^\d+\.\d+/.test(n))
@@ -1294,7 +1365,7 @@ function copyReleaseIntoStaging(
1294
1365
  // appearing in a serialized hooks block) so the two cannot disagree about whether
1295
1366
  // settings.json owns the hooks.
1296
1367
  function hasInstallManagedSettingsHooks() {
1297
- const settingsPath = join(homedir(), '.claude', 'settings.json');
1368
+ const settingsPath = join(claudeConfigDir(), 'settings.json');
1298
1369
  if (!existsSync(settingsPath)) return false;
1299
1370
  let s;
1300
1371
  try {
@@ -1336,7 +1407,7 @@ export function clearCacheHookResidue() {
1336
1407
  // status/doctor then see the shape of a healthy plugin-only install. Inlined
1337
1408
  // here for the same reason the rest of this function is (see header).
1338
1409
  if (!hasInstallManagedSettingsHooks()) return 0;
1339
- const cacheBase = join(homedir(), '.claude', 'plugins', 'cache', 'sdsrss', 'claude-mem-lite');
1410
+ const cacheBase = join(claudeConfigDir(), 'plugins', 'cache', 'sdsrss', 'claude-mem-lite');
1340
1411
  if (!existsSync(cacheBase)) return 0;
1341
1412
  let cleared = 0;
1342
1413
  for (const ver of readdirSync(cacheBase)) {
@@ -1385,7 +1456,7 @@ function isSameDir(a, b) {
1385
1456
  }
1386
1457
 
1387
1458
  export function prunePluginCache() {
1388
- const cacheBase = join(homedir(), '.claude', 'plugins', 'cache', 'sdsrss', 'claude-mem-lite');
1459
+ const cacheBase = join(claudeConfigDir(), 'plugins', 'cache', 'sdsrss', 'claude-mem-lite');
1389
1460
  if (!existsSync(cacheBase)) return 0;
1390
1461
 
1391
1462
  const entries = readdirSync(cacheBase)
@@ -1409,7 +1480,7 @@ export function prunePluginCache() {
1409
1480
  const runningRoot = process.env.CLAUDE_PLUGIN_ROOT;
1410
1481
  let recordedRoot = null;
1411
1482
  try {
1412
- recordedRoot = detectInstallShape({ home: homedir() }).activePluginVersion?.root || null;
1483
+ recordedRoot = detectInstallShape().activePluginVersion?.root || null;
1413
1484
  } catch {
1414
1485
  /* best-effort — a failure here must not stop pruning entirely */
1415
1486
  }
@@ -1444,6 +1515,9 @@ function readState() {
1444
1515
  }
1445
1516
 
1446
1517
  function saveState(state) {
1518
+ // The update-check worker runs this after its release lookup; a data dir removed meanwhile
1519
+ // stays removed (D#265, pre-tag review).
1520
+ if (workerDirGone(STATE_DIR)) return;
1447
1521
  try {
1448
1522
  const dir = join(STATE_DIR, 'runtime'); // runtime-dir:stays-put — mkdir for update-state.json above
1449
1523
  mkdirSync(dir, { recursive: true });
package/hook.mjs CHANGED
@@ -240,6 +240,8 @@ async function loadCacheGuard() {
240
240
  import { SKIP_TOOLS, SKIP_PREFIXES } from './skip-tools.mjs';
241
241
 
242
242
  import { DAY_MS } from './lib/time-constants.mjs';
243
+ import { workerDirGone } from './lib/worker-data-dir.mjs';
244
+ import { claudeConfigDir } from './lib/data-paths.mjs';
243
245
  // Prevent recursive hooks from background claude -p calls
244
246
  // Background workers (llm-episode, llm-summary) are exempt — they're ours
245
247
  const event = process.argv[2];
@@ -273,7 +275,7 @@ const BG_EVENTS = new Set([
273
275
  // install.mjs already holds a parsed settings object when it asks, this process does not.
274
276
  function pluginDisabledHere() {
275
277
  try {
276
- const settingsPath = join(homedir(), '.claude', 'settings.json');
278
+ const settingsPath = join(claudeConfigDir(), 'settings.json');
277
279
  return isPluginExplicitlyDisabled(JSON.parse(readFileSync(settingsPath, 'utf8')));
278
280
  } catch {
279
281
  // Missing or unparseable settings.json → not disabled. Fail OPEN: a corrupt file must
@@ -284,6 +286,9 @@ function pluginDisabledHere() {
284
286
 
285
287
  if (event && pluginDisabledHere()) process.exit(0);
286
288
  if (process.env.CLAUDE_MEM_HOOK_RUNNING && !BG_EVENTS.has(event)) process.exit(0);
289
+ // A worker whose data dir was removed before it started does nothing: no LLM call, no network,
290
+ // and no write that would re-create the dir (D#265).
291
+ if (BG_EVENTS.has(event) && workerDirGone(DB_DIR)) process.exit(0);
287
292
 
288
293
  // Crash-safe: flush episode buffer on unexpected termination to prevent data loss
289
294
  // Uses flag-based approach to avoid calling file I/O inside signal handlers,