claude-mem-lite 3.64.0 → 3.65.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.64.0",
13
+ "version": "3.65.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.64.0",
3
+ "version": "3.65.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/deep-search.mjs CHANGED
@@ -36,6 +36,7 @@ import { sanitizeFtsQuery } from './utils.mjs';
36
36
  import { RRF_K } from './tfidf.mjs';
37
37
  import { rrfAccumulate } from './lib/rrf.mjs';
38
38
  import { llmRerankOrder, defaultRerankLLM } from './rerank.mjs';
39
+ import { liveObsFilterSql } from './lib/inject-search-core.mjs';
39
40
 
40
41
  // original + up to 3 rewrites (keyword / concept-expansion / HyDE).
41
42
  export const MAX_VARIANTS = 4;
@@ -68,7 +69,7 @@ export const AUTO_DEEP_MIN_CORPUS = 10;
68
69
  */
69
70
  export function hasEscalatableCorpus(db, project, min = AUTO_DEEP_MIN_CORPUS) {
70
71
  try {
71
- const where = ['superseded_at IS NULL', 'COALESCE(compressed_into, 0) = 0'];
72
+ const where = [liveObsFilterSql('')];
72
73
  const params = [];
73
74
  if (project) { where.push('project = ?'); params.push(project); }
74
75
  const row = db.prepare(`SELECT COUNT(*) AS c FROM observations WHERE ${where.join(' AND ')}`).get(...params);
package/hook-context.mjs CHANGED
@@ -12,9 +12,10 @@ import {
12
12
  debugLog, debugCatch, neutralizeContextDelimiters,
13
13
  DECAY_HALF_LIFE_BY_TYPE, DEFAULT_DECAY_HALF_LIFE_MS, notLowSignalTitleClause,
14
14
  } from './utils.mjs';
15
- import { STALE_SESSION_MS, FALLBACK_OBS_WINDOW_MS, RUNTIME_DIR, effectiveQuiet, isQuietHooks } from './hook-shared.mjs';
15
+ import { STALE_SESSION_MS, FALLBACK_OBS_WINDOW_MS, RUNTIME_DIR, effectiveQuiet, isQuietHooks, KEY_CONTEXT_LIMIT } from './hook-shared.mjs';
16
16
  import { extractUnfinishedSummary } from './hook-handoff.mjs';
17
17
  import { recentInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
18
+ import { liveObsFilterSql } from './lib/inject-search-core.mjs';
18
19
 
19
20
  /**
20
21
  * Infer the project directory from environment variables or cwd.
@@ -83,12 +84,7 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
83
84
  const obsPool = db.prepare(`
84
85
  SELECT id, type, title, narrative, importance, created_at_epoch, files_modified, lesson_learned
85
86
  FROM observations
86
- WHERE project = ? AND COALESCE(compressed_into, 0) = 0
87
- -- superseded invisibility: auto-dedup (hook.mjs) sets superseded_at but leaves
88
- -- compressed_into=0, so the compressed filter alone lets the hidden near-duplicate
89
- -- resurface in the most-visible surface (injected every SessionStart). Sibling
90
- -- keyObs already filters this; obsPool + fallbackObs must match.
91
- AND superseded_at IS NULL
87
+ WHERE project = ? AND ${liveObsFilterSql('')}
92
88
  AND ${notLowSignalTitleClause('')}
93
89
  AND (
94
90
  (created_at_epoch > ? AND importance >= 1)
@@ -293,9 +289,15 @@ export function cleanupClaudeMdLegacyBlock() {
293
289
  * @param {string|null} [currentCcSessionId=null] Claude Code session id — when provided,
294
290
  * the "Working State (from /clear)" block is filtered to handoffs owned by this
295
291
  * session, preventing parallel-session bleed (see docs/bug.txt).
292
+ * @param {object|null} [collector=null] Optional out-param: when given, its
293
+ * `keyContextIds` property is set to the obs ids ACTUALLY rendered into the
294
+ * File Lessons / Key Context sections ([] under quiet/adopted or when the
295
+ * sections are empty). handleUserPrompt persists this as its exclude-set
296
+ * (D#123: the exclude-set must mirror real injections, not the keyObs query).
296
297
  * @returns {string} Joined markdown lines (without <claude-mem-context> wrappers)
297
298
  */
298
- export function buildSessionContextLines(db, project, now = new Date(), currentCcSessionId = null) {
299
+ export function buildSessionContextLines(db, project, now = new Date(), currentCcSessionId = null, collector = null) {
300
+ if (collector) collector.keyContextIds = [];
299
301
  // 1. Token-budgeted observation selection
300
302
  const selected = selectWithTokenBudget(db, project, 2000);
301
303
  const observations = selected.observations;
@@ -308,8 +310,7 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
308
310
  fallbackObs = db.prepare(`
309
311
  SELECT id, type, title, project, created_at
310
312
  FROM observations
311
- WHERE COALESCE(compressed_into, 0) = 0
312
- AND superseded_at IS NULL
313
+ WHERE ${liveObsFilterSql('')}
313
314
  AND ${notLowSignalTitleClause('')}
314
315
  AND (
315
316
  (created_at_epoch > ? AND importance >= 1)
@@ -335,10 +336,9 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
335
336
  // and Key Context (informational). Pushed into summaryLines.
336
337
  const keyObs = db.prepare(`
337
338
  SELECT o.id, o.type, o.title, o.lesson_learned, o.files_modified FROM observations o
338
- WHERE o.project = ? AND COALESCE(o.compressed_into, 0) = 0
339
- AND o.superseded_at IS NULL
339
+ WHERE o.project = ? AND ${liveObsFilterSql('o')}
340
340
  AND COALESCE(o.importance, 1) >= 2
341
- ORDER BY o.created_at_epoch DESC LIMIT 10
341
+ ORDER BY o.created_at_epoch DESC LIMIT ${KEY_CONTEXT_LIMIT}
342
342
  `).all(project);
343
343
 
344
344
  if (keyObs.length > 0) {
@@ -357,30 +357,35 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
357
357
  const files = JSON.parse(o.files_modified);
358
358
  const fname = basename(Array.isArray(files) && files.length > 0 ? files[0] : '');
359
359
  if (fname) {
360
- fileLessons.push(`- ${fname}: ${truncate(o.lesson_learned, 100)} (#${o.id})`);
360
+ fileLessons.push({ id: o.id, line: `- ${fname}: ${truncate(o.lesson_learned, 100)} (#${o.id})` });
361
361
  continue;
362
362
  }
363
363
  } catch { /* fall through to keyContext */ }
364
364
  }
365
365
  const lesson = hasLesson ? ` — ${truncate(o.lesson_learned, 60)}` : '';
366
- keyContext.push(`- [${o.type || 'discovery'}] ${truncate(clean, 80)} (#${o.id})${lesson}`);
366
+ keyContext.push({ id: o.id, line: `- [${o.type || 'discovery'}] ${truncate(clean, 80)} (#${o.id})${lesson}` });
367
367
  }
368
368
 
369
369
  // Phase A (QUIET_HOOKS) + Phase D (adopted sentinel): drop descriptive
370
370
  // File Lessons / Key Context sections when the user has opted into low-noise
371
371
  // hooks OR adopted invited-memory (MEMORY.md sentinel carries the triggers
372
372
  // at higher system-prompt authority). The Recent table still fires so #IDs
373
- // remain reachable via mem_get.
373
+ // remain reachable via mem_get. The collector sees only rows that survive
374
+ // BOTH the quiet gate and the per-section slice — rendered rows, nothing else.
374
375
  const quiet = effectiveQuiet();
375
376
  if (fileLessons.length > 0 && !quiet) {
377
+ const shown = fileLessons.slice(0, 5);
376
378
  summaryLines.push('### File Lessons');
377
- summaryLines.push(...fileLessons.slice(0, 5));
379
+ summaryLines.push(...shown.map((e) => e.line));
378
380
  summaryLines.push('');
381
+ if (collector) collector.keyContextIds.push(...shown.map((e) => e.id));
379
382
  }
380
383
  if (keyContext.length > 0 && !quiet) {
384
+ const shown = keyContext.slice(0, 5);
381
385
  summaryLines.push('### Key Context');
382
- summaryLines.push(...keyContext.slice(0, 5));
386
+ summaryLines.push(...shown.map((e) => e.line));
383
387
  summaryLines.push('');
388
+ if (collector) collector.keyContextIds.push(...shown.map((e) => e.id));
384
389
  }
385
390
  } else if (!latestSummary && !effectiveQuiet()) {
386
391
  // Fallback: no summary AND no key observations — show recent activity.
package/hook-handoff.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  // immutable in ESM and cannot be mocked after the fact.
14
14
  import * as gitStateModule from './lib/git-state.mjs';
15
15
  import * as taskReaderModule from './lib/task-reader.mjs';
16
+ import { liveObsFilterSql } from './lib/inject-search-core.mjs';
16
17
 
17
18
  /**
18
19
  * Build and save a handoff snapshot to session_handoffs table.
@@ -76,8 +77,7 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
76
77
  if (subjectPrompts.length === 0) {
77
78
  const fallback = db.prepare(`
78
79
  SELECT title FROM observations
79
- WHERE project = ? AND COALESCE(compressed_into, 0) = 0
80
- AND superseded_at IS NULL
80
+ WHERE project = ? AND ${liveObsFilterSql('')}
81
81
  AND COALESCE(importance, 1) >= 3
82
82
  AND ${notLowSignalTitleClause('')}
83
83
  ORDER BY created_at_epoch DESC LIMIT 1
@@ -185,8 +185,7 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
185
185
  const decisions = db.prepare(`
186
186
  SELECT title FROM observations
187
187
  WHERE memory_session_id = ? AND COALESCE(importance, 1) >= 2
188
- AND COALESCE(compressed_into, 0) = 0
189
- AND superseded_at IS NULL ${obsWindowClause}
188
+ AND ${liveObsFilterSql('')} ${obsWindowClause}
190
189
  ORDER BY created_at_epoch DESC LIMIT 10
191
190
  `).all(sessionId, ...obsWindowParams).filter(d => d.title && !LOW_SIGNAL_TITLE.test(d.title)).slice(0, 5);
192
191
 
package/hook-optimize.mjs CHANGED
@@ -20,6 +20,7 @@ import { getVocabulary, computeVector, cosineSimilarity, vecTextForRow } from '.
20
20
  import { MERGE_JACCARD_LOW, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
21
21
  import { DB_DIR } from './schema.mjs';
22
22
  import { OBS_TYPE_SET } from './lib/obs-types.mjs';
23
+ import { liveObsFilterSql } from './lib/inject-search-core.mjs';
23
24
 
24
25
  const RUNTIME_DIR = join(DB_DIR, 'runtime');
25
26
 
@@ -104,8 +105,7 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
104
105
  const stmt = db.prepare(`
105
106
  SELECT id, title, narrative, type, subtitle, concepts, facts, text, search_aliases, importance, project
106
107
  FROM observations
107
- WHERE COALESCE(compressed_into, 0) = 0
108
- AND superseded_at IS NULL
108
+ WHERE ${liveObsFilterSql('')}
109
109
  AND (search_aliases IS NULL OR search_aliases = '')
110
110
  AND LENGTH(COALESCE(narrative, '')) > 100
111
111
  AND ${notLowSignalTitleClause('')}
@@ -119,8 +119,7 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
119
119
  const stmt = db.prepare(`
120
120
  SELECT id, title, narrative, type, subtitle, concepts, facts, search_aliases, importance, project
121
121
  FROM observations
122
- WHERE COALESCE(compressed_into, 0) = 0
123
- AND superseded_at IS NULL
122
+ WHERE ${liveObsFilterSql('')}
124
123
  AND optimized_at IS NULL
125
124
  AND type IN ('bugfix','refactor','feature','decision')
126
125
  AND (lesson_learned IS NULL OR lesson_learned = '')
@@ -459,8 +458,7 @@ export function findMergeCandidates(db, maxClusters = 5, { project } = {}) {
459
458
  const stmt = db.prepare(`
460
459
  SELECT id, title, narrative, project, type, access_count, importance, created_at_epoch, minhash_sig, lesson_learned, concepts, facts
461
460
  FROM observations
462
- WHERE COALESCE(compressed_into, 0) = 0
463
- AND superseded_at IS NULL
461
+ WHERE ${liveObsFilterSql('')}
464
462
  AND optimized_at IS NULL
465
463
  AND title IS NOT NULL AND title != ''
466
464
  AND created_at_epoch > ?
@@ -5,11 +5,17 @@
5
5
  // Differs from SessionStart-on-compact (which fires AFTER compaction):
6
6
  // PreCompact ensures memory survives the compaction step itself.
7
7
 
8
+ import { writeFileSync } from 'fs';
9
+ import { join } from 'path';
8
10
  import { buildSessionContextLines } from './hook-context.mjs';
9
11
  import { inferProject, debugCatch, debugLog } from './utils.mjs';
12
+ import { RUNTIME_DIR } from './hook-shared.mjs';
13
+ import { keyContextIdsFileName } from './lib/injected-ids.mjs';
10
14
 
11
15
  /**
12
- * Build + emit the memory context block on stdout. Pure read; no DB writes.
16
+ * Build + emit the memory context block on stdout. Pure read; no DB writes
17
+ * (one runtime marker file: the Key Context ids the re-emitted block renders,
18
+ * refreshing handleUserPrompt's exclude-set — see D#123 in hook.mjs).
13
19
  *
14
20
  * @param {object} ctx
15
21
  * @param {import('better-sqlite3').Database} ctx.db
@@ -19,9 +25,16 @@ import { inferProject, debugCatch, debugLog } from './utils.mjs';
19
25
  */
20
26
  export function handlePreCompact({ db, project, sessionId }) {
21
27
  try {
22
- const body = buildSessionContextLines(db, project, new Date(), sessionId || null);
28
+ const collector = {};
29
+ const body = buildSessionContextLines(db, project, new Date(), sessionId || null, collector);
23
30
  if (!body || String(body).trim() === '') return;
24
31
  process.stdout.write(`<claude-mem-context>\n${body}\n</claude-mem-context>\n`);
32
+ try {
33
+ writeFileSync(
34
+ join(RUNTIME_DIR, keyContextIdsFileName(project, sessionId || null)),
35
+ JSON.stringify({ ids: collector.keyContextIds || [], ts: Date.now(), session: sessionId || null }),
36
+ );
37
+ } catch (e) { debugCatch(e, 'pre-compact-keyctx-marker'); }
25
38
  } catch (e) {
26
39
  debugCatch(e, 'handlePreCompact');
27
40
  }
package/hook-shared.mjs CHANGED
@@ -32,6 +32,12 @@ export const STALE_LOCK_MS = 30000; // 30s
32
32
  export const DEDUP_WINDOW_MS = 5 * 60 * 1000; // 5 min (title dedup)
33
33
  export const RELATED_OBS_WINDOW_MS = 7 * 86400000; // 7 days
34
34
  export const FALLBACK_OBS_WINDOW_MS = RELATED_OBS_WINDOW_MS; // same window
35
+ // Candidate rows the SessionStart Key Context surface considers (hook-context.mjs
36
+ // keyObs; each of the two sections then renders at most 5). The user-prompt
37
+ // exclude-set does NOT mirror this query — it reads the ids actually rendered
38
+ // from the keyctx marker (D#123 review C-1: query-mirroring suppressed
39
+ // <memory-context> injection on quiet/adopted projects where nothing renders).
40
+ export const KEY_CONTEXT_LIMIT = 10;
35
41
 
36
42
  // Phase A (v2.31.3+): MEM_QUIET_HOOKS=1 drops descriptive hook/MCP-instruction
37
43
  // bodies (File Lessons / Key Context headers, MCP WHEN-TO-USE & decision rules,
package/hook.mjs CHANGED
@@ -68,7 +68,7 @@ import { formatTaskImperative } from './lib/task-imperative.mjs';
68
68
  import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
69
69
  import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
70
70
  import { detectMemOverride } from './lib/mem-override.mjs';
71
- import { injectedIdsFileName } from './lib/injected-ids.mjs';
71
+ import { injectedIdsFileName, keyContextIdsFileName } from './lib/injected-ids.mjs';
72
72
  import { liveObsFilterSql, recencyDecaySql } from './lib/inject-search-core.mjs';
73
73
  import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
74
74
  import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
@@ -869,7 +869,8 @@ function gcStalePreRecallCooldowns() {
869
869
  // D#120: the injected-ids marker is also per-session now — same growth
870
870
  // shape as the cooldown files, same 24h GC (dedup window is 5 min).
871
871
  const isCooldown = name.startsWith('pre-recall-cooldown-') && name.endsWith('.json');
872
- const isInjectedMarker = name.startsWith('.claude-mem-injected-');
872
+ const isInjectedMarker = name.startsWith('.claude-mem-injected-')
873
+ || name.startsWith('.claude-mem-keyctx-'); // D#123 Key Context marker — same per-session growth, same 24h GC
873
874
  if (!isCooldown && !isInjectedMarker) continue;
874
875
  try {
875
876
  const p = join(RUNTIME_DIR, name);
@@ -1471,13 +1472,28 @@ async function handleSessionStart() {
1471
1472
  // token-budgeted observation pool directly from the DB.
1472
1473
  // Pass CC session id so the Working State block is scoped to this session,
1473
1474
  // preventing parallel sessions from seeing each other's /clear handoff.
1474
- const fullContext = buildSessionContextLines(db, project, now, ccSessionId);
1475
+ const contextCollector = {};
1476
+ const fullContext = buildSessionContextLines(db, project, now, ccSessionId, contextCollector);
1475
1477
 
1476
1478
  // Stdout is the sole context-delivery channel. The SessionStart hook output
1477
1479
  // is injected as a <system-reminder> at session start, giving Claude the
1478
1480
  // full summary + handoff state + observations table fresh from the DB.
1479
1481
  process.stdout.write(`<claude-mem-context>\n${fullContext}\n</claude-mem-context>\n`);
1480
1482
 
1483
+ // D#123 (review C-1): persist the Key Context ids ACTUALLY rendered above so
1484
+ // handleUserPrompt can exclude exactly those from <memory-context> — and
1485
+ // nothing else. Under quiet/adopted the sections don't render, the id list is
1486
+ // empty, and prompt-time injection is NOT suppressed (the old query-mirroring
1487
+ // exclude-set blanked the same-project leg on adopted projects). Written
1488
+ // unconditionally (even when empty) so a resumed session can't act on a
1489
+ // previous session's stale marker semantics; 24h GC below.
1490
+ try {
1491
+ writeFileSync(
1492
+ join(RUNTIME_DIR, keyContextIdsFileName(project, ccSessionId)),
1493
+ JSON.stringify({ ids: contextCollector.keyContextIds || [], ts: Date.now(), session: ccSessionId || null }),
1494
+ );
1495
+ } catch (e) { debugCatch(e, 'session-start-keyctx-marker'); }
1496
+
1481
1497
  // One-time migration: remove any stale <claude-mem-context> block left in
1482
1498
  // CLAUDE.md by pre-v2.30 installs. Idempotent no-op afterwards.
1483
1499
  cleanupClaudeMdLegacyBlock();
@@ -1651,13 +1667,21 @@ async function handleUserPrompt() {
1651
1667
  // (mirrors CC built-in memoryTypes.ts:215). Skip both Key Context lookup
1652
1668
  // and the <memory-context> emission for this turn.
1653
1669
  if (!detectMemOverride(promptText)) try {
1654
- const keyObs = db.prepare(`
1655
- SELECT id FROM observations
1656
- WHERE project = ? AND COALESCE(compressed_into, 0) = 0
1657
- AND COALESCE(importance, 1) >= 2
1658
- ORDER BY created_at_epoch DESC LIMIT 5
1659
- `).all(project);
1660
- const keyContextIds = keyObs.map(o => o.id);
1670
+ // D#123 (review C-1): the exclude-set is the Key Context ids ACTUALLY
1671
+ // rendered at SessionStart — read from the marker handleSessionStart wrote,
1672
+ // not re-derived from a query. The old query-mirroring set excluded rows
1673
+ // that were never shown (quiet/adopted projects render no Key Context at
1674
+ // all), blanking the same-project <memory-context> leg outright. Missing
1675
+ // or other-session marker → empty set: unknown injections must fail open
1676
+ // (inject, maybe duplicate) rather than fail closed (suppress).
1677
+ const keyContextIds = [];
1678
+ try {
1679
+ const raw = readFileSync(join(RUNTIME_DIR, keyContextIdsFileName(project, ccSessionId)), 'utf8');
1680
+ const { ids, session } = JSON.parse(raw);
1681
+ if (Array.isArray(ids) && !(session && ccSessionId && session !== ccSessionId)) {
1682
+ keyContextIds.push(...ids);
1683
+ }
1684
+ } catch { /* no marker — nothing was injected, exclude nothing */ }
1661
1685
  const pathAInjectedIds = [];
1662
1686
 
1663
1687
  // Read IDs already injected by user-prompt-search.js to avoid duplicate injection
@@ -1686,8 +1710,9 @@ async function handleUserPrompt() {
1686
1710
  const taskImperativeOn = process.env.CLAUDE_MEM_TASK_IMPERATIVE === 'on'
1687
1711
  || process.env.CLAUDE_MEM_TASK_IMPERATIVE === '1';
1688
1712
  // Exclude only ids path-A (user-prompt-search.js) already injected — NOT the
1689
- // key-context top-5, which overlaps the high-value lesson pool and would suppress
1690
- // the pick. The chosen id is excluded from the <memory-context> block below instead.
1713
+ // SessionStart Key Context set, which overlaps the high-value lesson pool and
1714
+ // would suppress the pick. The chosen id is excluded from the <memory-context>
1715
+ // block below instead.
1691
1716
  const imperativePick = taskImperativeOn
1692
1717
  ? selectImperativeLesson(db, promptText, project, pathAInjectedIds)
1693
1718
  : null;
@@ -1,10 +1,13 @@
1
- // lib/inject-search-core.mjs — the injection-side shared core (P2-11, audit
2
- // 2026-08-14). Shared home for the three SQL atoms that kept drifting across
3
- // hand-copied twins on the INJECTION-side retrieval surfaces (the five consumer
4
- // files the ledger test enforces — NOT yet the whole read surface: recall-core /
5
- // recent-core / timeline-core / hook-context / hook-handoff / hook-optimize /
6
- // deep-search / stats and the sessions/events decay arms in lib/search-core
7
- // still inline their own copies; extending them is the deferred second cut):
1
+ // lib/inject-search-core.mjs — the retrieval-side shared core (P2-11, audit
2
+ // 2026-08-14; second cut D#123, 2026-08-16). Shared home for the three SQL
3
+ // atoms that kept drifting across hand-copied twins first on the five
4
+ // injection-side surfaces, then extended across the remaining read surfaces
5
+ // (hook-context / hook-handoff / hook-optimize / mem-cli / search-scoring /
6
+ // tfidf / deep-search / recall-core / recent-core / timeline-core / stats-core /
7
+ // search-core incl. its sessions+events decay arms / maintain-core; the ledger
8
+ // test enforces the full list). Deliberate compressed-only singles (maintain
9
+ // UPDATE guards, stats noise-gauge counts, export tombstone toggles, session-own
10
+ // history) stay inline — see the ledger test's non-member notes:
8
11
  //
9
12
  // * live-row filter — the compressed+superseded pair whose omission was
10
13
  // the superseded-invariant's recurring reopening
@@ -17,12 +20,12 @@
17
20
  // behavior factors (audit M-3: wired on every auto
18
21
  // surface, missing from the explicit-surface score)
19
22
  //
20
- // Consumers: scripts/user-prompt-search.js, scripts/pre-tool-recall.js,
21
- // hook-memory.mjs, hook.mjs (error-recall), search-engine.mjs. Each surface keeps
22
- // its own deliberate pipeline composition (BM25-sort + JS scoring vs SQL full
23
- // chain vs file-keyed sort see #8786: per-surface asymmetries stay explicit);
24
- // only the ATOMS are shared. tests/inject-search-core.test.mjs holds the ledger:
25
- // the five consumer files must compose these builders, never re-inline copies.
23
+ // Each surface keeps its own deliberate pipeline composition (BM25-sort + JS
24
+ // scoring vs SQL full chain vs file-keyed sort — see #8786: per-surface
25
+ // asymmetries stay explicit); only the ATOMS are shared.
26
+ // tests/inject-search-core.test.mjs holds the ledger: consumer files must
27
+ // compose these builders, never re-inline copies (and the decay shape may not
28
+ // be hand-rolled anywhere, benchmark included).
26
29
  //
27
30
  // Lives under lib/ (not scripts/) so hook.mjs can statically import it without
28
31
  // colliding with the installExtractedRelease scripts-dir rename (same constraint
@@ -27,3 +27,23 @@ export function injectedIdsFileName(project, sessionId) {
27
27
  const safe = String(sessionId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
28
28
  return `${base}-${safe}`;
29
29
  }
30
+
31
+ /**
32
+ * Runtime-dir FILE NAME for the SessionStart Key Context marker: the obs ids
33
+ * ACTUALLY rendered into the <claude-mem-context> File Lessons / Key Context
34
+ * sections (empty under quiet/adopted). handleUserPrompt reads it as its
35
+ * exclude-set — D#123 review C-1: excluding the injector's QUERY result instead
36
+ * of what was really shown suppressed <memory-context> injection outright on
37
+ * quiet/adopted projects, where Key Context never renders at all.
38
+ * Session-lifetime validity (no time window): the SessionStart block stays in
39
+ * context for the whole session. Swept with the same 24h GC as the marker above.
40
+ * @param {string} project - inferProject() value (already filename-safe)
41
+ * @param {string} [sessionId] - CC session id
42
+ * @returns {string}
43
+ */
44
+ export function keyContextIdsFileName(project, sessionId) {
45
+ const base = `.claude-mem-keyctx-${project}`;
46
+ if (!sessionId) return base;
47
+ const safe = String(sessionId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
48
+ return `${base}-${safe}`;
49
+ }
@@ -15,6 +15,7 @@
15
15
  import { COMPRESSED_PENDING_PURGE, computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity } from '../utils.mjs';
16
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
+ import { liveObsFilterSql } from './inject-search-core.mjs';
18
19
 
19
20
  export const STALE_AGE_MS = 30 * 86400000;
20
21
  export const OP_CAP = 1000;
@@ -132,9 +133,8 @@ export function recoverOrphanedChildren(db, { projectFilter = '', baseParams = [
132
133
  export function recoverBuriedLessons(db, { projectFilter = '', baseParams = [] } = {}) {
133
134
  return db.prepare(`
134
135
  UPDATE observations SET importance = 1
135
- WHERE COALESCE(compressed_into, 0) = 0
136
+ WHERE ${liveObsFilterSql('')}
136
137
  AND COALESCE(importance, 1) = 0
137
- AND superseded_at IS NULL
138
138
  AND lesson_learned IS NOT NULL AND lesson_learned <> '' AND lower(lesson_learned) <> 'none'
139
139
  ${projectFilter}
140
140
  `).run(...baseParams).changes;
@@ -453,7 +453,7 @@ export function rebuildVectors(db) {
453
453
  if (!vocab) return { ok: false, reason: 'no observations to build vocabulary from' };
454
454
  const allObs = db.prepare(`
455
455
  SELECT id, title, narrative, concepts, lesson_learned, search_aliases FROM observations
456
- WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
456
+ WHERE ${liveObsFilterSql('')}
457
457
  `).all();
458
458
  let updated = 0;
459
459
  const insertStmt = db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)');
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { basename } from 'path';
9
9
  import { notLowSignalTitleClause } from '../utils.mjs';
10
+ import { liveObsFilterSql } from './inject-search-core.mjs';
10
11
 
11
12
  /**
12
13
  * Recall observations linked to a file (basename or full path). Returns
@@ -31,8 +32,7 @@ export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}
31
32
  o.created_at, o.created_at_epoch, o.project
32
33
  FROM observations o
33
34
  JOIN observation_files of2 ON of2.obs_id = o.id
34
- WHERE COALESCE(o.compressed_into, 0) = 0
35
- AND o.superseded_at IS NULL
35
+ WHERE ${liveObsFilterSql('o')}
36
36
  AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
37
37
  ${noiseClause}
38
38
  ORDER BY o.created_at_epoch DESC
@@ -14,6 +14,8 @@
14
14
  // MCP wants `project`) — same convention as recall-core, so neither surface needs
15
15
  // its own SELECT list.
16
16
 
17
+ import { liveObsFilterSql } from './inject-search-core.mjs';
18
+
17
19
  const RECENT_COLS = 'id, type, title, subtitle, importance, project, created_at, created_at_epoch';
18
20
 
19
21
  // Upper bound on rows a single `recent` call may pull, shared so the cap can't
@@ -38,7 +40,7 @@ export const RECENT_MAX = 1000;
38
40
  */
39
41
  export function fetchRecent(db, { project = null, type = null, since = null, limit = 10 } = {}) {
40
42
  const params = [];
41
- const wheres = ['COALESCE(compressed_into, 0) = 0', 'superseded_at IS NULL'];
43
+ const wheres = [liveObsFilterSql('')];
42
44
  if (project) { wheres.push('project = ?'); params.push(project); }
43
45
  if (type) { wheres.push('type = ?'); params.push(type); }
44
46
  if (Number.isFinite(since)) { wheres.push('created_at_epoch >= ?'); params.push(since); }
@@ -29,6 +29,7 @@ import { cjkPrecisionOk, extractCjkLikePatterns } from '../nlp.mjs';
29
29
  import { computeTier } from '../tier.mjs';
30
30
  import { countSearchTotal, attachBodyTokens } from '../search-engine.mjs';
31
31
  import { notLowSignalTitleClause } from '../scoring-sql.mjs';
32
+ import { liveObsFilterSql, recencyDecaySql } from './inject-search-core.mjs';
32
33
 
33
34
  /** Sanitize a user query to FTS5 syntax; optionally force OR semantics. */
34
35
  export function buildSearchFtsQuery(query, { or = false } = {}) {
@@ -146,7 +147,7 @@ export function searchSessionsFts(db, { ftsQuery, project = null, projectBoost =
146
147
  return db.prepare(`
147
148
  SELECT s.id, s.request, s.completed, s.project, s.created_at, s.created_at_epoch,
148
149
  ${SESS_BM25}
149
- * (1.0 + EXP(-0.693 * MAX(0, ? - s.created_at_epoch) / ${DEFAULT_DECAY_HALF_LIFE_MS}.0))
150
+ * ${recencyDecaySql({ tsExpr: 's.created_at_epoch', halfLifeSql: `${DEFAULT_DECAY_HALF_LIFE_MS}.0` })}
150
151
  * (CASE WHEN ? IS NOT NULL AND s.project = ? THEN 2.0 ELSE 1.0 END) as score
151
152
  FROM session_summaries_fts
152
153
  JOIN session_summaries s ON session_summaries_fts.rowid = s.id
@@ -235,7 +236,7 @@ export function searchEventsFts(db, { ftsQuery, project = null, projectBoost = n
235
236
  return db.prepare(`
236
237
  SELECT e.id, e.event_type, e.title, e.body, e.project, e.importance, e.file_paths, e.created_at_epoch,
237
238
  ${EVT_BM25}
238
- * (1.0 + EXP(-0.693 * MAX(0, ? - e.created_at_epoch) / ${DEFAULT_DECAY_HALF_LIFE_MS}.0))
239
+ * ${recencyDecaySql({ tsExpr: 'e.created_at_epoch', halfLifeSql: `${DEFAULT_DECAY_HALF_LIFE_MS}.0` })}
239
240
  * (CASE WHEN ? IS NOT NULL AND e.project = ? THEN 2.0 ELSE 1.0 END) as score
240
241
  FROM events_fts
241
242
  JOIN events e ON events_fts.rowid = e.id
@@ -607,7 +608,7 @@ export async function coreRunSearchPipeline(ctx, opts) {
607
608
 
608
609
  // ── Type-list fallback (MCP): obs_type set + 0 matches → list recent of that type ──
609
610
  if (obsTypeFallback && results.length === 0 && obsType) {
610
- const typeWheres = ['COALESCE(compressed_into, 0) = 0', 'superseded_at IS NULL', 'type = ?'];
611
+ const typeWheres = [liveObsFilterSql(''), 'type = ?'];
611
612
  // Mirror the FTS path's low-signal filter (buildObsFtsQuery): this fallback is still a
612
613
  // SEARCH surface, so degraded titles ("Modified X", "Error: …") must not lead it —
613
614
  // acute for obs_type='change', the noise band. (The no-query recent-listing in
@@ -10,6 +10,7 @@ import { inferProject } from '../utils.mjs';
10
10
  import { buildNotLowSignalSql } from './low-signal-patterns.mjs';
11
11
  import { TIER_CASE_SQL, tierSqlParams } from '../tier.mjs';
12
12
  import { computeNoiseGauge } from './stats-quality.mjs';
13
+ import { liveObsFilterSql } from './inject-search-core.mjs';
13
14
 
14
15
  /**
15
16
  * Compute the primary stats feed. Row shapes are returned exactly as the twin
@@ -98,7 +99,7 @@ export function computeStatsFeed(db, { project = null, days = 30, now = Date.now
98
99
  const tierDist = db.prepare(`
99
100
  SELECT tier, COUNT(*) as c FROM (
100
101
  SELECT ${TIER_CASE_SQL} as tier FROM observations
101
- WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
102
+ WHERE ${liveObsFilterSql('')} ${projectFilter}
102
103
  ) GROUP BY tier ORDER BY tier
103
104
  `).all(...tdParams, ...baseParams);
104
105
  const tierMap = Object.fromEntries(tierDist.map((r) => [r.tier, r.c]));
@@ -14,6 +14,7 @@
14
14
  import { parseIdToken } from './id-routing.mjs';
15
15
  import { findFtsAnchor } from '../search-engine.mjs';
16
16
  import { sanitizeFtsQuery } from '../utils.mjs';
17
+ import { liveObsFilterSql } from './inject-search-core.mjs';
17
18
 
18
19
  const TIMELINE_COLS = 'id, type, title, subtitle, project, created_at, created_at_epoch';
19
20
 
@@ -21,7 +22,7 @@ const TIMELINE_COLS = 'id, type, title, subtitle, project, created_at, created_a
21
22
  function nearestObservation(db, epoch, project) {
22
23
  return db.prepare(`
23
24
  SELECT id FROM observations
24
- WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${project ? 'AND project = ?' : ''}
25
+ WHERE ${liveObsFilterSql('')} ${project ? 'AND project = ?' : ''}
25
26
  ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
26
27
  `).get(...(project ? [project, epoch] : [epoch]));
27
28
  }
@@ -162,9 +163,9 @@ export function resolveQueryAnchor(db, queryStr, { project = null } = {}) {
162
163
 
163
164
  /** No-anchor fallback: most recent live (non-compressed, non-superseded) obs, newest first. */
164
165
  export function fetchRecentTimeline(db, { project = null, limit }) {
165
- // superseded_at IS NULL mirrors the before/after window legs (fetchTimelineWindow) and
166
- // every other read path a superseded row must not lead the "most recent" timeline.
167
- const liveFilter = 'COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL';
166
+ // A superseded row must not lead the "most recent" timeline same live-row
167
+ // invariant as the before/after window legs (fetchTimelineWindow).
168
+ const liveFilter = liveObsFilterSql('');
168
169
  const where = project ? `WHERE ${liveFilter} AND project = ?` : `WHERE ${liveFilter}`;
169
170
  const params = project ? [project, limit] : [limit];
170
171
  return db.prepare(`
@@ -200,7 +201,7 @@ export function fetchTimelineWindow(db, anchorId, { before, after, project = nul
200
201
  const beforeRows = db.prepare(`
201
202
  SELECT ${TIMELINE_COLS}
202
203
  FROM observations
203
- WHERE created_at_epoch < ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
204
+ WHERE created_at_epoch < ? AND ${liveObsFilterSql('')} ${projectFilter}
204
205
  ORDER BY created_at_epoch DESC
205
206
  LIMIT ?
206
207
  `).all(anchorRow.created_at_epoch, ...baseParams, before).reverse();
@@ -208,7 +209,7 @@ export function fetchTimelineWindow(db, anchorId, { before, after, project = nul
208
209
  const afterRows = db.prepare(`
209
210
  SELECT ${TIMELINE_COLS}
210
211
  FROM observations
211
- WHERE created_at_epoch > ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
212
+ WHERE created_at_epoch > ? AND ${liveObsFilterSql('')} ${projectFilter}
212
213
  ORDER BY created_at_epoch ASC
213
214
  LIMIT ?
214
215
  `).all(anchorRow.created_at_epoch, ...baseParams, after);
package/mem-cli.mjs CHANGED
@@ -32,6 +32,7 @@ import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
32
32
  import { buildSessionContextLines } from './hook-context.mjs';
33
33
  import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
34
34
  import { parseIntFlag, isNumericToken } from './lib/cli-flags.mjs';
35
+ import { liveObsFilterSql } from './lib/inject-search-core.mjs';
35
36
  import { auditMemdir, memdirPath } from './memdir.mjs';
36
37
  import { aggregateProjectCiteRecall } from './lib/citation-tracker.mjs';
37
38
  import { probeOtherSources as probeIdSources, bucketIdTokens, splitDeferredTokens } from './lib/id-routing.mjs';
@@ -1604,11 +1605,14 @@ function cmdExport(db, args) {
1604
1605
  if (rejectBareStringFlags(flags, ['project', 'type', 'from', 'to'])) return;
1605
1606
  const wheres = [];
1606
1607
  const params = [];
1607
- // --include-compressed: include compressed observations (aligned with MCP mem_export)
1608
- if (!(flags['include-compressed'] === true || flags['include-compressed'] === 'true')) {
1609
- wheres.push('COALESCE(compressed_into, 0) = 0');
1608
+ // --include-compressed: include compressed observations (aligned with MCP mem_export).
1609
+ // Superseded rows are excluded either way; the flag only toggles the compressed half
1610
+ // of the live-row pair (backup/export of tombstones is opt-in, retractions are not).
1611
+ if (flags['include-compressed'] === true || flags['include-compressed'] === 'true') {
1612
+ wheres.push('superseded_at IS NULL');
1613
+ } else {
1614
+ wheres.push(liveObsFilterSql(''));
1610
1615
  }
1611
- wheres.push('superseded_at IS NULL');
1612
1616
 
1613
1617
  const project = flags.project ? resolveProject(db, flags.project) : null;
1614
1618
  if (project) { wheres.push('project = ?'); params.push(project); }
@@ -2468,8 +2472,7 @@ function cmdCitationStats(db, args) {
2468
2472
  SUM(CASE WHEN uncited_streak >= 2 THEN 1 ELSE 0 END) AS at_risk
2469
2473
  FROM observations
2470
2474
  WHERE created_at_epoch >= ?
2471
- AND COALESCE(compressed_into, 0) = 0
2472
- AND superseded_at IS NULL
2475
+ AND ${liveObsFilterSql('')}
2473
2476
  GROUP BY project
2474
2477
  ORDER BY resolved DESC
2475
2478
  `).all(cutoff);
@@ -2478,8 +2481,7 @@ function cmdCitationStats(db, args) {
2478
2481
  SELECT id, project, type, title, importance, uncited_streak, cited_count
2479
2482
  FROM observations
2480
2483
  WHERE uncited_streak >= 2
2481
- AND COALESCE(compressed_into, 0) = 0
2482
- AND superseded_at IS NULL
2484
+ AND ${liveObsFilterSql('')}
2483
2485
  ORDER BY uncited_streak DESC, importance ASC
2484
2486
  LIMIT 20
2485
2487
  `).all();
@@ -2488,8 +2490,7 @@ function cmdCitationStats(db, args) {
2488
2490
  SELECT id, project, type, title, importance, cited_count
2489
2491
  FROM observations
2490
2492
  WHERE importance >= 3 AND cited_count >= 1
2491
- AND COALESCE(compressed_into, 0) = 0
2492
- AND superseded_at IS NULL
2493
+ AND ${liveObsFilterSql('')}
2493
2494
  ORDER BY cited_count DESC
2494
2495
  LIMIT 10
2495
2496
  `).all();
@@ -2499,8 +2500,7 @@ function cmdCitationStats(db, args) {
2499
2500
  FROM observations
2500
2501
  WHERE demoted_at IS NOT NULL
2501
2502
  AND demoted_at >= ?
2502
- AND COALESCE(compressed_into, 0) = 0
2503
- AND superseded_at IS NULL
2503
+ AND ${liveObsFilterSql('')}
2504
2504
  ORDER BY demoted_at DESC
2505
2505
  LIMIT 10
2506
2506
  `).all(cutoff);
@@ -2515,8 +2515,7 @@ function cmdCitationStats(db, args) {
2515
2515
  const pollutedRows = db.prepare(`
2516
2516
  SELECT COUNT(*) AS n FROM observations
2517
2517
  WHERE cited_count > decay_seen_count
2518
- AND COALESCE(compressed_into, 0) = 0
2519
- AND superseded_at IS NULL
2518
+ AND ${liveObsFilterSql('')}
2520
2519
  `).get();
2521
2520
  const dataPollutionNote = pollutedRows.n > 0
2522
2521
  ? `${pollutedRows.n} obs have cited_count > decay_seen_count (pre-v34 backfill — invariant holds for new data).`
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.64.0",
3
+ "version": "3.65.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.64.0",
9
+ "version": "3.65.0",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.26.0",
12
12
  "better-sqlite3": "^12.6.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.64.0",
3
+ "version": "3.65.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",
@@ -9,6 +9,7 @@ import { debugCatch, COMPRESSED_AUTO, COMPRESSED_PENDING_PURGE, OBS_BM25 } from
9
9
  import { BASE_STOP_WORDS } from './stop-words.mjs';
10
10
  import { porterStem } from './tfidf.mjs';
11
11
  import { CLI_INVOKE } from './cli-path.mjs';
12
+ import { liveObsFilterSql } from './lib/inject-search-core.mjs';
12
13
 
13
14
  // ─── MCP Server Instructions Builder ───────────────────────────────────────
14
15
  // Phase A (v2.31.3+): when quiet=true, drops WHEN-TO-USE proactive-trigger and
@@ -225,8 +226,7 @@ export function expandQueryByConcepts(db, ftsQuery, project) {
225
226
  rows = db.prepare(`
226
227
  SELECT o.concepts FROM observations_fts
227
228
  JOIN observations o ON observations_fts.rowid = o.id
228
- WHERE observations_fts MATCH ? AND COALESCE(o.compressed_into, 0) = 0
229
- AND o.superseded_at IS NULL
229
+ WHERE observations_fts MATCH ? AND ${liveObsFilterSql('o')}
230
230
  AND (? IS NULL OR o.project = ?)
231
231
  ORDER BY ${OBS_BM25}
232
232
  LIMIT 20
package/tfidf.mjs CHANGED
@@ -7,6 +7,7 @@ import { cjkBigrams } from './utils.mjs';
7
7
  import { BASE_STOP_WORDS } from './stop-words.mjs';
8
8
  import { createHash } from 'crypto';
9
9
  import { rrfAccumulate } from './lib/rrf.mjs';
10
+ import { liveObsFilterSql } from './lib/inject-search-core.mjs';
10
11
 
11
12
  export const VOCAB_DIM = 512;
12
13
  // Fraction of the vocab dimension reserved for the highest-IDF (rarest) terms when the
@@ -240,7 +241,7 @@ export function vecTextForRow(row) {
240
241
  export function buildVocabulary(db, { dim = VOCAB_DIM } = {}) {
241
242
  const rows = db.prepare(`
242
243
  SELECT title, narrative, concepts, lesson_learned, search_aliases FROM observations
243
- WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
244
+ WHERE ${liveObsFilterSql('')}
244
245
  `).all();
245
246
 
246
247
  const N = rows.length;
@@ -444,8 +445,7 @@ export function vectorSearch(db, queryVec, { project, type, vocabVersion, limit
444
445
  const now = Date.now();
445
446
 
446
447
  const wheres = [
447
- 'COALESCE(o.compressed_into, 0) = 0',
448
- 'o.superseded_at IS NULL',
448
+ liveObsFilterSql('o'),
449
449
  'ov.vocab_version = ?',
450
450
  ];
451
451
  const params = [vocabVersion];