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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +21 -13
- package/README.zh-CN.md +1 -1
- package/deep-search.mjs +26 -4
- package/hook-update.mjs +17 -1
- package/hook.mjs +403 -373
- package/install.mjs +691 -639
- package/lib/atomic-write.mjs +38 -0
- package/lib/doctor-benchmark.mjs +4 -4
- package/lib/err-sampler.mjs +7 -3
- package/lib/lesson-idents.mjs +32 -0
- package/lib/proc-lock.mjs +112 -0
- package/lib/search-core.mjs +272 -16
- package/mem-cli.mjs +56 -175
- package/package.json +6 -2
- package/schema.mjs +119 -65
- package/scoring-sql.mjs +25 -0
- package/scripts/post-tool-recall.js +71 -0
- package/scripts/pre-tool-recall.js +27 -2
- package/search-engine.mjs +1 -1
- package/{server-internals.mjs → search-scoring.mjs} +6 -2
- package/server.mjs +85 -295
- package/source-files.mjs +11 -1
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 './
|
|
12
|
-
import { searchObservationsHybrid
|
|
13
|
-
import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady
|
|
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,
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
//
|
|
560
|
-
//
|
|
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
|
-
//
|
|
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
|
|
609
|
-
//
|
|
610
|
-
//
|
|
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
|
-
|
|
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) */
|