claude-recall 0.25.1 → 0.26.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.
@@ -42,12 +42,50 @@ var __importStar = (this && this.__importStar) || (function () {
42
42
  Object.defineProperty(exports, "__esModule", { value: true });
43
43
  exports.handleMemoryStop = handleMemoryStop;
44
44
  const shared_1 = require("./shared");
45
+ const fs = __importStar(require("fs"));
46
+ const path = __importStar(require("path"));
45
47
  const memory_1 = require("../services/memory");
46
48
  const config_1 = require("../services/config");
47
49
  const failure_detectors_1 = require("./failure-detectors");
50
+ const llm_classifier_1 = require("./llm-classifier");
48
51
  const outcome_storage_1 = require("../services/outcome-storage");
49
52
  const event_processors_1 = require("../shared/event-processors");
50
53
  const MAX_STORE = 3;
54
+ /**
55
+ * Debounce state for the heavy pipeline. Claude Code fires Stop after EVERY
56
+ * assistant turn, not at session end — without a debounce the full pipeline
57
+ * (episode insert, up to two Haiku calls, failure scan, promotion cycle,
58
+ * prune) runs per turn. Citations are still scanned every turn (cheap, and
59
+ * they keep cite_count — the anti-demotion signal — fresh).
60
+ */
61
+ const STOP_DEBOUNCE_MS = (() => {
62
+ const raw = parseInt(process.env.CLAUDE_RECALL_STOP_DEBOUNCE_MS || '300000', 10); // 5 min
63
+ return Number.isFinite(raw) && raw >= 0 ? raw : 300000;
64
+ })();
65
+ function stopStateFile(sessionId) {
66
+ return path.join((0, shared_1.hookStateDir)(), `memory-stop-${sessionId.replace(/[^a-zA-Z0-9-]/g, '_')}.json`);
67
+ }
68
+ function shouldRunHeavyPipeline(sessionId) {
69
+ if (STOP_DEBOUNCE_MS === 0)
70
+ return true; // debounce disabled
71
+ const file = stopStateFile(sessionId);
72
+ try {
73
+ const state = JSON.parse(fs.readFileSync(file, 'utf-8'));
74
+ if (typeof state.lastHeavyRun === 'number' && Date.now() - state.lastHeavyRun < STOP_DEBOUNCE_MS) {
75
+ return false;
76
+ }
77
+ }
78
+ catch {
79
+ // Missing/corrupt state file → treat as never run
80
+ }
81
+ try {
82
+ fs.writeFileSync(file, JSON.stringify({ lastHeavyRun: Date.now() }));
83
+ }
84
+ catch {
85
+ // Non-fatal: worst case the pipeline runs again next turn
86
+ }
87
+ return true;
88
+ }
51
89
  async function handleMemoryStop(input) {
52
90
  const transcriptPath = input?.transcript_path ?? '';
53
91
  if (!transcriptPath) {
@@ -59,14 +97,14 @@ async function handleMemoryStop(input) {
59
97
  (0, shared_1.hookLog)('memory-stop', 'No transcript entries found');
60
98
  return;
61
99
  }
62
- // Create an episode for this session
100
+ // Debounced: run only the cheap citation scan on most turns
101
+ const sessionId = input?.session_id || 'default';
102
+ if (!shouldRunHeavyPipeline(sessionId)) {
103
+ scanForCitations(transcriptPath);
104
+ return;
105
+ }
63
106
  const outcomeStorage = outcome_storage_1.OutcomeStorage.getInstance();
64
107
  const projectId = config_1.ConfigService.getInstance().getProjectId();
65
- const episodeId = outcomeStorage.createEpisode({
66
- project_id: projectId,
67
- session_id: input?.session_id,
68
- source: 'memory-stop',
69
- });
70
108
  // Extract user-only texts, filter, then batch-classify in one API call
71
109
  const textsWithIndex = [];
72
110
  for (let i = 0; i < entries.length; i++) {
@@ -79,10 +117,19 @@ async function handleMemoryStop(input) {
79
117
  }
80
118
  if (textsWithIndex.length === 0) {
81
119
  (0, shared_1.hookLog)('memory-stop', 'No classifiable text in transcript entries');
82
- // Still scan for citations — assistant messages may contain them
120
+ // Still scan for citations — assistant messages may contain them.
121
+ // No episode was created yet, so this early return leaves no dangling row.
83
122
  scanForCitations(transcriptPath);
84
123
  return;
85
124
  }
125
+ // Create an episode for this session — AFTER the early returns so the
126
+ // frequent no-classifiable-text path doesn't leave permanently
127
+ // outcome-less episode rows.
128
+ const episodeId = outcomeStorage.createEpisode({
129
+ project_id: projectId,
130
+ session_id: input?.session_id,
131
+ source: 'memory-stop',
132
+ });
86
133
  const results = await (0, shared_1.classifyBatch)(textsWithIndex.map((t) => t.text));
87
134
  let stored = 0;
88
135
  for (let i = 0; i < results.length; i++) {
@@ -156,7 +203,7 @@ async function handleMemoryStop(input) {
156
203
  outcome_summary: `${stored} memories, ${allFailures.length} failures (${toolFailures.length} from tool events)`,
157
204
  });
158
205
  // Generate candidate lessons from high-confidence failures
159
- generateCandidateLessons(allFailures, episodeId, projectId);
206
+ await generateCandidateLessons(allFailures, episodeId, projectId);
160
207
  // Run promotion cycle
161
208
  try {
162
209
  const { PromotionEngine } = await Promise.resolve().then(() => __importStar(require('../services/promotion-engine')));
@@ -241,7 +288,7 @@ function scanForCitations(transcriptPath) {
241
288
  try {
242
289
  outcome_storage_1.OutcomeStorage.getInstance().recordHelpful(bestKey);
243
290
  }
244
- catch { }
291
+ catch { /* best-effort — ignore */ }
245
292
  (0, shared_1.hookLog)('memory-stop', `Citation matched: "${cite.substring(0, 50)}" → rule ${bestKey} (containment=${bestScore.toFixed(3)})`);
246
293
  }
247
294
  else {
@@ -304,7 +351,7 @@ function extractRuleContent(value) {
304
351
  /**
305
352
  * Scan the last 200 transcript entries for failure signals and store up to 3.
306
353
  */
307
- function detectAndStoreFailures(transcriptPath, episodeId) {
354
+ function detectAndStoreFailures(transcriptPath, _episodeId) {
308
355
  try {
309
356
  const entries = (0, shared_1.readTranscriptTail)(transcriptPath, 200);
310
357
  if (entries.length === 0) {
@@ -378,17 +425,42 @@ function getToolFailureEvents(outcomeStorage) {
378
425
  return [];
379
426
  }
380
427
  }
428
+ /** lesson_kind values extractHindsightHint may legitimately return. */
429
+ const VALID_LESSON_KINDS = new Set([
430
+ 'rule', 'preference', 'anti_pattern', 'workflow', 'debug_fix', 'failure_preventer',
431
+ ]);
381
432
  /**
382
433
  * Generate candidate lessons from high-confidence failures.
383
434
  * Deduplicates against existing lessons and increments evidence count for similar ones.
435
+ *
436
+ * The lesson text must be failure-specific. Detectors emit a constant
437
+ * what_should_do ("Check command syntax..."), so using it verbatim made every
438
+ * unrelated failure "similar" to every other — evidence counts inflated across
439
+ * unrelated failures and the promotion engine could only ever promote generic
440
+ * boilerplate. Prefer an LLM hindsight hint; without one, ground the generic
441
+ * remedy in what actually failed so similarity matching compares failures,
442
+ * not the shared remedy string.
384
443
  */
385
- function generateCandidateLessons(failures, episodeId, projectId) {
444
+ async function generateCandidateLessons(failures, episodeId, projectId) {
386
445
  try {
387
446
  const outcomeStorage = outcome_storage_1.OutcomeStorage.getInstance();
388
447
  for (const f of failures) {
389
448
  if (f.confidence < 0.7)
390
449
  continue;
391
- const similar = outcomeStorage.findSimilarLessons(f.content.what_should_do, projectId);
450
+ let lessonText = `${f.content.what_should_do} (failure: ${f.content.what_failed})`;
451
+ let lessonKind = 'failure_preventer';
452
+ let appliesWhen = extractTagsFromContext(f.content.context);
453
+ const hint = await (0, llm_classifier_1.extractHindsightHint)(`${f.content.what_failed}${f.content.why_failed ? ` — ${f.content.why_failed}` : ''}`, f.content.context || '');
454
+ if (hint) {
455
+ lessonText = hint.hint_text;
456
+ if (VALID_LESSON_KINDS.has(hint.hint_kind)) {
457
+ lessonKind = hint.hint_kind;
458
+ }
459
+ if (hint.applies_when.length > 0) {
460
+ appliesWhen = hint.applies_when;
461
+ }
462
+ }
463
+ const similar = outcomeStorage.findSimilarLessons(lessonText, projectId);
392
464
  if (similar.length > 0) {
393
465
  outcomeStorage.incrementEvidenceCount(similar[0].id);
394
466
  }
@@ -396,9 +468,9 @@ function generateCandidateLessons(failures, episodeId, projectId) {
396
468
  outcomeStorage.createCandidateLesson({
397
469
  project_id: projectId,
398
470
  episode_id: episodeId,
399
- lesson_text: f.content.what_should_do,
400
- lesson_kind: 'failure_preventer',
401
- applies_when: extractTagsFromContext(f.content.context),
471
+ lesson_text: lessonText,
472
+ lesson_kind: lessonKind,
473
+ applies_when: appliesWhen,
402
474
  outcome_type: 'negative',
403
475
  reward_band: -1,
404
476
  confidence: f.confidence,
@@ -39,7 +39,7 @@ function formatRules(rules) {
39
39
  }
40
40
  return sections.join('\n\n');
41
41
  }
42
- async function handlePostCompactReload(input) {
42
+ async function handlePostCompactReload(_input) {
43
43
  try {
44
44
  const projectId = config_1.ConfigService.getInstance().getProjectId();
45
45
  const rules = memory_1.MemoryService.getInstance().loadActiveRules(projectId);
@@ -41,6 +41,8 @@ exports.jaccardSimilarity = jaccardSimilarity;
41
41
  exports.isDuplicate = isDuplicate;
42
42
  exports.storeMemory = storeMemory;
43
43
  exports.searchExisting = searchExisting;
44
+ exports.claudeRecallDir = claudeRecallDir;
45
+ exports.hookStateDir = hookStateDir;
44
46
  exports.safeErrorMessage = safeErrorMessage;
45
47
  exports.hookLog = hookLog;
46
48
  exports.readTranscriptTail = readTranscriptTail;
@@ -194,6 +196,24 @@ function searchExisting(query) {
194
196
  const memoryService = memory_1.MemoryService.getInstance();
195
197
  return memoryService.search(query);
196
198
  }
199
+ /**
200
+ * Base data directory — same as the database. The env override keeps tests
201
+ * (and custom setups) away from the real ~/.claude-recall.
202
+ */
203
+ function claudeRecallDir() {
204
+ return process.env.CLAUDE_RECALL_DB_PATH || path.join(os.homedir(), '.claude-recall');
205
+ }
206
+ /**
207
+ * Directory for per-session hook state files (pending failures, debounce
208
+ * markers). Created on demand.
209
+ */
210
+ function hookStateDir() {
211
+ const dir = path.join(claudeRecallDir(), 'hook-state');
212
+ if (!fs.existsSync(dir)) {
213
+ fs.mkdirSync(dir, { recursive: true });
214
+ }
215
+ return dir;
216
+ }
197
217
  /**
198
218
  * Extract a safe error message without exposing stack traces or internal paths.
199
219
  */
@@ -209,7 +229,7 @@ function safeErrorMessage(err) {
209
229
  */
210
230
  function hookLog(hookName, message) {
211
231
  try {
212
- const logDir = path.join(os.homedir(), '.claude-recall', 'hook-logs');
232
+ const logDir = path.join(claudeRecallDir(), 'hook-logs');
213
233
  if (!fs.existsSync(logDir)) {
214
234
  fs.mkdirSync(logDir, { recursive: true });
215
235
  }
@@ -53,7 +53,6 @@ exports.shouldCaptureFailure = shouldCaptureFailure;
53
53
  exports.handleToolFailure = handleToolFailure;
54
54
  const fs = __importStar(require("fs"));
55
55
  const path = __importStar(require("path"));
56
- const os = __importStar(require("os"));
57
56
  const shared_1 = require("./shared");
58
57
  const memory_1 = require("../services/memory");
59
58
  const outcome_storage_1 = require("../services/outcome-storage");
@@ -84,7 +83,7 @@ const MCP_ERROR_PATTERNS = [
84
83
  ];
85
84
  // --- State management (Bash fix pairing only) ---
86
85
  function getStateDir() {
87
- return path.join(os.homedir(), '.claude-recall', 'hook-state');
86
+ return (0, shared_1.hookStateDir)();
88
87
  }
89
88
  function getStatePath(sessionId) {
90
89
  return path.join(getStateDir(), `${sessionId}-failures.json`);
@@ -306,8 +305,10 @@ async function handleBashSuccess(command, sessionId) {
306
305
  if (!matched && (0, shared_1.jaccardSimilarity)(pf.command, command) >= FIX_JACCARD_THRESHOLD) {
307
306
  try {
308
307
  const memoryService = memory_1.MemoryService.getInstance();
309
- memoryService.update(pf.memoryKey, {
310
- value: { what_should_do: `Fix: ${truncate(command, 200)}` },
308
+ // Merge, don't replace — the failure memory's what_failed/why_failed
309
+ // fields are the counterfactual context the fix enriches
310
+ memoryService.mergeIntoValue(pf.memoryKey, {
311
+ what_should_do: `Fix: ${truncate(command, 200)}`,
311
312
  });
312
313
  recordOutcomeEvent('Bash', { command }, `Success after previous failure: ${truncate(pf.command, 100)}`, 0);
313
314
  (0, shared_1.hookLog)(HOOK_NAME, `Paired fix: "${truncate(command, 60)}" → ${pf.memoryKey}`);
@@ -330,7 +331,6 @@ async function handleWriteToolOutcome(input) {
330
331
  const output = input.tool_output ?? '';
331
332
  const toolName = input.tool_name;
332
333
  const filePath = input.tool_input?.file_path ?? '';
333
- const sessionId = input.session_id ?? 'unknown';
334
334
  // Check for error patterns
335
335
  const errorMatch = WRITE_ERROR_PATTERNS.find(p => p.test(output));
336
336
  if (errorMatch) {
@@ -365,7 +365,6 @@ async function handleWriteToolOutcome(input) {
365
365
  async function handleMcpToolOutcome(input) {
366
366
  const output = input.tool_output ?? '';
367
367
  const toolName = input.tool_name;
368
- const sessionId = input.session_id ?? 'unknown';
369
368
  // Skip Claude Recall's own tools to avoid self-referential loops
370
369
  if (toolName.includes('claude-recall') || toolName.includes('claude_recall'))
371
370
  return;
@@ -14,7 +14,11 @@ class PromptsHandler {
14
14
  constructor() {
15
15
  this.logger = logging_1.LoggingService.getInstance();
16
16
  this.memoryService = memory_1.MemoryService.getInstance();
17
- this.memoryStorage = this.memoryService.storage;
17
+ this.memoryStorage = this.memoryService.getStorage();
18
+ }
19
+ /** Build a spec-conformant text message. */
20
+ textMessage(text) {
21
+ return { role: 'user', content: { type: 'text', text } };
18
22
  }
19
23
  /**
20
24
  * Handle prompts/list request
@@ -166,14 +170,11 @@ class PromptsHandler {
166
170
  return {
167
171
  description: 'User coding preferences automatically injected',
168
172
  messages: [
169
- {
170
- role: 'system',
171
- content: `# User Coding Preferences
173
+ this.textMessage(`# User Coding Preferences
172
174
 
173
175
  ${preferencesText}
174
176
 
175
- Apply these preferences when generating code or making suggestions.`
176
- }
177
+ Apply these preferences when generating code or making suggestions.`)
177
178
  ]
178
179
  };
179
180
  }
@@ -195,14 +196,11 @@ Apply these preferences when generating code or making suggestions.`
195
196
  ? `Project knowledge about ${topic}`
196
197
  : 'All project knowledge',
197
198
  messages: [
198
- {
199
- role: 'system',
200
- content: `# Project Knowledge
199
+ this.textMessage(`# Project Knowledge
201
200
 
202
201
  ${contextText}
203
202
 
204
- Use this information when working with the project.`
205
- }
203
+ Use this information when working with the project.`)
206
204
  ]
207
205
  };
208
206
  }
@@ -220,14 +218,11 @@ Use this information when working with the project.`
220
218
  return {
221
219
  description: 'Recent correction patterns to avoid mistakes',
222
220
  messages: [
223
- {
224
- role: 'system',
225
- content: `# Correction Patterns
221
+ this.textMessage(`# Correction Patterns
226
222
 
227
223
  ${correctionsText}
228
224
 
229
- Avoid these patterns when generating code.`
230
- }
225
+ Avoid these patterns when generating code.`)
231
226
  ]
232
227
  };
233
228
  }
@@ -247,14 +242,11 @@ Avoid these patterns when generating code.`
247
242
  return {
248
243
  description: `Full context for: ${task}`,
249
244
  messages: [
250
- {
251
- role: 'system',
252
- content: `# Relevant Context
245
+ this.textMessage(`# Relevant Context
253
246
 
254
247
  ${contextText}
255
248
 
256
- Use this context when working on: ${task}`
257
- }
249
+ Use this context when working on: ${task}`)
258
250
  ]
259
251
  };
260
252
  }
@@ -270,9 +262,7 @@ Use this context when working on: ${task}`
270
262
  return {
271
263
  description: 'Analyze conversation for preference extraction',
272
264
  messages: [
273
- {
274
- role: 'system',
275
- content: `You are analyzing a conversation to extract user coding preferences.
265
+ this.textMessage(`You are analyzing a conversation to extract user coding preferences.
276
266
 
277
267
  Extract any preferences about:
278
268
  - Programming languages and frameworks
@@ -293,12 +283,8 @@ Return a JSON array with format:
293
283
  }
294
284
  ]
295
285
 
296
- Be conservative - only extract clear, explicit preferences.`
297
- },
298
- {
299
- role: 'user',
300
- content: `Analyze this conversation for preferences:\n\n${conversation}`
301
- }
286
+ Be conservative - only extract clear, explicit preferences.`),
287
+ this.textMessage(`Analyze this conversation for preferences:\n\n${conversation}`)
302
288
  ]
303
289
  };
304
290
  }
@@ -468,20 +454,17 @@ Be conservative - only extract clear, explicit preferences.`
468
454
  return {
469
455
  description: topic ? `Active rules about ${topic}` : `All active rules (${totalRules} total)`,
470
456
  messages: [
471
- {
472
- role: 'system',
473
- content: body
474
- ? `# Active Rules\n\nApply these rules when working on this project.\n\n${body}`
475
- : 'No active rules found. This may be a new project.'
476
- }
457
+ this.textMessage(body
458
+ ? `# Active Rules\n\nApply these rules when working on this project.\n\n${body}`
459
+ : 'No active rules found. This may be a new project.')
477
460
  ]
478
461
  };
479
462
  }
480
463
  /**
481
464
  * Get session-review prompt — summarizes session outcomes and lessons
482
465
  */
483
- async getSessionReviewPrompt(sessionId) {
484
- let sections = [];
466
+ async getSessionReviewPrompt(_sessionId) {
467
+ const sections = [];
485
468
  try {
486
469
  const outcomeStorage = outcome_storage_1.OutcomeStorage.getInstance();
487
470
  const projectId = config_1.ConfigService.getInstance().getProjectId();
@@ -508,12 +491,9 @@ Be conservative - only extract clear, explicit preferences.`
508
491
  return {
509
492
  description: 'Session outcome review and lessons learned',
510
493
  messages: [
511
- {
512
- role: 'system',
513
- content: sections.length > 0
514
- ? `# Session Review\n\n${sections.join('\n\n')}`
515
- : '# Session Review\n\nNo outcome data available yet.'
516
- }
494
+ this.textMessage(sections.length > 0
495
+ ? `# Session Review\n\n${sections.join('\n\n')}`
496
+ : '# Session Review\n\nNo outcome data available yet.')
517
497
  ]
518
498
  };
519
499
  }
@@ -524,10 +504,7 @@ Be conservative - only extract clear, explicit preferences.`
524
504
  return {
525
505
  description: 'Error',
526
506
  messages: [
527
- {
528
- role: 'system',
529
- content: `Error: ${message}`
530
- }
507
+ this.textMessage(`Error: ${message}`)
531
508
  ]
532
509
  };
533
510
  }
@@ -13,7 +13,7 @@ class ResourcesHandler {
13
13
  constructor() {
14
14
  this.logger = logging_1.LoggingService.getInstance();
15
15
  this.memoryService = memory_1.MemoryService.getInstance();
16
- this.memoryStorage = this.memoryService.storage;
16
+ this.memoryStorage = this.memoryService.getStorage();
17
17
  }
18
18
  /**
19
19
  * Handle resources/list request