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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.3.3",
3
+ "version": "2.5.1",
4
4
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall",
5
5
  "author": {
6
6
  "name": "sdsrss"
@@ -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
@@ -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) + '...';
@@ -66,22 +66,32 @@ export const SUITE_AUTO_FLOWS = {
66
66
  },
67
67
  };
68
68
 
69
+ const SUITE_MOMENTUM_MAX_DISTANCE = 20;
70
+ const SUITE_MOMENTUM_MAX_AGE_MS = 15 * 60 * 1000; // 15 minutes
71
+
69
72
  /**
70
73
  * Detect if a suite auto-flow is active based on recent Skill tool events.
71
- * Scans backwards to find the most recent Skill invocation from a known suite.
74
+ * Scans backwards with momentum decay: suite influence fades after 20 tool calls or 15 minutes.
72
75
  * @param {object[]} sessionEvents Array of tool events
73
- * @returns {{suite: string, flow: object, lastSkill: string}|null}
76
+ * @returns {{suite: string, flow: object, lastSkill: string, distance: number}|null}
74
77
  */
75
78
  export function detectActiveSuite(sessionEvents) {
76
79
  if (!sessionEvents || sessionEvents.length === 0) return null;
77
80
 
78
81
  for (let i = sessionEvents.length - 1; i >= 0; i--) {
82
+ const distance = sessionEvents.length - 1 - i;
83
+
84
+ // Momentum decay: suite influence fades after 20 tool calls
85
+ if (distance > SUITE_MOMENTUM_MAX_DISTANCE) return null;
86
+
79
87
  const e = sessionEvents[i];
80
88
  if (e.tool_name === 'Skill' && e.tool_input?.skill) {
81
89
  const skill = e.tool_input.skill;
82
90
  const suite = skill.split(':')[0];
83
91
  if (SUITE_AUTO_FLOWS[suite]) {
84
- return { suite, flow: SUITE_AUTO_FLOWS[suite], lastSkill: skill };
92
+ // Time decay: suite influence expires after 15 minutes
93
+ if (e.timestamp && (Date.now() - e.timestamp) > SUITE_MOMENTUM_MAX_AGE_MS) return null;
94
+ return { suite, flow: SUITE_AUTO_FLOWS[suite], lastSkill: skill, distance };
85
95
  }
86
96
  }
87
97
  }
@@ -113,11 +123,16 @@ export function shouldRecommendForStage(activeSuite, currentStage) {
113
123
  * @param {{lastSkill: string}|null} activeSuite Active suite info
114
124
  * @returns {string|null} Stage name or null
115
125
  */
116
- export function inferCurrentStage(primaryIntent, activeSuite) {
126
+ export function inferCurrentStage(primaryIntent, activeSuite, suppressedIntents = []) {
117
127
  if (activeSuite?.lastSkill && SKILL_STAGE_MAP[activeSuite.lastSkill]) {
118
128
  return SKILL_STAGE_MAP[activeSuite.lastSkill];
119
129
  }
120
- return INTENT_STAGE_MAP[primaryIntent] || null;
130
+ if (INTENT_STAGE_MAP[primaryIntent]) return INTENT_STAGE_MAP[primaryIntent];
131
+ // Check suppressed intents — still the user's actual intent, just not used for FTS search
132
+ for (const si of suppressedIntents) {
133
+ if (INTENT_STAGE_MAP[si]) return INTENT_STAGE_MAP[si];
134
+ }
135
+ return null;
121
136
  }
122
137
 
123
138
  // ─── Explicit Request Detection ──────────────────────────────────────────────
package/dispatch.mjs CHANGED
@@ -29,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: Recommended within cooldown window (cross-session cooldown)
724
+ // Check 3: Consecutive rejection silencing
725
+ if (isConsecutivelyRejected(db, resourceId)) return true;
726
+
727
+ // Check 4: Recommended within adaptive cooldown window (cross-session cooldown)
728
+ const cooldown = getAdaptiveCooldown(db);
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, `-${COOLDOWN_MINUTES} minutes`);
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 out weak text matches regardless of intent.
760
- // Only apply when enough results exist (BM25 IDF is unreliable with < 3 matches).
761
- if (results.length >= 3) {
762
- results = results.filter(r => {
763
- const score = Math.abs(r.composite_score ?? r.relevance);
764
- return score >= BM25_MIN_THRESHOLD;
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
- const query = buildEnhancedQuery(signals);
1123
+ let query = buildEnhancedQuery(signals);
1124
+ if (!query && sessionCtx?.userPrompt) {
1125
+ query = buildQueryFromText(sessionCtx.userPrompt);
1126
+ if (!query) return null;
1127
+ }
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
  }