claude-mem-lite 3.7.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 +1 -1
- package/README.zh-CN.md +1 -1
- package/hook.mjs +403 -373
- package/install.mjs +666 -629
- package/lib/doctor-benchmark.mjs +4 -4
- package/lib/search-core.mjs +272 -16
- package/mem-cli.mjs +55 -174
- package/package.json +2 -2
- package/schema.mjs +7 -1
- package/search-engine.mjs +1 -1
- package/{server-internals.mjs → search-scoring.mjs} +6 -2
- package/server.mjs +72 -293
- package/source-files.mjs +1 -1
package/lib/doctor-benchmark.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { sanitizeFtsQuery, OBS_BM25 } from '../utils.mjs';
|
|
|
16
16
|
|
|
17
17
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
18
|
const SERVER_PATH = join(__dirname, '..', 'server.mjs');
|
|
19
|
-
const
|
|
19
|
+
const SEARCH_SCORING_PATH = join(__dirname, '..', 'search-scoring.mjs');
|
|
20
20
|
const BENCHMARK_VERSION = '1';
|
|
21
21
|
|
|
22
22
|
function extractStringArrayBody(body) {
|
|
@@ -40,7 +40,7 @@ function extractStringArrayBody(body) {
|
|
|
40
40
|
* 1. template literal in server.mjs: instructions: `...`
|
|
41
41
|
* 2. array-join in server.mjs: instructions: [ '...', '...' ].join('\n')
|
|
42
42
|
* 3. (v2.31.3+) builder call in server.mjs referencing INSTRUCTIONS_BASE +
|
|
43
|
-
* INSTRUCTIONS_VERBOSE arrays in
|
|
43
|
+
* INSTRUCTIONS_VERBOSE arrays in search-scoring.mjs. Measured at the
|
|
44
44
|
* verbose form — this is the cost-per-turn baseline the benchmark tracks.
|
|
45
45
|
* Returns '' if no shape matches (caller treats byte count as 0).
|
|
46
46
|
*/
|
|
@@ -56,10 +56,10 @@ function readMcpInstructions() {
|
|
|
56
56
|
if (arr) return extractStringArrayBody(arr[1]).join('\n');
|
|
57
57
|
|
|
58
58
|
// Form 3: buildServerInstructions() — reconstruct verbose form from
|
|
59
|
-
//
|
|
59
|
+
// search-scoring.mjs INSTRUCTIONS_BASE + INSTRUCTIONS_VERBOSE arrays.
|
|
60
60
|
if (/instructions:\s*buildServerInstructions\(/.test(src)) {
|
|
61
61
|
let internals;
|
|
62
|
-
try { internals = readFileSync(
|
|
62
|
+
try { internals = readFileSync(SEARCH_SCORING_PATH, 'utf8'); } catch { return ''; }
|
|
63
63
|
const base = internals.match(/INSTRUCTIONS_BASE\s*=\s*\[([\s\S]*?)\];/);
|
|
64
64
|
const verbose = internals.match(/INSTRUCTIONS_VERBOSE\s*=\s*\[([\s\S]*?)\];/);
|
|
65
65
|
const parts = [];
|
package/lib/search-core.mjs
CHANGED
|
@@ -1,26 +1,33 @@
|
|
|
1
|
-
// Shared cross-source search core
|
|
2
|
-
// normalization / sort / pagination math).
|
|
1
|
+
// Shared cross-source search core for cmdSearch (CLI) and mem_search (MCP).
|
|
3
2
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// fields), error-message wording, and output rendering.
|
|
3
|
+
// coreRunSearchPipeline (below) is the SINGLE orchestration body — deep /
|
|
4
|
+
// auto-escalation → per-source query (obs hybrid + sessions + prompts) →
|
|
5
|
+
// cross-source normalize+sort → context re-rank + supersede → tier filter →
|
|
6
|
+
// user sort → count+paginate. The two ~180-line bodies that cmdSearch and
|
|
7
|
+
// runSearchPipeline used to keep hand-synced via "paired-path" comments are
|
|
8
|
+
// gone (audit P1-2); each surface is now a thin adapter that parses/validates
|
|
9
|
+
// in and renders out. Cross-surface equivalence is enforced by
|
|
10
|
+
// tests/search-parity.test.mjs, not by comments.
|
|
13
11
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
12
|
+
// Surfaces legitimately differ on a few policy points; each is an explicit opt
|
|
13
|
+
// on coreRunSearchPipeline (obsTypeFallback / crossSourceEpochSortNoFts /
|
|
14
|
+
// rerankPolicy / recentListingNoFts / tolerateMissingFts / tierPosition …) so
|
|
15
|
+
// behavior is strictly preserved and any future convergence is a deliberate
|
|
16
|
+
// change, not an accident. Notable preserved asymmetries:
|
|
17
|
+
// • CLI forces source=observations for --type/--tier/--importance/--branch;
|
|
18
|
+
// MCP only forces it for obs_type. (effectiveSource is computed per-adapter.)
|
|
18
19
|
// • CLI warns on inverted --from/--to ranges; MCP does not.
|
|
19
|
-
// • CLI
|
|
20
|
+
// • CLI tolerates missing session/prompt FTS (pre-FTS legacy DBs); MCP does not.
|
|
21
|
+
// • MCP lists-recent-by-type on a 0-match obs_type query; CLI does not (#8217).
|
|
22
|
+
//
|
|
23
|
+
// Result rows use one canonical `source` key; session/prompt rows carry dual
|
|
24
|
+
// keys (date=created_at, text=prompt_text, session=content_session_id) so each
|
|
25
|
+
// surface's renderer reads its own field names off a single row shape.
|
|
20
26
|
|
|
21
27
|
import { sanitizeFtsQuery, relaxFtsQueryToOr, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS } from '../utils.mjs';
|
|
22
28
|
import { cjkPrecisionOk, extractCjkLikePatterns } from '../nlp.mjs';
|
|
23
29
|
import { computeTier } from '../tier.mjs';
|
|
30
|
+
import { countSearchTotal, attachBodyTokens } from '../search-engine.mjs';
|
|
24
31
|
|
|
25
32
|
/** Sanitize a user query to FTS5 syntax; optionally force OR semantics. */
|
|
26
33
|
export function buildSearchFtsQuery(query, { or = false } = {}) {
|
|
@@ -198,3 +205,252 @@ export function applyTierFilter(db, results, { tier, sourceKey, currentProject }
|
|
|
198
205
|
return full && computeTier(full, tierCtx) === tier;
|
|
199
206
|
});
|
|
200
207
|
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Finalize a merged, scored result set into one page: compute the TRUE
|
|
211
|
+
* (limit/offset-invariant) population, slice the requested page, and attach the
|
|
212
|
+
* ~Nt fetch-cost hint. Single source of truth for the count+paginate+enrich tail
|
|
213
|
+
* of cmdSearch (CLI) and runSearchPipeline (MCP) — exactly where #8635 drifted
|
|
214
|
+
* (the over-fetch cap leaked into the reported total on BOTH sides independently).
|
|
215
|
+
*
|
|
216
|
+
* `total`: every source over-fetched from offset 0 (computePerSourceWindow), so
|
|
217
|
+
* results.length is the over-fetched candidate pool, NOT the population —
|
|
218
|
+
* countSearchTotal re-derives the real MATCH+filter count. Clamp to
|
|
219
|
+
* >= results.length so vector/concept-augmented obs rows are never undercounted
|
|
220
|
+
* (#8217/#8638). For deep (explicit or auto-escalated) the population IS the fused
|
|
221
|
+
* variant set already in `results` (deepSearch is obs-only, capped at
|
|
222
|
+
* perSourceLimit); countSearchTotal would instead count the ORIGINAL query's FTS
|
|
223
|
+
* matches — wrong, and ~0 on the vocabulary-mismatch queries deep exists for (F1).
|
|
224
|
+
*
|
|
225
|
+
* Pagination always slices: single-source results can exceed SQL LIMIT via
|
|
226
|
+
* expansion (concept co-occurrence / PRF / vector), and `offset` is applied
|
|
227
|
+
* exactly ONCE here (the per-source SQL always saw offset 0).
|
|
228
|
+
*
|
|
229
|
+
* @returns {{ total: number, page: object[] }}
|
|
230
|
+
*/
|
|
231
|
+
export function finalizeSearchPage(db, results, {
|
|
232
|
+
isDeep, offset, limit, effectiveSource, ftsQuery, orFallbackFired,
|
|
233
|
+
project = null, obsType = null, importance = null, branch = null,
|
|
234
|
+
epochFrom = null, epochTo = null, includeNoise = false,
|
|
235
|
+
}) {
|
|
236
|
+
const total = isDeep
|
|
237
|
+
? results.length
|
|
238
|
+
: Math.max(countSearchTotal(db, {
|
|
239
|
+
effectiveSource: effectiveSource || null,
|
|
240
|
+
ftsQuery,
|
|
241
|
+
obsFtsQuery: effectiveObsFtsQuery(ftsQuery, orFallbackFired),
|
|
242
|
+
args: { project: project || null, obs_type: obsType || null, importance: importance || null, branch: branch || null },
|
|
243
|
+
project: project || null,
|
|
244
|
+
epochFrom, epochTo, includeNoise,
|
|
245
|
+
}), results.length);
|
|
246
|
+
const page = results.slice(offset, offset + limit);
|
|
247
|
+
attachBodyTokens(db, page);
|
|
248
|
+
return { total, page };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Unified cross-source search orchestrator — single source of truth for the CLI
|
|
253
|
+
* (cmdSearch) and MCP (mem_search) search bodies: deep / auto-escalation →
|
|
254
|
+
* per-source query (obs hybrid + sessions + prompts) → cross-source
|
|
255
|
+
* normalize+sort → context re-rank + supersede → tier post-filter → user sort →
|
|
256
|
+
* count+paginate. The two surfaces legitimately differ on a handful of policy
|
|
257
|
+
* points; each is a named opt so a future "fix" is a deliberate contract change,
|
|
258
|
+
* not an accident (see the per-opt comments).
|
|
259
|
+
*
|
|
260
|
+
* The core does NO flag/schema parsing, NO stdout/stderr, NO formatting — adapters
|
|
261
|
+
* validate+parse on the way in and render on the way out. Session/prompt rows
|
|
262
|
+
* carry dual keys (`date`=created_at, `text`=prompt_text, `session`=content_session_id)
|
|
263
|
+
* so both renderers read their own field names off one canonical row.
|
|
264
|
+
*
|
|
265
|
+
* #8743: `db` comes ONLY from `ctx.db` — there is no module-global fallback, so a
|
|
266
|
+
* per-source leg can never silently query the wrong database.
|
|
267
|
+
*
|
|
268
|
+
* @returns {Promise<{ page:object[], total:number, preFinalizeCount:number, isDeep:boolean,
|
|
269
|
+
* escalated:boolean, escalatedObsCount:number, variants:object[]|null,
|
|
270
|
+
* reranked:boolean, orFallbackFired:boolean, effectiveSource:string|null, ftsQuery:string }>}
|
|
271
|
+
*/
|
|
272
|
+
export async function coreRunSearchPipeline(ctx, opts) {
|
|
273
|
+
const {
|
|
274
|
+
db, currentProject = null, env = process.env,
|
|
275
|
+
searchObservationsHybrid, deepSearch, shouldEscalateToDeep,
|
|
276
|
+
autoDeepLlmReady, reRankWithContext, markSuperseded,
|
|
277
|
+
llm = null, rerankLlm = undefined,
|
|
278
|
+
} = ctx;
|
|
279
|
+
const {
|
|
280
|
+
query, ftsQuery, effectiveSource = null, deepMode = 'normal', rerank = false,
|
|
281
|
+
limit, offset, project = null, obsType = null, importance = null, branch = null,
|
|
282
|
+
includeNoise = false, epochFrom = null, epochTo = null, sort = 'relevance', tier = null,
|
|
283
|
+
// ── surface policy (strict behavior-preservation; the two surfaces differ) ──
|
|
284
|
+
obsTypeFallback = false, // A5: list-recent-by-type when 0 matches — MCP true, CLI false (#8217 removed it from CLI)
|
|
285
|
+
crossSourceEpochSortNoFts = false, // A3: epoch-sort the cross-source set when no ftsQuery — MCP true, CLI false
|
|
286
|
+
rerankPolicy = 'mcp', // A4: re-rank/supersede gate + re-sort condition — 'mcp' | 'cli'
|
|
287
|
+
rerankProject = null, // reRankWithContext project — MCP currentProject, CLI project||inferProject()
|
|
288
|
+
recentListingNoFts = false, // session/prompt recent-listing when no ftsQuery (explicit --source) — MCP true, CLI false
|
|
289
|
+
tolerateMissingFts = false, // wrap session/prompt FTS in try/catch for pre-FTS legacy DBs — CLI true, MCP false
|
|
290
|
+
tierPosition = 'late', // tier filter vs re-rank ordering — MCP 'late' (after re-rank), CLI 'early' (in obs block)
|
|
291
|
+
tierProject = null, // applyTierFilter project — MCP project||currentProject, CLI project||inferProject()
|
|
292
|
+
} = opts;
|
|
293
|
+
|
|
294
|
+
const { perSourceLimit, perSourceOffset } = computePerSourceWindow(limit, offset);
|
|
295
|
+
const isCrossSource = !effectiveSource;
|
|
296
|
+
const results = [];
|
|
297
|
+
let orFallbackFired = false;
|
|
298
|
+
let deepVariants = null;
|
|
299
|
+
let deepReranked = false;
|
|
300
|
+
let isDeep = deepMode === 'deep';
|
|
301
|
+
let escalated = false;
|
|
302
|
+
let escalatedObsCount = 0;
|
|
303
|
+
|
|
304
|
+
const obsCtx = {
|
|
305
|
+
db, ftsQuery,
|
|
306
|
+
args: { project, obs_type: obsType, importance, branch, include_noise: includeNoise },
|
|
307
|
+
epochFrom, epochTo, perSourceLimit, perSourceOffset, currentProject, limit,
|
|
308
|
+
orFallbackFired: false,
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const runDeep = async ({ auto = false } = {}) => {
|
|
312
|
+
const ds = await deepSearch(db, {
|
|
313
|
+
query, project, type: obsType, importance, branch, includeNoise,
|
|
314
|
+
epochFrom, epochTo, limit: perSourceLimit, currentProject,
|
|
315
|
+
}, llm ? { llm, rerank: rerank && !auto, rerankLlm } : { auto, rerank: rerank && !auto, rerankLlm });
|
|
316
|
+
deepVariants = ds.variants;
|
|
317
|
+
deepReranked = ds.reranked;
|
|
318
|
+
return ds.results;
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// ── Observations (hybrid engine; deep / auto-escalation; obs rows already carry source:'obs') ──
|
|
322
|
+
if (!effectiveSource || effectiveSource === 'observations') {
|
|
323
|
+
if (deepMode === 'deep') {
|
|
324
|
+
results.push(...await runDeep());
|
|
325
|
+
} else {
|
|
326
|
+
results.push(...searchObservationsHybrid(db, obsCtx));
|
|
327
|
+
if (obsCtx.orFallbackFired) orFallbackFired = true;
|
|
328
|
+
const obsCountBefore = results.filter((r) => r.source === 'obs').length;
|
|
329
|
+
if (deepMode === 'auto' && autoDeepLlmReady(env, llm) &&
|
|
330
|
+
shouldEscalateToDeep(results.filter((r) => r.source === 'obs'), obsCtx, { db, project })) {
|
|
331
|
+
const deepRows = await runDeep({ auto: true });
|
|
332
|
+
results.length = 0;
|
|
333
|
+
results.push(...deepRows);
|
|
334
|
+
isDeep = true;
|
|
335
|
+
escalated = true;
|
|
336
|
+
escalatedObsCount = obsCountBefore;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── Tier post-filter, CLI position: obs-only (tier forces observations), before re-rank ──
|
|
342
|
+
if (tier && tierPosition === 'early') {
|
|
343
|
+
const filtered = applyTierFilter(db, results, { tier, sourceKey: 'source', currentProject: tierProject });
|
|
344
|
+
results.length = 0; results.push(...filtered);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── Sessions (FTS via shared helper; optional recent-listing when no ftsQuery) ──
|
|
348
|
+
if ((!effectiveSource || effectiveSource === 'sessions') && !isDeep) {
|
|
349
|
+
const pushSessions = () => {
|
|
350
|
+
if (ftsQuery) {
|
|
351
|
+
const rows = searchSessionsFts(db, { ftsQuery, project, projectBoost: project ? null : currentProject, epochFrom, epochTo, perSourceLimit, perSourceOffset });
|
|
352
|
+
for (const r of rows) results.push({ ...r, source: 'session', date: r.created_at });
|
|
353
|
+
} else if (recentListingNoFts && effectiveSource === 'sessions') {
|
|
354
|
+
const params = []; const wheres = [];
|
|
355
|
+
if (project) { wheres.push('project = ?'); params.push(project); }
|
|
356
|
+
if (epochFrom !== null) { wheres.push('created_at_epoch >= ?'); params.push(epochFrom); }
|
|
357
|
+
if (epochTo !== null) { wheres.push('created_at_epoch <= ?'); params.push(epochTo); }
|
|
358
|
+
const where = wheres.length ? `WHERE ${wheres.join(' AND ')}` : '';
|
|
359
|
+
params.push(perSourceLimit, perSourceOffset);
|
|
360
|
+
const rows = db.prepare(`
|
|
361
|
+
SELECT id, request, completed, project, created_at, created_at_epoch
|
|
362
|
+
FROM session_summaries ${where}
|
|
363
|
+
ORDER BY created_at_epoch DESC
|
|
364
|
+
LIMIT ? OFFSET ?
|
|
365
|
+
`).all(...params);
|
|
366
|
+
for (const r of rows) results.push({ ...r, source: 'session', date: r.created_at });
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
if (tolerateMissingFts) { try { pushSessions(); } catch { /* session FTS may not exist in older DBs */ } } else pushSessions();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── Prompts (FTS via shared helper incl. CJK gate; optional recent-listing) ──
|
|
373
|
+
if ((!effectiveSource || effectiveSource === 'prompts') && !isDeep) {
|
|
374
|
+
const pushPrompts = () => {
|
|
375
|
+
if (ftsQuery) {
|
|
376
|
+
const rows = searchPromptsFts(db, { query, ftsQuery, project, epochFrom, epochTo, perSourceLimit, perSourceOffset });
|
|
377
|
+
for (const r of rows) results.push({ ...r, source: 'prompt', date: r.created_at, text: r.prompt_text, session: r.content_session_id });
|
|
378
|
+
} else if (recentListingNoFts && effectiveSource === 'prompts') {
|
|
379
|
+
const params = []; const wheres = [];
|
|
380
|
+
if (project) { wheres.push('s.project = ?'); params.push(project); }
|
|
381
|
+
if (epochFrom !== null) { wheres.push('p.created_at_epoch >= ?'); params.push(epochFrom); }
|
|
382
|
+
if (epochTo !== null) { wheres.push('p.created_at_epoch <= ?'); params.push(epochTo); }
|
|
383
|
+
const where = wheres.length ? `WHERE ${wheres.join(' AND ')}` : '';
|
|
384
|
+
params.push(perSourceLimit, perSourceOffset);
|
|
385
|
+
const rows = db.prepare(`
|
|
386
|
+
SELECT p.id, p.prompt_text, p.content_session_id, p.created_at, p.created_at_epoch
|
|
387
|
+
FROM user_prompts p
|
|
388
|
+
JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
|
|
389
|
+
${where}
|
|
390
|
+
ORDER BY p.created_at_epoch DESC
|
|
391
|
+
LIMIT ? OFFSET ?
|
|
392
|
+
`).all(...params);
|
|
393
|
+
for (const r of rows) results.push({ ...r, source: 'prompt', date: r.created_at, text: r.prompt_text, session: r.content_session_id });
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
if (tolerateMissingFts) { try { pushPrompts(); } catch { /* prompt FTS may not exist in older DBs */ } } else pushPrompts();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── Type-list fallback (MCP): obs_type set + 0 matches → list recent of that type ──
|
|
400
|
+
if (obsTypeFallback && results.length === 0 && obsType) {
|
|
401
|
+
const typeWheres = ['COALESCE(compressed_into, 0) = 0', 'superseded_at IS NULL', 'type = ?'];
|
|
402
|
+
const typeParams = [obsType];
|
|
403
|
+
if (project) { typeWheres.push('project = ?'); typeParams.push(project); }
|
|
404
|
+
if (epochFrom !== null) { typeWheres.push('created_at_epoch >= ?'); typeParams.push(epochFrom); }
|
|
405
|
+
if (epochTo !== null) { typeWheres.push('created_at_epoch <= ?'); typeParams.push(epochTo); }
|
|
406
|
+
if (importance) { typeWheres.push('COALESCE(importance, 1) >= ?'); typeParams.push(importance); }
|
|
407
|
+
typeParams.push(limit);
|
|
408
|
+
const typeRows = db.prepare(`
|
|
409
|
+
SELECT id, type, title, subtitle, project, created_at, importance, files_modified
|
|
410
|
+
FROM observations WHERE ${typeWheres.join(' AND ')}
|
|
411
|
+
ORDER BY created_at_epoch DESC LIMIT ?
|
|
412
|
+
`).all(...typeParams);
|
|
413
|
+
for (const r of typeRows) results.push({ source: 'obs', id: r.id, type: r.type, title: r.title, subtitle: r.subtitle, project: r.project, date: r.created_at, importance: r.importance, files_modified: r.files_modified, score: 0, snippet: '' });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ── Cross-source normalize + sort ──
|
|
417
|
+
if (isCrossSource && results.length > 0 && ftsQuery) normalizeCrossSourceScores(results, 'source');
|
|
418
|
+
if (isCrossSource && results.length > 0) {
|
|
419
|
+
if (ftsQuery) results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
|
|
420
|
+
else if (crossSourceEpochSortNoFts) results.sort((a, b) => (b.created_at_epoch ?? 0) - (a.created_at_epoch ?? 0));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ── Context re-rank + superseded marking (markSuperseded is pure stale-tagging) ──
|
|
424
|
+
const hasObs = results.some((r) => r.source === 'obs');
|
|
425
|
+
const rerankGate = rerankPolicy === 'mcp' ? ((ftsQuery || isDeep) && hasObs) : hasObs;
|
|
426
|
+
if (rerankGate) {
|
|
427
|
+
const obsResults = results.filter((r) => r.source === 'obs');
|
|
428
|
+
const doReRank = rerankPolicy === 'mcp' ? (ftsQuery && !deepReranked) : !deepReranked;
|
|
429
|
+
if (doReRank) reRankWithContext(db, obsResults, rerankProject);
|
|
430
|
+
markSuperseded(obsResults);
|
|
431
|
+
const doReSort = rerankPolicy === 'mcp' ? (ftsQuery && !deepReranked) : isCrossSource;
|
|
432
|
+
if (doReSort) results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── Tier post-filter, MCP position: after re-rank, on the merged set ──
|
|
436
|
+
if (tier && tierPosition === 'late') {
|
|
437
|
+
const filtered = applyTierFilter(db, results, { tier, sourceKey: 'source', currentProject: tierProject });
|
|
438
|
+
results.length = 0; results.push(...filtered);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ── User-requested sort (after relevance scoring) ──
|
|
442
|
+
applyUserSort(results, sort);
|
|
443
|
+
|
|
444
|
+
// ── Count + paginate + ~Nt enrich. preFinalizeCount lets the CLI adapter
|
|
445
|
+
// distinguish "nothing matched" from "this page is empty" (its two messages). ──
|
|
446
|
+
const preFinalizeCount = results.length;
|
|
447
|
+
const { total, page } = finalizeSearchPage(db, results, {
|
|
448
|
+
isDeep, offset, limit, effectiveSource, ftsQuery, orFallbackFired,
|
|
449
|
+
project, obsType, importance, branch, epochFrom, epochTo, includeNoise,
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
page, total, preFinalizeCount, isDeep, escalated, escalatedObsCount,
|
|
454
|
+
variants: deepVariants, reranked: deepReranked, orFallbackFired, effectiveSource, ftsQuery,
|
|
455
|
+
};
|
|
456
|
+
}
|
package/mem-cli.mjs
CHANGED
|
@@ -8,8 +8,8 @@ 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
|
|
11
|
+
import { autoBoostIfNeeded, reRankWithContext, markSuperseded } from './search-scoring.mjs';
|
|
12
|
+
import { searchObservationsHybrid } from './search-engine.mjs';
|
|
13
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';
|
|
@@ -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, { db, project: 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.7.
|
|
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",
|