claude-mem-lite 2.3.0 → 2.3.3

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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "2.1.6",
13
+ "version": "2.3.2",
14
14
  "source": "./",
15
15
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall"
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.2.0",
3
+ "version": "2.3.3",
4
4
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/.mcp.json CHANGED
@@ -1,8 +1,3 @@
1
1
  {
2
- "mcpServers": {
3
- "mem": {
4
- "command": "node",
5
- "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/launch.mjs"]
6
- }
7
- }
8
- }
2
+ "mcpServers": {}
3
+ }
package/commands/mem.md CHANGED
@@ -1,4 +1,5 @@
1
1
  ---
2
+ name: mem
2
3
  description: Search and manage project memory (observations, sessions, prompts)
3
4
  ---
4
5
 
@@ -13,6 +14,9 @@ Search and browse your project memory efficiently.
13
14
  - `/mem save <text>` — Save a manual memory/note
14
15
  - `/mem stats` — Show memory statistics
15
16
  - `/mem timeline <query>` — Browse timeline around a matching observation
17
+ - `/mem cleanup` — Scan and interactively purge stale data
18
+ - `/mem cleanup [N]d` — Purge stale data older than N days (e.g. `cleanup 60d`)
19
+ - `/mem cleanup keep [N]d` — Purge stale data but retain last N days (e.g. `cleanup keep 14d`)
16
20
 
17
21
  ## Efficient Search Workflow (3 steps, saves 10x tokens)
18
22
 
@@ -29,6 +33,9 @@ When the user invokes `/mem`, parse their intent:
29
33
  - `/mem save <text>` → call `mem_save` with the text as content
30
34
  - `/mem stats` → call `mem_stats`
31
35
  - `/mem timeline <query>` → call `mem_timeline` with the query
36
+ - `/mem cleanup` → run `mem_maintain(action="scan")`, report pending purge count and stale items to user, ask for confirmation, then run `mem_maintain(action="execute", operations=["purge_stale"])` if confirmed
37
+ - `/mem cleanup Nd` (e.g. `60d`) → same as above but use `retain_days=N` to only purge items older than N days
38
+ - `/mem cleanup keep Nd` (e.g. `keep 14d`) → same as above with `retain_days=N`
32
39
  - `/mem <query>` (no subcommand) → treat as search, call `mem_search`
33
40
 
34
41
  Always use the compact index from mem_search first, then mem_get for details only when needed. This minimizes token usage.
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: memory
3
+ description: Save content to memory — with explicit content, instructions, or auto-summarize current session
4
+ ---
5
+
6
+ # Memory Save
7
+
8
+ Save important content to your long-term memory database.
9
+
10
+ ## Commands
11
+
12
+ - `/mem:memory <content>` — Save the given content directly to memory
13
+ - `/mem:memory` (no args) — Auto-summarize recent session highlights and save key findings
14
+
15
+ ## Instructions
16
+
17
+ When the user invokes `/mem:memory`, determine the intent:
18
+
19
+ ### With explicit content
20
+
21
+ If the user provides content after the command:
22
+
23
+ 1. Analyze the content to determine appropriate type (decision, bugfix, feature, refactor, discovery, change)
24
+ 2. Generate a concise title from the content
25
+ 3. Call `mem_save` with:
26
+ - `content`: the provided text
27
+ - `title`: auto-generated title
28
+ - `type`: inferred type (default: "discovery")
29
+ - `importance`: 2 (notable — user explicitly requested save)
30
+
31
+ ### With instructions/prompt
32
+
33
+ If the user provides instructions like "save the database schema we discussed" or "remember the fix for the auth bug":
34
+
35
+ 1. Review recent conversation context
36
+ 2. Extract the relevant information per the user's instruction
37
+ 3. Call `mem_save` with extracted content, appropriate title and type, importance=2
38
+
39
+ ### No arguments (auto-save)
40
+
41
+ If no content is provided:
42
+
43
+ 1. Review the current session's recent key findings
44
+ 2. Identify: decisions made, bugs fixed, patterns discovered, important code changes
45
+ 3. For each significant finding (max 5), call `mem_save` with:
46
+ - Clear title and structured content
47
+ - Appropriate type and importance level (1=routine, 2=notable)
48
+ 4. Skip trivial or already-saved items
49
+ 5. Report what was saved in a concise summary
50
+
51
+ Always set importance=2 for explicit saves (user chose to save), importance=1 for auto-saves of routine items, importance=2 for auto-saves of notable discoveries.
@@ -0,0 +1,78 @@
1
+ ---
2
+ name: tools
3
+ description: Import skills and agents from GitHub repositories into the tool resource registry
4
+ ---
5
+
6
+ # Tool Import
7
+
8
+ Import skills and agents from GitHub repositories into the resource registry for intelligent dispatch.
9
+
10
+ ## Commands
11
+
12
+ - `/mem:tools <github-url>` — Import all skills/agents from a GitHub repo
13
+ - `/mem:tools <github-url> <instructions>` — Import with specific instructions (add/remove specific items)
14
+ - `/mem:tools <instructions>` — Directly add/remove/modify tools by prompt (no URL needed)
15
+ - `/mem:tools` (no args) — Show current registry stats and import help
16
+
17
+ ## Instructions
18
+
19
+ When the user invokes `/mem:tools`:
20
+
21
+ ### With GitHub URL
22
+
23
+ 1. Use WebFetch to fetch the repository README and skill/agent files:
24
+ - Try `https://raw.githubusercontent.com/{owner}/{repo}/main/README.md`
25
+ - Look for skill definitions (`.md` files with frontmatter), agent definitions, plugin.json
26
+ - If the repo has a `commands/` directory, fetch skill files from there
27
+ 2. Identify all skills and agents in the repository
28
+ 3. For each tool found, extract metadata using your understanding of the content:
29
+ - `name`: tool name (lowercase, hyphenated)
30
+ - `resource_type`: "skill" or "agent"
31
+ - `repo_url`: the GitHub URL
32
+ - `intent_tags`: comma-separated intent keywords (what the tool helps with)
33
+ - `domain_tags`: comma-separated technology/domain tags
34
+ - `capability_summary`: one-line description of what the tool does
35
+ - `trigger_patterns`: when to recommend this tool (natural language)
36
+ - `keywords`: additional search terms
37
+ - `tech_stack`: technology stack tags
38
+ - `use_cases`: usage scenarios
39
+ 4. Call `mem_registry(action="import", ...)` for each tool with extracted metadata
40
+ 5. Call `mem_registry(action="reindex")` to update FTS5 index
41
+ 6. Report imported tools in a table format
42
+
43
+ ### With GitHub URL + instructions
44
+
45
+ If the user provides instructions after the URL:
46
+ - "only add the TDD skill" → import only matching tools from that repo
47
+ - "remove the old testing tool" → call `mem_registry(action="remove", ...)`
48
+ - Follow user instructions for selective add/remove/modify operations
49
+
50
+ ### With instructions only (no URL)
51
+
52
+ If the user provides a prompt without a GitHub URL, parse the intent:
53
+
54
+ **Adding a tool:**
55
+ - "添加一个叫 my-linter 的 skill" or "add a skill called my-linter"
56
+ - → Ask for metadata (or infer from context): capability_summary, intent_tags, domain_tags, trigger_patterns
57
+ - → Call `mem_registry(action="import", name="my-linter", resource_type="skill", ...)`
58
+
59
+ **Removing a tool:**
60
+ - "删除 old-testing skill" or "remove the old-testing agent"
61
+ - → Call `mem_registry(action="remove", name="old-testing", resource_type="skill")`
62
+
63
+ **Listing/searching:**
64
+ - "有哪些 testing 相关的工具" or "list all agents"
65
+ - → Call `mem_registry(action="list", type="agent")` or search by keywords
66
+
67
+ **Modifying a tool:**
68
+ - "更新 my-linter 的描述" or "update tags for my-tool"
69
+ - → Call `mem_registry(action="import", ...)` with updated metadata (upsert)
70
+
71
+ Always call `mem_registry(action="reindex")` after any add/remove/modify operations.
72
+
73
+ ### Without URL or instructions
74
+
75
+ If no arguments provided:
76
+ 1. Call `mem_registry(action="stats")` to show current registry state
77
+ 2. Call `mem_registry(action="list")` to show all registered tools
78
+ 3. Explain usage examples
@@ -0,0 +1,38 @@
1
+ ---
2
+ name: update
3
+ description: Auto-maintain memory and resource registry — deduplicate, merge, decay, cleanup, reindex
4
+ ---
5
+
6
+ # Memory & Registry Maintenance
7
+
8
+ Run intelligent maintenance on both the memory database and tool resource registry.
9
+
10
+ ## Usage
11
+
12
+ - `/mem:update` — Run full maintenance cycle
13
+
14
+ ## Instructions
15
+
16
+ When the user invokes `/mem:update`, perform the following maintenance cycle:
17
+
18
+ ### Phase 1: Memory Maintenance
19
+
20
+ 1. Call `mem_maintain(action="scan")` to analyze maintenance candidates
21
+ 2. Report scan results to the user (duplicates, stale items, broken items, boostable items, **pending purge** items)
22
+ 3. Call `mem_maintain(action="execute", operations=["cleanup","decay","boost"])` to apply safe automatic changes
23
+ 4. If duplicates were found in scan, review them and call `mem_maintain(action="execute", operations=["dedup"], merge_ids=[[keepId, removeId1, ...], ...])` — keep the more important/recent observation in each pair
24
+ 5. Run `mem_compress(preview=false)` for old low-value observations
25
+ 6. **If pending purge items > 0**: Report the count to the user and ask for confirmation. If confirmed, call `mem_maintain(action="execute", operations=["purge_stale"])`. User may optionally specify `retain_days` (default 30) to control how many days of data to keep. Do NOT purge without explicit user confirmation.
26
+
27
+ ### Phase 2: Registry Maintenance
28
+
29
+ 1. Call `mem_registry(action="stats")` to get registry overview
30
+ 2. Call `mem_registry(action="reindex")` to rebuild FTS5 search index
31
+ 3. Report updated stats
32
+
33
+ ### Phase 3: Summary
34
+
35
+ Summarize all maintenance actions taken in zh-CN:
36
+ - Memory: observations cleaned, decayed, boosted, deduplicated, compressed
37
+ - Registry: total resources, adoption rates, reindex status
38
+ - Overall health assessment
@@ -2,7 +2,7 @@
2
2
  // Formats resource recommendations for Claude Code's additionalContext
3
3
 
4
4
  import { existsSync, readFileSync } from 'fs';
5
- import { join } from 'path';
5
+ import { join, resolve } from 'path';
6
6
  import { homedir } from 'os';
7
7
  import { truncate } from './utils.mjs';
8
8
  import { DB_DIR } from './schema.mjs';
@@ -18,13 +18,14 @@ function truncateContent(str, max) {
18
18
 
19
19
  // Allowed base directories for resource file reads (defense-in-depth)
20
20
  const ALLOWED_BASES = [
21
- join(homedir(), '.claude'),
22
- join(DB_DIR, 'managed'),
21
+ resolve(join(homedir(), '.claude')),
22
+ resolve(join(DB_DIR, 'managed')),
23
23
  ];
24
24
 
25
25
  function isAllowedPath(filePath) {
26
26
  if (!filePath) return false;
27
- return ALLOWED_BASES.some(base => filePath === base || filePath.startsWith(base + '/'));
27
+ const resolved = resolve(filePath);
28
+ return ALLOWED_BASES.some(base => resolved === base || resolved.startsWith(base + '/'));
28
29
  }
29
30
 
30
31
  // ─── Template Detection ──────────────────────────────────────────────────────
@@ -0,0 +1,155 @@
1
+ // claude-mem-lite: Workflow-aware dispatch intelligence
2
+ // Suite auto-flow protection, explicit request detection, stage model
3
+
4
+ // ─── Stage Model ─────────────────────────────────────────────────────────────
5
+
6
+ export const STAGES = ['ANALYZE', 'PLAN', 'REVIEW_PLAN', 'EXECUTE', 'TEST', 'REVIEW_CODE', 'COMMIT'];
7
+
8
+ // Skill invocation name → workflow stage
9
+ const SKILL_STAGE_MAP = {
10
+ 'superpowers:brainstorming': 'ANALYZE',
11
+ 'superpowers:writing-plans': 'PLAN',
12
+ 'gsd:start': 'PLAN',
13
+ 'gsd:prd': 'PLAN',
14
+ 'superpowers:executing-plans': 'EXECUTE',
15
+ 'superpowers:subagent-driven-development': 'EXECUTE',
16
+ 'gsd:resume': 'EXECUTE',
17
+ 'superpowers:test-driven-development': 'TEST',
18
+ 'superpowers:systematic-debugging': 'EXECUTE',
19
+ 'superpowers:verification-before-completion': 'TEST',
20
+ 'superpowers:requesting-code-review': 'REVIEW_CODE',
21
+ 'superpowers:receiving-code-review': 'REVIEW_CODE',
22
+ 'superpowers:finishing-a-development-branch': 'COMMIT',
23
+ 'commit-commands:commit': 'COMMIT',
24
+ 'commit-commands:commit-push-pr': 'COMMIT',
25
+ 'commit-commands:clean_gone': 'COMMIT',
26
+ };
27
+
28
+ // User intent → stage mapping (for stage inference from prompt)
29
+ export const INTENT_STAGE_MAP = {
30
+ 'plan': 'PLAN',
31
+ 'review': 'REVIEW_CODE',
32
+ 'test': 'TEST',
33
+ 'fix': 'EXECUTE',
34
+ 'clean': 'EXECUTE',
35
+ 'commit': 'COMMIT',
36
+ 'deploy': 'COMMIT',
37
+ 'design': 'ANALYZE',
38
+ 'doc': 'COMMIT',
39
+ 'build': 'EXECUTE',
40
+ 'fast': 'EXECUTE',
41
+ 'lint': 'EXECUTE',
42
+ 'db': 'EXECUTE',
43
+ 'api': 'EXECUTE',
44
+ 'secure': 'EXECUTE',
45
+ 'infra': 'EXECUTE',
46
+ };
47
+
48
+ // ─── Suite Auto-Flow Protection ──────────────────────────────────────────────
49
+
50
+ export const SUITE_AUTO_FLOWS = {
51
+ superpowers: {
52
+ stages: ['ANALYZE', 'PLAN', 'EXECUTE', 'TEST', 'REVIEW_CODE', 'COMMIT'],
53
+ gaps: ['REVIEW_PLAN'],
54
+ },
55
+ gsd: {
56
+ stages: ['PLAN', 'EXECUTE', 'TEST', 'REVIEW_CODE'],
57
+ gaps: ['ANALYZE', 'REVIEW_PLAN', 'COMMIT'],
58
+ },
59
+ 'feature-dev': {
60
+ stages: ['ANALYZE', 'EXECUTE', 'REVIEW_CODE'],
61
+ gaps: ['PLAN', 'REVIEW_PLAN', 'TEST', 'COMMIT'],
62
+ },
63
+ 'commit-commands': {
64
+ stages: ['COMMIT'],
65
+ gaps: ['ANALYZE', 'PLAN', 'REVIEW_PLAN', 'EXECUTE', 'TEST', 'REVIEW_CODE'],
66
+ },
67
+ };
68
+
69
+ /**
70
+ * Detect if a suite auto-flow is active based on recent Skill tool events.
71
+ * Scans backwards to find the most recent Skill invocation from a known suite.
72
+ * @param {object[]} sessionEvents Array of tool events
73
+ * @returns {{suite: string, flow: object, lastSkill: string}|null}
74
+ */
75
+ export function detectActiveSuite(sessionEvents) {
76
+ if (!sessionEvents || sessionEvents.length === 0) return null;
77
+
78
+ for (let i = sessionEvents.length - 1; i >= 0; i--) {
79
+ const e = sessionEvents[i];
80
+ if (e.tool_name === 'Skill' && e.tool_input?.skill) {
81
+ const skill = e.tool_input.skill;
82
+ const suite = skill.split(':')[0];
83
+ if (SUITE_AUTO_FLOWS[suite]) {
84
+ return { suite, flow: SUITE_AUTO_FLOWS[suite], lastSkill: skill };
85
+ }
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * Determine if a recommendation should be made for the current stage.
93
+ * @param {{suite: string, flow: object}|null} activeSuite Active suite info
94
+ * @param {string} currentStage Current workflow stage
95
+ * @returns {{shouldRecommend: boolean, reason: string}}
96
+ */
97
+ export function shouldRecommendForStage(activeSuite, currentStage) {
98
+ if (!activeSuite) return { shouldRecommend: true, reason: 'no_suite' };
99
+
100
+ const { flow } = activeSuite;
101
+ if (flow.stages.includes(currentStage)) {
102
+ return { shouldRecommend: false, reason: 'suite_covers_stage' };
103
+ }
104
+ if (flow.gaps.includes(currentStage)) {
105
+ return { shouldRecommend: true, reason: 'suite_gap' };
106
+ }
107
+ return { shouldRecommend: true, reason: 'unknown_stage' };
108
+ }
109
+
110
+ /**
111
+ * Infer the current workflow stage from intent or from the last skill used.
112
+ * @param {string} primaryIntent Primary intent from signal extraction
113
+ * @param {{lastSkill: string}|null} activeSuite Active suite info
114
+ * @returns {string|null} Stage name or null
115
+ */
116
+ export function inferCurrentStage(primaryIntent, activeSuite) {
117
+ if (activeSuite?.lastSkill && SKILL_STAGE_MAP[activeSuite.lastSkill]) {
118
+ return SKILL_STAGE_MAP[activeSuite.lastSkill];
119
+ }
120
+ return INTENT_STAGE_MAP[primaryIntent] || null;
121
+ }
122
+
123
+ // ─── Explicit Request Detection ──────────────────────────────────────────────
124
+
125
+ const EXPLICIT_REQUEST_PATTERNS = [
126
+ // EN: "use the playwright skill", "try the ppt skill"
127
+ /(?:use|try|invoke|run|activate|load)\s+(?:the\s+)?(\S+?)\s+(?:skill|agent|tool|plugin)\b/i,
128
+ // CN: "用ppt的技能", "帮我用playwright的skill"
129
+ /(?:用|使用|帮我用|试试|启用)\s*(\S+?)\s*(?:的|的技能|的skill|的agent|技能|skill|agent|工具|插件)/,
130
+ // "有没有xxx的skill", "is there a xxx agent"
131
+ /(?:有没有|有无|是否有|do you have|is there)\s*(?:一个|a|an)?\s*(\S+?)\s*(?:的|skill|agent|技能|工具)/i,
132
+ // "推荐一个xxx", "recommend a xxx agent"
133
+ /(?:推荐|suggest|recommend)\s*(?:一个|a|an)?\s*(\S+?)\s*(?:的|skill|agent|技能|工具)/i,
134
+ ];
135
+
136
+ /**
137
+ * Detect if the user is explicitly requesting a specific tool/skill.
138
+ * Highest priority — bypasses all dispatch restrictions.
139
+ * @param {string} userPrompt User's prompt text
140
+ * @returns {{isExplicit: boolean, searchTerm?: string}}
141
+ */
142
+ export function detectExplicitRequest(userPrompt) {
143
+ if (!userPrompt) return { isExplicit: false };
144
+
145
+ for (const pattern of EXPLICIT_REQUEST_PATTERNS) {
146
+ const match = userPrompt.match(pattern);
147
+ if (match && match[1]) {
148
+ const term = match[1].replace(/['"]/g, '').trim();
149
+ if (term.length >= 2 && term.length <= 30) {
150
+ return { isExplicit: true, searchTerm: term };
151
+ }
152
+ }
153
+ }
154
+ return { isExplicit: false };
155
+ }
package/dispatch.mjs CHANGED
@@ -32,22 +32,28 @@ export const BM25_MIN_THRESHOLD = 1.5;
32
32
  // ─── Haiku Circuit Breaker ──────────────────────────────────────────────────
33
33
  // Prevents cascading latency when Haiku API is down or slow.
34
34
  // After BREAKER_THRESHOLD consecutive failures, disable for BREAKER_RESET_MS.
35
+ // KNOWN LIMITATION: File-based state has a TOCTOU race under concurrent hook
36
+ // processes. Worst case: breaker trips on failure N+1 instead of N. This is
37
+ // acceptable — the breaker is a latency guard, not a correctness mechanism.
35
38
 
36
39
  const BREAKER_THRESHOLD = 3;
37
40
  const BREAKER_RESET_MS = 5 * 60 * 1000; // 5 minutes
38
- const BREAKER_FILE = join(RUNTIME_DIR, 'haiku-breaker.json');
41
+ let breakerFile = join(RUNTIME_DIR, 'haiku-breaker.json');
39
42
 
40
43
  function _readBreakerState() {
41
44
  try {
42
- if (!existsSync(BREAKER_FILE)) return { failures: 0, openUntil: 0 };
43
- return JSON.parse(readFileSync(BREAKER_FILE, 'utf8'));
45
+ if (!existsSync(breakerFile)) return { failures: 0, openUntil: 0 };
46
+ return JSON.parse(readFileSync(breakerFile, 'utf8'));
44
47
  } catch { return { failures: 0, openUntil: 0 }; }
45
48
  }
46
49
 
47
50
  function _writeBreakerState(state) {
48
- try { writeFileSync(BREAKER_FILE, JSON.stringify(state)); } catch {}
51
+ try { writeFileSync(breakerFile, JSON.stringify(state)); } catch {}
49
52
  }
50
53
 
54
+ /** Override breaker file path (for testing isolation). */
55
+ export function _setBreakerFile(path) { breakerFile = path; }
56
+
51
57
  function isHaikuCircuitOpen() {
52
58
  const state = _readBreakerState();
53
59
  if (state.openUntil > 0 && Date.now() < state.openUntil) return true;
@@ -636,15 +642,27 @@ JSON: {"query":"search keywords for finding the right skill or agent","type":"sk
636
642
 
637
643
  // ─── Cooldown & Dedup (DB-persisted, survives process restarts) ─────────────
638
644
 
645
+ /**
646
+ * Check if session has hit the recommendation cap.
647
+ * Separated from per-resource check so callers in filter loops can hoist this.
648
+ * @param {Database} db Registry database
649
+ * @param {string} sessionId Session identifier
650
+ * @returns {boolean} true if session cap is reached
651
+ */
652
+ export function isSessionCapped(db, sessionId) {
653
+ if (!sessionId) return false;
654
+ const sessionCount = db.prepare(
655
+ 'SELECT COUNT(*) as cnt FROM invocations WHERE session_id = ? AND recommended = 1'
656
+ ).get(sessionId);
657
+ return sessionCount.cnt >= SESSION_RECOMMEND_CAP;
658
+ }
659
+
639
660
  export function isRecentlyRecommended(db, resourceId, sessionId) {
640
- // Check 1 & 2: Session-scoped checks (cap + dedup) only when sessionId is available
661
+ // Check 1: Session cap (loop-invariant callers should prefer isSessionCapped for filter loops)
641
662
  if (sessionId) {
642
- const sessionCount = db.prepare(
643
- 'SELECT COUNT(*) as cnt FROM invocations WHERE session_id = ? AND recommended = 1'
644
- ).get(sessionId);
645
- if (sessionCount.cnt >= SESSION_RECOMMEND_CAP) return true;
663
+ if (isSessionCapped(db, sessionId)) return true;
646
664
 
647
- // Already recommended in this session (session dedup)
665
+ // Check 2: Already recommended in this session (session dedup)
648
666
  const sessionHit = db.prepare(
649
667
  'SELECT 1 FROM invocations WHERE resource_id = ? AND session_id = ? AND recommended = 1 LIMIT 1'
650
668
  ).get(resourceId, sessionId);
@@ -857,7 +875,8 @@ export async function dispatchOnSessionStart(db, userPrompt, sessionId, { hasHan
857
875
 
858
876
  if (results.length === 0) return null;
859
877
 
860
- // Filter by DB-persisted cooldown + session dedup
878
+ // Filter by DB-persisted cooldown + session dedup (hoisted cap check avoids N queries)
879
+ if (sessionId && isSessionCapped(db, sessionId)) return null;
861
880
  const viable = sessionId
862
881
  ? results.filter(r => !isRecentlyRecommended(db, r.id, sessionId))
863
882
  : results;
@@ -895,12 +914,13 @@ export async function dispatchOnUserPrompt(db, userPrompt, sessionId, { sessionE
895
914
  if (!userPrompt || !db) return null;
896
915
 
897
916
  try {
898
- // 1. Explicit request → highest priority, bypass all restrictions
917
+ // 1. Explicit request → highest priority, bypass cooldown but apply adoption decay
899
918
  const explicit = detectExplicitRequest(userPrompt);
900
919
  if (explicit.isExplicit) {
901
920
  const textQuery = buildQueryFromText(explicit.searchTerm);
902
921
  if (textQuery) {
903
- const explicitResults = retrieveResources(db, textQuery, { limit: 3, projectDomains: detectProjectDomains() });
922
+ let explicitResults = retrieveResources(db, textQuery, { limit: 3, projectDomains: detectProjectDomains() });
923
+ explicitResults = applyAdoptionDecay(explicitResults, db);
904
924
  if (explicitResults.length > 0) {
905
925
  const best = explicitResults[0];
906
926
  if (!sessionId || !isRecentlyRecommended(db, best.id, sessionId)) {
@@ -960,7 +980,8 @@ export async function dispatchOnUserPrompt(db, userPrompt, sessionId, { sessionE
960
980
  // Low confidence → skip (no Haiku in user_prompt path — stay fast)
961
981
  if (needsHaikuDispatch(results)) return null;
962
982
 
963
- // Filter by cooldown + session dedup (prevents double-recommend with SessionStart)
983
+ // Filter by cooldown + session dedup (hoisted cap check avoids N queries)
984
+ if (sessionId && isSessionCapped(db, sessionId)) return null;
964
985
  const viable = sessionId
965
986
  ? results.filter(r => !isRecentlyRecommended(db, r.id, sessionId))
966
987
  : results;
@@ -1028,8 +1049,9 @@ export async function dispatchOnPreToolUse(db, event, sessionCtx = {}) {
1028
1049
  // Low-confidence results: skip recommendation rather than suggest unreliable match
1029
1050
  if (needsHaikuDispatch(results)) return null;
1030
1051
 
1031
- // Apply DB-persisted cooldown and session dedup (filter all, not just top)
1052
+ // Apply DB-persisted cooldown and session dedup (hoisted cap check avoids N queries)
1032
1053
  const sid = sessionCtx.sessionId || null;
1054
+ if (sid && isSessionCapped(db, sid)) return null;
1033
1055
  const viable = sid
1034
1056
  ? results.filter(r => !isRecentlyRecommended(db, r.id, sid))
1035
1057
  : results;