Marks conversation content wrapped in <pin>...</pin> tags as compaction-proof: pinned content is re-injected verbatim into the transcript after every…

A Claude Mod (function-hooks plugin). Wrap anything in <pin>...</pin> in your prompts and it will survive /compact, auto-compaction, plugin-triggered compaction, and precompute.
<pin>
The prod database is read-only until Friday. Never suggest migrations.
API base: https://api.internal.example/v2
</pin>
Now help me refactor the ingestion job.
Run with the mod loaded (Mods are early access and gated behind function hooks):
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./pin-mod
session.compact. On the way down it extracts every unique <pin> block from the transcript and appends an instruction telling the summarizer to preserve pinned content.ui.render hook draws 📌 N pinned in the band above the prompt on the terminal surface.claude plugin validate ./pin-mod
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test ./pin-mod
Then in a throwaway session: pin something, generate filler conversation, run /compact, and confirm the pinned text is still in context (ask the model to repeat it, or inspect with /context).
Regenerate type declarations after any Claude Code update with /plugin-types — the API is early access and may change between releases without notice.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, the modules field in hooks.json is silently ignored — no error, no pinning.ui.render is written against the AbovePrompt component of Claude Code 2.1.277's published types; if your build's contract differs, delete that block — the compaction hook is independent of it.hooks/register.tsx 87 lines1/**
2 * pin — keep <pin>...</pin> content alive across compaction.
3 *
4 * How it works:
5 * 1. The `session.compact` hook fires whenever the conversation is about to
6 * be compacted (`/compact`, the auto threshold, a plugin, or precompute).
7 * `e.messages` is the full transcript being compacted.
8 * 2. We extract every <pin>...</pin> block from that transcript, nudge the
9 * summarizer (via `instructions`) to keep pinned content, then call
10 * `next(e)` to let the engine (and any hooks beneath) actually compact.
11 * 3. Whatever comes back, we append one built message carrying every pinned
12 * block *with its <pin> tags intact*. Because the tags survive in that
13 * message, the next compaction re-extracts them — pins are permanent
14 * until the user starts a fresh session or edits them away.
15 *
16 * Deduplication is by normalized pin text, so re-injection never snowballs.
17 */
18import type { Register, SessionMessage } from 'claude-code'
19
20const PIN_RE = /<pin>([\s\S]*?)<\/pin>/g
21const MARKER = '[pin] Pinned context (preserved across compaction — do not summarize away):'
22
23/** Extract unique, non-empty pinned blocks from a transcript, oldest first. */
24const extractPins = (messages: readonly SessionMessage[]): string[] => {
25 const seen = new Set<string>()
26 const pins: string[] = []
27 for (const m of messages) {
28 for (const match of m.text.matchAll(PIN_RE)) {
29 const body = (match[1] ?? '').trim()
30 if (!body) continue
31 const key = body.replace(/\s+/g, ' ')
32 if (seen.has(key)) continue
33 seen.add(key)
34 pins.push(body)
35 }
36 }
37 return pins
38}
39
40/** Build the carrier message that rides through every future compaction. */
41const pinCarrier = (pins: string[]): SessionMessage => ({
42 role: 'user',
43 text: [MARKER, ...pins.map((p) => `<pin>\n${p}\n</pin>`)].join('\n\n'),
44 toolUses: [],
45})
46
47export const register: Register = (on) => {
48 // Core behavior: wrap compaction, re-append pinned content afterwards.
49 on('session.compact', async ($, e, next) => {
50 const pins = extractPins(e.messages)
51 if (pins.length === 0) return next(e)
52
53 // Belt: tell the summarizer to respect pins.
54 const nudge =
55 'Preserve verbatim any content wrapped in <pin>...</pin> tags; it is critical context the user explicitly marked.'
56 const instructions = e.instructions ? `${e.instructions}\n\n${nudge}` : nudge
57
58 const result = await next({ ...e, instructions })
59 if (result.skip !== undefined) return result
60
61 // Suspenders: guarantee survival by appending a carrier message.
62 // Drop any previous carrier first so exactly one exists after each pass.
63 const kept = result.messages.filter((m) => !m.text.startsWith(MARKER))
64 const messages = [...kept, pinCarrier(pins)]
65
66 await $.ui.status(`pin: preserved ${pins.length} pinned block${pins.length === 1 ? '' : 's'} through compaction`)
67 $.ui.invalidate('ui.render')
68 return { ...result, messages }
69 })
70
71 // Optional nicety: a one-line strip above the prompt showing the pin count.
72 // Delete this block if your build's AbovePrompt contract differs — the
73 // compaction hook above is self-sufficient.
74 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
75 const below = await next(e)
76 const count = extractPins(await $.session.messages()).length
77 if (count === 0) return below
78 const { Box, Text } = $.ui.resolve(e)
79 return (
80 <Box flexDirection="column">
81 <Text dimColor>{`📌 ${count} pinned block${count === 1 ? '' : 's'} will survive compaction`}</Text>
82 {below}
83 </Box>
84 )
85 })
86}
87