SLOPSHOPPER

insight-pane

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

newpanerowsguardcommandstatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · insight-pane
│ ┃ Intel ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ │ ⏺ Read(src/auth.ts) │ ┃ │ Nothing yet │ ⎿ Read 6 lines │ ┃ │ Concepts, decisions, root causes, │ ⏺ Update(src/auth.ts) │ ┃ │ gotchas and risks collect here as Claude │ ⎿ Added 2 lines, removed 1 line │ ┃ │ works. │ ⏺ Bash(bun test) │ ┃ │ │ ⎿ 3 pass, 1 fail │ ┃ ╰──────────────────────────────────────────╯ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /intel │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Intel
╭──────────────────────────────────────────────────────────╮ │ │ │ Nothing yet │ │ Concepts, decisions, root causes, gotchas and risks │ │ collect here as Claude works. │ │ │ ╰──────────────────────────────────────────────────────────╯
README

insight-pane

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.

Install

claude plugin install insight-pane --marketplace sruthik27/claude-foundry

Requires Claude Code 2.1.296+. Reload plugins or start a new session after installation.

Try it

/intel

Details

MIT licensed. Maintained by Sruthik Issac.

Source 5 files
hooks/register.tsx 315 lines
1import { 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}
315
hooks/presets.ts 99 lines
1type 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[]
99
hooks/parse.ts 132 lines
1import 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}
132
hooks/metadata.ts 24 lines
1/** 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}
24
types/index.d.ts 13 lines
1export 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