claude-mem-lite 3.84.1 → 3.85.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.84.1",
13
+ "version": "3.85.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.84.1",
3
+ "version": "3.85.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/hook-memory.mjs CHANGED
@@ -13,6 +13,49 @@ import { formatSubagentContext } from './lib/task-imperative.mjs';
13
13
  import { DAY_MS } from './lib/time-constants.mjs';
14
14
  const MAX_MEMORY_INJECTIONS = 3;
15
15
  const MEMORY_LOOKBACK_MS = 60 * DAY_MS; // 60 days
16
+
17
+ /**
18
+ * Candidate-pool bounds for the `fyi` injection face (searchRelevantMemories).
19
+ *
20
+ * READ THESE AS REACHABILITY BOUNDS, NOT AS RANKING GATES — the same distinction
21
+ * D#172 cost us on IMPERATIVE_POOL_BACKSTOP, found again here by the 2026-08-29 audit
22
+ * (ALGO-3). The two SELECTs below `ORDER BY` RAW bm25, but the row that actually gets
23
+ * injected is chosen by the JS composite in `scored` (type quality × lesson bonus ×
24
+ * importance × cross-project × OR × noise × cite). So whatever these numbers are, a row
25
+ * outside the window cannot be picked however high its composite score would have been.
26
+ *
27
+ * The window has to be wide because the composite spread is enormous. Multiplying the
28
+ * extremes of the JS factors (same-project, AND mode): best = 1.5 decision × 1.5 lesson
29
+ * × 1.0 importance × 1.0 noise × 3.0 cite = 6.75; worst = 0.5 change × 1.0 no-lesson
30
+ * × 0.6 importance × 0.2 noise × 0.4 cite = 0.024. That is a **281× spread**, so a row
31
+ * ranked below the window on raw bm25 can outscore the window's contents by a wide
32
+ * margin. (The audit estimated ">10×"; the factor tables say 281×.)
33
+ *
34
+ * HONEST LIMIT OF THIS FIX: because the spread is 281× and bm25 magnitude decays slowly
35
+ * across a top-N window, NO finite pool size proves sufficiency. 30/15 is a 3× widening
36
+ * chosen where cost stays flat (the SELECT carries `narrative`, so the pool is the
37
+ * expensive term, not the sort) — it makes the bound loose, it does not remove it.
38
+ *
39
+ * The bound is REMOVABLE, and deliberately was not removed: ordering both SELECTs by the
40
+ * composite instead of raw bm25 is expressible in SQL today (every factor already has a
41
+ * clause — TYPE_QUALITY_CASE / noisePenaltyClause / citeFactorClause — and the two
42
+ * remaining factors, cross-project and OR, are per-QUERY constants that cannot affect
43
+ * within-query order). That would make LIMIT a true ranking bound. It is not done here
44
+ * because `lib/inject-search-core.mjs:23-25` records this surface's "BM25-sort + JS
45
+ * scoring" composition as a deliberate per-surface asymmetry (#8786), and this face is
46
+ * one `benchmark/denoise-ab.mjs` is structurally blind to (its suites drive the
47
+ * search-engine, not this function) — re-ranking an unmeasurable face is how this
48
+ * project has repeatedly shipped regressions. Widening is monotone and provable;
49
+ * re-ranking needs a ruler that does not exist yet.
50
+ *
51
+ * WHY WIDENING IS SAFE: the old window is a strict PREFIX of the new one (same ORDER BY,
52
+ * larger LIMIT), so the new candidate set is a superset. `scored` sorts by composite and
53
+ * the threshold filter is monotone in that score, so every row returned is at least as
54
+ * good as the row it displaced. The only non-monotone stage is the term-coverage filter,
55
+ * which is exactly why the pool needs slack rather than just `MAX_MEMORY_INJECTIONS`.
56
+ */
57
+ const RERANK_POOL_SAME_PROJECT = 30;
58
+ const RERANK_POOL_CROSS_PROJECT = 15;
16
59
  // Type weights come from scoring-sql.mjs — this was a hand-copy kept equal by an
17
60
  // "aligned with (R2)" comment (audit 2026-08-22, P2-10).
18
61
  // lesson_learned boost (1.5×) stacks for entries with a real takeaway.
@@ -225,7 +268,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
225
268
  AND ${liveObsFilterSql('o')}
226
269
  AND ${notLowSignalTitleClause('o')}
227
270
  ORDER BY ${OBS_BM25}
228
- LIMIT 10
271
+ LIMIT ${RERANK_POOL_SAME_PROJECT}
229
272
  `);
230
273
  let rows = selectStmt.all(ftsQuery, project, cutoff);
231
274
  let usedOrFallback = false;
@@ -280,7 +323,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
280
323
  AND ${liveObsFilterSql('o')}
281
324
  AND ${notLowSignalTitleClause('o')}
282
325
  ORDER BY ${OBS_BM25}
283
- LIMIT 5
326
+ LIMIT ${RERANK_POOL_CROSS_PROJECT}
284
327
  `);
285
328
  crossRows = crossStmt.all(ftsQuery, project, cutoff);
286
329
  if (crossRows.length === 0) {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.84.1",
3
+ "version": "3.85.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.84.1",
9
+ "version": "3.85.0",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.26.0",
12
12
  "better-sqlite3": "^12.6.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.84.1",
3
+ "version": "3.85.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",
@@ -61,6 +61,13 @@ const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtim
61
61
  // which already imports lib modules here, and the inlined value silently encoded the
62
62
  // same premise twice.
63
63
  import { DEDUP_STALE_MS as CROSS_HOOK_DEDUP_MS } from './prompt-search-utils.mjs';
64
+ // Upper bound on the over-fetch the cross-hook dedup buys itself (ALGO-4). The dedup
65
+ // runs in JS after the SELECTs, so each LIMIT is raised by the seen-set size to keep the
66
+ // dedup a re-ranking rather than a truncation. This cap exists because the seen-set is
67
+ // read from a file on disk: it is bounded by UPS's own per-prompt budget in practice
68
+ // (MAX_RESULTS 3), but an unbounded value read off disk must never size a query. 5 is
69
+ // well above that budget and still leaves the worst case at 2+5=7 rows per SELECT.
70
+ const CROSS_HOOK_DEDUP_SLACK_MAX = 5;
64
71
  // v2.33.1: cooldown path is session-scoped so same-file-twice within one
65
72
  // session never re-injects (was: global file, 5-min window). Cross-session:
66
73
  // fresh file, fresh nudges — this is intended. No session_id → fall back to
@@ -451,7 +458,18 @@ try {
451
458
  (o.lesson_learned IS NOT NULL AND o.lesson_learned != '')
452
459
  OR (o.type IN ('bugfix', 'decision') AND ${notLowSignalSql})
453
460
  )`;
454
- const obsLimit = isRead ? 1 : 2;
461
+ // Cross-hook dedup slack (audit 2026-08-29 ALGO-4, the D#172 shape again). The
462
+ // dedup below drops rows UPS already injected this prompt, and it used to run
463
+ // DOWNSTREAM of these LIMITs — so a deduped row left its slot EMPTY instead of
464
+ // yielding it to the next candidate, i.e. "dedup" was implemented as "shrink".
465
+ // On a Read (obsLimit 1 / eventsLimit 1) one dedup hit silenced the whole face.
466
+ // Read the seen-set FIRST and over-fetch by its size so the dedup removes rows
467
+ // from a pool that still has enough left to fill the cap. Capped at
468
+ // CROSS_HOOK_DEDUP_SLACK_MAX: the seen-set is bounded by UPS's own per-prompt
469
+ // budget in practice, but it is read off disk and must not size a query.
470
+ const crossHookSeen = readCrossHookInjected(project, sessionId);
471
+ const dedupSlack = Math.min(crossHookSeen.size, CROSS_HOOK_DEDUP_SLACK_MAX);
472
+ const obsLimit = (isRead ? 1 : 2) + dedupSlack;
455
473
  // A1.5 (v2.83.2): cite_factor as a tertiary sort key. When multiple file-
456
474
  // matching lessons exist, the one with proven cite history outranks the
457
475
  // merely-most-recent one. Single-match files unchanged (obsLimit=1 Read /
@@ -521,7 +539,7 @@ try {
521
539
  ? "AND body IS NOT NULL AND body != ''"
522
540
  : `AND ((body IS NOT NULL AND body != '')
523
541
  OR (event_type IN ('bugfix', 'decision', 'lesson') AND ${buildNotLowSignalSql('')}))`;
524
- const eventsLimit = isRead ? 1 : 2;
542
+ const eventsLimit = (isRead ? 1 : 2) + dedupSlack;
525
543
  let eventRows = [];
526
544
  try {
527
545
  eventRows = db.prepare(`
@@ -548,7 +566,7 @@ try {
548
566
  // P1 (D#78): tag each row's source table — events share the numeric id
549
567
  // space with observations, and the Stop-side edge attribution must never
550
568
  // feed an event id into observation_files updates.
551
- const crossHookSeen = readCrossHookInjected(project, sessionId);
569
+ // (crossHookSeen is read above, before the two SELECTs — it sizes their LIMITs.)
552
570
  const sourcedRows = [
553
571
  ...rows.map(r => ({ ...r, src: 'obs' })),
554
572
  ...eventRows.map(r => ({ ...r, src: 'evt' })),
@@ -50,6 +50,12 @@ const LOOKBACK_MS = 60 * DAY_MS; // 60 days
50
50
  // quality gate — only BM25 ordering — so additional rows inflate noise without
51
51
  // improving signal. Env-overridable for projects that want broader prompt recall.
52
52
  const PROMPT_FALLBACK_LIMIT = Number(process.env.CLAUDE_MEM_UPS_PROMPT_FALLBACK_LIMIT || 1);
53
+ // Over-fetch factor for that cap. searchByUserPrompts filters rows in JS (cjkPrecisionOk)
54
+ // AFTER the SQL LIMIT, so the LIMIT bounds reachability, not just output width — see the
55
+ // comment at the query. These size the pool only; the function still returns at most
56
+ // PROMPT_FALLBACK_LIMIT rows, so widening them cannot inflate the injection budget.
57
+ const PROMPT_FALLBACK_POOL_FACTOR = 5;
58
+ const PROMPT_FALLBACK_POOL_MAX = 25;
53
59
 
54
60
  // T3 (v2.31): per-row BM25 magnitude floor. OBS_BM25 (in scoring-sql.mjs)
55
61
  // returns the raw bm25() value — negative, smaller = better. Multiplied by
@@ -263,6 +269,13 @@ export function hasExplicitSignal(text, { errSig, files, intent } = {}) {
263
269
  // constants here is what would let them drift apart again.
264
270
 
265
271
  export const IDENTIFIER_BYPASS = process.env.CLAUDE_MEM_UPS_IDENTIFIER_BYPASS !== '0';
272
+ // How far past the main LIMIT the bypass may look, and how many rows it may pull from
273
+ // there (ALGO-2). These size the CANDIDATE POOL only — the injected set is still capped
274
+ // by MAX_RESULTS downstream, so neither widens the injection budget. Kept small on
275
+ // purpose: rows this deep matched the prompt weakly overall, and the identifier hit is
276
+ // the only reason they are admitted at all.
277
+ const IDENTIFIER_BYPASS_POOL_EXTRA = 7;
278
+ const IDENTIFIER_BYPASS_DEEP_MAX = 2;
266
279
  const TECH_IDENTIFIER_RE_G = new RegExp(TECH_IDENTIFIER_RE.source, 'g');
267
280
 
268
281
  // All tech-identifier tokens in `text`, lowercased + de-duped (for case-insensitive
@@ -426,12 +439,20 @@ function searchByUserPrompts(db, queryText, project, limit) {
426
439
  LIMIT ?
427
440
  `;
428
441
 
429
- let rows = db.prepare(sql).all(ftsQuery, project, cutoff, limit);
442
+ // Over-fetch, because the cjkPrecisionOk filter below runs in JS (audit 2026-08-29
443
+ // ALGO-5, the D#172 shape). At the shipped PROMPT_FALLBACK_LIMIT of 1 the SQL LIMIT
444
+ // was a REACHABILITY bound sitting upstream of a relevance filter: dropping one row
445
+ // dropped the whole face, so a CJK prompt whose best BM25 match happened to be a
446
+ // false bigram hit injected NOTHING even when rank 2 was a real match. Fetch a pool,
447
+ // filter, then take `limit` — same ORDER BY, so the old result is a prefix of this
448
+ // one and a row can only be added, never displaced by something worse.
449
+ const poolLimit = Math.min(limit * PROMPT_FALLBACK_POOL_FACTOR, PROMPT_FALLBACK_POOL_MAX);
450
+ let rows = db.prepare(sql).all(ftsQuery, project, cutoff, poolLimit);
430
451
 
431
452
  if (rows.length === 0) {
432
453
  const orQuery = relaxFtsQueryToOr(ftsQuery);
433
454
  if (orQuery) {
434
- try { rows = db.prepare(sql).all(orQuery, project, cutoff, limit); } catch {}
455
+ try { rows = db.prepare(sql).all(orQuery, project, cutoff, poolLimit); } catch {}
435
456
  }
436
457
  }
437
458
 
@@ -439,7 +460,7 @@ function searchByUserPrompts(db, queryText, project, limit) {
439
460
  // FTS degrades CJK bigram queries to single-char AND, letting any prose
440
461
  // sharing common chars leak through. Drop rows that miss < 20% of query
441
462
  // bigrams/keywords as contiguous substrings. Non-CJK queries bypass.
442
- return rows.filter(r => cjkPrecisionOk(queryText, r.prompt_text));
463
+ return rows.filter(r => cjkPrecisionOk(queryText, r.prompt_text)).slice(0, limit);
443
464
  }
444
465
 
445
466
  function searchRecent(db, project, limit) {
@@ -752,12 +773,24 @@ async function main() {
752
773
  } else {
753
774
  // FTS search: use the prompt as query, optionally type-filtered
754
775
  const files = filesForGate;
755
- let ftsResult = searchByFts(db, promptText, project, intent?.limit || MAX_RESULTS, intent?.type || null);
776
+ const mainLimit = intent?.limit || MAX_RESULTS;
777
+ // Over-fetch ONLY to feed the identifier bypass below (audit 2026-08-29 ALGO-2,
778
+ // the D#172 shape). The bypass used to select from `ftsRows`, i.e. from the same
779
+ // LIMIT-`mainLimit` window it exists to rescue rows into — so it could only ever
780
+ // recover a row the composite sort had ALREADY ranked top-3, and the df=1
781
+ // identifier row its own docblock argues for (rare token, so BM25-strong on the
782
+ // term but easily out-ranked by rows matching more of the prompt) was unreachable.
783
+ // `ftsRows` stays the exact old head slice, so the main path is byte-identical;
784
+ // only the bypass sees deeper. Pool sizing is bypass-only: with the bypass off
785
+ // this is the old query verbatim.
786
+ const poolLimit = IDENTIFIER_BYPASS ? mainLimit + IDENTIFIER_BYPASS_POOL_EXTRA : mainLimit;
787
+ let ftsResult = searchByFts(db, promptText, project, poolLimit, intent?.type || null);
756
788
  // Fallback: if typed search returned nothing, retry without type filter
757
789
  if (ftsResult.rows.length === 0 && intent?.type) {
758
- ftsResult = searchByFts(db, promptText, project, intent.limit || MAX_RESULTS, null);
790
+ ftsResult = searchByFts(db, promptText, project, poolLimit, null);
759
791
  }
760
- let ftsRows = ftsResult.rows;
792
+ const ftsPool = ftsResult.rows;
793
+ let ftsRows = ftsPool.slice(0, mainLimit);
761
794
  const ftsMode = ftsResult.mode;
762
795
  const fileRows = files.length > 0 ? searchByFile(db, files, project, 2) : [];
763
796
 
@@ -775,9 +808,26 @@ async function main() {
775
808
  // Capture rows that exact-match a prompt identifier BEFORE the set-floors below;
776
809
  // they carry independent precision signal (sigRows/fileRows rationale) and are
777
810
  // restored after the floors so a low top-score can't drop a named-identifier hit.
778
- const bypassRows = (IDENTIFIER_BYPASS && promptIdentifiers.length > 0)
779
- ? ftsRows.filter(r => rowMatchesIdentifier(r, promptIdentifiers))
780
- : [];
811
+ // STRICTLY ADDITIVE to the pre-ALGO-2 behaviour: `head` is what this expression
812
+ // used to return (the post-floor rows of the old LIMIT window that match an
813
+ // identifier), and `deep` adds at most IDENTIFIER_BYPASS_DEEP_MAX rows from
814
+ // beyond that window. The audit prescribed a standalone LIMIT-2 SELECT; a capped
815
+ // tail of the SAME query is the same reach with one fewer FTS scan, and it cannot
816
+ // regress the head — a flat cap of 2 over the merged set could have, by evicting
817
+ // a third head row that ships today.
818
+ const bypassFloorOk = (r) => typeof r.relevance === 'number' && Math.abs(r.relevance) >= bm25Floor;
819
+ let bypassRows = [];
820
+ if (IDENTIFIER_BYPASS && promptIdentifiers.length > 0) {
821
+ const head = ftsPool.slice(0, mainLimit)
822
+ .filter(bypassFloorOk)
823
+ .filter(r => rowMatchesIdentifier(r, promptIdentifiers));
824
+ const headIds = new Set(head.map(r => r.id));
825
+ const deep = ftsPool.slice(mainLimit)
826
+ .filter(bypassFloorOk)
827
+ .filter(r => !headIds.has(r.id) && rowMatchesIdentifier(r, promptIdentifiers))
828
+ .slice(0, IDENTIFIER_BYPASS_DEEP_MAX);
829
+ bypassRows = [...head, ...deep];
830
+ }
781
831
 
782
832
  // v2.43.x: OR-mode raw-BM25 floor. In OR-fallback mode the composite
783
833
  // TOP_REL_FLOOR below is inflated by importance × type_quality × decay