claude-mem-lite 2.5.4 → 2.9.2

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/dispatch.mjs CHANGED
@@ -1,18 +1,17 @@
1
- // claude-mem-lite: Dispatch orchestration — 3-tier intelligent resource dispatch
1
+ // claude-mem-lite: Dispatch orchestration — 2-tier intelligent resource dispatch
2
2
  // Tier 0: Local fast filter (<1ms)
3
3
  // Tier 1: Context signal extraction (<1ms)
4
4
  // Tier 2: Enhanced FTS5 retrieval (<5ms)
5
- // Tier 3: Haiku semantic dispatch (~500ms, only when needed)
6
5
 
7
6
  import { basename, join } from 'path';
8
- import { existsSync, readFileSync, writeFileSync } from 'fs';
7
+ import { existsSync } from 'fs';
9
8
  import { retrieveResources, buildEnhancedQuery, buildQueryFromText, DISPATCH_SYNONYMS } from './registry-retriever.mjs';
10
- import { renderInjection } from './dispatch-inject.mjs';
9
+ import { renderInjection, renderHint } from './dispatch-inject.mjs';
11
10
  import { updateResourceStats, recordInvocation } from './registry.mjs';
12
- import { callHaikuJSON } from './haiku-client.mjs';
13
- import { debugCatch, truncate } from './utils.mjs';
14
- import { peekToolEvents, RUNTIME_DIR } from './hook-shared.mjs';
11
+ import { debugCatch } from './utils.mjs';
12
+ import { peekToolEvents } from './hook-shared.mjs';
15
13
  import { detectActiveSuite, shouldRecommendForStage, detectExplicitRequest, inferCurrentStage } from './dispatch-workflow.mjs';
14
+ import { detectFailurePattern } from './dispatch-patterns.mjs';
16
15
 
17
16
  // ─── Constants ───────────────────────────────────────────────────────────────
18
17
 
@@ -29,74 +28,6 @@ export const SESSION_RECOMMEND_CAP = 3;
29
28
  // this filters only near-zero noise matches from incidental text overlap.
30
29
  export const BM25_MIN_THRESHOLD = 1.5;
31
30
 
32
- // Minimum confidence from Haiku semantic dispatch to replace FTS5 results.
33
- // Prevents low-confidence Haiku queries (e.g. 0.2) from overriding good FTS5 matches.
34
- export const HAIKU_CONFIDENCE_THRESHOLD = 0.6;
35
-
36
- // ─── Haiku Circuit Breaker ──────────────────────────────────────────────────
37
- // Prevents cascading latency when Haiku API is down or slow.
38
- // After BREAKER_THRESHOLD consecutive failures, disable for BREAKER_RESET_MS.
39
- // KNOWN LIMITATION: File-based state has a TOCTOU race under concurrent hook
40
- // processes. Worst case: breaker trips on failure N+1 instead of N. This is
41
- // acceptable — the breaker is a latency guard, not a correctness mechanism.
42
-
43
- const BREAKER_THRESHOLD = 3;
44
- const BREAKER_RESET_MS = 5 * 60 * 1000; // 5 minutes
45
- let breakerFile = join(RUNTIME_DIR, 'haiku-breaker.json');
46
-
47
- function _readBreakerState() {
48
- try {
49
- if (!existsSync(breakerFile)) return { failures: 0, openUntil: 0 };
50
- return JSON.parse(readFileSync(breakerFile, 'utf8'));
51
- } catch { return { failures: 0, openUntil: 0 }; }
52
- }
53
-
54
- function _writeBreakerState(state) {
55
- try { writeFileSync(breakerFile, JSON.stringify(state)); } catch {}
56
- }
57
-
58
- /** Override breaker file path (for testing isolation). */
59
- export function _setBreakerFile(path) { breakerFile = path; }
60
-
61
- function isHaikuCircuitOpen() {
62
- const state = _readBreakerState();
63
- if (state.openUntil > 0 && Date.now() < state.openUntil) return true;
64
- if (state.openUntil > 0 && Date.now() >= state.openUntil) {
65
- // Half-open: single probe failure re-trips immediately
66
- _writeBreakerState({ failures: BREAKER_THRESHOLD - 1, openUntil: 0 });
67
- }
68
- return false;
69
- }
70
-
71
- function recordHaikuSuccess() {
72
- _writeBreakerState({ failures: 0, openUntil: 0 });
73
- }
74
-
75
- // NOTE: read-modify-write without file locking — concurrent hook processes may lose
76
- // one increment. Acceptable: threshold is 3, worst case trips on 4th failure instead.
77
- function recordHaikuFailure() {
78
- const state = _readBreakerState();
79
- state.failures++;
80
- if (state.failures >= BREAKER_THRESHOLD) {
81
- state.openUntil = Date.now() + BREAKER_RESET_MS;
82
- }
83
- _writeBreakerState(state);
84
- }
85
-
86
- /** Reset circuit breaker state (for testing). */
87
- export function _resetCircuitBreaker() {
88
- _writeBreakerState({ failures: 0, openUntil: 0 });
89
- }
90
-
91
- /** Simulate Haiku failure (for testing). */
92
- export function _recordHaikuFailure() { recordHaikuFailure(); }
93
-
94
- /** Simulate Haiku success (for testing). */
95
- export function _recordHaikuSuccess() { recordHaikuSuccess(); }
96
-
97
- /** Check if circuit is open (for testing). */
98
- export function _isHaikuCircuitOpen() { return isHaikuCircuitOpen(); }
99
-
100
31
  // ─── Project Domain Detection ─────────────────────────────────────────────────
101
32
 
102
33
  // Module-level cache — project dir doesn't change during a session
@@ -214,6 +145,7 @@ export function extractContextSignals(event, sessionCtx = {}) {
214
145
  techStack: '',
215
146
  action: '',
216
147
  errorDomain: '',
148
+ failurePattern: null, // detected failure pattern from session events (repeated-test-fail, etc.)
217
149
  };
218
150
 
219
151
  // Extract weighted intent from user prompt (primary intent is first element)
@@ -258,6 +190,23 @@ export function extractContextSignals(event, sessionCtx = {}) {
258
190
  }
259
191
  }
260
192
 
193
+ // Failure pattern detection: override signals when Claude is struggling
194
+ const failurePattern = sessionCtx?.sessionEvents
195
+ ? detectFailurePattern(sessionCtx.sessionEvents)
196
+ : null;
197
+
198
+ if (failurePattern) {
199
+ if (!signals.primaryIntent || failurePattern.confidence > 0.7) {
200
+ signals.primaryIntent = failurePattern.resource_intent;
201
+ if (!signals.intent.includes(failurePattern.resource_intent)) {
202
+ signals.intent = signals.intent
203
+ ? `${failurePattern.resource_intent},${signals.intent}`
204
+ : failurePattern.resource_intent;
205
+ }
206
+ }
207
+ signals.failurePattern = failurePattern;
208
+ }
209
+
261
210
  return signals;
262
211
  }
263
212
 
@@ -267,7 +216,7 @@ const NEGATION_CJK = /(?:不要|别|不用|先别|暂时不|不需要|跳过|停
267
216
 
268
217
  // Test-run vs test-write disambiguation (module-scoped for performance)
269
218
  const _RUN_TEST = /\b(run\w*\s+(?:the\s+)?tests?|npm\s+test|npx\s+(?:vitest|jest|mocha|pytest)|yarn\s+test|pnpm\s+test|make\s+test|cargo\s+test|go\s+test|check\s+(?:if\s+)?tests?\s+pass|execute\s+(?:the\s+)?tests?)\b/i;
270
- const _RUN_TEST_CJK = /(?:运行测试|跑测试|跑一下测试|跑单测|执行测试|测试跑|看测试)/;
219
+ const _RUN_TEST_CJK = /(?:运行测试|跑测试|跑一下测试|跑单测|跑一下单测|执行测试|执行单测|测试跑|看测试|看单测)/;
271
220
  const _WRITE_TEST = /\b(write\s+tests?|add\s+tests?|create\s+tests?|need\s+tests?|missing\s+tests?|tdd|test.?driven|red.?green|increase\s+coverage|improve\s+coverage)\b/i;
272
221
  const _WRITE_TEST_CJK = /(?:写测试|加测试|补测试|补单测|缺测试|测试覆盖)/;
273
222
 
@@ -310,7 +259,7 @@ const _INTENT_PATTERNS = (() => {
310
259
  // ── Chinese patterns ──
311
260
  [/(测试|写测试|单测|单元测试|用例|覆盖率)/, 'test'],
312
261
  [/(修复|修bug|改bug|找bug|有bug|调试|排错|报错|出错|有问题|不工作|跑不起来|不能用|挂了|崩溃)/, 'fix'],
313
- [/(审查|审核|代码审查|评审|代码审核|看看代码|review)/, 'review'],
262
+ [/(审查|审核|审计|代码审查|评审|代码审核|看看代码|review)/, 'review'],
314
263
  [/(提交|推送|上传)/, 'commit'],
315
264
  [/(部署|上线|发布|回滚)/, 'deploy'],
316
265
  [/(规划|架构|方案|设计方案)/, 'plan'],
@@ -324,6 +273,10 @@ const _INTENT_PATTERNS = (() => {
324
273
  [/(优化|性能|卡顿|耗时|太慢|慢死了|好慢|缓存)/, 'fast'],
325
274
  [/(格式化|代码风格|代码规范|类型检查)/, 'lint'],
326
275
  [/(界面|前端|样式|页面|组件|布局)/, 'design'],
276
+ // search: only unambiguous web/info search indicators — NOT code search (grep/find).
277
+ // "搜索" alone is ambiguous (code search vs web search), so require context modifiers.
278
+ [/(联网搜索|网上搜索|在线搜索|上网查|搜索.{0,2}最新|搜一下.{0,2}最新|查.{0,2}最新|查资料|找资料|搜索资料|搜索文档)/, 'search'],
279
+ [/\b(google|search\s+online|web\s+search|look\s+up\s+(?:the\s+)?(?:latest|newest|recent|docs?|documentation))\b/i, 'search'],
327
280
  ];
328
281
  // Pre-compile global variants for matchAll — avoids creating new RegExp on every extractIntent call
329
282
  return raw.map(([p, tag]) => [p, new RegExp(p.source, p.flags.includes('g') ? p.flags : p.flags + 'g'), tag]);
@@ -362,15 +315,19 @@ function extractIntent(prompt) {
362
315
  }
363
316
 
364
317
  const found = [];
318
+ const suppressed = [];
365
319
  for (const tag of tagMatched) {
366
320
  if (tagHasAffirmative.get(tag) && !found.includes(tag)) {
367
321
  found.push(tag);
322
+ } else if (!tagHasAffirmative.get(tag)) {
323
+ // Tag was matched but ALL instances were negated → suppress it.
324
+ // This feeds the text-fallback filter to prevent recommending negated resources.
325
+ suppressed.push(tag);
368
326
  }
369
327
  }
370
328
 
371
329
  // Distinguish test-running from test-writing: "run tests" / "npm test" / "运行测试" should NOT
372
330
  // trigger TDD recommendations. Only keep 'test' intent when the prompt implies *writing* tests.
373
- const suppressed = [];
374
331
  if (found.includes('test')) {
375
332
  const isRunning = _RUN_TEST.test(prompt) || _RUN_TEST_CJK.test(prompt);
376
333
  const isWriting = _WRITE_TEST.test(prompt) || _WRITE_TEST_CJK.test(prompt);
@@ -384,7 +341,7 @@ function extractIntent(prompt) {
384
341
  }
385
342
 
386
343
  /** Exported for testing. */
387
- export { NEGATION_EN as _NEGATION_EN, NEGATION_CJK as _NEGATION_CJK, reRankByKeywords as _reRankByKeywords, applyAdoptionDecay as _applyAdoptionDecay, passesConfidenceGate as _passesConfidenceGate };
344
+ export { NEGATION_EN as _NEGATION_EN, NEGATION_CJK as _NEGATION_CJK, reRankByKeywords as _reRankByKeywords, applyAdoptionDecay as _applyAdoptionDecay, passesConfidenceGate as _passesConfidenceGate, filterAutoLoadedSkills as _filterAutoLoadedSkills, filterGarbageMetadata as _filterGarbageMetadata, decideTier as _decideTier };
388
345
 
389
346
  // Stop words for raw keyword extraction.
390
347
  // Includes common English stop words + action verbs already covered by intent patterns.
@@ -405,7 +362,7 @@ const RAW_KW_STOP = new Set([
405
362
  'review', 'deploy', 'commit', 'push', 'plan', 'clean', 'refactor',
406
363
  'find', 'get', 'set', 'show', 'list', 'change', 'move', 'copy', 'send',
407
364
  'start', 'stop', 'open', 'close', 'save', 'load', 'install', 'setup',
408
- 'implement', 'configure', 'code', 'file', 'function', 'module', 'app',
365
+ 'implement', 'configure', 'code', 'file', 'function', 'module', 'app', 'system',
409
366
  ]);
410
367
 
411
368
  /**
@@ -528,6 +485,59 @@ function inferTechFromPrompt(prompt) {
528
485
  return [...tags].join(',');
529
486
  }
530
487
 
488
+ // ─── Phase Transition Detection ─────────────────────────────────────────────
489
+
490
+ const PHASE_TOOL_MAP = {
491
+ Read: 'EXPLORE', Glob: 'EXPLORE', Grep: 'EXPLORE', LSP: 'EXPLORE',
492
+ Edit: 'IMPLEMENT', Write: 'IMPLEMENT', NotebookEdit: 'IMPLEMENT',
493
+ };
494
+
495
+ /**
496
+ * Infer current session phase from recent tool events.
497
+ * @param {object[]} events Recent tool events
498
+ * @returns {string} Phase: EXPLORE | IMPLEMENT | DEBUG | TEST | COMMIT
499
+ */
500
+ export function inferSessionPhase(events) {
501
+ if (!events || events.length === 0) return 'EXPLORE';
502
+
503
+ // Look at last 5 events, filter to significant ones (skip Read-only)
504
+ const recent = events.slice(-5);
505
+ const lastSignificant = recent.filter(e =>
506
+ e.tool_name !== 'Read' && e.tool_name !== 'Glob' && e.tool_name !== 'Grep'
507
+ ).slice(-3);
508
+
509
+ if (lastSignificant.length === 0) return 'EXPLORE';
510
+
511
+ const last = lastSignificant[lastSignificant.length - 1];
512
+
513
+ if (last.tool_name === 'Bash') {
514
+ const cmd = (last.tool_input?.command || '').toLowerCase();
515
+ const resp = (last.tool_response || '');
516
+ if (/\bgit\s+(commit|push|merge|tag)\b/.test(cmd)) return 'COMMIT';
517
+ if (/\b(test|jest|vitest|pytest|mocha)\b/.test(cmd)) return 'TEST';
518
+ if (/error|fail|exception/i.test(resp) && resp.length > 30) return 'DEBUG';
519
+ return 'IMPLEMENT';
520
+ }
521
+
522
+ return PHASE_TOOL_MAP[last.tool_name] || 'IMPLEMENT';
523
+ }
524
+
525
+ /**
526
+ * Check if a phase transition occurred.
527
+ * @param {string|null} prev Previous phase
528
+ * @param {string} current Current phase
529
+ * @returns {boolean}
530
+ */
531
+ export function isPhaseTransition(prev, current) {
532
+ return prev !== null && prev !== current;
533
+ }
534
+
535
+ // Module-level phase state for dispatchOnPreToolUse
536
+ let _lastPhase = null;
537
+
538
+ /** Reset phase state (for testing). */
539
+ export function _resetPhaseState() { _lastPhase = null; }
540
+
531
541
  /**
532
542
  * Infer action type from tool name and input.
533
543
  * @param {string} toolName Claude Code tool name (e.g. "Bash", "Edit")
@@ -575,76 +585,6 @@ function extractErrorDomain(cmd, response) {
575
585
  return 'error';
576
586
  }
577
587
 
578
- // ─── Tier 3: Haiku Semantic Dispatch ─────────────────────────────────────────
579
-
580
- /**
581
- * Check if Haiku dispatch is needed based on FTS5 results.
582
- * Uses relative confidence: top result must be strong enough relative to result set,
583
- * and gap between top two must be decisive.
584
- * @param {object[]} results FTS5 results with relevance scores
585
- * @returns {boolean} true if Haiku should be called
586
- */
587
- export function needsHaikuDispatch(results) {
588
- // Circuit breaker: if Haiku is tripped, never escalate (regardless of result quality)
589
- if (isHaikuCircuitOpen()) return false;
590
-
591
- if (results.length === 0) return true;
592
-
593
- // Prefer composite_score (includes behavioral signals) over raw BM25 relevance.
594
- // Both are negative (more negative = better). Use absolute values for comparison.
595
- const scoreOf = r => Math.abs(r.composite_score ?? r.relevance);
596
- const topScore = scoreOf(results[0]);
597
-
598
- // Relative threshold: if only one result or few results, use absolute minimum
599
- // For larger result sets, use mean-relative threshold
600
- if (results.length === 1) {
601
- // Single result: needs at least moderate relevance
602
- return topScore < 2.0;
603
- }
604
-
605
- // Compute mean relevance across results
606
- const meanScore = results.reduce((sum, r) => sum + scoreOf(r), 0) / results.length;
607
-
608
- // Top result should be significantly above mean (at least 1.5x)
609
- if (topScore < meanScore * 1.5 && topScore < 3.0) return true;
610
-
611
- // Top two results too close → ambiguous, need Haiku to disambiguate
612
- if (results.length > 1) {
613
- const gap = topScore - scoreOf(results[1]);
614
- // Gap should be at least 10% of top score, or at least 0.5 absolute
615
- if (gap < Math.max(topScore * 0.1, 0.5)) return true;
616
- }
617
-
618
- return false;
619
- }
620
-
621
- /**
622
- * Call Haiku LLM to semantically resolve the best resource query.
623
- * @param {string} userPrompt User's prompt text
624
- * @param {string} toolContext Current tool action context
625
- * @returns {Promise<{query: string, type: string, confidence: number}|null>} Haiku result or null
626
- */
627
- async function haikuDispatch(userPrompt, toolContext) {
628
- // Circuit breaker: skip if Haiku is tripped
629
- if (isHaikuCircuitOpen()) return null;
630
-
631
- const prompt = `Given this coding context, which resource (skill or agent) would be most helpful?
632
- Return ONLY valid JSON.
633
-
634
- User intent: ${truncate(userPrompt || '', 200)}
635
- Current action: ${truncate(toolContext || '', 200)}
636
-
637
- JSON: {"query":"search keywords for finding the right skill or agent","type":"skill|agent|either","confidence":0.0-1.0}`;
638
-
639
- const result = await callHaikuJSON(prompt, { timeout: 3000, maxTokens: 100 });
640
- if (result) {
641
- recordHaikuSuccess();
642
- } else {
643
- recordHaikuFailure();
644
- }
645
- return result;
646
- }
647
-
648
588
  // ─── Cooldown & Dedup (DB-persisted, survives process restarts) ─────────────
649
589
 
650
590
  /**
@@ -686,31 +626,93 @@ function getAdaptiveCooldown(db) {
686
626
  } catch { return COOLDOWN_MINUTES; }
687
627
  }
688
628
 
689
- const CONSECUTIVE_REJECT_THRESHOLD = 5;
629
+ /**
630
+ * Compute per-resource cooldown based on its individual adoption rate.
631
+ * High-adoption resources (like code-review-expert at 90%) get shorter cooldown,
632
+ * ensuring valuable resources are recommended more frequently.
633
+ * @param {Database} db Registry database
634
+ * @param {number} resourceId Resource ID
635
+ * @param {number} globalCd Global adaptive cooldown (fallback)
636
+ * @returns {number} Cooldown in minutes for this specific resource
637
+ */
638
+ function getPerResourceCooldown(db, resourceId, globalCd) {
639
+ try {
640
+ const stats = db.prepare(
641
+ 'SELECT recommend_count, adopt_count FROM resources WHERE id = ?'
642
+ ).get(resourceId);
643
+ if (!stats || stats.recommend_count < 5) return globalCd; // Not enough data
644
+ const rate = (stats.adopt_count + 1) / (stats.recommend_count + 2); // Laplace smoothed
645
+ if (rate > 0.5) return Math.min(globalCd, 15); // Very high adoption: 15 min
646
+ if (rate > 0.3) return Math.min(globalCd, 30); // High adoption: 30 min
647
+ return globalCd; // Default: use global cooldown
648
+ } catch { return globalCd; }
649
+ }
650
+
651
+ const CONSECUTIVE_REJECT_THRESHOLD = 8;
690
652
  const CONSECUTIVE_REJECT_WINDOW_DAYS = 7;
653
+ const BASE_COOLDOWN_HOURS = 1;
654
+ const MAX_COOLDOWN_HOURS = 256; // ~10.7 days cap
655
+ const COOLDOWN_RESET_DAYS = 7; // Reset backoff if no recommendation in 7 days
691
656
 
692
657
  /**
693
658
  * Check if a resource has been consecutively rejected (not adopted) in recent history.
659
+ * Uses exponential backoff instead of binary 30-day silence:
660
+ * 1h → 2h → 4h → 8h → ... → 256h (cap)
661
+ * Backoff resets after COOLDOWN_RESET_DAYS of no recommendations.
662
+ *
694
663
  * @param {Database} db Registry database
695
664
  * @param {number} resourceId Resource ID
696
665
  * @returns {boolean} true if resource should be silenced
697
666
  */
698
667
  function isConsecutivelyRejected(db, resourceId) {
699
668
  try {
669
+ // Check active silence first (most efficient)
670
+ const res = db.prepare(
671
+ `SELECT silenced_until, cooldown_hours FROM resources WHERE id = ?`
672
+ ).get(resourceId);
673
+ if (!res) return false;
674
+ if (res.silenced_until && new Date(res.silenced_until) > new Date()) return true;
675
+
676
+ // Reset backoff if no recommendation in COOLDOWN_RESET_DAYS
677
+ const lastRec = db.prepare(
678
+ `SELECT created_at FROM invocations WHERE resource_id = ? AND recommended = 1 ORDER BY created_at DESC LIMIT 1`
679
+ ).get(resourceId);
680
+ if (lastRec) {
681
+ const daysSince = (Date.now() - new Date(lastRec.created_at).getTime()) / 86400000;
682
+ if (daysSince > COOLDOWN_RESET_DAYS && (res.cooldown_hours || 0) > 0) {
683
+ db.prepare('UPDATE resources SET cooldown_hours = 0, silenced_until = NULL WHERE id = ?').run(resourceId);
684
+ return false;
685
+ }
686
+ }
687
+
700
688
  const recent = db.prepare(`
701
689
  SELECT adopted FROM invocations
702
- WHERE resource_id = ? AND recommended = 1 AND outcome IS NOT NULL
690
+ WHERE resource_id = ? AND recommended = 1
703
691
  AND created_at > datetime('now', ?)
704
692
  ORDER BY created_at DESC
705
693
  LIMIT ?
706
694
  `).all(resourceId, `-${CONSECUTIVE_REJECT_WINDOW_DAYS} days`, CONSECUTIVE_REJECT_THRESHOLD);
707
695
 
708
696
  if (recent.length < CONSECUTIVE_REJECT_THRESHOLD) return false;
709
- return recent.every(r => r.adopted === 0);
697
+ if (!recent.every(r => r.adopted === 0)) return false;
698
+
699
+ // Exponential backoff: double cooldown each cycle (or start at base)
700
+ const currentHours = res.cooldown_hours || 0;
701
+ const nextHours = Math.min(
702
+ currentHours === 0 ? BASE_COOLDOWN_HOURS : currentHours * 2,
703
+ MAX_COOLDOWN_HOURS
704
+ );
705
+
706
+ try {
707
+ db.prepare(
708
+ `UPDATE resources SET silenced_until = datetime('now', '+${nextHours} hours'), cooldown_hours = ? WHERE id = ?`
709
+ ).run(nextHours, resourceId);
710
+ } catch { /* best-effort */ }
711
+ return true;
710
712
  } catch { return false; }
711
713
  }
712
714
 
713
- export function isRecentlyRecommended(db, resourceId, sessionId, { skipCapCheck = false } = {}) {
715
+ export function isRecentlyRecommended(db, resourceId, sessionId, { skipCapCheck = false, cooldown } = {}) {
714
716
  // Check 1: Session cap (loop-invariant — callers should hoist isSessionCapped and pass skipCapCheck: true)
715
717
  if (sessionId && !skipCapCheck) {
716
718
  if (isSessionCapped(db, sessionId)) return true;
@@ -726,10 +728,12 @@ export function isRecentlyRecommended(db, resourceId, sessionId, { skipCapCheck
726
728
  if (isConsecutivelyRejected(db, resourceId)) return true;
727
729
 
728
730
  // Check 4: Recommended within adaptive cooldown window (cross-session cooldown)
729
- const cooldown = getAdaptiveCooldown(db);
731
+ // Per-resource cooldown: high-adoption resources get shorter cooldown
732
+ const globalCd = cooldown ?? getAdaptiveCooldown(db);
733
+ const resourceCd = getPerResourceCooldown(db, resourceId, globalCd);
730
734
  const cooldownHit = db.prepare(
731
735
  `SELECT 1 FROM invocations WHERE resource_id = ? AND created_at > datetime('now', ?) LIMIT 1`
732
- ).get(resourceId, `-${cooldown} minutes`);
736
+ ).get(resourceId, `-${resourceCd} minutes`);
733
737
  return !!cooldownHit;
734
738
  }
735
739
 
@@ -814,14 +818,39 @@ function applyAdoptionDecay(results, db) {
814
818
  */
815
819
  function passesConfidenceGate(results, signals) {
816
820
  // BM25 absolute minimum: filter weak text matches.
817
- // Stricter threshold for 3+ results (reliable IDF); gentler floor for 1-2 results.
818
- const minThreshold = results.length >= 3 ? BM25_MIN_THRESHOLD : 0.5;
821
+ // Threshold is relative to the top result's score to handle varying corpus sizes:
822
+ // small corpora (< 50 resources) naturally produce lower BM25 IDF values,
823
+ // so an absolute threshold would over-filter genuine matches.
824
+ const baseThreshold = results.length >= 3 ? BM25_MIN_THRESHOLD : 0.5;
825
+ const topScore = results.length > 0 ? Math.abs(results[0].composite_score ?? results[0].relevance ?? 0) : 0;
826
+ // Use the lower of: absolute threshold OR 30% of top score (corpus-size-adaptive floor)
827
+ const minThreshold = topScore > 0 ? Math.min(baseThreshold, topScore * 0.3) : baseThreshold;
819
828
  results = results.filter(r => {
820
829
  const raw = r.composite_score ?? r.relevance;
821
830
  if (raw === null || raw === undefined) return true; // no score → pass (pre-scored or synthetic result)
822
831
  return Math.abs(raw) >= minThreshold;
823
832
  });
824
833
 
834
+ // Gap check: if top-2 results are too close in score, the query is ambiguous.
835
+ // This prevents recommending when multiple resources match equally well,
836
+ // which usually means the match is incidental rather than precise.
837
+ // Skip the gap check when rawKeywords promoted #1 (keyword re-ranking changes order,
838
+ // so the BM25 gap no longer reflects true relevance — the keyword match is extra signal).
839
+ if (results.length >= 2) {
840
+ const top1 = Math.abs(results[0].composite_score ?? results[0].relevance ?? 0);
841
+ const top2 = Math.abs(results[1].composite_score ?? results[1].relevance ?? 0);
842
+ // After keyword re-ranking, #1 may have lower raw BM25 than #2.
843
+ // The keyword match provides additional confidence, so skip the gap check.
844
+ const wasReRanked = signals?.rawKeywords?.length > 0 && top1 < top2;
845
+ if (!wasReRanked && top1 > 0) {
846
+ const gapRatio = (top1 - top2) / top1;
847
+ if (gapRatio < 0.2) {
848
+ // Top-1 has no clear lead — ambiguous match, suppress recommendation
849
+ return [];
850
+ }
851
+ }
852
+ }
853
+
825
854
  // signals.intent is a comma-separated string (e.g. "test,fix"), not an array
826
855
  const intentTokens = typeof signals?.intent === 'string'
827
856
  ? signals.intent.split(',').filter(Boolean)
@@ -830,38 +859,165 @@ function passesConfidenceGate(results, signals) {
830
859
  // No structured intent → skip gate (rawKeywords match FTS5 text columns, not intent_tags)
831
860
  if (intentTokens.length === 0) return results;
832
861
 
833
- // Expand intent tokens through DISPATCH_SYNONYMS so "fast" also matches "performance", etc.
834
- const rawKw = signals?.rawKeywords || [];
835
- const intentSet = new Set([...intentTokens, ...rawKw]);
862
+ // Expand ALL intent tokens through DISPATCH_SYNONYMS.
863
+ // rawKeywords are excluded from intentSet — they contribute to FTS5 scoring
864
+ // but must NOT bypass the intent gate. Including them caused false positives
865
+ // (e.g. "debug the dispatch system" → llm-router matched on "dispatch" tag).
866
+ const intentSet = new Set(intentTokens);
836
867
  for (const token of intentTokens) {
837
868
  const syns = DISPATCH_SYNONYMS[token];
838
869
  if (syns) for (const s of syns) intentSet.add(s);
839
870
  }
840
871
 
841
- return results.filter(r => {
872
+ // Filter: resource must match at least one intent
873
+ const passing = results.filter(r => {
842
874
  const tags = (r.intent_tags || '').toLowerCase().split(/[\s,]+/).filter(Boolean);
843
875
  return tags.some(t => intentSet.has(t));
844
876
  });
877
+
878
+ // Primary intent preference: when multiple intents extracted (e.g. "fix,commit"),
879
+ // prefer resources matching the primary intent to avoid false positives from
880
+ // incidental context (e.g. recommending git-workflow when user primarily wants to debug).
881
+ if (intentTokens.length > 1 && passing.length > 1) {
882
+ const primaryIntent = signals?.primaryIntent || intentTokens[0] || '';
883
+ const primarySet = new Set([primaryIntent]);
884
+ const primarySyns = DISPATCH_SYNONYMS[primaryIntent];
885
+ if (primarySyns) for (const s of primarySyns) primarySet.add(s);
886
+
887
+ const primaryMatches = passing.filter(r => {
888
+ const tags = (r.intent_tags || '').toLowerCase().split(/[\s,]+/).filter(Boolean);
889
+ return tags.some(t => primarySet.has(t));
890
+ });
891
+ if (primaryMatches.length > 0) return primaryMatches;
892
+ }
893
+
894
+ return passing;
895
+ }
896
+
897
+ // ─── Auto-loaded Skill Filter ────────────────────────────────────────────────
898
+
899
+ /**
900
+ * Filter out skills that are auto-loaded via plugin hooks (listed in system-reminder).
901
+ * These skills don't need dispatch recommendations because the plugin's own hooks
902
+ * already surface them to Claude at the right moment.
903
+ *
904
+ * User-installed standalone skills (non-namespaced invocation_name like "build-error-resolver")
905
+ * are KEPT — users may not remember to invoke them at the right time, so contextual
906
+ * recommendations still add value (installed skills have 11.5% adoption vs 6.1% community).
907
+ *
908
+ * @param {object[]} results FTS5 results
909
+ * @returns {object[]} Filtered results — community + standalone installed skills
910
+ */
911
+ function filterAutoLoadedSkills(results) {
912
+ return results.filter(r => {
913
+ if (r.type !== 'skill') return true;
914
+ const inv = (r.invocation_name || '').trim();
915
+ if (inv === '') return true; // Community resource — always recommend
916
+ // Plugin-namespaced skills (e.g. "superpowers:systematic-debugging") are auto-loaded
917
+ // via the plugin's own hooks in system-reminder — dispatch recommendation is redundant
918
+ if (inv.includes(':')) return false;
919
+ // Standalone installed skills (e.g. "build-error-resolver") — keep for contextual recommendations
920
+ return true;
921
+ });
922
+ }
923
+
924
+ // ─── Metadata Quality Gate ──────────────────────────────────────────────────
925
+
926
+ const GARBAGE_METADATA_OVERLAP_THRESHOLD = 0.8;
927
+ const MIN_TOKEN_LENGTH = 2;
928
+
929
+ /**
930
+ * Filter out resources with auto-generated garbage metadata.
931
+ * Auto-generated metadata restates the resource name as capability_summary
932
+ * (e.g., "agent: error debugging/error detective"), causing overly broad FTS5 matches.
933
+ * @param {object[]} results FTS5 results
934
+ * @returns {object[]} Filtered results (garbage metadata removed)
935
+ */
936
+ function filterGarbageMetadata(results) {
937
+ return results.filter(r => {
938
+ const cap = (r.capability_summary || '').toLowerCase().trim();
939
+ if (!cap) return false; // No metadata at all — filter
940
+ const name = (r.name || '').toLowerCase();
941
+ // Garbage pattern: capability_summary is just "type: name" (restated name)
942
+ const nameTokens = name.replace(/[/-]/g, ' ').split(/\s+/).filter(t => t.length >= MIN_TOKEN_LENGTH);
943
+ if (nameTokens.length === 0) return true;
944
+ const capTokens = cap.replace(/[/-:]/g, ' ').split(/\s+/).filter(t => t.length >= MIN_TOKEN_LENGTH);
945
+ if (capTokens.length === 0) return true;
946
+ const overlap = capTokens.filter(t => nameTokens.includes(t)).length;
947
+ return overlap / capTokens.length < GARBAGE_METADATA_OVERLAP_THRESHOLD;
948
+ });
845
949
  }
846
950
 
847
951
  // ─── Shared Post-Processing Pipeline ────────────────────────────────────────
848
952
 
849
953
  /**
850
954
  * Standard post-processing pipeline for dispatch results.
851
- * Applies keyword re-ranking, adoption decay, confidence gating, and limit.
955
+ * Applies auto-loaded filter, metadata quality gate, keyword re-ranking,
956
+ * adoption decay, confidence gating, and limit.
852
957
  * @param {object[]} results FTS5 results
853
958
  * @param {object} signals Context signals
854
959
  * @param {object} db Registry database
855
960
  * @param {number} [limit=3] Maximum results to return
856
961
  * @returns {object[]} Post-processed results
857
962
  */
858
- function postProcessResults(results, signals, db, limit = 3) {
963
+ function postProcessResults(results, signals, db, limit = 3, { allowOnRequest = false } = {}) {
964
+ // Filter on_request resources from proactive dispatch (they're only for explicit user requests)
965
+ if (!allowOnRequest) {
966
+ results = results.filter(r => (r.recommendation_mode || 'proactive') === 'proactive');
967
+ }
968
+ results = filterAutoLoadedSkills(results);
969
+ results = filterGarbageMetadata(results);
859
970
  results = reRankByKeywords(results, signals.rawKeywords);
860
971
  results = applyAdoptionDecay(results, db);
861
972
  results = passesConfidenceGate(results, signals);
862
973
  return results.slice(0, limit);
863
974
  }
864
975
 
976
+ // ─── Tiered Rendering ────────────────────────────────────────────────────────
977
+
978
+ /**
979
+ * Decide rendering tier based on composite score.
980
+ * High confidence → full injection (~500 tokens)
981
+ * Medium confidence → one-line hint (~30 tokens)
982
+ * Low confidence → silent (no injection)
983
+ *
984
+ * @param {object} resource Best resource from post-processing
985
+ * @param {object} signals Context signals (may include failurePattern)
986
+ * @returns {'full'|'hint'|'silent'}
987
+ */
988
+ function decideTier(resource, signals) {
989
+ const raw = Math.abs(resource.composite_score ?? resource.relevance ?? 0);
990
+
991
+ // Pattern-detected pain point: boost confidence
992
+ const patternBoost = signals?.failurePattern?.confidence ?? 0;
993
+
994
+ // Normalize: typical good matches score 5-50, great matches 20+
995
+ // Sigmoid-like mapping to 0-1 range
996
+ const normalized = raw / (raw + 5.0); // 5→0.5, 10→0.67, 20→0.8, 50→0.91
997
+
998
+ // Signal-based confidence floor: if the result passed structured intent matching
999
+ // + keyword re-ranking, BM25 score alone shouldn't downgrade to 'silent'.
1000
+ // Small corpora produce low BM25 scores even for strong matches.
1001
+ let signalBoost = 0;
1002
+ if (signals?.primaryIntent) {
1003
+ const tags = (resource.intent_tags || '').toLowerCase().split(/[\s,]+/);
1004
+ // Direct intent match: resource's intent_tags contain the detected primary intent.
1005
+ // Strong boost (0.3) ensures small-corpus matches still reach 'hint' tier.
1006
+ if (tags.includes(signals.primaryIntent)) signalBoost += 0.3;
1007
+ else signalBoost += 0.1;
1008
+ }
1009
+ if (signals?.rawKeywords?.length > 0) {
1010
+ const tags = (resource.intent_tags || '').toLowerCase();
1011
+ if (signals.rawKeywords.some(kw => tags.includes(kw))) signalBoost += 0.2;
1012
+ }
1013
+
1014
+ const confidence = Math.min(1.0, normalized + patternBoost * 0.3 + signalBoost);
1015
+
1016
+ if (confidence >= 0.55) return 'full';
1017
+ if (confidence >= 0.3) return 'hint';
1018
+ return 'silent';
1019
+ }
1020
+
865
1021
  // ─── Recommendation Reason ──────────────────────────────────────────────────
866
1022
 
867
1023
  const INTENT_LABELS = {
@@ -895,97 +1051,13 @@ function buildRecommendReason(signals, { explicit = false } = {}) {
895
1051
  // ─── Main Dispatch Functions ─────────────────────────────────────────────────
896
1052
 
897
1053
  /**
898
- * Dispatch on SessionStart: analyze user prompt, return best resource suggestion.
899
- * Only dispatches when continuing from a previous session (handoff).
900
- * Cold starts (no previous session) showed 0% adoption — skip dispatch entirely.
901
- * @param {Database} db Registry database
902
- * @param {string} userPrompt User's prompt text
903
- * @param {string} [sessionId] Session identifier for dedup
904
- * @param {Object} [options]
905
- * @param {boolean} [options.hasHandoff=false] Whether a previous session handoff exists
906
- * @returns {Promise<string|null>} Injection text or null
1054
+ * Dispatch on SessionStart permanently disabled.
1055
+ * Data: 0/122 adoption across all session_start recommendations.
1056
+ * Session-start context injection (Last Session, Key Context) remains active via hook.mjs.
1057
+ * Resource dispatch at session-start adds no value — user_prompt and pre_tool_use cover all needs.
907
1058
  */
908
- export async function dispatchOnSessionStart(db, userPrompt, sessionId, { hasHandoff = false } = {}) {
909
- if (!db) return null;
910
- if (!hasHandoff) return null; // Only dispatch when continuing from a previous session
911
- if (!userPrompt) return null; // Prompt still required for FTS query
912
-
913
- try {
914
- const projectDomains = detectProjectDomains();
915
-
916
- // Primary: intent-aware enhanced query (column-targeted, better for mixed-domain prompts)
917
- const signals = extractContextSignals({ tool_name: '_session_start' }, { userPrompt });
918
- const enhancedQuery = buildEnhancedQuery(signals);
919
-
920
- // Fetch extra results when rawKeywords present — BM25 may rank intent-matching
921
- // resources above domain-specific ones; extra headroom lets reRankByKeywords promote them.
922
- const fetchLimit = signals.rawKeywords.length > 0 ? 8 : 3;
923
- let results = enhancedQuery ? retrieveResources(db, enhancedQuery, { limit: fetchLimit, projectDomains }) : [];
924
-
925
- // Fallback: broad text query (catches prompts without clear intent patterns)
926
- if (results.length === 0) {
927
- const textQuery = buildQueryFromText(userPrompt);
928
- if (!textQuery) return null;
929
- results = retrieveResources(db, textQuery, { limit: 3, projectDomains });
930
- // Filter out resources matching suppressed intents (e.g. TDD for test-running prompts)
931
- if (signals.suppressedIntents.length > 0) {
932
- results = results.filter(r => {
933
- const tags = (r.intent_tags || '').toLowerCase().split(/[\s,]+/);
934
- return !signals.suppressedIntents.some(s => tags.includes(s));
935
- });
936
- }
937
- }
938
-
939
- results = postProcessResults(results, signals, db);
940
-
941
- let tier = 2;
942
-
943
- // Tier 3: Haiku semantic fallback (SessionStart has 10s budget)
944
- if (needsHaikuDispatch(results)) {
945
- tier = 3;
946
- const haikuResult = await haikuDispatch(userPrompt, '');
947
- if (haikuResult?.query && (haikuResult.confidence ?? 0) >= HAIKU_CONFIDENCE_THRESHOLD) {
948
- const haikuQuery = buildQueryFromText(haikuResult.query);
949
- if (haikuQuery) {
950
- let haikuResults = retrieveResources(db, haikuQuery, {
951
- type: haikuResult.type === 'either' ? undefined : haikuResult.type,
952
- limit: 3,
953
- projectDomains,
954
- });
955
- if (haikuResults.length > 0) {
956
- haikuResults = postProcessResults(haikuResults, signals, db);
957
- if (haikuResults.length > 0) results = haikuResults;
958
- }
959
- }
960
- }
961
- }
962
-
963
- if (results.length === 0) return null;
964
-
965
- // Filter by DB-persisted cooldown + session dedup (hoisted cap check avoids N queries)
966
- if (sessionId && isSessionCapped(db, sessionId)) return null;
967
- const viable = sessionId
968
- ? results.filter(r => !isRecentlyRecommended(db, r.id, sessionId, { skipCapCheck: true }))
969
- : results;
970
- if (viable.length === 0) return null;
971
-
972
- const best = viable[0];
973
-
974
- // Record invocation (also serves as cooldown/dedup marker for future checks)
975
- recordInvocation(db, {
976
- resource_id: best.id,
977
- session_id: sessionId || null,
978
- trigger: 'session_start',
979
- tier,
980
- recommended: 1,
981
- });
982
- updateResourceStats(db, best.id, 'recommend_count');
983
-
984
- return renderInjection(best, buildRecommendReason(signals));
985
- } catch (e) {
986
- debugCatch(e, 'dispatchOnSessionStart');
987
- return null;
988
- }
1059
+ export async function dispatchOnSessionStart() {
1060
+ return null;
989
1061
  }
990
1062
 
991
1063
  /**
@@ -997,7 +1069,7 @@ export async function dispatchOnSessionStart(db, userPrompt, sessionId, { hasHan
997
1069
  * @param {string} [sessionId] Session identifier for dedup
998
1070
  * @returns {Promise<string|null>} Injection text or null
999
1071
  */
1000
- export async function dispatchOnUserPrompt(db, userPrompt, sessionId, { sessionEvents } = {}) {
1072
+ export async function dispatchOnUserPrompt(db, userPrompt, sessionId, { sessionEvents, prevContext } = {}) {
1001
1073
  if (!userPrompt || !db) return null;
1002
1074
 
1003
1075
  try {
@@ -1007,6 +1079,8 @@ export async function dispatchOnUserPrompt(db, userPrompt, sessionId, { sessionE
1007
1079
  const textQuery = buildQueryFromText(explicit.searchTerm);
1008
1080
  if (textQuery) {
1009
1081
  let explicitResults = retrieveResources(db, textQuery, { limit: 3, projectDomains: detectProjectDomains() });
1082
+ explicitResults = filterAutoLoadedSkills(explicitResults);
1083
+ explicitResults = filterGarbageMetadata(explicitResults);
1010
1084
  explicitResults = applyAdoptionDecay(explicitResults, db);
1011
1085
  if (explicitResults.length > 0) {
1012
1086
  const best = explicitResults[0];
@@ -1025,8 +1099,14 @@ export async function dispatchOnUserPrompt(db, userPrompt, sessionId, { sessionE
1025
1099
 
1026
1100
  const projectDomains = detectProjectDomains();
1027
1101
 
1102
+ // Enrich prompt with previous session context (cached at session-start).
1103
+ // Combines project history (next_steps) with user intent for richer signal.
1104
+ const enrichedPrompt = prevContext
1105
+ ? `${userPrompt}\n[Previous session: ${prevContext}]`
1106
+ : userPrompt;
1107
+
1028
1108
  // Intent-aware enhanced query (column-targeted)
1029
- const signals = extractContextSignals({ tool_name: '_user_prompt' }, { userPrompt });
1109
+ const signals = extractContextSignals({ tool_name: '_user_prompt' }, { userPrompt: enrichedPrompt });
1030
1110
 
1031
1111
  // Check if active suite covers the current stage
1032
1112
  if (activeSuite) {
@@ -1064,13 +1144,11 @@ export async function dispatchOnUserPrompt(db, userPrompt, sessionId, { sessionE
1064
1144
 
1065
1145
  if (results.length === 0) return null;
1066
1146
 
1067
- // Low confidence skip (no Haiku in user_prompt path stay fast)
1068
- if (needsHaikuDispatch(results)) return null;
1069
-
1070
- // Filter by cooldown + session dedup (hoisted cap check avoids N queries)
1147
+ // Filter by cooldown + session dedup (hoisted cap + cooldown avoids N queries)
1071
1148
  if (sessionId && isSessionCapped(db, sessionId)) return null;
1149
+ const cooldown = getAdaptiveCooldown(db);
1072
1150
  const viable = sessionId
1073
- ? results.filter(r => !isRecentlyRecommended(db, r.id, sessionId, { skipCapCheck: true }))
1151
+ ? results.filter(r => !isRecentlyRecommended(db, r.id, sessionId, { skipCapCheck: true, cooldown }))
1074
1152
  : results;
1075
1153
  if (viable.length === 0) return null;
1076
1154
 
@@ -1085,6 +1163,9 @@ export async function dispatchOnUserPrompt(db, userPrompt, sessionId, { sessionE
1085
1163
  });
1086
1164
  updateResourceStats(db, best.id, 'recommend_count');
1087
1165
 
1166
+ const tier = decideTier(best, signals);
1167
+ if (tier === 'silent') return null;
1168
+ if (tier === 'hint') return renderHint(best);
1088
1169
  return renderInjection(best, buildRecommendReason(signals));
1089
1170
  } catch (e) {
1090
1171
  debugCatch(e, 'dispatchOnUserPrompt');
@@ -1107,13 +1188,21 @@ export async function dispatchOnPreToolUse(db, event, sessionCtx = {}) {
1107
1188
  const { skip } = shouldSkipDispatch(event);
1108
1189
  if (skip) return null;
1109
1190
 
1191
+ // Phase transition gate: only dispatch on phase transitions to reduce noise.
1192
+ // The first few events (≤3) always pass to allow initial recommendations.
1193
+ const allEvents = peekToolEvents();
1194
+ const currentPhase = inferSessionPhase(allEvents);
1195
+ const phaseChanged = isPhaseTransition(_lastPhase, currentPhase);
1196
+ _lastPhase = currentPhase;
1197
+
1198
+ if (!phaseChanged && allEvents.length > 3) return null;
1199
+
1110
1200
  // Tier 1: Extract context signals
1111
1201
  const signals = extractContextSignals(event, sessionCtx);
1112
1202
 
1113
1203
  // Suite protection: if a suite auto-flow is active, suppress recommendations
1114
1204
  // for stages the suite already covers
1115
- const events = peekToolEvents();
1116
- const activeSuite = detectActiveSuite(events);
1205
+ const activeSuite = detectActiveSuite(allEvents);
1117
1206
  if (activeSuite) {
1118
1207
  const stage = inferCurrentStage(signals.primaryIntent, activeSuite, signals.suppressedIntents);
1119
1208
  if (stage) {
@@ -1135,16 +1224,12 @@ export async function dispatchOnPreToolUse(db, event, sessionCtx = {}) {
1135
1224
  results = postProcessResults(results, signals, db);
1136
1225
  if (results.length === 0) return null;
1137
1226
 
1138
- const tier = 2; // Tier 3 disabled for PreToolUse 2s hook timeout insufficient
1139
-
1140
- // Low-confidence results: skip recommendation rather than suggest unreliable match
1141
- if (needsHaikuDispatch(results)) return null;
1142
-
1143
- // Apply DB-persisted cooldown and session dedup (hoisted cap check avoids N queries)
1227
+ // Apply DB-persisted cooldown and session dedup (hoisted cap + cooldown avoids N queries)
1144
1228
  const sid = sessionCtx.sessionId || null;
1145
1229
  if (sid && isSessionCapped(db, sid)) return null;
1230
+ const cooldown = getAdaptiveCooldown(db);
1146
1231
  const viable = sid
1147
- ? results.filter(r => !isRecentlyRecommended(db, r.id, sid, { skipCapCheck: true }))
1232
+ ? results.filter(r => !isRecentlyRecommended(db, r.id, sid, { skipCapCheck: true, cooldown }))
1148
1233
  : results;
1149
1234
  if (viable.length === 0) return null;
1150
1235
  const best = viable[0];
@@ -1154,11 +1239,14 @@ export async function dispatchOnPreToolUse(db, event, sessionCtx = {}) {
1154
1239
  resource_id: best.id,
1155
1240
  session_id: sid,
1156
1241
  trigger: 'pre_tool_use',
1157
- tier,
1242
+ tier: 2,
1158
1243
  recommended: 1,
1159
1244
  });
1160
1245
  updateResourceStats(db, best.id, 'recommend_count');
1161
1246
 
1247
+ const tier = decideTier(best, signals);
1248
+ if (tier === 'silent') return null;
1249
+ if (tier === 'hint') return renderHint(best);
1162
1250
  return renderInjection(best, buildRecommendReason(signals));
1163
1251
  } catch (e) {
1164
1252
  debugCatch(e, 'dispatchOnPreToolUse');