claude-recall 0.22.1 → 0.23.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.
@@ -8,15 +8,15 @@ source: claude-recall
8
8
 
9
9
  # Preferences
10
10
 
11
- Auto-generated from 5 memories. Last updated: 2026-04-11.
11
+ Auto-generated from 5 memories. Last updated: 2026-04-22.
12
12
 
13
13
  ## Rules
14
14
 
15
- - Session test preference 1775902182248
16
- - Test preference 1775902182184-2
17
- - Test preference 1775902182184-1
18
- - Test preference 1775902182184-0
19
- - Test memory content
15
+ - integration-fixture session 1776885821647
16
+ - integration-fixture preference 1776885821594-2
17
+ - integration-fixture preference 1776885821594-1
18
+ - integration-fixture preference 1776885821594-0
19
+ - integration-fixture memory 1776885821539
20
20
 
21
21
  ---
22
22
  *Auto-generated by Claude Recall. Regenerate: `npx claude-recall skills generate`*
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "topicId": "preferences",
3
- "sourceHash": "a383c0d6502023d06954eb49fcab8886dc5181d5e59666f6c74a381221e44f87",
3
+ "sourceHash": "886f098b1a582616f10443d10ec5877b85152231a370636321f2f3f8dc37b2dc",
4
4
  "memoryCount": 5,
5
- "generatedAt": "2026-04-11T10:09:42.271Z",
5
+ "generatedAt": "2026-04-22T19:23:41.665Z",
6
6
  "memoryKeys": [
7
- "memory_1775902182249_x5rzzep7s",
8
- "memory_1775902182226_9uo2kaw57",
9
- "memory_1775902182211_pl5fzrb85",
10
- "memory_1775902182185_q6f9widp3",
11
- "memory_1775902182147_olowsptz3"
7
+ "memory_1776885821648_8b5enno4p",
8
+ "memory_1776885821623_73b897xzl",
9
+ "memory_1776885821611_admiwaa2s",
10
+ "memory_1776885821596_ii942py46",
11
+ "memory_1776885821543_yk1wyjqkz"
12
12
  ]
13
13
  }
package/README.md CHANGED
@@ -18,6 +18,7 @@ Your preferences, project structure, workflows, corrections, and coding style ar
18
18
  - **Failure Learning** — captures what failed, why, and what to do instead — so the agent doesn't repeat mistakes
19
19
  - **Outcome-Aware Learning** — tracks action outcomes (all tool results, test cycles, user corrections), synthesizes candidate lessons, and promotes validated patterns into active rules automatically
20
20
  - **Skill Crystallization** — auto-generates `.claude/skills/auto-*/` files from accumulated memories, using Anthropic's [Agent Skills](https://agentskills.io/) open standard
21
+ - **Rule Hygiene** — token-budgeted `load_rules` payload, citation-aware auto-demotion of rules that never earn citations, and retroactive dedup for near-duplicates
21
22
  - **Local-Only** — SQLite on your machine, no telemetry, no cloud, works fully offline
22
23
 
23
24
  ---
@@ -336,6 +337,17 @@ claude-recall outcomes --section stats # Retrieval/helpfulness stats
336
337
  claude-recall outcomes --limit 20 # More items per section
337
338
  claude-recall monitor # Memory search monitoring stats
338
339
 
340
+ # ── Rule Hygiene ─────────────────────────────────────────────────────
341
+ claude-recall rules demote [--dry-run] # Demote rules loaded >=N times but never cited
342
+ claude-recall rules demote --min-loads 20 # Tune load-count threshold (default 20)
343
+ claude-recall rules demote --min-age-days 7 # Minimum age before demotion (default 7)
344
+ claude-recall rules promote <id> # Restore an auto-demoted or auto-deduped rule
345
+ claude-recall rules dedup [--dry-run] # Collapse near-duplicate rules (Jaccard >= threshold)
346
+ claude-recall rules dedup --threshold 0.8 # Stricter similarity (default 0.65)
347
+
348
+ # ── Cleanup (destructive — always --dry-run first) ─────────────────
349
+ claude-recall cleanup test-pollution [--dry-run] # Delete legacy test-fixture rows
350
+
339
351
  # ── Task Checkpoints ────────────────────────────────────────────────
340
352
  claude-recall checkpoint save --completed <text> --remaining <text> [--blockers <text>] [--notes <text>] [--project <id>]
341
353
  claude-recall checkpoint load [--project <id>] [--json]
@@ -417,6 +429,25 @@ Global installation does **not** affect project scoping — project ID is still
417
429
 
418
430
  ---
419
431
 
432
+ ## Environment Variables
433
+
434
+ Runtime behavior can be tuned via environment variables. Defaults are chosen so out-of-the-box behavior stays close to historical output; opt in as needed.
435
+
436
+ | Variable | Default | Effect |
437
+ | ---------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------- |
438
+ | `CLAUDE_RECALL_DB_PATH` | `~/.claude-recall/` | Database directory. |
439
+ | `ANTHROPIC_API_KEY` | _(unset)_ | Enables LLM-based classification (Haiku). Falls back to regex silently when missing. |
440
+ | `CLAUDE_RECALL_LOAD_BUDGET_TOKENS` | `2000` | Token budget for the `load_rules` payload. Rules are emitted in priority order (corrections → preferences by citation → devops by citation → failures) and dropped rules surface via `search_memory`. |
441
+ | `CLAUDE_RECALL_AUTO_DEMOTE` | `false` | When `true`, auto-demote rules on MCP boot where `load_count >= CLAUDE_RECALL_DEMOTE_MIN_LOADS`, `cite_count = 0`, and age `> CLAUDE_RECALL_DEMOTE_MIN_AGE_DAYS`. Still reversible via `rules promote <id>`. |
442
+ | `CLAUDE_RECALL_DEMOTE_MIN_LOADS` | `20` | Minimum load count before a rule qualifies for auto-demotion. |
443
+ | `CLAUDE_RECALL_DEMOTE_MIN_AGE_DAYS` | `7` | Minimum rule age before auto-demotion can fire (avoids demoting brand-new rules). |
444
+ | `CLAUDE_RECALL_AUTO_CLEANUP` | `false` | Auto-kill stale MCP processes on start (otherwise reports and exits). |
445
+ | `CLAUDE_RECALL_COMPACT_THRESHOLD` | `10MB` | DB size at which automatic compaction kicks in. |
446
+ | `CLAUDE_RECALL_MAX_MEMORIES` | `10000` | Memory-row soft cap. |
447
+ | `CLAUDE_RECALL_ENFORCE_MODE` | `on` | Set to `off` to bypass the search-enforcer hook. |
448
+
449
+ ---
450
+
420
451
  ## Development & Contributions
421
452
 
422
453
  PRs welcome — Claude Recall is open to contributors.
@@ -261,6 +261,99 @@ class ClaudeRecallCLI {
261
261
  });
262
262
  this.logger.info('CLI', 'Failures displayed', { count: displayFailures.length });
263
263
  }
264
+ /**
265
+ * Demote rules loaded often but never cited — excludes them from future load_rules payloads.
266
+ * Rules remain searchable via search_memory and can be restored with `rules promote <id>`.
267
+ */
268
+ demoteRules(options) {
269
+ const minLoads = options.minLoads ?? 20;
270
+ const minAgeDays = options.minAgeDays ?? 7;
271
+ const dryRun = options.dryRun ?? false;
272
+ const demoted = this.memoryService.autoDemoteStaleRules({
273
+ minLoads,
274
+ minAgeDays,
275
+ dryRun,
276
+ force: true,
277
+ });
278
+ const verb = dryRun ? 'Would demote' : 'Demoted';
279
+ console.log(`\n${verb} ${demoted.length} stale rules (load>=${minLoads}, cite=0, age>${minAgeDays}d)\n`);
280
+ if (demoted.length === 0)
281
+ return;
282
+ demoted.forEach(r => {
283
+ console.log(` [${r.id}] ${r.type} · loaded ${r.load_count}x, cited 0x — key: ${r.key}`);
284
+ });
285
+ if (dryRun) {
286
+ console.log('\nRun without --dry-run to apply. Restore any row with `claude-recall rules promote <id>`.');
287
+ }
288
+ else {
289
+ console.log('\nRestore any row with `claude-recall rules promote <id>`.');
290
+ }
291
+ }
292
+ /**
293
+ * Retroactively collapse near-duplicate rules (predate write-time fuzzy dedup or
294
+ * slipped past its threshold). Keeps the oldest per cluster; sums cite/load into winner.
295
+ */
296
+ dedupSimilarRules(options) {
297
+ var _a;
298
+ const threshold = options.threshold ?? 0.65;
299
+ const dryRun = options.dryRun ?? false;
300
+ const collapses = this.memoryService.dedupSimilarRules({ threshold, dryRun });
301
+ const verb = dryRun ? 'Would collapse' : 'Collapsed';
302
+ console.log(`\n${verb} ${collapses.length} near-duplicate pairs (jaccard >= ${threshold})\n`);
303
+ if (collapses.length === 0)
304
+ return;
305
+ const byWinner = {};
306
+ for (const c of collapses) {
307
+ byWinner[_a = c.winnerId] ?? (byWinner[_a] = []);
308
+ byWinner[c.winnerId].push({ loserId: c.loserId, loserKey: c.loserKey, sim: c.similarity });
309
+ }
310
+ for (const [winnerId, losers] of Object.entries(byWinner)) {
311
+ const winnerKey = collapses.find(c => c.winnerId === Number(winnerId))?.winnerKey ?? '?';
312
+ console.log(` [${winnerId}] ${winnerKey}`);
313
+ for (const l of losers) {
314
+ console.log(` └─ ${l.loserKey} (sim ${l.sim})`);
315
+ }
316
+ }
317
+ if (dryRun) {
318
+ console.log('\nRun without --dry-run to apply. Losers are marked is_active=0 (reversible via `rules promote <id>`).');
319
+ }
320
+ else {
321
+ console.log('\nLosers marked is_active=0; restore individually with `rules promote <id>`.');
322
+ }
323
+ }
324
+ /**
325
+ * Delete legacy test-fixture rows that leaked into the production DB.
326
+ * Destructive — run with --dry-run first to preview.
327
+ */
328
+ cleanupTestPollution(options) {
329
+ const dryRun = options.dryRun ?? false;
330
+ const rows = this.memoryService.cleanupTestPollution({ dryRun });
331
+ const verb = dryRun ? 'Would delete' : 'Deleted';
332
+ console.log(`\n${verb} ${rows.length} test-pollution rows\n`);
333
+ if (rows.length === 0)
334
+ return;
335
+ const byType = {};
336
+ for (const r of rows)
337
+ byType[r.type] = (byType[r.type] || 0) + 1;
338
+ for (const [type, count] of Object.entries(byType)) {
339
+ console.log(` ${type}: ${count}`);
340
+ }
341
+ if (dryRun) {
342
+ console.log('\nRun without --dry-run to apply.');
343
+ }
344
+ }
345
+ /**
346
+ * Restore a previously auto-demoted rule. Refuses to touch preference-supersession rows.
347
+ */
348
+ promoteRule(id) {
349
+ const ok = this.memoryService.promoteRule(id);
350
+ if (ok) {
351
+ console.log(`Rule #${id} restored (is_active=1).`);
352
+ }
353
+ else {
354
+ console.log(`No change. Rule #${id} was not auto-demoted (may not exist, already active, or superseded by another mechanism).`);
355
+ }
356
+ }
264
357
  /**
265
358
  * Show outcome-aware learning status: episodes, outcome events, candidate lessons, memory stats
266
359
  */
@@ -1163,7 +1256,7 @@ async function main() {
1163
1256
  lastSearchAt: Date.now(),
1164
1257
  searchQuery: 'test query',
1165
1258
  toolHistory: [
1166
- { tool: 'mcp__claude-recall__search', at: Date.now() }
1259
+ { tool: 'search_memory', at: Date.now() }
1167
1260
  ]
1168
1261
  };
1169
1262
  fs.writeFileSync(stateFile, JSON.stringify(stateData, null, 2));
@@ -1392,7 +1485,7 @@ async function main() {
1392
1485
  console.log('\nTo use with Claude Code:');
1393
1486
  console.log('1. Ensure Claude Code is not running');
1394
1487
  console.log('2. Start Claude Code');
1395
- console.log('3. Use MCP tools like mcp__claude-recall__store_memory');
1488
+ console.log('3. Use MCP tools like store_memory (Claude Code adds the mcp__claude-recall__ prefix automatically)');
1396
1489
  }
1397
1490
  catch (error) {
1398
1491
  console.error('❌ Test failed:', error);
@@ -1467,6 +1560,59 @@ async function main() {
1467
1560
  });
1468
1561
  process.exit(0);
1469
1562
  });
1563
+ // Rules group: manage load_rules payload hygiene
1564
+ const rulesCmd = program
1565
+ .command('rules')
1566
+ .description('Manage rule lifecycle: demote stale rules, restore demoted rules');
1567
+ rulesCmd
1568
+ .command('demote')
1569
+ .description('Demote rules loaded >=N times with zero citations (excludes from load_rules)')
1570
+ .option('--dry-run', 'Preview without mutating', false)
1571
+ .option('--min-loads <number>', 'Minimum load count to qualify', '20')
1572
+ .option('--min-age-days <number>', 'Minimum age in days (avoid demoting brand-new rules)', '7')
1573
+ .action((options) => {
1574
+ const cli = new ClaudeRecallCLI(program.opts());
1575
+ cli.demoteRules({
1576
+ dryRun: options.dryRun,
1577
+ minLoads: parseInt(options.minLoads),
1578
+ minAgeDays: parseInt(options.minAgeDays),
1579
+ });
1580
+ process.exit(0);
1581
+ });
1582
+ rulesCmd
1583
+ .command('promote <id>')
1584
+ .description('Restore a previously auto-demoted or auto-deduped rule (safety valve)')
1585
+ .action((id) => {
1586
+ const cli = new ClaudeRecallCLI(program.opts());
1587
+ cli.promoteRule(parseInt(id));
1588
+ process.exit(0);
1589
+ });
1590
+ rulesCmd
1591
+ .command('dedup')
1592
+ .description('Retroactively collapse near-duplicate rules (write-time dedup handles new writes)')
1593
+ .option('--dry-run', 'Preview without mutating', false)
1594
+ .option('--threshold <number>', 'Jaccard similarity threshold (0-1)', '0.65')
1595
+ .action((options) => {
1596
+ const cli = new ClaudeRecallCLI(program.opts());
1597
+ cli.dedupSimilarRules({
1598
+ dryRun: options.dryRun,
1599
+ threshold: parseFloat(options.threshold),
1600
+ });
1601
+ process.exit(0);
1602
+ });
1603
+ // Cleanup group: destructive maintenance — opt-in only, always offers --dry-run
1604
+ const cleanupCmd = program
1605
+ .command('cleanup')
1606
+ .description('Maintenance commands (destructive — always preview with --dry-run first)');
1607
+ cleanupCmd
1608
+ .command('test-pollution')
1609
+ .description('Delete legacy test-fixture rows (Test preference 177…, Memory with complex metadata, etc.)')
1610
+ .option('--dry-run', 'Preview without deleting', false)
1611
+ .action((options) => {
1612
+ const cli = new ClaudeRecallCLI(program.opts());
1613
+ cli.cleanupTestPollution({ dryRun: options.dryRun });
1614
+ process.exit(0);
1615
+ });
1470
1616
  // Export command
1471
1617
  program
1472
1618
  .command('export <output>')
@@ -330,6 +330,12 @@ class MCPServer {
330
330
  // Write PID file after successful startup
331
331
  this.processManager.writePidFile(projectId, process.pid);
332
332
  this.logger.info('MCPServer', `MCP server started successfully (PID: ${process.pid}, Project: ${projectId})`);
333
+ // Prune stale rules (loaded often, never cited) from load_rules payload.
334
+ // Gated on CLAUDE_RECALL_AUTO_DEMOTE=true. Idempotent; safe per-boot.
335
+ const demoted = this.memoryService.autoDemoteStaleRules();
336
+ if (demoted.length > 0) {
337
+ this.logger.info('MCPServer', `Auto-demoted ${demoted.length} stale rules on boot`);
338
+ }
333
339
  }
334
340
  catch (error) {
335
341
  this.logger.logServiceError('MCPServer', 'start', error);
@@ -141,7 +141,7 @@ class MemoryTools {
141
141
  registerTools() {
142
142
  this.tools = [
143
143
  {
144
- name: 'mcp__claude-recall__load_rules',
144
+ name: 'load_rules',
145
145
  description: 'Load all active rules before starting work. Returns preferences, corrections, past failures, and devops rules. Call this once at the start of every task. No query needed.',
146
146
  inputSchema: {
147
147
  type: 'object',
@@ -155,7 +155,7 @@ class MemoryTools {
155
155
  handler: this.handleLoadRules.bind(this)
156
156
  },
157
157
  {
158
- name: 'mcp__claude-recall__store_memory',
158
+ name: 'store_memory',
159
159
  description: 'Store a rule or learning. Use for: corrections, preferences, devops rules, failures. The stored rule is immediately active in this conversation.',
160
160
  inputSchema: {
161
161
  type: 'object',
@@ -179,7 +179,7 @@ class MemoryTools {
179
179
  handler: this.handleStoreMemory.bind(this)
180
180
  },
181
181
  {
182
- name: 'mcp__claude-recall__search_memory',
182
+ name: 'search_memory',
183
183
  description: 'Search memories by keyword. Use to find specific memories before making decisions. Returns matched memories ranked by relevance.',
184
184
  inputSchema: {
185
185
  type: 'object',
@@ -206,7 +206,7 @@ class MemoryTools {
206
206
  handler: this.handleSearchMemory.bind(this)
207
207
  },
208
208
  {
209
- name: 'mcp__claude-recall__delete_memory',
209
+ name: 'delete_memory',
210
210
  description: 'Delete a specific memory by its ID (key). Use search_memory first to find the ID of the memory to delete.',
211
211
  inputSchema: {
212
212
  type: 'object',
@@ -221,7 +221,7 @@ class MemoryTools {
221
221
  handler: this.handleDeleteMemory.bind(this)
222
222
  },
223
223
  {
224
- name: 'mcp__claude-recall__save_checkpoint',
224
+ name: 'save_checkpoint',
225
225
  description: 'Save a task checkpoint — a structured snapshot of work in progress (completed/remaining/blockers/notes). Replaces any previous checkpoint for this project. Call when ending a work session or pausing on a task.',
226
226
  inputSchema: {
227
227
  type: 'object',
@@ -237,7 +237,7 @@ class MemoryTools {
237
237
  handler: this.handleSaveCheckpoint.bind(this),
238
238
  },
239
239
  {
240
- name: 'mcp__claude-recall__load_checkpoint',
240
+ name: 'load_checkpoint',
241
241
  description: 'Load the latest task checkpoint for the current project. Returns null if none exists. Call at the start of a session to recall where you left off — load_rules will hint when one exists.',
242
242
  inputSchema: {
243
243
  type: 'object',
@@ -274,6 +274,10 @@ class MemoryTools {
274
274
  ? metadata.type
275
275
  : 'preference';
276
276
  const key = `memory_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
277
+ const preferenceKey = typeof metadata?.preference_key === 'string' && metadata.preference_key.length > 0
278
+ ? metadata.preference_key
279
+ : undefined;
280
+ const isOverride = metadata?.isOverride === true;
277
281
  this.memoryService.store({
278
282
  key,
279
283
  value: {
@@ -288,8 +292,17 @@ class MemoryTools {
288
292
  projectId: scope === 'project' ? context.projectId : undefined,
289
293
  timestamp: context.timestamp,
290
294
  scope: scope || null
291
- }
295
+ },
296
+ preferenceKey,
297
+ isOverride
292
298
  });
299
+ // If this store overrides an existing rule, mark previous active rules with
300
+ // the same preference_key as superseded and surface their keys so the agent
301
+ // knows to ignore the stale text sitting higher up in its context.
302
+ let supersededKeys = [];
303
+ if (isOverride && preferenceKey) {
304
+ supersededKeys = this.memoryService.supersedeByPreferenceKey(preferenceKey, key, { sessionId: context.sessionId, projectId: context.projectId, timestamp: context.timestamp });
305
+ }
293
306
  this.logger.info('MemoryTools', 'Memory stored successfully', {
294
307
  key,
295
308
  type: detectedType,
@@ -320,6 +333,10 @@ class MemoryTools {
320
333
  activeRule: `Stored as active rule:\n- ${content}`,
321
334
  type: detectedType,
322
335
  _directive: 'Apply this rule immediately. No need to call load_rules again.',
336
+ ...(supersededKeys.length > 0 && {
337
+ supersededKeys,
338
+ _supersessionNotice: `Superseded ${supersededKeys.length} prior rule(s) for preference_key="${preferenceKey}". Ignore any earlier text from these rules still in your context: ${supersededKeys.join(', ')}`
339
+ }),
323
340
  ...(skillResults.length > 0 && {
324
341
  _skillsGenerated: skillResults
325
342
  .filter(r => r.action === 'created' || r.action === 'updated')
@@ -340,29 +357,59 @@ class MemoryTools {
340
357
  const { projectId } = input;
341
358
  const directive = this.getLoadRulesDirective();
342
359
  const rules = this.memoryService.loadActiveRules(projectId || context.projectId);
343
- // Format categorized markdown sections
360
+ // Token budget cap the load_rules payload so it doesn't dominate the host's
361
+ // context window (CC precedent: CAPPED_DEFAULT_MAX_TOKENS = 8_000 in utils/context.ts).
362
+ // Priority order of inclusion: corrections > preferences (by cite) > devops (by cite) > failures.
363
+ // Within each category, high-cited rules sink to the top so the first-to-drop are uncited.
364
+ const BUDGET = Number(process.env.CLAUDE_RECALL_LOAD_BUDGET_TOKENS ?? 2000);
365
+ const byCiteThenFresh = (a, b) => (b.cite_count || 0) - (a.cite_count || 0) ||
366
+ (b.timestamp || 0) - (a.timestamp || 0);
367
+ let remaining = BUDGET;
368
+ let droppedCount = 0;
369
+ const takeBounded = (items, maxCount) => {
370
+ const kept = [];
371
+ const limit = maxCount ?? items.length;
372
+ for (const item of items) {
373
+ if (kept.length >= limit) {
374
+ droppedCount += items.length - kept.length;
375
+ break;
376
+ }
377
+ const cost = this.estimateTokens([item]);
378
+ if (remaining - cost < 0) {
379
+ droppedCount += items.length - kept.length;
380
+ break;
381
+ }
382
+ kept.push(item);
383
+ remaining -= cost;
384
+ }
385
+ return kept;
386
+ };
387
+ // Allocate in priority order (corrections first to protect high-signal items).
388
+ const keptCorrections = takeBounded(rules.corrections);
389
+ const keptPreferences = takeBounded([...rules.preferences].sort(byCiteThenFresh));
390
+ const keptDevops = takeBounded([...rules.devops].sort(byCiteThenFresh));
391
+ const keptFailures = takeBounded(rules.failures, 3);
392
+ // Format categorized markdown sections (output order stays user-familiar).
344
393
  const sections = [];
345
- if (rules.preferences.length > 0) {
346
- sections.push('## Preferences\n' + rules.preferences.map(m => {
394
+ if (keptPreferences.length > 0) {
395
+ sections.push('## Preferences\n' + keptPreferences.map(m => {
347
396
  const val = formatRuleValue(m.value);
348
- // Only show key prefix if it's a meaningful name (not auto-generated)
349
397
  const key = m.preference_key || m.key || '';
350
398
  const isAutoKey = key.startsWith('memory_') || key.startsWith('auto_') || key.startsWith('pref_');
351
399
  return isAutoKey ? `- ${val}` : `- ${key}: ${val}`;
352
400
  }).join('\n'));
353
401
  }
354
- if (rules.corrections.length > 0) {
355
- sections.push('## Corrections\n' + rules.corrections.map(m => {
402
+ if (keptCorrections.length > 0) {
403
+ sections.push('## Corrections\n' + keptCorrections.map(m => {
356
404
  const val = formatRuleValue(m.value);
357
405
  const isPromoted = m.key.startsWith('promoted_') || m.value?.source === 'promotion-engine';
358
406
  const evidence = isPromoted && m.value?.evidence_count ? ` (learned from ${m.value.evidence_count} observations)` : '';
359
407
  return isPromoted ? `- [promoted lesson] ${val}${evidence}` : `- ${val}`;
360
408
  }).join('\n'));
361
409
  }
362
- if (rules.failures.length > 0) {
363
- // Separate promoted lessons from regular failures
364
- const promotedLessons = rules.failures.filter(m => m.key.startsWith('promoted_') || m.value?.source === 'promotion-engine');
365
- const regularFailures = rules.failures.filter(m => !m.key.startsWith('promoted_') && m.value?.source !== 'promotion-engine');
410
+ if (keptFailures.length > 0) {
411
+ const promotedLessons = keptFailures.filter(m => m.key.startsWith('promoted_') || m.value?.source === 'promotion-engine');
412
+ const regularFailures = keptFailures.filter(m => !m.key.startsWith('promoted_') && m.value?.source !== 'promotion-engine');
366
413
  if (promotedLessons.length > 0) {
367
414
  sections.push('## Promoted Lessons (learned from repeated outcomes)\n' + promotedLessons.map(m => {
368
415
  const val = formatRuleValue(m.value);
@@ -377,56 +424,28 @@ class MemoryTools {
377
424
  }).join('\n'));
378
425
  }
379
426
  }
380
- if (rules.devops.length > 0) {
381
- sections.push('## DevOps Rules\n' + rules.devops.map(m => {
427
+ if (keptDevops.length > 0) {
428
+ sections.push('## DevOps Rules\n' + keptDevops.map(m => {
382
429
  const val = formatRuleValue(m.value);
383
430
  return `- ${val}`;
384
431
  }).join('\n'));
385
432
  }
386
- // Add compliance section for rules loaded frequently but never cited
387
- const compliance = this.memoryService.getComplianceReport(projectId || context.projectId);
388
- const lowCompliance = compliance.rules.filter(r => r.load_count >= 5 && r.cite_count === 0);
389
- if (lowCompliance.length > 0) {
390
- sections.push('## Rule Health\nThese rules are loaded frequently but never cited — consider rewording or removing:\n' +
391
- lowCompliance.map(r => {
392
- let val;
393
- if (typeof r.value === 'string') {
394
- try {
395
- const parsed = JSON.parse(r.value);
396
- val = typeof parsed === 'string' ? parsed
397
- : typeof parsed?.content === 'string' ? parsed.content
398
- : typeof parsed?.value === 'string' ? parsed.value
399
- : r.value;
400
- }
401
- catch {
402
- val = r.value;
403
- }
404
- }
405
- else if (typeof r.value === 'object' && r.value !== null) {
406
- const v = r.value;
407
- val = typeof v.content === 'string' ? v.content
408
- : typeof v.value === 'string' ? v.value
409
- : JSON.stringify(r.value);
410
- }
411
- else {
412
- val = String(r.value ?? '');
413
- }
414
- return `- "${String(val).substring(0, 80)}" (loaded ${r.load_count}x, cited 0x)`;
415
- }).join('\n'));
433
+ // Truncation marker turns a capacity failure into a discoverable affordance.
434
+ // Rule Health diagnostic moved to `npx claude-recall outcomes` to save ~10 lines/payload.
435
+ if (droppedCount > 0) {
436
+ sections.push(`*${droppedCount} more rules available via \`search_memory\`. Run \`npx claude-recall outcomes\` for full stats.*`);
416
437
  }
417
- const totalRules = rules.preferences.length + rules.corrections.length +
418
- rules.failures.length + rules.devops.length;
419
- const resultTokens = this.estimateTokens([
420
- ...rules.preferences, ...rules.corrections,
421
- ...rules.failures, ...rules.devops
422
- ]);
438
+ const totalRules = keptPreferences.length + keptCorrections.length +
439
+ keptFailures.length + keptDevops.length;
440
+ const keptAll = [...keptPreferences, ...keptCorrections, ...keptFailures, ...keptDevops];
441
+ const resultTokens = this.estimateTokens(keptAll);
423
442
  // Record to SearchMonitor so monitoring/stats still work
424
443
  this.searchMonitor.recordSearch('load_rules', totalRules, context.sessionId, 'mcp', { tool: 'load_rules', tokenMetrics: { resultTokens, tokensSaved: totalRules > 0 ? totalRules * 200 : 0 } });
425
- // Track retrievals for outcome-aware scoring
444
+ // Track retrievals for outcome-aware scoring — only emitted rules, so dropped
445
+ // ones aren't credited as "retrieved" when the caller never saw them.
426
446
  try {
427
447
  const outcomeStorage = outcome_storage_1.OutcomeStorage.getInstance();
428
- const allMemories = [...rules.preferences, ...rules.corrections, ...rules.failures, ...rules.devops];
429
- for (const m of allMemories) {
448
+ for (const m of keptAll) {
430
449
  outcomeStorage.recordRetrieval(m.key);
431
450
  }
432
451
  }
@@ -461,11 +480,12 @@ class MemoryTools {
461
480
  return {
462
481
  rules: rulesText,
463
482
  counts: {
464
- preferences: rules.preferences.length,
465
- corrections: rules.corrections.length,
466
- failures: rules.failures.length,
467
- devops: rules.devops.length,
468
- total: totalRules
483
+ preferences: keptPreferences.length,
484
+ corrections: keptCorrections.length,
485
+ failures: keptFailures.length,
486
+ devops: keptDevops.length,
487
+ total: totalRules,
488
+ dropped: droppedCount,
469
489
  },
470
490
  summary: rules.summary,
471
491
  };
@@ -41,6 +41,7 @@ const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
41
41
  const crypto = __importStar(require("crypto"));
42
42
  const fs = __importStar(require("fs"));
43
43
  const path = __importStar(require("path"));
44
+ const test_pollution_1 = require("../services/test-pollution");
44
45
  class MemoryStorage {
45
46
  constructor(dbPath) {
46
47
  this.db = new better_sqlite3_1.default(dbPath);
@@ -562,6 +563,23 @@ class MemoryStorage {
562
563
  const stmt = this.db.prepare(`UPDATE memories SET ${setClause} WHERE key = ?`);
563
564
  stmt.run(...values, key);
564
565
  }
566
+ /**
567
+ * Get active memories (any type) that share the given preference_key.
568
+ * Used by store_memory's override path — a user can override a rule of any
569
+ * type (devops, correction, preference) as long as it was stored with a
570
+ * preference_key.
571
+ */
572
+ getActiveByPreferenceKeyAnyType(preferenceKey, projectId) {
573
+ let query = 'SELECT * FROM memories WHERE preference_key = ? AND is_active = 1';
574
+ const params = [preferenceKey];
575
+ if (projectId) {
576
+ query += ' AND (project_id = ? OR project_id IS NULL)';
577
+ params.push(projectId);
578
+ }
579
+ query += ' ORDER BY timestamp DESC';
580
+ const rows = this.db.prepare(query).all(...params);
581
+ return rows.map(row => this.rowToMemory(row));
582
+ }
565
583
  /**
566
584
  * Get preferences by preference key
567
585
  */
@@ -640,6 +658,174 @@ class MemoryStorage {
640
658
  incrementCiteCount(id) {
641
659
  this.db.prepare('UPDATE memories SET cite_count = cite_count + 1 WHERE id = ?').run(id);
642
660
  }
661
+ /**
662
+ * Demote rules that are loaded frequently but never cited.
663
+ * Matches rules where is_active=1, load_count >= minLoads, cite_count = 0,
664
+ * and timestamp older than minAgeDays. Flips is_active=0 and marks
665
+ * superseded_by='auto-demote' so they can be distinguished from preference supersession
666
+ * and selectively promoted back via promoteRule().
667
+ *
668
+ * Returns rows that were (or would be, in dryRun) demoted.
669
+ */
670
+ demoteStaleRules(options) {
671
+ const cutoff = Date.now() - options.minAgeDays * 86400000;
672
+ const candidates = this.db.prepare(`
673
+ SELECT id, key, type, load_count, cite_count, timestamp
674
+ FROM memories
675
+ WHERE is_active = 1
676
+ AND load_count >= ?
677
+ AND cite_count = 0
678
+ AND timestamp < ?
679
+ AND type IN ('preference', 'correction', 'failure', 'devops', 'project-knowledge')
680
+ ORDER BY load_count DESC
681
+ `).all(options.minLoads, cutoff);
682
+ if (!options.dryRun && candidates.length > 0) {
683
+ const ids = candidates.map(c => c.id);
684
+ const placeholders = ids.map(() => '?').join(',');
685
+ const now = Date.now();
686
+ this.db.prepare(`UPDATE memories SET is_active = 0, superseded_at = ?, superseded_by = 'auto-demote'
687
+ WHERE id IN (${placeholders})`).run(now, ...ids);
688
+ this.db.pragma('wal_checkpoint(TRUNCATE)');
689
+ }
690
+ return candidates;
691
+ }
692
+ /**
693
+ * Delete rows whose stored value matches legacy test-fixture patterns.
694
+ * Matches against json_extract(value, '$.content') OR the raw value (covers both
695
+ * structured and legacy string payloads). Returns rows that were (or would be,
696
+ * in dryRun) deleted. Destructive — CLI-gated, not called from boot.
697
+ */
698
+ deleteTestPollution(options) {
699
+ const dryRun = options?.dryRun ?? false;
700
+ if (test_pollution_1.TEST_POLLUTION_LIKE.length === 0)
701
+ return [];
702
+ const likeClauses = test_pollution_1.TEST_POLLUTION_LIKE
703
+ .map(() => `(json_extract(value, '$.content') LIKE ? OR value LIKE ?)`)
704
+ .join(' OR ');
705
+ const params = [];
706
+ for (const pat of test_pollution_1.TEST_POLLUTION_LIKE) {
707
+ params.push(pat, `%${pat.replace(/%$/, '')}%`);
708
+ }
709
+ const candidates = this.db
710
+ .prepare(`SELECT id, key, type, value FROM memories WHERE ${likeClauses}`)
711
+ .all(...params);
712
+ if (!dryRun && candidates.length > 0) {
713
+ const ids = candidates.map(c => c.id);
714
+ const placeholders = ids.map(() => '?').join(',');
715
+ this.db
716
+ .prepare(`DELETE FROM memories WHERE id IN (${placeholders})`)
717
+ .run(...ids);
718
+ this.db.pragma('wal_checkpoint(TRUNCATE)');
719
+ }
720
+ return candidates;
721
+ }
722
+ /**
723
+ * Retroactive dedup: collapse near-duplicate active rules that predate write-time
724
+ * fuzzy dedup (or slipped past its threshold).
725
+ *
726
+ * For each (type, project_id) group:
727
+ * 1. Sort by timestamp ASC (oldest first — preserves original authoring order).
728
+ * 2. Greedy clustering: for each unassigned rule, pull in all later rules with
729
+ * Jaccard >= threshold into a cluster.
730
+ * 3. In each cluster of size > 1, keep the oldest as winner; sum cite/load
731
+ * counts into it; mark the rest is_active=0, superseded_by='auto-dedup'.
732
+ *
733
+ * Returns one entry per collapse: {winnerId, loserId, similarity}.
734
+ */
735
+ dedupSimilar(options) {
736
+ const threshold = options.threshold ?? 0.65;
737
+ const dryRun = options.dryRun ?? false;
738
+ const RULE_TYPES = ['preference', 'correction', 'failure', 'devops', 'project-knowledge'];
739
+ const collapses = [];
740
+ for (const type of RULE_TYPES) {
741
+ const rows = this.db.prepare(`SELECT id, key, value, project_id, timestamp, load_count, cite_count
742
+ FROM memories
743
+ WHERE type = ? AND is_active = 1
744
+ ORDER BY COALESCE(project_id, ''), timestamp ASC`).all(type);
745
+ // Sub-group by project so cross-project rules never collapse into each other.
746
+ const byProject = new Map();
747
+ for (const row of rows) {
748
+ const pid = row.project_id ?? '';
749
+ const bucket = byProject.get(pid) ?? [];
750
+ bucket.push(row);
751
+ byProject.set(pid, bucket);
752
+ }
753
+ for (const bucket of byProject.values()) {
754
+ // Pre-tokenize once to avoid O(n^2) re-parse.
755
+ const tokenized = bucket.map(r => {
756
+ let text = '';
757
+ try {
758
+ const parsed = JSON.parse(r.value);
759
+ text = this.extractText(parsed);
760
+ }
761
+ catch {
762
+ text = r.value;
763
+ }
764
+ return { ...r, text: text.toLowerCase() };
765
+ });
766
+ const assigned = new Set();
767
+ for (let i = 0; i < tokenized.length; i++) {
768
+ const winner = tokenized[i];
769
+ if (assigned.has(winner.id))
770
+ continue;
771
+ if (winner.text.length < 40)
772
+ continue; // too short to fuzzy-match reliably
773
+ let mergedCite = winner.cite_count;
774
+ let mergedLoad = winner.load_count;
775
+ const losers = [];
776
+ for (let j = i + 1; j < tokenized.length; j++) {
777
+ const candidate = tokenized[j];
778
+ if (assigned.has(candidate.id))
779
+ continue;
780
+ if (candidate.text.length < 40)
781
+ continue;
782
+ const sim = this.jaccardSimilarity(winner.text, candidate.text);
783
+ if (sim >= threshold) {
784
+ assigned.add(candidate.id);
785
+ losers.push({ id: candidate.id, key: candidate.key, cite: candidate.cite_count, load: candidate.load_count, sim });
786
+ mergedCite += candidate.cite_count;
787
+ mergedLoad += candidate.load_count;
788
+ }
789
+ }
790
+ if (losers.length === 0)
791
+ continue;
792
+ for (const loser of losers) {
793
+ collapses.push({
794
+ winnerId: winner.id, winnerKey: winner.key,
795
+ loserId: loser.id, loserKey: loser.key,
796
+ similarity: Number(loser.sim.toFixed(3)),
797
+ });
798
+ }
799
+ if (!dryRun) {
800
+ const now = Date.now();
801
+ this.db.prepare('UPDATE memories SET cite_count = ?, load_count = ? WHERE id = ?').run(mergedCite, mergedLoad, winner.id);
802
+ const loserIds = losers.map(l => l.id);
803
+ const placeholders = loserIds.map(() => '?').join(',');
804
+ this.db.prepare(`UPDATE memories SET is_active = 0, superseded_by = 'auto-dedup', superseded_at = ?
805
+ WHERE id IN (${placeholders})`).run(now, ...loserIds);
806
+ }
807
+ }
808
+ }
809
+ }
810
+ if (!dryRun && collapses.length > 0) {
811
+ this.db.pragma('wal_checkpoint(TRUNCATE)');
812
+ }
813
+ return collapses;
814
+ }
815
+ /**
816
+ * Restore a previously auto-demoted or auto-deduped rule. Only flips rows where
817
+ * superseded_by IN ('auto-demote', 'auto-dedup') — refuses to touch rules
818
+ * superseded by preference override logic (where superseded_by points at another key).
819
+ * Returns true if a row was restored.
820
+ */
821
+ promoteRule(id) {
822
+ const result = this.db.prepare(`UPDATE memories SET is_active = 1, superseded_at = NULL, superseded_by = NULL
823
+ WHERE id = ? AND is_active = 0 AND superseded_by IN ('auto-demote', 'auto-dedup')`).run(id);
824
+ if (result.changes > 0) {
825
+ this.db.pragma('wal_checkpoint(TRUNCATE)');
826
+ }
827
+ return result.changes > 0;
828
+ }
643
829
  /**
644
830
  * Get rules with their compliance metrics (load_count, cite_count).
645
831
  * Returns rules that have been loaded at least once.
@@ -365,7 +365,13 @@ function default_1(pi) {
365
365
  label: 'Store Memory',
366
366
  description: 'Store a rule or learning. Use for: corrections, preferences, devops rules, failures.',
367
367
  promptSnippet: 'Store a rule, correction, or preference to memory',
368
- parameters: {},
368
+ parameters: {
369
+ type: 'object',
370
+ properties: {
371
+ content: { type: 'string', description: 'The rule, correction, or preference to store' },
372
+ },
373
+ required: ['content'],
374
+ },
369
375
  async execute(_id, params, _signal, _onUpdate, _ctx) {
370
376
  try {
371
377
  const content = params.content;
@@ -416,7 +422,13 @@ function default_1(pi) {
416
422
  label: 'Search Memory',
417
423
  description: 'Search memories by keyword. Returns matched memories ranked by relevance.',
418
424
  promptSnippet: 'Search stored memories by keyword',
419
- parameters: {},
425
+ parameters: {
426
+ type: 'object',
427
+ properties: {
428
+ query: { type: 'string', description: 'Keyword or phrase to search for' },
429
+ },
430
+ required: ['query'],
431
+ },
420
432
  async execute(_id, params, _signal, _onUpdate, _ctx) {
421
433
  try {
422
434
  const query = params.query;
@@ -466,7 +478,16 @@ function default_1(pi) {
466
478
  label: 'Save Task Checkpoint',
467
479
  description: 'Save a structured snapshot of work in progress (completed/remaining/blockers/notes). Replaces any previous checkpoint for this project. Call when ending a session or pausing on a task.',
468
480
  promptSnippet: 'Save a task checkpoint with what is done, what remains, and any blockers',
469
- parameters: {},
481
+ parameters: {
482
+ type: 'object',
483
+ properties: {
484
+ completed: { type: 'string', description: 'What has been completed so far' },
485
+ remaining: { type: 'string', description: 'What remains to be done' },
486
+ blockers: { type: 'string', description: 'Any blockers or issues (use "none" if none)' },
487
+ notes: { type: 'string', description: 'Optional additional notes' },
488
+ },
489
+ required: ['completed', 'remaining', 'blockers'],
490
+ },
470
491
  async execute(_id, params, _signal, _onUpdate, _ctx) {
471
492
  try {
472
493
  const { completed, remaining, blockers, notes } = params || {};
@@ -532,7 +553,13 @@ function default_1(pi) {
532
553
  name: 'recall_delete_memory',
533
554
  label: 'Delete Memory',
534
555
  description: 'Delete a specific memory by its ID. Use recall_search_memory first to find the ID.',
535
- parameters: {},
556
+ parameters: {
557
+ type: 'object',
558
+ properties: {
559
+ id: { type: 'string', description: 'Memory ID to delete (use recall_search_memory to find IDs)' },
560
+ },
561
+ required: ['id'],
562
+ },
536
563
  async execute(_id, params, _signal, _onUpdate, _ctx) {
537
564
  try {
538
565
  const id = params.id;
@@ -5,6 +5,7 @@ const storage_1 = require("../memory/storage");
5
5
  const retrieval_1 = require("../core/retrieval");
6
6
  const config_1 = require("./config");
7
7
  const logging_1 = require("./logging");
8
+ const test_pollution_1 = require("./test-pollution");
8
9
  class MemoryService {
9
10
  constructor() {
10
11
  this.config = config_1.ConfigService.getInstance();
@@ -25,6 +26,16 @@ class MemoryService {
25
26
  */
26
27
  store(request) {
27
28
  try {
29
+ // Write-time guard: silently drop values matching known test-fixture patterns
30
+ // (Test preference 177…, Session test preference …, Memory with complex metadata,
31
+ // Test memory content). Prevents legacy test harnesses from re-polluting the DB.
32
+ if ((0, test_pollution_1.isTestPollution)(request.value)) {
33
+ this.logger.info('MemoryService', 'Dropped test-pollution write', {
34
+ key: request.key,
35
+ type: request.type,
36
+ });
37
+ return;
38
+ }
28
39
  // Check memory limits and notify if approaching
29
40
  const stats = this.getStats();
30
41
  const config = this.config.getConfig();
@@ -43,7 +54,9 @@ class MemoryService {
43
54
  file_path: request.context?.filePath,
44
55
  timestamp: request.context?.timestamp || Date.now(),
45
56
  relevance_score: request.relevanceScore || 1.0,
46
- scope: scope
57
+ scope: scope,
58
+ preference_key: request.preferenceKey,
59
+ is_active: true
47
60
  };
48
61
  this.storage.save(memory);
49
62
  this.logger.logMemoryOperation('STORE', {
@@ -303,6 +316,30 @@ class MemoryService {
303
316
  throw error;
304
317
  }
305
318
  }
319
+ /**
320
+ * Mark all currently-active memories with the given preference_key as superseded by newKey.
321
+ * Returns the list of superseded keys so callers can surface them to the agent — this
322
+ * closes the "I stored an override but the old rule is still in my context" gap.
323
+ */
324
+ supersedeByPreferenceKey(preferenceKey, newKey, context) {
325
+ if (!preferenceKey || !newKey)
326
+ return [];
327
+ const superseded = [];
328
+ try {
329
+ const pid = context.projectId || this.config.getProjectId();
330
+ const existing = this.storage.getActiveByPreferenceKeyAnyType(preferenceKey, pid);
331
+ for (const prev of existing) {
332
+ if (prev.key !== newKey) {
333
+ this.storage.markSuperseded(prev.key, newKey);
334
+ superseded.push(prev.key);
335
+ }
336
+ }
337
+ }
338
+ catch (error) {
339
+ this.logger.logServiceError('MemoryService', 'supersedeByPreferenceKey', error, { preferenceKey, newKey });
340
+ }
341
+ return superseded;
342
+ }
306
343
  /**
307
344
  * Mark existing preferences as superseded
308
345
  */
@@ -378,21 +415,25 @@ class MemoryService {
378
415
  try {
379
416
  const pid = projectId || this.config.getProjectId();
380
417
  const searchContext = { project_id: pid };
418
+ // All categories filter is_active so auto-demoted rules are excluded uniformly.
419
+ const isActive = (m) => m.is_active !== false;
381
420
  // Preferences: active only
382
421
  const allPreferences = this.storage.searchByContext({ ...searchContext, type: 'preference' });
383
- const preferences = allPreferences.filter(m => m.is_active !== false);
422
+ const preferences = allPreferences.filter(isActive);
384
423
  // Corrections: top 10 by timestamp
385
424
  const allCorrections = this.storage.searchByContext({ ...searchContext, type: 'correction' });
386
425
  const corrections = allCorrections
426
+ .filter(isActive)
387
427
  .sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))
388
428
  .slice(0, 10);
389
429
  // Failures: top 5 by timestamp
390
430
  const allFailures = this.storage.searchByContext({ ...searchContext, type: 'failure' });
391
431
  const failures = allFailures
432
+ .filter(isActive)
392
433
  .sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))
393
434
  .slice(0, 5);
394
- // DevOps: all rules
395
- const devops = this.storage.searchByContext({ ...searchContext, type: 'devops' });
435
+ // DevOps: all active rules
436
+ const devops = this.storage.searchByContext({ ...searchContext, type: 'devops' }).filter(isActive);
396
437
  const counts = [
397
438
  preferences.length && `${preferences.length} preferences`,
398
439
  corrections.length && `${corrections.length} corrections`,
@@ -427,6 +468,52 @@ class MemoryService {
427
468
  this.storage.incrementCiteCount(memory.id);
428
469
  }
429
470
  }
471
+ /**
472
+ * Auto-demote rules that burn context without earning citations.
473
+ * Reads env vars: CLAUDE_RECALL_AUTO_DEMOTE (gate), CLAUDE_RECALL_DEMOTE_MIN_LOADS (default 20),
474
+ * CLAUDE_RECALL_DEMOTE_MIN_AGE_DAYS (default 7).
475
+ * Safe to call on every boot — idempotent, only acts on newly-qualifying rules.
476
+ */
477
+ autoDemoteStaleRules(options) {
478
+ const force = options?.force ?? false;
479
+ if (!force && process.env.CLAUDE_RECALL_AUTO_DEMOTE !== 'true') {
480
+ return [];
481
+ }
482
+ const minLoads = options?.minLoads ?? Number(process.env.CLAUDE_RECALL_DEMOTE_MIN_LOADS ?? 20);
483
+ const minAgeDays = options?.minAgeDays ?? Number(process.env.CLAUDE_RECALL_DEMOTE_MIN_AGE_DAYS ?? 7);
484
+ const dryRun = options?.dryRun ?? false;
485
+ try {
486
+ const demoted = this.storage.demoteStaleRules({ minLoads, minAgeDays, dryRun });
487
+ if (demoted.length > 0) {
488
+ this.logger.info('MemoryService', `${dryRun ? 'Would demote' : 'Demoted'} ${demoted.length} stale rules (load>=${minLoads}, cite=0, age>${minAgeDays}d)`, { count: demoted.length, dryRun });
489
+ }
490
+ return demoted;
491
+ }
492
+ catch (error) {
493
+ this.logger.logServiceError('MemoryService', 'autoDemoteStaleRules', error);
494
+ return [];
495
+ }
496
+ }
497
+ /**
498
+ * Restore an auto-demoted rule. Returns true if the row was flipped back.
499
+ */
500
+ promoteRule(id) {
501
+ return this.storage.promoteRule(id);
502
+ }
503
+ /**
504
+ * Delete legacy test-fixture rows that leaked into the production DB.
505
+ * Destructive — exposed via CLI only, not called from boot.
506
+ */
507
+ cleanupTestPollution(options) {
508
+ return this.storage.deleteTestPollution(options);
509
+ }
510
+ /**
511
+ * Retroactive fuzzy dedup. Write-time dedup (findFuzzyDuplicate) handles new
512
+ * writes; this method catches duplicates that predate it or crossed its threshold.
513
+ */
514
+ dedupSimilarRules(options) {
515
+ return this.storage.dedupSimilar(options ?? {});
516
+ }
430
517
  /**
431
518
  * Get top N rules scored for sync to Claude Code's auto-memory directory.
432
519
  * Scores by: (cite_count * 3) + (load_count * 0.5) + recency_bonus.
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ /**
3
+ * Test-pollution patterns — values produced by legacy unit/integration test fixtures
4
+ * that leaked into the production memory DB. Centralized here so the write-time guard
5
+ * (MemoryService.store) and the cleanup command (MemoryStorage.deleteTestPollution)
6
+ * never drift.
7
+ *
8
+ * If you legitimately want to test the store path, use values that don't match these
9
+ * patterns (e.g. fixture strings that include the test name, not "Test preference 177…").
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.TEST_POLLUTION_LIKE = exports.TEST_POLLUTION_REGEXES = void 0;
13
+ exports.isTestPollution = isTestPollution;
14
+ exports.TEST_POLLUTION_REGEXES = [
15
+ /^Test preference \d+/i,
16
+ /^Session test preference \d+/i,
17
+ /^Memory with complex metadata$/i,
18
+ /^Test memory content$/i,
19
+ ];
20
+ /**
21
+ * SQL LIKE patterns (case-insensitive via COLLATE NOCASE) mirroring the regexes above.
22
+ * better-sqlite3 has no regex built-in; LIKE is enough for these anchored prefixes.
23
+ */
24
+ exports.TEST_POLLUTION_LIKE = [
25
+ 'Test preference 1%',
26
+ 'Session test preference 1%',
27
+ 'Memory with complex metadata',
28
+ 'Test memory content',
29
+ ];
30
+ function extractContentString(value) {
31
+ if (typeof value === 'string')
32
+ return value;
33
+ if (value && typeof value === 'object') {
34
+ const v = value;
35
+ if (typeof v.content === 'string')
36
+ return v.content;
37
+ if (typeof v.value === 'string')
38
+ return v.value;
39
+ if (typeof v.title === 'string')
40
+ return v.title;
41
+ }
42
+ return '';
43
+ }
44
+ /**
45
+ * Returns true if the provided memory value looks like test-fixture pollution.
46
+ * Only string-ish payloads are inspected; structured rules with real content pass through.
47
+ */
48
+ function isTestPollution(value) {
49
+ const str = extractContentString(value);
50
+ if (!str)
51
+ return false;
52
+ return exports.TEST_POLLUTION_REGEXES.some(rx => rx.test(str));
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-recall",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
4
4
  "description": "Persistent memory for Claude Code and Pi with native Skills integration, automatic capture, failure learning, and project scoping",
5
5
  "main": "dist/index.js",
6
6
  "bin": {