SLOPSHOPPER

jev-compact-trigger

Companion to jev-compact: watches context use after every turn and starts a compaction at the configured percentage. Separate plugin on purpose — the engine…

newtimer
v0.1.1MITupdated 2026-09-24HAR5HA-7663/jev-compact/trigger
A shopper browsing a rack in a slop shop
README

jev-compact

Context compaction for Claude Code that prunes instead of summarising.

The built-in /compact asks the model to rewrite your whole conversation as a summary: slow, lossy, and it replaces every tool output — including the ones you still need — with prose. jev-compact keeps the conversation as it is and removes only what is provably dead weight:

  1. Rules first, locally. Exploration output (Read, Grep, Glob, read-only Bash, snapshots, MCP reads) older than the recent window is replaced by a one-line stub. Hook noise (<system-reminder> blocks, hook chatter) is stripped from old user turns. Long tool inputs (heredoc scripts, Write contents, big Edit strings) are shortened — the files are on disk. Nothing leaves the machine for this step.
  2. Jev ranks the rest. If the result is still above the budget, the remaining judgement calls (agent reports, mutations, passing test runs) go to Jev (TypeSafe System One) in one batched request (~100–400 ms): "will the assistant need this output again to finish the task?" The lowest-scoring outputs are dropped until the budget is met.
  3. Never touched: every user and assistant message, every error or failing test output, the first message, the last N messages.
  4. A note tells the assistant what was removed so it re-runs a tool instead of guessing from memory, and the removed outputs are archived to ~/brain/archive/compaction/ (optional).
  5. Falls back to the built-in summary — on the pruned set — whenever pruning alone cannot reach the budget or anything errors. You never get a worse outcome than today.

On a real 100k-token Claude Code window: 54 % smaller in 19 ms, no Jev call needed. On a 35k window: 37 % from rules, then the built-in summary handles the rest. First live compaction on a 270k-token session: 55 % smaller (270k → 121k), 164 exploration outputs pruned, 19 error/test outputs kept, zero Jev calls.

Install

Requires Claude Code ≥ 2.1.274 with function hooks enabled and a TypeSafe API key. Enable function hooks for every launcher (terminal, desktop app, claude agents daemon) by putting the flag in ~/.claude/settings.json rather than in your shell:

{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
claude plugin marketplace add HAR5HA-7663/jev-compact
claude plugin install jev-compact@jev-compact
claude plugin install jev-compact-trigger@jev-compact

Two plugins, one feature. jev-compact holds the session.compact hook (the pruning). jev-compact-trigger watches context use after every turn and starts a compaction at compactAtPercent. They are separate because the engine skips a plugin's own session.compact hook when that plugin raises the compaction — a trigger inside jev-compact would only ever get the built-in summary. Sessions already running when you install do not pick the plugins up; restart them.

The key is read from TYPESAFE_API_KEY in the environment, else from a TYPESAFE_API_KEY= line in ~/.env. Without a key the rule-based pruning still runs; only the Jev ranking is skipped.

Nothing about how you use Claude Code changes: /compact and auto-compact behave as before, just faster and with less lost.

Configuration

claude plugin install jev-compact@jev-compact --config targetPercent=45 --config preserveRecentMessages=12

optiondefaultmeaning
targetPercent45prune down to this share of the context window (estimated)
compactAtPercent75context percentage at which a finished turn triggers compaction (acted on by jev-compact-trigger, which reads this row)
preserveRecentMessages12newest messages never touched
minReductionRatio0.25below this reduction the built-in summary runs on the pruned set
modeljev-1.13.0pinned Jev model
brainArchivetruewrite removed outputs to ~/brain/archive/compaction/<date>/

Opt out for a session or a repo without disabling the plugin: JEV_COMPACT_OFF=1, a .jev-compact-off file in the working directory, or [[jev:private]] anywhere in the conversation — pruning stays local, nothing is sent to Jev.

What Jev sees

Only what it needs to rank: the first user message (the task), the latest user message, the recent assistant text, and for each candidate the tool name, the head of its input and the head/tail of its output — secrets masked (bearer tokens, *_KEY=…, URL passwords). It never sees the whole conversation.

Development

npx -y tsx --test tests/core.test.mts            # unit tests (no network)
npx -y -p typescript tsc --noEmit -p tsconfig.json
TYPESAFE_API_KEY=… npx -y tsx bench/run.mts messages.json [--no-jev] [--target 45]

src/core.ts is pure (no engine access) so it can be benchmarked on converted transcripts; hooks/jev-compact.ts is the thin Claude Code hook around it.

Log

~/.local/state/jev/compact.log — one line per compaction: reduction, what was pruned, Jev requests, and whether the built-in summary ran afterwards (hybrid). The trigger adds auto N% >= T% -> compacted | skipped (…) | failed: …; JEV_COMPACT_DEBUG=1 adds one line per turn with the context percentage. The engine's own view is in ~/.claude/debug/<session>.txt (or --debug-file).

Credits

Started from tamaratran/fast-jev-compaction, which scores every message with Jev. This fork moves most decisions into local rules, prunes tool inputs, keeps errors and user text unconditionally, batches Jev into one request, and adds the note, the archive and the fallback.

MIT.

Source 1 files
hooks/trigger.ts 74 lines
1/**
2 * jev-compact-trigger — after every answered turn, read the context fill and start a
3 * compaction once it passes the threshold. Lives in its own plugin because the engine skips a
4 * plugin's own `session.compact` hook when that plugin raised the compaction (re-entry rule):
5 * raised from here, jev-compact's pruning hook runs as it does for a typed `/compact`.
6 *
7 * `$.session.compact()` rejects while a turn runs and `turn.complete` still sits inside the
8 * turn, so the call is deferred to a short timer; if a new turn started meanwhile it rejects,
9 * the failure is logged, and the next turn.complete tries again. Threshold: jev-compact's
10 * `compactAtPercent` config row when present (one knob for both plugins), else this plugin's
11 * own, else 75. Log: ~/.local/state/jev/compact.log. JEV_COMPACT_DEBUG=1 logs every turn.
12 */
13import type { On, PluginOptions, Register } from 'claude-code';
14
15const DEFAULT_AT = 75;
16const DEFER_MS = 1000;
17
18function mask(text: string): string {
19  return text.replace(/(bearer\s+)[A-Za-z0-9._\-]{12,}/gi, '$1<redacted>').replace(/\bapikey_[0-9a-f]{20,}[0-9a-f_]*/g, '<redacted>');
20}
21
22async function appendLog($: any, line: string): Promise<void> {
23  try {
24    const home = (await $.env.get('HOME')) ?? '';
25    const p = `${home}/.local/state/jev/compact.log`;
26    let prev = '';
27    try { prev = await $.fs.read(p); } catch { /* first write */ }
28    if (prev.length > 200_000) prev = prev.slice(-100_000);
29    await $.fs.write(p, `${prev}${new Date().toISOString().slice(0, 19)} ${line}\n`);
30  } catch { /* logging is best-effort */ }
31}
32
33async function threshold($: any, options: PluginOptions): Promise<number> {
34  try {
35    const rows: Array<{ key: string; value: unknown }> = await $.config.list();
36    const v = Number(rows.find((r) => r.key === 'jev-compact.compactAtPercent')?.value);
37    if (Number.isFinite(v) && v > 0) return v;
38  } catch { /* fall through to our own option */ }
39  const own = Number(options['compactAtPercent']);
40  return Number.isFinite(own) && own > 0 ? own : DEFAULT_AT;
41}
42
43export const register: Register = (on: On, options: PluginOptions) => {
44  let pending = false;
45
46  on('turn.complete', async ($: any, event: any, next: any) => {
47    try {
48      const { context } = await $.session.usage();
49      const pct = context?.percent ?? 0;
50      const at = await threshold($, options);
51      if (await $.env.get('JEV_COMPACT_DEBUG')) await appendLog($, `turn ${event.reason} ${pct}% of ${context?.window ?? '?'} (threshold ${at}%)`);
52      if (!pending && event.reason === 'answer' && pct >= at) {
53        pending = true;
54        $.clock.after(DEFER_MS, () => {
55          void (async () => {
56            try {
57              const r = await $.session.compact();
58              const skipped = r && typeof r === 'object' && 'skip' in r ? (r as { skip: string }).skip : null;
59              await appendLog($, `auto ${pct}% >= ${at}% -> ${skipped ? `skipped (${skipped})` : 'compacted'}`);
60            } catch (error) {
61              await appendLog($, `auto ${pct}% >= ${at}% failed: ${mask(error instanceof Error ? error.message : String(error))}`);
62            } finally {
63              pending = false;
64            }
65          })();
66        });
67      }
68    } catch (error) {
69      await appendLog($, `auto-compact check failed: ${mask(error instanceof Error ? error.message : String(error))}`);
70    }
71    return next(event);
72  });
73};
74