claude-mem-lite 2.5.4 → 2.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.mcp.json +0 -0
- package/LICENSE +0 -0
- package/README.md +0 -0
- package/README.zh-CN.md +0 -0
- package/commands/mem.md +0 -0
- package/commands/memory.md +0 -0
- package/commands/tools.md +0 -0
- package/commands/update.md +0 -0
- package/dispatch-feedback.mjs +129 -24
- package/dispatch-inject.mjs +73 -34
- package/dispatch-patterns.mjs +173 -0
- package/dispatch-workflow.mjs +0 -0
- package/dispatch.mjs +359 -271
- package/haiku-client.mjs +0 -0
- package/hook-context.mjs +24 -6
- package/hook-episode.mjs +2 -2
- package/hook-handoff.mjs +38 -18
- package/hook-llm.mjs +98 -21
- package/hook-memory.mjs +47 -15
- package/hook-semaphore.mjs +0 -0
- package/hook-shared.mjs +21 -0
- package/hook-update.mjs +262 -0
- package/hook.mjs +165 -28
- package/hooks/hooks.json +0 -0
- package/install.mjs +149 -4
- package/package.json +3 -1
- package/registry/preinstalled.json +13 -0
- package/registry-indexer.mjs +0 -0
- package/registry-retriever.mjs +13 -8
- package/registry-scanner.mjs +0 -0
- package/registry.mjs +15 -7
- package/resource-discovery.mjs +0 -0
- package/schema.mjs +0 -0
- package/scripts/launch.mjs +0 -0
- package/server-internals.mjs +0 -0
- package/server.mjs +58 -13
- package/skill.md +0 -0
- package/tool-schemas.mjs +41 -16
- package/utils.mjs +87 -30
package/haiku-client.mjs
CHANGED
|
File without changes
|
package/hook-context.mjs
CHANGED
|
@@ -58,7 +58,7 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
|
|
|
58
58
|
|
|
59
59
|
// Candidate pool: tiered time windows by importance (adaptive)
|
|
60
60
|
const obsPool = db.prepare(`
|
|
61
|
-
SELECT id, type, title, narrative, importance, created_at_epoch, files_modified
|
|
61
|
+
SELECT id, type, title, narrative, importance, created_at_epoch, files_modified, lesson_learned
|
|
62
62
|
FROM observations
|
|
63
63
|
WHERE project = ? AND COALESCE(compressed_into, 0) = 0
|
|
64
64
|
AND (
|
|
@@ -87,7 +87,8 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
|
|
|
87
87
|
const ageDays = (now_ms - o.created_at_epoch) / 86400000;
|
|
88
88
|
const recency = 1 / (1 + ageDays);
|
|
89
89
|
const impBoost = 0.5 + 0.5 * (o.importance || 1);
|
|
90
|
-
const
|
|
90
|
+
const lessonBoost = o.lesson_learned ? 1.3 : 1.0;
|
|
91
|
+
const value = recency * impBoost * lessonBoost;
|
|
91
92
|
const cost = estimateTokens((o.title || '') + (o.narrative || ''));
|
|
92
93
|
return { ...o, value, cost, valueDensity: cost > 0 ? value / Math.sqrt(cost) : 0 };
|
|
93
94
|
});
|
|
@@ -107,10 +108,17 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
|
|
|
107
108
|
].sort((a, b) => b.valueDensity - a.valueDensity);
|
|
108
109
|
|
|
109
110
|
const selectedFiles = new Set();
|
|
111
|
+
const selectedTypes = new Map(); // type → count for diversity constraint
|
|
110
112
|
|
|
111
113
|
for (const c of allCandidates) {
|
|
112
114
|
if (totalTokens + c.cost > budget) continue;
|
|
113
115
|
|
|
116
|
+
// Type diversity: max 3 observations of same type (checked first to avoid file set pollution)
|
|
117
|
+
if (c._kind === 'obs' && c.type) {
|
|
118
|
+
const typeCount = selectedTypes.get(c.type) || 0;
|
|
119
|
+
if (typeCount >= 3) continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
114
122
|
// Diversity penalty: reduce value for file overlap with already-selected
|
|
115
123
|
if (c._kind === 'obs' && c.files_modified) {
|
|
116
124
|
let cFiles;
|
|
@@ -119,11 +127,16 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
|
|
|
119
127
|
const overlap = cFiles.filter(f => selectedFiles.has(f)).length;
|
|
120
128
|
const overlapRatio = overlap / cFiles.length;
|
|
121
129
|
const penalizedValue = c.valueDensity * (1 - 0.3 * overlapRatio);
|
|
122
|
-
if (penalizedValue < 0.001) continue;
|
|
130
|
+
if (penalizedValue < 0.001) continue;
|
|
123
131
|
}
|
|
124
132
|
for (const f of cFiles) selectedFiles.add(f);
|
|
125
133
|
}
|
|
126
134
|
|
|
135
|
+
// Commit type diversity counter after both gates pass
|
|
136
|
+
if (c._kind === 'obs' && c.type) {
|
|
137
|
+
selectedTypes.set(c.type, (selectedTypes.get(c.type) || 0) + 1);
|
|
138
|
+
}
|
|
139
|
+
|
|
127
140
|
totalTokens += c.cost;
|
|
128
141
|
if (c._kind === 'obs') {
|
|
129
142
|
selectedObs.push({ id: c.id, type: c.type, title: c.title, created_at: new Date(c.created_at_epoch).toISOString() });
|
|
@@ -151,11 +164,16 @@ export function updateClaudeMd(contextBlock) {
|
|
|
151
164
|
const hintComment = '<!-- claude-mem-lite: auto-updated context. To avoid git noise, add CLAUDE.md to .gitignore -->';
|
|
152
165
|
const newSection = `${startTag}\n${contextBlock}\n${endTag}`;
|
|
153
166
|
|
|
154
|
-
|
|
155
|
-
|
|
167
|
+
// Use lastIndexOf for both tags — prevents matching documentation references
|
|
168
|
+
// to <claude-mem-context> that appear in code/markdown before the actual context block
|
|
169
|
+
const startIdx = content.lastIndexOf(startTag);
|
|
170
|
+
const endIdx = content.lastIndexOf(endTag);
|
|
156
171
|
|
|
157
172
|
if (startIdx !== -1 && endIdx !== -1 && startIdx < endIdx) {
|
|
158
|
-
//
|
|
173
|
+
// Skip write if content is unchanged — reduces git noise
|
|
174
|
+
const existingSection = content.slice(startIdx, endIdx + endTag.length);
|
|
175
|
+
if (existingSection === newSection) return;
|
|
176
|
+
// Replace from first start to last end — collapses any duplicate sections into one
|
|
159
177
|
content = content.slice(0, startIdx) + newSection + content.slice(endIdx + endTag.length);
|
|
160
178
|
} else if (content.length > 0) {
|
|
161
179
|
// Append to end — never disturb existing CLAUDE.md structure
|
package/hook-episode.mjs
CHANGED
|
@@ -211,7 +211,7 @@ export function mergePendingEntries(episode) {
|
|
|
211
211
|
/**
|
|
212
212
|
* Check if an episode has significant content worth processing with LLM.
|
|
213
213
|
* Significant = contains file edits, Bash errors, or a review/research pattern
|
|
214
|
-
* (
|
|
214
|
+
* (8+ Read/Grep entries indicate investigation worth recording).
|
|
215
215
|
* @param {object} episode The episode to check
|
|
216
216
|
* @returns {boolean} true if the episode has significant content
|
|
217
217
|
*/
|
|
@@ -236,5 +236,5 @@ export function episodeHasSignificantContent(episode) {
|
|
|
236
236
|
const readCount = episode.entries.filter(e =>
|
|
237
237
|
e.tool === 'Read' || e.tool === 'Grep'
|
|
238
238
|
).length;
|
|
239
|
-
return readCount >=
|
|
239
|
+
return readCount >= 8;
|
|
240
240
|
}
|
package/hook-handoff.mjs
CHANGED
|
@@ -25,7 +25,14 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
25
25
|
`).all(sessionId);
|
|
26
26
|
if (prompts.length === 0) return; // Empty session — nothing to hand off
|
|
27
27
|
|
|
28
|
-
const
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
const uniquePrompts = prompts.filter(p => {
|
|
30
|
+
const t = truncate(p.prompt_text, 200);
|
|
31
|
+
if (seen.has(t)) return false;
|
|
32
|
+
seen.add(t);
|
|
33
|
+
return true;
|
|
34
|
+
});
|
|
35
|
+
const workingOn = uniquePrompts.map(p => truncate(p.prompt_text, 200)).join(' → ');
|
|
29
36
|
|
|
30
37
|
// 2. Completed — from observations (include narrative for richer handoff)
|
|
31
38
|
const completed = db.prepare(`
|
|
@@ -37,45 +44,47 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
37
44
|
// 3. Unfinished — episode snapshot + full session edit history from narratives
|
|
38
45
|
let unfinished = '';
|
|
39
46
|
if (episodeSnapshot?.entries) {
|
|
47
|
+
const seenDescs = new Set();
|
|
40
48
|
const pendingDescs = episodeSnapshot.entries
|
|
41
49
|
.filter(e => e.isSignificant || e.isError)
|
|
42
|
-
.map(e => e.desc)
|
|
50
|
+
.map(e => e.desc)
|
|
51
|
+
.filter(d => { if (seenDescs.has(d)) return false; seenDescs.add(d); return true; });
|
|
43
52
|
if (pendingDescs.length > 0) unfinished = pendingDescs.join('; ');
|
|
44
53
|
}
|
|
45
|
-
// Only the most recent bugfix is an "unfinished" signal (earlier ones are likely resolved)
|
|
46
|
-
if (!unfinished) {
|
|
47
|
-
const lastBugfix = completed.find(o => o.type === 'bugfix');
|
|
48
|
-
if (lastBugfix) unfinished = lastBugfix.title;
|
|
49
|
-
}
|
|
50
54
|
// Enrich unfinished with full session edit history from observation narratives.
|
|
51
55
|
// Since handoff is UPSERT (max 2 rows per project), storing more data is free.
|
|
56
|
+
// Always use \n---\n separator so extractUnfinishedSummary can distinguish
|
|
57
|
+
// pending work (before separator) from narrative history (after separator).
|
|
52
58
|
const narratives = completed
|
|
53
59
|
.filter(c => c.narrative)
|
|
54
60
|
.map(c => c.narrative);
|
|
55
61
|
if (narratives.length > 0) {
|
|
56
62
|
const editHistory = narratives.join('\n');
|
|
57
|
-
unfinished
|
|
63
|
+
unfinished += '\n---\n' + editHistory;
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
// 4. Key files — from episode snapshot + observations
|
|
61
67
|
const fileSet = new Set();
|
|
62
|
-
|
|
68
|
+
const isValidFile = f => f && f.length > 2 && f.includes('/') && f.indexOf('/', 1) !== -1
|
|
69
|
+
&& !f.startsWith('/dev/') && !f.startsWith('/proc/') && !f.startsWith('/tmp/');
|
|
70
|
+
if (episodeSnapshot?.files) episodeSnapshot.files.filter(isValidFile).forEach(f => fileSet.add(f));
|
|
63
71
|
const obsFiles = db.prepare(`
|
|
64
72
|
SELECT files_modified FROM observations
|
|
65
73
|
WHERE memory_session_id = ? AND files_modified IS NOT NULL
|
|
66
74
|
ORDER BY created_at_epoch DESC LIMIT 10
|
|
67
75
|
`).all(sessionId);
|
|
68
76
|
for (const row of obsFiles) {
|
|
69
|
-
try { JSON.parse(row.files_modified).forEach(f => fileSet.add(f)); } catch {}
|
|
77
|
+
try { JSON.parse(row.files_modified).filter(isValidFile).forEach(f => fileSet.add(f)); } catch {}
|
|
70
78
|
}
|
|
71
79
|
|
|
72
|
-
// 5. Key decisions — high importance observations
|
|
80
|
+
// 5. Key decisions — high importance observations (skip low-signal degraded titles)
|
|
81
|
+
const LOW_SIGNAL_TITLE = /^(Error (while working|in)|Modified |Worked on |Reviewed \d+ files:)/;
|
|
73
82
|
const decisions = db.prepare(`
|
|
74
83
|
SELECT title FROM observations
|
|
75
84
|
WHERE memory_session_id = ? AND COALESCE(importance, 1) >= 2
|
|
76
85
|
AND COALESCE(compressed_into, 0) = 0
|
|
77
|
-
ORDER BY created_at_epoch DESC LIMIT
|
|
78
|
-
`).all(sessionId);
|
|
86
|
+
ORDER BY created_at_epoch DESC LIMIT 10
|
|
87
|
+
`).all(sessionId).filter(d => d.title && !LOW_SIGNAL_TITLE.test(d.title)).slice(0, 5);
|
|
79
88
|
|
|
80
89
|
// 6. Match keywords
|
|
81
90
|
const allText = [workingOn, ...completed.map(c => c.title).filter(Boolean), unfinished].join(' ');
|
|
@@ -98,7 +107,7 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
98
107
|
project, type, sessionId,
|
|
99
108
|
truncate(workingOn, 1000),
|
|
100
109
|
completed.map(c => `[${c.type}] ${c.title}`).join('\n'),
|
|
101
|
-
|
|
110
|
+
unfinished.length > 3000 ? unfinished.slice(0, 2999) + '…' : unfinished,
|
|
102
111
|
JSON.stringify([...fileSet].slice(0, 20)),
|
|
103
112
|
decisions.map(d => d.title).join('\n'),
|
|
104
113
|
keywords,
|
|
@@ -116,12 +125,19 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
116
125
|
* @returns {boolean}
|
|
117
126
|
*/
|
|
118
127
|
export function detectContinuationIntent(db, promptText, project) {
|
|
119
|
-
// Stage 0: Non-expired 'clear' handoff
|
|
128
|
+
// Stage 0: Non-expired 'clear' handoff — assume continuation unless long unrelated prompt
|
|
120
129
|
const clearHandoff = db.prepare(`
|
|
121
|
-
SELECT created_at_epoch FROM session_handoffs WHERE project = ? AND type = 'clear'
|
|
130
|
+
SELECT created_at_epoch, match_keywords FROM session_handoffs WHERE project = ? AND type = 'clear'
|
|
122
131
|
`).get(project);
|
|
123
132
|
if (clearHandoff && (Date.now() - clearHandoff.created_at_epoch <= HANDOFF_EXPIRY_CLEAR)) {
|
|
124
|
-
|
|
133
|
+
// Short/ambiguous prompts: assume continuation (user may say "ok", "start", etc.)
|
|
134
|
+
if (promptText.length < 40) return true;
|
|
135
|
+
// Long prompts: check keyword overlap to confirm same-task intent
|
|
136
|
+
if (!clearHandoff.match_keywords) return true; // no keywords stored, can't verify
|
|
137
|
+
const clearPromptTokens = tokenizeHandoff(promptText);
|
|
138
|
+
const clearHandoffTokens = new Set(tokenizeHandoff(clearHandoff.match_keywords));
|
|
139
|
+
if (clearPromptTokens.some(t => clearHandoffTokens.has(t))) return true;
|
|
140
|
+
// Long prompt with zero keyword overlap → likely new task, fall through
|
|
125
141
|
}
|
|
126
142
|
|
|
127
143
|
// Stage 1: Explicit keyword match — always works, even without handoff
|
|
@@ -195,7 +211,11 @@ export function renderHandoffInjection(db, project) {
|
|
|
195
211
|
lines.push('## Completed', ...handoff.completed.split('\n').map(l => `- ${l}`), '');
|
|
196
212
|
}
|
|
197
213
|
if (handoff.unfinished) {
|
|
198
|
-
|
|
214
|
+
// Extract only the pending-work portion (before narrative history separator)
|
|
215
|
+
const pending = extractUnfinishedSummary(handoff.unfinished);
|
|
216
|
+
if (pending) {
|
|
217
|
+
lines.push('## Unfinished', ...pending.split('; ').map(l => `- ${l}`), '');
|
|
218
|
+
}
|
|
199
219
|
}
|
|
200
220
|
if (handoff.key_files) {
|
|
201
221
|
try {
|
package/hook-llm.mjs
CHANGED
|
@@ -39,7 +39,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
39
39
|
VALUES (?, ?, ?, ?, ?, 'active')
|
|
40
40
|
`).run(sessionId, sessionId, project, now.toISOString(), now.getTime());
|
|
41
41
|
|
|
42
|
-
//
|
|
42
|
+
// Three-tier dedup
|
|
43
43
|
// Tier 1 (fast): 5-min Jaccard on titles
|
|
44
44
|
const fiveMinAgo = now.getTime() - DEDUP_WINDOW_MS;
|
|
45
45
|
const recent = db.prepare(`
|
|
@@ -52,6 +52,21 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
52
52
|
return null;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// Tier 1.5: Extended title dedup for low-signal degraded titles (1-day window)
|
|
56
|
+
// "Error in X", "Modified X" titles are low-specificity → use longer dedup window
|
|
57
|
+
const LOW_SIGNAL = /^(Error (while working|in)|Modified |Worked on |Reviewed \d+ files:)/;
|
|
58
|
+
if (obs.title && LOW_SIGNAL.test(obs.title)) {
|
|
59
|
+
const oneDayAgo = now.getTime() - 86400000;
|
|
60
|
+
const extRecent = db.prepare(`
|
|
61
|
+
SELECT title FROM observations
|
|
62
|
+
WHERE project = ? AND created_at_epoch > ? AND created_at_epoch <= ?
|
|
63
|
+
ORDER BY created_at_epoch DESC LIMIT 30
|
|
64
|
+
`).all(project, oneDayAgo, fiveMinAgo);
|
|
65
|
+
if (extRecent.some(r => jaccardSimilarity(r.title, obs.title) > 0.85)) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
// Tier 2 (slow): MinHash cross-session dedup (7-day window)
|
|
56
71
|
const minhashSig = computeMinHash((obs.title || '') + ' ' + (obs.narrative || ''));
|
|
57
72
|
if (minhashSig) {
|
|
@@ -174,10 +189,31 @@ export function buildDegradedTitle(episode) {
|
|
|
174
189
|
const hasError = episode.entries.some(e => e.isError);
|
|
175
190
|
const hasEdit = episode.entries.some(e => EDIT_TOOLS.has(e.tool));
|
|
176
191
|
|
|
192
|
+
// Extract a short error hint from the first error entry's desc
|
|
193
|
+
let errorHint = '';
|
|
194
|
+
if (hasError) {
|
|
195
|
+
const errEntry = episode.entries.find(e => e.isError);
|
|
196
|
+
if (errEntry?.desc) {
|
|
197
|
+
// Extract meaningful error text from "cmd → ERROR: ..." format
|
|
198
|
+
const errMatch = errEntry.desc.match(/→ ERROR: (.{3,80})/);
|
|
199
|
+
if (errMatch) {
|
|
200
|
+
// Clean JSON/noise from the error snippet
|
|
201
|
+
const cleaned = errMatch[1].replace(/[{"[\]]/g, '').replace(/\\n/g, ' ').trim();
|
|
202
|
+
if (cleaned.length >= 4) errorHint = `: ${truncate(cleaned, 50)}`;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
177
207
|
if (files.length > 0) {
|
|
178
208
|
const names = files.map(f => basename(f)).slice(0, 3).join(', ');
|
|
179
209
|
const suffix = files.length > 3 ? ` +${files.length - 3} more` : '';
|
|
180
|
-
if (hasError)
|
|
210
|
+
if (hasError) {
|
|
211
|
+
// Include the triggering command for richer context: "Error: dispatch.mjs — npm test failed"
|
|
212
|
+
const errEntry = episode.entries.find(e => e.isError);
|
|
213
|
+
const cmd = errEntry?.desc?.match(/^(.{3,30}?) →/)?.[1]?.trim();
|
|
214
|
+
const cmdHint = cmd ? ` — ${cmd}` : '';
|
|
215
|
+
return `Error: ${names}${suffix}${errorHint || cmdHint}`;
|
|
216
|
+
}
|
|
181
217
|
if (hasEdit) return `Modified ${names}${suffix}`;
|
|
182
218
|
return `Worked on ${names}${suffix}`;
|
|
183
219
|
}
|
|
@@ -214,6 +250,20 @@ export function buildImmediateObservation(episode) {
|
|
|
214
250
|
title = truncate(buildDegradedTitle(episode), 120);
|
|
215
251
|
}
|
|
216
252
|
|
|
253
|
+
const ruleImportance = computeRuleImportance(episode);
|
|
254
|
+
// Low-signal degraded titles ("Error in...", "Modified...") should not inflate importance.
|
|
255
|
+
// Cap at 1 unless rule-based signals indicate genuine importance (error-in-test → 3, config → 2).
|
|
256
|
+
const LOW_SIGNAL = /^(Error (while working|in)|Modified |Worked on |Reviewed \d+ files:)/;
|
|
257
|
+
const isLowSignal = LOW_SIGNAL.test(title);
|
|
258
|
+
let importance;
|
|
259
|
+
if (isReviewPattern) {
|
|
260
|
+
importance = Math.max(2, ruleImportance);
|
|
261
|
+
} else if (isLowSignal && ruleImportance <= 2) {
|
|
262
|
+
importance = 1; // Degraded titles stay low unless rule signals critical (imp=3)
|
|
263
|
+
} else {
|
|
264
|
+
importance = ruleImportance;
|
|
265
|
+
}
|
|
266
|
+
|
|
217
267
|
return {
|
|
218
268
|
type: inferredType,
|
|
219
269
|
title,
|
|
@@ -223,7 +273,7 @@ export function buildImmediateObservation(episode) {
|
|
|
223
273
|
facts: [],
|
|
224
274
|
files: episode.files,
|
|
225
275
|
filesRead: episode.filesRead || [],
|
|
226
|
-
importance
|
|
276
|
+
importance,
|
|
227
277
|
};
|
|
228
278
|
}
|
|
229
279
|
|
|
@@ -268,10 +318,10 @@ File: ${episode.files.join(', ') || 'unknown'}
|
|
|
268
318
|
Action: ${e.desc}
|
|
269
319
|
Error: ${e.isError ? 'yes' : 'no'}
|
|
270
320
|
|
|
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
|
|
321
|
+
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 'none' if routine","search_aliases":["alt query 1","alt query 2"]}
|
|
272
322
|
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"
|
|
273
|
-
importance:
|
|
274
|
-
lesson_learned:
|
|
323
|
+
importance: Be strict — default to 1. 0=pure browsing with zero learning value. 1=routine file edits, standard changes, normal workflow (MOST episodes). 2=notable ONLY if it reveals something non-obvious: error fix with discovered root cause, architectural decision with explicit tradeoff, config change with unexpected side effects. 3=critical: breaking change affecting users, security vulnerability fix, data migration. Ask yourself: "would a future session benefit from knowing this?" — if not, it's importance=1.
|
|
324
|
+
lesson_learned: REQUIRED field. State what was learned that isn't obvious from reading the code. Examples: "FTS5 porter stemmer doesn't tokenize CJK — need bigram workaround", "vitest --reporter=verbose hangs on large test suites, use default reporter". If purely routine with nothing learned, write "none" (not null).
|
|
275
325
|
search_aliases: 2-6 alternative search terms someone might use to find this memory later (include CJK if project uses Chinese)`;
|
|
276
326
|
} else {
|
|
277
327
|
const actionList = episode.entries.map((e, i) =>
|
|
@@ -285,10 +335,10 @@ Files: ${fileList}
|
|
|
285
335
|
Actions (${episode.entries.length} total):
|
|
286
336
|
${actionList}
|
|
287
337
|
|
|
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
|
|
338
|
+
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 'none' if routine","search_aliases":["alt query 1","alt query 2"]}
|
|
289
339
|
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"
|
|
290
|
-
importance:
|
|
291
|
-
lesson_learned:
|
|
340
|
+
importance: Be strict — default to 1. 0=pure browsing with zero learning value. 1=routine file edits, standard changes, normal workflow (MOST episodes). 2=notable ONLY if it reveals something non-obvious: error fix with discovered root cause, architectural decision with explicit tradeoff, config change with unexpected side effects. 3=critical: breaking change affecting users, security vulnerability fix, data migration. Ask yourself: "would a future session benefit from knowing this?" — if not, it's importance=1.
|
|
341
|
+
lesson_learned: REQUIRED field. State what was learned that isn't obvious from reading the code. Examples: "FTS5 porter stemmer doesn't tokenize CJK — need bigram workaround", "vitest --reporter=verbose hangs on large test suites, use default reporter". If purely routine with nothing learned, write "none" (not null).
|
|
292
342
|
search_aliases: 2-6 alternative search terms someone might use to find this memory later (include CJK if project uses Chinese)`;
|
|
293
343
|
}
|
|
294
344
|
|
|
@@ -323,7 +373,10 @@ search_aliases: 2-6 alternative search terms someone might use to find this memo
|
|
|
323
373
|
return;
|
|
324
374
|
}
|
|
325
375
|
|
|
326
|
-
const lessonLearned = typeof parsed.lesson_learned === 'string'
|
|
376
|
+
const lessonLearned = typeof parsed.lesson_learned === 'string'
|
|
377
|
+
&& parsed.lesson_learned.toLowerCase() !== 'none'
|
|
378
|
+
&& parsed.lesson_learned.trim().length > 0
|
|
379
|
+
? parsed.lesson_learned.slice(0, 500) : null;
|
|
327
380
|
const searchAliases = Array.isArray(parsed.search_aliases)
|
|
328
381
|
? parsed.search_aliases.slice(0, 6).join(' ')
|
|
329
382
|
: null;
|
|
@@ -471,17 +524,41 @@ key_decisions: Only decisions with lasting impact (library choices, architecture
|
|
|
471
524
|
? JSON.stringify(llmParsed.lessons) : null;
|
|
472
525
|
const decisionsJson = Array.isArray(llmParsed.key_decisions) && llmParsed.key_decisions.length > 0
|
|
473
526
|
? JSON.stringify(llmParsed.key_decisions) : null;
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
527
|
+
|
|
528
|
+
// Upgrade existing fast summary instead of creating a duplicate
|
|
529
|
+
const existingFast = db.prepare(`
|
|
530
|
+
SELECT id FROM session_summaries
|
|
531
|
+
WHERE memory_session_id = ? AND notes = 'fast'
|
|
532
|
+
LIMIT 1
|
|
533
|
+
`).get(sessionId);
|
|
534
|
+
|
|
535
|
+
if (existingFast) {
|
|
536
|
+
db.prepare(`
|
|
537
|
+
UPDATE session_summaries
|
|
538
|
+
SET request=?, investigated=?, learned=?, completed=?, next_steps=?, remaining_items=?,
|
|
539
|
+
lessons=?, key_decisions=?, notes='llm', created_at=?, created_at_epoch=?
|
|
540
|
+
WHERE id = ?
|
|
541
|
+
`).run(
|
|
542
|
+
llmParsed.request || '', llmParsed.investigated || '', llmParsed.learned || '',
|
|
543
|
+
llmParsed.completed || '', llmParsed.next_steps || '',
|
|
544
|
+
llmParsed.remaining_items || '',
|
|
545
|
+
lessonsJson, decisionsJson,
|
|
546
|
+
now.toISOString(), now.getTime(),
|
|
547
|
+
existingFast.id
|
|
548
|
+
);
|
|
549
|
+
} else {
|
|
550
|
+
db.prepare(`
|
|
551
|
+
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)
|
|
552
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', '', ?, ?, ?, ?)
|
|
553
|
+
`).run(
|
|
554
|
+
sessionId, project,
|
|
555
|
+
llmParsed.request || '', llmParsed.investigated || '', llmParsed.learned || '',
|
|
556
|
+
llmParsed.completed || '', llmParsed.next_steps || '',
|
|
557
|
+
llmParsed.remaining_items || '',
|
|
558
|
+
lessonsJson, decisionsJson,
|
|
559
|
+
now.toISOString(), now.getTime()
|
|
560
|
+
);
|
|
561
|
+
}
|
|
485
562
|
}
|
|
486
563
|
} finally {
|
|
487
564
|
db.close();
|
package/hook-memory.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { sanitizeFtsQuery, debugCatch } from './utils.mjs';
|
|
5
5
|
|
|
6
6
|
const MAX_MEMORY_INJECTIONS = 3;
|
|
7
|
-
const MEMORY_LOOKBACK_MS =
|
|
7
|
+
const MEMORY_LOOKBACK_MS = 60 * 86400000; // 60 days
|
|
8
8
|
const MEMORY_TYPE_BOOST = { bugfix: 1.5, decision: 1.3, discovery: 1.0, feature: 0.8, change: 0.5, refactor: 0.5 };
|
|
9
9
|
|
|
10
10
|
const FILE_RECALL_LOOKBACK_MS = 60 * 86400000; // 60 days
|
|
@@ -12,7 +12,7 @@ const MAX_FILE_RECALL = 2;
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Search for relevant past observations to inject as memory context.
|
|
15
|
-
*
|
|
15
|
+
* Quality gates: importance>=1 (with 0.6x penalty), type-boosted, lesson-boosted, BM25-thresholded (>=1.5).
|
|
16
16
|
* @param {import('better-sqlite3').Database} db Memory database
|
|
17
17
|
* @param {string} userPrompt User's prompt text
|
|
18
18
|
* @param {string} project Current project
|
|
@@ -29,14 +29,15 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
29
29
|
const cutoff = Date.now() - MEMORY_LOOKBACK_MS;
|
|
30
30
|
const excludeSet = new Set(excludeIds);
|
|
31
31
|
|
|
32
|
+
// Phase 1: Same-project search (highest priority)
|
|
32
33
|
const selectStmt = db.prepare(`
|
|
33
|
-
SELECT o.id, o.type, o.title, o.importance, o.lesson_learned,
|
|
34
|
+
SELECT o.id, o.type, o.title, o.importance, o.lesson_learned, o.project,
|
|
34
35
|
bm25(observations_fts, 10, 5, 5, 3, 3, 2) as relevance
|
|
35
36
|
FROM observations_fts
|
|
36
37
|
JOIN observations o ON o.id = observations_fts.rowid
|
|
37
38
|
WHERE observations_fts MATCH ?
|
|
38
39
|
AND o.project = ?
|
|
39
|
-
AND o.importance >=
|
|
40
|
+
AND o.importance >= 1
|
|
40
41
|
AND o.created_at_epoch > ?
|
|
41
42
|
AND COALESCE(o.compressed_into, 0) = 0
|
|
42
43
|
ORDER BY bm25(observations_fts, 10, 5, 5, 3, 3, 2)
|
|
@@ -44,17 +45,45 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
44
45
|
`);
|
|
45
46
|
const rows = selectStmt.all(ftsQuery, project, cutoff);
|
|
46
47
|
|
|
47
|
-
//
|
|
48
|
-
|
|
48
|
+
// Phase 2: Cross-project search for high-value decisions/discoveries
|
|
49
|
+
// These are transferable insights (debugging patterns, architectural reasons, gotchas)
|
|
50
|
+
let crossRows = [];
|
|
51
|
+
try {
|
|
52
|
+
crossRows = db.prepare(`
|
|
53
|
+
SELECT o.id, o.type, o.title, o.importance, o.lesson_learned, o.project,
|
|
54
|
+
bm25(observations_fts, 10, 5, 5, 3, 3, 2) as relevance
|
|
55
|
+
FROM observations_fts
|
|
56
|
+
JOIN observations o ON o.id = observations_fts.rowid
|
|
57
|
+
WHERE observations_fts MATCH ?
|
|
58
|
+
AND o.project != ?
|
|
59
|
+
AND o.type IN ('decision', 'discovery')
|
|
60
|
+
AND o.importance >= 2
|
|
61
|
+
AND o.created_at_epoch > ?
|
|
62
|
+
AND COALESCE(o.compressed_into, 0) = 0
|
|
63
|
+
ORDER BY bm25(observations_fts, 10, 5, 5, 3, 3, 2)
|
|
64
|
+
LIMIT 5
|
|
65
|
+
`).all(ftsQuery, project, cutoff);
|
|
66
|
+
} catch (e) { debugCatch(e, 'crossProjectSearch'); }
|
|
67
|
+
|
|
68
|
+
// Merge and score: same-project full weight, cross-project 0.7x
|
|
69
|
+
const allRows = [...rows, ...crossRows];
|
|
70
|
+
const scored = allRows
|
|
49
71
|
.filter(r => !excludeSet.has(r.id))
|
|
50
|
-
.map(r =>
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
72
|
+
.map(r => {
|
|
73
|
+
const crossProjectPenalty = r.project === project ? 1.0 : 0.7;
|
|
74
|
+
return {
|
|
75
|
+
...r,
|
|
76
|
+
score: Math.abs(r.relevance)
|
|
77
|
+
* (MEMORY_TYPE_BOOST[r.type] || 1.0)
|
|
78
|
+
* (r.lesson_learned ? 1.5 : 1.0)
|
|
79
|
+
* (r.importance >= 2 ? 1.0 : 0.6)
|
|
80
|
+
* crossProjectPenalty,
|
|
81
|
+
};
|
|
82
|
+
})
|
|
54
83
|
.sort((a, b) => b.score - a.score);
|
|
55
84
|
|
|
56
|
-
// Strict threshold:
|
|
57
|
-
if (scored.length === 0 || scored[0].score < 1.
|
|
85
|
+
// Strict threshold: raised from 1.0 to 1.5 to compensate for wider pool
|
|
86
|
+
if (scored.length === 0 || scored[0].score < 1.5) return [];
|
|
58
87
|
|
|
59
88
|
// Update access_count for injected memories
|
|
60
89
|
const result = scored.slice(0, MAX_MEMORY_INJECTIONS);
|
|
@@ -83,7 +112,10 @@ export function recallForFile(db, filePath, project) {
|
|
|
83
112
|
try {
|
|
84
113
|
const basename = filePath.split('/').pop();
|
|
85
114
|
const cutoff = Date.now() - FILE_RECALL_LOOKBACK_MS;
|
|
86
|
-
|
|
115
|
+
// Match both full paths (/path/to/file.mjs) and basename-only entries ("file.mjs")
|
|
116
|
+
// Two patterns avoid false positives: %/file.mjs"% won't match /webapp.mjs
|
|
117
|
+
const pathPattern = `%/${basename}"%`;
|
|
118
|
+
const namePattern = `%"${basename}"%`;
|
|
87
119
|
const rows = db.prepare(`
|
|
88
120
|
SELECT id, type, title, importance, lesson_learned
|
|
89
121
|
FROM observations
|
|
@@ -91,10 +123,10 @@ export function recallForFile(db, filePath, project) {
|
|
|
91
123
|
AND importance >= 2
|
|
92
124
|
AND COALESCE(compressed_into, 0) = 0
|
|
93
125
|
AND created_at_epoch > ?
|
|
94
|
-
AND files_modified LIKE ?
|
|
126
|
+
AND (files_modified LIKE ? OR files_modified LIKE ?)
|
|
95
127
|
ORDER BY created_at_epoch DESC
|
|
96
128
|
LIMIT ?
|
|
97
|
-
`).all(project, cutoff,
|
|
129
|
+
`).all(project, cutoff, pathPattern, namePattern, MAX_FILE_RECALL);
|
|
98
130
|
const updateStmt = db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1 WHERE id = ?');
|
|
99
131
|
for (const r of rows) updateStmt.run(r.id);
|
|
100
132
|
return rows;
|
package/hook-semaphore.mjs
CHANGED
|
File without changes
|
package/hook-shared.mjs
CHANGED
|
@@ -140,6 +140,27 @@ export function incrementInjection() { _injectionCount++; }
|
|
|
140
140
|
export function resetInjectionBudget() { _injectionCount = 0; }
|
|
141
141
|
export function hasInjectionBudget() { return _injectionCount < MAX_INJECTIONS_PER_SESSION; }
|
|
142
142
|
|
|
143
|
+
// ─── Previous Session Context (for user-prompt dispatch enrichment) ──────────
|
|
144
|
+
// Session-start caches next_steps; first user-prompt reads+clears for richer dispatch.
|
|
145
|
+
|
|
146
|
+
export function prevContextFile() {
|
|
147
|
+
return join(RUNTIME_DIR, `prev-context-${inferProject()}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function cachePrevContext(nextSteps) {
|
|
151
|
+
try { writeFileSync(prevContextFile(), JSON.stringify({ nextSteps, ts: Date.now() })); } catch {}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function readAndClearPrevContext() {
|
|
155
|
+
const file = prevContextFile();
|
|
156
|
+
try {
|
|
157
|
+
const data = JSON.parse(readFileSync(file, 'utf8'));
|
|
158
|
+
try { unlinkSync(file); } catch {}
|
|
159
|
+
if (Date.now() - data.ts > 12 * 3600000) return null; // 12h expiry
|
|
160
|
+
return data.nextSteps || null;
|
|
161
|
+
} catch { return null; }
|
|
162
|
+
}
|
|
163
|
+
|
|
143
164
|
// ─── Tool Event Tracking (for dispatch feedback) ────────────────────────────
|
|
144
165
|
// PostToolUse appends feedback-relevant tool events (Skill, Task, Edit, Write, Bash errors).
|
|
145
166
|
// Stop handler reads them and passes to collectFeedback for adoption/outcome detection.
|