claude-mem-lite 2.28.2 → 2.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "2.28.2",
13
+ "version": "2.29.0",
14
14
  "source": "./",
15
15
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall"
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.28.2",
3
+ "version": "2.29.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/commands/mem.md CHANGED
@@ -1,5 +1,6 @@
1
1
  ---
2
- description: "Search and manage project memory (observations, sessions, prompts). Use when: user asks about past work, wants to find a previous bugfix, check project history, save a decision, or manage stored memories"
2
+ name: mem
3
+ description: "Use when: querying past work, managing memories, or checking project history"
3
4
  ---
4
5
 
5
6
  # Memory
@@ -1,5 +1,6 @@
1
1
  ---
2
- description: "Save content to memory — with explicit content, instructions, or auto-summarize current session. Use when: the user asks to remember something, after solving a non-obvious problem, or to capture key session findings"
2
+ name: memory
3
+ description: "Use when: user asks to remember something, after solving a non-obvious problem, or to capture key session findings"
3
4
  ---
4
5
 
5
6
  # Memory Save
package/commands/tools.md CHANGED
@@ -1,5 +1,6 @@
1
1
  ---
2
- description: "Import skills and agents from GitHub repositories into the tool resource registry. Use when: looking for a skill to solve a problem, importing tools from a repo, or managing installed tools"
2
+ name: tools
3
+ description: "Use when: importing skills/agents from GitHub, managing registry resources, or searching for tools to solve a problem"
3
4
  ---
4
5
 
5
6
  # Tool Import
@@ -1,5 +1,6 @@
1
1
  ---
2
- description: "Auto-maintain memory and resource registry — deduplicate, merge, decay, cleanup, reindex. Use when: search results seem noisy, after bulk imports, or during periodic maintenance"
2
+ name: update
3
+ description: "Use when: search results seem noisy, after bulk imports, or for periodic memory/registry maintenance"
3
4
  ---
4
5
 
5
6
  # Memory & Registry Maintenance
package/haiku-client.mjs CHANGED
@@ -100,6 +100,109 @@ export async function callHaikuJSON(prompt, opts) {
100
100
  return parseJsonFromLLM(result.text);
101
101
  }
102
102
 
103
+ // ─── Model-Selectable API ────────────────────────────────────────────────────
104
+
105
+ /**
106
+ * Call LLM with explicit model selection. Supports 'haiku' and 'sonnet'.
107
+ * Reuses existing API/CLI dual-mode infrastructure.
108
+ * Never throws — returns null on any error.
109
+ *
110
+ * @param {string} prompt The prompt text
111
+ * @param {'haiku'|'sonnet'} model Model to use (default: 'haiku')
112
+ * @param {object} [opts] Options
113
+ * @param {number} [opts.timeout=15000] Timeout in milliseconds
114
+ * @param {number} [opts.maxTokens=1000] Max tokens in response
115
+ * @returns {Promise<{text: string}|null>} Response or null on failure
116
+ */
117
+ export async function callLLMWithModel(prompt, model = 'haiku', { timeout = 15000, maxTokens = 1000 } = {}) {
118
+ if (!prompt) return null;
119
+ const resolvedModel = MODEL_MAP[model] ? model : 'haiku';
120
+ const mode = detectMode();
121
+
122
+ try {
123
+ if (mode === 'api') {
124
+ return await callModelAPI(prompt, resolvedModel, { timeout, maxTokens });
125
+ }
126
+ return callModelCLI(prompt, resolvedModel, { timeout });
127
+ } catch (e) {
128
+ debugCatch(e, `callLLMWithModel:${resolvedModel}`);
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Call LLM with model selection and parse JSON response.
135
+ * @param {string} prompt
136
+ * @param {'haiku'|'sonnet'} model
137
+ * @param {object} [opts]
138
+ * @returns {Promise<object|null>}
139
+ */
140
+ export async function callModelJSON(prompt, model = 'haiku', opts) {
141
+ const result = await callLLMWithModel(prompt, model, opts);
142
+ if (!result?.text) return null;
143
+ return parseJsonFromLLM(result.text);
144
+ }
145
+
146
+ async function callModelAPI(prompt, model, { timeout, maxTokens }) {
147
+ const apiKey = process.env.ANTHROPIC_API_KEY;
148
+ if (!apiKey) return null;
149
+
150
+ const modelId = MODEL_MAP[model];
151
+ const controller = new AbortController();
152
+ const timer = setTimeout(() => controller.abort(), timeout);
153
+
154
+ try {
155
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
156
+ method: 'POST',
157
+ headers: {
158
+ 'Content-Type': 'application/json',
159
+ 'x-api-key': apiKey,
160
+ 'anthropic-version': '2023-06-01',
161
+ },
162
+ body: JSON.stringify({
163
+ model: modelId,
164
+ max_tokens: maxTokens,
165
+ messages: [{ role: 'user', content: prompt }],
166
+ }),
167
+ signal: controller.signal,
168
+ });
169
+
170
+ if (!res.ok) {
171
+ debugLog('WARN', `${model}-api`, `HTTP ${res.status}`);
172
+ return null;
173
+ }
174
+
175
+ const data = await res.json();
176
+ const text = data.content?.[0]?.text;
177
+ return text ? { text } : null;
178
+ } finally {
179
+ clearTimeout(timer);
180
+ }
181
+ }
182
+
183
+ function callModelCLI(prompt, model, { timeout }) {
184
+ const modelName = MODEL_MAP[model] ? model : 'haiku';
185
+ try {
186
+ const result = execFileSync(getClaudePath(), ['-p', '--model', modelName], {
187
+ input: prompt,
188
+ timeout,
189
+ encoding: 'utf8',
190
+ env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1' },
191
+ stdio: ['pipe', 'pipe', 'pipe'],
192
+ cwd: '/tmp',
193
+ });
194
+ const text = result.trim();
195
+ return text ? { text } : null;
196
+ } catch (e) {
197
+ const out = e.stdout?.toString?.()?.trim() || e.output?.[1]?.toString?.()?.trim();
198
+ if (out && out.startsWith('{') && out.endsWith('}')) {
199
+ try { JSON.parse(out); return { text: out }; } catch {}
200
+ }
201
+ debugCatch(e, `${model}-cli`);
202
+ return null;
203
+ }
204
+ }
205
+
103
206
  // ─── API Mode ────────────────────────────────────────────────────────────────
104
207
 
105
208
  async function callHaikuAPI(prompt, { timeout, maxTokens }) {
package/hook-memory.mjs CHANGED
@@ -6,8 +6,13 @@ import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, OBS_BM25 } from './uti
6
6
  const MAX_MEMORY_INJECTIONS = 3;
7
7
  const MEMORY_LOOKBACK_MS = 60 * 86400000; // 60 days
8
8
  // Aligned with TYPE_QUALITY_CASE: high-signal types > noisy types
9
- // Bugfix lessons are still surfaced via the separate lesson_learned boost (1.5×)
10
- const MEMORY_TYPE_BOOST = { decision: 1.5, discovery: 1.3, feature: 1.2, refactor: 1.0, change: 0.8, bugfix: 0.5 };
9
+ // Bugfix raised from 0.5→0.75 to match scoring-sql.mjs; lesson_learned boost (1.5×) stacks
10
+ const MEMORY_TYPE_BOOST = { decision: 1.5, discovery: 1.3, feature: 1.2, refactor: 1.0, change: 0.8, bugfix: 0.75 };
11
+ // Adaptive BM25 thresholds — scale with corpus size to filter noise.
12
+ // Larger corpora produce more weak matches from common words.
13
+ const BM25_THRESHOLD = { TINY: 0, SMALL: 1.5, MEDIUM: 2.5, LARGE: 3.5 };
14
+ // OR fallback max token count — queries with 3+ tokens that fail AND are likely off-topic
15
+ const OR_FALLBACK_MAX_TOKENS = 2;
11
16
 
12
17
  const FILE_RECALL_LOOKBACK_MS = 60 * 86400000; // 60 days
13
18
  const MAX_FILE_RECALL = 2;
@@ -47,18 +52,25 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
47
52
  LIMIT 10
48
53
  `);
49
54
  let rows = selectStmt.all(ftsQuery, project, cutoff);
55
+ let usedOrFallback = false;
50
56
 
51
- // OR fallback when AND returns nothing
57
+ // OR fallback when AND returns nothing — only for short queries (specific enough).
58
+ // 3+ token queries that fail AND are likely off-topic; OR would match individual common words.
59
+ // Count original search terms (AND-separated groups), not expanded synonym tokens.
60
+ const queryTokenCount = ftsQuery.includes(' AND ')
61
+ ? ftsQuery.split(' AND ').length
62
+ : ftsQuery.split(/\s+/).filter(t => t && !t.startsWith('(') || !t.endsWith(')')).length;
52
63
  if (rows.length === 0) {
53
64
  const orQuery = relaxFtsQueryToOr(ftsQuery);
54
- if (orQuery) {
55
- try { rows = selectStmt.all(orQuery, project, cutoff); } catch {}
65
+ if (orQuery && queryTokenCount <= OR_FALLBACK_MAX_TOKENS) {
66
+ try { rows = selectStmt.all(orQuery, project, cutoff); usedOrFallback = true; } catch {}
56
67
  }
57
68
  }
58
69
 
59
70
  // Phase 2: Cross-project search for high-value decisions/discoveries
60
71
  // These are transferable insights (debugging patterns, architectural reasons, gotchas)
61
72
  let crossRows = [];
73
+ let crossUsedOr = false;
62
74
  try {
63
75
  const crossStmt = db.prepare(`
64
76
  SELECT o.id, o.type, o.title, o.importance, o.lesson_learned, o.project,
@@ -78,40 +90,44 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
78
90
  crossRows = crossStmt.all(ftsQuery, project, cutoff);
79
91
  if (crossRows.length === 0) {
80
92
  const orQuery = relaxFtsQueryToOr(ftsQuery);
81
- if (orQuery) {
82
- try { crossRows = crossStmt.all(orQuery, project, cutoff); } catch {}
93
+ if (orQuery && queryTokenCount <= OR_FALLBACK_MAX_TOKENS) {
94
+ try { crossRows = crossStmt.all(orQuery, project, cutoff); crossUsedOr = true; } catch {}
83
95
  }
84
96
  }
85
97
  } catch (e) { debugCatch(e, 'crossProjectSearch'); }
86
98
 
87
99
  // Merge and score: same-project full weight, cross-project 0.7x
88
- const allRows = [...rows, ...crossRows];
100
+ // OR-fallback results get 0.4x penalty — they matched individual words, not the full intent
101
+ const allRows = [...rows.map(r => ({ ...r, _or: usedOrFallback })), ...crossRows.map(r => ({ ...r, _or: crossUsedOr }))];
89
102
  const scored = allRows
90
103
  .filter(r => !excludeSet.has(r.id))
91
104
  .map(r => {
92
105
  const crossProjectPenalty = r.project === project ? 1.0 : 0.7;
106
+ const orFallbackPenalty = r._or ? 0.4 : 1.0;
93
107
  return {
94
108
  ...r,
95
109
  score: Math.abs(r.relevance)
96
110
  * (MEMORY_TYPE_BOOST[r.type] || 1.0)
97
111
  * (r.lesson_learned ? 1.5 : 1.0)
98
112
  * (r.importance >= 2 ? 1.0 : 0.6)
99
- * crossProjectPenalty,
113
+ * crossProjectPenalty
114
+ * orFallbackPenalty,
100
115
  };
101
116
  })
102
117
  .sort((a, b) => b.score - a.score);
103
118
 
104
- // Adaptive threshold: BM25 IDF collapses when corpus has <5 observations,
105
- // producing scores ~0.00001 even for exact matches. At 5+ obs, IDF provides
106
- // meaningful discrimination and the calibrated 1.5 threshold works well.
119
+ // Adaptive threshold: scales with corpus size to filter noise.
120
+ // Each result must individually exceed the threshold (not just the top one).
107
121
  const obsCount = db.prepare(
108
122
  'SELECT COUNT(*) as c FROM observations WHERE project = ? AND COALESCE(compressed_into, 0) = 0',
109
123
  ).get(project)?.c || 0;
110
- const threshold = obsCount < 5 ? 0 : 1.5;
111
- if (scored.length === 0 || scored[0].score < threshold) return [];
124
+ const { TINY, SMALL, MEDIUM, LARGE } = BM25_THRESHOLD;
125
+ const threshold = obsCount < 5 ? TINY : obsCount < 100 ? SMALL : obsCount < 500 ? MEDIUM : LARGE;
126
+ const aboveThreshold = scored.filter(r => r.score >= threshold);
127
+ if (aboveThreshold.length === 0) return [];
112
128
 
113
129
  // Update access_count for injected memories
114
- const result = scored.slice(0, MAX_MEMORY_INJECTIONS);
130
+ const result = aboveThreshold.slice(0, MAX_MEMORY_INJECTIONS);
115
131
  const now = Date.now();
116
132
  const updateStmt = db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id = ?');
117
133
  for (const r of result) {
package/hook.mjs CHANGED
@@ -31,13 +31,14 @@ import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObse
31
31
  import { searchRelevantMemories } from './hook-memory.mjs';
32
32
  import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, extractUnfinishedSummary } from './hook-handoff.mjs';
33
33
  import { checkForUpdate } from './hook-update.mjs';
34
+ import { handleLLMOptimize } from './hook-optimize.mjs';
34
35
  import { SKIP_TOOLS, SKIP_PREFIXES } from './skip-tools.mjs';
35
36
  import { getVocabulary } from './tfidf.mjs';
36
37
 
37
38
  // Prevent recursive hooks from background claude -p calls
38
39
  // Background workers (llm-episode, llm-summary) are exempt — they're ours
39
40
  const event = process.argv[2];
40
- const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'auto-compress']);
41
+ const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'auto-compress', 'llm-optimize']);
41
42
 
42
43
  // Respect Claude Code plugin disable state even when legacy settings.json hooks remain.
43
44
  // install.mjs writes direct hooks into ~/.claude/settings.json, so disabling the plugin
@@ -122,6 +123,22 @@ function flushEpisode(episode) {
122
123
 
123
124
  if (isSignificant) {
124
125
  spawnBackground('llm-episode', flushFile);
126
+
127
+ // P3: Auto-save hint — detect error→fix pattern (error entry followed by Edit/Write)
128
+ // and nudge Claude to save the lesson for future recall
129
+ try {
130
+ const entries = episode.entries || [];
131
+ const hasError = entries.some(e => e.isError);
132
+ const hasEdit = entries.some(e => EDIT_TOOLS.has(e.tool));
133
+ if (hasError && hasEdit && entries.length >= 3) {
134
+ const editFiles = entries.filter(e => EDIT_TOOLS.has(e.tool)).flatMap(e => e.files || []);
135
+ const uniqueFiles = [...new Set(editFiles)].slice(0, 3);
136
+ const filesHint = uniqueFiles.length > 0 ? ` (files: ${uniqueFiles.join(', ')})` : '';
137
+ process.stdout.write(
138
+ `[mem] 💡 Error→fix pattern detected${filesHint}. Consider: mem_save(type="bugfix", lesson_learned="root cause & fix")\n`,
139
+ );
140
+ }
141
+ } catch { /* never block on hint */ }
125
142
  } else {
126
143
  try { unlinkSync(flushFile); } catch {}
127
144
  }
@@ -539,6 +556,7 @@ async function handleSessionStart() {
539
556
  writeFileSync(maintainFile, JSON.stringify({ epoch: Date.now() }));
540
557
  // Weekly summary grouping runs in background to avoid blocking SessionStart
541
558
  spawnBackground('auto-compress');
559
+ if (!process.env.CLAUDE_MEM_SKIP_OPTIMIZE) spawnBackground('llm-optimize');
542
560
  } catch (e) { debugCatch(e, 'auto-maintain'); }
543
561
  }
544
562
 
@@ -1060,6 +1078,7 @@ try {
1060
1078
  case 'llm-episode': await handleLLMEpisode(); break;
1061
1079
  case 'llm-summary': await handleLLMSummary(); break;
1062
1080
  case 'auto-compress': handleAutoCompress(); break;
1081
+ case 'llm-optimize': await handleLLMOptimize(); break;
1063
1082
  }
1064
1083
  } catch (err) {
1065
1084
  // Always log fatal errors (ungated) with structured format
package/install.mjs CHANGED
@@ -204,7 +204,7 @@ async function install() {
204
204
  const SOURCE_FILES = [
205
205
  'cli.mjs', 'server.mjs', 'server-internals.mjs', 'tool-schemas.mjs',
206
206
  'hook.mjs', 'hook-shared.mjs', 'hook-llm.mjs', 'hook-memory.mjs', 'skip-tools.mjs',
207
- 'hook-semaphore.mjs', 'hook-episode.mjs', 'hook-context.mjs', 'hook-handoff.mjs', 'hook-update.mjs',
207
+ 'hook-semaphore.mjs', 'hook-episode.mjs', 'hook-context.mjs', 'hook-handoff.mjs', 'hook-update.mjs', 'hook-optimize.mjs',
208
208
  'haiku-client.mjs', 'utils.mjs', 'schema.mjs', 'package.json', 'package-lock.json', 'skill.md',
209
209
  'registry.mjs', 'registry-scanner.mjs', 'registry-indexer.mjs',
210
210
  'registry-retriever.mjs', 'resource-discovery.mjs',
package/mem-cli.mjs CHANGED
@@ -5,12 +5,14 @@
5
5
  import { homedir } from 'os';
6
6
  import { ensureDb, DB_PATH, REGISTRY_DB_PATH, checkFTSIntegrity, rebuildFTS } from './schema.mjs';
7
7
  import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, jaccardSimilarity, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, isoWeekKey, COMPRESSED_PENDING_PURGE, OBS_BM25, SESS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, DEFAULT_DECAY_HALF_LIFE_MS, getCurrentBranch } from './utils.mjs';
8
+ import { extractCjkLikePatterns } from './nlp.mjs';
8
9
  import { resolveProject } from './project-utils.mjs';
9
10
  import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
10
11
  import { getVocabulary, computeVector, vectorSearch, rrfMerge, VECTOR_SCAN_LIMIT, rebuildVocabulary, _resetVocabCache } from './tfidf.mjs';
11
12
  import { autoBoostIfNeeded, reRankWithContext, markSuperseded, extractPRFTerms, expandQueryByConcepts } from './server-internals.mjs';
12
13
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
13
14
  import { searchResources } from './registry-retriever.mjs';
15
+ import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
14
16
  import { basename, join } from 'path';
15
17
  import { readFileSync } from 'fs';
16
18
 
@@ -280,6 +282,31 @@ function cmdSearch(db, args) {
280
282
  LIMIT ? OFFSET ?
281
283
  `).all(...promptParams);
282
284
  for (const r of promptRows) results.push({ ...r, _source: 'prompt' });
285
+ // CJK LIKE fallback: FTS5 unicode61 can't tokenize CJK substrings in prompts
286
+ if (promptRows.length === 0) {
287
+ const cjkPatterns = extractCjkLikePatterns(query);
288
+ if (cjkPatterns.length > 0) {
289
+ const likeConds = cjkPatterns.map(() => 'p.prompt_text LIKE ?');
290
+ const likeParams = cjkPatterns.map(p => `%${p}%`);
291
+ if (project) likeParams.push(project);
292
+ if (dateFrom) likeParams.push(dateFrom);
293
+ if (dateTo) likeParams.push(dateTo);
294
+ likeParams.push(effectiveSource ? limit : limit, effectiveSource ? offset : 0);
295
+ const fallbackRows = db.prepare(`
296
+ SELECT p.id, p.prompt_text, p.content_session_id, p.created_at, p.created_at_epoch
297
+ FROM user_prompts p
298
+ JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
299
+ WHERE (${likeConds.join(' OR ')})
300
+ AND p.prompt_text NOT LIKE '<task-notification>%'
301
+ ${project ? 'AND s.project = ?' : ''}
302
+ ${dateFrom ? 'AND p.created_at_epoch >= ?' : ''}
303
+ ${dateTo ? 'AND p.created_at_epoch <= ?' : ''}
304
+ ORDER BY p.created_at_epoch DESC
305
+ LIMIT ? OFFSET ?
306
+ `).all(...likeParams);
307
+ for (const r of fallbackRows) results.push({ ...r, _source: 'prompt', score: 0 });
308
+ }
309
+ }
283
310
  } catch { /* prompt FTS may not exist in older DBs */ }
284
311
  }
285
312
 
@@ -1980,6 +2007,39 @@ async function cmdEnrich(argv) {
1980
2007
  }
1981
2008
  }
1982
2009
 
2010
+ async function cmdOptimize(db, args) {
2011
+ const run = args.includes('--run');
2012
+ const runAll = args.includes('--run-all');
2013
+ const taskIdx = args.indexOf('--task');
2014
+ const tasks = taskIdx >= 0 && args[taskIdx + 1] ? [args[taskIdx + 1]] : undefined;
2015
+ const maxIdx = args.indexOf('--max');
2016
+ const maxItems = maxIdx >= 0 ? parseInt(args[maxIdx + 1], 10) || 15 : 15;
2017
+
2018
+ if (!run && !runAll) {
2019
+ const preview = optimizePreview(db);
2020
+ out('[mem] 🔍 LLM Optimization Preview:');
2021
+ out(` Re-enrich candidates: ${preview.reenrich}`);
2022
+ out(` Normalize: ${preview.normalizeGateOpen ? `${preview.normalize} unique concepts` : 'gate closed (7-day interval)'}`);
2023
+ out(` Cluster-merge: ${preview.clusterMerge} clusters`);
2024
+ out(` Smart-compress: ${preview.smartCompress} clusters`);
2025
+ out(` Total: ${preview.total} items`);
2026
+ out('');
2027
+ out('Run with --run to execute, --run-all to bypass gates.');
2028
+ return;
2029
+ }
2030
+
2031
+ out('[mem] Running LLM optimization...');
2032
+ const results = await optimizeRun(db, { tasks, maxItems, force: runAll });
2033
+
2034
+ if (results.reenrich) out(` Re-enrich: ${results.reenrich.processed || 0} processed, ${results.reenrich.skipped || 0} skipped`);
2035
+ if (results.normalize) {
2036
+ if (results.normalize.skipped) out(` Normalize: skipped (${results.normalize.reason})`);
2037
+ else out(` Normalize: ${results.normalize.processed || 0} updated, ${results.normalize.groups || 0} synonym groups`);
2038
+ }
2039
+ if (results.clusterMerge) out(` Cluster-merge: ${results.clusterMerge.merged || 0} merged of ${results.clusterMerge.processed || 0} clusters`);
2040
+ if (results.smartCompress) out(` Smart-compress: ${results.smartCompress.compressed || 0} compressed of ${results.smartCompress.processed || 0} clusters`);
2041
+ }
2042
+
1983
2043
  // ─── Main Entry Point ────────────────────────────────────────────────────────
1984
2044
 
1985
2045
  export async function run(argv) {
@@ -2020,6 +2080,7 @@ export async function run(argv) {
2020
2080
  case 'export': cmdExport(db, cmdArgs); break;
2021
2081
  case 'compress': cmdCompress(db, cmdArgs); break;
2022
2082
  case 'maintain': cmdMaintain(db, cmdArgs); break;
2083
+ case 'optimize': await cmdOptimize(db, cmdArgs); break;
2023
2084
  case 'fts-check': cmdFtsCheck(db, cmdArgs); break;
2024
2085
  case 'stats': cmdStats(db, cmdArgs); break;
2025
2086
  case 'context': cmdContext(db, cmdArgs); break;
package/nlp.mjs CHANGED
@@ -108,6 +108,22 @@ export function extractCjkKeywords(text) {
108
108
  return found;
109
109
  }
110
110
 
111
+ /**
112
+ * Extract CJK patterns suitable for SQL LIKE fallback when FTS5 fails on CJK text.
113
+ * Uses dictionary extraction + bigram fallback for unmatched portions.
114
+ * @param {string} query Raw query text
115
+ * @returns {string[]} CJK patterns (≥2 chars each), empty if no CJK content
116
+ */
117
+ export function extractCjkLikePatterns(query) {
118
+ if (!query || !/[\u4e00-\u9fff\u3400-\u4dbf]{2,}/.test(query)) return [];
119
+ const keywords = extractCjkKeywords(query);
120
+ // Bigrams for unmatched CJK portions
121
+ let remainder = query;
122
+ for (const w of keywords) remainder = remainder.split(w).join(' ');
123
+ const bigrams = cjkBigrams(remainder).split(' ').filter(Boolean);
124
+ return [...new Set([...keywords, ...bigrams])];
125
+ }
126
+
111
127
  // ─── FTS5 Token Formatting ──────────────────────────────────────────────────
112
128
 
113
129
  // Format a term for FTS5: quote if it contains spaces, hyphens, or special chars
@@ -166,6 +182,16 @@ export function sanitizeFtsQuery(query) {
166
182
  if (cjkWords.length > 0) {
167
183
  expandedTokens.push(...cjkWords);
168
184
  cjkExtracted = true;
185
+ // Preserve unmatched CJK portions as bigrams (don't silently drop them)
186
+ const matched = new Set(cjkWords);
187
+ let remainder = t;
188
+ for (const w of matched) remainder = remainder.split(w).join(' ');
189
+ const gapBigrams = cjkBigrams(remainder);
190
+ if (gapBigrams) {
191
+ for (const bg of gapBigrams.split(' ')) {
192
+ if (bg && !CJK_STOP_WORDS.has(bg) && !matched.has(bg)) expandedTokens.push(bg);
193
+ }
194
+ }
169
195
  continue;
170
196
  }
171
197
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.28.2",
3
+ "version": "2.29.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "engines": {
@@ -64,10 +64,6 @@
64
64
  "skill.md",
65
65
  "commands/mem.md",
66
66
  "commands/memory.md",
67
- "commands/search.md",
68
- "commands/recall.md",
69
- "commands/recent.md",
70
- "commands/timeline.md",
71
67
  "commands/update.md",
72
68
  "commands/tools.md",
73
69
  "hooks/hooks.json",
package/project-utils.mjs CHANGED
@@ -20,12 +20,25 @@ export function resolveProject(db, name) {
20
20
 
21
21
  // Short name: prefer the canonical "parent--name" form (from inferProject())
22
22
  // which typically has far more data than manually-saved short names.
23
+ // 1) Exact suffix match: "mem" → "projects--mem"
23
24
  const suffixed = db.prepare(
24
25
  'SELECT project FROM observations WHERE project LIKE ? GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1'
25
26
  ).get(`%--${name}`);
26
27
  if (suffixed) { _cache.set(name, suffixed.project); return suffixed.project; }
27
28
 
28
- // Fallback: synthesize canonical form from current directory
29
+ // 2) Prefix-in-suffix match: "code-graph" "projects--code-graph-mcp"
30
+ const prefixed = db.prepare(
31
+ 'SELECT project FROM observations WHERE project LIKE ? GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1'
32
+ ).get(`%--${name}%`);
33
+ if (prefixed) { _cache.set(name, prefixed.project); return prefixed.project; }
34
+
35
+ // 3) Substring match: broader fallback for partial names
36
+ const substr = db.prepare(
37
+ 'SELECT project FROM observations WHERE project LIKE ? GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1'
38
+ ).get(`%${name}%`);
39
+ if (substr) { _cache.set(name, substr.project); return substr.project; }
40
+
41
+ // 4) Fallback: synthesize canonical form from current directory
29
42
  const inferred = inferProject();
30
43
  if (inferred.endsWith(`--${name}`)) { _cache.set(name, inferred); return inferred; }
31
44
 
package/schema.mjs CHANGED
@@ -13,7 +13,7 @@ export const DB_PATH = join(DB_DIR, 'claude-mem-lite.db');
13
13
  export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
14
14
 
15
15
  // Increment when schema changes (tables, columns, indexes, FTS, migrations)
16
- export const CURRENT_SCHEMA_VERSION = 20;
16
+ export const CURRENT_SCHEMA_VERSION = 21;
17
17
 
18
18
  const CORE_SCHEMA = `
19
19
  CREATE TABLE IF NOT EXISTS sdk_sessions (
@@ -111,6 +111,7 @@ const MIGRATIONS = [
111
111
  'ALTER TABLE observations ADD COLUMN superseded_at INTEGER DEFAULT NULL',
112
112
  'ALTER TABLE observations ADD COLUMN superseded_by INTEGER DEFAULT NULL',
113
113
  'ALTER TABLE observations ADD COLUMN last_accessed_at INTEGER DEFAULT NULL',
114
+ 'ALTER TABLE observations ADD COLUMN optimized_at INTEGER DEFAULT NULL',
114
115
  ];
115
116
 
116
117
  /**
package/scoring-sql.mjs CHANGED
@@ -41,8 +41,9 @@ export const TYPE_DECAY_CASE = `(
41
41
  )`;
42
42
 
43
43
  /**
44
- * Type quality multiplier — demotes noisy types (bugfix error logs)
45
- * and promotes high-signal types (decisions, discoveries).
44
+ * Type quality multiplier — promotes high-signal types (decisions, discoveries).
45
+ * Bugfix raised from 0.35→0.75: lesson_learned bugfixes are valuable during
46
+ * active debugging; raw error logs are filtered by importance, not type penalty.
46
47
  * Applied as: BM25 × time_decay × TYPE_QUALITY × project_boost × importance
47
48
  */
48
49
  export const TYPE_QUALITY_CASE = `(
@@ -52,7 +53,7 @@ export const TYPE_QUALITY_CASE = `(
52
53
  WHEN 'feature' THEN 1.2
53
54
  WHEN 'refactor' THEN 1.0
54
55
  WHEN 'change' THEN 0.8
55
- WHEN 'bugfix' THEN 0.35
56
+ WHEN 'bugfix' THEN 0.75
56
57
  ELSE 1.0
57
58
  END
58
59
  )`;
@@ -88,29 +88,44 @@ try {
88
88
  // 60-day lookback to avoid surfacing ancient observations
89
89
  const cutoff = Date.now() - 60 * 86400000;
90
90
 
91
+ // Surface actionable lessons first, then high-importance bugfix/decision observations.
92
+ // Priority: 1) observations with lesson_learned (most actionable for preventing repeat bugs)
93
+ // 2) bugfix/decision types with importance>=2 (contextual history)
94
+ // Skip pure change/discovery without lessons — they add noise without actionable value.
91
95
  const rows = db.prepare(`
92
96
  SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned
93
97
  FROM observations o
94
98
  JOIN observation_files of2 ON of2.obs_id = o.id
95
99
  WHERE o.project = ?
96
100
  AND o.importance >= 2
97
- AND o.lesson_learned IS NOT NULL
98
- AND o.lesson_learned != ''
99
101
  AND COALESCE(o.compressed_into, 0) = 0
100
102
  AND o.superseded_at IS NULL
101
103
  AND o.created_at_epoch > ?
102
104
  AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
103
- ORDER BY o.created_at_epoch DESC
105
+ AND (
106
+ (o.lesson_learned IS NOT NULL AND o.lesson_learned != '')
107
+ OR o.type IN ('bugfix', 'decision')
108
+ )
109
+ ORDER BY
110
+ CASE WHEN o.lesson_learned IS NOT NULL AND o.lesson_learned != '' THEN 0 ELSE 1 END,
111
+ o.created_at_epoch DESC
104
112
  LIMIT 2
105
113
  `).all(project, cutoff, filePath, likePattern);
106
114
 
107
115
  if (rows.length > 0) {
108
116
  console.log(`[mem] Lessons for ${fname}:`);
109
117
  for (const r of rows) {
110
- const lesson = r.lesson_learned.length > 120
111
- ? r.lesson_learned.slice(0, 117) + '...'
112
- : r.lesson_learned;
113
- console.log(` #${r.id} [${r.type}] ${lesson}`);
118
+ if (r.lesson_learned) {
119
+ const lesson = r.lesson_learned.length > 120
120
+ ? r.lesson_learned.slice(0, 117) + '...'
121
+ : r.lesson_learned;
122
+ console.log(` #${r.id} [${r.type}] ${lesson}`);
123
+ } else {
124
+ const title = (r.title || '').length > 120
125
+ ? r.title.slice(0, 117) + '...'
126
+ : (r.title || '');
127
+ console.log(` #${r.id} [${r.type}] ${title}`);
128
+ }
114
129
  }
115
130
  // Update cooldown
116
131
  cooldown[filePath] = now;
@@ -25,12 +25,32 @@ export function shouldSkip(text) {
25
25
  // ─── Intent Detection ───────────────────────────────────────────────────────
26
26
 
27
27
  export const INTENTS = [
28
- // Error/debug intent
29
- { pattern: /error|bug|crash|broken|fail|fix|报错|出错|错误|崩溃|修复/i, type: 'bugfix', limit: 3 },
30
- // Decision/architecture intent (before recall "为什么...之前" is a decision question, not recall)
31
- { pattern: /why|decided|architecture|design|为什么|决定|架构|设计/i, type: 'decision', limit: 3 },
28
+ // Error/debug intent — highest priority, most actionable
29
+ // CJK: 不工作/有问题/挂了 from real prompts; 异常/失败/排查/定位/诊断 from dev vocabulary
30
+ { pattern: /error|bug|crash|broken|fail(?:ed|ing|ure)?|fix(?:ed|ing)?|debug|调试|报错|出错|错误|崩溃|修复|故障|不工作|有问题|出了问题|挂了|异常|失败|解决|排查|定位|诊断/i, type: 'bugfix', limit: 3 },
31
+ // Test intent test failures surface bugfix memories
32
+ // CJK: 跑测试/写测试/测试用例/覆盖率 from real prompts
33
+ { pattern: /\btest(?:s|ing)?\b|spec\b|assert|单元测试|测试失败|test fail|测试|跑测试|写测试|测试用例|覆盖率/i, type: 'bugfix', limit: 3 },
34
+ // Review/audit intent — from real data: 审查(6x), 检查(9x), 审核, 代码审核
35
+ { pattern: /\breview\b|audit|inspect|审查|审核|检查|代码审核|审阅|code.?review/i, type: 'discovery', limit: 3 },
36
+ // Refactor intent — surface past refactor decisions and patterns
37
+ // CJK: 拆分/提取/简化/解耦/清理 from real prompts; 优化代码 = refactor (not perf)
38
+ { pattern: /refactor|restructur|cleanup|clean up|重构|整理|代码质量|拆分|提取|简化|解耦|清理/i, type: 'refactor', limit: 3 },
39
+ // Performance intent — before decision (so "slow" doesn't get classified as decision)
40
+ // CJK: 卡顿/超时/内存泄漏/优化 from real prompts; 加速/提速 from dev vocabulary
41
+ { pattern: /performance|perf\b|slow|latency|bottleneck|optimiz|性能|慢|延迟|耗时|效率低|卡顿|超时|内存泄漏|优化|加速|提速/i, type: 'discovery', limit: 3 },
42
+ // Decision/architecture intent
43
+ // CJK: 方案/原因/考虑/权衡/思路 from real prompts
44
+ { pattern: /why\b|decided|architecture|design\b|为什么|决定|架构|设计|方案|原因|考虑|权衡|思路/i, type: 'decision', limit: 3 },
45
+ // Database/schema intent — surface migration decisions
46
+ // CJK: 索引/查询/建表/改表 from dev vocabulary
47
+ { pattern: /schema|migration|数据库|迁移|database\b|表结构|字段|索引|查询|建表|改表/i, type: 'decision', limit: 3 },
48
+ // Implementation intent — surface related feature history (no type filter for broader recall)
49
+ // CJK: 开发/编写/创建/构建/做一个/写一个 from real prompts
50
+ { pattern: /implement|feature\b|add\s+(?:a\s+)?new|实现|添加|新功能|新增|开发|编写|创建|构建|做一个|加一个|写一个/i, type: null, limit: 3 },
32
51
  // Recall/history intent (catch-all temporal, lowest priority)
33
- { pattern: /before|previously|last time|remember|之前|上次|以前|记得/i, type: null, limit: 5, useRecent: true },
52
+ // CJK: 刚才/历史/回顾 from real prompts
53
+ { pattern: /before|previously|last time|remember|之前|上次|以前|记得|刚才|历史|回顾/i, type: null, limit: 5, useRecent: true },
34
54
  ];
35
55
 
36
56
  export function detectIntent(text) {
@@ -42,15 +62,15 @@ export function detectIntent(text) {
42
62
  if (matches.length === 0) return null;
43
63
  if (matches.length === 1) return matches[0];
44
64
 
45
- // Disambiguation: specifically when bugfix and recall both match, use
46
- // position-based resolution — the pattern appearing earlier in text wins.
65
+ // Disambiguation: when recall intent overlaps with an actionable intent,
66
+ // use position-based resolution — the pattern appearing earlier in text wins.
47
67
  // "I remember we fixed..." → recall leads. "fix the bug from before" → bugfix leads.
48
68
  const first = matches[0];
49
- const second = matches[1];
50
- if (first.type === 'bugfix' && second.useRecent) {
51
- const bugPos = text.search(first.pattern);
52
- const recallPos = text.search(second.pattern);
53
- if (recallPos < bugPos) return second;
69
+ const recallMatch = matches.find(m => m.useRecent);
70
+ if (recallMatch && first !== recallMatch) {
71
+ const actionPos = text.search(first.pattern);
72
+ const recallPos = text.search(recallMatch.pattern);
73
+ if (recallPos < actionPos) return recallMatch;
54
74
  }
55
75
  return first;
56
76
  }
@@ -112,8 +132,13 @@ export function matchRegistrySkillName(text, skillNames) {
112
132
 
113
133
  // ─── File Path Detection ─────────────────────────────────────────────────────
114
134
 
115
- /** Detect file paths in text */
135
+ /** Detect file paths in text — excludes URLs and pure version numbers */
116
136
  export function extractFiles(text) {
117
137
  const matches = text.match(/[\w./-]+\.\w{1,10}/g) || [];
118
- return matches.filter(m => m.includes('.') && !m.startsWith('http'));
138
+ return matches.filter(m =>
139
+ m.includes('.') &&
140
+ !m.startsWith('http') &&
141
+ !m.includes('//') &&
142
+ !/^\d+\.\d+$/.test(m) // Exclude pure version numbers like "3.14" (not paths like "1.0/config.json")
143
+ );
119
144
  }
package/server.mjs CHANGED
@@ -5,11 +5,13 @@
5
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
7
  import { jaccardSimilarity, truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, fmtDate, isoWeekKey, debugLog, debugCatch, COMPRESSED_PENDING_PURGE, OBS_BM25, SESS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, getCurrentBranch, DEFAULT_DECAY_HALF_LIFE_MS, isPathConfined } from './utils.mjs';
8
+ import { extractCjkLikePatterns } from './nlp.mjs';
8
9
  import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
9
10
  import { ensureDb, DB_PATH, REGISTRY_DB_PATH, checkFTSIntegrity, rebuildFTS } from './schema.mjs';
10
11
  import { reRankWithContext, markSuperseded, extractPRFTerms, expandQueryByConcepts, autoBoostIfNeeded, runIdleCleanup } from './server-internals.mjs';
11
12
  import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
12
- import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema } from './tool-schemas.mjs';
13
+ import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memOptimizeSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema } from './tool-schemas.mjs';
14
+ import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
13
15
  import { basename, join } from 'path';
14
16
  import { homedir } from 'os';
15
17
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
@@ -110,12 +112,14 @@ const server = new McpServer(
110
112
  'mem_save: Save non-obvious insights (bugfix lessons, architecture decisions).',
111
113
  'Search tips: short keywords (2-3 words), filter with obs_type when relevant.',
112
114
  '',
113
- 'WHEN TO USE (proactive triggers):',
114
- ' • Before fixing a bugrecall the file: claude-mem-lite recall "file.mjs"',
115
- ' • Encountering an error → search for similar: claude-mem-lite search "error message" --type bugfix',
116
- ' • Starting work on a module recall past decisions: claude-mem-lite search "module-name" --type decision',
117
- ' • After solving a non-obvious problem save the lesson: mem_save with lesson_learned',
118
- ' • When hook-injected context mentions a relevant IDget details: claude-mem-lite get ID',
115
+ 'WHEN TO USE (proactive triggers during coding):',
116
+ ' • About to Edit/Write a filemem_recall(file="path") FIRST past bugfixes & lessons',
117
+ ' • Test failure or error → mem_search(query="error keywords", obs_type="bugfix")',
118
+ ' • Before refactoringmem_search(query="module-name", obs_type="refactor") for past decisions',
119
+ ' • Starting new feature mem_search(query="feature area") for prior art & patterns',
120
+ ' • After fixing a tricky bugmem_save(type="bugfix", lesson_learned="root cause & fix")',
121
+ ' • After architecture decision → mem_save(type="decision", lesson_learned="rationale")',
122
+ ' • Hook-injected context mentions #ID → mem_get(ids=[ID]) for full details',
119
123
  '',
120
124
  'Decision rules (use INSTEAD OF multi-step search):',
121
125
  ' • "what happened recently?" → mem_recent (NOT search with empty query)',
@@ -453,6 +457,35 @@ function searchPrompts(ctx) {
453
457
  for (const r of rows) {
454
458
  results.push({ source: 'prompt', id: r.id, text: r.prompt_text, session: r.content_session_id, date: r.created_at, score: r.score });
455
459
  }
460
+ // CJK LIKE fallback: FTS5 unicode61 can't tokenize CJK substrings in prompts
461
+ if (rows.length === 0 && args.query) {
462
+ const cjkPatterns = extractCjkLikePatterns(args.query);
463
+ if (cjkPatterns.length > 0) {
464
+ const likeConds = cjkPatterns.map(() => 'p.prompt_text LIKE ?');
465
+ const likeParams = cjkPatterns.map(p => `%${p}%`);
466
+ const fallbackRows = db.prepare(`
467
+ SELECT p.id, p.prompt_text, p.content_session_id, p.created_at
468
+ FROM user_prompts p
469
+ JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
470
+ WHERE (${likeConds.join(' OR ')})
471
+ AND p.prompt_text NOT LIKE '<task-notification>%'
472
+ AND (? IS NULL OR s.project = ?)
473
+ AND (? IS NULL OR p.created_at_epoch >= ?)
474
+ AND (? IS NULL OR p.created_at_epoch <= ?)
475
+ ORDER BY p.created_at_epoch DESC
476
+ LIMIT ? OFFSET ?
477
+ `).all(
478
+ ...likeParams,
479
+ args.project ?? null, args.project ?? null,
480
+ epochFrom, epochFrom,
481
+ epochTo, epochTo,
482
+ perSourceLimit, perSourceOffset
483
+ );
484
+ for (const r of fallbackRows) {
485
+ results.push({ source: 'prompt', id: r.id, text: r.prompt_text, session: r.content_session_id, date: r.created_at, score: 0 });
486
+ }
487
+ }
488
+ }
456
489
  } else if (searchType === 'prompts') {
457
490
  const params = [];
458
491
  const wheres = [];
@@ -524,7 +557,7 @@ function formatSearchOutput(paginatedResults, args, ftsQuery, totalCount, isCros
524
557
  server.registerTool(
525
558
  'mem_search',
526
559
  {
527
- description: 'Search project memory for past bugfixes, decisions, and discoveries. Use when: encountering a familiar error, investigating a module before changes, or looking for prior art on a problem. Returns compact index (use mem_get for full details).',
560
+ description: 'Search project memory for past bugfixes, decisions, and discoveries. Use proactively when: encountering an error (search with obs_type="bugfix"), investigating a module before changes, or looking for prior art. Returns compact index (use mem_get for full details).',
528
561
  inputSchema: memSearchSchema,
529
562
  },
530
563
  safeHandler(async (args) => {
@@ -699,7 +732,7 @@ server.registerTool(
699
732
  server.registerTool(
700
733
  'mem_timeline',
701
734
  {
702
- description: 'Browse observations as a timeline around an anchor point. Use when: exploring what happened before/after a specific observation, understanding the sequence of changes that led to a bug, or reviewing a session chronologically.',
735
+ description: 'Browse observations as a timeline around an anchor point. Accepts anchor ID or a query string to auto-find the anchor via FTS. Use when: exploring what happened before/after a specific observation, understanding the sequence of changes that led to a bug, or reviewing a session chronologically. Example: mem_timeline(query="FTS5 search bug") or mem_timeline(anchor=42).',
703
736
  inputSchema: memTimelineSchema,
704
737
  },
705
738
  safeHandler(async (args) => {
@@ -802,7 +835,7 @@ server.registerTool(
802
835
  server.registerTool(
803
836
  'mem_get',
804
837
  {
805
- description: 'Get full details for one or more records by ID. Use when: hook-injected context mentions a relevant observation ID, or after mem_search to drill into specific results for narrative, lesson_learned, and file details.',
838
+ description: 'Get full details for one or more records by ID. Use when: hook-injected context mentions a relevant observation ID, or after mem_search to drill into specific results for narrative, lesson_learned, and file details. For session results (S#15), pass source="session". For prompt results (P#22), pass source="prompt".',
806
839
  inputSchema: memGetSchema,
807
840
  },
808
841
  safeHandler(async (args) => {
@@ -925,7 +958,7 @@ server.registerTool(
925
958
  server.registerTool(
926
959
  'mem_save',
927
960
  {
928
- description: 'Save a memory/observation. Use when: solving a non-obvious bug (save the lesson), making an architecture decision, discovering something not obvious from code alone, or when the user asks to remember something.',
961
+ description: 'Save a memory/observation with optional lesson_learned. Use after: solving a non-obvious bug (pass lesson_learned="root cause & fix"), making an architecture decision (pass lesson_learned="rationale"), or discovering something not obvious from code. Also when user asks to remember something.',
929
962
  inputSchema: memSaveSchema,
930
963
  },
931
964
  safeHandler(async (args) => {
@@ -960,18 +993,20 @@ server.registerTool(
960
993
 
961
994
  const safeContent = scrubSecrets(args.content);
962
995
  const safeTitle = scrubSecrets(title);
996
+ const safeLesson = args.lesson_learned ? scrubSecrets(args.lesson_learned) : null;
963
997
  const minhashSig = computeMinHash(safeTitle + ' ' + safeContent);
964
998
  // Append CJK bigrams to text field for FTS5 indexing of Chinese content
965
- const bigramText = cjkBigrams(safeTitle + ' ' + safeContent);
999
+ const indexText = [safeTitle, safeContent, safeLesson].filter(Boolean).join(' ');
1000
+ const bigramText = cjkBigrams(indexText);
966
1001
  const textField = bigramText ? safeContent + ' ' + bigramText : safeContent;
967
1002
 
968
1003
  // Atomic: insert observation + observation_files + TF-IDF vector in one transaction
969
1004
  const saveFiles = args.files || [];
970
1005
  const saveTx = db.transaction(() => {
971
1006
  const result = db.prepare(`
972
- INSERT INTO observations (memory_session_id, project, text, type, title, narrative, concepts, facts, files_read, files_modified, importance, minhash_sig, branch, created_at, created_at_epoch)
973
- VALUES (?, ?, ?, ?, ?, ?, '', '', '[]', ?, ?, ?, ?, ?, ?)
974
- `).run(sessionId, project, textField, type, safeTitle, safeContent, JSON.stringify(saveFiles), args.importance ?? 2, minhashSig, getCurrentBranch(), now.toISOString(), now.getTime());
1007
+ INSERT INTO observations (memory_session_id, project, text, type, title, narrative, concepts, facts, files_read, files_modified, importance, minhash_sig, lesson_learned, branch, created_at, created_at_epoch)
1008
+ VALUES (?, ?, ?, ?, ?, ?, '', '', '[]', ?, ?, ?, ?, ?, ?, ?)
1009
+ `).run(sessionId, project, textField, type, safeTitle, safeContent, JSON.stringify(saveFiles), args.importance ?? 2, minhashSig, safeLesson, getCurrentBranch(), now.toISOString(), now.getTime());
975
1010
  const savedId = Number(result.lastInsertRowid);
976
1011
 
977
1012
  // Populate observation_files junction table
@@ -998,7 +1033,8 @@ server.registerTool(
998
1033
  });
999
1034
  const result = saveTx();
1000
1035
 
1001
- return { content: [{ type: 'text', text: `Saved as observation #${result.lastInsertRowid} [${type}] in project "${project}".` }] };
1036
+ const lessonNote = safeLesson ? ` 💡lesson captured` : '';
1037
+ return { content: [{ type: 'text', text: `Saved as observation #${result.lastInsertRowid} [${type}] in project "${project}".${lessonNote}` }] };
1002
1038
  })
1003
1039
  );
1004
1040
 
@@ -1486,6 +1522,50 @@ server.registerTool(
1486
1522
  })
1487
1523
  );
1488
1524
 
1525
+ // ─── Tool: mem_optimize ────────────────────────────────────────────────────
1526
+
1527
+ server.registerTool(
1528
+ 'mem_optimize',
1529
+ {
1530
+ description: 'LLM-powered database optimization: re-enrich degraded records, normalize concepts, merge related observations, smart-compress old data. Use when: database quality seems low, search results are noisy, or for periodic deep maintenance.',
1531
+ inputSchema: memOptimizeSchema,
1532
+ },
1533
+ safeHandler(async (args) => {
1534
+ const action = args.action || 'preview';
1535
+
1536
+ if (action === 'preview') {
1537
+ const preview = optimizePreview(db);
1538
+ const lines = [
1539
+ `🔍 LLM Optimization Preview:`,
1540
+ ` Re-enrich candidates: ${preview.reenrich}`,
1541
+ ` Normalize: ${preview.normalizeGateOpen ? `${preview.normalize} unique concepts` : 'gate closed (7-day interval)'}`,
1542
+ ` Cluster-merge candidates: ${preview.clusterMerge} clusters`,
1543
+ ` Smart-compress candidates: ${preview.smartCompress} clusters`,
1544
+ ` Total: ${preview.total} items`,
1545
+ ];
1546
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1547
+ }
1548
+
1549
+ const force = action === 'run_all';
1550
+ const results = await optimizeRun(db, {
1551
+ tasks: args.tasks,
1552
+ maxItems: args.max_items || 15,
1553
+ force,
1554
+ });
1555
+
1556
+ const lines = ['🔧 LLM Optimization Results:'];
1557
+ if (results.reenrich) lines.push(` Re-enrich: ${results.reenrich.processed || 0} processed, ${results.reenrich.skipped || 0} skipped`);
1558
+ if (results.normalize) {
1559
+ if (results.normalize.skipped) lines.push(` Normalize: skipped (${results.normalize.reason})`);
1560
+ else lines.push(` Normalize: ${results.normalize.processed || 0} updated, ${results.normalize.groups || 0} synonym groups`);
1561
+ }
1562
+ if (results.clusterMerge) lines.push(` Cluster-merge: ${results.clusterMerge.merged || 0} merged of ${results.clusterMerge.processed || 0} clusters`);
1563
+ if (results.smartCompress) lines.push(` Smart-compress: ${results.smartCompress.compressed || 0} compressed of ${results.smartCompress.processed || 0} clusters`);
1564
+
1565
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1566
+ })
1567
+ );
1568
+
1489
1569
  // ─── Tool: mem_registry ─────────────────────────────────────────────────────
1490
1570
 
1491
1571
  server.registerTool(
@@ -1872,7 +1952,7 @@ server.registerTool(
1872
1952
  server.registerTool(
1873
1953
  'mem_recall',
1874
1954
  {
1875
- description: 'Recall observations related to a file. Use when: about to edit a file, investigating a file with past issues, or before refactoring to recall past bugfixes, decisions, and context.',
1955
+ description: 'Recall observations related to a file. ALWAYS use before editing a file with known issues. Also use when: investigating a file, or before refactoring to recall past bugfixes, decisions, and context.',
1876
1956
  inputSchema: memRecallSchema,
1877
1957
  },
1878
1958
  safeHandler(async (args) => {
package/skill.md CHANGED
@@ -1,35 +1,22 @@
1
1
  ---
2
2
  name: mem
3
- description: Search and manage project memory (observations, sessions, prompts)
3
+ description: "Use when: querying past work, managing memories, checking project history, or saving session findings"
4
4
  ---
5
5
 
6
- # Memory Skill
7
-
8
- Search and browse your project memory efficiently.
6
+ # Memory
9
7
 
10
8
  ## Commands
11
9
 
12
- - `/mem search <query>` — FTS5 full-text search across all memories
13
- - `/mem recent [n]` — Show recent N observations (default 10)
14
- - `/mem save <text>` — Save a manual memory/note
15
- - `/mem stats`Show memory statistics
16
- - `/mem timeline <query>` — Browse timeline around a matching observation
17
-
18
- ## Efficient Search Workflow (3 steps, saves 10x tokens)
19
-
20
- 1. **Search** → `mem_search(query="...")` → get compact ID index
21
- 2. **Browse** → `mem_timeline(anchor=ID)` → see surrounding context
22
- 3. **Detail** → `mem_get(ids=[...])` → get full content for specific IDs
23
-
24
- ## Instructions
25
-
26
- When the user invokes `/mem`, parse their intent:
10
+ - `/mem search <query>` — FTS5 full-text search
11
+ - `/mem recent [n]` — Recent observations (default 5)
12
+ - `/mem recall <file>` — File history before editing
13
+ - `/mem timeline <id>` Browse around an observation
14
+ - `/mem save <text>` — Save a note
15
+ - `/mem stats` — Memory statistics
16
+ - `/mem cleanup [Nd]` Purge stale data
27
17
 
28
- - `/mem search <query>` call `mem_search` with the query
29
- - `/mem recent` or `/mem recent 20` → call `mem_search` with no query, limit=N
30
- - `/mem save <text>` → call `mem_save` with the text as content
31
- - `/mem stats` → call `mem_stats`
32
- - `/mem timeline <query>` → call `mem_timeline` with the query
33
- - `/mem <query>` (no subcommand) → treat as search, call `mem_search`
18
+ ## Efficient Workflow (saves 10x tokens)
34
19
 
35
- Always use the compact index from mem_search first, then mem_get for details only when needed. This minimizes token usage.
20
+ 1. `mem_search(query)` compact ID index
21
+ 2. `mem_timeline(anchor=ID)` → surrounding context
22
+ 3. `mem_get(ids=[...])` → full content only when needed
package/synonyms.mjs CHANGED
@@ -71,6 +71,43 @@ export const SYNONYM_PAIRS = [
71
71
  ['debug', 'troubleshoot'],
72
72
  ['error', 'failure'],
73
73
  ['migrate', 'migration'],
74
+ // ─── Concurrency & Async ───
75
+ ['promise', 'async'],
76
+ ['callback', 'handler'],
77
+ ['deadlock', 'race condition'],
78
+ ['mutex', 'lock'],
79
+ ['semaphore', 'lock'],
80
+ ['concurrent', 'parallel'],
81
+ ['thread', 'worker'],
82
+ ['await', 'async'],
83
+ // ─── Type System ───
84
+ ['interface', 'type'],
85
+ ['generic', 'generics'],
86
+ ['typedef', 'type'],
87
+ ['enum', 'enumeration'],
88
+ // ─── Testing ───
89
+ ['mock', 'stub'],
90
+ ['mock', 'fake'],
91
+ ['fixture', 'testdata'],
92
+ ['assert', 'assertion'],
93
+ ['jest', 'vitest'],
94
+ ['pytest', 'unittest'],
95
+ ['cypress', 'playwright'],
96
+ // ─── Networking ───
97
+ ['http', 'request'],
98
+ ['rest', 'api'],
99
+ ['grpc', 'rpc'],
100
+ ['timeout', 'deadline'],
101
+ ['retry', 'backoff'],
102
+ ['cors', 'cross origin'],
103
+ ['ssl', 'tls'],
104
+ ['throttle', 'rate limit'],
105
+ // ─── Build Tools ───
106
+ ['webpack', 'bundler'],
107
+ ['vite', 'bundler'],
108
+ ['rollup', 'bundler'],
109
+ ['esbuild', 'bundler'],
110
+ ['transpile', 'compile'],
74
111
  // ─── CJK ↔ EN cross-language synonyms ───
75
112
  // Authentication & Authorization
76
113
  ['认证', 'auth'], ['认证', 'authentication'], ['登录', 'login'], ['登录', 'auth'],
@@ -108,6 +145,16 @@ export const SYNONYM_PAIRS = [
108
145
  ['钩子', 'hook'], ['回调', 'callback'],
109
146
  ['异步', 'async'], ['同步', 'sync'],
110
147
  ['并发', 'concurrent'], ['线程', 'thread'],
148
+ ['竞态', 'race condition'], ['死锁', 'deadlock'], ['互斥', 'mutex'],
149
+ ['协程', 'coroutine'], ['事件循环', 'event loop'],
150
+ ['泛型', 'generic'], ['枚举', 'enum'],
151
+ ['断言', 'assert'], ['单元测试', 'unit test'], ['集成测试', 'integration test'],
152
+ ['模拟测试', 'mock'], ['测试覆盖', 'coverage'],
153
+ ['错误处理', 'error handling'], ['异常捕获', 'try catch'], ['堆栈跟踪', 'stack trace'],
154
+ ['容错', 'fault tolerance'],
155
+ ['跨域', 'cors'], ['限流', 'rate limit'], ['熔断', 'circuit breaker'],
156
+ ['负载均衡', 'load balancing'], ['心跳', 'heartbeat'],
157
+ ['请求', 'request'], ['失败', 'failure'], ['覆盖率', 'coverage'],
111
158
  // Performance
112
159
  ['性能', 'performance'], ['性能', 'perf'],
113
160
  ['内存', 'memory'], ['泄漏', 'leak'],
@@ -140,6 +187,23 @@ export const SYNONYM_PAIRS = [
140
187
  ['安装', 'install'], ['导入', 'import'],
141
188
  ['导出', 'export'], ['状态', 'state'],
142
189
  ['系统', 'system'], ['算法', 'algorithm'],
190
+ // Common dev terms (mined from real usage data)
191
+ ['文件', 'file'], ['代码', 'code'],
192
+ ['执行', 'execute'], ['执行', 'run'],
193
+ ['调用', 'call'], ['调用', 'invoke'],
194
+ ['运行', 'run'], ['运行', 'execute'],
195
+ ['检查', 'check'], ['检查', 'inspect'],
196
+ ['分析', 'analyze'], ['分析', 'analysis'],
197
+ ['项目', 'project'], ['流程', 'workflow'],
198
+ ['更新', 'update'], ['提示', 'prompt'],
199
+ ['查找', 'find'], ['记录', 'record'],
200
+ ['记录', 'log'], ['校验', 'validate'],
201
+ ['计算', 'compute'], ['计算', 'calculate'],
202
+ ['单元', 'unit'], ['资源', 'resource'],
203
+ ['问题', 'issue'], ['问题', 'problem'],
204
+ ['历史', 'history'], ['描述', 'description'],
205
+ ['推荐', 'recommend'], ['建议', 'suggestion'],
206
+ ['智能', 'smart'], ['智能', 'intelligent'],
143
207
  ];
144
208
 
145
209
  // ─── Bidirectional SYNONYM_MAP (case-insensitive) ──────────────────────────────
@@ -168,11 +232,25 @@ export const CJK_COMPOUNDS = new Set([
168
232
  '认证', '授权', '加密', '解密', '序列', '并发', '异步', '同步', '线程', '进程',
169
233
  '容器', '集群', '服务器', '中间件', '网关', '负载', '监控', '日志', '告警',
170
234
  '前端', '后端', '全栈', '响应式', '路由', '状态', '渲染', '样式', '布局',
235
+ '代码', '文件', '项目', '资源', '单元', '智能',
236
+ // concurrency & async (extended)
237
+ '竞态', '死锁', '互斥', '协程', '回调', '事件循环',
238
+ // type system
239
+ '泛型', '枚举', '联合类型', '类型推断', '类型守卫', '接口定义',
240
+ // testing (extended)
241
+ '单元测试', '集成测试', '端到端', '测试覆盖', '覆盖率', '模拟测试', '断言',
242
+ // error handling (extended)
243
+ '错误处理', '异常捕获', '堆栈跟踪', '错误码', '容错', '失败', '请求',
244
+ // networking
245
+ '跨域', '限流', '熔断', '负载均衡', '心跳',
171
246
  // actions
172
247
  '修复', '重构', '优化', '升级', '安装', '卸载', '导入', '导出', '上传', '下载',
173
248
  '提交', '推送', '合并', '发布', '上线', '回退', '审查', '审核', '评审',
249
+ '执行', '调用', '运行', '检查', '分析', '查找', '计算', '校验', '更新', '描述',
174
250
  // errors/issues
175
- '报错', '崩溃', '泄露', '溢出', '死锁', '超时', '中断', '异常', '故障',
251
+ '报错', '崩溃', '泄露', '溢出', '超时', '中断', '异常', '故障',
252
+ // general (tokenization only — keeps CJK segmentation clean)
253
+ '问题', '使用', '继续', '推荐', '建议', '历史', '记录', '中文', '提示', '流程',
176
254
  // architecture
177
255
  '架构', '设计', '方案', '规划', '文档', '注释', '版本', '分支', '依赖',
178
256
  '性能', '安全', '漏洞', '补丁', '系统', '算法',
package/tool-schemas.mjs CHANGED
@@ -74,6 +74,7 @@ export const memSaveSchema = {
74
74
  project: z.string().optional().describe('Project name (default: inferred from CWD)'),
75
75
  importance: coerceInt.pipe(z.number().int().min(1).max(3)).optional().describe('Importance level: 1=routine, 2=notable, 3=critical (default: 2 for explicit saves)'),
76
76
  files: z.array(z.string()).optional().describe('File paths associated with this observation'),
77
+ lesson_learned: z.string().max(500).optional().describe('Key lesson or takeaway (for bugfix: root cause & fix; for decision: rationale)'),
77
78
  };
78
79
 
79
80
  export const memStatsSchema = {
@@ -87,6 +88,15 @@ export const memCompressSchema = {
87
88
  project: z.string().optional().describe('Filter by project'),
88
89
  };
89
90
 
91
+ export const memOptimizeSchema = {
92
+ action: z.enum(['preview', 'run', 'run_all']).optional().default('preview')
93
+ .describe('preview=scan candidates, run=execute with limits, run_all=bypass gates'),
94
+ tasks: z.array(z.enum(['re-enrich', 'normalize', 'cluster-merge', 'smart-compress'])).optional()
95
+ .describe('Which optimization tasks to run (default: all)'),
96
+ max_items: coerceInt.pipe(z.number().int().min(1).max(100)).optional().default(15)
97
+ .describe('Maximum LLM calls across all tasks (default: 15)'),
98
+ };
99
+
90
100
  export const memMaintainSchema = {
91
101
  action: z.enum(['scan', 'execute']).describe('scan=analyze candidates, execute=apply changes'),
92
102
  operations: z.array(z.enum(['dedup', 'decay', 'cleanup', 'boost', 'purge_stale', 'rebuild_vectors'])).optional()
package/utils.mjs CHANGED
@@ -9,7 +9,7 @@ import { execSync } from 'child_process';
9
9
  // Backward compatibility: all consumers import from utils.mjs
10
10
 
11
11
  export { DECAY_HALF_LIFE_BY_TYPE, DEFAULT_DECAY_HALF_LIFE_MS, OBS_BM25, SESS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, OBS_FTS_COLUMNS } from './scoring-sql.mjs';
12
- export { cjkBigrams, extractCjkSynonymTokens, extractCjkKeywords, SYNONYM_MAP, expandToken, sanitizeFtsQuery, relaxFtsQueryToOr, FTS_STOP_WORDS, CJK_COMPOUNDS } from './nlp.mjs';
12
+ export { cjkBigrams, extractCjkSynonymTokens, extractCjkKeywords, extractCjkLikePatterns, SYNONYM_MAP, expandToken, sanitizeFtsQuery, relaxFtsQueryToOr, FTS_STOP_WORDS, CJK_COMPOUNDS } from './nlp.mjs';
13
13
  export { resolveProject, _resetProjectCache } from './project-utils.mjs';
14
14
  export { scrubSecrets, SECRET_PATTERNS } from './secret-scrub.mjs';
15
15
  export { truncate, typeIcon, fmtDate, fmtTime, isoWeekKey } from './format-utils.mjs';
@@ -1,9 +0,0 @@
1
- ---
2
- description: "Recall past observations for a file before editing. Use when: about to edit a file, investigating a file with past issues, or before refactoring to check for past lessons"
3
- argument-hint: <file_path>
4
- ---
5
-
6
- ## File Memory
7
- !`claude-mem-lite recall $ARGUMENTS 2>/dev/null || echo "No history found"`
8
-
9
- Consider these past observations before making changes.
@@ -1,7 +0,0 @@
1
- ---
2
- description: "Show recent memory observations. Use when: checking what happened recently, reviewing session progress, or verifying recent changes were captured"
3
- argument-hint: [count]
4
- ---
5
-
6
- ## Recent Observations
7
- !`claude-mem-lite recent $ARGUMENTS 2>/dev/null || echo "No observations"`
@@ -1,9 +0,0 @@
1
- ---
2
- description: "Search memory for past bugfixes, decisions, discoveries. Use when: encountering a familiar error, investigating a module before changes, or looking for prior solutions to a similar problem"
3
- argument-hint: <query>
4
- ---
5
-
6
- ## Memory Search
7
- !`claude-mem-lite search $ARGUMENTS 2>/dev/null || echo "No results found"`
8
-
9
- Use `claude-mem-lite get <id>` via Bash for full details on any result.
@@ -1,7 +0,0 @@
1
- ---
2
- description: "Browse memory timeline around an observation. Use when: exploring what happened before/after a specific event, understanding the sequence of changes that led to a bug, or reviewing chronological context"
3
- argument-hint: <observation_id>
4
- ---
5
-
6
- ## Timeline
7
- !`claude-mem-lite timeline --anchor $ARGUMENTS 2>/dev/null || echo "Not found"`