Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

README.md

barber

Query-aware context trimming for LLM requests.

Your context could use a trim.

npm

This is the JavaScript port of barber-llm (Python). Same algorithm, same defaults, zero dependencies. Every release is verified against golden fixtures generated by the Python implementation: the port must reproduce its decisions exactly, message for message and count for count.

It ports trim(), not the whole package: sweep(), the semantic embedders and the eval harness stay on the Python side, and the two packages carry different version numbers on purpose. See relation to the Python package.

What it does

Retrieved context is mostly irrelevant to any single question. Your RAG stack fetches ten paragraphs, the question needs two, and you pay for all ten on every request.

barber embeds the chunks and the question, keeps what matters, and drops the rest. Nothing is rewritten or summarized: chunks survive verbatim or vanish. No model calls at trim time, no dependencies, deterministic output.

In the published benchmark that locked barber's defaults, that meant 31.8 to 34.1% of context tokens gone with answer quality within noise of full context. Numbers and method: the benchmark post. The benchmark ran on the Python implementation; this package replays its selection decisions identically on the fixture suite.

Install

npm install barber-llm

Node 18 or newer. ESM only.

Quickstart

import { trim } from "barber-llm";

const messages = [
  { role: "system", content: "Answer from the provided context." },
  { role: "user", content: retrievedContext }, // big block, many chunks
  { role: "user", content: "What is the export size limit?" },
];

const result = trim(messages, { keep: 0.6 });
console.log(result.tokensSaved, result.chunksDropped);
// send result.messages instead of messages

Or one-shot from the shell, no install:

npx barber-llm --keep 0.6 < messages.json > trimmed.json

stdin takes a JSON array of {role, content} messages or {"messages": [...]}. The trimmed messages go to stdout, a one-line summary goes to stderr.

API

trim(messages, options) returns { messages, tokensSaved, chunksDropped, changed }.

  • keep: fraction of chunks retained per block. 0.6 is the benchmark default.
  • embedder: optional embed(texts) -> vectors function. Defaults to a deterministic lexical scorer with zero dependencies. Dense vectors (arrays or typed arrays) work; the embedder has to be synchronous.
  • cfg: partial config overrides (dropMarker, minMessageChars, ...).
  • cache: shared decision cache for multi-turn use, see below.

Relevance scoring is Unicode-aware, so any script ranks. The never-drop patterns are not: cfg.pinPatterns ships English deontic and PII vocabulary ("must", "never", "password", ...), so give it your own for another language:

trim(messages, { keep: 0.6, cfg: { pinPatterns: [
  /(?<![\p{L}\p{N}_])(?:doit|doivent|interdit|obligatoire|jamais)(?![\p{L}\p{N}_])/iu,
] } });

makeTransform({ keep, embedder, cfg, cache }) returns a ["barber", fn] pair where fn(messages) -> [messages, changed], for pipeline integration.

new Cache({ maxsize }) is a bounded LRU for long-running processes. Passing a shared cache gives freeze-on-first-sight: the first turn to see a block decides it, every later turn replays that decision byte-identically, so the stable prefix never mutates and the provider prompt cache stays warm. A plain Map also works and never evicts.

tokensSaved is an estimate (code points / 4) and it is signed: negative means the drop markers cost more than the dropped chunks saved.

When barber does nothing

A message is only trimmed when all of these hold:

  • role is user, tool, or function (system and assistant are never touched)
  • it is not the latest user message (that one is the question)
  • content is a plain string, not an array of content parts
  • at least 800 characters and at least 4 chunks

Context and question packed into one user message is a no-op. Put the context in its own earlier message.

Guards

Always on: the first and last chunk of a block survive, chunks matching safety and policy wording survive, chunks carrying rare query entities survive, and chunks far below the top relevance score drop even when the budget would keep them.

The prompt cache

Providers bill a cached token at a fraction of a fresh one — Anthropic reads at 0.1x and writes at 1.25x — and the cache is a prefix match: change one byte at position N and everything after N is billed as new.

trim() is built to respect that. It never rewrites earlier turns, and with a shared cache the decision for a block is frozen the first time it is seen and replayed byte-identically forever after, so the prefix your provider cached stays exactly as it was. Pass a shared cache in any multi-turn loop — without one, trim() still removes tokens but re-decides each block against the current question, and a prefix that changes shape every turn is a prefix nobody caches.

A cached token is cheap, not free, and it still occupies the context window. Trimming the stable prefix of a long session is a context-quality decision, not a cost one.

Relation to the Python package

The Python package is the source of truth and carries the evaluation harness (pip install barber-llm[eval]). The selection logic is benchmark-locked: changes land there first, get re-benchmarked, then port here with regenerated fixtures. Full docs live in the repository.

What this port is

npm barber-llm PyPI barber-llm
trim() — query-aware chunk selection yes, decision-identical yes
Guards (lead/tail, deontic + PII pins, rare-entity pins, relevance floor) yes yes
makeTransform() / make_transform(), shared decision Cache yes yes
CLI (npx barber-llm / stdin JSON) yes
Lexical zero-dependency embedder yes yes
Semantic embedders (sentence_transformers, OpenAI-compatible endpoint) no yes
sweep() — agent-transcript dead-weight removal no yes
barber-eval benchmark harness no yes

sweep() is deliberately absent, not merely pending. Unlike trim() it rewrites history in the middle of a conversation, so it has to price the resulting prompt-cache re-prime against the turns remaining — arithmetic that belongs next to the benchmark that calibrated it. Bring your own embedder via the embedder option if you need semantic scoring here; the interface is a synchronous embed(texts) -> vectors function.

The version numbers are not meant to match

npm and PyPI carry different numbers on purpose, and no release will ever be cut to line them up. They version independently because they ship different surfaces: a Python-only sweep() fix is a Python release and nothing changes here, so PyPI runs ahead. The npm number describes this package, the PyPI number describes that one, and neither is a statement about the other.

What is guaranteed across the two is the only thing that could be: given the same messages and the same options, trim() returns the same messages and the same counts on both, whatever version each is at. That is what the golden fixtures — generated by the Python implementation, replayed by this package's test suite — gate in CI on every change.

License

MIT