claude-mem-lite 3.6.0 → 3.7.1

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.
package/mem-cli.mjs CHANGED
@@ -8,9 +8,9 @@ import { truncate, typeIcon, inferProject, scrubSecrets } from './utils.mjs';
8
8
  import { resolveProject } from './project-utils.mjs';
9
9
  import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
10
10
  import { _resetVocabCache } from './tfidf.mjs';
11
- import { autoBoostIfNeeded, reRankWithContext, markSuperseded } from './server-internals.mjs';
12
- import { searchObservationsHybrid, countSearchTotal, attachBodyTokens } from './search-engine.mjs';
13
- import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady, hasEscalatableCorpus } from './deep-search.mjs';
11
+ import { autoBoostIfNeeded, reRankWithContext, markSuperseded } from './search-scoring.mjs';
12
+ import { searchObservationsHybrid } from './search-engine.mjs';
13
+ import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
14
14
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
15
15
  import { searchResources } from './registry-retriever.mjs';
16
16
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
@@ -37,7 +37,7 @@ import { saveObservation } from './lib/save-observation.mjs';
37
37
  import { rebuildObservationDerived } from './lib/observation-write.mjs';
38
38
  import { recallByFile } from './lib/recall-core.mjs';
39
39
  import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
40
- import { buildSearchFtsQuery, parseDateBounds, computePerSourceWindow, effectiveObsFtsQuery, searchSessionsFts, searchPromptsFts, normalizeCrossSourceScores, applyUserSort, applyTierFilter } from './lib/search-core.mjs';
40
+ import { buildSearchFtsQuery, parseDateBounds, coreRunSearchPipeline } from './lib/search-core.mjs';
41
41
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
42
42
  import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
43
43
  import { computeCitationFunnelTrend } from './lib/citation-tracker.mjs';
@@ -156,116 +156,51 @@ async function cmdSearch(db, args, { llm } = {}) {
156
156
  ? 'observations'
157
157
  : (source || ((type || tier || minImportance || branch) ? 'observations' : null));
158
158
 
159
- // Cross-source mode: each source needs more candidates than the final limit
160
- // so the post-merge sort has room to pick the best from each (shared sizing
161
- // with mem_search without this, obs gets systematically squeezed out by
162
- // sessions). Over-fetch from offset 0; --offset applies ONCE at the final
163
- // slice below (see computePerSourceWindow for the #8217/#8638 rationale).
164
- const isCrossSourceMode = !effectiveSource;
165
- const { perSourceLimit, perSourceOffset } = computePerSourceWindow(limit, offset);
166
-
167
- const results = [];
168
- // Tracks whether AND returned 0 and OR recovered non-empty. Mirrors server.mjs
169
- // ctx.orFallbackFired so the header can surface a "(relaxed AND→OR)" hint.
170
- let orFallbackFired = false;
171
-
172
- let deepVariants = null;
173
- let isReranked = false;
174
- let isDeep = deepMode === 'deep';
175
-
176
- // Search observations shared engine with server.mjs (#8198/#8212 paired-path fix)
177
- if (!effectiveSource || effectiveSource === 'observations') {
178
- const obsCtx = {
179
- ftsQuery,
180
- args: {
181
- project: project || null,
182
- obs_type: type || null,
183
- importance: minImportance || null,
184
- branch: branch || null,
185
- include_noise: includeNoise,
186
- },
187
- epochFrom: dateFrom,
188
- epochTo: dateTo,
189
- perSourceLimit,
190
- perSourceOffset,
191
- currentProject: project ? null : inferProject(),
192
- limit,
193
- orFallbackFired: false,
194
- };
195
-
196
- const runDeep = async ({ auto = false } = {}) => {
197
- const ds = await deepSearch(db, {
198
- query,
199
- project: project || null,
200
- type: type || null,
201
- importance: minImportance || null,
202
- branch: branch || null,
203
- includeNoise,
204
- epochFrom: dateFrom,
205
- epochTo: dateTo,
206
- limit: perSourceLimit,
207
- currentProject: project ? null : inferProject(),
208
- }, llm ? { llm, rerank: rerank && !auto } : { auto, rerank: rerank && !auto });
209
- deepVariants = ds.variants;
210
- isReranked = ds.reranked;
211
- if (deepVariants.length > 1) {
212
- process.stderr.write(`[mem] Deep search: rewrote into ${deepVariants.length} query variants, RRF-fused\n`);
213
- } else {
214
- process.stderr.write('[mem] Deep search: rewrite returned no usable variants; used original query only\n');
215
- }
216
- if (rerank && !auto) {
217
- process.stderr.write(ds.reranked
218
- ? '[mem] Deep search: LLM-reranked the fused top-20\n'
219
- : '[mem] Deep search: rerank produced no usable order; kept fused order\n');
220
- }
221
- return ds.results;
222
- };
223
-
224
- let obsResults;
225
- if (deepMode === 'deep') {
226
- obsResults = await runDeep();
227
- } else {
228
- obsResults = searchObservationsHybrid(db, obsCtx);
229
- if (obsCtx.orFallbackFired) orFallbackFired = true;
230
- if (deepMode === 'auto' && autoDeepLlmReady(process.env, llm) && shouldEscalateToDeep(obsResults, obsCtx) && hasEscalatableCorpus(db, project || null)) {
231
- process.stderr.write(`[mem] auto-escalated to deep search (weak results: ${obsResults.length} hits)\n`);
232
- obsResults = await runDeep({ auto: true });
233
- isDeep = true;
234
- }
235
- }
236
- for (const r of obsResults) results.push({ ...r, _source: 'obs', score: r.score ?? 0 });
237
-
238
- // Tier post-filter — applied to ALL obs results from the engine.
239
- if (tier) {
240
- const filtered = applyTierFilter(db, results, { tier, sourceKey: '_source', currentProject: project || inferProject() });
241
- results.length = 0;
242
- results.push(...filtered);
243
- }
244
- }
245
-
246
- // Search sessions (shared engine with MCP mem_search — lib/search-core.mjs)
247
- if ((!effectiveSource || effectiveSource === 'sessions') && !isDeep) {
248
- try {
249
- const sessRows = searchSessionsFts(db, {
250
- ftsQuery, project, projectBoost: project ? null : inferProject(),
251
- epochFrom: dateFrom, epochTo: dateTo, perSourceLimit, perSourceOffset,
252
- });
253
- for (const r of sessRows) results.push({ ...r, _source: 'session' });
254
- } catch { /* session FTS may not exist in older DBs */ }
255
- }
256
-
257
- // Search prompts (shared engine incl. CJK precision gate + LIKE fallback)
258
- if ((!effectiveSource || effectiveSource === 'prompts') && !isDeep) {
259
- try {
260
- const promptRows = searchPromptsFts(db, {
261
- query, ftsQuery, project,
262
- epochFrom: dateFrom, epochTo: dateTo, perSourceLimit, perSourceOffset,
263
- });
264
- for (const r of promptRows) results.push({ ...r, _source: 'prompt' });
265
- } catch { /* prompt FTS may not exist in older DBs */ }
266
- }
267
-
268
- if (results.length === 0) {
159
+ const res = await coreRunSearchPipeline(
160
+ {
161
+ db, currentProject: project ? null : inferProject(), env: process.env,
162
+ searchObservationsHybrid, deepSearch, shouldEscalateToDeep, autoDeepLlmReady,
163
+ reRankWithContext, markSuperseded, llm,
164
+ },
165
+ {
166
+ query, ftsQuery, effectiveSource, deepMode, rerank,
167
+ limit, offset, project: project || null, obsType: type, importance: minImportance,
168
+ branch, includeNoise, epochFrom: dateFrom, epochTo: dateTo, sort, tier,
169
+ // ── CLI surface policy ──
170
+ obsTypeFallback: false, // #8217 removed list-by-type fallback from the CLI
171
+ crossSourceEpochSortNoFts: false, // CLI never reaches cross-source with empty ftsQuery (fails earlier)
172
+ rerankPolicy: 'cli', // re-rank/supersede on any obs; re-sort gated on cross-source
173
+ rerankProject: project || inferProject(),
174
+ recentListingNoFts: false,
175
+ tolerateMissingFts: true, // pre-FTS legacy DBs: swallow session/prompt FTS errors
176
+ tierPosition: 'early', // tier filter inside the obs block (before sessions/prompts)
177
+ tierProject: project || inferProject(),
178
+ }
179
+ );
180
+ const isDeep = res.isDeep;
181
+ const orFallbackFired = res.orFallbackFired;
182
+ const deepVariants = res.variants;
183
+ const paged = res.page;
184
+ const total = res.total;
185
+
186
+ // Deep / escalation observability on stderr — reconstructed from core signals.
187
+ // The CLI emitted these inline in runDeep; same strings, same order (escalation →
188
+ // variants → rerank). rerank is only ever true on explicit --deep (never auto).
189
+ if (res.escalated) process.stderr.write(`[mem] auto-escalated to deep search (weak results: ${res.escalatedObsCount} hits)\n`);
190
+ if (isDeep && deepVariants) {
191
+ process.stderr.write(deepVariants.length > 1
192
+ ? `[mem] Deep search: rewrote into ${deepVariants.length} query variants, RRF-fused\n`
193
+ : '[mem] Deep search: rewrite returned no usable variants; used original query only\n');
194
+ }
195
+ if (rerank) {
196
+ process.stderr.write(res.reranked
197
+ ? '[mem] Deep search: LLM-reranked the fused top-20\n'
198
+ : '[mem] Deep search: rerank produced no usable order; kept fused order\n');
199
+ }
200
+
201
+ // "nothing matched" (no offset) vs "this page is empty" (with offset) — the two
202
+ // CLI messages. preFinalizeCount is the pre-pagination population (post-tier).
203
+ if (res.preFinalizeCount === 0) {
269
204
  if (jsonOutput) {
270
205
  out(JSON.stringify({ query, total: 0, returned: 0, offset, limit, deep: isDeep, variants: isDeep ? deepVariants : undefined, results: [] }));
271
206
  } else {
@@ -274,60 +209,6 @@ async function cmdSearch(db, args, { llm } = {}) {
274
209
  return;
275
210
  }
276
211
 
277
- // Cross-source score normalization (shared with mem_search).
278
- // ftsQuery gate prevents normalization when scores are all 0 (no-FTS path).
279
- const isCrossSource = isCrossSourceMode;
280
- if (isCrossSource && results.length > 0 && ftsQuery) {
281
- normalizeCrossSourceScores(results, '_source');
282
- results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
283
- }
284
-
285
- // Context re-ranking + superseded marking (aligned with MCP mem_search)
286
- const obsResults = results.filter(r => r._source === 'obs');
287
- if (obsResults.length > 0) {
288
- // reRankWithContext/markSuperseded expect source='obs' — alias _source for compatibility
289
- for (const r of obsResults) r.source = 'obs';
290
- // Explicit LLM rerank order is final — skip file-context re-rank when reranked
291
- // (paired-path with mem_search; markSuperseded still runs for stale-tagging).
292
- if (!isReranked) reRankWithContext(db, obsResults, project || inferProject());
293
- markSuperseded(obsResults);
294
- if (isCrossSource) results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
295
- }
296
-
297
- // Apply user-requested sort (after relevance scoring; shared with mem_search)
298
- applyUserSort(results, sort);
299
-
300
- // Trim to limit with offset. The engine always received perSourceOffset=0 and
301
- // over-fetched (see above), so the merged+reranked `results` start at row 0 and
302
- // the offset is applied exactly ONCE here — for every mode.
303
- //
304
- // `total` must be the TRUE population, independent of --limit/--offset (else the
305
- // over-fetched candidate count grew with the page and broke the "N of M" /
306
- // pagination contract). countSearchTotal mirrors each source's MATCH+filters;
307
- // clamp to >= results.length so it never understates the rows actually shown
308
- // (vector/concept augmentation can add obs rows beyond the FTS count).
309
- // For --deep the population is the fused variant result set: deepSearch already
310
- // returned all fused rows (capped at perSourceLimit) and they are the only rows
311
- // in `results` (deep is obs-only). countSearchTotal would instead count the
312
- // ORIGINAL query's FTS matches — wrong, and ~0 on the vocabulary-mismatch
313
- // queries deep exists for, which falsely shrinks the "N of M" total (F1).
314
- const total = isDeep
315
- ? results.length
316
- : Math.max(countSearchTotal(db, {
317
- effectiveSource,
318
- ftsQuery,
319
- obsFtsQuery: effectiveObsFtsQuery(ftsQuery, orFallbackFired),
320
- args: { project: project || null, obs_type: type || null, importance: minImportance || null, branch: branch || null },
321
- project: project || null,
322
- epochFrom: dateFrom,
323
- epochTo: dateTo,
324
- includeNoise,
325
- }), results.length);
326
- const paged = results.slice(offset, offset + limit);
327
- // Enrich the final page with the ~Nt fetch-cost hint (paired with MCP mem_search; #8654 both
328
- // source keys handled). Batch-fetches heavy obs fields by id — no-op on an empty page.
329
- attachBodyTokens(db, paged);
330
-
331
212
  if (paged.length === 0) {
332
213
  if (jsonOutput) {
333
214
  out(JSON.stringify({ query, total, returned: 0, offset, limit, deep: isDeep, variants: isDeep ? deepVariants : undefined, results: [] }));
@@ -337,24 +218,24 @@ async function cmdSearch(db, args, { llm } = {}) {
337
218
  return;
338
219
  }
339
220
 
340
- // paired-path with server.mjs formatSearchOutput (#8198): "N of M" total when paged < total.
221
+ // "N of M" total when paged < total (paired-path with server.mjs formatSearchOutput, #8198).
341
222
  const showTime = sort === 'time';
342
- const hasMixed = paged.some(r => r._source === 'session' || r._source === 'prompt');
223
+ const hasMixed = paged.some(r => r.source === 'session' || r.source === 'prompt');
343
224
  // Suppressed when --or was explicit — user already asked for OR, no "fallback" there.
344
225
  const fallbackHint = orFallbackFired && !useOr ? ' (relaxed AND→OR)' : '';
345
226
 
346
227
  if (jsonOutput) {
347
228
  const items = paged.map(r => {
348
229
  const base = {
349
- source: r._source,
230
+ source: r.source,
350
231
  id: r.id,
351
232
  created_at: r.created_at,
352
233
  score: r.score ?? null,
353
234
  };
354
- if (r._source === 'session') {
235
+ if (r.source === 'session') {
355
236
  return { ...base, request: r.request || null, completed: r.completed || null, project: r.project || null };
356
237
  }
357
- if (r._source === 'prompt') {
238
+ if (r.source === 'prompt') {
358
239
  return { ...base, prompt_text: r.prompt_text || null };
359
240
  }
360
241
  return {
@@ -392,10 +273,10 @@ async function cmdSearch(db, args, { llm } = {}) {
392
273
  const tok = r => (r.bodyTokens ? ` ~${r.bodyTokens}t` : '');
393
274
  for (const r of paged) {
394
275
  const timeStr = showTime && r.created_at_epoch ? ` (${relativeTime(r.created_at_epoch)})` : '';
395
- if (r._source === 'session') {
276
+ if (r.source === 'session') {
396
277
  const date = fmtDateShort(r.created_at);
397
278
  out(`S#${r.id} 📋 ${date}${timeStr} ${truncate(r.request || r.completed || '(no summary)', 80)}${tok(r)}`);
398
- } else if (r._source === 'prompt') {
279
+ } else if (r.source === 'prompt') {
399
280
  const date = fmtDateShort(r.created_at);
400
281
  out(`P#${r.id} 💬 ${date}${timeStr} ${truncate(r.prompt_text || '(empty)', 80)}${tok(r)}`);
401
282
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.6.0",
3
+ "version": "3.7.1",
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",
@@ -28,7 +28,7 @@
28
28
  "cli-path.mjs",
29
29
  "mem-cli.mjs",
30
30
  "server.mjs",
31
- "server-internals.mjs",
31
+ "search-scoring.mjs",
32
32
  "search-engine.mjs",
33
33
  "deep-search.mjs",
34
34
  "rerank.mjs",
@@ -69,7 +69,10 @@
69
69
  "lib/file-intel.mjs",
70
70
  "lib/reread-guard.mjs",
71
71
  "lib/metrics.mjs",
72
+ "lib/lesson-idents.mjs",
72
73
  "lib/binding-probe.mjs",
74
+ "lib/proc-lock.mjs",
75
+ "lib/atomic-write.mjs",
73
76
  "lib/mem-override.mjs",
74
77
  "lib/save-observation.mjs",
75
78
  "lib/observation-write.mjs",
@@ -131,6 +134,7 @@
131
134
  "scripts/post-tool-use.sh",
132
135
  "scripts/user-prompt-search.js",
133
136
  "scripts/pre-tool-recall.js",
137
+ "scripts/post-tool-recall.js",
134
138
  "scripts/pre-skill-bridge.js",
135
139
  "scripts/prompt-search-utils.mjs",
136
140
  "scripts/hook-launcher.mjs",
package/schema.mjs CHANGED
@@ -6,7 +6,7 @@ import Database from 'better-sqlite3';
6
6
  import { homedir } from 'os';
7
7
  import { join } from 'path';
8
8
  import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, chmodSync } from 'fs';
9
- import { OBS_FTS_COLUMNS } from './utils.mjs';
9
+ import { OBS_FTS_COLUMNS, debugCatch } from './utils.mjs';
10
10
 
11
11
  // DATA location — DB, managed resources, registry DB, runtime/. Honors
12
12
  // CLAUDE_MEM_DIR so users can relocate state to a larger/faster volume.
@@ -79,7 +79,16 @@ export const CODE_DIR = join(homedir(), '.claude-mem-lite');
79
79
  // New TABLE (not a column) reached via CORE_SCHEMA's CREATE TABLE IF NOT EXISTS on the
80
80
  // forced migration pass; LATEST_MIGRATION_COLUMN unchanged (no new column) — same
81
81
  // pattern as v35/v36.
82
- export const CURRENT_SCHEMA_VERSION = 38;
82
+ // v39 (audit P1-5): migration_cleanups table — a sentinel registry that makes the
83
+ // one-shot DATA cleanups (orphan deletes, project-name normalization) RETRYABLE.
84
+ // They previously ran inside the version-gated migration body and were swallowed
85
+ // on failure AFTER the version stamp committed, so a failed cleanup could never
86
+ // re-run (the fast-path then skipped the whole body). They now run via
87
+ // runDeferredCleanups() on every ensureDb, each gated by a done-marker row: a
88
+ // failure leaves the marker unset and retries on the next open. New TABLE via
89
+ // CORE_SCHEMA on the forced pass; LATEST_MIGRATION_COLUMN unchanged (no new
90
+ // column) — same pattern as v35/v36/v38.
91
+ export const CURRENT_SCHEMA_VERSION = 39;
83
92
 
84
93
  // Sentinel column for the LATEST migration set. The fast-path uses this to
85
94
  // self-heal half-migrated DBs — schema_version bumped but column ALTERs rolled
@@ -185,6 +194,11 @@ const CORE_SCHEMA = `
185
194
  cited_n INTEGER NOT NULL DEFAULT 0,
186
195
  PRIMARY KEY (project, memory_session_id)
187
196
  );
197
+
198
+ CREATE TABLE IF NOT EXISTS migration_cleanups (
199
+ name TEXT PRIMARY KEY,
200
+ done_at_epoch INTEGER NOT NULL
201
+ );
188
202
  `;
189
203
 
190
204
  // Column migrations (idempotent — only swallow "duplicate column" errors)
@@ -556,18 +570,8 @@ export function initSchema(db) {
556
570
  }
557
571
  } catch { /* non-critical — migration can retry on next open */ }
558
572
 
559
- // v35 (v2.87.0) P1: one-shot cleanup of orphaned observation_files. Same root
560
- // cause as the v28 observation_vectors cleanup below until the warm-start
561
- // fast-path FK fix (initSchema early returns now restore foreign_keys=ON),
562
- // ensureDb() handles ran with FK OFF, so ON DELETE CASCADE never fired and
563
- // junction rows leaked (live DB: 6440/9569 = 67% orphans). Idempotent (NOT IN
564
- // is empty on a clean DB); runs once per version bump via the fast-path gate.
565
- try {
566
- db.prepare(`
567
- DELETE FROM observation_files
568
- WHERE obs_id NOT IN (SELECT id FROM observations)
569
- `).run();
570
- } catch { /* non-critical — table-missing path handled by earlier CREATE */ }
573
+ // observation_files orphan cleanup moved to runDeferredCleanups() (audit P1-5):
574
+ // it now runs retryably outside the version fast-path. See DEFERRED_CLEANUPS.
571
575
 
572
576
  // Observation vectors table for TF-IDF vector search
573
577
  db.exec(`
@@ -582,16 +586,7 @@ export function initSchema(db) {
582
586
 
583
587
  db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_vectors_version ON observation_vectors(vocab_version)`);
584
588
 
585
- // v28 (v2.47) P0-1: one-shot cleanup of orphaned observation_vectors.
586
- // Live DBs accumulated 44% orphans even with ON DELETE CASCADE because
587
- // early migrations ran with `foreign_keys=OFF` and deletes skipped cascade.
588
- // Idempotent (NOT IN is empty on a clean DB), runs once per ensureDb().
589
- try {
590
- db.prepare(`
591
- DELETE FROM observation_vectors
592
- WHERE observation_id NOT IN (SELECT id FROM observations)
593
- `).run();
594
- } catch { /* non-critical — table-missing path handled by earlier CREATE */ }
589
+ // observation_vectors orphan cleanup moved to runDeferredCleanups() (audit P1-5).
595
590
 
596
591
  // Persisted vocabulary for stable TF-IDF vector indexing
597
592
  db.exec(`
@@ -605,46 +600,9 @@ export function initSchema(db) {
605
600
  `);
606
601
  db.exec('CREATE INDEX IF NOT EXISTS idx_vocab_state_version ON vocab_state(version)');
607
602
 
608
- // Project name normalization: migrate short names ("mem") to canonical form ("projects--mem")
609
- // Strategy: exact suffix match first, then substring match for package-name aliases
610
- // Idempotent: only runs when short-name records exist
611
- try {
612
- const shortProjects = db.prepare(`
613
- SELECT DISTINCT project FROM observations
614
- WHERE project NOT LIKE '%--_%' AND project != '' AND project IS NOT NULL
615
- UNION
616
- SELECT DISTINCT project FROM sdk_sessions
617
- WHERE project NOT LIKE '%--_%' AND project != '' AND project IS NOT NULL
618
- `).all();
619
- if (shortProjects.length > 0) {
620
- const normalize = db.transaction(() => {
621
- for (const { project: shortName } of shortProjects) {
622
- // Strategy 1: exact suffix match (e.g., "mem" → "projects--mem")
623
- let canonical = db.prepare(
624
- `SELECT project FROM observations WHERE project LIKE ? GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`
625
- ).get(`%--${shortName}`);
626
- // Strategy 2: substring match for aliases (e.g., "claude-mem-lite" → match project containing "mem")
627
- // Extract the most distinctive token from the short name for fuzzy matching
628
- if (!canonical) {
629
- const tokens = shortName.split(/[-_.]/).filter(t => t.length >= 5);
630
- for (const token of tokens) {
631
- canonical = db.prepare(
632
- `SELECT project FROM observations WHERE project LIKE ? AND project LIKE '%--_%'
633
- GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`
634
- ).get(`%${token}%`);
635
- if (canonical) break;
636
- }
637
- }
638
- if (canonical) {
639
- for (const table of ['observations', 'sdk_sessions', 'session_summaries']) {
640
- db.prepare(`UPDATE ${table} SET project = ? WHERE project = ?`).run(canonical.project, shortName);
641
- }
642
- }
643
- }
644
- });
645
- normalize();
646
- }
647
- } catch { /* non-critical — normalization can retry on next open */ }
603
+ // Project-name normalization moved to runDeferredCleanups() (audit P1-5) it
604
+ // now retries on a later open if it fails, instead of being lost behind the
605
+ // version fast-path. See DEFERRED_CLEANUPS.
648
606
 
649
607
  // ─── v29 (v2.57.x): session-id mix invariant + lesson-retry stats ─────────
650
608
  //
@@ -804,6 +762,96 @@ export function auditSessionConsistency(db, { graceMinutes = 5 } = {}) {
804
762
  };
805
763
  }
806
764
 
765
+ // ─── Deferred one-shot cleanups (retryable) ─────────────────────────────────
766
+ // Idempotent DATA cleanups that must survive a transient failure. They run on
767
+ // EVERY ensureDb (after initSchema), each gated by a row in migration_cleanups:
768
+ // a cleanup that throws leaves its marker unset and retries on the next open —
769
+ // unlike the old version-gated body, where a swallowed failure committed
770
+ // alongside the version stamp and the fast-path then skipped it forever (P1-5).
771
+ const DEFERRED_CLEANUPS = [
772
+ {
773
+ // v35 (v2.87.0): orphaned observation_files. ON DELETE CASCADE didn't fire
774
+ // while early warm-start handles ran with foreign_keys OFF, so junction rows
775
+ // leaked. Idempotent (NOT IN is empty on a clean DB).
776
+ name: 'orphan-observation-files',
777
+ run: (db) => db.prepare(
778
+ `DELETE FROM observation_files WHERE obs_id NOT IN (SELECT id FROM observations)`
779
+ ).run(),
780
+ },
781
+ {
782
+ // v28 (v2.47) P0-1: orphaned observation_vectors — same FK-OFF root cause.
783
+ name: 'orphan-observation-vectors',
784
+ run: (db) => db.prepare(
785
+ `DELETE FROM observation_vectors WHERE observation_id NOT IN (SELECT id FROM observations)`
786
+ ).run(),
787
+ },
788
+ {
789
+ // Project-name normalization: migrate short names ("mem") to canonical
790
+ // ("projects--mem"). Exact suffix match first, then distinctive-token
791
+ // substring. Idempotent: only acts on remaining short-name records.
792
+ name: 'normalize-project-names',
793
+ run: (db) => {
794
+ const shortProjects = db.prepare(`
795
+ SELECT DISTINCT project FROM observations
796
+ WHERE project NOT LIKE '%--_%' AND project != '' AND project IS NOT NULL
797
+ UNION
798
+ SELECT DISTINCT project FROM sdk_sessions
799
+ WHERE project NOT LIKE '%--_%' AND project != '' AND project IS NOT NULL
800
+ `).all();
801
+ if (shortProjects.length === 0) return;
802
+ const normalize = db.transaction(() => {
803
+ for (const { project: shortName } of shortProjects) {
804
+ let canonical = db.prepare(
805
+ `SELECT project FROM observations WHERE project LIKE ? GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`
806
+ ).get(`%--${shortName}`);
807
+ if (!canonical) {
808
+ const tokens = shortName.split(/[-_.]/).filter(t => t.length >= 5);
809
+ for (const token of tokens) {
810
+ canonical = db.prepare(
811
+ `SELECT project FROM observations WHERE project LIKE ? AND project LIKE '%--_%'
812
+ GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`
813
+ ).get(`%${token}%`);
814
+ if (canonical) break;
815
+ }
816
+ }
817
+ if (canonical) {
818
+ for (const table of ['observations', 'sdk_sessions', 'session_summaries']) {
819
+ db.prepare(`UPDATE ${table} SET project = ? WHERE project = ?`).run(canonical.project, shortName);
820
+ }
821
+ }
822
+ }
823
+ });
824
+ normalize();
825
+ },
826
+ },
827
+ ];
828
+
829
+ /**
830
+ * Run registered one-shot data cleanups that haven't completed yet. Each is
831
+ * gated by a row in migration_cleanups, so a transient failure retries on the
832
+ * next open instead of being silently lost behind the schema-version fast-path
833
+ * (audit P1-5). Best-effort: never throws — callers open the DB regardless.
834
+ */
835
+ export function runDeferredCleanups(db) {
836
+ let done;
837
+ try {
838
+ done = new Set(db.prepare('SELECT name FROM migration_cleanups').all().map(r => r.name));
839
+ } catch {
840
+ return; // table not present yet (pre-migration open) — nothing to do
841
+ }
842
+ const mark = db.prepare('INSERT OR IGNORE INTO migration_cleanups (name, done_at_epoch) VALUES (?, ?)');
843
+ for (const { name, run } of DEFERRED_CLEANUPS) {
844
+ if (done.has(name)) continue;
845
+ try {
846
+ run(db);
847
+ mark.run(name, Date.now());
848
+ } catch (e) {
849
+ // Leave the marker unset → retried next open. Surface for observability.
850
+ debugCatch(e, `deferred-cleanup:${name}`);
851
+ }
852
+ }
853
+ }
854
+
807
855
  /**
808
856
  * Ensure DB directory, database file, and all tables exist.
809
857
  * Safe to call from any process (hook or server). Idempotent.
@@ -846,7 +894,13 @@ export function ensureDb() {
846
894
  db.pragma('foreign_keys = OFF'); // Enabled after dedup migration
847
895
 
848
896
  try {
849
- return initSchema(db);
897
+ const ready = initSchema(db);
898
+ // P1-5: sentinel-gated data cleanups must run on EVERY open (schema.mjs:766).
899
+ // They were extracted out of initSchema into runDeferredCleanups but never
900
+ // wired into a production opener — without this call they ran nowhere but
901
+ // tests, silently halting orphan/normalize hygiene. Best-effort: never throws.
902
+ runDeferredCleanups(ready);
903
+ return ready;
850
904
  } catch (e) {
851
905
  try { db.close(); } catch {}
852
906
  throw e;
package/scoring-sql.mjs CHANGED
@@ -3,6 +3,31 @@
3
3
 
4
4
  import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
5
5
 
6
+ // ─── Why these multipliers exist (read before "simplifying" them) ────────────
7
+ //
8
+ // The recency-decay, type-quality, project-boost, importance, cite, and noise
9
+ // multipliers below encode PRODUCT PRIORS: recent / same-project / important /
10
+ // high-signal-type / frequently-cited memories are more relevant to the CURRENT
11
+ // dev session. A periodic audit tends to flag them as "0-lift dead weight" —
12
+ // resist that on benchmark evidence alone. Measured (audit ②, obs #8773):
13
+ // * benchmark.mjs --matrix (micro-fixture, now models the full FULL_SCORE
14
+ // chain): type-quality is the TOP contributor (drop-type ΔnDCG=0.0082,
15
+ // ΔMRR=0.0166), decay +0.0043 nDCG, importance +0.0012; the chain lifts
16
+ // hybrid over bm25_only by +0.0093 nDCG / +0.0166 MRR (net 0 queries hurt).
17
+ // project, access and lesson read exactly 0 — but that is STRUCTURAL: the
18
+ // fixture is single-project, access_count=0, and has 0 lesson_learned rows,
19
+ // so it cannot vary those three axes.
20
+ // * longmemeval.mjs --temporal (n=500, real dates): bit-identical to uniform —
21
+ // LongMemEval-S windows (mean 27.9d, 74% <30d) are far shorter than these
22
+ // half-lives, so decay moves no rank there either.
23
+ // Where a multiplier reads 0 it is a benchmark-MISMATCH artifact (the instrument
24
+ // can't vary that axis), NOT proven dead weight. Decision: KEEP them; do NOT
25
+ // delete on "0 lift". Guardrail: the ci-gate `hybrid_over_bm25 >= -0.05` floor
26
+ // (benchmark/ci-gate.mjs) covers the full modelled chain
27
+ // (decay/type/project/importance/access/lesson); cite + noise live on the
28
+ // injection path (hook-memory.mjs) with no recall-benchmark coverage. Genuine
29
+ // validation of the prior-encoding axes needs a labeled real-dev-memory eval.
30
+
6
31
  // ─── Type-Differentiated Recency Decay ──────────────────────────────────────
7
32
 
8
33
  /** Recency half-life per observation type (in milliseconds) */