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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.mcp.json +2 -7
- package/commands/mem.md +7 -0
- package/commands/update.md +2 -1
- package/dispatch-feedback.mjs +45 -0
- package/dispatch-inject.mjs +9 -5
- package/dispatch-workflow.mjs +20 -5
- package/dispatch.mjs +145 -33
- package/hook-context.mjs +32 -1
- package/hook-handoff.mjs +27 -0
- package/hook-llm.mjs +35 -12
- package/hook-memory.mjs +44 -7
- package/hook-shared.mjs +11 -7
- package/hook.mjs +71 -40
- package/hooks/hooks.json +1 -1
- package/install.mjs +869 -11
- package/package.json +1 -1
- package/registry/preinstalled.json +0 -13
- package/registry-indexer.mjs +4 -1
- package/registry-retriever.mjs +0 -3
- package/registry.mjs +13 -3
- package/schema.mjs +5 -0
- package/scripts/setup.sh +20 -1
- package/server-internals.mjs +68 -1
- package/server.mjs +163 -177
- package/tool-schemas.mjs +4 -2
- package/utils.mjs +74 -2
package/.mcp.json
CHANGED
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.
|
package/commands/update.md
CHANGED
|
@@ -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
|
|
package/dispatch-feedback.mjs
CHANGED
|
@@ -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
|
package/dispatch-inject.mjs
CHANGED
|
@@ -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
|
-
|
|
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) + '...';
|
package/dispatch-workflow.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
+
let breakerFile = join(RUNTIME_DIR, 'haiku-breaker.json');
|
|
39
46
|
|
|
40
47
|
function _readBreakerState() {
|
|
41
48
|
try {
|
|
42
|
-
if (!existsSync(
|
|
43
|
-
return JSON.parse(readFileSync(
|
|
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(
|
|
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
|
|
713
|
+
// Check 1: Session cap (loop-invariant — callers should prefer isSessionCapped for filter loops)
|
|
641
714
|
if (sessionId) {
|
|
642
|
-
|
|
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:
|
|
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, `-${
|
|
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
|
|
742
|
-
//
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
+
}
|