claude-mem-lite 2.1.6 → 2.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.1.6",
3
+ "version": "2.2.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"
@@ -183,13 +183,12 @@ export async function collectFeedback(db, sessionId, sessionEvents = []) {
183
183
  const invocations = getSessionInvocations(db, sessionId);
184
184
  if (invocations.length === 0) return;
185
185
 
186
- const outcome = detectOutcome(sessionEvents);
187
-
188
186
  for (const inv of invocations) {
189
187
  // Skip if already collected (prevents double-collection from stop + session-start)
190
188
  if (inv.outcome) continue;
191
189
 
192
190
  const adopted = detectAdoption(inv, sessionEvents);
191
+ const outcome = adopted ? detectOutcome(sessionEvents) : 'ignored';
193
192
  const score = adopted ? (outcome === 'success' ? 1.0 : outcome === 'partial' ? 0.5 : 0.2) : 0;
194
193
 
195
194
  // Update invocation record
@@ -24,7 +24,7 @@ const ALLOWED_BASES = [
24
24
 
25
25
  function isAllowedPath(filePath) {
26
26
  if (!filePath) return false;
27
- return ALLOWED_BASES.some(base => filePath.startsWith(base));
27
+ return ALLOWED_BASES.some(base => filePath === base || filePath.startsWith(base + '/'));
28
28
  }
29
29
 
30
30
  // ─── Template Detection ──────────────────────────────────────────────────────
package/dispatch.mjs CHANGED
@@ -5,17 +5,14 @@
5
5
  // Tier 3: Haiku semantic dispatch (~500ms, only when needed)
6
6
 
7
7
  import { basename, join } from 'path';
8
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
8
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
9
9
  import { retrieveResources, buildEnhancedQuery, buildQueryFromText, DISPATCH_SYNONYMS } from './registry-retriever.mjs';
10
10
  import { renderInjection } from './dispatch-inject.mjs';
11
11
  import { updateResourceStats, recordInvocation } from './registry.mjs';
12
12
  import { callHaikuJSON } from './haiku-client.mjs';
13
13
  import { debugCatch, truncate } from './utils.mjs';
14
- import { DB_DIR } from './schema.mjs';
15
-
16
- // Duplicated from hook-shared.mjs to avoid circular import (dispatch ← hook ← hook-shared)
17
- const RUNTIME_DIR = join(DB_DIR, 'runtime');
18
- try { if (!existsSync(RUNTIME_DIR)) mkdirSync(RUNTIME_DIR, { recursive: true }); } catch {}
14
+ import { peekToolEvents, RUNTIME_DIR } from './hook-shared.mjs';
15
+ import { detectActiveSuite, shouldRecommendForStage, detectExplicitRequest, inferCurrentStage } from './dispatch-workflow.mjs';
19
16
 
20
17
  // ─── Constants ───────────────────────────────────────────────────────────────
21
18
 
@@ -28,6 +25,10 @@ const READ_ONLY_TOOLS = new Set([
28
25
  export const COOLDOWN_MINUTES = 60;
29
26
  export const SESSION_RECOMMEND_CAP = 3;
30
27
 
28
+ // Minimum absolute BM25 composite score to recommend. Typical good matches score 5-50;
29
+ // this filters only near-zero noise matches from incidental text overlap.
30
+ export const BM25_MIN_THRESHOLD = 1.5;
31
+
31
32
  // ─── Haiku Circuit Breaker ──────────────────────────────────────────────────
32
33
  // Prevents cascading latency when Haiku API is down or slow.
33
34
  // After BREAKER_THRESHOLD consecutive failures, disable for BREAKER_RESET_MS.
@@ -494,7 +495,7 @@ function inferTechFromPrompt(prompt) {
494
495
  [/\b(typescript|ts)\b/i, 'typescript'],
495
496
  [/\b(python|django|flask|fastapi)\b/i, 'python'],
496
497
  [/\b(rust|cargo)\b/i, 'rust'],
497
- [/\b(golang|go\s+\w+)\b/i, 'go'],
498
+ [/\b(golang|go\s+(?:build|test|run|get|mod|install|fmt|vet|generate|clean|work|tool))\b/i, 'go'],
498
499
  [/\b(java|spring|maven|gradle)\b/i, 'java'],
499
500
  [/\b(ruby|rails)\b/i, 'ruby'],
500
501
  [/\b(php|laravel|symfony)\b/i, 'php'],
@@ -645,7 +646,7 @@ export function isRecentlyRecommended(db, resourceId, sessionId) {
645
646
 
646
647
  // Already recommended in this session (session dedup)
647
648
  const sessionHit = db.prepare(
648
- 'SELECT 1 FROM invocations WHERE resource_id = ? AND session_id = ? LIMIT 1'
649
+ 'SELECT 1 FROM invocations WHERE resource_id = ? AND session_id = ? AND recommended = 1 LIMIT 1'
649
650
  ).get(resourceId, sessionId);
650
651
  if (sessionHit) return true;
651
652
  }
@@ -691,7 +692,7 @@ function reRankByKeywords(results, rawKeywords) {
691
692
  * @param {object[]} results FTS5 results with recommend_count/adopt_count
692
693
  * @returns {object[]} Filtered results with decayed scores
693
694
  */
694
- function applyAdoptionDecay(results) {
695
+ function applyAdoptionDecay(results, db) {
695
696
  return results.map(r => {
696
697
  const recs = r.recommend_count || 0;
697
698
  const adopts = r.adopt_count || 0;
@@ -699,15 +700,24 @@ function applyAdoptionDecay(results) {
699
700
 
700
701
  const rate = (adopts + 1) / (recs + 2); // Laplace smoothing
701
702
  let multiplier = 1.0;
702
- if (recs > 100 && rate < 0.01) multiplier = 0; // Block entirely
703
- else if (recs > 50 && rate < 0.02) multiplier = 0.1; // Heavy penalty
704
- else if (recs > 20 && rate < 0.05) multiplier = 0.3; // Light penalty
703
+ if (recs > 100 && rate < 0.01) multiplier = 0.05; // Near-block but not permanent
704
+ else if (recs > 50 && rate < 0.02) multiplier = 0.15;
705
+ else if (recs > 20 && rate < 0.05) multiplier = 0.4;
706
+
707
+ // Recent rejection boost: extra penalty for resources rejected many times recently
708
+ if (db && multiplier < 1) {
709
+ try {
710
+ const recentRejects = db.prepare(
711
+ `SELECT COUNT(*) as cnt FROM invocations WHERE resource_id = ? AND adopted = 0 AND created_at > datetime('now', '-7 days')`
712
+ ).get(r.id)?.cnt || 0;
713
+ if (recentRejects >= 10) multiplier *= 0.3;
714
+ else if (recentRejects >= 5) multiplier *= 0.5;
715
+ } catch (e) { debugCatch(e, 'applyAdoptionDecay-recentRejects'); }
716
+ }
705
717
 
706
- if (multiplier === 0) return null;
718
+ if (multiplier < 0.01) return null;
707
719
  if (multiplier < 1) {
708
- // BM25 scores are negative (more negative = more relevant).
709
- // To penalize: divide by multiplier to make less negative (worse rank).
710
- return { ...r, relevance: r.relevance / multiplier, _decayed: true };
720
+ return { ...r, composite_score: (r.composite_score ?? r.relevance) * multiplier, _decayed: true };
711
721
  }
712
722
  return r;
713
723
  }).filter(Boolean);
@@ -722,6 +732,15 @@ function applyAdoptionDecay(results) {
722
732
  * @returns {object[]} Filtered results that pass the gate
723
733
  */
724
734
  function passesConfidenceGate(results, signals) {
735
+ // BM25 absolute minimum: filter out weak text matches regardless of intent.
736
+ // Only apply when enough results exist (BM25 IDF is unreliable with < 3 matches).
737
+ if (results.length >= 3) {
738
+ results = results.filter(r => {
739
+ const score = Math.abs(r.composite_score ?? r.relevance);
740
+ return score >= BM25_MIN_THRESHOLD;
741
+ });
742
+ }
743
+
725
744
  // signals.intent is a comma-separated string (e.g. "test,fix"), not an array
726
745
  const intentTokens = typeof signals?.intent === 'string'
727
746
  ? signals.intent.split(',').filter(Boolean)
@@ -748,13 +767,19 @@ function passesConfidenceGate(results, signals) {
748
767
 
749
768
  /**
750
769
  * Dispatch on SessionStart: analyze user prompt, return best resource suggestion.
770
+ * Only dispatches when continuing from a previous session (handoff).
771
+ * Cold starts (no previous session) showed 0% adoption — skip dispatch entirely.
751
772
  * @param {Database} db Registry database
752
773
  * @param {string} userPrompt User's prompt text
753
774
  * @param {string} [sessionId] Session identifier for dedup
775
+ * @param {Object} [options]
776
+ * @param {boolean} [options.hasHandoff=false] Whether a previous session handoff exists
754
777
  * @returns {Promise<string|null>} Injection text or null
755
778
  */
756
- export async function dispatchOnSessionStart(db, userPrompt, sessionId) {
757
- if (!userPrompt || !db) return null;
779
+ export async function dispatchOnSessionStart(db, userPrompt, sessionId, { hasHandoff = false } = {}) {
780
+ if (!db) return null;
781
+ if (!hasHandoff) return null; // Only dispatch when continuing from a previous session
782
+ if (!userPrompt) return null; // Prompt still required for FTS query
758
783
 
759
784
  try {
760
785
  const projectDomains = detectProjectDomains();
@@ -783,7 +808,7 @@ export async function dispatchOnSessionStart(db, userPrompt, sessionId) {
783
808
  }
784
809
 
785
810
  results = reRankByKeywords(results, signals.rawKeywords);
786
- results = applyAdoptionDecay(results);
811
+ results = applyAdoptionDecay(results, db);
787
812
  results = passesConfidenceGate(results, signals);
788
813
  results = results.slice(0, 3);
789
814
 
@@ -796,7 +821,7 @@ export async function dispatchOnSessionStart(db, userPrompt, sessionId) {
796
821
  if (haikuResult?.query) {
797
822
  const haikuQuery = buildQueryFromText(haikuResult.query);
798
823
  if (haikuQuery) {
799
- const haikuResults = retrieveResources(db, haikuQuery, {
824
+ let haikuResults = retrieveResources(db, haikuQuery, {
800
825
  type: haikuResult.type === 'either' ? undefined : haikuResult.type,
801
826
  limit: 3,
802
827
  projectDomains,
@@ -804,7 +829,7 @@ export async function dispatchOnSessionStart(db, userPrompt, sessionId) {
804
829
  if (haikuResults.length > 0) {
805
830
  // Apply same post-processing as Tier2 to prevent zombie/low-confidence bypass
806
831
  haikuResults = reRankByKeywords(haikuResults, signals.rawKeywords);
807
- haikuResults = applyAdoptionDecay(haikuResults);
832
+ haikuResults = applyAdoptionDecay(haikuResults, db);
808
833
  haikuResults = passesConfidenceGate(haikuResults, signals);
809
834
  if (haikuResults.length > 0) results = haikuResults;
810
835
  }
@@ -848,14 +873,46 @@ export async function dispatchOnSessionStart(db, userPrompt, sessionId) {
848
873
  * @param {string} [sessionId] Session identifier for dedup
849
874
  * @returns {Promise<string|null>} Injection text or null
850
875
  */
851
- export async function dispatchOnUserPrompt(db, userPrompt, sessionId) {
876
+ export async function dispatchOnUserPrompt(db, userPrompt, sessionId, { sessionEvents } = {}) {
852
877
  if (!userPrompt || !db) return null;
853
878
 
854
879
  try {
880
+ // 1. Explicit request → highest priority, bypass all restrictions
881
+ const explicit = detectExplicitRequest(userPrompt);
882
+ if (explicit.isExplicit) {
883
+ const textQuery = buildQueryFromText(explicit.searchTerm);
884
+ if (textQuery) {
885
+ const explicitResults = retrieveResources(db, textQuery, { limit: 3, projectDomains: detectProjectDomains() });
886
+ if (explicitResults.length > 0) {
887
+ const best = explicitResults[0];
888
+ if (!sessionId || !isRecentlyRecommended(db, best.id, sessionId)) {
889
+ recordInvocation(db, { resource_id: best.id, session_id: sessionId, trigger: 'user_prompt', tier: 1, recommended: 1 });
890
+ updateResourceStats(db, best.id, 'recommend_count');
891
+ return renderInjection(best);
892
+ }
893
+ }
894
+ }
895
+ }
896
+
897
+ // 2. Suite auto-flow protection
898
+ const events = sessionEvents || peekToolEvents();
899
+ const activeSuite = detectActiveSuite(events);
900
+
855
901
  const projectDomains = detectProjectDomains();
856
902
 
857
903
  // Intent-aware enhanced query (column-targeted)
858
904
  const signals = extractContextSignals({ tool_name: '_user_prompt' }, { userPrompt });
905
+
906
+ // Check if active suite covers the current stage
907
+ if (activeSuite) {
908
+ const currentStage = inferCurrentStage(signals.primaryIntent, activeSuite);
909
+ if (currentStage) {
910
+ const { shouldRecommend } = shouldRecommendForStage(activeSuite, currentStage);
911
+ if (!shouldRecommend) return null;
912
+ }
913
+ }
914
+
915
+ // 3. Normal FTS flow
859
916
  const enhancedQuery = buildEnhancedQuery(signals);
860
917
 
861
918
  // Fetch extra results when rawKeywords are present — the top-3 by BM25 may be
@@ -882,21 +939,14 @@ export async function dispatchOnUserPrompt(db, userPrompt, sessionId) {
882
939
  // match those keywords. "帮我做一下SEO审查" → rawKeywords=["seo"] → SEO audit
883
940
  // resources should rank above generic code-review resources.
884
941
  results = reRankByKeywords(results, signals.rawKeywords);
885
- results = applyAdoptionDecay(results);
942
+ results = applyAdoptionDecay(results, db);
886
943
  results = passesConfidenceGate(results, signals);
887
944
  results = results.slice(0, 3);
888
945
 
889
946
  if (results.length === 0) return null;
890
947
 
891
- // Skip if low confidence (no Haiku fallback — stay fast).
892
- // Exception: when results match the user's raw domain keywords (e.g. "seo"),
893
- // close BM25 scores indicate "multiple equally good options in the right domain"
894
- // rather than "ambiguous/wrong match". Trust the domain match.
895
- if (needsHaikuDispatch(results)) {
896
- const hasKeywordMatch = signals.rawKeywords?.length > 0 && results.some(r =>
897
- signals.rawKeywords.some(kw => (r.intent_tags || '').toLowerCase().includes(kw)));
898
- if (!hasKeywordMatch) return null;
899
- }
948
+ // Low confidence skip (no Haiku in user_prompt path — stay fast)
949
+ if (needsHaikuDispatch(results)) return null;
900
950
 
901
951
  // Filter by cooldown + session dedup (prevents double-recommend with SessionStart)
902
952
  const viable = sessionId
@@ -939,6 +989,18 @@ export async function dispatchOnPreToolUse(db, event, sessionCtx = {}) {
939
989
 
940
990
  // Tier 1: Extract context signals
941
991
  const signals = extractContextSignals(event, sessionCtx);
992
+
993
+ // Suite protection: if a suite auto-flow is active, suppress recommendations
994
+ // for stages the suite already covers
995
+ const events = peekToolEvents();
996
+ const activeSuite = detectActiveSuite(events);
997
+ if (activeSuite) {
998
+ const stage = inferCurrentStage(signals.primaryIntent, activeSuite);
999
+ if (stage) {
1000
+ const { shouldRecommend } = shouldRecommendForStage(activeSuite, stage);
1001
+ if (!shouldRecommend) return null;
1002
+ }
1003
+ }
942
1004
  const query = buildEnhancedQuery(signals);
943
1005
  if (!query) return null;
944
1006
 
@@ -946,7 +1008,7 @@ export async function dispatchOnPreToolUse(db, event, sessionCtx = {}) {
946
1008
 
947
1009
  // Tier 2: FTS5 retrieval
948
1010
  let results = retrieveResources(db, query, { limit: 3, projectDomains });
949
- results = applyAdoptionDecay(results);
1011
+ results = applyAdoptionDecay(results, db);
950
1012
  results = passesConfidenceGate(results, signals);
951
1013
  if (results.length === 0) return null;
952
1014
 
package/hook-context.mjs CHANGED
@@ -154,7 +154,7 @@ export function updateClaudeMd(contextBlock) {
154
154
  const startIdx = content.indexOf(startTag);
155
155
  const endIdx = content.indexOf(endTag);
156
156
 
157
- if (startIdx !== -1 && endIdx !== -1) {
157
+ if (startIdx !== -1 && endIdx !== -1 && startIdx < endIdx) {
158
158
  // Replace existing section in-place — preserves surrounding content (including hint if present)
159
159
  content = content.slice(0, startIdx) + newSection + content.slice(endIdx + endTag.length);
160
160
  } else if (content.length > 0) {
package/hook-episode.mjs CHANGED
@@ -210,13 +210,31 @@ export function mergePendingEntries(episode) {
210
210
 
211
211
  /**
212
212
  * Check if an episode has significant content worth processing with LLM.
213
- * Significant = contains file edits or Bash errors.
213
+ * Significant = contains file edits, Bash errors, or a review/research pattern
214
+ * (5+ Read/Grep entries indicate investigation worth recording).
214
215
  * @param {object} episode The episode to check
215
216
  * @returns {boolean} true if the episode has significant content
216
217
  */
217
218
  export function episodeHasSignificantContent(episode) {
218
- return episode.entries.some(e =>
219
+ // 1. File edits or Bash errors → always significant
220
+ const hasEditsOrErrors = episode.entries.some(e =>
219
221
  EDIT_TOOLS.has(e.tool) ||
220
222
  (e.tool === 'Bash' && e.isError)
221
223
  );
224
+ if (hasEditsOrErrors) return true;
225
+
226
+ // 2. Important files touched (config, schema, security, migration)
227
+ // Checks episode.files (all touched files, including reads) — catches important-file investigation
228
+ const allFiles = episode.files || [];
229
+ const hasImportantFile = allFiles.some(f =>
230
+ /\.(env|yml|yaml|toml|lock|sql|prisma|proto)$/.test(f) ||
231
+ /(config|schema|migration|auth|security)/i.test(f)
232
+ );
233
+ if (hasImportantFile) return true;
234
+
235
+ // 3. Research pattern: reading many files indicates investigation
236
+ const readCount = episode.entries.filter(e =>
237
+ e.tool === 'Read' || e.tool === 'Grep'
238
+ ).length;
239
+ return readCount >= 5;
222
240
  }
package/hook-llm.mjs CHANGED
@@ -183,6 +183,47 @@ export function buildDegradedTitle(episode) {
183
183
  return desc.replace(/ → (?:ERROR: )?\{.*$/, hasError ? ' (error)' : '');
184
184
  }
185
185
 
186
+ /**
187
+ * Build a rule-based observation from episode metadata for immediate DB persistence.
188
+ * Used as pre-save (before LLM) and as fallback when LLM is unavailable.
189
+ * @param {object} episode Episode with entries, files, filesRead arrays
190
+ * @returns {object} Observation object ready for saveObservation()
191
+ */
192
+ export function buildImmediateObservation(episode) {
193
+ const hasError = episode.entries.some(e => e.isError);
194
+ const hasEdit = episode.entries.some(e => EDIT_TOOLS.has(e.tool));
195
+ const readCount = episode.entries.filter(e => e.tool === 'Read' || e.tool === 'Grep').length;
196
+ const isReviewPattern = !hasEdit && !hasError && readCount >= 5;
197
+ const inferredType = hasError ? 'bugfix' : hasEdit ? 'change' : 'discovery';
198
+ const fileList = (episode.files || []).map(f => basename(f)).join(', ') || '(multiple)';
199
+
200
+ // Review/research episodes: use a descriptive title with file count
201
+ let title;
202
+ if (isReviewPattern) {
203
+ const allFiles = [...new Set([
204
+ ...(episode.files || []),
205
+ ...(episode.filesRead || []),
206
+ ])].map(f => basename(f));
207
+ const names = allFiles.slice(0, 4).join(', ');
208
+ const suffix = allFiles.length > 4 ? ` +${allFiles.length - 4} more` : '';
209
+ title = truncate(`Reviewed ${allFiles.length} files: ${names}${suffix}`, 120);
210
+ } else {
211
+ title = truncate(buildDegradedTitle(episode), 120);
212
+ }
213
+
214
+ return {
215
+ type: inferredType,
216
+ title,
217
+ subtitle: fileList,
218
+ narrative: episode.entries.map(e => e.desc).join('; '),
219
+ concepts: [],
220
+ facts: [],
221
+ files: episode.files,
222
+ filesRead: episode.filesRead || [],
223
+ importance: isReviewPattern ? Math.max(2, computeRuleImportance(episode)) : computeRuleImportance(episode),
224
+ };
225
+ }
226
+
186
227
  // ─── Background: LLM Episode Extraction (Tier 2 F) ──────────────────────────
187
228
 
188
229
  export async function handleLLMEpisode() {
@@ -226,7 +267,7 @@ Error: ${e.isError ? 'yes' : 'no'}
226
267
 
227
268
  JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"concise ≤80 char description","narrative":"what changed, why, and outcome (2-3 sentences)","concepts":["kw1","kw2"],"facts":["fact1","fact2"],"importance":1}
228
269
  Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
229
- importance: 1=routine, 2=notable (error fix, arch decision, config change), 3=critical (breaking change, security fix, data migration)`;
270
+ importance: 0=not worth saving (pure browsing, trivial query, no learning value), 1=routine, 2=notable (error fix, arch decision, config change), 3=critical (breaking change, security fix, data migration)`;
230
271
  } else {
231
272
  const actionList = episode.entries.map((e, i) =>
232
273
  `${i + 1}. [${e.tool}] ${e.desc}${e.isError ? ' (ERROR)' : ''}`
@@ -241,7 +282,7 @@ ${actionList}
241
282
 
242
283
  JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"coherent ≤80 char summary","narrative":"what was done, why, and outcome (3-5 sentences)","concepts":["keyword1","keyword2"],"facts":["specific fact 1","specific fact 2"],"importance":1}
243
284
  Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
244
- importance: 1=routine, 2=notable (error fix, arch decision, config change), 3=critical (breaking change, security fix, data migration)`;
285
+ importance: 0=not worth saving (pure browsing, trivial query, no learning value), 1=routine, 2=notable (error fix, arch decision, config change), 3=critical (breaking change, security fix, data migration)`;
245
286
  }
246
287
 
247
288
  const ruleImportance = computeRuleImportance(episode);
@@ -260,6 +301,21 @@ importance: 1=routine, 2=notable (error fix, arch decision, config change), 3=cr
260
301
  }
261
302
 
262
303
  if (parsed && parsed.title) {
304
+ // Discard if LLM judges observation has no learning value
305
+ if (parsed.importance === 0 || parsed.importance === '0') {
306
+ debugLog('DEBUG', 'llm-episode', `Discarded low-value observation: ${parsed.title}`);
307
+ // If pre-saved, delete it too
308
+ if (episode.savedId) {
309
+ const ddb = openDb();
310
+ if (ddb) {
311
+ try { ddb.prepare('DELETE FROM observations WHERE id = ?').run(episode.savedId); }
312
+ finally { ddb.close(); }
313
+ }
314
+ }
315
+ try { unlinkSync(tmpFile); } catch {}
316
+ return;
317
+ }
318
+
263
319
  obs = {
264
320
  type: validTypes.has(parsed.type) ? parsed.type : 'change',
265
321
  title: truncate(parsed.title, 120),
@@ -282,20 +338,7 @@ importance: 1=routine, 2=notable (error fix, arch decision, config change), 3=cr
282
338
  try { unlinkSync(tmpFile); } catch {}
283
339
  return;
284
340
  }
285
- const hasError = episode.entries.some(e => e.isError);
286
- const hasEdit = episode.entries.some(e => EDIT_TOOLS.has(e.tool));
287
- const inferredType = hasError ? 'bugfix' : hasEdit ? 'change' : 'discovery';
288
- obs = {
289
- type: inferredType,
290
- title: truncate(buildDegradedTitle(episode), 120),
291
- subtitle: fileList,
292
- narrative: episode.entries.map(e => e.desc).join('; '),
293
- concepts: [],
294
- facts: [],
295
- files: episode.files,
296
- filesRead: episode.filesRead || [],
297
- importance: ruleImportance,
298
- };
341
+ obs = buildImmediateObservation(episode);
299
342
  }
300
343
 
301
344
  const db = openDb();
@@ -371,16 +414,25 @@ export async function handleLLMSummary() {
371
414
  if (recentObs.length < 1) return;
372
415
 
373
416
  const obsList = recentObs.map((o, i) =>
374
- `${i + 1}. [${o.type}] ${o.title}${o.narrative ? ': ' + truncate(o.narrative, 80) : ''}`
417
+ `${i + 1}. [${o.type}] ${o.title}${o.narrative ? ': ' + truncate(o.narrative, 200) : ''}`
375
418
  ).join('\n');
376
419
 
420
+ // Include user prompts for richer context
421
+ const userPrompts = db.prepare(`
422
+ SELECT prompt_text FROM user_prompts
423
+ WHERE content_session_id = ? ORDER BY prompt_number ASC LIMIT 10
424
+ `).all(sessionId).map(p => truncate(p.prompt_text, 300));
425
+ const promptCtx = userPrompts.length > 0
426
+ ? `\nUser requests: ${userPrompts.join(' → ')}\n`
427
+ : '';
428
+
377
429
  const prompt = `Summarize this coding session. Return ONLY valid JSON, no markdown fences.
378
430
 
379
- Project: ${project}
431
+ Project: ${project}${promptCtx}
380
432
  Observations (${recentObs.length} total):
381
433
  ${obsList}
382
434
 
383
- JSON: {"request":"what the user was working on","investigated":"what was explored/analyzed","learned":"key findings","completed":"what was accomplished","next_steps":"suggested follow-up"}`;
435
+ JSON: {"request":"what the user was working on","completed":"specific items accomplished with file names","remaining_items":"specific unfinished items from the original request — compare investigation scope with actual changes to infer what was NOT yet done; be precise with file:issue format, or empty string if all done","next_steps":"suggested follow-up"}`;
384
436
 
385
437
  if (!(await acquireLLMSlot())) {
386
438
  debugLog('WARN', 'llm-summary', 'semaphore timeout, skipping summary');
@@ -398,12 +450,13 @@ JSON: {"request":"what the user was working on","investigated":"what was explore
398
450
  if (llmParsed && llmParsed.request) {
399
451
  const now = new Date();
400
452
  db.prepare(`
401
- INSERT INTO session_summaries (memory_session_id, project, request, investigated, learned, completed, next_steps, files_read, files_edited, notes, created_at, created_at_epoch)
402
- VALUES (?, ?, ?, ?, ?, ?, ?, '[]', '[]', '', ?, ?)
453
+ INSERT INTO session_summaries (memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, created_at, created_at_epoch)
454
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', '', ?, ?)
403
455
  `).run(
404
456
  sessionId, project,
405
457
  llmParsed.request || '', llmParsed.investigated || '', llmParsed.learned || '',
406
458
  llmParsed.completed || '', llmParsed.next_steps || '',
459
+ llmParsed.remaining_items || '',
407
460
  now.toISOString(), now.getTime()
408
461
  );
409
462
  }
package/hook-shared.mjs CHANGED
@@ -179,6 +179,20 @@ export function readAndClearToolEvents() {
179
179
  }
180
180
  }
181
181
 
182
+ /**
183
+ * Read tracked tool events WITHOUT clearing the file.
184
+ * Used by dispatch to detect active suites mid-session.
185
+ * @returns {object[]} Array of tool event objects
186
+ */
187
+ export function peekToolEvents() {
188
+ try {
189
+ const raw = readFileSync(toolEventsFile(), 'utf8');
190
+ return raw.trim().split('\n').filter(Boolean).map(line => {
191
+ try { return JSON.parse(line); } catch { return null; }
192
+ }).filter(Boolean);
193
+ } catch { return []; }
194
+ }
195
+
182
196
  /**
183
197
  * Extract partial response from CLI error output (timeout/error recovery).
184
198
  * @param {Error} error The caught error from execFileSync
package/hook.mjs CHANGED
@@ -10,7 +10,7 @@ import { readFileSync, writeFileSync, unlinkSync, readdirSync, renameSync, statS
10
10
  import {
11
11
  truncate, typeIcon, inferProject, detectBashSignificance,
12
12
  extractErrorKeywords, extractFilePaths, isRelatedToEpisode,
13
- makeEntryDesc, scrubSecrets, computeRuleImportance, EDIT_TOOLS, debugCatch, debugLog, fmtTime,
13
+ makeEntryDesc, scrubSecrets, EDIT_TOOLS, debugCatch, debugLog, fmtTime,
14
14
  } from './utils.mjs';
15
15
  import {
16
16
  readEpisodeRaw, episodeFile,
@@ -29,7 +29,7 @@ import {
29
29
  closeRegistryDb, spawnBackground, appendToolEvent, readAndClearToolEvents,
30
30
  resetInjectionBudget, hasInjectionBudget, incrementInjection,
31
31
  } from './hook-shared.mjs';
32
- import { handleLLMEpisode, handleLLMSummary, saveObservation, buildDegradedTitle } from './hook-llm.mjs';
32
+ import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObservation } from './hook-llm.mjs';
33
33
  import { searchRelevantMemories } from './hook-memory.mjs';
34
34
  import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection } from './hook-handoff.mjs';
35
35
 
@@ -89,21 +89,7 @@ function flushEpisode(episode) {
89
89
  // LLM background worker will upgrade title/narrative/importance later.
90
90
  if (isSignificant) {
91
91
  try {
92
- const hasError = episode.entries.some(e => e.isError);
93
- const hasEdit = episode.entries.some(e => EDIT_TOOLS.has(e.tool));
94
- const inferredType = hasError ? 'bugfix' : hasEdit ? 'change' : 'discovery';
95
- const fileList = (episode.files || []).map(f => basename(f)).join(', ') || '(multiple)';
96
- const obs = {
97
- type: inferredType,
98
- title: truncate(buildDegradedTitle(episode), 120),
99
- subtitle: fileList,
100
- narrative: episode.entries.map(e => e.desc).join('; '),
101
- concepts: [],
102
- facts: [],
103
- files: episode.files,
104
- filesRead: episode.filesRead || [],
105
- importance: computeRuleImportance(episode),
106
- };
92
+ const obs = buildImmediateObservation(episode);
107
93
  const id = saveObservation(obs, episode.project, episode.sessionId);
108
94
  if (id) episode.savedId = id;
109
95
  } catch (e) { debugCatch(e, 'flushEpisode-immediateSave'); }
@@ -160,7 +146,7 @@ async function handlePostToolUse() {
160
146
 
161
147
  // Skip noise
162
148
  if (SKIP_TOOLS.has(tool_name)) return;
163
- if (tool_name.startsWith('mem_') || tool_name.startsWith('mcp__mem__')) return;
149
+ if (tool_name.startsWith('mem_') || tool_name.startsWith('mcp__mem__') || tool_name.startsWith('mcp__plugin_claude-mem-lite')) return;
164
150
  if (tool_name.startsWith('mcp__sequential') || tool_name.startsWith('mcp__plugin_context7')) return;
165
151
 
166
152
  const resp = typeof tool_response === 'string' ? tool_response : JSON.stringify(tool_response || '');
@@ -347,21 +333,7 @@ async function handleStop() {
347
333
  // Immediate save: persist rule-based observation to DB before spawning background worker.
348
334
  // Without this, data is lost if the background worker fails.
349
335
  try {
350
- const hasError = episode.entries.some(e => e.isError);
351
- const hasEdit = episode.entries.some(e => EDIT_TOOLS.has(e.tool));
352
- const inferredType = hasError ? 'bugfix' : hasEdit ? 'change' : 'discovery';
353
- const fileList = (episode.files || []).map(f => basename(f)).join(', ') || '(multiple)';
354
- const obs = {
355
- type: inferredType,
356
- title: truncate(buildDegradedTitle(episode), 120),
357
- subtitle: fileList,
358
- narrative: episode.entries.map(e => e.desc).join('; '),
359
- concepts: [],
360
- facts: [],
361
- files: episode.files,
362
- filesRead: episode.filesRead || [],
363
- importance: computeRuleImportance(episode),
364
- };
336
+ const obs = buildImmediateObservation(episode);
365
337
  const id = saveObservation(obs, episode.project, episode.sessionId);
366
338
  if (id) episode.savedId = id;
367
339
  } catch (e) { debugCatch(e, 'handleStop-fallback-immediateSave'); }
@@ -397,9 +369,12 @@ async function handleStop() {
397
369
  // Always clear event file to prevent stale events accumulating if registry DB is unavailable.
398
370
  try {
399
371
  const sessionEvents = readAndClearToolEvents();
400
- const rdb = getRegistryDb();
401
- if (rdb) {
402
- await collectFeedback(rdb, sessionId, sessionEvents);
372
+ // Skip feedback for zero-interaction sessions (no tool events = no meaningful signal)
373
+ if (sessionEvents.length > 0) {
374
+ const rdb = getRegistryDb();
375
+ if (rdb) {
376
+ await collectFeedback(rdb, sessionId, sessionEvents);
377
+ }
403
378
  }
404
379
  } catch (e) { debugCatch(e, 'handleStop-feedback'); }
405
380
 
@@ -456,7 +431,7 @@ async function handleSessionStart() {
456
431
 
457
432
  // ── DB mutations in a transaction (crash-safe consistency) ──
458
433
  const staleSessionCutoff = Date.now() - STALE_SESSION_MS;
459
- const autoCompressAge = Date.now() - 90 * 86400000; // 90 days
434
+ const autoCompressAge = Date.now() - 30 * 86400000; // 30 days (accelerated from 90)
460
435
 
461
436
  db.transaction(() => {
462
437
  // Ensure session exists in DB (INSERT OR IGNORE avoids race condition)
@@ -479,7 +454,7 @@ async function handleSessionStart() {
479
454
  WHERE status = 'active' AND started_at_epoch < ?
480
455
  `).run(staleSessionCutoff);
481
456
 
482
- // Auto-compress: mark old low-importance observations as compressed (90+ days, importance=1)
457
+ // Auto-compress: mark old low-importance observations as compressed (30+ days, importance=1)
483
458
  // Lightweight: only marks rows, doesn't create summaries (full compression via mem_compress)
484
459
  const compressed = db.prepare(`
485
460
  UPDATE observations SET compressed_into = -1
@@ -703,8 +678,10 @@ async function handleSessionStart() {
703
678
  try {
704
679
  const rdb = getRegistryDb();
705
680
  if (rdb && hasInjectionBudget()) {
706
- const promptCtx = latestSummary?.next_steps || '';
707
- const dispatchResult = await dispatchOnSessionStart(rdb, promptCtx, sessionId);
681
+ // Build prompt context with fallbacks: next_steps request → completed (empty = no dispatch)
682
+ const promptCtx = latestSummary?.next_steps || latestSummary?.request || latestSummary?.completed || '';
683
+ const hasHandoff = !!prevSessionId; // prevSessionId set when /clear or rapid restart detected
684
+ const dispatchResult = await dispatchOnSessionStart(rdb, promptCtx, sessionId, { hasHandoff });
708
685
  if (dispatchResult) {
709
686
  process.stdout.write(dispatchResult + '\n');
710
687
  incrementInjection();
@@ -906,6 +883,7 @@ async function handleResourceScan() {
906
883
  }
907
884
 
908
885
  // Upsert changed resources with fallback metadata (no Haiku)
886
+ let firstErr = true;
909
887
  for (const res of toIndex) {
910
888
  try {
911
889
  upsertResource(rdb, {
@@ -920,7 +898,7 @@ async function handleResourceScan() {
920
898
  trigger_patterns: `when user needs ${res.name.replace(/-/g, ' ').replace(/\//g, ' ')}`,
921
899
  capability_summary: `${res.type}: ${res.name.replace(/-/g, ' ')}`,
922
900
  });
923
- } catch {}
901
+ } catch (e) { if (firstErr) { debugCatch(e, 'handleResourceScan-upsert'); firstErr = false; } }
924
902
  }
925
903
 
926
904
  // Disable resources no longer on filesystem
package/install.mjs CHANGED
@@ -1217,7 +1217,7 @@ async function install() {
1217
1217
  'haiku-client.mjs', 'utils.mjs', 'schema.mjs', 'package.json', 'skill.md',
1218
1218
  'registry.mjs', 'registry-scanner.mjs', 'registry-indexer.mjs',
1219
1219
  'registry-retriever.mjs', 'resource-discovery.mjs',
1220
- 'dispatch.mjs', 'dispatch-inject.mjs', 'dispatch-feedback.mjs',
1220
+ 'dispatch.mjs', 'dispatch-inject.mjs', 'dispatch-feedback.mjs', 'dispatch-workflow.mjs',
1221
1221
  ];
1222
1222
 
1223
1223
  if (IS_DEV) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.1.6",
3
+ "version": "2.2.2",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "engines": {
@@ -54,13 +54,13 @@ function fallbackExtract(resource) {
54
54
  infra: 'infrastructure,devops,cloud',
55
55
  };
56
56
 
57
- let intentTags = '';
57
+ const intentTagSet = new Set();
58
58
  for (const [key, tags] of Object.entries(intentMap)) {
59
59
  if (name.includes(key) || content.includes(key)) {
60
- intentTags = tags;
61
- break;
60
+ for (const t of tags.split(',')) intentTagSet.add(t);
62
61
  }
63
62
  }
63
+ const intentTags = [...intentTagSet].join(',');
64
64
 
65
65
  // Infer domain tags from content
66
66
  const domainPatterns = [
@@ -197,8 +197,9 @@ export function buildEnhancedQuery(signals) {
197
197
  // directly across name, intent_tags, capability_summary, trigger_patterns.
198
198
  if (signals.rawKeywords?.length > 0) {
199
199
  for (const kw of signals.rawKeywords) {
200
- parts.push(`intent_tags:${kw}`);
201
- parts.push(kw); // literal, no synonym expansion
200
+ const safeKw = expandToken(kw);
201
+ parts.push(`intent_tags:${safeKw}`);
202
+ parts.push(safeKw);
202
203
  }
203
204
  }
204
205
 
@@ -376,27 +377,31 @@ const COMPOSITE_EXPR = `(
376
377
  const COMPOSITE_ORDER = `ORDER BY ${COMPOSITE_EXPR} ASC`;
377
378
 
378
379
  const SEARCH_SQL = `
379
- SELECT r.*,
380
- bm25(resources_fts, 5.0, 3.0, 3.0, 2.0, 2.0, 1.0, 1.0, 1.0) AS relevance,
381
- ${COMPOSITE_EXPR} AS composite_score
382
- FROM resources_fts
383
- JOIN resources r ON r.id = resources_fts.rowid
384
- WHERE resources_fts MATCH ?
385
- AND r.status = 'active'
386
- ORDER BY ${COMPOSITE_EXPR} ASC
380
+ SELECT *, composite_score FROM (
381
+ SELECT r.*,
382
+ bm25(resources_fts, 5.0, 3.0, 3.0, 2.0, 2.0, 1.0, 1.0, 1.0) AS relevance,
383
+ ${COMPOSITE_EXPR} AS composite_score
384
+ FROM resources_fts
385
+ JOIN resources r ON r.id = resources_fts.rowid
386
+ WHERE resources_fts MATCH ?
387
+ AND r.status = 'active'
388
+ ) sub
389
+ ORDER BY composite_score ASC
387
390
  LIMIT ?
388
391
  `;
389
392
 
390
393
  const SEARCH_BY_TYPE_SQL = `
391
- SELECT r.*,
392
- bm25(resources_fts, 5.0, 3.0, 3.0, 2.0, 2.0, 1.0, 1.0, 1.0) AS relevance,
393
- ${COMPOSITE_EXPR} AS composite_score
394
- FROM resources_fts
395
- JOIN resources r ON r.id = resources_fts.rowid
396
- WHERE resources_fts MATCH ?
397
- AND r.status = 'active'
398
- AND r.type = ?
399
- ${COMPOSITE_ORDER}
394
+ SELECT *, composite_score FROM (
395
+ SELECT r.*,
396
+ bm25(resources_fts, 5.0, 3.0, 3.0, 2.0, 2.0, 1.0, 1.0, 1.0) AS relevance,
397
+ ${COMPOSITE_EXPR} AS composite_score
398
+ FROM resources_fts
399
+ JOIN resources r ON r.id = resources_fts.rowid
400
+ WHERE resources_fts MATCH ?
401
+ AND r.status = 'active'
402
+ AND r.type = ?
403
+ ) sub
404
+ ORDER BY composite_score ASC
400
405
  LIMIT ?
401
406
  `;
402
407
 
package/registry.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  import Database from 'better-sqlite3';
5
5
  import { existsSync, mkdirSync } from 'fs';
6
6
  import { dirname } from 'path';
7
- // debugLog, debugCatch available from utils.mjs if needed
7
+ import { debugCatch } from './utils.mjs';
8
8
 
9
9
  // ─── Schema ──────────────────────────────────────────────────────────────────
10
10
 
@@ -104,7 +104,7 @@ const INVOCATIONS_SCHEMA = `
104
104
  tier INTEGER CHECK(tier IN (1,2,3)),
105
105
  recommended INTEGER DEFAULT 1,
106
106
  adopted INTEGER DEFAULT 0,
107
- outcome TEXT CHECK(outcome IN ('success','partial','failure','skipped',NULL)),
107
+ outcome TEXT CHECK(outcome IN ('success','partial','failure','skipped','ignored',NULL)),
108
108
  score REAL,
109
109
  created_at TEXT DEFAULT (datetime('now'))
110
110
  );
@@ -178,6 +178,9 @@ export function ensureRegistryDb(dbPath) {
178
178
  const schema = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='invocations'`).get();
179
179
  if (schema?.sql && !schema.sql.includes('user_prompt')) {
180
180
  db.transaction(() => {
181
+ // Clean up leftover from previous failed migration attempt
182
+ const hasOld = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name='invocations_old'`).get();
183
+ if (hasOld) db.exec(`DROP TABLE invocations_old`);
181
184
  db.exec(`ALTER TABLE invocations RENAME TO invocations_old`);
182
185
  db.exec(INVOCATIONS_SCHEMA);
183
186
  db.exec(`INSERT INTO invocations
@@ -187,7 +190,25 @@ export function ensureRegistryDb(dbPath) {
187
190
  db.exec(`DROP TABLE invocations_old`);
188
191
  })();
189
192
  }
190
- } catch {}
193
+ } catch (e) { debugCatch(e, 'ensureRegistryDb-migration'); }
194
+
195
+ // Migrate invocations CHECK constraint: add 'ignored' outcome value
196
+ try {
197
+ const schema = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='invocations'`).get();
198
+ if (schema?.sql && !schema.sql.includes("'ignored'")) {
199
+ db.transaction(() => {
200
+ const hasOld = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name='invocations_old'`).get();
201
+ if (hasOld) db.exec(`DROP TABLE invocations_old`);
202
+ db.exec(`ALTER TABLE invocations RENAME TO invocations_old`);
203
+ db.exec(INVOCATIONS_SCHEMA);
204
+ db.exec(`INSERT INTO invocations
205
+ (id, resource_id, session_id, trigger, tier, recommended, adopted, outcome, score, created_at)
206
+ SELECT id, resource_id, session_id, trigger, tier, recommended, adopted, outcome, score, created_at
207
+ FROM invocations_old`);
208
+ db.exec(`DROP TABLE invocations_old`);
209
+ })();
210
+ }
211
+ } catch (e) { debugCatch(e, 'ensureRegistryDb-ignored-migration'); }
191
212
 
192
213
  db.exec(PREINSTALLED_SCHEMA);
193
214
 
@@ -223,7 +244,7 @@ const UPSERT_SQL = `
223
244
  */
224
245
  export function upsertResource(db, r) {
225
246
  return db.transaction(() => {
226
- const result = db.prepare(UPSERT_SQL).run(
247
+ db.prepare(UPSERT_SQL).run(
227
248
  r.name, r.type, r.status || 'active', r.source || 'preinstalled',
228
249
  r.repo_url || null, r.repo_stars || 0, r.local_path,
229
250
  r.file_hash || null, r.invocation_name || '',
@@ -233,7 +254,6 @@ export function upsertResource(db, r) {
233
254
  r.keywords || '', r.tech_stack || '', r.use_cases || '', r.complexity || 'intermediate',
234
255
  r.indexed_at || null
235
256
  );
236
- if (result.changes > 0 && result.lastInsertRowid) return Number(result.lastInsertRowid);
237
257
  const row = db.prepare('SELECT id FROM resources WHERE type = ? AND name = ?').get(r.type, r.name);
238
258
  return row?.id || 0;
239
259
  })();
package/schema.mjs CHANGED
@@ -97,6 +97,7 @@ const MIGRATIONS = [
97
97
  'ALTER TABLE observations ADD COLUMN minhash_sig TEXT',
98
98
  'ALTER TABLE observations ADD COLUMN access_count INTEGER DEFAULT 0',
99
99
  'ALTER TABLE observations ADD COLUMN compressed_into INTEGER DEFAULT NULL',
100
+ 'ALTER TABLE session_summaries ADD COLUMN remaining_items TEXT',
100
101
  ];
101
102
 
102
103
  /**
@@ -153,7 +154,7 @@ export function initSchema(db) {
153
154
 
154
155
  // FTS5 full-text search tables + triggers (idempotent)
155
156
  ensureFTS(db, 'observations_fts', 'observations', ['title', 'subtitle', 'narrative', 'text', 'facts', 'concepts']);
156
- ensureFTS(db, 'session_summaries_fts', 'session_summaries', ['request', 'investigated', 'learned', 'completed', 'next_steps', 'notes']);
157
+ ensureFTS(db, 'session_summaries_fts', 'session_summaries', ['request', 'investigated', 'learned', 'completed', 'next_steps', 'notes', 'remaining_items']);
157
158
  ensureFTS(db, 'user_prompts_fts', 'user_prompts', ['prompt_text']);
158
159
 
159
160
  return db;
@@ -196,7 +197,12 @@ export function ensureDb() {
196
197
  db.pragma('synchronous = NORMAL');
197
198
  db.pragma('foreign_keys = OFF'); // Enabled after dedup migration
198
199
 
199
- return initSchema(db);
200
+ try {
201
+ return initSchema(db);
202
+ } catch (e) {
203
+ try { db.close(); } catch {}
204
+ throw e;
205
+ }
200
206
  }
201
207
 
202
208
  /**
@@ -211,10 +217,12 @@ export function ensureDb() {
211
217
  */
212
218
  export function rebuildFTS(db) {
213
219
  const FTS_TABLES = ['observations_fts', 'session_summaries_fts', 'user_prompts_fts'];
220
+ const idRe = /^[a-z][a-z0-9_]*$/;
214
221
  const rebuilt = [];
215
222
  const errors = [];
216
223
  for (const fts of FTS_TABLES) {
217
224
  try {
225
+ if (!idRe.test(fts)) { errors.push(`${fts}: invalid identifier`); continue; }
218
226
  const exists = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`).get(fts);
219
227
  if (!exists) { errors.push(`${fts}: not found`); continue; }
220
228
  db.exec(`INSERT INTO ${fts}(${fts}) VALUES('rebuild')`);
@@ -233,10 +241,12 @@ export function rebuildFTS(db) {
233
241
  */
234
242
  export function checkFTSIntegrity(db) {
235
243
  const FTS_TABLES = ['observations_fts', 'session_summaries_fts', 'user_prompts_fts'];
244
+ const idRe = /^[a-z][a-z0-9_]*$/;
236
245
  const details = [];
237
246
  let healthy = true;
238
247
  for (const fts of FTS_TABLES) {
239
248
  try {
249
+ if (!idRe.test(fts)) { details.push(`${fts}: invalid identifier`); healthy = false; continue; }
240
250
  const exists = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`).get(fts);
241
251
  if (!exists) { details.push(`${fts}: missing`); healthy = false; continue; }
242
252
  db.exec(`INSERT INTO ${fts}(${fts}) VALUES('integrity-check')`);
@@ -254,7 +264,7 @@ export function ensureFTS(db, ftsName, tableName, columns) {
254
264
  if (exists) return;
255
265
 
256
266
  // Validate identifiers to prevent SQL injection
257
- const idRe = /^[a-z_]+$/;
267
+ const idRe = /^[a-z][a-z0-9_]*$/;
258
268
  if (!idRe.test(ftsName) || !idRe.test(tableName) || !columns.every(c => idRe.test(c))) {
259
269
  throw new Error(`Invalid identifier in ensureFTS: ${ftsName}, ${tableName}`);
260
270
  }
@@ -50,7 +50,7 @@ case "$tool" in
50
50
  exit 0
51
51
  ;;
52
52
  # Prefix filters
53
- mem_*|mcp__mem__*|mcp__sequential*|mcp__plugin_context7*)
53
+ mem_*|mcp__mem__*|mcp__plugin_claude-mem-lite*|mcp__sequential*|mcp__plugin_context7*)
54
54
  exit 0
55
55
  ;;
56
56
  esac
package/server.mjs CHANGED
@@ -61,7 +61,7 @@ const RECENCY_HALF_LIFE_MS = 1209600000; // 14 days in milliseconds
61
61
  // ─── MCP Server ─────────────────────────────────────────────────────────────
62
62
 
63
63
  const server = new McpServer(
64
- { name: 'claude-mem-lite', version: '2.0.0' },
64
+ { name: 'claude-mem-lite', version: '2.2.2' },
65
65
  {
66
66
  instructions: [
67
67
  'Proactively search memory to leverage past experience. This is your long-term memory across sessions.',
@@ -83,9 +83,15 @@ const server = new McpServer(
83
83
  },
84
84
  );
85
85
 
86
+ // Track MCP request activity for idle-time cleanup (see idle timer below)
87
+ let lastMcpRequestTime = Date.now();
88
+ let idleCleanupRan = false;
89
+
86
90
  function safeHandler(fn) {
87
91
  return async (args, extra) => {
88
92
  try {
93
+ lastMcpRequestTime = Date.now();
94
+ idleCleanupRan = false;
89
95
  return await fn(args, extra);
90
96
  } catch (err) {
91
97
  return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
@@ -980,10 +986,58 @@ const walTimer = setInterval(() => {
980
986
  }, WAL_CHECKPOINT_INTERVAL);
981
987
  walTimer.unref(); // Don't keep process alive just for checkpoints
982
988
 
989
+ // ─── Idle-Time Memory Optimization ──────────────────────────────────────────
990
+ // When no MCP requests for 5 minutes, run lightweight DB maintenance.
991
+ // lastMcpRequestTime and idleCleanupRan are declared near safeHandler (which updates them).
992
+
993
+ const IDLE_THRESHOLD_MS = 5 * 60 * 1000;
994
+
995
+ const idleTimer = setInterval(() => {
996
+ if (idleCleanupRan) return;
997
+ if (Date.now() - lastMcpRequestTime < IDLE_THRESHOLD_MS) return;
998
+ idleCleanupRan = true;
999
+
1000
+ try {
1001
+ const thirtyDaysAgo = Date.now() - 30 * 86400000;
1002
+
1003
+ db.transaction(() => {
1004
+ // Delete old low-quality observations (importance<=1, never accessed, 30+ days)
1005
+ const deleted = db.prepare(`
1006
+ DELETE FROM observations
1007
+ WHERE importance <= 1 AND COALESCE(access_count, 0) = 0
1008
+ AND created_at_epoch < ? AND COALESCE(compressed_into, 0) = 0
1009
+ `).run(thirtyDaysAgo);
1010
+ if (deleted.changes > 0) {
1011
+ debugLog('INFO', 'idle-cleanup', `Deleted ${deleted.changes} stale low-quality observations`);
1012
+ }
1013
+
1014
+ // Mark old importance=1 as compressed (30+ days)
1015
+ // NOTE: compressed_into = -1 is an established sentinel meaning "auto-compressed without merge target"
1016
+ // (same pattern used in hook.mjs:456 for time-based compression)
1017
+ const compressed = db.prepare(`
1018
+ UPDATE observations SET compressed_into = -1
1019
+ WHERE COALESCE(compressed_into, 0) = 0 AND importance = 1
1020
+ AND created_at_epoch < ?
1021
+ `).run(thirtyDaysAgo);
1022
+ if (compressed.changes > 0) {
1023
+ debugLog('INFO', 'idle-cleanup', `Compressed ${compressed.changes} old observations`);
1024
+ }
1025
+ })();
1026
+
1027
+ // FTS5 index optimization (outside transaction — WAL-friendly)
1028
+ db.exec("INSERT INTO observations_fts(observations_fts) VALUES('optimize')");
1029
+ debugLog('DEBUG', 'idle-cleanup', 'FTS5 optimize complete');
1030
+ } catch (e) {
1031
+ debugCatch(e, 'idle-cleanup');
1032
+ }
1033
+ }, 60000); // Check every minute
1034
+ idleTimer.unref();
1035
+
983
1036
  // ─── Shutdown Cleanup ────────────────────────────────────────────────────────
984
1037
 
985
1038
  function shutdown(exitCode = 0) {
986
1039
  clearInterval(walTimer);
1040
+ clearInterval(idleTimer);
987
1041
  try { db.pragma('wal_checkpoint(TRUNCATE)'); } catch {}
988
1042
  try { db.close(); } catch {}
989
1043
  process.exit(exitCode);
package/utils.mjs CHANGED
@@ -622,12 +622,11 @@ export function fmtTime(iso) {
622
622
  */
623
623
  export function isoWeekKey(epochMs) {
624
624
  const d = new Date(epochMs);
625
- const tmp = new Date(d.getTime());
626
- tmp.setHours(0, 0, 0, 0);
627
- tmp.setDate(tmp.getDate() + 4 - (tmp.getDay() || 7));
628
- const yearStart = new Date(tmp.getFullYear(), 0, 1);
625
+ const tmp = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
626
+ tmp.setUTCDate(tmp.getUTCDate() + 4 - (tmp.getUTCDay() || 7));
627
+ const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1));
629
628
  const weekNum = Math.ceil(((tmp - yearStart) / 86400000 + 1) / 7);
630
- const isoYear = tmp.getFullYear();
629
+ const isoYear = tmp.getUTCFullYear();
631
630
  return `${isoYear}-W${String(weekNum).padStart(2, '0')}`;
632
631
  }
633
632