claude-mem-lite 3.39.2 → 3.41.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": "3.39.2",
13
+ "version": "3.41.0",
14
14
  "source": "./",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.39.2",
3
+ "version": "3.41.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/claudemd.mjs CHANGED
@@ -143,7 +143,10 @@ export function writeManaged(cwd, { slug, version, block, doc }) {
143
143
  else next = raw + '\n\n' + section + '\n';
144
144
  action = 'created';
145
145
  } else {
146
- next = raw.replace(m[0], section);
146
+ // Function replacer (not a string): a `$`-sequence in `section` (a future template with
147
+ // a shell example / regex / `$1`) would otherwise be interpreted as a replacement
148
+ // back-reference and corrupt the block on every SessionStart refresh. Matches line 153.
149
+ next = raw.replace(m[0], () => section);
147
150
  action = next !== raw ? 'updated' : 'unchanged';
148
151
  }
149
152
  // H2: collapse any DUPLICATE same-slug blocks (keep the first, drop the rest).
package/hook-context.mjs CHANGED
@@ -305,6 +305,7 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
305
305
  FROM observations
306
306
  WHERE COALESCE(compressed_into, 0) = 0
307
307
  AND superseded_at IS NULL
308
+ AND ${notLowSignalTitleClause('')}
308
309
  AND (
309
310
  (created_at_epoch > ? AND importance >= 1)
310
311
  OR (created_at_epoch > ? AND importance >= 2)
package/hook-handoff.mjs CHANGED
@@ -271,13 +271,28 @@ export function detectContinuationIntent(db, promptText, project, currentCcSessi
271
271
  try {
272
272
  const currentSha = gitStateModule.readGitState({ cwd: process.cwd() }).headSha;
273
273
  if (currentSha) {
274
- const anchor = db.prepare(`
275
- SELECT created_at_epoch FROM session_handoffs
276
- WHERE project = ? AND git_sha_at_handoff = ?
277
- ORDER BY created_at_epoch DESC LIMIT 1
278
- `).get(project, currentSha);
274
+ // Scope like Stage 2: an 'exit' anchor is cross-session (resume after /exit), but a 'clear'
275
+ // anchor is same-session only — else a parallel same-project session at the same commit
276
+ // would hijack (and then delete) another session's clear handoff.
277
+ const anchor = currentCcSessionId
278
+ ? db.prepare(`
279
+ SELECT created_at_epoch, match_keywords FROM session_handoffs
280
+ WHERE project = ? AND git_sha_at_handoff = ? AND (type = 'exit' OR session_id = ?)
281
+ ORDER BY created_at_epoch DESC LIMIT 1
282
+ `).get(project, currentSha, currentCcSessionId)
283
+ : db.prepare(`
284
+ SELECT created_at_epoch, match_keywords FROM session_handoffs
285
+ WHERE project = ? AND git_sha_at_handoff = ?
286
+ ORDER BY created_at_epoch DESC LIMIT 1
287
+ `).get(project, currentSha);
279
288
  if (anchor && (Date.now() - anchor.created_at_epoch <= HANDOFF_ANCHOR_MAX_AGE)) {
280
- return true;
289
+ // Unmoved HEAD is a strong resume signal, but must not hijack a NEW task typed at the
290
+ // same commit: gate long prompts on keyword overlap (mirror Stage 0). Short prompts
291
+ // (resume nudges) auto-continue.
292
+ if (promptText.length < 40) return true;
293
+ const hTokens = anchor.match_keywords ? new Set(tokenizeHandoff(anchor.match_keywords)) : null;
294
+ if (!hTokens || tokenizeHandoff(promptText).some(t => hTokens.has(t))) return true;
295
+ // long prompt with zero keyword overlap → fall through to Stage 0/1/2
281
296
  }
282
297
  }
283
298
  } catch { /* git/DB failure must not break the rest of the pipeline */ }
package/hook-llm.mjs CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  } from './utils.mjs';
12
12
  import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
13
13
  import { scrubRecord } from './lib/scrub-record.mjs';
14
- import { getVocabulary, computeVector } from './tfidf.mjs';
14
+ import { getVocabulary, computeVector, vecTextForRow } from './tfidf.mjs';
15
15
  import { insertObservationRow, insertObservationFiles, insertObservationVector } from './lib/observation-write.mjs';
16
16
  import { DEDUP_JACCARD_THRESHOLD, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
17
17
  import {
@@ -124,10 +124,12 @@ function buildFtsTextField(obs) {
124
124
  // weight) and search_aliases (finding #8: previously omitted, so even with vectors
125
125
  // enabled the paraphrase-bridge alias terms were invisible to cosine similarity).
126
126
  export function buildVecText(obs) {
127
- const conceptsText = Array.isArray(obs.concepts) ? obs.concepts.join(' ') : (obs.concepts || '');
128
- const aliasesText = obs.searchAliases || '';
129
- return [obs.title || '', obs.narrative || '', conceptsText, obs.lessonLearned || '', aliasesText]
130
- .filter(Boolean).join(' ');
127
+ // Single source (V-F1): map the camelCase obs onto vecTextForRow's row shape so save and
128
+ // every rebuild path encode the identical field set (title/narrative/concepts/lesson/aliases).
129
+ return vecTextForRow({
130
+ title: obs.title, narrative: obs.narrative, concepts: obs.concepts,
131
+ lesson_learned: obs.lessonLearned, search_aliases: obs.searchAliases,
132
+ });
131
133
  }
132
134
 
133
135
  /**
@@ -996,7 +998,8 @@ ${actionList}`;
996
998
  search_aliases: obs.searchAliases || null,
997
999
  });
998
1000
  db.prepare(`
999
- UPDATE observations SET type=?, title=?, subtitle=?, narrative=?, concepts=?, facts=?,
1001
+ UPDATE observations SET type=?, title=?, subtitle=?,
1002
+ narrative=COALESCE(NULLIF(?, ''), narrative), concepts=?, facts=?,
1000
1003
  text=?, importance=?, files_read=?, minhash_sig=?, lesson_learned=?, search_aliases=?
1001
1004
  WHERE id = ?
1002
1005
  `).run(
@@ -1018,7 +1021,7 @@ ${actionList}`;
1018
1021
  try {
1019
1022
  const vocab = getVocabulary(db);
1020
1023
  if (vocab) {
1021
- const vecText = [obs.title || '', obs.narrative || '', conceptsText].filter(Boolean).join(' ');
1024
+ const vecText = vecTextForRow({ title: obs.title, narrative: obs.narrative, concepts: conceptsText, lesson_learned: safe.lesson_learned, search_aliases: safe.search_aliases });
1022
1025
  const vec = computeVector(vecText, vocab);
1023
1026
  if (vec) {
1024
1027
  db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
package/hook-memory.mjs CHANGED
@@ -255,6 +255,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
255
255
  try {
256
256
  const crossStmt = db.prepare(`
257
257
  SELECT o.id, o.type, o.title, o.subtitle, o.narrative, o.importance, o.lesson_learned, o.project,
258
+ o.created_at_epoch, o.files_modified,
258
259
  o.cited_count, o.uncited_streak,
259
260
  ${OBS_BM25} as relevance,
260
261
  ${noisePenaltyClause('o')} as noise_penalty
package/hook-optimize.mjs CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  import { callModelJSON } from './haiku-client.mjs';
13
13
  import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
14
14
  import { scrubRecord } from './lib/scrub-record.mjs';
15
- import { getVocabulary, computeVector, cosineSimilarity } from './tfidf.mjs';
15
+ import { getVocabulary, computeVector, cosineSimilarity, vecTextForRow } from './tfidf.mjs';
16
16
  import { MERGE_JACCARD_LOW, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
17
17
  import { DB_DIR } from './schema.mjs';
18
18
 
@@ -25,9 +25,14 @@ export function distributeBudget(total = 15) {
25
25
  const reenrich = Math.max(1, Math.floor(total * 0.4));
26
26
  const clusterMerge = Math.max(1, Math.floor(total * 0.3));
27
27
  const smartCompress = Math.max(1, total - reenrich - normalize - clusterMerge);
28
- // Clamp: if total is too small for 4 tasks, cap each so sum total
28
+ // Clamp: if total is too small for all 4 tasks, allocate 1 each by priority until `total`
29
+ // is exhausted so the returned sum is ≤ total (the old fallback returned {1,1,1,1}=4 for
30
+ // total≤3, over-running `optimize --max N`).
29
31
  if (reenrich + normalize + clusterMerge + smartCompress > total) {
30
- return { reenrich: Math.max(1, total - 3), normalize: 1, clusterMerge: 1, smartCompress: 1 };
32
+ const alloc = { reenrich: 0, clusterMerge: 0, smartCompress: 0, normalize: 0 };
33
+ const order = ['reenrich', 'clusterMerge', 'smartCompress', 'normalize'];
34
+ for (let i = 0; i < total && i < order.length; i++) alloc[order[i]] = 1;
35
+ return alloc;
31
36
  }
32
37
  return { reenrich, normalize, clusterMerge, smartCompress };
33
38
  }
@@ -39,11 +44,16 @@ export function distributeBudget(total = 15) {
39
44
  * Exported for testing; also kept as the single source of vector-rebuild logic
40
45
  * for the optimize / re-enrich path to avoid drift with the hook-llm write path.
41
46
  */
42
- export function rebuildVector(db, obsId, textParts) {
47
+ export function rebuildVector(db, obsId, textPartsOrRow) {
43
48
  try {
44
49
  const vocab = getVocabulary(db);
45
50
  if (!vocab) return;
46
- const vec = computeVector(textParts.filter(Boolean).join(' '), vocab);
51
+ // Accept a legacy [parts] array OR an observation row (preferred single-source field set
52
+ // incl. lesson_learned/search_aliases via vecTextForRow, so rebuilds match the save path).
53
+ const text = Array.isArray(textPartsOrRow)
54
+ ? textPartsOrRow.filter(Boolean).join(' ')
55
+ : vecTextForRow(textPartsOrRow);
56
+ const vec = computeVector(text, vocab);
47
57
  if (vec) {
48
58
  // Bug #1 fix: column is `created_at_epoch`, not `computed_at`. Every other
49
59
  // INSERT callsite (server.mjs, hook-llm.mjs, mem-cli.mjs) uses the correct
@@ -87,7 +97,7 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
87
97
  // deliberately NOT gated on optimized_at, so a lesson-less row can still be
88
98
  // picked up by wide scope for lesson enrichment afterward.
89
99
  const stmt = db.prepare(`
90
- SELECT id, title, narrative, type, subtitle, concepts, facts, text, search_aliases, project
100
+ SELECT id, title, narrative, type, subtitle, concepts, facts, text, search_aliases, importance, project
91
101
  FROM observations
92
102
  WHERE COALESCE(compressed_into, 0) = 0
93
103
  AND superseded_at IS NULL
@@ -102,7 +112,7 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
102
112
  }
103
113
  if (scope === 'wide') {
104
114
  const stmt = db.prepare(`
105
- SELECT id, title, narrative, type, subtitle, concepts, facts, search_aliases, project
115
+ SELECT id, title, narrative, type, subtitle, concepts, facts, search_aliases, importance, project
106
116
  FROM observations
107
117
  WHERE COALESCE(compressed_into, 0) = 0
108
118
  AND superseded_at IS NULL
@@ -120,7 +130,7 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
120
130
  return project ? stmt.all(project, limit) : stmt.all(limit);
121
131
  }
122
132
  const stmt = db.prepare(`
123
- SELECT id, title, narrative, type, subtitle, project
133
+ SELECT id, title, narrative, type, subtitle, importance, project
124
134
  FROM observations
125
135
  WHERE COALESCE(compressed_into, 0) = 0
126
136
  AND (concepts IS NULL OR concepts = '')
@@ -205,7 +215,10 @@ search_aliases: 2-6 alternative search terms (include CJK if applicable).`;
205
215
  continue;
206
216
  }
207
217
 
208
- const type = validTypes.has(parsed.type) ? parsed.type : cand.type || 'change';
218
+ // Enrichment ("add a lesson") must not reclassify a specific type down to the generic
219
+ // 'change' (lower TYPE_QUALITY + faster decay); keep the stored type on that downgrade.
220
+ let type = validTypes.has(parsed.type) ? parsed.type : (cand.type || 'change');
221
+ if (type === 'change' && cand.type && cand.type !== 'change') type = cand.type;
209
222
  const concepts = Array.isArray(parsed.concepts) ? parsed.concepts.slice(0, 10) : [];
210
223
  const facts = Array.isArray(parsed.facts) ? parsed.facts.slice(0, 10) : [];
211
224
  // Preserve-on-empty: wide-scope candidates can already carry concepts/facts/aliases
@@ -227,7 +240,10 @@ search_aliases: 2-6 alternative search terms (include CJK if applicable).`;
227
240
  ? parsed.search_aliases.slice(0, 6).join(' ') : (cand.search_aliases || null);
228
241
  const title = truncate(scrubSecrets(parsed.title || ''), 120);
229
242
  const narrative = truncate(scrubSecrets(parsed.narrative || cand.narrative || ''), 500);
230
- const importance = clampImportance(parsed.importance);
243
+ // Floor at the stored importance: re-enrich adds a lesson, it must never silently downgrade
244
+ // a user-set/promoted importance (the UPDATE also sets optimized_at → the loss is permanent).
245
+ // Upgrades are still honored.
246
+ const importance = Math.max(clampImportance(parsed.importance), cand.importance || 1);
231
247
 
232
248
  const bigramText = cjkBigrams((title || '') + ' ' + (narrative || ''));
233
249
  const textField = [conceptsText, factsText, searchAliases || '', bigramText].filter(Boolean).join(' ');
@@ -250,7 +266,7 @@ search_aliases: 2-6 alternative search terms (include CJK if applicable).`;
250
266
  `).run(type, safe.title, safe.narrative, safe.concepts, safe.facts, safe.text,
251
267
  importance, safe.lesson_learned, safe.search_aliases, minhashSig, Date.now(), cand.id);
252
268
 
253
- rebuildVector(db, cand.id, [title, narrative, conceptsText]);
269
+ rebuildVector(db, cand.id, { title, narrative, concepts: conceptsText, lesson_learned: safe.lesson_learned, search_aliases: safe.search_aliases });
254
270
 
255
271
  processed++;
256
272
  } catch (e) {
@@ -282,7 +298,12 @@ export function _normalizeGateOpen(last, now) {
282
298
  return now - epoch >= NORMALIZE_INTERVAL_MS;
283
299
  }
284
300
 
285
- export function shouldRunNormalize() {
301
+ export function shouldRunNormalize(project = null) {
302
+ // The 7-day gate rate-limits the UNSCOPED whole-store normalize. An explicit --project is
303
+ // targeted work: it must not be blocked by a prior global (or other-project) run, and it
304
+ // does not advance the shared timer (see executeNormalize). Without this, `optimize --run
305
+ // --task normalize --project B` returned skipped(gate) for 7 days if ANY project had run.
306
+ if (project) return true;
286
307
  try {
287
308
  const last = JSON.parse(readFileSync(NORMALIZE_GATE_FILE, 'utf8'));
288
309
  return _normalizeGateOpen(last, Date.now());
@@ -357,7 +378,7 @@ export function applyNormalization(db, groups, { project = null } = {}) {
357
378
  // contamination the --project flag was added to prevent. NULL → all projects (legacy
358
379
  // unscoped run), matching the search-engine `(? IS NULL OR project = ?)` idiom.
359
380
  const rows = db.prepare(`
360
- SELECT id, concepts, search_aliases FROM observations
381
+ SELECT id, title, narrative, concepts, search_aliases, lesson_learned FROM observations
361
382
  WHERE COALESCE(compressed_into, 0) = 0
362
383
  AND concepts IS NOT NULL AND concepts != ''
363
384
  AND (? IS NULL OR project = ?)
@@ -390,6 +411,9 @@ export function applyNormalization(db, groups, { project = null } = {}) {
390
411
  search_aliases: newAliases,
391
412
  });
392
413
  updateStmt.run(safe.concepts, safe.search_aliases, Date.now(), row.id);
414
+ // V-F3: normalize mutated concepts + search_aliases (both vector fields) — rebuild the
415
+ // vector so it reflects the canonicalized terms (no-op when the vector arm is disabled).
416
+ rebuildVector(db, row.id, { title: row.title, narrative: row.narrative, concepts: safe.concepts, search_aliases: safe.search_aliases, lesson_learned: row.lesson_learned });
393
417
  updated++;
394
418
  }
395
419
  }
@@ -399,7 +423,7 @@ export function applyNormalization(db, groups, { project = null } = {}) {
399
423
  }
400
424
 
401
425
  export async function executeNormalize(db, force = false, { project } = {}) {
402
- if (!force && !shouldRunNormalize()) return { skipped: true, reason: 'gate' };
426
+ if (!force && !shouldRunNormalize(project)) return { skipped: true, reason: 'gate' };
403
427
 
404
428
  const concepts = extractUniqueConcepts(db, 500, { project });
405
429
  if (concepts.length < 5) return { skipped: true, reason: 'too few concepts' };
@@ -409,7 +433,10 @@ export async function executeNormalize(db, force = false, { project } = {}) {
409
433
 
410
434
  const result = applyNormalization(db, groups, { project });
411
435
 
412
- try { writeFileSync(NORMALIZE_GATE_FILE, JSON.stringify({ epoch: Date.now() })); } catch {}
436
+ // Only the UNSCOPED (whole-store) run advances the shared 7-day gate. A project-scoped run
437
+ // must not reset the global timer (it never consulted it — shouldRunNormalize(project) is
438
+ // always open), or one `--project X` run would silently block the next global normalize.
439
+ if (!project) { try { writeFileSync(NORMALIZE_GATE_FILE, JSON.stringify({ epoch: Date.now() })); } catch { /* best-effort */ } }
413
440
 
414
441
  return { processed: result.updated, groups: groups.length };
415
442
  }
@@ -425,7 +452,7 @@ export function findMergeCandidates(db, maxClusters = 5, { project } = {}) {
425
452
  const cutoff = Date.now() - MERGE_TIME_WINDOW_MS;
426
453
  const projectClause = project ? 'AND project = ?' : '';
427
454
  const stmt = db.prepare(`
428
- SELECT id, title, narrative, project, type, access_count, importance, created_at_epoch, minhash_sig, lesson_learned
455
+ SELECT id, title, narrative, project, type, access_count, importance, created_at_epoch, minhash_sig, lesson_learned, concepts, facts
429
456
  FROM observations
430
457
  WHERE COALESCE(compressed_into, 0) = 0
431
458
  AND superseded_at IS NULL
@@ -512,12 +539,15 @@ Return ONLY valid JSON:
512
539
 
513
540
  const concepts = Array.isArray(parsed.merged_concepts) ? parsed.merged_concepts.slice(0, 10) : [];
514
541
  const facts = Array.isArray(parsed.merged_facts) ? parsed.merged_facts.slice(0, 10) : [];
515
- const conceptsText = concepts.join(' ');
516
- const factsText = facts.join(' ');
542
+ // Preserve-on-empty (mirror the merged_lesson guard below + the re-enrich path): the merge
543
+ // overwrites the keeper in place, so a partial LLM response that omits these must fall back to
544
+ // the keeper's own values, not blank its live concepts/facts (findMergeCandidates now selects them).
545
+ const conceptsText = concepts.length ? concepts.join(' ') : (keeper.concepts || '');
546
+ const factsText = facts.length ? facts.join(' ') : (keeper.facts || '');
517
547
  // Scrub BEFORE truncate (see re-enrich note): keep the boundary cut on
518
548
  // already-scrubbed text so a straddling secret can't leak a sub-floor head.
519
549
  const title = truncate(scrubSecrets(parsed.merged_title || ''), 120);
520
- const narrative = truncate(scrubSecrets(parsed.merged_narrative || ''), 800);
550
+ const narrative = truncate(scrubSecrets(parsed.merged_narrative || keeper.narrative || ''), 800);
521
551
  // Preserve-on-empty. The merge overwrites the keeper in place and hides every non-keeper
522
552
  // member (compressed_into=keeper.id), so if the LLM returns merged_lesson:null (the prompt
523
553
  // at :429 explicitly permits it) every cluster lesson would leave all live surfaces at once
@@ -581,7 +611,7 @@ Return ONLY valid JSON:
581
611
  .run(keeper.id, ...otherIds);
582
612
  })();
583
613
 
584
- rebuildVector(db, keeper.id, [title, narrative, conceptsText]);
614
+ rebuildVector(db, keeper.id, { title, narrative, concepts: conceptsText, lesson_learned: lessonLearned, search_aliases: keeper.search_aliases });
585
615
 
586
616
  debugLog('DEBUG', 'llm-optimize', `merged ${cluster.length} observations into #${keeper.id}`);
587
617
  return { merged: true, keeperId: keeper.id, mergedCount: others.length };
@@ -785,7 +815,7 @@ JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive sum
785
815
  return sId;
786
816
  })();
787
817
 
788
- rebuildVector(db, summaryId, [title, narrative, conceptsText]);
818
+ rebuildVector(db, summaryId, { title, narrative, concepts: conceptsText });
789
819
 
790
820
  debugLog('DEBUG', 'llm-optimize', `smart-compressed ${observations.length} observations into #${summaryId}`);
791
821
  return { compressed: true, summaryId, count: observations.length };
@@ -837,7 +867,7 @@ export function optimizePreview(db, { project, detail = false } = {}) {
837
867
  const reenrichAliases = findReenrichCandidates(db, 5000, { scope: 'aliases', project }).length;
838
868
 
839
869
  const concepts = extractUniqueConcepts(db, 500, { project });
840
- const normalizeReady = shouldRunNormalize() && concepts.length >= 5;
870
+ const normalizeReady = shouldRunNormalize(project) && concepts.length >= 5;
841
871
 
842
872
  const mergeClusters = findMergeCandidates(db, 50, { project });
843
873
  const clusterMerge = mergeClusters.length;
@@ -851,7 +881,7 @@ export function optimizePreview(db, { project, detail = false } = {}) {
851
881
  reenrichWide,
852
882
  reenrichAliases,
853
883
  normalize: normalizeReady ? concepts.length : 0,
854
- normalizeGateOpen: shouldRunNormalize(),
884
+ normalizeGateOpen: shouldRunNormalize(project),
855
885
  clusterMerge,
856
886
  smartCompress,
857
887
  total: reenrich + (normalizeReady ? 1 : 0) + clusterMerge + smartCompress,
package/hook-shared.mjs CHANGED
@@ -98,7 +98,9 @@ export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE
98
98
  const readsCutoff = now - readsAgeMs;
99
99
  let count = 0;
100
100
  for (const f of entries) {
101
- const isEpisode = f.startsWith('ep-flush-') || f.startsWith('pending-');
101
+ // `.claim-` = handleStop's lock-contended fallback claim file (ep-<project>.json.claim-<pid>-<ts>),
102
+ // which leaks if the process dies between rename and unlink; sweep it on the same 1h cutoff.
103
+ const isEpisode = f.startsWith('ep-flush-') || f.startsWith('pending-') || f.includes('.claim-');
102
104
  const isReads = f.startsWith('reads-') && f.endsWith('.txt');
103
105
  if (!isEpisode && !isReads) continue;
104
106
  const full = join(runtimeDir, f);
package/hook.mjs CHANGED
@@ -484,6 +484,10 @@ async function handleStop() {
484
484
  for (const sub of planEpisodeFlush(episode)) {
485
485
  if (!sub.sessionId) sub.sessionId = sessionId;
486
486
  if (!sub.project) sub.project = project;
487
+ // Per-sub significance gate — parity with flushEpisodeGroup. The whole-episode check
488
+ // above can pass while an interleaved concurrent-session sub is pure noise (e.g. a lone
489
+ // Read); without this the fallback persists that noise sub AND spawns an LLM worker for it.
490
+ if (!episodeHasSignificantContent(sub)) continue;
487
491
  try {
488
492
  const obs = buildImmediateObservation(sub);
489
493
  const id = saveObservation(obs, sub.project, sub.sessionId);
@@ -1412,7 +1416,14 @@ async function handleUserPrompt() {
1412
1416
  );
1413
1417
 
1414
1418
  // Cross-session handoff injection (first 3 prompts window, before semantic memory).
1415
- if (promptNumber <= 3) {
1419
+ // prompt_counter is project-scoped (shared across concurrent same-project CC sessions), so a
1420
+ // parallel session would start past the window and never get its handoff injected. Count THIS
1421
+ // cc_session's own prompts instead (the current one is already inserted above); legacy null cc
1422
+ // id falls back to the shared counter.
1423
+ const windowPos = ccSessionId
1424
+ ? (db.prepare('SELECT COUNT(*) c FROM user_prompts WHERE cc_session_id = ?').get(ccSessionId)?.c || promptNumber)
1425
+ : promptNumber;
1426
+ if (windowPos <= 3) {
1416
1427
  try {
1417
1428
  if (detectContinuationIntent(db, promptText, project, ccSessionId)) {
1418
1429
  const picked = pickHandoffToInject(db, project, ccSessionId);
package/install.mjs CHANGED
@@ -1688,12 +1688,18 @@ async function doctor() {
1688
1688
 
1689
1689
  // ─── Settings helpers ───────────────────────────────────────────────────────
1690
1690
 
1691
- function isMemHook(cfg) {
1691
+ // Identify a settings hook as one of OURS (to replace on install / strip on uninstall).
1692
+ // Must be tight: the old `hook.mjs` + event-word test matched a user's OWN generic hook
1693
+ // (`node ~/.config/hook.mjs session-start`) and install/uninstall silently deleted it.
1694
+ // The launcher marker (`hook-launcher.mjs`, which every Node hook routes through since v2.84)
1695
+ // replaces that clause; the product-name substring (our install-dir / legacy-direct hooks)
1696
+ // and the bash prefilter round out the real markers.
1697
+ export function isMemHook(cfg) {
1692
1698
  if (!cfg.hooks) return false;
1693
1699
  return cfg.hooks.some(h => {
1694
1700
  const cmd = h.command || '';
1695
1701
  return cmd.includes('claude-mem-lite') ||
1696
- (cmd.includes('hook.mjs') && /\b(session-start|stop|user-prompt|pre-tool-use)\b/.test(cmd)) ||
1702
+ cmd.includes('hook-launcher.mjs') ||
1697
1703
  cmd.includes('scripts/post-tool-use.sh');
1698
1704
  });
1699
1705
  }
@@ -13,7 +13,7 @@
13
13
  // { projectFilter: 'AND project = ?' | '', baseParams: [project?] , staleAge, opCap }
14
14
 
15
15
  import { COMPRESSED_PENDING_PURGE, computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity } from '../utils.mjs';
16
- import { rebuildVocabulary, computeVector, _resetVocabCache, vectorsEnabled } from '../tfidf.mjs';
16
+ import { rebuildVocabulary, computeVector, _resetVocabCache, vectorsEnabled, vecTextForRow } from '../tfidf.mjs';
17
17
  import { DEDUP_JACCARD_THRESHOLD, MINHASH_PRE_THRESHOLD as MINHASH_PRE_THRESHOLD_SRC, FUZZY_DEDUP_THRESHOLD, FUZZY_BODY_THRESHOLD, MINHASH_PREFILTER } from './dedup-constants.mjs';
18
18
 
19
19
  export const STALE_AGE_MS = 30 * 86400000;
@@ -397,10 +397,15 @@ export function maintenanceStats(db, { projectFilter, baseParams, staleAge }) {
397
397
  -- the scan stat previews what decay will mark idle, and decay protects
398
398
  -- injected rows. Omitting it over-counted "stale" by the injected-but-decayed
399
399
  -- rows decay never touches (e.g. demote_pinned's output: imp=1 but inj>0).
400
+ -- lesson_learned guard mirrors decayAndMarkIdle (:188) / cleanupBroken (:153): those
401
+ -- ops NEVER touch a lesson-bearing row ("lessons never auto-GC"), so the scan preview
402
+ -- must exclude them too or it over-forecasts "Stale"/"Broken" vs what execute does.
400
403
  COALESCE(SUM(CASE WHEN COALESCE(importance, 1) = 1 AND COALESCE(access_count, 0) = 0
401
404
  AND COALESCE(injection_count, 0) = 0
405
+ AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
402
406
  AND created_at_epoch < ? THEN 1 ELSE 0 END), 0) as stale,
403
407
  COALESCE(SUM(CASE WHEN (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '')
408
+ AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
404
409
  THEN 1 ELSE 0 END), 0) as broken,
405
410
  COALESCE(SUM(CASE WHEN COALESCE(access_count, 0) > 3 AND COALESCE(importance, 1) < 3
406
411
  THEN 1 ELSE 0 END), 0) as boostable,
@@ -423,7 +428,7 @@ export function rebuildVectors(db) {
423
428
  const vocab = rebuildVocabulary(db);
424
429
  if (!vocab) return { ok: false, reason: 'no observations to build vocabulary from' };
425
430
  const allObs = db.prepare(`
426
- SELECT id, title, narrative, concepts FROM observations
431
+ SELECT id, title, narrative, concepts, lesson_learned, search_aliases FROM observations
427
432
  WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
428
433
  `).all();
429
434
  let updated = 0;
@@ -432,8 +437,7 @@ export function rebuildVectors(db) {
432
437
  db.transaction(() => {
433
438
  db.prepare('DELETE FROM observation_vectors').run();
434
439
  for (const obs of allObs) {
435
- const text = [obs.title || '', obs.narrative || '', obs.concepts || ''].filter(Boolean).join(' ');
436
- const vec = computeVector(text, vocab);
440
+ const vec = computeVector(vecTextForRow(obs), vocab);
437
441
  if (vec) {
438
442
  insertStmt.run(obs.id, Buffer.from(vec.buffer), vocab.version, now);
439
443
  updated++;
@@ -54,6 +54,27 @@ export function parseDuration(raw) {
54
54
  return { ok: true, ms: n * DURATION_UNIT_MS[m[2].toLowerCase()] };
55
55
  }
56
56
 
57
+ /**
58
+ * Parse one from/to bound to epoch ms. A bare `YYYY-MM-DD` is the user's LOCAL
59
+ * calendar day (created_at_epoch is Date.now() = local wall-clock), so it must be
60
+ * built in local time — `new Date('YYYY-MM-DD')` parses as UTC midnight and shifts the
61
+ * boundary by the tz offset (8h for the UTC+8 base), silently dropping early-morning
62
+ * rows and leaking the next day's early hours. `endOfDay` extends a date-only bound to
63
+ * 23:59:59.999 local (the inclusive `--to` day). Full ISO 8601 (with time/offset) is
64
+ * parsed as-is, honoring any explicit zone. Out-of-range date-only strings return NaN so
65
+ * the caller's isNaN guard rejects them (matches the old UTC parse's strict Invalid Date).
66
+ */
67
+ function parseCalendarBound(raw, endOfDay) {
68
+ if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
69
+ const [y, m, d] = raw.split('-').map(Number);
70
+ const dt = endOfDay ? new Date(y, m - 1, d, 23, 59, 59, 999) : new Date(y, m - 1, d, 0, 0, 0, 0);
71
+ // new Date(2026, 12, 45) silently rolls over; reject so 2026-13-45 fails like before.
72
+ if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return NaN;
73
+ return dt.getTime();
74
+ }
75
+ return new Date(raw).getTime();
76
+ }
77
+
57
78
  /**
58
79
  * Parse from/to date bounds to epoch ms. Date-only `to` (YYYY-MM-DD) extends
59
80
  * to end-of-day so "to 2026-06-12" includes that day's rows. An optional
@@ -63,11 +84,8 @@ export function parseDuration(raw) {
63
84
  * | { ok: false, bad: 'from'|'to'|'since', value: string }}
64
85
  */
65
86
  export function parseDateBounds(fromRaw, toRaw, sinceRaw = null, now = Date.now()) {
66
- let epochFrom = fromRaw ? new Date(fromRaw).getTime() : null;
67
- let epochTo = toRaw ? new Date(toRaw).getTime() : null;
68
- if (epochTo !== null && toRaw && /^\d{4}-\d{2}-\d{2}$/.test(toRaw)) {
69
- epochTo += 86400000 - 1; // extend to 23:59:59.999
70
- }
87
+ let epochFrom = fromRaw ? parseCalendarBound(fromRaw, false) : null;
88
+ const epochTo = toRaw ? parseCalendarBound(toRaw, true) : null;
71
89
  if (epochFrom !== null && isNaN(epochFrom)) return { ok: false, bad: 'from', value: fromRaw };
72
90
  if (epochTo !== null && isNaN(epochTo)) return { ok: false, bad: 'to', value: toRaw };
73
91
  if (sinceRaw) {
@@ -17,11 +17,11 @@ import { sanitizeFtsQuery } from '../utils.mjs';
17
17
 
18
18
  const TIMELINE_COLS = 'id, type, title, subtitle, project, created_at, created_at_epoch';
19
19
 
20
- /** Nearest non-compressed observation to `epoch` (optionally project-scoped). */
20
+ /** Nearest live (non-compressed, non-superseded) observation to `epoch`. */
21
21
  function nearestObservation(db, epoch, project) {
22
22
  return db.prepare(`
23
23
  SELECT id FROM observations
24
- WHERE COALESCE(compressed_into, 0) = 0 ${project ? 'AND project = ?' : ''}
24
+ WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${project ? 'AND project = ?' : ''}
25
25
  ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
26
26
  `).get(...(project ? [project, epoch] : [epoch]));
27
27
  }
@@ -56,7 +56,7 @@ export function resolveAnchorToken(db, rawAnchor, { project = null } = {}) {
56
56
  // Bare "#N" or "N" — observation first. Route compressed obs to its live
57
57
  // parent so the window (which filters compressed) isn't shown around a dead
58
58
  // record; negative sentinels (-1 dropped, -2 pending purge) have no parent.
59
- const obsRow = db.prepare('SELECT compressed_into FROM observations WHERE id = ?').get(parsed.id);
59
+ const obsRow = db.prepare('SELECT compressed_into, superseded_at, superseded_by FROM observations WHERE id = ?').get(parsed.id);
60
60
  if (obsRow) {
61
61
  const ci = obsRow.compressed_into;
62
62
  if (ci && ci > 0) {
@@ -65,6 +65,16 @@ export function resolveAnchorToken(db, rawAnchor, { project = null } = {}) {
65
65
  if (ci && ci < 0) {
66
66
  return { ok: false, error: { code: 'compressed-pruned', id: parsed.id } };
67
67
  }
68
+ // Superseded obs → hop to its live successor, mirroring compressed→parent. A
69
+ // superseded row is dropped from every other read path (and from the before/after
70
+ // window legs below), so anchoring ON it would surface a dead record. superseded_by
71
+ // is polymorphic: a numeric obs id for explicit supersession (save-observation), or a
72
+ // string marker ('auto-dedup'/'auto-dedup-fuzzy') for hook auto-dedup — only the
73
+ // numeric case has a successor to redirect to, so guard on `typeof … number` (not
74
+ // `> 0`, which a string sentinel would silently pass as NaN→false anyway but reads wrong).
75
+ if (obsRow.superseded_at !== null && typeof obsRow.superseded_by === 'number' && obsRow.superseded_by > 0) {
76
+ return { ok: true, anchorId: obsRow.superseded_by, anchorNote: `(anchored to #${obsRow.superseded_by}, #${parsed.id} was superseded by it)` };
77
+ }
68
78
  return { ok: true, anchorId: parsed.id, anchorNote: null };
69
79
  }
70
80
 
@@ -138,10 +148,12 @@ export function resolveQueryAnchor(db, queryStr, { project = null } = {}) {
138
148
  };
139
149
  }
140
150
 
141
- /** No-anchor fallback: most recent non-compressed observations, newest first. */
151
+ /** No-anchor fallback: most recent live (non-compressed, non-superseded) obs, newest first. */
142
152
  export function fetchRecentTimeline(db, { project = null, limit }) {
143
- const compressedFilter = 'COALESCE(compressed_into, 0) = 0';
144
- const where = project ? `WHERE ${compressedFilter} AND project = ?` : `WHERE ${compressedFilter}`;
153
+ // superseded_at IS NULL mirrors the before/after window legs (fetchTimelineWindow) and
154
+ // every other read path a superseded row must not lead the "most recent" timeline.
155
+ const liveFilter = 'COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL';
156
+ const where = project ? `WHERE ${liveFilter} AND project = ?` : `WHERE ${liveFilter}`;
145
157
  const params = project ? [project, limit] : [limit];
146
158
  return db.prepare(`
147
159
  SELECT ${TIMELINE_COLS}
package/mem-cli.mjs CHANGED
@@ -1569,7 +1569,10 @@ function cmdUpdate(db, args) {
1569
1569
  updates.push('lesson_learned = ?');
1570
1570
  params.push(scrubSecrets(rawLesson));
1571
1571
  }
1572
- if (flags.concepts !== undefined) { updates.push('concepts = ?'); params.push(flags.concepts); }
1572
+ // Scrub like the sibling text fields above (title/narrative/lesson) and the MCP twin
1573
+ // mem_update — concepts is a scrub-target + FTS-indexed column, so a raw secret here
1574
+ // lands searchable + exportable (rebuildObservationDerived folds it into `text`).
1575
+ if (flags.concepts !== undefined) { updates.push('concepts = ?'); params.push(scrubSecrets(flags.concepts)); }
1573
1576
 
1574
1577
  if (updates.length === 0) {
1575
1578
  fail('[mem] No fields to update. Use --title, --type, --importance, --lesson/--lesson-learned, --narrative, --concepts');
@@ -1769,8 +1772,16 @@ function cmdRestore(db, argv) {
1769
1772
  // Re-apply the fields saveObservation zeros/derives so the backup is faithful.
1770
1773
  // search_aliases is its own FTS5 column, so this UPDATE re-syncs the index
1771
1774
  // (via the observations FTS triggers) and restored aliases stay searchable.
1775
+ // Scrub the FTS-indexed text fields on the way in — the sibling ingest paths
1776
+ // (import-jsonl, compress-core) scrub as defense-in-depth, and restore is the only
1777
+ // rewrite path that skipped it. A backup made before a SECRET_PATTERNS entry existed
1778
+ // would otherwise re-index an old secret in facts/concepts even though narrative
1779
+ // (routed through saveObservation) gets re-scrubbed. files_read/branch are
1780
+ // paths/identifiers, not scrub-target text — left as-is.
1772
1781
  signalUpdate.run(
1773
- r.subtitle || '', r.concepts || '', r.facts || '', r.search_aliases ?? null, r.files_read || '[]', r.branch ?? null,
1782
+ scrubSecrets(r.subtitle || ''), scrubSecrets(r.concepts || ''), scrubSecrets(r.facts || ''),
1783
+ r.search_aliases === null || r.search_aliases === undefined ? null : scrubSecrets(r.search_aliases),
1784
+ r.files_read || '[]', r.branch ?? null,
1774
1785
  num(r.access_count), num(r.cited_count), num(r.uncited_streak), num(r.injection_count),
1775
1786
  num(r.decay_seen_count), r.last_accessed_at ?? null,
1776
1787
  res.id,
@@ -2239,7 +2250,22 @@ function cmdRegistry(_memDb, args) {
2239
2250
  const name = flags.name;
2240
2251
  const resourceType = flags['resource-type'];
2241
2252
  if (!name || !resourceType) { fail('[mem] Usage: claude-mem-lite registry import --name N --resource-type skill|agent [--invocation-name I] [--capability-summary S]'); return; }
2242
- const fields = { name, type: resourceType, status: 'active', source: flags.source || 'user' };
2253
+ // Validate --source against its CHECK enum (parity with memRegistrySchema.source on MCP):
2254
+ // an invalid value otherwise reaches the INSERT and throws a raw SqliteError stacktrace
2255
+ // (the dispatcher's catch only special-cases SQLITE_BUSY/LOCKED).
2256
+ if (flags.source && !new Set(['preinstalled', 'user', 'github']).has(flags.source)) {
2257
+ fail(`[mem] Invalid --source "${flags.source}". Valid: preinstalled, user, github`);
2258
+ return;
2259
+ }
2260
+ // Preserve provenance on a metadata-only re-import: default source to 'user' only for
2261
+ // a genuinely NEW resource. Re-importing an existing github/preinstalled row without
2262
+ // --source must not flip it to 'user' (which also mis-grants the user-source rank boost).
2263
+ let source = flags.source;
2264
+ if (!source) {
2265
+ const existing = rdb.prepare('SELECT source FROM resources WHERE type = ? AND name = ?').get(resourceType, name);
2266
+ source = existing ? existing.source : 'user';
2267
+ }
2268
+ const fields = { name, type: resourceType, status: 'active', source };
2243
2269
  for (const f of ['repo-url', 'local-path', 'invocation-name', 'intent-tags', 'domain-tags', 'trigger-patterns', 'capability-summary', 'keywords', 'tech-stack', 'use-cases']) {
2244
2270
  const camel = f.replace(/-([a-z])/g, (_, c) => '_' + c);
2245
2271
  fields[camel] = flags[f] || '';
package/nlp.mjs CHANGED
@@ -314,6 +314,16 @@ export function sanitizeFtsQuery(query) {
314
314
  if (bg && !isCjkNoiseBigram(bg) && !matched.has(bg)) expandedTokens.push(bg);
315
315
  }
316
316
  }
317
+ // Preserve embedded Latin/English identifiers glued into the CJK token — e.g. the
318
+ // "redis" in "redis缓存问题". extractCjkKeywords + cjkBigrams only see CJK runs, so
319
+ // without this the Latin anchor (often the single most precise term — a proper noun
320
+ // like redis/grafana/oauth with no CJK synonym) was dropped, zeroing recall on
321
+ // whitespace-free mixed-script prompts. Mirrors registry-retriever.mjs's embedded-
322
+ // English extraction. Lowercased for parity with the write-path unicode61 folding.
323
+ for (const en of (remainder.match(/[a-zA-Z]{2,}/g) || [])) {
324
+ const low = en.toLowerCase();
325
+ if (!FTS_STOP_WORDS.has(low) && !expandedTokens.includes(low)) expandedTokens.push(low);
326
+ }
317
327
  continue;
318
328
  }
319
329
  // No dictionary word matched. For a PURE-CJK run, pushing the whole
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.39.2",
3
+ "version": "3.41.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -233,7 +233,7 @@ export async function importFromGitHub(db, url, opts = {}) {
233
233
  // 1. Parse GitHub URL
234
234
  const parsed = parseGitHubUrl(url);
235
235
  if (!parsed) throw new Error('Invalid GitHub URL');
236
- const { owner, repo, branch, path: pathFilter } = parsed;
236
+ const { owner, repo, branch: parsedBranch, path: pathFilter } = parsed;
237
237
 
238
238
  // 2. Fetch repo metadata (stars, forks, updated_at)
239
239
  const repoResp = await fetchFn(buildRepoUrl(owner, repo), { headers });
@@ -247,6 +247,13 @@ export async function importFromGitHub(db, url, opts = {}) {
247
247
  const repoForks = repoMeta.forks_count || 0;
248
248
  const repoUpdatedAt = repoMeta.updated_at || null;
249
249
 
250
+ // parseGitHubUrl defaults branch to 'main' when the URL omits `/tree/<branch>`. Prefer the
251
+ // repo's ACTUAL default branch in that case: a repo defaulting to master/develop/trunk
252
+ // otherwise 404s on a non-existent 'main' (GitHub does not redirect a missing ref), failing
253
+ // a URL that opens fine in the browser. An explicit `/tree/<branch>` in the URL still wins.
254
+ const branchExplicit = /\/tree\//.test(url.split(/[?#]/)[0]);
255
+ const branch = branchExplicit ? parsedBranch : (repoMeta.default_branch || parsedBranch);
256
+
250
257
  // 3. Fetch file tree via GitHub API (recursive)
251
258
  const treeResp = await fetchFn(buildTreeUrl(owner, repo, branch), { headers });
252
259
  if (!treeResp.ok) {
package/registry.mjs CHANGED
@@ -368,9 +368,15 @@ const UPSERT_SQL = `
368
368
  indexed_at, updated_at)
369
369
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
370
370
  ON CONFLICT(type, name) DO UPDATE SET
371
- status=excluded.status, source=excluded.source, repo_url=excluded.repo_url,
371
+ status=excluded.status, source=excluded.source,
372
+ -- Preserve-on-empty (mirror the FTS text columns below): a PARTIAL re-upsert defaults
373
+ -- repo_url/local_path to null/'' in the caller (import is the only edit path). Clobbering
374
+ -- them ORPHANS the resource — mem_use/enrich read local_path (NOT NULL, so '' passes the
375
+ -- constraint and silently breaks reads), and the scanner needs local_path to disable it.
376
+ repo_url=CASE WHEN excluded.repo_url != '' THEN excluded.repo_url ELSE repo_url END,
372
377
  repo_stars=CASE WHEN excluded.repo_stars > 0 THEN excluded.repo_stars ELSE repo_stars END,
373
- local_path=excluded.local_path, file_hash=excluded.file_hash,
378
+ local_path=CASE WHEN excluded.local_path != '' THEN excluded.local_path ELSE local_path END,
379
+ file_hash=excluded.file_hash,
374
380
  invocation_name=CASE WHEN excluded.invocation_name != '' THEN excluded.invocation_name ELSE invocation_name END,
375
381
  -- Preserve-on-empty (mirror repo_stars/invocation_name above): a PARTIAL re-upsert --
376
382
  -- e.g. "registry import --name X --capability-summary ...", where mem-cli defaults every
@@ -164,8 +164,14 @@ export function shouldSkipByDedup(newIds, injectedFile) {
164
164
  if (count >= MAX_SESSION_INJECTIONS) return true;
165
165
  if (!ts || Date.now() - ts > DEDUP_STALE_MS) return false;
166
166
  if (!Array.isArray(prevIds) || prevIds.length === 0) return false;
167
- const prevSet = new Set(prevIds);
168
- const overlapCount = newIds.filter(id => prevSet.has(id)).length;
167
+ // Normalize both sides to strings before comparing: UPS writes obs ids as numbers
168
+ // (rows.map(r => r.id)) while pre-tool-recall's mergeCrossHookInjected writes them as
169
+ // strings (.map(String)), and both hooks SHARE this file. Without normalization
170
+ // Set.has(8829) misses "8829" → cross-hook dedup never fires and the same lesson
171
+ // double-injects within the window. (pre-tool-recall's readCrossHookInjected already
172
+ // String-normalizes; this brings the UPS-side reader in line.)
173
+ const prevSet = new Set(prevIds.map(String));
174
+ const overlapCount = newIds.filter(id => prevSet.has(String(id))).length;
169
175
  return overlapCount / newIds.length >= 0.8;
170
176
  } catch { return false; }
171
177
  }
package/scripts/setup.sh CHANGED
@@ -39,20 +39,18 @@ fi
39
39
  mkdir -p "$DATA_DIR"
40
40
  log_ok "Data directory: $DATA_DIR"
41
41
 
42
- # 3. Migrate from old ~/.claude-mem/ if needed
42
+ # 3. Legacy ~/.claude-mem/ DB is schema-v16 (no memory_session_id) with no migration bridge to
43
+ # the current schema — activating it FATALs on first launch ("no such column: memory_session_id")
44
+ # and the "! -f claude-mem-lite.db" guard would re-copy it every time the user deletes the broken
45
+ # DB (recovery loop). Mirror install.mjs migrateLegacyClaudeMemData: back it up (don't activate)
46
+ # and let a fresh DB be created. Source ~/.claude-mem/ is left intact.
43
47
  OLD_DIR="$HOME/.claude-mem"
44
48
  if [[ -f "$OLD_DIR/claude-mem.db" && ! -f "$DATA_DIR/claude-mem-lite.db" && ! -f "$DATA_DIR/claude-mem.db" ]]; then
45
- log_info "Migrating data from ~/.claude-mem/ → ~/.claude-mem-lite/..."
46
- if cp "$OLD_DIR/claude-mem.db" "$DATA_DIR/claude-mem-lite.db" 2>/dev/null; then
47
- # Main DB copied successfully, WAL/SHM are optional
48
- cp "$OLD_DIR/claude-mem.db-wal" "$DATA_DIR/claude-mem-lite.db-wal" 2>/dev/null || true
49
- cp "$OLD_DIR/claude-mem.db-shm" "$DATA_DIR/claude-mem-lite.db-shm" 2>/dev/null || true
50
- if [[ -d "$OLD_DIR/runtime" && ! -d "$DATA_DIR/runtime" ]]; then
51
- cp -r "$OLD_DIR/runtime" "$DATA_DIR/runtime" 2>/dev/null || true
52
- fi
53
- log_ok "Data migrated (old ~/.claude-mem/ preserved)"
49
+ BACKUP="$DATA_DIR/claude-mem-lite.db.legacy-backup-$(date +%s)"
50
+ if cp "$OLD_DIR/claude-mem.db" "$BACKUP" 2>/dev/null; then
51
+ log_info "Legacy ~/.claude-mem/ DB is schema-incompatible; backed up to $(basename "$BACKUP") (a fresh DB will be created). Old ~/.claude-mem/ preserved."
54
52
  else
55
- log_warn "Migration failed — using fresh database"
53
+ log_warn "Legacy DB backup failed — a fresh database will be created"
56
54
  fi
57
55
  fi
58
56
 
package/server.mjs CHANGED
@@ -286,9 +286,15 @@ async function runSearchPipeline(db, args, { llm, rerankLlm } = {}) {
286
286
  return { ...formatSearchOutput([], args, ftsQuery, 0), escalated: false, results: [], total: 0, variants: null };
287
287
  }
288
288
 
289
- // obs_type ⇒ observations-only; deep is observations-only too (deepSearch fuses
290
- // hybrid-obs lists). args.type is the source filter (observations|sessions|prompts).
291
- const effectiveType = deepMode === 'deep' ? 'observations' : (args.type || (args.obs_type ? 'observations' : undefined));
289
+ // obs_type/importance/branch/tier ⇒ observations-only; deep is observations-only too
290
+ // (deepSearch fuses hybrid-obs lists). args.type is the source filter
291
+ // (observations|sessions|prompts). Forcing obs-only for the obs-exclusive fields
292
+ // matches the CLI (mem-cli.mjs:177): session/prompt tables have no importance/branch/tier
293
+ // column, so without the force those legs return UNFILTERED and leak rows that can't be
294
+ // scoped to the filter (branch/importance/tier previously leaked cross-source on MCP).
295
+ const effectiveType = deepMode === 'deep'
296
+ ? 'observations'
297
+ : (args.type || ((args.obs_type || args.importance || args.branch || args.tier) ? 'observations' : undefined));
292
298
 
293
299
  const r = await coreRunSearchPipeline(
294
300
  {
@@ -1402,7 +1408,15 @@ server.registerTool(
1402
1408
  }
1403
1409
  const IMPORT_STRING_FIELDS = ['repo_url', 'local_path', 'invocation_name', 'intent_tags',
1404
1410
  'domain_tags', 'trigger_patterns', 'capability_summary', 'keywords', 'tech_stack', 'use_cases'];
1405
- const fields = { name: args.name, type: args.resource_type, status: 'active', source: args.source || 'user' };
1411
+ // Preserve provenance on a metadata-only re-import (parity with cmdRegistry): default
1412
+ // source to 'user' only for a NEW resource — a partial re-upsert of an existing
1413
+ // github/preinstalled row without `source` must not flip it to 'user'.
1414
+ let source = args.source;
1415
+ if (!source) {
1416
+ const existing = rdb.prepare('SELECT source FROM resources WHERE type = ? AND name = ?').get(args.resource_type, args.name);
1417
+ source = existing ? existing.source : 'user';
1418
+ }
1419
+ const fields = { name: args.name, type: args.resource_type, status: 'active', source };
1406
1420
  for (const f of IMPORT_STRING_FIELDS) fields[f] = args[f] || '';
1407
1421
  const id = upsertResource(rdb, fields);
1408
1422
  return { content: [{ type: 'text', text: `Imported: ${args.resource_type}:${args.name} (id=${id})` }] };
package/source-files.mjs CHANGED
@@ -198,3 +198,16 @@ export const HOOK_SCRIPT_FILES = [
198
198
  // through this wrapper so any partial-install drift heals automatically.
199
199
  'hook-launcher.mjs',
200
200
  ];
201
+
202
+ // The complete set of files the release signature MUST cover: every runtime .mjs
203
+ // (SOURCE_FILES) PLUS the executable hook scripts (copyReleaseIntoStaging installs these
204
+ // into the live dir and they run on every hook fire). HOOK_SCRIPT_FILES were historically
205
+ // NOT in the signed manifest, so an attacker able to PUBLISH a release — but without the
206
+ // signing key — could swap a hook script (e.g. post-tool-use.sh / hook-launcher.mjs) while
207
+ // every SOURCE_FILES hash still matched, and fail-closed verification would still pass →
208
+ // RCE on the next hook fire. Keys are ROOT-relative, matching the extracted-tarball layout
209
+ // that verifyReleaseFiles hashes against.
210
+ export const RELEASE_SIGNED_FILES = [
211
+ ...SOURCE_FILES,
212
+ ...HOOK_SCRIPT_FILES.map(name => `scripts/${name}`),
213
+ ];
package/tfidf.mjs CHANGED
@@ -207,6 +207,19 @@ let _vocabCache = null;
207
207
  /** Reset vocabulary cache (for testing). */
208
208
  export function _resetVocabCache() { _vocabCache = null; }
209
209
 
210
+ /**
211
+ * Canonical TF-IDF vector text for an observation ROW (snake_case DB fields). Single source for
212
+ * EVERY (re)build path — save, enrich, rebuild_vectors, cluster-merge, compress, normalize — so
213
+ * they encode the identical field set and can't drift (V-F1). Mirrors the FTS-searchable columns
214
+ * incl. lesson_learned + search_aliases (finding #8). `concepts` may be an array or a string.
215
+ */
216
+ export function vecTextForRow(row) {
217
+ if (!row) return '';
218
+ const concepts = Array.isArray(row.concepts) ? row.concepts.join(' ') : (row.concepts || '');
219
+ return [row.title || '', row.narrative || '', concepts, row.lesson_learned || '', row.search_aliases || '']
220
+ .filter(Boolean).join(' ');
221
+ }
222
+
210
223
  /**
211
224
  * Build global vocabulary (IDF table) from all active observations.
212
225
  * @param {object} db - better-sqlite3 database
@@ -214,7 +227,7 @@ export function _resetVocabCache() { _vocabCache = null; }
214
227
  */
215
228
  export function buildVocabulary(db, { dim = VOCAB_DIM } = {}) {
216
229
  const rows = db.prepare(`
217
- SELECT title, narrative, concepts FROM observations
230
+ SELECT title, narrative, concepts, lesson_learned, search_aliases FROM observations
218
231
  WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
219
232
  `).all();
220
233
 
@@ -224,7 +237,10 @@ export function buildVocabulary(db, { dim = VOCAB_DIM } = {}) {
224
237
  // Count document frequency for each term
225
238
  const df = new Map();
226
239
  for (const row of rows) {
227
- const text = [row.title || '', row.narrative || '', row.concepts || ''].join(' ');
240
+ // V-F2: include lesson_learned + search_aliases so terms living only there get a vocab
241
+ // dimension (else computeVector silently drops them — the exact paraphrase-bridge terms
242
+ // search_aliases exists to carry). Mirrors vecTextForRow's field set.
243
+ const text = [row.title || '', row.narrative || '', row.concepts || '', row.lesson_learned || '', row.search_aliases || ''].join(' ');
228
244
  const docTerms = new Set(tokenize(text));
229
245
  for (const term of docTerms) {
230
246
  df.set(term, (df.get(term) || 0) + 1);
package/utils.mjs CHANGED
@@ -404,7 +404,13 @@ export function isMetaTriggerPrompt(text) {
404
404
  .replace(/上次(到哪了|说到哪了)?/g, '')
405
405
  .replace(/总结一下|复盘一下/g, '')
406
406
  .replace(/前面(的)?(工作|话题|讨论|内容)/g, '')
407
+ // R3 H-M2: control phrases named in lesson #8287 / common continuations. Phrase-anchored
408
+ // (or bare suffix/prefix) so a real subject that merely contains one keeps its other tokens.
409
+ .replace(/停了|停不下来/g, '')
410
+ .replace(/怎么(回事|了|样了|停的)?/g, '')
411
+ .replace(/再来(一次|一遍|一下)?/g, '')
407
412
  .replace(/\/?(clear|exit)\b/gi, '')
413
+ .replace(/\b(go on|go ahead|keep going|carry on|proceed|why(?:'?d| did) you stop)\b/gi, '')
408
414
  .replace(/\b(commit|continue|resume|push|save|restart|exit|next)\b/gi, '')
409
415
  .replace(/[,,。.!!??::;;()()【】[\]\s/\\-]+/g, '')
410
416
  .trim();