claude-mem-lite 3.48.0 → 3.49.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.49.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.49.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,28 @@ 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.
944
+ // An explicit --scope aliases still runs exactly that one scope (below).
945
+ const half = Math.max(1, Math.floor(budget.reenrich / 2));
946
+ const aliasBudget = Math.min(half, findReenrichCandidates(db, half, { scope: 'aliases', project }).length);
947
+ const mainRes = await executeReenrich(db, budget.reenrich - aliasBudget, { scope: reenrichScope, project });
948
+ const aliasRes = aliasBudget > 0
949
+ ? await executeReenrich(db, aliasBudget, { scope: 'aliases', project })
950
+ : { processed: 0, skipped: 0 };
941
951
  results.reenrich = {
942
- processed: (narrowRes.processed || 0) + (aliasRes.processed || 0),
943
- skipped: (narrowRes.skipped || 0) + (aliasRes.skipped || 0),
944
- byScope: { narrow: narrowRes, aliases: aliasRes },
952
+ processed: (mainRes.processed || 0) + (aliasRes.processed || 0),
953
+ skipped: (mainRes.skipped || 0) + (aliasRes.skipped || 0),
954
+ byScope: { [reenrichScope]: mainRes, aliases: aliasRes },
945
955
  };
946
956
  } else {
947
957
  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
  /**
@@ -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',
@@ -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
+ }
package/mem-cli.mjs CHANGED
@@ -15,7 +15,6 @@ import { ensureRegistryDb, upsertResource } from './registry.mjs';
15
15
  import { searchResources } from './registry-retriever.mjs';
16
16
  import { computeFunnel, formatFunnel, computeSweep, formatSweep, DEFAULT_SWEEP_FLOORS, DEFAULT_SWEEP_MARGINS } from './registry-recommend.mjs';
17
17
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
18
- import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
19
18
  import {
20
19
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
21
20
  recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans,
@@ -25,6 +24,9 @@ import {
25
24
  } from './lib/maintain-core.mjs';
26
25
  import { snapshotDb } from './lib/db-backup.mjs';
27
26
  import { deleteObservations } from './lib/delete-core.mjs';
27
+ import { OBS_TYPE_SET } from './lib/obs-types.mjs';
28
+ import { computeStatsFeed } from './lib/stats-core.mjs';
29
+ import { buildLessonNudge } from './lib/save-nudge.mjs';
28
30
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
29
31
  import { buildSessionContextLines } from './hook-context.mjs';
30
32
  import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
@@ -42,7 +44,6 @@ import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatP
42
44
  import { saveObservation } from './lib/save-observation.mjs';
43
45
  import { rebuildObservationDerived, normalizeScope } from './lib/observation-write.mjs';
44
46
  import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
45
- import { computeNoiseGauge } from './lib/stats-quality.mjs';
46
47
  import { recallByFile } from './lib/recall-core.mjs';
47
48
  import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
48
49
  import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
@@ -72,7 +73,7 @@ async function cmdSearch(db, args, { llm } = {}) {
72
73
 
73
74
  const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 20, max: 1000 });
74
75
  const type = flags.type || null;
75
- const validObsTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
76
+ const validObsTypes = OBS_TYPE_SET;
76
77
  if (type && !validObsTypes.has(type)) {
77
78
  fail(`[mem] Invalid --type "${type}". Valid: ${[...validObsTypes].join(', ')}`);
78
79
  return;
@@ -369,7 +370,7 @@ function cmdRecent(db, args) {
369
370
  // try this for "show recent bugfixes". Mirror cmdSearch's enum validation.
370
371
  const type = flags.type || null;
371
372
  if (type) {
372
- const validObsTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
373
+ const validObsTypes = OBS_TYPE_SET;
373
374
  if (!validObsTypes.has(type)) {
374
375
  fail(`[mem] Invalid --type "${type}". Valid: ${[...validObsTypes].join(', ')}`);
375
376
  return;
@@ -803,7 +804,7 @@ function cmdSave(db, args) {
803
804
  if (rejectBareStringFlags(flags, ['title', 'files', 'lesson', 'lesson-learned', 'project', 'type'])) return;
804
805
 
805
806
  const type = flags.type || 'discovery';
806
- const validTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
807
+ const validTypes = OBS_TYPE_SET;
807
808
  if (!validTypes.has(type)) {
808
809
  fail(`[mem] Invalid type "${type}". Valid: ${[...validTypes].join(', ')}`);
809
810
  return;
@@ -912,7 +913,7 @@ function cmdSave(db, args) {
912
913
  const supersededNote = result.supersededIds && result.supersededIds.length > 0
913
914
  ? ` Superseded: ${result.supersededIds.map(i => `#${i}`).join(', ')}.`
914
915
  : '';
915
- out(`[mem] Saved #${result.id} [${result.type}] "${truncate(result.title, 80)}" (project: ${result.project})${lessonNote}${closedNote}${supersededNote}`);
916
+ out(`[mem] Saved #${result.id} [${result.type}] "${truncate(result.title, 80)}" (project: ${result.project})${lessonNote}${closedNote}${supersededNote}${buildLessonNudge({ type: result.type, id: result.id, lessonCaptured: result.lessonCaptured, surface: 'cli' })}`);
916
917
  }
917
918
 
918
919
  // ─── cmdDefer (sub-dispatch: add | list | drop) ──────────────────────────────
@@ -1112,98 +1113,12 @@ async function cmdStats(db, args) {
1112
1113
  return;
1113
1114
  }
1114
1115
 
1115
- const projectFilter = project ? 'AND project = ?' : '';
1116
- const baseParams = project ? [project] : [];
1117
-
1118
1116
  const now = Date.now();
1119
- const cutoff = now - days * 86400000;
1120
-
1121
- // Total counts (aligned with MCP mem_stats: use session_summaries, not sdk_sessions)
1122
- const obsTotal = db.prepare(
1123
- `SELECT COUNT(*) as c FROM observations WHERE 1=1 ${projectFilter}`
1124
- ).get(...baseParams);
1125
- const sessTotal = db.prepare(
1126
- `SELECT COUNT(*) as c FROM session_summaries WHERE 1=1 ${projectFilter}`
1127
- ).get(...baseParams);
1128
- const promptTotal = project
1129
- ? 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)
1130
- : db.prepare('SELECT COUNT(*) as c FROM user_prompts').get();
1131
-
1132
- // Recent counts
1133
- const obsRecent = db.prepare(
1134
- `SELECT COUNT(*) as c FROM observations WHERE created_at_epoch >= ? ${projectFilter}`
1135
- ).get(cutoff, ...baseParams);
1136
- const sessRecent = db.prepare(
1137
- `SELECT COUNT(*) as c FROM session_summaries WHERE created_at_epoch >= ? ${projectFilter}`
1138
- ).get(cutoff, ...baseParams);
1139
-
1140
- // Type distribution (recent)
1141
- const types = db.prepare(`
1142
- SELECT type, COUNT(*) as c FROM observations
1143
- WHERE created_at_epoch >= ? ${projectFilter}
1144
- GROUP BY type ORDER BY c DESC
1145
- `).all(cutoff, ...baseParams);
1146
-
1147
- // Top projects (global view — skipped when filtering by single project; aligned with MCP)
1148
- const projects = project ? [] : db.prepare(`
1149
- SELECT project, COUNT(*) as c FROM observations
1150
- GROUP BY project ORDER BY c DESC LIMIT 20
1151
- `).all();
1152
-
1153
- // Daily activity (last 7 days; aligned with MCP mem_stats)
1154
- const daily = db.prepare(`
1155
- SELECT date(created_at) as day, COUNT(*) as c FROM observations
1156
- WHERE created_at_epoch >= ? ${projectFilter}
1157
- GROUP BY day ORDER BY day DESC LIMIT 7
1158
- `).all(now - 7 * 86400000, ...baseParams);
1159
-
1160
- // Data health (aligned with MCP mem_stats)
1161
- const tokenEst = db.prepare(`
1162
- SELECT SUM(LENGTH(COALESCE(title,'')) + LENGTH(COALESCE(narrative,'')) + LENGTH(COALESCE(text,''))) / 4 as t
1163
- FROM observations WHERE 1=1 ${projectFilter}
1164
- `).get(...baseParams);
1165
- const avgImp = db.prepare(
1166
- `SELECT AVG(COALESCE(importance,1)) as v FROM observations WHERE 1=1 ${projectFilter}`
1167
- ).get(...baseParams);
1168
- const thirtyDaysAgo = now - 30 * 86400000;
1169
- // v3.23 noise-gauge de-blinding: the prior `importance = 1` predicate was
1170
- // structurally blind to imp=0 — decay's floor + the LLM low-signal filter push
1171
- // dormant rows to 0, which on a real store is ~half the live corpus. The gauge
1172
- // therefore reported "0.0% noise" while the store was dormant-heavy. `<= 1` makes
1173
- // imp=0 visible; injection_count=0 mirrors decay's NEVER-INJECTED guard so an
1174
- // injected-but-decayed row (pinned noise, tracked separately) is not miscounted
1175
- // as "never used".
1176
- const lowVal = db.prepare(`
1177
- SELECT COUNT(*) as c FROM observations
1178
- WHERE COALESCE(importance,1) <= 1 AND COALESCE(access_count,0) = 0
1179
- AND COALESCE(injection_count,0) = 0
1180
- AND COALESCE(compressed_into, 0) = 0
1181
- AND created_at_epoch < ? ${projectFilter}
1182
- `).get(thirtyDaysAgo, ...baseParams);
1183
- // Low-signal-title population: template / tool-log titles (Modified, Worked on,
1184
- // Error while working, Error:, node/npm/npx …) that the retrieval layer already
1185
- // filters out by default. The imp=1 "Low-value" metric above structurally can't
1186
- // see these — they often carry inflated importance and recent access — so the
1187
- // health gauge under-reports real noise without this line. Same LOW_SIGNAL
1188
- // pattern source as the read-side filter (lib/low-signal-patterns.mjs).
1189
- const lowSignalTitle = db.prepare(`
1190
- SELECT COUNT(*) as c FROM observations
1191
- WHERE NOT ${buildNotLowSignalSql()}
1192
- AND COALESCE(compressed_into, 0) = 0 ${projectFilter}
1193
- `).get(...baseParams);
1194
- // F7: both noise numerators exclude compressed rows → divide by the LIVE count, not
1195
- // obsTotal (all rows), so a compress-heavy store isn't reported cleaner than it is.
1196
- // Shared with the MCP mem_stats gauge via computeNoiseGauge (lib/stats-quality.mjs).
1197
- const liveTotal = db.prepare(
1198
- `SELECT COUNT(*) as c FROM observations WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}`
1199
- ).get(...baseParams);
1200
- const { noiseRatio, lowSignalRatio } = computeNoiseGauge({ liveTotal: liveTotal.c, lowValCount: lowVal.c, lowSignalCount: lowSignalTitle.c });
1201
- const compressedCount = db.prepare(
1202
- `SELECT COUNT(*) as c FROM observations WHERE compressed_into IS NOT NULL ${projectFilter}`
1203
- ).get(...baseParams);
1204
- const supersededOnlyCount = db.prepare(
1205
- `SELECT COUNT(*) as c FROM observations WHERE superseded_at IS NOT NULL AND compressed_into IS NULL ${projectFilter}`
1206
- ).get(...baseParams);
1117
+ const {
1118
+ obsTotal, sessTotal, promptTotal, obsRecent, sessRecent,
1119
+ types, projects, daily, tokenEst, avgImp, lowVal, lowSignalTitle,
1120
+ noiseRatio, lowSignalRatio, compressedCount, supersededOnlyCount, tierMap,
1121
+ } = computeStatsFeed(db, { project, days, now });
1207
1122
 
1208
1123
  // Hook self-observation: count PreToolUse / Skill-bridge script failures
1209
1124
  // recorded in the last 24h. Surfaces silent breakage (DB corruption,
@@ -1211,17 +1126,6 @@ async function cmdStats(db, args) {
1211
1126
  // failure mode that left code-graph's matcher bug undetected for 10 sessions.
1212
1127
  const hookErrors24h = countRecentHookErrors(join(DB_DIR, 'runtime'), now - 86400000);
1213
1128
 
1214
- // Tier distribution (aligned with MCP mem_stats)
1215
- const tierCtx = { now, currentProject: project || inferProject(), currentSessionId: '' };
1216
- const tdParams = tierSqlParams(tierCtx);
1217
- const tierDist = db.prepare(`
1218
- SELECT tier, COUNT(*) as c FROM (
1219
- SELECT ${TIER_CASE_SQL} as tier FROM observations
1220
- WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
1221
- ) GROUP BY tier ORDER BY tier
1222
- `).all(...tdParams, ...baseParams);
1223
- const tierMap = Object.fromEntries(tierDist.map(r => [r.tier, r.c]));
1224
-
1225
1129
  if (jsonOutput) {
1226
1130
  out(JSON.stringify({
1227
1131
  project,
@@ -1570,7 +1474,7 @@ function cmdUpdate(db, args) {
1570
1474
  updates.push('narrative = ?'); params.push(scrubSecrets(flags.narrative));
1571
1475
  }
1572
1476
  if (flags.type) {
1573
- const validTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
1477
+ const validTypes = OBS_TYPE_SET;
1574
1478
  if (!validTypes.has(flags.type)) {
1575
1479
  fail(`[mem] Invalid type "${flags.type}". Valid: ${[...validTypes].join(', ')}`);
1576
1480
  return;
@@ -1654,7 +1558,7 @@ function cmdExport(db, args) {
1654
1558
  if (flags.type) {
1655
1559
  // Reject unknown types — silently returning [] for `--type bogus` looked like a
1656
1560
  // legitimate empty filter result, hiding the typo. Mirrors cmdSearch / cmdSave / cmdUpdate.
1657
- const validObsTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
1561
+ const validObsTypes = OBS_TYPE_SET;
1658
1562
  if (!validObsTypes.has(flags.type)) {
1659
1563
  fail(`[mem] Invalid --type "${flags.type}". Valid: ${[...validObsTypes].join(', ')}`);
1660
1564
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.48.0",
3
+ "version": "3.49.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -92,6 +92,9 @@
92
92
  "lib/db-backup.mjs",
93
93
  "lib/delete-core.mjs",
94
94
  "lib/events-injection.mjs",
95
+ "lib/stats-core.mjs",
96
+ "lib/obs-types.mjs",
97
+ "lib/save-nudge.mjs",
95
98
  "lib/maintain-core.mjs",
96
99
  "lib/dedup-constants.mjs",
97
100
  "lib/deferred-work.mjs",
package/registry.mjs CHANGED
@@ -376,7 +376,7 @@ const UPSERT_SQL = `
376
376
  repo_url=CASE WHEN excluded.repo_url != '' THEN excluded.repo_url ELSE repo_url END,
377
377
  repo_stars=CASE WHEN excluded.repo_stars > 0 THEN excluded.repo_stars ELSE repo_stars END,
378
378
  local_path=CASE WHEN excluded.local_path != '' THEN excluded.local_path ELSE local_path END,
379
- file_hash=excluded.file_hash,
379
+ file_hash=CASE WHEN excluded.file_hash IS NOT NULL AND excluded.file_hash != '' THEN excluded.file_hash ELSE file_hash END,
380
380
  invocation_name=CASE WHEN excluded.invocation_name != '' THEN excluded.invocation_name ELSE invocation_name END,
381
381
  -- Preserve-on-empty (mirror repo_stars/invocation_name above): a PARTIAL re-upsert --
382
382
  -- e.g. "registry import --name X --capability-summary ...", where mem-cli defaults every
package/schema.mjs CHANGED
@@ -176,7 +176,7 @@ const CORE_SCHEMA = `
176
176
  memory_session_id TEXT NOT NULL,
177
177
  project TEXT NOT NULL,
178
178
  text TEXT,
179
- type TEXT NOT NULL CHECK(type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change')),
179
+ type TEXT NOT NULL CHECK(type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change')), -- keep in sync with lib/obs-types.mjs OBS_TYPES (locked by tests/obs-types-invariant.test.mjs)
180
180
  title TEXT,
181
181
  subtitle TEXT,
182
182
  facts TEXT,
@@ -787,8 +787,11 @@ async function main() {
787
787
  if (matched) {
788
788
  const cooldown = getSkillCooldown();
789
789
  if (!cooldown[matched]) {
790
+ // Registry skill names come from third-party repos (tools/adopt import) — an
791
+ // untrusted boundary like every other DB-derived string on this surface.
792
+ const safeName = neutralizeContextDelimiters(matched);
790
793
  process.stdout.write(
791
- `\n[mem] Skill "${matched}" may apply — invoke via SkillTool or run: claude-mem-lite registry show ${matched}\n`
794
+ `\n[mem] Skill "${safeName}" may apply — invoke via SkillTool or run: claude-mem-lite registry show ${safeName}\n`
792
795
  );
793
796
  setSkillCooldown(matched);
794
797
  }
package/server.mjs CHANGED
@@ -12,7 +12,6 @@ import { reRankWithContext, autoBoostIfNeeded, runIdleCleanup, buildServerInstru
12
12
  import { searchObservationsHybrid } from './search-engine.mjs';
13
13
  import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
14
14
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
15
- import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
16
15
  import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
17
16
  import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
18
17
  import {
@@ -26,6 +25,8 @@ import { snapshotDb } from './lib/db-backup.mjs';
26
25
  import { deleteObservations } from './lib/delete-core.mjs';
27
26
  import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
28
27
  import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
28
+ import { computeStatsFeed } from './lib/stats-core.mjs';
29
+ import { buildLessonNudge } from './lib/save-nudge.mjs';
29
30
  import { formatObsFieldValue } from './cli/common.mjs';
30
31
  import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memOptimizeSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema, memDeferSchema, memDeferListSchema, memDeferDropSchema, tools as TOOL_DEFS } from './tool-schemas.mjs';
31
32
 
@@ -46,7 +47,6 @@ import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-ro
46
47
  import { saveObservation } from './lib/save-observation.mjs';
47
48
  import { rebuildObservationDerived } from './lib/observation-write.mjs';
48
49
  import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
49
- import { computeNoiseGauge } from './lib/stats-quality.mjs';
50
50
  import { recallByFile } from './lib/recall-core.mjs';
51
51
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
52
52
  import {
@@ -750,7 +750,8 @@ server.registerTool(
750
750
  const supersededNote = result.supersededIds && result.supersededIds.length > 0
751
751
  ? ` Superseded: ${result.supersededIds.map(i => `#${i}`).join(', ')}.`
752
752
  : '';
753
- return { content: [{ type: 'text', text: `Saved as observation #${result.id} [${result.type}] in project "${project}".${lessonNote}${closedNote}${supersededNote}` }] };
753
+ const nudge = buildLessonNudge({ type: result.type, id: result.id, lessonCaptured: result.lessonCaptured, surface: 'mcp' });
754
+ return { content: [{ type: 'text', text: `Saved as observation #${result.id} [${result.type}] in project "${project}".${lessonNote}${closedNote}${supersededNote}${nudge}` }] };
754
755
  })
755
756
  );
756
757
 
@@ -848,97 +849,11 @@ server.registerTool(
848
849
  return { content: [{ type: 'text', text: formatQualityReport(data) }] };
849
850
  }
850
851
 
851
- const cutoff = Date.now() - days * 86400000;
852
- const projectFilter = args.project ? 'AND project = ?' : '';
853
- const baseParams = args.project ? [args.project] : [];
854
-
855
- // Total counts
856
- const obsTotal = db.prepare(`SELECT COUNT(*) as c FROM observations WHERE 1=1 ${projectFilter}`).get(...baseParams);
857
- const sessTotal = db.prepare(`SELECT COUNT(*) as c FROM session_summaries WHERE 1=1 ${projectFilter}`).get(...baseParams);
858
- const promptTotal = args.project
859
- ? 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(args.project)
860
- : db.prepare(`SELECT COUNT(*) as c FROM user_prompts`).get();
861
-
862
- // Recent counts
863
- const obsRecent = db.prepare(`SELECT COUNT(*) as c FROM observations WHERE created_at_epoch >= ? ${projectFilter}`).get(cutoff, ...baseParams);
864
- const sessRecent = db.prepare(`SELECT COUNT(*) as c FROM session_summaries WHERE created_at_epoch >= ? ${projectFilter}`).get(cutoff, ...baseParams);
865
-
866
- // Type distribution (recent)
867
- const types = db.prepare(`
868
- SELECT type, COUNT(*) as c FROM observations
869
- WHERE created_at_epoch >= ? ${projectFilter}
870
- GROUP BY type ORDER BY c DESC
871
- `).all(cutoff, ...baseParams);
872
-
873
- // Projects (global view — skipped when filtering by single project)
874
- const projects = args.project ? [] : db.prepare(`
875
- SELECT project, COUNT(*) as c FROM observations
876
- GROUP BY project ORDER BY c DESC
877
- LIMIT 20
878
- `).all();
879
-
880
- // Daily activity (last 7 days)
881
- const daily = db.prepare(`
882
- SELECT date(created_at) as day, COUNT(*) as c FROM observations
883
- WHERE created_at_epoch >= ? ${projectFilter}
884
- GROUP BY day ORDER BY day DESC
885
- LIMIT 7
886
- `).all(Date.now() - 7 * 86400000, ...baseParams);
887
-
888
- // Health metrics
889
- const tokenEst = db.prepare(`
890
- SELECT SUM(LENGTH(COALESCE(title,'')) + LENGTH(COALESCE(narrative,'')) + LENGTH(COALESCE(text,''))) / 4 as t
891
- FROM observations WHERE 1=1 ${projectFilter}
892
- `).get(...baseParams);
893
-
894
- const avgImp = db.prepare(`
895
- SELECT AVG(COALESCE(importance,1)) as v FROM observations WHERE 1=1 ${projectFilter}
896
- `).get(...baseParams);
897
-
898
- const thirtyDaysAgo = Date.now() - 30 * 86400000;
899
- // v3.23 noise-gauge de-blinding (mirrors mem-cli cmdStats): `<= 1` makes the
900
- // imp=0 dormant population visible (decay floor + LLM low-signal filter push
901
- // ~half the live corpus to 0); injection_count=0 mirrors decay's NEVER-INJECTED
902
- // guard so injected-but-decayed pinned noise isn't miscounted as "never used".
903
- const lowVal = db.prepare(`
904
- SELECT COUNT(*) as c FROM observations
905
- WHERE COALESCE(importance,1) <= 1 AND COALESCE(access_count,0) = 0
906
- AND COALESCE(injection_count,0) = 0
907
- AND COALESCE(compressed_into, 0) = 0
908
- AND created_at_epoch < ? ${projectFilter}
909
- `).get(thirtyDaysAgo, ...baseParams);
910
-
911
- // Low-signal-title population (template / tool-log titles the read-side filter
912
- // already excludes). The imp=1 "Low-value" metric can't see these, so the
913
- // gauge under-reports real noise without it. See lib/low-signal-patterns.mjs.
914
- const lowSignalTitle = db.prepare(`
915
- SELECT COUNT(*) as c FROM observations
916
- WHERE NOT ${buildNotLowSignalSql()}
917
- AND COALESCE(compressed_into, 0) = 0 ${projectFilter}
918
- `).get(...baseParams);
919
- // F7: both noise numerators exclude compressed rows → divide by the LIVE count, not
920
- // obsTotal (all rows), so a compress-heavy store isn't reported cleaner than it is.
921
- const liveTotal = db.prepare(
922
- `SELECT COUNT(*) as c FROM observations WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}`
923
- ).get(...baseParams);
924
- const { noiseRatio, lowSignalRatio } = computeNoiseGauge({ liveTotal: liveTotal.c, lowValCount: lowVal.c, lowSignalCount: lowSignalTitle.c });
925
- const compressedCount = db.prepare(`
926
- SELECT COUNT(*) as c FROM observations WHERE compressed_into IS NOT NULL ${projectFilter}
927
- `).get(...baseParams);
928
- const supersededOnlyCount = db.prepare(`
929
- SELECT COUNT(*) as c FROM observations WHERE superseded_at IS NOT NULL AND compressed_into IS NULL ${projectFilter}
930
- `).get(...baseParams);
931
-
932
- // Tier distribution
933
- const tierCtx = { now: Date.now(), currentProject: args.project || inferProject(), currentSessionId: '' };
934
- const tdParams = tierSqlParams(tierCtx);
935
- const tierDist = db.prepare(`
936
- SELECT tier, COUNT(*) as c FROM (
937
- SELECT ${TIER_CASE_SQL} as tier FROM observations
938
- WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
939
- ) GROUP BY tier ORDER BY tier
940
- `).all(...tdParams, ...baseParams);
941
- const tierMap = Object.fromEntries(tierDist.map(r => [r.tier, r.c]));
852
+ const {
853
+ obsTotal, sessTotal, promptTotal, obsRecent, sessRecent,
854
+ types, projects, daily, tokenEst, avgImp, lowVal, lowSignalTitle,
855
+ noiseRatio, lowSignalRatio, compressedCount, supersededOnlyCount, tierMap,
856
+ } = computeStatsFeed(db, { project: args.project || null, days });
942
857
 
943
858
  const lines = [
944
859
  `Memory Statistics${args.project ? ` (project: ${args.project})` : ''}:`,
package/source-files.mjs CHANGED
@@ -162,6 +162,19 @@ export const SOURCE_FILES = [
162
162
  // (cmdDelete) — extracted to kill the byte-duplicated twin. Missing it from the
163
163
  // manifest would leave both delete surfaces unsigned/broken on auto-update.
164
164
  'lib/delete-core.mjs',
165
+ // Shared primary stats feed (audit 2026-07-17 MED-4) — statically imported by
166
+ // server.mjs (mem_stats) and mem-cli.mjs (cmdStats); killed the ~80-line
167
+ // byte-duplicated twin. Missing it breaks both stats surfaces on auto-update.
168
+ 'lib/stats-core.mjs',
169
+ // Observation `type` vocabulary single source (audit 2026-07-17 MED-3) —
170
+ // statically imported by tool-schemas.mjs, mem-cli.mjs, hook-llm.mjs,
171
+ // hook-optimize.mjs, lib/activity.mjs. Missing it breaks every save/validate
172
+ // path on auto-update.
173
+ 'lib/obs-types.mjs',
174
+ // Save-time lesson nudge (audit 2026-07-17 P4) — statically imported by server.mjs
175
+ // (mem_save) and mem-cli.mjs (cmdSave). Missing it breaks both save surfaces on
176
+ // auto-update.
177
+ 'lib/save-nudge.mjs',
165
178
  // P10 dedup/merge threshold constants — single source of truth for the Jaccard
166
179
  // dedup/merge cutoffs. Statically imported by hook.mjs, hook-llm.mjs,
167
180
  // hook-optimize.mjs, mem-cli.mjs, server.mjs, and the save/maintain cores;
package/tool-schemas.mjs CHANGED
@@ -3,8 +3,9 @@
3
3
 
4
4
  import { z } from 'zod';
5
5
  import { CLI_INVOKE } from './cli-path.mjs';
6
+ import { OBS_TYPES } from './lib/obs-types.mjs';
6
7
 
7
- export const OBS_TYPE_ENUM = z.enum(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
8
+ export const OBS_TYPE_ENUM = z.enum([...OBS_TYPES]);
8
9
 
9
10
  // LLM-friendly coercion: accept string numbers and normalize to proper types
10
11
  const coerceInt = z.preprocess(