claude-mem-lite 3.2.0 → 3.3.0

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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.2.0",
13
+ "version": "3.3.0",
14
14
  "source": "./",
15
15
  "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)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
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
  "author": {
6
6
  "name": "sdsrss"
package/deep-search.mjs CHANGED
@@ -68,15 +68,24 @@ export function hasEscalatableCorpus(db, project, min = AUTO_DEEP_MIN_CORPUS) {
68
68
 
69
69
  /**
70
70
  * Is a usable LLM available for AUTO escalation? True when a stub/real llm is
71
- * injected (tests), or a FAST provider key is set. The claude-CLI fallback is
72
- * deliberately excluded spawning a subprocess per search is too slow for the
73
- * default (automatic) path; explicit deep=true may still use it.
71
+ * injected (tests), a FAST provider key is set, OR the claude-CLI fallback is
72
+ * enabled (D#40: default-on for CLI-auth users; kill switch
73
+ * CLAUDE_MEM_AUTO_DEEP_CLI=0). The CLI path is made safe for the long-lived
74
+ * server hot path by the async/fail-fast/throttled auto provider (deepSearch
75
+ * auto), not by being excluded as it was before D#40.
74
76
  * @param {object} [env=process.env]
75
77
  * @param {Function|undefined} [injectedLlm]
76
78
  * @returns {boolean}
77
79
  */
78
80
  export function autoDeepLlmReady(env = process.env, injectedLlm) {
79
- return !!injectedLlm || !!(env.ANTHROPIC_API_KEY || env.OPENROUTER_API_KEY);
81
+ if (injectedLlm) return true;
82
+ if (env.ANTHROPIC_API_KEY || env.OPENROUTER_API_KEY) return true;
83
+ // No provider key → detectMode() would be 'cli'. CLI-auth users get auto
84
+ // escalation by default; the burst/latency cost is bounded by the auto
85
+ // provider (fail-fast + throttle) and a failed rewrite degrades to baseline.
86
+ // Kill switch honors the common disable spellings, not just the exact '0'.
87
+ const off = String(env.CLAUDE_MEM_AUTO_DEEP_CLI ?? '').trim().toLowerCase();
88
+ return !(off === '0' || off === 'false' || off === 'no' || off === 'off');
80
89
  }
81
90
 
82
91
  /**
@@ -189,12 +198,75 @@ export function assembleVariants(query, parsed, { max = MAX_VARIANTS } = {}) {
189
198
  return out;
190
199
  }
191
200
 
192
- // Default provider: pulled in lazily so importing deep-search.mjs (e.g. in tests
193
- // with an injected llm) never loads the LLM client. callModelJSON returns parsed
194
- // JSON or null, and never throws.
201
+ // ─── Auto-escalation safety machinery (D#40) ─────────────────────────────────
202
+ // The AUTO path can fire on every weak search across the long-lived MCP server,
203
+ // so it must be fail-fast (short timeout, no retry), throttled (bound bursts),
204
+ // and cached (skip repeat rewrites). The EXPLICIT deep=true path stays patient.
205
+
206
+ export const AUTO_DEEP_TIMEOUT_MS = 5000; // fail-fast budget for the auto path; no retry
207
+ export const AUTO_DEEP_THROTTLE_MS = 3000; // min gap between auto LLM rewrites, per process (bounds spawn rate)
208
+ const REWRITE_CACHE_MAX = 256; // LRU cap for the query→variants cache
209
+
210
+ let _lastAutoLlmAt = 0;
211
+ const _rewriteCache = new Map(); // normalized query → variants (string[]); successes only
212
+
213
+ /** Reset auto-path throttle + cache. Test-only; production state is per-process. */
214
+ export function _resetAutoDeepState() { _lastAutoLlmAt = 0; _rewriteCache.clear(); }
215
+
216
+ function cacheGet(key) {
217
+ if (!_rewriteCache.has(key)) return null;
218
+ const v = _rewriteCache.get(key);
219
+ _rewriteCache.delete(key); _rewriteCache.set(key, v); // LRU bump
220
+ return v.slice();
221
+ }
222
+ function cacheSet(key, variants) {
223
+ if (_rewriteCache.has(key)) _rewriteCache.delete(key);
224
+ _rewriteCache.set(key, variants.slice());
225
+ if (_rewriteCache.size > REWRITE_CACHE_MAX) {
226
+ _rewriteCache.delete(_rewriteCache.keys().next().value); // evict oldest
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Wrap an llm so it fires at most once per `intervalMs` per process. A throttled
232
+ * call resolves null → rewriteQuery degrades to baseline (never worse). Exported
233
+ * for tests. Throttle state is module-global (shared across deepSearch calls).
234
+ *
235
+ * The clock advances on every ACTUAL call — success OR failure — deliberately:
236
+ * the throttle bounds the subprocess SPAWN RATE, and a failed spawn still costs a
237
+ * subprocess + its timeout, so a broken provider that always fails must be rate-
238
+ * limited too (gating only on success would let a persistent failure spawn on
239
+ * every weak search). The interval is kept short so one failure suppresses
240
+ * escalation only briefly, not for a long window.
241
+ */
242
+ export function makeThrottled(llm, { intervalMs = AUTO_DEEP_THROTTLE_MS } = {}) {
243
+ return async (prompt) => {
244
+ const now = Date.now();
245
+ if (now - _lastAutoLlmAt < intervalMs) return null;
246
+ _lastAutoLlmAt = now;
247
+ return llm(prompt);
248
+ };
249
+ }
250
+
251
+ // Run one rewrite LLM call via the fully-async dispatcher (callModelJSONAsync):
252
+ // every CLI invocation — cli-mode primary AND the post-provider-failure fallback
253
+ // — is non-blocking, so an MCP request handler never blocks the event loop even
254
+ // under a keyed-provider outage (D#40). Lazy import so tests with an injected llm
255
+ // never load the LLM client.
256
+ async function callRewriteLLM(prompt, { timeout }) {
257
+ const { callModelJSONAsync } = await import('./haiku-client.mjs');
258
+ return callModelJSONAsync(prompt, 'haiku', { timeout, maxTokens: 400 });
259
+ }
260
+
261
+ // Default (explicit deep=true) provider: patient timeout, no throttle/cache.
195
262
  async function defaultLLM(prompt) {
196
- const { callModelJSON } = await import('./haiku-client.mjs');
197
- return callModelJSON(prompt, 'haiku', { timeout: 12000, maxTokens: 400 });
263
+ return callRewriteLLM(prompt, { timeout: 12000 });
264
+ }
265
+
266
+ // Auto-path provider: fail-fast timeout + throttle. Built fresh per deepSearch
267
+ // call; the throttle clock it reads is module-global (per-process).
268
+ function makeAutoLlm() {
269
+ return makeThrottled((prompt) => callRewriteLLM(prompt, { timeout: AUTO_DEEP_TIMEOUT_MS }));
198
270
  }
199
271
 
200
272
  /**
@@ -205,11 +277,17 @@ async function defaultLLM(prompt) {
205
277
  * @param {object} [opts]
206
278
  * @param {(prompt: object) => Promise<object|null>} [opts.llm]
207
279
  * @param {number} [opts.retries=1]
280
+ * @param {boolean} [opts.cache=false] memoize successful rewrites (auto path)
208
281
  * @returns {Promise<string[]>}
209
282
  */
210
- export async function rewriteQuery(query, { llm = defaultLLM, retries = 1 } = {}) {
283
+ export async function rewriteQuery(query, { llm = defaultLLM, retries = 1, cache = false } = {}) {
211
284
  const original = String(query ?? '').trim();
212
285
  if (!original) return [];
286
+ const key = original.toLowerCase();
287
+ if (cache) {
288
+ const hit = cacheGet(key);
289
+ if (hit) return hit; // process-lifetime memo of a prior successful rewrite
290
+ }
213
291
  const prompt = buildRewritePrompt(original);
214
292
  for (let attempt = 0; attempt <= retries; attempt++) {
215
293
  let parsed;
@@ -219,7 +297,10 @@ export async function rewriteQuery(query, { llm = defaultLLM, retries = 1 } = {}
219
297
  parsed = null;
220
298
  }
221
299
  const variants = assembleVariants(original, parsed);
222
- if (variants.length > 1) return variants; // got at least one real rewrite
300
+ if (variants.length > 1) { // got at least one real rewrite
301
+ if (cache) cacheSet(key, variants); // cache successes only — failures retry next time
302
+ return variants;
303
+ }
223
304
  }
224
305
  return [original]; // robust floor — single-query == baseline
225
306
  }
@@ -304,13 +385,24 @@ function defaultSearchFn(db, query, params) {
304
385
  * @param {(prompt:object)=>Promise<object|null>} [deps.llm]
305
386
  * @param {(db:Database, query:string, params:object)=>Array} [deps.searchFn]
306
387
  * @param {number} [deps.rrfK=RRF_K]
388
+ * @param {boolean} [deps.auto=false] use the fail-fast/throttled/cached auto provider
307
389
  * @returns {Promise<{results: Array, variants: string[]}>}
308
390
  */
309
- export async function deepSearch(db, params, { llm = defaultLLM, searchFn = defaultSearchFn, rrfK = RRF_K } = {}) {
391
+ export async function deepSearch(db, params, { llm, searchFn = defaultSearchFn, rrfK = RRF_K, auto = false } = {}) {
310
392
  const query = String(params?.query ?? '').trim();
311
393
  if (!query) return { results: [], variants: [] };
312
394
 
313
- const variants = await rewriteQuery(query, { llm });
395
+ // No injected llm: EXPLICIT deep=true uses the patient defaultLLM; the AUTO
396
+ // path uses a fail-fast + throttled provider with no retry and a process-
397
+ // lifetime rewrite cache (D#40). An injected llm (tests) is used verbatim.
398
+ let rewriteLlm = llm;
399
+ let retries = 1;
400
+ let cache = false;
401
+ if (!rewriteLlm) {
402
+ if (auto) { rewriteLlm = makeAutoLlm(); retries = 0; cache = true; }
403
+ else rewriteLlm = defaultLLM;
404
+ }
405
+ const variants = await rewriteQuery(query, { llm: rewriteLlm, retries, cache });
314
406
  const lists = variants.map((v, i) => {
315
407
  // variant[0] is the ORIGINAL query: let an engine error propagate exactly as
316
408
  // it does on the single-query baseline path, so "never worse than baseline"
package/haiku-client.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  // Model configurable via CLAUDE_MEM_MODEL (haiku|sonnet); OpenRouter slug
6
6
  // overridable via OPENROUTER_MODEL
7
7
 
8
- import { execFileSync } from 'child_process';
8
+ import { execFileSync, spawn } from 'child_process';
9
9
  import { readFileSync } from 'fs';
10
10
  import { join } from 'path';
11
11
  import { randomUUID } from 'crypto';
@@ -247,6 +247,44 @@ export async function callModelJSON(prompt, model = 'haiku', opts) {
247
247
  return parseJsonFromLLM(result.text);
248
248
  }
249
249
 
250
+ /**
251
+ * JSON-returning, FULLY-ASYNC model call for the long-lived server hot path
252
+ * (deep-search auto-escalation). Like callModelJSON, but every CLI invocation —
253
+ * cli-mode primary AND the post-provider-failure fallback — uses the
254
+ * non-blocking callModelCLIAsync, so a keyed-provider outage can never drop onto
255
+ * the blocking execFileSync path and freeze the MCP event loop (D#40). Never
256
+ * throws; returns parsed JSON or null.
257
+ * @param {string|{system?:string,user:string}} prompt
258
+ * @param {'haiku'|'sonnet'} model
259
+ * @param {{timeout?:number,maxTokens?:number,temperature?:number}} [opts]
260
+ * @returns {Promise<object|null>}
261
+ */
262
+ export async function callModelJSONAsync(prompt, model = 'haiku', { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE } = {}) {
263
+ if (!prompt) return null;
264
+ const resolvedModel = MODEL_MAP[model] ? model : 'haiku';
265
+ const mode = detectMode();
266
+
267
+ if (mode === 'cli') {
268
+ const res = await callModelCLIAsync(prompt, resolvedModel, { timeout });
269
+ return res?.text ? parseJsonFromLLM(res.text) : null;
270
+ }
271
+
272
+ // Keyed provider (api/openrouter): try it, then degrade to the ASYNC CLI on any
273
+ // failure — NOT the blocking execFileSync callModelCLI that callModelJSON uses.
274
+ let primary = null;
275
+ try {
276
+ primary = mode === 'api'
277
+ ? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature })
278
+ : await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature });
279
+ } catch (e) {
280
+ debugCatch(e, `callModelJSONAsync:${mode}:${resolvedModel}`);
281
+ }
282
+ if (primary?.text) return parseJsonFromLLM(primary.text);
283
+
284
+ const res = await callModelCLIAsync(prompt, resolvedModel, { timeout });
285
+ return res?.text ? parseJsonFromLLM(res.text) : null;
286
+ }
287
+
250
288
  async function callModelAPI(prompt, model, { timeout, maxTokens, temperature = DEFAULT_LLM_TEMPERATURE }) {
251
289
  const apiKey = process.env.ANTHROPIC_API_KEY;
252
290
  if (!apiKey) return null;
@@ -319,6 +357,72 @@ function callModelCLI(prompt, model, { timeout }) {
319
357
  }
320
358
  }
321
359
 
360
+ /**
361
+ * Async, non-blocking sibling of callModelCLI for the long-lived MCP server hot
362
+ * path (deep-search auto-escalation, D#40). execFileSync blocks the event loop for
363
+ * the whole subprocess lifetime — acceptable in short-lived hook processes
364
+ * (callModelCLI), not inside an MCP request handler. Uses spawn + stdin so the
365
+ * untrusted query stays out of argv (ps-visible) and the boundary-marker model is
366
+ * preserved. Never rejects: resolves {text} on non-empty stdout, null on
367
+ * error/empty. On timeout it SIGKILLs the child with NO retry (fail-fast) and
368
+ * salvages a complete JSON payload from partial stdout (mirrors callModelCLI's
369
+ * catch-salvage; tolerant of Haiku's ```json fencing per #8605, which the upstream
370
+ * parseJsonFromLLM strips).
371
+ * @param {string|{system?:string,user:string}} prompt
372
+ * @param {'haiku'|'sonnet'} model
373
+ * @param {{timeout:number}} opts SIGKILL after `timeout` ms; no retry.
374
+ * @returns {Promise<{text:string}|null>}
375
+ */
376
+ export function callModelCLIAsync(prompt, model, { timeout }) {
377
+ return new Promise((resolve) => {
378
+ const modelName = MODEL_MAP[model] ? model : 'haiku';
379
+ let child;
380
+ try {
381
+ child = spawn(getClaudePath(), ['-p', '--model', modelName], {
382
+ env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1' },
383
+ cwd: '/tmp',
384
+ stdio: ['pipe', 'pipe', 'pipe'],
385
+ });
386
+ } catch (e) {
387
+ debugCatch(e, `${model}-cli-async`);
388
+ resolve(null);
389
+ return;
390
+ }
391
+ let stdout = '';
392
+ let settled = false;
393
+ const done = (val) => {
394
+ if (settled) return;
395
+ settled = true;
396
+ clearTimeout(timer);
397
+ resolve(val);
398
+ };
399
+ const timer = setTimeout(() => {
400
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
401
+ const t = stdout.trim();
402
+ if (t.startsWith('{') && t.endsWith('}')) {
403
+ try { JSON.parse(t); done({ text: t }); return; } catch { /* not complete JSON */ }
404
+ }
405
+ done(null);
406
+ }, timeout);
407
+ child.stdout?.setEncoding('utf8'); // decode multi-byte UTF-8 (CJK) across chunk boundaries
408
+ child.stdout?.on('data', (d) => { stdout += d; });
409
+ child.stderr?.on('data', () => {}); // drain stderr so a chatty child can't block on a full pipe
410
+ child.on('error', (e) => { debugCatch(e, `${model}-cli-async`); done(null); });
411
+ child.on('close', () => {
412
+ const t = stdout.trim();
413
+ done(t ? { text: t } : null);
414
+ });
415
+ // EPIPE guard: the child may exit before we finish writing stdin.
416
+ child.stdin?.on('error', () => {});
417
+ try {
418
+ child.stdin?.write(flattenForCLI(prompt));
419
+ child.stdin?.end();
420
+ } catch (e) {
421
+ debugCatch(e, `${model}-cli-async:stdin`);
422
+ }
423
+ });
424
+ }
425
+
322
426
  // ─── API Mode ────────────────────────────────────────────────────────────────
323
427
 
324
428
  async function callHaikuAPI(prompt, { timeout, maxTokens, temperature = DEFAULT_LLM_TEMPERATURE }) {
package/mem-cli.mjs CHANGED
@@ -182,7 +182,7 @@ async function cmdSearch(db, args, { llm } = {}) {
182
182
  orFallbackFired: false,
183
183
  };
184
184
 
185
- const runDeep = async () => {
185
+ const runDeep = async ({ auto = false } = {}) => {
186
186
  const ds = await deepSearch(db, {
187
187
  query,
188
188
  project: project || null,
@@ -194,7 +194,7 @@ async function cmdSearch(db, args, { llm } = {}) {
194
194
  epochTo: dateTo,
195
195
  limit: perSourceLimit,
196
196
  currentProject: project ? null : inferProject(),
197
- }, llm ? { llm } : undefined);
197
+ }, llm ? { llm } : { auto });
198
198
  deepVariants = ds.variants;
199
199
  if (deepVariants.length > 1) {
200
200
  process.stderr.write(`[mem] Deep search: rewrote into ${deepVariants.length} query variants, RRF-fused\n`);
@@ -212,7 +212,7 @@ async function cmdSearch(db, args, { llm } = {}) {
212
212
  if (obsCtx.orFallbackFired) orFallbackFired = true;
213
213
  if (deepMode === 'auto' && autoDeepLlmReady(process.env, llm) && shouldEscalateToDeep(obsResults, obsCtx) && hasEscalatableCorpus(db, project || null)) {
214
214
  process.stderr.write(`[mem] auto-escalated to deep search (weak results: ${obsResults.length} hits)\n`);
215
- obsResults = await runDeep();
215
+ obsResults = await runDeep({ auto: true });
216
216
  isDeep = true;
217
217
  }
218
218
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
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",
package/server.mjs CHANGED
@@ -370,7 +370,7 @@ async function runSearchPipeline(db, args, { llm } = {}) {
370
370
  let escalatedObsCount = 0;
371
371
 
372
372
  // Helper: run deepSearch and load results into the shared `results` array.
373
- const runDeepInto = async () => {
373
+ const runDeepInto = async ({ auto = false } = {}) => {
374
374
  const { results: deepRows, variants } = await deepSearch(db, {
375
375
  query: args.query,
376
376
  project: args.project || null,
@@ -381,7 +381,7 @@ async function runSearchPipeline(db, args, { llm } = {}) {
381
381
  epochFrom, epochTo,
382
382
  limit: perSourceLimit,
383
383
  currentProject,
384
- }, llm ? { llm } : undefined);
384
+ }, llm ? { llm } : { auto });
385
385
  // Safe to reset: sessions/prompts are pushed AFTER the obs block, so nothing is lost here.
386
386
  results.length = 0;
387
387
  results.push(...deepRows);
@@ -405,7 +405,7 @@ async function runSearchPipeline(db, args, { llm } = {}) {
405
405
  // filter makes the invariant explicit and robust to future reordering.
406
406
  const obsCountBeforeEscalation = results.length;
407
407
  if (deepMode === 'auto' && autoDeepLlmReady(process.env, llm) && shouldEscalateToDeep(results.filter(r => r.source === 'obs'), ctx) && hasEscalatableCorpus(db, args.project || null)) {
408
- await runDeepInto();
408
+ await runDeepInto({ auto: true });
409
409
  isDeep = true;
410
410
  escalated = true;
411
411
  escalatedObsCount = obsCountBeforeEscalation;