claude-mem-lite 3.96.0 → 3.97.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.
Files changed (143) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/adopt-cli.mjs +125 -51
  4. package/bash-utils.mjs +105 -29
  5. package/claudemd.mjs +63 -19
  6. package/cli/activity.mjs +36 -18
  7. package/cli/common.mjs +101 -18
  8. package/cli/doctor.mjs +29 -9
  9. package/cli.mjs +69 -10
  10. package/deep-search.mjs +63 -20
  11. package/format-utils.mjs +15 -7
  12. package/haiku-client.mjs +189 -111
  13. package/hash-utils.mjs +15 -6
  14. package/hook-context.mjs +135 -46
  15. package/hook-episode.mjs +76 -23
  16. package/hook-handoff.mjs +216 -84
  17. package/hook-llm.mjs +476 -216
  18. package/hook-memory.mjs +106 -43
  19. package/hook-optimize.mjs +272 -109
  20. package/hook-precompact.mjs +1 -1
  21. package/hook-semaphore.mjs +46 -12
  22. package/hook-shared.mjs +101 -131
  23. package/hook-update.mjs +286 -88
  24. package/hook.mjs +1207 -617
  25. package/install-metadata.mjs +246 -125
  26. package/install.mjs +1327 -917
  27. package/lib/activity.mjs +52 -29
  28. package/lib/atomic-write.mjs +23 -4
  29. package/lib/binding-probe.mjs +40 -38
  30. package/lib/browse-core.mjs +22 -8
  31. package/lib/citation-tracker.mjs +189 -79
  32. package/lib/cite-back-hint.mjs +37 -19
  33. package/lib/cite-recall-path.mjs +3 -1
  34. package/lib/cli-flags.mjs +4 -2
  35. package/lib/cli-project.mjs +8 -5
  36. package/lib/compress-core.mjs +34 -9
  37. package/lib/cooldown-path.mjs +3 -1
  38. package/lib/db-backup.mjs +33 -11
  39. package/lib/deferred-work.mjs +73 -32
  40. package/lib/delete-core.mjs +25 -9
  41. package/lib/doctor-benchmark.mjs +5 -1
  42. package/lib/doctor-drift.mjs +25 -8
  43. package/lib/edge-attribution.mjs +23 -9
  44. package/lib/env-number.mjs +7 -8
  45. package/lib/err-sampler.mjs +22 -11
  46. package/lib/error-recall-core.mjs +15 -17
  47. package/lib/events-injection.mjs +25 -8
  48. package/lib/export-columns.mjs +49 -10
  49. package/lib/fast-summary.mjs +22 -9
  50. package/lib/file-edge-match.mjs +4 -2
  51. package/lib/file-intel.mjs +38 -13
  52. package/lib/frontmatter.mjs +22 -5
  53. package/lib/get-core.mjs +78 -11
  54. package/lib/handoff-constants.mjs +18 -0
  55. package/lib/hook-stdin.mjs +10 -3
  56. package/lib/hook-stdout.mjs +21 -7
  57. package/lib/hook-telemetry.mjs +44 -17
  58. package/lib/id-routing.mjs +11 -6
  59. package/lib/import-jsonl.mjs +92 -41
  60. package/lib/inject-search-core.mjs +5 -2
  61. package/lib/injected-ids.mjs +19 -12
  62. package/lib/install-shape.mjs +47 -23
  63. package/lib/keyctx-marker.mjs +3 -1
  64. package/lib/lesson-bridge.mjs +8 -5
  65. package/lib/llm-call.mjs +76 -0
  66. package/lib/llm-provider-probe.mjs +24 -12
  67. package/lib/low-signal-patterns.mjs +25 -22
  68. package/lib/maintain-core.mjs +221 -92
  69. package/lib/mem-override.mjs +4 -2
  70. package/lib/metrics.mjs +30 -25
  71. package/lib/native-binding-hint.mjs +29 -8
  72. package/lib/observation-write.mjs +74 -19
  73. package/lib/patha-exclude-meter.mjs +10 -6
  74. package/lib/persist-reminder.mjs +22 -10
  75. package/lib/plan-reader.mjs +2 -2
  76. package/lib/private-strip.mjs +3 -3
  77. package/lib/proc-lock.mjs +15 -3
  78. package/lib/proxy-fetch.mjs +49 -13
  79. package/lib/quiet-scope.mjs +42 -0
  80. package/lib/recall-core.mjs +19 -7
  81. package/lib/recent-core.mjs +18 -5
  82. package/lib/registry-core.mjs +12 -6
  83. package/lib/release-digest.mjs +4 -1
  84. package/lib/relevance-floor.mjs +5 -2
  85. package/lib/reread-guard.mjs +16 -8
  86. package/lib/resolve-data-dir.mjs +1 -1
  87. package/lib/rrf.mjs +4 -1
  88. package/lib/save-enrich.mjs +36 -18
  89. package/lib/save-observation.mjs +78 -35
  90. package/lib/scrub-record.mjs +20 -6
  91. package/lib/search-core.mjs +456 -123
  92. package/lib/shard-gc.mjs +50 -0
  93. package/lib/startup-dashboard.mjs +20 -9
  94. package/lib/stats-core.mjs +101 -35
  95. package/lib/stats-quality.mjs +56 -20
  96. package/lib/summary-extractor.mjs +2 -2
  97. package/lib/task-imperative.mjs +12 -4
  98. package/lib/timeline-core.mjs +88 -28
  99. package/lib/tmp-fixture-sweep.mjs +32 -9
  100. package/lib/transcript-scan.mjs +20 -4
  101. package/lib/upgrade-banner.mjs +11 -7
  102. package/mem-cli.mjs +1314 -506
  103. package/memdir.mjs +53 -21
  104. package/nlp.mjs +68 -38
  105. package/npm-shrinkwrap.json +2 -2
  106. package/package.json +9 -2
  107. package/plugin-cache-guard.mjs +31 -15
  108. package/project-utils.mjs +44 -20
  109. package/registry-enricher.mjs +26 -6
  110. package/registry-github.mjs +4 -2
  111. package/registry-importer.mjs +151 -40
  112. package/registry-recommend.mjs +189 -96
  113. package/registry-retriever.mjs +229 -70
  114. package/registry-scanner.mjs +11 -6
  115. package/registry.mjs +163 -66
  116. package/rerank.mjs +18 -4
  117. package/resource-discovery.mjs +14 -4
  118. package/schema.mjs +258 -99
  119. package/scoring-sql.mjs +21 -10
  120. package/scripts/binding-probe-cli.mjs +17 -12
  121. package/scripts/hook-launcher.mjs +148 -31
  122. package/scripts/launch-preflight.mjs +5 -7
  123. package/scripts/launch.mjs +21 -9
  124. package/scripts/post-tool-recall.js +21 -5
  125. package/scripts/pre-agent-inject.js +51 -13
  126. package/scripts/pre-skill-bridge.js +22 -7
  127. package/scripts/pre-tool-recall.js +157 -77
  128. package/scripts/prompt-search-utils.mjs +70 -25
  129. package/scripts/setup.sh +16 -1
  130. package/scripts/user-prompt-search.js +238 -97
  131. package/search-engine.mjs +329 -96
  132. package/search-scoring.mjs +75 -33
  133. package/secret-scrub.mjs +48 -12
  134. package/server/fts-check.mjs +12 -9
  135. package/server.mjs +674 -243
  136. package/skip-tools.mjs +19 -8
  137. package/source-files.mjs +54 -21
  138. package/stop-words.mjs +152 -19
  139. package/synonyms.mjs +579 -164
  140. package/tfidf.mjs +130 -40
  141. package/tier.mjs +1 -1
  142. package/tool-schemas.mjs +381 -131
  143. package/utils.mjs +184 -36
package/haiku-client.mjs CHANGED
@@ -110,7 +110,9 @@ export function detectMode() {
110
110
  }
111
111
 
112
112
  /** Reset cached mode (for testing). */
113
- export function _resetMode() { _mode = null; }
113
+ export function _resetMode() {
114
+ _mode = null;
115
+ }
114
116
 
115
117
  // ─── CLI Path ────────────────────────────────────────────────────────────────
116
118
 
@@ -174,15 +176,22 @@ export function flattenForCLI(input) {
174
176
  * @param {number} [opts.maxTokens=500] Max tokens in response
175
177
  * @returns {Promise<{text: string}|null>} Response or null on failure
176
178
  */
177
- export async function callHaiku(prompt, { timeout = 10000, maxTokens = 500, temperature = DEFAULT_LLM_TEMPERATURE } = {}) {
179
+ export async function callHaiku(
180
+ prompt,
181
+ { timeout = 10000, maxTokens = 500, temperature = DEFAULT_LLM_TEMPERATURE } = {},
182
+ ) {
178
183
  if (!prompt) return null;
179
184
 
180
185
  const mode = detectMode();
181
186
 
182
187
  // CLI is terminal — no provider to fall back to.
183
188
  if (mode === 'cli') {
184
- try { return callHaikuCLI(prompt, { timeout }); }
185
- catch (e) { debugCatch(e, 'callHaiku'); return null; }
189
+ try {
190
+ return callHaikuCLI(prompt, { timeout });
191
+ } catch (e) {
192
+ debugCatch(e, 'callHaiku');
193
+ return null;
194
+ }
186
195
  }
187
196
 
188
197
  // Keyed provider (api/openrouter): attempt it, then degrade to the CLI on any
@@ -196,17 +205,22 @@ export async function callHaiku(prompt, { timeout = 10000, maxTokens = 500, temp
196
205
  // log label that lied under CLAUDE_MEM_MODEL=sonnet. Two copies of an HTTP client
197
206
  // means every proxy fix has to land twice, on the path where missing the proxy is
198
207
  // the difference between 1.4s and 13.5s.
199
- primary = mode === 'api'
200
- ? await callModelAPI(prompt, resolveModel().cli, { timeout, maxTokens, temperature })
201
- : await callOpenRouterAPI(prompt, resolveModel().cli, { timeout, maxTokens, temperature });
208
+ primary =
209
+ mode === 'api'
210
+ ? await callModelAPI(prompt, resolveModel().cli, { timeout, maxTokens, temperature })
211
+ : await callOpenRouterAPI(prompt, resolveModel().cli, { timeout, maxTokens, temperature });
202
212
  } catch (e) {
203
213
  debugCatch(e, `callHaiku:${mode}`);
204
214
  }
205
215
  if (primary) return primary;
206
216
 
207
217
  debugLog('WARN', 'haiku-client', `${mode} call failed, falling back to claude CLI`);
208
- try { return callHaikuCLI(prompt, { timeout }); }
209
- catch (e) { debugCatch(e, 'callHaiku:cli-fallback'); return null; }
218
+ try {
219
+ return callHaikuCLI(prompt, { timeout });
220
+ } catch (e) {
221
+ debugCatch(e, 'callHaiku:cli-fallback');
222
+ return null;
223
+ }
210
224
  }
211
225
 
212
226
  /**
@@ -240,7 +254,10 @@ export async function callHaikuJSON(prompt, opts) {
240
254
  * @param {{timeout?:number,maxTokens?:number,temperature?:number}} [opts]
241
255
  * @returns {Promise<object|null>} Parsed JSON or null
242
256
  */
243
- export async function callHaikuJSONAsync(prompt, { timeout = 10000, maxTokens = 500, temperature = DEFAULT_LLM_TEMPERATURE } = {}) {
257
+ export async function callHaikuJSONAsync(
258
+ prompt,
259
+ { timeout = 10000, maxTokens = 500, temperature = DEFAULT_LLM_TEMPERATURE } = {},
260
+ ) {
244
261
  return callModelJSONAsync(prompt, resolveModel().cli, { timeout, maxTokens, temperature });
245
262
  }
246
263
 
@@ -258,32 +275,45 @@ export async function callHaikuJSONAsync(prompt, { timeout = 10000, maxTokens =
258
275
  * @param {number} [opts.maxTokens=1000] Max tokens in response
259
276
  * @returns {Promise<{text: string}|null>} Response or null on failure
260
277
  */
261
- export async function callLLMWithModel(prompt, model = 'haiku', { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE } = {}) {
278
+ export async function callLLMWithModel(
279
+ prompt,
280
+ model = 'haiku',
281
+ { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE } = {},
282
+ ) {
262
283
  if (!prompt) return null;
263
284
  const resolvedModel = MODEL_MAP[model] ? model : 'haiku';
264
285
  const mode = detectMode();
265
286
 
266
287
  // CLI is terminal — no provider to fall back to.
267
288
  if (mode === 'cli') {
268
- try { return callModelCLI(prompt, resolvedModel, { timeout }); }
269
- catch (e) { debugCatch(e, `callLLMWithModel:${resolvedModel}`); return null; }
289
+ try {
290
+ return callModelCLI(prompt, resolvedModel, { timeout });
291
+ } catch (e) {
292
+ debugCatch(e, `callLLMWithModel:${resolvedModel}`);
293
+ return null;
294
+ }
270
295
  }
271
296
 
272
297
  // Keyed provider (api/openrouter): attempt it, then degrade to the CLI on any
273
298
  // failure so a region-blocked / out-of-credit key still produces output.
274
299
  let primary = null;
275
300
  try {
276
- primary = mode === 'api'
277
- ? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature })
278
- : await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature });
301
+ primary =
302
+ mode === 'api'
303
+ ? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature })
304
+ : await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature });
279
305
  } catch (e) {
280
306
  debugCatch(e, `callLLMWithModel:${mode}:${resolvedModel}`);
281
307
  }
282
308
  if (primary) return primary;
283
309
 
284
310
  debugLog('WARN', 'haiku-client', `${mode} call failed, falling back to claude CLI (${resolvedModel})`);
285
- try { return callModelCLI(prompt, resolvedModel, { timeout }); }
286
- catch (e) { debugCatch(e, `callLLMWithModel:cli-fallback:${resolvedModel}`); return null; }
311
+ try {
312
+ return callModelCLI(prompt, resolvedModel, { timeout });
313
+ } catch (e) {
314
+ debugCatch(e, `callLLMWithModel:cli-fallback:${resolvedModel}`);
315
+ return null;
316
+ }
287
317
  }
288
318
 
289
319
  /**
@@ -302,7 +332,11 @@ export async function callLLMWithModel(prompt, model = 'haiku', { timeout = 1500
302
332
  * @param {{timeout?:number,maxTokens?:number,temperature?:number}} [opts]
303
333
  * @returns {Promise<{text: string}|null>} Response or null on failure
304
334
  */
305
- export async function callLLMWithModelAsync(prompt, model = 'haiku', { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE } = {}) {
335
+ export async function callLLMWithModelAsync(
336
+ prompt,
337
+ model = 'haiku',
338
+ { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE } = {},
339
+ ) {
306
340
  if (!prompt) return null;
307
341
  const resolvedModel = MODEL_MAP[model] ? model : 'haiku';
308
342
  const mode = detectMode();
@@ -312,15 +346,20 @@ export async function callLLMWithModelAsync(prompt, model = 'haiku', { timeout =
312
346
 
313
347
  let primary = null;
314
348
  try {
315
- primary = mode === 'api'
316
- ? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature })
317
- : await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature });
349
+ primary =
350
+ mode === 'api'
351
+ ? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature })
352
+ : await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature });
318
353
  } catch (e) {
319
354
  debugCatch(e, `callLLMWithModelAsync:${mode}:${resolvedModel}`);
320
355
  }
321
356
  if (primary) return primary;
322
357
 
323
- debugLog('WARN', 'haiku-client', `${mode} call failed, falling back to async claude CLI (${resolvedModel})`);
358
+ debugLog(
359
+ 'WARN',
360
+ 'haiku-client',
361
+ `${mode} call failed, falling back to async claude CLI (${resolvedModel})`,
362
+ );
324
363
  return callModelCLIAsync(prompt, resolvedModel, { timeout });
325
364
  }
326
365
 
@@ -349,7 +388,11 @@ export async function callModelJSON(prompt, model = 'haiku', opts) {
349
388
  * @param {{timeout?:number,maxTokens?:number,temperature?:number}} [opts]
350
389
  * @returns {Promise<object|null>}
351
390
  */
352
- export async function callModelJSONAsync(prompt, model = 'haiku', { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE } = {}) {
391
+ export async function callModelJSONAsync(
392
+ prompt,
393
+ model = 'haiku',
394
+ { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE } = {},
395
+ ) {
353
396
  if (!prompt) return null;
354
397
  const resolvedModel = MODEL_MAP[model] ? model : 'haiku';
355
398
  const mode = detectMode();
@@ -363,9 +406,10 @@ export async function callModelJSONAsync(prompt, model = 'haiku', { timeout = 15
363
406
  // failure — NOT the blocking execFileSync callModelCLI that callModelJSON uses.
364
407
  let primary = null;
365
408
  try {
366
- primary = mode === 'api'
367
- ? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature })
368
- : await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature });
409
+ primary =
410
+ mode === 'api'
411
+ ? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature })
412
+ : await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature });
369
413
  } catch (e) {
370
414
  debugCatch(e, `callModelJSONAsync:${mode}:${resolvedModel}`);
371
415
  }
@@ -413,13 +457,17 @@ async function callModelAPI(prompt, model, { timeout, maxTokens, temperature = D
413
457
  };
414
458
  const apiProxy = httpConnectProxyFor(apiUrl);
415
459
  const res = apiProxy
416
- ? await postViaConnectProxy(apiProxy, apiUrl, { headers: apiHeaders, body: JSON.stringify(body), timeout })
460
+ ? await postViaConnectProxy(apiProxy, apiUrl, {
461
+ headers: apiHeaders,
462
+ body: JSON.stringify(body),
463
+ timeout,
464
+ })
417
465
  : await fetch(apiUrl, {
418
- method: 'POST',
419
- headers: apiHeaders,
420
- body: JSON.stringify(body),
421
- signal: controller.signal,
422
- });
466
+ method: 'POST',
467
+ headers: apiHeaders,
468
+ body: JSON.stringify(body),
469
+ signal: controller.signal,
470
+ });
423
471
 
424
472
  if (!res.ok) {
425
473
  debugLog('WARN', `${model}-api`, `HTTP ${res.status}`);
@@ -460,12 +508,12 @@ const HEADLESS_FLAG = '--no-session-persistence';
460
508
  let _headlessFlagOk = true;
461
509
 
462
510
  /** @internal test hook — module-level compat state must not leak across cases. */
463
- export function _resetHeadlessFlag() { _headlessFlagOk = true; }
511
+ export function _resetHeadlessFlag() {
512
+ _headlessFlagOk = true;
513
+ }
464
514
 
465
515
  function claudeArgs(modelName) {
466
- return _headlessFlagOk
467
- ? ['-p', '--model', modelName, HEADLESS_FLAG]
468
- : ['-p', '--model', modelName];
516
+ return _headlessFlagOk ? ['-p', '--model', modelName, HEADLESS_FLAG] : ['-p', '--model', modelName];
469
517
  }
470
518
 
471
519
  // A retry is only ever worth it when the diagnostic NAMES the token it rejected —
@@ -480,7 +528,8 @@ function claudeArgs(modelName) {
480
528
  // Deliberately NOT keyed on exit code alone either: a non-zero exit is also the
481
529
  // normal shape of an overload/auth failure.
482
530
  const FLAG_TOKEN = /no-session-persistence/;
483
- const PARSE_REJECTION = /(unknown|unrecognized|unsupported|invalid|unexpected)[^\n]{0,40}(option|argument|flag|switch)/i;
531
+ const PARSE_REJECTION =
532
+ /(unknown|unrecognized|unsupported|invalid|unexpected)[^\n]{0,40}(option|argument|flag|switch)/i;
484
533
 
485
534
  // Below this many ms left, a retry can only spawn a process and immediately kill
486
535
  // it — worse than returning the original failure.
@@ -541,7 +590,11 @@ export function execClaudeCliSync(modelName, { input, timeout }) {
541
590
  if (remaining < RETRY_MIN_BUDGET_MS) throw e;
542
591
  const out = execFileSync(getClaudePath(), ['-p', '--model', modelName], { ...opts, timeout: remaining });
543
592
  _headlessFlagOk = false;
544
- debugLog('WARN', 'cli-compat', `claude CLI rejected ${HEADLESS_FLAG}; dropped for this process (the headless session tax returns — upgrade Claude Code to avoid it)`);
593
+ debugLog(
594
+ 'WARN',
595
+ 'cli-compat',
596
+ `claude CLI rejected ${HEADLESS_FLAG}; dropped for this process (the headless session tax returns — upgrade Claude Code to avoid it)`,
597
+ );
545
598
  return out;
546
599
  }
547
600
  }
@@ -590,75 +643,90 @@ export async function callModelCLIAsync(prompt, model, { timeout }) {
590
643
  // a number ONLY when the child exited on its own; a timeout/SIGKILL or a spawn
591
644
  // error reports null, which is what keeps either from being mistaken for an
592
645
  // argument-parse rejection and costing a second full-budget spawn.
593
- const attempt = (args, budget) => new Promise((resolve) => {
594
- let child;
595
- try {
596
- // Same headless-tax flags + flag-compat retry as callModelCLI (rationale there).
597
- child = spawn(getClaudePath(), args, {
598
- env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
599
- cwd: '/tmp',
600
- stdio: ['pipe', 'pipe', 'pipe'],
601
- });
602
- } catch (e) {
603
- debugCatch(e, `${model}-cli-async`);
604
- resolve({ result: null, stderr: '', stdout: '', code: null });
605
- return;
606
- }
607
- let stdout = '';
608
- let stderr = '';
609
- let settled = false;
610
- const done = (val) => {
611
- if (settled) return;
612
- settled = true;
613
- clearTimeout(timer);
614
- resolve(val);
615
- };
616
- const timer = setTimeout(() => {
617
- try { child.kill('SIGKILL'); } catch { /* already gone */ }
618
- const t = stdout.trim();
619
- // Salvage fenced-or-bare JSON from partial stdout (mirrors callModelCLI). A raw
620
- // brace check would discard a complete-but-```json-fenced payload (#8605);
621
- // parseJsonFromLLM strips fences before validating, and the caller re-parses
622
- // the returned text the same way.
623
- if (t && parseJsonFromLLM(t) !== null) { done({ result: { text: t }, stderr, stdout, code: null }); return; }
624
- done({ result: null, stderr, stdout, code: null });
625
- }, budget);
626
- child.stdout?.setEncoding('utf8'); // decode multi-byte UTF-8 (CJK) across chunk boundaries
627
- child.stdout?.on('data', (d) => { stdout += d; });
628
- // Keep draining stderr so a chatty child can't block on a full pipe, but keep
629
- // a bounded head of it — the flag-compat probe needs the parser's complaint.
630
- // Slice AFTER appending: checking the length first lets one arbitrarily large
631
- // chunk through whole, which is the shape a single big stderr write takes.
632
- child.stderr?.setEncoding?.('utf8');
633
- child.stderr?.on('data', (d) => { stderr = (stderr + d).slice(0, 4096); });
634
- child.on('error', (e) => { debugCatch(e, `${model}-cli-async`); done({ result: null, stderr: '', stdout: '', code: null }); });
635
- child.on('close', (code) => {
636
- const t = stdout.trim();
637
- // Parity with callModelCLI: execFileSync THROWS on a non-zero exit, so the
638
- // sync leg only ever returns such output when parseJsonFromLLM accepts it
639
- // (its catch-salvage). Without the same gate, a CLI that prints a
640
- // diagnostic to stdout and dies — auth failure, overload banner, wrapper
641
- // error — has that diagnostic returned as the model's ANSWER. rerank is the
642
- // first caller to consume the raw {text}: extractRanked's last resort
643
- // matches any bracketed number list in prose, so a `[1]` inside a stack
644
- // frame becomes a ranking and silently reorders search results. The
645
- // flag-compat probe below reads stderr/stdout/code directly, not `result`,
646
- // so nulling here does not cost it its retry.
647
- if (t && typeof code === 'number' && code !== 0 && parseJsonFromLLM(t) === null) {
648
- done({ result: null, stderr, stdout, code });
646
+ const attempt = (args, budget) =>
647
+ new Promise((resolve) => {
648
+ let child;
649
+ try {
650
+ // Same headless-tax flags + flag-compat retry as callModelCLI (rationale there).
651
+ child = spawn(getClaudePath(), args, {
652
+ env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
653
+ cwd: '/tmp',
654
+ stdio: ['pipe', 'pipe', 'pipe'],
655
+ });
656
+ } catch (e) {
657
+ debugCatch(e, `${model}-cli-async`);
658
+ resolve({ result: null, stderr: '', stdout: '', code: null });
649
659
  return;
650
660
  }
651
- done({ result: t ? { text: t } : null, stderr, stdout, code });
661
+ let stdout = '';
662
+ let stderr = '';
663
+ let settled = false;
664
+ const done = (val) => {
665
+ if (settled) return;
666
+ settled = true;
667
+ clearTimeout(timer);
668
+ resolve(val);
669
+ };
670
+ const timer = setTimeout(() => {
671
+ try {
672
+ child.kill('SIGKILL');
673
+ } catch {
674
+ /* already gone */
675
+ }
676
+ const t = stdout.trim();
677
+ // Salvage fenced-or-bare JSON from partial stdout (mirrors callModelCLI). A raw
678
+ // brace check would discard a complete-but-```json-fenced payload (#8605);
679
+ // parseJsonFromLLM strips fences before validating, and the caller re-parses
680
+ // the returned text the same way.
681
+ if (t && parseJsonFromLLM(t) !== null) {
682
+ done({ result: { text: t }, stderr, stdout, code: null });
683
+ return;
684
+ }
685
+ done({ result: null, stderr, stdout, code: null });
686
+ }, budget);
687
+ child.stdout?.setEncoding('utf8'); // decode multi-byte UTF-8 (CJK) across chunk boundaries
688
+ child.stdout?.on('data', (d) => {
689
+ stdout += d;
690
+ });
691
+ // Keep draining stderr so a chatty child can't block on a full pipe, but keep
692
+ // a bounded head of it — the flag-compat probe needs the parser's complaint.
693
+ // Slice AFTER appending: checking the length first lets one arbitrarily large
694
+ // chunk through whole, which is the shape a single big stderr write takes.
695
+ child.stderr?.setEncoding?.('utf8');
696
+ child.stderr?.on('data', (d) => {
697
+ stderr = (stderr + d).slice(0, 4096);
698
+ });
699
+ child.on('error', (e) => {
700
+ debugCatch(e, `${model}-cli-async`);
701
+ done({ result: null, stderr: '', stdout: '', code: null });
702
+ });
703
+ child.on('close', (code) => {
704
+ const t = stdout.trim();
705
+ // Parity with callModelCLI: execFileSync THROWS on a non-zero exit, so the
706
+ // sync leg only ever returns such output when parseJsonFromLLM accepts it
707
+ // (its catch-salvage). Without the same gate, a CLI that prints a
708
+ // diagnostic to stdout and dies — auth failure, overload banner, wrapper
709
+ // error — has that diagnostic returned as the model's ANSWER. rerank is the
710
+ // first caller to consume the raw {text}: extractRanked's last resort
711
+ // matches any bracketed number list in prose, so a `[1]` inside a stack
712
+ // frame becomes a ranking and silently reorders search results. The
713
+ // flag-compat probe below reads stderr/stdout/code directly, not `result`,
714
+ // so nulling here does not cost it its retry.
715
+ if (t && typeof code === 'number' && code !== 0 && parseJsonFromLLM(t) === null) {
716
+ done({ result: null, stderr, stdout, code });
717
+ return;
718
+ }
719
+ done({ result: t ? { text: t } : null, stderr, stdout, code });
720
+ });
721
+ // EPIPE guard: the child may exit before we finish writing stdin.
722
+ child.stdin?.on('error', () => {});
723
+ try {
724
+ child.stdin?.write(payload);
725
+ child.stdin?.end();
726
+ } catch (e) {
727
+ debugCatch(e, `${model}-cli-async:stdin`);
728
+ }
652
729
  });
653
- // EPIPE guard: the child may exit before we finish writing stdin.
654
- child.stdin?.on('error', () => {});
655
- try {
656
- child.stdin?.write(payload);
657
- child.stdin?.end();
658
- } catch (e) {
659
- debugCatch(e, `${model}-cli-async:stdin`);
660
- }
661
- });
662
730
 
663
731
  const firstArgs = claudeArgs(modelName);
664
732
  const first = await attempt(firstArgs, timeout);
@@ -668,9 +736,11 @@ export async function callModelCLIAsync(prompt, model, { timeout }) {
668
736
  // deep-search escalations). Gating on the exit code before `first.result` also
669
737
  // covers a CLI that prints its usage banner to stdout and exits non-zero —
670
738
  // otherwise that banner is returned as the model's answer and nothing retries.
671
- const rejected = firstArgs.includes(HEADLESS_FLAG)
672
- && typeof first.code === 'number' && first.code !== 0
673
- && _isUnknownFlagError(`${first.stderr}\n${first.stdout.slice(0, 4096)}`);
739
+ const rejected =
740
+ firstArgs.includes(HEADLESS_FLAG) &&
741
+ typeof first.code === 'number' &&
742
+ first.code !== 0 &&
743
+ _isUnknownFlagError(`${first.stderr}\n${first.stdout.slice(0, 4096)}`);
674
744
  if (!rejected) return first.result;
675
745
  // The rejection is instantaneous (the child dies in argv parsing), so the retry
676
746
  // normally gets nearly the whole budget; spend only what is left of it.
@@ -684,7 +754,11 @@ export async function callModelCLIAsync(prompt, model, { timeout }) {
684
754
  // twin caches on any non-throwing run; this now means the same thing.
685
755
  if (second.code === 0) {
686
756
  _headlessFlagOk = false;
687
- debugLog('WARN', `${model}-cli-async`, `claude CLI rejected ${HEADLESS_FLAG}; dropped for this process (the headless session tax returns — upgrade Claude Code to avoid it)`);
757
+ debugLog(
758
+ 'WARN',
759
+ `${model}-cli-async`,
760
+ `claude CLI rejected ${HEADLESS_FLAG}; dropped for this process (the headless session tax returns — upgrade Claude Code to avoid it)`,
761
+ );
688
762
  }
689
763
  return second.result;
690
764
  }
@@ -698,7 +772,11 @@ export async function callModelCLIAsync(prompt, model, { timeout }) {
698
772
  // `cache_control` field has no OpenAI-format equivalent and is omitted.
699
773
  // `tier` is the resolved model tier ('haiku'|'sonnet'); OPENROUTER_MODEL can
700
774
  // override the resulting slug entirely (see resolveOpenRouterModel).
701
- async function callOpenRouterAPI(prompt, tier, { timeout, maxTokens, temperature = DEFAULT_LLM_TEMPERATURE }) {
775
+ async function callOpenRouterAPI(
776
+ prompt,
777
+ tier,
778
+ { timeout, maxTokens, temperature = DEFAULT_LLM_TEMPERATURE },
779
+ ) {
702
780
  const apiKey = process.env.OPENROUTER_API_KEY;
703
781
  if (!apiKey) return null;
704
782
 
@@ -715,7 +793,7 @@ async function callOpenRouterAPI(prompt, tier, { timeout, maxTokens, temperature
715
793
  const url = 'https://openrouter.ai/api/v1/chat/completions';
716
794
  const reqHeaders = {
717
795
  'Content-Type': 'application/json',
718
- 'Authorization': `Bearer ${apiKey}`,
796
+ Authorization: `Bearer ${apiKey}`,
719
797
  // Optional OpenRouter attribution headers (ignored by the API if absent).
720
798
  'X-Title': 'claude-mem-lite',
721
799
  };
package/hash-utils.mjs CHANGED
@@ -11,11 +11,17 @@ export function jaccardSimilarity(a, b) {
11
11
  if (!a || !b) return 0;
12
12
  // Strip trailing punctuation from tokens to match MinHash normalization
13
13
  // (prevents "server.rs," ≠ "server.rs" dedup failures)
14
- const norm = s => s.toLowerCase().split(/\s+/).map(t => t.replace(/[,;:!?]+$/, ''));
14
+ const norm = (s) =>
15
+ s
16
+ .toLowerCase()
17
+ .split(/\s+/)
18
+ .map((t) => t.replace(/[,;:!?]+$/, ''));
15
19
  const setA = new Set(norm(a));
16
20
  const setB = new Set(norm(b));
17
21
  let intersection = 0;
18
- for (const w of setA) { if (setB.has(w)) intersection++; }
22
+ for (const w of setA) {
23
+ if (setB.has(w)) intersection++;
24
+ }
19
25
  const union = setA.size + setB.size - intersection;
20
26
  return union === 0 ? 0 : intersection / union;
21
27
  }
@@ -42,19 +48,22 @@ function fnv1a(str) {
42
48
  */
43
49
  export function computeMinHash(text, numHashes = 64) {
44
50
  if (!text || typeof text !== 'string') return null;
45
- const tokens = text.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/)
46
- .filter(t => t.length > 2);
51
+ const tokens = text
52
+ .toLowerCase()
53
+ .replace(/[^a-z0-9\s]/g, ' ')
54
+ .split(/\s+/)
55
+ .filter((t) => t.length > 2);
47
56
  // Require at least 3 tokens for meaningful signature (avoids high collision on short texts)
48
57
  if (tokens.length < 3) return null;
49
58
 
50
- const mins = new Array(numHashes).fill(0xFFFFFFFF);
59
+ const mins = new Array(numHashes).fill(0xffffffff);
51
60
  for (const token of tokens) {
52
61
  for (let i = 0; i < numHashes; i++) {
53
62
  const val = fnv1a(`${i}-${token}`);
54
63
  if (val < mins[i]) mins[i] = val;
55
64
  }
56
65
  }
57
- return mins.map(v => v.toString(16).padStart(8, '0')).join('');
66
+ return mins.map((v) => v.toString(16).padStart(8, '0')).join('');
58
67
  }
59
68
 
60
69
  /**