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.
- package/.claude/skills/auto-preferences/SKILL.md +6 -6
- package/.claude/skills/auto-preferences/manifest.json +7 -7
- package/README.md +31 -0
- package/dist/cli/claude-recall-cli.js +148 -2
- package/dist/mcp/server.js +6 -0
- package/dist/mcp/tools/memory-tools.js +83 -63
- package/dist/memory/storage.js +186 -0
- package/dist/pi/extension.js +31 -4
- package/dist/services/memory.js +91 -4
- package/dist/services/test-pollution.js +53 -0
- package/package.json +1 -1
|
@@ -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
|
+
Auto-generated from 5 memories. Last updated: 2026-04-22.
|
|
12
12
|
|
|
13
13
|
## Rules
|
|
14
14
|
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
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": "
|
|
3
|
+
"sourceHash": "886f098b1a582616f10443d10ec5877b85152231a370636321f2f3f8dc37b2dc",
|
|
4
4
|
"memoryCount": 5,
|
|
5
|
-
"generatedAt": "2026-04-
|
|
5
|
+
"generatedAt": "2026-04-22T19:23:41.665Z",
|
|
6
6
|
"memoryKeys": [
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
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: '
|
|
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-
|
|
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>')
|
package/dist/mcp/server.js
CHANGED
|
@@ -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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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
|
-
//
|
|
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 (
|
|
346
|
-
sections.push('## Preferences\n' +
|
|
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 (
|
|
355
|
-
sections.push('## Corrections\n' +
|
|
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 (
|
|
363
|
-
|
|
364
|
-
const
|
|
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 (
|
|
381
|
-
sections.push('## DevOps Rules\n' +
|
|
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
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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 =
|
|
418
|
-
|
|
419
|
-
const
|
|
420
|
-
|
|
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
|
|
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:
|
|
465
|
-
corrections:
|
|
466
|
-
failures:
|
|
467
|
-
devops:
|
|
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
|
};
|
package/dist/memory/storage.js
CHANGED
|
@@ -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.
|
package/dist/pi/extension.js
CHANGED
|
@@ -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;
|
package/dist/services/memory.js
CHANGED
|
@@ -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(
|
|
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