claude-mem-lite 3.1.0 → 3.1.2
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 +4 -4
- package/README.zh-CN.md +5 -3
- package/adopt-content.mjs +21 -19
- package/cli-path.mjs +21 -0
- package/commands/adopt.md +1 -1
- package/commands/bug.md +1 -1
- package/commands/lesson.md +1 -1
- package/commands/mem.md +8 -8
- package/commands/unadopt.md +1 -1
- package/deep-search.mjs +238 -0
- package/hook-llm.mjs +17 -1
- package/install.mjs +17 -0
- package/lib/native-binding-hint.mjs +46 -12
- package/mem-cli.mjs +89 -34
- package/package.json +3 -1
- package/scripts/hook-launcher.mjs +87 -10
- package/server-internals.mjs +8 -7
- package/server.mjs +74 -22
- package/source-files.mjs +1 -1
- package/tool-schemas.mjs +22 -19
package/mem-cli.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
|
|
|
10
10
|
import { _resetVocabCache } from './tfidf.mjs';
|
|
11
11
|
import { autoBoostIfNeeded, reRankWithContext, markSuperseded } from './server-internals.mjs';
|
|
12
12
|
import { searchObservationsHybrid, countSearchTotal } from './search-engine.mjs';
|
|
13
|
+
import { deepSearch } from './deep-search.mjs';
|
|
13
14
|
import { ensureRegistryDb, upsertResource } from './registry.mjs';
|
|
14
15
|
import { searchResources } from './registry-retriever.mjs';
|
|
15
16
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
@@ -47,11 +48,11 @@ import {
|
|
|
47
48
|
|
|
48
49
|
// ─── Commands ────────────────────────────────────────────────────────────────
|
|
49
50
|
|
|
50
|
-
function cmdSearch(db, args) {
|
|
51
|
+
async function cmdSearch(db, args) {
|
|
51
52
|
const { positional, flags } = parseArgs(args);
|
|
52
53
|
const query = positional.join(' ');
|
|
53
54
|
if (!query) {
|
|
54
|
-
fail('[mem] Usage: claude-mem-lite search <query> [--type TYPE] [--source SOURCE] [--limit N] [--project P] [--from DATE] [--to DATE] [--importance N] [--branch B] [--offset N] [--sort relevance|time|importance] [--include-noise]');
|
|
55
|
+
fail('[mem] Usage: claude-mem-lite search <query> [--type TYPE] [--source SOURCE] [--limit N] [--project P] [--from DATE] [--to DATE] [--importance N] [--branch B] [--offset N] [--sort relevance|time|importance] [--include-noise] [--deep]');
|
|
55
56
|
return;
|
|
56
57
|
}
|
|
57
58
|
|
|
@@ -99,6 +100,10 @@ function cmdSearch(db, args) {
|
|
|
99
100
|
// when explicitly searching for a file/command that produced a degraded title.
|
|
100
101
|
const includeNoise = flags['include-noise'] === true || flags['include-noise'] === 'true';
|
|
101
102
|
const jsonOutput = flags.json === true || flags.json === 'true';
|
|
103
|
+
// --deep: opt-in LLM multi-query / HyDE deep search (deep-search.mjs). Costs one
|
|
104
|
+
// Haiku call + N hybrid searches; observations-only. NOT the passive path — this
|
|
105
|
+
// is the explicit "search harder" lever for vocabulary-mismatch recall misses.
|
|
106
|
+
const deep = flags.deep === true || flags.deep === 'true';
|
|
102
107
|
|
|
103
108
|
if (source && !['observations', 'sessions', 'prompts'].includes(source)) {
|
|
104
109
|
fail(`[mem] Invalid --source "${source}". Use: observations, sessions, prompts`);
|
|
@@ -106,10 +111,17 @@ function cmdSearch(db, args) {
|
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
const ftsQuery = buildSearchFtsQuery(query, { or: useOr });
|
|
109
|
-
|
|
114
|
+
// --deep proceeds even when the literal query sanitizes to nothing — its LLM
|
|
115
|
+
// rewrite may still produce searchable variants (F3, parity with server.mjs).
|
|
116
|
+
if (!ftsQuery && !deep) {
|
|
110
117
|
fail(`[mem] No valid search terms in "${query}"`);
|
|
111
118
|
return;
|
|
112
119
|
}
|
|
120
|
+
// --deep ignores --or: each variant runs AND + the engine's built-in
|
|
121
|
+
// OR-fallback, so --or has no effect on the deep path — say so (F8).
|
|
122
|
+
if (deep && useOr) {
|
|
123
|
+
process.stderr.write('[mem] Note: --or has no effect with --deep (variants use AND + engine OR-fallback)\n');
|
|
124
|
+
}
|
|
113
125
|
|
|
114
126
|
// Warn if obs-only filters used with non-observation source
|
|
115
127
|
if (source && source !== 'observations' && (type || tier || minImportance || branch)) {
|
|
@@ -121,7 +133,14 @@ function cmdSearch(db, args) {
|
|
|
121
133
|
// --branch was previously cross-source: sessions/prompts have no branch column, so a query like
|
|
122
134
|
// `search "cache" --branch main` would include unrelated session/prompt rows, surprising users
|
|
123
135
|
// who passed --branch expecting a branch-scoped result.
|
|
124
|
-
|
|
136
|
+
// --deep is observations-only (deepSearch fuses searchObservationsHybrid lists);
|
|
137
|
+
// it overrides --source and the obs-only filter inference.
|
|
138
|
+
if (deep && source && source !== 'observations') {
|
|
139
|
+
process.stderr.write(`[mem] Note: --deep searches observations only; ignoring --source ${source}\n`);
|
|
140
|
+
}
|
|
141
|
+
const effectiveSource = deep
|
|
142
|
+
? 'observations'
|
|
143
|
+
: (source || ((type || tier || minImportance || branch) ? 'observations' : null));
|
|
125
144
|
|
|
126
145
|
// Cross-source mode: each source needs more candidates than the final limit
|
|
127
146
|
// so the post-merge sort has room to pick the best from each (shared sizing
|
|
@@ -136,27 +155,55 @@ function cmdSearch(db, args) {
|
|
|
136
155
|
// ctx.orFallbackFired so the header can surface a "(relaxed AND→OR)" hint.
|
|
137
156
|
let orFallbackFired = false;
|
|
138
157
|
|
|
158
|
+
let deepVariants = null;
|
|
139
159
|
// Search observations — shared engine with server.mjs (#8198/#8212 paired-path fix)
|
|
140
160
|
if (!effectiveSource || effectiveSource === 'observations') {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
161
|
+
let obsResults;
|
|
162
|
+
if (deep) {
|
|
163
|
+
// Opt-in deep search: rewrite the query into variants (keyword / concept /
|
|
164
|
+
// HyDE), run each through the hybrid engine, RRF-fuse. Collapses to the
|
|
165
|
+
// single query when the rewrite yields nothing — never worse than baseline
|
|
166
|
+
// (deep-search.mjs). Over-fetch perSourceLimit so the offset/slice below has room.
|
|
167
|
+
const ds = await deepSearch(db, {
|
|
168
|
+
query,
|
|
144
169
|
project: project || null,
|
|
145
|
-
|
|
170
|
+
type: type || null,
|
|
146
171
|
importance: minImportance || null,
|
|
147
172
|
branch: branch || null,
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
173
|
+
includeNoise,
|
|
174
|
+
epochFrom: dateFrom,
|
|
175
|
+
epochTo: dateTo,
|
|
176
|
+
limit: perSourceLimit,
|
|
177
|
+
currentProject: project ? null : inferProject(),
|
|
178
|
+
});
|
|
179
|
+
obsResults = ds.results;
|
|
180
|
+
deepVariants = ds.variants;
|
|
181
|
+
if (deepVariants.length > 1) {
|
|
182
|
+
process.stderr.write(`[mem] Deep search: rewrote into ${deepVariants.length} query variants, RRF-fused\n`);
|
|
183
|
+
} else {
|
|
184
|
+
process.stderr.write('[mem] Deep search: rewrite returned no usable variants; used original query only\n');
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
const obsCtx = {
|
|
188
|
+
ftsQuery,
|
|
189
|
+
args: {
|
|
190
|
+
project: project || null,
|
|
191
|
+
obs_type: type || null,
|
|
192
|
+
importance: minImportance || null,
|
|
193
|
+
branch: branch || null,
|
|
194
|
+
include_noise: includeNoise,
|
|
195
|
+
},
|
|
196
|
+
epochFrom: dateFrom,
|
|
197
|
+
epochTo: dateTo,
|
|
198
|
+
perSourceLimit,
|
|
199
|
+
perSourceOffset,
|
|
200
|
+
currentProject: project ? null : inferProject(),
|
|
201
|
+
limit,
|
|
202
|
+
orFallbackFired: false,
|
|
203
|
+
};
|
|
204
|
+
obsResults = searchObservationsHybrid(db, obsCtx);
|
|
205
|
+
if (obsCtx.orFallbackFired) orFallbackFired = true;
|
|
206
|
+
}
|
|
160
207
|
for (const r of obsResults) results.push({ ...r, _source: 'obs', score: r.score ?? 0 });
|
|
161
208
|
|
|
162
209
|
// Tier post-filter — applied to ALL obs results from the engine.
|
|
@@ -191,7 +238,7 @@ function cmdSearch(db, args) {
|
|
|
191
238
|
|
|
192
239
|
if (results.length === 0) {
|
|
193
240
|
if (jsonOutput) {
|
|
194
|
-
out(JSON.stringify({ query, total: 0, returned: 0, offset, limit, results: [] }));
|
|
241
|
+
out(JSON.stringify({ query, total: 0, returned: 0, offset, limit, deep, variants: deep ? deepVariants : undefined, results: [] }));
|
|
195
242
|
} else {
|
|
196
243
|
out(`[mem] No results for "${query}"`);
|
|
197
244
|
}
|
|
@@ -228,22 +275,28 @@ function cmdSearch(db, args) {
|
|
|
228
275
|
// pagination contract). countSearchTotal mirrors each source's MATCH+filters;
|
|
229
276
|
// clamp to >= results.length so it never understates the rows actually shown
|
|
230
277
|
// (vector/concept augmentation can add obs rows beyond the FTS count).
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
278
|
+
// For --deep the population is the fused variant result set: deepSearch already
|
|
279
|
+
// returned all fused rows (capped at perSourceLimit) and they are the only rows
|
|
280
|
+
// in `results` (deep is obs-only). countSearchTotal would instead count the
|
|
281
|
+
// ORIGINAL query's FTS matches — wrong, and ~0 on the vocabulary-mismatch
|
|
282
|
+
// queries deep exists for, which falsely shrinks the "N of M" total (F1).
|
|
283
|
+
const total = deep
|
|
284
|
+
? results.length
|
|
285
|
+
: Math.max(countSearchTotal(db, {
|
|
286
|
+
effectiveSource,
|
|
287
|
+
ftsQuery,
|
|
288
|
+
obsFtsQuery: effectiveObsFtsQuery(ftsQuery, orFallbackFired),
|
|
289
|
+
args: { project: project || null, obs_type: type || null, importance: minImportance || null, branch: branch || null },
|
|
290
|
+
project: project || null,
|
|
291
|
+
epochFrom: dateFrom,
|
|
292
|
+
epochTo: dateTo,
|
|
293
|
+
includeNoise,
|
|
294
|
+
}), results.length);
|
|
242
295
|
const paged = results.slice(offset, offset + limit);
|
|
243
296
|
|
|
244
297
|
if (paged.length === 0) {
|
|
245
298
|
if (jsonOutput) {
|
|
246
|
-
out(JSON.stringify({ query, total, returned: 0, offset, limit, results: [] }));
|
|
299
|
+
out(JSON.stringify({ query, total, returned: 0, offset, limit, deep, variants: deep ? deepVariants : undefined, results: [] }));
|
|
247
300
|
} else {
|
|
248
301
|
out(`[mem] No results for "${query}" at offset ${offset}`);
|
|
249
302
|
}
|
|
@@ -286,6 +339,8 @@ function cmdSearch(db, args) {
|
|
|
286
339
|
returned: paged.length,
|
|
287
340
|
offset,
|
|
288
341
|
limit,
|
|
342
|
+
deep,
|
|
343
|
+
variants: deep ? deepVariants : undefined,
|
|
289
344
|
relaxed_and_to_or: orFallbackFired && !useOr,
|
|
290
345
|
mixed_sources: hasMixed,
|
|
291
346
|
results: items,
|
|
@@ -2785,7 +2840,7 @@ export async function run(argv) {
|
|
|
2785
2840
|
|
|
2786
2841
|
try {
|
|
2787
2842
|
switch (cmd) {
|
|
2788
|
-
case 'search': cmdSearch(db, cmdArgs); break;
|
|
2843
|
+
case 'search': await cmdSearch(db, cmdArgs); break;
|
|
2789
2844
|
case 'recent': cmdRecent(db, cmdArgs); break;
|
|
2790
2845
|
case 'recall': cmdRecall(db, cmdArgs); break;
|
|
2791
2846
|
case 'get': cmdGet(db, cmdArgs); break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.2",
|
|
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",
|
|
@@ -25,10 +25,12 @@
|
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
27
|
"cli.mjs",
|
|
28
|
+
"cli-path.mjs",
|
|
28
29
|
"mem-cli.mjs",
|
|
29
30
|
"server.mjs",
|
|
30
31
|
"server-internals.mjs",
|
|
31
32
|
"search-engine.mjs",
|
|
33
|
+
"deep-search.mjs",
|
|
32
34
|
"hook.mjs",
|
|
33
35
|
"hook-shared.mjs",
|
|
34
36
|
"hook-llm.mjs",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// would defeat the entire purpose — the launcher must survive a broken
|
|
25
25
|
// install.
|
|
26
26
|
|
|
27
|
-
import { existsSync, mkdirSync, writeFileSync, statSync } from 'node:fs';
|
|
27
|
+
import { existsSync, mkdirSync, writeFileSync, statSync, unlinkSync, readFileSync } from 'node:fs';
|
|
28
28
|
import { spawnSync } from 'node:child_process';
|
|
29
29
|
import { dirname, join } from 'node:path';
|
|
30
30
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
@@ -37,8 +37,18 @@ const RUNTIME_DIR = process.env.CLAUDE_MEM_DIR
|
|
|
37
37
|
: join(homedir(), '.claude-mem-lite', 'runtime');
|
|
38
38
|
const HEAL_MARKER = join(RUNTIME_DIR, 'hook-launcher-lastheal');
|
|
39
39
|
const HEAL_COOLDOWN_MS = 6 * 60 * 60 * 1000;
|
|
40
|
+
// Observable breakage state: written when the launcher degrades a broken install
|
|
41
|
+
// to exit 0, cleared once the install is confirmed healthy. `doctor` reads it so
|
|
42
|
+
// the intentional silence (no stack trace per fire) stays detectable. (#4/#8)
|
|
43
|
+
const BROKEN_MARKER = join(RUNTIME_DIR, 'hook-launcher-broken');
|
|
40
44
|
|
|
41
|
-
//
|
|
45
|
+
// Resolvable invocation of the bundled CLI's repair path. Absolute via
|
|
46
|
+
// INSTALL_DIR (import.meta.url) so it works on a plugin-only install, where
|
|
47
|
+
// bare `claude-mem-lite` is not on PATH and ~/.claude-mem-lite/ holds no source.
|
|
48
|
+
// cli.mjs routes `repair` → install.mjs. (review #3)
|
|
49
|
+
const CLI_REPAIR = `node ${join(INSTALL_DIR, 'cli.mjs')} repair`;
|
|
50
|
+
|
|
51
|
+
// Last-resort recovery string for users whose `cli.mjs repair` path
|
|
42
52
|
// itself failed (install.mjs missing / repair errored / retry still drifting).
|
|
43
53
|
// Duplicated in install.mjs::repair() catch; both are reachable when local
|
|
44
54
|
// scripts are broken, so neither can import a shared constant.
|
|
@@ -76,13 +86,51 @@ async function runEntry({ bustCache = false } = {}) {
|
|
|
76
86
|
// Anchor on any path the error exposes — the missing URL and/or the importer
|
|
77
87
|
// (present in both messages as "imported from <path>"). If it sits inside our
|
|
78
88
|
// install, a self-heal could fix it.
|
|
89
|
+
// package.json dependency set of THIS install — read lazily, best-effort. Lets
|
|
90
|
+
// isLocalModuleErr tell a genuinely-ours missing bare dependency (better-sqlite3,
|
|
91
|
+
// zod, …) apart from a foreign/mistyped package name that merely happens to be
|
|
92
|
+
// imported from an install-dir file. The former is self-healable; the latter is
|
|
93
|
+
// a real packaging bug that must surface a Node stack trace rather than be
|
|
94
|
+
// swallowed by an exit-0 self-heal. (review #5/#7)
|
|
95
|
+
function ownDependencies() {
|
|
96
|
+
try {
|
|
97
|
+
const pkg = JSON.parse(readFileSync(join(INSTALL_DIR, 'package.json'), 'utf8'));
|
|
98
|
+
return new Set([
|
|
99
|
+
...Object.keys(pkg.dependencies || {}),
|
|
100
|
+
...Object.keys(pkg.optionalDependencies || {}),
|
|
101
|
+
]);
|
|
102
|
+
} catch {
|
|
103
|
+
return null; // unreadable package.json → caller stays permissive
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
79
107
|
function isLocalModuleErr(e) {
|
|
80
108
|
if (!e || e.code !== 'ERR_MODULE_NOT_FOUND') return false;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
109
|
+
// Missing RELATIVE module: e.url is the missing file's URL. Ours iff it sits
|
|
110
|
+
// under our install dir (the `.claude-mem-lite` substring also covers the
|
|
111
|
+
// symlink-farm dev/direct-install case where INSTALL_DIR is the realpath).
|
|
112
|
+
if (e.url) {
|
|
113
|
+
const p = String(e.url).replace(/^file:\/\//, '');
|
|
114
|
+
return p.startsWith(INSTALL_DIR) || p.includes('.claude-mem-lite');
|
|
115
|
+
}
|
|
116
|
+
// Missing BARE dependency: e.url is UNDEFINED; message is
|
|
117
|
+
// `Cannot find package '<name>' imported from <importer>`. The (.+) capture
|
|
118
|
+
// needs no `m` flag (`.` stops at a newline) and so tolerates a multi-line
|
|
119
|
+
// message that appends a hint line after the importer path — the old
|
|
120
|
+
// `(.+?)\s*$` returned undefined there and misclassified the dep. (review #11/#15)
|
|
121
|
+
const msg = String(e.message || '');
|
|
122
|
+
const importer = /imported from (.+)/.exec(msg)?.[1]?.trim();
|
|
123
|
+
if (!importer) return false;
|
|
124
|
+
const importerPath = importer.replace(/^file:\/\//, '');
|
|
125
|
+
if (!(importerPath.startsWith(INSTALL_DIR) || importerPath.includes('.claude-mem-lite'))) return false;
|
|
126
|
+
// Importer is ours — but only self-heal if the missing package is one we
|
|
127
|
+
// actually declare. A foreign/typo'd name re-throws so the bug is visible.
|
|
128
|
+
const pkgName = /Cannot find package '([^']+)'/.exec(msg)?.[1];
|
|
129
|
+
const deps = ownDependencies();
|
|
130
|
+
if (!deps || !pkgName) return true; // best-effort: can't verify → stay permissive
|
|
131
|
+
// Normalize sub-path / scoped imports to the package root (better-sqlite3/x → better-sqlite3).
|
|
132
|
+
const root = pkgName.startsWith('@') ? pkgName.split('/').slice(0, 2).join('/') : pkgName.split('/')[0];
|
|
133
|
+
return deps.has(root);
|
|
86
134
|
}
|
|
87
135
|
|
|
88
136
|
// Human-readable label for the "Detected broken install (<reason>)" line:
|
|
@@ -107,11 +155,29 @@ function recordHealAttempt() {
|
|
|
107
155
|
} catch { /* best-effort */ }
|
|
108
156
|
}
|
|
109
157
|
|
|
158
|
+
// Drop the 6h cooldown once a heal fully resolves. The marker is written BEFORE
|
|
159
|
+
// spawn (rate-limits concurrent fires), but a SUCCESSFUL heal must not keep
|
|
160
|
+
// blocking an unrelated later breakage that happens within the window. (#6/#9)
|
|
161
|
+
function clearHealMarker() {
|
|
162
|
+
try { unlinkSync(HEAL_MARKER); } catch { /* already gone — fine */ }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function recordBreakage(reason) {
|
|
166
|
+
try {
|
|
167
|
+
mkdirSync(RUNTIME_DIR, { recursive: true });
|
|
168
|
+
writeFileSync(BROKEN_MARKER, JSON.stringify({ reason, ts: Date.now() }));
|
|
169
|
+
} catch { /* best-effort */ }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function clearBreakage() {
|
|
173
|
+
try { if (existsSync(BROKEN_MARKER)) unlinkSync(BROKEN_MARKER); } catch { /* best-effort */ }
|
|
174
|
+
}
|
|
175
|
+
|
|
110
176
|
async function attemptHeal(reason) {
|
|
111
177
|
if (recentHealAttempt()) {
|
|
112
178
|
process.stderr.write(
|
|
113
179
|
`[claude-mem-lite] Self-heal skipped (last attempt < 6h ago).\n` +
|
|
114
|
-
`[claude-mem-lite] Manual recovery:
|
|
180
|
+
`[claude-mem-lite] Manual recovery: ${CLI_REPAIR}\n` +
|
|
115
181
|
`[claude-mem-lite] If that fails, run: ${TARBALL_FALLBACK}\n`,
|
|
116
182
|
);
|
|
117
183
|
return false;
|
|
@@ -160,19 +226,30 @@ if (rest.includes('session-start')) {
|
|
|
160
226
|
|
|
161
227
|
try {
|
|
162
228
|
await runEntry();
|
|
229
|
+
// A clean session-start fire confirms the install is healthy → clear any stale
|
|
230
|
+
// breakage marker. Gated to session-start so the per-tool hot path pays nothing.
|
|
231
|
+
if (rest.includes('session-start')) clearBreakage();
|
|
163
232
|
} catch (e) {
|
|
164
233
|
if (!isLocalModuleErr(e)) throw e;
|
|
165
|
-
const
|
|
234
|
+
const reason = describeFailure(e);
|
|
235
|
+
const healed = await attemptHeal(reason);
|
|
166
236
|
if (!healed) {
|
|
167
237
|
// Broken/missing dependency we can't repair right now (repair failed, or
|
|
168
238
|
// was skipped within the 6h cooldown). attemptHeal already wrote actionable
|
|
169
239
|
// guidance — degrade quietly instead of re-throwing the original import
|
|
170
|
-
// error, which would spew a Node stack trace on every hook fire.
|
|
240
|
+
// error, which would spew a Node stack trace on every hook fire. Record the
|
|
241
|
+
// breakage so the exit-0 silence stays observable to `doctor`. (#4/#8)
|
|
242
|
+
recordBreakage(reason);
|
|
171
243
|
process.exit(0);
|
|
172
244
|
}
|
|
173
245
|
try {
|
|
174
246
|
await runEntry({ bustCache: true });
|
|
247
|
+
// Fully healed: drop the cooldown so an UNRELATED later break can heal
|
|
248
|
+
// immediately (#6/#9), and clear the breakage marker.
|
|
249
|
+
clearHealMarker();
|
|
250
|
+
clearBreakage();
|
|
175
251
|
} catch (retryErr) {
|
|
252
|
+
recordBreakage(`retry-failed: ${retryErr.message}`);
|
|
176
253
|
process.stderr.write(
|
|
177
254
|
`[claude-mem-lite] Hook still failing after self-heal: ${retryErr.message}\n` +
|
|
178
255
|
`[claude-mem-lite] Manual recovery: ${TARBALL_FALLBACK}\n`,
|
package/server-internals.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { debugCatch, COMPRESSED_AUTO, COMPRESSED_PENDING_PURGE, OBS_BM25 } from './utils.mjs';
|
|
5
5
|
import { BASE_STOP_WORDS } from './stop-words.mjs';
|
|
6
6
|
import { porterStem } from './tfidf.mjs';
|
|
7
|
+
import { CLI_INVOKE } from './cli-path.mjs';
|
|
7
8
|
|
|
8
9
|
// ─── MCP Server Instructions Builder ───────────────────────────────────────
|
|
9
10
|
// Phase A (v2.31.3+): when quiet=true, drops WHEN-TO-USE proactive-trigger and
|
|
@@ -14,13 +15,13 @@ import { porterStem } from './tfidf.mjs';
|
|
|
14
15
|
const INSTRUCTIONS_BASE = [
|
|
15
16
|
'Long-term memory across sessions. Hooks auto-inject context; CLI preferred for explicit queries.',
|
|
16
17
|
'',
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
`CLI (via Bash) — invoke as \`${CLI_INVOKE} <cmd>\` (resolves on any install shape; the bare \`claude-mem-lite\` shorthand works only after an optional global \`npm i -g claude-mem-lite\`):`,
|
|
19
|
+
` ${CLI_INVOKE} search "query" — FTS5 full-text search`,
|
|
20
|
+
` ${CLI_INVOKE} search "err" --type bugfix — filter by type`,
|
|
21
|
+
` ${CLI_INVOKE} recall "file.mjs" — file-related memories`,
|
|
22
|
+
` ${CLI_INVOKE} recent 5 — latest observations`,
|
|
23
|
+
` ${CLI_INVOKE} get 42,43 — full details by ID`,
|
|
24
|
+
` ${CLI_INVOKE} timeline --anchor 42 — chronological context`,
|
|
24
25
|
'',
|
|
25
26
|
'MCP tools: mem_search, mem_recent, mem_save, mem_get, mem_recall, mem_timeline for programmatic access (always available — no PATH/CLI install needed).',
|
|
26
27
|
'mem_save: Save non-obvious insights (bugfix lessons, architecture decisions).',
|
package/server.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
|
|
|
10
10
|
import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
|
|
11
11
|
import { reRankWithContext, markSuperseded, autoBoostIfNeeded, runIdleCleanup, buildServerInstructions } from './server-internals.mjs';
|
|
12
12
|
import { searchObservationsHybrid, countSearchTotal } from './search-engine.mjs';
|
|
13
|
+
import { deepSearch } from './deep-search.mjs';
|
|
13
14
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
14
15
|
import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
|
|
15
16
|
import { buildSearchFtsQuery, parseDateBounds, computePerSourceWindow, effectiveObsFtsQuery, searchSessionsFts, searchPromptsFts, normalizeCrossSourceScores, applyUserSort, applyTierFilter } from './lib/search-core.mjs';
|
|
@@ -249,7 +250,13 @@ function searchPrompts(ctx) {
|
|
|
249
250
|
function formatSearchOutput(paginatedResults, args, ftsQuery, totalCount, orFallbackFired = false) {
|
|
250
251
|
if (paginatedResults.length === 0) {
|
|
251
252
|
const hint = [];
|
|
252
|
-
if (args.
|
|
253
|
+
if (args.deep) {
|
|
254
|
+
// Deep search runs even when the literal query sanitizes to empty, so the
|
|
255
|
+
// "query was filtered" hint below would be misleading — the LLM rewrite ran
|
|
256
|
+
// N variants and simply found nothing (F9).
|
|
257
|
+
hint.push('No results — deep search rewrote the query into variants and still found nothing.');
|
|
258
|
+
hint.push('This is a recall miss (the rewrite ran), not a query-syntax issue; the memory likely has no related observations.');
|
|
259
|
+
} else if (args.query && !ftsQuery) {
|
|
253
260
|
hint.push(`Query "${args.query}" was filtered (FTS5 keywords/special chars only).`);
|
|
254
261
|
hint.push('Tip: use content words instead of operators (AND, OR, NOT, NEAR).');
|
|
255
262
|
} else {
|
|
@@ -331,18 +338,44 @@ server.registerTool(
|
|
|
331
338
|
if (!bounds.ok) throw new Error(`Invalid date_${bounds.bad}: "${bounds.value}" (use ISO 8601 or YYYY-MM-DD)`);
|
|
332
339
|
const { epochFrom, epochTo } = bounds;
|
|
333
340
|
|
|
334
|
-
// Early return when query was provided but sanitized to nothing (all FTS5
|
|
335
|
-
|
|
341
|
+
// Early return when query was provided but sanitized to nothing (all FTS5
|
|
342
|
+
// keywords/special chars). Skipped for deep search — its LLM rewrite may
|
|
343
|
+
// still produce searchable variants from a query the FTS sanitizer rejects.
|
|
344
|
+
if (args.query && !ftsQuery && !epochFrom && !epochTo && !args.obs_type && !args.importance && !args.deep) {
|
|
336
345
|
return formatSearchOutput([], args, ftsQuery, 0);
|
|
337
346
|
}
|
|
338
347
|
|
|
339
|
-
// When obs_type is specified, implicitly restrict to observations only
|
|
340
|
-
|
|
348
|
+
// When obs_type is specified, implicitly restrict to observations only.
|
|
349
|
+
// --deep is observations-only too (deepSearch fuses hybrid-obs lists).
|
|
350
|
+
const effectiveType = args.deep ? 'observations' : (searchType || (args.obs_type ? 'observations' : undefined));
|
|
341
351
|
const isCrossSource = !effectiveType;
|
|
342
352
|
const ctx = { ftsQuery, searchType: effectiveType, args, epochFrom, epochTo, perSourceLimit, perSourceOffset, currentProject, limit };
|
|
343
353
|
const results = [];
|
|
344
|
-
|
|
345
|
-
|
|
354
|
+
let deepVariants = null;
|
|
355
|
+
|
|
356
|
+
if (!effectiveType || effectiveType === 'observations') {
|
|
357
|
+
if (args.deep) {
|
|
358
|
+
// Opt-in LLM multi-query/HyDE deep search: rewrite → per-variant hybrid
|
|
359
|
+
// search → RRF fusion, collapsing to the single query (== baseline) when
|
|
360
|
+
// the rewrite yields nothing (deep-search.mjs). Over-fetch perSourceLimit
|
|
361
|
+
// so the pagination slice below has room.
|
|
362
|
+
const { results: deepRows, variants } = await deepSearch(db, {
|
|
363
|
+
query: args.query,
|
|
364
|
+
project: args.project || null,
|
|
365
|
+
type: args.obs_type || null,
|
|
366
|
+
importance: args.importance || null,
|
|
367
|
+
branch: args.branch || null,
|
|
368
|
+
includeNoise: args.include_noise === true,
|
|
369
|
+
epochFrom, epochTo,
|
|
370
|
+
limit: perSourceLimit,
|
|
371
|
+
currentProject,
|
|
372
|
+
});
|
|
373
|
+
results.push(...deepRows);
|
|
374
|
+
deepVariants = variants;
|
|
375
|
+
} else {
|
|
376
|
+
results.push(...searchObservations(ctx));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
346
379
|
if (!effectiveType || effectiveType === 'sessions') results.push(...searchSessions(ctx));
|
|
347
380
|
if (!effectiveType || effectiveType === 'prompts') results.push(...searchPrompts(ctx));
|
|
348
381
|
|
|
@@ -382,12 +415,17 @@ server.registerTool(
|
|
|
382
415
|
}
|
|
383
416
|
}
|
|
384
417
|
|
|
385
|
-
// Re-rank observations by file context overlap and mark superseded
|
|
386
|
-
|
|
418
|
+
// Re-rank observations by file context overlap and mark superseded.
|
|
419
|
+
// markSuperseded is pure correctness (stale-tag) and must run for deep results
|
|
420
|
+
// too, including the case where the ORIGINAL query sanitized to an empty
|
|
421
|
+
// ftsQuery but the rewrite still returned rows (F2). reRankWithContext + the
|
|
422
|
+
// re-sort are FTS-rank operations; deep rows are already RRF-ranked, so on the
|
|
423
|
+
// empty-ftsQuery deep path we tag-but-don't-reorder (keep RRF order).
|
|
424
|
+
if ((ftsQuery || args.deep) && results.some(r => r.source === 'obs')) {
|
|
387
425
|
const obsResults = results.filter(r => r.source === 'obs');
|
|
388
|
-
reRankWithContext(db, obsResults, currentProject);
|
|
426
|
+
if (ftsQuery) reRankWithContext(db, obsResults, currentProject);
|
|
389
427
|
markSuperseded(obsResults);
|
|
390
|
-
results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
|
|
428
|
+
if (ftsQuery) results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
|
|
391
429
|
}
|
|
392
430
|
|
|
393
431
|
// Tier post-filter: batch-lookup full rows and classify (shared with CLI).
|
|
@@ -407,20 +445,34 @@ server.registerTool(
|
|
|
407
445
|
// results.length is NOT the population — count the real MATCH set instead. Clamp
|
|
408
446
|
// to >= results.length so vector/concept-augmented obs rows are never undercounted.
|
|
409
447
|
// (paired-path with mem-cli.mjs via shared countSearchTotal — #8217)
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
448
|
+
// For --deep the population is the fused variant set already in `results`
|
|
449
|
+
// (deep is obs-only, returned by deepSearch capped at perSourceLimit).
|
|
450
|
+
// countSearchTotal would count the ORIGINAL query's FTS matches instead —
|
|
451
|
+
// wrong, and ~0 on the vocabulary-mismatch queries deep exists for (F1).
|
|
452
|
+
const totalBeforePagination = args.deep
|
|
453
|
+
? results.length
|
|
454
|
+
: Math.max(countSearchTotal(db, {
|
|
455
|
+
effectiveSource: effectiveType || null,
|
|
456
|
+
ftsQuery,
|
|
457
|
+
obsFtsQuery: effectiveObsFtsQuery(ftsQuery, ctx.orFallbackFired === true),
|
|
458
|
+
args: { project: args.project || null, obs_type: args.obs_type || null, importance: args.importance || null, branch: args.branch || null },
|
|
459
|
+
project: args.project || null,
|
|
460
|
+
epochFrom, epochTo,
|
|
461
|
+
includeNoise: args.include_noise === true,
|
|
462
|
+
}), results.length);
|
|
420
463
|
// Always apply pagination — single-source results can exceed SQL LIMIT due to expansion (concept co-occurrence, PRF, vector search)
|
|
421
464
|
const paginatedResults = (offset > 0 || results.length > limit) ? results.slice(offset, offset + limit) : results;
|
|
422
465
|
|
|
423
|
-
|
|
466
|
+
const output = formatSearchOutput(paginatedResults, args, ftsQuery, totalBeforePagination, ctx.orFallbackFired === true);
|
|
467
|
+
// Surface the rewrite to the calling agent (CLI prints this to stderr + JSON;
|
|
468
|
+
// MCP had no signal at all — F13). Tells the agent whether deep actually
|
|
469
|
+
// reformulated the query or collapsed to the single-query baseline.
|
|
470
|
+
if (args.deep && deepVariants && output.content?.[0]?.type === 'text') {
|
|
471
|
+
output.content[0].text += deepVariants.length > 1
|
|
472
|
+
? `\n\n[deep search: rewrote into ${deepVariants.length} variants — ${deepVariants.slice(1).map(v => JSON.stringify(v)).join(', ')}]`
|
|
473
|
+
: '\n\n[deep search: rewrite produced no usable variants; searched the original query only (== baseline)]';
|
|
474
|
+
}
|
|
475
|
+
return output;
|
|
424
476
|
})
|
|
425
477
|
);
|
|
426
478
|
|
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', 'server.mjs', 'server-internals.mjs', 'search-engine.mjs', 'tool-schemas.mjs',
|
|
9
|
+
'cli.mjs', 'cli-path.mjs', 'server.mjs', 'server-internals.mjs', 'search-engine.mjs', 'deep-search.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',
|