SLOPSHOPPER

reflect

个人规则自动捕捉:你说「以后永远不要…」「每次都要…」时弹出小卡片,一键存进内置 Memory / 项目 CLAUDE.md / 你的第二大脑文件

newbandpromptmodelprocesstimer
v0.2.0no licenseupdated 2026-10-23weizhongl/claude-code-mods/reflect
A shopper browsing a rack in a slop shop
README

Claude Code Mods

十个给 Claude Code 用的 Mod。只能装在 Claude Code(终端和桌面 App 的 Code 页)。Codex、Anti-Gravity 没有 Mod 接口,装不了。

先装 switchboard(总控台),其余随意挑。

安装

终端里:

/plugin install switchboard --marketplace weizhongl/claude-code-mods

第一次会问 Add marketplace?,回 y,再选 user scope。其余 Mod 把名字换掉即可:usage-gauge、brain-dump、helper-desk、cache-clock、handoff、reflect、chat-inventory、screen-share、replay-theater。

在 shell 里:

claude plugin install switchboard --marketplace weizhongl/claude-code-mods

桌面 App(/plugin 在那里不能用):点输入框旁的 +,选 Plugins → Add marketplace,填 weizhongl/claude-code-mods,再逐个 Add plugin。

每个 Mod 怎么用

Mod怎么用注意
switchboard输入 /mods 打开总控台。每个 Mod 一行:On/Off、Hide UI Panel。输入框上方那一行条(额度、缓存倒计时、任务进度、👁 按钮)也是它画的:平时只占一行,别的 Mod 有事才在它旁边多出一行。条的右端有按钮:「收起」变成一小行(只剩几个百分比和倒计时),再点「隐藏」只留一个「📊 展开」按钮;也可以输入 /bar(或 /bar full、/bar mini、/bar off)切换,你的选择会记住。进度条在桌面版是细线,在终端里是小方块。也可以 /mods off 名字、/mods on 名字、/mods hide 名字、/mods show 名字。新放进同一个文件夹的 Mod 会自动出现。Off = 该 Mod 的钩子立刻停手;Hide = 后台照跑,只收起面板。
usage-gauge自动。显示在总控台那一行条里:对话上下文用了多少、5 小时(会话)额度、本周额度,各有短进度条、颜色(绿 / 黄 / 红)和重置时间,最后是本次对话花费。/gauge 看文字版。读的是引擎自己的数字(和状态栏同一份),零 Token。额度数字要等发过一条消息、引擎读到后才出现。
brain-dump直接打字或语音说一大段(默认 180 字以上、没有排版)。它会拦住,先问你 2–3 个问题;你在输入框直接回答(回「跳过」= 不补充),然后重写成 The Task / The Why / Guardrails / What Done Looks Like 再发给 Claude。输入框上方有 跳过提问 / 原话直接发 / 放弃 三个按钮,也可用 /dump skip、/dump raw、/dump cancel。消息开头加 >> 可以跳过拦截。会用到两次小的模型调用(提问用 haiku,重写用 sonnet)。
helper-desk自动。Claude 有待办清单时,一行条里显示「任务 n/m」和短进度条;Claude 派出子代理时,侧边栏出现 Helper Desk,每个子代理一张卡(进度、正在做什么、读入/输出 token、花费)。/helpers 手动打开。子代理花费是按公开标价估算;整段对话总花费取自引擎账本。零额外 Token。
cache-clock自动。第一条回复之后,一行条里显示「⏱ mm:ss」。点旁边的 Keep Warm 或输入 /keep warm,快冷之前会自动发一条极小的心跳续命。你发下一条真实消息,或连续保温满 2 小时,会自动关闭。拿不到服务器的缓存时间,所以倒计时是推算(订阅登入 60 分钟,API 5 分钟,并按真实缓存命中自动校正)。设置里的「缓存有效分钟数」可固定。
handoff对话累计到 250,000 token、或缓存变冷时,输入框上方提醒你。按「生成交接」或输入 /handoff,会写出一份详细的交接提示词(存在 ~/.claude/handoffs/,可直接发给同事),再按 Start Fresh Chat with Handoff 带去新对话。生成会花 Token,默认只提醒、不自动生成(设置里「触发后做什么」选 generate 才会自动生成)。桌面 App 不允许自动开新对话时,会改成复制到剪贴板。
reflect你在对话里说「以后永远不要…」「每次都要…」「记住…」这类话,输入框上方弹出小卡片,一键存进:内置 Memory / 项目 CLAUDE.md(没有就 AGENTS.md)/ 你指定的文件 / 仅本次忽略。只在句子里有规则关键词时才调用一次小模型判断。设置里可填默认的第二大脑文件路径。
chat-inventory自动。侧边栏「In this chat」:目标与待办、调用过的连接器(含次数)、用过的技能、读过和新建/修改过的文件(点文件在文件管理器里定位)。/inventory 手动打开。纯追踪,零 Token。
screen-share输入 /screen share 开或关,或点一行条右端的 👁 按钮。开启后,画面上的邮箱、电话、金额、API key、公司名、人名遮成灰条。只改画面,不改真实数据;不遮输入框里正在打的字。人名只遮设置里填的名单和「客户:xxx」「陈小姐」这类写法,没有真正的人名识别。
replay-theater自动录下 Claude 用 Edit / Write / NotebookEdit 对文件做的每次修改。输入 /replay 打开:选文件、播放/暂停、上一步/下一步、点步骤号跳转,看每一步的 diff。Revert 按钮按一次变成「再按一次确认」,第二次才会写回文件。/replay clear 清空。用 Bash 改文件(如 sed)录不到;单个文件超过 20 万字不录。记录只在当前会话。

目录

每个 Mod 一个文件夹:.claude-plugin/plugin.json、hooks/hooks.json、hooks/register.tsx,有状态的还有 types/index.d.ts。switchboard、screen-share、replay-theater 带自动测试(claude plugin test <文件夹>)。

各 Mod 里的 hooks/flag.ts 是同一小段代码(读总控台的开关),Mod 之间不能共享文件,所以每个放一份。

Source 4 files
hooks/register.tsx 200 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { flagOf } from './flag'
5import {
6  CLASSIFIER_SYSTEM,
7  looksLikeRule,
8  memoryFile,
9  parseVerdict,
10  slugOfPath,
11  withBullet,
12} from './rules'
13import type { ReflectPending } from '../types'
14
15const MOD = 'reflect'
16
17const pending = atom({ plugin: 'reflect', key: 'pending' } as const, null)
18const step = atom({ plugin: 'reflect', key: 'step' } as const, 'choose')
19const message = atom({ plugin: 'reflect', key: 'message' } as const, '')
20
21let brainDefault = ''
22
23async function homeDir($: EngineInterface): Promise<string> {
24  return (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '.'
25}
26
27async function stamps($: EngineInterface) {
28  const at = new Date(await $.clock.now())
29  const two = (n: number) => String(n).padStart(2, '0')
30  const day = `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())}`
31
32  return {
33    day,
34    id: `${day.replaceAll('-', '')}-${two(at.getHours())}${two(at.getMinutes())}${two(at.getSeconds())}`,
35  }
36}
37
38async function readText($: EngineInterface, file: string): Promise<string> {
39  return $.fs.read(file).catch(() => '')
40}
41
42async function writeText($: EngineInterface, file: string, text: string) {
43  try {
44    await $.fs.write(file, text)
45  } catch {
46    const dir = file.replace(/[\\/][^\\/]*$/, '')
47    const isWindows = (await $.env.get('OS')) === 'Windows_NT'
48    await $.process.run(
49      isWindows ? ['cmd', '/c', 'mkdir', dir.replaceAll('/', '\\')] : ['mkdir', '-p', dir],
50    )
51    await $.fs.write(file, text)
52  }
53}
54
55async function saveMemory($: EngineInterface, one: ReflectPending): Promise<string> {
56  const { day, id } = await stamps($)
57  const root = await $.session.root()
58  const dir = `${await homeDir($)}/.claude/projects/${slugOfPath(root)}/memory`
59  const name = `feedback_rule_${id}.md`
60  await writeText($, `${dir}/${name}`, memoryFile(one.rule, day, one.original, id))
61  const index = await readText($, `${dir}/MEMORY.md`)
62  const line = `- [规则 ${day}](${name}) — ${one.rule.replace(/\n/g, ' ').slice(0, 70)}`
63  await writeText($, `${dir}/MEMORY.md`, `${index.replace(/\s*$/, '')}${index === '' ? '' : '\n'}${line}\n`)
64
65  return `${dir}/${name}`
66}
67
68async function saveProject($: EngineInterface, one: ReflectPending): Promise<string> {
69  const { day } = await stamps($)
70  const root = (await $.session.root()).replace(/[\\/]$/, '')
71  const claude = `${root}/CLAUDE.md`
72  const agents = `${root}/AGENTS.md`
73  const file =
74    (await $.fs.exists(claude)) || !(await $.fs.exists(agents)) ? claude : agents
75  await writeText($, file, withBullet(await readText($, file), one.rule, day))
76
77  return file
78}
79
80async function saveBrain($: EngineInterface, one: ReflectPending, given: string): Promise<string> {
81  const { day } = await stamps($)
82  const file = given.startsWith('~') ? `${await homeDir($)}${given.slice(1)}` : given
83  await writeText($, file, withBullet(await readText($, file), one.rule, day))
84
85  return file
86}
87
88async function finish($: EngineInterface, text: string) {
89  await update($, pending, () => null)
90  await update($, step, () => 'choose')
91  await update($, message, () => text)
92  $.clock.after(7000, () => void update($, message, () => ''))
93}
94
95async function choose($: EngineInterface, kind: 'memory' | 'project' | 'brain' | 'skip', given?: string) {
96  const one = await read($, pending)
97  if (one === null) return
98  try {
99    if (kind === 'skip') {
100      await finish($, '好,这条只管这次,不记。')
101    } else if (kind === 'memory') {
102      await finish($, `✔ 已存进内置 Memory:${await saveMemory($, one)}`)
103    } else if (kind === 'project') {
104      await finish($, `✔ 已写进项目规则文件:${await saveProject($, one)}`)
105    } else {
106      const target = (given ?? brainDefault).trim()
107      if (target === '') {
108        await update($, step, () => 'path')
109
110        return
111      }
112      await finish($, `✔ 已写进:${await saveBrain($, one, target)}`)
113    }
114  } catch (failure) {
115    await update($, message, () => `保存失败:${failure instanceof Error ? failure.message : String(failure)}`)
116  }
117}
118
119async function analyze($: EngineInterface, text: string) {
120  const reply = await $.model.complete({
121    model: 'haiku',
122    system: CLASSIFIER_SYSTEM,
123    prompt: text,
124    maxTokens: 250,
125  })
126  if (!reply.isAnswered) return
127  const verdict = parseVerdict(reply.text)
128  if (!verdict.isRule) return
129  await update($, pending, () => ({
130    id: String(Date.now()),
131    rule: verdict.rule,
132    original: text,
133  }))
134  await update($, step, () => 'choose')
135  await update($, message, () => '')
136}
137
138export const register: Register = (on, options) => {
139  brainDefault = String(options.brainPath ?? '')
140
141  // Never holds the prompt back: the rule is spotted while the turn already runs.
142  on('prompt.submit', async ($, e, next) => {
143    const ran = await next(e)
144    const isTyped = e.command === undefined && e.isMeta !== true && e.origin.kind === 'composer'
145    if (isTyped && looksLikeRule(e.text) && (await flagOf($, MOD)).isOn) {
146      void analyze($, e.text).catch(() => undefined)
147    }
148
149    return ran
150  }).catch(($, e, next) => next(e))
151
152  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
153    const below = await next(e)
154    const flag = await flagOf($, MOD)
155    const one = await read($, pending)
156    const text = await read($, message)
157    if (!flag.isOn || flag.isHidden || (one === null && text === '')) return below
158
159    const table = $.ui.resolve(e)
160    const { Box, Text, Button } = table
161    const Input = 'Input' in table ? table.Input : undefined
162    const asking = (await read($, step)) === 'path'
163
164    return (
165      <Box flexDirection="column">
166        {below}
167        {one === null ? (
168          <Text color="green">{text}</Text>
169        ) : (
170          <Box columnGap={1} flexWrap="wrap">
171            <Text color="#8b5cf6">●</Text>
172            <Text>
173              记下这条规则?<Text dimColor>「{one.rule}」</Text>
174            </Text>
175            {text !== '' && <Text color="red">{text}</Text>}
176            {asking && Input === undefined && (
177              <Text dimColor>这里不能输入路径:请先在 Mod 设置里填「第二大脑 / Skill 文件路径」。</Text>
178            )}
179            {asking && Input !== undefined ? (
180              <Input
181                key="brain-path"
182                label="文件路径"
183                placeholder="~/Documents/Brain/rules.md,按 Enter 保存"
184                onSubmit={(value: string) => choose($, 'brain', value)}
185              />
186            ) : (
187              <Box gap={1} flexWrap="wrap">
188                <Button key="memory" label="Memory" variant="primary" onPress={() => choose($, 'memory')} />
189                <Button key="project" label="项目 CLAUDE.md" onPress={() => choose($, 'project')} />
190                <Button key="brain" label="第二大脑" onPress={() => choose($, 'brain')} />
191                <Button key="skip" label="忽略" onPress={() => choose($, 'skip')} />
192              </Box>
193            )}
194          </Box>
195        )}
196      </Box>
197    )
198  })
199}
200
hooks/flag.ts 14 lines
1import type { EngineInterface } from 'claude-code'
2
3const FLAGS = { plugin: 'switchboard', key: 'flags' } as const
4
5export type Flag = { isOn: boolean; isHidden: boolean }
6
7// Switchboard owns the flags. A mod it has never heard of counts as on, shown.
8export async function flagOf($: EngineInterface, mod: string): Promise<Flag> {
9  const { value } = await $.state.get(FLAGS)
10  const own = value?.[mod]
11
12  return { isOn: own?.isOn ?? true, isHidden: own?.isHidden ?? false }
13}
14
hooks/rules.ts 62 lines
1// Pure helpers: spot a standing rule cheaply, parse the model's verdict, shape files.
2
3const TRIGGERS: RegExp[] = [
4  /以后|永远|从不|每次|一律|务必|必须|不准|不许|禁止|别再|不要再|千万别|记住|始终|总是|默认|规定|从现在起|今后/,
5  /(保持|控制在|限制在|不超过|至少|最多).{0,14}(字|个|行|句|条|段|秒|分钟)/,
6  /\b(always|never|from now on|going forward|remember (to|that)|don'?t ever|do not ever|every time|must always|make sure (to|you)|by default|stop (using|doing|adding))\b/i,
7]
8
9export const looksLikeRule = (text: string) =>
10  text.length >= 6 && text.length <= 2000 && TRIGGERS.some(pattern => pattern.test(text))
11
12export const CLASSIFIER_SYSTEM =
13  'You read one message from a user to their AI assistant and decide if it states a STANDING rule or long-term preference (something that should apply to future work, not just the current request). ' +
14  'Answer with JSON only, no prose: {"isRule":boolean,"rule":string}. ' +
15  '"rule" is one short imperative sentence in the same language as the message, fully self-contained (no "this", "above", "it"), keeping every concrete number, name and format. ' +
16  'If the message is only a one-off request, a question, or a description, answer {"isRule":false,"rule":""}.'
17
18export const parseVerdict = (text: string): { isRule: boolean; rule: string } => {
19  const match = text.match(/\{[\s\S]*\}/)
20  if (match === null) return { isRule: false, rule: '' }
21  try {
22    const json = JSON.parse(match[0]) as { isRule?: unknown; rule?: unknown }
23
24    return {
25      isRule: json.isRule === true && typeof json.rule === 'string' && json.rule.trim() !== '',
26      rule: typeof json.rule === 'string' ? json.rule.trim() : '',
27    }
28  } catch {
29    return { isRule: false, rule: '' }
30  }
31}
32
33export const slugOfPath = (path: string) => path.replace(/[^A-Za-z0-9]/g, '-')
34
35export const memoryFile = (rule: string, day: string, original: string, id: string) =>
36  [
37    '---',
38    `name: feedback-rule-${id}`,
39    `description: ${rule.replace(/\n/g, ' ').slice(0, 120)}`,
40    'metadata:',
41    '  type: feedback',
42    '---',
43    '',
44    rule,
45    '',
46    `**Why:** 用户在对话里明确说过(${day}):「${original.replace(/\s+/g, ' ').slice(0, 200)}」`,
47    '**How to apply:** 之后所有相关任务都照这条做,不用再被提醒。',
48    '',
49  ].join('\n')
50
51const HEADING = '## 用户规则(Reflect 记录)'
52
53export const withBullet = (existing: string, rule: string, day: string) => {
54  if (existing.includes(rule)) return existing
55  const bullet = `- ${rule}(${day})`
56  if (existing.includes(HEADING)) {
57    return `${existing.replace(/\s*$/, '')}\n${bullet}\n`
58  }
59
60  return `${existing.replace(/\s*$/, '')}${existing === '' ? '' : '\n\n'}${HEADING}\n${bullet}\n`
61}
62
types/index.d.ts 19 lines
1export type ReflectPending = {
2  id: string
3  rule: string
4  original: string
5}
6
7declare module 'claude-code' {
8  interface PluginState {
9    reflect: {
10      pending: ReflectPending | null
11      // 'choose' = four buttons, 'path' = asking for a file path
12      step: 'choose' | 'path'
13      message: string
14    }
15    // Read-only view of Switchboard's value (it alone writes it).
16    switchboard: { flags: Record<string, { isOn: boolean; isHidden: boolean }> }
17  }
18}
19