SLOPSHOPPER

sharp-compact

Every compaction keeps what matters: the session writes its own handoff brief first.

newtoastmodel
★ 1v0.2.1MITupdated 2026-10-15RemyLespagnol/sharp-compact
A shopper browsing a rack in a slop shop
README

sharp-compact

Claude Code's /compact and auto-compact keep the gist and lose the details you need to pick the work back up: exact figures, thresholds, timestamps, file paths, how to reach the data, and what is still unverified.

The usual workaround is to ask the session "give me the /compact command" and paste the answer back. It works because the session knows what matters. sharp-compact does exactly that on every compaction, manual or automatic, without you asking.

What it does

Before each compaction (/compact, auto-compact, or one started by a plugin):

  1. It asks your session for a handoff brief. The brief is written by the session's own model over its own transcript, using $.model.fork, so the cached prefix is reused.
  2. It gives that brief to the summarizer as instructions. Your own /compact <text> stays first, as your priority.
  3. It adds the brief word for word to the compacted conversation as a hidden note (the model reads it, your transcript doesn't show it), so exact details survive even if the summary paraphrases them.

Short conversations (under ~5k tokens of messages) compact normally: the summary alone keeps everything. The brief's length follows the work done, and it leaves out what reloads anyway (CLAUDE.md, memory, skills).

A toast tells you what happened: brief kept (N words), short conversation, normal compaction, or, if anything goes wrong (no conversation to fork yet, an API error, an empty reply, a timeout), brief skipped (<reason>), in which case the compaction runs as it normally would.

Before / after

A plain /compact on a database migration might keep:

Worked on the orders table migration; backfill mostly done.

With sharp-compact, the brief carries:

Backfill batches of 5,000 rows, throttled to 200 ms. Rows before id 1,284,330 are done; resume with scripts/backfill.sql --from 1284331. legacy_status is only reliable since 2026-03-02 09:00 UTC. Dry run on staging: 3 rows failed the NOT NULL check, IDs in tmp/failed_ids.txt. Rollback plan written but not tested.

Install

In a Claude Code session:

/plugin install sharp-compact --marketplace RemyLespagnol/sharp-compact

Answer y to add the marketplace, then pick the user scope.

Requires Claude Code 2.1.301 or newer. sharp-compact is a mod built on Claude Code's function-hook API, which is in early access and may change between releases. Where an organization manages Claude Code, compaction hooks only run for plugins the organization allows.

Cost

Each compaction adds one fork of your session plus the brief's output. That's a few hundred tokens of output and roughly 2–20 s. The fork is served from the prompt cache, so on a warm cache it costs about what asking "give me the /compact command" yourself would. On a live session in testing, the fork read 49.5k tokens from cache and wrote 44.

If you compact after the cache has expired, the fork pays for the full context once, like any other request would.

Options

Set in /plugin → sharp-compact → configure, or under pluginConfigs.sharp-compact in settings:

OptionDefaultMeaning
timeoutSeconds90How long to wait for the brief before compacting normally (10–600).
briefPromptemptyReplaces the default prompt that asks for the brief. Empty uses the default in hooks/brief.ts.

Not in v1

  • A context gauge or "turns until auto-compact" warning.
  • Fallback shell hooks for Claude Code builds without mod support.
  • When auto-compact fires after a "prompt too long" API error, the fork hits the same limit and compaction runs normally.
  • Subagent compactions run normally: the fork only sees the main conversation.
  • If the brief times out, the fork is not cancelled and its tokens are still billed.

Development

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

License

MIT

Releasing

Bump version in .claude-plugin/plugin.json in the PR. When it merges to main, the release workflow tags vX.Y.Z and publishes a GitHub release with notes generated from the merged PRs.

Source 2 files
hooks/register.ts 34 lines
1import type { Register } from 'claude-code'
2import { DEFAULT_BRIEF_PROMPT, briefNote, buildInstructions, conversationChars, isHandled, resolveBrief } from './brief'
3
4// ~5k tokens: below that the summary alone keeps everything
5const MIN_CHARS = 20_000
6
7export const register: Register = (on, options) => {
8  const prompt = String(options.briefPrompt ?? '').trim() || DEFAULT_BRIEF_PROMPT
9  const timeoutMs = Number(options.timeoutSeconds ?? 90) * 1000
10
11  on('session.compact', async ($, e, next) => {
12    // $.model.fork only forks the main thread, so a subagent's brief would be the parent's
13    if (!isHandled(e.trigger) || e.agentId) return next(e)
14    if (conversationChars(e.messages) < MIN_CHARS) {
15      $.ui.toast('sharp-compact: short conversation, normal compaction')
16      return next(e)
17    }
18    $.ui.toast('sharp-compact: writing handoff brief…')
19    const brief = await resolveBrief(() => $.model.fork({ prompt }), ms => $.clock.sleep(ms), timeoutMs)
20    if (!brief.ok) {
21      $.ui.toast(`sharp-compact: brief skipped (${brief.reason}), normal compaction`)
22      return next(e)
23    }
24    const r = await next({ ...e, instructions: buildInstructions(e.instructions, brief.text) })
25    // appended once the compacted transcript is installed, as a row the model reads and the person doesn't see
26    if (r.messages) void $.clock.sleep(0).then(async () => {
27      const kept = await $.session.append({ message: briefNote(brief.text) })
28      const words = brief.text.split(/\s+/).length
29      $.ui.toast('deny' in kept ? `sharp-compact: brief not kept (${kept.deny})` : `sharp-compact: brief kept (${words} ${words === 1 ? 'word' : 'words'})`)
30    })
31    return r
32  }).catch(($, e, next) => next(e))
33}
34
hooks/brief.ts 64 lines
1import type { SessionMessage } from 'claude-code'
2
3export const DEFAULT_BRIEF_PROMPT = `The conversation is about to be compacted. Write a handoff brief that lets you resume this exact work afterwards, as if a colleague had to pick it up cold.
4
5Write it in the conversation's language. Keep verbatim, never rounded or paraphrased: figures, thresholds, timestamps, IDs, file paths, commands, queries, and how to access data or systems.
6
7Cover, skipping what does not apply:
8- the goal and current state of the work;
9- constraints and caveats on data or environment;
10- decisions taken, each with its reason;
11- dead ends already tried;
12- what is unverified or assumed;
13- the user's last request and whether it was answered;
14- the next step.
15
16Point to existing artifacts (PRs, files, URLs) by reference instead of copying their content. Leave out what reloads on its own after compaction: CLAUDE.md, memory files, skills, the system prompt and the user's standing preferences. Always redact secrets, tokens and personal data.
17
18Keep the length proportional to the work actually done: a few lines when little happened, at most 600 words. No tools.`
19
20export type ForkOutcome = { ok: true; text: string } | { ok: false; reason: string }
21
22type ForkReply = { isAnswered: boolean; text?: string; reason?: string }
23
24export function buildInstructions(userText: string | undefined, brief: string): string {
25  const head = userText?.trim() ? `User's priority:\n${userText.trim()}\n\n` : ''
26  return `${head}Handoff brief written by this session before compaction. Keep its exact details in the summary:\n\n${brief}`
27}
28
29export function briefNote(brief: string): { type: 'user'; content: [{ type: 'text'; text: string }] } {
30  return {
31    type: 'user',
32    content: [{
33      type: 'text',
34      text: `[sharp-compact] Handoff brief written by this session just before compaction. Use it as the reference for exact figures, paths and caveats.\n\n${brief}`,
35    }],
36  }
37}
38
39export function conversationChars(messages: readonly SessionMessage[]): number {
40  let n = 0
41  for (const m of messages) {
42    n += m.text.length
43    for (const u of m.toolUses) n += JSON.stringify(u.input).length + (u.text?.length ?? 0)
44    for (const r of m.toolResults ?? []) n += r.text.length
45  }
46  return n
47}
48
49export async function resolveBrief(
50  fork: () => Promise<ForkReply>,
51  sleep: (ms: number) => Promise<void>,
52  timeoutMs: number,
53): Promise<ForkOutcome> {
54  const timeout = sleep(timeoutMs).then((): ForkReply => ({ isAnswered: false, reason: 'timeout' }))
55  const r = await Promise.race([fork(), timeout])
56  if (!r.isAnswered) return { ok: false, reason: r.reason ?? 'unknown' }
57  const text = r.text?.trim() ?? ''
58  return text ? { ok: true, text } : { ok: false, reason: 'empty-reply' }
59}
60
61export function isHandled(trigger: string): boolean {
62  return trigger !== 'precompute'
63}
64