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.
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ // scripts/post-tool-recall.js — PostToolUse companion to pre-tool-recall.js for
3
+ // the bind-salience forcing-function (component 2). After an Edit/Write, if a
4
+ // lesson surfaced for this file named an identifier that was present BEFORE the
5
+ // edit (recorded in the cooldown by pre-tool-recall.js) and is now GONE, emit a
6
+ // one-line non-blocking nudge. Only active under CLAUDE_MEM_SALIENCE=bind.
7
+ //
8
+ // Catches "you removed a required reference" lessons. It does NOT catch "you
9
+ // failed to ADD a call" (the identifier was never in the pre-edit file →
10
+ // presentIdents excluded it); that class is carried by the pre-edit
11
+ // BIND_DIRECTIVE, not here. See the spec's component-2 limits.
12
+ //
13
+ // Safety: readonly, no DB, exit 0 always. cooldownPathFor mirrors
14
+ // pre-tool-recall.js (inlined per the #8447 fast-path convention).
15
+
16
+ import { existsSync, readFileSync } from 'fs';
17
+ import { basename, join } from 'path';
18
+ import { homedir } from 'os';
19
+
20
+ const SALIENCE_BIND = process.env.CLAUDE_MEM_SALIENCE === 'bind';
21
+
22
+ const DATA_DIR = process.env.CLAUDE_MEM_DIR || join(homedir(), '.claude-mem-lite');
23
+ const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtime');
24
+ const LEGACY_COOLDOWN_PATH = join(RUNTIME_DIR, 'pre-recall-cooldown.json');
25
+
26
+ function cooldownPathFor(sessionId) {
27
+ if (!sessionId) return LEGACY_COOLDOWN_PATH;
28
+ const safe = String(sessionId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
29
+ return join(RUNTIME_DIR, `pre-recall-cooldown-${safe}.json`);
30
+ }
31
+
32
+ async function main() {
33
+ if (!SALIENCE_BIND) return;
34
+ if (process.env.CLAUDE_MEM_HOOK_RUNNING) return;
35
+ let input = '';
36
+ for await (const chunk of process.stdin) input += chunk;
37
+ let filePath, sessionId;
38
+ try {
39
+ const e = JSON.parse(input);
40
+ filePath = e.tool_input?.file_path;
41
+ sessionId = e.session_id || null;
42
+ } catch { return; }
43
+ if (!filePath) return;
44
+
45
+ const cdPath = cooldownPathFor(sessionId);
46
+ if (!existsSync(cdPath)) return;
47
+ let entry;
48
+ try { entry = JSON.parse(readFileSync(cdPath, 'utf8'))[filePath]; } catch { return; }
49
+ const idents = entry && entry.lessonIdents;
50
+ if (!idents || typeof idents !== 'object') return;
51
+
52
+ let post;
53
+ try { post = readFileSync(filePath, 'utf8'); } catch { return; }
54
+
55
+ const dropped = [];
56
+ for (const [obsId, tokens] of Object.entries(idents)) {
57
+ for (const t of tokens) if (!post.includes(t)) dropped.push({ obsId, token: t });
58
+ }
59
+ if (!dropped.length) return;
60
+
61
+ const lines = ['[mem] PostToolUse recall — system-injected context, continue your planned action:'];
62
+ for (const d of dropped.slice(0, 3)) {
63
+ lines.push(`[mem] ⚠ your edit to ${basename(filePath)} dropped \`${d.token}\` flagged by #${d.obsId} — if intentional say so, else re-check before moving on.`);
64
+ }
65
+ process.stdout.write(JSON.stringify({
66
+ suppressOutput: true,
67
+ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: lines.join('\n') },
68
+ }));
69
+ }
70
+
71
+ main().catch(() => {}).finally(() => process.exit(0));
@@ -13,6 +13,7 @@ import { citeFactorClause } from '../scoring-sql.mjs';
13
13
  import { fileIntelFor } from '../lib/file-intel.mjs';
14
14
  import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/reread-guard.mjs';
15
15
  import { recordMetric } from '../lib/metrics.mjs';
16
+ import { presentIdents } from '../lib/lesson-idents.mjs';
16
17
 
17
18
  // CLAUDE_MEM_DIR matches schema.mjs / main CLI — one env var sandboxes the
18
19
  // whole system. CLAUDE_MEM_DB_PATH / CLAUDE_MEM_RUNTIME_DIR remain as
@@ -40,7 +41,14 @@ const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes (used only for legacy fallback)
40
41
  // restores the pre-v2.98 passive behavior.
41
42
  const SALIENCE_LEGACY = process.env.CLAUDE_MEM_SALIENCE === 'legacy'
42
43
  || process.env.CLAUDE_MEM_SALIENCE === '0';
44
+ const SALIENCE_BIND = process.env.CLAUDE_MEM_SALIENCE === 'bind';
43
45
  const ACK_DIRECTIVE = "apply each lesson to this edit or rule it out — state '#NN applied' or '#NN n/a — <reason>' in your next user-facing message.";
46
+ // v-bind salience forcing-function (#8771 audit: ack ≠ act). Instead of a cheap
47
+ // '#NN applied / n/a' verdict, demand the model bind the lesson to the concrete
48
+ // line it's editing and quote the satisfying edit line. Selected by
49
+ // CLAUDE_MEM_SALIENCE=bind; default stays ACK_DIRECTIVE.
50
+ const BIND_DIRECTIVE = "For each lesson: state the one concrete check it forces on the line(s) you're editing, quote the edit line that satisfies it, then report '#NN: <check> — pass' or '#NN: n/a — <why this edit can't reach it>'.";
51
+ const ACTIVE_DIRECTIVE = SALIENCE_BIND ? BIND_DIRECTIVE : ACK_DIRECTIVE;
44
52
  const STALE_MS = 10 * 60 * 1000; // 10 minutes cleanup threshold for legacy file
45
53
  // Feature ① (file intelligence): on the first Read of a file each session, inject
46
54
  // its approximate token size + a one-line summary so the agent can decide to read
@@ -238,7 +246,7 @@ try {
238
246
  hookEventName: 'PreToolUse',
239
247
  additionalContext: [
240
248
  '[mem] PreToolUse recall — system-injected context, continue your planned action:',
241
- `[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${ACK_DIRECTIVE}`,
249
+ `[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${ACTIVE_DIRECTIVE}`,
242
250
  ].join('\n'),
243
251
  },
244
252
  }));
@@ -434,7 +442,7 @@ try {
434
442
  // Read keeps the quiet form; its forcing-function fires at the later Edit
435
443
  // via the Read→Edit ack nudge above.
436
444
  if (!isRead && !SALIENCE_LEGACY) {
437
- lines.push(`[mem] ⚠ Before this edit: ${ACK_DIRECTIVE}`);
445
+ lines.push(`[mem] ⚠ Before this edit: ${ACTIVE_DIRECTIVE}`);
438
446
  }
439
447
  } else if (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1') {
440
448
  // R-4: Edit/Write empty → short backfill reminder. OPT-IN (default off) as
@@ -474,10 +482,27 @@ try {
474
482
  // full re-read of the unchanged file can be flagged. Read-only, session-scoped;
475
483
  // one stat + bounded read, first-read only.
476
484
  const rereadMeta = (isRead && !REREAD_GUARD_OFF && isSessionScoped) ? readFileMeta(filePath) : null;
485
+ // bind salience (component 2): record the identifiers each lesson NAMES that
486
+ // ALSO appear in the current (pre-edit) file, so post-tool-recall.js can flag
487
+ // an edit that drops one. Only under =bind with lessons — keeps the default
488
+ // path free of the extra file read. Bounded read; never throws.
489
+ let lessonIdents;
490
+ if (SALIENCE_BIND && allRows.length > 0) {
491
+ try {
492
+ const pre = readFileSync(filePath, 'utf8').slice(0, 256 * 1024);
493
+ const acc = {};
494
+ for (const r of allRows) {
495
+ const present = presentIdents(`${r.lesson_learned || ''} ${r.title || ''}`, pre);
496
+ if (present.length) acc[r.id] = present;
497
+ }
498
+ if (Object.keys(acc).length) lessonIdents = acc;
499
+ } catch { /* unreadable pre-edit file — skip the diff check */ }
500
+ }
477
501
  cooldown[filePath] = {
478
502
  ts: now,
479
503
  lessonIds: allRows.map(r => r.id),
480
504
  mode: isRead ? 'read' : 'edit',
505
+ ...(lessonIdents ? { lessonIdents } : {}),
481
506
  ...(rereadMeta ? { reread: { mtimeMs: rereadMeta.mtimeMs, tokens: rereadMeta.tokens, full: isFullRead } } : {}),
482
507
  };
483
508
  writeCooldown(cooldownPath, cooldown, isSessionScoped);
package/search-engine.mjs CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  relaxFtsQueryToOr, debugLog, debugCatch, estimateTokens,
13
13
  } from './utils.mjs';
14
14
  import { getVocabulary, computeVector, vectorSearch, rrfMerge } from './tfidf.mjs';
15
- import { extractPRFTerms, expandQueryByConcepts } from './server-internals.mjs';
15
+ import { extractPRFTerms, expandQueryByConcepts } from './search-scoring.mjs';
16
16
 
17
17
  // Scoring expressions — full adds project boost + access bonus; simple is for
18
18
  // expansion paths where boost would over-amplify already-loose matches.
@@ -1,5 +1,9 @@
1
- // claude-mem-lite server internal functions
2
- // Extracted from server.mjs for testability (server.mjs has top-level side effects)
1
+ // claude-mem-lite shared search-scoring / ranking helpers: re-ranking, supersede
2
+ // marking, PRF term extraction, concept-expansion plus the MCP instructions
3
+ // builder and idle-cleanup/access-boost side helpers. Used by the MCP server,
4
+ // the CLI (mem-cli), and search-engine; originally extracted from server.mjs for
5
+ // testability (server.mjs has top-level side effects), hence the former
6
+ // "server-internals" name — renamed in audit P3 since it is not server-only.
3
7
 
4
8
  import { debugCatch, COMPRESSED_AUTO, COMPRESSED_PENDING_PURGE, OBS_BM25 } from './utils.mjs';
5
9
  import { BASE_STOP_WORDS } from './stop-words.mjs';
package/server.mjs CHANGED
@@ -8,12 +8,12 @@ import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
8
8
  import { truncate, typeIcon, inferProject, scrubSecrets, fmtDate, debugLog, debugCatch, isPathConfined } from './utils.mjs';
9
9
  import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
10
10
  import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
11
- import { reRankWithContext, markSuperseded, autoBoostIfNeeded, runIdleCleanup, buildServerInstructions } 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 { reRankWithContext, markSuperseded, autoBoostIfNeeded, runIdleCleanup, buildServerInstructions } from './search-scoring.mjs';
12
+ import { searchObservationsHybrid } from './search-engine.mjs';
13
+ import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
14
14
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
15
15
  import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
16
- import { buildSearchFtsQuery, parseDateBounds, computePerSourceWindow, effectiveObsFtsQuery, searchSessionsFts, searchPromptsFts, normalizeCrossSourceScores, applyUserSort, applyTierFilter } from './lib/search-core.mjs';
16
+ import { buildSearchFtsQuery, parseDateBounds, coreRunSearchPipeline } from './lib/search-core.mjs';
17
17
  import {
18
18
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
19
19
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
@@ -61,8 +61,19 @@ let db;
61
61
  try {
62
62
  db = ensureDb();
63
63
  } catch (firstErr) {
64
+ // WAL-delete recovery is ONLY safe for genuine corruption. On a transient
65
+ // error (SQLITE_BUSY) or the forward-version guard throw, deleting the WAL
66
+ // would discard committed-but-uncheckpointed transactions — silent data loss.
67
+ // Restrict the rm to corruption signatures; otherwise fail fast, WAL intact.
68
+ const sig = `${firstErr.code || ''} ${firstErr.message || ''}`;
69
+ const isCorruption = /SQLITE_CORRUPT|SQLITE_NOTADB|malformed|not a database|disk image/i.test(sig);
70
+ if (!isCorruption) {
71
+ console.error(`[claude-mem-lite] FATAL: Database cannot be opened: ${firstErr.message}`);
72
+ console.error(`[claude-mem-lite] Left WAL/SHM intact (not a corruption error). If this persists, retry or reinstall: node install.mjs install`);
73
+ process.exit(1);
74
+ }
64
75
  // Recovery: remove WAL/SHM files (corrupt WAL is the most common cause) and retry
65
- debugLog('WARN', 'server', `DB open failed, attempting WAL recovery: ${firstErr.message}`);
76
+ debugLog('WARN', 'server', `DB corruption detected, attempting WAL recovery: ${firstErr.message}`);
66
77
  try { rmSync(DB_PATH + '-wal', { force: true }); } catch {}
67
78
  try { rmSync(DB_PATH + '-shm', { force: true }); } catch {}
68
79
  try {
@@ -166,91 +177,9 @@ function safeHandler(fn) {
166
177
  // Observation-search core (FTS query/params builders, hybrid pipeline) lives in
167
178
  // search-engine.mjs so mem-cli.mjs gets the identical implementation.
168
179
 
169
- // Thin wrapper around the shared engine keeps the existing call sites
170
- // (searchObservations(ctx)) without ferrying `db` through every layer.
171
- // ctx.db is set by runSearchPipeline when an injected db is present (e.g. tests);
172
- // falls back to the module-level db for the normal MCP handler path.
173
- function searchObservations(ctx) {
174
- return searchObservationsHybrid(ctx.db ?? db, ctx);
175
- }
176
-
177
- function searchSessions(ctx) {
178
- const _db = ctx.db ?? db;
179
- const { ftsQuery, searchType, args, epochFrom, epochTo, perSourceLimit, perSourceOffset, currentProject } = ctx;
180
- const results = [];
181
-
182
- if (ftsQuery) {
183
- const rows = searchSessionsFts(_db, {
184
- ftsQuery, project: args.project ?? null,
185
- projectBoost: args.project ? null : currentProject,
186
- epochFrom, epochTo, perSourceLimit, perSourceOffset,
187
- });
188
- for (const r of rows) {
189
- results.push({ source: 'session', id: r.id, request: r.request, completed: r.completed, project: r.project, date: r.created_at, created_at_epoch: r.created_at_epoch, score: r.score });
190
- }
191
- } else if (!searchType) {
192
- // Skip sessions in unfiltered no-query mode (too noisy)
193
- } else {
194
- const params = [];
195
- const wheres = [];
196
- if (args.project) { wheres.push('project = ?'); params.push(args.project); }
197
- if (epochFrom !== null) { wheres.push('created_at_epoch >= ?'); params.push(epochFrom); }
198
- if (epochTo !== null) { wheres.push('created_at_epoch <= ?'); params.push(epochTo); }
199
- const where = wheres.length ? `WHERE ${wheres.join(' AND ')}` : '';
200
- params.push(perSourceLimit, perSourceOffset);
201
- const rows = _db.prepare(`
202
- SELECT id, request, completed, project, created_at, created_at_epoch
203
- FROM session_summaries ${where}
204
- ORDER BY created_at_epoch DESC
205
- LIMIT ? OFFSET ?
206
- `).all(...params);
207
- for (const r of rows) {
208
- results.push({ source: 'session', id: r.id, request: r.request, completed: r.completed, project: r.project, date: r.created_at, created_at_epoch: r.created_at_epoch });
209
- }
210
- }
211
-
212
- return results;
213
- }
214
-
215
- function searchPrompts(ctx) {
216
- const _db = ctx.db ?? db;
217
- const { ftsQuery, searchType, args, epochFrom, epochTo, perSourceLimit, perSourceOffset } = ctx;
218
- const results = [];
219
-
220
- if (ftsQuery) {
221
- // CJK precision gate + LIKE fallback live in the shared core (see
222
- // lib/search-core.mjs for the leak rationale).
223
- const rows = searchPromptsFts(_db, {
224
- query: args.query, ftsQuery, project: args.project ?? null,
225
- epochFrom, epochTo, perSourceLimit, perSourceOffset,
226
- });
227
- for (const r of rows) {
228
- results.push({ source: 'prompt', id: r.id, text: r.prompt_text, session: r.content_session_id, date: r.created_at, created_at_epoch: r.created_at_epoch, score: r.score });
229
- }
230
- } else if (searchType === 'prompts') {
231
- const params = [];
232
- const wheres = [];
233
- if (args.project) { wheres.push('s.project = ?'); params.push(args.project); }
234
- if (epochFrom !== null) { wheres.push('p.created_at_epoch >= ?'); params.push(epochFrom); }
235
- if (epochTo !== null) { wheres.push('p.created_at_epoch <= ?'); params.push(epochTo); }
236
- const where = wheres.length ? `WHERE ${wheres.join(' AND ')}` : '';
237
- params.push(perSourceLimit, perSourceOffset);
238
- const rows = _db.prepare(`
239
- SELECT p.id, p.prompt_text, p.content_session_id, p.created_at, p.created_at_epoch
240
- FROM user_prompts p
241
- JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
242
- ${where}
243
- ORDER BY p.created_at_epoch DESC
244
- LIMIT ? OFFSET ?
245
- `).all(...params);
246
- for (const r of rows) {
247
- results.push({ source: 'prompt', id: r.id, text: r.prompt_text, session: r.content_session_id, date: r.created_at, created_at_epoch: r.created_at_epoch });
248
- }
249
- }
250
-
251
- return results;
252
- }
253
-
180
+ // searchObservations / searchSessions / searchPrompts were consolidated into the
181
+ // shared coreRunSearchPipeline (lib/search-core.mjs). This surface is now a thin
182
+ // adapter (runSearchPipeline below); only output formatting stays local.
254
183
  function formatSearchOutput(paginatedResults, args, ftsQuery, totalCount, orFallbackFired = false, isDeepSearch = false) {
255
184
  if (paginatedResults.length === 0) {
256
185
  const hint = [];
@@ -328,213 +257,74 @@ export async function handleSearchForTest(db, args, { llm, rerankLlm } = {}) {
328
257
  }
329
258
 
330
259
  async function runSearchPipeline(db, args, { llm, rerankLlm } = {}) {
331
- if (args.project) args = { ...args, project: resolveProject(args.project) };
332
- const limit = args.limit ?? 20;
333
- const offset = args.offset ?? 0;
334
- // args.or (Batch A CLI↔MCP alignment): force OR from start, matching
335
- // CLI `search --or`. The default path still does AND with OR-fallback
336
- // inside searchObservations when AND returns 0.
337
- const ftsQuery = buildSearchFtsQuery(args.query, { or: args.or });
338
- const searchType = args.type;
339
- const currentProject = inferProject();
340
-
341
- // Over-fetch from offset 0 for EVERY mode, then apply `offset` exactly once
342
- // at the merge slice below — shared sizing with the CLI (see
343
- // computePerSourceWindow for the #8217 double-offset rationale).
344
- const { perSourceLimit, perSourceOffset } = computePerSourceWindow(limit, offset);
345
-
346
- // Parse date bounds to epoch (with validation; date-only date_to extends
347
- // to end-of-day 23:59:59.999Z — shared with CLI --from/--to)
348
- const bounds = parseDateBounds(args.date_from, args.date_to);
349
- if (!bounds.ok) throw new Error(`Invalid date_${bounds.bad}: "${bounds.value}" (use ISO 8601 or YYYY-MM-DD)`);
350
- const { epochFrom, epochTo } = bounds;
351
-
352
- // Resolve tri-state deep mode. MCP defaults to 'auto' (escalate on weak results)
353
- // unless explicitly overridden via args.deep or CLAUDE_MEM_AUTO_DEEP env flag.
354
- const deepMode = resolveDeepMode(args.deep, { surface: 'mcp' });
355
- // Opt-in LLM rerank (D#43): explicit-deep only — never on AUTO escalation — so
356
- // no default search behaviour changes. Parity with CLI `search --deep --rerank`.
357
- const rerank = args.rerank === true && deepMode === 'deep';
358
-
359
- // Early return when query was provided but sanitized to nothing (all FTS5
360
- // keywords/special chars). Skipped for deep/auto — deep's LLM rewrite may
361
- // still produce searchable variants from a query the FTS sanitizer rejects,
362
- // and auto could escalate similarly.
363
- if (args.query && !ftsQuery && !epochFrom && !epochTo && !args.obs_type && !args.importance && deepMode === 'normal') {
364
- return { ...formatSearchOutput([], args, ftsQuery, 0), escalated: false, results: [], total: 0, variants: null };
365
- }
366
-
367
- // When obs_type is specified, implicitly restrict to observations only.
368
- // deep mode is observations-only too (deepSearch fuses hybrid-obs lists).
369
- const effectiveType = deepMode === 'deep' ? 'observations' : (searchType || (args.obs_type ? 'observations' : undefined));
370
- const isCrossSource = !effectiveType;
371
- const ctx = { db, ftsQuery, searchType: effectiveType, args, epochFrom, epochTo, perSourceLimit, perSourceOffset, currentProject, limit };
372
- const results = [];
373
- let deepVariants = null;
374
- let deepReranked = false;
375
- let isDeep = deepMode === 'deep';
376
- let escalated = false;
377
- let escalatedObsCount = 0;
378
-
379
- // Helper: run deepSearch and load results into the shared `results` array.
380
- const runDeepInto = async ({ auto = false } = {}) => {
381
- const { results: deepRows, variants, reranked } = await deepSearch(db, {
382
- query: args.query,
383
- project: args.project || null,
384
- type: args.obs_type || null,
385
- importance: args.importance || null,
386
- branch: args.branch || null,
387
- includeNoise: args.include_noise === true,
388
- epochFrom, epochTo,
389
- limit: perSourceLimit,
390
- currentProject,
391
- }, llm ? { llm, rerank: rerank && !auto, rerankLlm } : { auto, rerank: rerank && !auto, rerankLlm });
392
- // Safe to reset: sessions/prompts are pushed AFTER the obs block, so nothing is lost here.
393
- results.length = 0;
394
- results.push(...deepRows);
395
- deepVariants = variants;
396
- deepReranked = reranked;
397
- };
398
-
399
- if (!effectiveType || effectiveType === 'observations') {
400
- if (deepMode === 'deep') {
401
- // Opt-in LLM multi-query/HyDE deep search: rewrite → per-variant hybrid
402
- // search → RRF fusion, collapsing to the single query (== baseline) when
403
- // the rewrite yields nothing (deep-search.mjs). Over-fetch perSourceLimit
404
- // so the pagination slice below has room.
405
- await runDeepInto();
406
- } else {
407
- results.push(...searchObservations(ctx));
408
- // Auto-escalate: if normal search is weak (too few results or OR fallback
409
- // fired — a vocabulary-mismatch symptom), escalate to deep. ctx is mutated
410
- // by searchObservations to set ctx.orFallbackFired when the AND→OR relaxation
411
- // fires, so we read it here after the call.
412
- // results is already obs-only here (sessions/prompts pushed below), but the
413
- // filter makes the invariant explicit and robust to future reordering.
414
- const obsCountBeforeEscalation = results.length;
415
- if (deepMode === 'auto' && autoDeepLlmReady(process.env, llm) && shouldEscalateToDeep(results.filter(r => r.source === 'obs'), ctx) && hasEscalatableCorpus(db, args.project || null)) {
416
- await runDeepInto({ auto: true });
417
- isDeep = true;
418
- escalated = true;
419
- escalatedObsCount = obsCountBeforeEscalation;
420
- }
421
- }
422
- }
423
- // Sessions and prompts are excluded when deep (obs-only invariant, #8735).
424
- if ((!effectiveType || effectiveType === 'sessions') && !isDeep) results.push(...searchSessions(ctx));
425
- if ((!effectiveType || effectiveType === 'prompts') && !isDeep) results.push(...searchPrompts(ctx));
426
-
427
- // Type-list fallback: when obs_type is specified and FTS finds nothing,
428
- // list recent observations of that type (user likely wants to browse by type)
429
- if (results.length === 0 && args.obs_type) {
430
- const typeWheres = ['COALESCE(compressed_into, 0) = 0', 'superseded_at IS NULL', 'type = ?'];
431
- const typeParams = [args.obs_type];
432
- if (args.project) { typeWheres.push('project = ?'); typeParams.push(args.project); }
433
- if (epochFrom !== null) { typeWheres.push('created_at_epoch >= ?'); typeParams.push(epochFrom); }
434
- if (epochTo !== null) { typeWheres.push('created_at_epoch <= ?'); typeParams.push(epochTo); }
435
- if (args.importance) { typeWheres.push('COALESCE(importance, 1) >= ?'); typeParams.push(args.importance); }
436
- typeParams.push(limit);
437
- const typeRows = db.prepare(`
438
- SELECT id, type, title, subtitle, project, created_at, importance, files_modified
439
- FROM observations WHERE ${typeWheres.join(' AND ')}
440
- ORDER BY created_at_epoch DESC LIMIT ?
441
- `).all(...typeParams);
442
- for (const r of typeRows) {
443
- 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: '' });
444
- }
445
- }
446
-
447
- // Cross-source score normalization (shared with CLI — lib/search-core.mjs):
448
- // normalize each source to [-1, 0] before merging so observations (BM25 can
449
- // reach -40) don't systematically outrank sessions (-6) and prompts (-1).
450
- if (isCrossSource && results.length > 0 && ftsQuery) {
451
- normalizeCrossSourceScores(results, 'source');
452
- }
453
-
454
- // Global sort (cross-source)
455
- if (isCrossSource && results.length > 0) {
456
- if (ftsQuery) {
457
- results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
458
- } else {
459
- results.sort((a, b) => (b.created_at_epoch ?? 0) - (a.created_at_epoch ?? 0));
460
- }
461
- }
462
-
463
- // Re-rank observations by file context overlap and mark superseded.
464
- // markSuperseded is pure correctness (stale-tag) and must run for deep results
465
- // too, including the case where the ORIGINAL query sanitized to an empty
466
- // ftsQuery but the rewrite still returned rows (F2). reRankWithContext + the
467
- // re-sort are FTS-rank operations; deep rows are already RRF-ranked, so on the
468
- // empty-ftsQuery deep path we tag-but-don't-reorder (keep RRF order).
469
- if ((ftsQuery || isDeep) && results.some(r => r.source === 'obs')) {
470
- const obsResults = results.filter(r => r.source === 'obs');
471
- // When the deep candidates were explicitly LLM-reranked, that order is final:
472
- // skip the file-context re-rank + re-sort (they would perturb the rerank order
473
- // via score multiplication / score-sort). markSuperseded is pure stale-tagging
474
- // and still runs. (D#43 — parity with the CLI deep path, which keeps array order.)
475
- if (ftsQuery && !deepReranked) reRankWithContext(db, obsResults, currentProject);
476
- markSuperseded(obsResults);
477
- if (ftsQuery && !deepReranked) results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
478
- }
479
-
480
- // Tier post-filter: batch-lookup full rows and classify (shared with CLI).
481
- // Classification uses the explicitly-requested project, not the CWD-inferred
482
- // one — see applyTierFilter for the cross-project rationale.
483
- if (args.tier) {
484
- const filtered = applyTierFilter(db, results, { tier: args.tier, sourceKey: 'source', currentProject: args.project || currentProject });
485
- results.length = 0;
486
- results.push(...filtered);
487
- }
260
+ if (args.project) args = { ...args, project: resolveProject(args.project) };
261
+ const limit = args.limit ?? 20;
262
+ const offset = args.offset ?? 0;
263
+ // args.or: force OR from the start (CLI `search --or` parity). The default path
264
+ // still does AND with the engine's OR-fallback when AND returns 0.
265
+ const ftsQuery = buildSearchFtsQuery(args.query, { or: args.or });
266
+ const currentProject = inferProject();
267
+
268
+ const bounds = parseDateBounds(args.date_from, args.date_to);
269
+ if (!bounds.ok) throw new Error(`Invalid date_${bounds.bad}: "${bounds.value}" (use ISO 8601 or YYYY-MM-DD)`);
270
+ const { epochFrom, epochTo } = bounds;
271
+
272
+ // MCP defaults to 'auto' (escalate on weak results) unless overridden by
273
+ // args.deep or CLAUDE_MEM_AUTO_DEEP. Rerank is explicit-deep only (D#43).
274
+ const deepMode = resolveDeepMode(args.deep, { surface: 'mcp' });
275
+ const rerank = args.rerank === true && deepMode === 'deep';
276
+
277
+ // Early return when query was provided but sanitized to nothing (all FTS5
278
+ // keywords/special chars). Skipped for deep/auto (the LLM rewrite may still
279
+ // produce variants) and for filter-only listings (date/obs_type/importance).
280
+ if (args.query && !ftsQuery && !epochFrom && !epochTo && !args.obs_type && !args.importance && deepMode === 'normal') {
281
+ return { ...formatSearchOutput([], args, ftsQuery, 0), escalated: false, results: [], total: 0, variants: null };
282
+ }
488
283
 
489
- // Apply user-requested sort (after relevance scoring; shared with CLI)
490
- applyUserSort(results, args.sort || 'relevance');
491
-
492
- // `total` must be the TRUE population, invariant to limit/offset. In cross-source
493
- // mode results is over-fetched (perSourceLimit scales with limit+offset), so
494
- // results.length is NOT the population — count the real MATCH set instead. Clamp
495
- // to >= results.length so vector/concept-augmented obs rows are never undercounted.
496
- // (paired-path with mem-cli.mjs via shared countSearchTotal — #8217)
497
- // For deep (explicit or auto-escalated), the population is the fused variant set
498
- // already in `results` (deep is obs-only, returned by deepSearch capped at
499
- // perSourceLimit). countSearchTotal would count the ORIGINAL query's FTS matches
500
- // instead — wrong, and ~0 on the vocabulary-mismatch queries deep exists for (F1).
501
- const totalBeforePagination = isDeep
502
- ? results.length
503
- : Math.max(countSearchTotal(db, {
504
- effectiveSource: effectiveType || null,
505
- ftsQuery,
506
- obsFtsQuery: effectiveObsFtsQuery(ftsQuery, ctx.orFallbackFired === true),
507
- args: { project: args.project || null, obs_type: args.obs_type || null, importance: args.importance || null, branch: args.branch || null },
508
- project: args.project || null,
509
- epochFrom, epochTo,
510
- includeNoise: args.include_noise === true,
511
- }), results.length);
512
- // Always apply pagination — single-source results can exceed SQL LIMIT due to expansion (concept co-occurrence, PRF, vector search)
513
- const paginatedResults = (offset > 0 || results.length > limit) ? results.slice(offset, offset + limit) : results;
514
- // Enrich the FINAL page with a fetch-cost estimate (~Nt) so the agent budgets before mem_get.
515
- // Uses the same db threaded through the pipeline (#8743) — batch-fetches heavy obs fields by id.
516
- attachBodyTokens(db, paginatedResults);
517
-
518
- // Observability: announce auto-escalation on stderr (parity with CLI deep note).
519
- if (escalated) process.stderr.write(`[mem] auto-escalated to deep search (weak results: ${escalatedObsCount} hits)\n`);
520
-
521
- const output = formatSearchOutput(paginatedResults, args, ftsQuery, totalBeforePagination, ctx.orFallbackFired === true, isDeep);
522
- // Surface the rewrite to the calling agent (CLI prints this to stderr + JSON;
523
- // MCP had no signal at all — F13). Tells the agent whether deep actually
524
- // reformulated the query or collapsed to the single-query baseline.
525
- if (isDeep && deepVariants && output.content?.[0]?.type === 'text') {
526
- output.content[0].text += deepVariants.length > 1
527
- ? `\n\n[deep search: rewrote into ${deepVariants.length} variants — ${deepVariants.slice(1).map(v => JSON.stringify(v)).join(', ')}]`
528
- : '\n\n[deep search: rewrite produced no usable variants; searched the original query only (== baseline)]';
529
- }
530
- // Discoverability signal for the opt-in rerank (D#43): tell the calling agent the
531
- // candidates were LLM-reranked — parity with the CLI stderr note.
532
- if (deepReranked && output.content?.[0]?.type === 'text') {
533
- output.content[0].text += '\n\n[deep search: LLM-reranked the top candidates by relevance]';
534
- }
284
+ // obs_type ⇒ observations-only; deep is observations-only too (deepSearch fuses
285
+ // hybrid-obs lists). args.type is the source filter (observations|sessions|prompts).
286
+ const effectiveType = deepMode === 'deep' ? 'observations' : (args.type || (args.obs_type ? 'observations' : undefined));
287
+
288
+ const r = await coreRunSearchPipeline(
289
+ {
290
+ db, currentProject, env: process.env,
291
+ searchObservationsHybrid, deepSearch, shouldEscalateToDeep, autoDeepLlmReady,
292
+ reRankWithContext, markSuperseded, llm, rerankLlm,
293
+ },
294
+ {
295
+ query: args.query, ftsQuery, effectiveSource: effectiveType, deepMode, rerank,
296
+ limit, offset, project: args.project ?? null, obsType: args.obs_type ?? null,
297
+ importance: args.importance ?? null, branch: args.branch ?? null,
298
+ includeNoise: args.include_noise === true, epochFrom, epochTo,
299
+ sort: args.sort || 'relevance', tier: args.tier ?? null,
300
+ // ── MCP surface policy ──
301
+ obsTypeFallback: true, // list-recent-by-type when 0 matches
302
+ crossSourceEpochSortNoFts: true, // epoch-sort cross-source with no ftsQuery
303
+ rerankPolicy: 'mcp', // (ftsQuery||isDeep) gate; re-rank/re-sort on ftsQuery&&!reranked
304
+ rerankProject: currentProject,
305
+ recentListingNoFts: true, // recent-listing for explicit --source with no ftsQuery
306
+ tolerateMissingFts: false,
307
+ tierPosition: 'late', // tier filter after re-rank
308
+ tierProject: args.project || currentProject,
309
+ }
310
+ );
311
+
312
+ // Observability: announce auto-escalation on stderr (parity with CLI deep note).
313
+ if (r.escalated) process.stderr.write(`[mem] auto-escalated to deep search (weak results: ${r.escalatedObsCount} hits)\n`);
314
+
315
+ const output = formatSearchOutput(r.page, args, ftsQuery, r.total, r.orFallbackFired, r.isDeep);
316
+ // Surface the rewrite to the calling agent (F13) + the rerank signal (D#43).
317
+ if (r.isDeep && r.variants && output.content?.[0]?.type === 'text') {
318
+ output.content[0].text += r.variants.length > 1
319
+ ? `\n\n[deep search: rewrote into ${r.variants.length} variants ${r.variants.slice(1).map(v => JSON.stringify(v)).join(', ')}]`
320
+ : '\n\n[deep search: rewrite produced no usable variants; searched the original query only (== baseline)]';
321
+ }
322
+ if (r.reranked && output.content?.[0]?.type === 'text') {
323
+ output.content[0].text += '\n\n[deep search: LLM-reranked the top candidates by relevance]';
324
+ }
535
325
 
536
- // Return an object that exposes structured fields for tests + the MCP content blob.
537
- return { ...output, results: paginatedResults, total: totalBeforePagination, escalated, variants: deepVariants, reranked: deepReranked };
326
+ // Expose structured fields for tests + the MCP content blob.
327
+ return { ...output, results: r.page, total: r.total, escalated: r.escalated, variants: r.variants, reranked: r.reranked };
538
328
  }
539
329
 
540
330
  server.registerTool(
package/source-files.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  export const SOURCE_FILES = [
8
8
  // Entry points and top-level modules
9
- 'cli.mjs', 'cli-path.mjs', 'server.mjs', 'server-internals.mjs', 'search-engine.mjs', 'deep-search.mjs', 'rerank.mjs', 'tool-schemas.mjs',
9
+ 'cli.mjs', 'cli-path.mjs', 'server.mjs', 'search-scoring.mjs', 'search-engine.mjs', 'deep-search.mjs', 'rerank.mjs', 'tool-schemas.mjs',
10
10
  'hook.mjs', 'hook-shared.mjs', 'hook-llm.mjs', 'hook-memory.mjs', 'skip-tools.mjs',
11
11
  'hook-semaphore.mjs', 'hook-episode.mjs', 'hook-context.mjs', 'hook-handoff.mjs',
12
12
  'hook-update.mjs', 'hook-optimize.mjs', 'hook-precompact.mjs',
@@ -59,11 +59,20 @@ export const SOURCE_FILES = [
59
59
  'lib/file-intel.mjs',
60
60
  'lib/reread-guard.mjs',
61
61
  'lib/metrics.mjs',
62
+ // v3.6.x: bind-salience producer — extracts identifiers a lesson names that
63
+ // are present in the pre-edit file (component 2). Imported ONLY by
64
+ // scripts/pre-tool-recall.js; kept here for the same reason as file-intel.mjs.
65
+ 'lib/lesson-idents.mjs',
62
66
  // v2.71.x: better-sqlite3 ABI probe + auto-rebuild. Shared by install.mjs
63
67
  // (post-`npm install` verify) and scripts/launch.mjs (pre-server-launch
64
68
  // self-heal after Node ABI changes). Missing from manifest → auto-update
65
69
  // ships a stale install that FATALs on first DB open after Node upgrade.
66
70
  'lib/binding-probe.mjs',
71
+ // audit P0/P1: inter-process install lock + atomic config writes — imported by
72
+ // install.mjs (settings.json + install lock) and hook-update.mjs (.claude.json
73
+ // + auto-update lock). Must ship or a partial install/update skips them.
74
+ 'lib/proc-lock.mjs',
75
+ 'lib/atomic-write.mjs',
67
76
  // v2.41 god-module split — mem-cli.mjs router + per-cmd handlers under cli/
68
77
  'cli/common.mjs',
69
78
  'cli/fts-check.mjs',
@@ -149,6 +158,7 @@ export const HOOK_SCRIPT_FILES = [
149
158
  'user-prompt-search.js',
150
159
  'prompt-search-utils.mjs',
151
160
  'pre-tool-recall.js',
161
+ 'post-tool-recall.js',
152
162
  'pre-skill-bridge.js',
153
163
  // v2.84: self-heal wrapper that detects ERR_MODULE_NOT_FOUND under the
154
164
  // install dir and runs install.mjs repair before retrying the entry.