/tldr: a short plain-English summary of Claude's last reply, shown in a box above the prompt the model never reads

hooks/register.tsx 178 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { TldrSummary } from '../types'
5
6import {
7 buildPrompt,
8 buildTunePrompt,
9 DEFAULT_LEVEL,
10 isLevel,
11 isWorthSummarising,
12 lastReply,
13 levelRules,
14 LEVELS,
15 parseArgs,
16 systemPrompt,
17 toLines,
18 TUNE_SYSTEM,
19} from './summarize'
20import type { Level } from './summarize'
21
22const COMMAND = 'tldr'
23const LEVEL_KEY = 'level'
24
25/** Persisted: the reading level is a preference, kept across sessions. */
26async function readLevel($: EngineInterface): Promise<Level> {
27 const stored = await $.store.get(LEVEL_KEY)
28 return typeof stored === 'string' && isLevel(stored) ? stored : DEFAULT_LEVEL
29}
30
31/** Per session on purpose: every session starts off, `/tldr auto` turns it on for that session only. */
32const isAuto = atom({ plugin: 'tldr', key: 'isAuto' } as const, false)
33/** The box above the prompt; cleared when the next turn starts so it never describes an older reply. */
34const summary = atom({ plugin: 'tldr', key: 'summary' } as const, null as TldrSummary | null)
35
36/**
37 * The summary is drawn above the prompt, never sent: the model does not read
38 * it and the conversation carries on untouched.
39 */
40async function summarise($: EngineInterface, options: PluginOptions, reply: string, args: string): Promise<void> {
41 // the engine prefixes the plugin name: this reads `tldr: summarising…`
42 $.ui.status('summarising…')
43 let result
44 try {
45 result = await $.model.complete({
46 model: 'haiku',
47 system: systemPrompt(await readLevel($), options),
48 prompt: buildPrompt(reply, args),
49 maxTokens: 600,
50 effort: 'low',
51 timeoutMs: 30_000,
52 })
53 } finally {
54 $.ui.status(undefined)
55 }
56
57 if (!result.isAnswered) {
58 $.ui.log(`tldr: no summary (${result.reason})`)
59 return
60 }
61 await update($, summary, () => toLines(result.text))
62}
63
64/** `options` holds the per-level overrides from `/config`; a change there reloads the module. */
65/**
66 * Sonnet rewrites the level's rules from the feedback: rare, and worth the better wording.
67 * Saved as the plugin's `/config` field (`tldr.<level>`), so it is global and editable there.
68 */
69async function tune($: EngineInterface, options: PluginOptions, level: Level, feedback: string): Promise<void> {
70 $.ui.status(`tuning ${level}…`)
71 let result
72 try {
73 result = await $.model.complete({
74 model: 'sonnet',
75 system: TUNE_SYSTEM,
76 prompt: buildTunePrompt(level, levelRules(level, options), feedback),
77 maxTokens: 400,
78 effort: 'low',
79 timeoutMs: 60_000,
80 })
81 } finally {
82 $.ui.status(undefined)
83 }
84 if (!result.isAnswered) {
85 $.ui.log(`tldr: ${level} not changed (${result.reason})`)
86 return
87 }
88 const rules = result.text.trim()
89 const { deny } = await $.config.set({ key: `tldr.${level}`, value: rules })
90 $.ui.log(deny === undefined ? `tldr: ${level} rules now: ${rules}` : `tldr: ${level} not saved (${deny})`)
91}
92
93export const register: Register = (on, options) => {
94 on('session.start', async ($, e, next) => {
95 await $.command.register({
96 name: COMMAND,
97 description: `TL;DR of the last reply, not sent to the model ("auto" toggles it after every reply; ${LEVELS.join('/')} sets the reading level; "tune <level> <feedback>" rewrites a level's rules; else an extra ask, e.g. "one line")`,
98 })
99
100 return next(e)
101 })
102
103 on('command.run', { command: COMMAND }, async ($, e) => {
104 const parsed = parseArgs(e.args)
105 if (parsed.kind === 'auto') {
106 const enabled = !(await read($, isAuto))
107 await update($, isAuto, () => enabled)
108 return { text: enabled ? 'auto on.' : 'auto off.' }
109 }
110 if (parsed.kind === 'usage') {
111 $.ui.log(`tldr: ${parsed.text}`)
112 return {}
113 }
114 if (parsed.kind === 'tune') {
115 await tune($, options, parsed.level, parsed.feedback)
116 return {}
117 }
118 if (parsed.kind === 'reset') {
119 const { deny } = await $.config.set({ key: `tldr.${parsed.level}`, value: '' })
120 $.ui.log(deny === undefined ? `tldr: ${parsed.level} back to built-in rules` : `tldr: ${parsed.level} not reset (${deny})`)
121 return {}
122 }
123 if (parsed.level !== undefined) {
124 await $.store.set(LEVEL_KEY, parsed.level)
125 $.ui.log(`tldr: level ${parsed.level}`)
126 }
127
128 const reply = lastReply(await $.session.messages())
129 if (reply === undefined) {
130 $.ui.log('tldr: no reply to summarise yet')
131 return {}
132 }
133 await summarise($, options, reply, parsed.ask)
134
135 return {}
136 })
137
138 on('turn.start', async ($, e, next) => {
139 await update($, summary, () => null)
140
141 return next(e)
142 })
143
144 /** Main loop answers only; not awaited, so the turn ends without waiting on haiku. */
145 on('turn.complete', async ($, e, next) => {
146 const result = await next(e)
147 if (e.agentId === undefined && e.reason === 'answer' && isWorthSummarising(e.answer) && (await read($, isAuto))) {
148 void summarise($, options, e.answer, '')
149 }
150
151 return result
152 })
153
154 /** Stacks under whatever else draws in the band (context-bar), never replaces it. */
155 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
156 const lines = await read($, summary)
157 if (e.props.hasSurvey || e.props.isWorking || lines === null) return next(e)
158
159 const { Box, Text } = $.ui.resolve(e)
160 const rest = await next(e)
161
162 return (
163 <Box flexDirection="column">
164 {rest}
165 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
166 <Text>
167 <Text color="cyan">◆ </Text>
168 <Text bold>tl;dr</Text>
169 </Text>
170 {lines.map(line => (
171 <Text>{line}</Text>
172 ))}
173 </Box>
174 </Box>
175 )
176 })
177}
178hooks/summarize.ts 110 lines1import type { SessionMessage } from 'claude-code'
2
3/** Reading levels, simplest first; `/tldr <level>` picks one and it is remembered across sessions. */
4export const LEVELS = ['eli5', 'eli8', 'eli12', 'eli15'] as const
5export type Level = (typeof LEVELS)[number]
6export const DEFAULT_LEVEL: Level = 'eli12'
7
8const LEVEL_RULES: Record<Level, string> = {
9 eli5: 'Write for a 5-year-old: tiny words, one idea per sentence, an everyday comparison instead of any technical term. At most 3 short sentences, no bullets.',
10 eli8: 'Write for an 8-year-old: simple words, short sentences, explain any technical term in a few plain words or leave it out. Formulas and symbols are never copied.',
11 eli12: 'Write for a smart 12-year-old: plain words, technical terms only when needed and then explained in passing. No formulas or symbols; describe what they mean.',
12 eli15: 'Write for a bright 15-year-old: normal vocabulary, key technical terms kept with a short gloss. At most one formula, only if it is the point.',
13}
14
15export function isLevel(word: string): word is Level {
16 return (LEVELS as readonly string[]).includes(word)
17}
18
19/** A level's rules from the plugin's config (`/config`, one field per level); blank keeps the built-in. */
20export function levelRules(level: Level, overrides: Readonly<Record<string, unknown>>): string {
21 const own = overrides[level]
22 return typeof own === 'string' && own.trim() !== '' ? own.trim() : LEVEL_RULES[level]
23}
24
25/** The level only changes the wording: paths, commands and numbers the person must act on are kept at every level. */
26export function systemPrompt(level: Level, overrides: Readonly<Record<string, unknown>> = {}): string {
27 return [
28 'You rewrite one assistant reply as a TL;DR for the person who read it.',
29 levelRules(level, overrides),
30 'Lead with the answer or the outcome in one or two sentences. Then up to 4 bullets with the points that matter most; do not label bullets with fixed headings.',
31 'Keep every file path, command and number the person has to act on, verbatim. Drop everything else.',
32 'Do not add facts. Do not address the assistant. No preamble, no heading.',
33 'Plain text only: no bold, no italics, no headings; bullets start with "- ".',
34 ].join(' ')
35}
36
37/** The newest assistant message with text; tool-only turns have none. */
38export function lastReply(messages: readonly SessionMessage[]): string | undefined {
39 for (let i = messages.length - 1; i >= 0; i--) {
40 const m = messages[i]
41 if (m?.role === 'assistant' && m.text.trim() !== '') return m.text
42 }
43 return undefined
44}
45
46/** Below this a reply is already short enough to read as is. */
47const MIN_CHARS = 400
48
49export function isWorthSummarising(answer: string): boolean {
50 return answer.trim().length >= MIN_CHARS
51}
52
53export type Parsed =
54 | { kind: 'auto' }
55 /** Rewrite a level's rules from feedback; saved to settings. */
56 | { kind: 'tune'; level: Level; feedback: string }
57 /** Back to the built-in rules for a level. */
58 | { kind: 'reset'; level: Level }
59 /** A malformed `tune` / `reset`: what to say instead. */
60 | { kind: 'usage'; text: string }
61 /** `level` set when the first word names one: it is remembered; `ask` is what is left. */
62 | { kind: 'summarise'; level: Level | undefined; ask: string }
63
64const USAGE = `usage: /tldr tune <${LEVELS.join('|')}> <feedback>, /tldr reset <level>`
65
66/**
67 * `/tldr auto` toggles; `/tldr tune eli5 <feedback>` and `/tldr reset eli5` change a level's rules;
68 * `/tldr eli5 [ask]` sets the level, then summarises; else the args are an extra ask.
69 */
70export function parseArgs(args: string): Parsed {
71 const trimmed = args.trim()
72 const [first = '', second = '', ...rest] = trimmed.split(/\s+/)
73 const verb = first.toLowerCase()
74 const level = second.toLowerCase()
75 if (verb === 'auto' && second === '') return { kind: 'auto' }
76 if (verb === 'tune') {
77 const feedback = rest.join(' ')
78 return isLevel(level) && feedback !== '' ? { kind: 'tune', level, feedback } : { kind: 'usage', text: USAGE }
79 }
80 if (verb === 'reset') return isLevel(level) && rest.length === 0 ? { kind: 'reset', level } : { kind: 'usage', text: USAGE }
81 if (isLevel(verb)) return { kind: 'summarise', level: verb, ask: [second, ...rest].join(' ').trim() }
82 return { kind: 'summarise', level: undefined, ask: trimmed }
83}
84
85export const TUNE_SYSTEM = [
86 'You maintain the wording rules a summariser follows at one reading level.',
87 'You get the current rules and feedback from the person who reads the summaries.',
88 'Return the full revised rules: 1 to 4 imperative sentences, addressed to the summariser, under 400 characters.',
89 'Keep what the feedback does not contradict. Do not mention file paths, commands or bullets: other rules cover those.',
90 'Return only the rules, no preamble, no quotes.',
91].join(' ')
92
93export function buildTunePrompt(level: Level, current: string, feedback: string): string {
94 return `<level>${level}</level>\n<current>\n${current}\n</current>\n<feedback>\n${feedback}\n</feedback>`
95}
96
97/** `args` is an optional extra ask (`/tldr one line`), appended as an instruction. */
98export function buildPrompt(reply: string, args: string): string {
99 const extra = args.trim() === '' ? '' : `\n\nAlso: ${args.trim()}`
100 return `<reply>\n${reply}\n</reply>\n\nWrite the TL;DR of this reply.${extra}`
101}
102
103/** Blank lines dropped so the box stays compact. */
104export function toLines(summary: string): string[] {
105 return summary
106 .split('\n')
107 .map(line => line.trimEnd())
108 .filter(line => line.trim() !== '')
109}
110types/index.d.ts 9 lines1/** The TL;DR as drawn in the band above the prompt, one entry per line. */
2export type TldrSummary = string[]
3
4declare module 'claude-code' {
5 interface PluginState {
6 tldr: { isAuto: boolean; summary: TldrSummary | null }
7 }
8}
9