clawmem 0.11.0 → 0.11.3

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Inference Services
4
4
 
5
- ClawMem uses three `llama-server` instances for neural inference. By default, the `bin/clawmem` wrapper points at `localhost:8088/8089/8090`.
5
+ ClawMem uses three inference services (embedding, LLM, reranker). The **default** stack runs all three as `llama-server` instances; the **SOTA** stack swaps the reranker for a transformers sidecar (same `/v1/rerank` contract). The `bin/clawmem` wrapper points at `localhost:8088/8089/8090`.
6
6
 
7
7
  **Default (QMD native combo, any GPU or in-process):**
8
8
 
@@ -14,15 +14,15 @@ ClawMem uses three `llama-server` instances for neural inference. By default, th
14
14
 
15
15
  All three models auto-download via `node-llama-cpp` if no server is running (Metal on Apple Silicon, Vulkan where available, CPU as last resort). Fast with GPU acceleration (Metal/Vulkan); significantly slower on CPU-only.
16
16
 
17
- **SOTA upgrade (12GB+ GPU):** CC-BY-NC-4.0 — non-commercial only.
17
+ **SOTA upgrade (16GB+ GPU):** CC-BY-NC-4.0 — non-commercial only.
18
18
 
19
19
  | Service | Port | Model | VRAM | Protocol |
20
20
  |---|---|---|---|---|
21
21
  | Embedding | 8088 | zembed-1-Q4_K_M | ~4.4GB | `/v1/embeddings` |
22
22
  | LLM | 8089 | qmd-query-expansion-1.7B-q4_k_m | ~2.2GB | `/v1/chat/completions` |
23
- | Reranker | 8090 | zerank-2-Q4_K_M | ~3.3GB | `/v1/rerank` |
23
+ | Reranker | 8090 | zerank-2 seq-cls sidecar | ~9GB (bf16) | `/v1/rerank` |
24
24
 
25
- Total ~10GB VRAM. zembed-1 (2560d, 32K context, SOTA retrieval) distilled from zerank-2 via zELO. Optimal pairing.
25
+ zembed-1 (2560d, 32K context, SOTA retrieval) distilled from zerank-2 via zELO optimal pairing. The SOTA reranker is the **zerank-2 seq-cls sidecar** at `extras/rerankers/zerank-2-seq/` (transformers, bf16, ~9GB VRAM) — **not** a GGUF. The older `zerank-2-Q4_K_M` GGUF is **deprecated**: llama.cpp drops zerank's score head, so its scores are near-zero and uninformative (final ordering stays RRF-dominated). Full SOTA stack ≈ 16GB VRAM.
26
26
 
27
27
  **Remote option:** Set `CLAWMEM_EMBED_URL`, `CLAWMEM_LLM_URL`, `CLAWMEM_RERANK_URL` to the remote host. Set `CLAWMEM_NO_LOCAL_MODELS=true` to prevent surprise fallback downloads.
28
28
 
@@ -36,7 +36,7 @@ Total ~10GB VRAM. zembed-1 (2560d, 32K context, SOTA retrieval) distilled from z
36
36
  |---|---|---|---|
37
37
  | Embedding | [EmbeddingGemma-300M-Q8_0](https://huggingface.co/ggml-org/embeddinggemma-300M-GGUF) (314MB, 768d) | [zembed-1-Q4_K_M](https://huggingface.co/Abhiray/zembed-1-Q4_K_M-GGUF) (2.4GB, 2560d) | zembed-1: 32K context, SOTA retrieval. `-ub` must match `-b`. |
38
38
  | LLM | [qmd-query-expansion-1.7B-q4_k_m](https://huggingface.co/tobil/qmd-query-expansion-1.7B-gguf) (~1.1GB) | Same | QMD's Qwen3-1.7B finetune for query expansion. |
39
- | Reranker | [qwen3-reranker-0.6B-Q8_0](https://huggingface.co/ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF) (~600MB) | [zerank-2-Q4_K_M](https://huggingface.co/keisuke-miyako/zerank-2-gguf-q4_k_m) (2.4GB) | zerank-2: outperforms Cohere rerank-3.5. `-ub` must match `-b`. |
39
+ | Reranker | [qwen3-reranker-0.6B-Q8_0](https://huggingface.co/ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF) (~600MB) | [zerank-2 seq-cls sidecar](extras/rerankers/zerank-2-seq/) (bf16, ~9GB) | zerank-2: NDCG@10 ahead of Cohere rerank-3.5. Served via transformers sidecar, **not** GGUF (the GGUF drops the score head → inert). CC-BY-NC. |
40
40
 
41
41
  ### Server Setup (all three use llama-server)
42
42
 
@@ -55,15 +55,15 @@ llama-server -m qmd-query-expansion-1.7B-q4_k_m.gguf \
55
55
  llama-server -m Qwen3-Reranker-0.6B-Q8_0.gguf \
56
56
  --reranking --port 8090 --host 0.0.0.0 -ngl 99 -c 2048 --batch-size 512
57
57
 
58
- # === SOTA upgrade (12GB+ GPU) — -ub must match -b for non-causal attention ===
58
+ # === SOTA upgrade (16GB+ GPU) ===
59
59
 
60
- # Embedding
60
+ # Embedding (zembed-1) — -ub must match -b for non-causal attention
61
61
  llama-server -m zembed-1-Q4_K_M.gguf \
62
62
  --embeddings --port 8088 --host 0.0.0.0 -ngl 99 -c 8192 -b 2048 -ub 2048
63
63
 
64
- # Reranker
65
- llama-server -m zerank-2-Q4_K_M.gguf \
66
- --reranking --port 8090 --host 0.0.0.0 -ngl 99 -c 2048 -b 2048 -ub 2048
64
+ # Reranker (zerank-2) — seq-cls SIDECAR (transformers, bf16), NOT a llama-server GGUF:
65
+ # cd extras/rerankers/zerank-2-seq && docker compose run --rm convert && docker compose up -d reranker
66
+ # The zerank-2 GGUF is deprecated: llama.cpp drops its score head -> near-zero, uninformative scores.
67
67
  ```
68
68
 
69
69
  ### Verify Endpoints
@@ -546,7 +546,7 @@ User Query + optional intent hint
546
546
  → Temporal Extraction (regex date range from query: "last week", "March 2026" → WHERE modified_at BETWEEN filters)
547
547
  → BM25 Probe → Strong Signal Check (skip expansion if top hit ≥ 0.85 with gap ≥ 0.15; disabled when intent provided)
548
548
  → Query Expansion (LLM generates text variants; intent steers expansion prompt)
549
- → Parallel: BM25(original) + Vector(original) + BM25(each expanded) + Vector(each expanded)
549
+ → Parallel (typed routing): BM25(original) + Vector(original) + BM25(lex expansions) + Vector(vec/hyde expansions)
550
550
  + Temporal Proximity (date-range filtered, if temporal constraint extracted)
551
551
  + Entity Graph (conditional 1-hop entity walk from top seeds, if entity signals present)
552
552
  → Original query lists get positional 2× weight in RRF; expanded get 1×
@@ -734,7 +734,7 @@ clawmem focus clear --session-id abc123
734
734
  - **Entity resolution (v0.2.0+):** A-MEM enrichment extracts named entities via LLM, resolves to canonical forms using FTS5 + Levenshtein fuzzy matching with **type-agnostic compatibility buckets** (person, org, location stay separate; project/service/tool/concept merge freely as "tech" bucket). Quality filters reject title-as-entity, long names, template placeholders, and invalid locations. Entity edges use IDF-based specificity scoring (rare entities create edges; ubiquitous entities alone cannot). See `docs/internals/entity-resolution.md` for customization (extending type vocabulary and buckets).
735
735
  - QMD retrieval (BM25, vector, RRF, rerank, query expansion) is forked into ClawMem. Do not call standalone QMD tools.
736
736
  - SAME (composite scoring), MAGMA (intent + graph), A-MEM (self-evolving notes) layer on top of QMD substrate.
737
- - Three `llama-server` instances (embedding, LLM, reranker) on local or remote GPU. Wrapper defaults to `localhost:8088/8089/8090`.
737
+ - Three inference services (embedding, LLM, reranker) on local or remote GPU — `llama-server` for all three in the default stack; the SOTA stack serves the reranker as a transformers sidecar. Wrapper defaults to `localhost:8088/8089/8090`.
738
738
  - `CLAWMEM_NO_LOCAL_MODELS=false` (default) allows in-process LLM/reranker fallback via `node-llama-cpp`. Set `true` for remote-only setups to fail fast on unreachable endpoints.
739
739
  - Consolidation worker (`CLAWMEM_ENABLE_CONSOLIDATION=true`) backfills unenriched docs with A-MEM notes + links. Only runs if the MCP process stays alive long enough to tick (every 5min).
740
740
  - Beads integration: `syncBeadsIssues()` queries `bd` CLI (Dolt backend, v0.58.0+) for live issue data, creates markdown docs in `beads` collection, maps all dependency edge types into `memory_relations`, and triggers A-MEM enrichment for new docs. Watcher auto-triggers on `.beads/` directory changes; `beads_sync` MCP tool for manual sync. Requires `bd` binary on PATH or at `~/go/bin/bd`.
package/CLAUDE.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Inference Services
4
4
 
5
- ClawMem uses three `llama-server` instances for neural inference. By default, the `bin/clawmem` wrapper points at `localhost:8088/8089/8090`.
5
+ ClawMem uses three inference services (embedding, LLM, reranker). The **default** stack runs all three as `llama-server` instances; the **SOTA** stack swaps the reranker for a transformers sidecar (same `/v1/rerank` contract). The `bin/clawmem` wrapper points at `localhost:8088/8089/8090`.
6
6
 
7
7
  **Default (QMD native combo, any GPU or in-process):**
8
8
 
@@ -14,15 +14,15 @@ ClawMem uses three `llama-server` instances for neural inference. By default, th
14
14
 
15
15
  All three models auto-download via `node-llama-cpp` if no server is running (Metal on Apple Silicon, Vulkan where available, CPU as last resort). Fast with GPU acceleration (Metal/Vulkan); significantly slower on CPU-only.
16
16
 
17
- **SOTA upgrade (12GB+ GPU):** CC-BY-NC-4.0 — non-commercial only.
17
+ **SOTA upgrade (16GB+ GPU):** CC-BY-NC-4.0 — non-commercial only.
18
18
 
19
19
  | Service | Port | Model | VRAM | Protocol |
20
20
  |---|---|---|---|---|
21
21
  | Embedding | 8088 | zembed-1-Q4_K_M | ~4.4GB | `/v1/embeddings` |
22
22
  | LLM | 8089 | qmd-query-expansion-1.7B-q4_k_m | ~2.2GB | `/v1/chat/completions` |
23
- | Reranker | 8090 | zerank-2-Q4_K_M | ~3.3GB | `/v1/rerank` |
23
+ | Reranker | 8090 | zerank-2 seq-cls sidecar | ~9GB (bf16) | `/v1/rerank` |
24
24
 
25
- Total ~10GB VRAM. zembed-1 (2560d, 32K context, SOTA retrieval) distilled from zerank-2 via zELO. Optimal pairing.
25
+ zembed-1 (2560d, 32K context, SOTA retrieval) distilled from zerank-2 via zELO optimal pairing. The SOTA reranker is the **zerank-2 seq-cls sidecar** at `extras/rerankers/zerank-2-seq/` (transformers, bf16, ~9GB VRAM) — **not** a GGUF. The older `zerank-2-Q4_K_M` GGUF is **deprecated**: llama.cpp drops zerank's score head, so its scores are near-zero and uninformative (final ordering stays RRF-dominated). Full SOTA stack ≈ 16GB VRAM.
26
26
 
27
27
  **Remote option:** Set `CLAWMEM_EMBED_URL`, `CLAWMEM_LLM_URL`, `CLAWMEM_RERANK_URL` to the remote host. Set `CLAWMEM_NO_LOCAL_MODELS=true` to prevent surprise fallback downloads.
28
28
 
@@ -36,7 +36,7 @@ Total ~10GB VRAM. zembed-1 (2560d, 32K context, SOTA retrieval) distilled from z
36
36
  |---|---|---|---|
37
37
  | Embedding | [EmbeddingGemma-300M-Q8_0](https://huggingface.co/ggml-org/embeddinggemma-300M-GGUF) (314MB, 768d) | [zembed-1-Q4_K_M](https://huggingface.co/Abhiray/zembed-1-Q4_K_M-GGUF) (2.4GB, 2560d) | zembed-1: 32K context, SOTA retrieval. `-ub` must match `-b`. |
38
38
  | LLM | [qmd-query-expansion-1.7B-q4_k_m](https://huggingface.co/tobil/qmd-query-expansion-1.7B-gguf) (~1.1GB) | Same | QMD's Qwen3-1.7B finetune for query expansion. |
39
- | Reranker | [qwen3-reranker-0.6B-Q8_0](https://huggingface.co/ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF) (~600MB) | [zerank-2-Q4_K_M](https://huggingface.co/keisuke-miyako/zerank-2-gguf-q4_k_m) (2.4GB) | zerank-2: outperforms Cohere rerank-3.5. `-ub` must match `-b`. |
39
+ | Reranker | [qwen3-reranker-0.6B-Q8_0](https://huggingface.co/ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF) (~600MB) | [zerank-2 seq-cls sidecar](extras/rerankers/zerank-2-seq/) (bf16, ~9GB) | zerank-2: NDCG@10 ahead of Cohere rerank-3.5. Served via transformers sidecar, **not** GGUF (the GGUF drops the score head → inert). CC-BY-NC. |
40
40
 
41
41
  ### Server Setup (all three use llama-server)
42
42
 
@@ -55,15 +55,15 @@ llama-server -m qmd-query-expansion-1.7B-q4_k_m.gguf \
55
55
  llama-server -m Qwen3-Reranker-0.6B-Q8_0.gguf \
56
56
  --reranking --port 8090 --host 0.0.0.0 -ngl 99 -c 2048 --batch-size 512
57
57
 
58
- # === SOTA upgrade (12GB+ GPU) — -ub must match -b for non-causal attention ===
58
+ # === SOTA upgrade (16GB+ GPU) ===
59
59
 
60
- # Embedding
60
+ # Embedding (zembed-1) — -ub must match -b for non-causal attention
61
61
  llama-server -m zembed-1-Q4_K_M.gguf \
62
62
  --embeddings --port 8088 --host 0.0.0.0 -ngl 99 -c 8192 -b 2048 -ub 2048
63
63
 
64
- # Reranker
65
- llama-server -m zerank-2-Q4_K_M.gguf \
66
- --reranking --port 8090 --host 0.0.0.0 -ngl 99 -c 2048 -b 2048 -ub 2048
64
+ # Reranker (zerank-2) — seq-cls SIDECAR (transformers, bf16), NOT a llama-server GGUF:
65
+ # cd extras/rerankers/zerank-2-seq && docker compose run --rm convert && docker compose up -d reranker
66
+ # The zerank-2 GGUF is deprecated: llama.cpp drops its score head -> near-zero, uninformative scores.
67
67
  ```
68
68
 
69
69
  ### Verify Endpoints
@@ -546,7 +546,7 @@ User Query + optional intent hint
546
546
  → Temporal Extraction (regex date range from query: "last week", "March 2026" → WHERE modified_at BETWEEN filters)
547
547
  → BM25 Probe → Strong Signal Check (skip expansion if top hit ≥ 0.85 with gap ≥ 0.15; disabled when intent provided)
548
548
  → Query Expansion (LLM generates text variants; intent steers expansion prompt)
549
- → Parallel: BM25(original) + Vector(original) + BM25(each expanded) + Vector(each expanded)
549
+ → Parallel (typed routing): BM25(original) + Vector(original) + BM25(lex expansions) + Vector(vec/hyde expansions)
550
550
  + Temporal Proximity (date-range filtered, if temporal constraint extracted)
551
551
  + Entity Graph (conditional 1-hop entity walk from top seeds, if entity signals present)
552
552
  → Original query lists get positional 2× weight in RRF; expanded get 1×
@@ -734,7 +734,7 @@ clawmem focus clear --session-id abc123
734
734
  - **Entity resolution (v0.2.0+):** A-MEM enrichment extracts named entities via LLM, resolves to canonical forms using FTS5 + Levenshtein fuzzy matching with **type-agnostic compatibility buckets** (person, org, location stay separate; project/service/tool/concept merge freely as "tech" bucket). Quality filters reject title-as-entity, long names, template placeholders, and invalid locations. Entity edges use IDF-based specificity scoring (rare entities create edges; ubiquitous entities alone cannot). See `docs/internals/entity-resolution.md` for customization (extending type vocabulary and buckets).
735
735
  - QMD retrieval (BM25, vector, RRF, rerank, query expansion) is forked into ClawMem. Do not call standalone QMD tools.
736
736
  - SAME (composite scoring), MAGMA (intent + graph), A-MEM (self-evolving notes) layer on top of QMD substrate.
737
- - Three `llama-server` instances (embedding, LLM, reranker) on local or remote GPU. Wrapper defaults to `localhost:8088/8089/8090`.
737
+ - Three inference services (embedding, LLM, reranker) on local or remote GPU — `llama-server` for all three in the default stack; the SOTA stack serves the reranker as a transformers sidecar. Wrapper defaults to `localhost:8088/8089/8090`.
738
738
  - `CLAWMEM_NO_LOCAL_MODELS=false` (default) allows in-process LLM/reranker fallback via `node-llama-cpp`. Set `true` for remote-only setups to fail fast on unreachable endpoints.
739
739
  - Consolidation worker (`CLAWMEM_ENABLE_CONSOLIDATION=true`) backfills unenriched docs with A-MEM notes + links. Only runs if the MCP process stays alive long enough to tick (every 5min).
740
740
  - Beads integration: `syncBeadsIssues()` queries `bd` CLI (Dolt backend, v0.58.0+) for live issue data, creates markdown docs in `beads` collection, maps all dependency edge types into `memory_relations`, and triggers A-MEM enrichment for new docs. Watcher auto-triggers on `.beads/` directory changes; `beads_sync` MCP tool for manual sync. Requires `bd` binary on PATH or at `~/go/bin/bd`.
package/README.md CHANGED
@@ -114,8 +114,8 @@ After installing, here's the full journey from zero to working memory:
114
114
  | Step | What | How | Details |
115
115
  |------|------|-----|---------|
116
116
  | **1. Bootstrap** | Create a vault, index your first collection, embed, install hooks and MCP | `clawmem bootstrap ~/notes --name notes` | One command does it all. Or run each step manually (see below). |
117
- | **2. Choose models** | Pick embedding + reranker models based on your hardware | 12GB+ VRAM → SOTA stack (zembed-1 + zerank-2). Less → QMD native combo. No GPU → cloud embedding or CPU fallback. | [GPU Services](#gpu-services) |
118
- | **3. Download models** | Get the GGUF files for your chosen stack | `wget` from HuggingFace, or let `node-llama-cpp` auto-download the QMD native models on first use | [Embedding](#embedding), [LLM Server](#llm-server), [Reranker Server](#reranker-server) |
117
+ | **2. Choose models** | Pick embedding + reranker models based on your hardware | 16GB+ VRAM → SOTA stack (zembed-1 + zerank-2 sidecar). Less → QMD native combo. No GPU → cloud embedding or CPU fallback. | [GPU Services](#gpu-services) |
118
+ | **3. Download models** | Get the model files for your chosen stack (GGUFs for embedding/LLM/default-reranker; the zerank-2 SOTA reranker builds its own sidecar artifact) | `wget` from HuggingFace, let `node-llama-cpp` auto-download the QMD native models, or run the sidecar recipe | [Embedding](#embedding), [LLM Server](#llm-server), [Reranker Server](#reranker-server) |
119
119
  | **4. Start services** | Run GPU servers (if using dedicated GPU) and background services. Optionally enable the v0.8.2 background maintenance workers in the watcher unit so consolidation + deductive synthesis run automatically. | `llama-server` for each model. systemd units for watcher + embed timer. Drop-in for the watcher to enable workers + tune intervals + set the quiet window. | [systemd services](docs/guides/systemd-services.md), [background workers](docs/guides/systemd-services.md#background-maintenance-workers-v082) |
120
120
  | **5. Decide what to index** | Add collections for your projects, notes, research, and domain docs | `clawmem collection add ~/project --name project` | The more relevant markdown you index, the better retrieval works. See [building a rich context field](docs/introduction.md#building-a-rich-context-field). |
121
121
  | **6. Connect your agent** | Hook into Claude Code, OpenClaw, Hermes, or any MCP client | `clawmem setup hooks && clawmem setup mcp` for Claude Code. `clawmem setup openclaw` for OpenClaw. Copy `src/hermes/` to Hermes plugins for Hermes. | [Integration](#integration) |
@@ -305,17 +305,19 @@ vault_sync(vault="work", content_root="~/work/docs")
305
305
 
306
306
  ### GPU Services
307
307
 
308
- ClawMem uses three `llama-server` (llama.cpp) instances for neural inference. All three have in-process fallbacks via `node-llama-cpp` (auto-downloads on first use), so ClawMem works without a dedicated GPU. `node-llama-cpp` auto-detects the best available backend — Metal on Apple Silicon, Vulkan where available, CPU as last resort. With GPU acceleration (Metal/Vulkan), in-process inference is fast for these small models (0.3B–1.7B); on CPU-only systems it is significantly slower. For production use, run the servers via [systemd services](docs/guides/systemd-services.md) to prevent silent fallback.
308
+ ClawMem uses three inference services — embedding, LLM, and reranker. In the **default** stack all three run as `llama-server` (llama.cpp) instances, each with an in-process `node-llama-cpp` fallback (auto-downloads on first use), so ClawMem works without a dedicated GPU. (The **SOTA** reranker is the exception — it is a transformers sidecar, see [Reranker Server](#reranker-server).) `node-llama-cpp` auto-detects the best available backend — Metal on Apple Silicon, Vulkan where available, CPU as last resort. With GPU acceleration (Metal/Vulkan), in-process inference is fast for these small models (0.3B–1.7B); on CPU-only systems it is significantly slower. For production use, run the servers via [systemd services](docs/guides/systemd-services.md) to prevent silent fallback.
309
309
 
310
- **GPU with VRAM to spare (12GB+, recommended):** ZeroEntropy's distillation-paired stack delivers best retrieval quality — total ~10GB VRAM.
310
+ **GPU with VRAM to spare (16GB+, recommended):** ZeroEntropy's distillation-paired stack delivers best retrieval quality — total ~16GB VRAM.
311
311
 
312
312
  | Service | Port | Model | VRAM | Purpose |
313
313
  |---|---|---|---|---|
314
314
  | Embedding | 8088 | [zembed-1-Q4_K_M](https://huggingface.co/Abhiray/zembed-1-Q4_K_M-GGUF) | ~4.4GB | SOTA embedding (2560d, 32K context). Distilled from zerank-2 via zELO. |
315
315
  | LLM | 8089 | [qmd-query-expansion-1.7B-q4_k_m](https://huggingface.co/tobil/qmd-query-expansion-1.7B-gguf) | ~2.2GB | Intent classification, query expansion, A-MEM |
316
- | Reranker | 8090 | [zerank-2-Q4_K_M](https://huggingface.co/keisuke-miyako/zerank-2-gguf-q4_k_m) | ~3.3GB | SOTA reranker. Outperforms Cohere rerank-3.5. Optimal pairing with zembed-1. |
316
+ | Reranker | 8090 | [zerank-2 seq-cls sidecar](extras/rerankers/zerank-2-seq/) | ~9GB (bf16) | SOTA reranker. NDCG@10 ahead of Cohere rerank-3.5. Optimal pairing with zembed-1. |
317
317
 
318
- **Important:** zembed-1 and zerank-2 use non-causal attention`-ub` must equal `-b` on llama-server (e.g. `-b 2048 -ub 2048`). See [Reranker Server](#reranker-server) for details.
318
+ **The SOTA reranker is a sidecar, not a GGUF.** It is served by the [zerank-2 seq-cls sidecar](extras/rerankers/zerank-2-seq/) (transformers, bf16). The previously-listed `zerank-2-Q4_K_M` GGUF is **deprecated** llama.cpp's converter drops zerank's score head, so under `--reranking` it produces near-zero, uninformative scores (final ordering stays RRF-dominated). See the [sidecar README](extras/rerankers/zerank-2-seq/) and [Reranker Server](#reranker-server).
319
+
320
+ **Important:** zembed-1 uses non-causal attention — `-ub` must equal `-b` on llama-server (e.g. `-b 2048 -ub 2048`). See [Embedding](#embedding) for details.
319
321
 
320
322
  **License:** zembed-1 and zerank-2 are released under **CC-BY-NC-4.0** — non-commercial only. The QMD native models below have no such restriction.
321
323
 
@@ -466,15 +468,15 @@ Cross-encoder reranking for `query` and `intent_search` pipelines on port 8090.
466
468
 
467
469
  Scores each candidate against the original query (cross-encoder architecture). `query` pipeline: 4000 char context per doc (deep reranking); `intent_search`: 200 char context per doc (fast reranking).
468
470
 
469
- **GPU with VRAM to spare (recommended):** [zerank-2-Q4_K_M](https://huggingface.co/keisuke-miyako/zerank-2-gguf-q4_k_m) (2.4GB, ~3.3GB VRAM). Outperforms Cohere rerank-3.5 and Gemini 2.5 Flash. Optimal pairing with zembed-1 (same distillation architecture via zELO). **CC-BY-NC-4.0** — non-commercial only.
471
+ **GPU with VRAM to spare (recommended):** the **[zerank-2 seq-cls sidecar](extras/rerankers/zerank-2-seq/)** (bf16, ~9GB VRAM). zerank-2's NDCG@10 is ahead of Cohere rerank-3.5 and Gemini 2.5 Flash. Optimal pairing with zembed-1 (same distillation architecture via zELO). **CC-BY-NC-4.0** — non-commercial only.
470
472
 
471
- ```bash
472
- wget https://huggingface.co/keisuke-miyako/zerank-2-gguf-q4_k_m/resolve/main/zerank-2-Q4_k_m.gguf
473
+ zerank-2 is **not** servable as a llama.cpp GGUF — its CrossEncoder/LogitScore head is dropped by the GGUF converter (the model becomes a headless causal LM that produces near-zero, uninformative scores under `--reranking`, leaving final ordering RRF-dominated). The sidecar serves the real head via transformers, with a reproducible correctness gate:
473
474
 
474
- # -ub must match -b for non-causal attention
475
- llama-server -m zerank-2-Q4_K_M.gguf \
476
- --reranking --port 8090 --host 0.0.0.0 \
477
- -ngl 99 -c 2048 -b 2048 -ub 2048
475
+ ```bash
476
+ cd extras/rerankers/zerank-2-seq
477
+ docker compose build
478
+ HF_TOKEN=hf_xxx docker compose run --rm convert # download + convert + verify (all gates must pass)
479
+ docker compose up -d reranker # serves /v1/rerank on :8090
478
480
  ```
479
481
 
480
482
  **CPU / GPU without VRAM to spare:** [qwen3-reranker-0.6B-Q8_0](https://huggingface.co/ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF) (~600MB, ~1.3GB VRAM). The QMD native reranker — auto-downloaded by `node-llama-cpp` if no server is running.
@@ -487,7 +489,7 @@ llama-server -m Qwen3-Reranker-0.6B-Q8_0.gguf \
487
489
  -ngl 99 -c 2048 --batch-size 512
488
490
  ```
489
491
 
490
- **Note:** zerank-2 and zembed-1 use non-causal attention — `-ub` (ubatch) must equal `-b` (batch). Omitting `-ub` or setting it lower causes assertion crashes. qwen3-reranker-0.6B does not have this requirement. See [llama.cpp#12836](https://github.com/ggml-org/llama.cpp/issues/12836).
492
+ **Note:** zembed-1 (embedding) uses non-causal attention — `-ub` (ubatch) must equal `-b` (batch); omitting `-ub` or setting it lower causes assertion crashes. The qwen3-reranker-0.6B GGUF does not have this requirement, and the zerank-2 sidecar is served via transformers (no llama.cpp batch flags). See [llama.cpp#12836](https://github.com/ggml-org/llama.cpp/issues/12836).
491
493
 
492
494
  ### MCP Server
493
495
 
@@ -1151,7 +1153,7 @@ clawmem serve --port 7438 &
1151
1153
 
1152
1154
  ## Deployment
1153
1155
 
1154
- Three-tier retrieval architecture: infrastructure (watcher + embed timer) → hooks (~90%) → agent MCP (~10%). Works out of the box without a dedicated GPU (all models auto-download via `node-llama-cpp`, uses Metal on Apple Silicon). For best performance, run three `llama-server` instances — see [GPU Services](#gpu-services) for model tiers (SOTA vs QMD native) and [Cloud Embedding](#option-c-cloud-embedding-api) for cloud embedding alternatives.
1156
+ Three-tier retrieval architecture: infrastructure (watcher + embed timer) → hooks (~90%) → agent MCP (~10%). Works out of the box without a dedicated GPU (all models auto-download via `node-llama-cpp`, uses Metal on Apple Silicon). For best performance, run the inference services on GPU (three `llama-server` instances in the default stack; the SOTA reranker is a transformers sidecar) — see [GPU Services](#gpu-services) for model tiers (SOTA vs QMD native) and [Cloud Embedding](#option-c-cloud-embedding-api) for cloud embedding alternatives.
1155
1157
 
1156
1158
  Key services: `clawmem-watcher` (auto-index on file change + beads sync), `clawmem-embed` timer (daily embedding sweep), 7 Claude Code hooks installed by default (context injection, curator nudge, compaction support, decision extraction, handoffs, feedback). Optional `clawmem-curator` agent for on-demand lifecycle triage, retrieval health checks, and maintenance (`clawmem setup curator`).
1157
1159
 
package/SKILL.md CHANGED
@@ -18,7 +18,7 @@ Two tiers: **hooks** handle automatic context flow (surfacing, extraction, compa
18
18
 
19
19
  ## Inference Services
20
20
 
21
- Three `llama-server` instances for neural inference. The `bin/clawmem` wrapper defaults to `localhost:8088/8089/8090`.
21
+ Three inference services (embedding, LLM, reranker). The **default** stack runs all three as `llama-server` instances; the **SOTA** stack serves the reranker as a transformers sidecar (same `/v1/rerank` contract). The `bin/clawmem` wrapper defaults to `localhost:8088/8089/8090`.
22
22
 
23
23
  **Default (QMD native combo, any GPU or in-process):**
24
24
 
@@ -30,7 +30,7 @@ Three `llama-server` instances for neural inference. The `bin/clawmem` wrapper d
30
30
 
31
31
  All three models auto-download via `node-llama-cpp` if no server is running (Metal on Apple Silicon, Vulkan where available, CPU as last resort). Fast with GPU acceleration (Metal/Vulkan); significantly slower on CPU-only.
32
32
 
33
- **SOTA upgrade (12GB+ GPU):** zembed-1-Q4_K_M (embedding, 2560d, ~4.4GB) + zerank-2-Q4_K_M (reranker, ~3.3GB). Total ~10GB with LLM. Distillation-paired via zELO. `-ub` must match `-b` for both. **CC-BY-NC-4.0** — non-commercial only.
33
+ **SOTA upgrade (16GB+ GPU):** zembed-1-Q4_K_M (embedding, 2560d, ~4.4GB) + the **zerank-2 seq-cls sidecar** (reranker, bf16 ~9GB — see `extras/rerankers/zerank-2-seq/`). Total ~16GB with LLM. Distillation-paired via zELO. zembed-1's `-ub` must match `-b`. **CC-BY-NC-4.0** — non-commercial only. The older `zerank-2-Q4_K_M` GGUF reranker is **deprecated** (llama.cpp drops zerank's score head → inert reranking); use the sidecar.
34
34
 
35
35
  **Remote option:** Set `CLAWMEM_EMBED_URL`, `CLAWMEM_LLM_URL`, `CLAWMEM_RERANK_URL` to remote host. Set `CLAWMEM_NO_LOCAL_MODELS=true` to prevent fallback downloads.
36
36
 
@@ -53,15 +53,14 @@ llama-server -m qmd-query-expansion-1.7B-q4_k_m.gguf \
53
53
  llama-server -m Qwen3-Reranker-0.6B-Q8_0.gguf \
54
54
  --reranking --port 8090 --host 0.0.0.0 -ngl 99 -c 2048 --batch-size 512
55
55
 
56
- # === SOTA upgrade (12GB+ GPU) — -ub must match -b ===
56
+ # === SOTA upgrade (16GB+ GPU) ===
57
57
 
58
- # Embedding (zembed-1)
58
+ # Embedding (zembed-1) — -ub must match -b
59
59
  llama-server -m zembed-1-Q4_K_M.gguf \
60
60
  --embeddings --port 8088 --host 0.0.0.0 -ngl 99 -c 8192 -b 2048 -ub 2048
61
61
 
62
- # Reranker (zerank-2)
63
- llama-server -m zerank-2-Q4_K_M.gguf \
64
- --reranking --port 8090 --host 0.0.0.0 -ngl 99 -c 2048 -b 2048 -ub 2048
62
+ # Reranker (zerank-2) — seq-cls SIDECAR (transformers, bf16), NOT GGUF:
63
+ # see extras/rerankers/zerank-2-seq/ (the zerank-2 GGUF is deprecated — drops the score head)
65
64
  ```
66
65
 
67
66
  ### Verify Endpoints
@@ -791,7 +790,7 @@ clawmem focus clear --session-id abc123
791
790
 
792
791
  - QMD retrieval (BM25, vector, RRF, rerank, query expansion) is forked into ClawMem. Do not call standalone QMD tools.
793
792
  - SAME (composite scoring), MAGMA (intent + graph), A-MEM (self-evolving notes) layer on top of QMD substrate.
794
- - Three `llama-server` instances on local or remote GPU. Wrapper defaults to `localhost:8088/8089/8090`.
793
+ - Three inference services on local or remote GPU — `llama-server` for all three in the default stack; the SOTA stack serves the reranker as a transformers sidecar. Wrapper defaults to `localhost:8088/8089/8090`.
795
794
  - `CLAWMEM_NO_LOCAL_MODELS=false` (default) allows in-process fallback. Set `true` for remote-only to fail fast.
796
795
  - Consolidation worker (`CLAWMEM_ENABLE_CONSOLIDATION=true`) backfills unenriched docs and runs Phase 2 merge / Phase 3 deductive synthesis. **v0.8.2:** hosted by either `clawmem watch` (long-lived, canonical) or `clawmem mcp` (per-session fallback); every tick acquires a `light-consolidation` `worker_leases` row before doing work, so dual-hosting against the same vault is safe.
797
796
  - Beads integration: `syncBeadsIssues()` queries `bd` CLI (Dolt backend, v0.58.0+), creates markdown docs, maps dependency edges into `memory_relations`. Watcher auto-triggers on `.beads/` changes; `beads_sync` MCP for manual sync.
@@ -0,0 +1,26 @@
1
+ # ClawMem zerank-2 seq-cls reranker sidecar.
2
+ # Serves zeroentropy/zerank-2-reranker as a faithful Qwen3ForSequenceClassification cross-encoder
3
+ # (the llama.cpp GGUF route drops zerank's score head; this serves the real one via transformers).
4
+ #
5
+ # LICENSE: this image bundles only the MIT recipe code. zerank-2's WEIGHTS are CC-BY-NC-4.0
6
+ # (non-commercial) and are downloaded at convert time for YOUR own use — never redistributed here.
7
+ FROM pytorch/pytorch:2.6.0-cuda12.4-cudnn9-runtime
8
+
9
+ RUN pip install --no-cache-dir \
10
+ "transformers>=4.51" \
11
+ "huggingface_hub>=0.24" \
12
+ "uvicorn[standard]" \
13
+ fastapi \
14
+ safetensors \
15
+ accelerate
16
+
17
+ WORKDIR /app
18
+ COPY zr_common.py build_and_verify.py server.py /app/
19
+
20
+ ENV ZR_OUT=/models/zerank-2-seq \
21
+ ZR_PORT=8090 \
22
+ ZR_TEMP=5.0 \
23
+ ZR_MAXLEN=8192
24
+
25
+ EXPOSE 8090
26
+ CMD ["python3", "server.py"]
@@ -0,0 +1,107 @@
1
+ # zerank-2 seq-cls reranker sidecar (SOTA, non-commercial)
2
+
3
+ A drop-in `/v1/rerank` sidecar that serves **ZeroEntropy [zerank-2](https://huggingface.co/zeroentropy/zerank-2-reranker)** — a state-of-the-art cross-encoder (NDCG@10 ≈ 0.671, ahead of Cohere rerank-3.5 and Gemini-2.5-Flash listwise) — **faithfully**, via transformers, with a reproducible correctness gate.
4
+
5
+ Point ClawMem at it and nothing else changes:
6
+
7
+ ```bash
8
+ export CLAWMEM_RERANK_URL=http://<this-host>:8090
9
+ ```
10
+
11
+ ClawMem's `query` and `intent_search` pipelines call `/v1/rerank` on that URL exactly as before.
12
+
13
+ ---
14
+
15
+ ## Why this exists (and why the GGUF is deprecated)
16
+
17
+ The previously-recommended `zerank-2-Q4_K_M.gguf` reranker is **broken** and is deprecated. zerank-2 is a `Qwen3ForCausalLM` that scores a `(query, document)` pair on the logit of a single relevance token (`"Yes"`, id `9454`) via a sentence-transformers `LogitScore` head. llama.cpp's `convert_hf_to_gguf.py` only synthesizes a rerank head when it finds the literal string `# Qwen3-Reranker` in the model card — zerank-2's card lacks it, so the previously-recommended GGUF (and anything built by the current/standard llama.cpp converter path) is a **headless causal LM**. Served under `--reranking` it produces near-zero, uninformative scores → reranking degrades to an inert RRF-dominated passthrough, silently.
18
+
19
+ This recipe sidesteps GGUF entirely. Because zerank-2 uses **tied embeddings**, the relevance logit `hidden · embed_tokens.weight[9454]` is reproduced **exactly** by a standard `Qwen3ForSequenceClassification` whose `num_labels=1` score head is that one embedding row. We convert to that form, **prove** the conversion is bit-exact, and serve it with the model's real chat template and `sigmoid(logit/5)` calibration.
20
+
21
+ > **License:** zerank-2 is **CC-BY-NC-4.0 (non-commercial)**. This recipe is MIT code; it **never bundles the weights** — `build_and_verify.py` downloads them for *your own* non-commercial use. ClawMem's default reranker stays the permissively-licensed `qwen3-reranker-0.6B`; this is an opt-in upgrade.
22
+
23
+ ---
24
+
25
+ ## Requirements
26
+
27
+ - An NVIDIA GPU with **~9 GiB free VRAM** (bf16) for the reranker. A 12 GB card is comfortable for the sidecar **alone**; budget **16 GB+** for the full co-located SOTA stack (embedding + LLM + reranker).
28
+ - **NVIDIA Container Toolkit** (for `--gpus`/`docker compose` GPU access).
29
+ - A Hugging Face account that has **accepted the zerank-2 license**, and an `HF_TOKEN` for the one-off convert step.
30
+
31
+ ---
32
+
33
+ ## Quick start (Docker Compose)
34
+
35
+ ```bash
36
+ cd extras/rerankers/zerank-2-seq
37
+ docker compose build
38
+
39
+ # One-off: download + convert + run ALL correctness gates into ./models/zerank-2-seq
40
+ HF_TOKEN=hf_xxx docker compose run --rm convert
41
+
42
+ # Serve on :8090 (boot-persistent)
43
+ docker compose up -d reranker
44
+
45
+ # Verify
46
+ curl -s localhost:8090/health
47
+ curl -s -X POST localhost:8090/v1/rerank -H 'Content-Type: application/json' \
48
+ -d '{"query":"capital of France","documents":["Paris is the capital of France.","Bananas grow in the tropics."]}'
49
+ # -> relevant ~0.96, irrelevant ~0.08
50
+ ```
51
+
52
+ Then set `CLAWMEM_RERANK_URL=http://<this-host>:8090` for ClawMem (see the main README's *GPU Services*).
53
+
54
+ The convert step **refuses to finish unless every gate passes** (`RESULT PASS`, exit 0). If a gate fails it exits non-zero and does not leave a half-baked model serving.
55
+
56
+ ---
57
+
58
+ ## What the gate proves
59
+
60
+ `build_and_verify.py` is the correctness proof — it converts, then asserts:
61
+
62
+ | Gate | Asserts |
63
+ |---|---|
64
+ | **GATE 1** | the score-head row equals `lm_head[9454]` in fp32, saved as bf16 (no silent fp16 downcast) |
65
+ | **GATE 1.5** | the served tokenizer == the source tokenizer (formatted strings + token ids + `encode_pair` through truncation); the transformers `fix_mistral_regex` warning is benign |
66
+ | **GATE-AP** | the assistant-generation prefix survives even a near-`MAXLEN` input (truncating it would move the scored position) |
67
+ | **GATE 2** | the seq-cls logit equals the causal model's token-9454 logit **bit-exactly** over the real served path, incl. batched right-padded pooling and empty/whitespace-doc edges |
68
+
69
+ It runs against the source model, so it verifies **any** copy of the weights — see the two source paths below.
70
+
71
+ ---
72
+
73
+ ## Two ways to get the weights
74
+
75
+ 1. **Reproduce (trustless, default).** `build_and_verify.py` downloads `zeroentropy/zerank-2-reranker` and performs the conversion itself, then gates it. You trust only ZeroEntropy's official weights + this MIT code.
76
+ 2. **Pull a pre-converted upload, then verify it.** A community conversion exists at [`baseten-admin/zerank-2-reranker-seq`](https://huggingface.co/baseten-admin/zerank-2-reranker-seq). Download it into `./models/zerank-2-seq`, then run the gate in **verify-only** mode — it skips conversion but still downloads the official `ZR_SRC` to prove the seq-cls logits match: `ZR_VERIFY_ONLY=1 HF_TOKEN=hf_xxx docker compose run --rm convert`. The gate verifies whatever you point `ZR_OUT` at, and **fails a bad upload** (e.g. one converted on the wrong token).
77
+
78
+ ---
79
+
80
+ ## How it works
81
+
82
+ - **Conversion** (`build_and_verify.py`): copy `embed_tokens.weight[9454]` into a `Linear(hidden, 1, bias=False)` score head of `Qwen3ForSequenceClassification(num_labels=1)`, in **bf16**. Tied embeddings make this logit identical to the native causal score by construction.
83
+ - **Serving** (`server.py`): `batch=1` (deterministic, no padding drift), applies zerank's chat template (`query`→system, `document`→user, assistant prefix), returns `sigmoid(logit/5)` — the native calibration.
84
+ - **Shared encoder** (`zr_common.py`): the gate and the server import the *same* `encode_pair`, so served scores == gated scores by construction, and the assistant prefix is never truncated.
85
+
86
+ ---
87
+
88
+ ## Configuration
89
+
90
+ | Env | Default | Meaning |
91
+ |---|---|---|
92
+ | `ZR_SRC` | `zeroentropy/zerank-2-reranker` | source model to convert |
93
+ | `ZR_OUT` | `/models/zerank-2-seq` | converted seq-cls model path (mounted volume) |
94
+ | `ZR_PORT` | `8090` | sidecar port |
95
+ | `ZR_TEMP` | `5.0` | calibration temperature → `sigmoid(logit/ZR_TEMP)` |
96
+ | `ZR_MAXLEN` | `8192` | max tokens per pair (doc tail truncated to fit; prefix preserved) |
97
+ | `HF_TOKEN` | — | required for the convert download (gated model) |
98
+
99
+ ---
100
+
101
+ ## Rollback
102
+
103
+ This sidecar only occupies `:8090` and speaks the standard contract, so reverting is just pointing `CLAWMEM_RERANK_URL` back at your previous reranker (e.g. a `qwen3-reranker-0.6B` `llama-server`) and stopping the container:
104
+
105
+ ```bash
106
+ docker compose down
107
+ ```
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env python3
2
+ """Reproduce zerank-2 -> Qwen3ForSequenceClassification(num_labels=1), bf16, token 9454, then GATE.
3
+ GATE1 : fp32 weight-equality (score row == lm_head[9454], bf16 preserved).
4
+ GATE1.5 : served-tokenizer path identity -> OUT tok == SRC tok: (a) formatted-string + ids,
5
+ (b) encode_pair(SRC)==encode_pair(OUT) INCLUDING the truncation path,
6
+ (c) fix_mistral_regex default-vs-flag token-id identity (persisted).
7
+ GATE-AP : assistant-prefix preserved under near-MAXLEN via encode_pair.
8
+ GATE2 : runtime FIDELITY (seq-cls logit == causal token-9454 logit) over the EXACT served path
9
+ (OUT tokenizer + encode_pair, batch=1) + a batched right-padded pooling check + edge cases.
10
+ Exit 0 only if every gate passes. Runs in the provided Dockerfile image (torch + transformers + CUDA)."""
11
+ import os, sys, gc, math
12
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
13
+ import torch
14
+ from transformers import AutoModelForCausalLM, AutoModelForSequenceClassification, AutoTokenizer
15
+ from zr_common import format_pair, encode_pair, ASSISTANT_PREFIX
16
+
17
+ SRC = os.environ.get("ZR_SRC", "zeroentropy/zerank-2-reranker")
18
+ OUT = os.environ.get("ZR_OUT", "/models/zerank-2-seq")
19
+ TOKEN_ID = 9454
20
+ TEMP = 5.0
21
+ MAXLEN = int(os.environ.get("ZR_MAXLEN", "8192"))
22
+ DEV = "cuda" if torch.cuda.is_available() else "cpu"
23
+ VERIFY_ONLY = os.environ.get("ZR_VERIFY_ONLY", "").lower() in ("1", "true", "yes") # gate an existing OUT (e.g. a pulled upload) without re-converting
24
+ def log(*a): print("[build]", *a, flush=True)
25
+
26
+ # ---- Phase A: convert (CPU, bf16, explicit token 9454) — skipped under ZR_VERIFY_ONLY ----
27
+ if VERIFY_ONLY:
28
+ if not os.path.isdir(OUT):
29
+ log(f"ZR_VERIFY_ONLY set but {OUT} does not exist — nothing to verify"); sys.exit(2)
30
+ log(f"ZR_VERIFY_ONLY: skipping conversion; gating the existing model at {OUT} against {SRC}")
31
+ else:
32
+ log(f"loading causal {SRC} (bf16, cpu)")
33
+ causal = AutoModelForCausalLM.from_pretrained(SRC, torch_dtype=torch.bfloat16)
34
+ assert causal.lm_head.bias is None
35
+ assert getattr(causal.config, "tie_word_embeddings", False)
36
+ hidden = causal.lm_head.in_features
37
+ row = causal.lm_head.weight[TOKEN_ID].detach().clone()
38
+ log(f"extracted lm_head row {TOKEN_ID}: shape={tuple(row.shape)} dtype={row.dtype}")
39
+ del causal; gc.collect()
40
+
41
+ log("loading seq-cls backbone (bf16, cpu, num_labels=1)")
42
+ seq = AutoModelForSequenceClassification.from_pretrained(SRC, num_labels=1, torch_dtype=torch.bfloat16)
43
+ lin = torch.nn.Linear(hidden, 1, bias=False, dtype=torch.bfloat16)
44
+ with torch.no_grad():
45
+ lin.weight.copy_(row.unsqueeze(0))
46
+ seq.score = lin
47
+ seq.config.num_labels = 1
48
+ seq.config.id2label = {0: "relevant"}; seq.config.label2id = {"relevant": 0}
49
+ tok = AutoTokenizer.from_pretrained(SRC)
50
+ pad_id = tok.pad_token_id if tok.pad_token_id is not None else tok.convert_tokens_to_ids("<|endoftext|>")
51
+ seq.config.pad_token_id = pad_id
52
+ tok.padding_side = "right"
53
+ log(f"pad_token_id={pad_id} padding_side=right score.weight.dtype={seq.score.weight.dtype}")
54
+ os.makedirs(OUT, exist_ok=True)
55
+ seq.save_pretrained(OUT); tok.save_pretrained(OUT)
56
+ log(f"saved seq-cls -> {OUT}")
57
+ del seq; gc.collect()
58
+
59
+ # ---- GATE 1: fp32 weight equality ----
60
+ log("GATE1: reload + fp32 weight equality")
61
+ c2 = AutoModelForCausalLM.from_pretrained(SRC, torch_dtype=torch.bfloat16)
62
+ src_row = c2.lm_head.weight[TOKEN_ID].float().cpu(); del c2; gc.collect()
63
+ s2 = AutoModelForSequenceClassification.from_pretrained(OUT, num_labels=1, torch_dtype=torch.bfloat16)
64
+ saved_dtype = s2.score.weight.dtype
65
+ saved_row = s2.score.weight[0].float().cpu(); del s2; gc.collect()
66
+ maxd1 = (src_row - saved_row).abs().max().item()
67
+ gate1 = maxd1 < 1e-7
68
+ log(f"GATE1 saved_score_dtype={saved_dtype} weight_maxdiff(fp32)={maxd1:.3e} -> {'PASS' if gate1 else 'FAIL'}")
69
+ if not gate1:
70
+ log("GATE1 FAIL — aborting."); sys.exit(2)
71
+
72
+ PAIRS = [
73
+ ("What is the capital of France?", "The capital of France is Paris."),
74
+ ("What is the capital of France?", "Bananas are rich in potassium and grow in tropical climates."),
75
+ ("How do I configure TLS in nginx?", "In nginx set ssl_certificate and ssl_certificate_key in the server block on 443, then reload."),
76
+ ("python async database connection pooling", "asyncpg exposes create_pool; set min_size/max_size and acquire via 'async with pool.acquire()'."),
77
+ ("short q", "x"),
78
+ ("distributed consensus and leader election in raft", ("Raft elects a leader via randomized election timeouts. " * 12)[:400]),
79
+ ("empty doc edge", ""),
80
+ ("whitespace doc edge", " "),
81
+ ]
82
+
83
+ # ---- GATE 1.5: served-tokenizer path identity (OUT == SRC) ----
84
+ src_tok = AutoTokenizer.from_pretrained(SRC); src_tok.padding_side = "right"
85
+ out_tok = AutoTokenizer.from_pretrained(OUT); out_tok.padding_side = "right"
86
+ pad_id = out_tok.pad_token_id if out_tok.pad_token_id is not None else out_tok.convert_tokens_to_ids("<|endoftext|>") # set here so GATE2 has it under ZR_VERIFY_ONLY too
87
+ # (a) formatted-string + token-id identity
88
+ tokpath_ok = True
89
+ for q, d in PAIRS:
90
+ s_str, o_str = format_pair(src_tok, q, d), format_pair(out_tok, q, d)
91
+ if (s_str != o_str) or (src_tok(s_str, add_special_tokens=False)["input_ids"] != out_tok(o_str, add_special_tokens=False)["input_ids"]):
92
+ tokpath_ok = False; log(f" GATE1.5a MISMATCH q={q!r}")
93
+ log(f"GATE1.5a served==source tokenizer (string+ids) -> {'PASS' if tokpath_ok else 'FAIL'}")
94
+ # (b) encode_pair(SRC) == encode_pair(OUT) INCLUDING the truncation path (gate path == served path)
95
+ ep_ok = True
96
+ ep_pairs = PAIRS + [("very long query " + "stress " * 3000, "document body " + "lorem ipsum " * 6000)]
97
+ for q, d in ep_pairs:
98
+ a = encode_pair(src_tok, q, d, max_total_tokens=MAXLEN)["input_ids"].tolist()
99
+ b = encode_pair(out_tok, q, d, max_total_tokens=MAXLEN)["input_ids"].tolist()
100
+ if a != b: ep_ok = False; log(f" GATE1.5b encode_pair MISMATCH q[:30]={q[:30]!r} lens {len(a[0])} vs {len(b[0])}")
101
+ log(f"GATE1.5b encode_pair(SRC)==encode_pair(OUT) incl truncation -> {'PASS' if ep_ok else 'FAIL'}")
102
+ # (c) fix_mistral_regex default-vs-flag token-id identity (the transformers warning is benign if 0 diffs)
103
+ try:
104
+ fix_tok = AutoTokenizer.from_pretrained(OUT, fix_mistral_regex=True); fix_flag = "supported"
105
+ except TypeError as e:
106
+ fix_tok = None; fix_flag = f"UNSUPPORTED({type(e).__name__})"
107
+ regex_diffs = 0
108
+ for q, d in PAIRS:
109
+ base = out_tok(format_pair(out_tok, q, d), add_special_tokens=False)["input_ids"]
110
+ cmp = (fix_tok or out_tok)(format_pair((fix_tok or out_tok), q, d), add_special_tokens=False)["input_ids"]
111
+ if base != cmp: regex_diffs += 1
112
+ mixed = "cafe naive 2026 x=1;y=2 https://a.b/c?q=1 path/to::thing emoji"
113
+ mixed_diff = out_tok(mixed, add_special_tokens=False)["input_ids"] != (fix_tok or out_tok)(mixed, add_special_tokens=False)["input_ids"]
114
+ regex_ok = (regex_diffs == 0) and (not mixed_diff)
115
+ log(f"GATE1.5c fix_mistral_regex flag={fix_flag} pair_id_diffs={regex_diffs} mixed_diff={mixed_diff} -> {'BENIGN/PASS' if regex_ok else 'MATERIAL/FAIL'}")
116
+
117
+ # ---- GATE-AP: assistant-prefix preserved under near-MAXLEN input ----
118
+ ap_ids = out_tok(ASSISTANT_PREFIX, add_special_tokens=False)["input_ids"]
119
+ long_doc = "lorem ipsum dolor sit amet consectetur " * 4000
120
+ enc_long = encode_pair(out_tok, "very long reranking stress-test query " * 200, long_doc, max_total_tokens=MAXLEN, device="cpu")
121
+ long_ids = enc_long["input_ids"][0].tolist()
122
+ ap_ok = (len(long_ids) <= MAXLEN) and (long_ids[-len(ap_ids):] == ap_ids)
123
+ log(f"GATE-AP near-MAXLEN: len={len(long_ids)}<= {MAXLEN} and tail==assistant_prefix -> {'PASS' if ap_ok else 'FAIL'}")
124
+
125
+ # ---- GATE 2: runtime fidelity over the EXACT served path (OUT tokenizer + encode_pair) + batched pooling ----
126
+ log("GATE2: loading causal on GPU")
127
+ c3 = AutoModelForCausalLM.from_pretrained(SRC, torch_dtype=torch.bfloat16).to(DEV).eval()
128
+ causal_served, causal_single, causal_batched = [], [], []
129
+ with torch.no_grad():
130
+ for q, d in PAIRS:
131
+ enc = encode_pair(out_tok, q, d, max_total_tokens=MAXLEN, device=DEV) # EXACT served path (OUT tok)
132
+ li = int(enc["attention_mask"][0].sum().item()) - 1
133
+ causal_served.append(c3(**enc).logits[0, li, TOKEN_ID].float().item())
134
+ texts = [format_pair(out_tok, q, d) for q, d in PAIRS]
135
+ for t in texts:
136
+ e = out_tok(t, return_tensors="pt", add_special_tokens=False).to(DEV)
137
+ causal_single.append(c3(**e).logits[0, -1, TOKEN_ID].float().item())
138
+ eb = out_tok(texts, return_tensors="pt", padding=True, add_special_tokens=False).to(DEV)
139
+ ob = c3(**eb).logits; last = eb["attention_mask"].sum(1) - 1
140
+ causal_batched = [ob[i, last[i], TOKEN_ID].float().item() for i in range(len(texts))]
141
+ del c3; gc.collect(); torch.cuda.empty_cache()
142
+
143
+ log("GATE2: loading seq-cls on GPU")
144
+ s3 = AutoModelForSequenceClassification.from_pretrained(OUT, num_labels=1, torch_dtype=torch.bfloat16).to(DEV).eval()
145
+ s3.config.pad_token_id = pad_id
146
+ seq_served, seq_single, seq_batched = [], [], []
147
+ with torch.no_grad():
148
+ for q, d in PAIRS:
149
+ enc = encode_pair(out_tok, q, d, max_total_tokens=MAXLEN, device=DEV) # EXACT served path (OUT tok)
150
+ seq_served.append(s3(**enc).logits.reshape(-1)[0].float().item())
151
+ for t in texts:
152
+ e = out_tok(t, return_tensors="pt", add_special_tokens=False).to(DEV)
153
+ seq_single.append(s3(**e).logits.reshape(-1)[0].float().item())
154
+ eb = out_tok(texts, return_tensors="pt", padding=True, add_special_tokens=False).to(DEV)
155
+ seq_batched = s3(**eb).logits.reshape(-1).float().tolist()
156
+ del s3; gc.collect(); torch.cuda.empty_cache()
157
+
158
+ def sig(x): return 1.0 / (1.0 + math.exp(-x / TEMP))
159
+ max_served = max_eqB = max_eqS = max_dS = max_dC = 0.0
160
+ log("idx servd(prod) seqServd eqServed eqB|seqB-causalB| eqS|seq1-causal1| driftSeq driftCausal score")
161
+ for i, (q, d) in enumerate(PAIRS):
162
+ eqSrv = abs(seq_served[i] - causal_served[i]) # PRODUCTION path fidelity (OUT tok + encode_pair, batch=1)
163
+ eqB = abs(seq_batched[i] - causal_batched[i]); eqS = abs(seq_single[i] - causal_single[i])
164
+ dS = abs(seq_batched[i] - seq_single[i]); dC = abs(causal_batched[i] - causal_single[i])
165
+ max_served = max(max_served, eqSrv); max_eqB = max(max_eqB, eqB); max_eqS = max(max_eqS, eqS)
166
+ max_dS = max(max_dS, dS); max_dC = max(max_dC, dC)
167
+ log(f"{i:>3} {causal_served[i]:+8.3f} {seq_served[i]:+8.3f} {eqSrv:.1e} {eqB:.1e} {eqS:.1e} {dS:.1e} {dC:.1e} {sig(seq_served[i]):.4f}")
168
+
169
+ TOL = 1e-3
170
+ gate2 = (max_served < TOL) and (max_eqB < TOL) and (max_eqS < TOL)
171
+ log(f"GATE2 FIDELITY served={max_served:.2e} batched={max_eqB:.2e} single={max_eqS:.2e} TOL={TOL} -> {'PASS' if gate2 else 'FAIL'}")
172
+ log(f"INFO backbone padding drift max|seqB-seq1|={max_dS:.2e} ~= max|causalB-causal1|={max_dC:.2e} (shared bf16 right-pad)")
173
+ log(f"SANITY relevant>irrelevant: {sig(seq_served[0]):.4f} > {sig(seq_served[1]):.4f} -> {'OK' if seq_served[0] > seq_served[1] else 'WARN'}")
174
+ allpass = gate1 and tokpath_ok and ep_ok and regex_ok and ap_ok and gate2
175
+ log(f"GATES gate1={gate1} tokpath={tokpath_ok} encode_pair_id={ep_ok} fix_mistral_regex={regex_ok} assistant_prefix={ap_ok} gate2={gate2}")
176
+ print("RESULT", "PASS" if allpass else "FAIL", flush=True)
177
+ sys.exit(0 if allpass else 3)
@@ -0,0 +1,49 @@
1
+ # ClawMem zerank-2 seq-cls reranker sidecar.
2
+ #
3
+ # 1. Build: docker compose build
4
+ # 2. Convert + verify: HF_TOKEN=hf_xxx docker compose run --rm convert
5
+ # 3. Serve on :8090: docker compose up -d reranker
6
+ #
7
+ # Then point ClawMem at it: export CLAWMEM_RERANK_URL=http://<this-host>:8090
8
+ #
9
+ # Requires the NVIDIA Container Toolkit. zerank-2 weights are CC-BY-NC-4.0 (non-commercial);
10
+ # accept the model license on Hugging Face and pass HF_TOKEN for the one-off convert step.
11
+ x-gpu: &gpu
12
+ deploy:
13
+ resources:
14
+ reservations:
15
+ devices:
16
+ - driver: nvidia
17
+ count: all
18
+ capabilities: [gpu]
19
+
20
+ services:
21
+ reranker: # the live sidecar — serves the verified model on :8090
22
+ build: .
23
+ image: clawmem-zerank-2-seq
24
+ restart: unless-stopped
25
+ ports:
26
+ - "8090:8090"
27
+ volumes:
28
+ - ./models:/models
29
+ environment:
30
+ ZR_OUT: /models/zerank-2-seq
31
+ ZR_PORT: "8090"
32
+ <<: *gpu
33
+
34
+ convert: # one-off: download + convert + run all gates into ./models
35
+ build: .
36
+ image: clawmem-zerank-2-seq
37
+ profiles: ["tools"]
38
+ environment:
39
+ ZR_SRC: zeroentropy/zerank-2-reranker
40
+ ZR_OUT: /models/zerank-2-seq
41
+ HF_TOKEN: ${HF_TOKEN:-}
42
+ volumes:
43
+ - ./models:/models
44
+ - hf-cache:/root/.cache/huggingface
45
+ command: ["python3", "build_and_verify.py"]
46
+ <<: *gpu
47
+
48
+ volumes:
49
+ hf-cache:
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env python3
2
+ """ClawMem zerank-2 seq-cls reranker sidecar. Drop-in for CLAWMEM_RERANK_URL.
3
+ POST /v1/rerank {query, documents:[...]} -> {results:[{index, relevance_score}]}.
4
+ Uses zr_common.encode_pair (the SAME path the build gate verified) -> served scores == gated scores by
5
+ construction, and the assistant prefix is never truncated. batch=1 -> deterministic, drift-free scores."""
6
+ import os, sys, math, torch
7
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
8
+ from fastapi import FastAPI
9
+ from pydantic import BaseModel
10
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
11
+ from zr_common import encode_pair
12
+
13
+ MODEL = os.environ.get("ZR_OUT", "/models/zerank-2-seq")
14
+ TEMP = float(os.environ.get("ZR_TEMP", "5.0"))
15
+ MAXLEN = int(os.environ.get("ZR_MAXLEN", "8192"))
16
+ DEV = "cuda" if torch.cuda.is_available() else "cpu"
17
+
18
+ tok = AutoTokenizer.from_pretrained(MODEL); tok.padding_side = "right"
19
+ model = AutoModelForSequenceClassification.from_pretrained(MODEL, num_labels=1, torch_dtype=torch.bfloat16).to(DEV).eval()
20
+ if model.config.pad_token_id is None:
21
+ model.config.pad_token_id = tok.pad_token_id or tok.convert_tokens_to_ids("<|endoftext|>")
22
+
23
+ app = FastAPI()
24
+
25
+ class RerankReq(BaseModel):
26
+ query: str
27
+ documents: list[str]
28
+
29
+ @app.post("/v1/rerank")
30
+ @torch.no_grad()
31
+ def rerank(req: RerankReq):
32
+ results = []
33
+ for i, d in enumerate(req.documents): # batch=1: deterministic, no padding drift
34
+ enc = encode_pair(tok, req.query, d, max_total_tokens=MAXLEN, device=DEV)
35
+ lg = model(**enc).logits.reshape(-1)[0].float().item()
36
+ results.append({"index": i, "relevance_score": 1.0 / (1.0 + math.exp(-lg / TEMP))})
37
+ return {"results": results}
38
+
39
+ @app.get("/v1/models")
40
+ def models():
41
+ return {"models": [{"id": "zerank-2-seq", "object": "model"}]}
42
+
43
+ @app.get("/health")
44
+ def health():
45
+ return {"status": "ok", "model": MODEL, "device": DEV}
46
+
47
+ if __name__ == "__main__":
48
+ import uvicorn
49
+ uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("ZR_PORT", "8090")))
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env python3
2
+ """Shared formatting + token-budgeting for the zerank-2 seq-cls reranker.
3
+ Imported by BOTH build_and_verify.py (the gate) and server.py (serving) so the gated path and the
4
+ served path are identical BY CONSTRUCTION.
5
+
6
+ Guarantees the assistant-generation prefix is NEVER truncated: truncating it would move the scored/pooled
7
+ position off the assistant prefix -> wrong score. We right-truncate the DOCUMENT
8
+ tail (then the QUERY tail if needed) BEFORE templating, leaving room for the template scaffold + assistant
9
+ prefix, with a final left-truncation belt so the prefix (always at the end) survives any re-encode overshoot."""
10
+
11
+ ASSISTANT_PREFIX = "<|im_start|>assistant\n"
12
+
13
+
14
+ def format_pair(tok, query, document):
15
+ return tok.apply_chat_template(
16
+ [{"role": "query", "content": query}, {"role": "document", "content": document}],
17
+ tokenize=False, add_generation_prompt=True)
18
+
19
+
20
+ def _ids(tok, text):
21
+ return tok(text, add_special_tokens=False)["input_ids"]
22
+
23
+
24
+ def encode_pair(tok, query, document, max_total_tokens=8192, device="cpu", safety=16):
25
+ """Return a tokenized (query, document) pair whose total length <= max_total_tokens and whose tail is
26
+ always the assistant prefix. Truncates document tail first, then query tail, then (belt) left-truncates."""
27
+ scaffold = len(_ids(tok, format_pair(tok, "", "")))
28
+ avail = max(2, max_total_tokens - scaffold - safety)
29
+ q_ids = _ids(tok, query)
30
+ if len(q_ids) > avail - 1: # cap query so a document always has room
31
+ q_ids = q_ids[:avail - 1]
32
+ query = tok.decode(q_ids)
33
+ d_budget = max(1, avail - len(q_ids))
34
+ d_ids = _ids(tok, document)
35
+ if len(d_ids) > d_budget: # truncate the document TAIL (least-important part)
36
+ d_ids = d_ids[:d_budget]
37
+ document = tok.decode(d_ids)
38
+ text = format_pair(tok, query, document)
39
+ enc = tok(text, return_tensors="pt", add_special_tokens=False)
40
+ if enc["input_ids"].shape[1] > max_total_tokens: # belt: keep the LAST tokens -> assistant prefix survives
41
+ enc = {k: v[:, -max_total_tokens:] for k, v in enc.items()}
42
+ return {k: v.to(device) for k, v in enc.items()}
43
+ return enc.to(device)
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "clawmem",
3
- "version": "0.11.0",
3
+ "version": "0.11.3",
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": {
7
- "clawmem": "./bin/clawmem"
7
+ "clawmem": "bin/clawmem"
8
8
  },
9
9
  "files": [
10
10
  "bin/",
11
11
  "src/",
12
+ "extras/",
12
13
  "README.md",
13
14
  "LICENSE",
14
15
  "CLAUDE.md",
package/src/clawmem.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  canonicalDocId,
14
14
  type Store,
15
15
  type SearchResult,
16
+ type ExpandedQuery,
16
17
  DEFAULT_EMBED_MODEL,
17
18
  DEFAULT_QUERY_MODEL,
18
19
  DEFAULT_RERANK_MODEL,
@@ -939,19 +940,13 @@ async function cmdQuery(args: string[]) {
939
940
  const secondScore = ftsResults[1]?.score ?? 0;
940
941
  const strongSignal = topScore >= 0.85 && (topScore - secondScore) >= 0.15;
941
942
 
942
- // Step 2: Query expansion (skip if strong BM25 signal)
943
- let expandedQueries: { type: string; text: string }[] = [];
943
+ // Step 2: Query expansion (skip if strong BM25 signal). expandQuery now returns
944
+ // typed ExpandedQuery[] (lex/vec/hyde) no more brittle string re-parsing, and
945
+ // the original query is no longer echoed back as a phantom "vec" expansion.
946
+ let expandedQueries: ExpandedQuery[] = [];
944
947
  if (!strongSignal) {
945
948
  try {
946
- const expanded = await s.expandQuery(query, DEFAULT_QUERY_MODEL);
947
- expandedQueries = expanded.map(text => {
948
- // Parse "type: text" format from expansion
949
- const colonIdx = text.indexOf(": ");
950
- if (colonIdx > 0 && colonIdx < 5) {
951
- return { type: text.slice(0, colonIdx), text: text.slice(colonIdx + 2) };
952
- }
953
- return { type: "vec", text };
954
- });
949
+ expandedQueries = await s.expandQuery(query, DEFAULT_QUERY_MODEL);
955
950
  } catch {
956
951
  // Fallback: no expansion
957
952
  }
@@ -959,20 +954,27 @@ async function cmdQuery(args: string[]) {
959
954
 
960
955
  // Step 3: Parallel searches
961
956
  const allRanked: { results: RankedResult[]; weight: number }[] = [];
957
+ // Retain the raw SearchResult from every leg (original + typed expansions) so a
958
+ // candidate found ONLY via an expansion leg survives Step 8's resultMap lookup.
959
+ const candidateResults: SearchResult[] = [];
962
960
 
963
961
  // Original query BM25 + vec (weight 2x)
964
962
  allRanked.push({ results: ftsResults.map(toRanked), weight: 2 });
963
+ candidateResults.push(...ftsResults);
965
964
  const vecResults = await s.searchVec(query, DEFAULT_EMBED_MODEL, 20);
966
965
  allRanked.push({ results: vecResults.map(toRanked), weight: 2 });
966
+ candidateResults.push(...vecResults);
967
967
 
968
- // Expanded queries (weight 1x)
968
+ // Expanded queries (weight 1x): lex → FTS, vec/hyde → vector
969
969
  for (const eq of expandedQueries) {
970
970
  if (eq.type === "lex") {
971
- const r = s.searchFTS(eq.text, 20);
971
+ const r = s.searchFTS(eq.query, 20);
972
972
  allRanked.push({ results: r.map(toRanked), weight: 1 });
973
+ candidateResults.push(...r);
973
974
  } else {
974
- const r = await s.searchVec(eq.text, DEFAULT_EMBED_MODEL, 20);
975
+ const r = await s.searchVec(eq.query, DEFAULT_EMBED_MODEL, 20);
975
976
  allRanked.push({ results: r.map(toRanked), weight: 1 });
977
+ candidateResults.push(...r);
976
978
  }
977
979
  }
978
980
 
@@ -1009,9 +1011,10 @@ async function cmdQuery(args: string[]) {
1009
1011
  });
1010
1012
  blended.sort((a, b) => b.score - a.score);
1011
1013
 
1012
- // Step 8: Map back to full results and apply composite scoring
1014
+ // Step 8: Map back to full results and apply composite scoring. Build the map from
1015
+ // ALL legs (incl. typed expansions) so expansion-only candidates aren't dropped.
1013
1016
  const resultMap = new Map(
1014
- [...ftsResults, ...vecResults].map(r => [r.filepath, r])
1017
+ candidateResults.map(r => [r.filepath, r])
1015
1018
  );
1016
1019
  const fullResults = blended
1017
1020
  .map(b => resultMap.get(b.file))
@@ -264,8 +264,14 @@ export async function contextSurfacing(
264
264
  const seen = new Set(results.map(r => r.filepath));
265
265
  for (const eq of expanded.slice(0, 3)) {
266
266
  if (Date.now() - startTime > 6000) break; // hard stop at 6s
267
- const ftsExp = store.searchFTS(eq, 5);
268
- for (const r of ftsExp) {
267
+ // Typed routing: lex → FTS; vec/hyde → vector (deep profile + time budget only).
268
+ let hits: SearchResult[] = [];
269
+ if (eq.type === 'lex') {
270
+ hits = store.searchFTS(eq.query, 5);
271
+ } else if (profile.useVector) {
272
+ try { hits = await store.searchVec(eq.query, DEFAULT_EMBED_MODEL, 5); } catch { /* vector leg non-fatal */ }
273
+ }
274
+ for (const r of hits) {
269
275
  if (!seen.has(r.filepath)) {
270
276
  seen.add(r.filepath);
271
277
  results.push(r);
package/src/llm.ts CHANGED
@@ -141,6 +141,88 @@ export type Queryable = {
141
141
  text: string;
142
142
  };
143
143
 
144
+ /**
145
+ * Template-residue / leak patterns that indicate an expansion line is NOT a real
146
+ * query. The qmd-query-expansion finetune, when prompted out of distribution,
147
+ * echoed the old verbose prompt's format hints and leaked Qwen3 thinking tags;
148
+ * these never belong in a search query. Kept as a defensive guard even though the
149
+ * terse QMD-faithful prompt (expandQueryRemote, 2026-06-23) fixed generation.
150
+ */
151
+ const EXPANSION_JUNK_PATTERNS: RegExp[] = [
152
+ /<\/?think>/i,
153
+ /keyword search terms \(/i,
154
+ /semantic search queries \(/i,
155
+ /hypothetical document passage that answers the query/i,
156
+ ];
157
+
158
+ /** True if an expansion text is empty or matches a known template-residue pattern. */
159
+ export function isJunkExpansion(text: string): boolean {
160
+ const t = text.trim();
161
+ if (!t) return true;
162
+ return EXPANSION_JUNK_PATTERNS.some(re => re.test(t));
163
+ }
164
+
165
+ /**
166
+ * Strip stray Qwen3 `/no_think` / `/think` control tokens the model sometimes
167
+ * echoes mid-line (generateRemote appends ` /no_think`, and the finetune can copy
168
+ * it into a hyde passage). Only matches the token when it stands alone (start/space
169
+ * bounded) so real paths like `src/think.ts` are never touched. Unlike the `<think>`
170
+ * tag (which signals reasoning leakage → whole line rejected), a stray control token
171
+ * just gets cleaned out so the otherwise-good line survives.
172
+ */
173
+ function stripControlTokens(text: string): string {
174
+ return text.replace(/(?:^|\s)\/(?:no_)?think(?=\s|$)/gi, " ").replace(/\s+/g, " ").trim();
175
+ }
176
+
177
+ /**
178
+ * Shared guard for query-expansion output. Drops empty / template-residue /
179
+ * think-tag lines and de-duplicates by (type, text). Used by BOTH the llm.ts
180
+ * parsers and the store.ts wrapper (defense-in-depth across every provider).
181
+ * Does NOT inject a fallback — callers decide what to do with an empty result,
182
+ * and does NOT require the original query terms to appear (that would reject
183
+ * legitimate synonyms and keyword-only expansions, e.g. a future zegen lex leg).
184
+ */
185
+ export function sanitizeExpandedQueries(items: Queryable[]): Queryable[] {
186
+ const seen = new Set<string>();
187
+ const out: Queryable[] = [];
188
+ for (const q of items) {
189
+ const text = stripControlTokens(q.text);
190
+ if (isJunkExpansion(text)) continue;
191
+ const key = `${q.type}:${text}`;
192
+ if (seen.has(key)) continue;
193
+ seen.add(key);
194
+ out.push({ type: q.type, text });
195
+ }
196
+ return out;
197
+ }
198
+
199
+ /**
200
+ * Typed fallback expansion set, used when generation fails or sanitization leaves
201
+ * nothing usable. lex+vec reuse the original query; hyde gets a minimal stub so the
202
+ * vector leg still has a hypothetical-document signal.
203
+ */
204
+ export function expansionFallback(query: string, includeLexical: boolean = true): Queryable[] {
205
+ const out: Queryable[] = [
206
+ { type: 'vec', text: query },
207
+ { type: 'hyde', text: `Information about ${query}` },
208
+ ];
209
+ if (includeLexical) out.unshift({ type: 'lex', text: query });
210
+ return out;
211
+ }
212
+
213
+ /**
214
+ * True if `items` is exactly the typed fallback set for `query` — i.e. what every
215
+ * llm.expandQuery failure path returns. The store wrapper uses this to detect a
216
+ * leaked generation failure so it can return an expansions-only form WITHOUT
217
+ * caching it (a transient failure must not poison the cache). Compares against the
218
+ * default (lexical-included) fallback, which is what the store always requests.
219
+ */
220
+ export function isFallbackExpansion(items: Queryable[], query: string): boolean {
221
+ const fb = expansionFallback(query);
222
+ return items.length === fb.length
223
+ && items.every((q, i) => q.type === fb[i]!.type && q.text === fb[i]!.text);
224
+ }
225
+
144
226
  /**
145
227
  * Document to rerank
146
228
  */
@@ -1074,24 +1156,22 @@ export class LlamaCpp implements LLM {
1074
1156
  }
1075
1157
 
1076
1158
  private async expandQueryRemote(query: string, includeLexical: boolean, context?: string, intent?: string): Promise<Queryable[]> {
1077
- const prompt = `Rewrite this search query for better retrieval. Output lines in format "type: text" where type is lex, vec, or hyde.
1078
- - lex: keyword search terms (1-3 lines)
1079
- - vec: semantic search queries (1-3 lines)
1080
- - hyde: hypothetical document passage that answers the query (1 line)
1081
-
1082
- Query: ${query}${intent ? `\nQuery intent: ${intent}` : ""}${context ? `\nContext: ${context}` : ""}
1083
-
1084
- Output:`;
1159
+ // QMD-faithful terse prompt. The qmd-query-expansion-1.7B finetune was trained
1160
+ // on "/no_think Expand this search query: X" (cf. QMD src/llm.ts:1467). The prior
1161
+ // verbose prose prompt was out-of-distribution: the model echoed the template
1162
+ // ("lex: keyword search terms (") and leaked </think> (verified live 2026-06-23).
1163
+ let prompt = intent
1164
+ ? `/no_think Expand this search query: ${query}\nQuery intent: ${intent}`
1165
+ : `/no_think Expand this search query: ${query}`;
1166
+ if (context) prompt += `\nContext: ${context}`;
1085
1167
 
1086
1168
  const result = await this.generateRemote(prompt, 500, 0.7);
1087
1169
  if (!result?.text) {
1088
- const fallback: Queryable[] = [{ type: 'vec', text: query }];
1089
- if (includeLexical) fallback.unshift({ type: 'lex', text: query });
1090
- return fallback;
1170
+ return expansionFallback(query, includeLexical);
1091
1171
  }
1092
1172
 
1093
1173
  const lines = result.text.trim().split("\n");
1094
- const queryables: Queryable[] = lines.map(line => {
1174
+ const parsed: Queryable[] = lines.map(line => {
1095
1175
  const colonIdx = line.indexOf(":");
1096
1176
  if (colonIdx === -1) return null;
1097
1177
  const type = line.slice(0, colonIdx).trim();
@@ -1101,16 +1181,11 @@ Output:`;
1101
1181
  return { type: type as QueryType, text };
1102
1182
  }).filter((q): q is Queryable => q !== null);
1103
1183
 
1104
- if (queryables.length === 0) {
1105
- const fallback: Queryable[] = [{ type: 'vec', text: query }];
1106
- if (includeLexical) fallback.unshift({ type: 'lex', text: query });
1107
- return fallback;
1108
- }
1109
-
1110
- if (!includeLexical) {
1111
- return queryables.filter(q => q.type !== 'lex');
1112
- }
1113
- return queryables;
1184
+ // Drop template residue / <think> leaks / dups, then scope to requested types.
1185
+ const cleaned = sanitizeExpandedQueries(parsed);
1186
+ const scoped = includeLexical ? cleaned : cleaned.filter(q => q.type !== 'lex');
1187
+ if (scoped.length === 0) return expansionFallback(query, includeLexical);
1188
+ return scoped;
1114
1189
  }
1115
1190
 
1116
1191
  async modelExists(modelUri: string): Promise<ModelInfo> {
@@ -1148,10 +1223,8 @@ Output:`;
1148
1223
  // Remote is in cooldown (pre-existing or just set) — fall through to local
1149
1224
  if (this.remoteLlmUrl && this.isRemoteLlmDown()) {
1150
1225
  if (process.env.CLAWMEM_NO_LOCAL_MODELS === "true") {
1151
- // Can't fall back — return passthrough
1152
- const fallback: Queryable[] = [{ type: 'vec', text: query }];
1153
- if (includeLexical) fallback.unshift({ type: 'lex', text: query });
1154
- return fallback;
1226
+ // Can't fall back to local inference — return the typed passthrough set
1227
+ return expansionFallback(query, includeLexical);
1155
1228
  }
1156
1229
  this.noteRemoteFallback(
1157
1230
  "llm",
@@ -1230,7 +1303,7 @@ Final Output:`;
1230
1303
  });
1231
1304
 
1232
1305
  const lines = result.trim().split("\n");
1233
- const queryables: Queryable[] = lines.map(line => {
1306
+ const parsed: Queryable[] = lines.map(line => {
1234
1307
  const colonIdx = line.indexOf(":");
1235
1308
  if (colonIdx === -1) return null;
1236
1309
  const type = line.slice(0, colonIdx).trim();
@@ -1239,17 +1312,14 @@ Final Output:`;
1239
1312
  return { type: type as QueryType, text };
1240
1313
  }).filter((q): q is Queryable => q !== null);
1241
1314
 
1242
- // Filter out lex entries if not requested
1243
- if (!includeLexical) {
1244
- return queryables.filter(q => q.type !== 'lex');
1245
- }
1246
- return queryables;
1315
+ // Same guard as the remote path — drop residue/dups, scope to requested types.
1316
+ const cleaned = sanitizeExpandedQueries(parsed);
1317
+ const scoped = includeLexical ? cleaned : cleaned.filter(q => q.type !== 'lex');
1318
+ if (scoped.length === 0) return expansionFallback(query, includeLexical);
1319
+ return scoped;
1247
1320
  } catch (error) {
1248
1321
  console.error("Structured query expansion failed:", error);
1249
- // Fallback to original query
1250
- const fallback: Queryable[] = [{ type: 'vec', text: query }];
1251
- if (includeLexical) fallback.unshift({ type: 'lex', text: query });
1252
- return fallback;
1322
+ return expansionFallback(query, includeLexical);
1253
1323
  } finally {
1254
1324
  await genContext.dispose();
1255
1325
  }
package/src/mcp.ts CHANGED
@@ -629,19 +629,38 @@ This is the recommended entry point for ALL memory queries.`,
629
629
  const hasStrongSignal = !intent && initialFts.length > 0
630
630
  && topScore >= 0.85 && (topScore - secondScore) >= 0.15;
631
631
 
632
- // Step 2: Query expansion (skipped if strong signal)
633
- const queries = hasStrongSignal
634
- ? [query]
632
+ // Step 2: Query expansion (skipped if strong signal). Typed routing —
633
+ // original BOTH FTS + vector (2× RRF anchor), lex → FTS only, vec/hyde → vector only.
634
+ const expanded = hasStrongSignal
635
+ ? []
635
636
  : await store.expandQuery(query, DEFAULT_QUERY_MODEL, intent);
636
637
 
637
- for (const q of queries) {
638
- const ftsResults = q === query ? initialFts : store.searchFTS(q, 20, undefined, collections, dateRange);
639
- if (ftsResults.length > 0) {
640
- for (const r of ftsResults) docidMap.set(r.filepath, r.docid);
641
- rankedLists.push(ftsResults.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
638
+ // Original query both backends, pushed FIRST so the positional 2× weight
639
+ // below lands on exactly the original's lists.
640
+ if (initialFts.length > 0) {
641
+ for (const r of initialFts) docidMap.set(r.filepath, r.docid);
642
+ rankedLists.push(initialFts.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
643
+ }
644
+ if (hasVectors) {
645
+ const vecResults = await store.searchVec(query, DEFAULT_EMBED_MODEL, 20, undefined, collections, dateRange);
646
+ if (vecResults.length > 0) {
647
+ for (const r of vecResults) docidMap.set(r.filepath, r.docid);
648
+ rankedLists.push(vecResults.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
642
649
  }
643
- if (hasVectors) {
644
- const vecResults = await store.searchVec(q, DEFAULT_EMBED_MODEL, 20, undefined, collections, dateRange);
650
+ }
651
+ // Lists contributed by the original query these get the 2× RRF weight.
652
+ const numOriginalLists = rankedLists.length;
653
+
654
+ // Typed expansions — route by type: lex → FTS, vec/hyde → vector.
655
+ for (const eq of expanded) {
656
+ if (eq.type === 'lex') {
657
+ const ftsResults = store.searchFTS(eq.query, 20, undefined, collections, dateRange);
658
+ if (ftsResults.length > 0) {
659
+ for (const r of ftsResults) docidMap.set(r.filepath, r.docid);
660
+ rankedLists.push(ftsResults.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
661
+ }
662
+ } else if (hasVectors) {
663
+ const vecResults = await store.searchVec(eq.query, DEFAULT_EMBED_MODEL, 20, undefined, collections, dateRange);
645
664
  if (vecResults.length > 0) {
646
665
  for (const r of vecResults) docidMap.set(r.filepath, r.docid);
647
666
  rankedLists.push(vecResults.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
@@ -705,8 +724,9 @@ This is the recommended entry point for ALL memory queries.`,
705
724
  }
706
725
  }
707
726
 
708
- // Weight: original query BM25+vec get 2x, expanded queries get 1x, temporal/entity legs get 1x
709
- const numOriginalLists = hasVectors ? 2 : 1; // first BM25 + first vector from original query
727
+ // Weight: the original query's lists (pushed first) get 2×; expansion, temporal,
728
+ // and entity legs get 1×. numOriginalLists (computed above) is the actual count
729
+ // the original contributed — robust to an empty BM25 or vector leg.
710
730
  const weights = rankedLists.map((_, i) => i < numOriginalLists ? 2.0 : 1.0);
711
731
  const fused = reciprocalRankFusion(rankedLists, weights);
712
732
  const candidates = fused.slice(0, candLimit);
package/src/store.ts CHANGED
@@ -21,6 +21,9 @@ import {
21
21
  getDefaultLlamaCpp,
22
22
  formatQueryForEmbedding,
23
23
  formatDocForEmbedding,
24
+ sanitizeExpandedQueries,
25
+ expansionFallback,
26
+ isFallbackExpansion,
24
27
  type RerankDocument,
25
28
  } from "./llm.ts";
26
29
  import {
@@ -1139,7 +1142,7 @@ export type Store = {
1139
1142
  searchVec: (query: string, model: string, limit?: number, collectionId?: number, collections?: string[], dateRange?: { start: string; end: string }) => Promise<SearchResult[]>;
1140
1143
 
1141
1144
  // Query expansion & reranking
1142
- expandQuery: (query: string, model?: string, intent?: string) => Promise<string[]>;
1145
+ expandQuery: (query: string, model?: string, intent?: string) => Promise<ExpandedQuery[]>;
1143
1146
  rerank: (query: string, documents: { file: string; text: string }[], model?: string, intent?: string) => Promise<{ file: string; score: number }[]>;
1144
1147
 
1145
1148
  // Document retrieval
@@ -3543,28 +3546,84 @@ export function insertEmbedding(
3543
3546
  // Query expansion
3544
3547
  // =============================================================================
3545
3548
 
3546
- export async function expandQuery(query: string, model: string = DEFAULT_QUERY_MODEL, db: Database, intent?: string): Promise<string[]> {
3547
- // Check cache first (include intent in cache key)
3548
- const cacheKey = getCacheKey("expandQuery", { query, model, ...(intent && { intent }) });
3549
+ /**
3550
+ * A typed query-expansion result. Decoupled from llm.ts's internal Queryable —
3551
+ * same information, but store.ts owns its own public API type and field name.
3552
+ *
3553
+ * Routing contract (every consumer MUST honor it):
3554
+ * - lex → FTS (BM25) only
3555
+ * - vec → vector only
3556
+ * - hyde → vector only (hypothetical-document embedding)
3557
+ * The original query is searched on BOTH backends and is NOT included here —
3558
+ * callers add it explicitly with the 2× RRF anchor weight.
3559
+ */
3560
+ export type ExpandedQuery = {
3561
+ type: 'lex' | 'vec' | 'hyde';
3562
+ query: string;
3563
+ };
3564
+
3565
+ // Cache version + provider fingerprint for query expansion. Bumping the version
3566
+ // invalidates every stale entry automatically: old newline-format and pre-terse-
3567
+ // prompt garbage simply never hit again and age out of llm_cache via LRU (no manual
3568
+ // purge). The provider fingerprint will distinguish qmd from a future zegen lex
3569
+ // provider (P4) so a provider swap also invalidates the cache by construction.
3570
+ const EXPAND_CACHE_VERSION = "v3-qmd-terse-typed";
3571
+ const EXPAND_PROVIDER_FINGERPRINT = "qmd-terse";
3572
+
3573
+ export async function expandQuery(query: string, model: string = DEFAULT_QUERY_MODEL, db: Database, intent?: string): Promise<ExpandedQuery[]> {
3574
+ // Typed-JSON cache. Versioned key (include intent + provider fingerprint).
3575
+ const cacheKey = getCacheKey(`expandQuery:${EXPAND_CACHE_VERSION}`, {
3576
+ query,
3577
+ model,
3578
+ provider: EXPAND_PROVIDER_FINGERPRINT,
3579
+ ...(intent && { intent }),
3580
+ });
3549
3581
  const cached = getCachedResult(db, cacheKey);
3550
3582
  if (cached) {
3551
- const lines = cached.split('\n').map(l => l.trim()).filter(l => l.length > 0);
3552
- return [query, ...lines.slice(0, 2)];
3583
+ try {
3584
+ const parsed = JSON.parse(cached) as unknown;
3585
+ // Accept ONLY a fully-valid, already-clean typed payload. A shape error on ANY
3586
+ // element, an empty array, or anything sanitization would drop/rewrite → treat
3587
+ // the entry as stale and re-expand (never return partial or dirty cached data).
3588
+ if (Array.isArray(parsed) && parsed.length > 0
3589
+ && parsed.every(r => r !== null && typeof r === "object"
3590
+ && typeof (r as Record<string, unknown>).query === "string"
3591
+ && ((r as Record<string, unknown>).type === "lex"
3592
+ || (r as Record<string, unknown>).type === "vec"
3593
+ || (r as Record<string, unknown>).type === "hyde"))) {
3594
+ const rows = parsed as Array<{ type: ExpandedQuery["type"]; query: string }>;
3595
+ const sanitized = sanitizeExpandedQueries(rows.map(r => ({ type: r.type, text: r.query })));
3596
+ const clean = sanitized.length === rows.length
3597
+ && sanitized.every((s, i) => s.type === rows[i]!.type && s.text === rows[i]!.query);
3598
+ if (clean) return rows;
3599
+ }
3600
+ } catch {
3601
+ // Malformed JSON — fall through and re-expand.
3602
+ }
3553
3603
  }
3554
3604
 
3555
3605
  const llm = getDefaultLlamaCpp();
3556
- // Note: LlamaCpp uses hardcoded model, model parameter is ignored
3557
- // Pass intent to steer expansion when provided
3606
+ // Note: LlamaCpp uses a hardcoded model; the model parameter is ignored here.
3607
+ // Pass intent to steer expansion when provided.
3558
3608
  const results = await llm.expandQuery(query, { intent });
3559
- const queryTexts = results.map(r => r.text);
3560
3609
 
3561
- // Cache the expanded queries (excluding original)
3562
- const expandedOnly = queryTexts.filter(t => t !== query);
3563
- if (expandedOnly.length > 0) {
3564
- setCachedResult(db, cacheKey, expandedOnly.join('\n'));
3610
+ // Defense-in-depth: re-run the shared guard (also covers the local GBNF path and
3611
+ // any future provider), then drop entries that just echo the original query.
3612
+ // llm.expandQuery substitutes its OWN typed fallback on any generation failure
3613
+ // (remote-empty, cooldown under NO_LOCAL_MODELS, local parse-empty/error). Detect
3614
+ // that leaked fallback AND the all-junk case, and return an expansions-only set
3615
+ // that is NOT cached — a transient failure must not poison the cache or break the
3616
+ // "expansions only, original excluded" contract.
3617
+ const cleaned = sanitizeExpandedQueries(results).filter(r => r.text !== query);
3618
+ if (cleaned.length === 0 || isFallbackExpansion(results, query)) {
3619
+ return expansionFallback(query)
3620
+ .filter(r => r.text !== query) // expansions-only per the ExpandedQuery contract
3621
+ .map(r => ({ type: r.type, query: r.text }));
3565
3622
  }
3566
3623
 
3567
- return Array.from(new Set([query, ...queryTexts]));
3624
+ const expanded: ExpandedQuery[] = cleaned.map(r => ({ type: r.type, query: r.text }));
3625
+ setCachedResult(db, cacheKey, JSON.stringify(expanded));
3626
+ return expanded;
3568
3627
  }
3569
3628
 
3570
3629
  // =============================================================================