claude-mem-lite 3.62.0 → 3.63.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.62.0",
13
+ "version": "3.63.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.62.0",
3
+ "version": "3.63.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
@@ -529,6 +529,32 @@ Notes:
529
529
  - Direct install / npx mode keeps auto-update enabled and uses staged replacement with rollback on install failure.
530
530
  - If you disabled the plugin but still have old mem hooks in `~/.claude/settings.json`, run `node install.mjs cleanup-hooks`.
531
531
 
532
+ #### Trust model per install path
533
+
534
+ The three install paths do **not** carry the same supply-chain guarantees — pick the one that matches your threat model:
535
+
536
+ | Path | Update mechanism | Ed25519 release-signature verification |
537
+ |------|------------------|----------------------------------------|
538
+ | npm / npx / git-clone direct install | auto-update from GitHub Releases | **Yes** — every runtime file (140 entries incl. hook scripts, MCP launcher, plugin declaration files) is hash-pinned in a signed manifest; verification is fail-closed |
539
+ | `/plugin install` (marketplace) | manual `/plugin marketplace update` + reinstall | **No** — Claude Code installs from a git clone of the marketplace repo; the plugin's own signature chain is not consulted on this path. You are trusting GitHub + the repo's branch protection, not the release signing key |
540
+
541
+ **Rollback recipe (plugin path).** If an update misbehaves, pin the marketplace clone to the previous release tag and reinstall from it:
542
+
543
+ ```bash
544
+ # 1. Find the local marketplace clone
545
+ ls ~/.claude/plugins/marketplaces/ # e.g. sdsrss
546
+
547
+ # 2. Pin it to the previous good tag (tags mirror npm versions, e.g. v3.62.0)
548
+ cd ~/.claude/plugins/marketplaces/sdsrss
549
+ git fetch --tags && git checkout v3.62.0
550
+
551
+ # 3. Reinstall from the pinned clone — inside Claude Code:
552
+ # /plugin install claude-mem-lite@sdsrss
553
+ # 4. To leave the pin later: git checkout main, then the normal update flow.
554
+ ```
555
+
556
+ Your data directory (`~/.claude-mem-lite/`) is untouched by install/rollback; schema migrations are forward-only, so after rolling back more than one minor version check `node install.mjs doctor` before trusting search results.
557
+
532
558
  ### doctor
533
559
 
534
560
  Checks Node.js version, dependencies, server/hook files, database integrity, FTS5 indexes, and stale processes.
package/format-utils.mjs CHANGED
@@ -123,6 +123,27 @@ export function neutralizeSkillDelimiters(s) {
123
123
  return defangToFixpoint(s, SKILL_BLOCK_RE);
124
124
  }
125
125
 
126
+ // <skill-bridge> is the wrapper scripts/pre-skill-bridge.js puts around a managed
127
+ // skill body it injects as PreToolUse additionalContext. The body comes from a
128
+ // third-party repo (tools/adopt import) — an untrusted boundary — so a literal
129
+ // `</skill-bridge>` inside it would close the wrapper early and spill the rest of
130
+ // the payload (e.g. a forged <system-reminder>) as undelimited context (audit
131
+ // 2026-08-14 M-4). Not in CONTEXT_DELIMITER_RE for the same reason <skill-loaded>
132
+ // isn't: the bridge's OWN wrapper must stay live, so the defang is applied per
133
+ // call site to the untrusted body only.
134
+ const SKILL_BRIDGE_RE = /<\/?skill-bridge(?:\s[^>]*)?>/gi;
135
+
136
+ /**
137
+ * Defang a literal `<skill-bridge>` opener/closer in untrusted text that is about
138
+ * to be wrapped in a real skill-bridge block. Same fixpoint treatment as the
139
+ * classes above. Never apply to the wrapper itself.
140
+ * @param {string} s Input string (any type; coerced)
141
+ * @returns {string} Text with skill-bridge delimiters defanged
142
+ */
143
+ export function neutralizeSkillBridgeDelimiters(s) {
144
+ return defangToFixpoint(s, SKILL_BRIDGE_RE);
145
+ }
146
+
126
147
  /**
127
148
  * Render the PostToolUse error-recall hint block (hook.mjs::triggerErrorRecall).
128
149
  * The single most-relevant hit (rows[0]) that carries a lesson_learned gets its
package/hook.mjs CHANGED
@@ -494,8 +494,12 @@ function triggerErrorRecall(db, toolInput, response) {
494
494
  AND COALESCE(o.compressed_into, 0) = 0
495
495
  AND o.superseded_at IS NULL
496
496
  AND ${notLowSignalTitleClause('o')}
497
+ -- MAX(0, …) clamps recency age to >= 0 (parity with search-engine FULL_SCORE):
498
+ -- a far-future created_at (reachable via restore/import-jsonl, which accept
499
+ -- arbitrary epochs) made the exponent large-positive → EXP overflow → that row
500
+ -- pinned #1 for every error until its "future" passed (audit 2026-08-14 M-1).
497
501
  ORDER BY ${OBS_BM25}
498
- * (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / 1209600000.0))
502
+ * (1.0 + EXP(-0.693 * MAX(0, ? - o.created_at_epoch) / 1209600000.0))
499
503
  LIMIT 3
500
504
  `).all(ftsQuery, project, nowR);
501
505
 
@@ -1017,6 +1021,15 @@ function runSessionStartAutoMaintain(db) {
1017
1021
 
1018
1022
  // Auto-dedup (exact): merge identical-title observations within 1h.
1019
1023
  // Catches rapid duplicate writes (same hook firing twice, race conditions).
1024
+ // BOTH join sides must be live (audit 2026-08-14 H-1): without the
1025
+ // superseded_at filters (which the fuzzy channel below always had), a row the
1026
+ // fuzzy pass had tombstoned could come back as `a` (a.id < b.id) and tombstone
1027
+ // the LIVE keeper `b` — both copies gone from every read path. Worse, a user
1028
+ // correction saved with supersedes=[#A] (A.superseded_by = B's NUMERIC id) has
1029
+ // the same title as A, so the pair (A, B) tombstoned the correction B itself
1030
+ // and the string 'auto-dedup' write clobbered numeric supersession chains that
1031
+ // citation-tracker decay hand-off and timeline re-anchoring both follow. The
1032
+ // UPDATE repeats the guard so a concurrent writer can't re-stamp a chain.
1020
1033
  const dupPairs = db.prepare(`
1021
1034
  SELECT a.id as keep_id, b.id as remove_id
1022
1035
  FROM observations a
@@ -1025,12 +1038,14 @@ function runSessionStartAutoMaintain(db) {
1025
1038
  AND ABS(a.created_at_epoch - b.created_at_epoch) < 3600000
1026
1039
  AND COALESCE(a.compressed_into, 0) = 0
1027
1040
  AND COALESCE(b.compressed_into, 0) = 0
1041
+ AND a.superseded_at IS NULL
1042
+ AND b.superseded_at IS NULL
1028
1043
  LIMIT 20
1029
1044
  `).all();
1030
1045
  if (dupPairs.length > 0) {
1031
1046
  const removeIds = dupPairs.map(p => p.remove_id);
1032
1047
  const ph = removeIds.map(() => '?').join(',');
1033
- db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = 'auto-dedup' WHERE id IN (${ph})`).run(Date.now(), ...removeIds);
1048
+ db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = 'auto-dedup' WHERE id IN (${ph}) AND superseded_at IS NULL`).run(Date.now(), ...removeIds);
1034
1049
  debugLog('DEBUG', 'auto-maintain', `auto-deduped ${dupPairs.length} near-identical observations`);
1035
1050
  }
1036
1051
 
@@ -1648,9 +1663,13 @@ async function handleUserPrompt() {
1648
1663
  try {
1649
1664
  const injectedFile = join(RUNTIME_DIR, `.claude-mem-injected-${project}`);
1650
1665
  const raw = readFileSync(injectedFile, 'utf8');
1651
- const { ids, ts } = JSON.parse(raw);
1652
- // Only use if written within last 10 seconds (same prompt cycle)
1653
- if (ts && Date.now() - ts < 10000 && Array.isArray(ids)) {
1666
+ const { ids, ts, session } = JSON.parse(raw);
1667
+ // Only use if written within last 10 seconds (same prompt cycle) AND by this
1668
+ // CC session the file is project-keyed, so a concurrent session's write
1669
+ // would otherwise dedup-suppress OUR injection (M-6, audit 2026-08-14).
1670
+ // Legacy payloads without `session` keep the old time-window-only behavior.
1671
+ if (ts && Date.now() - ts < 10000 && Array.isArray(ids)
1672
+ && !(session && ccSessionId && session !== ccSessionId)) {
1654
1673
  for (const id of ids) { keyContextIds.push(id); pathAInjectedIds.push(id); }
1655
1674
  }
1656
1675
  } catch { /* file may not exist — that's fine */ }
@@ -1871,6 +1890,11 @@ try {
1871
1890
  // lib/native-binding-hint.mjs.
1872
1891
  const line = formatHookError(err, event, { runtimeDir: RUNTIME_DIR });
1873
1892
  if (line) console.error(line);
1893
+ // stderr alone is invisible to `stats` self-observation — only the native-binding
1894
+ // family was persisted, so a non-binding fatal (schema drift, bad stdin shape)
1895
+ // could kill every dispatch-routed surface while the hook-errors log read zero
1896
+ // (audit 2026-08-14 M-5; same blindness one layer up from the v3.60 outage).
1897
+ recordHookError(`hook:${event}`, err, RUNTIME_DIR);
1874
1898
  }
1875
1899
 
1876
1900
  process.exit(0);
package/install.mjs CHANGED
@@ -1561,6 +1561,27 @@ async function doctor() {
1561
1561
  ok(`Native DB binding: loadable on Node ${process.version}`);
1562
1562
  }
1563
1563
 
1564
+ // Disk footprint (audit 2026-08-14 M-9): a "lite" data dir had grown to 653MB
1565
+ // against a 59MB DB — 360MB of it orphaned per-tag .bak snapshots — with no
1566
+ // check anywhere. Cheap probes only (DB file + .bak aggregate, no tree walk).
1567
+ // The budget itself is enforced by lib/db-backup on every new snapshot; this
1568
+ // check surfaces stores that predate the budget or exceed it between snapshots.
1569
+ try {
1570
+ const { listSnapshots, backupBudgetBytes } = await import('./lib/db-backup.mjs');
1571
+ const dbFile = join(MEM_DATA_DIR, 'claude-mem-lite.db');
1572
+ const dbBytes = existsSync(dbFile) ? statSync(dbFile).size : 0;
1573
+ const snaps = listSnapshots(dbFile);
1574
+ const backupBytes = snaps.reduce((s, x) => s + x.size, 0);
1575
+ const mb = (n) => (n / (1024 * 1024)).toFixed(1);
1576
+ // Warn threshold = the REAL eviction budget (pre-release review 2026-08-16) —
1577
+ // warning below it promised an eviction enforceBackupBudget would never do.
1578
+ if (backupBytes > backupBudgetBytes()) {
1579
+ dwarn(`Disk footprint: ${snaps.length} backup snapshot(s) hold ${mb(backupBytes)}MB, over the ${mb(backupBudgetBytes())}MB budget (CLAUDE_MEM_BACKUP_BUDGET_MB) — the next maintain/save snapshot evicts oldest snapshots past the 7d undo grace`);
1580
+ } else {
1581
+ ok(`Disk footprint: DB ${mb(dbBytes)}MB, ${snaps.length} backup snapshot(s) ${mb(backupBytes)}MB (budget ${mb(backupBudgetBytes())}MB)`);
1582
+ }
1583
+ } catch { /* footprint check is informational — never block doctor */ }
1584
+
1564
1585
  // Plugin/hook lifecycle state
1565
1586
  const settings = readSettings();
1566
1587
  const hasHooks = hasMemHooksConfigured(settings);
package/lib/db-backup.mjs CHANGED
@@ -7,10 +7,92 @@
7
7
  //
8
8
  // MUST be called OUTSIDE any transaction — VACUUM cannot run inside one, which is
9
9
  // why the maintenance entry points snapshot before opening their db.transaction().
10
- import { readdirSync, unlinkSync } from 'fs';
10
+ import { readdirSync, unlinkSync, statSync } from 'fs';
11
11
  import { dirname, basename, join } from 'path';
12
12
  import { debugLog } from '../utils.mjs';
13
13
 
14
+ // M-9 (audit 2026-08-14): per-tag retention alone let one-shot tags live forever —
15
+ // each tag kept its newest 3, but a tag written once (pre-backfill-v3390 and four
16
+ // siblings) never got a second snapshot to age it out, so 9 orphaned .bak files held
17
+ // 360MB against a 59MB live DB. A TOTAL byte budget across ALL tags bounds the
18
+ // footprint of a system whose name promises "lite". Oldest-first eviction, newest
19
+ // snapshot always survives (the most recent safety net must outlive any budget).
20
+ const DEFAULT_BACKUP_BUDGET_BYTES = 256 * 1024 * 1024;
21
+
22
+ /** Effective budget (env-tunable). Exported so stats/doctor derive their
23
+ * footprint-warning thresholds from the SAME number eviction acts on —
24
+ * pre-release review 2026-08-16: a hardcoded `3× DB` hint fired ~2.7× below
25
+ * the real budget and promised an eviction that would never happen. */
26
+ export function backupBudgetBytes() {
27
+ const mb = Number(process.env.CLAUDE_MEM_BACKUP_BUDGET_MB);
28
+ return Number.isFinite(mb) && mb > 0 ? mb * 1024 * 1024 : DEFAULT_BACKUP_BUDGET_BYTES;
29
+ }
30
+
31
+ // Eviction grace: snapshots younger than this are never budget-evicted. This is
32
+ // what protects a fresh `pre-delete` undo pre-image (deleteObservations reports
33
+ // its path as the recovery route) from being unlinked by same-day maintain churn
34
+ // (pre-release review 2026-08-16). Deliberately age-based, NOT newest-per-tag:
35
+ // a per-tag exemption would make every one-shot tag's only snapshot immortal —
36
+ // exactly the 360MB orphan shape M-9 exists to evict. Mirrors the purge grace
37
+ // convention (7d) used by stale-observation retention.
38
+ export const BACKUP_EVICTION_GRACE_MS = 7 * 86400000;
39
+
40
+ // Canonical snapshot shape `<base>.<tag>-<ISO stamp>-<pid>-<seq>.bak` (what
41
+ // snapshotDb writes). Budget eviction deletes ONLY names matching this — a
42
+ // user's hand-made `cp db db.before-upgrade.bak` must never be auto-unlinked.
43
+ const SNAPSHOT_STAMP_RE = /-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-\d+-\d+\.bak$/;
44
+
45
+ /** All `<db>.<tag>-<ts>.bak` snapshots for `dbPath`, any tag, with size + mtime. */
46
+ export function listSnapshots(dbPath) {
47
+ try {
48
+ const dir = dirname(dbPath);
49
+ const prefix = `${basename(dbPath)}.`;
50
+ const out = [];
51
+ for (const n of readdirSync(dir)) {
52
+ if (!n.startsWith(prefix) || !n.endsWith('.bak')) continue;
53
+ const full = join(dir, n);
54
+ try {
55
+ const st = statSync(full);
56
+ out.push({ path: full, size: st.size, mtimeMs: st.mtimeMs });
57
+ } catch { /* raced away */ }
58
+ }
59
+ return out;
60
+ } catch { return []; }
61
+ }
62
+
63
+ /**
64
+ * Evict oldest snapshots (across ALL tags) until the total is within the byte
65
+ * budget. Never evicted: the single newest snapshot (even if it alone exceeds
66
+ * the budget), anything younger than BACKUP_EVICTION_GRACE_MS, and any file not
67
+ * matching the canonical snapshot name shape. The total counts every .bak
68
+ * (exempt files included), so a burst of young snapshots can hold the dir above
69
+ * budget for up to the grace window — bounded by write rate, and the price of
70
+ * never deleting an undo pre-image someone may still want.
71
+ * Returns the number of files removed. Best-effort, never throws.
72
+ */
73
+ export function enforceBackupBudget(dbPath, { budgetBytes = backupBudgetBytes(), nowMs = Date.now() } = {}) {
74
+ try {
75
+ const snaps = listSnapshots(dbPath).sort((a, b) => b.mtimeMs - a.mtimeMs); // newest first
76
+ let total = snaps.reduce((s, x) => s + x.size, 0);
77
+ let removed = 0;
78
+ const graceCutoff = nowMs - BACKUP_EVICTION_GRACE_MS;
79
+ // Walk oldest-first; stop before touching the newest (index 0).
80
+ for (let i = snaps.length - 1; i >= 1 && total > budgetBytes; i--) {
81
+ if (!SNAPSHOT_STAMP_RE.test(snaps[i].path)) continue; // not ours — never delete
82
+ if (snaps[i].mtimeMs > graceCutoff) continue; // inside undo grace
83
+ try {
84
+ unlinkSync(snaps[i].path);
85
+ total -= snaps[i].size;
86
+ removed++;
87
+ } catch { /* per-entry best-effort */ }
88
+ }
89
+ if (removed > 0) {
90
+ try { debugLog('DEBUG', 'db-backup', `budget eviction removed ${removed} snapshot(s)`); } catch { /* ignore */ }
91
+ }
92
+ return removed;
93
+ } catch { return 0; }
94
+ }
95
+
14
96
  // Monotonic per-process suffix so two snapshots in the same millisecond (same pid)
15
97
  // still get unique filenames (VACUUM INTO fails if the target already exists).
16
98
  let _seq = 0;
@@ -31,6 +113,7 @@ export function snapshotDb(db, { tag = 'pre-maintain', retain = 3 } = {}) {
31
113
  // defensively since VACUUM INTO takes a string literal, not a bound param.
32
114
  db.exec(`VACUUM INTO '${out.replace(/'/g, "''")}'`);
33
115
  pruneSnapshots(dbPath, tag, retain);
116
+ enforceBackupBudget(dbPath);
34
117
  return out;
35
118
  } catch (e) {
36
119
  try { debugLog('WARN', 'db-backup', `snapshot skipped (proceeding without): ${e.message}`); } catch { /* ignore */ }
@@ -18,10 +18,16 @@
18
18
  // exported: `related_ids` (holds observation ids, stale/dangling after restore remaps ids)
19
19
  // and `discovery_tokens` (a derived retrieval metric, rebuilt by the live system; exporting
20
20
  // it would freeze a stale value into backups).
21
+ // `compressed_into` (audit 2026-08-14 M-8): only ever non-null in an export taken
22
+ // with --include-compressed / include_compressed — the default WHERE excludes those
23
+ // rows. Without the column, a compressed member round-tripped through restore as a
24
+ // LIVE row and turned up in search results next to the weekly-summary keeper that
25
+ // already absorbed it. Restore does not remap it (the keeper's id is meaningless in
26
+ // the target store); it REJECTS marked rows instead — see cmdRestore.
21
27
  export const EXPORT_COLUMNS = [
22
28
  'id', 'memory_session_id', 'project', 'type', 'title', 'subtitle', 'narrative', 'text',
23
29
  'concepts', 'facts', 'files_read', 'files_modified', 'lesson_learned', 'search_aliases',
24
- 'scope',
30
+ 'scope', 'compressed_into',
25
31
  'importance', 'branch', 'access_count', 'cited_count', 'uncited_streak', 'injection_count',
26
32
  'decay_seen_count', 'last_accessed_at', 'created_at', 'created_at_epoch',
27
33
  ];
package/mem-cli.mjs CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  hardDeleteCandidateCount,
23
23
  OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
24
24
  } from './lib/maintain-core.mjs';
25
- import { snapshotDb } from './lib/db-backup.mjs';
25
+ import { snapshotDb, listSnapshots, backupBudgetBytes } from './lib/db-backup.mjs';
26
26
  import { deleteObservations } from './lib/delete-core.mjs';
27
27
  import { OBS_TYPE_SET } from './lib/obs-types.mjs';
28
28
  import { computeStatsFeed } from './lib/stats-core.mjs';
@@ -36,7 +36,7 @@ import { aggregateProjectCiteRecall } from './lib/citation-tracker.mjs';
36
36
  import { probeOtherSources as probeIdSources, bucketIdTokens, splitDeferredTokens } from './lib/id-routing.mjs';
37
37
  import { join, sep, dirname } from 'path';
38
38
  import { spawnSync } from 'child_process';
39
- import { readFileSync, existsSync, readdirSync } from 'fs';
39
+ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
40
40
 
41
41
  // v2.41: shared CLI helpers extracted to cli/common.mjs. Keep this file as the
42
42
  // router + remaining-command bodies during the incremental split. Future work:
@@ -1200,6 +1200,15 @@ async function cmdStats(db, args) {
1200
1200
  // failure mode that left code-graph's matcher bug undetected for 10 sessions.
1201
1201
  const hookErrors24h = countRecentHookErrors(join(DB_DIR, 'runtime'), now - 86400000);
1202
1202
 
1203
+ // M-9 (audit 2026-08-14): disk footprint — a "lite" store had accumulated 360MB of
1204
+ // pre-maintain snapshots against a 59MB DB with nothing reporting it. Cheap probes
1205
+ // only (DB file + .bak aggregate), no recursive tree walk.
1206
+ let dbBytes = 0;
1207
+ try { dbBytes = statSync(join(DB_DIR, 'claude-mem-lite.db')).size; } catch { /* fresh */ }
1208
+ const snaps = listSnapshots(join(DB_DIR, 'claude-mem-lite.db'));
1209
+ const backupBytes = snaps.reduce((s, x) => s + x.size, 0);
1210
+ const mb = (n) => (n / (1024 * 1024)).toFixed(1);
1211
+
1203
1212
  if (jsonOutput) {
1204
1213
  out(JSON.stringify({
1205
1214
  project,
@@ -1226,6 +1235,9 @@ async function cmdStats(db, args) {
1226
1235
  compressed: compressedCount.c,
1227
1236
  superseded_only: supersededOnlyCount.c,
1228
1237
  hook_errors_24h: hookErrors24h,
1238
+ db_bytes: dbBytes,
1239
+ backup_count: snaps.length,
1240
+ backup_bytes: backupBytes,
1229
1241
  },
1230
1242
  tier_distribution: {
1231
1243
  working: tierMap.working ?? 0,
@@ -1265,6 +1277,9 @@ async function cmdStats(db, args) {
1265
1277
  out(` Low-signal titles (Modified/Error/Worked on…): ${lowSignalTitle.c} (${(lowSignalRatio * 100).toFixed(1)}%)`);
1266
1278
  out(` Compressed: ${compressedCount.c}`);
1267
1279
  out(` Hook errors (last 24h): ${hookErrors24h}${hookErrors24h > 0 ? ` ← tail ${join(DB_DIR, 'runtime/hook-errors')}` : ''}`);
1280
+ // Hint threshold = the REAL eviction budget (pre-release review 2026-08-16: a
1281
+ // hardcoded 3×-DB heuristic promised an eviction that fires only past the budget).
1282
+ out(` Disk: DB ${mb(dbBytes)}MB | ${snaps.length} backup snapshot(s) ${mb(backupBytes)}MB${backupBytes > backupBudgetBytes() ? ` ← over the ${mb(backupBudgetBytes())}MB backup budget; next maintain/save snapshot evicts oldest (>7d old)` : ''}`);
1268
1283
  // Tier-1 firing counters for ① file-intel + ② reread-guard (recorded by
1269
1284
  // pre-tool-recall.js via lib/metrics.mjs; CLAUDE_MEM_METRICS=1 to enable).
1270
1285
  const featAgg = aggregateMetrics(DB_DIR, 7);
@@ -1799,9 +1814,15 @@ function cmdRestore(db, argv) {
1799
1814
  decay_seen_count = ?, last_accessed_at = ?
1800
1815
  WHERE id = ?`);
1801
1816
 
1802
- let restored = 0, skipped = 0, malformed = 0;
1817
+ let restored = 0, skipped = 0, malformed = 0, tombstoned = 0;
1803
1818
  for (const r of rows) {
1804
1819
  if (!r || typeof r !== 'object' || !r.type || !r.title) { malformed++; continue; }
1820
+ // M-8 (audit 2026-08-14): a row exported with --include-compressed carries its
1821
+ // compressed_into tombstone. Restoring it as a live row resurrects a member its
1822
+ // weekly-summary keeper already absorbed (duplicate search hits, and the marker's
1823
+ // target id is meaningless in this store). Reject rather than remap; the content
1824
+ // lives on in the keeper.
1825
+ if (r.compressed_into) { tombstoned++; continue; }
1805
1826
  const project = projOverride || r.project || inferProject();
1806
1827
  const createdEpoch = Number.isFinite(Number(r.created_at_epoch)) ? Number(r.created_at_epoch) : Date.now();
1807
1828
  // Durable exact-dup guard — saveObservation's 5-min Jaccard window can't catch a
@@ -1868,7 +1889,8 @@ function cmdRestore(db, argv) {
1868
1889
  // ones that parsed.
1869
1890
  const totalMalformed = malformed + parseFailures;
1870
1891
  const totalLines = rows.length + parseFailures;
1871
- out(`[mem] Restore${dryRun ? ' (dry-run)' : ''}: ${restored} restored, ${skipped} duplicate(s) skipped, ${totalMalformed} malformed/failed from ${totalLines} row(s).`);
1892
+ const tombstoneNote = tombstoned > 0 ? `, ${tombstoned} compressed member(s) rejected (already absorbed by their summary keeper)` : '';
1893
+ out(`[mem] Restore${dryRun ? ' (dry-run)' : ''}: ${restored} restored, ${skipped} duplicate(s) skipped${tombstoneNote}, ${totalMalformed} malformed/failed from ${totalLines} row(s).`);
1872
1894
  // Name the lossiness where the user meets it. Export omits related_ids and drops
1873
1895
  // superseded rows, and restore re-inserts under fresh AUTOINCREMENT ids — so no
1874
1896
  // cross-link can survive the round-trip. That is a deliberate format tradeoff (stored
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.62.0",
3
+ "version": "3.63.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.62.0",
9
+ "version": "3.63.0",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.26.0",
12
12
  "better-sqlite3": "^12.6.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.62.0",
3
+ "version": "3.63.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",
@@ -16,6 +16,7 @@
16
16
  import { existsSync, readFileSync } from 'fs';
17
17
  import { basename, join } from 'path';
18
18
  import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
19
+ import { recordHookError } from '../lib/hook-telemetry.mjs';
19
20
 
20
21
  const SALIENCE_BIND = process.env.CLAUDE_MEM_SALIENCE === 'bind';
21
22
 
@@ -45,7 +46,13 @@ async function main() {
45
46
  const cdPath = cooldownPathFor(sessionId);
46
47
  if (!existsSync(cdPath)) return;
47
48
  let entry;
48
- try { entry = JSON.parse(readFileSync(cdPath, 'utf8'))[filePath]; } catch { return; }
49
+ try { entry = JSON.parse(readFileSync(cdPath, 'utf8'))[filePath]; } catch (e) {
50
+ // A corrupt cooldown file (torn concurrent write — audit 2026-08-14 M-6) turns
51
+ // the bind-salience check into a zero-trace no-op; record it so `stats` can see
52
+ // the surface die instead of silently reading zero errors (M-5).
53
+ recordHookError('post-recall:cooldown-parse', e, RUNTIME_DIR, { file: basename(cdPath) });
54
+ return;
55
+ }
49
56
  const idents = entry && entry.lessonIdents;
50
57
  if (!idents || typeof idents !== 'object') return;
51
58
 
@@ -77,4 +84,7 @@ async function main() {
77
84
  // stream emits 'error' — without the (now-removed) forced exit, an unhandled one would
78
85
  // surface as a non-zero exit + stack. A hook must never fail loud on a dropped pipe.
79
86
  process.stdout.on('error', () => {});
80
- main().catch(() => {});
87
+ // Record what slips past main()'s early returns before the mandatory swallow —
88
+ // this script had zero telemetry (audit 2026-08-14 M-5). Recorder never throws;
89
+ // the outer catch keeps the exit code 0 regardless.
90
+ main().catch((e) => { try { recordHookError('post-recall:main', e, RUNTIME_DIR); } catch { /* never */ } });
@@ -15,6 +15,23 @@
15
15
  const ENABLED = process.env.CLAUDE_MEM_SUBAGENT_INJECT === 'on'
16
16
  || process.env.CLAUDE_MEM_SUBAGENT_INJECT === '1';
17
17
 
18
+ // Telemetry via DYNAMIC import so the default-off fast path stays import-free
19
+ // (the file's stated contract). Only ever reached on the enabled path's failure
20
+ // branches — this script had zero recordHookError coverage, so a dead DB or
21
+ // schema drift silently disabled subagent injection with no trace (audit
22
+ // 2026-08-14 M-5). Swallows everything: telemetry must never break a dispatch.
23
+ async function recordFailure(scope, err, ctx) {
24
+ try {
25
+ const [{ recordHookError }, { resolveDataDir }, { join }] = await Promise.all([
26
+ import('../lib/hook-telemetry.mjs'),
27
+ import('../lib/resolve-data-dir.mjs'),
28
+ import('path'),
29
+ ]);
30
+ const dataDir = resolveDataDir(process.env.CLAUDE_MEM_DIR);
31
+ recordHookError(scope, err, process.env.CLAUDE_MEM_RUNTIME_DIR || join(dataDir, 'runtime'), ctx);
32
+ } catch { /* never */ }
33
+ }
34
+
18
35
  function readStdin() {
19
36
  return new Promise((resolve) => {
20
37
  let data = '';
@@ -51,7 +68,7 @@ async function main() {
51
68
  const { buildSubagentInjection } = await import('../hook-memory.mjs');
52
69
 
53
70
  let db;
54
- try { db = ensureDb(); } catch { return; }
71
+ try { db = ensureDb(); } catch (e) { await recordFailure('agent-inject:db-open', e); return; }
55
72
  try {
56
73
  const updatedInput = buildSubagentInjection(db, hook.tool_input, inferProject());
57
74
  if (updatedInput) {
@@ -59,7 +76,7 @@ async function main() {
59
76
  hookSpecificOutput: { hookEventName: 'PreToolUse', updatedInput },
60
77
  }));
61
78
  }
62
- } catch { /* never break a dispatch */ } finally {
79
+ } catch (e) { await recordFailure('agent-inject:query', e); /* never break a dispatch */ } finally {
63
80
  try { db.close(); } catch { /* */ }
64
81
  }
65
82
  }
@@ -69,5 +86,6 @@ async function main() {
69
86
  // which FLUSHES stdout. The emitted updatedInput echoes the whole prompt back, so the
70
87
  // payload can exceed the ~64KB pipe buffer; a forced process.exit() would drop that
71
88
  // pending async write and truncate the JSON (the gotcha every sibling hook avoids).
72
- // Swallow any rejection so the exit code can never go non-zero.
73
- main().catch(() => {});
89
+ // Swallow any rejection so the exit code can never go non-zero — but record it
90
+ // first (recordFailure itself swallows everything, including its own failures).
91
+ main().catch((e) => recordFailure('agent-inject:main', e));
@@ -8,6 +8,9 @@ import { join, resolve, sep } from 'path';
8
8
  import { homedir } from 'os';
9
9
  import { recordHookError } from '../lib/hook-telemetry.mjs';
10
10
  import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
11
+ // format-utils.mjs is import-free — pulling three defang helpers keeps this script
12
+ // inside its "lightweight standalone" budget (no heavy transitive deps).
13
+ import { neutralizeContextDelimiters, neutralizeSkillDelimiters, neutralizeSkillBridgeDelimiters } from '../format-utils.mjs';
11
14
 
12
15
  // CLAUDE_MEM_DIR mirrors pre-tool-recall.js — one env var sandboxes everything.
13
16
  const DATA_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
@@ -88,12 +91,22 @@ try {
88
91
  // console.log() form would render on stock CC but no-op on those variants.
89
92
  // Token budget: ~4 chars per token, 4000 token limit = 16000 chars.
90
93
  const portablePath = resolvedPath.startsWith(homedir()) ? '~' + resolvedPath.slice(homedir().length) : resolvedPath;
94
+ // Defang the untrusted skill body + name before wrapping (audit 2026-08-14 M-4):
95
+ // registry rows come from third-party repos, and this was the one AUTO injection
96
+ // surface of that data with zero neutralization — a body carrying a literal
97
+ // `</skill-bridge>` + forged <system-reminder> (or a `<skill-loaded>` execute
98
+ // block) escaped the wrapper verbatim. Name additionally drops quote/bracket
99
+ // chars: it lands in an ATTRIBUTE position, where `"` breaks out of the wrapper
100
+ // tag itself. Applied to the truncated summary too (a cut can't be trusted to
101
+ // land mid-tag).
102
+ const defang = (s) => neutralizeSkillBridgeDelimiters(neutralizeSkillDelimiters(neutralizeContextDelimiters(s)));
103
+ const safeName = String(row.name).replace(/["'<>]/g, '');
91
104
  let additionalContext;
92
105
  if (content.length > 16000) {
93
- const summary = content.slice(0, 800);
94
- additionalContext = `<skill-bridge name="${row.name}" source="managed" truncated="true">\n${summary}\n...\n</skill-bridge>\n\nSkill content truncated. Read("${portablePath}") to load full content.`;
106
+ const summary = defang(content.slice(0, 800));
107
+ additionalContext = `<skill-bridge name="${safeName}" source="managed" truncated="true">\n${summary}\n...\n</skill-bridge>\n\nSkill content truncated. Read("${portablePath}") to load full content.`;
95
108
  } else {
96
- additionalContext = `<skill-bridge name="${row.name}" source="managed">\n${content}\n</skill-bridge>\n\nThis skill was loaded from the managed registry. Follow the instructions above.`;
109
+ additionalContext = `<skill-bridge name="${safeName}" source="managed">\n${defang(content)}\n</skill-bridge>\n\nThis skill was loaded from the managed registry. Follow the instructions above.`;
97
110
  }
98
111
  process.stdout.write(JSON.stringify({
99
112
  suppressOutput: true,
@@ -4,9 +4,10 @@
4
4
  // and the pure-data lib/low-signal-patterns.mjs (zero runtime deps, ~1ms overhead).
5
5
  // Safety: readonly DB, exit 0 always, 3s timeout
6
6
 
7
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
7
+ import { existsSync, readFileSync, mkdirSync } from 'fs';
8
8
  import { basename, join } from 'path';
9
9
  import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
10
+ import { atomicWriteFileSync } from '../lib/atomic-write.mjs';
10
11
  import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
11
12
  import { recordHookError } from '../lib/hook-telemetry.mjs';
12
13
  import { citeFactorClause } from '../scoring-sql.mjs';
@@ -159,17 +160,24 @@ function crossHookInjectedFile(project) {
159
160
  return join(RUNTIME_DIR, `.claude-mem-injected-${project}`);
160
161
  }
161
162
 
162
- function readCrossHookInjected(project) {
163
+ // M-6 (audit 2026-08-14): the marker file is keyed by PROJECT, so two concurrent
164
+ // CC sessions in the same project shared one suppression state — session A's
165
+ // injections silently deduped session B's, and B inherited A's count cap. Both
166
+ // read and merge now carry the CC session id: a payload written by a DIFFERENT
167
+ // session is ignored (read) / replaced (merge), mirroring the v3.35.2 episode
168
+ // session-key fix. Legacy payloads without `session` keep the old behavior.
169
+ function readCrossHookInjected(project, sessionId) {
163
170
  try {
164
171
  const raw = readFileSync(crossHookInjectedFile(project), 'utf8');
165
- const { ids, ts } = JSON.parse(raw);
172
+ const { ids, ts, session } = JSON.parse(raw);
173
+ if (session && sessionId && session !== sessionId) return new Set();
166
174
  if (!ts || Date.now() - ts > CROSS_HOOK_DEDUP_MS) return new Set();
167
175
  if (!Array.isArray(ids)) return new Set();
168
176
  return new Set(ids.map(String));
169
177
  } catch { return new Set(); }
170
178
  }
171
179
 
172
- function mergeCrossHookInjected(project, newIds) {
180
+ function mergeCrossHookInjected(project, newIds, sessionId) {
173
181
  if (!newIds || newIds.length === 0) return;
174
182
  try {
175
183
  mkdirSync(RUNTIME_DIR, { recursive: true });
@@ -178,8 +186,10 @@ function mergeCrossHookInjected(project, newIds) {
178
186
  try {
179
187
  const raw = readFileSync(file, 'utf8');
180
188
  const parsed = JSON.parse(raw);
181
- // Within the staleness window: union. Outside: replace (fresh session).
182
- if (parsed.ts && Date.now() - parsed.ts < CROSS_HOOK_DEDUP_MS) {
189
+ // Within the staleness window AND same session (or legacy): union.
190
+ // Outside / other session: replace.
191
+ if (parsed.ts && Date.now() - parsed.ts < CROSS_HOOK_DEDUP_MS
192
+ && !(parsed.session && sessionId && parsed.session !== sessionId)) {
183
193
  prev = parsed;
184
194
  }
185
195
  } catch { /* fresh file */ }
@@ -187,10 +197,13 @@ function mergeCrossHookInjected(project, newIds) {
187
197
  ...(Array.isArray(prev.ids) ? prev.ids.map(String) : []),
188
198
  ...newIds.map(String),
189
199
  ])];
190
- writeFileSync(file, JSON.stringify({
200
+ // Atomic (tmp+rename, M-6): a plain write torn by a concurrent hook left the
201
+ // shared marker as invalid JSON, silently disabling cross-hook dedup.
202
+ atomicWriteFileSync(file, JSON.stringify({
191
203
  ids,
192
204
  ts: Date.now(),
193
205
  count: (prev.count || 0) + 1,
206
+ ...(sessionId ? { session: sessionId } : {}),
194
207
  }));
195
208
  } catch { /* silent — dedup is best-effort */ }
196
209
  }
@@ -209,7 +222,10 @@ function writeCooldown(cooldownPath, data, isSessionScoped) {
209
222
  if (ts && now - ts < STALE_MS) cleaned[k] = v;
210
223
  }
211
224
  }
212
- writeFileSync(cooldownPath, JSON.stringify(cleaned));
225
+ // Atomic (tmp+rename, M-6): the cooldown carries lessonIdents that the
226
+ // PostToolUse bind-salience check reads back — a torn write turned that
227
+ // check into a zero-trace no-op.
228
+ atomicWriteFileSync(cooldownPath, JSON.stringify(cleaned));
213
229
  } catch { /* silent */ }
214
230
  }
215
231
 
@@ -474,7 +490,7 @@ try {
474
490
  // P1 (D#78): tag each row's source table — events share the numeric id
475
491
  // space with observations, and the Stop-side edge attribution must never
476
492
  // feed an event id into observation_files updates.
477
- const crossHookSeen = readCrossHookInjected(project);
493
+ const crossHookSeen = readCrossHookInjected(project, sessionId);
478
494
  const sourcedRows = [
479
495
  ...rows.map(r => ({ ...r, src: 'obs' })),
480
496
  ...eventRows.map(r => ({ ...r, src: 'evt' })),
@@ -638,7 +654,7 @@ try {
638
654
  // file so the next UPS prompt skips them too. Always write, even on
639
655
  // empty allRows, so the file's ts stays fresh for the no-op case where
640
656
  // we'd otherwise drift outside the dedup window.
641
- mergeCrossHookInjected(project, allRows.map(r => r.id));
657
+ mergeCrossHookInjected(project, allRows.map(r => r.id), sessionId);
642
658
  } catch (e) {
643
659
  // Silent failure — never block editing, but record for self-observation.
644
660
  recordHookError('pre-recall:query', e, RUNTIME_DIR, { filePath });
@@ -154,13 +154,17 @@ export const DEDUP_STALE_MS = 300_000; // 5 minutes
154
154
  * Check if injection should be skipped based on deduplication state.
155
155
  * @param {number[]} newIds - candidate observation IDs
156
156
  * @param {string} injectedFile - path to the dedup state file
157
+ * @param {string} [sessionId] - CC session id; a payload written by a DIFFERENT
158
+ * session never suppresses (M-6, audit 2026-08-14: the file is keyed by project,
159
+ * so session A's injections + count cap silently carried into session B)
157
160
  * @returns {boolean} true if injection should be skipped
158
161
  */
159
- export function shouldSkipByDedup(newIds, injectedFile) {
162
+ export function shouldSkipByDedup(newIds, injectedFile, sessionId) {
160
163
  if (!newIds || newIds.length === 0) return true;
161
164
  try {
162
165
  const raw = readFileSync(injectedFile, 'utf8');
163
- const { ids: prevIds, ts, count = 0 } = JSON.parse(raw);
166
+ const { ids: prevIds, ts, count = 0, session } = JSON.parse(raw);
167
+ if (session && sessionId && session !== sessionId) return false;
164
168
  if (count >= MAX_SESSION_INJECTIONS) return true;
165
169
  if (!ts || Date.now() - ts > DEDUP_STALE_MS) return false;
166
170
  if (!Array.isArray(prevIds) || prevIds.length === 0) return false;
@@ -14,9 +14,14 @@ import Database from 'better-sqlite3';
14
14
  import { shouldSkip, computeEffectiveLen, detectIntent, shouldSkipByDedup, extractFiles, extractErrorSignature, extractDeferredRefs, DEDUP_STALE_MS, matchRegistrySkillName, detectMemOverride } from './prompt-search-utils.mjs';
15
15
  import { getDeferredByIds } from '../lib/deferred-work.mjs';
16
16
  import { recommendSkill } from '../registry-recommend.mjs';
17
+ import { recordHookError } from '../lib/hook-telemetry.mjs';
18
+ import { atomicWriteFileSync } from '../lib/atomic-write.mjs';
17
19
 
18
20
  // ─── Constants ──────────────────────────────────────────────────────────────
19
21
 
22
+ // Telemetry sink (lib/hook-telemetry.mjs contract): env override for tests, else
23
+ // <data-dir>/runtime — the same dir the sibling hook scripts + `stats` read.
24
+ const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DB_DIR, 'runtime');
20
25
  const INJECTED_IDS_FILE = join(DB_DIR, 'runtime', `.claude-mem-injected-${inferProject()}`);
21
26
  // Per-prompt UPS cap. Cut from 5 → 3 after the 2026-05-09 per-hook recall
22
27
  // scan (#8255): UPS contributed 74% of silent injected IDs (131/177) at 26%
@@ -344,7 +349,7 @@ export function searchByFts(db, queryText, project, limit, typeFilter,
344
349
  SELECT o.id, o.type, o.title, o.lesson_learned,
345
350
  ${OBS_BM25} as bm25_raw,
346
351
  ${OBS_BM25}
347
- * (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / ${TYPE_DECAY_CASE}))
352
+ * (1.0 + EXP(-0.693 * MAX(0, ? - o.created_at_epoch) / ${TYPE_DECAY_CASE}))
348
353
  * ${TYPE_QUALITY_CASE}
349
354
  * (0.5 + 0.5 * COALESCE(o.importance, 1))
350
355
  * ${noisePenaltyClause('o')}
@@ -660,7 +665,7 @@ async function main() {
660
665
  // Namespace dedup ids as "D<id>" (parity with the "P<id>" prompt-corpus
661
666
  // convention) so obs ids can't collide in the shared injected-ids file.
662
667
  const dedupIds = openRows.map(r => `D${r.id}`);
663
- if (openRows.length > 0 && !shouldSkipByDedup(dedupIds, INJECTED_IDS_FILE)) {
668
+ if (openRows.length > 0 && !shouldSkipByDedup(dedupIds, INJECTED_IDS_FILE, hookData.session_id)) {
664
669
  const lines = ['[mem] Deferred work referenced in prompt (open items, full detail):'];
665
670
  for (const r of openRows) {
666
671
  const pTag = r.priority === 3 ? '🔴' : r.priority === 1 ? '⚪' : '🟡';
@@ -680,15 +685,20 @@ async function main() {
680
685
  let prevCount = 0;
681
686
  try {
682
687
  const prev = JSON.parse(readFileSync(INJECTED_IDS_FILE, 'utf8'));
683
- if (prev.ts && Date.now() - prev.ts < DEDUP_STALE_MS) {
688
+ // M-6: inherit only same-session (or legacy) state another session's
689
+ // ids/count must not carry over. Atomic write below: a torn concurrent
690
+ // write left the shared marker as invalid JSON (dedup silently off).
691
+ if (prev.ts && Date.now() - prev.ts < DEDUP_STALE_MS
692
+ && !(prev.session && hookData.session_id && prev.session !== hookData.session_id)) {
684
693
  prevIds = Array.isArray(prev.ids) ? prev.ids : [];
685
694
  prevCount = prev.count || 0;
686
695
  }
687
696
  } catch {}
688
- writeFileSync(INJECTED_IDS_FILE, JSON.stringify({
697
+ atomicWriteFileSync(INJECTED_IDS_FILE, JSON.stringify({
689
698
  ids: [...new Set([...prevIds.map(String), ...dedupIds])],
690
699
  ts: Date.now(),
691
700
  count: prevCount + 1,
701
+ ...(hookData.session_id ? { session: hookData.session_id } : {}),
692
702
  }));
693
703
  } catch {}
694
704
  }
@@ -712,7 +722,13 @@ async function main() {
712
722
  if (!db) {
713
723
  try {
714
724
  db = ensureDb();
715
- } catch { return; }
725
+ } catch (e) {
726
+ // A failed DB open silently kills EVERY prompt-time injection while `stats`
727
+ // reads zero errors (audit 2026-08-14 M-5) — record before the mandatory
728
+ // swallow. Exact blindness class of the 2026-08-13 pre-recall:db-open outage.
729
+ recordHookError('ups:db-open', e, RUNTIME_DIR);
730
+ return;
731
+ }
716
732
  }
717
733
 
718
734
  try {
@@ -854,7 +870,7 @@ async function main() {
854
870
  const candidateIds = rows.length > 0
855
871
  ? rows.map(r => r.id)
856
872
  : promptRows.map(r => `P${r.id}`);
857
- const dedupSkip = shouldSkipByDedup(candidateIds, INJECTED_IDS_FILE);
873
+ const dedupSkip = shouldSkipByDedup(candidateIds, INJECTED_IDS_FILE, hookData.session_id);
858
874
 
859
875
  const output = !dedupSkip
860
876
  ? (rows.length > 0 ? formatResults(rows) : formatPromptResults(promptRows))
@@ -866,12 +882,17 @@ async function main() {
866
882
  let prevCount = 0;
867
883
  try {
868
884
  const prev = JSON.parse(readFileSync(INJECTED_IDS_FILE, 'utf8'));
869
- if (prev.ts && Date.now() - prev.ts < DEDUP_STALE_MS) prevCount = prev.count || 0;
885
+ // M-6: same-session (or legacy) count only; atomic write (torn-write guard).
886
+ if (prev.ts && Date.now() - prev.ts < DEDUP_STALE_MS
887
+ && !(prev.session && hookData.session_id && prev.session !== hookData.session_id)) {
888
+ prevCount = prev.count || 0;
889
+ }
870
890
  } catch {}
871
- writeFileSync(INJECTED_IDS_FILE, JSON.stringify({
891
+ atomicWriteFileSync(INJECTED_IDS_FILE, JSON.stringify({
872
892
  ids: candidateIds,
873
893
  ts: Date.now(),
874
894
  count: prevCount + 1,
895
+ ...(hookData.session_id ? { session: hookData.session_id } : {}),
875
896
  }));
876
897
  } catch {}
877
898
  // v26 P0: bump injection_count for obs-based emits only (prompt-corpus
@@ -932,8 +953,11 @@ async function main() {
932
953
  finally { rdb.close(); }
933
954
  }
934
955
  } catch { /* silent — never block on recommendation failure */ }
935
- } catch {
936
- // Hooks must never break Claude Code — swallow all errors
956
+ } catch (e) {
957
+ // Hooks must never break Claude Code — swallow, but RECORD: this catch wraps
958
+ // every FTS query on the surface, so a schema/FTS drift here would zero out
959
+ // prompt-time injection with no trace anywhere (audit 2026-08-14 M-5).
960
+ recordHookError('ups:search', e, RUNTIME_DIR);
937
961
  } finally {
938
962
  try { db.close(); } catch {}
939
963
  }
@@ -962,5 +986,8 @@ export function isDirectInvocation(metaUrl, argv1) {
962
986
  return metaUrl.split('?')[0] === pathToFileURL(argv1).href;
963
987
  }
964
988
  if (isDirectInvocation(import.meta.url, process.argv[1])) {
965
- main().catch(() => {});
989
+ // Last-resort telemetry for anything that escapes main()'s own catches (e.g. a
990
+ // throw between the entry and the guarded body). Recorder never throws; the
991
+ // outer catch keeps the never-non-zero-exit invariant regardless.
992
+ main().catch((e) => { try { recordHookError('ups:main', e, RUNTIME_DIR); } catch { /* never */ } });
966
993
  }
package/search-engine.mjs CHANGED
@@ -10,7 +10,9 @@ import {
10
10
  DEFAULT_DECAY_HALF_LIFE_MS,
11
11
  notLowSignalTitleClause, LOW_SIGNAL_TITLE,
12
12
  relaxFtsQueryToOr, debugLog, debugCatch, estimateTokens,
13
+ noisePenaltyClause,
13
14
  } from './utils.mjs';
15
+ import { citeFactorClause } from './scoring-sql.mjs';
14
16
  import { getVocabulary, computeVector, vectorSearch, rrfMerge, vectorsEnabled } from './tfidf.mjs';
15
17
  import { extractPRFTerms, expandQueryByConcepts } from './search-scoring.mjs';
16
18
 
@@ -21,13 +23,26 @@ import { extractPRFTerms, expandQueryByConcepts } from './search-scoring.mjs';
21
23
  // exponent large-positive → EXP overflowed to +Infinity → score -Infinity → that row sorted
22
24
  // #1 for any match AND JSON.stringify emitted `"score": null` (numeric-contract break). A
23
25
  // future row now reads as age 0 = max (finite) recency, not Infinity.
26
+ // M-3 (audit 2026-08-14): cite/noise behavior factors joined FULL_SCORE — they had
27
+ // shipped for months on every AUTO surface (UPS / pre-tool-recall / hook-memory)
28
+ // while the EXPLICIT surfaces (mem_search / CLI search) discarded the accumulated
29
+ // citation + noise signal (the mirror image of "guards wired on the auto face,
30
+ // missing on the explicit face"). Both factors are hard-bounded (cite ∈ [0.4, 3.0],
31
+ // noise ∈ {0.2, 0.5, 1.0}), so they reorder, never dominate. algo-F6 note: the
32
+ // access-count LN bonus below stays — noisePenalty uses access_count only inside a
33
+ // ratio GUARD (never as a bonus) and citeFactor reads cited/uncited columns, so no
34
+ // term is counted twice as a reward. Denoise A/B 2026-08-16: NEUTRAL (suite rows
35
+ // carry zero cite/noise state); the behavioral pin lives in
36
+ // tests/audit-fixes-20260816.test.mjs (M-3).
24
37
  const FULL_SCORE = `${OBS_BM25}
25
38
  * (1.0 + EXP(-0.693 * MAX(0, ? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
26
39
  * ${TYPE_QUALITY_CASE}
27
40
  * (CASE WHEN ? IS NOT NULL AND o.project = ? THEN 2.0 ELSE 1.0 END)
28
41
  * (0.5 + 0.5 * COALESCE(o.importance, 1))
29
42
  * (1.0 + 0.1 * LN(1 + COALESCE(o.access_count, 0)))
30
- * (1.0 + 0.3 * (o.lesson_learned IS NOT NULL AND o.lesson_learned NOT IN ('', 'none')))`;
43
+ * (1.0 + 0.3 * (o.lesson_learned IS NOT NULL AND o.lesson_learned NOT IN ('', 'none')))
44
+ * ${noisePenaltyClause('o')}
45
+ * ${citeFactorClause('o')}`;
31
46
 
32
47
  const SIMPLE_SCORE = `${OBS_BM25}
33
48
  * (1.0 + EXP(-0.693 * MAX(0, ? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
@@ -294,6 +309,10 @@ function expandObsByConceptCo(db, ctx, now, existingIds, results, includeNoise =
294
309
  function expandObsByPRF(db, ctx, now, primaryCount, existingIds, results, includeNoise = false) {
295
310
  const { ftsQuery, args, epochFrom, epochTo, limit } = ctx;
296
311
  if (primaryCount < 3) return;
312
+ // effectiveFtsQuery = the query that actually matched (OR-relaxed when the strict
313
+ // AND missed and the fallback rescued rows — M-2). The strict query here returned
314
+ // zero top docs in that case, silently disabling PRF where it helps most.
315
+ const seedQuery = ctx.effectiveFtsQuery || ftsQuery;
297
316
  const topResults = db.prepare(`
298
317
  SELECT o.title, o.narrative FROM observations_fts
299
318
  JOIN observations o ON observations_fts.rowid = o.id
@@ -302,7 +321,7 @@ function expandObsByPRF(db, ctx, now, primaryCount, existingIds, results, includ
302
321
  AND (? IS NULL OR o.project = ?)
303
322
  ORDER BY ${OBS_BM25}
304
323
  LIMIT 8
305
- `).all(ftsQuery, args.project ?? null, args.project ?? null);
324
+ `).all(seedQuery, args.project ?? null, args.project ?? null);
306
325
  const prfTerms = extractPRFTerms(topResults, ftsQuery);
307
326
  if (prfTerms.length === 0) return;
308
327
  const prfFts = prfTerms.map(t => `"${t.replace(/"/g, '""')}"`).join(' OR ');
@@ -428,17 +447,28 @@ export function searchObservationsHybrid(db, ctx) {
428
447
  try {
429
448
  const orRows = db.prepare(buildObsFtsQuery('full', { multiplier: 0.5, withSnippet: true, withOffset: true, includeNoise }))
430
449
  .all(...buildObsFtsParams({ now, projectBoost, ftsQuery: orQuery, args, epochFrom, epochTo, limit: perSourceLimit, offset: perSourceOffset }));
431
- if (orRows.length > 0) ctx.orFallbackFired = true;
450
+ if (orRows.length > 0) {
451
+ ctx.orFallbackFired = true;
452
+ // M-2: PRF's top-doc probe re-queries FTS itself — with the strict-AND
453
+ // query it reads 0 docs in exactly the OR-rescue case, keeping PRF inert.
454
+ // Record the query that actually produced the evidence rows.
455
+ ctx.effectiveFtsQuery = orQuery;
456
+ }
432
457
  for (const r of orRows) results.push(ftsRowToResult(r, { snippet: true }));
433
458
  } catch (e) { debugCatch(e, 'searchObservationsHybrid-or-fallback'); }
434
459
  }
435
460
  }
436
461
 
437
- // Two-phase query expansion (only when well below limit)
438
- if (rows.length > 0 && results.length < Math.ceil(limit / 2)) {
462
+ // Two-phase query expansion (only when well below limit). Gate on results.length,
463
+ // NOT rows.length (M-2, audit 2026-08-14): `rows` is the strict-AND set only, so
464
+ // when strict-AND missed and the OR fallback rescued a few rows — exactly the
465
+ // vocab-mismatch shape where expansion helps most — the old gate read rows.length
466
+ // === 0 and skipped concept/PRF expansion entirely. PRF likewise seeds from the
467
+ // rescued rows now (they are the only relevance evidence available).
468
+ if (results.length > 0 && results.length < Math.ceil(limit / 2)) {
439
469
  const existingIds = new Set(results.map(r => r.id));
440
470
  expandObsByConceptCo(db, ctx, now, existingIds, results, includeNoise);
441
- expandObsByPRF(db, ctx, now, rows.length, existingIds, results, includeNoise);
471
+ expandObsByPRF(db, ctx, now, results.length, existingIds, results, includeNoise);
442
472
  }
443
473
 
444
474
  // Vector search + RRF hybrid merge
@@ -165,8 +165,13 @@ export function extractPRFTerms(results, ftsQuery, limit = 3) {
165
165
  // "cach" returns zero rows, only "caching"/"cache" match). Emitting a bare stem would
166
166
  // silently match nothing and kill expansion recall. Track each stem's surface forms with
167
167
  // their occurrence counts and emit the most frequent (best-matchable) surface.
168
- const stemDocCount = {}; // stem -> # of top docs it appears in (the >=2 bar)
169
- const stemSurfaces = {}; // stem -> Map(surface -> total occurrences)
168
+ // Prototype-less: doc text tokenizes to arbitrary identifiers, and a stem that
169
+ // collides with an Object.prototype property ("constructor" common in code
170
+ // narratives) made `stemSurfaces[stem] ||= new Map()` read the INHERITED function
171
+ // as truthy, skip the assignment, and crash on sm.get (surfaced 2026-08-16 when
172
+ // the M-2 gate fix first ran PRF over OR-rescued rows).
173
+ const stemDocCount = Object.create(null); // stem -> # of top docs it appears in (the >=2 bar)
174
+ const stemSurfaces = Object.create(null); // stem -> Map(surface -> total occurrences)
170
175
  const docCount = Math.min(results.length, 8);
171
176
  for (let i = 0; i < docCount; i++) {
172
177
  const r = results[i];
package/server.mjs CHANGED
@@ -1162,32 +1162,22 @@ server.registerTool(
1162
1162
  const staleAge = Date.now() - STALE_AGE_MS;
1163
1163
  const mctx = { projectFilter, baseParams, staleAge, opCap: OP_CAP };
1164
1164
 
1165
- // T2-P0-A: purge_stale is the only DELETE in this handler. Require confirm=true;
1166
- // a first call without confirm returns a dry-run preview so callers know the blast radius.
1167
- const purgeRequested = ops.includes('purge_stale');
1168
- if (purgeRequested && args.confirm !== true) {
1169
- const retainDays = args.retain_days ?? 30;
1170
- const retainCutoff = Date.now() - retainDays * 86400000;
1171
- const previewRow = purgeStalePreview(db, mctx, retainCutoff);
1172
- const lines = [
1173
- 'purge_stale preview (confirm=false):',
1174
- ` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`,
1175
- ];
1176
- if (previewRow.candidates > 0) {
1177
- lines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
1178
- lines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
1179
- }
1180
- lines.push('');
1181
- lines.push('Nothing was deleted. To execute, re-run with confirm=true:');
1182
- lines.push(` mem_maintain(action="execute", operations=${JSON.stringify(ops)}, confirm=true${args.retain_days ? `, retain_days=${args.retain_days}` : ''}${args.project ? `, project="${args.project}"` : ''})`);
1183
- return { content: [{ type: 'text', text: lines.join('\n') }] };
1184
- }
1165
+ // T2-P0-A: purge_stale is the only op gated on confirm=true — cleanupBroken also
1166
+ // hard-deletes (broken rows only) but always ran unconfirmed on both surfaces, and
1167
+ // the pre-transaction snapshot above covers it. An unconfirmed call gets a dry-run
1168
+ // preview of the purge INSTEAD OF the purge.
1169
+ // M-7 (audit 2026-08-14): preview-instead-of-early-return — the old
1170
+ // `return`-on-unconfirmed skipped EVERY requested op, while the CLI twin ran the
1171
+ // non-destructive ones (cleanup/decay/boost) and previewed only the purge. Same
1172
+ // op list, two different amounts of work done, both reporting success. Aligned
1173
+ // to the CLI semantics (the safer surface changed less: nothing destructive
1174
+ // runs unconfirmed on either surface now or before).
1175
+ const purgeConfirmed = args.confirm === true;
1185
1176
 
1186
1177
  // MED-2: snapshot the DB before the irreversible cleanup/purge hard-deletes —
1187
1178
  // only when rows will actually be removed, and OUTSIDE the transaction below
1188
- // (VACUUM cannot run inside one). purge_stale is already confirmed by here (the
1189
- // preview branch returned above otherwise). Best-effort; snapshotDb never throws.
1190
- if (hardDeleteCandidateCount(db, mctx, { cleanup: ops.includes('cleanup'), purge: ops.includes('purge_stale') }) > 0) {
1179
+ // (VACUUM cannot run inside one). Best-effort; snapshotDb never throws.
1180
+ if (hardDeleteCandidateCount(db, mctx, { cleanup: ops.includes('cleanup'), purge: ops.includes('purge_stale') && purgeConfirmed }) > 0) {
1191
1181
  snapshotDb(db, { tag: 'pre-maintain' });
1192
1182
  }
1193
1183
 
@@ -1201,8 +1191,25 @@ server.registerTool(
1201
1191
  if (ops.includes('purge_stale')) {
1202
1192
  const retainDays = args.retain_days ?? 30;
1203
1193
  const retainCutoff = Date.now() - retainDays * 86400000;
1204
- const purged = purgeStale(db, mctx, retainCutoff);
1205
- results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)` + (purged >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
1194
+ if (!purgeConfirmed) {
1195
+ // Dry-run preview (parity with CLI `maintain` without --confirm): the other
1196
+ // requested non-destructive ops still run below.
1197
+ const previewRow = purgeStalePreview(db, mctx, retainCutoff);
1198
+ const lines = [
1199
+ 'purge_stale preview (confirm=false):',
1200
+ ` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`,
1201
+ ];
1202
+ if (previewRow.candidates > 0) {
1203
+ lines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
1204
+ lines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
1205
+ }
1206
+ lines.push(' Nothing was deleted. To delete, re-run with confirm=true:');
1207
+ lines.push(` mem_maintain(action="execute", operations=${JSON.stringify(ops)}, confirm=true${args.retain_days ? `, retain_days=${args.retain_days}` : ''}${args.project ? `, project="${args.project}"` : ''})`);
1208
+ results.push(lines.join('\n'));
1209
+ } else {
1210
+ const purged = purgeStale(db, mctx, retainCutoff);
1211
+ results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)` + (purged >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
1212
+ }
1206
1213
  }
1207
1214
 
1208
1215
  if (ops.includes('cleanup')) {
package/source-files.mjs CHANGED
@@ -264,9 +264,37 @@ const LAUNCHER_SCRIPT_FILES = [
264
264
  'binding-probe-cli.mjs',
265
265
  ];
266
266
 
267
+ // Plugin/marketplace DECLARATION files (audit 2026-08-14 P-2). Not executable
268
+ // themselves, but they NAME what gets executed: hooks/hooks.json declares the
269
+ // command lines Claude Code runs on every hook fire, .mcp.json declares the MCP
270
+ // server launch command, plugin.json/marketplace.json steer the install source,
271
+ // commands/*.md are model-visible skill bodies, registry/preinstalled.json seeds
272
+ // the resource registry. All ship in the tarball; none were signed — the same
273
+ // shape as the two closed RCE gaps (hook scripts v3.40, launch.mjs v3.42): a
274
+ // release published without the signing key could swap hooks.json to point a
275
+ // hook event at an arbitrary command while every signed hash still matched.
276
+ // Additive: buildReleaseManifest skips entries absent at sign time, and
277
+ // verifyReleaseFiles needs no change to enforce whatever the manifest carries.
278
+ const PLUGIN_DECLARATION_FILES = [
279
+ 'hooks/hooks.json',
280
+ '.mcp.json',
281
+ '.claude-plugin/plugin.json',
282
+ '.claude-plugin/marketplace.json',
283
+ 'registry/preinstalled.json',
284
+ 'commands/mem.md',
285
+ 'commands/memory.md',
286
+ 'commands/update.md',
287
+ 'commands/tools.md',
288
+ 'commands/adopt.md',
289
+ 'commands/unadopt.md',
290
+ 'commands/lesson.md',
291
+ 'commands/bug.md',
292
+ ];
293
+
267
294
  // The complete set of files the release signature MUST cover: every runtime .mjs
268
295
  // (SOURCE_FILES) PLUS the executable hook scripts (copyReleaseIntoStaging installs these
269
- // into the live dir and they run on every hook fire) PLUS the launcher/setup scripts.
296
+ // into the live dir and they run on every hook fire) PLUS the launcher/setup scripts
297
+ // PLUS the plugin declaration files above.
270
298
  // HOOK_SCRIPT_FILES were historically NOT in the signed manifest, so an attacker able to
271
299
  // PUBLISH a release — but without the signing key — could swap a hook script (e.g.
272
300
  // post-tool-use.sh / hook-launcher.mjs) while every SOURCE_FILES hash still matched, and
@@ -278,4 +306,5 @@ export const RELEASE_SIGNED_FILES = [
278
306
  ...SOURCE_FILES,
279
307
  ...HOOK_SCRIPT_FILES.map(name => `scripts/${name}`),
280
308
  ...LAUNCHER_SCRIPT_FILES.map(name => `scripts/${name}`),
309
+ ...PLUGIN_DECLARATION_FILES,
281
310
  ];