/smart-compact: write custom /compact instructions from the session, then compact with them

Source for kinefractal.com, the public site for FearLab: an end-of-day research engine for SPY, QQQ and IWM that buys into fear and trims into strength. The site shows the engine's latest decisions, its backtest reports and a charts page, and lets visitors sign up for email alerts.
The engine (Python) lives in a separate private repo, ryanportfolio/range. It publishes data to an S3 bucket each night; this site reads that bucket and never places orders.
React and Vite in client/, Express in server/, a Drizzle schema in shared/, Postgres. Hosted on Railway as the web service of the kinefractal project.
npm ci
npx tsx server/index-dev.ts # dev server on http://localhost:5000
npm run check # type-check
npm test
npm run build && npm start # production bundle
Without the Railway environment variables the site still runs: engine data routes return 503 and pages fall back to the bundled snapshot. Variable names are listed in .claude/reference/secrets.md.
Merging to main deploys the web service. Build and data flow: .claude/reference/deployment.md.
The FearLab snapshot (client/public/fearlab/, client/src/data/fearlab-snapshot.generated.json) is a copy of engine output. Refresh it with npm run sync:snapshot, which needs the GitHub CLI logged in with access to ryanportfolio/range.
The /charts/ page, server/charts-app/charts.html, belongs to this repo and is edited here.
hooks/register.ts 86 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Asked of a fork of the session, which sees the whole transcript. Its answer is
4// passed to /compact as the custom instructions the summarizer follows.
5const REVIEW_PROMPT = `Review this whole conversation so far and write the custom instructions to pass to /compact. The summarizer reads them as directions for what to keep, so write imperatives to it.
6
7Keep, in priority order:
8
91. The user's goal for the session and any standing instructions or preferences they gave in it (style, scope limits, things to never do).
102. Current state: repo, branch, worktree path, PR URL, last commit SHA, deploy or preview URL, files created or changed.
113. Decisions made and the reason for each, including options the user rejected.
124. What is verified and how, and what is still unverified.
135. Open work: the next concrete step, pending questions to the user, background tasks still running.
146. Failed approaches and gotchas that would otherwise be retried.
157. Exact identifiers to keep verbatim: paths, commands, error strings, IDs, numbers.
16
17Tell the summarizer to drop resolved tangents, raw tool output, superseded plans, and anything re-readable from files or CLAUDE.md. Never invent a fact; if a state item is unknown, leave it out. Keep the block under about 300 words; cut from the bottom of the priority list first.
18
19Return exactly one fenced \`text\` block and nothing else. When the session's replies follow a named style or output style (caveman ultra, for example), make the first line \`Always use <that style>.\` so it survives the compaction; otherwise leave that line out.
20
21\`\`\`text
22Always use <reply style>.
23Goal: <one line>.
24Keep: <standing user instructions>.
25State: <branch, worktree, PR, SHA, files>.
26Decisions: <decision, why>; ...
27Verified: <what, how>. Unverified: <what>.
28Next: <next step>; open questions: <...>.
29Don't retry: <failed approach, cause>.
30Verbatim: <paths, commands, errors>.
31Drop tool output, resolved tangents, and superseded plans.
32\`\`\`
33
34Omit any line with nothing to say.`
35
36// The fork answers with one fenced `text` block; take its body.
37const fencedBody = (reply: string) => {
38 const match = reply.match(/```(?:text)?\r?\n([\s\S]*?)\r?\n```/)
39 return (match ? match[1] : reply).trim()
40}
41
42export const register: Register = on => {
43 on('session.start', async ($, e, next) => {
44 await $.command.register({
45 name: 'smart-compact',
46 description: 'Write custom /compact instructions from this session, then compact with them',
47 })
48 return next(e)
49 })
50
51 on('command.run', { command: 'smart-compact' }, async $ => {
52 $.ui.status('smart-compact: writing instructions')
53 const reply = await $.model.fork({ prompt: REVIEW_PROMPT })
54 if (!reply.isAnswered) {
55 $.ui.status(undefined)
56 return { text: `review failed (${reply.reason}); nothing compacted. Run /compact yourself.` }
57 }
58
59 const instructions = fencedBody(reply.text)
60 $.ui.status('smart-compact: compacting')
61 // The engine refuses to run a command from inside this hook, which still
62 // holds the command's turn; run it from a timer once the command finishes.
63 $.clock.after(500, () => void compactWhenIdle($, instructions, 10))
64
65 return { text: `compacting with these instructions:\n\n${instructions}` }
66 })
67}
68
69const compactWhenIdle = async ($: EngineInterface, instructions: string, triesLeft: number) => {
70 try {
71 // `$.session.compact` is refused in SDK sessions (the desktop app's Code
72 // tab); running `/compact` itself works in those and in the terminal.
73 await $.command.run({ command: 'compact', args: instructions })
74 $.ui.status(undefined)
75 } catch (err) {
76 // Retry only the refusal for a command run from inside a hook that still
77 // holds the turn; a cancelled or interrupted /compact stays cancelled.
78 if (triesLeft > 1 && /hook/i.test(String(err))) {
79 $.clock.after(1000, () => void compactWhenIdle($, instructions, triesLeft - 1))
80 return
81 }
82 $.ui.status(undefined)
83 $.ui.log(`smart-compact: compaction failed (${String(err)}). Paste the instructions above after /compact.`)
84 }
85}
86