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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.mcp.json +0 -0
- package/LICENSE +0 -0
- package/README.md +0 -0
- package/README.zh-CN.md +0 -0
- package/commands/mem.md +0 -0
- package/commands/memory.md +0 -0
- package/commands/tools.md +0 -0
- package/commands/update.md +0 -0
- package/dispatch-feedback.mjs +129 -24
- package/dispatch-inject.mjs +73 -34
- package/dispatch-patterns.mjs +173 -0
- package/dispatch-workflow.mjs +0 -0
- package/dispatch.mjs +359 -271
- package/haiku-client.mjs +0 -0
- package/hook-context.mjs +24 -6
- package/hook-episode.mjs +2 -2
- package/hook-handoff.mjs +38 -18
- package/hook-llm.mjs +98 -21
- package/hook-memory.mjs +47 -15
- package/hook-semaphore.mjs +0 -0
- package/hook-shared.mjs +21 -0
- package/hook-update.mjs +262 -0
- package/hook.mjs +165 -28
- package/hooks/hooks.json +0 -0
- package/install.mjs +149 -4
- package/package.json +3 -1
- package/registry/preinstalled.json +13 -0
- package/registry-indexer.mjs +0 -0
- package/registry-retriever.mjs +13 -8
- package/registry-scanner.mjs +0 -0
- package/registry.mjs +15 -7
- package/resource-discovery.mjs +0 -0
- package/schema.mjs +0 -0
- package/scripts/launch.mjs +0 -0
- package/server-internals.mjs +0 -0
- package/server.mjs +58 -13
- package/skill.md +0 -0
- package/tool-schemas.mjs +41 -16
- package/utils.mjs +87 -30
package/dispatch.mjs
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
// claude-mem-lite: Dispatch orchestration —
|
|
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
|
|
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 {
|
|
13
|
-
import {
|
|
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
|
-
[/(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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, `-${
|
|
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
|
-
//
|
|
818
|
-
|
|
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
|
|
834
|
-
|
|
835
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
899
|
-
*
|
|
900
|
-
*
|
|
901
|
-
*
|
|
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(
|
|
909
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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');
|