Adds a one-line TL;DR beneath long answers, written by a quick Haiku 5.5 call.

Adds a one-line TL;DR beneath long answers. When a main-loop turn ends with an answer that is long (1,200+ characters) or took a while (90+ seconds), a quick Haiku 5.5 call boils it down to one sentence of 25 words or fewer, shown under the answer. The transcript itself is never rewritten. Subagent turns, interrupted turns and errored turns are skipped. If the Haiku call fails, times out (8s) or rambles, nothing is added.
Cost: a single Haiku 5.5 call at low effort, and only on long turns. The answer is clipped to about 8,000 characters (head and tail) and the reply is capped at 120 tokens, so each recap costs a fraction of a cent.
/plugin marketplace add troyjlorents-gh/mod-squad
/plugin install turn-recap@mod-squad
/recap: shows whether recaps are on, plus the last one./recap on / /recap off: turns recaps on or off. The setting is remembered.| Setting | Default | Meaning |
|---|---|---|
minChars | 1200 | Recap answers at least this many characters long. |
minSeconds | 90 | Also recap any answer whose turn ran at least this many seconds. |
hooks/register.ts 104 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const MODEL = 'claude-haiku-5-5'
4const MAX_WORDS = 25
5const CLIP = 8000
6
7const SYSTEM =
8 'You write a one-sentence TL;DR of an assistant answer. Reply with exactly one plain sentence of at most 25 words: ' +
9 'the key outcome or takeaway. No preamble, no "TL;DR", no quotes, no markdown, no lists.'
10
11type Options = { minChars?: number; minSeconds?: number }
12
13/** Keeps the head and tail of a long answer, where the setup and the conclusion usually are. */
14export function clip(text: string, max = CLIP): string {
15 if (text.length <= max) return text
16 const marker = '\n\n[… middle omitted …]\n\n'
17 const half = Math.floor((max - marker.length) / 2)
18 return text.slice(0, half) + marker + text.slice(-half)
19}
20
21/** Cleans the model's reply down to one sentence of at most MAX_WORDS words, or null. */
22export function tidy(reply: string): string | null {
23 let s = reply
24 .replace(/```[\s\S]*?```/g, ' ')
25 .replace(/^\s*(?:[-*+>#]+|\d+[.)])\s*/gm, '')
26 .replace(/[*_`~]+/g, '')
27 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
28 .replace(/\s+/g, ' ')
29 .trim()
30 s = s.replace(/^tl;?\s?dr\s*[:\-–—]?\s*/i, '').trim()
31 s = s.replace(/^["'“”‘’«»]+|["'“”‘’«»]+$/g, '').trim()
32 // First sentence only (unless the cut lands on an abbreviation like "e.g.").
33 const m = s.match(/^.*?[.!?](?=\s|$)/)
34 if (m && m[0].trim().split(/\s+/).length >= 4) s = m[0].trim()
35 s = s.replace(/^["'“”‘’«»]+|["'“”‘’«»]+$/g, '').trim()
36 if (!s) return null
37 if (s.split(/\s+/).length > MAX_WORDS) return null
38 return s
39}
40
41async function isOn($: EngineInterface): Promise<boolean> {
42 return (await $.store.get('enabled')) !== false
43}
44
45async function recap($: EngineInterface, answer: string): Promise<string | null> {
46 const r = await $.model
47 .complete({
48 model: MODEL,
49 effort: 'low',
50 maxTokens: 120,
51 timeoutMs: 8000,
52 system: SYSTEM,
53 prompt: `Summarize this answer in one sentence:\n\n${clip(answer)}`,
54 })
55 .catch(() => null)
56 if (!r?.isAnswered) return null
57 return tidy(r.text)
58}
59
60export const register: Register = (on, options) => {
61 const opts = (options ?? {}) as Options
62 const minChars = Number(opts.minChars ?? 1200)
63 const minMs = Number(opts.minSeconds ?? 90) * 1000
64
65 on('session.start', async ($, e, next) => {
66 await $.command.register({
67 name: 'recap',
68 description: 'Turn recap: show state and the last TL;DR, or turn it on | off',
69 argumentHint: '[on | off]',
70 immediate: true,
71 })
72 return next(e)
73 })
74
75 on('turn.complete', async ($, e, next) => {
76 if (e.agentId !== undefined || e.reason !== 'answer') return next(e)
77 const answer = e.answer.trim()
78 if (!answer) return next(e)
79 if (e.answer.length < minChars && e.durationMs < minMs) return next(e)
80 if (!(await isOn($))) return next(e)
81
82 const done = await next(e)
83 const line = await recap($, answer)
84 if (!line) return done
85
86 const text = `TL;DR: ${line}`
87 await $.store.set('last', text)
88 return { ...done, text }
89 })
90
91 on('command.run', { command: 'recap' }, async ($, e) => {
92 const verb = e.args.trim().toLowerCase()
93 if (verb === 'on' || verb === 'off') {
94 await $.store.set('enabled', verb === 'on')
95 return { text: `Turn recap: ${verb}.` }
96 }
97 if (verb) return { text: 'Usage: /recap [on | off]' }
98
99 const last = (await $.store.get('last')) as string | undefined
100 const when = `answers ≥ ${minChars} chars or turns ≥ ${minMs / 1000}s`
101 return { text: `Turn recap: ${(await isOn($)) ? 'on' : 'off'} (${when}).${last ? `\nLast: ${last}` : ''}` }
102 }).catch(($, e, next) => next(e))
103}
104