SLOPSHOPPER

tool-trim-compaction

compaction で古いツール呼び出しと結果を消し、発言は原文のまま残す。削減が足りなければ標準の要約に回す

newtoast
v?MITupdated 2026-10-03okamyuji/tool-trim-compaction
A shopper browsing a rack in a slop shop
README

tool-trim-compaction

Claude Codeのcompactionを、古いツール呼び出しの削除で済ませるプラグインです。会話の要約を作らないので、圧縮にかかる時間は数ミリ秒で、API費用もかかりません。ユーザーとアシスタントの発言は原文のまま残ります。

削除だけでは十分に小さくならない会話は、Claude Code標準のcompaction(LLMによる要約)にそのまま回します。

動き方

このプラグインは、ターンが終わるたびにcontext windowの使用率を確認します。使用率が60%以上なら、compactionを始める仕組みです。/compactを手で実行した場合も、同じ処理を通ります。

compactionでは、次の範囲のメッセージを残します。

  • 会話の先頭の1件
  • 直近の6件

この範囲より古いツール呼び出しは、その結果と一緒に削除します。呼び出しと結果は必ず組で扱います。結果だけを残すと、呼び出しの無い結果になり、APIがリクエストを拒否するためです。

削除によって会話の文字数が25%以上減った場合は、削除後の会話でそのまま続けます。25%に届かない場合は、標準compactionの要約に処理を渡します。

実際のセッションで試した例では、48,694 tokenの会話が9,953 tokenになり、所要時間は10msでした。

必要なもの

  • Claude Code 2.1.288で動作を確認しています
  • プラグインのhook(session.compactイベント)が使える版のClaude Codeが必要です

導入方法

1. リポジトリを取得する

置き場所はどこでも構いません。ここでは~/.claude/local-pluginsの下に置く例を示します。

mkdir -p ~/.claude/local-plugins
git clone https://github.com/okamyuji/tool-trim-compaction.git ~/.claude/local-plugins/tool-trim-compaction
cd ~/.claude/local-plugins/tool-trim-compaction
git checkout "$(git describe --tags --abbrev=0)"

mainへのcommitごとに、v0.1.1のような版のタグが自動で付きます。上の手順で取り出すのは、その時点で最新のタグです。

このプラグインは、Claude Codeの中で会話の内容を書き換えます。タグで版を決めておけば、後から入った変更を確かめないまま読み込むことはありません。新しい版に上げるときは、git fetch --tagsのあと変更内容を読んでから、git checkout <タグ>で切り替えてください。

2. Claude Codeに読み込ませる

~/.claude/settings.jsonのenvに、CLAUDE_CODE_PLUGIN_DIRSを追加します。すでにenvがある場合は、その中に1行を足してください。

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/local-plugins/tool-trim-compaction"
  }
}

ほかにもディレクトリから読み込むプラグインがある場合は、パスを:でつないで並べます。

3. Claude Codeを起動し直す

設定は、次に起動したセッションから有効になります。

1回だけ試したい場合は、設定を変えずに--plugin-dirで指定することもできます。

claude --plugin-dir ~/.claude/local-plugins/tool-trim-compaction

動作の確認

ツールを何度か使った会話で/compactを実行してください。削除で圧縮できた場合は、次のような行が表示されます。

tool-trim: dropped 10 tool calls, 82% reduction, kept 8/28 messages

削減率が25%に届かなかった場合は、次の行が表示されたあと、標準compactionの要約が動きます。

tool-trim: 16% reduction, below 25%; standard summary

プラグインの検証とテストは、次のコマンドで実行できます。

claude plugin validate ~/.claude/local-plugins/tool-trim-compaction
claude plugin test ~/.claude/local-plugins/tool-trim-compaction

値の変更

残す件数、削減率の閾値、compactionを始める使用率は、hooks/register.tsの先頭にある定数で決めています。

定数既定値意味
KEEP_RECENT_MESSAGES6削除の対象から外す直近のメッセージの件数
MIN_REDUCTION0.25この削減率に届かなければ標準の要約に回す
COMPACT_AT_PERCENT60ターン終了時に、この使用率以上ならcompactionを始める

注意点

削除したツール呼び出しの結果は、後から参照できなくなります。例えば、何十ターンも前に出たエラーメッセージの原文は文脈に残りません。エラーがあったこと自体が残らないので、モデルが「エラーは出ていない」と答える場合もあります。古いエラーの経緯を後でたどる作業では、標準compactionのほうが向いています。

session.compactイベントを使うほかのcompactionプラグインとは、同時に有効にしないでください。どちらの処理が先に動くかによって、結果が変わるためです。

削除方法

~/.claude/settings.jsonのenvからCLAUDE_CODE_PLUGIN_DIRSの該当パスを消し、Claude Codeを起動し直します。そのあと、取得したディレクトリを削除してください。

ライセンス

MIT

Source 2 files
hooks/register.ts 49 lines
1import type { Register } from 'claude-code';
2import { trimToolHistory } from './trim';
3
4const KEEP_RECENT_MESSAGES = 6;
5const MIN_REDUCTION = 0.25;
6const COMPACT_AT_PERCENT = 60;
7
8const pct = (r: number) => `${Math.round(r * 100)}%`;
9
10export const register: Register = (on) => {
11  let compacting = false;
12
13  on('session.compact', async ($, e, next) => {
14    if (e.trigger === 'precompute') return next(e);
15    let out;
16    try {
17      out = trimToolHistory(e.messages, KEEP_RECENT_MESSAGES);
18    } catch (error) {
19      $.ui.log(`tool-trim: failed (${error instanceof Error ? error.message : String(error)}); standard summary`);
20      return next(e);
21    }
22    if (out.reduction < MIN_REDUCTION) {
23      $.ui.log(`tool-trim: ${pct(out.reduction)} reduction, below ${pct(MIN_REDUCTION)}; standard summary`);
24      return next(e);
25    }
26    const line = `tool-trim: dropped ${out.dropped} tool calls, ${pct(out.reduction)} reduction, kept ${out.messages.length}/${e.messages.length} messages`;
27    $.ui.log(line);
28    $.ui.toast(line, { timeoutMs: 15_000 });
29    return { messages: out.messages };
30  });
31
32  // 標準の自動 compaction より早い 60% で切る。要約を作らないので、早めに切っても費用が増えない。
33  on('turn.complete', async ($, e, next) => {
34    if (compacting) return next(e);
35    try {
36      const { context } = await $.session.usage();
37      if ((context.percent ?? 0) >= COMPACT_AT_PERCENT) {
38        compacting = true;
39        await $.session.compact();
40      }
41    } catch (error) {
42      $.ui.log(`tool-trim: auto-compact skipped (${error instanceof Error ? error.message : String(error)})`);
43    } finally {
44      compacting = false;
45    }
46    return next(e);
47  });
48};
49
hooks/trim.ts 47 lines
1import type { SessionMessage } from 'claude-code';
2
3export type TrimResult = {
4  messages: SessionMessage[];
5  dropped: number;
6  charsBefore: number;
7  charsAfter: number;
8  reduction: number;
9};
10
11function chars(m: SessionMessage): number {
12  let n = m.text.length;
13  for (const t of m.toolUses) n += JSON.stringify(t.input ?? {}).length + (t.text?.length ?? 0);
14  for (const r of m.toolResults ?? []) n += r.text.length;
15  return n;
16}
17
18// 先頭メッセージと直近 keepRecent 件にある呼び出しだけを残す。結果は呼び出しと組で扱い、
19// 呼び出しを消したら結果も消す(呼び出しの無い結果を API に渡すと拒否されるため)。
20export function trimToolHistory(input: readonly SessionMessage[], keepRecent: number): TrimResult {
21  const isPinned = (i: number) => i === 0 || i >= input.length - keepRecent;
22  const pinnedCalls = new Set<string>();
23  input.forEach((m, i) => {
24    if (isPinned(i)) for (const t of m.toolUses) pinnedCalls.add(t.tool_use_id);
25  });
26
27  let dropped = 0;
28  const messages: SessionMessage[] = [];
29  input.forEach((m) => {
30    const toolUses = m.toolUses.filter((t) => pinnedCalls.has(t.tool_use_id));
31    const toolResults = (m.toolResults ?? []).filter((r) => pinnedCalls.has(r.tool_use_id));
32    dropped += m.toolUses.length - toolUses.length;
33    if (toolUses.length === m.toolUses.length && toolResults.length === (m.toolResults ?? []).length) {
34      messages.push(m);
35      return;
36    }
37    if (!m.text.trim() && toolUses.length === 0 && toolResults.length === 0) return;
38    const rebuilt: SessionMessage = { role: m.role, text: m.text, toolUses };
39    if (toolResults.length > 0) rebuilt.toolResults = toolResults;
40    messages.push(rebuilt);
41  });
42
43  const charsBefore = input.reduce((n, m) => n + chars(m), 0);
44  const charsAfter = messages.reduce((n, m) => n + chars(m), 0);
45  return { messages, dropped, charsBefore, charsAfter, reduction: charsBefore ? 1 - charsAfter / charsBefore : 0 };
46}
47