clawmem 0.11.3 → 0.12.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.
package/AGENTS.md CHANGED
@@ -553,7 +553,7 @@ User Query + optional intent hint
553
553
  → Reciprocal Rank Fusion (k=60, top candidateLimit, up to 4-way parallel legs)
554
554
  → Intent-Aware Chunk Selection (intent terms at 0.5× weight alongside query terms at 1.0×)
555
555
  → Cross-Encoder Reranking (4000 char context; intent prepended to rerank query; chunk dedup; batch cap=4)
556
- Position-Aware Blending (α=0.75 top3, 0.60 mid, 0.40 tail)
556
+ Rerank/RRF Blend (blendRerank: 0.9·reranker + 0.1·normalized-RRF tiebreaker; reranker can promote over RRF #1; falls back to RRF order if reranker unavailable)
557
557
  → SAME Composite Scoring
558
558
  → MMR Diversity Filter (Jaccard bigram similarity > 0.6 → demoted, not removed)
559
559
  ```
package/CLAUDE.md CHANGED
@@ -553,7 +553,7 @@ User Query + optional intent hint
553
553
  → Reciprocal Rank Fusion (k=60, top candidateLimit, up to 4-way parallel legs)
554
554
  → Intent-Aware Chunk Selection (intent terms at 0.5× weight alongside query terms at 1.0×)
555
555
  → Cross-Encoder Reranking (4000 char context; intent prepended to rerank query; chunk dedup; batch cap=4)
556
- Position-Aware Blending (α=0.75 top3, 0.60 mid, 0.40 tail)
556
+ Rerank/RRF Blend (blendRerank: 0.9·reranker + 0.1·normalized-RRF tiebreaker; reranker can promote over RRF #1; falls back to RRF order if reranker unavailable)
557
557
  → SAME Composite Scoring
558
558
  → MMR Diversity Filter (Jaccard bigram similarity > 0.6 → demoted, not removed)
559
559
  ```
package/README.md CHANGED
@@ -813,7 +813,7 @@ User Query + optional intent hint
813
813
  → Reciprocal Rank Fusion → slice to candidateLimit (default 30)
814
814
  → Intent-Aware Chunk Selection (intent terms at 0.5× weight alongside query terms at 1.0×)
815
815
  → Cross-Encoder Reranking (4000 char context; intent prepended; chunk dedup; batch cap=4)
816
- Position-Aware Blending (α=0.75 top3, 0.60 mid, 0.40 tail)
816
+ Rerank/RRF Blend (blendRerank: 0.9·reranker + 0.1·normalized-RRF tiebreaker; reranker can promote over RRF #1; falls back to RRF order if reranker unavailable)
817
817
  → SAME Composite Scoring ((search × 0.5 + recency × 0.25 + confidence × 0.25) × qualityMultiplier × lengthNorm × coActivationBoost + pinBoost)
818
818
  → MMR Diversity Filter (Jaccard bigram similarity > 0.6 → demoted)
819
819
  → Ranked Results
package/SKILL.md CHANGED
@@ -389,7 +389,7 @@ User Query + optional intent hint
389
389
  -> Reciprocal Rank Fusion (k=60, top candidateLimit)
390
390
  -> Intent-Aware Chunk Selection (intent terms at 0.5x weight alongside query terms at 1.0x)
391
391
  -> Cross-Encoder Reranking (4000 char context; intent prepended to rerank query; chunk dedup; batch cap=4)
392
- -> Position-Aware Blending (alpha=0.75 top3, 0.60 mid, 0.40 tail)
392
+ -> Rerank/RRF Blend (blendRerank: 0.9*reranker + 0.1*normalized-RRF tiebreaker; reranker can promote over RRF #1; falls back to RRF order if reranker unavailable)
393
393
  -> Composite Scoring
394
394
  -> MMR Diversity Filter (Jaccard bigram similarity > 0.6 -> demoted, not removed)
395
395
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmem",
3
- "version": "0.11.3",
3
+ "version": "0.12.0",
4
4
  "description": "On-device memory layer for AI agents. Claude Code, OpenClaw, and Hermes. Hooks + MCP server + hybrid RAG search.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/mcp.ts CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  type EnrichedResult,
32
32
  type CoActivationFn,
33
33
  } from "./memory.ts";
34
- import { enrichResults, reciprocalRankFusion, toRanked, type RankedResult } from "./search-utils.ts";
34
+ import { enrichResults, reciprocalRankFusion, toRanked, blendRerank, type RankedResult } from "./search-utils.ts";
35
35
  import { applyMMRDiversity } from "./mmr.ts";
36
36
  import { indexCollection, type IndexStats } from "./indexer.ts";
37
37
  import { listCollections } from "./collections.ts";
@@ -753,19 +753,21 @@ This is the recommended entry point for ALL memory queries.`,
753
753
  return { file: c.file, text };
754
754
  });
755
755
 
756
- const reranked = await store.rerank(query, chunksToRerank, DEFAULT_RERANK_MODEL, intent);
756
+ let reranked: { file: string; score: number }[] = [];
757
+ try {
758
+ reranked = await store.rerank(query, chunksToRerank, DEFAULT_RERANK_MODEL, intent);
759
+ } catch {
760
+ reranked = []; // reranker unavailable → blendRerank falls back to pure RRF order
761
+ }
757
762
 
758
763
  const candidateMap = new Map(candidates.map(c => [c.file, c]));
759
- const rrfRankMap = new Map(candidates.map((c, i) => [c.file, i + 1]));
760
-
761
- // Blend RRF + reranker scores (position-aware)
762
- const blended = reranked.map(r => {
763
- const rrfRank = rrfRankMap.get(r.file) || candidates.length;
764
- const rrfWeight = rrfRank <= 3 ? 0.75 : rrfRank <= 10 ? 0.60 : 0.40;
765
- const blendedScore = rrfWeight * (1 / rrfRank) + (1 - rrfWeight) * r.score;
766
- return { file: r.file, score: blendedScore };
767
- });
768
- blended.sort((a, b) => b.score - a.score);
764
+
765
+ // Blend the reranker (dominant signal) with a thin normalized-RRF tiebreaker; falls back
766
+ // to pure RRF order when the reranker is unavailable / all-zero. See blendRerank: the
767
+ // prior w·(1/rrfRank) blend made RRF rank-1 immovable by the reranker. Harness-validated
768
+ // 2026-06-25 (NL+KW known-item recall): lifts recall@1-5 + MRR@10 with no material pooled
769
+ // recall@10 regression.
770
+ const blended = blendRerank(candidates, reranked);
769
771
 
770
772
  // Map to SearchResults for composite scoring — hydrate from DB when needed
771
773
  const allSearchResults = [...store.searchFTS(query, 30)];
@@ -113,6 +113,51 @@ export function reciprocalRankFusion(
113
113
  .map(v => ({ ...v.result, score: v.score }));
114
114
  }
115
115
 
116
+ // =============================================================================
117
+ // Rerank / RRF Score Blend
118
+ // =============================================================================
119
+
120
+ /**
121
+ * Blend a cross-encoder reranker's scores with the upstream RRF ranking.
122
+ *
123
+ * The reranker is the dominant relevance signal; normalized RRF contributes a thin tiebreaker.
124
+ * An earlier blend used `w·(1/rrfRank)` with `w≥0.75` on the top tier, which made RRF rank-1
125
+ * mathematically immovable by the reranker (0.75·(1/1) exceeds any rank-2 ceiling) — the
126
+ * reranker could never promote the best document to the top. This blend normalizes the RRF
127
+ * score to [0,1] and gives the reranker the dominant weight, so a strong rerank score CAN
128
+ * promote a doc over RRF #1. Harness-validated 2026-06-25 against NL+KW known-item recall:
129
+ * lifts recall@1-5 and MRR@10 with no material pooled recall@10 regression.
130
+ *
131
+ * Falls back to pure RRF order when the reranker is unavailable or returned no usable signal
132
+ * (empty, or all-zero — e.g. a total remote+local failure). Maps over `candidates` (not the
133
+ * rerank output) so partial rerank coverage can never drop a candidate; an unscored doc takes
134
+ * rerank score 0 (so it sorts on its thin `(1-rerankWeight)·rrfNorm` term) and unscored docs
135
+ * preserve their relative RRF order among themselves.
136
+ *
137
+ * @param candidates - RRF-ordered candidates; `score` is the RRF fusion score (positive).
138
+ * @param reranked - reranker output `{file, score in [0,1]}`; may be empty/partial/all-zero.
139
+ * @param rerankWeight - weight on the reranker term (default 0.9; `1-rerankWeight` on RRF).
140
+ * @returns candidates re-scored and sorted by blended score descending.
141
+ */
142
+ export function blendRerank(
143
+ candidates: { file: string; score: number }[],
144
+ reranked: { file: string; score: number }[],
145
+ rerankWeight: number = 0.9
146
+ ): { file: string; score: number }[] {
147
+ const rerankScoreMap = new Map(reranked.map(r => [r.file, r.score]));
148
+ const rerankUsable = reranked.length > 0 && reranked.some(r => Number.isFinite(r.score) && r.score > 0);
149
+ const maxRrf = candidates.reduce((m, c) => Math.max(m, c.score), 0) || 1;
150
+ return candidates
151
+ .map(c => {
152
+ const rrfNorm = c.score / maxRrf; // [0,1]
153
+ if (!rerankUsable) return { file: c.file, score: rrfNorm };
154
+ const rr = rerankScoreMap.get(c.file);
155
+ const rerankScore = Number.isFinite(rr) ? (rr as number) : 0;
156
+ return { file: c.file, score: (1 - rerankWeight) * rrfNorm + rerankWeight * rerankScore };
157
+ })
158
+ .sort((a, b) => b.score - a.score);
159
+ }
160
+
116
161
  /**
117
162
  * Convert a SearchResult to a RankedResult for use in RRF.
118
163
  */