claude-mem-lite 2.28.2 → 2.30.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.
@@ -7,8 +7,10 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
7
7
  import { basename, join } from 'path';
8
8
  import { homedir } from 'os';
9
9
 
10
- const DB_PATH = join(homedir(), '.claude-mem-lite', 'claude-mem-lite.db');
11
- const RUNTIME_DIR = join(homedir(), '.claude-mem-lite', 'runtime');
10
+ // CLAUDE_MEM_DB_PATH / CLAUDE_MEM_RUNTIME_DIR env overrides allow tests and debug tools to
11
+ // point the hook at an isolated DB + cooldown dir without touching the user's real state.
12
+ const DB_PATH = process.env.CLAUDE_MEM_DB_PATH || join(homedir(), '.claude-mem-lite', 'claude-mem-lite.db');
13
+ const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(homedir(), '.claude-mem-lite', 'runtime');
12
14
  const COOLDOWN_PATH = join(RUNTIME_DIR, 'pre-recall-cooldown.json');
13
15
  const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
14
16
  const STALE_MS = 10 * 60 * 1000; // 10 minutes cleanup threshold
@@ -88,34 +90,55 @@ try {
88
90
  // 60-day lookback to avoid surfacing ancient observations
89
91
  const cutoff = Date.now() - 60 * 86400000;
90
92
 
93
+ // Surface actionable lessons first, then high-importance bugfix/decision observations.
94
+ // Priority: 1) observations with lesson_learned (most actionable for preventing repeat bugs)
95
+ // 2) bugfix/decision types with importance>=2 (contextual history)
96
+ // Skip pure change/discovery without lessons — they add noise without actionable value.
91
97
  const rows = db.prepare(`
92
98
  SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned
93
99
  FROM observations o
94
100
  JOIN observation_files of2 ON of2.obs_id = o.id
95
101
  WHERE o.project = ?
96
102
  AND o.importance >= 2
97
- AND o.lesson_learned IS NOT NULL
98
- AND o.lesson_learned != ''
99
103
  AND COALESCE(o.compressed_into, 0) = 0
100
104
  AND o.superseded_at IS NULL
101
105
  AND o.created_at_epoch > ?
102
106
  AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
103
- ORDER BY o.created_at_epoch DESC
107
+ AND (
108
+ (o.lesson_learned IS NOT NULL AND o.lesson_learned != '')
109
+ OR o.type IN ('bugfix', 'decision')
110
+ )
111
+ ORDER BY
112
+ CASE WHEN o.lesson_learned IS NOT NULL AND o.lesson_learned != '' THEN 0 ELSE 1 END,
113
+ o.created_at_epoch DESC
104
114
  LIMIT 2
105
115
  `).all(project, cutoff, filePath, likePattern);
106
116
 
107
117
  if (rows.length > 0) {
108
118
  console.log(`[mem] Lessons for ${fname}:`);
109
119
  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}`);
120
+ if (r.lesson_learned) {
121
+ const lesson = r.lesson_learned.length > 120
122
+ ? r.lesson_learned.slice(0, 117) + '...'
123
+ : r.lesson_learned;
124
+ console.log(` #${r.id} [${r.type}] ${lesson}`);
125
+ } else {
126
+ const title = (r.title || '').length > 120
127
+ ? r.title.slice(0, 117) + '...'
128
+ : (r.title || '');
129
+ console.log(` #${r.id} [${r.type}] ${title}`);
130
+ }
114
131
  }
115
- // Update cooldown
116
- cooldown[filePath] = now;
117
- writeCooldown(cooldown);
132
+ } else {
133
+ // R-4: emit a short backfill reminder instead of staying silent.
134
+ // Two goals: (1) Claude sees that the system actually ran, (2) Claude is
135
+ // nudged to mem_save a lesson when solving a non-obvious bug. The reminder
136
+ // is one line to minimize per-Edit context cost.
137
+ console.log(`[mem] No prior lessons for ${fname} — if you solve a non-obvious bug here, run: claude-mem-lite save --type bugfix --lesson "<one-line root cause + fix>"`);
118
138
  }
139
+ // Cooldown applies to BOTH branches so the reminder doesn't spam every Edit.
140
+ cooldown[filePath] = now;
141
+ writeCooldown(cooldown);
119
142
  } catch {
120
143
  // Silent failure — never block editing
121
144
  } finally {
@@ -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
  }
@@ -4,7 +4,7 @@
4
4
  // Lightweight: only imports schema.mjs and utils.mjs, no MCP SDK
5
5
 
6
6
  import { ensureDb, DB_DIR, REGISTRY_DB_PATH } from '../schema.mjs';
7
- import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, isPathConfined } from '../utils.mjs';
7
+ import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, isPathConfined, notLowSignalTitleClause } from '../utils.mjs';
8
8
  import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
9
9
  import { join } from 'path';
10
10
  import { homedir } from 'os';
@@ -27,6 +27,8 @@ function searchByFts(db, queryText, project, limit, typeFilter) {
27
27
 
28
28
  const typeClause = typeFilter ? 'AND o.type = ?' : '';
29
29
  const now = Date.now();
30
+ // R1: notLowSignalTitleClause() excludes hook-llm degraded titles
31
+ // ("Modified X", "Worked on X", "Reviewed N files:", raw error logs).
30
32
  const sql = `
31
33
  SELECT o.id, o.type, o.title, o.lesson_learned,
32
34
  ${OBS_BM25}
@@ -40,6 +42,7 @@ function searchByFts(db, queryText, project, limit, typeFilter) {
40
42
  AND o.importance >= 1
41
43
  AND o.created_at_epoch > ?
42
44
  AND COALESCE(o.compressed_into, 0) = 0
45
+ AND ${notLowSignalTitleClause('o')}
43
46
  ${typeClause}
44
47
  ORDER BY relevance
45
48
  LIMIT ?
@@ -75,6 +78,7 @@ function searchByFile(db, files, project, limit) {
75
78
  const escaped = basename.replace(/%/g, '\\%').replace(/_/g, '\\_');
76
79
  const likePattern = `%${escaped}`;
77
80
 
81
+ // R1: exclude LOW_SIGNAL degraded titles from file-level recall.
78
82
  const rows = db.prepare(`
79
83
  SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned
80
84
  FROM observations o
@@ -84,6 +88,7 @@ function searchByFile(db, files, project, limit) {
84
88
  AND COALESCE(o.compressed_into, 0) = 0
85
89
  AND o.created_at_epoch > ?
86
90
  AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
91
+ AND ${notLowSignalTitleClause('o')}
87
92
  ORDER BY o.created_at_epoch DESC
88
93
  LIMIT ?
89
94
  `).all(project, cutoff, file, likePattern, limit);
@@ -102,6 +107,9 @@ function searchByFile(db, files, project, limit) {
102
107
 
103
108
  function searchRecent(db, project, limit) {
104
109
  const cutoff = Date.now() - LOOKBACK_MS;
110
+ // R1: exclude LOW_SIGNAL degraded titles from "recent" recall intent
111
+ // (e.g. when user asks "what did I do earlier"). Unqualified alias because
112
+ // this query selects directly from observations with no join.
105
113
  return db.prepare(`
106
114
  SELECT id, type, title, lesson_learned
107
115
  FROM observations
@@ -109,6 +117,7 @@ function searchRecent(db, project, limit) {
109
117
  AND importance >= 1
110
118
  AND COALESCE(compressed_into, 0) = 0
111
119
  AND created_at_epoch > ?
120
+ AND ${notLowSignalTitleClause('')}
112
121
  ORDER BY created_at_epoch DESC
113
122
  LIMIT ?
114
123
  `).all(project, cutoff, limit);
package/server.mjs CHANGED
@@ -4,12 +4,14 @@
4
4
 
5
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
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';
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, notLowSignalTitleClause, LOW_SIGNAL_TITLE } 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)',
@@ -151,26 +155,32 @@ function safeHandler(fn) {
151
155
 
152
156
  // Score expression variants for FTS5 queries (see Scoring Model Constants above)
153
157
  // TYPE_QUALITY_CASE demotes bugfix (×0.6) and promotes decision/discovery (×1.5/1.3)
158
+ // R-3: lesson_learned presence adds ×1.3 boost — empirical +6.3pp hit-rate lift on bugfix.
154
159
  const FULL_SCORE = `${OBS_BM25}
155
160
  * (1.0 + EXP(-0.693 * (? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
156
161
  * ${TYPE_QUALITY_CASE}
157
162
  * (CASE WHEN ? IS NOT NULL AND o.project = ? THEN 2.0 ELSE 1.0 END)
158
163
  * (0.5 + 0.5 * COALESCE(o.importance, 1))
159
- * (1.0 + 0.1 * LN(1 + COALESCE(o.access_count, 0)))`;
164
+ * (1.0 + 0.1 * LN(1 + COALESCE(o.access_count, 0)))
165
+ * (1.0 + 0.3 * (o.lesson_learned IS NOT NULL))`;
160
166
 
161
167
  const SIMPLE_SCORE = `${OBS_BM25}
162
168
  * (1.0 + EXP(-0.693 * (? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
163
169
  * ${TYPE_QUALITY_CASE}
164
- * (0.5 + 0.5 * COALESCE(o.importance, 1))`;
170
+ * (0.5 + 0.5 * COALESCE(o.importance, 1))
171
+ * (1.0 + 0.3 * (o.lesson_learned IS NOT NULL))`;
165
172
 
166
173
  /**
167
174
  * Build an FTS5 observation search query.
168
175
  * @param {'full'|'simple'} scoring - full includes project boost + access bonus
169
- * @param {object} opts - { multiplier, withSnippet, withOffset }
176
+ * @param {object} opts - { multiplier, withSnippet, withOffset, includeNoise }
177
+ * includeNoise=true keeps hook-llm fallback titles ("Modified X", "Worked on X", etc.);
178
+ * default false mirrors the filter already applied in hook-memory.mjs / user-prompt-search.js.
170
179
  */
171
- function buildObsFtsQuery(scoring, { multiplier, withSnippet, withOffset } = {}) {
180
+ function buildObsFtsQuery(scoring, { multiplier, withSnippet, withOffset, includeNoise } = {}) {
172
181
  const scoreExpr = scoring === 'full' ? FULL_SCORE : SIMPLE_SCORE;
173
182
  const mult = multiplier ? ` * ${multiplier}` : '';
183
+ const lowSignalClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
174
184
  return `
175
185
  SELECT o.id, o.type, o.title, o.subtitle, o.project, o.created_at, o.importance,
176
186
  o.files_modified,
@@ -187,6 +197,7 @@ function buildObsFtsQuery(scoring, { multiplier, withSnippet, withOffset } = {})
187
197
  AND (? IS NULL OR o.created_at_epoch <= ?)
188
198
  AND (? IS NULL OR COALESCE(o.importance, 1) >= ?)
189
199
  AND (? IS NULL OR o.branch = ?)
200
+ ${lowSignalClause}
190
201
  ORDER BY score
191
202
  LIMIT ?${withOffset ? ' OFFSET ?' : ''}`;
192
203
  }
@@ -221,12 +232,14 @@ function ftsRowToResult(r, { scoreMultiplier, snippet } = {}) {
221
232
  function searchObservations(ctx) {
222
233
  const { ftsQuery, args, epochFrom, epochTo, perSourceLimit, perSourceOffset, currentProject, limit } = ctx;
223
234
  const results = [];
235
+ // R-1: hide hook-llm fallback titles unless caller explicitly opts in via include_noise=true.
236
+ const includeNoise = args.include_noise === true;
224
237
 
225
238
  if (ftsQuery) {
226
239
  const now = Date.now();
227
240
  const projectBoost = args.project ? null : currentProject;
228
241
 
229
- const rows = db.prepare(buildObsFtsQuery('full', { withSnippet: true, withOffset: true }))
242
+ const rows = db.prepare(buildObsFtsQuery('full', { withSnippet: true, withOffset: true, includeNoise }))
230
243
  .all(...buildObsFtsParams({ now, projectBoost, ftsQuery, args, epochFrom, epochTo, limit: perSourceLimit, offset: perSourceOffset }));
231
244
  for (const r of rows) results.push(ftsRowToResult(r, { snippet: true }));
232
245
 
@@ -235,7 +248,7 @@ function searchObservations(ctx) {
235
248
  const orQuery = relaxFtsQueryToOr(ftsQuery);
236
249
  if (orQuery) {
237
250
  try {
238
- const orRows = db.prepare(buildObsFtsQuery('full', { multiplier: 0.5, withSnippet: true, withOffset: true }))
251
+ const orRows = db.prepare(buildObsFtsQuery('full', { multiplier: 0.5, withSnippet: true, withOffset: true, includeNoise }))
239
252
  .all(...buildObsFtsParams({ now, projectBoost, ftsQuery: orQuery, args, epochFrom, epochTo, limit: perSourceLimit, offset: perSourceOffset }));
240
253
  for (const r of orRows) results.push(ftsRowToResult(r, { snippet: true }));
241
254
  } catch (e) { debugCatch(e, 'searchObservations-or-fallback'); }
@@ -245,8 +258,8 @@ function searchObservations(ctx) {
245
258
  // Two-phase query expansion for sparse results (only when well below limit)
246
259
  if (rows.length > 0 && results.length < Math.ceil(limit / 2)) {
247
260
  const existingIds = new Set(results.map(r => r.id));
248
- expandObsByConceptCo(ctx, now, existingIds, results);
249
- expandObsByPRF(ctx, now, rows.length, existingIds, results);
261
+ expandObsByConceptCo(ctx, now, existingIds, results, includeNoise);
262
+ expandObsByPRF(ctx, now, rows.length, existingIds, results, includeNoise);
250
263
  }
251
264
 
252
265
  // Vector search + RRF hybrid merge
@@ -275,6 +288,9 @@ function searchObservations(ctx) {
275
288
  if (epochTo !== null && obs.created_at_epoch > epochTo) continue;
276
289
  if (args.importance && (obs.importance ?? 1) < args.importance) continue;
277
290
  if (args.branch && obs.branch !== args.branch) continue;
291
+ // R-1: parity with FTS5 WHERE — vector path must also reject LOW_SIGNAL titles
292
+ // so RRF cannot re-admit what the SQL clause excluded.
293
+ if (!includeNoise && obs.title && LOW_SIGNAL_TITLE.test(obs.title)) continue;
278
294
  resultMap.set(vr.id, { source: 'obs', id: obs.id, type: obs.type, title: obs.title, subtitle: obs.subtitle, project: obs.project, date: obs.created_at, importance: obs.importance, files_modified: obs.files_modified, snippet: '' });
279
295
  }
280
296
  }
@@ -294,6 +310,7 @@ function searchObservations(ctx) {
294
310
  if (epochTo !== null && obs.created_at_epoch > epochTo) continue;
295
311
  if (args.importance && (obs.importance ?? 1) < args.importance) continue;
296
312
  if (args.branch && obs.branch !== args.branch) continue;
313
+ if (!includeNoise && obs.title && LOW_SIGNAL_TITLE.test(obs.title)) continue;
297
314
  results.push({ source: 'obs', id: obs.id, type: obs.type, title: obs.title, subtitle: obs.subtitle, project: obs.project, date: obs.created_at, importance: obs.importance, files_modified: obs.files_modified, score: -vr.similarity, snippet: '' });
298
315
  }
299
316
  }
@@ -325,14 +342,14 @@ function searchObservations(ctx) {
325
342
  return results;
326
343
  }
327
344
 
328
- function expandObsByConceptCo(ctx, now, existingIds, results) {
345
+ function expandObsByConceptCo(ctx, now, existingIds, results, includeNoise = false) {
329
346
  const { ftsQuery, args, epochFrom, epochTo, limit } = ctx;
330
347
  if (results.length >= Math.ceil(limit / 2)) return;
331
348
  const expanded = expandQueryByConcepts(db, ftsQuery, args.project);
332
349
  if (expanded.length === 0) return;
333
350
  const expansionFts = expanded.map(c => `"${c.replace(/"/g, '""')}"`).join(' OR ');
334
351
  try {
335
- const expRows = db.prepare(buildObsFtsQuery('simple'))
352
+ const expRows = db.prepare(buildObsFtsQuery('simple', { includeNoise }))
336
353
  .all(...buildObsFtsParams({ now, ftsQuery: expansionFts, args, epochFrom, epochTo, limit }));
337
354
  for (const r of expRows) {
338
355
  if (!existingIds.has(r.id)) {
@@ -343,7 +360,7 @@ function expandObsByConceptCo(ctx, now, existingIds, results) {
343
360
  } catch (e) { debugLog('WARN', 'mem_search', `concept expansion error: ${e.message}`); }
344
361
  }
345
362
 
346
- function expandObsByPRF(ctx, now, primaryCount, existingIds, results) {
363
+ function expandObsByPRF(ctx, now, primaryCount, existingIds, results, includeNoise = false) {
347
364
  const { ftsQuery, args, epochFrom, epochTo, limit } = ctx;
348
365
  if (primaryCount < 3) return;
349
366
  const topResults = db.prepare(`
@@ -358,7 +375,7 @@ function expandObsByPRF(ctx, now, primaryCount, existingIds, results) {
358
375
  if (prfTerms.length === 0) return;
359
376
  const prfFts = prfTerms.map(t => `"${t.replace(/"/g, '""')}"`).join(' OR ');
360
377
  try {
361
- const prfRows = db.prepare(buildObsFtsQuery('simple'))
378
+ const prfRows = db.prepare(buildObsFtsQuery('simple', { includeNoise }))
362
379
  .all(...buildObsFtsParams({ now, ftsQuery: prfFts, args, epochFrom, epochTo, limit }));
363
380
  for (const r of prfRows) {
364
381
  if (!existingIds.has(r.id)) {
@@ -453,6 +470,35 @@ function searchPrompts(ctx) {
453
470
  for (const r of rows) {
454
471
  results.push({ source: 'prompt', id: r.id, text: r.prompt_text, session: r.content_session_id, date: r.created_at, score: r.score });
455
472
  }
473
+ // CJK LIKE fallback: FTS5 unicode61 can't tokenize CJK substrings in prompts
474
+ if (rows.length === 0 && args.query) {
475
+ const cjkPatterns = extractCjkLikePatterns(args.query);
476
+ if (cjkPatterns.length > 0) {
477
+ const likeConds = cjkPatterns.map(() => 'p.prompt_text LIKE ?');
478
+ const likeParams = cjkPatterns.map(p => `%${p}%`);
479
+ const fallbackRows = db.prepare(`
480
+ SELECT p.id, p.prompt_text, p.content_session_id, p.created_at
481
+ FROM user_prompts p
482
+ JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
483
+ WHERE (${likeConds.join(' OR ')})
484
+ AND p.prompt_text NOT LIKE '<task-notification>%'
485
+ AND (? IS NULL OR s.project = ?)
486
+ AND (? IS NULL OR p.created_at_epoch >= ?)
487
+ AND (? IS NULL OR p.created_at_epoch <= ?)
488
+ ORDER BY p.created_at_epoch DESC
489
+ LIMIT ? OFFSET ?
490
+ `).all(
491
+ ...likeParams,
492
+ args.project ?? null, args.project ?? null,
493
+ epochFrom, epochFrom,
494
+ epochTo, epochTo,
495
+ perSourceLimit, perSourceOffset
496
+ );
497
+ for (const r of fallbackRows) {
498
+ results.push({ source: 'prompt', id: r.id, text: r.prompt_text, session: r.content_session_id, date: r.created_at, score: 0 });
499
+ }
500
+ }
501
+ }
456
502
  } else if (searchType === 'prompts') {
457
503
  const params = [];
458
504
  const wheres = [];
@@ -524,7 +570,7 @@ function formatSearchOutput(paginatedResults, args, ftsQuery, totalCount, isCros
524
570
  server.registerTool(
525
571
  'mem_search',
526
572
  {
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).',
573
+ 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
574
  inputSchema: memSearchSchema,
529
575
  },
530
576
  safeHandler(async (args) => {
@@ -699,7 +745,7 @@ server.registerTool(
699
745
  server.registerTool(
700
746
  'mem_timeline',
701
747
  {
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.',
748
+ 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
749
  inputSchema: memTimelineSchema,
704
750
  },
705
751
  safeHandler(async (args) => {
@@ -802,7 +848,7 @@ server.registerTool(
802
848
  server.registerTool(
803
849
  'mem_get',
804
850
  {
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.',
851
+ 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
852
  inputSchema: memGetSchema,
807
853
  },
808
854
  safeHandler(async (args) => {
@@ -925,7 +971,7 @@ server.registerTool(
925
971
  server.registerTool(
926
972
  'mem_save',
927
973
  {
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.',
974
+ 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
975
  inputSchema: memSaveSchema,
930
976
  },
931
977
  safeHandler(async (args) => {
@@ -960,18 +1006,20 @@ server.registerTool(
960
1006
 
961
1007
  const safeContent = scrubSecrets(args.content);
962
1008
  const safeTitle = scrubSecrets(title);
1009
+ const safeLesson = args.lesson_learned ? scrubSecrets(args.lesson_learned) : null;
963
1010
  const minhashSig = computeMinHash(safeTitle + ' ' + safeContent);
964
1011
  // Append CJK bigrams to text field for FTS5 indexing of Chinese content
965
- const bigramText = cjkBigrams(safeTitle + ' ' + safeContent);
1012
+ const indexText = [safeTitle, safeContent, safeLesson].filter(Boolean).join(' ');
1013
+ const bigramText = cjkBigrams(indexText);
966
1014
  const textField = bigramText ? safeContent + ' ' + bigramText : safeContent;
967
1015
 
968
1016
  // Atomic: insert observation + observation_files + TF-IDF vector in one transaction
969
1017
  const saveFiles = args.files || [];
970
1018
  const saveTx = db.transaction(() => {
971
1019
  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());
1020
+ 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)
1021
+ VALUES (?, ?, ?, ?, ?, ?, '', '', '[]', ?, ?, ?, ?, ?, ?, ?)
1022
+ `).run(sessionId, project, textField, type, safeTitle, safeContent, JSON.stringify(saveFiles), args.importance ?? 2, minhashSig, safeLesson, getCurrentBranch(), now.toISOString(), now.getTime());
975
1023
  const savedId = Number(result.lastInsertRowid);
976
1024
 
977
1025
  // Populate observation_files junction table
@@ -998,7 +1046,8 @@ server.registerTool(
998
1046
  });
999
1047
  const result = saveTx();
1000
1048
 
1001
- return { content: [{ type: 'text', text: `Saved as observation #${result.lastInsertRowid} [${type}] in project "${project}".` }] };
1049
+ const lessonNote = safeLesson ? ` 💡lesson captured` : '';
1050
+ return { content: [{ type: 'text', text: `Saved as observation #${result.lastInsertRowid} [${type}] in project "${project}".${lessonNote}` }] };
1002
1051
  })
1003
1052
  );
1004
1053
 
@@ -1486,6 +1535,50 @@ server.registerTool(
1486
1535
  })
1487
1536
  );
1488
1537
 
1538
+ // ─── Tool: mem_optimize ────────────────────────────────────────────────────
1539
+
1540
+ server.registerTool(
1541
+ 'mem_optimize',
1542
+ {
1543
+ 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.',
1544
+ inputSchema: memOptimizeSchema,
1545
+ },
1546
+ safeHandler(async (args) => {
1547
+ const action = args.action || 'preview';
1548
+
1549
+ if (action === 'preview') {
1550
+ const preview = optimizePreview(db);
1551
+ const lines = [
1552
+ `🔍 LLM Optimization Preview:`,
1553
+ ` Re-enrich candidates: ${preview.reenrich}`,
1554
+ ` Normalize: ${preview.normalizeGateOpen ? `${preview.normalize} unique concepts` : 'gate closed (7-day interval)'}`,
1555
+ ` Cluster-merge candidates: ${preview.clusterMerge} clusters`,
1556
+ ` Smart-compress candidates: ${preview.smartCompress} clusters`,
1557
+ ` Total: ${preview.total} items`,
1558
+ ];
1559
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1560
+ }
1561
+
1562
+ const force = action === 'run_all';
1563
+ const results = await optimizeRun(db, {
1564
+ tasks: args.tasks,
1565
+ maxItems: args.max_items || 15,
1566
+ force,
1567
+ });
1568
+
1569
+ const lines = ['🔧 LLM Optimization Results:'];
1570
+ if (results.reenrich) lines.push(` Re-enrich: ${results.reenrich.processed || 0} processed, ${results.reenrich.skipped || 0} skipped`);
1571
+ if (results.normalize) {
1572
+ if (results.normalize.skipped) lines.push(` Normalize: skipped (${results.normalize.reason})`);
1573
+ else lines.push(` Normalize: ${results.normalize.processed || 0} updated, ${results.normalize.groups || 0} synonym groups`);
1574
+ }
1575
+ if (results.clusterMerge) lines.push(` Cluster-merge: ${results.clusterMerge.merged || 0} merged of ${results.clusterMerge.processed || 0} clusters`);
1576
+ if (results.smartCompress) lines.push(` Smart-compress: ${results.smartCompress.compressed || 0} compressed of ${results.smartCompress.processed || 0} clusters`);
1577
+
1578
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1579
+ })
1580
+ );
1581
+
1489
1582
  // ─── Tool: mem_registry ─────────────────────────────────────────────────────
1490
1583
 
1491
1584
  server.registerTool(
@@ -1872,7 +1965,7 @@ server.registerTool(
1872
1965
  server.registerTool(
1873
1966
  'mem_recall',
1874
1967
  {
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.',
1968
+ 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
1969
  inputSchema: memRecallSchema,
1877
1970
  },
1878
1971
  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