claude-mem-lite 2.3.1 → 2.5.1

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.5.1",
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.
@@ -18,10 +18,11 @@ When the user invokes `/mem:update`, perform the following maintenance cycle:
18
18
  ### Phase 1: Memory Maintenance
19
19
 
20
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)
21
+ 2. Report scan results to the user (duplicates, stale items, broken items, boostable items, **pending purge** items)
22
22
  3. Call `mem_maintain(action="execute", operations=["cleanup","decay","boost"])` to apply safe automatic changes
23
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
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.
25
26
 
26
27
  ### Phase 2: Registry Maintenance
27
28
 
@@ -165,6 +165,49 @@ function detectOutcome(sessionEvents) {
165
165
  return 'success'; // No errors, no edits = informational session, ok
166
166
  }
167
167
 
168
+ // ─── Rejection Classification ────────────────────────────────────────────────
169
+
170
+ /**
171
+ * Classify why a recommendation was not adopted.
172
+ * Analyzes post-recommendation events to determine the reason.
173
+ * @param {object} invocation Invocation record with created_at
174
+ * @param {object[]} sessionEvents All session tool events
175
+ * @returns {string} Rejection reason
176
+ */
177
+ function classifyRejection(invocation, sessionEvents) {
178
+ if (!sessionEvents || sessionEvents.length === 0) return 'session_end';
179
+
180
+ const recTime = new Date(invocation.created_at).getTime();
181
+ const afterEvents = sessionEvents.filter(e =>
182
+ (e.timestamp || 0) > recTime || !e.timestamp
183
+ );
184
+
185
+ if (afterEvents.length <= 2) return 'session_end';
186
+
187
+ // Alternative: Claude used a different skill/agent instead
188
+ const { resource_type, invocation_name, resource_name } = invocation;
189
+ for (const e of afterEvents) {
190
+ if (resource_type === 'skill' && e.tool_name === 'Skill') {
191
+ const used = (e.tool_input?.skill || '').toLowerCase();
192
+ const expected = (invocation_name || resource_name || '').toLowerCase();
193
+ if (used && used !== expected && !used.includes(expected)) return 'alternative';
194
+ }
195
+ if (resource_type === 'agent' && e.tool_name === 'Agent') {
196
+ return 'alternative';
197
+ }
198
+ }
199
+
200
+ // Manual: Claude completed work without any skill/agent
201
+ const hasEdits = afterEvents.some(e => EDIT_TOOLS.has(e.tool_name));
202
+ const noSkillAgent = !afterEvents.some(e => e.tool_name === 'Skill' || e.tool_name === 'Agent');
203
+ if (hasEdits && noSkillAgent) return 'manual';
204
+
205
+ // Context switch: lots of activity but unrelated
206
+ if (afterEvents.length > 5) return 'context_switch';
207
+
208
+ return 'unknown';
209
+ }
210
+
168
211
  // ─── Main Feedback Collection ────────────────────────────────────────────────
169
212
 
170
213
  /**
@@ -190,12 +233,14 @@ export async function collectFeedback(db, sessionId, sessionEvents = []) {
190
233
  const adopted = detectAdoption(inv, sessionEvents);
191
234
  const outcome = adopted ? detectOutcome(sessionEvents) : 'ignored';
192
235
  const score = adopted ? (outcome === 'success' ? 1.0 : outcome === 'partial' ? 0.5 : 0.2) : 0;
236
+ const rejection_reason = adopted ? null : classifyRejection(inv, sessionEvents);
193
237
 
194
238
  // Update invocation record
195
239
  updateInvocation(db, inv.id, {
196
240
  adopted: adopted ? 1 : 0,
197
241
  outcome,
198
242
  score,
243
+ rejection_reason,
199
244
  });
200
245
 
201
246
  // Update resource stats
@@ -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 ──────────────────────────────────────────────────────
@@ -142,9 +143,10 @@ ${truncatedDef}
142
143
  * Enforces MAX_INJECTION_CHARS hard limit.
143
144
  *
144
145
  * @param {object} resource Resource object from DB
146
+ * @param {string} [reason] Brief reason why this resource was recommended
145
147
  * @returns {string} Injection text for additionalContext
146
148
  */
147
- export function renderInjection(resource) {
149
+ export function renderInjection(resource, reason) {
148
150
  let injection;
149
151
 
150
152
  if (resource.type === 'skill') {
@@ -161,6 +163,8 @@ export function renderInjection(resource) {
161
163
  injection = injectAgent(resource);
162
164
  }
163
165
 
166
+ if (reason) injection += `\nReason: ${reason}`;
167
+
164
168
  // Hard limit enforcement
165
169
  if (injection.length > MAX_INJECTION_CHARS) {
166
170
  injection = injection.slice(0, MAX_INJECTION_CHARS - 3) + '...';
@@ -66,22 +66,32 @@ export const SUITE_AUTO_FLOWS = {
66
66
  },
67
67
  };
68
68
 
69
+ const SUITE_MOMENTUM_MAX_DISTANCE = 20;
70
+ const SUITE_MOMENTUM_MAX_AGE_MS = 15 * 60 * 1000; // 15 minutes
71
+
69
72
  /**
70
73
  * 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.
74
+ * Scans backwards with momentum decay: suite influence fades after 20 tool calls or 15 minutes.
72
75
  * @param {object[]} sessionEvents Array of tool events
73
- * @returns {{suite: string, flow: object, lastSkill: string}|null}
76
+ * @returns {{suite: string, flow: object, lastSkill: string, distance: number}|null}
74
77
  */
75
78
  export function detectActiveSuite(sessionEvents) {
76
79
  if (!sessionEvents || sessionEvents.length === 0) return null;
77
80
 
78
81
  for (let i = sessionEvents.length - 1; i >= 0; i--) {
82
+ const distance = sessionEvents.length - 1 - i;
83
+
84
+ // Momentum decay: suite influence fades after 20 tool calls
85
+ if (distance > SUITE_MOMENTUM_MAX_DISTANCE) return null;
86
+
79
87
  const e = sessionEvents[i];
80
88
  if (e.tool_name === 'Skill' && e.tool_input?.skill) {
81
89
  const skill = e.tool_input.skill;
82
90
  const suite = skill.split(':')[0];
83
91
  if (SUITE_AUTO_FLOWS[suite]) {
84
- return { suite, flow: SUITE_AUTO_FLOWS[suite], lastSkill: skill };
92
+ // Time decay: suite influence expires after 15 minutes
93
+ if (e.timestamp && (Date.now() - e.timestamp) > SUITE_MOMENTUM_MAX_AGE_MS) return null;
94
+ return { suite, flow: SUITE_AUTO_FLOWS[suite], lastSkill: skill, distance };
85
95
  }
86
96
  }
87
97
  }
@@ -113,11 +123,16 @@ export function shouldRecommendForStage(activeSuite, currentStage) {
113
123
  * @param {{lastSkill: string}|null} activeSuite Active suite info
114
124
  * @returns {string|null} Stage name or null
115
125
  */
116
- export function inferCurrentStage(primaryIntent, activeSuite) {
126
+ export function inferCurrentStage(primaryIntent, activeSuite, suppressedIntents = []) {
117
127
  if (activeSuite?.lastSkill && SKILL_STAGE_MAP[activeSuite.lastSkill]) {
118
128
  return SKILL_STAGE_MAP[activeSuite.lastSkill];
119
129
  }
120
- return INTENT_STAGE_MAP[primaryIntent] || null;
130
+ if (INTENT_STAGE_MAP[primaryIntent]) return INTENT_STAGE_MAP[primaryIntent];
131
+ // Check suppressed intents — still the user's actual intent, just not used for FTS search
132
+ for (const si of suppressedIntents) {
133
+ if (INTENT_STAGE_MAP[si]) return INTENT_STAGE_MAP[si];
134
+ }
135
+ return null;
121
136
  }
122
137
 
123
138
  // ─── Explicit Request Detection ──────────────────────────────────────────────
package/dispatch.mjs CHANGED
@@ -29,25 +29,35 @@ export const SESSION_RECOMMEND_CAP = 3;
29
29
  // this filters only near-zero noise matches from incidental text overlap.
30
30
  export const BM25_MIN_THRESHOLD = 1.5;
31
31
 
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
+
32
36
  // ─── Haiku Circuit Breaker ──────────────────────────────────────────────────
33
37
  // Prevents cascading latency when Haiku API is down or slow.
34
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.
35
42
 
36
43
  const BREAKER_THRESHOLD = 3;
37
44
  const BREAKER_RESET_MS = 5 * 60 * 1000; // 5 minutes
38
- const BREAKER_FILE = join(RUNTIME_DIR, 'haiku-breaker.json');
45
+ let breakerFile = join(RUNTIME_DIR, 'haiku-breaker.json');
39
46
 
40
47
  function _readBreakerState() {
41
48
  try {
42
- if (!existsSync(BREAKER_FILE)) return { failures: 0, openUntil: 0 };
43
- return JSON.parse(readFileSync(BREAKER_FILE, 'utf8'));
49
+ if (!existsSync(breakerFile)) return { failures: 0, openUntil: 0 };
50
+ return JSON.parse(readFileSync(breakerFile, 'utf8'));
44
51
  } catch { return { failures: 0, openUntil: 0 }; }
45
52
  }
46
53
 
47
54
  function _writeBreakerState(state) {
48
- try { writeFileSync(BREAKER_FILE, JSON.stringify(state)); } catch {}
55
+ try { writeFileSync(breakerFile, JSON.stringify(state)); } catch {}
49
56
  }
50
57
 
58
+ /** Override breaker file path (for testing isolation). */
59
+ export function _setBreakerFile(path) { breakerFile = path; }
60
+
51
61
  function isHaikuCircuitOpen() {
52
62
  const state = _readBreakerState();
53
63
  if (state.openUntil > 0 && Date.now() < state.openUntil) return true;
@@ -636,25 +646,89 @@ JSON: {"query":"search keywords for finding the right skill or agent","type":"sk
636
646
 
637
647
  // ─── Cooldown & Dedup (DB-persisted, survives process restarts) ─────────────
638
648
 
649
+ /**
650
+ * Check if session has hit the recommendation cap.
651
+ * Separated from per-resource check so callers in filter loops can hoist this.
652
+ * @param {Database} db Registry database
653
+ * @param {string} sessionId Session identifier
654
+ * @returns {boolean} true if session cap is reached
655
+ */
656
+ export function isSessionCapped(db, sessionId) {
657
+ if (!sessionId) return false;
658
+ const sessionCount = db.prepare(
659
+ 'SELECT COUNT(*) as cnt FROM invocations WHERE session_id = ? AND recommended = 1'
660
+ ).get(sessionId);
661
+ return sessionCount.cnt >= SESSION_RECOMMEND_CAP;
662
+ }
663
+
664
+ /**
665
+ * Compute adaptive cooldown based on recent adoption rate.
666
+ * High adoption → shorter cooldown (user welcomes recommendations).
667
+ * Low adoption → longer cooldown (reduce noise).
668
+ * @param {Database} db Registry database
669
+ * @returns {number} Cooldown in minutes
670
+ */
671
+ function getAdaptiveCooldown(db) {
672
+ try {
673
+ const stats = db.prepare(`
674
+ SELECT COUNT(*) as total,
675
+ SUM(CASE WHEN adopted = 1 THEN 1 ELSE 0 END) as adopted
676
+ FROM invocations
677
+ WHERE recommended = 1 AND created_at > datetime('now', '-7 days')
678
+ `).get();
679
+ if (!stats || stats.total < 5) return COOLDOWN_MINUTES; // Not enough data, use default
680
+ const rate = stats.adopted / stats.total;
681
+ if (rate > 0.5) return 30; // High adoption: 30 min
682
+ if (rate > 0.2) return 60; // Medium: 60 min (default)
683
+ if (rate > 0.1) return 120; // Low: 2 hours
684
+ return 240; // Very low: 4 hours
685
+ } catch { return COOLDOWN_MINUTES; }
686
+ }
687
+
688
+ const CONSECUTIVE_REJECT_THRESHOLD = 5;
689
+ const CONSECUTIVE_REJECT_WINDOW_DAYS = 7;
690
+
691
+ /**
692
+ * Check if a resource has been consecutively rejected (not adopted) in recent history.
693
+ * @param {Database} db Registry database
694
+ * @param {number} resourceId Resource ID
695
+ * @returns {boolean} true if resource should be silenced
696
+ */
697
+ function isConsecutivelyRejected(db, resourceId) {
698
+ try {
699
+ const recent = db.prepare(`
700
+ SELECT adopted FROM invocations
701
+ WHERE resource_id = ? AND recommended = 1 AND outcome IS NOT NULL
702
+ AND created_at > datetime('now', '-${CONSECUTIVE_REJECT_WINDOW_DAYS} days')
703
+ ORDER BY created_at DESC
704
+ LIMIT ?
705
+ `).all(resourceId, CONSECUTIVE_REJECT_THRESHOLD);
706
+
707
+ if (recent.length < CONSECUTIVE_REJECT_THRESHOLD) return false;
708
+ return recent.every(r => r.adopted === 0);
709
+ } catch { return false; }
710
+ }
711
+
639
712
  export function isRecentlyRecommended(db, resourceId, sessionId) {
640
- // Check 1 & 2: Session-scoped checks (cap + dedup) only when sessionId is available
713
+ // Check 1: Session cap (loop-invariant callers should prefer isSessionCapped for filter loops)
641
714
  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;
715
+ if (isSessionCapped(db, sessionId)) return true;
646
716
 
647
- // Already recommended in this session (session dedup)
717
+ // Check 2: Already recommended in this session (session dedup)
648
718
  const sessionHit = db.prepare(
649
719
  'SELECT 1 FROM invocations WHERE resource_id = ? AND session_id = ? AND recommended = 1 LIMIT 1'
650
720
  ).get(resourceId, sessionId);
651
721
  if (sessionHit) return true;
652
722
  }
653
723
 
654
- // Check 3: Recommended within cooldown window (cross-session cooldown)
724
+ // Check 3: Consecutive rejection silencing
725
+ if (isConsecutivelyRejected(db, resourceId)) return true;
726
+
727
+ // Check 4: Recommended within adaptive cooldown window (cross-session cooldown)
728
+ const cooldown = getAdaptiveCooldown(db);
655
729
  const cooldownHit = db.prepare(
656
730
  `SELECT 1 FROM invocations WHERE resource_id = ? AND created_at > datetime('now', ?) LIMIT 1`
657
- ).get(resourceId, `-${COOLDOWN_MINUTES} minutes`);
731
+ ).get(resourceId, `-${cooldown} minutes`);
658
732
  return !!cooldownHit;
659
733
  }
660
734
 
@@ -738,14 +812,14 @@ function applyAdoptionDecay(results, db) {
738
812
  * @returns {object[]} Filtered results that pass the gate
739
813
  */
740
814
  function passesConfidenceGate(results, signals) {
741
- // BM25 absolute minimum: filter out weak text matches regardless of intent.
742
- // Only apply when enough results exist (BM25 IDF is unreliable with < 3 matches).
743
- if (results.length >= 3) {
744
- results = results.filter(r => {
745
- const score = Math.abs(r.composite_score ?? r.relevance);
746
- return score >= BM25_MIN_THRESHOLD;
747
- });
748
- }
815
+ // BM25 absolute minimum: filter weak text matches.
816
+ // Stricter threshold for 3+ results (reliable IDF); gentler floor for 1-2 results.
817
+ const minThreshold = results.length >= 3 ? BM25_MIN_THRESHOLD : 0.5;
818
+ results = results.filter(r => {
819
+ const raw = r.composite_score ?? r.relevance;
820
+ if (raw === null || raw === undefined) return true; // no score pass (pre-scored or synthetic result)
821
+ return Math.abs(raw) >= minThreshold;
822
+ });
749
823
 
750
824
  // signals.intent is a comma-separated string (e.g. "test,fix"), not an array
751
825
  const intentTokens = typeof signals?.intent === 'string'
@@ -787,6 +861,36 @@ function postProcessResults(results, signals, db, limit = 3) {
787
861
  return results.slice(0, limit);
788
862
  }
789
863
 
864
+ // ─── Recommendation Reason ──────────────────────────────────────────────────
865
+
866
+ const INTENT_LABELS = {
867
+ test: 'testing', fix: 'debugging', review: 'code review', commit: 'git workflow',
868
+ deploy: 'deployment', plan: 'planning', clean: 'refactoring', doc: 'documentation',
869
+ db: 'database', api: 'API', secure: 'security', infra: 'infrastructure',
870
+ build: 'build tooling', fast: 'performance', lint: 'code style', design: 'UI/frontend',
871
+ };
872
+
873
+ /**
874
+ * Build a brief human-readable reason for why a resource was recommended.
875
+ * @param {object} signals Context signals from extractContextSignals
876
+ * @param {object} [options]
877
+ * @param {boolean} [options.explicit] Whether this was an explicit user request
878
+ * @returns {string} Brief reason string
879
+ */
880
+ function buildRecommendReason(signals, { explicit = false } = {}) {
881
+ if (explicit) return 'Matched your explicit request';
882
+
883
+ const parts = [];
884
+ if (signals?.primaryIntent) {
885
+ const label = INTENT_LABELS[signals.primaryIntent] || signals.primaryIntent;
886
+ parts.push(`${label} intent detected`);
887
+ }
888
+ if (signals?.rawKeywords?.length > 0) {
889
+ parts.push(`keywords: ${signals.rawKeywords.slice(0, 3).join(', ')}`);
890
+ }
891
+ return parts.join('; ') || '';
892
+ }
893
+
790
894
  // ─── Main Dispatch Functions ─────────────────────────────────────────────────
791
895
 
792
896
  /**
@@ -839,7 +943,7 @@ export async function dispatchOnSessionStart(db, userPrompt, sessionId, { hasHan
839
943
  if (needsHaikuDispatch(results)) {
840
944
  tier = 3;
841
945
  const haikuResult = await haikuDispatch(userPrompt, '');
842
- if (haikuResult?.query) {
946
+ if (haikuResult?.query && (haikuResult.confidence ?? 0) >= HAIKU_CONFIDENCE_THRESHOLD) {
843
947
  const haikuQuery = buildQueryFromText(haikuResult.query);
844
948
  if (haikuQuery) {
845
949
  let haikuResults = retrieveResources(db, haikuQuery, {
@@ -857,7 +961,8 @@ export async function dispatchOnSessionStart(db, userPrompt, sessionId, { hasHan
857
961
 
858
962
  if (results.length === 0) return null;
859
963
 
860
- // Filter by DB-persisted cooldown + session dedup
964
+ // Filter by DB-persisted cooldown + session dedup (hoisted cap check avoids N queries)
965
+ if (sessionId && isSessionCapped(db, sessionId)) return null;
861
966
  const viable = sessionId
862
967
  ? results.filter(r => !isRecentlyRecommended(db, r.id, sessionId))
863
968
  : results;
@@ -875,7 +980,7 @@ export async function dispatchOnSessionStart(db, userPrompt, sessionId, { hasHan
875
980
  });
876
981
  updateResourceStats(db, best.id, 'recommend_count');
877
982
 
878
- return renderInjection(best);
983
+ return renderInjection(best, buildRecommendReason(signals));
879
984
  } catch (e) {
880
985
  debugCatch(e, 'dispatchOnSessionStart');
881
986
  return null;
@@ -895,18 +1000,19 @@ export async function dispatchOnUserPrompt(db, userPrompt, sessionId, { sessionE
895
1000
  if (!userPrompt || !db) return null;
896
1001
 
897
1002
  try {
898
- // 1. Explicit request → highest priority, bypass all restrictions
1003
+ // 1. Explicit request → highest priority, bypass cooldown but apply adoption decay
899
1004
  const explicit = detectExplicitRequest(userPrompt);
900
1005
  if (explicit.isExplicit) {
901
1006
  const textQuery = buildQueryFromText(explicit.searchTerm);
902
1007
  if (textQuery) {
903
- const explicitResults = retrieveResources(db, textQuery, { limit: 3, projectDomains: detectProjectDomains() });
1008
+ let explicitResults = retrieveResources(db, textQuery, { limit: 3, projectDomains: detectProjectDomains() });
1009
+ explicitResults = applyAdoptionDecay(explicitResults, db);
904
1010
  if (explicitResults.length > 0) {
905
1011
  const best = explicitResults[0];
906
1012
  if (!sessionId || !isRecentlyRecommended(db, best.id, sessionId)) {
907
1013
  recordInvocation(db, { resource_id: best.id, session_id: sessionId, trigger: 'user_prompt', tier: 1, recommended: 1 });
908
1014
  updateResourceStats(db, best.id, 'recommend_count');
909
- return renderInjection(best);
1015
+ return renderInjection(best, buildRecommendReason(null, { explicit: true }));
910
1016
  }
911
1017
  }
912
1018
  }
@@ -923,7 +1029,7 @@ export async function dispatchOnUserPrompt(db, userPrompt, sessionId, { sessionE
923
1029
 
924
1030
  // Check if active suite covers the current stage
925
1031
  if (activeSuite) {
926
- const currentStage = inferCurrentStage(signals.primaryIntent, activeSuite);
1032
+ const currentStage = inferCurrentStage(signals.primaryIntent, activeSuite, signals.suppressedIntents);
927
1033
  if (currentStage) {
928
1034
  const { shouldRecommend } = shouldRecommendForStage(activeSuite, currentStage);
929
1035
  if (!shouldRecommend) return null;
@@ -960,7 +1066,8 @@ export async function dispatchOnUserPrompt(db, userPrompt, sessionId, { sessionE
960
1066
  // Low confidence → skip (no Haiku in user_prompt path — stay fast)
961
1067
  if (needsHaikuDispatch(results)) return null;
962
1068
 
963
- // Filter by cooldown + session dedup (prevents double-recommend with SessionStart)
1069
+ // Filter by cooldown + session dedup (hoisted cap check avoids N queries)
1070
+ if (sessionId && isSessionCapped(db, sessionId)) return null;
964
1071
  const viable = sessionId
965
1072
  ? results.filter(r => !isRecentlyRecommended(db, r.id, sessionId))
966
1073
  : results;
@@ -977,7 +1084,7 @@ export async function dispatchOnUserPrompt(db, userPrompt, sessionId, { sessionE
977
1084
  });
978
1085
  updateResourceStats(db, best.id, 'recommend_count');
979
1086
 
980
- return renderInjection(best);
1087
+ return renderInjection(best, buildRecommendReason(signals));
981
1088
  } catch (e) {
982
1089
  debugCatch(e, 'dispatchOnUserPrompt');
983
1090
  return null;
@@ -1007,13 +1114,17 @@ export async function dispatchOnPreToolUse(db, event, sessionCtx = {}) {
1007
1114
  const events = peekToolEvents();
1008
1115
  const activeSuite = detectActiveSuite(events);
1009
1116
  if (activeSuite) {
1010
- const stage = inferCurrentStage(signals.primaryIntent, activeSuite);
1117
+ const stage = inferCurrentStage(signals.primaryIntent, activeSuite, signals.suppressedIntents);
1011
1118
  if (stage) {
1012
1119
  const { shouldRecommend } = shouldRecommendForStage(activeSuite, stage);
1013
1120
  if (!shouldRecommend) return null;
1014
1121
  }
1015
1122
  }
1016
- const query = buildEnhancedQuery(signals);
1123
+ let query = buildEnhancedQuery(signals);
1124
+ if (!query && sessionCtx?.userPrompt) {
1125
+ query = buildQueryFromText(sessionCtx.userPrompt);
1126
+ if (!query) return null;
1127
+ }
1017
1128
  if (!query) return null;
1018
1129
 
1019
1130
  const projectDomains = detectProjectDomains();
@@ -1028,8 +1139,9 @@ export async function dispatchOnPreToolUse(db, event, sessionCtx = {}) {
1028
1139
  // Low-confidence results: skip recommendation rather than suggest unreliable match
1029
1140
  if (needsHaikuDispatch(results)) return null;
1030
1141
 
1031
- // Apply DB-persisted cooldown and session dedup (filter all, not just top)
1142
+ // Apply DB-persisted cooldown and session dedup (hoisted cap check avoids N queries)
1032
1143
  const sid = sessionCtx.sessionId || null;
1144
+ if (sid && isSessionCapped(db, sid)) return null;
1033
1145
  const viable = sid
1034
1146
  ? results.filter(r => !isRecentlyRecommended(db, r.id, sid))
1035
1147
  : results;
@@ -1046,7 +1158,7 @@ export async function dispatchOnPreToolUse(db, event, sessionCtx = {}) {
1046
1158
  });
1047
1159
  updateResourceStats(db, best.id, 'recommend_count');
1048
1160
 
1049
- return renderInjection(best);
1161
+ return renderInjection(best, buildRecommendReason(signals));
1050
1162
  } catch (e) {
1051
1163
  debugCatch(e, 'dispatchOnPreToolUse');
1052
1164
  return null;
package/hook-context.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { join } from 'path';
5
5
  import { readFileSync, writeFileSync, renameSync } from 'fs';
6
- import { estimateTokens, debugLog, debugCatch } from './utils.mjs';
6
+ import { estimateTokens, truncate, debugLog, debugCatch } from './utils.mjs';
7
7
 
8
8
  /**
9
9
  * Infer the project directory from environment variables or cwd.
@@ -173,3 +173,34 @@ export function updateClaudeMd(contextBlock) {
173
173
  debugLog('ERROR', 'updateClaudeMd', `CLAUDE.md write failed: ${e.message}`);
174
174
  }
175
175
  }
176
+
177
+ /**
178
+ * Build summary lines from a latestSummary row.
179
+ * Extracted for testability — used by handleSessionStart.
180
+ * @param {object} latestSummary Row from session_summaries with request, completed, etc.
181
+ * @returns {string[]} Lines to include in context output
182
+ */
183
+ export function buildSummaryLines(latestSummary) {
184
+ const lines = [];
185
+ if (!latestSummary) return lines;
186
+
187
+ lines.push('### Last Session');
188
+ if (latestSummary.request) lines.push(`Request: ${truncate(latestSummary.request, 120)}`);
189
+ if (latestSummary.completed) lines.push(`Completed: ${truncate(latestSummary.completed, 120)}`);
190
+ if (latestSummary.remaining_items) lines.push(`Remaining: ${truncate(latestSummary.remaining_items, 120)}`);
191
+ if (latestSummary.next_steps) lines.push(`Next: ${truncate(latestSummary.next_steps, 120)}`);
192
+ if (latestSummary.lessons) {
193
+ try {
194
+ const lessons = JSON.parse(latestSummary.lessons);
195
+ if (lessons.length > 0) lines.push(`Lessons: ${lessons.slice(0, 3).join('; ')}`);
196
+ } catch {}
197
+ }
198
+ if (latestSummary.key_decisions) {
199
+ try {
200
+ const decisions = JSON.parse(latestSummary.key_decisions);
201
+ if (decisions.length > 0) lines.push(`Decisions: ${decisions.slice(0, 3).join('; ')}`);
202
+ } catch {}
203
+ }
204
+ lines.push('');
205
+ return lines;
206
+ }
package/hook-handoff.mjs CHANGED
@@ -116,6 +116,14 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
116
116
  * @returns {boolean}
117
117
  */
118
118
  export function detectContinuationIntent(db, promptText, project) {
119
+ // Stage 0: Non-expired 'clear' handoff = always continue (/clear means user is resuming)
120
+ const clearHandoff = db.prepare(`
121
+ SELECT created_at_epoch FROM session_handoffs WHERE project = ? AND type = 'clear'
122
+ `).get(project);
123
+ if (clearHandoff && (Date.now() - clearHandoff.created_at_epoch <= HANDOFF_EXPIRY_CLEAR)) {
124
+ return true;
125
+ }
126
+
119
127
  // Stage 1: Explicit keyword match — always works, even without handoff
120
128
  if (CONTINUE_KEYWORDS.test(promptText)) return true;
121
129
 
@@ -220,3 +228,22 @@ export function renderHandoffInjection(db, project) {
220
228
 
221
229
  return lines.join('\n');
222
230
  }
231
+
232
+ // Separator used by buildAndSaveHandoff to join pending entries with narrative history.
233
+ const UNFINISHED_NARRATIVE_SEP = '\n---\n';
234
+ const UNFINISHED_ENTRY_SEP = '; ';
235
+
236
+ /**
237
+ * Extract the pending-work portion of the unfinished field (before narrative history).
238
+ * @param {string} unfinished Raw unfinished text from session_handoffs
239
+ * @param {number} [maxItems=3] Max number of pending entries to return
240
+ * @returns {string} Pending work summary (empty string if none)
241
+ */
242
+ export function extractUnfinishedSummary(unfinished, maxItems = 3) {
243
+ if (!unfinished) return '';
244
+ const pending = unfinished.split(UNFINISHED_NARRATIVE_SEP)[0];
245
+ if (maxItems > 0) {
246
+ return pending.split(UNFINISHED_ENTRY_SEP).slice(0, maxItems).join(UNFINISHED_ENTRY_SEP);
247
+ }
248
+ return pending;
249
+ }