claude-mem-lite 3.47.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.47.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.47.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-context.mjs CHANGED
@@ -8,8 +8,9 @@ import {
8
8
  debugLog, debugCatch, neutralizeContextDelimiters,
9
9
  DECAY_HALF_LIFE_BY_TYPE, DEFAULT_DECAY_HALF_LIFE_MS, notLowSignalTitleClause,
10
10
  } from './utils.mjs';
11
- import { STALE_SESSION_MS, FALLBACK_OBS_WINDOW_MS, RUNTIME_DIR, effectiveQuiet } from './hook-shared.mjs';
11
+ import { STALE_SESSION_MS, FALLBACK_OBS_WINDOW_MS, RUNTIME_DIR, effectiveQuiet, isQuietHooks } from './hook-shared.mjs';
12
12
  import { extractUnfinishedSummary } from './hook-handoff.mjs';
13
+ import { recentInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
13
14
 
14
15
  /**
15
16
  * Infer the project directory from environment variables or cwd.
@@ -390,6 +391,24 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
390
391
  }
391
392
  }
392
393
 
394
+ // HIGH-1 (full audit 2026-07-16): surface recent high-importance events — the
395
+ // canonical store for promoted bugfix/decision/lesson memories that
396
+ // persistHaikuSummary upgrade-deletes out of observations. Without this section
397
+ // SessionStart never shows them. E# prefix keeps citation extractors (bare-`#`
398
+ // anchored) from reading an event id as an observation id. Gated on isQuietHooks()
399
+ // ONLY (explicit low-noise opt-out), NOT effectiveQuiet: unlike Key Context, events
400
+ // never appear in the obs-only Recent table and are absent from the MEMORY.md
401
+ // sentinel, so an adopted project (the default) would otherwise have zero
402
+ // SessionStart surface for them. Never throws (recentInjectableEvents catches).
403
+ if (!isQuietHooks()) {
404
+ const keyEvents = recentInjectableEvents(db, { project, limit: 5 });
405
+ if (keyEvents.length > 0) {
406
+ summaryLines.push('### Key Events');
407
+ for (const e of keyEvents) summaryLines.push(`- ${renderInjectableEvent(e)}`);
408
+ summaryLines.push('');
409
+ }
410
+ }
411
+
393
412
  // 5. Working state from latest /clear handoff.
394
413
  // Session scoping: when currentCcSessionId is provided, restrict to this session's
395
414
  // own clear handoff so parallel sessions don't see each other's Working State block.
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/hook.mjs CHANGED
@@ -61,6 +61,7 @@ import {
61
61
  import { resolveEdgeAttribution, readPreRecallFileEdges } from './lib/edge-attribution.mjs';
62
62
  import { extractTailAssistantText, extractStructuredSummary } from './lib/summary-extractor.mjs';
63
63
  import { searchRelevantMemories, formatMemoryLine, selectImperativeLesson } from './hook-memory.mjs';
64
+ import { searchInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
64
65
  import { formatTaskImperative } from './lib/task-imperative.mjs';
65
66
  import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
66
67
  import { gcOldMetricShards } from './lib/metrics.mjs';
@@ -87,7 +88,7 @@ import { getVocabulary } from './tfidf.mjs';
87
88
  // Prevent recursive hooks from background claude -p calls
88
89
  // Background workers (llm-episode, llm-summary) are exempt — they're ours
89
90
  const event = process.argv[2];
90
- const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'auto-compress', 'llm-optimize']);
91
+ const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'auto-compress', 'llm-optimize', 'auto-maintain']);
91
92
 
92
93
  // Respect Claude Code plugin disable state even when legacy settings.json hooks remain.
93
94
  // install.mjs writes direct hooks into ~/.claude/settings.json, so disabling the plugin
@@ -420,7 +421,18 @@ function triggerErrorRecall(db, toolInput, response) {
420
421
  `).all(ftsQuery, project, nowR);
421
422
 
422
423
  const out = formatErrorRecallHints(rows);
423
- if (out) process.stdout.write(out);
424
+ if (out) {
425
+ // MED-3 (full audit 2026-07-16): emit via the JSON envelope (trailing '\n'),
426
+ // NOT raw stdout. A raw multi-line write corrupts a co-emitted episode-flush
427
+ // receipt (both write to PostToolUse stdout in the same call → `<text>{json}`)
428
+ // and is silently dropped on CC variants that ignore plain-text PostToolUse
429
+ // stdout. This is the same suppressOutput+additionalContext channel
430
+ // flushEpisode uses; two separate JSON lines each parse independently.
431
+ process.stdout.write(JSON.stringify({
432
+ suppressOutput: true,
433
+ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: out },
434
+ }) + '\n');
435
+ }
424
436
  } catch (e) { debugCatch(e, 'triggerErrorRecall'); }
425
437
  }
426
438
 
@@ -975,6 +987,29 @@ function runSessionStartAutoMaintain(db) {
975
987
  }
976
988
  }
977
989
 
990
+ // SessionStart boot path (MED-4): cheap 24h gate pre-check only. When maintenance is
991
+ // due, the heavy pass (VACUUM-INTO snapshot + purge/cleanup/decay/dedup) is handed to
992
+ // a detached `auto-maintain` worker via spawnBackground so it never blocks interactive
993
+ // session start. The worker re-checks the same gate (idempotent) before doing the work.
994
+ function scheduleSessionStartAutoMaintain() {
995
+ const maintainFile = join(RUNTIME_DIR, 'last-auto-maintain.json');
996
+ try {
997
+ const last = JSON.parse(readFileSync(maintainFile, 'utf8'));
998
+ if (Date.now() - last.epoch < 24 * 3600000) return; // not due — no spawn
999
+ } catch { /* no gate file → due */ }
1000
+ if (!process.env.CLAUDE_MEM_SKIP_MAINTAIN) spawnBackground('auto-maintain');
1001
+ }
1002
+
1003
+ // Detached `auto-maintain` worker entry: opens its own DB and runs the maintenance
1004
+ // pass off the interactive boot path. runSessionStartAutoMaintain still owns the 24h
1005
+ // gate + the compress/optimize spawns at its tail.
1006
+ function handleAutoMaintain() {
1007
+ const db = openDb();
1008
+ if (!db) return;
1009
+ try { runSessionStartAutoMaintain(db); }
1010
+ finally { try { db.close(); } catch { /* ignore */ } }
1011
+ }
1012
+
978
1013
  function saveHandoffAndFastSummary(db, { prevSessionId, prevProject, project, ccSessionId, episodeSnapshot, now }) {
979
1014
  // Shared clear handoff reference — queried once, used by fast summary + working state
980
1015
  let prevClearHandoff = null;
@@ -1285,7 +1320,7 @@ async function handleSessionStart() {
1285
1320
 
1286
1321
  runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now });
1287
1322
 
1288
- runSessionStartAutoMaintain(db);
1323
+ scheduleSessionStartAutoMaintain();
1289
1324
 
1290
1325
  // ── Non-transactional operations (side effects, background work) ──
1291
1326
 
@@ -1520,6 +1555,21 @@ async function handleUserPrompt() {
1520
1555
  lines.push('</memory-context>');
1521
1556
  process.stdout.write(lines.join('\n') + '\n');
1522
1557
  }
1558
+ // HIGH-1 (full audit 2026-07-16): surface FTS-matched events — the canonical
1559
+ // store for promoted bugfix/decision/lesson memories that persistHaikuSummary
1560
+ // upgrade-deletes out of observations. Without this leg they are unreachable at
1561
+ // prompt time. Separate E#-tagged block so it doesn't perturb observation
1562
+ // ranking and citation extractors (bare-`#` anchored) never read an event id as
1563
+ // an obs id. Nested try so an events failure can't suppress the imperative pick.
1564
+ try {
1565
+ const events = searchInjectableEvents(db, { prompt: promptText, project });
1566
+ if (events.length > 0) {
1567
+ const elines = ['<memory-context relevance="events">'];
1568
+ for (const e of events) elines.push(`- ${renderInjectableEvent(e)}`);
1569
+ elines.push('</memory-context>');
1570
+ process.stdout.write(elines.join('\n') + '\n');
1571
+ }
1572
+ } catch (e) { debugCatch(e, 'handleUserPrompt-events'); }
1523
1573
  if (imperativePick) {
1524
1574
  // Guard the write on a non-empty return — formatTaskImperative yields '' for a
1525
1575
  // lesson that strips to empty (e.g. "."), which would otherwise emit a bare line.
@@ -1639,6 +1689,7 @@ try {
1639
1689
  case 'llm-episode': await handleLLMEpisode(); break;
1640
1690
  case 'llm-summary': await handleLLMSummary(); break;
1641
1691
  case 'auto-compress': handleAutoCompress(); break;
1692
+ case 'auto-maintain': handleAutoMaintain(); break;
1642
1693
  case 'llm-optimize': await handleLLMOptimize(); break;
1643
1694
  // Detached update refresh spawned by handleSessionStart (audit P3d) — does the
1644
1695
  // GitHub fetch + (non-plugin) install off the SessionStart critical path,
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
  /**
@@ -707,7 +707,13 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
707
707
  }
708
708
  }
709
709
  });
710
- try { txn(); } catch (e) { debugCatch(e, 'applyCitationDecay-txn'); return empty; }
710
+ // IMMEDIATE (not the default DEFERRED): this txn reads each obs then writes it.
711
+ // Under concurrent same-project Stop hooks a DEFERRED txn pins a read snapshot
712
+ // first, and the second session's write then hits SQLITE_BUSY_SNAPSHOT — a
713
+ // snapshot-upgrade conflict busy_timeout cannot resolve — silently dropping the
714
+ // whole decay pass (promotes/demotes/streak + the paired funnel deltas). Taking
715
+ // the write lock up front lets busy_timeout actually serialize the two sessions.
716
+ try { txn.immediate(); } catch (e) { debugCatch(e, 'applyCitationDecay-txn'); return empty; }
711
717
  return { promoted, demoted, touched };
712
718
  }
713
719
 
@@ -0,0 +1,72 @@
1
+ // lib/delete-core.mjs — shared hard-delete orchestration for the CLI `delete`
2
+ // command (mem-cli.mjs cmdDelete) and the MCP `mem_delete` tool (server.mjs).
3
+ // Both surfaces previously inlined a byte-for-byte-equivalent copy of this logic,
4
+ // kept in sync by "aligned with MCP mem_delete" / "matching the CLI delete" parity
5
+ // comments — the project's documented #1 drift risk. Consolidated here so there is
6
+ // one implementation: pre-delete snapshot, stale related_ids cleanup,
7
+ // merged/compressed-child recovery, and the delete transaction.
8
+ import { snapshotDb } from './db-backup.mjs';
9
+ import { recoverChildrenOf } from './maintain-core.mjs';
10
+ import { debugCatch } from '../utils.mjs';
11
+
12
+ /**
13
+ * Hard-delete the given observation ids with full orchestration. The CALLER owns
14
+ * the confirm/preview gating and fetches whatever rows it needs to render (existence
15
+ * check, preview list, missing-id note); this performs only the irreversible mutation
16
+ * once the caller has decided to proceed.
17
+ *
18
+ * Order (preserved from the pre-extraction inline paths):
19
+ * 1. snapshotDb OUTSIDE any transaction (VACUUM INTO cannot run inside one). Best-
20
+ * effort: no-op on :memory:, never throws.
21
+ * 2. In ONE transaction: strip the deleted ids out of other rows' related_ids
22
+ * (coarse LIKE pre-filter → JSON-parse precise filter), recover children
23
+ * merged/compressed INTO the doomed rows (compressed_into has no FK), then DELETE.
24
+ *
25
+ * @param {import('better-sqlite3').Database} db
26
+ * @param {number[]} ids observation ids to delete
27
+ * @param {{ snapshotTag?: string }} [opts]
28
+ * @returns {{ deleted: number, recoveredChildren: number, snapshotPath: string|null }}
29
+ */
30
+ export function deleteObservations(db, ids, { snapshotTag = 'pre-delete' } = {}) {
31
+ const placeholders = ids.map(() => '?').join(',');
32
+
33
+ // Snapshot before the irreversible hard-delete so a wrong-id delete has a pre-image,
34
+ // matching the maintain purge/cleanup hard-delete paths (audit MED-2). Best-effort
35
+ // (never throws, skips :memory:). Must run OUTSIDE the transaction below (VACUUM INTO).
36
+ const snapshotPath = snapshotDb(db, { tag: snapshotTag });
37
+
38
+ const deletedIds = new Set(ids);
39
+ const deleteTx = db.transaction(() => {
40
+ // Clean up stale references in other observations' related_ids.
41
+ // LIKE %id% is a coarse pre-filter (false positives: %1% matches [10], [21]) — it only
42
+ // narrows which rows to fetch; the JSON parse + Set.has below is the precise filter.
43
+ // Acceptable because observation count per user is typically <10K.
44
+ const likeConditions = ids.map(() => `related_ids LIKE ?`).join(' OR ');
45
+ const likeParams = ids.map(id => `%${id}%`);
46
+ const referencing = db.prepare(`
47
+ SELECT id, related_ids FROM observations
48
+ WHERE related_ids IS NOT NULL AND related_ids != '[]' AND (${likeConditions})
49
+ `).all(...likeParams);
50
+ for (const r of referencing) {
51
+ let refIds;
52
+ try { refIds = JSON.parse(r.related_ids); } catch (e) { debugCatch(e, 'deleteRelatedIds'); continue; }
53
+ // Only rewrite a well-formed integer array; a malformed related_ids value is left
54
+ // untouched rather than reshaped by the filter (the stricter of the two originals).
55
+ if (!Array.isArray(refIds) || !refIds.every(id => Number.isInteger(id))) continue;
56
+ const filtered = refIds.filter(id => !deletedIds.has(id));
57
+ if (filtered.length !== refIds.length) {
58
+ db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(JSON.stringify(filtered), r.id);
59
+ }
60
+ }
61
+ // Resurface rows merged/compressed INTO the doomed keepers before deleting, else they
62
+ // dangle behind a now-missing parent (compressed_into has no FK) — invisible to every
63
+ // COALESCE(compressed_into,0)=0 view and unrecoverable. Same guard the maintain
64
+ // hard-delete paths use (recoverChildrenOf).
65
+ const recovered = recoverChildrenOf(db, ids);
66
+ // Execute deletion (FTS5 cleanup handled by the observations_ad trigger).
67
+ const deleted = db.prepare(`DELETE FROM observations WHERE id IN (${placeholders})`).run(...ids);
68
+ return { changes: deleted.changes, recovered };
69
+ });
70
+ const result = deleteTx();
71
+ return { deleted: result.changes, recoveredChildren: result.recovered, snapshotPath };
72
+ }
@@ -0,0 +1,100 @@
1
+ // lib/events-injection.mjs — surface `events` into the passive injection surfaces.
2
+ //
3
+ // HIGH-1 (full audit 2026-07-16): persistHaikuSummary upgrade-deletes every
4
+ // event-typed memory (bugfix/decision/lesson/discovery/…) out of `observations`
5
+ // and into the `events` table, so after Haiku enrichment the high-value history
6
+ // lives ONLY in events. v3.44 wired events into mem_search + PreToolUse recall,
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.
15
+ //
16
+ // id-space discipline: events share the numeric id space with observations but are a
17
+ // separate table, so injected events are rendered with an `E#` prefix. The
18
+ // citation-tracker injected-id extractors all anchor on a BARE `#`
19
+ // (FYI `/^#\d/`, memory-context `/\(#\d/`, error-recall row-anchored), so an `E#`
20
+ // row is never mis-attributed to an observation id (which would let citation decay
21
+ // mutate an unrelated observation sharing that id). Events carry no citation columns,
22
+ // so they inject as reference-only — reachability, not decay bookkeeping.
23
+
24
+ import { searchEventsFts } from './search-core.mjs';
25
+ import { neutralizeContextDelimiters } from '../format-utils.mjs';
26
+ import { sanitizeFtsQuery } from '../utils.mjs';
27
+
28
+ const DEFAULT_LIMIT = 3;
29
+ const DEFAULT_MIN_IMPORTANCE = 2;
30
+ const TITLE_MAX = 80;
31
+ const LESSON_MAX = 160;
32
+
33
+ function normalizeRow(r) {
34
+ return {
35
+ id: r.id,
36
+ type: r.event_type || r.type || 'event',
37
+ title: r.title || '',
38
+ lesson_learned: (r.body ?? r.lesson_learned) || null,
39
+ importance: r.importance,
40
+ created_at_epoch: r.created_at_epoch,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * FTS-matched events for a prompt (UserPromptSubmit surfaces). Superseded events are
46
+ * excluded by searchEventsFts; importance floor drops low-value rows. Never throws.
47
+ * @returns {Array<{id,type,title,lesson_learned,importance,created_at_epoch}>}
48
+ */
49
+ export function searchInjectableEvents(db, { prompt, ftsQuery, project, limit = DEFAULT_LIMIT, minImportance = DEFAULT_MIN_IMPORTANCE } = {}) {
50
+ if (!db || !project) return [];
51
+ const q = ftsQuery || (prompt ? sanitizeFtsQuery(prompt) : null);
52
+ if (!q) return [];
53
+ try {
54
+ const rows = searchEventsFts(db, {
55
+ ftsQuery: q, project, projectBoost: project,
56
+ importance: minImportance, perSourceLimit: limit,
57
+ });
58
+ return rows.map(normalizeRow);
59
+ } catch { return []; }
60
+ }
61
+
62
+ /**
63
+ * Recency + importance events (SessionStart context — no FTS query available).
64
+ * Excludes superseded events. Never throws.
65
+ * @returns {Array<{id,type,title,lesson_learned,importance,created_at_epoch}>}
66
+ */
67
+ export function recentInjectableEvents(db, { project, limit = DEFAULT_LIMIT, minImportance = DEFAULT_MIN_IMPORTANCE } = {}) {
68
+ if (!db || !project) return [];
69
+ try {
70
+ const rows = db.prepare(`
71
+ SELECT id, event_type, title, body, importance, created_at_epoch
72
+ FROM events
73
+ WHERE project = ?
74
+ AND superseded_at_epoch IS NULL
75
+ AND COALESCE(importance, 1) >= ?
76
+ ORDER BY created_at_epoch DESC
77
+ LIMIT ?
78
+ `).all(project, minImportance, limit);
79
+ return rows.map(normalizeRow);
80
+ } catch { return []; }
81
+ }
82
+
83
+ /**
84
+ * Render one injectable event as a defanged `E#<id> [<type>] <title> — <lesson>` line.
85
+ * The E# prefix keeps citation-tracker extractors (which anchor on a bare `#`) from
86
+ * mis-reading the event id as an observation id.
87
+ */
88
+ export function renderInjectableEvent(row) {
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.
94
+ const head = `E#${row.id} [${row.type}] ${title}`;
95
+ if (row.lesson_learned) {
96
+ const lesson = neutralizeContextDelimiters(row.lesson_learned.trim().slice(0, LESSON_MAX));
97
+ if (lesson) return `${head} — ${lesson}`;
98
+ }
99
+ return head;
100
+ }
@@ -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,17 +245,59 @@ 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 (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
+ ];
278
+
248
279
  /**
249
280
  * Normalize each source's BM25 scores to [-1, 0] before cross-source merge.
250
281
  * Prevents observations (BM25 can reach -40) from systematically outranking
251
- * sessions (-6) and prompts (-1) regardless of relevance. Sources with a
252
- * single scored row are skipped normalizing would inflate a weak match to
253
- * -1.0. Mutates `results` in place; callers re-sort afterwards.
282
+ * sessions (-6) and prompts (-1) regardless of relevance. A source with a single
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.
254
286
  */
255
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;
256
290
  for (const src of ['obs', 'session', 'prompt', 'event']) {
257
- const srcResults = results.filter((r) => r[sourceKey] === src && r.score !== null && r.score !== undefined);
258
- if (srcResults.length < 2) continue;
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);
295
+ if (srcResults.length === 0) continue;
296
+ if (srcResults.length === 1) {
297
+ const ratio = globalMaxAbs > 0 ? Math.abs(srcResults[0].score) / globalMaxAbs : 0;
298
+ srcResults[0].score = SINGLE_MATCH_BANDS.find(([floor]) => ratio >= floor)[1];
299
+ continue;
300
+ }
259
301
  const maxAbs = Math.max(...srcResults.map((r) => Math.abs(r.score)));
260
302
  if (maxAbs > 0) {
261
303
  for (const r of srcResults) r.score = r.score / maxAbs;