ctxjev-core
v0.6.1
Published
Score AI agent context for relevance with Jev, and decide what to keep, drop, or summarize.
Maintainers
Readme
ctxjev-core
Score AI agent context for relevance with Jev, the engine behind ctxjev.
Why · Install · How It Works · API · Related Packages
Why
Long-running agent loops, such as coding agents, browser agents, or anything with a growing tool-call history, accumulate context faster than it stays useful. Most of that history isn't hard to judge: "is this old tool result still relevant to the current task?" is exactly the kind of fast, cheap, structured decision Jev (TypeSafe AI's typed-decision model) is built for. It returns typed judgments (a yes/no probability, a choice, a score) instead of writing a sentence about it.
ctxjev-core can ask Jev that question about every entry, and decides what to keep, drop, or
summarize. Whether Jev's answers beat simpler rankings is an open question, and so far the evidence
says they don't on unseen tasks; see the main README. The default
is plain truncation.
It never asks Jev to see images, do arithmetic, or generate text. Token counting happens in code,
and the keep/drop/summarize decision is a plain threshold applied to Jev's typed output.
Install
npm install ctxjev-coreNo key needed by default: the default scorer is 'recency' (newest kept, i.e. plain truncation),
which needs no network and sends nothing. On a preregistered holdout
comparison, it tied Jev on whether the agent finished the job (+0 points,
95% CI [+0, +0]); see the main README for the full result,
including where Jev's ranking did and didn't separate itself. Pass { scorer: 'jev' } to opt in,
which needs TYPESAFE_API_KEY (get one at
console.typesafe.ai/settings/keys, no waitlist), or
{ scorer: 'local' } for offline keyword overlap instead.
What the default does: with 'recency', the goal isn't used at all. Every entry's relevance is
its position in the batch (oldest 0, newest 1), so with the default thresholds (dropBelow 0.3,
summarizeBelow 0.6) pruneContext() drops roughly the oldest 30% of entries and marks the next
30% for summarizing, whatever they say, including the first request. pruneMessages() never
touches the first message, the latest turn, or (by default) anything the user wrote; pruneContext()
has no such protection, so exclude what must stay before calling it.
With 'local', keyword overlap is ranked within the batch before the thresholds apply: each
decision's relevance is the entry's percentile rank of overlap (tied entries share their average
rank), so the thresholds read as shares of the batch rather than as probabilities. Raw overlap
rarely reaches 0.3, and the thresholds used to drop almost everything, relevant entries included.
When most entries share no word with the goal, they tie in the middle and are marked for
summarizing, not dropped; pass targetTokens to pruneMessages() if you need a fixed size.
scoreEntries() still returns the raw overlap.
With scorer: 'jev', entry content and the goal are sent to TypeSafe AI's Jev API. Every request
passes through redactSecrets() first, masking common secret formats to [REDACTED] (best-effort, not
exhaustive). It's exported too, if you want to apply the same masking elsewhere.
How It Works
With scorer: 'jev', every entry becomes its own question, and the questions for up to 50 entries
are evaluated in parallel against one shared state, as a single request. What Jev bills is input
tokens, and those still grow with the entries in the request, since each entry's excerpt is part of
the state; at Jev's published price that stays small, and every request's actual usage is reported
through onUsage.
import { pruneContext } from 'ctxjev-core'
const decisions = await pruneContext(
entries, // your agent's tool-call / message history
'Fix a bug where checkout charges customers twice on a slow network retry.',
undefined, // the default policy
{ scorer: 'jev' }, // without this, the default 'recency' ranks by position
)[
{ "entryId": "a", "relevance": 0.96, "recency": 0, "combinedScore": 0.864, "action": "keep" },
{ "entryId": "b", "relevance": 0.04, "recency": 1, "combinedScore": 0.136, "action": "drop" }
]This is a real response shape, captured against the live API. relevance is Jev's own judgment,
recency is this entry's position in the batch (oldest=0, newest=1), and combinedScore blends
the two per PruningPolicy.recencyWeight before action is decided.
API
scoreEntries(entries, goal, recencyWeight?, options?)scores every entry with no decision made, and returns{ entryId, relevance, recency, combinedScore }[].pruneContext(entries, goal, policy?, options?)runsscoreEntries()and appliesPruningPolicy'sdropBelow/summarizeBelowthresholds, returning the same shape plusaction.pruneMessages(messages, goal, options?)takes an Anthropic Messages conversation and returns{ messages, decisions, removed }: the conversation with dropped entries removed, still a valid request. Atool_useand itstool_resultare removed together, a message left empty is removed, and the first message and the latest turn are never touched.messagesToEntries(messages)exposes the entry mapping on its own. Options:protectLastTurn(defaulttrue): the latest turn is the last user message with text of its own (an instruction, not only tool results) and everything after it, however many tool round-trips that is. In an agent loop whose only user text is the first message, that's the whole conversation, so nothing is pruned: passprotectLastTurn: falsethere.protectLast(default 2): the last this-many messages are never touched either way, a floor for tool calls still waiting on a result.targetTokens: after the drops, keep removing the lowest-scoring unprotected entries until the conversation fits.overBudgetin the result says if only protected entries are left.summarize: shorten entries markedsummarizeinstead of leaving them as they are, either'excerpt'(the head and tail of the text) or your own(entry, text) => Promise<string>. A tool call keeps itstool_use; only its result is replaced.minSavedTokens: change nothing unless it saves at least this many tokens.keepUserText(defaulttrue): never remove what the user wrote. It costs few tokens and holds the constraints; in the task eval, an agent that lost "keep the mark for 24 hours" chose its own TTL.marker(defaulttrue): add a one-line note where history was removed, so the model knows to re-read rather than trust what it half-remembers.
The result reports
savedTokens,summarized, andcache(see below), andkeptDrops: the entries markeddropthat weren't removed, by reason.firstMessage,latestTurn,lastMessages, anduserTextare protected;noNetSaving(removing them wouldn't save any tokens once the removal note is counted) andbelowMinSaved(held back byminSavedTokens) are not.parseClaudeCodeTranscript(jsonl, { countTokens? })/resolveClaudeCodeGoal(jsonl, entries)parse a real Claude Code session.jsonltranscript intoEntry[](what's still in context, without the text Claude Code writes into the user turn itself), and find the goal: the latest/ctxjev:set-goal, or else the session's first request plus its latest instruction. PasscountTokens: estimateTokensto fill in each entry'ssourceTokens.summarizeSavings(entries, decisions)/estimateTokens(text)provide token-based savings reporting, using a real tokenizer and never asking Jev to count.options.onUsage(onscoreEntries/pruneContext) is an optional callback fired once per Jev request with that request's real{ inputTokens, outputTokens }, for cost tracking.options.cachetakes any{ get, set }score cache, checked before each Jev request.options.scorer: 'recency'(default) ranks by position alone (plain truncation);'local'scores by keyword overlap withlocalRelevance(). Both are offline.'jev'opts in to Jev.options.scoreralso takes your own function (CustomScorer), to score with another model or a rule set. It gets the goal, a chunk of up to 50 entries (content already masked byredactSecrets()), and the batch's latest activity, and returns one relevance from 0 to 1 per entry, in order.cacheandonUsageapply to Jev only.const decisions = await pruneContext(entries, goal, undefined, { scorer: async (goal, chunk) => chunk.map((entry) => (entry.content.includes('[error]') ? 0.9 : myModel.score(goal, entry.content))), })redactSecrets(text)is the secret masking every Jev request already goes through.summarizeSavings()counts the verdicts:droppedTokens(in entries markeddrop: saved once they're all removed, which is what ctxjev-format pruning does; for an Anthropic Messages conversation,pruneMessages()'savedTokensis what's actually saved) separately fromsummarizableTokens(entries markedsummarize). Jev doesn't generate text, so how much of the latter is saved depends on what you do with those entries:pruneMessages'summarizeoption can cut them to an excerpt or hand them to your own summarizer. Both counts use each entry'ssourceTokens(the full payload's size) when it's set.
With prompt caching
Removing anything from a conversation changes every request after it, so a prompt cache starts
over from the first changed message: that much has to be written to the cache again (at a
premium) instead of being read from it (at a discount). Pruning on every turn can easily cost more
than it saves. Prune in bulk instead, when the context crosses a threshold you choose, and check
the result's cache.invalidatedTokens against savedTokens:
if (contextTokens > 120_000) {
const result = await pruneMessages(messages, goal, { targetTokens: 60_000, summarize: 'excerpt', minSavedTokens: 20_000 })
messages = result.messages // result.cache: { firstChangedMessage, invalidatedTokens }
}A large saving pays for one rewrite quickly, because every later turn reads the smaller
conversation from the cache again. minSavedTokens makes a small saving a no-op, so an unchanged
conversation keeps its cache.
Full type definitions ship with the package. Design notes (why relevance and recency are separate
fields, why recency is batch-relative not wall-clock, how recencyWeight's default was tuned
against labeled fixtures) live in the main repo's README.
Related Packages
| Package | What it is |
| --- | --- |
| ctxjev-cli | ctxjev analyze <transcript>: a plain-text report, no UI. |
| ctxjev-mcp | MCP server exposing this engine as tools for Claude Code, Codex, and other MCP hosts. |
| ctxjev-claude | Claude Code plugin (not on npm; see the main repo). |
Full docs, design notes, and the Claude Code plugin live in the main repo: github.com/x96x64/ctxjev.
License
This package is released under the MIT license: free to use, modify, and distribute,
including in a commercial product, as long as the license text and copyright notice ship with it.
See the main repo for how this matches every
dependency ctxjev currently uses.
