claude-mem-lite 3.63.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.63.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.63.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-memory.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords, neutralizeContextDelimiters, basenameAnySep } from './utils.mjs';
5
5
  import { citeFactorJs } from './scoring-sql.mjs';
6
+ import { liveObsFilterSql } from './lib/inject-search-core.mjs';
6
7
  import { recordMetric } from './lib/metrics.mjs';
7
8
  import { DB_DIR } from './schema.mjs';
8
9
  import { extractIdents } from './lib/lesson-idents.mjs';
@@ -218,8 +219,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
218
219
  AND o.project = ?
219
220
  AND o.importance >= 1
220
221
  AND o.created_at_epoch > ?
221
- AND COALESCE(o.compressed_into, 0) = 0
222
- AND o.superseded_at IS NULL
222
+ AND ${liveObsFilterSql('o')}
223
223
  AND ${notLowSignalTitleClause('o')}
224
224
  ORDER BY ${OBS_BM25}
225
225
  LIMIT 10
@@ -266,8 +266,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
266
266
  AND o.type IN ('decision', 'discovery')
267
267
  AND o.importance >= 2
268
268
  AND o.created_at_epoch > ?
269
- AND COALESCE(o.compressed_into, 0) = 0
270
- AND o.superseded_at IS NULL
269
+ AND ${liveObsFilterSql('o')}
271
270
  AND ${notLowSignalTitleClause('o')}
272
271
  ORDER BY ${OBS_BM25}
273
272
  LIMIT 5
@@ -316,7 +315,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
316
315
  // Adaptive threshold: scales with corpus size to filter noise.
317
316
  // Each result must individually exceed the threshold (not just the top one).
318
317
  const obsCount = db.prepare(
319
- 'SELECT COUNT(*) as c FROM observations WHERE project = ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL',
318
+ `SELECT COUNT(*) as c FROM observations WHERE project = ? AND ${liveObsFilterSql('')}`,
320
319
  ).get(project)?.c || 0;
321
320
  const { TINY, SMALL, MEDIUM, LARGE } = BM25_THRESHOLD;
322
321
  const threshold = obsCount < 5 ? TINY : obsCount < 100 ? SMALL : obsCount < 500 ? MEDIUM : LARGE;
@@ -389,8 +388,7 @@ export function recallForFile(db, filePath, project) {
389
388
  JOIN observation_files of2 ON of2.obs_id = o.id
390
389
  WHERE o.project = ?
391
390
  AND o.importance >= 2
392
- AND COALESCE(o.compressed_into, 0) = 0
393
- AND o.superseded_at IS NULL
391
+ AND ${liveObsFilterSql('o')}
394
392
  AND o.created_at_epoch > ?
395
393
  AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
396
394
  ORDER BY o.created_at_epoch DESC
@@ -431,8 +429,7 @@ export function rankImperativeCandidates(db, userPrompt, project, excludeIds = [
431
429
  SELECT id, title, lesson_learned, importance
432
430
  FROM observations
433
431
  WHERE project = ?
434
- AND COALESCE(compressed_into, 0) = 0
435
- AND superseded_at IS NULL
432
+ AND ${liveObsFilterSql('')}
436
433
  AND COALESCE(importance, 1) >= 2
437
434
  AND lesson_learned IS NOT NULL
438
435
  AND TRIM(lesson_learned) != ''
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,6 +68,8 @@ 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, keyContextIdsFileName } from './lib/injected-ids.mjs';
72
+ import { liveObsFilterSql, recencyDecaySql } from './lib/inject-search-core.mjs';
71
73
  import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
72
74
  import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
73
75
  import { handleLLMOptimize } from './hook-optimize.mjs';
@@ -491,15 +493,12 @@ function triggerErrorRecall(db, toolInput, response) {
491
493
  -- only for symmetry: the block's own footer is a mem_get(ids=...) pointer, and a
492
494
  -- COMPRESSED_PENDING_PURGE row is queued for deletion by maintain purge_stale,
493
495
  -- so that pointer would resolve to nothing.
494
- AND COALESCE(o.compressed_into, 0) = 0
495
- AND o.superseded_at IS NULL
496
+ AND ${liveObsFilterSql('o')}
496
497
  AND ${notLowSignalTitleClause('o')}
497
- -- MAX(0, …) clamps recency age to >= 0 (parity with search-engine FULL_SCORE):
498
- -- a far-future created_at (reachable via restore/import-jsonl, which accept
499
- -- arbitrary epochs) made the exponent large-positive → EXP overflow → that row
500
- -- pinned #1 for every error until its "future" passed (audit 2026-08-14 M-1).
498
+ -- Decay via the shared core (P2-11): the M-1 MAX(0,…) age clamp lives there.
499
+ -- Fixed 14d half-life (error recency matters more than obs type here).
501
500
  ORDER BY ${OBS_BM25}
502
- * (1.0 + EXP(-0.693 * MAX(0, ? - o.created_at_epoch) / 1209600000.0))
501
+ * ${recencyDecaySql({ tsExpr: 'o.created_at_epoch', halfLifeSql: '1209600000.0' })}
503
502
  LIMIT 3
504
503
  `).all(ftsQuery, project, nowR);
505
504
 
@@ -857,16 +856,22 @@ function buildCiteRecallNudge(project) {
857
856
  return libBuildCiteRecallNudge(project, RUNTIME_DIR);
858
857
  }
859
858
 
860
- // GC pre-recall cooldown files older than 24h. Pulled out of pre-tool-recall.js
861
- // (where it ran on every Edit, costing 15-30 disk stats per call on long-lived
862
- // projects) and consolidated here once per SessionStart is enough to keep
863
- // RUNTIME_DIR from growing unbounded across stale sessions.
859
+ // GC stale per-session runtime files older than 24h: pre-recall cooldowns AND
860
+ // (D#120) the per-session injected-ids markers both grow one file per session.
861
+ // Pulled out of pre-tool-recall.js (where it ran on every Edit, costing 15-30
862
+ // disk stats per call on long-lived projects) and consolidated here — once per
863
+ // SessionStart is enough to keep RUNTIME_DIR from growing unbounded.
864
864
  const PRE_RECALL_COOLDOWN_STALE_MS = 24 * 60 * 60 * 1000;
865
865
  function gcStalePreRecallCooldowns() {
866
866
  try {
867
867
  const now = Date.now();
868
868
  for (const name of readdirSync(RUNTIME_DIR)) {
869
- if (!name.startsWith('pre-recall-cooldown-') || !name.endsWith('.json')) continue;
869
+ // D#120: the injected-ids marker is also per-session now same growth
870
+ // shape as the cooldown files, same 24h GC (dedup window is 5 min).
871
+ const isCooldown = name.startsWith('pre-recall-cooldown-') && name.endsWith('.json');
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
874
+ if (!isCooldown && !isInjectedMarker) continue;
870
875
  try {
871
876
  const p = join(RUNTIME_DIR, name);
872
877
  const st = statSync(p);
@@ -1036,10 +1041,8 @@ function runSessionStartAutoMaintain(db) {
1036
1041
  JOIN observations b ON a.title = b.title AND a.project = b.project
1037
1042
  AND a.id < b.id
1038
1043
  AND ABS(a.created_at_epoch - b.created_at_epoch) < 3600000
1039
- AND COALESCE(a.compressed_into, 0) = 0
1040
- AND COALESCE(b.compressed_into, 0) = 0
1041
- AND a.superseded_at IS NULL
1042
- AND b.superseded_at IS NULL
1044
+ AND ${liveObsFilterSql('a')}
1045
+ AND ${liveObsFilterSql('b')}
1043
1046
  LIMIT 20
1044
1047
  `).all();
1045
1048
  if (dupPairs.length > 0) {
@@ -1062,8 +1065,7 @@ function runSessionStartAutoMaintain(db) {
1062
1065
  const recent = db.prepare(`
1063
1066
  SELECT id, title, importance, created_at_epoch, narrative, text
1064
1067
  FROM observations
1065
- WHERE COALESCE(compressed_into, 0) = 0
1066
- AND superseded_at IS NULL
1068
+ WHERE ${liveObsFilterSql('')}
1067
1069
  AND created_at_epoch > ?
1068
1070
  AND title IS NOT NULL AND title != ''
1069
1071
  ORDER BY created_at_epoch DESC LIMIT ${SCAN_LIMIT}
@@ -1470,13 +1472,28 @@ async function handleSessionStart() {
1470
1472
  // token-budgeted observation pool directly from the DB.
1471
1473
  // Pass CC session id so the Working State block is scoped to this session,
1472
1474
  // preventing parallel sessions from seeing each other's /clear handoff.
1473
- const fullContext = buildSessionContextLines(db, project, now, ccSessionId);
1475
+ const contextCollector = {};
1476
+ const fullContext = buildSessionContextLines(db, project, now, ccSessionId, contextCollector);
1474
1477
 
1475
1478
  // Stdout is the sole context-delivery channel. The SessionStart hook output
1476
1479
  // is injected as a <system-reminder> at session start, giving Claude the
1477
1480
  // full summary + handoff state + observations table fresh from the DB.
1478
1481
  process.stdout.write(`<claude-mem-context>\n${fullContext}\n</claude-mem-context>\n`);
1479
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
+
1480
1497
  // One-time migration: remove any stale <claude-mem-context> block left in
1481
1498
  // CLAUDE.md by pre-v2.30 installs. Idempotent no-op afterwards.
1482
1499
  cleanupClaudeMdLegacyBlock();
@@ -1650,23 +1667,33 @@ async function handleUserPrompt() {
1650
1667
  // (mirrors CC built-in memoryTypes.ts:215). Skip both Key Context lookup
1651
1668
  // and the <memory-context> emission for this turn.
1652
1669
  if (!detectMemOverride(promptText)) try {
1653
- const keyObs = db.prepare(`
1654
- SELECT id FROM observations
1655
- WHERE project = ? AND COALESCE(compressed_into, 0) = 0
1656
- AND COALESCE(importance, 1) >= 2
1657
- ORDER BY created_at_epoch DESC LIMIT 5
1658
- `).all(project);
1659
- 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 */ }
1660
1685
  const pathAInjectedIds = [];
1661
1686
 
1662
1687
  // Read IDs already injected by user-prompt-search.js to avoid duplicate injection
1663
1688
  try {
1664
- const injectedFile = join(RUNTIME_DIR, `.claude-mem-injected-${project}`);
1689
+ // D#120: the marker file is session-keyed (no ccSessionId → legacy
1690
+ // project-keyed name), so a concurrent session's write can no longer
1691
+ // replace this session's payload between the UPS write and this read.
1692
+ const injectedFile = join(RUNTIME_DIR, injectedIdsFileName(project, ccSessionId));
1665
1693
  const raw = readFileSync(injectedFile, 'utf8');
1666
1694
  const { ids, ts, session } = JSON.parse(raw);
1667
1695
  // Only use if written within last 10 seconds (same prompt cycle) AND by this
1668
- // CC session the file is project-keyed, so a concurrent session's write
1669
- // would otherwise dedup-suppress OUR injection (M-6, audit 2026-08-14).
1696
+ // CC session (M-6 payload gate, still load-bearing for legacy files).
1670
1697
  // Legacy payloads without `session` keep the old time-window-only behavior.
1671
1698
  if (ts && Date.now() - ts < 10000 && Array.isArray(ids)
1672
1699
  && !(session && ccSessionId && session !== ccSessionId)) {
@@ -1683,8 +1710,9 @@ async function handleUserPrompt() {
1683
1710
  const taskImperativeOn = process.env.CLAUDE_MEM_TASK_IMPERATIVE === 'on'
1684
1711
  || process.env.CLAUDE_MEM_TASK_IMPERATIVE === '1';
1685
1712
  // Exclude only ids path-A (user-prompt-search.js) already injected — NOT the
1686
- // key-context top-5, which overlaps the high-value lesson pool and would suppress
1687
- // 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.
1688
1716
  const imperativePick = taskImperativeOn
1689
1717
  ? selectImperativeLesson(db, promptText, project, pathAInjectedIds)
1690
1718
  : null;
@@ -0,0 +1,67 @@
1
+ // lib/browse-core.mjs — shared data collection for the CLI `browse` / MCP
2
+ // `mem_browse` twin (P2-12, audit 2026-08-14). The tier count + row queries were
3
+ // duplicated and had already drifted (the CLI SELECT carried `importance`, the
4
+ // MCP one had dropped it). Collection lives here with the superset column shape;
5
+ // each face keeps its own rendering (text dashboard vs --json vs MCP text).
6
+
7
+ import { TIER_CASE_SQL, tierSqlParams } from '../tier.mjs';
8
+ import { liveObsFilterSql } from './inject-search-core.mjs';
9
+
10
+ export const BROWSE_TIERS = ['working', 'active', 'archive'];
11
+ export const BROWSE_TIER_LABELS = { working: '🔴 Working Memory', active: '🟡 Active Memory', archive: '🔵 Archive' };
12
+
13
+ /** Newest active memory_session_id for the project ('' when none) — the tier
14
+ * classifier's "current session" input, needed identically by both faces. */
15
+ export function getActiveMemorySessionId(db, project) {
16
+ const row = db.prepare(
17
+ "SELECT memory_session_id FROM sdk_sessions WHERE project = ? AND status = 'active' ORDER BY started_at_epoch DESC LIMIT 1"
18
+ ).get(project);
19
+ return row?.memory_session_id ?? '';
20
+ }
21
+
22
+ /**
23
+ * Collect per-tier counts + rows for the memory dashboard.
24
+ * Archive keeps its count but skips row fetch in the unfiltered view (both faces'
25
+ * documented behavior — the archive tail is reachable via `browse --tier archive`).
26
+ * @returns {{showTiers: string[], tierData: object, tierCounts: object, grandTotal: number}}
27
+ */
28
+ export function collectBrowseTiers(db, { project, tierFilter, limit, now, currentSessionId }) {
29
+ const ctx = { now, currentProject: project, currentSessionId };
30
+ const params = tierSqlParams(ctx);
31
+ const showTiers = tierFilter ? [tierFilter] : BROWSE_TIERS;
32
+
33
+ const tierData = {};
34
+ const tierCounts = {};
35
+ let grandTotal = 0;
36
+
37
+ for (const tier of showTiers) {
38
+ const countRow = db.prepare(`
39
+ SELECT COUNT(*) as c FROM (
40
+ SELECT ${TIER_CASE_SQL} as tier FROM observations
41
+ WHERE project = ? AND ${liveObsFilterSql('')}
42
+ ) WHERE tier = ?
43
+ `).get(...params, project, tier);
44
+ const count = countRow?.c ?? 0;
45
+ tierCounts[tier] = count;
46
+ grandTotal += count;
47
+
48
+ const skipRows = tier === 'archive' && !tierFilter;
49
+ if (count === 0 || skipRows) {
50
+ tierData[tier] = { count, rows: [] };
51
+ continue;
52
+ }
53
+
54
+ const rows = db.prepare(`
55
+ SELECT * FROM (
56
+ SELECT id, type, title, importance, created_at, created_at_epoch, ${TIER_CASE_SQL} as tier
57
+ FROM observations
58
+ WHERE project = ? AND ${liveObsFilterSql('')}
59
+ ) WHERE tier = ?
60
+ ORDER BY created_at_epoch DESC
61
+ LIMIT ?
62
+ `).all(...params, project, tier, limit);
63
+ tierData[tier] = { count, rows };
64
+ }
65
+
66
+ return { showTiers, tierData, tierCounts, grandTotal };
67
+ }
@@ -7,7 +7,7 @@
7
7
  // merged/compressed-child recovery, and the delete transaction.
8
8
  import { snapshotDb } from './db-backup.mjs';
9
9
  import { recoverChildrenOf } from './maintain-core.mjs';
10
- import { debugCatch } from '../utils.mjs';
10
+ import { debugCatch, truncate } from '../utils.mjs';
11
11
 
12
12
  /**
13
13
  * Hard-delete the given observation ids with full orchestration. The CALLER owns
@@ -70,3 +70,20 @@ export function deleteObservations(db, ids, { snapshotTag = 'pre-delete' } = {})
70
70
  const result = deleteTx();
71
71
  return { deleted: result.changes, recoveredChildren: result.recovered, snapshotPath };
72
72
  }
73
+
74
+ /**
75
+ * Shared delete-preview body (P2-12): fetch the doomed rows and format the
76
+ * per-row lines both faces print between their own header ("Preview: N …")
77
+ * and footer (--confirm vs confirm=true remedy). The SELECT and the row shape
78
+ * were duplicated in mem-cli.mjs + server.mjs and are the exact place a
79
+ * preview/execute drift would hide.
80
+ * @param {import('better-sqlite3').Database} db
81
+ * @param {number[]} ids
82
+ * @returns {{rows: Array<{id:number,type:string,title:string,project:string}>, lines: string[]}}
83
+ */
84
+ export function previewDeleteRows(db, ids) {
85
+ const ph = ids.map(() => '?').join(',');
86
+ const rows = db.prepare(`SELECT id, type, title, project FROM observations WHERE id IN (${ph})`).all(...ids);
87
+ const lines = rows.map((r) => ` #${r.id} [${r.type}] ${truncate(r.title || '(untitled)', 80)} | ${r.project}`);
88
+ return { rows, lines };
89
+ }
@@ -0,0 +1,33 @@
1
+ // lib/get-core.mjs — shared core for the CLI `get` / MCP `mem_get` twin (P2-12,
2
+ // audit 2026-08-14). The 23-element OBS_FIELDS array was duplicated verbatim in
3
+ // mem-cli.mjs and server.mjs (the 16-vs-24-column export data-loss incident's
4
+ // precursor shape), and the session detail field sets had ALREADY diverged
5
+ // (MCP 13 fields vs CLI 6 — a `remaining_items` FTS hit was a dead end in the
6
+ // CLI detail view). Field sets + the access-bump fetch live here; each face
7
+ // keeps its own header/label rendering conventions.
8
+
9
+ import { autoBoostIfNeeded } from '../search-scoring.mjs';
10
+
11
+ /** Every observation column `get --fields` accepts, in render order. */
12
+ export const OBS_FIELDS = ['id', 'type', 'title', 'subtitle', 'narrative', 'text', 'facts', 'concepts', 'lesson_learned', 'search_aliases', 'files_read', 'files_modified', 'project', 'created_at', 'memory_session_id', 'prompt_number', 'importance', 'related_ids', 'access_count', 'branch', 'superseded_at', 'superseded_by', 'last_accessed_at'];
13
+
14
+ /** Session-summary detail render set — the FULL set (both faces). The CLI's old
15
+ * 6-field subset made notes/remaining_items/files_* searchable-but-unrenderable. */
16
+ export const SESSION_DETAIL_FIELDS = ['id', 'request', 'investigated', 'learned', 'completed', 'next_steps', 'remaining_items', 'files_read', 'files_edited', 'notes', 'project', 'created_at', 'memory_session_id', 'prompt_number'];
17
+
18
+ /**
19
+ * Fetch observation detail rows: bump access_count/last_accessed_at (reading a
20
+ * detail IS an access signal — feeds noisePenalty's ratio guard), run the
21
+ * auto-boost heuristic, and return rows oldest-first.
22
+ * @param {import('better-sqlite3').Database} db
23
+ * @param {number[]} ids
24
+ * @returns {object[]} full observation rows (SELECT *), created order
25
+ */
26
+ export function fetchObsDetail(db, ids) {
27
+ const ph = ids.map(() => '?').join(',');
28
+ try {
29
+ db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`).run(Date.now(), ...ids);
30
+ autoBoostIfNeeded(db, ids);
31
+ } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
32
+ return db.prepare(`SELECT * FROM observations WHERE id IN (${ph}) ORDER BY created_at_epoch ASC`).all(...ids);
33
+ }