Proposal: Jev questions and thresholds

September 19, 2026 · View on GitHub

AGENTS.md asks for the Jev questions and thresholds to be proposed before they are wired in. This file records that proposal for fast-jev. The question wording is the vendored library's (vendor/fast-jev-compaction/compact.ts), not this plugin's.

Questions asked per tool call

For every candidate tool call tN the plugin asks two noul judgments in one Jev request:

KeyInstructions sent
call_tN"Tool call tN (<tool>) should stay in the history: knowing this call was made, with its input, still matters for what the assistant does next"
result_tN"The full output of tool call tN (<tool>, N chars) should stay in the history verbatim: the assistant still needs its contents and re-running the tool would not do"

Each answer is a probability in [0, 1] (the noul field), read with noulAnswer, and compared against keepThreshold:

  • keepCall < threshold and keepResult < thresholddrop_call: the call and its result leave the outgoing request together.
  • only keepResult < threshold, and the result is long enough to shorten → drop_result: the call stays, the result becomes its head plus a note.
  • otherwise → keep.

Because the pair is always decided and applied together, the request can never carry an orphan call or an orphan result.

Thresholds

ThresholdValueWhy
keepThreshold0.5Matches upstream. Above 0.5 Jev leans "keep", and a wrongly dropped result is paid for by re-running a tool. Must be in [0,1]; an invalid value falls back to 0.5 instead of clamping, so a bad config.json cannot make every eligible call look droppable.
preserveRecentMessages6Upstream default. Together with the pinned first message and the preserveCurrentTurn boundary, this is the floor.
preserveCurrentTurntrueRequest-time pruning is not compaction: everything since the last real user prompt stays, so the live turn can always finish. false is upstream's aggressive behaviour.
truncateHeadChars300Upstream default, measured in characters of the result text.
maxStateTokens25000Upstream default; the fitting stages shrink the state until it fits.
maxRequestTokens30000Upstream default; batches split so that state plus questions fit one request.
minMessageChars60000Plugin-only. Below this the plugin does not call Jev at all, so short sessions cost nothing.
timeoutMs8000Plugin-only. Hard timeout per Jev attempt; on timeout the request goes out unpruned (fail-open), and timeouts are not retried.

Guardrails

Two non-Jev limits keep the pruner honest:

GuardDefaultWhy
protectedTools / protectedToolsMode["shell","bash","write","edit","patch","question","execute","subagent"], "truncate"A dropped call erases the memory that the action happened, and some calls are expensive or impossible to reproduce. Protected calls are never dropped; truncate still bounds their result (so the fact they ran survives), pin leaves both completely untouched. Names are matched case-insensitively. patch is grouped with edit/write because an applied patch is a mutation; question is on the list because a user's answer is irreproducible; execute because Code Mode code can call any tool, including writers. An explicit protectedTools list replaces the default, so an existing config must add patch to keep parity.
maxRewritesPerRequest0 (off)Ceiling on calls dropped plus results truncated in one request, oldest calls first. Off by default because pruning is not cumulative — each request is rebuilt from stored history — so a low ceiling re-applies the same oldest rewrites forever and the backlog never converges (measured on a live long session: 26 % saved with a ceiling of 60, 89 % without). Keep it only as a deliberate blast-radius bound.

Fail-open bounds

Three checks keep a bad input from becoming a bad rewrite:

  • A call is structurally eligible only when it is not pinned and, with a live-turn boundary, both callIndex and resultIndex sit strictly before the last real prompt. A call that starts before the prompt but whose result arrives after it stays, so the live turn can never lose an orphan call.
  • Jev answers are validated at the transport boundary: every question asked must come back as a finite noul in [0,1]. A missing, negative, NaN or out-of-range value aborts the whole request and caches nothing, instead of being read as a confident drop. Cached entries are checked the same way on load, so a corrupt entry is re-asked rather than trusted.
  • Tool payloads are checked before selection: a malformed message (bad role or non-array content), a tool-call without input, or a tool-result that does not match its variant (text needs a string value; json/error need value; content entries need text or a file with string uri/mime and an optional string name) fails the whole request open rather than letting the adapter drop half a pair. Provider-executed and non-tool parts are never inspected.

Where this diverges from upstream

  • Batching. No fixed 50-calls-per-request cap: batching follows the vendored library's token budget (maxRequestTokens), so a huge transcript may need more — and a small one fewer — requests than upstream's ceil(N/50) heuristic.
  • Transport. Jev is called directly at the configured baseUrl (default https://api.typesafe.ai/v1/systemone) instead of shelling out to the jev CLI: the plugin runs inside the OpenCode server and the vendored JevAsker interface is HTTP-shaped. Keep baseUrl on HTTPS — the key is sent to it.
  • Skip guards. A transcript under three messages, a fully frozen message array (Object.isFrozen), or no detectable user prompt (while preserveCurrentTurn is on) skips or fails closed instead of pruning. An event.messages property that cannot be replaced also leaves the request intact (selection still runs). A partially readonly array — a non-writable index, not a frozen array — still admits a new published view via copy-on-write (selftest 18). Log entries cap their decision list.
  • Truncation wording. truncatedResultText mirrors the library's private truncation note (the original is not exported); selftest.ts pins the exact wording so drift fails.
  • Module layout. prune.ts keeps orchestration (candidate selection, batching, decisions, stats and logging) in one module on purpose: config lives in config.ts, the Jev transport in jev.ts and file logging in log.ts, but splitting the orchestration further would add indirection without a second consumer.

Evidence

  • Dry run over live sessions reads log.jsonl: projected character reduction reached 72–92 % once enabled, with cached steps at 1–5 ms.
  • bun livecheck.ts asks Jev with a synthetic transcript: 2 questions, 1 request, ~600 ms.
  • bun selftest.ts pins the adapter, the boundary rules and the decision mapping; it never touches the network.