claude-mem-lite 6.23.3 → 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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.23.3",
12
+ "version": "6.24.0",
13
13
  "source": "./",
14
14
  "homepage": "https://github.com/sdsrss/claude-mem-lite",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword 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)."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.23.3",
3
+ "version": "6.24.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword 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/README.md CHANGED
@@ -237,6 +237,27 @@ rm -rf ~/claude-mem-lite/ # pre-v0.5 unhidden (if not auto-moved)
237
237
  repos/ # Shallow-cloned source repos
238
238
  ```
239
239
 
240
+ ## Upgrading to 6.24.0
241
+
242
+ **If you set `CLAUDE_CONFIG_DIR`, the installer, updates and hooks now use that directory, as
243
+ Claude Code does.** No schema-version change: 6.23.3 still opens the database after this
244
+ release has. Without `CLAUDE_CONFIG_DIR` nothing in this item changes.
245
+
246
+ - **Who is affected.** Claude Code keeps `settings.json`, `plugins/` and `.claude.json` in
247
+ `CLAUDE_CONFIG_DIR` when it is set. The plugin kept using `~/.claude` and `~/.claude.json`:
248
+ `install` wrote its hooks where Claude Code never reads them, and with two profiles the
249
+ session-start setup and `self-update` pruned the other profile's plugin cache and edited its
250
+ `.claude.json`.
251
+ - **What to do.** Installed with npm or npx: run `claude-mem-lite install` once more with the
252
+ variable set, so the hooks land in your config directory. Plugin installs need nothing. Hook
253
+ entries an older version wrote into `~/.claude/settings.json` stay there; if no other profile
254
+ uses `~/.claude`, `env -u CLAUDE_CONFIG_DIR claude-mem-lite cleanup-hooks` removes them.
255
+ - **Also in this release:** `npm install -g claude-mem-lite` and `npx` no longer install the
256
+ development toolchain (96 packages / 57 MB instead of 298 / 541 MB). A confirmed
257
+ `maintain execute --ops purge_stale` now deletes every row its preview counted in one run,
258
+ behind one snapshot. `doctor` warns about a failing update check and about an
259
+ `ANTHROPIC_BASE_URL` that ends in `/v1`. Details in CHANGELOG.md.
260
+
240
261
  ## Upgrading to 6.23.1
241
262
 
242
263
  **If you set `ANTHROPIC_BASE_URL` and `ANTHROPIC_API_KEY`, background LLM calls now go to that
@@ -568,9 +589,13 @@ depends on the Claude Code version. Allow the server once in the `settings.json`
568
589
  { "permissions": { "allow": ["mcp__plugin_claude-mem-lite_mem-lite"] } }
569
590
  ```
570
591
 
571
- That is the server's name under a plugin install. An npx or git-clone install registers it as
592
+ For a single run, `claude -p … --allowedTools "mcp__plugin_claude-mem-lite_mem-lite"` does the
593
+ same (both forms checked on 2.1.292, 2026-10-07: no refusal, and `mem_search` answered). That is
594
+ the server's name under a plugin install. An npx or git-clone install registers it as
572
595
  `mem-lite`, so the rule there is `mcp__mem-lite`. An interactive session asks instead; answering
573
- "always allow" there records a rule of its own.
596
+ "always allow" there records a rule of its own. The plugin does not tell the model to fall back
597
+ to the CLI when a call is refused: the refusal is Claude Code's permission check for a tool you
598
+ have not allowed, and routing the same action through Bash would sidestep it.
574
599
 
575
600
  ### Skill Commands (in Claude Code chat)
576
601
 
package/cli/activity.mjs CHANGED
@@ -188,7 +188,16 @@ export async function cmdActivity(db, args) {
188
188
  return;
189
189
  }
190
190
 
191
- const result = db.prepare(`DELETE FROM events WHERE id IN (${placeholders})`).run(...ids);
191
+ // The opt-in search telemetry keeps a title per returned result; an explicit delete drops the
192
+ // deleted events' titles, as lib/delete-core.mjs does for observations (D#245 N6).
193
+ const result = db.transaction(() => {
194
+ if (db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'search_results'").get()) {
195
+ db.prepare(
196
+ `UPDATE search_results SET snapshot_label = NULL WHERE source = 'event' AND result_id IN (${placeholders})`,
197
+ ).run(...ids);
198
+ }
199
+ return db.prepare(`DELETE FROM events WHERE id IN (${placeholders})`).run(...ids);
200
+ })();
192
201
  out(`[mem] Deleted ${result.changes} event(s).`);
193
202
  return;
194
203
  }
package/haiku-client.mjs CHANGED
@@ -20,6 +20,7 @@ import { randomUUID } from 'crypto';
20
20
  import { debugLog, debugCatch, parseJsonFromLLM } from './utils.mjs';
21
21
  import { DB_DIR } from './schema.mjs';
22
22
  import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
23
+ import { workerDirGone } from './lib/worker-data-dir.mjs';
23
24
  import { httpConnectProxyFor, postViaConnectProxy, postDirectHttp } from './lib/proxy-fetch.mjs';
24
25
  import { resolveAnthropicBaseUrl } from './lib/anthropic-base-url.mjs';
25
26
 
@@ -40,6 +41,9 @@ import { resolveAnthropicBaseUrl } from './lib/anthropic-base-url.mjs';
40
41
  */
41
42
  function cliSpawnCwd() {
42
43
  const dir = join(resolveRuntimeDir(DB_DIR), 'cli-cwd');
44
+ // A background worker whose data dir was removed during its API attempt must not re-create
45
+ // it here (D#265); the spawn then fails on the missing cwd, and the worker gives up.
46
+ if (workerDirGone(DB_DIR)) return dir;
43
47
  try {
44
48
  mkdirSync(dir, { recursive: true, mode: 0o700 });
45
49
  } catch {
@@ -555,8 +559,8 @@ async function callModelAPI(prompt, model, { timeout, maxTokens, temperature = D
555
559
  };
556
560
  // Plain http is loopback-only (resolveAnthropicBaseUrl), and loopback must stay on this
557
561
  // machine: native fetch would hand it, x-api-key in cleartext, to HTTP_PROXY under
558
- // NODE_USE_ENV_PROXY=1. (D#250) Parsed, not a prefix test: the resolver accepts the scheme
559
- // in any case (`HTTP://127.0.0.1`) and returns the raw text.
562
+ // NODE_USE_ENV_PROXY=1. (D#250) Parsed, not a prefix test: a prefix test let `HTTP://127.0.0.1`
563
+ // through to the proxy when the resolver still returned the raw text (b59e0cec, D#259).
560
564
  const plainHttp = new URL(apiUrl).protocol === 'http:';
561
565
  const send = (payload) => {
562
566
  const json = JSON.stringify(payload);
package/hook-optimize.mjs CHANGED
@@ -485,7 +485,7 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
485
485
  // A compression keeper is never hidden: hiding it hides every member compressed into it
486
486
  // (the same rule NOT_COMPRESSION_KEEPER_SQL enforces on the maintenance writers). It falls
487
487
  // through to the normal update instead: floored to importance 1 and stamped, so it is not
488
- // re-sent. In narrow scope that update still replaces its title and narrative.
488
+ // re-sent. That update keeps its stored title and narrative in both scopes (keepStoredText).
489
489
  const isKeeper = !!db
490
490
  .prepare('SELECT 1 FROM observations WHERE compressed_into = ? LIMIT 1')
491
491
  .get(cand.id);
@@ -494,12 +494,8 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
494
494
  const humanSet =
495
495
  (db.prepare('SELECT importance_set_at FROM observations WHERE id = ?').get(cand.id)
496
496
  ?.importance_set_at ?? null) !== null;
497
- if (
498
- (parsed.importance === 0 || parsed.importance === '0') &&
499
- scope !== 'wide' &&
500
- !isKeeper &&
501
- !humanSet
502
- ) {
497
+ const scoredZero = parsed.importance === 0 || parsed.importance === '0';
498
+ if (scoredZero && scope !== 'wide' && !isKeeper && !humanSet) {
503
499
  // D#12, and this one is not a stale-write guard — it is a POINTER guard.
504
500
  // `compressed_into` is the child -> keeper link, and COMPRESSED_AUTO is -1. If a
505
501
  // concurrent cluster-merge or smart-compress adopts this row during the 45 s Haiku
@@ -511,7 +507,8 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
511
507
  const res = db
512
508
  .prepare(
513
509
  `UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}, optimized_at = ?
514
- WHERE id = ? AND ${liveObsFilterSql('')} AND optimized_at IS NULL AND ${NOT_COMPRESSION_KEEPER_SQL}`,
510
+ WHERE id = ? AND ${liveObsFilterSql('')} AND optimized_at IS NULL AND ${NOT_COMPRESSION_KEEPER_SQL}
511
+ AND importance_set_at IS NULL`,
515
512
  )
516
513
  .run(Date.now(), cand.id);
517
514
  if (res.changes === 0) {
@@ -561,9 +558,15 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
561
558
  // For `narrow`, the truncate stays on LLM output but NOT on the preserve-on-empty
562
559
  // fallback — truncating the row's own stored narrative to buy nothing was the same
563
560
  // bug in miniature.
561
+ //
562
+ // A reply that scored the row 0 reaches this point only for a row that may not be hidden
563
+ // (a compression keeper, or an importance a person set). It judged the row worthless, so
564
+ // its title and narrative do not replace the stored ones either (D#207: 'Weekly summary:
565
+ // auth refactor' became 'Weekly summary', narrative 'x').
564
566
  const isWide = scope === 'wide';
565
- const title = isWide ? cand.title : truncate(scrubSecrets(parsed.title || ''), 120);
566
- const narrative = isWide
567
+ const keepStoredText = isWide || scoredZero;
568
+ const title = keepStoredText ? cand.title : truncate(scrubSecrets(parsed.title || ''), 120);
569
+ const narrative = keepStoredText
567
570
  ? cand.narrative
568
571
  : parsed.narrative
569
572
  ? truncate(scrubSecrets(parsed.narrative), 500)
@@ -571,9 +574,10 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
571
574
  // Floor at the stored importance: re-enrich adds a lesson, it must never silently downgrade
572
575
  // a user-set/promoted importance (the UPDATE also sets optimized_at → the loss is permanent).
573
576
  // Upgrades are still honored — except over an importance a person set (D10), which stays.
574
- const importance = humanSet
575
- ? cand.importance
576
- : Math.max(clampImportance(parsed.importance), cand.importance || 1);
577
+ // Decided in the UPDATE itself, on the row as it is then: the candidate's importance was read
578
+ // before the model call, and writing it back undid a person's change made during the call
579
+ // (D#206).
580
+ const modelImportance = clampImportance(parsed.importance);
577
581
 
578
582
  const bigramText = cjkBigrams((title || '') + ' ' + (narrative || ''));
579
583
  const textField = [conceptsText, factsText, searchAliases || '', bigramText].filter(Boolean).join(' ');
@@ -607,10 +611,10 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
607
611
  // Narrow replaces title and narrative with model text, so an explicit save it rewrites (one
608
612
  // whose save-time enrich failed) moves to the re-enrich writer's id, as a cluster-merge
609
613
  // keeper does (D#146, D#138). Wide keeps the stored title and narrative (it fills the lesson
610
- // and the side fields), so the row stays an explicit save. The writer's session row is
611
- // best-effort.
614
+ // and the side fields), so the row stays an explicit save, and so does a row whose stored
615
+ // text a 0 reply left in place. The writer's session row is best-effort.
612
616
  let rewriteSessionId = null;
613
- if (!isWide) {
617
+ if (!keepStoredText) {
614
618
  const cur = db.prepare('SELECT memory_session_id FROM observations WHERE id = ?').get(cand.id);
615
619
  if (cur?.memory_session_id?.startsWith(MANUAL_SESSION_ID_PREFIX)) {
616
620
  const enrichSessionId = writerSessionId('enrich-', cand.project);
@@ -629,7 +633,10 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
629
633
  .prepare(
630
634
  `
631
635
  UPDATE observations SET type=?, title=?, narrative=?, concepts=?, facts=?,
632
- text=?, importance=?, lesson_learned=?, search_aliases=?, minhash_sig=?, optimized_at=?,
636
+ text=?,
637
+ importance = CASE WHEN importance_set_at IS NOT NULL THEN importance
638
+ ELSE MAX(?, COALESCE(NULLIF(importance, 0), 1)) END,
639
+ lesson_learned=?, search_aliases=?, minhash_sig=?, optimized_at=?,
633
640
  scope=COALESCE(?, scope), memory_session_id = COALESCE(?, memory_session_id)
634
641
  WHERE id = ? AND ${liveObsFilterSql('')} AND optimized_at IS NULL
635
642
  `,
@@ -641,7 +648,7 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
641
648
  safe.concepts,
642
649
  safe.facts,
643
650
  safe.text,
644
- importance,
651
+ modelImportance,
645
652
  safe.lesson_learned,
646
653
  safe.search_aliases,
647
654
  minhashSig,
@@ -1300,6 +1307,8 @@ Return ONLY valid JSON:
1300
1307
  const bigramText = cjkBigrams((title || '') + ' ' + (narrative || ''));
1301
1308
  const textField = [conceptsText, factsText, bigramText].filter(Boolean).join(' ');
1302
1309
  const minhashSig = computeMinHash((title || '') + ' ' + (narrative || ''));
1310
+ // An importance a person set on the keeper stays (D10); the UPDATE decides that on the row as
1311
+ // it is then, since a person can set it while this call waits on the model (D#198).
1303
1312
  const importance = Math.max(clampImportance(parsed.importance || 2), maxClusterImportance);
1304
1313
 
1305
1314
  // Scrub LLM-output cluster-merge text fields at the UPDATE boundary.
@@ -1382,7 +1391,8 @@ Return ONLY valid JSON:
1382
1391
  db.prepare(
1383
1392
  `
1384
1393
  UPDATE observations SET title=?, narrative=?, concepts=?, facts=?, text=?,
1385
- importance=?, lesson_learned=?, minhash_sig=?, optimized_at=?,
1394
+ importance = CASE WHEN importance_set_at IS NOT NULL THEN importance ELSE ? END,
1395
+ lesson_learned=?, minhash_sig=?, optimized_at=?,
1386
1396
  memory_session_id = COALESCE(?, memory_session_id)
1387
1397
  WHERE id = ?
1388
1398
  `,
package/hook-shared.mjs CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  } from './lib/schema-skew.mjs';
31
31
  import { isDbUnusableError, DB_UNUSABLE_MARKER_PREFIX } from './lib/db-unusable.mjs';
32
32
  import { shouldRecordOnce } from './lib/record-once.mjs';
33
+ import { backgroundWorkerEnv, workerDirGone } from './lib/worker-data-dir.mjs';
33
34
  import { hookSessionId } from './lib/provenance.mjs';
34
35
  import { PROJECT_REKEY_MARKER_PREFIX } from './lib/project-rekey.mjs';
35
36
  // Audit 2026-09-05 P1-2 (carried from 2026-09-02 P2-9): `callLLM`, the quiet/adoption
@@ -378,9 +379,11 @@ export function sweepStaleProjectMarkers(
378
379
  // exists, so harden here too: create 0700, and chmod a pre-existing dir a prior version
379
380
  // created at the default umask. A 0700 dir blocks traversal to every file inside,
380
381
  // current and future, regardless of individual file mode (audit sec P3-2 2026-07-24).
382
+ // A background worker whose data dir was removed while it ran does not re-create it (D#265).
381
383
  try {
382
- if (!existsSync(RUNTIME_DIR)) mkdirSync(RUNTIME_DIR, { recursive: true, mode: 0o700 });
383
- else chmodSync(RUNTIME_DIR, 0o700);
384
+ if (!existsSync(RUNTIME_DIR)) {
385
+ if (!workerDirGone(DB_DIR)) mkdirSync(RUNTIME_DIR, { recursive: true, mode: 0o700 });
386
+ } else chmodSync(RUNTIME_DIR, 0o700);
384
387
  } catch {}
385
388
 
386
389
  // ─── Session ID Management ───────────────────────────────────────────────────
@@ -604,7 +607,7 @@ export function spawnBackground(bgEvent, ...extraArgs) {
604
607
  const child = spawn(process.execPath, args, {
605
608
  detached: true,
606
609
  stdio: 'ignore',
607
- env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1' },
610
+ env: backgroundWorkerEnv(),
608
611
  });
609
612
  child.on('error', (err) => {
610
613
  debugCatch(err, 'spawnBackground');
package/hook-update.mjs CHANGED
@@ -21,13 +21,14 @@ import {
21
21
  } from 'node:fs';
22
22
  import { join, dirname, resolve } from 'node:path';
23
23
  import { pathToFileURL } from 'node:url';
24
- import { tmpdir, homedir } from 'node:os';
24
+ import { tmpdir } from 'node:os';
25
25
  // lib/data-paths.mjs, NOT schema.mjs: this module is what install.mjs::repair() imports to
26
26
  // reach the signature-verified release path, and schema.mjs statically imports
27
27
  // better-sqlite3 — so importing two path constants from there made the verified repair
28
28
  // unreachable on exactly the broken-install state it exists to repair (2026-09-08).
29
29
  // tests/repair-path-no-native-dep.test.mjs fails on any package edge reachable from here.
30
- 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';
31
32
  import { debugCatch, debugLog } from './utils.mjs';
32
33
  import { NATIVE_BINDING_SOURCE_BUILD_CMD } from './lib/binding-probe.mjs';
33
34
  // Local manifest is fallback only — the active manifest is loaded from the
@@ -41,7 +42,7 @@ import {
41
42
  // version check AND the release download — dies instantly behind a proxy, and
42
43
  // because checkForUpdate is silent on network failure the plugin then reports
43
44
  // itself permanently up to date. Same tunnel the OpenRouter call site uses.
44
- import { httpConnectProxyFor, getViaConnectProxy } from './lib/proxy-fetch.mjs';
45
+ import { httpConnectProxyFor, getViaConnectProxy, redactProxyUrl } from './lib/proxy-fetch.mjs';
45
46
  import { acquireLock } from './lib/proc-lock.mjs';
46
47
  import { atomicWriteFileSync } from './lib/atomic-write.mjs';
47
48
  import { verifyReleaseFiles, verifyManifestSignature } from './lib/release-digest.mjs';
@@ -97,15 +98,25 @@ export async function checkForUpdate(options = {}) {
97
98
  return null;
98
99
  }
99
100
 
100
- const latest = await fetchLatestRelease();
101
+ const lookup = await lookupLatestRelease();
102
+ const latest = lookup.release;
101
103
  if (!latest) {
102
- // 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.
103
105
  // Spreading the stale in-memory `state` (captured above with rateLimited:false) would
104
106
  // clobber that flag back to false, so shouldCheck never honors the backoff and the
105
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.
106
111
  const fresh = readState();
107
- saveState({ ...fresh, lastCheck: new Date().toISOString() });
108
- return failed(lastLookupRateLimited);
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);
109
120
  }
110
121
 
111
122
  const currentVersion = getCurrentVersion();
@@ -145,6 +156,8 @@ export async function checkForUpdate(options = {}) {
145
156
  updateAvailable: false,
146
157
  rateLimited: false,
147
158
  lastError: null,
159
+ lookupError: null,
160
+ lookupFailingSince: null,
148
161
  });
149
162
  return null;
150
163
  } catch (err) {
@@ -304,64 +317,94 @@ function shouldCheck(state) {
304
317
 
305
318
  // ── GitHub API ─────────────────────────────────────────────
306
319
  // Try releases/latest first, fallback to tags (some repos only use tags)
307
- // Whether the most recent fetchLatestRelease call was refused with 403/429. The persisted
308
- // `rateLimited` flag is not that: only a successful lookup clears it, so reading it after a
309
- // network failure blamed a rate limit from an earlier call. (pre-tag review of cd9f1ab7)
310
- let lastLookupRateLimited = false;
311
-
312
320
  export async function fetchLatestRelease() {
313
- lastLookupRateLimited = false;
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() {
314
329
  const headers = {
315
330
  Accept: 'application/vnd.github+json',
316
331
  'User-Agent': 'claude-mem-lite-updater/1.0',
317
332
  };
333
+ const fail = (rateLimited, error) => ({ release: null, rateLimited, error });
318
334
 
319
335
  // Attempt 1: GitHub Releases API
320
- const result = await fetchWithTimeout(
321
- `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`,
322
- headers,
323
- );
324
- 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);
325
338
  // Guard tag_name: a 200-OK with a malformed body ({} / {tag_name:null}) would throw
326
339
  // `Cannot read properties of undefined (reading 'replace')`. Caught upstream, but it
327
340
  // poisons lastError and blocks the tags fallback below — fall through instead.
328
- if (result && typeof result.tag_name === 'string') {
341
+ const rel = result.body;
342
+ if (rel && typeof rel.tag_name === 'string') {
329
343
  return {
330
- version: result.tag_name.replace(/^v/, ''),
331
- tarballUrl: result.tarball_url,
332
- releaseUrl: result.html_url,
333
- 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,
334
352
  };
335
353
  }
336
354
 
337
355
  // Attempt 2: Tags API fallback (for repos without formal releases)
338
- const tags = await fetchWithTimeout(`https://api.github.com/repos/${GITHUB_REPO}/tags?per_page=1`, headers);
339
- if (tags === 'rate-limited') return null;
340
- if (Array.isArray(tags) && tags.length > 0 && typeof tags[0]?.name === 'string') {
341
- 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];
342
361
  return {
343
- version: tag.name.replace(/^v/, ''),
344
- tarballUrl: `https://api.github.com/repos/${GITHUB_REPO}/tarball/${tag.name}`,
345
- releaseUrl: `https://github.com/${GITHUB_REPO}/releases/tag/${tag.name}`,
346
- 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,
347
370
  };
348
371
  }
349
372
 
350
- 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
+ }
351
393
  }
352
394
 
353
- async function fetchWithTimeout(url, headers) {
395
+ async function fetchJson(url, headers) {
354
396
  const controller = new AbortController();
355
397
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
398
+ let proxy = null;
356
399
  try {
357
400
  // Proxy configured → CONNECT tunnel; otherwise native fetch, byte-for-byte
358
401
  // the previous behaviour. Both shapes expose { status, ok, json() }, and the
359
402
  // tunnel REJECTS on transport failure exactly as a failed fetch does, so the
360
- // catch below still returns null and the caller still stays silent.
403
+ // catch below still yields no body and the caller still stays silent.
361
404
  // The AbortController above governs only the fetch branch; the tunnel takes
362
405
  // the same budget as an explicit argument and bounds the whole call with it
363
406
  // (redirect chain included). (pre-tag review NOTE 7)
364
- const proxy = httpConnectProxyFor(url);
407
+ proxy = httpConnectProxyFor(url);
365
408
  const res = proxy
366
409
  ? await getViaConnectProxy(proxy, url, { headers, timeout: FETCH_TIMEOUT_MS })
367
410
  : await fetch(url, { signal: controller.signal, headers });
@@ -372,13 +415,15 @@ async function fetchWithTimeout(url, headers) {
372
415
  const state = readState();
373
416
  saveState({ ...state, rateLimited: true });
374
417
  debugLog('DEBUG', 'hook-update', 'GitHub API rate limited; will retry on the 6h rate-limit cadence');
375
- lastLookupRateLimited = true;
376
- return 'rate-limited';
418
+ return {
419
+ rateLimited: true,
420
+ error: describeLookupError({ message: `HTTP ${res.status} (rate limited)` }, proxy),
421
+ };
377
422
  }
378
- if (!res.ok) return null;
379
- return await res.json();
380
- } catch {
381
- 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) };
382
427
  } finally {
383
428
  clearTimeout(timeout);
384
429
  }
@@ -1056,7 +1101,7 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
1056
1101
  // guard rejects. Failure is non-fatal — a stale entry is noisy, not broken, and must
1057
1102
  // never roll back an otherwise-good update.
1058
1103
  try {
1059
- const settingsPath = join(homedir(), '.claude', 'settings.json');
1104
+ const settingsPath = join(claudeConfigDir(), 'settings.json');
1060
1105
  if (existsSync(settingsPath)) {
1061
1106
  const { pruneDanglingMemHooks } = await import('./lib/hook-prune.mjs');
1062
1107
  const before = JSON.parse(readFileSync(settingsPath, 'utf8'));
@@ -1085,7 +1130,7 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
1085
1130
  // server was deleted on every plugin update that re-synced a direct install.
1086
1131
  try {
1087
1132
  if (isPluginMode()) {
1088
- const claudeJsonPath = join(homedir(), '.claude.json');
1133
+ const claudeJsonPath = claudeStatePath();
1089
1134
  const cfg = JSON.parse(readFileSync(claudeJsonPath, 'utf8'));
1090
1135
  let changed = false;
1091
1136
  for (const k of ['mem', 'mem-lite']) {
@@ -1180,7 +1225,7 @@ export async function syncDataDirFromCache(opts = {}) {
1180
1225
  let sourceDir = opts.sourceDir || null;
1181
1226
  if (!sourceDir) {
1182
1227
  const cacheBase =
1183
- opts.cacheBase || join(homedir(), '.claude', 'plugins', 'cache', 'sdsrss', 'claude-mem-lite');
1228
+ opts.cacheBase || join(claudeConfigDir(), 'plugins', 'cache', 'sdsrss', 'claude-mem-lite');
1184
1229
  if (!existsSync(cacheBase)) return { synced: false, reason: 'no-cache' };
1185
1230
  const versions = readdirSync(cacheBase)
1186
1231
  .filter((n) => /^\d+\.\d+/.test(n))
@@ -1320,7 +1365,7 @@ function copyReleaseIntoStaging(
1320
1365
  // appearing in a serialized hooks block) so the two cannot disagree about whether
1321
1366
  // settings.json owns the hooks.
1322
1367
  function hasInstallManagedSettingsHooks() {
1323
- const settingsPath = join(homedir(), '.claude', 'settings.json');
1368
+ const settingsPath = join(claudeConfigDir(), 'settings.json');
1324
1369
  if (!existsSync(settingsPath)) return false;
1325
1370
  let s;
1326
1371
  try {
@@ -1362,7 +1407,7 @@ export function clearCacheHookResidue() {
1362
1407
  // status/doctor then see the shape of a healthy plugin-only install. Inlined
1363
1408
  // here for the same reason the rest of this function is (see header).
1364
1409
  if (!hasInstallManagedSettingsHooks()) return 0;
1365
- const cacheBase = join(homedir(), '.claude', 'plugins', 'cache', 'sdsrss', 'claude-mem-lite');
1410
+ const cacheBase = join(claudeConfigDir(), 'plugins', 'cache', 'sdsrss', 'claude-mem-lite');
1366
1411
  if (!existsSync(cacheBase)) return 0;
1367
1412
  let cleared = 0;
1368
1413
  for (const ver of readdirSync(cacheBase)) {
@@ -1411,7 +1456,7 @@ function isSameDir(a, b) {
1411
1456
  }
1412
1457
 
1413
1458
  export function prunePluginCache() {
1414
- const cacheBase = join(homedir(), '.claude', 'plugins', 'cache', 'sdsrss', 'claude-mem-lite');
1459
+ const cacheBase = join(claudeConfigDir(), 'plugins', 'cache', 'sdsrss', 'claude-mem-lite');
1415
1460
  if (!existsSync(cacheBase)) return 0;
1416
1461
 
1417
1462
  const entries = readdirSync(cacheBase)
@@ -1435,7 +1480,7 @@ export function prunePluginCache() {
1435
1480
  const runningRoot = process.env.CLAUDE_PLUGIN_ROOT;
1436
1481
  let recordedRoot = null;
1437
1482
  try {
1438
- recordedRoot = detectInstallShape({ home: homedir() }).activePluginVersion?.root || null;
1483
+ recordedRoot = detectInstallShape().activePluginVersion?.root || null;
1439
1484
  } catch {
1440
1485
  /* best-effort — a failure here must not stop pruning entirely */
1441
1486
  }
@@ -1470,6 +1515,9 @@ function readState() {
1470
1515
  }
1471
1516
 
1472
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;
1473
1521
  try {
1474
1522
  const dir = join(STATE_DIR, 'runtime'); // runtime-dir:stays-put — mkdir for update-state.json above
1475
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,