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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +27 -2
- package/cli/activity.mjs +10 -1
- package/haiku-client.mjs +6 -2
- package/hook-optimize.mjs +29 -19
- package/hook-shared.mjs +6 -3
- package/hook-update.mjs +96 -48
- package/hook.mjs +6 -1
- package/install.mjs +56 -27
- 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/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 +39 -3134
- package/package.json +3 -2
- 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
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
559
|
-
//
|
|
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.
|
|
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
|
-
|
|
498
|
-
|
|
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
|
|
566
|
-
const
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
|
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 (!
|
|
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=?,
|
|
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
|
-
|
|
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
|
|
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))
|
|
383
|
-
|
|
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:
|
|
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
|
|
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
|
|
101
|
+
const lookup = await lookupLatestRelease();
|
|
102
|
+
const latest = lookup.release;
|
|
101
103
|
if (!latest) {
|
|
102
|
-
// Re-read from disk: a 403 inside
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
|
321
|
-
|
|
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
|
-
|
|
341
|
+
const rel = result.body;
|
|
342
|
+
if (rel && typeof rel.tag_name === 'string') {
|
|
329
343
|
return {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
339
|
-
if (tags
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
418
|
+
return {
|
|
419
|
+
rateLimited: true,
|
|
420
|
+
error: describeLookupError({ message: `HTTP ${res.status} (rate limited)` }, proxy),
|
|
421
|
+
};
|
|
377
422
|
}
|
|
378
|
-
if (!res.ok) return
|
|
379
|
-
return await res.json();
|
|
380
|
-
} catch {
|
|
381
|
-
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) };
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|