SLOPSHOPPER

image-diet

Shrinks large images from Read and MCP tools before they enter Claude's context

newguardprocess
A shopper browsing a rack in a slop shop
README

image-diet

A Claude Code mod that shrinks large images before they enter Claude's context.

Screenshots are expensive. On Claude Opus 4.7 and later, an image costs about ceil(width/28) × ceil(height/28) tokens, and a 2000 px screenshot costs 3,000–4,800 tokens. A long session with many screenshots fills the context fast and triggers more compactions. image-diet shrinks each image to 1280 px on the long edge. That saves about 60% of the image tokens, and in our tests Claude lost no information.

Install

In a Claude Code terminal session:

/plugin install image-diet --marketplace sergical/claude-plugins

Answer y to add the marketplace, then pick a scope (user scope loads it in every session).

Needs a Claude Code version with mods (function hooks), and sips (built into macOS) or ImageMagick (magick) on Linux.

What it does

  • Read tool: a PNG or JPEG larger than 1280 px on its long edge is shrunk before Claude sees it.
  • MCP tools: image blocks in any mcp__* tool result (browser screenshots, Figma, and so on) are shrunk the same way.
  • Click tools are skipped: computer-use and browser tools that click by screenshot pixel position (names containing computer, cua or browser_batch) are left alone. A smaller screenshot would make every click land in the wrong place.
  • Claude is told: each shrink adds a short note with the old and new size, so Claude knows it is looking at a smaller copy.
  • Full size on demand: a file with .full. in its name is never shrunk. When Claude needs full detail, it can copy the file to such a name and Read it.
  • Safe failure: if the resize fails for any reason, Claude gets the original image.

It does not shrink images you paste into the prompt. A mod can see that a pasted image exists, but not change it.

Settings

Env varDefaultEffect
IMAGE_DIET_MAX_EDGE1280Long-edge size in pixels. 1024 saves more; 1568 keeps more detail.
IMAGE_DIET_SKIP_TOOLS`computer\cua\browser_batch`Regex (case-insensitive) of MCP tool names to leave alone. Add any tool that takes click coordinates from its own screenshots.

Why 1280

We found no published evals of vision accuracy versus image size for screenshots, so we measured it. See eval/.

Opus 5.5 answered questions with known answers about six kinds of retina screenshots (app dashboard, dense table, code, chart, tiny text from 7 to 14 px, a subtle visual defect) at five sizes, through the real Read tool:

Long edgeCorrectImage tokens
2000100%3,240
1568100%1,960
1280100%1,334
1024100%851
76893%504
  • At 768 px, every miss came back as "unreadable". Claude never gave a confident wrong answer.
  • The first thing to fail is text smaller than about 7 device pixels after the shrink.
  • The test pages were window-sized (1000 CSS px wide). A full-screen MacBook capture is wider, so 1024 px takes its 11 px text down to about 6.5 px, near the failure point. At 1280 px, that text stays at about 8 px. That is why the default is 1280, not 1024.
  • Limits: one model, two repeats per cell, synthetic pages. Anthropic's own guidance points the same way: start near 1080p on newer Opus models, and avoid going below 960×540.

Run it yourself with eval/run.sh (macOS, Google Chrome, and a logged-in claude CLI). Full tables are in eval/results.md.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .

License

MIT

Source 1 files
hooks/register.ts 117 lines
1import type { EngineInterface, McpContentBlock, Register } from 'claude-code'
2
3const DEFAULT_MAX_EDGE = 1280
4const FULL_SIZE_MARKER = '.full.'
5// These tools take click coordinates in the pixels of the screenshot they
6// returned, so a shrunk screenshot would move every click.
7const DEFAULT_SKIP_TOOLS = 'computer|cua|browser_batch'
8const EXT: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg' }
9
10// Prints "<oldW> <oldH> <newW> <newH>" then the resized base64 on one line.
11// Exit 3 means the image already fits. sips ships with macOS; ImageMagick
12// covers Linux.
13const SHRINK_SH = `set -eu
14max=$1 ext=$2
15d=$(mktemp -d); trap 'rm -rf "$d"' EXIT
16in="$d/in.$ext" out="$d/out.$ext"
17base64 -d > "$in"
18dims() {
19  if command -v sips >/dev/null 2>&1; then
20    sips -g pixelWidth -g pixelHeight "$1" | awk '/pixel/{printf "%s ", $2}'
21  else
22    magick identify -format '%w %h' "$1[0]"
23  fi
24}
25set -- $(dims "$in")
26[ "$1" -gt "$max" ] || [ "$2" -gt "$max" ] || exit 3
27if command -v sips >/dev/null 2>&1; then
28  sips -Z "$max" "$in" --out "$out" >/dev/null
29else
30  magick "$in[0]" -resize "\${max}x\${max}>" "$out"
31fi
32echo "$1 $2 $(dims "$out")"
33base64 < "$out" | tr -d '\\n'
34`
35
36type Shrunk = { base64: string; from: string; to: string; width: number; height: number }
37type ReadImage = {
38  type: 'image'
39  file: { base64: string; type: string; dimensions?: Record<string, number> }
40}
41type McpResult = { content: McpContentBlock[] }
42
43const isReadImage = (r: unknown): r is ReadImage =>
44  (r as ReadImage | undefined)?.type === 'image' && typeof (r as ReadImage).file?.base64 === 'string'
45const isMcpResult = (r: unknown): r is McpResult => Array.isArray((r as McpResult | undefined)?.content)
46
47async function shrink($: EngineInterface, base64: string, mime: string | undefined): Promise<Shrunk | undefined> {
48  const ext = mime && EXT[mime]
49  if (!ext) return undefined
50  const maxEdge = Number(await $.env.get('IMAGE_DIET_MAX_EDGE')) || DEFAULT_MAX_EDGE
51  const ran = await $.process.run(['sh', '-c', SHRINK_SH, 'sh', String(maxEdge), ext], {
52    stdin: base64,
53    timeoutMs: 10_000,
54  })
55  if (ran.exitCode !== 0 || ran.isStdoutTruncated) return undefined
56  const newline = ran.stdout.indexOf('\n')
57  const [ow, oh, w = 0, h = 0] = ran.stdout.slice(0, newline).trim().split(/\s+/).map(Number)
58  const out = ran.stdout.slice(newline + 1).trim()
59  if (!out || !(w > 0 && h > 0)) return undefined
60  return { base64: out, from: `${ow}x${oh}`, to: `${w}x${h}`, width: w, height: h }
61}
62
63const note = (what: string, s: Shrunk) =>
64  `image-diet: shrank ${what} from ${s.from} to ${s.to} to save context. ` +
65  `For full resolution, copy the file to a name containing "${FULL_SIZE_MARKER}" and Read that copy.`
66
67export const register: Register = on => {
68  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
69    const ran = await next(e)
70    try {
71      if (!('result' in ran) || e.file_path.includes(FULL_SIZE_MARKER)) return ran
72      const result = ran.result
73      if (!isReadImage(result)) return ran
74      const s = await shrink($, result.file.base64, result.file.type)
75      if (!s) return ran
76      const dims = result.file.dimensions
77      return {
78        result: {
79          ...result,
80          file: {
81            ...result.file,
82            base64: s.base64,
83            dimensions: dims && { ...dims, displayWidth: s.width, displayHeight: s.height },
84          },
85        },
86        context: [note(e.file_path, s)],
87      }
88    } catch {
89      return ran
90    }
91  }).catch(($, e, next) => next(e))
92
93  on('tool.call', async ($, e, next) => {
94    const ran = await next(e)
95    if (!e.tool.startsWith('mcp__') || !('result' in ran) || !isMcpResult(ran.result)) return ran
96    const result = ran.result
97    try {
98      const skip = new RegExp((await $.env.get('IMAGE_DIET_SKIP_TOOLS')) || DEFAULT_SKIP_TOOLS, 'i')
99      if (skip.test(e.tool)) return ran
100      const notes: string[] = []
101      const content = await Promise.all(
102        result.content.map(async block => {
103          if (block.type !== 'image' || typeof block.data !== 'string') return block
104          const s = await shrink($, block.data, block.mimeType)
105          if (!s) return block
106          notes.push(note(`a ${e.tool} image`, s))
107          return { ...block, data: s.base64 }
108        }),
109      )
110      if (!notes.length) return ran
111      return { result: { ...result, content }, context: notes }
112    } catch {
113      return ran
114    }
115  }).catch(($, e, next) => next(e))
116}
117