Claude 답변에 처음 나온 기술 용어를 비개발자도 알 수 있게 한 줄로 풀어 보여 준다

Claude Code 모드(함수 훅 플러그인) 모음이다. 이 저장소 자체가 플러그인 마켓플레이스(seodongin-mods)다.
Claude 의 답변에 처음 나온 기술 용어를 개발자가 아닌 사람도 알 수 있게 한국어 한 줄로 풀어 준다.
/terms 로 전체 목록을 패널에서 본다.Claude Code 가 도는 기기마다 한 번 한다(맥 · Windows 터미널). 모드를 쓰려면 Claude Code 가 최신이어야 한다.
claude update
claude plugin marketplace add luisdonginseo/claude-mods
claude plugin install term-explainer@seodongin-mods --scope user
터미널 세션 안에서는 /plugin install term-explainer --marketplace luisdonginseo/claude-mods 로도 된다.
저장소의 .claude/settings.json 에 넣고 커밋한다.
{
"extraKnownMarketplaces": {
"seodongin-mods": { "source": { "source": "github", "repo": "luisdonginseo/claude-mods" } }
},
"enabledPlugins": { "term-explainer@seodongin-mods": true }
}
claude plugin marketplace update seodongin-mods
claude plugin update term-explainer@seodongin-mods
세션에서 /reload-plugins.
claude plugin validate .
claude plugin test term-explainerhooks/register.tsx 127 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Term } from '../types'
5import {
6 MIN_ANSWER,
7 MODEL,
8 SYSTEM,
9 asGlossary,
10 buildPrompt,
11 keyOf,
12 knownTerms,
13 onlyNew,
14 parseTerms,
15} from './terms'
16
17const PANE = 'term-explainer'
18const STORE_KEY = 'glossary'
19// 입력창 위에 보여 줄 최근 새 용어. 닫기를 누르거나 다음 새 용어가 오면 바뀐다.
20const recent = atom({ plugin: 'term-explainer', key: 'recent' } as const, [] as Term[])
21const isHidden = atom({ plugin: 'term-explainer', key: 'isHidden' } as const, false)
22
23export const register: Register = on => {
24 // 답변이 끝날 때 바로 모델을 부르지 않는다. 큐에 넣고 타이머가 처리한다.
25 // 그래야 답변 끝이 모델 호출 시간만큼 늦어지지 않는다.
26 const queue: string[] = []
27 let isDraining = false
28
29 on('session.start', async ($, e, next) => {
30 await $.command.register({
31 name: 'terms',
32 description: '지금까지 풀이한 용어 전체를 패널로 연다',
33 })
34
35 const drain = async () => {
36 if (isDraining) return
37 isDraining = true
38 try {
39 while (queue.length > 0) {
40 const answer = queue.shift()!
41 const glossary = asGlossary(await $.store.get(STORE_KEY))
42 const res = await $.model.complete({
43 model: MODEL,
44 system: SYSTEM,
45 prompt: buildPrompt(answer, knownTerms(glossary)),
46 maxTokens: 800,
47 effort: 'low',
48 timeoutMs: 20000,
49 })
50 if (!res.isAnswered) {
51 $.ui.log(`term-explainer: model call skipped (${res.reason})`)
52 continue
53 }
54 const fresh = onlyNew(parseTerms(res.text), glossary)
55 if (fresh.length === 0) continue
56 const now = await $.clock.now()
57 const terms: Term[] = fresh.map(t => ({ ...t, seenAt: now }))
58 // 저장 직전에 다시 읽는다. 다른 세션이 그사이 넣은 용어를 덮어쓰지 않게 한다.
59 const latest = asGlossary(await $.store.get(STORE_KEY))
60 for (const t of terms) latest[keyOf(t.term)] ??= t
61 await $.store.set(STORE_KEY, latest)
62 await update($, recent, () => terms)
63 await update($, isHidden, () => false)
64 $.ui.toast(`새 용어 ${terms.length}개: ${terms.map(t => t.term).join(', ')}`)
65 }
66 } finally {
67 isDraining = false
68 }
69 }
70
71 $.clock.every(2000, () => {
72 if (queue.length > 0) void drain()
73 })
74 return next(e)
75 })
76
77 on('turn.complete', async ($, e, next) => {
78 const result = await next(e)
79 // 하위 에이전트의 답변은 사업주가 직접 읽지 않으므로 건너뛴다.
80 if (e.agentId === undefined && e.reason === 'answer' && e.answer.length >= MIN_ANSWER) {
81 queue.push(e.answer)
82 }
83 return result
84 })
85
86 on('command.run', { command: 'terms' }, async $ => {
87 await $.ui.open({ id: PANE, title: '용어 풀이' })
88 return { text: '용어 풀이 패널을 열었습니다.' }
89 })
90
91 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
92 const terms = await read($, recent)
93 if (e.props.hasSurvey || terms.length === 0 || (await read($, isHidden))) {
94 return next(e)
95 }
96 const { Box, Button, Text } = $.ui.resolve(e)
97 return (
98 <Box flexDirection="column">
99 {terms.map(t => (
100 <Text key={`t-${t.term}`}>
101 <Text bold>{t.term}</Text>
102 <Text dimColor> — {t.plain}</Text>
103 </Text>
104 ))}
105 <Button key="hide" label="닫기" onPress={() => update($, isHidden, () => true)} />
106 </Box>
107 )
108 })
109
110 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
111 const { Box, Text } = $.ui.resolve(e)
112 const glossary = asGlossary(await $.store.get(STORE_KEY))
113 const all = Object.values(glossary).sort((a, b) => b.seenAt - a.seenAt)
114 return (
115 <Box flexDirection="column">
116 {all.length === 0 && <Text dimColor>아직 풀이한 용어가 없습니다.</Text>}
117 {all.map(t => (
118 <Text key={`p-${t.term}`}>
119 <Text bold>{t.term}</Text>
120 <Text dimColor> — {t.plain}</Text>
121 </Text>
122 ))}
123 </Box>
124 )
125 })
126}
127hooks/terms.ts 90 lines1import type { Term } from '../types'
2
3// 이름만 담은 용어집. 키는 소문자 용어다(같은 말을 두 번 풀이하지 않는다).
4export type Glossary = Record<string, Term>
5
6export const MODEL = 'haiku'
7// 짧은 답변에는 풀이할 용어가 거의 없다. 모델 호출을 아낀다.
8export const MIN_ANSWER = 40
9// 답변 끝부분만 보낸다. 긴 코드 출력으로 비용이 커지지 않게 한다.
10export const MAX_ANSWER = 6000
11// 이미 아는 용어 목록은 최근 것부터 이만큼만 보낸다.
12export const MAX_KNOWN = 400
13export const MAX_NEW = 5
14
15export const SYSTEM =
16 '너는 개발자가 아닌 사업주에게 기술 용어를 쉽게 풀어 주는 도우미다. ' +
17 '반드시 JSON 배열만 답한다. 설명 문장이나 코드 블록 표시를 붙이지 않는다.'
18
19export function buildPrompt(answer: string, known: readonly string[]): string {
20 const text = answer.length > MAX_ANSWER ? answer.slice(-MAX_ANSWER) : answer
21 return [
22 '아래 <answer> 는 개발 도우미가 사업주에게 한 답변이다.',
23 `개발자가 아닌 사람이 모를 만한 전문 용어를 최대 ${MAX_NEW}개 골라라.`,
24 '',
25 '고르는 기준:',
26 '- 업계에서 실제로 쓰는 기술 · 개발 용어만 (예: 워크트리, 디스커버리, 해시, 하위 호환).',
27 '- 고르지 않는 것: 일상어, 파일 경로, 코드 식별자, 명령어 그 자체, 사람 · 회사 · 제품 이름, 아래 <known> 에 있는 용어.',
28 '- 답변에 나온 표기 그대로 적는다.',
29 '',
30 '각 항목의 "plain" 은 한국어 한두 문장, 60자 안팎. 비유를 써도 된다. 영어 용어를 직역하지 않는다.',
31 '',
32 '형식: [{"term":"워크트리","plain":"한 저장소를 여러 폴더에 동시에 펼쳐 놓고 따로 작업하는 기능."}]',
33 '고를 용어가 없으면 [] 만 답한다.',
34 '',
35 `<known>${known.join(', ')}</known>`,
36 '',
37 `<answer>\n${text}\n</answer>`,
38 ].join('\n')
39}
40
41export function parseTerms(reply: string): { term: string; plain: string }[] {
42 const start = reply.indexOf('[')
43 const end = reply.lastIndexOf(']')
44 if (start < 0 || end <= start) return []
45 let raw: unknown
46 try {
47 raw = JSON.parse(reply.slice(start, end + 1))
48 } catch {
49 return []
50 }
51 if (!Array.isArray(raw)) return []
52 const out: { term: string; plain: string }[] = []
53 for (const item of raw) {
54 if (typeof item !== 'object' || item === null) continue
55 const { term, plain } = item as { term?: unknown; plain?: unknown }
56 if (typeof term !== 'string' || typeof plain !== 'string') continue
57 const t = term.trim()
58 const p = plain.trim()
59 if (t.length === 0 || t.length > 60 || p.length === 0) continue
60 out.push({ term: t, plain: p.slice(0, 200) })
61 }
62 return out.slice(0, MAX_NEW)
63}
64
65export function asGlossary(raw: unknown): Glossary {
66 return raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Glossary) : {}
67}
68
69export const keyOf = (term: string) => term.trim().toLowerCase()
70
71// 용어집에 없는 것만 남긴다. 한 답변 안의 중복도 하나로 친다.
72export function onlyNew(found: readonly { term: string; plain: string }[], glossary: Glossary) {
73 const seen = new Set(Object.keys(glossary))
74 const out: { term: string; plain: string }[] = []
75 for (const one of found) {
76 const k = keyOf(one.term)
77 if (seen.has(k)) continue
78 seen.add(k)
79 out.push(one)
80 }
81 return out
82}
83
84export function knownTerms(glossary: Glossary): string[] {
85 return Object.values(glossary)
86 .sort((a, b) => b.seenAt - a.seenAt)
87 .slice(0, MAX_KNOWN)
88 .map(t => t.term)
89}
90types/index.d.ts 8 lines1export type Term = { term: string; plain: string; seenAt: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'term-explainer': { recent: Term[]; isHidden: boolean }
6 }
7}
8