SLOPSHOPPER

knowledge-card

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

newbandguardtoastpromptmodel
★ 1v0.2.1MITupdated 2026-10-04YanZiBin/knowledge-card/plugins/knowledge-card
A shopper browsing a rack in a slop shop
README

knowledge-card

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.

安装 / Install

/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).

改了源码之后怎么更新 / Updating after you edit

已装的是源码的一份拷贝,版本号不变时 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.

它怎么工作 / How it works

  1. 你发消息,插件记下这个任务。
  2. 主对话这一轮跑满约 10 秒后,插件调用一次小模型,生成一张卡片:标题、一句大白话、一个例子。内容以 AI 眼前这一步用到的概念为主,没有合适的就讲任务所在领域的通用知识。
  3. 卡片一直留着,AI 干完活也不会自己消失,直到你点我知道啦。
  4. 点了以后,如果 AI 还在干活,马上生成下一张;如果 AI 停着,就等你下次发消息、AI 干满 10 秒再生成。你不点,就不会再调用小模型。
  5. 点过的标题会被记住(跨会话,最多 500 个,超出时挤掉最旧的),之后不会再出。
  • 一轮不到 10 秒就结束,不出卡片。
  • 一次没生成出卡片(比如连续两次都撞上学过的标题,或小模型没回复),这个任务里就不再重试,等你发下一个任务或点按钮再说。
  • 卡片会放在其他同样用这个位置的插件上面,不会把它们顶掉。
  • 子 Agent 的事件不算,后台任务的通知不会清掉卡片或重置任务。
  1. You send a prompt; the plugin notes the task.
  2. Once the main turn has run for about 10 s, a small model writes one card: a title, a plain sentence and an example. It prefers the concept in the step the AI is on right now, and falls back to general knowledge of the task's field.
  3. The card stays until you press the button, even after the turn ends.
  4. After you press it, the next card is made at once if the AI is still working; otherwise it waits for your next prompt and 10 s of work. If you never press, no more model calls are made.
  5. Titles you have dismissed are remembered across sessions (up to 500, the oldest drop out) and are not shown again.

费用与隐私 / Cost and privacy

  • 每张卡片是一次独立的小模型请求(输入约一两千 token),两次请求之间至少隔 20 秒;卡片还在屏幕上时一次都不调。这些调用计入你自己的账号额度。
  • 发给小模型的内容有:你的任务原话、主对话最近说的 3 段话(每段最多 400 字)、最近 14 次操作(命令只取前 120 个字)、最近 80 个学过的标题。看起来像密钥的内容(Bearer …、sk-…、ghp_…、github_pat_…、glpat-…、AIza…、JWT、私钥块、https://用户:密码@…、token=…、--password …)会先替换成 *,但这只按常见写法识别,不保证**所有密钥都认得出来。
  • 学过的标题存在 Claude Code 给这个插件的本地存储里($.store),不写你的项目目录,也不上传到别处。
  • 请求发给你自己的 Claude Code 账号所连接的服务,没有第三方。

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.

改成你想要的样子 / Make it yours

都在 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.

已知局限 / Known limits

  • 卡片的内容由小模型生成,可能讲错。当成"帮你入门的一句话",别当权威答案。
  • 吉祥物的颜色是固定的,不随浅色/深色主题变化;深色主题下黑色教鞭会不太显眼。
  • 小模型调用失败(比如模型不可用)时,每个任务弹一次提示,然后保持安静。
  • 吉祥物是依照 Claude Code 里橙色像素小怪物 Clawd 的样子画的非官方同人作品,与 Anthropic 无关。
  • 这是一个小工具,不保证稳定。

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.

License

MIT

Source 2 files
hooks/register.tsx 269 lines
1import { 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}
269
types/index.d.ts 8 lines
1export 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