claude-mem-lite 2.3.3 → 2.5.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/plugin.json +1 -1
- package/dispatch-feedback.mjs +45 -0
- package/dispatch-inject.mjs +4 -1
- package/dispatch-workflow.mjs +20 -5
- package/dispatch.mjs +108 -18
- package/haiku-client.mjs +1 -0
- 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 +2 -1
- package/hook.mjs +65 -35
- package/install.mjs +430 -1
- package/package.json +1 -1
- package/registry-indexer.mjs +4 -1
- package/registry.mjs +12 -2
- package/schema.mjs +4 -0
- package/server-internals.mjs +68 -1
- package/server.mjs +22 -30
- package/utils.mjs +64 -0
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
|
@@ -143,9 +143,10 @@ ${truncatedDef}
|
|
|
143
143
|
* Enforces MAX_INJECTION_CHARS hard limit.
|
|
144
144
|
*
|
|
145
145
|
* @param {object} resource Resource object from DB
|
|
146
|
+
* @param {string} [reason] Brief reason why this resource was recommended
|
|
146
147
|
* @returns {string} Injection text for additionalContext
|
|
147
148
|
*/
|
|
148
|
-
export function renderInjection(resource) {
|
|
149
|
+
export function renderInjection(resource, reason) {
|
|
149
150
|
let injection;
|
|
150
151
|
|
|
151
152
|
if (resource.type === 'skill') {
|
|
@@ -162,6 +163,8 @@ export function renderInjection(resource) {
|
|
|
162
163
|
injection = injectAgent(resource);
|
|
163
164
|
}
|
|
164
165
|
|
|
166
|
+
if (reason) injection += `\nReason: ${reason}`;
|
|
167
|
+
|
|
165
168
|
// Hard limit enforcement
|
|
166
169
|
if (injection.length > MAX_INJECTION_CHARS) {
|
|
167
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,6 +29,10 @@ 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.
|
|
@@ -657,6 +661,54 @@ export function isSessionCapped(db, sessionId) {
|
|
|
657
661
|
return sessionCount.cnt >= SESSION_RECOMMEND_CAP;
|
|
658
662
|
}
|
|
659
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
|
+
|
|
660
712
|
export function isRecentlyRecommended(db, resourceId, sessionId) {
|
|
661
713
|
// Check 1: Session cap (loop-invariant — callers should prefer isSessionCapped for filter loops)
|
|
662
714
|
if (sessionId) {
|
|
@@ -669,10 +721,14 @@ export function isRecentlyRecommended(db, resourceId, sessionId) {
|
|
|
669
721
|
if (sessionHit) return true;
|
|
670
722
|
}
|
|
671
723
|
|
|
672
|
-
// 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);
|
|
673
729
|
const cooldownHit = db.prepare(
|
|
674
730
|
`SELECT 1 FROM invocations WHERE resource_id = ? AND created_at > datetime('now', ?) LIMIT 1`
|
|
675
|
-
).get(resourceId, `-${
|
|
731
|
+
).get(resourceId, `-${cooldown} minutes`);
|
|
676
732
|
return !!cooldownHit;
|
|
677
733
|
}
|
|
678
734
|
|
|
@@ -756,14 +812,14 @@ function applyAdoptionDecay(results, db) {
|
|
|
756
812
|
* @returns {object[]} Filtered results that pass the gate
|
|
757
813
|
*/
|
|
758
814
|
function passesConfidenceGate(results, signals) {
|
|
759
|
-
// BM25 absolute minimum: filter
|
|
760
|
-
//
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
}
|
|
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
|
+
});
|
|
767
823
|
|
|
768
824
|
// signals.intent is a comma-separated string (e.g. "test,fix"), not an array
|
|
769
825
|
const intentTokens = typeof signals?.intent === 'string'
|
|
@@ -805,6 +861,36 @@ function postProcessResults(results, signals, db, limit = 3) {
|
|
|
805
861
|
return results.slice(0, limit);
|
|
806
862
|
}
|
|
807
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
|
+
|
|
808
894
|
// ─── Main Dispatch Functions ─────────────────────────────────────────────────
|
|
809
895
|
|
|
810
896
|
/**
|
|
@@ -857,7 +943,7 @@ export async function dispatchOnSessionStart(db, userPrompt, sessionId, { hasHan
|
|
|
857
943
|
if (needsHaikuDispatch(results)) {
|
|
858
944
|
tier = 3;
|
|
859
945
|
const haikuResult = await haikuDispatch(userPrompt, '');
|
|
860
|
-
if (haikuResult?.query) {
|
|
946
|
+
if (haikuResult?.query && (haikuResult.confidence ?? 0) >= HAIKU_CONFIDENCE_THRESHOLD) {
|
|
861
947
|
const haikuQuery = buildQueryFromText(haikuResult.query);
|
|
862
948
|
if (haikuQuery) {
|
|
863
949
|
let haikuResults = retrieveResources(db, haikuQuery, {
|
|
@@ -894,7 +980,7 @@ export async function dispatchOnSessionStart(db, userPrompt, sessionId, { hasHan
|
|
|
894
980
|
});
|
|
895
981
|
updateResourceStats(db, best.id, 'recommend_count');
|
|
896
982
|
|
|
897
|
-
return renderInjection(best);
|
|
983
|
+
return renderInjection(best, buildRecommendReason(signals));
|
|
898
984
|
} catch (e) {
|
|
899
985
|
debugCatch(e, 'dispatchOnSessionStart');
|
|
900
986
|
return null;
|
|
@@ -926,7 +1012,7 @@ export async function dispatchOnUserPrompt(db, userPrompt, sessionId, { sessionE
|
|
|
926
1012
|
if (!sessionId || !isRecentlyRecommended(db, best.id, sessionId)) {
|
|
927
1013
|
recordInvocation(db, { resource_id: best.id, session_id: sessionId, trigger: 'user_prompt', tier: 1, recommended: 1 });
|
|
928
1014
|
updateResourceStats(db, best.id, 'recommend_count');
|
|
929
|
-
return renderInjection(best);
|
|
1015
|
+
return renderInjection(best, buildRecommendReason(null, { explicit: true }));
|
|
930
1016
|
}
|
|
931
1017
|
}
|
|
932
1018
|
}
|
|
@@ -943,7 +1029,7 @@ export async function dispatchOnUserPrompt(db, userPrompt, sessionId, { sessionE
|
|
|
943
1029
|
|
|
944
1030
|
// Check if active suite covers the current stage
|
|
945
1031
|
if (activeSuite) {
|
|
946
|
-
const currentStage = inferCurrentStage(signals.primaryIntent, activeSuite);
|
|
1032
|
+
const currentStage = inferCurrentStage(signals.primaryIntent, activeSuite, signals.suppressedIntents);
|
|
947
1033
|
if (currentStage) {
|
|
948
1034
|
const { shouldRecommend } = shouldRecommendForStage(activeSuite, currentStage);
|
|
949
1035
|
if (!shouldRecommend) return null;
|
|
@@ -998,7 +1084,7 @@ export async function dispatchOnUserPrompt(db, userPrompt, sessionId, { sessionE
|
|
|
998
1084
|
});
|
|
999
1085
|
updateResourceStats(db, best.id, 'recommend_count');
|
|
1000
1086
|
|
|
1001
|
-
return renderInjection(best);
|
|
1087
|
+
return renderInjection(best, buildRecommendReason(signals));
|
|
1002
1088
|
} catch (e) {
|
|
1003
1089
|
debugCatch(e, 'dispatchOnUserPrompt');
|
|
1004
1090
|
return null;
|
|
@@ -1028,13 +1114,17 @@ export async function dispatchOnPreToolUse(db, event, sessionCtx = {}) {
|
|
|
1028
1114
|
const events = peekToolEvents();
|
|
1029
1115
|
const activeSuite = detectActiveSuite(events);
|
|
1030
1116
|
if (activeSuite) {
|
|
1031
|
-
const stage = inferCurrentStage(signals.primaryIntent, activeSuite);
|
|
1117
|
+
const stage = inferCurrentStage(signals.primaryIntent, activeSuite, signals.suppressedIntents);
|
|
1032
1118
|
if (stage) {
|
|
1033
1119
|
const { shouldRecommend } = shouldRecommendForStage(activeSuite, stage);
|
|
1034
1120
|
if (!shouldRecommend) return null;
|
|
1035
1121
|
}
|
|
1036
1122
|
}
|
|
1037
|
-
|
|
1123
|
+
let query = buildEnhancedQuery(signals);
|
|
1124
|
+
if (!query && sessionCtx?.userPrompt) {
|
|
1125
|
+
query = buildQueryFromText(sessionCtx.userPrompt);
|
|
1126
|
+
if (!query) return null;
|
|
1127
|
+
}
|
|
1038
1128
|
if (!query) return null;
|
|
1039
1129
|
|
|
1040
1130
|
const projectDomains = detectProjectDomains();
|
|
@@ -1068,7 +1158,7 @@ export async function dispatchOnPreToolUse(db, event, sessionCtx = {}) {
|
|
|
1068
1158
|
});
|
|
1069
1159
|
updateResourceStats(db, best.id, 'recommend_count');
|
|
1070
1160
|
|
|
1071
|
-
return renderInjection(best);
|
|
1161
|
+
return renderInjection(best, buildRecommendReason(signals));
|
|
1072
1162
|
} catch (e) {
|
|
1073
1163
|
debugCatch(e, 'dispatchOnPreToolUse');
|
|
1074
1164
|
return null;
|
package/haiku-client.mjs
CHANGED
|
@@ -150,6 +150,7 @@ function callHaikuCLI(prompt, { timeout }) {
|
|
|
150
150
|
encoding: 'utf8',
|
|
151
151
|
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1' },
|
|
152
152
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
153
|
+
cwd: '/tmp', // Prevent ghost sessions in user's /resume list
|
|
153
154
|
});
|
|
154
155
|
const text = result.trim();
|
|
155
156
|
return text ? { text } : 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
|
+
}
|
package/hook-llm.mjs
CHANGED
|
@@ -16,12 +16,13 @@ import {
|
|
|
16
16
|
|
|
17
17
|
// ─── Save Observation to DB ─────────────────────────────────────────────────
|
|
18
18
|
|
|
19
|
-
/** Build the FTS5 text field from observation data (concepts + facts + CJK bigrams). */
|
|
19
|
+
/** Build the FTS5 text field from observation data (concepts + facts + searchAliases + CJK bigrams). */
|
|
20
20
|
function buildFtsTextField(obs) {
|
|
21
21
|
const conceptsText = Array.isArray(obs.concepts) ? obs.concepts.join(' ') : '';
|
|
22
22
|
const factsText = Array.isArray(obs.facts) ? obs.facts.join(' ') : '';
|
|
23
|
+
const aliasesText = obs.searchAliases || '';
|
|
23
24
|
const bigramText = cjkBigrams((obs.title || '') + ' ' + (obs.narrative || ''));
|
|
24
|
-
return { conceptsText, factsText, textField: [conceptsText, factsText, bigramText].filter(Boolean).join(' ') };
|
|
25
|
+
return { conceptsText, factsText, textField: [conceptsText, factsText, aliasesText, bigramText].filter(Boolean).join(' ') };
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
export function saveObservation(obs, projectOverride, sessionIdOverride, externalDb) {
|
|
@@ -69,8 +70,8 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
69
70
|
const { conceptsText, factsText, textField } = buildFtsTextField(obs);
|
|
70
71
|
|
|
71
72
|
const result = db.prepare(`
|
|
72
|
-
INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, minhash_sig, created_at, created_at_epoch)
|
|
73
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
73
|
+
INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, minhash_sig, lesson_learned, search_aliases, created_at, created_at_epoch)
|
|
74
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
74
75
|
`).run(
|
|
75
76
|
sessionId, project,
|
|
76
77
|
textField, obs.type, obs.title, obs.subtitle || '',
|
|
@@ -81,6 +82,8 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
81
82
|
JSON.stringify(obs.files || []),
|
|
82
83
|
obs.importance ?? 1,
|
|
83
84
|
minhashSig,
|
|
85
|
+
obs.lessonLearned || null,
|
|
86
|
+
obs.searchAliases || null,
|
|
84
87
|
now.toISOString(), now.getTime()
|
|
85
88
|
);
|
|
86
89
|
return Number(result.lastInsertRowid);
|
|
@@ -265,9 +268,11 @@ File: ${episode.files.join(', ') || 'unknown'}
|
|
|
265
268
|
Action: ${e.desc}
|
|
266
269
|
Error: ${e.isError ? 'yes' : 'no'}
|
|
267
270
|
|
|
268
|
-
JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"concise ≤80 char description","narrative":"what changed, why, and outcome (2-3 sentences)","concepts":["kw1","kw2"],"facts":["fact1","fact2"],"importance":1}
|
|
271
|
+
JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"concise ≤80 char description","narrative":"what changed, why, and outcome (2-3 sentences)","concepts":["kw1","kw2"],"facts":["fact1","fact2"],"importance":1,"lesson_learned":"non-obvious insight or null if routine","search_aliases":["alt query 1","alt query 2"]}
|
|
269
272
|
Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
|
|
270
|
-
importance: 0=not worth saving (pure browsing, trivial query, no learning value), 1=routine, 2=notable (error fix, arch decision, config change), 3=critical (breaking change, security fix, data migration)
|
|
273
|
+
importance: 0=not worth saving (pure browsing, trivial query, no learning value), 1=routine, 2=notable (error fix, arch decision, config change), 3=critical (breaking change, security fix, data migration)
|
|
274
|
+
lesson_learned: If this episode revealed something NON-OBVIOUS (a debugging insight, a gotcha, a design reason), capture it as a reusable lesson. null if routine.
|
|
275
|
+
search_aliases: 2-6 alternative search terms someone might use to find this memory later (include CJK if project uses Chinese)`;
|
|
271
276
|
} else {
|
|
272
277
|
const actionList = episode.entries.map((e, i) =>
|
|
273
278
|
`${i + 1}. [${e.tool}] ${e.desc}${e.isError ? ' (ERROR)' : ''}`
|
|
@@ -280,9 +285,11 @@ Files: ${fileList}
|
|
|
280
285
|
Actions (${episode.entries.length} total):
|
|
281
286
|
${actionList}
|
|
282
287
|
|
|
283
|
-
JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"coherent ≤80 char summary","narrative":"what was done, why, and outcome (3-5 sentences)","concepts":["keyword1","keyword2"],"facts":["specific fact 1","specific fact 2"],"importance":1}
|
|
288
|
+
JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"coherent ≤80 char summary","narrative":"what was done, why, and outcome (3-5 sentences)","concepts":["keyword1","keyword2"],"facts":["specific fact 1","specific fact 2"],"importance":1,"lesson_learned":"non-obvious insight or null if routine","search_aliases":["alt query 1","alt query 2"]}
|
|
284
289
|
Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
|
|
285
|
-
importance: 0=not worth saving (pure browsing, trivial query, no learning value), 1=routine, 2=notable (error fix, arch decision, config change), 3=critical (breaking change, security fix, data migration)
|
|
290
|
+
importance: 0=not worth saving (pure browsing, trivial query, no learning value), 1=routine, 2=notable (error fix, arch decision, config change), 3=critical (breaking change, security fix, data migration)
|
|
291
|
+
lesson_learned: If this episode revealed something NON-OBVIOUS (a debugging insight, a gotcha, a design reason), capture it as a reusable lesson. null if routine.
|
|
292
|
+
search_aliases: 2-6 alternative search terms someone might use to find this memory later (include CJK if project uses Chinese)`;
|
|
286
293
|
}
|
|
287
294
|
|
|
288
295
|
const ruleImportance = computeRuleImportance(episode);
|
|
@@ -316,6 +323,11 @@ importance: 0=not worth saving (pure browsing, trivial query, no learning value)
|
|
|
316
323
|
return;
|
|
317
324
|
}
|
|
318
325
|
|
|
326
|
+
const lessonLearned = typeof parsed.lesson_learned === 'string' ? parsed.lesson_learned.slice(0, 500) : null;
|
|
327
|
+
const searchAliases = Array.isArray(parsed.search_aliases)
|
|
328
|
+
? parsed.search_aliases.slice(0, 6).join(' ')
|
|
329
|
+
: null;
|
|
330
|
+
|
|
319
331
|
obs = {
|
|
320
332
|
type: validTypes.has(parsed.type) ? parsed.type : 'change',
|
|
321
333
|
title: truncate(parsed.title, 120),
|
|
@@ -326,6 +338,8 @@ importance: 0=not worth saving (pure browsing, trivial query, no learning value)
|
|
|
326
338
|
files: episode.files,
|
|
327
339
|
filesRead: episode.filesRead || [],
|
|
328
340
|
importance: Math.max(ruleImportance, clampImportance(parsed.importance)),
|
|
341
|
+
lessonLearned,
|
|
342
|
+
searchAliases,
|
|
329
343
|
};
|
|
330
344
|
}
|
|
331
345
|
}
|
|
@@ -353,7 +367,7 @@ importance: 0=not worth saving (pure browsing, trivial query, no learning value)
|
|
|
353
367
|
const minhashSig = computeMinHash((obs.title || '') + ' ' + (obs.narrative || ''));
|
|
354
368
|
db.prepare(`
|
|
355
369
|
UPDATE observations SET type=?, title=?, subtitle=?, narrative=?, concepts=?, facts=?,
|
|
356
|
-
text=?, importance=?, files_read=?, minhash_sig=?
|
|
370
|
+
text=?, importance=?, files_read=?, minhash_sig=?, lesson_learned=?, search_aliases=?
|
|
357
371
|
WHERE id = ?
|
|
358
372
|
`).run(
|
|
359
373
|
obs.type, truncate(obs.title, 120), obs.subtitle || '',
|
|
@@ -362,6 +376,8 @@ importance: 0=not worth saving (pure browsing, trivial query, no learning value)
|
|
|
362
376
|
obs.importance,
|
|
363
377
|
JSON.stringify(obs.filesRead || []),
|
|
364
378
|
minhashSig,
|
|
379
|
+
obs.lessonLearned || null,
|
|
380
|
+
obs.searchAliases || null,
|
|
365
381
|
episode.savedId
|
|
366
382
|
);
|
|
367
383
|
savedId = episode.savedId;
|
|
@@ -432,7 +448,9 @@ Project: ${project}${promptCtx}
|
|
|
432
448
|
Observations (${recentObs.length} total):
|
|
433
449
|
${obsList}
|
|
434
450
|
|
|
435
|
-
JSON: {"request":"what the user was working on","completed":"specific items accomplished with file names","remaining_items":"specific unfinished items from the original request — compare investigation scope with actual changes to infer what was NOT yet done; be precise with file:issue format, or empty string if all done","next_steps":"suggested follow-up"}
|
|
451
|
+
JSON: {"request":"what the user was working on","completed":"specific items accomplished with file names","remaining_items":"specific unfinished items from the original request — compare investigation scope with actual changes to infer what was NOT yet done; be precise with file:issue format, or empty string if all done","next_steps":"suggested follow-up","lessons":["non-obvious insights discovered during this session"],"key_decisions":["important design choices made and WHY"]}
|
|
452
|
+
lessons: Only genuinely non-obvious insights (debugging discoveries, gotchas, architectural reasons). Empty array if routine.
|
|
453
|
+
key_decisions: Only decisions with lasting impact (library choices, architecture, data model). Include reasoning. Empty array if none.`;
|
|
436
454
|
|
|
437
455
|
if (!(await acquireLLMSlot())) {
|
|
438
456
|
debugLog('WARN', 'llm-summary', 'semaphore timeout, skipping summary');
|
|
@@ -449,14 +467,19 @@ JSON: {"request":"what the user was working on","completed":"specific items acco
|
|
|
449
467
|
|
|
450
468
|
if (llmParsed && llmParsed.request) {
|
|
451
469
|
const now = new Date();
|
|
470
|
+
const lessonsJson = Array.isArray(llmParsed.lessons) && llmParsed.lessons.length > 0
|
|
471
|
+
? JSON.stringify(llmParsed.lessons) : null;
|
|
472
|
+
const decisionsJson = Array.isArray(llmParsed.key_decisions) && llmParsed.key_decisions.length > 0
|
|
473
|
+
? JSON.stringify(llmParsed.key_decisions) : null;
|
|
452
474
|
db.prepare(`
|
|
453
|
-
INSERT INTO session_summaries (memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, created_at, created_at_epoch)
|
|
454
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', '', ?, ?)
|
|
475
|
+
INSERT INTO session_summaries (memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, lessons, key_decisions, created_at, created_at_epoch)
|
|
476
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', '', ?, ?, ?, ?)
|
|
455
477
|
`).run(
|
|
456
478
|
sessionId, project,
|
|
457
479
|
llmParsed.request || '', llmParsed.investigated || '', llmParsed.learned || '',
|
|
458
480
|
llmParsed.completed || '', llmParsed.next_steps || '',
|
|
459
481
|
llmParsed.remaining_items || '',
|
|
482
|
+
lessonsJson, decisionsJson,
|
|
460
483
|
now.toISOString(), now.getTime()
|
|
461
484
|
);
|
|
462
485
|
}
|