clawmem 0.11.3 → 0.13.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
@@ -461,6 +461,8 @@ Recency intent detected ("latest", "recent", "last session"):
461
461
  compositeScore = (0.10 × searchScore + 0.70 × recencyScore + 0.20 × confidenceScore) × qualityMultiplier × coActivationBoost
462
462
  ```
463
463
 
464
+ **`query` tool (v0.13.0+):** for non-recency queries the `query` tool uses retrieval-tuned weights `0.70 × searchScore + 0.15 × recencyScore + 0.15 × confidenceScore` (from a held-out judged eval). Recency intent still switches to the `0.10 / 0.70 / 0.20` weights above. `search`, `vsearch`, `memory_retrieve`, and the context-surfacing hook keep the `0.50 / 0.25 / 0.25` default.
465
+
464
466
  | Content Type | Half-Life | Effect |
465
467
  |--------------|-----------|--------|
466
468
  | decision, deductive, preference, hub | ∞ | Never decay |
@@ -553,7 +555,7 @@ User Query + optional intent hint
553
555
  → Reciprocal Rank Fusion (k=60, top candidateLimit, up to 4-way parallel legs)
554
556
  → Intent-Aware Chunk Selection (intent terms at 0.5× weight alongside query terms at 1.0×)
555
557
  → 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)
558
+ 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
559
  → SAME Composite Scoring
558
560
  → MMR Diversity Filter (Jaccard bigram similarity > 0.6 → demoted, not removed)
559
561
  ```
package/CLAUDE.md CHANGED
@@ -461,6 +461,8 @@ Recency intent detected ("latest", "recent", "last session"):
461
461
  compositeScore = (0.10 × searchScore + 0.70 × recencyScore + 0.20 × confidenceScore) × qualityMultiplier × coActivationBoost
462
462
  ```
463
463
 
464
+ **`query` tool (v0.13.0+):** for non-recency queries the `query` tool uses retrieval-tuned weights `0.70 × searchScore + 0.15 × recencyScore + 0.15 × confidenceScore` (from a held-out judged eval). Recency intent still switches to the `0.10 / 0.70 / 0.20` weights above. `search`, `vsearch`, `memory_retrieve`, and the context-surfacing hook keep the `0.50 / 0.25 / 0.25` default.
465
+
464
466
  | Content Type | Half-Life | Effect |
465
467
  |--------------|-----------|--------|
466
468
  | decision, deductive, preference, hub | ∞ | Never decay |
@@ -553,7 +555,7 @@ User Query + optional intent hint
553
555
  → Reciprocal Rank Fusion (k=60, top candidateLimit, up to 4-way parallel legs)
554
556
  → Intent-Aware Chunk Selection (intent terms at 0.5× weight alongside query terms at 1.0×)
555
557
  → 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)
558
+ 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
559
  → SAME Composite Scoring
558
560
  → MMR Diversity Filter (Jaccard bigram similarity > 0.6 → demoted, not removed)
559
561
  ```
package/README.md CHANGED
@@ -813,8 +813,8 @@ 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)
817
- SAME Composite Scoring ((search × 0.5 + recency × 0.25 + confidence × 0.25) × qualityMultiplier × lengthNorm × coActivationBoost + pinBoost)
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
+ → Composite Scoring (query-tuned: (search × 0.70 + recency × 0.15 + confidence × 0.15) × qualityMultiplier × lengthNorm × coActivationBoost + pinBoost; recency-intent queries → 0.10/0.70/0.20; other tools keep the 0.50/0.25/0.25 default)
818
818
  → MMR Diversity Filter (Jaccard bigram similarity > 0.6 → demoted)
819
819
  → Ranked Results
820
820
  ```
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.13.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
@@ -28,10 +28,11 @@ import {
28
28
  import {
29
29
  applyCompositeScoring,
30
30
  hasRecencyIntent,
31
+ QUERY_WEIGHTS,
31
32
  type EnrichedResult,
32
33
  type CoActivationFn,
33
34
  } from "./memory.ts";
34
- import { enrichResults, reciprocalRankFusion, toRanked, type RankedResult } from "./search-utils.ts";
35
+ import { enrichResults, reciprocalRankFusion, toRanked, blendRerank, type RankedResult } from "./search-utils.ts";
35
36
  import { applyMMRDiversity } from "./mmr.ts";
36
37
  import { indexCollection, type IndexStats } from "./indexer.ts";
37
38
  import { listCollections } from "./collections.ts";
@@ -753,19 +754,21 @@ This is the recommended entry point for ALL memory queries.`,
753
754
  return { file: c.file, text };
754
755
  });
755
756
 
756
- const reranked = await store.rerank(query, chunksToRerank, DEFAULT_RERANK_MODEL, intent);
757
+ let reranked: { file: string; score: number }[] = [];
758
+ try {
759
+ reranked = await store.rerank(query, chunksToRerank, DEFAULT_RERANK_MODEL, intent);
760
+ } catch {
761
+ reranked = []; // reranker unavailable → blendRerank falls back to pure RRF order
762
+ }
757
763
 
758
764
  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);
765
+
766
+ // Blend the reranker (dominant signal) with a thin normalized-RRF tiebreaker; falls back
767
+ // to pure RRF order when the reranker is unavailable / all-zero. See blendRerank: the
768
+ // prior w·(1/rrfRank) blend made RRF rank-1 immovable by the reranker. Harness-validated
769
+ // 2026-06-25 (NL+KW known-item recall): lifts recall@1-5 + MRR@10 with no material pooled
770
+ // recall@10 regression.
771
+ const blended = blendRerank(candidates, reranked);
769
772
 
770
773
  // Map to SearchResults for composite scoring — hydrate from DB when needed
771
774
  const allSearchResults = [...store.searchFTS(query, 30)];
@@ -805,7 +808,10 @@ This is the recommended entry point for ALL memory queries.`,
805
808
 
806
809
  const coFn = (path: string) => store.getCoActivated(path);
807
810
  const enriched = enrichResults(store, searchResults, query);
808
- let scored = applyCompositeScoring(enriched, query, coFn)
811
+ // Phase B (§11.12): the `query` tool's hybrid+rerank pipeline uses QUERY_WEIGHTS (search 0.70)
812
+ // the eval-validated re-weight. Recency intent still wins RECENCY_WEIGHTS by construction
813
+ // (applyCompositeScoring ignores options.weights when hasRecencyIntent(query) and !forceWeights).
814
+ let scored = applyCompositeScoring(enriched, query, coFn, { weights: QUERY_WEIGHTS })
809
815
  .filter(r => r.compositeScore >= (minScore || 0));
810
816
  if (diverse !== false) scored = applyMMRDiversity(scored);
811
817
  scored = scored.slice(0, limit || 10);
package/src/memory.ts CHANGED
@@ -185,6 +185,15 @@ export type CompositeWeights = {
185
185
 
186
186
  export const DEFAULT_WEIGHTS: CompositeWeights = { search: 0.5, recency: 0.25, confidence: 0.25 };
187
187
  export const RECENCY_WEIGHTS: CompositeWeights = { search: 0.1, recency: 0.7, confidence: 0.2 };
188
+ // Query-tool retrieval weights (Phase B, 2026-06-25). A held-out judged eval (n=199, GLM-5.2 judge,
189
+ // quadratic-weighted κ=0.681 vs an independent annotator) showed the default 0.50 search weight
190
+ // under-weights topical relevance for a work-memory vault: w_search 0.70 lifts graded NDCG@10 by +0.064
191
+ // (paired permutation p<1e-4, robust across precision/exploratory/temporal families) with ZERO freshness regression —
192
+ // the newest-correct doc is never demoted in the supersession guard (demotionRate 0), whereas 0.80
193
+ // demotes it out of the top-10 in 2/19 cases. Applied ONLY by the `query` tool's full hybrid+rerank
194
+ // pipeline (the path the eval mirrored). NOT applied under recency intent — RECENCY_WEIGHTS wins by
195
+ // construction in applyCompositeScoring. See BLEND-COMPOSITE-REBALANCE-DESIGN.md §11.12.
196
+ export const QUERY_WEIGHTS: CompositeWeights = { search: 0.7, recency: 0.15, confidence: 0.15 };
188
197
 
189
198
  const RECENCY_PATTERNS = [
190
199
  /\brecent(ly)?\b/i,
@@ -274,13 +283,28 @@ function canonicalMemoryMultiplier(path: string, contentType: string, query: str
274
283
  return 1.0;
275
284
  }
276
285
 
286
+ export type CompositeScoringOptions = {
287
+ /** Query-scoped weights override. Replaces DEFAULT_WEIGHTS; NOT applied under recency intent unless forceWeights. */
288
+ weights?: CompositeWeights;
289
+ /** Injected clock for deterministic scoring (tests/eval). Defaults to new Date(). */
290
+ now?: Date;
291
+ /** Test/experiment only: apply `weights` even under recency intent (bypass the RECENCY_WEIGHTS switch). */
292
+ forceWeights?: boolean;
293
+ };
294
+
277
295
  export function applyCompositeScoring(
278
296
  results: EnrichedResult[],
279
297
  query: string,
280
- coActivationFn?: CoActivationFn
298
+ coActivationFn?: CoActivationFn,
299
+ options?: CompositeScoringOptions
281
300
  ): ScoredResult[] {
282
- const weights = hasRecencyIntent(query) ? RECENCY_WEIGHTS : DEFAULT_WEIGHTS;
283
- const now = new Date();
301
+ const recencyIntent = hasRecencyIntent(query);
302
+ // Recency intent keeps RECENCY_WEIGHTS (production contract) unless forceWeights overrides (experiments).
303
+ // A query-scoped `weights` override otherwise replaces DEFAULT_WEIGHTS; absent options => exact prior behavior.
304
+ const weights = (recencyIntent && !options?.forceWeights)
305
+ ? RECENCY_WEIGHTS
306
+ : (options?.weights ?? (recencyIntent ? RECENCY_WEIGHTS : DEFAULT_WEIGHTS));
307
+ const now = options?.now ?? new Date();
284
308
 
285
309
  const scored = results.map(r => {
286
310
  const recency = recencyScore(r.modifiedAt, r.contentType, now, r.accessCount, r.lastAccessedAt);
@@ -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
  */