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
|
@@ -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)} — ${
|
|
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: ${
|
|
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 './
|
|
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
|
|
2
|
-
//
|
|
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 './
|
|
12
|
-
import { searchObservationsHybrid
|
|
13
|
-
import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady
|
|
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,
|
|
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
|
|
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
|
-
//
|
|
170
|
-
// (
|
|
171
|
-
//
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
:
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
537
|
-
|
|
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', '
|
|
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.
|