SLOPSHOPPER

prompt-offload

Writes a prompt longer than the limit (2000 characters by default) to a temp file and sends the model its first 200 characters with the path, so one paste does…

newcommandpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-offload
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /prompt-offload ⎿ prompt-offload: on · limit 2000 characters ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

prompt-offload

You paste a long log, a stack trace or a whole file into the prompt, and all of it stays in the context for the rest of the session, also when the model needed one part of it. This mod writes a long pasted prompt to a file and sends the model its first lines with the path, so one paste does not fill the context.

What it does

  1. Each prompt you type, or send through Remote Control, is measured. A notification, a peer message, a schedule or another plugin's prompt is left alone.
  2. A prompt longer than the limit (2000 characters by default) is written to $TMPDIR/prompt-offload/<hash>.txt, whole and unchanged. The hash covers the text and the time it was sent.
  3. The model reads the first 200 characters of the prompt, then one line naming the file, the character count and the line count, and telling it to read the file before answering. When those 200 characters hold a line break past their middle, the head is cut at the last one.
  4. The transcript gets one line: how many characters went to the file and where.
  5. A failed write is not a lost prompt: the whole prompt reaches the model as it is, and the reason is logged once until a different one comes.

In the live check a 2980-character prompt was written to the file, the model read the file with Read and answered the question that only the tail of the prompt held.

Command

/prompt-offload on or off, and the limit /prompt-offload on | off on by default /prompt-offload limit <n> at least 500 characters; kept across sessions

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install prompt-offload@kilimcininkoroglu-mods

Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.

After installing

  1. Allow reading $TMPDIR/prompt-offload, or answer the permission question the first Read of a file raises.
  2. Restart Claude Code.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.283:

❯ ./register.ts hooks: session.start, command.run{command=prompt-offload}, prompt.submit ❯ ./register.ts calls: $.clock.now (via offload), $.command.register, $.env.get (via tempDir), $.fs.write (via offload), $.process.run (via tempDir), $.store.get (via readSettings), $.store.set (via setEnabled, setLimit), $.ui.log (via offload, report) ❯ ./register.ts env writes: nothing ❯ ./register.ts env reads: TMPDIR

Reach L2, writes files and runs a process.

  1. Reads: the text and origin of each submitted prompt, and TMPDIR
  2. Runs: mkdir -p, by argv
  3. Sends: the first 200 characters of the prompt and the path to the model; nothing leaves the machine
  4. Persists: the whole prompt under $TMPDIR/prompt-offload, and the on/off setting and the limit in $.store
  5. Hostile input: only a prompt from the composer or Remote Control is written, the file name is a hash the mod builds, and the path never reaches a shell

Limits

  • The file is not deleted by the mod; the system clears the temp directory.
  • An attachment, an image or a file reference in the prompt is not counted or moved: the mod measures the text alone.
  • The model needs one Read to see the whole prompt, so a prompt it would have answered at once costs one tool call.
  • A limit under 500 characters is refused, because a head of 200 characters leaves too little of the prompt behind.

Development

make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test

Source 2 files
hooks/register.ts 99 lines
1import type { EngineInterface, PromptOrigin, Register } from 'claude-code'
2import { DEFAULT_LIMIT, MIN_LIMIT, fileName, isLong, limitOf, logText, offloadText } from './offload.ts'
3
4const ENABLED_KEY = 'enabled'
5const LIMIT_KEY = 'limit'
6
7const USAGE = `expects nothing (the status), on, off or limit <n> (at least ${MIN_LIMIT})`
8
9type State = { enabled: boolean; limit: number; lastError?: string }
10
11/** The person's own prompts: typed at the terminal or sent from a phone. */
12function isPersons(origin: PromptOrigin): boolean {
13  return origin.kind === 'composer' || origin.kind === 'bridge'
14}
15
16function errorText(err: unknown): string {
17  return err instanceof Error ? err.message : String(err)
18}
19
20/** Logs an error once until a different one comes; the prompt itself is never dropped. */
21function report($: EngineInterface, state: State, err: unknown): void {
22  const text = errorText(err)
23  if (text !== state.lastError) $.ui.log(`the prompt was left as it is: ${text}`)
24  state.lastError = text
25}
26
27/** The directory the files go in, made when it is missing. */
28async function tempDir($: EngineInterface): Promise<string> {
29  const dir = `${((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/+$/, '')}/prompt-offload`
30  const made = await $.process.run(['mkdir', '-p', dir], { timeoutMs: 5_000 })
31  if (made.exitCode !== 0) throw new Error(`mkdir ${dir} failed: ${made.stderr.trim()}`)
32  return dir
33}
34
35/** Writes the whole prompt to a file and answers the text the model reads, or the prompt unchanged. */
36async function offload($: EngineInterface, state: State, text: string): Promise<string> {
37  try {
38    const path = `${await tempDir($)}/${fileName(text, await $.clock.now())}`
39    await $.fs.write(path, text)
40    $.ui.log(logText(text, path))
41    return offloadText(text, path)
42  } catch (err) {
43    report($, state, err)
44    return text
45  }
46}
47
48async function setLimit($: EngineInterface, state: State, arg: string): Promise<string> {
49  const limit = limitOf(arg)
50  if (limit === undefined) return `limit expects a whole number of at least ${MIN_LIMIT}`
51  await $.store.set(LIMIT_KEY, limit)
52  state.limit = limit
53  return `limit ${limit}: a prompt longer than that goes to a file`
54}
55
56async function setEnabled($: EngineInterface, state: State, on: boolean): Promise<string> {
57  await $.store.set(ENABLED_KEY, on)
58  state.enabled = on
59  return on ? `on: a prompt over ${state.limit} characters goes to a file` : 'off: every prompt reaches the model as it is'
60}
61
62/**
63 * Reads the on/off setting and the limit from the store, which every window shares, so a change made in
64 * another window applies here at the next hook that acts on it.
65 */
66async function readSettings($: EngineInterface, state: State): Promise<void> {
67  state.enabled = (await $.store.get(ENABLED_KEY)) !== false
68  state.limit = Number(await $.store.get(LIMIT_KEY)) || DEFAULT_LIMIT
69}
70
71async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
72  const [word = '', arg = ''] = args.trim().split(/\s+/)
73  await readSettings($, state)
74  if (word === 'on' || word === 'off') return setEnabled($, state, word === 'on')
75  if (word === 'limit') return setLimit($, state, arg)
76  return word === '' ? `${state.enabled ? 'on' : 'off'} · limit ${state.limit} characters` : USAGE
77}
78
79export const register: Register = on => {
80  const state: State = { enabled: true, limit: DEFAULT_LIMIT }
81
82  on('session.start', async ($, e, next) => {
83    const r = await next(e)
84    await $.command.register({ name: 'prompt-offload', description: 'Long pasted prompts to a file: status, on, off, limit <n> (prompt-offload)', argumentHint: '[on | off | limit <n>]' })
85    await readSettings($, state)
86    return r
87  })
88
89  // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
90  on('command.run', { command: 'prompt-offload' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
91
92  on('prompt.submit', async ($, e, next) => {
93    if (!isPersons(e.origin)) return next(e)
94    await readSettings($, state)
95    if (!state.enabled || !isLong(e.text, state.limit)) return next(e)
96    return next({ ...e, text: await offload($, state, e.text) })
97  })
98}
99
hooks/offload.ts 45 lines
1/** Which prompt is too long, where its file goes, and what the model reads in its place. */
2
3/** The prompt is offloaded above this many characters, until a /prompt-offload limit says otherwise. */
4export const DEFAULT_LIMIT = 2000
5
6/** A limit under this keeps too little of the prompt to be worth a file. */
7export const MIN_LIMIT = 500
8
9/** How many characters of the prompt stay in the message. */
10export const HEAD = 200
11
12/** A name for the file, from the prompt and the time it was sent, FNV-1a 32. */
13export function fileName(text: string, now: number): string {
14  let h = 0x811c9dc5
15  for (const ch of `${text}\0${now}`) h = Math.imul(h ^ (ch.codePointAt(0) ?? 0), 0x01000193) >>> 0
16  return `${h.toString(16).padStart(8, '0')}.txt`
17}
18
19/** The start of the prompt, cut at the last line break of the head when there is one. */
20export function head(text: string, count = HEAD): string {
21  const cut = text.slice(0, count)
22  const line = cut.lastIndexOf('\n')
23  return (line > count / 2 ? cut.slice(0, line) : cut).trimEnd()
24}
25
26/** What the model reads: the start of the prompt, then where the whole of it is. */
27export function offloadText(text: string, path: string): string {
28  const lines = text.split('\n').length
29  return `${head(text)}\n\n[prompt-offload] The message was ${text.length} characters, so it was written to ${path} (${lines} line(s)) and this prompt holds its first ${head(text).length}. Read that file before you answer.`
30}
31
32/** The line the person reads in the transcript. */
33export function logText(text: string, path: string): string {
34  return `${text.length} characters written to ${path}; the model reads the first ${head(text).length} here`
35}
36
37/** The limit a `/prompt-offload limit <n>` argument names, or undefined when it is not one. */
38export function limitOf(arg: string): number | undefined {
39  const n = Number(arg)
40  return /^\d+$/.test(arg) && n >= MIN_LIMIT ? n : undefined
41}
42
43/** Whether this prompt is long enough to be written to a file. */
44export const isLong = (text: string, limit: number): boolean => text.length > limit
45