Mod 探勘:讀最近的對話紀錄,找出重複交代的事、讓你緊張的動作、固定檢查成果的動作,排除 Hook/Skill 就能解決的,提出最多 5 個 Mod 提案並附原話證據;/mod-scout

Ricky 的 Claude Code mods。
上下文快滿時,把目前的工作整理成交接摘要,清空對話後當成第一句送出,標題自動加「(續N)」。雲端對話改成開一個新的雲端對話送出摘要、再封存舊的(原地清空會在容器重啟時被還原)。
/handoff/handoff 送出);不要了就說「取消」(或 /handoff 取消);想改內容就直接說要改什麼/clear、改標題、把摘要送出卡住的話用 /handoff 重來。
/config 裡的 handoff 項目)thresholdTokens:門檻 token 數,預設 150000。用 token 數而不是百分比,是因為本機上限約 20 萬、雲端 100 萬,同一個百分比差很多autoSend:跳過預覽直接交接,預設關讀最近的對話紀錄(本機 ~/.claude/projects/ 加上雲端帳號的其他對話),找出一直重複交代的事、讓你緊張的動作、每次檢查成果的固定動作,排除 Hook 或 Skill 就能解決的,提出最多 5 個 Mod 提案,每個附原話證據。
/mod-scout:開始探勘。先由程式把你親手打的字整理成 ~/.claude/mod-scout/digest-日期.md(金鑰、token 先遮蔽),再自動送出分析要求/mod-scout 做 N:之後再做上次報告的第 N 個/config 裡的 mod-scout 項目)sessions:讀最近幾次對話,預設 30remindDays:幾天提醒一次,預設 90,0 表示不提醒本機讀紀錄要有 python3;沒有的話只讀雲端對話。
/plugin marketplace add ricky94511/claude-mods
/plugin install handoff@claude-mods
/plugin install mod-scout@claude-mods
桌面 app:點輸入框旁的 + → Plugins → Add marketplace,輸入 ricky94511/claude-mods,再 Add plugin。
雲端環境會跳過 marketplace,所以改用 setup script 把 repo clone 進容器,再用 CLAUDE_CODE_PLUGIN_DIRS 載入。 在環境設定的 Setup script 最後加上這段(失敗也不會讓 session 起不來):
if [ -d /home/user/plugins/claude-mods/.git ]; then
git -C /home/user/plugins/claude-mods fetch -q --depth 1 origin main && git -C /home/user/plugins/claude-mods reset -q --hard origin/main || true
else
GIT_TERMINAL_PROMPT=0 git clone -q --depth 1 https://github.com/ricky94511/claude-mods /home/user/plugins/claude-mods || true
fi
if [ -d /home/user/plugins/claude-mods ]; then
python3 - <<'PY'
import json, pathlib
root = pathlib.Path("/home/user/plugins/claude-mods")
p = pathlib.Path.home() / ".claude/settings.json"
d = json.loads(p.read_text()) if p.exists() else {}
env = d.setdefault("env", {})
dirs = [x for x in env.get("CLAUDE_CODE_PLUGIN_DIRS", "").split(":") if x]
# repo 裡每個有 plugin.json 的資料夾都載入,之後加新 mod 不用再改 setup script
for mod in sorted(root.glob("*/.claude-plugin/plugin.json")):
path = str(mod.parent.parent)
if path not in dirs:
dirs.append(path)
env["CLAUDE_CODE_PLUGIN_DIRS"] = ":".join(dirs)
cmd = "git -C /home/user/plugins/claude-mods pull -q --ff-only origin main 2>/dev/null || true"
starts = d.setdefault("hooks", {}).setdefault("SessionStart", [])
if not any(h.get("command") == cmd for s in starts for h in s.get("hooks", [])):
starts.append({"hooks": [{"type": "command", "command": cmd}]})
p.write_text(json.dumps(d, indent=2))
PY
fi
exit 0
改完開新的雲端對話才會生效。
hooks/register.tsx 231 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Phase, Proposal } from '../types'
5import {
6 analysisPrompt,
7 buildDigest,
8 buildPrompt,
9 humanFromEvents,
10 parseJson,
11 parseProposals,
12 pickNumber,
13 projectOf,
14 type Inventory,
15 type Session,
16} from './lib'
17
18const phase = atom({ plugin: 'mod-scout', key: 'phase' } as const, 'idle' as Phase)
19
20const REMOTE = 'claude-code-remote'
21const DAY = 24 * 60 * 60 * 1000
22// 一次對話最多翻幾頁(每頁 100 筆,tool_result 也算在內)
23const MAX_PAGES = 8
24const SKIP = '先不做'
25const USAGE = '用法:/mod-scout(開始探勘)、/mod-scout 做 N(做上次報告的第 N 個提案)'
26
27type Report = { at: number; proposals: Proposal[] }
28
29// 指令和 turn.complete 的 hook 裡不能等人回答或送出 prompt,等 hook 結束後再用計時器開始
30function detach($: EngineInterface, work: () => Promise<void>) {
31 $.clock.after(50, () =>
32 void work().catch(async error => {
33 await update($, phase, () => 'idle')
34 $.ui.status(undefined)
35 $.ui.toast(`Mod 探勘中途出錯:${String(error).slice(0, 200)}`)
36 }),
37 )
38}
39
40async function mcpJson($: EngineInterface, tool: string, args: Record<string, unknown>) {
41 const { content, isError } = await $.mcp.call(REMOTE, tool, args)
42 if (isError) return null
43 const text = content.find(c => c.type === 'text' && c.text)?.text
44 if (!text) return null
45 const data = parseJson(text)
46 return (data?.ccr ?? data) as Record<string, unknown> | null
47}
48
49// 本機:~/.claude/projects/ 底下的紀錄檔可能超過 $.fs 的 4 MiB 上限,交給腳本逐行讀
50async function collectLocal($: EngineInterface, limit: number, exclude: string) {
51 const script = `${$.plugin.root}/scripts/collect.py`
52 const run = await $.process.run(['python3', script, '--limit', String(limit), '--exclude', exclude], { timeoutMs: 120_000 })
53 if (run.exitCode !== 0 || run.isStdoutTruncated) throw new Error(run.stderr.slice(0, 300) || '本機紀錄讀取失敗')
54 const data = JSON.parse(run.stdout) as { sessions: Omit<Session, 'source'>[]; inventory: Inventory }
55 return { sessions: data.sessions.map(s => ({ ...s, source: '本機' as const })), inventory: data.inventory }
56}
57
58async function remoteSession($: EngineInterface, session: Record<string, unknown>): Promise<Session | null> {
59 const id = String(session.id)
60 const messages = []
61 let before: string | undefined
62 for (let page = 0; page < MAX_PAGES; page++) {
63 const data = await mcpJson($, 'list_events', { session_id: id, kinds: ['user'], limit: 100, ...(before ? { before_id: before } : {}) })
64 if (!data) break
65 messages.unshift(...humanFromEvents((data.data ?? []) as never[]))
66 if (!data.has_more || !data.first_id) break
67 before = String(data.first_id)
68 }
69 if (messages.length === 0) return null
70 return {
71 id,
72 title: String(session.title ?? ''),
73 project: projectOf(session),
74 start: String(session.created_at ?? '').slice(0, 16),
75 source: '雲端',
76 messages,
77 }
78}
79
80// 雲端:用 claude-code-remote 讀自己帳號的其他對話;沒有這個 server(例如純本機終端機)就略過
81async function collectRemote($: EngineInterface, limit: number, exclude: string) {
82 let listed: Record<string, unknown> | null
83 try {
84 listed = await mcpJson($, 'list_sessions', { limit: Math.min(limit + 10, 100), mine: true })
85 } catch {
86 return null
87 }
88 if (!listed) return null
89 const rows = ((listed.data ?? []) as Record<string, unknown>[]).filter(s => s.id !== exclude).slice(0, limit)
90 const sessions: Session[] = []
91 for (let i = 0; i < rows.length; i += 4) {
92 $.ui.status(`Mod 探勘:讀雲端對話 ${Math.min(i + 4, rows.length)}/${rows.length}…`)
93 const batch = await Promise.all(rows.slice(i, i + 4).map(row => remoteSession($, row).catch(() => null)))
94 for (const session of batch) if (session) sessions.push(session)
95 }
96 return sessions
97}
98
99async function currentRemoteId($: EngineInterface) {
100 try {
101 const data = await mcpJson($, 'get_session', {})
102 return data?.id ? String(data.id) : ''
103 } catch {
104 return ''
105 }
106}
107
108async function scout($: EngineInterface, limit: number) {
109 $.ui.status('Mod 探勘:整理本機對話紀錄…')
110 const notes: string[] = []
111 const localId = await $.session.id()
112 let local: Awaited<ReturnType<typeof collectLocal>> | null = null
113 try {
114 local = await collectLocal($, limit, localId)
115 } catch (error) {
116 notes.push(`本機紀錄沒讀到(${String(error).slice(0, 120)}),只用雲端對話。`)
117 }
118
119 const remote = await collectRemote($, limit, await currentRemoteId($))
120 if (remote === null) notes.push('這個環境連不到雲端對話(claude-code-remote),只用本機紀錄。')
121
122 // 遠端遙控的本機對話會同時出現在兩邊:第一句的時間和內容一樣就當同一個
123 const seen = new Set((local?.sessions ?? []).flatMap(s => s.messages.slice(0, 3).map(m => `${m.at}|${m.text.slice(0, 40)}`)))
124 const remoteOnly = (remote ?? []).filter(s => !s.messages.slice(0, 3).some(m => seen.has(`${m.at}|${m.text.slice(0, 40)}`)))
125 const sessions = [...(local?.sessions ?? []), ...remoteOnly]
126 .sort((a, b) => (b.messages.at(-1)?.at ?? b.start).localeCompare(a.messages.at(-1)?.at ?? a.start))
127 .slice(0, limit)
128
129 if (sessions.length === 0) {
130 await update($, phase, () => 'idle')
131 $.ui.status(undefined)
132 $.ui.toast('Mod 探勘:找不到任何對話紀錄,先累積一些對話再來')
133 return
134 }
135
136 const home = (await $.env.get('HOME')) ?? ''
137 const date = new Date(await $.clock.now()).toISOString().slice(0, 10)
138 const digest = `${home}/.claude/mod-scout/digest-${date}.md`
139 await $.fs.write(digest, buildDigest(sessions, local?.inventory ?? null, notes))
140
141 await update($, phase, () => 'analyzing')
142 $.ui.status(`Mod 探勘:分析 ${sessions.length} 次對話中…`)
143 await $.prompt.submit({ text: analysisPrompt(digest, sessions.length) })
144}
145
146async function build($: EngineInterface, proposal: Proposal) {
147 $.ui.status(undefined)
148 await $.prompt.submit({ text: buildPrompt(proposal) })
149}
150
151async function choose($: EngineInterface, report: Report) {
152 if (report.proposals.length === 0) {
153 $.ui.toast('Mod 探勘:這次沒有需要做成 Mod 的,報告在對話裡')
154 return
155 }
156 const options = [...report.proposals.slice(0, 3).map(p => `${p.number}. ${p.name}`), SKIP]
157 let choice: string
158 try {
159 choice = await $.ui.ask('提案出來了,要做哪一個?(其他編號可以直接打數字)', { header: 'Mod 探勘', options })
160 } catch {
161 return
162 }
163 const number = choice === SKIP ? null : pickNumber(choice)
164 const proposal = report.proposals.find(p => p.number === number)
165 if (!proposal) {
166 $.ui.toast('之後想做的話,輸入 /mod-scout 做 N')
167 return
168 }
169 await build($, proposal)
170}
171
172export const register: Register = (on, options) => {
173 const limit = Number(options.sessions ?? 30)
174 const remindDays = Number(options.remindDays ?? 90)
175
176 on('session.start', async ($, e, next) => {
177 const result = await next(e)
178 await $.command.register({
179 name: 'mod-scout',
180 description: 'Mod 探勘:讀最近的對話,提出最多 5 個值得做的 Mod(附原話證據)',
181 argumentHint: '[做 N]',
182 })
183 const last = (await $.store.get('last')) as Report | undefined
184 const now = await $.clock.now()
185 if (remindDays > 0 && last && now - last.at > remindDays * DAY) {
186 $.ui.toast(`上次 Mod 探勘是 ${Math.floor((now - last.at) / DAY)} 天前,可以輸入 /mod-scout 再跑一次`)
187 }
188 return result
189 })
190
191 on('command.run', { command: 'mod-scout' }, async ($, e) => {
192 const args = e.args.trim()
193 if (args === 'help' || args === '說明') return { text: USAGE }
194
195 if (args) {
196 const number = pickNumber(args)
197 const last = (await $.store.get('last')) as Report | undefined
198 const proposal = last?.proposals.find(p => p.number === number)
199 if (!proposal) return { text: `找不到第 ${number ?? '?'} 個提案。先輸入 /mod-scout 跑一次探勘。\n${USAGE}` }
200 detach($, () => build($, proposal))
201 return { text: `接著做第 ${proposal.number} 個提案:${proposal.name}` }
202 }
203
204 if ((await read($, phase)) !== 'idle') {
205 await update($, phase, () => 'idle')
206 $.ui.status(undefined)
207 return { text: '上一次探勘還沒結束(或卡住了),已重設。再輸入一次 /mod-scout 重新開始。' }
208 }
209 await update($, phase, () => 'collecting')
210 detach($, () => scout($, limit))
211 return { text: `Mod 探勘:開始整理最近 ${limit} 次對話,整理完會自動送出分析。` }
212 })
213
214 on('turn.complete', async ($, e, next) => {
215 const result = await next(e)
216 if (e.agentId || (await read($, phase)) !== 'analyzing') return result
217 await update($, phase, () => 'idle')
218 $.ui.status(undefined)
219 if (e.reason !== 'answer') return result
220 const proposals = parseProposals(e.answer)
221 if (proposals === null) {
222 $.ui.toast('Mod 探勘:沒找到提案段落,報告直接看對話;要做的話說「幫我做第 N 個」')
223 return result
224 }
225 const report: Report = { at: await $.clock.now(), proposals }
226 await $.store.set('last', report)
227 detach($, () => choose($, report))
228 return result
229 })
230}
231hooks/lib.ts 161 lines1import type { Proposal } from '../types'
2
3export type Message = { at: string; text: string }
4export type Session = { id: string; title: string; project: string; start: string; source: '本機' | '雲端'; messages: Message[] }
5export type Inventory = { hooks: string[]; pluginDirs: string[]; skills: string[] }
6
7export const START = '<<<Mod 提案開始>>>'
8export const END = '<<<Mod 提案結束>>>'
9const MAX_CHARS = 500
10
11// 跟 scripts/collect.py 的 SECRET 同一套:金鑰、token、密碼先遮掉,模型看不到原文
12const SECRET = new RegExp(
13 [
14 'sk-[A-Za-z0-9_\\-]{12,}',
15 'gh[pousr]_[A-Za-z0-9]{20,}',
16 'github_pat_[A-Za-z0-9_]{20,}',
17 'xox[abprs]-[A-Za-z0-9\\-]{10,}',
18 'AKIA[0-9A-Z]{16}',
19 'AIza[0-9A-Za-z_\\-]{30,}',
20 'eyJ[A-Za-z0-9_\\-]{10,}\\.[A-Za-z0-9_\\-]{10,}\\.[A-Za-z0-9_\\-]{10,}',
21 '[Bb]earer\\s+[A-Za-z0-9._\\-]{16,}',
22 '(?:[Pp]assword|[Pp]asswd|[Ss]ecret|[Tt]oken|[Aa]pi[_-]?[Kk]ey)\\s*[:=]\\s*\\S+',
23 '\\b[A-Fa-f0-9]{32,}\\b',
24 '\\b[A-Za-z0-9+/]{40,}={0,2}',
25 ].join('|'),
26 'g',
27)
28
29export const redact = (text: string) => text.replace(SECRET, '<已遮蔽>')
30
31// 系統或外掛塞進對話的內容,不是使用者打的
32const NOISE = ['<', 'Caveat:', '[Request interrupted', 'This is how Claude Code surfaces', 'The handoff plugin sent']
33
34export function cleanHuman(text: string): string | null {
35 const plain = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
36 if (!plain || NOISE.some(prefix => plain.startsWith(prefix))) return null
37 return redact(plain).slice(0, MAX_CHARS)
38}
39
40// MCP 回傳的文字可能被包在說明文字裡,取最外層的 JSON
41export function parseJson(text: string): Record<string, unknown> | null {
42 const start = text.indexOf('{')
43 const end = text.lastIndexOf('}')
44 if (start < 0 || end <= start) return null
45 try {
46 return JSON.parse(text.slice(start, end + 1)) as Record<string, unknown>
47 } catch {
48 return null
49 }
50}
51
52type Event = { created_at?: string; user?: { internal_anthropic_catchall?: Record<string, unknown> } }
53
54// list_events(kinds: user)的一頁:tool_result 也算 user,只留人打的字
55export function humanFromEvents(events: readonly Event[]): Message[] {
56 const out: Message[] = []
57 for (const event of events) {
58 const row = event.user?.internal_anthropic_catchall
59 if (!row || row.isSynthetic || row.isMeta) continue
60 const origin = row.origin as { kind?: string } | undefined
61 if (origin?.kind && origin.kind !== 'human') continue
62 const content = (row.message as { content?: unknown } | undefined)?.content
63 let text: string | null = null
64 if (typeof content === 'string') text = content
65 else if (Array.isArray(content) && !content.some(b => (b as { type?: string })?.type === 'tool_result')) {
66 text = content
67 .filter(b => (b as { type?: string })?.type === 'text')
68 .map(b => String((b as { text?: unknown }).text ?? ''))
69 .join('\n')
70 }
71 const clean = text === null ? null : cleanHuman(text)
72 if (clean) out.push({ at: String(event.created_at ?? '').slice(0, 16), text: clean })
73 }
74 return out
75}
76
77// 「https://github.com/ricky94511/shorts-studio」→「shorts-studio」;沒有 repo 就用標題的 [類別]
78export function projectOf(session: Record<string, unknown>): string {
79 const context = session.session_context as { sources?: { git_repository?: { url?: string } }[] } | undefined
80 const url = context?.sources?.[0]?.git_repository?.url
81 if (url) return url.replace(/\.git$/, '').split('/').pop() ?? url
82 const category = String(session.title ?? '').match(/^\[([^\]]+)\]/)
83 return category ? category[1]! : '(無專案)'
84}
85
86// 本機路徑只留最後一段,比較好讀
87const shortProject = (project: string) => project.split(/[\\/]/).filter(Boolean).pop() ?? project
88
89export function buildDigest(sessions: readonly Session[], inventory: Inventory | null, notes: readonly string[]): string {
90 const lines = [
91 '# Mod 探勘:最近對話的原話整理',
92 '',
93 `共 ${sessions.length} 次對話、${sessions.reduce((n, s) => n + s.messages.length, 0)} 句使用者訊息。只收使用者親手打的字;金鑰、token、密碼已遮蔽成 <已遮蔽>。`,
94 '每句格式:`- 日期時間|原話`,標題列有專案與來源。',
95 ...notes.map(note => `> ${note}`),
96 '',
97 ]
98 if (inventory) {
99 lines.push('## 已經有的 Hook/外掛/Skill(提案不要跟這些重複)', '')
100 lines.push(`- 設定裡的 Hook 事件:${inventory.hooks.join('、') || '(無)'}`)
101 lines.push(`- 載入的外掛:${inventory.pluginDirs.map(shortProject).join('、') || '(無)'}`)
102 lines.push(`- 使用者的 Skill:${inventory.skills.join('、') || '(無)'}`, '')
103 }
104 for (const session of sessions) {
105 lines.push(`## ${session.start.slice(0, 10)}|${shortProject(session.project)}|${session.title || '(無標題)'}(${session.source})`, '')
106 for (const message of session.messages) lines.push(`- ${message.at.replace('T', ' ')}|${message.text.replace(/\s*\n\s*/g, ' / ')}`)
107 lines.push('')
108 }
109 return lines.join('\n')
110}
111
112export function analysisPrompt(digestPath: string, count: number): string {
113 return `請幫我找出最值得做的 Claude Code mod。不要寫任何程式,只做分析。
114
1151. 讀我最近 ${count} 次對話的原話整理:${digestPath}(Mod 探勘已經從對話紀錄抽好,只有我親手打的字;整份讀完再分析)。
1162. 找三種訊號:
117 - 我一直重複交代的事:同一個提醒或要求出現 3 次以上(不能只用關鍵字精確比對,同義和變體說法要算同一件,例如「我說過」「我也說過」「講過了」)
118 - 讓我緊張的動作:我要求你先停下來、先給我看、或事後追問「你剛剛刪了什麼」的動作,例如刪檔、推 code、改設定
119 - 我檢查成果的固定動作:例如每次都叫你跑測試、問花了多少、問改了哪些檔
1203. 每個訊號先判斷:用 Hook 或 Skill 能不能做到?能的話列在「不需要 mod」,說明用哪個就好、為什麼。整理檔開頭列了我已經有的 Hook/外掛/Skill,已經解決的不要再提。
1214. 剩下的,提出最多 5 個 mod 提案,每個寫:
122 - 名稱與一句話功能
123 - 證據:引用 2 句以上我在對話裡說過的原話,標明是哪一天、哪個專案
124 - 插手位置:動作前/取代動作/動作後/前後包住,以及插手後要放行、改內容再放行、還是擋下
125 - 用到的能力:在畫面上多一塊/記得前面發生的事/停下來問我/改掉你的輸出或行為
126 - 為什麼 Hook 或 Skill 做不到
127 - 資料要不要跨對話保存
1285. 依「每週能幫我省下幾次重複動作」排序,每個提案寫出估計的次數。
129
130讀紀錄時遇到金鑰、密碼、token 這類內容,不要引用也不要列出。
131
132格式:先寫「不需要 mod」那一段,再把提案整段放在 ${START} 和 ${END} 之間,每個提案用「### 提案 N:名稱」當標題(N 從 1 開始)。全部用繁體中文。寫完就停,先不要做任何一個,我挑了再做。`
133}
134
135export function buildPrompt(proposal: Proposal): string {
136 return `幫我做第 ${proposal.number} 個 Mod 提案「${proposal.name}」,內容照上面 Mod 探勘報告裡的提案。
137
138- 先載入 plugin-authoring skill,先給我設計稿(畫面、資料存哪、什麼時候插手),我確認後再寫
139- 對話標題改成「[Mod] ${proposal.name}」,加 mod、claude-設定 標籤
140- 寫完要跑 validate 和測試,結果照實跟我說
141- 做完之後,用選項問我要不要永久保存(推到 GitHub 的 claude-mods repo,雲端 setup script 也要加上),我說要才推`
142}
143
144export function parseProposals(answer: string): Proposal[] | null {
145 const start = answer.indexOf(START)
146 const end = answer.indexOf(END, start + START.length)
147 if (start < 0 || end < 0) return null
148 const proposals: Proposal[] = []
149 for (const match of answer.slice(start + START.length, end).matchAll(/^#{2,4}\s*提案\s*(\d+)\s*[::]\s*(.+?)\s*$/gm)) {
150 const name = match[2]!.replace(/[*`]/g, '').trim()
151 if (name) proposals.push({ number: Number(match[1]), name })
152 }
153 return proposals.slice(0, 5)
154}
155
156// 「做 3」「3」「第3個」→ 3
157export function pickNumber(args: string): number | null {
158 const match = args.match(/(\d+)/)
159 return match ? Number(match[1]) : null
160}
161types/index.d.ts 12 lines1export type Phase = 'idle' | 'collecting' | 'analyzing'
2
3export type Proposal = { number: number; name: string }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'mod-scout': {
8 phase: Phase
9 }
10 }
11}
12