Live pane of the decisions, root causes and gotchas worth knowing, fed into ~/learning notes

Toggle the Intel pane. Cards summarize concepts, decisions, root causes, gotchas, facts and risks. Cards are persisted per session and capped by importance. A Haiku fallback can extract cards when Claude did not supply them.
claude plugin install insight-pane --marketplace sruthik27/claude-foundry
Requires Claude Code 2.1.296+. Reload plugins or start a new session after installation.
/intel
MIT licensed. Maintained by Sruthik Issac.
hooks/register.tsx 315 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4declare module 'claude-code' {
5 interface EngineInterface {
6 lumen: { markdown: (args: { surface: RenderSurface; text: string; columns: number }) => Promise<RenderElement | undefined> }
7 }
8}
9
10import type { Insight, InsightKind } from '../types'
11import { PRESETS } from './presets.js'
12import { CAP, cap, clipText, extract, extractPrompt, grouped, ledger, merge, strip, toMarkdown, worthExtracting } from './parse.js'
13
14const PANE = 'insight-pane'
15const TITLE = 'Intel'
16const items = atom({ plugin: 'insight-pane', key: 'items' } as const, [])
17/** Sections the person folded; kept across sessions in $.store. */
18const collapsed = atom({ plugin: 'insight-pane', key: 'collapsed' } as const, [])
19
20const ICON: Record<InsightKind, string> = {
21 concept: '◇', decision: '◆', why: '?', gotcha: '!', fact: '·', risk: '▲',
22}
23const HEADING: Record<InsightKind, string> = {
24 concept: 'Concepts', decision: 'Decisions', why: 'Root causes', gotcha: 'Gotchas', fact: 'Facts', risk: 'Risks',
25}
26/** Colours follow the lumen theme (settings.json), re-read on session start and /intel. */
27type Palette = Record<string, string>
28const T: { name: string; p: Palette } = { name: 'catppuccin-mocha', p: (PRESETS as Record<string, Palette>)['catppuccin-mocha']! }
29/** Which theme token colours each section. */
30const TOKEN: Record<InsightKind, string> = {
31 concept: 'accent', decision: 'link', why: 'heading', gotcha: 'number', fact: 'quote', risk: 'codeFlag',
32}
33const hue = (k: InsightKind) => T.p[TOKEN[k]] ?? '#cdd6f4'
34
35async function loadTheme($: import('claude-code').EngineInterface): Promise<void> {
36 try {
37 const home = (await $.env.get('HOME')) ?? ''
38 const cfg = JSON.parse(await $.fs.read(`${home}/.claude/settings.json`))
39 const name = String((cfg?.pluginConfigs?.['lumen@claude-code-mods'] ?? cfg?.pluginConfigs?.['lumen@inline'] ?? cfg?.pluginConfigs?.['prismantis@prismantis'])?.options?.theme ?? 'catppuccin-mocha')
40 const p = (PRESETS as Record<string, Palette>)[name]
41 if (p) { T.name = name; T.p = p }
42 } catch { /* keep the current theme */ }
43}
44
45/** A letter to fold each section from the keyboard (the pane holds the keys after a click or ctrl+x tab). */
46const FOLD_KEY: Record<InsightKind, string> = { concept: 'c', decision: 'd', why: 'w', gotcha: 'g', fact: 'f', risk: 'r' }
47
48/** Importance as five pips: ●●●●○ */
49const pips = (w: number) => (w >= 5 ? '★ ' : '') + '●'.repeat(w) + '○'.repeat(5 - w)
50
51const GUIDE = `# Insight reporting (insight-pane mod)
52The user delegates most work to you and is at risk of losing track of what is happening in their own systems. While you work, surface what a strong senior engineer who did this by hand would know, so the user stays genuinely competent, not a passive shipper.
53
54These tags are separate from, and never replace, any ★ Insight blocks your output style asks for: keep writing those as usual.
55
56Emit a tag, on its own line, anywhere in your visible reply text:
57<insight id="short-slug" kind="KIND" weight="1-5">one or two plain-English sentences, at most ~35 words (it must fit in 5 lines of a side pane)</insight>
58
59The pane is a small, living summary, not a log. Keep it short and dense:
60- To refine, extend or correct an existing card, re-emit its id with the improved full text (it is rewritten in place). Fold related new knowledge into the closest existing card rather than adding a near-duplicate.
61- To remove a card that is superseded or wrong: <insight id="its-id" drop/>
62- When you fix, mitigate or rule out something listed under Risks, drop that risk card in the same reply. A risk you resolved must not stay on the pane.
63- New id only for genuinely new knowledge.
64
65Ranking. Each section holds at most 10 cards; when full, the lowest-ranked card is evicted automatically. weight is importance to the user's competence:
665 = essential (they'd be lost or embarrassed without it; a core mechanism, a decision that shapes the system, a root cause)
674 = high (would come up in a review or incident)
683 = useful context
692 = minor, nice to know
701 = trivia (rejected outright, never shown)
71Weight honestly: most cards are 3-4; reserve 5. Re-emitting a card (refining it) also boosts its rank, so update the card that keeps proving relevant instead of adding a new one. When a section is near full, prefer merging two related cards, or dropping a stale or superseded one, over adding a marginal new one.
72
73KIND is one of:
74- concept: a 1-2 sentence plain explanation of a tool, feature, mechanism, protocol or pattern you relied on that the user may not know (what it is and why it matters here). Emit this the first time the work depends on it, even if the user never asked, because by hand they would have had to learn it.
75- decision: a choice you made where a real alternative existed, and what it traded away
76- why: the root cause of a bug, or the reason something behaves as it does
77- gotcha: a non-obvious behaviour of a tool, protocol, library or system that bit you or would bite them
78- fact: a crucial fact about their codebase/system they should be able to state if asked
79- risk: something you left fragile, skipped, assumed, or that could break later
80
81Bar: is this knowledge the user would have built by doing the work manually, and would not knowing it make them look clueless in a code review, incident, or design discussion? If not, do not emit it. Never emit: file lists, step narration, formatting, boilerplate, things already said in an earlier insight. Most turns warrant 0-3; never more than 5. Never re-explain a concept already on the pane. Prefer the quiet defaults you picked without being asked; those are the ones the user would otherwise never learn.`
82
83export const register: Register = on => {
84 /** Tool calls in the running main turn: a turn that did real work is worth a fallback call. */
85 let work = 0
86 let ask = ''
87 let turn = 0 // main-loop turns; reseeded from saved items after a reload
88
89 on('session.start', async ($, e, next) => {
90 const r = await next(e)
91 await loadTheme($)
92 await $.command.register({ name: 'intel', description: 'Toggle the Intel pane' })
93 // $.state lives only as long as this process; $.store survives exits. A resumed
94 // session keeps its id, so its cards come back; a new or /clear'd one starts empty.
95 let saved = (await $.store.get(`cards:${await $.session.id()}`)) as Insight[] | undefined
96 // A resume that came back under a new id: merge in the session it continued from (this one's cards win).
97 const prev = await predecessorOf($, e.cwd)
98 if (prev) {
99 const old = ((await $.store.get(`cards:${prev}`)) as Insight[] | undefined) ?? []
100 const mine = saved ?? []
101 saved = [...old.filter(o => !mine.some(x => x.id === o.id)), ...mine]
102 }
103 if (saved && saved.length > 0 && (await read($, items)).length < saved.length) {
104 await update($, items, () => cap(saved.map(i => ({ ...i, text: clipText(i.text) }))))
105 $.ui.status(`◆ ${saved.length} insights · /intel`)
106 }
107 // Folded sections are a preference: they apply to every session.
108 const folds = (await $.store.get('collapsed')) as InsightKind[] | undefined
109 if (folds?.length) await update($, collapsed, () => folds)
110 return r
111 })
112
113 on('classic.SessionStart', async ($, e, next) => {
114 const r = await next(e)
115 if (!['clear', 'resume', 'fork'].includes(e.source)) return r
116 work = 0; ask = ''; turn = 0
117 let saved = e.source === 'clear' ? [] : (await $.store.get(`cards:${await $.session.id()}`) as Insight[] | undefined) ?? []
118 if (e.source !== 'clear' && e.cwd) {
119 const prev = await predecessorOf($, e.cwd)
120 if (prev) { const old = await $.store.get(`cards:${prev}`) as Insight[] | undefined; saved = [...(old ?? []).filter(o => !saved.some(x => x.id === o.id)), ...saved] }
121 }
122 const restored = cap(saved.map(i => ({ ...i, text: clipText(i.text) })))
123 await update($, items, () => restored)
124 turn = Math.max(0, ...restored.map(i => i.turn))
125 $.ui.status(undefined)
126 return r
127 })
128
129 on('command.run', { command: 'intel' }, async $ => {
130 await loadTheme($)
131 const open = (await $.ui.panes()).some(p => p.id === PANE)
132 if (open) await $.ui.close({ id: PANE })
133 else await $.ui.open({ id: PANE, title: TITLE })
134 return {}
135 })
136
137 on('prompt.compose', async ($, e, next) => {
138 const r = await next(e)
139 const list = await read($, items)
140 const text = list.length === 0 ? GUIDE : `${GUIDE}\n\nCards currently on the pane, best-first per section (id weight text):\n${ledger(list)}`
141 return { ...r, sections: [...r.sections, { id: 'insight-pane:guide', text, scope: 'session' }] }
142 })
143
144 on('turn.start', async ($, e, next) => {
145 if (!(e as { agentId?: string }).agentId) { work = 0; ask = String((e as { text?: string }).text ?? '').slice(0, 1500) }
146 return next(e)
147 })
148
149 on('tool.call', async ($, e, next) => {
150 if (!e.agentId) work += 1
151 return next(e)
152 })
153
154 // A short nudge with each prompt: Claude writes its own cards more often, so fewer fallback calls.
155 on('prompt.submit', async ($, e, next) => {
156 const nudge = 'Reminder from the insight pane: if this turn decides, explains, fixes or learns something worth knowing, add the <insight .../> tag(s) your system prompt describes, in addition to (never instead of) any ★ Insight blocks your output style asks for.'
157 return next({ ...e, context: [...(e.context ?? []), nudge] })
158 })
159
160 on('turn.complete', async ($, e, next) => {
161 const r = await next(e)
162 if (e.agentId) return r // subagent turns: their summary reaches the main loop anyway
163 if (turn === 0) turn = Math.max(0, ...(await read($, items)).map(i => i.turn))
164 turn += 1
165 let ops = extract(e.answer, turn)
166 const risks = (await read($, items)).filter(i => i.kind === 'risk')
167 const dropped = new Set(ops.filter(o => o.op === 'drop').map(o => (o as { id: string }).id))
168 const openRisks = risks.filter(i => !dropped.has(i.id))
169 // A working turn with no cards, or one that left risks standing: one small call writes the
170 // cards Claude didn't and drops the risks this turn resolved.
171 if ((ops.length === 0 || openRisks.length > 0) && worthExtracting(e.answer, work)) {
172 try {
173 const reply = await $.model.complete({
174 model: 'haiku', effort: 'low', maxTokens: 700, timeoutMs: 30000,
175 prompt: extractPrompt(ask, e.answer, ledger(await read($, items))),
176 })
177 if (reply.isAnswered) {
178 const more = extract(reply.text, turn)
179 const own = new Set(ops.map(o => (o.op === 'set' ? o.insight.id : o.id)))
180 // Claude's own cards win; the fallback only adds what is missing and drops resolved risks.
181 const keep = more.filter(o => o.op === 'drop' ? openRisks.some(i => i.id === o.id) : ops.length === 0 && !own.has(o.insight.id))
182 ops = [...ops, ...keep]
183 }
184 } catch { /* nothing this turn */ }
185 }
186 work = 0
187 if (ops.length === 0) return r
188
189 const all = await update($, items, list => merge(list, ops))
190 $.ui.status(`◆ ${all.length} insights · /intel`)
191
192 const sid = await $.session.id()
193 await $.store.set(`cards:${sid}`, all)
194 // Keep the 40 most recent sessions' cards; insertion order is oldest first.
195 const keys = (await $.store.keys()).filter(k => k.startsWith('cards:') && k !== `cards:${sid}`)
196 for (const k of keys.slice(0, Math.max(0, keys.length - 39))) await $.store.delete(k)
197
198 try {
199 if (!(await $.ui.panes()).some(p => p.id === PANE)) void $.ui.open({ id: PANE, title: TITLE })
200 } catch { /* no surface to open on (headless) */ }
201
202 // Optional: mirror the cards as markdown into ~/learning/.insights/ for a notes pipeline.
203 // Off unless that folder already exists, so nothing is created in a home directory uninvited.
204 try {
205 const home = await $.env.get('HOME')
206 if (home && (await $.fs.exists(`${home}/learning/.insights`)) && !(await $.fs.exists(`${home}/learning/.paused`))) {
207 await $.fs.write(`${home}/learning/.insights/${sid}.md`, toMarkdown(all))
208 }
209 } catch { /* sidecar is optional */ }
210 return r
211 })
212
213 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
214 if (!e.props.text.includes('<insight')) return next(e)
215 return next({ ...e, props: { ...e.props, text: strip(e.props.text) } })
216 })
217
218 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
219 const { Box, Text, Button } = $.ui.resolve(e)
220 const list = await read($, items)
221 const folded = new Set(await read($, collapsed))
222 const width = Math.max(16, e.props.bodyColumns - 2)
223 const rows = Math.max(1, e.props.scroll?.bodyRows ?? 1)
224 const ink = T.p.codeText ?? '#cdd6f4'
225 const muted = T.p.codeComment ?? '#6c7086'
226 const rule = T.p.rule ?? '#45475a'
227
228 if (list.length === 0) {
229 return (
230 <Box flexDirection="column" paddingX={1} paddingY={1} borderStyle="round" borderColor={rule}>
231 <Text color={T.p.heading} bold>Nothing yet</Text>
232 <Text color={muted} italic wrap="wrap">Concepts, decisions, root causes, gotchas and risks collect here as Claude works.</Text>
233 </Box>
234 )
235 }
236 // Card text goes through lumen so code, paths and numbers are highlighted as in replies.
237 const body = async (text: string, cols: number, top: boolean) => {
238 try {
239 const d = await $.lumen.markdown({ surface: e.surface, text, columns: cols })
240 if (d) return d
241 } catch { /* lumen absent */ }
242 return <Text color={top ? T.p.strong : ink} bold={top} wrap="wrap">{text}</Text>
243 }
244 const groups = grouped(list)
245 const total = list.length
246 const textCols = Math.max(20, width - 4)
247 const drawn = await Promise.all(groups.map(async g => folded.has(g.kind) ? [] :
248 Promise.all(g.items.map(i => body(i.text, textCols, (i.weight ?? 3) >= 5)))))
249 return (
250 <Box flexDirection="column" gap={1} width={e.props.bodyColumns} minHeight={rows} paddingX={1} paddingY={1}>
251 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
252 {groups.map(g => (
253 <Text key={`c-${g.kind}`} color={hue(g.kind)} bold>{`${ICON[g.kind]} ${g.items.length}`}</Text>
254 ))}
255 <Text color={muted}>{`· ${total}`}</Text>
256 </Box>
257 {groups.map((g, gi) => {
258 const label = ` ${ICON[g.kind]} ${HEADING[g.kind].toUpperCase()} `
259 const count = folded.has(g.kind) ? `${g.items.length} hidden` : `${g.items.length}/${CAP}`
260 const fill = Math.max(1, width - label.length - count.length - 9)
261 return (
262 <Box key={g.kind} flexDirection="column">
263 <Box flexDirection="row">
264 <Box flexShrink={0}><Text backgroundColor={hue(g.kind)} color="#11111b" bold>{label}</Text></Box>
265 <Box flexShrink={1}><Text color={rule} wrap="truncate">{' ' + '─'.repeat(fill) + ' '}</Text></Box>
266 <Box flexShrink={0}><Text color={muted}>{count + ' '}</Text></Box>
267 <Button key={`fold-${g.kind}`} plain label={folded.has(g.kind) ? '▸' : '▾'} hotkey={FOLD_KEY[g.kind]}
268 onPress={async () => {
269 const next = await update($, collapsed, c => (c.includes(g.kind) ? c.filter(k => k !== g.kind) : [...c, g.kind]))
270 try { await $.store.set('collapsed', next) } catch { /* only this session then */ }
271 }} />
272 </Box>
273 {!folded.has(g.kind) && g.items.map((i, ii) => {
274 const w = i.weight ?? 3
275 const top = w >= 5
276 return (
277 <Box key={i.id} flexDirection="row" width={width - 1} marginTop={1}>
278 <Text color={hue(g.kind)}>{'┃ '}</Text>
279 <Box flexDirection="column" flexShrink={1}>
280 {drawn[gi]![ii]}
281 <Box flexDirection="row" columnGap={1}>
282 <Text color={top ? hue(g.kind) : muted}>{pips(w)}</Text>
283 <Text color={muted} italic>{`turn ${i.turn}${i.revised ? ' · revised' : ''}`}</Text>
284 </Box>
285 </Box>
286 </Box>
287 )
288 })}
289 </Box>
290 )
291 })}
292 </Box>
293 )
294 })
295}
296
297/* A resumed session can come back under a new id with the old history copied in. This finds the
298 session it continued from: the other transcript in the same project that holds the same first
299 message. Mods key their saved state by session id, so they look there when their own is empty. */
300async function predecessorOf($: import('claude-code').EngineInterface, cwd: string): Promise<string | undefined> {
301 try {
302 const home = (await $.env.get('HOME')) ?? ''
303 const dir = `${home}/.claude/projects/${cwd.replace(/[^A-Za-z0-9]/g, '-')}`
304 const sid = await $.session.id()
305 const r = await $.process.run(['sh', '-c',
306 'f="$1/$2.jsonl"; [ -f "$f" ] || exit 0; u=$(grep -m1 -o \'"uuid":"[^"]*"\' "$f") || exit 0; ' +
307 'ls -t "$1"/*.jsonl 2>/dev/null | head -40 | grep -v "/$2.jsonl" | xargs grep -l -F "$u" 2>/dev/null | head -1',
308 'prev', dir, sid], { timeoutMs: 20000 })
309 const hit = r.stdout.trim().split('/').pop()?.replace(/\.jsonl$/, '')
310 return hit && hit !== sid ? hit : undefined
311 } catch {
312 return undefined
313 }
314}
315hooks/presets.ts 99 lines1type Theme = Record<string, string> // copied from lumen (MIT; originally from prismantis)
2
3export const PRESETS = {
4 'catppuccin-mocha': {
5 accent: '#cba6f7', heading: '#f9e2af', tableHeader: '#f9e2af', strong: '#ffffff', emphasis: '#f5c2e7',
6 inlineCode: '#89dceb', codeText: '#cdd6f4', codeCommand: '#89b4fa', codeFlag: '#f38ba8', codeString: '#a6e3a1',
7 codeComment: '#6c7086', link: '#89b4fa', path: '#89dceb', number: '#a6e3a1', quote: '#9399b2',
8 rule: '#45475a', tableRule: '#585b70', bullet: '#cba6f7', diagram: '#89b4fa', diagramText: '#cdd6f4',
9 },
10 'catppuccin-latte': {
11 accent: '#8839ef', heading: '#df8e1d', tableHeader: '#df8e1d', strong: '#11111b', emphasis: '#ea76cb',
12 inlineCode: '#04a5e5', codeText: '#4c4f69', codeCommand: '#1e66f5', codeFlag: '#d20f39', codeString: '#40a02b',
13 codeComment: '#9ca0b0', link: '#1e66f5', path: '#209fb5', number: '#40a02b', quote: '#6c6f85',
14 rule: '#bcc0cc', tableRule: '#acb0be', bullet: '#8839ef', diagram: '#1e66f5', diagramText: '#4c4f69',
15 },
16 dracula: {
17 accent: '#bd93f9', heading: '#bd93f9', tableHeader: '#f1fa8c', strong: '#f8f8f2', emphasis: '#ff79c6',
18 inlineCode: '#8be9fd', codeText: '#f8f8f2', codeCommand: '#50fa7b', codeFlag: '#ff79c6', codeString: '#f1fa8c',
19 codeComment: '#6272a4', link: '#8be9fd', path: '#ffb86c', number: '#bd93f9', quote: '#6272a4',
20 rule: '#44475a', tableRule: '#6272a4', bullet: '#ff79c6', diagram: '#bd93f9', diagramText: '#f8f8f2',
21 },
22 nord: {
23 accent: '#88c0d0', heading: '#88c0d0', tableHeader: '#ebcb8b', strong: '#eceff4', emphasis: '#b48ead',
24 inlineCode: '#8fbcbb', codeText: '#d8dee9', codeCommand: '#88c0d0', codeFlag: '#81a1c1', codeString: '#a3be8c',
25 codeComment: '#4c566a', link: '#88c0d0', path: '#8fbcbb', number: '#b48ead', quote: '#81a1c1',
26 rule: '#3b4252', tableRule: '#4c566a', bullet: '#88c0d0', diagram: '#81a1c1', diagramText: '#d8dee9',
27 },
28 'tokyo-night': {
29 accent: '#bb9af7', heading: '#7dcfff', tableHeader: '#e0af68', strong: '#c0caf5', emphasis: '#f7768e',
30 inlineCode: '#7dcfff', codeText: '#a9b1d6', codeCommand: '#7aa2f7', codeFlag: '#bb9af7', codeString: '#9ece6a',
31 codeComment: '#565f89', link: '#73daca', path: '#2ac3de', number: '#ff9e64', quote: '#9aa5ce',
32 rule: '#414868', tableRule: '#565f89', bullet: '#bb9af7', diagram: '#7aa2f7', diagramText: '#c0caf5',
33 },
34 'gruvbox-dark': {
35 accent: '#d3869b', heading: '#fabd2f', tableHeader: '#fabd2f', strong: '#fbf1c7', emphasis: '#d3869b',
36 inlineCode: '#8ec07c', codeText: '#ebdbb2', codeCommand: '#83a598', codeFlag: '#fe8019', codeString: '#b8bb26',
37 codeComment: '#928374', link: '#83a598', path: '#8ec07c', number: '#d3869b', quote: '#a89984',
38 rule: '#3c3836', tableRule: '#504945', bullet: '#fe8019', diagram: '#83a598', diagramText: '#ebdbb2',
39 },
40 'gruvbox-light': {
41 accent: '#8f3f71', heading: '#b57614', tableHeader: '#b57614', strong: '#282828', emphasis: '#8f3f71',
42 inlineCode: '#427b58', codeText: '#3c3836', codeCommand: '#076678', codeFlag: '#af3a03', codeString: '#79740e',
43 codeComment: '#928374', link: '#076678', path: '#427b58', number: '#8f3f71', quote: '#7c6f64',
44 rule: '#ebdbb2', tableRule: '#a89984', bullet: '#af3a03', diagram: '#076678', diagramText: '#3c3836',
45 },
46 'rose-pine': {
47 accent: '#c4a7e7', heading: '#c4a7e7', tableHeader: '#f6c177', strong: '#e0def4', emphasis: '#ebbcba',
48 inlineCode: '#9ccfd8', codeText: '#e0def4', codeCommand: '#9ccfd8', codeFlag: '#eb6f92', codeString: '#f6c177',
49 codeComment: '#6e6a86', link: '#c4a7e7', path: '#9ccfd8', number: '#ebbcba', quote: '#908caa',
50 rule: '#26233a', tableRule: '#6e6a86', bullet: '#eb6f92', diagram: '#c4a7e7', diagramText: '#e0def4',
51 },
52 'rose-pine-dawn': {
53 accent: '#907aa9', heading: '#907aa9', tableHeader: '#ea9d34', strong: '#464261', emphasis: '#d7827e',
54 inlineCode: '#56949f', codeText: '#464261', codeCommand: '#56949f', codeFlag: '#b4637a', codeString: '#ea9d34',
55 codeComment: '#9893a5', link: '#907aa9', path: '#286983', number: '#d7827e', quote: '#797593',
56 rule: '#f2e9e1', tableRule: '#9893a5', bullet: '#b4637a', diagram: '#907aa9', diagramText: '#464261',
57 },
58 everforest: {
59 accent: '#a7c080', heading: '#e69875', tableHeader: '#dbbc7f', strong: '#d3c6aa', emphasis: '#d699b6',
60 inlineCode: '#83c092', codeText: '#d3c6aa', codeCommand: '#7fbbb3', codeFlag: '#e67e80', codeString: '#a7c080',
61 codeComment: '#859289', link: '#7fbbb3', path: '#83c092', number: '#d699b6', quote: '#9da9a0',
62 rule: '#475258', tableRule: '#7a8478', bullet: '#e69875', diagram: '#7fbbb3', diagramText: '#d3c6aa',
63 },
64 'github-dark': {
65 accent: '#d2a8ff', heading: '#79c0ff', tableHeader: '#ffa657', strong: '#e6edf3', emphasis: '#d2a8ff',
66 inlineCode: '#79c0ff', codeText: '#e6edf3', codeCommand: '#d2a8ff', codeFlag: '#ff7b72', codeString: '#a5d6ff',
67 codeComment: '#7d8590', link: '#58a6ff', path: '#7ee787', number: '#79c0ff', quote: '#7d8590',
68 rule: '#30363d', tableRule: '#8b949e', bullet: '#ff7b72', diagram: '#58a6ff', diagramText: '#e6edf3',
69 },
70 'github-light': {
71 accent: '#8250df', heading: '#0550ae', tableHeader: '#953800', strong: '#1f2328', emphasis: '#8250df',
72 inlineCode: '#0550ae', codeText: '#1f2328', codeCommand: '#8250df', codeFlag: '#cf222e', codeString: '#0a3069',
73 codeComment: '#6e7781', link: '#0969da', path: '#116329', number: '#0550ae', quote: '#656d76',
74 rule: '#d0d7de', tableRule: '#6e7781', bullet: '#cf222e', diagram: '#0969da', diagramText: '#1f2328',
75 },
76 'one-dark': {
77 accent: '#c678dd', heading: '#e06c75', tableHeader: '#e5c07b', strong: '#d19a66', emphasis: '#c678dd',
78 inlineCode: '#56b6c2', codeText: '#abb2bf', codeCommand: '#61afef', codeFlag: '#e06c75', codeString: '#98c379',
79 codeComment: '#5c6370', link: '#61afef', path: '#56b6c2', number: '#d19a66', quote: '#828997',
80 rule: '#3a3f4b', tableRule: '#5c6370', bullet: '#c678dd', diagram: '#61afef', diagramText: '#abb2bf',
81 },
82 'solarized-dark': {
83 accent: '#6c71c4', heading: '#b58900', tableHeader: '#b58900', strong: '#eee8d5', emphasis: '#d33682',
84 inlineCode: '#2aa198', codeText: '#93a1a1', codeCommand: '#268bd2', codeFlag: '#cb4b16', codeString: '#859900',
85 codeComment: '#586e75', link: '#268bd2', path: '#2aa198', number: '#859900', quote: '#657b83',
86 rule: '#073642', tableRule: '#586e75', bullet: '#6c71c4', diagram: '#268bd2', diagramText: '#93a1a1',
87 },
88 'solarized-light': {
89 accent: '#6c71c4', heading: '#b58900', tableHeader: '#b58900', strong: '#073642', emphasis: '#d33682',
90 inlineCode: '#2aa198', codeText: '#586e75', codeCommand: '#268bd2', codeFlag: '#cb4b16', codeString: '#859900',
91 codeComment: '#93a1a1', link: '#268bd2', path: '#2aa198', number: '#859900', quote: '#839496',
92 rule: '#eee8d5', tableRule: '#93a1a1', bullet: '#6c71c4', diagram: '#268bd2', diagramText: '#586e75',
93 },
94 mono: {} as Theme,
95} satisfies Record<string, Theme>
96
97export type PresetName = keyof typeof PRESETS
98export const PRESET_NAMES = Object.keys(PRESETS) as PresetName[]
99hooks/parse.ts 132 lines1import type { Insight, InsightKind, InsightOp } from '../types'
2import { outsideCode, stripOutsideCode } from './metadata.js'
3
4/** Most cards a section may hold; the lowest-ranked beyond it are evicted. */
5export const CAP = 10
6export const MIN_WEIGHT = 2
7
8export const KINDS: readonly InsightKind[] = ['concept', 'decision', 'why', 'gotcha', 'fact', 'risk']
9// A real tag carries attributes (`<insight id=… kind=…>`); a bare `<insight>` is prose about the tag.
10const TAG = /<insight\s+([^>]*?)(?:\/>|>([\s\S]{0,4000}?)<\/insight>)/g
11/** The most a card may hold: about five lines of the pane. */
12export const MAX_TEXT = 300
13
14/** Cuts a card to MAX_TEXT, at a sentence or word end where possible. */
15export function clipText(t: string): string {
16 if (t.length <= MAX_TEXT) return t
17 const head = t.slice(0, MAX_TEXT)
18 const stop = Math.max(head.lastIndexOf('. '), head.lastIndexOf('; '))
19 if (stop > MAX_TEXT * 0.6) return head.slice(0, stop + 1)
20 return head.slice(0, head.lastIndexOf(' ') > 0 ? head.lastIndexOf(' ') : MAX_TEXT).replace(/[,;:]$/, '') + '…'
21}
22const ATTR = /([a-z]+)(?:="([^"]*)")?/g
23
24function attrs(raw: string): Record<string, string> {
25 const out: Record<string, string> = {}
26 for (const m of raw.matchAll(ATTR)) out[m[1] ?? ''] = m[2] ?? ''
27 return out
28}
29
30function slug(text: string): string {
31 return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 24) || 'x'
32}
33
34/** Reads every <insight> tag in a reply as an operation on the card list. */
35export function extract(text: string, turn: number): InsightOp[] {
36 const out: InsightOp[] = []
37 for (const m of outsideCode(text, TAG)) {
38 const a = attrs(m[1] ?? '')
39 if (!a.id && !a.kind && !('drop' in a)) continue
40 if ('drop' in a && a.id) { out.push({ op: 'drop', id: a.id }); continue }
41 const body = clipText((m[2] ?? '').replace(/\s+/g, ' ').trim())
42 if (!body) continue
43 const kind = (KINDS as readonly string[]).includes(a.kind ?? '') ? (a.kind as InsightKind) : 'fact'
44 const w = Math.round(Number(a.weight))
45 const weight = w >= 1 && w <= 5 ? w : 3
46 if (weight < MIN_WEIGHT) continue // below the bar: rejected outright
47 out.push({ op: 'set', insight: { id: a.id || slug(body), turn, kind, text: body, weight } })
48 }
49 return out
50}
51
52/** Applies operations: same id rewrites in place (keeps its slot), drop removes. */
53export function merge(list: readonly Insight[], ops: readonly InsightOp[]): Insight[] {
54 let next = list.map(i => (i.id ? i : { ...i, id: slug(i.text) })) // cards saved before ids existed
55 for (const o of ops) {
56 if (o.op === 'drop') { next = next.filter(i => i.id !== o.id); continue }
57 const at = next.findIndex(i => i.id === o.insight.id)
58 if (at === -1) next.push(o.insight)
59 else {
60 const prev = next[at]!
61 next[at] = { ...o.insight, revised: true, hits: (prev.hits ?? 1) + 1 }
62 }
63 }
64 return cap(next)
65}
66
67/**
68 * Rank: importance dominates; each re-confirmation adds a point (capped), so knowledge
69 * that keeps proving relevant rises; recency only breaks ties.
70 */
71export function score(i: Insight): number {
72 return (i.weight ?? 3) * 10 + Math.min((i.hits ?? 1) - 1, 5) * 3 + i.turn / 1000
73}
74
75/** Keeps the top CAP per section by score; returns each section best-first. */
76export function cap(list: readonly Insight[]): Insight[] {
77 return KINDS.flatMap(k =>
78 list.filter(i => i.kind === k).sort((a, b) => score(b) - score(a)).slice(0, CAP),
79 )
80}
81
82export function strip(text: string): string {
83 return stripOutsideCode(text, TAG)
84}
85
86/** Sections in a fixed order, empty ones left out. */
87export function grouped(list: readonly Insight[]): { kind: InsightKind; items: Insight[] }[] {
88 return KINDS.map(kind => ({ kind, items: list.filter(i => i.kind === kind) })).filter(g => g.items.length > 0)
89}
90
91/** Current cards, compact, for the system prompt so Claude can merge into them. */
92export function ledger(list: readonly Insight[]): string {
93 return KINDS.map(k => {
94 const g = list.filter(i => i.kind === k)
95 return g.length === 0 ? '' : `${k} (${g.length}/${CAP}):\n` + g.map(i => `- ${i.id} w${i.weight ?? 3} ${i.text}`).join('\n')
96 }).filter(Boolean).join('\n')
97}
98
99/** The sidecar learning-capture.sh folds into its digest: the current state, by section. */
100export function toMarkdown(list: readonly Insight[]): string {
101 return grouped(list).map(g => `${g.kind.toUpperCase()}\n` + g.items.map(i => `- ${i.text}`).join('\n')).join('\n\n') + '\n'
102}
103
104/** A turn is worth a fallback call when it did work (tools) or said something substantial. */
105export function worthExtracting(answer: string, toolCalls: number): boolean {
106 const words = answer.trim().split(/\s+/).filter(Boolean).length
107 return (toolCalls >= 2 && words >= 40) || words >= 250
108}
109
110/** The fallback: read one turn's reply and write the cards Claude did not. */
111export function extractPrompt(ask: string, answer: string, current: string): string {
112 return `You maintain a developer's "What you should know" pane: a small, ranked set of cards with the knowledge they would have built doing the work by hand. Read one turn of an AI coding session and output only cards worth keeping.
113
114The user asked:
115${ask.slice(0, 1500) || '(not available)'}
116
117The assistant's reply:
118${answer.slice(0, 12000)}
119
120Cards already on the pane (id [kind] weight text) — re-use an id to refine one instead of repeating it:
121${current || '(none)'}
122
123Output 0-3 lines, each exactly:
124<insight id="short-slug" kind="concept|decision|why|gotcha|fact|risk" weight="1-5">one or two plain sentences, <= 35 words</insight>
125concept = what a tool/mechanism is and why it matters here; decision = a choice with a real alternative and what it traded; why = a root cause; gotcha = non-obvious behaviour that bites; fact = a crucial fact about their system; risk = something left fragile or assumed.
126Weight 5 essential, 4 high, 3 useful, 2 minor. Skip narration, file lists, formatting and anything already on the pane.
127
128Then check every card on the pane whose kind is [risk]: if this turn fixed it, mitigated it, or showed it does not apply, output a line to remove it:
129<insight id="<that risk's id>" drop/>
130Only drop a risk the reply clearly resolves; keep any risk that is still open. If nothing qualifies, output nothing.`
131}
132hooks/metadata.ts 24 lines1/** Reporting tags inside Markdown code are examples. Preserve them verbatim. */
2export function codeSpans(text: string): readonly (readonly [number, number])[] {
3 const spans: [number, number][] = []
4 const re = /(`+|~{3,})/g
5 let m: RegExpExecArray | null
6 while ((m = re.exec(text))) {
7 const marker = m[0]
8 if (marker[0] === '~' && text.slice(text.lastIndexOf('\n', m.index) + 1, m.index).trim()) continue
9 const end = text.indexOf(marker, re.lastIndex)
10 // An unfinished code span/fence is still code while a reply streams.
11 const stop = end < 0 ? text.length : end + marker.length
12 spans.push([m.index, stop]); re.lastIndex = stop
13 }
14 return spans
15}
16export function outsideCode(text: string, tag: RegExp): RegExpMatchArray[] {
17 const spans = codeSpans(text)
18 return [...text.matchAll(tag)].filter(m => !spans.some(([a, b]) => m.index! >= a && m.index! < b))
19}
20export function stripOutsideCode(text: string, tag: RegExp): string {
21 for (const m of outsideCode(text, tag).reverse()) text = text.slice(0, m.index!) + text.slice(m.index! + m[0].length)
22 return text.replace(/\n{3,}/g, '\n\n')
23}
24types/index.d.ts 13 lines1export type InsightKind = 'concept' | 'decision' | 'why' | 'gotcha' | 'fact' | 'risk'
2/** One card. `id` is Claude's short slug; re-emitting it rewrites the card in place. */
3/** weight: Claude's 1-5 importance; hits: how many times it was re-confirmed/refined. */
4export type Insight = { id: string; turn: number; kind: InsightKind; text: string; revised?: boolean; weight?: number; hits?: number }
5/** What one tag asks for: a new or rewritten card, or the removal of one. */
6export type InsightOp = { op: 'set'; insight: Insight } | { op: 'drop'; id: string }
7
8declare module 'claude-code' {
9 interface PluginState {
10 'insight-pane': { items: Insight[]; collapsed: InsightKind[] }
11 }
12}
13