claude-mem-lite 2.36.0 → 2.38.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": "2.36.0",
13
+ "version": "2.38.0",
14
14
  "source": "./",
15
15
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall"
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.36.0",
3
+ "version": "2.38.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/hook-memory.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // claude-mem-lite — Semantic Memory Injection
2
2
  // Search past observations for relevant memories to inject as context at user-prompt time.
3
3
 
4
- import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, OBS_BM25, notLowSignalTitleClause } from './utils.mjs';
4
+ import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause } from './utils.mjs';
5
5
 
6
6
  const MAX_MEMORY_INJECTIONS = 3;
7
7
  const MEMORY_LOOKBACK_MS = 60 * 86400000; // 60 days
@@ -42,9 +42,14 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
42
42
  // R1: notLowSignalTitleClause() excludes hook-llm fallback titles
43
43
  // ("Modified X", "Worked on X", "Reviewed N files:", raw error logs, etc.)
44
44
  // that almost never get referenced (3.3% access rate) but compete for BM25 rank.
45
+ // v26 P0: noise_penalty is multiplied AFTER sort-BM25 so the column used
46
+ // for ORDER BY stays the penalty-adjusted `relevance` applied downstream
47
+ // in JS (scored.sort). SELECT exposes both raw BM25 (for sort) and the
48
+ // penalty factor (for the final JS score).
45
49
  const selectStmt = db.prepare(`
46
50
  SELECT o.id, o.type, o.title, o.importance, o.lesson_learned, o.project,
47
- ${OBS_BM25} as relevance
51
+ ${OBS_BM25} as relevance,
52
+ ${noisePenaltyClause('o')} as noise_penalty
48
53
  FROM observations_fts
49
54
  JOIN observations o ON o.id = observations_fts.rowid
50
55
  WHERE observations_fts MATCH ?
@@ -80,7 +85,8 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
80
85
  try {
81
86
  const crossStmt = db.prepare(`
82
87
  SELECT o.id, o.type, o.title, o.importance, o.lesson_learned, o.project,
83
- ${OBS_BM25} as relevance
88
+ ${OBS_BM25} as relevance,
89
+ ${noisePenaltyClause('o')} as noise_penalty
84
90
  FROM observations_fts
85
91
  JOIN observations o ON o.id = observations_fts.rowid
86
92
  WHERE observations_fts MATCH ?
@@ -105,12 +111,14 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
105
111
 
106
112
  // Merge and score: same-project full weight, cross-project 0.7x
107
113
  // OR-fallback results get 0.4x penalty — they matched individual words, not the full intent
114
+ // v26 P0: noise_penalty (from SQL) shrinks high-inject/low-cite rows.
108
115
  const allRows = [...rows.map(r => ({ ...r, _or: usedOrFallback })), ...crossRows.map(r => ({ ...r, _or: crossUsedOr }))];
109
116
  const scored = allRows
110
117
  .filter(r => !excludeSet.has(r.id))
111
118
  .map(r => {
112
119
  const crossProjectPenalty = r.project === project ? 1.0 : 0.7;
113
120
  const orFallbackPenalty = r._or ? 0.4 : 1.0;
121
+ const noisePenalty = typeof r.noise_penalty === 'number' ? r.noise_penalty : 1.0;
114
122
  return {
115
123
  ...r,
116
124
  score: Math.abs(r.relevance)
@@ -118,7 +126,8 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
118
126
  * (r.lesson_learned ? 1.5 : 1.0)
119
127
  * (r.importance >= 2 ? 1.0 : 0.6)
120
128
  * crossProjectPenalty
121
- * orFallbackPenalty,
129
+ * orFallbackPenalty
130
+ * noisePenalty,
122
131
  };
123
132
  })
124
133
  .sort((a, b) => b.score - a.score);
@@ -133,12 +142,19 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
133
142
  const aboveThreshold = scored.filter(r => r.score >= threshold);
134
143
  if (aboveThreshold.length === 0) return [];
135
144
 
136
- // Update access_count for injected memories
145
+ // v26 P0: bump injection_count (NOT access_count) for injected rows.
146
+ // Before v26 this was bumping access_count, which conflated auto-injection
147
+ // with real cites/recalls/opens — polluting the noise-ratio signal the
148
+ // penalty clause now depends on. access_count is reserved for explicit
149
+ // access (cmdRecall/cmdGet/cmdTimeline/pre-tool-recall/citation-tracker).
150
+ // Per-row try/catch for FTS trigger safety (project_non_obvious.md).
137
151
  const result = aboveThreshold.slice(0, MAX_MEMORY_INJECTIONS);
138
152
  const now = Date.now();
139
- const updateStmt = db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id = ?');
153
+ const bumpStmt = db.prepare(
154
+ 'UPDATE observations SET injection_count = COALESCE(injection_count, 0) + 1, last_injected_at = ? WHERE id = ?'
155
+ );
140
156
  for (const r of result) {
141
- updateStmt.run(now, r.id);
157
+ try { bumpStmt.run(now, r.id); } catch {}
142
158
  }
143
159
 
144
160
  return result;
@@ -0,0 +1,37 @@
1
+ // Shared probe for "ID-not-found-in-requested-source" hints.
2
+ // Used by CLI cmdGet (mem-cli.mjs) and MCP mem_get (server.mjs) so both
3
+ // produce consistent redirect hints — if the probe schema drifts, both
4
+ // paths update together.
5
+ //
6
+ // The formatter stays per-call-site because CLI and MCP surface format
7
+ // differently (stderr vs response text); only the SQL layer is shared.
8
+
9
+ /**
10
+ * Probe the observations / session_summaries / user_prompts tables for any
11
+ * of the given numeric IDs, excluding the sources the caller already queried.
12
+ *
13
+ * @param {import('better-sqlite3').Database} db
14
+ * @param {number[]} ids Numeric IDs to probe (non-negative ints).
15
+ * @param {Set<'obs'|'session'|'prompt'>} excludeSrcs Sources to skip.
16
+ * @returns {{obs:number[], session:number[], prompt:number[]}}
17
+ */
18
+ export function probeOtherSources(db, ids, excludeSrcs) {
19
+ const result = { obs: [], session: [], prompt: [] };
20
+ if (!ids || ids.length === 0) return result;
21
+ const placeholders = ids.map(() => '?').join(',');
22
+ try {
23
+ if (!excludeSrcs.has('obs')) {
24
+ const hits = db.prepare(`SELECT id FROM observations WHERE id IN (${placeholders})`).all(...ids);
25
+ result.obs = hits.map(r => r.id);
26
+ }
27
+ if (!excludeSrcs.has('session')) {
28
+ const hits = db.prepare(`SELECT id FROM session_summaries WHERE id IN (${placeholders})`).all(...ids);
29
+ result.session = hits.map(r => r.id);
30
+ }
31
+ if (!excludeSrcs.has('prompt')) {
32
+ const hits = db.prepare(`SELECT id FROM user_prompts WHERE id IN (${placeholders})`).all(...ids);
33
+ result.prompt = hits.map(r => r.id);
34
+ }
35
+ } catch { /* best-effort hint; never block the caller */ }
36
+ return result;
37
+ }
package/mem-cli.mjs CHANGED
@@ -15,6 +15,7 @@ import { searchResources } from './registry-retriever.mjs';
15
15
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
16
16
  import { buildSessionContextLines } from './hook-context.mjs';
17
17
  import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
18
+ import { probeOtherSources as probeIdSources } from './lib/id-routing.mjs';
18
19
  import { basename } from 'path';
19
20
  import { readFileSync } from 'fs';
20
21
 
@@ -77,6 +78,30 @@ function fmtDateShort(iso) {
77
78
  return iso.slice(0, 10); // YYYY-MM-DD
78
79
  }
79
80
 
81
+ // Parse an ID token from a command positional argument.
82
+ // Accepts: `123`, `#123`, `P#123` / `p123` (prompt), `S#123` / `s123` (session).
83
+ // Returns { source: 'obs'|'session'|'prompt'|null, id: number } or null if unparseable.
84
+ // source===null means no explicit prefix — caller picks default (typically 'obs').
85
+ function parseIdToken(raw) {
86
+ const m = /^([PpSs]?)#?(\d+)$/.exec(String(raw).trim());
87
+ if (!m) return null;
88
+ const p = m[1].toUpperCase();
89
+ const id = parseInt(m[2], 10);
90
+ if (!Number.isFinite(id) || id <= 0) return null;
91
+ const source = p === 'P' ? 'prompt' : p === 'S' ? 'session' : null;
92
+ return { source, id };
93
+ }
94
+
95
+ // Format the shared `probeIdSources` output as CLI hint strings.
96
+ // Example: ["#5419 (obs)", "P#5417 (prompt)"] — callers join with "; ".
97
+ function formatProbeHints(probe) {
98
+ const hints = [];
99
+ if (probe.obs.length > 0) hints.push(`#${probe.obs.join(', #')} (obs)`);
100
+ if (probe.session.length > 0) hints.push(`S#${probe.session.join(', S#')} (session)`);
101
+ if (probe.prompt.length > 0) hints.push(`P#${probe.prompt.join(', P#')} (prompt)`);
102
+ return hints;
103
+ }
104
+
80
105
  // ─── Commands ────────────────────────────────────────────────────────────────
81
106
 
82
107
  function cmdSearch(db, args) {
@@ -571,57 +596,107 @@ function cmdRecall(db, args) {
571
596
  }
572
597
  }
573
598
 
599
+ 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'];
600
+
601
+ function renderObsRows(db, ids, requestedFields) {
602
+ const placeholders = ids.map(() => '?').join(',');
603
+ try {
604
+ db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${placeholders})`).run(Date.now(), ...ids);
605
+ autoBoostIfNeeded(db, ids);
606
+ } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
607
+
608
+ const rows = db.prepare(`SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...ids);
609
+ if (rows.length === 0) return null;
610
+ const fields = requestedFields || OBS_FIELDS;
611
+ const parts = [];
612
+ for (const r of rows) {
613
+ const lines = [`#${r.id} [${r.type}] ${fmtDateShort(r.created_at)}`];
614
+ for (const f of fields) {
615
+ if (f === 'id' || f === 'type' || f === 'created_at') continue;
616
+ const val = r[f];
617
+ if (val === null || val === undefined || val === '') continue;
618
+ if (f === 'text' && r.narrative && typeof val === 'string' && val.startsWith(r.narrative)) continue;
619
+ const maxLen = f === 'narrative' ? 1000 : f === 'lesson_learned' ? 500 : f === 'text' ? 500 : 200;
620
+ const display = typeof val === 'string' && val.length > maxLen ? val.slice(0, maxLen) + '…' : val;
621
+ lines.push(`${f}: ${display}`);
622
+ }
623
+ parts.push(lines.join('\n'));
624
+ }
625
+ return { text: parts.join('\n\n'), count: rows.length };
626
+ }
627
+
628
+ function renderSessionRows(db, ids) {
629
+ const placeholders = ids.map(() => '?').join(',');
630
+ const rows = db.prepare(`SELECT * FROM session_summaries WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...ids);
631
+ if (rows.length === 0) return null;
632
+ const parts = [];
633
+ for (const r of rows) {
634
+ const lines = [`S#${r.id} ${fmtDateShort(r.created_at)}`];
635
+ if (r.request) lines.push(`Request: ${r.request}`);
636
+ if (r.completed) lines.push(`Completed: ${r.completed}`);
637
+ if (r.investigated) lines.push(`Investigated: ${r.investigated}`);
638
+ if (r.learned) lines.push(`Learned: ${r.learned}`);
639
+ if (r.next_steps) lines.push(`Next steps: ${r.next_steps}`);
640
+ if (r.project) lines.push(`Project: ${r.project}`);
641
+ parts.push(lines.join('\n'));
642
+ }
643
+ return { text: parts.join('\n\n'), count: rows.length };
644
+ }
645
+
646
+ function renderPromptRows(db, ids) {
647
+ const placeholders = ids.map(() => '?').join(',');
648
+ const rows = db.prepare(`SELECT * FROM user_prompts WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...ids);
649
+ if (rows.length === 0) return null;
650
+ const parts = [];
651
+ for (const r of rows) {
652
+ const lines = [`P#${r.id} ${fmtDateShort(r.created_at)}`];
653
+ if (r.prompt_text) lines.push(`Text: ${r.prompt_text}`);
654
+ if (r.content_session_id) lines.push(`Session: ${r.content_session_id}`);
655
+ parts.push(lines.join('\n'));
656
+ }
657
+ return { text: parts.join('\n\n'), count: rows.length };
658
+ }
659
+
574
660
  function cmdGet(db, args) {
575
661
  const { positional, flags } = parseArgs(args);
576
662
  const idStr = positional.join(',');
577
663
  if (!idStr) {
578
- fail('[mem] Usage: mem get <id1,id2,...> [--source obs|session|prompt] [--fields f1,f2,...]');
664
+ fail('[mem] Usage: mem get <id1,id2,...> [--source obs|session|prompt] [--fields f1,f2,...]\n' +
665
+ ' IDs accept prefix from search output: #123 (obs), P#123 (prompt), S#123 (session).');
579
666
  return;
580
667
  }
581
668
 
582
- const ids = idStr.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
583
- if (ids.length === 0) {
669
+ const tokens = idStr.split(',').map(s => s.trim()).filter(Boolean);
670
+ const unparseable = [];
671
+ const parsed = [];
672
+ for (const t of tokens) {
673
+ const p = parseIdToken(t);
674
+ if (p) parsed.push(p);
675
+ else unparseable.push(t);
676
+ }
677
+ if (unparseable.length > 0) {
678
+ process.stderr.write(`[mem] Ignoring unparseable ID token(s): ${unparseable.join(', ')}\n`);
679
+ }
680
+ if (parsed.length === 0) {
584
681
  fail('[mem] No valid IDs provided');
585
682
  return;
586
683
  }
587
684
 
588
- const source = flags.source || 'obs';
589
- const placeholders = ids.map(() => '?').join(',');
590
-
591
- if (source === 'session') {
592
- const rows = db.prepare(`SELECT * FROM session_summaries WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...ids);
593
- if (rows.length === 0) { fail('[mem] No sessions found for given IDs'); return; }
594
- const parts = [];
595
- for (const r of rows) {
596
- const lines = [`S#${r.id} ${fmtDateShort(r.created_at)}`];
597
- if (r.request) lines.push(`Request: ${r.request}`);
598
- if (r.completed) lines.push(`Completed: ${r.completed}`);
599
- if (r.investigated) lines.push(`Investigated: ${r.investigated}`);
600
- if (r.learned) lines.push(`Learned: ${r.learned}`);
601
- if (r.next_steps) lines.push(`Next steps: ${r.next_steps}`);
602
- if (r.project) lines.push(`Project: ${r.project}`);
603
- parts.push(lines.join('\n'));
604
- }
605
- out(parts.join('\n\n'));
685
+ // Explicit --source overrides any prefix; otherwise each token's prefix routes individually.
686
+ const explicit = flags.source;
687
+ const validSources = new Set(['obs', 'session', 'prompt']);
688
+ if (explicit && !validSources.has(explicit)) {
689
+ fail(`[mem] Invalid --source "${explicit}". Use: obs, session, prompt`);
606
690
  return;
607
691
  }
608
692
 
609
- if (source === 'prompt') {
610
- const rows = db.prepare(`SELECT * FROM user_prompts WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...ids);
611
- if (rows.length === 0) { fail('[mem] No prompts found for given IDs'); return; }
612
- const parts = [];
613
- for (const r of rows) {
614
- const lines = [`P#${r.id} ${fmtDateShort(r.created_at)}`];
615
- if (r.prompt_text) lines.push(`Text: ${r.prompt_text}`);
616
- if (r.content_session_id) lines.push(`Session: ${r.content_session_id}`);
617
- parts.push(lines.join('\n'));
618
- }
619
- out(parts.join('\n\n'));
620
- return;
693
+ const bySrc = { obs: [], session: [], prompt: [] };
694
+ for (const p of parsed) {
695
+ const src = explicit || p.source || 'obs';
696
+ bySrc[src].push(p.id);
621
697
  }
622
698
 
623
- // Default: observations (aligned with MCP mem_get)
624
- 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'];
699
+ // Validate --fields against obs schema (only meaningful for obs rows).
625
700
  let requestedFields = null;
626
701
  if (flags.fields) {
627
702
  const allRequested = flags.fields.split(',').map(s => s.trim());
@@ -636,50 +711,82 @@ function cmdGet(db, args) {
636
711
  }
637
712
  }
638
713
 
639
- // Update access_count + auto-boost (aligned with MCP mem_get)
640
- try {
641
- db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${placeholders})`).run(Date.now(), ...ids);
642
- autoBoostIfNeeded(db, ids);
643
- } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
644
-
645
- const rows = db.prepare(`
646
- SELECT * FROM observations
647
- WHERE id IN (${placeholders})
648
- ORDER BY created_at_epoch ASC
649
- `).all(...ids);
650
-
651
- if (rows.length === 0) {
652
- fail('[mem] No observations found for given IDs');
653
- return;
714
+ const sections = [];
715
+ let totalFound = 0;
716
+ if (bySrc.obs.length > 0) {
717
+ const s = renderObsRows(db, bySrc.obs, requestedFields);
718
+ if (s) { sections.push(s.text); totalFound += s.count; }
719
+ }
720
+ if (bySrc.session.length > 0) {
721
+ const s = renderSessionRows(db, bySrc.session);
722
+ if (s) { sections.push(s.text); totalFound += s.count; }
723
+ }
724
+ if (bySrc.prompt.length > 0) {
725
+ const s = renderPromptRows(db, bySrc.prompt);
726
+ if (s) { sections.push(s.text); totalFound += s.count; }
654
727
  }
655
728
 
656
- const fields = requestedFields || OBS_FIELDS;
657
- const parts = [];
658
- for (const r of rows) {
659
- const lines = [`#${r.id} [${r.type}] ${fmtDateShort(r.created_at)}`];
660
- for (const f of fields) {
661
- if (f === 'id' || f === 'type' || f === 'created_at') continue; // already in header
662
- const val = r[f];
663
- if (val === null || val === undefined || val === '') continue;
664
- // Skip 'text' field when it duplicates narrative (aligned with MCP mem_get)
665
- if (f === 'text' && r.narrative && typeof val === 'string' && val.startsWith(r.narrative)) continue;
666
- const maxLen = f === 'narrative' ? 1000 : f === 'lesson_learned' ? 500 : f === 'text' ? 500 : 200;
667
- const display = typeof val === 'string' && val.length > maxLen ? val.slice(0, maxLen) + '…' : val;
668
- lines.push(`${f}: ${display}`);
669
- }
670
- parts.push(lines.join('\n'));
729
+ if (totalFound === 0) {
730
+ // Probe the OTHER sources so the caller can retry with the right prefix.
731
+ const queried = new Set(Object.entries(bySrc).filter(([, v]) => v.length > 0).map(([k]) => k));
732
+ const allIds = parsed.map(p => p.id);
733
+ const probe = probeIdSources(db, allIds, queried);
734
+ const hits = formatProbeHints(probe);
735
+ const hint = hits.length > 0 ? ` Try: ${hits.join('; ')}.` : '';
736
+ const queriedList = [...queried].join(', ');
737
+ fail(`[mem] No records found in source(s) [${queriedList}] for the given ID(s).${hint}`);
738
+ return;
671
739
  }
672
740
 
673
- out(parts.join('\n\n'));
741
+ out(sections.join('\n\n'));
674
742
  }
675
743
 
676
744
  function cmdTimeline(db, args) {
677
745
  const { positional, flags } = parseArgs(args);
678
- let anchorId = parseInt(flags.anchor, 10);
679
746
  const before = parseInt(flags.before, 10) || 5;
680
747
  const after = parseInt(flags.after, 10) || 5;
681
748
  const project = flags.project ? resolveProject(db, flags.project) : null;
682
749
 
750
+ // Parse --anchor, accepting P#/S#/# prefix so callers can paste search-result IDs verbatim.
751
+ // For prompt/session anchors, resolve to the nearest-in-time observation so timeline semantics
752
+ // (before/after observations) still apply.
753
+ let anchorId = null;
754
+ let anchorNote = null; // hint line for output when anchor was resolved via conversion
755
+ if (flags.anchor !== undefined && flags.anchor !== true) {
756
+ const parsed = parseIdToken(flags.anchor);
757
+ if (!parsed) {
758
+ fail(`[mem] Invalid --anchor "${flags.anchor}". Expected N, #N, P#N, or S#N.`);
759
+ return;
760
+ }
761
+ if (parsed.source === 'prompt') {
762
+ const row = db.prepare('SELECT created_at_epoch FROM user_prompts WHERE id = ?').get(parsed.id);
763
+ if (!row) { fail(`[mem] Prompt P#${parsed.id} not found`); return; }
764
+ const proj = project;
765
+ const nearest = db.prepare(`
766
+ SELECT id FROM observations
767
+ WHERE COALESCE(compressed_into, 0) = 0 ${proj ? 'AND project = ?' : ''}
768
+ ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
769
+ `).get(...(proj ? [proj, row.created_at_epoch] : [row.created_at_epoch]));
770
+ if (!nearest) { fail(`[mem] No observations near P#${parsed.id}`); return; }
771
+ anchorId = nearest.id;
772
+ anchorNote = `(anchored to #${nearest.id}, closest obs to P#${parsed.id})`;
773
+ } else if (parsed.source === 'session') {
774
+ const row = db.prepare('SELECT created_at_epoch FROM session_summaries WHERE id = ?').get(parsed.id);
775
+ if (!row) { fail(`[mem] Session S#${parsed.id} not found`); return; }
776
+ const proj = project;
777
+ const nearest = db.prepare(`
778
+ SELECT id FROM observations
779
+ WHERE COALESCE(compressed_into, 0) = 0 ${proj ? 'AND project = ?' : ''}
780
+ ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
781
+ `).get(...(proj ? [proj, row.created_at_epoch] : [row.created_at_epoch]));
782
+ if (!nearest) { fail(`[mem] No observations near S#${parsed.id}`); return; }
783
+ anchorId = nearest.id;
784
+ anchorNote = `(anchored to #${nearest.id}, closest obs to S#${parsed.id})`;
785
+ } else {
786
+ anchorId = parsed.id;
787
+ }
788
+ }
789
+
683
790
  // Support query-based anchor: `timeline --query "search terms"` or positional
684
791
  // Uses recency-weighted BM25 + project filter (aligned with MCP mem_timeline)
685
792
  const queryStr = flags.query || positional.join(' ');
@@ -773,7 +880,7 @@ function cmdTimeline(db, args) {
773
880
 
774
881
  const all = [...beforeRows.reverse(), anchor, ...afterRows];
775
882
 
776
- out(`[mem] Timeline around #${anchorId}:`);
883
+ out(`[mem] Timeline around #${anchorId}${anchorNote ? ' ' + anchorNote : ''}:`);
777
884
  for (const r of all) {
778
885
  const marker = r.id === anchorId ? ' <--' : '';
779
886
  const time = relativeTime(r.created_at_epoch);
@@ -1162,7 +1269,19 @@ function cmdDelete(db, args) {
1162
1269
  return;
1163
1270
  }
1164
1271
 
1165
- const ids = idStr.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
1272
+ // delete operates on observations only. Reject P#/S# explicitly so callers aren't
1273
+ // surprised by silent NaN filtering when they paste search-output IDs.
1274
+ const tokens = idStr.split(',').map(s => s.trim()).filter(Boolean);
1275
+ const nonObs = tokens.filter(t => /^[PpSs]#?\d+$/.test(t));
1276
+ if (nonObs.length > 0) {
1277
+ fail(`[mem] delete only works on observations. Rejected: ${nonObs.join(', ')}. ` +
1278
+ `Prompts and sessions are append-only — inspect with \`mem get P#N --source prompt\` / \`--source session\`.`);
1279
+ return;
1280
+ }
1281
+ const ids = tokens.map(t => {
1282
+ const p = parseIdToken(t);
1283
+ return p && p.source === null ? p.id : NaN;
1284
+ }).filter(n => !isNaN(n));
1166
1285
  if (ids.length === 0) {
1167
1286
  fail('[mem] No valid IDs provided');
1168
1287
  return;
@@ -1215,7 +1334,14 @@ function cmdDelete(db, args) {
1215
1334
 
1216
1335
  function cmdUpdate(db, args) {
1217
1336
  const { positional, flags } = parseArgs(args);
1218
- const id = parseInt(positional[0], 10);
1337
+ const raw = positional[0];
1338
+ if (raw && /^[PpSs]#?\d+$/.test(String(raw).trim())) {
1339
+ fail(`[mem] update only works on observations. Rejected: ${raw}. ` +
1340
+ `Prompts and sessions are append-only.`);
1341
+ return;
1342
+ }
1343
+ const parsed = raw ? parseIdToken(raw) : null;
1344
+ const id = parsed && parsed.source === null ? parsed.id : parseInt(raw, 10);
1219
1345
  if (!id || isNaN(id)) {
1220
1346
  fail('[mem] Usage: mem update <id> [--title T] [--type T] [--importance N] [--lesson T] [--narrative T] [--concepts T]');
1221
1347
  return;
@@ -1919,11 +2045,14 @@ Commands:
1919
2045
  --limit N Max results (default 10)
1920
2046
 
1921
2047
  get <id1,id2,...> Get full details by ID
1922
- --source S Record type: obs (default), session, prompt
1923
- --fields f1,f2,... Select specific fields to return
2048
+ IDs accept search-output prefixes: #123 (obs), P#123 (prompt), S#123 (session).
2049
+ Bare N defaults to obs. Mixed prefixes in one call route each token correctly.
2050
+ --source S Force record type (obs|session|prompt); overrides prefixes.
2051
+ --fields f1,f2,... Select specific fields to return (observations only).
1924
2052
 
1925
2053
  timeline Show observations around an anchor (shows recent if no anchor)
1926
- --anchor ID Center on this observation ID
2054
+ --anchor ID Center on this ID. Accepts N, #N, P#N, or S#N — P#/S# anchors
2055
+ resolve to the nearest-in-time observation in the same project.
1927
2056
  --query "text" Find anchor by FTS5 search
1928
2057
  --before N Show N before anchor (default 5)
1929
2058
  --after N Show N after anchor (default 5)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.36.0",
3
+ "version": "2.38.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "engines": {
@@ -51,6 +51,7 @@
51
51
  "lib/stats-quality.mjs",
52
52
  "lib/low-signal-patterns.mjs",
53
53
  "lib/citation-tracker.mjs",
54
+ "lib/id-routing.mjs",
54
55
  "registry.mjs",
55
56
  "registry-retriever.mjs",
56
57
  "registry-indexer.mjs",
package/schema.mjs CHANGED
@@ -13,7 +13,7 @@ export const DB_PATH = join(DB_DIR, 'claude-mem-lite.db');
13
13
  export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
14
14
 
15
15
  // Increment when schema changes (tables, columns, indexes, FTS, migrations)
16
- export const CURRENT_SCHEMA_VERSION = 25;
16
+ export const CURRENT_SCHEMA_VERSION = 26;
17
17
 
18
18
  const CORE_SCHEMA = `
19
19
  CREATE TABLE IF NOT EXISTS sdk_sessions (
@@ -112,6 +112,13 @@ const MIGRATIONS = [
112
112
  'ALTER TABLE observations ADD COLUMN superseded_by INTEGER DEFAULT NULL',
113
113
  'ALTER TABLE observations ADD COLUMN last_accessed_at INTEGER DEFAULT NULL',
114
114
  'ALTER TABLE observations ADD COLUMN optimized_at INTEGER DEFAULT NULL',
115
+ // v26 (P0 injection-noise): per-obs injection tracking for noise-ratio
116
+ // penalty. injection_count bumps only on UserPromptSubmit / hook-memory
117
+ // auto-injection (not on explicit recall/get/timeline — those keep bumping
118
+ // access_count). Pair with access_count to compute noise ratio: high
119
+ // injection_count + low access_count = low-signal, deprioritize.
120
+ 'ALTER TABLE observations ADD COLUMN injection_count INTEGER NOT NULL DEFAULT 0',
121
+ 'ALTER TABLE observations ADD COLUMN last_injected_at INTEGER DEFAULT NULL',
115
122
  ];
116
123
 
117
124
  /**
package/scoring-sql.mjs CHANGED
@@ -61,6 +61,44 @@ export const TYPE_QUALITY_CASE = `(
61
61
  END
62
62
  )`;
63
63
 
64
+ /**
65
+ * Noise-ratio penalty: deprioritizes observations that get auto-injected often
66
+ * but rarely "used" (cited via Stop-hook citation tracker, or explicitly
67
+ * recalled/opened via pre-tool-recall / cmdRecall / cmdGet / cmdTimeline).
68
+ *
69
+ * Signal sources:
70
+ * - injection_count: bumped ONLY on UserPromptSubmit / hook-memory auto-inject
71
+ * - access_count: bumped on citation (c039352 P4), explicit recall, get, timeline
72
+ *
73
+ * Empirical thresholds (see docs/p0-injection-noise-baseline.txt, 53 transcripts):
74
+ * • High-noise legitimate use (#5597 29/10=2.9x): kept at 1.0× (below tier-1)
75
+ * • Moderate noise (#4352 44/9=4.89x): drops to 0.5× (tier-1 hit)
76
+ * • Pure noise (#4046 14/0=inf): drops to 0.5× (tier-1; count≥10 gate protects
77
+ * cold-start obs with legitimately no cites yet)
78
+ * • Entrenched noise (≥20 inject, ≥5× ratio): drops to 0.2× (tier-2)
79
+ *
80
+ * Applied as: BM25 × time_decay × TYPE_QUALITY × (0.5 + 0.5·importance) × NOISE_PENALTY
81
+ * Note: multiplicative so ORDER BY relevance ASC (negative scores) still works —
82
+ * penalty shrinks magnitude, making the row less preferable.
83
+ *
84
+ * @param {string} [alias='o'] Table alias for the observations row.
85
+ * @returns {string} SQL CASE expression (already parenthesized).
86
+ */
87
+ export function noisePenaltyClause(alias = 'o') {
88
+ const a = alias ? `${alias}.` : '';
89
+ return `(
90
+ CASE
91
+ WHEN COALESCE(${a}injection_count, 0) >= 20
92
+ AND COALESCE(${a}injection_count, 0) > COALESCE(${a}access_count, 0) * 5
93
+ THEN 0.2
94
+ WHEN COALESCE(${a}injection_count, 0) >= 10
95
+ AND COALESCE(${a}injection_count, 0) > COALESCE(${a}access_count, 0) * 3
96
+ THEN 0.5
97
+ ELSE 1.0
98
+ END
99
+ )`;
100
+ }
101
+
64
102
  /**
65
103
  * SQL WHERE clause fragment excluding LOW_SIGNAL degraded titles — the fallback
66
104
  * titles hook-llm.mjs writes when Haiku summarization is unavailable or skipped
@@ -4,7 +4,7 @@
4
4
  // Lightweight: only imports schema.mjs and utils.mjs, no MCP SDK
5
5
 
6
6
  import { ensureDb, DB_DIR, REGISTRY_DB_PATH } from '../schema.mjs';
7
- import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, notLowSignalTitleClause } from '../utils.mjs';
7
+ import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, notLowSignalTitleClause, noisePenaltyClause } from '../utils.mjs';
8
8
  import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
9
9
  import { join } from 'path';
10
10
  import Database from 'better-sqlite3';
@@ -87,12 +87,16 @@ function searchByFts(db, queryText, project, limit, typeFilter) {
87
87
  const now = Date.now();
88
88
  // R1: notLowSignalTitleClause() excludes hook-llm degraded titles
89
89
  // ("Modified X", "Worked on X", "Reviewed N files:", raw error logs).
90
+ // v26 P0: noise penalty shrinks relevance magnitude for obs with high
91
+ // inject:access ratio (auto-injected often, never cited/opened). See
92
+ // docs/p0-injection-noise-baseline.txt.
90
93
  const sql = `
91
94
  SELECT o.id, o.type, o.title, o.lesson_learned,
92
95
  ${OBS_BM25}
93
96
  * (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / ${TYPE_DECAY_CASE}))
94
97
  * ${TYPE_QUALITY_CASE}
95
- * (0.5 + 0.5 * COALESCE(o.importance, 1)) as relevance
98
+ * (0.5 + 0.5 * COALESCE(o.importance, 1))
99
+ * ${noisePenaltyClause('o')} as relevance
96
100
  FROM observations_fts
97
101
  JOIN observations o ON o.id = observations_fts.rowid
98
102
  WHERE observations_fts MATCH ?
@@ -460,6 +464,22 @@ async function main() {
460
464
  count: prevCount + 1,
461
465
  }));
462
466
  } catch {}
467
+ // v26 P0: bump injection_count for obs-based emits only (prompt-corpus
468
+ // rows have "P<id>" string IDs; skip those — they live in user_prompts).
469
+ // Per-row try/catch: observations_au trigger reinserts FTS on any UPDATE
470
+ // (project_non_obvious.md); an FTS corruption on one row must not abort
471
+ // counter bumps for other rows.
472
+ if (rows.length > 0) {
473
+ try {
474
+ const now = Date.now();
475
+ const bumpStmt = db.prepare(
476
+ 'UPDATE observations SET injection_count = COALESCE(injection_count, 0) + 1, last_injected_at = ? WHERE id = ?'
477
+ );
478
+ for (const r of rows) {
479
+ try { bumpStmt.run(now, r.id); } catch {}
480
+ }
481
+ } catch {}
482
+ }
463
483
  }
464
484
 
465
485
  // ─── L1: Registry skill pointer (T4 v2.31) ──────────────────────────
package/server.mjs CHANGED
@@ -27,6 +27,7 @@ import { basename, join } from 'path';
27
27
  import { homedir } from 'os';
28
28
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
29
29
  import { searchResources } from './registry-retriever.mjs';
30
+ import { probeOtherSources as probeIdSources } from './lib/id-routing.mjs';
30
31
  import { getVocabulary, rebuildVocabulary, _resetVocabCache, computeVector, vectorSearch, rrfMerge } from './tfidf.mjs';
31
32
  import { createRequire } from 'module';
32
33
 
@@ -898,17 +899,14 @@ server.registerTool(
898
899
  }
899
900
 
900
901
  if (rows.length === 0) {
901
- // P2-7: for source=session/prompt, check whether the IDs exist as observations so the
902
- // caller can switch source instead of chasing a phantom miss.
903
- let hint = '';
904
- if (source === 'session' || source === 'prompt') {
905
- try {
906
- const obsHits = db.prepare(`SELECT id FROM observations WHERE id IN (${placeholders})`).all(...args.ids);
907
- if (obsHits.length > 0) {
908
- hint = ` These ID(s) exist as observations: ${obsHits.map(r => r.id).join(', ')}. Try source='obs'.`;
909
- }
910
- } catch { /* best-effort hint */ }
911
- }
902
+ // Symmetric probe via shared lib/id-routing.mjs so CLI cmdGet and MCP mem_get
903
+ // stay aligned if a table's ID semantics change.
904
+ const probe = probeIdSources(db, args.ids, new Set([source]));
905
+ const hints = [];
906
+ if (probe.obs.length > 0) hints.push(`#${probe.obs.join(', #')} (obs — use source='obs')`);
907
+ if (probe.session.length > 0) hints.push(`S#${probe.session.join(', S#')} (session use source='session')`);
908
+ if (probe.prompt.length > 0) hints.push(`P#${probe.prompt.join(', P#')} (prompt — use source='prompt')`);
909
+ const hint = hints.length > 0 ? ` Try: ${hints.join('; ')}.` : '';
912
910
  const msg = `No ${sourceLabel} found for given IDs.${hint}`;
913
911
  return { content: [{ type: 'text', text: fieldsNote ? `${msg}\n\n${fieldsNote}` : msg }] };
914
912
  }
package/source-files.mjs CHANGED
@@ -38,6 +38,7 @@ export const SOURCE_FILES = [
38
38
  'lib/stats-quality.mjs',
39
39
  'lib/low-signal-patterns.mjs',
40
40
  'lib/citation-tracker.mjs',
41
+ 'lib/id-routing.mjs',
41
42
  // v2.32 invited-memory: memdir primitives + adopt/unadopt CLI
42
43
  'memdir.mjs',
43
44
  'adopt-content.mjs',
package/tool-schemas.mjs CHANGED
@@ -275,7 +275,9 @@ export const tools = [
275
275
  ' - A mem_search hit looks relevant and you need the supporting detail\n' +
276
276
  ' - For session (S#) or prompt (P#) hits, pass source="session" or "prompt"\n' +
277
277
  '\n' +
278
- 'Equivalent CLI: claude-mem-lite get <id>[,<id>,...]',
278
+ 'On miss, response includes "Try: …" hint listing other sources the ID lives in.\n' +
279
+ '\n' +
280
+ 'Equivalent CLI: claude-mem-lite get <id>[,<id>,...] — accepts P#/S#/# prefix.',
279
281
  inputSchema: memGetSchema,
280
282
  },
281
283
  {
package/utils.mjs CHANGED
@@ -9,7 +9,7 @@ import { buildLowSignalRegex } from './lib/low-signal-patterns.mjs';
9
9
  // ─── Re-exports from extracted modules ──────────────────────────────────────
10
10
  // Backward compatibility: all consumers import from utils.mjs
11
11
 
12
- export { DECAY_HALF_LIFE_BY_TYPE, DEFAULT_DECAY_HALF_LIFE_MS, OBS_BM25, SESS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, OBS_FTS_COLUMNS, notLowSignalTitleClause } from './scoring-sql.mjs';
12
+ export { DECAY_HALF_LIFE_BY_TYPE, DEFAULT_DECAY_HALF_LIFE_MS, OBS_BM25, SESS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, OBS_FTS_COLUMNS, notLowSignalTitleClause, noisePenaltyClause } from './scoring-sql.mjs';
13
13
  export { cjkBigrams, extractCjkSynonymTokens, extractCjkKeywords, extractCjkLikePatterns, SYNONYM_MAP, expandToken, sanitizeFtsQuery, relaxFtsQueryToOr, FTS_STOP_WORDS, CJK_COMPOUNDS } from './nlp.mjs';
14
14
  export { resolveProject, _resetProjectCache } from './project-utils.mjs';
15
15
  export { scrubSecrets, SECRET_PATTERNS } from './secret-scrub.mjs';