claude-mem-lite 3.48.0 → 3.50.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.48.0",
13
+ "version": "3.50.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.48.0",
3
+ "version": "3.50.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-llm.mjs CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  import { EVENT_TYPES, saveEvent } from './lib/activity.mjs';
22
22
  import { isNoiseObservation, capNoiseImportance, isLowYieldChangeObs } from './lib/low-signal-patterns.mjs';
23
23
  import { episodeHasSignificantContent } from './hook-episode.mjs';
24
+ import { OBS_TYPE_SET } from './lib/obs-types.mjs';
24
25
 
25
26
  // T9: memdir-incompatible types live in the `events` table, not `observations`.
26
27
  // Set lookup is O(1) — authoritative source is lib/activity.mjs::EVENT_TYPES.
@@ -771,7 +772,7 @@ ${actionList}`;
771
772
  const ruleImportance = computeRuleImportance(episode);
772
773
 
773
774
  let obs;
774
- const validTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
775
+ const validTypes = OBS_TYPE_SET;
775
776
 
776
777
  const gotSlot = await acquireLLMSlot();
777
778
  if (gotSlot) {
package/hook-optimize.mjs CHANGED
@@ -15,6 +15,7 @@ import { scrubRecord } from './lib/scrub-record.mjs';
15
15
  import { getVocabulary, computeVector, cosineSimilarity, vecTextForRow } from './tfidf.mjs';
16
16
  import { MERGE_JACCARD_LOW, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
17
17
  import { DB_DIR } from './schema.mjs';
18
+ import { OBS_TYPE_SET } from './lib/obs-types.mjs';
18
19
 
19
20
  const RUNTIME_DIR = join(DB_DIR, 'runtime');
20
21
 
@@ -150,7 +151,7 @@ export async function executeReenrich(db, limit = 10, { scope = 'narrow', projec
150
151
  if (candidates.length === 0) return { processed: 0, skipped: 0 };
151
152
 
152
153
  let processed = 0, skipped = 0;
153
- const validTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
154
+ const validTypes = OBS_TYPE_SET;
154
155
 
155
156
  for (const cand of candidates) {
156
157
  const gotSlot = await acquireLLMSlot();
@@ -929,19 +930,32 @@ export async function optimizeRun(db, { tasks, maxItems = 15, force = false, ree
929
930
  try {
930
931
  switch (task) {
931
932
  case 're-enrich':
932
- if (reenrichScope === 'narrow') {
933
- // P1-2: the default maintenance pass covers BOTH narrow (fill lesson/concepts on
934
- // fully-degraded rows) AND aliases (backfill search_aliases on lesson-bearing
935
- // manual saves that narrow+wide both skip — mem_save writes no aliases, so without
936
- // this they stay paraphrase-unfindable). Split the budget so neither starves.
937
- // An explicit --scope wide|aliases still runs exactly that one scope (below).
938
- const aliasBudget = Math.max(1, Math.floor(budget.reenrich / 2));
939
- const narrowRes = await executeReenrich(db, budget.reenrich - aliasBudget, { scope: 'narrow', project });
940
- const aliasRes = await executeReenrich(db, aliasBudget, { scope: 'aliases', project });
933
+ if (reenrichScope === 'narrow' || reenrichScope === 'wide') {
934
+ // P1-2 (v3.43) + audit 2026-07-17 P4: the maintenance pass covers BOTH the main
935
+ // scope (narrow = fill lesson/concepts on fully-degraded rows; wide = lesson
936
+ // backfill on substantive event-typed rows) AND aliases (backfill search_aliases
937
+ // on lesson-bearing manual saves that narrow+wide both skip mem_save writes no
938
+ // aliases, so without this they stay paraphrase-unfindable). v3.43 hung the split
939
+ // only on the DEFAULT 'narrow' branch, but the DAILY auto path (handleLLMOptimize
940
+ // via auto-maintain) passes 'wide' explicitly so aliases never had a cadence and
941
+ // live coverage crawled at ~15%. The split is ADAPTIVE: aliases takes at most half
942
+ // the budget and only what its candidate pool actually holds, so a zero-candidate
943
+ // aliases pass costs nothing and the main scope keeps its full budget. Boundary:
944
+ // at budget.reenrich === 1, `half` floors to 1 (the whole budget), so with ≥1
945
+ // alias candidate the main scope gets 0 that cycle — pre-existing v3.43 semantics
946
+ // (reachable only via manual `optimize --max ≤4`; the daily path runs reenrich=6),
947
+ // and the starved scope self-corrects next cycle.
948
+ // An explicit --scope aliases still runs exactly that one scope (below).
949
+ const half = Math.max(1, Math.floor(budget.reenrich / 2));
950
+ const aliasBudget = Math.min(half, findReenrichCandidates(db, half, { scope: 'aliases', project }).length);
951
+ const mainRes = await executeReenrich(db, budget.reenrich - aliasBudget, { scope: reenrichScope, project });
952
+ const aliasRes = aliasBudget > 0
953
+ ? await executeReenrich(db, aliasBudget, { scope: 'aliases', project })
954
+ : { processed: 0, skipped: 0 };
941
955
  results.reenrich = {
942
- processed: (narrowRes.processed || 0) + (aliasRes.processed || 0),
943
- skipped: (narrowRes.skipped || 0) + (aliasRes.skipped || 0),
944
- byScope: { narrow: narrowRes, aliases: aliasRes },
956
+ processed: (mainRes.processed || 0) + (aliasRes.processed || 0),
957
+ skipped: (mainRes.skipped || 0) + (aliasRes.skipped || 0),
958
+ byScope: { [reenrichScope]: mainRes, aliases: aliasRes },
945
959
  };
946
960
  } else {
947
961
  results.reenrich = await executeReenrich(db, budget.reenrich, { scope: reenrichScope, project });
package/lib/activity.mjs CHANGED
@@ -8,10 +8,11 @@ import { sanitizeFtsQuery } from '../utils.mjs';
8
8
  import { scrubRecord } from './scrub-record.mjs';
9
9
  import { saveObservation } from './save-observation.mjs';
10
10
  import { notLowSignalTitleClause } from '../scoring-sql.mjs';
11
+ import { OBS_TYPE_SET } from './obs-types.mjs';
11
12
 
12
13
  // Observation types (mirrors the observations.type enum) — events carry a wider
13
14
  // set, so promotion maps the extras (lesson/bug/observation) onto valid obs types.
14
- const OBS_TYPES = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
15
+ const OBS_TYPES = OBS_TYPE_SET;
15
16
  const EVENT_TO_OBS_TYPE = { bug: 'bugfix', lesson: 'discovery', observation: 'discovery' };
16
17
 
17
18
  /**
@@ -74,6 +74,143 @@ export function dropDeferred(db, id, reason) {
74
74
  return { changed: r.changes };
75
75
  }
76
76
 
77
+ /**
78
+ * Fetch full deferred_work rows by raw id — ANY status, input order preserved,
79
+ * missing ids omitted. This is the read half of the D# surface: `defer list`
80
+ * stays title-only by design (dashboard noise budget), so `get D#N` is where
81
+ * the detail field becomes readable at all.
82
+ * @param {Database} db
83
+ * @param {number[]} ids Raw deferred_work ids
84
+ * @returns {Array<object>} Full rows
85
+ */
86
+ export function getDeferredByIds(db, ids) {
87
+ if (!Array.isArray(ids) || ids.length === 0) return [];
88
+ const stmt = db.prepare(`SELECT * FROM deferred_work WHERE id = ?`);
89
+ const rows = [];
90
+ for (const id of ids) {
91
+ if (!Number.isInteger(id) || id <= 0) continue;
92
+ const r = stmt.get(id);
93
+ if (r) rows.push(r);
94
+ }
95
+ return rows;
96
+ }
97
+
98
+ /**
99
+ * Render one deferred_work row with FULL detail (never truncated) — shared by
100
+ * CLI cmdGet and MCP mem_get so the two surfaces cannot drift.
101
+ * @param {object} row Full deferred_work row (from getDeferredByIds)
102
+ * @returns {string}
103
+ */
104
+ export function formatDeferredDetail(row) {
105
+ const pTag = row.priority === 3 ? '🔴' : row.priority === 1 ? '⚪' : '🟡';
106
+ const lines = [`── D#${row.id} ── deferred (${row.status}) ${pTag} [P${row.priority}]`];
107
+ lines.push(`project: ${row.project}`);
108
+ lines.push(`title: ${row.title}`);
109
+ if (row.detail) lines.push(`detail: ${row.detail}`);
110
+ if (row.files) {
111
+ try {
112
+ const f = JSON.parse(row.files);
113
+ if (Array.isArray(f) && f.length > 0) lines.push(`files: ${f.join(', ')}`);
114
+ } catch { /* legacy non-JSON files value — skip rather than render garbage */ }
115
+ }
116
+ if (row.created_at_epoch) lines.push(`created: ${new Date(row.created_at_epoch).toISOString()}`);
117
+ if (row.status === 'dropped' && row.drop_reason) lines.push(`drop_reason: ${row.drop_reason}`);
118
+ if (row.status === 'done' && row.closed_by_obs_id) lines.push(`closed_by: #${row.closed_by_obs_id}`);
119
+ return lines.join('\n');
120
+ }
121
+
122
+ /**
123
+ * P2 search leg: make deferred items reachable from mem_search / CLI search.
124
+ *
125
+ * Two channels, merged direct-first and deduped:
126
+ * 1. Explicit "D#N" refs in the query → direct id lookup, ANY status
127
+ * (an explicit reference deserves the row even if closed), project-scoped.
128
+ * 2. Keyword match over OPEN items' title+detail — JS substring matching
129
+ * (never SQL LIKE, so %/_ are literals and wildcard injection is
130
+ * structurally impossible; corpus is ≤50 open rows per project).
131
+ * Multi-token queries need ceil(n/2) token hits so one generic token
132
+ * ("test", "lesson") can't drag the trailer into every search.
133
+ *
134
+ * Date bounds are deliberately NOT applied — open items are few, current by
135
+ * definition, and the trailer is labeled as deferred work, not search results.
136
+ *
137
+ * @param {Database} db
138
+ * @param {string} query Raw user query
139
+ * @param {string} project Project scope (required — trailer is project-local)
140
+ * @param {{limit?: number}} [opts]
141
+ * @returns {Array<object>} Full rows, direct refs first, capped at limit
142
+ */
143
+ export function searchDeferredWork(db, query, project, { limit = 3 } = {}) {
144
+ if (!query || typeof query !== 'string' || !project) return [];
145
+
146
+ const refIds = [];
147
+ const refRe = /\bD#(\d+)\b/gi;
148
+ let m;
149
+ while ((m = refRe.exec(query)) !== null) {
150
+ const id = parseInt(m[1], 10);
151
+ if (id > 0 && !refIds.includes(id)) refIds.push(id);
152
+ }
153
+ const direct = refIds.length > 0
154
+ ? getDeferredByIds(db, refIds).filter(r => r.project === project)
155
+ : [];
156
+
157
+ const tokens = query
158
+ .replace(/\bD#\d+\b/gi, ' ')
159
+ .toLowerCase()
160
+ .split(/\s+/)
161
+ .filter(t => t.length >= 2)
162
+ .slice(0, 8);
163
+
164
+ let keyword = [];
165
+ if (tokens.length > 0) {
166
+ const open = db.prepare(`
167
+ SELECT * FROM deferred_work
168
+ WHERE project = ? AND status = 'open'
169
+ ORDER BY priority DESC, created_at_epoch ASC
170
+ LIMIT 50
171
+ `).all(project);
172
+ const need = Math.max(1, Math.ceil(tokens.length / 2));
173
+ keyword = open
174
+ .map(r => {
175
+ const hay = `${r.title || ''} ${r.detail || ''}`.toLowerCase();
176
+ return { r, matched: tokens.filter(t => hay.includes(t)).length };
177
+ })
178
+ .filter(x => x.matched >= need)
179
+ .sort((a, b) => b.matched - a.matched)
180
+ .map(x => x.r);
181
+ }
182
+
183
+ const seen = new Set();
184
+ const out = [];
185
+ for (const r of [...direct, ...keyword]) {
186
+ if (seen.has(r.id)) continue;
187
+ seen.add(r.id);
188
+ out.push(r);
189
+ if (out.length >= limit) break;
190
+ }
191
+ return out;
192
+ }
193
+
194
+ /**
195
+ * Render the deferred search trailer — appended AFTER (and never counted in)
196
+ * the main result set. Title-only lines; `invokeHint` names the surface's
197
+ * full-detail reader (CLI `get D#N` / MCP mem_get).
198
+ * @param {Array<object>} rows From searchDeferredWork
199
+ * @param {string} invokeHint e.g. 'claude-mem-lite get D#<id>'
200
+ * @returns {string[]} Lines (empty array when rows is empty)
201
+ */
202
+ export function formatDeferredSearchTrailer(rows, invokeHint) {
203
+ if (!Array.isArray(rows) || rows.length === 0) return [];
204
+ const lines = [`[mem] Deferred work matches — full detail: ${invokeHint}`];
205
+ for (const r of rows) {
206
+ const pTag = r.priority === 3 ? '🔴' : r.priority === 1 ? '⚪' : '🟡';
207
+ const statusTag = r.status === 'open' ? '' : ` [${r.status}]`;
208
+ const title = (r.title || '(untitled)').length > 80 ? `${r.title.slice(0, 80)}…` : (r.title || '(untitled)');
209
+ lines.push(` D#${r.id} ${pTag} [P${r.priority}]${statusTag} ${title}`);
210
+ }
211
+ return lines;
212
+ }
213
+
77
214
  /**
78
215
  * Resolve mixed ordinal (int) + raw-id ("D#<n>") tokens to real deferred_work
79
216
  * ids, validated against caller project + status='open'.
@@ -4,9 +4,14 @@
4
4
  // event-typed memory (bugfix/decision/lesson/discovery/…) out of `observations`
5
5
  // and into the `events` table, so after Haiku enrichment the high-value history
6
6
  // lives ONLY in events. v3.44 wired events into mem_search + PreToolUse recall,
7
- // but the three PASSIVE injection surfaces (SessionStart context, UserPromptSubmit
8
- // path A = user-prompt-search.js, path B = hook.mjs memory-context) still read only
9
- // observations — so the promoted memories were unreachable at prompt/session time.
7
+ // but the passive injection surfaces still read only observations — so the promoted
8
+ // memories were unreachable at prompt/session time.
9
+ //
10
+ // Wiring (v3.48.0): SessionStart context (hook-context.mjs, recency) + UserPromptSubmit
11
+ // path B (hook.mjs memory-context, FTS). Path A (user-prompt-search.js) is INTENTIONALLY
12
+ // not wired: both UserPromptSubmit hooks fire on every prompt and there is no cross-hook
13
+ // events dedup, so wiring path A too would double-inject the same event. Do NOT "complete"
14
+ // the wiring by adding events here to path A.
10
15
  //
11
16
  // id-space discipline: events share the numeric id space with observations but are a
12
17
  // separate table, so injected events are rendered with an `E#` prefix. The
@@ -82,6 +87,10 @@ export function recentInjectableEvents(db, { project, limit = DEFAULT_LIMIT, min
82
87
  */
83
88
  export function renderInjectableEvent(row) {
84
89
  const title = neutralizeContextDelimiters((row.title || '').slice(0, TITLE_MAX));
90
+ // row.type is a frozen event_type enum (saveEvent is reached only via
91
+ // hook-llm.mjs behind `if (EVENT_TYPE_SET.has(summary.type))`), so it carries no
92
+ // injection markers and is intentionally NOT defanged — mirrors the un-defanged
93
+ // `[type]` for observations. If an unvalidated event writer is ever added, defang it.
85
94
  const head = `E#${row.id} [${row.type}] ${title}`;
86
95
  if (row.lesson_learned) {
87
96
  const lesson = neutralizeContextDelimiters(row.lesson_learned.trim().slice(0, LESSON_MAX));
@@ -14,7 +14,10 @@
14
14
  // + branch + timing. `id` + `memory_session_id` are informational (restore remaps id and
15
15
  // buckets under a synthetic restore session). Session-idempotency keys (last_decided/
16
16
  // last_cited_session_id, demoted_at, optimized_at) are intentionally NOT exported — they are
17
- // meaningless after a row is re-bucketed under a restore session.
17
+ // meaningless after a row is re-bucketed under a restore session. Also intentionally NOT
18
+ // exported: `related_ids` (holds observation ids, stale/dangling after restore remaps ids)
19
+ // and `discovery_tokens` (a derived retrieval metric, rebuilt by the live system; exporting
20
+ // it would freeze a stale value into backups).
18
21
  export const EXPORT_COLUMNS = [
19
22
  'id', 'memory_session_id', 'project', 'type', 'title', 'subtitle', 'narrative', 'text',
20
23
  'concepts', 'facts', 'files_read', 'files_modified', 'lesson_learned', 'search_aliases',
@@ -55,6 +55,32 @@ export function bucketIdTokens(tokens, { explicit = null, defaultSource = 'obs'
55
55
  return { bySrc, invalid };
56
56
  }
57
57
 
58
+ /**
59
+ * Peel D#N deferred-work tokens off a mixed get-token list BEFORE bucketing.
60
+ * D# is a get-only read surface: deferred rows live outside the observation
61
+ * timeline, so they never enter bucketIdTokens' obs/session/prompt/event
62
+ * buckets, are exempt from `source` forcing, and stay rejected by the
63
+ * delete/timeline schemas. Requires the `#` (bare "D92" is prose, not a token).
64
+ *
65
+ * @param {Array<string|number>} tokens Mixed input
66
+ * @returns {{deferredIds: number[], rest: Array<string|number>}} deferredIds
67
+ * deduped in input order; rest preserved for bucketIdTokens.
68
+ */
69
+ export function splitDeferredTokens(tokens) {
70
+ const deferredIds = [];
71
+ const rest = [];
72
+ for (const raw of tokens) {
73
+ const m = typeof raw === 'string' ? /^[Dd]#(\d+)$/.exec(raw.trim()) : null;
74
+ if (m) {
75
+ const id = parseInt(m[1], 10);
76
+ if (id > 0 && !deferredIds.includes(id)) deferredIds.push(id);
77
+ } else {
78
+ rest.push(raw);
79
+ }
80
+ }
81
+ return { deferredIds, rest };
82
+ }
83
+
58
84
  /**
59
85
  * Probe the observations / session_summaries / user_prompts tables for any
60
86
  * of the given numeric IDs, excluding the sources the caller already queried.
@@ -0,0 +1,12 @@
1
+ // Single source of truth for the observation `type` vocabulary.
2
+ //
3
+ // Audit 2026-07-17 MED-3: this list was hardcoded verbatim in 10 JS sites + the SQL
4
+ // CHECK constraint (schema.mjs observations DDL) — the project's #1 bug class
5
+ // (fix-lands-on-one-surface) sitting latent on the core type vocabulary. Every
6
+ // validator now imports from here; the SQL CHECK stays a literal (SQLite DDL cannot
7
+ // interpolate at migration time) and is locked to this list by
8
+ // tests/obs-types-invariant.test.mjs, which also fails if a new hardcoded copy of
9
+ // the list appears anywhere else in the runtime source.
10
+ export const OBS_TYPES = Object.freeze(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
11
+
12
+ export const OBS_TYPE_SET = new Set(OBS_TYPES);
@@ -0,0 +1,22 @@
1
+ // Save-time lesson nudge (audit 2026-07-17 P4): bugfix/decision are the types whose
2
+ // value lives in the lesson (root cause + fix / constraint + tradeoff), yet they are
3
+ // exactly where lessonless writes concentrate (14d live data: bugfix 18.8% / decision
4
+ // 28.7% lessonless, worsening). The nudge fires in the SAVE RESPONSE — the one moment
5
+ // the model still has the context to write the lesson — and names the exact follow-up
6
+ // call, so acting on it costs one tool call. Zero LLM cost; the async backfill arm
7
+ // (optimizeRun wide/aliases re-enrich) stays the safety net for saves that ignore it.
8
+ //
9
+ // Shared by server.mjs (mem_save) and mem-cli.mjs (cmdSave) — one gate, two phrasings,
10
+ // no twin drift.
11
+ const NUDGE_TYPES = new Set(['bugfix', 'decision']);
12
+
13
+ /**
14
+ * @param {{ type: string, id: number, lessonCaptured: boolean, surface: 'mcp'|'cli' }} p
15
+ * @returns {string} nudge text to append ('' when no nudge applies)
16
+ */
17
+ export function buildLessonNudge({ type, id, lessonCaptured, surface }) {
18
+ if (lessonCaptured || !NUDGE_TYPES.has(type)) return '';
19
+ return surface === 'cli'
20
+ ? `\n[mem] ⚠ ${type} #${id} saved without a lesson — capture the root cause + fix while it's fresh: claude-mem-lite update ${id} --lesson "<root cause + fix>"`
21
+ : ` ⚠ Saved without lesson_learned — a ${type} is worth keeping only with its lesson. Capture it now: mem_update(id=${id}, lesson_learned="<root cause + fix>").`;
22
+ }
@@ -245,31 +245,57 @@ export function searchEventsFts(db, { ftsQuery, project = null, projectBoost = n
245
245
  `).all(...params);
246
246
  }
247
247
 
248
- // A lone match has no within-source spread to normalize against. Clamp it to
249
- // the neutral middle of the [-1, 0] band (MED-5): leaving its raw BM25 magnitude
250
- // (a title hit can reach -8..-40) let one incidental single-source match — and
251
- // `event` is now a common low-cardinality source outrank an entire page of
252
- // strongly-matched, normalized rows from another source. -0.5 is magnitude-
253
- // independent, so it fixes both the dominating strong lone match and does not
254
- // inflate a weak one to -1.0 (the reason single-row sources were previously
255
- // skipped). Global-max normalization would NOT fix a lone match that IS the
256
- // global max, so a fixed neutral value is used instead.
257
- const NEUTRAL_SINGLE_MATCH_SCORE = -0.5;
248
+ // A lone match has no within-source spread to normalize against (MED-5, v3.48.0).
249
+ // A magnitude-blind constant (-0.5) fixed the dominating direction — an incidental
250
+ // lone hit no longer outranks a strongly-matched page but buried the opposite,
251
+ // equally common shape (audit 2026-07-17 MED-1): events are the CANONICAL store for
252
+ // promoted bugfix/decision memories AND a low-cardinality source, so "the best
253
+ // answer is one strong event" is routine, and within-source max-normalization pins
254
+ // every multi-hit source's best to -1 regardless of absolute strength.
255
+ //
256
+ // Fix: band the lone hit by its raw magnitude RELATIVE to the global raw max across
257
+ // all scored rows — the cross-source signal the per-source normalization destroys.
258
+ // obs/event/session BM25 live on comparable scales (weighted bm25 × decay ×
259
+ // project-boost), so the ratio is meaningful there; small-scale sources (prompts,
260
+ // bm25 ≈ -1) can only be penalized by this comparison, never inflated — the safe
261
+ // direction. Bands (ratio = |lone| / globalMaxAbs, first match wins):
262
+ // ratio ≥ 1 → -1.05 the lone hit IS the strongest raw match anywhere: rank it
263
+ // strictly ahead of every normalized -1 (ties in raw
264
+ // strength resolve toward the lone hit — the [-1, 0] band
265
+ // convention is deliberately exceeded here)
266
+ // ratio ≥ 0.5 → -0.75 comparable to the best: above neutral, below the leaders
267
+ // ratio ≥ 0.1 → -0.5 the MED-5 neutral mid
268
+ // ratio < 0.1 → -0.25 grazing match: sink it
269
+ // score === 0 rows (the prompts CJK LIKE fallback — zero confidence) are excluded
270
+ // from normalization entirely and keep their 0, sorting last in the ascending
271
+ // merge (audit L3: the old clamp promoted a lone 0-score row to the neutral mid).
272
+ const SINGLE_MATCH_BANDS = [
273
+ [1.0, -1.05],
274
+ [0.5, -0.75],
275
+ [0.1, -0.5],
276
+ [0, -0.25],
277
+ ];
258
278
 
259
279
  /**
260
280
  * Normalize each source's BM25 scores to [-1, 0] before cross-source merge.
261
281
  * Prevents observations (BM25 can reach -40) from systematically outranking
262
282
  * sessions (-6) and prompts (-1) regardless of relevance. A source with a single
263
- * scored row is clamped to the neutral mid (see NEUTRAL_SINGLE_MATCH_SCORE) —
264
- * it has no within-source spread to normalize against. Mutates `results` in
265
- * place; callers re-sort afterwards.
283
+ * scored row is banded by its magnitude relative to the global raw max (see
284
+ * SINGLE_MATCH_BANDS) — it has no within-source spread to normalize against.
285
+ * Mutates `results` in place; callers re-sort afterwards.
266
286
  */
267
287
  export function normalizeCrossSourceScores(results, sourceKey) {
288
+ const scored = results.filter((r) => r.score !== null && r.score !== undefined && r.score !== 0);
289
+ const globalMaxAbs = scored.length ? Math.max(...scored.map((r) => Math.abs(r.score))) : 0;
268
290
  for (const src of ['obs', 'session', 'prompt', 'event']) {
269
- const srcResults = results.filter((r) => r[sourceKey] === src && r.score !== null && r.score !== undefined);
291
+ // score === 0 stays out: for multi-row sources 0/maxAbs would be 0 anyway, and a
292
+ // lone 0-score row must not be banded upward (it only occurs as the prompts LIKE
293
+ // fallback, which never coexists with real prompt FTS hits).
294
+ const srcResults = results.filter((r) => r[sourceKey] === src && r.score !== null && r.score !== undefined && r.score !== 0);
270
295
  if (srcResults.length === 0) continue;
271
296
  if (srcResults.length === 1) {
272
- srcResults[0].score = NEUTRAL_SINGLE_MATCH_SCORE;
297
+ const ratio = globalMaxAbs > 0 ? Math.abs(srcResults[0].score) / globalMaxAbs : 0;
298
+ srcResults[0].score = SINGLE_MATCH_BANDS.find(([floor]) => ratio >= floor)[1];
273
299
  continue;
274
300
  }
275
301
  const maxAbs = Math.max(...srcResults.map((r) => Math.abs(r.score)));
@@ -0,0 +1,113 @@
1
+ // lib/stats-core.mjs — shared primary stats feed for CLI `stats` and MCP `mem_stats`.
2
+ //
3
+ // Audit 2026-07-17 MED-4: these ~15 COUNT/GROUP-BY queries were hand-copied
4
+ // byte-equivalent between server.mjs (mem_stats) and mem-cli.mjs (cmdStats) — the
5
+ // same twin-drift class the `--quality` sub-report already closed via
6
+ // lib/stats-quality.mjs, and delete orchestration via lib/delete-core.mjs. One
7
+ // computation, two renderers: callers keep their own formatting (MCP text block /
8
+ // CLI console+JSON) plus any surface-only extras (CLI hookErrors24h).
9
+ import { inferProject } from '../utils.mjs';
10
+ import { buildNotLowSignalSql } from './low-signal-patterns.mjs';
11
+ import { TIER_CASE_SQL, tierSqlParams } from '../tier.mjs';
12
+ import { computeNoiseGauge } from './stats-quality.mjs';
13
+
14
+ /**
15
+ * Compute the primary stats feed. Row shapes are returned exactly as the twin
16
+ * blocks produced them ({c}/{t}/{v} single-row objects, arrays for distributions)
17
+ * so both call sites render with minimal diff.
18
+ */
19
+ export function computeStatsFeed(db, { project = null, days = 30, now = Date.now() } = {}) {
20
+ const cutoff = now - days * 86400000;
21
+ const projectFilter = project ? 'AND project = ?' : '';
22
+ const baseParams = project ? [project] : [];
23
+
24
+ // Total counts (session_summaries, not sdk_sessions — CLI↔MCP aligned)
25
+ const obsTotal = db.prepare(`SELECT COUNT(*) as c FROM observations WHERE 1=1 ${projectFilter}`).get(...baseParams);
26
+ const sessTotal = db.prepare(`SELECT COUNT(*) as c FROM session_summaries WHERE 1=1 ${projectFilter}`).get(...baseParams);
27
+ const promptTotal = project
28
+ ? db.prepare('SELECT COUNT(*) as c FROM user_prompts p JOIN sdk_sessions s ON p.content_session_id = s.content_session_id WHERE s.project = ?').get(project)
29
+ : db.prepare('SELECT COUNT(*) as c FROM user_prompts').get();
30
+
31
+ // Recent counts
32
+ const obsRecent = db.prepare(`SELECT COUNT(*) as c FROM observations WHERE created_at_epoch >= ? ${projectFilter}`).get(cutoff, ...baseParams);
33
+ const sessRecent = db.prepare(`SELECT COUNT(*) as c FROM session_summaries WHERE created_at_epoch >= ? ${projectFilter}`).get(cutoff, ...baseParams);
34
+
35
+ // Type distribution (recent)
36
+ const types = db.prepare(`
37
+ SELECT type, COUNT(*) as c FROM observations
38
+ WHERE created_at_epoch >= ? ${projectFilter}
39
+ GROUP BY type ORDER BY c DESC
40
+ `).all(cutoff, ...baseParams);
41
+
42
+ // Top projects (global view — skipped when filtering by single project)
43
+ const projects = project ? [] : db.prepare(`
44
+ SELECT project, COUNT(*) as c FROM observations
45
+ GROUP BY project ORDER BY c DESC LIMIT 20
46
+ `).all();
47
+
48
+ // Daily activity (last 7 days)
49
+ const daily = db.prepare(`
50
+ SELECT date(created_at) as day, COUNT(*) as c FROM observations
51
+ WHERE created_at_epoch >= ? ${projectFilter}
52
+ GROUP BY day ORDER BY day DESC LIMIT 7
53
+ `).all(now - 7 * 86400000, ...baseParams);
54
+
55
+ // Data health
56
+ const tokenEst = db.prepare(`
57
+ SELECT SUM(LENGTH(COALESCE(title,'')) + LENGTH(COALESCE(narrative,'')) + LENGTH(COALESCE(text,''))) / 4 as t
58
+ FROM observations WHERE 1=1 ${projectFilter}
59
+ `).get(...baseParams);
60
+ const avgImp = db.prepare(`SELECT AVG(COALESCE(importance,1)) as v FROM observations WHERE 1=1 ${projectFilter}`).get(...baseParams);
61
+
62
+ const thirtyDaysAgo = now - 30 * 86400000;
63
+ // v3.23 noise-gauge de-blinding: `<= 1` makes the imp=0 dormant population visible
64
+ // (decay floor + LLM low-signal filter push ~half the live corpus to 0);
65
+ // injection_count=0 mirrors decay's NEVER-INJECTED guard so injected-but-decayed
66
+ // pinned noise isn't miscounted as "never used".
67
+ const lowVal = db.prepare(`
68
+ SELECT COUNT(*) as c FROM observations
69
+ WHERE COALESCE(importance,1) <= 1 AND COALESCE(access_count,0) = 0
70
+ AND COALESCE(injection_count,0) = 0
71
+ AND COALESCE(compressed_into, 0) = 0
72
+ AND created_at_epoch < ? ${projectFilter}
73
+ `).get(thirtyDaysAgo, ...baseParams);
74
+ // Low-signal-title population (template / tool-log titles the read-side filter
75
+ // already excludes). The imp≤1 "Low-value" metric can't see these, so the gauge
76
+ // under-reports real noise without it. Same source as lib/low-signal-patterns.mjs.
77
+ const lowSignalTitle = db.prepare(`
78
+ SELECT COUNT(*) as c FROM observations
79
+ WHERE NOT ${buildNotLowSignalSql()}
80
+ AND COALESCE(compressed_into, 0) = 0 ${projectFilter}
81
+ `).get(...baseParams);
82
+ // F7: both noise numerators exclude compressed rows → divide by the LIVE count, not
83
+ // obsTotal (all rows), so a compress-heavy store isn't reported cleaner than it is.
84
+ const liveTotal = db.prepare(
85
+ `SELECT COUNT(*) as c FROM observations WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}`
86
+ ).get(...baseParams);
87
+ const { noiseRatio, lowSignalRatio } = computeNoiseGauge({ liveTotal: liveTotal.c, lowValCount: lowVal.c, lowSignalCount: lowSignalTitle.c });
88
+ const compressedCount = db.prepare(
89
+ `SELECT COUNT(*) as c FROM observations WHERE compressed_into IS NOT NULL ${projectFilter}`
90
+ ).get(...baseParams);
91
+ const supersededOnlyCount = db.prepare(
92
+ `SELECT COUNT(*) as c FROM observations WHERE superseded_at IS NOT NULL AND compressed_into IS NULL ${projectFilter}`
93
+ ).get(...baseParams);
94
+
95
+ // Tier distribution
96
+ const tierCtx = { now, currentProject: project || inferProject(), currentSessionId: '' };
97
+ const tdParams = tierSqlParams(tierCtx);
98
+ const tierDist = db.prepare(`
99
+ SELECT tier, COUNT(*) as c FROM (
100
+ SELECT ${TIER_CASE_SQL} as tier FROM observations
101
+ WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
102
+ ) GROUP BY tier ORDER BY tier
103
+ `).all(...tdParams, ...baseParams);
104
+ const tierMap = Object.fromEntries(tierDist.map((r) => [r.tier, r.c]));
105
+
106
+ return {
107
+ obsTotal, sessTotal, promptTotal, obsRecent, sessRecent,
108
+ types, projects, daily,
109
+ tokenEst, avgImp, lowVal, lowSignalTitle, liveTotal,
110
+ noiseRatio, lowSignalRatio, compressedCount, supersededOnlyCount,
111
+ tierMap,
112
+ };
113
+ }