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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +29 -4
- package/README.zh-CN.md +2 -2
- package/cli/activity.mjs +10 -1
- package/haiku-client.mjs +17 -1
- package/hook-optimize.mjs +29 -19
- package/hook-shared.mjs +6 -3
- package/hook-update.mjs +124 -50
- package/hook.mjs +6 -1
- package/install.mjs +82 -31
- package/lib/anthropic-base-url.mjs +9 -2
- package/lib/data-paths.mjs +10 -0
- package/lib/err-sampler.mjs +2 -1
- package/lib/hook-telemetry.mjs +2 -0
- package/lib/install-shape.mjs +5 -5
- package/lib/llm-provider-probe.mjs +20 -4
- package/lib/maintain-core.mjs +11 -4
- package/lib/metrics.mjs +2 -0
- package/lib/native-binding-hint.mjs +5 -0
- package/lib/proc-lock.mjs +6 -1
- package/lib/proxy-fetch.mjs +88 -14
- package/lib/record-once.mjs +3 -1
- package/lib/save-enrich.mjs +2 -1
- package/lib/search-telemetry.mjs +34 -22
- package/lib/time-constants.mjs +7 -0
- package/lib/worker-data-dir.mjs +34 -0
- package/npm-shrinkwrap.json +20 -3133
- package/package.json +5 -4
- package/plugin-cache-guard.mjs +5 -8
- package/schema.mjs +12 -3
- package/scripts/post-tool-use.sh +7 -3
- package/scripts/setup.sh +16 -3
- package/server.mjs +4 -4
- package/source-files.mjs +4 -0
- package/utils.mjs +5 -2
package/hook-update.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// claude-mem-lite:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
|
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 (
|
|
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
|
|
101
|
+
const lookup = await lookupLatestRelease();
|
|
102
|
+
const latest = lookup.release;
|
|
95
103
|
if (!latest) {
|
|
96
|
-
// Re-read from disk: a 403 inside
|
|
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
|
-
|
|
102
|
-
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
|
296
|
-
|
|
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
|
-
|
|
341
|
+
const rel = result.body;
|
|
342
|
+
if (rel && typeof rel.tag_name === 'string') {
|
|
304
343
|
return {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
314
|
-
if (tags
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
418
|
+
return {
|
|
419
|
+
rateLimited: true,
|
|
420
|
+
error: describeLookupError({ message: `HTTP ${res.status} (rate limited)` }, proxy),
|
|
421
|
+
};
|
|
351
422
|
}
|
|
352
|
-
if (!res.ok) return
|
|
353
|
-
return await res.json();
|
|
354
|
-
} catch {
|
|
355
|
-
return
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|