claude-mem-lite 3.7.1 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.7.1",
13
+ "version": "3.9.0",
14
14
  "source": "./",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.7.1",
3
+ "version": "3.9.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/README.md CHANGED
@@ -172,6 +172,8 @@ npx github:sdsrss/claude-mem-lite
172
172
 
173
173
  Source files are automatically copied to `~/.claude-mem-lite/` for persistence.
174
174
 
175
+ > **Note:** `npx github:…` installs from the repo's **default branch (HEAD)**, which can be ahead of the latest published release. For the stable released version use the npm package (`npx claude-mem-lite`), or pin a release tag: `npx github:sdsrss/claude-mem-lite#vX.Y.Z`.
176
+
175
177
  ### Method 3: git clone
176
178
 
177
179
  ```bash
package/hook-llm.mjs CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  } from './hook-shared.mjs';
21
21
  import { EVENT_TYPES, saveEvent } from './lib/activity.mjs';
22
22
  import { isNoiseObservation, capNoiseImportance, isLowYieldChangeObs } from './lib/low-signal-patterns.mjs';
23
+ import { episodeHasSignificantContent } from './hook-episode.mjs';
23
24
 
24
25
  // T9: memdir-incompatible types live in the `events` table, not `observations`.
25
26
  // Set lookup is O(1) — authoritative source is lib/activity.mjs::EVENT_TYPES.
@@ -467,6 +468,24 @@ export function buildDegradedTitle(episode) {
467
468
  .trim();
468
469
  }
469
470
 
471
+ // Best-effort SYNCHRONOUS persist of an episode's rule-based observation. Shared by
472
+ // the normal flush and the SIGTERM/SIGINT shutdown handler. The ep-flush-* file the
473
+ // shutdown handler writes has NO consumer (only spawnBackground-passed files are
474
+ // processed), so without this the in-flight episode is silently lost on abnormal
475
+ // termination — and spawning a detached child from a dying process is unreliable, so
476
+ // the save must be synchronous (audit #6). Never throws; returns the obs id or null.
477
+ export function saveEpisodeImmediate(episode, externalDb) {
478
+ try {
479
+ if (!episode || !Array.isArray(episode.entries) || episode.entries.length === 0) return null;
480
+ if (!episodeHasSignificantContent(episode)) return null;
481
+ const obs = buildImmediateObservation(episode);
482
+ return saveObservation(obs, episode.project, episode.sessionId, externalDb) || null;
483
+ } catch (e) {
484
+ debugCatch(e, 'saveEpisodeImmediate');
485
+ return null;
486
+ }
487
+ }
488
+
470
489
  /**
471
490
  * Build a rule-based observation from episode metadata for immediate DB persistence.
472
491
  * Used as pre-save (before LLM) and as fallback when LLM is unavailable.
package/hook-update.mjs CHANGED
@@ -15,6 +15,7 @@ import { debugCatch, debugLog } from './utils.mjs';
15
15
  import { SOURCE_FILES as LOCAL_SOURCE_FILES, HOOK_SCRIPT_FILES as LOCAL_HOOK_SCRIPT_FILES } from './source-files.mjs';
16
16
  import { acquireLock } from './lib/proc-lock.mjs';
17
17
  import { atomicWriteFileSync } from './lib/atomic-write.mjs';
18
+ import { verifyReleaseFiles, verifyManifestSignature } from './lib/release-digest.mjs';
18
19
 
19
20
  // ── Configuration ──────────────────────────────────────────
20
21
  const GITHUB_REPO = 'sdsrss/claude-mem-lite';
@@ -77,7 +78,7 @@ export async function checkForUpdate(options = {}) {
77
78
  if (hasUpdate) {
78
79
  debugLog('DEBUG', 'hook-update', `Update available: ${currentVersion} → ${latest.version}`);
79
80
  const canInstall = !pluginMode && Boolean(allowInstall);
80
- const success = canInstall ? await downloadAndInstall(latest.tarballUrl, latest.version) : false;
81
+ const success = canInstall ? await downloadAndInstall(latest.tarballUrl, latest.version, latest.assets) : false;
81
82
  const newState = {
82
83
  lastCheck: new Date().toISOString(),
83
84
  installedVersion: success ? latest.version : currentVersion,
@@ -206,6 +207,7 @@ async function fetchLatestRelease() {
206
207
  version: result.tag_name.replace(/^v/, ''),
207
208
  tarballUrl: result.tarball_url,
208
209
  releaseUrl: result.html_url,
210
+ assets: Array.isArray(result.assets) ? result.assets : [],
209
211
  };
210
212
  }
211
213
 
@@ -221,6 +223,7 @@ async function fetchLatestRelease() {
221
223
  version: tag.name.replace(/^v/, ''),
222
224
  tarballUrl: `https://api.github.com/repos/${GITHUB_REPO}/tarball/${tag.name}`,
223
225
  releaseUrl: `https://github.com/${GITHUB_REPO}/releases/tag/${tag.name}`,
226
+ assets: [],
224
227
  };
225
228
  }
226
229
 
@@ -301,7 +304,7 @@ async function loadReleaseManifest(sourceDir) {
301
304
 
302
305
  // ── Download & Install ─────────────────────────────────────
303
306
  // Direct file copy instead of running old install.mjs (avoids symlink overwrite in dev)
304
- async function downloadAndInstall(tarballUrl, expectedVersion) {
307
+ async function downloadAndInstall(tarballUrl, expectedVersion, assets = []) {
305
308
  const tmpDir = join(tmpdir(), `claude-mem-lite-update-${Date.now()}`);
306
309
  try {
307
310
  mkdirSync(tmpDir, { recursive: true });
@@ -324,6 +327,17 @@ async function downloadAndInstall(tarballUrl, expectedVersion) {
324
327
  return false;
325
328
  }
326
329
 
330
+ // P1 supply-chain: cryptographically verify the release before installing.
331
+ // Opportunistic + inert until keyed — ok=false ONLY on a real tampering
332
+ // signal (signature present but invalid, or a file hash mismatch). Missing
333
+ // key / missing signature assets / fetch failure / escape hatch all proceed,
334
+ // so this never bricks auto-update for unsigned or pre-key releases.
335
+ const authentic = await verifyReleaseAuthenticity(tmpDir, assets);
336
+ if (!authentic.ok) {
337
+ debugLog('WARN', 'hook-update', `Release authenticity check failed (${authentic.action}) — aborting update`);
338
+ return false;
339
+ }
340
+
327
341
  return await installExtractedRelease(tmpDir);
328
342
  } catch (err) {
329
343
  debugCatch(err, 'downloadAndInstall');
@@ -372,6 +386,99 @@ export function validateExtractedTarball(sourceDir, expectedVersion, expectedNam
372
386
  return { ok: true };
373
387
  }
374
388
 
389
+ // ── Release signature verification (P1 supply-chain hardening) ──────────────
390
+ // Embedded Ed25519 PUBLIC key (SPKI PEM). EMPTY = unconfigured → verification is
391
+ // INERT and auto-update behaves exactly as before. Activating it is a one-time
392
+ // ops step (no private key ever ships in the repo):
393
+ // 1. Generate a keypair (writes the PRIVATE key to a local file, prints PUBLIC):
394
+ // node -e "const c=require('crypto');const{publicKey,privateKey}=c.generateKeyPairSync('ed25519');process.stdout.write(publicKey.export({type:'spki',format:'pem'}));require('fs').writeFileSync('release-signing-key.pem',privateKey.export({type:'pkcs8',format:'pem'}))"
395
+ // 2. Paste the printed PUBLIC key between the backticks below.
396
+ // 3. Add the PRIVATE key (release-signing-key.pem contents) as the GitHub
397
+ // Actions secret RELEASE_SIGNING_KEY, then delete the local file.
398
+ // NEVER commit the private key.
399
+ // Once a signed release exists, clients verify it; unsigned/older releases still
400
+ // install (opportunistic). Signer: scripts/sign-release.mjs. Core: lib/release-digest.mjs.
401
+ const RELEASE_PUBLIC_KEY = '';
402
+ const MANIFEST_ASSET_NAME = 'release-manifest.json';
403
+ const SIGNATURE_ASSET_NAME = 'release-manifest.json.sig';
404
+
405
+ // Pure verifier (no I/O) — exported for unit testing. ok=true ONLY when the
406
+ // Ed25519 signature over `manifestBytes` is valid for `publicKeyPem` AND every
407
+ // file the manifest lists matches its sha256 under `extractedDir`.
408
+ export function verifyDownloadedRelease(extractedDir, manifestBytes, signatureB64, publicKeyPem = RELEASE_PUBLIC_KEY) {
409
+ if (!verifyManifestSignature(manifestBytes, signatureB64, publicKeyPem)) {
410
+ return { ok: false, reason: 'signature-invalid' };
411
+ }
412
+ let manifest;
413
+ try {
414
+ manifest = JSON.parse(Buffer.isBuffer(manifestBytes) ? manifestBytes.toString('utf8') : String(manifestBytes));
415
+ } catch {
416
+ return { ok: false, reason: 'manifest-unparseable' };
417
+ }
418
+ const files = verifyReleaseFiles(extractedDir, manifest);
419
+ if (!files.ok) {
420
+ return { ok: false, reason: `file-mismatch: ${[...files.mismatches, ...files.missing].slice(0, 5).join(', ')}` };
421
+ }
422
+ return { ok: true, reason: 'verified' };
423
+ }
424
+
425
+ // Fetch a GitHub Release asset as a Buffer. Host-locked to github.com (the asset
426
+ // browser_download_url); GitHub's own 302 to its CDN is followed by fetch.
427
+ async function fetchAssetBuffer(url) {
428
+ if (!/^https:\/\/github\.com\/[\w./%~-]+$/.test(url || '')) {
429
+ throw new Error(`rejected asset url: ${url}`);
430
+ }
431
+ const controller = new AbortController();
432
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
433
+ try {
434
+ const res = await fetch(url, { signal: controller.signal, redirect: 'follow' });
435
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
436
+ return Buffer.from(await res.arrayBuffer());
437
+ } finally {
438
+ clearTimeout(timeout);
439
+ }
440
+ }
441
+
442
+ // I/O gate called from downloadAndInstall after validateExtractedTarball.
443
+ // Two regimes, switched by whether a public key is embedded:
444
+ // • No embedded key (the shipped default, RELEASE_PUBLIC_KEY=''): INERT —
445
+ // skipped-no-pubkey so an un-provisioned key can never brick auto-update.
446
+ // • Key embedded (signing active): FAIL CLOSED — a missing signature asset, a
447
+ // signature-asset fetch failure, or an invalid signature all return ok=false.
448
+ // Once we publish signed releases, an attacker who can publish a release or MITM
449
+ // the asset CDN must not bypass verification by stripping the signature assets
450
+ // (the tags-fallback path also sends assets:[]). A transient fetch failure only
451
+ // defers the install to the next ~6h poll, not a permanent brick. (audit P1 #5)
452
+ // The CLAUDE_MEM_SKIP_SIG_VERIFY escape hatch still forces a skip. publicKey is a
453
+ // param (defaulting to the embedded constant) only so tests can exercise both regimes.
454
+ export async function verifyReleaseAuthenticity(extractedDir, assets, publicKey = RELEASE_PUBLIC_KEY) {
455
+ if (process.env.CLAUDE_MEM_SKIP_SIG_VERIFY) return { ok: true, action: 'skipped-env' };
456
+ if (!publicKey) return { ok: true, action: 'skipped-no-pubkey' };
457
+
458
+ const list = Array.isArray(assets) ? assets : [];
459
+ const manifestAsset = list.find(a => a && a.name === MANIFEST_ASSET_NAME);
460
+ const sigAsset = list.find(a => a && a.name === SIGNATURE_ASSET_NAME);
461
+ if (!manifestAsset || !sigAsset) {
462
+ debugLog('WARN', 'hook-update', 'Signed-release mode: release carries no signature assets — refusing to install (possible downgrade/strip)');
463
+ return { ok: false, action: 'missing-signature' };
464
+ }
465
+
466
+ let manifestBytes, signatureB64;
467
+ try {
468
+ manifestBytes = await fetchAssetBuffer(manifestAsset.browser_download_url);
469
+ signatureB64 = (await fetchAssetBuffer(sigAsset.browser_download_url)).toString('utf8').trim();
470
+ } catch (e) {
471
+ // Can't fetch the signature → can't verify → don't install this cycle (retries next poll).
472
+ debugLog('WARN', 'hook-update', `Signed-release mode: signature asset fetch failed (${e.message}) — refusing to install this cycle`);
473
+ return { ok: false, action: 'signature-fetch-failed' };
474
+ }
475
+
476
+ const r = verifyDownloadedRelease(extractedDir, manifestBytes, signatureB64, publicKey);
477
+ if (!r.ok) return { ok: false, action: r.reason };
478
+ debugLog('DEBUG', 'hook-update', 'Release signature verified');
479
+ return { ok: true, action: 'verified' };
480
+ }
481
+
375
482
  // opts.skipNpmInstall — copy + atomically switch the source files WITHOUT
376
483
  // running `npm install` in staging. Used by syncDataDirFromCache: when the
377
484
  // source is a local plugin-cache version (not a downloaded tarball), the
package/hook.mjs CHANGED
@@ -27,7 +27,6 @@ import {
27
27
  extractErrorKeywords, extractFilePaths, isRelatedToEpisode,
28
28
  makeEntryDesc, scrubSecrets, stripPrivate, EDIT_TOOLS, debugCatch, debugLog,
29
29
  COMPRESSED_AUTO, COMPRESSED_PENDING_PURGE, OBS_BM25,
30
- computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity,
31
30
  } from './utils.mjs';
32
31
  import {
33
32
  readEpisodeRaw, episodeFile,
@@ -43,11 +42,11 @@ import {
43
42
  sessionFile, getSessionId, createSessionId, openDb,
44
43
  spawnBackground, sweepOrphanEpisodeFiles,
45
44
  } from './hook-shared.mjs';
46
- import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObservation } from './hook-llm.mjs';
45
+ import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObservation, saveEpisodeImmediate } from './hook-llm.mjs';
47
46
  import { scrubRecord } from './lib/scrub-record.mjs';
48
47
  import { formatHookError } from './lib/native-binding-hint.mjs';
49
48
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
50
- import { cleanupBroken, decayAndMarkIdle, boostAccessed } from './lib/maintain-core.mjs';
49
+ import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds } from './lib/maintain-core.mjs';
51
50
  import {
52
51
  extractCitationsFromTranscript,
53
52
  extractAllInjected,
@@ -66,7 +65,6 @@ import { handleLLMOptimize } from './hook-optimize.mjs';
66
65
  import { silentAutoAdopt, hasAutoAdoptMarker } from './adopt-cli.mjs';
67
66
  import { emitV270UpgradeBanner } from './lib/upgrade-banner.mjs';
68
67
  import { loadCiteBackForEpisode, extractCiteBackSignals, buildUnsavedBugfixHint, countUnsavedBugfixShape, buildCiteRecallNudge as libBuildCiteRecallNudge, nextCiteLowStreak } from './lib/cite-back-hint.mjs';
69
- import { MINHASH_PREFILTER, FUZZY_DEDUP_THRESHOLD } from './lib/dedup-constants.mjs';
70
68
  // plugin-cache-guard.mjs loaded dynamically — pre-2.31.2 installs that auto-upgraded
71
69
  // from an older hook-update.mjs SOURCE_FILES (which did not list this module) would
72
70
  // crash on static import. Degrade gracefully to no-op when the module is absent.
@@ -115,6 +113,10 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
115
113
  try {
116
114
  const ep = readEpisodeRaw();
117
115
  if (ep && ep.entries && ep.entries.length > 0) {
116
+ // Persist a rule-based observation synchronously BEFORE writing the flush
117
+ // file — that file has no consumer, so this is the only thing that prevents
118
+ // the in-flight episode being lost on abnormal termination (audit #6).
119
+ saveEpisodeImmediate(ep);
118
120
  const flushFile = join(RUNTIME_DIR, `ep-flush-${Date.now()}-${randomUUID().slice(0, 8)}.json`);
119
121
  writeFileSync(flushFile, JSON.stringify(ep));
120
122
  try { unlinkSync(join(RUNTIME_DIR, `ep-${inferProject()}.json`)); } catch {}
@@ -788,7 +790,7 @@ function runSessionStartAutoMaintain(db) {
788
790
  const SCAN_LIMIT = 500;
789
791
  const FUZZY_MAX_MERGES = 20;
790
792
  const recent = db.prepare(`
791
- SELECT id, title, importance, created_at_epoch
793
+ SELECT id, title, importance, created_at_epoch, narrative, text
792
794
  FROM observations
793
795
  WHERE COALESCE(compressed_into, 0) = 0
794
796
  AND superseded_at IS NULL
@@ -797,24 +799,14 @@ function runSessionStartAutoMaintain(db) {
797
799
  ORDER BY created_at_epoch DESC LIMIT ${SCAN_LIMIT}
798
800
  `).all(STALE_AGE);
799
801
  if (recent.length >= 2) {
800
- const titles = recent.map(r => r.title.trim());
801
- const minhashes = titles.map(t => t ? computeMinHash(t) : null);
802
- const fuzzyRemoveIds = [];
803
- const removed = new Set();
804
- outer: for (let i = 0; i < recent.length; i++) {
805
- if (!minhashes[i] || removed.has(recent[i].id)) continue;
806
- for (let j = i + 1; j < recent.length; j++) {
807
- if (!minhashes[j] || removed.has(recent[j].id)) continue;
808
- if (estimateJaccardFromMinHash(minhashes[i], minhashes[j]) < MINHASH_PREFILTER) continue;
809
- if (jaccardSimilarity(titles[i], titles[j]) < FUZZY_DEDUP_THRESHOLD) continue;
810
- // Keep the higher-importance row; tiebreak by older (lower id wins access history)
811
- const keep = (recent[i].importance ?? 1) >= (recent[j].importance ?? 1) ? recent[i] : recent[j];
812
- const remove = keep === recent[i] ? recent[j] : recent[i];
813
- fuzzyRemoveIds.push(remove.id);
814
- removed.add(remove.id);
815
- if (fuzzyRemoveIds.length >= FUZZY_MAX_MERGES) break outer;
816
- }
817
- }
802
+ // audit #8: supersede only when title AND body match — title-only (a word-SET
803
+ // metric) collapsed distinct observations sharing a title token-set. The
804
+ // selection is the shared pure core in lib/maintain-core (unit-tested there).
805
+ const rows = recent.map(r => ({
806
+ id: r.id, title: r.title, importance: r.importance,
807
+ body: (r.narrative && r.narrative.trim()) || (r.text && r.text.trim()) || '',
808
+ }));
809
+ const fuzzyRemoveIds = selectFuzzyDedupeIds(rows, { maxMerges: FUZZY_MAX_MERGES });
818
810
  if (fuzzyRemoveIds.length > 0) {
819
811
  const ph = fuzzyRemoveIds.map(() => '?').join(',');
820
812
  db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = 'auto-dedup-fuzzy' WHERE id IN (${ph})`)
@@ -33,3 +33,10 @@ export const MINHASH_PREFILTER = 0.7;
33
33
  // 0.95: strict title-Jaccard cutoff for the hook post-inject fuzzy-dedup pass — only
34
34
  // collapse near-identical titles inline; anything softer waits for the maintain sweep.
35
35
  export const FUZZY_DEDUP_THRESHOLD = 0.95;
36
+
37
+ // 0.5: companion BODY-Jaccard floor for the hook fuzzy-dedup pass (audit #8). Titles
38
+ // alone are a word-SET metric, so two distinct observations sharing a title token-set
39
+ // ("Fix auth bug in login.mjs" vs "Fix login.mjs auth bug") would collapse and hide
40
+ // one body. Requiring the narratives to also overlap means only a genuine re-save of
41
+ // the same event (near-identical body) supersedes; distinct bodies are kept.
42
+ export const FUZZY_BODY_THRESHOLD = 0.5;
@@ -15,7 +15,7 @@
15
15
  // Gated entirely by CLAUDE_MEM_CATCH_SAMPLE env (0..1). Default off. All
16
16
  // failures inside the sampler are swallowed — never crash the caller.
17
17
 
18
- import { appendFileSync, mkdirSync, existsSync } from 'fs';
18
+ import { appendFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from 'fs';
19
19
  import { join } from 'path';
20
20
  import { scrubSecrets } from '../secret-scrub.mjs';
21
21
 
@@ -32,6 +32,22 @@ function parseSampleRate(raw) {
32
32
  return Number.isFinite(n) && n >= 0 && n <= 1 ? n : 0;
33
33
  }
34
34
 
35
+ // Delete daily shards older than the retention window. Mirrors
36
+ // lib/hook-telemetry.pruneOldShards (the sibling JSONL sink). Without this the
37
+ // retention constant was dead and errors/ grew one shard/day forever once
38
+ // CLAUDE_MEM_CATCH_SAMPLE was set — a slow unbounded leak in the user data dir.
39
+ function pruneOldShards(dir) {
40
+ let entries;
41
+ try { entries = readdirSync(dir); } catch { return; }
42
+ const cutoff = Date.now() - SAMPLE_LOG_RETENTION_MS;
43
+ for (const f of entries) {
44
+ if (!/^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f)) continue;
45
+ try {
46
+ if (statSync(join(dir, f)).mtimeMs < cutoff) unlinkSync(join(dir, f));
47
+ } catch { /* gone or unreadable — skip */ }
48
+ }
49
+ }
50
+
35
51
  /**
36
52
  * Sample one caught error into the daily JSONL log.
37
53
  * @param {Error|unknown} e Caught error
@@ -59,6 +75,7 @@ export function maybeSampleError(e, ctx, dbDir) {
59
75
  }) + '\n';
60
76
 
61
77
  appendFileSync(join(errDir, `${today()}.jsonl`), line, { mode: 0o600 });
78
+ pruneOldShards(errDir);
62
79
  } catch { /* sampler must never throw */ }
63
80
  }
64
81
 
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { COMPRESSED_PENDING_PURGE, computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity } from '../utils.mjs';
16
16
  import { rebuildVocabulary, computeVector, _resetVocabCache } from '../tfidf.mjs';
17
- import { DEDUP_JACCARD_THRESHOLD, MINHASH_PRE_THRESHOLD as MINHASH_PRE_THRESHOLD_SRC } from './dedup-constants.mjs';
17
+ import { DEDUP_JACCARD_THRESHOLD, MINHASH_PRE_THRESHOLD as MINHASH_PRE_THRESHOLD_SRC, FUZZY_DEDUP_THRESHOLD, FUZZY_BODY_THRESHOLD, MINHASH_PREFILTER } from './dedup-constants.mjs';
18
18
 
19
19
  export const STALE_AGE_MS = 30 * 86400000;
20
20
  export const OP_CAP = 1000;
@@ -28,6 +28,57 @@ export const MINHASH_PRE_THRESHOLD = MINHASH_PRE_THRESHOLD_SRC;
28
28
  // the regular decay op can't touch (decay protects injection_count>0).
29
29
  export const PINNED_INJ_THRESHOLD = 8;
30
30
 
31
+ // Two trimmed bodies count as "the same body" when both are empty (a genuine
32
+ // no-body re-save) or their word-set Jaccard clears the floor. One-empty-one-not
33
+ // is treated as DISTINCT so a body-bearing observation is never hidden by a
34
+ // body-less peer that merely shares its title.
35
+ function bodiesSimilar(a, b, threshold) {
36
+ const ba = (a || '').trim();
37
+ const bb = (b || '').trim();
38
+ if (!ba && !bb) return true;
39
+ if (!ba || !bb) return false;
40
+ return jaccardSimilarity(ba, bb) >= threshold;
41
+ }
42
+
43
+ /**
44
+ * Pick which near-duplicate observation ids to supersede in the hook fuzzy-dedup
45
+ * pass. Pure (no DB) so it is unit-testable. A pair must clear BOTH the title
46
+ * thresholds (MinHash prefilter → exact title Jaccard) AND the body Jaccard floor
47
+ * before the lower-importance row is marked for superseding (audit #8 — title-only
48
+ * matching collapsed observations with the same title token-set but different bodies).
49
+ * @param {Array<{id:number,title:string,body:string,importance:number}>} rows
50
+ * Candidate rows in scan order (caller decides ordering / recency window).
51
+ * @returns {number[]} ids to supersede (lower-importance member of each kept pair).
52
+ */
53
+ export function selectFuzzyDedupeIds(rows, {
54
+ titleThreshold = FUZZY_DEDUP_THRESHOLD,
55
+ bodyThreshold = FUZZY_BODY_THRESHOLD,
56
+ minhashPrefilter = MINHASH_PREFILTER,
57
+ maxMerges = 20,
58
+ } = {}) {
59
+ const removeIds = [];
60
+ if (!Array.isArray(rows) || rows.length < 2) return removeIds;
61
+ const removed = new Set();
62
+ const titles = rows.map(r => (r.title || '').trim());
63
+ const minhashes = titles.map(t => t ? computeMinHash(t) : null);
64
+ outer: for (let i = 0; i < rows.length; i++) {
65
+ if (!minhashes[i] || removed.has(rows[i].id)) continue;
66
+ for (let j = i + 1; j < rows.length; j++) {
67
+ if (!minhashes[j] || removed.has(rows[j].id)) continue;
68
+ if (estimateJaccardFromMinHash(minhashes[i], minhashes[j]) < minhashPrefilter) continue;
69
+ if (jaccardSimilarity(titles[i], titles[j]) < titleThreshold) continue;
70
+ if (!bodiesSimilar(rows[i].body, rows[j].body, bodyThreshold)) continue;
71
+ // Keep the higher-importance row; tiebreak by earlier scan position (kept as i).
72
+ const keep = (rows[i].importance ?? 1) >= (rows[j].importance ?? 1) ? rows[i] : rows[j];
73
+ const remove = keep === rows[i] ? rows[j] : rows[i];
74
+ removeIds.push(remove.id);
75
+ removed.add(remove.id);
76
+ if (removeIds.length >= maxMerges) break outer;
77
+ }
78
+ }
79
+ return removeIds;
80
+ }
81
+
31
82
  /** Delete broken observations (no title AND no narrative). Returns rows deleted. */
32
83
  // Before hard-deleting observations, un-hide any rows merged INTO them. A child has
33
84
  // compressed_into = <keeperId>; deleting that keeper (compressed_into has no FK) would
@@ -0,0 +1,106 @@
1
+ // lib/release-digest.mjs — shared release-signing core (P1 supply-chain hardening).
2
+ //
3
+ // One source of truth for BOTH sides of the auto-update authenticity check so the
4
+ // CI signer and the runtime verifier can never drift:
5
+ // * scripts/sign-release.mjs (CI) — builds the manifest, serializes it, signs
6
+ // the EXACT serialized bytes with an Ed25519 private key (a GitHub secret),
7
+ // and uploads release-manifest.json (+ .sig) as GitHub Release assets.
8
+ // * hook-update.mjs (client) — downloads those assets, verifies the signature
9
+ // over the downloaded manifest bytes with an EMBEDDED public key, then checks
10
+ // each extracted file's sha256 against the signed manifest.
11
+ //
12
+ // Why content hashes, not a tarball-byte hash: GitHub's on-the-fly git-archive
13
+ // tarballs are not guaranteed byte-stable over time, but the FILE CONTENTS at a
14
+ // tag are (they are the git blobs). Hashing the extracted files sidesteps the
15
+ // archive-byte-stability problem entirely.
16
+ //
17
+ // Why verify over the downloaded file bytes (not a re-serialization): the client
18
+ // verifies the signature against the manifest bytes exactly as downloaded and
19
+ // only THEN parses it — so there is no canonical-JSON drift between signer and
20
+ // verifier. serializeManifest() exists so CI writes precisely what it signs.
21
+ //
22
+ // Pure Node built-ins (node:crypto Ed25519) — zero dependencies, no `gh`/cosign
23
+ // needed on the user's machine, works in the silent SessionStart hook.
24
+
25
+ import { createHash, verify as cryptoVerify } from 'node:crypto';
26
+ import { readFileSync, existsSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+
29
+ const PACKAGE_NAME = 'claude-mem-lite';
30
+
31
+ export function sha256Hex(buf) {
32
+ return createHash('sha256').update(buf).digest('hex');
33
+ }
34
+
35
+ export function sha256File(absPath) {
36
+ return sha256Hex(readFileSync(absPath));
37
+ }
38
+
39
+ /**
40
+ * Build a release manifest: { name, version, algo, files: { <relPath>: <sha256> } }.
41
+ * Only files that exist under rootDir are listed; keys are sorted so the
42
+ * serialization is deterministic regardless of fileList order.
43
+ *
44
+ * @param {string} rootDir Extracted release root (or CI checkout root)
45
+ * @param {string[]} fileList Relative paths to hash (typically SOURCE_FILES)
46
+ * @param {string} version Release version (for the manifest body)
47
+ */
48
+ export function buildReleaseManifest(rootDir, fileList, version) {
49
+ const files = {};
50
+ for (const rel of [...fileList].sort()) {
51
+ const abs = join(rootDir, rel);
52
+ if (existsSync(abs)) files[rel] = sha256File(abs);
53
+ }
54
+ return { name: PACKAGE_NAME, version, algo: 'sha256', files };
55
+ }
56
+
57
+ /**
58
+ * Deterministic byte serialization. CI writes EXACTLY this to
59
+ * release-manifest.json and signs these bytes; the client verifies the signature
60
+ * against the downloaded file bytes (it does not re-serialize), so signer and
61
+ * verifier cannot disagree on canonical form.
62
+ */
63
+ export function serializeManifest(manifest) {
64
+ return JSON.stringify(manifest, null, 2) + '\n';
65
+ }
66
+
67
+ /**
68
+ * Verify every file the manifest lists matches its signed sha256 on disk.
69
+ * Returns { ok, mismatches: string[], missing: string[] }.
70
+ * A content change → mismatches; a manifest-listed file absent → missing.
71
+ * Both are failures (ok=false).
72
+ */
73
+ export function verifyReleaseFiles(rootDir, manifest) {
74
+ const mismatches = [];
75
+ const missing = [];
76
+ const files = (manifest && manifest.files) || {};
77
+ for (const [rel, expected] of Object.entries(files)) {
78
+ const abs = join(rootDir, rel);
79
+ if (!existsSync(abs)) { missing.push(rel); continue; }
80
+ if (sha256File(abs) !== expected) mismatches.push(rel);
81
+ }
82
+ return { ok: mismatches.length === 0 && missing.length === 0, mismatches, missing };
83
+ }
84
+
85
+ /**
86
+ * Verify an Ed25519 signature over the manifest bytes. Never throws — a malformed
87
+ * key/signature/algorithm returns false so callers can treat "can't verify" the
88
+ * same as "invalid" without a try/catch at every call site.
89
+ *
90
+ * @param {Buffer|string} manifestBytes The EXACT manifest bytes that were signed
91
+ * @param {string} signatureB64 Base64-encoded Ed25519 signature
92
+ * @param {string} publicKeyPem SPKI PEM public key
93
+ * @returns {boolean}
94
+ */
95
+ export function verifyManifestSignature(manifestBytes, signatureB64, publicKeyPem) {
96
+ try {
97
+ if (!publicKeyPem || !signatureB64) return false;
98
+ const data = Buffer.isBuffer(manifestBytes) ? manifestBytes : Buffer.from(manifestBytes);
99
+ const sig = Buffer.from(signatureB64, 'base64');
100
+ if (sig.length === 0) return false;
101
+ // `null` algorithm = Ed25519 (the key type carries the algorithm in Node).
102
+ return cryptoVerify(null, data, publicKeyPem, sig);
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
@@ -428,7 +428,10 @@ export async function coreRunSearchPipeline(ctx, opts) {
428
428
  const doReRank = rerankPolicy === 'mcp' ? (ftsQuery && !deepReranked) : !deepReranked;
429
429
  if (doReRank) reRankWithContext(db, obsResults, rerankProject);
430
430
  markSuperseded(obsResults);
431
- const doReSort = rerankPolicy === 'mcp' ? (ftsQuery && !deepReranked) : isCrossSource;
431
+ // CLI single-source path must also re-sort when a context re-rank actually ran,
432
+ // else reRankWithContext's score boost mutates scores but never reorders output
433
+ // (audit #9). MCP branch unchanged. doReRank already implies a rerank happened.
434
+ const doReSort = rerankPolicy === 'mcp' ? (ftsQuery && !deepReranked) : (isCrossSource || doReRank);
432
435
  if (doReSort) results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
433
436
  }
434
437
 
package/mem-cli.mjs CHANGED
@@ -57,6 +57,11 @@ async function cmdSearch(db, args, { llm } = {}) {
57
57
  return;
58
58
  }
59
59
 
60
+ // Bare string flags parse to boolean `true`; without this guard `--branch` reaches
61
+ // the SQLite bind and crashes, while `--to`/`--project` silently change results
62
+ // (epoch-1 upper bound → zero rows; unscoped search). (audit P1 #3)
63
+ if (rejectBareStringFlags(flags, ['source', 'project', 'from', 'to', 'branch'])) return;
64
+
60
65
  const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 20, max: 1000 });
61
66
  const type = flags.type || null;
62
67
  const validObsTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.7.1",
3
+ "version": "3.9.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -73,6 +73,7 @@
73
73
  "lib/binding-probe.mjs",
74
74
  "lib/proc-lock.mjs",
75
75
  "lib/atomic-write.mjs",
76
+ "lib/release-digest.mjs",
76
77
  "lib/mem-override.mjs",
77
78
  "lib/save-observation.mjs",
78
79
  "lib/observation-write.mjs",
package/registry.mjs CHANGED
@@ -110,7 +110,7 @@ const TRIGGERS_SCHEMA = `
110
110
  const INVOCATIONS_SCHEMA = `
111
111
  CREATE TABLE IF NOT EXISTS invocations (
112
112
  id INTEGER PRIMARY KEY AUTOINCREMENT,
113
- resource_id INTEGER NOT NULL REFERENCES resources(id),
113
+ resource_id INTEGER NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
114
114
  session_id TEXT,
115
115
  trigger TEXT CHECK(trigger IN ('session_start','pre_tool_use','user_explicit','user_prompt')),
116
116
  tier INTEGER CHECK(tier IN (1,2,3)),
@@ -195,11 +195,19 @@ export function ensureRegistryDb(dbPath) {
195
195
  } catch (e) { debugCatch(e, 'resources-column-migration'); }
196
196
 
197
197
  // Migrate: add 'github' to source CHECK constraint (required for smart import)
198
- // Must disable FK checks during table recreation (RENAME triggers FK validation)
198
+ // Must disable FK checks during table recreation (RENAME triggers FK validation).
199
+ // legacy_alter_table=ON is REQUIRED: under modern SQLite (the better-sqlite3
200
+ // default) `ALTER TABLE resources RENAME TO resources_old` rewrites child-table FK
201
+ // references, so invocations.resource_id would become `REFERENCES resources_old`
202
+ // and the trailing DROP would leave it dangling — silently killing every future
203
+ // `INSERT INTO invocations` (audit P0 #1). Legacy mode keeps child FKs pointing at
204
+ // the original name, which the freshly-created `resources` table then satisfies.
205
+ let resourcesRebuilt = false;
199
206
  try {
200
207
  const resSchema = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='resources'`).get();
201
208
  if (resSchema?.sql && !resSchema.sql.includes("'github'")) {
202
209
  db.pragma('foreign_keys = OFF');
210
+ db.pragma('legacy_alter_table = ON');
203
211
  try {
204
212
  db.transaction(() => {
205
213
  const hasOld = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name='resources_old'`).get();
@@ -216,10 +224,18 @@ export function ensureRegistryDb(dbPath) {
216
224
  const common = cols.filter(c => newCols.has(c)).join(', ');
217
225
  db.exec(`INSERT INTO resources (${common}) SELECT ${common} FROM resources_old`);
218
226
  db.exec(`DROP TABLE resources_old`);
227
+ // Recreate the table's indexes: the CREATE INDEX IF NOT EXISTS inside
228
+ // RESOURCES_SCHEMA above was SKIPPED while resources_old still held the
229
+ // index names, so the rebuilt table had NONE — including the UNIQUE
230
+ // idx_res_type_name that upsertResource's ON CONFLICT(type,name) requires
231
+ // (review HIGH-1; pre-existing, closed here). Names are free post-DROP.
232
+ db.exec(RESOURCES_SCHEMA);
219
233
  })();
220
234
  } finally {
235
+ db.pragma('legacy_alter_table = OFF');
221
236
  db.pragma('foreign_keys = ON');
222
237
  }
238
+ resourcesRebuilt = true;
223
239
  }
224
240
  } catch (e) { debugCatch(e, 'resources-source-check-migration'); }
225
241
 
@@ -231,6 +247,16 @@ export function ensureRegistryDb(dbPath) {
231
247
  // Triggers: always ensure (IF NOT EXISTS) — fixes DBs where FTS5 was created without triggers
232
248
  db.exec(TRIGGERS_SCHEMA);
233
249
 
250
+ // The source-CHECK migration replaced the `resources` content table out from under
251
+ // the external-content FTS index (content=resources), leaving resources_fts stale.
252
+ // Rebuild it so a later DELETE's res_fts_delete trigger doesn't throw "database disk
253
+ // image is malformed" against the mismatched index. Gated on the migration actually
254
+ // having run so we don't rebuild on every open.
255
+ if (resourcesRebuilt) {
256
+ try { db.exec("INSERT INTO resources_fts(resources_fts) VALUES('rebuild')"); }
257
+ catch (e) { debugCatch(e, 'resources-fts-rebuild-after-source-check'); }
258
+ }
259
+
234
260
  db.exec(INVOCATIONS_SCHEMA);
235
261
 
236
262
  // Migrate invocations CHECK constraint: add 'user_prompt' trigger value
@@ -281,10 +307,44 @@ export function ensureRegistryDb(dbPath) {
281
307
  }
282
308
  } catch (e) { debugCatch(e, 'rejection_reason-migration'); }
283
309
 
284
- // Migrate: ensure composite index on invocations(resource_id, created_at) for correlated subqueries
310
+ // Migrate: add ON DELETE CASCADE to invocations.resource_id (audit P0 #4). Old DBs
311
+ // declared the FK with no ON DELETE action, so deleting a resource that had
312
+ // invocation history threw SQLITE_CONSTRAINT_FOREIGNKEY (registry remove /
313
+ // mem_registry delete) or silently no-op'd (dead-repo purge). SQLite can't ALTER an
314
+ // FK, so rebuild the table. Renaming the CHILD table is safe (nothing references
315
+ // invocations), so legacy_alter_table is not a concern here. Runs after the
316
+ // rejection_reason ADD COLUMN so the column exists in both old and new tables.
285
317
  try {
286
- db.exec(`CREATE INDEX IF NOT EXISTS idx_invocations_resource_created ON invocations(resource_id, created_at)`);
287
- } catch (e) { debugCatch(e, 'invocations-resource-created-index-migration'); }
318
+ const schema = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='invocations'`).get();
319
+ if (schema?.sql && !/ON DELETE CASCADE/i.test(schema.sql)) {
320
+ db.transaction(() => {
321
+ const hasOld = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name='invocations_old'`).get();
322
+ if (hasOld) db.exec(`DROP TABLE invocations_old`);
323
+ db.exec(`ALTER TABLE invocations RENAME TO invocations_old`);
324
+ db.exec(INVOCATIONS_SCHEMA);
325
+ // Omit rejection_reason from the copy (matching the CHECK migrations above):
326
+ // it was historically a bare TEXT with NO CHECK, so an old row could hold a
327
+ // value outside INVOCATIONS_SCHEMA's current rejection_reason CHECK whitelist.
328
+ // Copying it would throw SQLITE_CONSTRAINT_CHECK → rollback → the FK is left
329
+ // un-cascaded forever and every retry re-fails (review HIGH-2). The column is
330
+ // never written at runtime, so copied rows get NULL — no data loss.
331
+ db.exec(`INSERT INTO invocations
332
+ (id, resource_id, session_id, trigger, tier, recommended, adopted, outcome, score, created_at)
333
+ SELECT id, resource_id, session_id, trigger, tier, recommended, adopted, outcome, score, created_at
334
+ FROM invocations_old`);
335
+ db.exec(`DROP TABLE invocations_old`);
336
+ // Recreate the table's indexes — the INVOCATIONS_SCHEMA CREATE INDEX above was
337
+ // skipped while invocations_old held the names (review HIGH-1). Free post-DROP.
338
+ db.exec(INVOCATIONS_SCHEMA);
339
+ })();
340
+ }
341
+ } catch (e) { debugCatch(e, 'invocations-ondelete-cascade-migration'); }
342
+
343
+ // (Removed the separate idx_invocations_resource_created migration — it was a column-
344
+ // identical duplicate of idx_inv_resource (resource_id, created_at) in INVOCATIONS_SCHEMA.
345
+ // It only ever survived because the rebuild migrations dropped idx_inv_resource; now that
346
+ // the rebuilds recreate their indexes (review HIGH-1), the duplicate is pure dead weight.
347
+ // Pre-existing DBs keep their old idx_invocations_resource_created; it's harmless.)
288
348
 
289
349
  db.exec(PREINSTALLED_SCHEMA);
290
350
 
package/scripts/setup.sh CHANGED
@@ -86,6 +86,7 @@ mark_deps_broken() {
86
86
  # having to re-derive them. Delegate JSON serialization to node so embedded
87
87
  # quotes / shell metachars in $ROOT or $reason can't produce an invalid file
88
88
  # (bash `printf '"..%s.."'` cannot escape arbitrary strings safely; v2.79.1 fix).
89
+ # shellcheck disable=SC2016 # node script single-quoted on purpose; vars passed via env (MARK_*), not shell expansion
89
90
  MARK_REASON="$reason" MARK_ROOT="$ROOT" MARK_FLAG="$DEPS_FLAG" node -e '
90
91
  const fs = require("fs");
91
92
  const reason = process.env.MARK_REASON || "unknown";
@@ -141,6 +142,7 @@ fi
141
142
  # versions; same shape as the .deps-broken self-heal pattern.
142
143
  MCP_MIGRATION="$DATA_DIR/runtime/.mcp-dedup-v2.78"
143
144
  if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && ! -f "$MCP_MIGRATION" ]]; then
145
+ # shellcheck disable=SC2016 # node script single-quoted on purpose; CLAUDE_JSON passed via env, not shell expansion
144
146
  CLAUDE_JSON="$HOME/.claude.json" node -e '
145
147
  const fs = require("fs");
146
148
  let changed = false;
package/secret-scrub.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  // claude-mem-lite: Secret pattern detection and scrubbing
2
2
  // Extracted from utils.mjs for focused responsibility
3
3
 
4
+ import { stripPrivate } from './lib/private-strip.mjs';
5
+
4
6
  // ─── Secret Patterns ──────────────────────────────────────────────────────
5
7
 
6
8
  export const SECRET_PATTERNS = [
@@ -28,7 +30,28 @@ export const SECRET_PATTERNS = [
28
30
  // access_token / refresh_token are the canonical OAuth2 field names — they were
29
31
  // missing from this KV list (drift vs the JSON list below). `(?:\b|_)` for the same
30
32
  // underscore-prefix reason.
31
- [/((?:\b|_)(?:api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token)\s*[=:]\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
33
+ // `pgpassword|pgpass|mysql_pwd` are well-known credential ENV-VAR names whose
34
+ // keyword tail is unreachable via the noun list above (`PGPASSWORD`=PG+password has
35
+ // no \b/_ before "password"; `MYSQL_PWD` has no "password"/"token" substring). They
36
+ // live in THIS pattern (no prose lookbehind) so `export PGPASSWORD=x` / `env MYSQL_PWD=x`
37
+ // scrub — a compound credential env-var name is unambiguous config even after a word.
38
+ // Enumerating known names (not a blanket letter-prefix) preserves the deliberate
39
+ // low-FP decision that `topsecret=` / `access_token_count:` are non-credentials
40
+ // (#8283 + utils.test.mjs:1089-1100); bare `pwd` is omitted so `PWD=` (a path) survives.
41
+ [/((?:\b|_)(?:api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token|pgpassword|pgpass|mysql_pwd)\s*[=:]\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
42
+ // Bare-key QUOTED values — `api_key="..."`, `password: '...'`. The unquoted KV
43
+ // patterns above stop at `'`/`"` (excluded from their value class), so a quoted
44
+ // value matched 0 chars and slipped through. Consumes the opening quote, the value,
45
+ // and the matching close quote (backref \2), replacing only the value. Unlike the
46
+ // JSON pattern below it does NOT require the KEY to be quoted, covering `key="value"`
47
+ // object-literal / YAML / quoted-.env shapes. Split into the SAME two patterns as the
48
+ // unquoted KV pairs above so prose survives — a quoted value does not turn prose into
49
+ // config (`the token: "x"` is still prose, must NOT scrub; #8283 / utils.test.mjs:1090).
50
+ // (a) bare credential nouns keep the prose lookbehind:
51
+ [/((?<![A-Za-z][ \t])(?:\b|_)(?:password|passwd|token|bearer|secret)\s*[=:]\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
52
+ // (b) structured keys + named env vars are unambiguous config even after a word
53
+ // (`see api_key: "x"` DOES scrub, mirroring the unquoted structured-key path):
54
+ [/((?:\b|_)(?:pgpassword|pgpass|mysql_pwd|api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token)\s*[=:]\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
32
55
  // AWS access keys (AKIA...)
33
56
  [/\bAKIA[A-Z0-9]{16}\b/g, '***'],
34
57
  // OpenAI / Anthropic keys (sk-...) — specific prefixes have lower length threshold
@@ -94,12 +117,15 @@ export const SECRET_PATTERNS = [
94
117
 
95
118
  /**
96
119
  * Scrub known secret patterns (API keys, tokens, credentials) from text.
120
+ * Also strips user-marked `<private>...</private>` blocks first, so every
121
+ * persistence/log path that scrubs secrets inherits the `<private>` opt-out —
122
+ * previously stripPrivate ran only on the user-prompt hook, not on writes.
97
123
  * @param {string} text Input text potentially containing secrets
98
124
  * @returns {string} Text with secrets replaced by '***'
99
125
  */
100
126
  export function scrubSecrets(text) {
101
127
  if (!text || typeof text !== 'string') return text || '';
102
- let result = text;
128
+ let result = stripPrivate(text);
103
129
  for (const [pattern, replacement] of SECRET_PATTERNS) {
104
130
  result = result.replace(pattern, replacement);
105
131
  }
package/source-files.mjs CHANGED
@@ -73,6 +73,10 @@ export const SOURCE_FILES = [
73
73
  // + auto-update lock). Must ship or a partial install/update skips them.
74
74
  'lib/proc-lock.mjs',
75
75
  'lib/atomic-write.mjs',
76
+ // P1 supply-chain: shared release-signing core (sha256 manifest + Ed25519
77
+ // verify). Imported by hook-update.mjs (verify) + scripts/sign-release.mjs (CI
78
+ // sign). Must ship or auto-update can't verify release signatures.
79
+ 'lib/release-digest.mjs',
76
80
  // v2.41 god-module split — mem-cli.mjs router + per-cmd handlers under cli/
77
81
  'cli/common.mjs',
78
82
  'cli/fts-check.mjs',