claude-mem-lite 3.61.1 → 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.
@@ -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
@@ -27,8 +27,11 @@ import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
27
27
  import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
28
28
  import { computeStatsFeed } from './lib/stats-core.mjs';
29
29
  import { buildLessonNudge } from './lib/save-nudge.mjs';
30
- import { formatObsFieldValue } from './cli/common.mjs';
31
- import { neutralizeContextDelimiters } from './format-utils.mjs';
30
+ import { formatObsFieldValue, obsFieldLabel, formatPendingPurgeLine } from './cli/common.mjs';
31
+ // The partial-export warning points the caller at the CLI twin, which exports the complete
32
+ // set by default — the invocation has to be the one that actually works on this install.
33
+ import { CLI_INVOKE } from './cli-path.mjs';
34
+ import { neutralizeContextDelimiters, neutralizeSkillDelimiters } from './format-utils.mjs';
32
35
  import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memOptimizeSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema, memDeferSchema, memDeferListSchema, memDeferDropSchema, tools as TOOL_DEFS } from './tool-schemas.mjs';
33
36
 
34
37
  // Lookup helper: all user-facing tool descriptions live in tool-schemas.mjs
@@ -638,7 +641,7 @@ server.registerTool(
638
641
  // gets a scannable hint instead of a bare millisecond integer.
639
642
  const display = formatObsFieldValue(f, val);
640
643
  const maxLen = f === 'narrative' ? 1000 : f === 'lesson_learned' ? 500 : f === 'text' ? 500 : 200;
641
- lines.push(`${f}: ${typeof display === 'string' && display.length > maxLen ? display.slice(0, maxLen) + '…' : display}`);
644
+ lines.push(`${obsFieldLabel(f)}: ${typeof display === 'string' && display.length > maxLen ? display.slice(0, maxLen) + '…' : display}`);
642
645
  }
643
646
  sections.push(lines.join('\n'));
644
647
  }
@@ -1113,7 +1116,7 @@ server.registerTool(
1113
1116
  ` Stale (>30d, imp=1, no access, never injected): ${stats.stale}`,
1114
1117
  ` Broken (no title/narrative): ${stats.broken}`,
1115
1118
  ` Boostable (accessed>3, imp<3): ${stats.boostable}`,
1116
- ` Pending purge (idle-marked): ${stats.pendingPurge}`,
1119
+ formatPendingPurgeLine(stats.pendingPurge),
1117
1120
  ];
1118
1121
  if (duplicates.length > 0) {
1119
1122
  const autoMergeable = duplicates.filter(d => parseFloat(d.similarity) >= AUTO_MERGE_THRESHOLD);
@@ -1159,32 +1162,22 @@ server.registerTool(
1159
1162
  const staleAge = Date.now() - STALE_AGE_MS;
1160
1163
  const mctx = { projectFilter, baseParams, staleAge, opCap: OP_CAP };
1161
1164
 
1162
- // T2-P0-A: purge_stale is the only DELETE in this handler. Require confirm=true;
1163
- // a first call without confirm returns a dry-run preview so callers know the blast radius.
1164
- const purgeRequested = ops.includes('purge_stale');
1165
- if (purgeRequested && args.confirm !== true) {
1166
- const retainDays = args.retain_days ?? 30;
1167
- const retainCutoff = Date.now() - retainDays * 86400000;
1168
- const previewRow = purgeStalePreview(db, mctx, retainCutoff);
1169
- const lines = [
1170
- 'purge_stale preview (confirm=false):',
1171
- ` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`,
1172
- ];
1173
- if (previewRow.candidates > 0) {
1174
- lines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
1175
- lines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
1176
- }
1177
- lines.push('');
1178
- lines.push('Nothing was deleted. To execute, re-run with confirm=true:');
1179
- 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}"` : ''})`);
1180
- return { content: [{ type: 'text', text: lines.join('\n') }] };
1181
- }
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;
1182
1176
 
1183
1177
  // MED-2: snapshot the DB before the irreversible cleanup/purge hard-deletes —
1184
1178
  // only when rows will actually be removed, and OUTSIDE the transaction below
1185
- // (VACUUM cannot run inside one). purge_stale is already confirmed by here (the
1186
- // preview branch returned above otherwise). Best-effort; snapshotDb never throws.
1187
- 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) {
1188
1181
  snapshotDb(db, { tag: 'pre-maintain' });
1189
1182
  }
1190
1183
 
@@ -1198,8 +1191,25 @@ server.registerTool(
1198
1191
  if (ops.includes('purge_stale')) {
1199
1192
  const retainDays = args.retain_days ?? 30;
1200
1193
  const retainCutoff = Date.now() - retainDays * 86400000;
1201
- const purged = purgeStale(db, mctx, retainCutoff);
1202
- 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
+ }
1203
1213
  }
1204
1214
 
1205
1215
  if (ops.includes('cleanup')) {
@@ -1567,6 +1577,11 @@ server.registerTool(
1567
1577
 
1568
1578
  // ─── Tool: mem_use ──────────────────────────────────────────────────────────
1569
1579
 
1580
+ // Cap on the caller-supplied name echoed back in a miss message. Well past any real
1581
+ // skill/agent name (the longest registered one here is 23 chars), short enough that an
1582
+ // unbounded argument cannot pad the response — the echo appears twice.
1583
+ const ECHO_NAME_MAX = 80;
1584
+
1570
1585
  server.registerTool(
1571
1586
  'mem_use',
1572
1587
  {
@@ -1582,8 +1597,8 @@ server.registerTool(
1582
1597
  const name = args.name.trim();
1583
1598
  const type = args.type || 'skill';
1584
1599
 
1585
- // 1. Exact match by name or invocation_name
1586
- let row = rdb.prepare(`
1600
+ // 1. Exact match by name or invocation_name — the ONLY path that loads content.
1601
+ const row = rdb.prepare(`
1587
1602
  SELECT id, name, type, local_path, invocation_name, capability_summary
1588
1603
  FROM resources
1589
1604
  WHERE status = 'active' AND type = ?
@@ -1591,16 +1606,36 @@ server.registerTool(
1591
1606
  LIMIT 1
1592
1607
  `).get(type, name, name);
1593
1608
 
1594
- // 2. Fuzzy fallback: FTS5 search, take top result
1609
+ // 2. Name miss → SUGGEST, never substitute. The FTS5 search still runs (it is what
1610
+ // produces the candidate list), but its result is only ever rendered as names: loading
1611
+ // the top hit under the caller's requested name shipped a different skill's body inside
1612
+ // <skill-loaded> plus "Follow the instructions above to execute this <type>." — with
1613
+ // nothing marking the swap, so an agent that asked for A executed B (audit F1,
1614
+ // 2026-08-14: with only `deploy-rollback-runbook` registered, `deploy-notes` /
1615
+ // `rollback-checklist` / `runbook-index` each returned its full body). Loading stays an
1616
+ // exact-name decision the caller makes.
1595
1617
  if (!row) {
1596
- const results = searchResources(rdb, name, { type, limit: 1 });
1597
- if (results.length > 0) {
1598
- row = rdb.prepare(`SELECT id, name, type, local_path, invocation_name, capability_summary FROM resources WHERE name = ? AND type = ? AND status = 'active'`).get(results[0].name, results[0].type);
1618
+ let candidates = [];
1619
+ try { candidates = searchResources(rdb, name, { type, limit: 5 }).map((r) => r.name).filter(Boolean); }
1620
+ catch { /* a suggestion is best-effort; the miss message below still stands */ }
1621
+ // Every echo of the caller's own name below is bounded + delimiter-inert (audit F7):
1622
+ // raw interpolation let a crafted `name` forge a <skill-loaded> block and the execute
1623
+ // imperative inside this message, and the handler-wide defangResult cannot catch it —
1624
+ // <skill-loaded> is off CONTEXT_DELIMITER_RE precisely so the real load path can emit
1625
+ // it. `truncate` also folds newlines, so a multi-line name cannot fake block structure.
1626
+ // Registered names are defanged too (a crafted one can be imported), but NOT truncated:
1627
+ // the suggestion tells the caller to load one by its exact name, so it must stay exact.
1628
+ const echoed = neutralizeSkillDelimiters(truncate(name, ECHO_NAME_MAX));
1629
+ const echoedCandidates = candidates.map((n) => neutralizeSkillDelimiters(n));
1630
+ const head = `No ${type} found for "${echoed}".`;
1631
+ const browse = `mem_registry(action="search", query="${echoed}")`;
1632
+ if (candidates.length === 0) {
1633
+ return { content: [{ type: 'text', text: `${head} Try ${browse} to browse.` }] };
1599
1634
  }
1600
- }
1601
-
1602
- if (!row) {
1603
- return { content: [{ type: 'text', text: `No ${type} found for "${name}". Try mem_registry(action="search", query="${name}") to browse.` }] };
1635
+ const list = echoedCandidates.map((n) => ` - ${n}`).join('\n');
1636
+ return { content: [{ type: 'text', text:
1637
+ `${head} Closest ${type}s by search (NOT loaded — none matched the name you asked for):\n${list}\n\n` +
1638
+ `Load one deliberately with its exact name, e.g. mem_use(name="${echoedCandidates[0]}"${type === 'skill' ? '' : `, type="${type}"`}), or browse with ${browse}.` }] };
1604
1639
  }
1605
1640
 
1606
1641
  // 3. Resolve path: directory skills → SKILL.md (agents always have full .md paths)
@@ -1717,7 +1752,13 @@ async function runExport(db, args) {
1717
1752
  }
1718
1753
 
1719
1754
  const where = wheres.length > 0 ? 'WHERE ' + wheres.join(' AND ') : '';
1720
- const exportLimit = Math.min(args.limit ?? 200, 1000);
1755
+ // No clamp (audit 2026-08-14 A2): `Math.min(args.limit ?? 200, 1000)` made an MCP-driven
1756
+ // backup of a >1000-row store impossible, on the tool whose own description says "USE
1757
+ // when: Backing up memory before a migration or reinstall" — while the CLI twin exported
1758
+ // the complete matching set (mem-cli.mjs cmdExport, fixed there for the same reason). The
1759
+ // DEFAULT stays 200: an MCP result is model context, so a bare exploratory call must not
1760
+ // dump a whole store into the transcript. An explicit limit is now honoured at any size.
1761
+ const exportLimit = args.limit ?? 200;
1721
1762
  // T3-P2-B: probe limit+1 so we can tell "user hit their own limit with more waiting" from
1722
1763
  // "user got exactly what existed". Trim to exportLimit before rendering.
1723
1764
  // EXPORT_COLUMNS_SQL: shared with CLI cmdExport — the full round-trippable set restore
@@ -1733,7 +1774,27 @@ async function runExport(db, args) {
1733
1774
  ? rows.map(r => JSON.stringify(r)).join('\n')
1734
1775
  : JSON.stringify(rows, null, 2);
1735
1776
 
1736
- const cap = moreAvailable ? `\nNote: Results capped at ${exportLimit}. Use date_from/date_to or increase limit (max 1000) to export more.` : '';
1777
+ // A truncated backup is the failure mode this tool must never produce quietly: the old
1778
+ // note ("Results capped at N … increase limit (max 1000)") never said how much was
1779
+ // missing, and its advice was a dead end on a store past the ceiling. Name the real
1780
+ // total, the number of rows left out, and a remedy that actually returns all of them.
1781
+ //
1782
+ // The remedy has to lead with a FILE REDIRECT (pre-tag review, 2026-08-14). Removing the
1783
+ // 1000-row ceiling took away the only bound on an MCP export's size, and the 200 default
1784
+ // is kept precisely because an MCP result IS model context — so "re-run with
1785
+ // limit: <total>", the previous first suggestion, told the caller to put the entire store
1786
+ // into one tool result (thin fixture rows measure ~612 bytes each, so a few-thousand-row
1787
+ // store is megabytes in a single message), and a bare `cli.mjs export` had the same
1788
+ // property unredirected. The limit re-run stays mentioned but demoted, with its cost
1789
+ // stated: it is occasionally what the caller actually wants, and silently dropping it
1790
+ // would send them back to guessing.
1791
+ let cap = '';
1792
+ if (moreAvailable) {
1793
+ const total = db.prepare(`SELECT COUNT(*) AS c FROM observations ${where}`).get(...params).c;
1794
+ cap = `\nWARNING — PARTIAL EXPORT, NOT A COMPLETE BACKUP: capped at ${exportLimit} of ${total} matching observations; ${total - exportLimit} rows are missing from this payload and restoring it would lose them.` +
1795
+ `\nFor a complete backup, write it to a FILE instead of pulling it through this conversation: \`${CLI_INVOKE} export --format jsonl > backup.jsonl\` (the CLI exports the complete set by default). Narrowing with date_from/date_to also works.` +
1796
+ `\nRaising \`limit\` here is the last resort, not the first: this result is model context, so all ${total} rows would be loaded into the transcript.`;
1797
+ }
1737
1798
  return { content: [{ type: 'text', text: `Exported ${rows.length} observations:${cap}\n${output}` }] };
1738
1799
  }
1739
1800
 
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
  ];
package/tool-schemas.mjs CHANGED
@@ -206,7 +206,7 @@ export const memSaveSchema = {
206
206
  type: OBS_TYPE_ENUM.optional().describe('Observation type (default: discovery)'),
207
207
  project: z.string().optional().describe('Project name (default: inferred from CWD)'),
208
208
  importance: coerceInt.pipe(z.number().int().min(1).max(3)).optional().describe('Importance level: 1=routine, 2=notable, 3=critical (default: 2 for explicit saves)'),
209
- files: coerceStringArray.optional().describe('File paths associated with this observation'),
209
+ files: coerceStringArray.optional().describe('File paths associated with this observation. Stored in the `files_modified` column and rendered as `files` — passing a path here does not assert the file was edited; a file you only read belongs here too'),
210
210
  lesson_learned: z.string().max(500).optional().describe('Key lesson or takeaway, ≤500 chars (for bugfix: root cause & fix; for decision: rationale)'),
211
211
  closes_deferred: coerceDeferredTokens.optional().describe('Close one or more deferred_work items in the same project. Mixed array: bare integer = ordinal-within-project, "D#<n>" string = raw id. Transactional with the obs insert — a single invalid id rolls back the whole save.'),
212
212
  supersedes: coerceSupersedes.optional().describe('Observation ids (same project) that this save overturns. They are marked superseded — dropped from live search — and linked to the new row (superseded_by). Use ONLY when this genuinely replaces a prior conclusion; do NOT use for merely-related or updated-but-still-valid memories.'),
@@ -280,7 +280,13 @@ export const memExportSchema = {
280
280
  from: z.string().optional().describe('Alias for `date_from` (CLI `export --from`)'),
281
281
  to: z.string().optional().describe('Alias for `date_to` (CLI `export --to`)'),
282
282
  include_compressed: coerceBool.optional().describe('Include compressed observations (default: false)'),
283
- limit: coerceInt.pipe(z.number().int().min(1).max(1000)).optional().describe('Max observations to export (default: 200, max: 1000)'),
283
+ // No upper bound: this is the BACKUP tool ("USE when: Backing up memory before a
284
+ // migration or reinstall"), and a 1000-row ceiling made a complete backup of a bigger
285
+ // store impossible over MCP while the CLI twin exported everything (audit 2026-08-14 A2).
286
+ // The default stays 200 because an MCP result is model context — a bare exploratory call
287
+ // must not dump a whole store into the transcript — but a capped result now announces
288
+ // itself as PARTIAL and names the limit that would return all of it.
289
+ limit: coerceInt.pipe(z.number().int().min(1)).optional().describe('Max observations to export (default: 200 — a capped result is flagged PARTIAL and names the total; pass that total for a complete backup, no upper bound)'),
284
290
  };
285
291
 
286
292
  export const memRecallSchema = {
@@ -589,7 +595,8 @@ export const tools = [
589
595
  {
590
596
  name: 'mem_use',
591
597
  description:
592
- 'Load and activate a skill or agent from the registry by name (exact or fuzzy).\n' +
598
+ 'Load and activate a skill or agent from the registry by EXACT name or invocation_name.\n' +
599
+ 'A name that matches nothing returns closest-match names to pick from — never another resource\'s content.\n' +
593
600
  '\n' +
594
601
  'DO NOT use when:\n' +
595
602
  ' - You have not confirmed the skill exists (run mem_registry action="list" first)\n' +