While the AI works, a plain-language knowledge card above the prompt explains a concept from the current task. Click 我知道啦 for the next one.

AI 干活的时候,你在输入框上方看到一张知识卡片:一个当前任务里用到的概念,一句大白话,一个贴近任务的小例子。等待的时间,顺手学一点马上能懂的东西。
A Claude Code mod that shows one plain-language knowledge card above the prompt while the AI works, so waiting time becomes learning time.
mod 是运行在你 Claude Code 会话里的代码,权限和 Claude Code 一样大。安装前请先读源码,这个插件只有一个文件:
plugins/knowledge-card/hooks/register.tsx。 A mod runs inside your session with the same access as Claude Code. Read the source first; this one is a single file.
/plugin marketplace add YanZiBin/knowledge-card
/plugin install knowledge-card@knowledge-card
/reload-plugins
这个插件没有上架 Claude 官方目录,仓库本身就是一个插件市场,所以上面三行直接从 GitHub 安装。网络访问 GitHub 不方便的话,可以先把仓库克隆到本地,再从本地文件夹安装:
git clone https://github.com/YanZiBin/knowledge-card
/plugin marketplace add ./knowledge-card
/plugin install knowledge-card@knowledge-card
/reload-plugins
需要支持 mod 的 Claude Code(2.1.287 或更新)。mod 的接口可能随版本变化。吉祥物用 Svg 元素画,目前只在桌面版里测过;终端里没有这个元素,会去掉吉祥物,只留卡片文字(未测试)。 It is not listed in Claude's official directory; the repo itself is the marketplace, so the commands above install straight from GitHub. You can also git clone it and run /plugin marketplace add ./knowledge-card instead.
Needs a Claude Code build that supports mods (2.1.287 or later). The mascot is drawn with the Svg element and has only been tried in the desktop app; elsewhere it is left out (untested).
已装的是源码的一份拷贝,版本号不变时 plugin update 不会更新:改 plugins/knowledge-card/.claude-plugin/plugin.json 的 version,再执行 claude plugin marketplace update knowledge-card、claude plugin update knowledge-card@knowledge-card,最后在会话里 /reload-plugins。 plugin update does nothing while the version number is unchanged: bump version in plugin.json, run claude plugin marketplace update knowledge-card and claude plugin update knowledge-card@knowledge-card, then /reload-plugins.
Bearer …、sk-…、ghp_…、github_pat_…、glpat-…、AIza…、JWT、私钥块、https://用户:密码@…、token=…、--password …)会先替换成 *,但这只按常见写法识别,不保证**所有密钥都认得出来。$.store),不写你的项目目录,也不上传到别处。Each card is one separate small-model request, at least 20 s apart, none while a card is on screen; it counts against your own account. What is sent: your prompt, the last 3 things the assistant said, the last 14 actions (commands cut to 120 characters) and the last 80 dismissed titles; credential-looking strings are masked first, but only for common shapes. Dismissed titles live in the plugin's local store.
都在 plugins/knowledge-card/hooks/register.tsx 开头:
MODEL / EFFORT:用哪个模型和思考强度(默认 claude-sonnet-5-5、low,你的账号需要能用它)。FIRST_AFTER_MS / MIN_GAP_MS:多久出第一张、两次调用的最短间隔。LEARNED_MAX / LEARNED_SENT:记住多少个标题、发给小模型多少个。SYSTEM:给小模型的说明。默认让它写简体中文、按零基础来讲,想要别的语言或难度,改这里。PIXELS:吉祥物的像素表,一行一个色块 [x, y, 宽, 高, 颜色],想换帽子、教鞭或灯泡就改它。Constants at the top of register.tsx. The SYSTEM prompt asks for Simplified Chinese and a zero-background explanation; edit it for another language or level. PIXELS is the mascot, one block per row.
Card text is model-written and can be wrong. The mascot is an unofficial fan drawing inspired by Claude Code's Clawd, not affiliated with Anthropic.
MIT
hooks/register.tsx 269 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Card } from '../types'
5
6const MODEL = 'claude-sonnet-5-5'
7const EFFORT = 'low'
8const EVERY_MS = 3_000 // how often to check whether a card is due
9const FIRST_AFTER_MS = 10_000 // a turn must run this long before it earns a card
10const MIN_GAP_MS = 20_000 // at least this long between two model calls, so a failing call cannot loop
11const KEEP = 14 // recent actions handed to the model
12const SAID = 3 // recent things the assistant said, handed to the model
13const SAID_CHARS = 400
14const LEARNED_MAX = 500 // titles remembered across sessions; the oldest drop out
15const LEARNED_SENT = 80 // recent titles handed to the model so it avoids them
16const STORE_KEY = 'learned'
17
18const card = atom({ plugin: 'knowledge-card', key: 'card' } as const, null)
19
20const SYSTEM = `你是一个“知识卡片”作者。用户在等 AI 干活,你要借这段等待时间,教他一个立刻能看懂的小知识。你会拿到:用户任务、助手最近说的话、最近的操作、用户已经学过的知识点。
21请严格输出三行,格式如下:
22标题:<这个知识点的名字,不超过 12 个字>
23解释:<一句大白话,不超过 50 个字,说清它是什么、有什么用>
24例子:<一个贴近当前任务的小例子,不超过 60 个字>
25要求:
26- 讲什么:优先讲助手“眼前这一步”用到的概念;这一步没有值得讲的,就讲当前任务所在领域的一个通用知识。
27- 面向零基础:解释里不能依赖别的术语。必须出现专业词(如 rebase、图层、API)时,保留英文原词(中文词也附上常见英文名),并用大白话顺手解释。
28- 例子要具体,用当前任务里的真实对象,不要泛泛而谈。
29- 不要重复“已学过”里的任何知识点,也不要换个名字讲同一件事。
30- 不要编造没出现过的事实;不要提到密钥、令牌、密码。`
31
32// anything that looks like a credential is masked before it leaves for the model
33const redact = (s: string) =>
34 s
35 .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, '***')
36 .replace(/Bearer\s+\S+/gi, 'Bearer ***')
37 .replace(/\b(?:sk|pk|ghp|gho|ghs|github_pat|glpat|xox[bpas]|AKIA|AIza)[-_A-Za-z0-9]{8,}/g, '***')
38 .replace(/\beyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]*/g, '***')
39 .replace(/(\w+:\/\/[^\s:@/]+:)[^\s@/]+@/g, '$1***@')
40 .replace(/((?:token|key|secret|passw(?:or)?d|auth\w*)["']?\s*[=:]\s*["']?)\S+/gi, '$1***')
41 .replace(/(--(?:\w+-)?(?:token|key|secret|passw(?:or)?d|auth\w*)\s+)\S+/gi, '$1***')
42
43// why the small model gave no reply, in the words the person reads
44const REASONS: Record<string, string> = { 'api-error': '接口出错', 'empty-reply': '回复为空', aborted: '被中断' }
45
46// a short, readable trace of one tool call: what it was and what it touched
47const brief = (tool: string, e: Record<string, unknown>) => {
48 const pick = [e.description, e.command, e.file_path, e.path, e.pattern, e.url, e.query, e.prompt].find(v => typeof v === 'string') as string | undefined
49 return `${tool}${pick ? `: ${redact(pick).replace(/\s+/g, ' ').slice(0, 120)}` : ''}`
50}
51
52// titles are compared without spaces or case, so "Git rebase" and "git Rebase" count as one ("C++" and "C#" stay apart)
53const norm = (t: string) => t.toLowerCase().replace(/\s+/g, '')
54
55const parse = (raw: string): Card | null => {
56 const text = raw.replace(/[*`]/g, '')
57 const field = (name: string) => text.match(new RegExp(`^\\s*${name}[::]\\s*(.+)$`, 'm'))?.[1]?.trim().replace(/^["“「]|["”」]$/g, '') ?? ''
58 const title = field('标题').slice(0, 24)
59 const explain = field('解释').slice(0, 120)
60 const example = field('例子').slice(0, 140)
61 return title && explain ? { title, explain, example } : null
62}
63
64// titles the person has already learned, oldest first
65async function learned($: EngineInterface): Promise<string[]> {
66 try {
67 const v = await $.store.get(STORE_KEY)
68 return Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
69 } catch {
70 return []
71 }
72}
73
74// Clawd in pixels (a 24 x 13 grid): graduation cap, a black pointer held up at an angle, and a bulb that glows
75const PIXELS: [number, number, number, number, string][] = [
76 [8, 11, 1, 2, '#D97757'], [10, 11, 1, 2, '#D97757'], [13, 11, 1, 2, '#D97757'], [15, 11, 1, 2, '#D97757'], // feet
77 [7, 5, 10, 6, '#D97757'], // body
78 [9, 6, 1, 2, '#1F1E1D'], [14, 6, 1, 2, '#1F1E1D'], // eyes
79 [6, 8, 1, 2, '#B9573B'], [17, 7, 1, 2, '#B9573B'], [18, 7, 1, 1, '#B9573B'], // arms, the right one raised
80 [18, 8, 1, 1, '#1F1E1D'], [19, 7, 1, 1, '#1F1E1D'], [20, 6, 1, 1, '#1F1E1D'], [21, 5, 1, 1, '#1F1E1D'], [22, 4, 1, 1, '#1F1E1D'], [23, 3, 1, 1, '#C9C9CE'], // pointer
81 [9, 2, 6, 1, '#3B3B47'], [6, 3, 12, 1, '#2A2A33'], [9, 4, 6, 1, '#2A2A33'], // cap
82 [6, 4, 1, 2, '#F2C14E'], [6, 6, 1, 1, '#D9A02A'], // tassel
83 [2, 0, 2, 1, '#FFD43B'], [1, 1, 4, 2, '#FFD43B'], [2, 3, 2, 1, '#FFD43B'], [2, 1, 1, 1, '#FFF2A8'], [2, 4, 2, 1, '#8D8D93'], // bulb
84 [0, 1, 1, 1, '#FFD43B'], [5, 1, 1, 1, '#FFD43B'] // rays
85]
86const CLAWD =
87 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 13" shape-rendering="crispEdges">' +
88 '<circle cx="3" cy="2.2" r="2.8" fill="#FFD43B" opacity="0.3"><animate attributeName="opacity" values="0.12;0.45;0.12" dur="1.8s" repeatCount="indefinite"/></circle>' +
89 PIXELS.map(([x, y, w, h, c]) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${c}"/>`).join('') +
90 '</svg>'
91
92export const register: Register = on => {
93 let task = ''
94 let log: string[] = []
95 let said: string[] = []
96 let isRunning = false // a main-loop turn is in progress
97 let isBusy = false // a model call is in flight
98 let startedAt = 0 // when the current turn began
99 let lastAt = 0 // when the last model call began
100 let hasWarned = false // one notice per task when the model call fails
101 let isStopped = false // this task's round made no card (a repeat, a bad reply, a failed call): no more calls until the next task or a click
102 let timer: { cancel: () => void } | undefined
103
104 on('session.start', async ($, e, next) => {
105 const result = await next(e)
106
107 timer?.cancel() // never two timers, however often the session starts
108 timer = $.clock.every(EVERY_MS, async () => {
109 if (!isRunning || isBusy || isStopped) return
110 isBusy = true // before any await, so two ticks cannot both get past the check
111 let hasCalled = false
112 let isShown = false
113 try {
114 if (await read($, card)) return // a card is on screen: nothing new until it is dismissed
115 const now = await $.clock.now()
116 if (now - startedAt < FIRST_AFTER_MS || now - lastAt < MIN_GAP_MS) return
117 hasCalled = true
118
119 const seen = await learned($)
120 const seenSet = new Set(seen.map(norm))
121 const recent = seen.slice(-LEARNED_SENT)
122 const base =
123 `用户任务:${redact(task).slice(0, 500) || '(未知)'}\n\n` +
124 `助手最近说的话(旧到新):\n${said.slice(-SAID).join('\n') || '(没有)'}\n\n` +
125 `最近的操作(旧到新):\n${log.slice(-KEEP).join('\n') || '(没有)'}\n\n` +
126 `已学过的知识点:${recent.join('、') || '无'}`
127
128 let made: Card | null = null
129 let tried = ''
130 for (let attempt = 0; attempt < 2 && !made; attempt++) {
131 const r = await $.model.complete({
132 model: MODEL,
133 effort: EFFORT,
134 maxTokens: 1500, // room for the thinking as well as the three lines
135 timeoutMs: 25_000,
136 system: SYSTEM,
137 prompt: tried ? `${base}\n\n刚才的“${tried}”已经学过了,换一个不同的知识点。` : base
138 })
139 if (!r.isAnswered) {
140 if (!hasWarned) {
141 hasWarned = true
142 $.ui.toast(`知识卡片:小模型没有回复(${REASONS[r.reason] ?? r.reason})`)
143 }
144 return
145 }
146 const c = parse(r.text)
147 if (!c) return // unreadable reply: try again after the gap
148 if (seenSet.has(norm(c.title))) tried = c.title
149 else made = c
150 }
151
152 // another card may have appeared while the call was out; never replace what the person is reading
153 if (made && !(await read($, card))) {
154 await update($, card, () => made)
155 isShown = true
156 }
157 } catch (error) {
158 if (!hasWarned) {
159 hasWarned = true
160 $.ui.toast(`知识卡片:小模型调用失败(${error instanceof Error ? error.message.slice(0, 60) : '未知原因'})`)
161 }
162 } finally {
163 if (hasCalled && !isShown) isStopped = true // no card came of this round: wait for the next task rather than call again
164 try {
165 if (hasCalled) lastAt = await $.clock.now()
166 } finally {
167 isBusy = false
168 }
169 }
170 })
171
172 return result
173 })
174
175 // a prompt the user typed (or a scheduled trigger) starts a new task. A background task's notification, a peer's
176 // message or a plugin's prompt must not wipe the task context.
177 on('prompt.submit', async ($, e, next) => {
178 const kind = e.origin?.kind
179 const isNewTask = kind === 'composer' || kind === 'bridge' || kind === 'sdk' || kind === 'scheduled-trigger'
180 if (e.text && isNewTask) {
181 task = e.text
182 log = []
183 said = []
184 hasWarned = false
185 isStopped = false
186 }
187 return next(e)
188 })
189
190 // a main-loop turn begins: the clock for the first card starts here, however the turn was started
191 on('turn.start', async ($, e, next) => {
192 isRunning = true
193 startedAt = await $.clock.now()
194 return next(e)
195 })
196
197 // note what the main assistant says as each block is stored, instead of re-reading the whole transcript every time
198 on('session.append', { door: 'response' }, async ($, e, next) => {
199 if (e.agentId === undefined) {
200 for (const block of e.message.content) {
201 if (block.type === 'text' && typeof block.text === 'string' && block.text.trim() !== '') {
202 said.push(`- ${redact(block.text.trim()).slice(-SAID_CHARS).replace(/\s+/g, ' ')}`)
203 }
204 }
205 if (said.length > 10) said = said.slice(-5)
206 }
207 return next(e)
208 })
209
210 // the card stays after the turn ends; only the person's click removes it
211 on('turn.complete', async ($, e, next) => {
212 if (!e.agentId) isRunning = false
213 return next(e)
214 })
215
216 on('tool.call', async ($, e, next) => {
217 if (!e.agentId) isRunning = true
218 log.push(`${e.agentId ? '(子任务) ' : ''}${brief(e.tool, e as unknown as Record<string, unknown>)}`)
219 if (log.length > 60) log = log.slice(-30)
220 return next(e)
221 })
222
223 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
224 // what the plugins beneath drew (or nothing): ours goes above it instead of replacing it
225 const below = await next(e)
226 const c = await read($, card)
227 if (e.props.hasSurvey || !c) return below
228
229 const { Box, Button, Text, ...rest } = $.ui.resolve(e)
230 const Svg = 'Svg' in rest ? rest.Svg : undefined // the desktop draws the mascot; other surfaces go without
231
232 const done = async () => {
233 const shown = (await read($, card)) ?? c
234 try {
235 const seen = await learned($)
236 const kept = seen.filter(t => norm(t) !== norm(shown.title))
237 await $.store.set(STORE_KEY, [...kept, shown.title].slice(-LEARNED_MAX))
238 } catch {
239 // not remembered: the same card may come round again, which is harmless
240 }
241 isStopped = false // the person is reading again: the next card may be made
242 lastAt = 0 // and it is due as soon as the turn has run long enough
243 await update($, card, () => null)
244 }
245
246 const mine = (
247 <Box flexDirection="row" paddingX={1} gap={1} alignItems="center">
248 {Svg && <Svg source={CLAWD} alt="Clawd 吉祥物,戴着学士帽,举着教鞭,旁边有一个亮着的灯泡" width={104} />}
249 {Svg && <Text dimColor>◀</Text>}
250 <Box flexDirection="column" flexGrow={1} borderStyle="round" borderDimColor paddingX={1}>
251 <Box flexDirection="row" justifyContent="space-between" alignItems="center" minHeight={2}>
252 <Text bold>{c.title}</Text>
253 <Button key="learned" label="我知道啦" onPress={done} />
254 </Box>
255 <Text>{c.explain}</Text>
256 {c.example && (
257 <Text dimColor>
258 <Text color="blue" bold>例</Text>
259 {` ${c.example}`}
260 </Text>
261 )}
262 </Box>
263 </Box>
264 )
265
266 return below ? <Box flexDirection="column">{mine}{below}</Box> : mine
267 })
268}
269types/index.d.ts 8 lines1export type Card = { title: string; explain: string; example: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'knowledge-card': { card: Card | null }
6 }
7}
8