Fit any conversation into any context window β deterministically, with a full audit of what you dropped.
Quickstart Β· Audit trail Β· Live demo Β· Strategies Β· Providers Β· Tokenizers Β· Contributing
You give it a list of messages (system + history + tool results) and a token budget; it returns a pruned/compacted list that fits β plus structured metadata about what it dropped and why. Zero runtime dependencies, deterministic by default, and never a silent cut.
Token counting is solved (js-tiktoken, gpt-tokenizer, provider count endpoints). Agent frameworks solve compaction internally β but lock you in. Memory services are heavy and often hosted. There is no small, dependency-light, framework-agnostic library whose one job is "here are my messages and my budget β make them fit, and tell me what you cut." Every agent developer writes this by hand in their loop. contextfit is that missing piece.
- Zero required runtime dependencies. A fast built-in heuristic estimator; exact tokenizers are optional and injected by you.
- Deterministic by default. The default pipeline never calls an LLM and never hits the network. Same input + budget β byte-identical output.
- Never drops silently. Every removal, shrink, and summary is recorded in
result.droppedwith the message ref, the strategy, and token deltas. - Provider-agnostic. One normalized
Messagemodel in and out; adapters for OpenAI, Anthropic, and Gemini. - Runs everywhere. Node β₯ 20, Bun, Deno, Cloudflare Workers, Vercel Edge. No
node:*in core.
npm install contextfit # or: pnpm add contextfit / bun add contextfitimport { type Message, fit, pinSystem, slidingWindow, truncateToolOutput } from "contextfit";
const messages: Message[] = [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Explain context windows in one paragraph." },
];
const result = await fit(messages, {
budget: { context: 128_000, reserveForOutput: 4_096 },
strategies: [pinSystem(), truncateToolOutput({ maxTokensPerResult: 2_000 }), slidingWindow()],
});
console.log(result.messages, result.tokens, result.fits);The headline feature: know exactly what was cut and why.
import { type Message, fit } from "contextfit";
const messages: Message[] = [
{ role: "system", content: "You are a helpful assistant." },
{
role: "assistant",
content: [{ type: "tool_call", id: "c1", name: "search", arguments: { q: "rust" } }],
},
{
role: "tool",
content: [{ type: "tool_result", toolCallId: "c1", content: "β¦10k tokens of resultsβ¦" }],
},
{ role: "user", content: "Thanks!" },
];
const result = await fit(messages, { budget: { context: 200 } });
// Every removal, shrink, and summary is recorded β nothing is dropped silently.
for (const decision of result.dropped) {
console.log(
`${decision.strategy} ${decision.action} ${decision.messageId}: -${decision.tokensSaved} tokens`,
);
}fit throws BudgetUnsatisfiableError when the mandatory keep-set (system + pinned) alone exceeds the budget. Use fitSafe for a non-throwing, discriminated result:
const outcome = await fitSafe(messages, { budget: { context: 4_096 } });
if (outcome.ok) {
send(outcome.result.messages);
} else {
console.warn(outcome.error.message); // requiredTokens vs availableTokens
}β contextfit.vercel.app β an interactive playground: drag the budget slider or edit the JSON and watch the conversation re-fit in real time, with the full audit trail.
Deploy your own copy in one click, or run it locally with cd demo && npm install && npm run dev:
Strategies are pure, composable, independently testable functions over (messages, ctx). The default pipeline is pin-system β truncate-tool-output β sliding-window: protect the system/pins, reclaim tokens from oversized tool output, then drop the oldest turns only if still over budget.
| Strategy | Keeps | Cuts | Fidelity | Network | In default pipeline |
|---|---|---|---|---|---|
pin-system |
system + pinned + user-specified | nothing | lossless | no | β |
truncate-tool-output |
every message; non-tool content | tool results over the cap | lossy | no | β |
sliding-window |
protected + recent suffix | older turns | lossy | no | β |
drop-middle |
protected + head + tail | middle turns | lossy | no | β |
summarize-spans |
protected + recent; a summary of the rest | old turns (folded into a summary) | lossy (summary) | yes (hook) | β |
Each strategy lives in its own folder under src/strategies/ with tests and a README describing what it keeps, what it cuts, and its fidelity/latency trade-off. Order is explicit and yours to choose.
One normalized Message shape in, the same shape out. Adapters convert to/from each vendor's format so the same pipeline serves all three. Tool-call and tool-result blocks survive the round trip.
| Provider | Import | Exports | Reference |
|---|---|---|---|
| OpenAI | contextfit/openai |
toOpenAI, fromOpenAI |
docs |
| Anthropic | contextfit/anthropic |
toAnthropic, fromAnthropic |
docs |
| Gemini | contextfit/gemini |
toGemini, fromGemini |
docs |
import { type Message, fit } from "contextfit";
import { toOpenAI } from "contextfit/openai";
const messages: Message[] = [
{ role: "system", content: "You are helpful." },
{ role: "user", content: "Hi!" },
];
const { messages: fitted } = await fit(messages, { budget: { context: 128_000 } });
// The same normalized messages, ready for the OpenAI SDK.
const openaiMessages = toOpenAI(fitted);
console.log(openaiMessages.length);The default counter is a fast heuristic β an estimate, good enough for budgeting. Inject an exact counter when you need precision. Exact counts for Claude and Gemini require an API call (there is no local exact tokenizer for those models); OpenAI-family models have local exact tokenizers.
| Counter | Kind | Exactness | Factory |
|---|---|---|---|
| built-in heuristic | sync (local) | estimate | heuristicCounter (default) |
| js-tiktoken / gpt-tokenizer | sync (local) | exact (OpenAI family) | encodeCounter(encode) |
| Anthropic count_tokens | async (API) | exact (Claude) | anthropicApiCounter({ apiKey, model }) |
| Gemini countTokens | async (API) | exact (Gemini) | geminiApiCounter({ apiKey, model }) |
import { type Message, fit } from "contextfit";
import { encodeCounter } from "contextfit/tokenizers";
import { encode } from "gpt-tokenizer";
const messages: Message[] = [{ role: "user", content: "Count me exactly." }];
const result = await fit(messages, {
counter: encodeCounter(encode), // exact OpenAI-family counts, injected by you
budget: { context: 8_192, reserveForOutput: 1_024 },
});
console.log(result.tokens.before);For lossy compaction that keeps meaning, pass a summarize hook. contextfit orchestrates when to summarize and which span; you own the model call β it imports no SDK.
import { type Message, fit, pinSystem, slidingWindow, summarizeSpans } from "contextfit";
const messages: Message[] = [
{ role: "system", content: "You are a helpful assistant." },
// ...a long history...
{ role: "user", content: "What did we decide earlier?" },
];
const result = await fit(messages, {
budget: { context: 128_000, reserveForOutput: 4_096 },
// contextfit decides which span to collapse; you own the model call.
summarize: async (span) => `Summary of ${span.length} earlier messages.`,
strategies: [pinSystem(), summarizeSpans({ keepRecent: 6 }), slidingWindow()],
});
console.log(result.dropped.filter((d) => d.action === "summarize").length);| Export | What it is |
|---|---|
fit(messages, options) |
Fit into the budget; throws BudgetUnsatisfiableError if the mandatory set can't fit. |
fitSafe(messages, options) |
Same, but returns a discriminated { ok, result, error? }. |
defaultStrategies() |
The default pin-system β truncate-tool-output β sliding-window pipeline. |
Message, ContentPart, Decision, Budget, Strategy, TokenCounter |
The core types. |
pinSystem, slidingWindow, truncateToolOutput, dropMiddle, summarizeSpans |
Strategy factories. |
FitResult is { messages, tokens: { before, after, budget, available }, dropped: Decision[], fits }.
Adding a strategy or a provider is meant to take an afternoon:
pnpm new-strategy recency-decay # scaffolds folder + tests + README, and registers it
pnpm new-provider cohere # scaffolds a provider adapterSee CONTRIBUTING.md for the quality gate and the authoring cheat-sheet, BACKLOG.md for good-first-issues, and CLAUDE.md for the repo conventions your editor/agent should follow.