{"id":"28816658-7b9a-4b62-82ba-587812eefd1e","slug":"barber","kind":"skill","name":"barber","summary":"Query-aware context trimming for LLM requests. Your context could use a trim.","owner":"NadirRouter","repoUrl":"https://github.com/NadirRouter/barber","license":"MIT","licenseTier":"permissive","trust":"community","stars":5,"match":null,"fewStars":false,"format":"skill","createdAt":"2026-07-24T14:42:04.000Z","addedAt":"2026-10-21T17:48:05.329Z","logoUrl":"https://avatars.githubusercontent.com/u/267796829?v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["memory-knowledge","search"],"tags":["context","cost-optimization","llm","prompt-compression","rag","tokens"],"runtime":"python","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-25T15:28:36.547Z","sample":false,"url":"https://conduits.dev/tools/barber","language":"Python","transports":["stdio"],"install":{"pypi":"barber-llm"},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"<p align=\"center\">\n  <img src=\"https://raw.githubusercontent.com/NadirRouter/barber/main/assets/barber-readme-hero.png\" alt=\"barber: your context could use a trim\" width=\"100%\">\n</p>\n\n# barber\n\nQuery-aware context trimming for LLM requests.\n\n**Your context could use a trim.** *33% off the top.*\n\n[![PyPI](https://img.shields.io/pypi/v/barber-llm)](https://pypi.org/project/barber-llm/)\n[![npm](https://img.shields.io/npm/v/barber-llm)](https://www.npmjs.com/package/barber-llm)\n[![CI](https://github.com/NadirRouter/barber/actions/workflows/ci.yml/badge.svg)](https://github.com/NadirRouter/barber/actions/workflows/ci.yml)\n[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://pypi.org/project/barber-llm/)\n[![License: MIT](https://img.shields.io/badge/license-MIT-green)](https://github.com/NadirRouter/barber/blob/main/LICENSE)\n[![Deps](https://img.shields.io/badge/dependencies-zero-brightgreen)](https://github.com/NadirRouter/barber/blob/main/pyproject.toml)\n\n## The 30-second pitch\n\nRetrieved context is mostly irrelevant to any single question. Your RAG stack\nfetches ten paragraphs, the question needs two, and you pay for all ten on\nevery request.\n\nbarber embeds the chunks and the question, keeps what matters, and drops the\nrest. Nothing is rewritten or summarized: chunks survive verbatim or vanish.\nNo model calls at trim time, no required dependencies, deterministic output.\n\nIn the published benchmark that locked barber's defaults, that meant 31.8 to\n34.1% of context tokens gone with answer quality within noise of full context\n([numbers below](#the-benchmark)).\n\n## Install\n\n```bash\npip install barber-llm\n```\n\nThe PyPI name is `barber-llm`; the import name is `barber`. Zero required\ndependencies. Extras when you want them:\n\n| Extra | Pulls in | Gives you |\n|---|---|---|\n| `barber-llm[semantic]` | `sentence-transformers` | semantic scoring, paraphrase-safe |\n| `barber-llm[tokens]` | `tiktoken` | exact token counts in `TrimResult` |\n| `barber-llm[eval]` | `datasets`, `openai`, `tiktoken` | the `barber-eval` benchmark harness |\n| `barber-llm[langchain]` | `langchain-core` | a `BaseDocumentCompressor` for `ContextualCompressionRetriever` |\n| `barber-llm[llamaindex]` | `llama-index-core` | a `BaseNodePostprocessor` for a query engine |\n\nJavaScript is a first-class citizen too. The [npm package](https://www.npmjs.com/package/barber-llm)\nis a zero-dependency port of the same algorithm with the same defaults, kept\ndecision-identical to this package by golden fixtures regenerated from the\nPython implementation and replayed in CI ([js/](js/)):\n\n```bash\nnpm install barber-llm\n```\n\nor one-shot from the shell:\n\n```bash\nnpx barber-llm --keep 0.6 < messages.json > trimmed.json\n```\n\nThe two packages version independently, so the numbers in the two badges above\ndo not line up and are not meant to: PyPI runs ahead because `sweep()` and the\neval harness are Python-only. `trim()` is the same algorithm with the same\ndefaults and the same decisions on both, which is what the golden fixtures gate\nin CI.\n\n## Quickstart\n\nZero-dependency lexical mode, runnable as pasted:\n\n```python\nfrom barber import trim\n\ncontext = \"\\n\\n\".join(f\"Passage {i}: facts about topic {i}.\" for i in range(40))  # your RAG block\nmessages = [{\"role\": \"user\", \"content\": context},\n            {\"role\": \"user\", \"content\": \"What do the passages say about topic 7?\"}]\n\nresult = trim(messages)   # keep=0.6, lexical fallback, zero deps\nprint(result.tokens_saved, result.chunks_dropped, result.changed)\n# result.messages is the same conversation, fewer tokens; send it to your LLM\n```\n\nEach dropped run costs about 20 tokens of marker. If those markers cost at\nleast as much as a message's dropped text saved, barber leaves that message\nuntouched. `tokens_saved` is never negative.\n\nSemantic mode, the configuration the benchmark shipped with:\n\n```python\nfrom barber import trim, embedders\n\nembed = embedders.sentence_transformers(\n    \"llm-semantic-router/mmbert-embed-32k-2d-matryoshka\",\n    trust_remote_code=True,   # this checkpoint ships code; see below\n)\nresult = trim(messages, keep=0.6, embedder=embed)\n```\n\n`trust_remote_code` runs Python from the HF repo, at load time, in your\nprocess. It defaults to `False`, so passing a `model_name` through from config\ncannot execute someone else's code without you writing that flag. mmbert needs\nit; `BAAI/bge-small-en-v1.5` (which tied with it in the benchmark) does not.\n\nThe lexical fallback is deterministic and dependency-free, but it matches\nwords, not meaning. Its tokenizer is Unicode-aware, so accented Latin,\nCyrillic, Greek, Hebrew, Arabic and Hangul all score normally; unspaced CJK\nfalls back to character unigrams, which is real signal but a weak one, and\n`min_message_chars` (800) is a Latin-sized gate you will want lower for Chinese\nor Japanese. What it never does in any language is match a paraphrase. For\nproduction traffic use the semantic embedder above (32K context window) or\n`BAAI/bge-small-en-v1.5` as the lightweight alternative; the two tied on\nquality in the published runs. There is also\n`embedders.endpoint(base_url, model, api_key)` for any OpenAI compatible\n`/v1/embeddings` server (vLLM, TEI), which needs the `openai` package.\n\nFor a local Jev-style decision scorer on Python 3.11+, install\n`decider-ai[gguf]` and select it explicitly:\n\n```python\nscore = embedders.decider()  # Mapika/decider-4b-GGUF, Q4_K_M; downloads 2.7 GB\ntry:\n    result = trim(messages, keep=0.6, embedder=score)\nfinally:\n    score.close()\n```\n\nDecider scores all passages in one typed decision call. It needs more time and\nmemory than BGE; [the paired trial](barber/eval/results/decider_hotpotqa_2026-09-29.md)\nshows the measured savings and answer-quality limits. Pass\n`gguf_options={\"n_ctx\": ...}` if you want to size its context explicitly.\n\nMulti-turn pipelines use the transform form. Pass a shared cache and a block is\ndecided once, then replayed byte-identically on every later turn, so your\nprovider prompt cache stays warm:\n\n```python\nfrom barber import make_transform, Cache\n\ncache = Cache()                          # bounded LRU, safe for a long-running process\nname, fn = make_transform(embedder=embed, keep=0.6, cache=cache)\nmessages, changed = fn(messages)         # call this every turn\n```\n\nA plain dict works too, but it never evicts: one entry per distinct context\nblock for the life of the process. `Cache` bounds it and is thread-safe, so a\nthreaded server can share one. Two things to size against:\n\n- An entry holds one whole trimmed block, so the bound is a count, not a byte\n  budget. At `maxsize=4096` with large RAG blocks that is hundreds of megabytes\n  per process. Lower it if your blocks are big.\n- Keep `maxsize` above your live-conversation count. Evicting a block means the\n  next turn decides it again against the then-current question, which is the\n  prefix churn the cache exists to prevent.\n\n## When barber does nothing\n\n`trim()` returns `changed=False` and leaves your messages untouched unless some\nmessage meets every one of these:\n\n| Requirement | Why |\n|---|---|\n| Role is `user`, `tool`, or `function` | System and assistant messages are never touched. |\n| Not the latest user message | That message is the question, and the question is never trimmed. |\n| `content` is a string, or text/`tool_result` parts | Other parts (images, `tool_use` inputs) pass through untouched. |\n| At least 800 characters | Anything shorter is not retrieved context. |\n| At least 4 chunks | Below that there is nothing to choose between. |\n\nThe common surprise is the single-message shape. This is a no-op, because the\nonly message present is the question:\n\n```python\nmessages = [{\"role\": \"user\", \"content\": f\"Context:\\n{docs}\\n\\nQuestion: {q}\"}]\n```\n\nGive the context its own message and barber has something to work with:\n\n```python\nmessages = [{\"role\": \"user\", \"content\": docs},\n            {\"role\": \"user\", \"content\": q}]\n```\n\nMost APIs accept consecutive user messages. If yours does not, put the context\nin a `tool` or `function` message, which is where retrieved context usually\narrives anyway.\n\n## LangChain and LlamaIndex\n\nA retriever post-processor gets a query and N candidate documents and returns a\nsubset. That is the same job the benchmark below measured — query plus candidate\npassages, keep what answers the question — so it is the one framework slot where\nthose numbers describe the work being done rather than something adjacent to it.\n\n```bash\npip install \"barber-llm[langchain]\"\n```\n\n```python\nfrom langchain_classic.retrievers import ContextualCompressionRetriever\nfrom barber.integrations.langchain import BarberDocumentCompressor\n\nretriever = ContextualCompressionRetriever(\n    base_compressor=BarberDocumentCompressor(keep=0.6),\n    base_retriever=vectorstore.as_retriever(search_kwargs={\"k\": 20}),\n)\ndocs = retriever.invoke(\"What is the refund policy?\")\n```\n\n`BarberDocumentCompressor` subclasses `langchain_core.documents.compressor.BaseDocumentCompressor`,\nso it drops into any slot that takes one, including `DocumentCompressorPipeline`.\n`acompress_documents` is inherited: the base class runs the sync path in an\nexecutor, which is what you want for CPU work with no I/O to await.\n`ContextualCompressionRetriever` lives in `langchain_classic.retrievers` on\nLangChain 1.x and `langchain.retrievers` on 0.x; the compressor itself only\nimports `langchain_core`, which both share (tested from `langchain-core` 0.3.0).\n\nThe incumbent in that slot, `LLMChainExtractor`, invokes its chain once per\nretrieved document, sequentially — 20 LLM calls for a `k=20` retrieval, on every\nquery. barber makes none: scoring is one embedding pass, or pure lexical math\nwith no dependencies at all.\n\nLlamaIndex is the same shape:\n\n```bash\npip install \"barber-llm[llamaindex]\"\n```\n\n```python\nfrom barber.integrations.llama_index import BarberNodePostprocessor\n\nengine = index.as_query_engine(\n    similarity_top_k=20,\n    node_postprocessors=[BarberNodePostprocessor(keep=0.6)],\n)\n```\n\n`BarberNodePostprocessor` subclasses `BaseNodePostprocessor` and inherits\n`apostprocess_nodes`, which the base class runs on a thread. That method arrived\nin `llama-index-core` 0.13, which is where the extra floors; the sync path alone\nworks from 0.11.\n\nBoth take `keep`, an `embedder`, and a `SelectionConfig`, and both retrieve\nwide-then-cut: `k=3` leaves nothing to select between, and barber will say so by\nreturning all three. Four things to know before wiring either one in:\n\n- **It filters, it does not extract.** A kept document comes back as the object\n  that went in — same text, same metadata, same order, no relevance score\n  written. Nothing is rewritten, so citations and source links downstream still\n  resolve. The flip side is that a long document that is half relevant comes\n  back whole; trimming *inside* a document is `trim()` on a message list.\n- **barber's gates still apply.** Under 4 documents, or under 800 characters\n  across all of them, there is nothing worth cutting and the list comes back\n  untouched ([the full table](#when-barber-does-nothing)).\n- **`keep` is a budget, not a quota.** Pinning and lead/tail keep documents\n  above it; the relevance floor cuts below it. On a candidate set where only one\n  or two documents share vocabulary with the query — the normal case for the\n  zero-dependency lexical fallback, which matches words and not meaning — the\n  floor is what decides and `keep` barely shows up. Pass the same encoder you\n  retrieve with, via `embedders.sentence_transformers()` or\n  `embedders.endpoint()`, and the decision becomes semantic.\n- **The published numbers are not a benchmark of these adapters.** [The table\n  below](#the-benchmark) is HotpotQA passages, LLM-judged, run through\n  `trim()`. The adapters map a document set onto that same call — one document,\n  one chunk, the block shape the harness builds — but nobody has run the eval\n  through `ContextualCompressionRetriever` on your corpus. `barber-eval` is\n  committed if you want your own number.\n\n## Coding agents\n\nA coding-agent transcript is not a RAG block, and two of its habits defeat\nplain `trim()`:\n\n* **Its context lives in content parts.** A tool result is a `tool_result`\n  block, not a string, and that is where the big text is. `trim()` now walks\n  into text and `tool_result` parts (`tool_use` inputs and images are passed\n  through untouched).\n* **Its output has no blank lines.** A file read, a grep, an `ls`, a diff — the\n  paragraph splitter finds one chunk and selection declines the block. barber\n  now falls back to line structure when, and only when, nothing else found any:\n  a line-numbered read is chunked on the blank lines of the underlying *file*,\n  a diff on its hunks, flat output on line windows. A JSON body is never\n  line-chunked, because half a JSON object does not parse.\n\nFor shell tools, trim text before it reaches the agent's conversation:\n\n```bash\nnl -ba src/request.py | barber-trim --query \"Where are request retries handled?\"\n```\n\n`barber-trim` reads stdin and writes only the selected text to stdout. It uses\nthe hook's conservative `keep=0.8` default and passes input through when\ntrimming would save no tokens. Re-read a file if a dropped section is needed;\nthe coding-agent path has not been tested for task success.\n\nSelection still only guesses relevance. In an agent transcript, most of the\ndead weight does not need a guess at all — it is content the transcript itself\nproves is no longer true:\n\n```python\nfrom barber import trim\nfrom barber.agent import sweep\n\nresult = sweep(trim(messages).messages)\n```\n\n`sweep()` drops three things, each provable from the transcript, each marked\nwith the file path so the agent can just read it again:\n\n| Dropped | Because |\n|---|---|\n| A file body written before the same file was **fully rewritten** | It is not the file any more. A later `Edit` does *not* count: the file still holds that body. |\n| A `Read` result for a file **modified since** | It is not the file any more. |\n| A byte-identical repeat of an earlier tool result | It says nothing new. |\n\nBlocks are emptied, never removed: a request rejects a `tool_use` with no\nmatching `tool_result`, so the structure survives even when the content does\nnot.\n\n**`sweep()` rewrites history, and `trim()` deliberately does not.** The\nfreeze-on-first-sight cache exists to keep the prefix byte-stable so the\nprovider prompt cache stays warm; `sweep()` edits messages in the middle of the\nconversation, so everything after the earliest edit is re-primed once. That is\na good trade at a compaction point with many turns left to amortize it, and a\nbad one on turn three — which is why it is a call you make, not something\n`trim()` does behind your back.\n\nTell it how many turns are left and it will do that arithmetic for you:\n\n```python\nsweep(messages, remaining_turns=50)\n```\n\nAn edit at position P costs re-writing every token after P at 1.25x instead of\nreading it back at 0.1x, and buys those removed tokens never being re-read\nagain. With `T` tokens after the cut and `S` of them removed, it only pays once\n\n    S/T > 1.15 / (1.15 + 0.1 * remaining_turns)\n\n— 53% of the tail at 10 turns, 19% at 50, 10% at 100. Editing later costs less\nand saves less, so `sweep()` scores every candidate cut point and keeps only\nthe edits at or after the best one; everything before it is left alone and\nstays cached. When nothing clears the bar it changes nothing and says so. The\n15.7% of tool tokens the sweep can find needs roughly 62 remaining turns to pay\nfor itself if you claim all of it from the front of the transcript.\n\nLeft unset, `remaining_turns` edits everything it finds — right only when the\ncache is already cold.\n\nMeasured on six real Claude Code sessions (1.16M tokens of transcript), in\nquota units that charge cache reads at 0.1x and cache writes at 1.25x, and\npaying the sweep's re-prime cost in full:\n\n| | Tokens removed | Quota saved |\n|---|---|---|\n| `trim()` before this (content-parts guard) | 0.0% | 0.0% |\n| `trim()` | 11.5% | 14.9% |\n| `trim()` + `sweep()` | 19.5% | 23.7% |\n\nThat is 1.31x the turns per unit of quota. Your mileage varies with what your\nsession does: the spread across those six sessions was 1.18x to 1.68x, and the\nsessions that gain most are the ones that rewrite the same files repeatedly.\n`sweep()` is Python-only for now; the JS port has the `trim()` half.\n\n## Claude Code plugin\n\n`trim()` and `sweep()` act on a conversation you already own. Inside Claude Code\nyou do not own it, so the same work happens in a `PostToolUse` hook, and this\nrepo installs as a plugin that registers it:\n\n```bash\nclaude plugin marketplace add NadirRouter/barber\nclaude plugin install barber@barber\n```\n\nThere is no `pip install` step and no path to edit. barber's hook import chain\n(`barber.core` plus the lexical embedder) touches nothing outside the standard\nlibrary, and a plugin install is a clone of this repo with `barber/` already in\nit, so the hook resolves the package next to itself. An installed `barber-llm`\nstill wins if you have one.\n\nThe hook trims `Read`, `Grep`, `Glob`, `Bash`, `BashOutput`, `NotebookRead`,\n`WebFetch` and `WebSearch` results against the live question plus that call's own\narguments — the grep pattern or file path is usually the sharper signal. It\nleaves `Edit`, `Write`, `TodoWrite` and `Task` alone, along with anything under\n800 characters, any non-string result, and any body starting `{` or `[`, because\nhalf a JSON object does not parse. Measured on 12 real sessions (3,995 tool\nresults, 958K tokens of tool output) the policy fires 464 times and removes 21.5%\nof tool-output tokens, versus 12.1% for token-optimizer's first-read structure\nmap, and survivors stay byte-exact where a structure map discards function\nbodies irrecoverably.\n\nThree env vars, no settings file involved:\n\n| | |\n|---|---|\n| `BARBER_HOOK_KEEP=0.8` | fraction of chunks kept |\n| `BARBER_HOOK_MIN_CHARS=800` | leave anything smaller alone |\n| `BARBER_HOOK_DISABLE=1` | off for this shell |\n\n**This is experimental and [the benchmark](#the-benchmark) does not cover it.**\nThose numbers were judged on RAG passages answering a question, not on tool\noutput an agent is about to act on, and dropping the one grep hit the agent\nneeded is a different and worse failure than dropping a passage a reader didn't\nneed. The hook therefore defaults to `keep=0.8` where the library defaults to\n0.6.\n\nThat margin is close to free, which a second replay over 30 sessions from 30\ndifferent projects (3,970 tool results, 1.57M tokens of tool output) puts a\nnumber on:\n\n| `keep` | fires | of eligible tokens | of all tool output |\n|---|---|---|---|\n| 0.6 | 476 | 18.3% | 10.4% |\n| 0.8 | 467 | 18.1% | 10.3% |\n\nTwo tenths of a point, because `keep` is a budget cap and the relative floor is\nwhat does the cutting; the cap rarely binds. Note also the two denominators:\nonly 56.6% of tool-output tokens clear the eligibility gates at all, so the same\nremoval is \"18.1% of eligible\" or \"10.3% of everything the tools emitted\"\ndepending on which you quote. Read the four caveats at the bottom of\n[contrib/claude_code_hook.py](https://github.com/NadirRouter/barber/blob/main/contrib/claude_code_hook.py)\nbefore running it on real work. Off again with\n`claude plugin uninstall barber@barber`.\n\nIt is also the one thing here that is free: the rewrite happens before the\noutput enters context, so nothing cached is disturbed ([next section](#the-prompt-cache)).\n\n## MCP server\n\nThe hook above only works in Claude Code, because it needs a point that\nrewrites tool output before the model sees it and Claude Code is currently the\nonly agent that honours one. MCP is the portable shape. After\n`pip install barber-llm`:\n\n```json\n{\"mcpServers\": {\"barber\": {\"command\": \"barber-mcp\"}}}\n```\n\nOne tool, `trim(text, query, keep?)`, which drops the parts of a block that are\nirrelevant to the question and returns the rest byte-for-byte. It is the\nbenchmarked path rather than the experimental one: a question and some\ncandidate passages is exactly the shape [the benchmark](#the-benchmark) judged.\n\nThe server speaks JSON-RPC over stdio directly instead of taking the `mcp` SDK,\nwhich would pull pydantic, anyio and httpx into a package whose whole premise is\nhaving no dependencies. `pip install barber-llm` still installs barber and\nnothing else.\n\nUnlike the hook, this one is pull rather than push: the agent decides when to\ncall it, so it trims what you point it at instead of everything that goes past.\n\n## The prompt cache\n\nProviders bill a cached token at a fraction of a fresh one — Anthropic reads at\n0.1x and writes at 1.25x — and the cache is a **prefix** match: change one byte\nat position N and everything after N is billed as new. That single fact decides\nwhat each part of barber is allowed to do.\n\n| | Disturbs the cached prefix | What it costs |\n|---|---|---|\n| `trim()` with a shared `Cache` | no | nothing; decisions are frozen and replayed byte-identically |\n| `trim()` with no cache | yes, every turn | each block is re-decided against the current question, so the prefix churns |\n| `sweep()` | yes, from the earliest edit | one re-prime of everything after it — priced when you pass `remaining_turns` |\n| `contrib/claude_code_hook.py` | no | nothing; it rewrites tool output before it ever enters context |\n\nThree rules follow from the table:\n\n1. **Pass a shared `Cache` in any multi-turn loop.** Without one, `trim()` still\n   removes tokens but re-decides every block each turn, and a prefix that\n   changes shape each turn is a prefix nobody caches. This is the single\n   cheapest thing on this page.\n2. **Prefer trimming at admission over trimming in hindsight.** An agent\n   re-sends its whole conversation every turn, so a token removed before it\n   enters context is saved once per remaining turn, while the same token\n   removed afterwards costs a re-prime to collect. Both are worth doing; only\n   one is free.\n3. **Treat `sweep()` as a spend, not a saving, until you have counted the\n   turns.** It is the only call here that rewrites history, which is why it is\n   explicit and why `remaining_turns` exists.\n\nOne caveat worth stating plainly: a cached token is cheap, not free, and it\nstill occupies the context window. Trimming the stable prefix of a long session\nis a context-quality decision, not a cost decision — do not expect the quota\nnumbers above to follow.\n\n## The benchmark\n\n<img src=\"https://raw.githubusercontent.com/NadirRouter/barber/main/assets/barber-benchmark-results.png\" alt=\"benchmark stat card\" align=\"right\" width=\"300\">\n\nbarber's defaults were locked by a paired A/B on HotpotQA (distractor config):\nanswer each question with full context and with trimmed context, then have a\nblind judge grade both answers against the gold reference.\n\n| | Medium (~6K tok) | Large (~14K tok) |\n|---|---|---|\n| Tokens saved | 31.8% | 34.1% |\n| Answer-paragraph retention | 100% | 100% |\n| Full-context accuracy | 97.2% | 94.8% |\n| Trimmed-context accuracy | 96.0% | 95.9% |\n\nAbout 350 judged pairs, MiniMax M3 as the blind judge, answers graded against\ngold references, `keep=0.6`. On large contexts trimmed beat full: selection\nremoves the distractors models trip over.\n\nTwo things to know before you rely on that table. **The judge and the generator\nare the same model by default**, which is the classic setup for\nself-preferencing; the protocol blunts it (blind, order-randomized, graded\nagainst a gold answer rather than on preference) but does not eliminate it, so\nre-run with `GEN_MODEL` pointed at a different model before treating these as\nmodel-independent. **And these are our numbers on our run** — the harness is\ncommitted and seeded, the per-pair records are not, so reproducing the table\nmeans spending your own credit. `barber-eval --out results.jsonl` writes the\nper-pair records if you want to keep or publish yours.\n\nFull write-up: [the benchmark post](https://getnadir.com/blog/context-selection-benchmark-minimax.html).\nFull protocol: [docs/methodology.md](https://github.com/NadirRouter/barber/blob/main/docs/methodology.md). Reproduce it:\n\n```bash\npip install \"barber-llm[eval]\" && barber-eval --n 200 --keep 0.6 --size large\n```\n\n<br clear=\"right\">\n\n## How it works\n\n<p align=\"center\">\n  <img src=\"https://raw.githubusercontent.com/NadirRouter/barber/main/assets/barber-how-it-works.png\" alt=\"how barber works\" width=\"100%\">\n</p>\n\n1. **Query.** The latest user message is the question. It is never trimmed.\n2. **Candidate blocks.** Large user, tool, and function messages (800+ chars,\n   4+ chunks). System and assistant messages are never candidates.\n3. **Chunk.** Split each block on blank lines, `---` rules, headings, and\n   `[n]` citation markers, the boundaries RAG concatenations already have.\n   Falls back to sentences for a single wall of prose.\n4. **Score.** Embed every chunk plus the question, cosine each chunk against\n   the question, keep the top `keep` fraction.\n5. **Guards.** Pinned chunks bypass the budget entirely, the first and last\n   chunk always survive, and a relevance floor drops pure noise even when the\n   budget would keep it. Details below.\n6. **Marker.** Each dropped run collapses into one line, so the model knows\n   the cut happened and doesn't go looking for missing text:\n\n   ```\n   [… 4 passage(s) omitted as not relevant to this question — the remaining context is sufficient …]\n   ```\n\n   The assertive wording is deliberate and benchmark-locked: it won our\n   marker ablation. The working theory is that a neutral \"lower-relevance\n   passages omitted\" invites the model to hedge, while this one tells it to\n   proceed. Re-run the ablation yourself with `barber-eval --marker neutral`.\n\nDecisions are memoized on the block hash alone, never the query. The first\nturn to see a block decides it; every later turn replays the decision\nbyte-identically. Your history prefix stays stable and your provider prompt\ncache keeps hitting.\n\n## What barber does not do\n\n- **No summarization.** Chunks survive verbatim or vanish. A summary is a\n  rewrite, and rewrites can silently invent or lose facts.\n- **No letter tricks.** Dropping vowels, truncating words, gzip-then-base64:\n  in the same benchmark, every character-level scheme cost MORE tokens, not\n  fewer. Letter removal measured 1.34x to 1.69x the tokens; classic\n  compression 3.2x to 3.7x. Tokenizers are already compressors; fighting them\n  backfires. [The numbers](https://getnadir.com/blog/context-selection-benchmark-minimax.html).\n- **No history compaction.** Providers do that natively now, and re-writing\n  old turns busts their prompt caches. barber targets fresh retrieved context\n  only.\n- **No model calls at trim time.** Scoring is an embedding pass (or pure\n  lexical math). Nothing is sent anywhere unless you opt into the\n  `endpoint()` embedder.\n\n## Guards, or why your answers survive\n\nThe failure mode of context pruning is silent: drop the one chunk that held\nthe answer and nothing errors, the model just answers worse. barber ships with\nevery guard on:\n\n- **Deontic and PII pinning.** Chunks with constraint language (\"must\",\n  \"never\", \"do not\", \"don't\", \"shall not\", \"prohibited\", \"required\",\n  \"only if\") or sensitive-data markers (PII, HIPAA, PCI, SSN, password,\n  secret, API key) are never dropped. **These patterns are English.** Nothing\n  else in barber is: the tokenizer is Unicode-aware and scores any script. But\n  a French block saying *\"ne doit jamais\"* is not pinned unless you say so:\n\n  ```python\n  import re\n  from barber import SelectionConfig, trim\n\n  fr = SelectionConfig(pin_patterns=[\n      re.compile(r\"\\b(doit|doivent|ne\\s+doit\\s+pas|interdit|obligatoire|jamais)\\b\", re.I),\n      re.compile(r\"\\b(?:RGPD|données\\s+personnelles|mot\\s+de\\s+passe|clé\\s+API)\\b\", re.I),\n  ])\n  trim(messages, keep=0.6, cfg=fr)\n  ```\n\n  The JS port takes the same list as `config.pinPatterns`.\n- **Rare-query-entity pinning.** A query term that appears in only one or two\n  chunks of a block is a strong \"this chunk answers the question\" signal.\n  Those chunks are never dropped, which is what protects multi-hop questions.\n- **Lead and tail keep.** The first and last chunk of every block survive:\n  headers, conclusions, and the lost-in-the-middle mitigation.\n- **Relevance floor (0.35).** A chunk scoring far below the block's top chunk\n  is noise even if the budget has room for it.\n- **Never touched at all:** the latest user message, system messages,\n  assistant messages, non-string content, any message under 800 chars, any\n  block under 4 chunks. See [when barber does nothing](#when-barber-does-nothing).\n\nIn the benchmark these guards held answer-paragraph retention at 100% in both\npublished sizes ([table above](#the-benchmark)).\n\n## Evaluate it on your data\n\nPublished numbers are an existence proof, not a guarantee for your traffic.\nThe harness that produced them ships in the package:\n\n```bash\npip install \"barber-llm[eval]\"\nexport JUDGE_API_KEY=sk-...     # any OpenAI compatible judge; MiniMax by default\n\nbarber-eval --baseline --n 25   # sanity-check generator + judge first\nbarber-eval --n 200 --keep 0.6 --size large\n```\n\nThe number to gate on is the **regression rate**: how often trimming turned a\nright answer wrong. Hold it under 1 to 2%, then take the most aggressive\n`keep` that stays under your bar. Tokens saved is the payoff, not the gate.\nThe exact judge prompt ships in\n[barber/eval/JUDGE_PROMPT.md](https://github.com/NadirRouter/barber/blob/main/barber/eval/JUDGE_PROMPT.md), including a\nreference-free variant for chat data without gold answers. Judge and generator\nare swappable via `JUDGE_*` and `GEN_*` env vars\n([details](https://github.com/NadirRouter/barber/blob/main/docs/methodology.md)).\n\n## Credits\n\n- The **MiniMax team**: MiniMax M3 was the generator and the blind judge for\n  the entire eval, about 2,000 model calls on well under $20 of credit.\n- The **vLLM Semantic Router team**: their\n  [mmbert-embed-32k-2d-matryoshka](https://huggingface.co/llm-semantic-router/mmbert-embed-32k-2d-matryoshka)\n  is the recommended embedder, picked for its 32K window.\n\n---\n\nWant this tier-aware, quality-monitored in production, and billed only on\nverified savings? That's [Nadir](https://getnadir.com).\n"}