claude-mem-lite 3.63.0 → 3.64.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.64.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.64.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/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.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 } 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,21 @@ 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
+ if (!isCooldown && !isInjectedMarker) continue;
870
874
  try {
871
875
  const p = join(RUNTIME_DIR, name);
872
876
  const st = statSync(p);
@@ -1036,10 +1040,8 @@ function runSessionStartAutoMaintain(db) {
1036
1040
  JOIN observations b ON a.title = b.title AND a.project = b.project
1037
1041
  AND a.id < b.id
1038
1042
  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
1043
+ AND ${liveObsFilterSql('a')}
1044
+ AND ${liveObsFilterSql('b')}
1043
1045
  LIMIT 20
1044
1046
  `).all();
1045
1047
  if (dupPairs.length > 0) {
@@ -1062,8 +1064,7 @@ function runSessionStartAutoMaintain(db) {
1062
1064
  const recent = db.prepare(`
1063
1065
  SELECT id, title, importance, created_at_epoch, narrative, text
1064
1066
  FROM observations
1065
- WHERE COALESCE(compressed_into, 0) = 0
1066
- AND superseded_at IS NULL
1067
+ WHERE ${liveObsFilterSql('')}
1067
1068
  AND created_at_epoch > ?
1068
1069
  AND title IS NOT NULL AND title != ''
1069
1070
  ORDER BY created_at_epoch DESC LIMIT ${SCAN_LIMIT}
@@ -1661,12 +1662,14 @@ async function handleUserPrompt() {
1661
1662
 
1662
1663
  // Read IDs already injected by user-prompt-search.js to avoid duplicate injection
1663
1664
  try {
1664
- const injectedFile = join(RUNTIME_DIR, `.claude-mem-injected-${project}`);
1665
+ // D#120: the marker file is session-keyed (no ccSessionId → legacy
1666
+ // project-keyed name), so a concurrent session's write can no longer
1667
+ // replace this session's payload between the UPS write and this read.
1668
+ const injectedFile = join(RUNTIME_DIR, injectedIdsFileName(project, ccSessionId));
1665
1669
  const raw = readFileSync(injectedFile, 'utf8');
1666
1670
  const { ids, ts, session } = JSON.parse(raw);
1667
1671
  // 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).
1672
+ // CC session (M-6 payload gate, still load-bearing for legacy files).
1670
1673
  // Legacy payloads without `session` keep the old time-window-only behavior.
1671
1674
  if (ts && Date.now() - ts < 10000 && Array.isArray(ids)
1672
1675
  && !(session && ccSessionId && session !== ccSessionId)) {
@@ -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
+ }
@@ -0,0 +1,84 @@
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):
8
+ //
9
+ // * live-row filter — the compressed+superseded pair whose omission was
10
+ // the superseded-invariant's recurring reopening
11
+ // (7th occurrence fixed in v3.63 H-1, write-side)
12
+ // * clamped recency decay — the MAX(0,…) age clamp (audit M-1: a far-future
13
+ // created_at from restore/import-jsonl EXP-overflowed
14
+ // and pinned that row #1; the fix reached
15
+ // search-engine but not the UPS/error-recall twins)
16
+ // * injection relevance — the full multiplicative chain incl. cite/noise
17
+ // behavior factors (audit M-3: wired on every auto
18
+ // surface, missing from the explicit-surface score)
19
+ //
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.
26
+ //
27
+ // Lives under lib/ (not scripts/) so hook.mjs can statically import it without
28
+ // colliding with the installExtractedRelease scripts-dir rename (same constraint
29
+ // as lib/mem-override.mjs). Dependency-light by design: pre-tool-recall's
30
+ // standalone fast-path (#8447) already imports scoring-sql.mjs.
31
+
32
+ import {
33
+ OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE,
34
+ noisePenaltyClause, citeFactorClause,
35
+ } from '../scoring-sql.mjs';
36
+
37
+ /**
38
+ * Live-row filter: rows a model-facing retrieval surface may return. Excludes
39
+ * compression tombstones (positive keeper ids AND -2 pending-purge) and
40
+ * superseded rows (a retracted lesson must never outrank its correction).
41
+ * @param {string} [alias='o'] table alias; '' for unqualified single-table queries
42
+ * @returns {string} SQL boolean expression (no leading AND)
43
+ */
44
+ export function liveObsFilterSql(alias = 'o') {
45
+ const a = alias ? `${alias}.` : '';
46
+ return `COALESCE(${a}compressed_into, 0) = 0 AND ${a}superseded_at IS NULL`;
47
+ }
48
+
49
+ /**
50
+ * Clamped recency-decay factor: (1 + EXP(-ln2 · age / halfLife)), age >= 0.
51
+ * The MAX(0,…) clamp is the M-1 fix — restore/import-jsonl accept arbitrary
52
+ * epochs, and an unclamped future timestamp makes the exponent large-positive →
53
+ * EXP overflows to +Infinity → that row sorts #1 for every query (and its score
54
+ * serializes as null). A future row reads as age 0 = max finite recency instead.
55
+ * Binds one `?` placeholder: the caller passes `now` (epoch ms) at that position.
56
+ * @param {object} opts
57
+ * @param {string} opts.tsExpr - SQL expression for the row's reference timestamp
58
+ * (e.g. 'o.created_at_epoch', or the created/last-accessed MAX search-engine uses)
59
+ * @param {string} [opts.halfLifeSql=TYPE_DECAY_CASE] - SQL expression for the
60
+ * half-life in ms (constant or per-type CASE; TYPE_DECAY_CASE assumes alias 'o')
61
+ * @returns {string} SQL numeric expression (parenthesized)
62
+ */
63
+ export function recencyDecaySql({ tsExpr, halfLifeSql = TYPE_DECAY_CASE }) {
64
+ return `(1.0 + EXP(-0.693 * MAX(0, ? - ${tsExpr}) / ${halfLifeSql}))`;
65
+ }
66
+
67
+ /**
68
+ * Injection relevance: the full multiplicative chain the prompt-time injection
69
+ * surface (UPS searchByFts) ranks by — BM25 × clamped type-decay × type-quality
70
+ * × importance × noise penalty × cite factor. Alias is fixed to 'o' because
71
+ * TYPE_DECAY_CASE / TYPE_QUALITY_CASE bake that alias in.
72
+ * Binds one `?` placeholder (the decay `now`), first in parameter order.
73
+ * @param {string} [alias='o'] must be 'o'
74
+ * @returns {string} SQL numeric expression
75
+ */
76
+ export function injectionRelevanceSql(alias = 'o') {
77
+ if (alias !== 'o') throw new Error('injectionRelevanceSql: alias must be "o" (TYPE_*_CASE bake it in)');
78
+ return `${OBS_BM25}
79
+ * ${recencyDecaySql({ tsExpr: 'o.created_at_epoch' })}
80
+ * ${TYPE_QUALITY_CASE}
81
+ * (0.5 + 0.5 * COALESCE(o.importance, 1))
82
+ * ${noisePenaltyClause('o')}
83
+ * ${citeFactorClause('o')}`;
84
+ }
@@ -0,0 +1,29 @@
1
+ // lib/injected-ids.mjs — file-name derivation for the cross-hook injected-ids
2
+ // dedup marker. Single source of truth for user-prompt-search.js (writer),
3
+ // pre-tool-recall.js (read/merge), and hook.mjs (path-A reader): all three must
4
+ // derive the same name or cross-hook dedup silently goes blind.
5
+ //
6
+ // D#120: M-6 session-keyed the marker's PAYLOAD but kept ONE file per project,
7
+ // so two concurrent CC windows full-replaced each other's marker — no dedup
8
+ // between them and `count` reset on every alternation (MAX_SESSION_INJECTIONS
9
+ // unreachable). One file per SESSION instead, mirroring
10
+ // pre-recall-cooldown-<session>.json in the same runtime dir. GC: session-start
11
+ // sweep in hook.mjs (24h mtime, same policy as the cooldown files).
12
+ //
13
+ // Lives under lib/ (not scripts/) so hook.mjs can statically import it without
14
+ // colliding with the scripts/ directory rename in installExtractedRelease —
15
+ // same constraint as lib/mem-override.mjs.
16
+
17
+ /**
18
+ * Runtime-dir FILE NAME for the injected-ids marker (no directory component).
19
+ * No sessionId → legacy project-keyed name (env-less harnesses, old callers).
20
+ * @param {string} project - inferProject() value (already filename-safe)
21
+ * @param {string} [sessionId] - CC session id
22
+ * @returns {string}
23
+ */
24
+ export function injectedIdsFileName(project, sessionId) {
25
+ const base = `.claude-mem-injected-${project}`;
26
+ if (!sessionId) return base;
27
+ const safe = String(sessionId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
28
+ return `${base}-${safe}`;
29
+ }
@@ -9,7 +9,7 @@
9
9
  // + vector writes in one db.transaction so a failure can't leave a partial row).
10
10
 
11
11
  import { getVocabulary, computeVector, vectorsEnabled } from '../tfidf.mjs';
12
- import { debugCatch, cjkBigrams } from '../utils.mjs';
12
+ import { debugCatch, cjkBigrams, scrubSecrets } from '../utils.mjs';
13
13
 
14
14
  // Canonical column order — must mirror the observations schema (schema.mjs).
15
15
  const OBS_COLUMNS = [
@@ -84,7 +84,9 @@ export function insertObservationVector(db, obsId, vecText) {
84
84
  * block — the same drift class #8614/#8639 closed for compress/maintain. Caller
85
85
  * owns the transaction (vector write is internally non-critical).
86
86
  */
87
- export function rebuildObservationDerived(db, obsId) {
87
+ // P2-12: internal-only since applyObsUpdate became the single update choke point
88
+ // (both faces previously imported this directly; un-exported per knip discipline).
89
+ function rebuildObservationDerived(db, obsId) {
88
90
  const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases FROM observations WHERE id = ?').get(obsId);
89
91
  if (!row) return;
90
92
  const base = [row.title, row.subtitle, row.narrative, row.concepts, row.facts, row.lesson_learned, row.search_aliases].filter(Boolean).join(' ');
@@ -93,3 +95,37 @@ export function rebuildObservationDerived(db, obsId) {
93
95
  db.prepare('UPDATE observations SET text = ? WHERE id = ?').run(textField, obsId);
94
96
  insertObservationVector(db, obsId, textField);
95
97
  }
98
+
99
+ // P2-12 (audit 2026-08-14): shared update mutation for the CLI `update` / MCP
100
+ // `mem_update` twin. Both faces previously built the SET list + ran the
101
+ // transaction + rebuild inline (byte-equivalent copies); each keeps its own
102
+ // validation front-end (CLI flag guards / MCP zod) and passes only the fields
103
+ // it accepted. String values are secret-scrubbed here — the single choke point,
104
+ // so a new face can't forget it (concepts had already slipped through once).
105
+ const UPDATABLE_OBS_COLS = ['title', 'narrative', 'type', 'importance', 'lesson_learned', 'concepts'];
106
+
107
+ /**
108
+ * Apply a validated field patch to one observation: UPDATE + derived-column
109
+ * rebuild (FTS text + vector) in one transaction.
110
+ * @param {import('better-sqlite3').Database} db
111
+ * @param {number} id - observation id (caller has verified existence)
112
+ * @param {object} fields - subset of {title, narrative, type, importance, lesson_learned, concepts}
113
+ * @returns {string[]} column names actually updated ([] = nothing to do, no write)
114
+ */
115
+ export function applyObsUpdate(db, id, fields) {
116
+ const updates = [];
117
+ const params = [];
118
+ for (const col of UPDATABLE_OBS_COLS) {
119
+ if (fields[col] !== undefined) {
120
+ updates.push(`${col} = ?`);
121
+ params.push(typeof fields[col] === 'string' ? scrubSecrets(fields[col]) : fields[col]);
122
+ }
123
+ }
124
+ if (updates.length === 0) return [];
125
+ params.push(id);
126
+ db.transaction(() => {
127
+ db.prepare(`UPDATE observations SET ${updates.join(', ')} WHERE id = ?`).run(...params);
128
+ rebuildObservationDerived(db, id);
129
+ })();
130
+ return updates.map((u) => u.split(' =')[0]);
131
+ }
@@ -258,7 +258,16 @@ export function searchEventsFts(db, { ftsQuery, project = null, projectBoost = n
258
258
  // obs/event/session BM25 live on comparable scales (weighted bm25 × decay ×
259
259
  // project-boost), so the ratio is meaningful there; small-scale sources (prompts,
260
260
  // bm25 ≈ -1) can only be penalized by this comparison, never inflated — the safe
261
- // direction. Bands (ratio = |lone| / globalMaxAbs, first match wins):
261
+ // direction.
262
+ // D#121 (2026-08-16): obs FULL_SCORE additionally carries citeFactor (0.4–3.0) ×
263
+ // noisePenalty (0.2–1.0) — behavior state events/sessions never have. A heavily-
264
+ // cited obs can therefore widen globalMaxAbs and demote a lone event's band: this
265
+ // is ACCEPTED direction (cite is genuine relevance evidence), and it is BOUNDED —
266
+ // citeFactor caps at 3.0, so a lone hit that was the raw max keeps ratio >= 1/3
267
+ // and never sinks below the -0.5 neutral mid; noise only shrinks obs (can only
268
+ // IMPROVE the lone hit's band). Real-SQL pins: benchmark/events-pipeline-probes.mjs
269
+ // (cite-widening-bounded-3x / decisive-lone-event-survives-max-cite / noise-shrink).
270
+ // Bands (ratio = |lone| / globalMaxAbs, first match wins):
262
271
  // ratio ≥ 1 → -1.05 the lone hit IS the strongest raw match anywhere: rank it
263
272
  // strictly ahead of every normalized -1 (ties in raw
264
273
  // strength resolve toward the lone hit — the [-1, 0] band