claude-mem-lite 2.28.1 → 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.1",
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.1",
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/cli.mjs CHANGED
@@ -16,7 +16,19 @@ if (cmd === '--version' || cmd === '-v') {
16
16
  } else if (CLI_COMMANDS.has(cmd)) {
17
17
  const { run } = await import('./mem-cli.mjs');
18
18
  await run(process.argv.slice(2));
19
- } else if (!cmd || INSTALL_COMMANDS.has(cmd)) {
19
+ } else if (!cmd) {
20
+ // No command: show CLI help if installed, install help if not
21
+ const { existsSync } = await import('fs');
22
+ const { join } = await import('path');
23
+ const dbPath = join(process.env.HOME || '', '.claude-mem-lite', 'claude-mem-lite.db');
24
+ if (existsSync(dbPath)) {
25
+ const { run } = await import('./mem-cli.mjs');
26
+ await run(['help']);
27
+ } else {
28
+ const { main } = await import('./install.mjs');
29
+ await main([]);
30
+ }
31
+ } else if (INSTALL_COMMANDS.has(cmd)) {
20
32
  const { main } = await import('./install.mjs');
21
33
  await main(process.argv.slice(2));
22
34
  } else {
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-episode.mjs CHANGED
@@ -107,7 +107,7 @@ export function readEpisode() {
107
107
  */
108
108
  export function writeEpisode(episode) {
109
109
  const target = episodeFile();
110
- const tmp = target + '.tmp';
110
+ const tmp = target + `.tmp-${process.pid}`;
111
111
  const { _fileSet, ...serializable } = episode;
112
112
  writeFileSync(tmp, JSON.stringify(serializable));
113
113
  try {
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) {
@@ -2,7 +2,7 @@
2
2
  // Limits concurrent claude -p calls to prevent resource contention
3
3
 
4
4
  import { join } from 'path';
5
- import { readFileSync, writeFileSync, unlinkSync, readdirSync, openSync, closeSync, writeSync, constants as fsConstants } from 'fs';
5
+ import { readFileSync, unlinkSync, readdirSync, openSync, closeSync, writeSync, constants as fsConstants } from 'fs';
6
6
  import { RUNTIME_DIR } from './hook-shared.mjs';
7
7
 
8
8
  export const LLM_SEM_MAX = 2;
@@ -21,7 +21,7 @@ export async function acquireLLMSlot() {
21
21
 
22
22
  while (Date.now() < deadline) {
23
23
  // Acquire-then-verify: atomically create our slot first, then check total count
24
- let created = false;
24
+ let created;
25
25
  try {
26
26
  let fd;
27
27
  try {
@@ -33,8 +33,9 @@ export async function acquireLLMSlot() {
33
33
  if (fd !== undefined) closeSync(fd);
34
34
  }
35
35
  } catch {
36
- // Slot file already exists for this PID — update timestamp
37
- try { writeFileSync(slotFile, JSON.stringify({ pid: process.pid, ts: Date.now() })); created = true; } catch {}
36
+ // Slot file already exists for this PID — stale cleanup should have removed it;
37
+ // retry and let O_CREAT|O_EXCL succeed on next iteration (avoid non-atomic fallback)
38
+ continue;
38
39
  }
39
40
 
40
41
  if (!created) { await sleepMs(200 + Math.random() * 800); continue; }
package/hook-update.mjs CHANGED
@@ -214,7 +214,7 @@ async function downloadAndInstall(tarballUrl) {
214
214
 
215
215
  // Download tarball via curl (available on all supported platforms)
216
216
  // Validate URL to prevent command injection via crafted tarball URLs
217
- if (!/^https:\/\/[a-zA-Z0-9./-]+$/.test(tarballUrl)) {
217
+ if (!/^https:\/\/(?:api\.)?github\.com\/[a-zA-Z0-9./_-]+$/.test(tarballUrl)) {
218
218
  debugLog('WARN', 'hook-update', `Rejected suspicious tarball URL: ${tarballUrl}`);
219
219
  return false;
220
220
  }
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']);
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',
@@ -529,8 +529,9 @@ async function install() {
529
529
  }
530
530
 
531
531
  // 6. Install pre-installed resources (skills + agents)
532
- log('Setting up skill/agent registry...');
533
- try {
532
+ if (process.env.CLAUDE_MEM_SKIP_REPOS) {
533
+ ok('Skill/agent registry: skipped (CLAUDE_MEM_SKIP_REPOS)');
534
+ } else try {
534
535
  const manifestPath = join(INSTALL_DIR, 'registry', 'preinstalled.json');
535
536
  if (!existsSync(manifestPath)) {
536
537
  // For git-clone mode, check PROJECT_DIR
@@ -565,7 +566,7 @@ async function install() {
565
566
  };
566
567
 
567
568
  for (const [repoUrl, entries] of repos) {
568
- const repoName = repoUrl.split('/').slice(-2).join('-');
569
+ const repoName = repoUrl.split('/').slice(-2).join('-').replace(/[^a-zA-Z0-9._-]/g, '_');
569
570
  const clonePath = join(managedDir, 'repos', repoName);
570
571
  let repoReady = false;
571
572
 
@@ -959,12 +960,8 @@ async function status() {
959
960
 
960
961
  // CLI
961
962
  try {
962
- const cliVer = execSync('claude-mem-lite --help 2>/dev/null && echo OK', { encoding: 'utf8', timeout: 5000 });
963
- if (cliVer.includes('OK')) {
964
- ok('CLI: claude-mem-lite command available');
965
- } else {
966
- warn('CLI: command not on PATH');
967
- }
963
+ execFileSync('claude-mem-lite', ['--help'], { encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
964
+ ok('CLI: claude-mem-lite command available');
968
965
  } catch {
969
966
  warn('CLI: command not on PATH — run install again to create symlink');
970
967
  }
@@ -1087,7 +1084,7 @@ async function doctor() {
1087
1084
 
1088
1085
  // Check for stale processes
1089
1086
  try {
1090
- const procs = execSync('pgrep -af "chroma|claude-mem.*worker" 2>/dev/null', { encoding: 'utf8' }).trim();
1087
+ const procs = execFileSync('pgrep', ['-af', 'chroma|claude-mem.*worker'], { encoding: 'utf8', timeout: 5000, stdio: 'pipe' }).trim();
1091
1088
  // Filter out the pgrep process itself (matches its own pattern)
1092
1089
  const real = procs.split('\n').filter(l => !l.includes('pgrep'));
1093
1090
  if (real.length > 0) {
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
 
@@ -52,7 +54,7 @@ function out(text) {
52
54
  }
53
55
 
54
56
  function fail(text) {
55
- process.stdout.write(text + '\n');
57
+ process.stderr.write(text + '\n');
56
58
  process.exitCode = 1;
57
59
  }
58
60
 
@@ -115,13 +117,15 @@ function cmdSearch(db, args) {
115
117
  fail(`[mem] Invalid --sort "${sort}". Use: relevance, time, importance`);
116
118
  return;
117
119
  }
120
+ const useOr = flags.or === true || flags.or === 'true';
118
121
 
119
122
  if (source && !['observations', 'sessions', 'prompts'].includes(source)) {
120
123
  fail(`[mem] Invalid --source "${source}". Use: observations, sessions, prompts`);
121
124
  return;
122
125
  }
123
126
 
124
- const ftsQuery = sanitizeFtsQuery(query);
127
+ let ftsQuery = sanitizeFtsQuery(query);
128
+ if (ftsQuery && useOr) ftsQuery = relaxFtsQueryToOr(ftsQuery) || ftsQuery;
125
129
  if (!ftsQuery) {
126
130
  fail(`[mem] No valid search terms in "${query}"`);
127
131
  return;
@@ -278,6 +282,31 @@ function cmdSearch(db, args) {
278
282
  LIMIT ? OFFSET ?
279
283
  `).all(...promptParams);
280
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
+ }
281
310
  } catch { /* prompt FTS may not exist in older DBs */ }
282
311
  }
283
312
 
@@ -449,7 +478,7 @@ function searchFts(db, ftsQuery, { type, project, limit, dateFrom, dateTo, minIm
449
478
  function cmdRecent(db, args) {
450
479
  const { positional, flags } = parseArgs(args);
451
480
  const rawLimit = parseInt(positional[0], 10);
452
- const limit = Math.max(1, Number.isFinite(rawLimit) ? rawLimit : 10);
481
+ const limit = (Number.isInteger(rawLimit) && rawLimit > 0) ? rawLimit : 10;
453
482
  const project = flags.project ? resolveProject(db, flags.project) : inferProject();
454
483
 
455
484
  const params = [];
@@ -511,7 +540,9 @@ function cmdRecall(db, args) {
511
540
  // Update access_count for recalled observations (aligned with MCP mem_recall)
512
541
  const recalledIds = rows.map(r => r.id);
513
542
  const recallPh = recalledIds.map(() => '?').join(',');
514
- db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${recallPh})`).run(Date.now(), ...recalledIds);
543
+ try {
544
+ db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${recallPh})`).run(Date.now(), ...recalledIds);
545
+ } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
515
546
 
516
547
  out(`[mem] History for ${filename} (${rows.length}):`);
517
548
  for (const r of rows) {
@@ -588,8 +619,10 @@ function cmdGet(db, args) {
588
619
  }
589
620
 
590
621
  // Update access_count + auto-boost (aligned with MCP mem_get)
591
- db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${placeholders})`).run(Date.now(), ...ids);
592
- autoBoostIfNeeded(db, ids);
622
+ try {
623
+ db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${placeholders})`).run(Date.now(), ...ids);
624
+ autoBoostIfNeeded(db, ids);
625
+ } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
593
626
 
594
627
  const rows = db.prepare(`
595
628
  SELECT * FROM observations
@@ -680,7 +713,9 @@ function cmdTimeline(db, args) {
680
713
  }
681
714
 
682
715
  // Update access_count for anchor (aligned with MCP mem_timeline)
683
- db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id = ?').run(Date.now(), anchorId);
716
+ try {
717
+ db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id = ?').run(Date.now(), anchorId);
718
+ } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
684
719
 
685
720
  // Get anchor epoch
686
721
  const anchorRow = db.prepare('SELECT created_at_epoch, project FROM observations WHERE id = ?').get(anchorId);
@@ -1786,6 +1821,7 @@ Commands:
1786
1821
  --offset N Skip first N results (pagination)
1787
1822
  --tier T Filter by tier (working|active|archive, observations only)
1788
1823
  --sort S Sort: relevance (default), time, importance
1824
+ --or Use OR instead of AND between search terms
1789
1825
 
1790
1826
  recent [N] Show N most recent observations (default 10)
1791
1827
  --project P Filter by project
@@ -1869,7 +1905,7 @@ DB: ${DB_PATH}`);
1869
1905
 
1870
1906
  // ─── Import (GitHub) ────────────────────────────────────────────────────────
1871
1907
 
1872
- async function cmdImport(db, argv) {
1908
+ async function cmdImport(argv) {
1873
1909
  const { positional, flags } = parseArgs(argv);
1874
1910
  const url = positional[0];
1875
1911
 
@@ -1923,7 +1959,7 @@ async function cmdImport(db, argv) {
1923
1959
 
1924
1960
  // ─── Enrich ─────────────────────────────────────────────────────────────────
1925
1961
 
1926
- async function cmdEnrich(db, argv) {
1962
+ async function cmdEnrich(argv) {
1927
1963
  const { positional, flags } = parseArgs(argv);
1928
1964
  const name = positional[0];
1929
1965
 
@@ -1971,6 +2007,39 @@ async function cmdEnrich(db, argv) {
1971
2007
  }
1972
2008
  }
1973
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
+
1974
2043
  // ─── Main Entry Point ────────────────────────────────────────────────────────
1975
2044
 
1976
2045
  export async function run(argv) {
@@ -2011,13 +2080,14 @@ export async function run(argv) {
2011
2080
  case 'export': cmdExport(db, cmdArgs); break;
2012
2081
  case 'compress': cmdCompress(db, cmdArgs); break;
2013
2082
  case 'maintain': cmdMaintain(db, cmdArgs); break;
2083
+ case 'optimize': await cmdOptimize(db, cmdArgs); break;
2014
2084
  case 'fts-check': cmdFtsCheck(db, cmdArgs); break;
2015
2085
  case 'stats': cmdStats(db, cmdArgs); break;
2016
2086
  case 'context': cmdContext(db, cmdArgs); break;
2017
2087
  case 'browse': cmdBrowse(db, cmdArgs); break;
2018
2088
  case 'registry': cmdRegistry(db, cmdArgs); break;
2019
- case 'import': await cmdImport(db, cmdArgs); break;
2020
- case 'enrich': await cmdEnrich(db, cmdArgs); break;
2089
+ case 'import': await cmdImport(cmdArgs); break;
2090
+ case 'enrich': await cmdEnrich(cmdArgs); break;
2021
2091
  default:
2022
2092
  out(`[mem] Unknown command: ${cmd}`);
2023
2093
  out('[mem] Run "claude-mem-lite help" for usage');