M0 probe for cc-sticky-notes: logs what the Mods API really does

一個 Claude Code mod:在專案 session 裡隨口問的「為什麼 / 這是什麼」不會進主線對話,而是變成「便利貼」,在背景回答、顯示在右側 pane,並存成一棵同專案所有 session 共用的樹。主線的 context 保持乾淨,追問也能一路掛在同一條旁支下。
版本 0.0.1,給朋友試用中。設計與決策:PLAN.md;Mods API 實測紀錄:PROBE.md。
git clone https://github.com/k7term1a/cc-sticky-notes.git
終端機:每次啟動時帶上資料夾路徑。
claude --plugin-dir /path/to/cc-sticky-notes
Claude Desktop(Code 分頁):在 ~/.claude/settings.json 的 env 加上這個資料夾,之後開的 session 都會載入。
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "C:\\Users\\you\\cc-sticky-notes",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}
}
CLAUDE_CODE_PLUGIN_DIR_WATCH 是選用的:設了之後,mod 的檔案一更新(例如 git pull),開著的 session 就會自動重載。cc-sticky-note。打 /sn doctor 可以檢查金鑰有沒有讀到。建一個 ~/.claude/sticky-notes/.env(Windows 是 C:\Users\<你>\.claude\sticky-notes\.env):
TYPESAFE_API_KEY=apikey_...
OPENAI_API_KEY=sk-...
~/.claude/sticky-notes/.env → mod 資料夾裡的 .env。不會讀你專案自己的 .env(那裡常有那個專案自己的 OpenAI key)。/sn doctor 只告訴你找到沒有、從哪裡找到。/sn new <問題> 或 pane 的追問框建立。像平常一樣打字。Jev 判斷這句是「專案任務」還是「知識旁問」:
A hook blocked your prompt
Prompt dropped by a hook: cc-sticky-note:Jev 96% 判斷是旁支 → 便利貼「CRDT 是什麼?」
幾秒後答案出現在右側 pane(會自動打開),對話裡留一行灰字預覽。灰字和卡片 Claude 都讀不到。
問完一則之後,輸入框下方會顯示 cc-sticky-note <正在追問的題目>。這時在輸入框直接打字,Jev 會判斷是不是在延續這個話題;是的話就掛在同一條旁支下。想結束追問,按 pane 裡的「回到主線」或打 /sn back。
也可以在 pane 裡點任何一則便利貼,接著打字就是追問它;或用 pane 卡片裡的追問框(不經 Jev,直接掛在那一則下面)。
打 /sn 開關。內容:
● 是這個 session 正在追問的、◀ 是目前選中的、… 是還在回答、↑ 是已回到主線的。/sn 和 /sticky-note 完全一樣,/sn 比較好打。
| 指令 | 作用 | |||
|---|---|---|---|---|
/sn | 開 / 關右側 pane | |||
/sn new <問題> | 手動開一張便利貼(不經 Jev) | |||
/sn back | 結束追問,回到主線 | |||
/sn feedback good | 剛剛那句 Jev 分對了 | |||
| `/sn feedback bad <main\ | sidebar\ | followup\ | project>` | 分錯了,正確應該是哪一種(也可以打「錯 主線」) |
/sn calibrate | 用你標記過的資料,看各門檻下 Jev 的準確率 | |||
/sn doctor | 檢查兩把金鑰找到沒有、從哪裡找到 |
最有用的回饋是 Jev 分錯的時候:被攔成便利貼的其實是任務,或任務被當成旁問。打 /sn feedback bad main(或 sidebar 等),再截圖給我。
下列欄位可以在 Claude Code 的設定選單(/config,終端機)調整:
| 欄位 | 預設 | 說明 |
|---|---|---|
autoOpenPane | true | 答案回來時自動打開 pane 並選中 |
routeThreshold | 0.6 | Jev 信心低於這個就問你 |
followupThreshold | 0.6 | 判成追問的門檻 |
summaryProvider | claude | 標題 / 摘要用誰寫:claude 或 openai |
claudeModel / claudeEffort | haiku / low | 寫標題摘要的 Claude 模型與 effort(旁答本身一律用主線的模型) |
openaiModel / openaiReasoningEffort | gpt-5-mini / minimal | summaryProvider = openai 時用 |
~/.claude/plugins/store/cc-sticky-notes_*.json。用 --plugin-dir 載入和正式安裝可能是不同的檔。claude plugin validate .
claude plugin test .
npx -p typescript@5.6 tsc -p .
tsconfig.json extends .claude-plugin/types/tsconfig.json,那是引擎載入 mod 時寫出來的型別(已 git-ignore),所以第一次 type-check 前要先載入一次 mod。
hooks/register.tsx:所有 hook,以及 portsOf($)。只有這個檔碰 $(PROBE.md P1)。hooks/tree.ts、route.ts、jev.ts、redact.ts、digest.ts、answer.ts:邏輯模組,純函式或吃 Ports。hooks/notes.ts:流程(路由 → 便利貼 → 背景回答 → 路由樣本)。hooks/feedback.ts:路由樣本、/sn feedback、calibrate。hooks/secrets.ts:金鑰從哪裡來。hooks/pane.tsx:右側 pane 與輸入框下方的狀態列。probes/m0/:M0 探針 mod(cc-sticky-probe),獨立的 plugin。hooks/register.tsx 268 lines1// M0 probe. Every line it logs starts with "PROBE " so the stream-json output can be grepped.
2// Driven by the prompt prefix: "probe-<name> <rest>". Unknown prompts pass through.
3import type { Register } from 'claude-code'
4
5export const register: Register = on => {
6
7 on('session.start', async ($, e, next) => {
8 const out = (s: string) => $.ui.log(`PROBE ${s}`)
9 out(`session.start e=${JSON.stringify(e)}`)
10 out(`version=${JSON.stringify(await $.session.version())}`)
11 out(`id=${await $.session.id()} root=${await $.session.root()} cwd=${await $.session.cwd()}`)
12 out(`surfaces=${JSON.stringify(await $.session.surfaces())} plugin.root=${$.plugin.root}`)
13 out(`env TYPESAFE_API_KEY set=${(await $.env.get('TYPESAFE_API_KEY')) !== undefined}`)
14 out(`env OPENAI_API_KEY set=${(await $.env.get('OPENAI_API_KEY')) !== undefined}`)
15 // store: who was here before (cross-session persistence)
16 const id = await $.session.id()
17 const visits = ((await $.store.get('visits')) as string[] | undefined) ?? []
18 out(`store visits(before)=${JSON.stringify(visits)}`)
19 await $.store.set('visits', [...visits, id].slice(-10))
20 await $.store.set(`mark:${id}`, await $.clock.now())
21 out(`store keys=${JSON.stringify(await $.store.keys())}`)
22 return next(e)
23 })
24
25 on('turn.start', async ($, e, next) => {
26 $.ui.log(`PROBE turn.start turnId=${e.turnId} text=${JSON.stringify(e.text.slice(0, 60))}`)
27 return next(e)
28 })
29
30 on('turn.complete', async ($, e, next) => {
31 $.ui.log(`PROBE turn.complete keys=${Object.keys(e).join(',')}`)
32 // second look at the store: did a concurrent session write meanwhile?
33 $.ui.log(`PROBE store keys at turn.complete=${JSON.stringify(await $.store.keys())}`)
34 return next(e)
35 })
36
37 on('prompt.submit', async ($, e, next) => {
38 const out = (s: string) => $.ui.log(`PROBE ${s}`)
39 out(`prompt.submit keys=${Object.keys(e).join(',')} turnId=${e.turnId} wait=${e.wait} origin=${JSON.stringify(e.origin)}`)
40 const m = /^probe-([a-z-]+)\s*([\s\S]*)$/.exec(e.text)
41 if (!m) return next(e)
42 const [, name, rest] = m
43 const t0 = await $.clock.now()
44 const ms = async () => (await $.clock.now()) - t0
45
46 switch (name) {
47 case 'drop':
48 return { drop: 'cc-sticky-probe: dropped on purpose' }
49
50 case 'fork': {
51 const r = await $.model.fork({ prompt: rest || 'In one sentence: what is TCP backpressure?' })
52 out(`fork ms=${await ms()} result=${JSON.stringify(r).slice(0, 600)}`)
53 return { drop: 'cc-sticky-probe: fork answered off the transcript' }
54 }
55
56 case 'fork-history': {
57 // Can a fork carry side-thread history? Only via the prompt text (ModelForkRequest is { prompt }).
58 const history = 'Q1: What is TCP backpressure?\nA1: When a receiver is slower than a sender, buffers fill and the sender is told to slow down.\n'
59 const r = await $.model.fork({ prompt: `[side thread so far]\n${history}\n[follow-up]\n${rest || 'How does that relate to Node streams? One sentence.'}` })
60 out(`fork-history ms=${await ms()} result=${JSON.stringify(r).slice(0, 600)}`)
61 return { drop: 'cc-sticky-probe: fork-history done' }
62 }
63
64 case 'fork-bg': {
65 // Drop first, answer in the background.
66 $.clock.after(0, async () => {
67 const r = await $.model.fork({ prompt: rest || 'One sentence: what is a hash map?' })
68 $.ui.log(`PROBE fork-bg ms=${(await $.clock.now()) - t0} result=${JSON.stringify(r).slice(0, 400)}`)
69 })
70 return { drop: 'cc-sticky-probe: fork-bg scheduled' }
71 }
72
73 case 'complete': {
74 const r = await $.model.complete({
75 model: 'haiku',
76 effort: 'low',
77 system: 'Reply with a JSON object {"title": string (<=12 chars), "summary": string (3 sentences)}.',
78 prompt: rest || 'Q: What is TCP backpressure? A: Receiver slower than sender, sender slows down.',
79 maxTokens: 300,
80 timeoutMs: 20000,
81 })
82 out(`complete ms=${await ms()} result=${JSON.stringify(r).slice(0, 600)}`)
83 return { drop: 'cc-sticky-probe: complete done' }
84 }
85
86 case 'model': {
87 // Does $.model.complete honour an alias and a full id? (claudeModel userConfig)
88 for (const model of (rest || 'sonnet claude-haiku-4-5-20251001 opus').split(/\s+/)) {
89 try {
90 const r = await $.model.complete({ model, prompt: 'Reply with only the name of the model you are.', maxTokens: 40, effort: 'low', timeoutMs: 30000 })
91 out(`model ${model} ms=${await ms()} result=${JSON.stringify(r).slice(0, 300)}`)
92 } catch (err) {
93 out(`model ${model} threw ${String(err).slice(0, 200)}`)
94 }
95 }
96 return { drop: 'cc-sticky-probe: model done' }
97 }
98
99 case 'classify': {
100 try {
101 const label = await $.model.classify(rest || 'why does TCP need a three-way handshake?', [
102 'main_task', 'sidebar_knowledge', 'project_question',
103 ])
104 out(`classify ms=${await ms()} label=${JSON.stringify(label)}`)
105 } catch (err) {
106 out(`classify threw ${String(err)}`)
107 }
108 return { drop: 'cc-sticky-probe: classify done' }
109 }
110
111 case 'messages': {
112 const msgs = await $.session.messages()
113 if (Array.isArray(msgs)) {
114 out(`messages n=${msgs.length}`)
115 for (const one of msgs.slice(-4)) {
116 out(` msg role=${one.role} keys=${Object.keys(one).join(',')} text=${JSON.stringify(one.text.slice(0, 80))} toolUses=${one.toolUses.length}`)
117 }
118 } else out(`messages deny=${JSON.stringify(msgs)}`)
119 out(`turns=${await $.session.turns()} usage=${JSON.stringify(await $.session.usage()).slice(0, 400)}`)
120 return { drop: 'cc-sticky-probe: messages done' }
121 }
122
123 case 'http': {
124 for (const url of ['https://api.openai.com/v1/models', 'https://api.typesafe.ai/v1/systemone']) {
125 try {
126 const r = await $.http.fetch(url, { method: url.includes('typesafe') ? 'POST' : 'GET', headers: { 'content-type': 'application/json' }, body: url.includes('typesafe') ? '{}' : undefined })
127 out(`http ${url} status=${r.status} ok=${r.ok} headerKeys=${Object.keys(r.headers).slice(0, 8).join(',')} text=${JSON.stringify(r.text.slice(0, 200))}`)
128 } catch (err) {
129 out(`http ${url} threw ${String(err)}`)
130 }
131 }
132 return { drop: 'cc-sticky-probe: http done' }
133 }
134
135 case 'jev': {
136 // Real Jev call with the four PLAN.md questions; logs the raw response.
137 const key = await $.env.get('TYPESAFE_API_KEY')
138 if (!key) {
139 out('jev: no TYPESAFE_API_KEY')
140 return { drop: 'cc-sticky-probe: jev skipped' }
141 }
142 const prompt = rest || 'TCP 的 backpressure 是什麼原理?'
143 const body = {
144 model: 'jev-latest',
145 state: `[recent main-line context]\nuser: 把 auth 改成 session cookie\nassistant: 已改好,剩 token 輪替。\n\n[sidebar state]\nactive_thread_title: none\nactive_thread_last_question: none\nmain_turns_since_last_sidebar: 2\n\n[new prompt]\n${prompt}`,
146 questions: {
147 route: {
148 type: 'choice',
149 instructions: 'Where should the new prompt go?',
150 criteria: {
151 main_task: 'Advances the project: an instruction, a code change, or a decision about what Claude just did',
152 sidebar_knowledge: 'Asks about a principle, concept or background knowledge; does not ask Claude to change anything',
153 project_question: 'A question about this project itself whose answer belongs in the main conversation',
154 },
155 },
156 is_followup: { type: 'noul', instructions: 'Does the new prompt continue the topic of active_thread?' },
157 needs_project_ctx: { type: 'noul', instructions: "Does answering the new prompt require seeing the project's code or conversation?" },
158 tag: { type: 'choice', instructions: 'Which topic tag fits the new prompt?', criteria: { network: 'About network', db: 'About db', __new__: 'None of the existing tags fits' } },
159 },
160 }
161 const r = await $.http.fetch('https://api.typesafe.ai/v1/systemone', {
162 method: 'POST',
163 headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
164 body: JSON.stringify(body),
165 })
166 out(`jev ms=${await ms()} status=${r.status} text=${r.text.slice(0, 1500)}`)
167 return { drop: 'cc-sticky-probe: jev done' }
168 }
169
170 case 'openai': {
171 const key = await $.env.get('OPENAI_API_KEY')
172 if (!key) {
173 out('openai: no OPENAI_API_KEY')
174 return { drop: 'cc-sticky-probe: openai skipped' }
175 }
176 const r = await $.http.fetch('https://api.openai.com/v1/chat/completions', {
177 method: 'POST',
178 headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
179 body: JSON.stringify({
180 model: rest || 'gpt-5-mini',
181 max_completion_tokens: 400,
182 reasoning_effort: 'minimal',
183 messages: [
184 { role: 'system', content: 'Reply with JSON only: {"title": "<=12 chars", "summary": "three sentences"}' },
185 { role: 'user', content: 'Q: What is TCP backpressure? A: When the receiver is slower, buffers fill and the sender slows down.' },
186 ],
187 }),
188 })
189 out(`openai ms=${await ms()} status=${r.status} text=${r.text.slice(0, 1500)}`)
190 return { drop: 'cc-sticky-probe: openai done' }
191 }
192
193 case 'agents': {
194 try {
195 const r = await $.tool.call({ tool: 'ListAgents' })
196 out(`ListAgents result=${JSON.stringify(r).slice(0, 800)}`)
197 } catch (err) {
198 out(`ListAgents threw ${String(err)}`)
199 }
200 return { drop: 'cc-sticky-probe: agents done' }
201 }
202
203 case 'suggest': {
204 const r = await $.prompt.suggest({ text: '正在追問:probe' })
205 out(`suggest result=${JSON.stringify(r)}`)
206 return { drop: 'cc-sticky-probe: suggest done' }
207 }
208
209 case 'store-race': {
210 // Two processes run this at once. Separate keys + one shared read-modify-write key.
211 const id = await $.session.id()
212 await $.store.set(`race:${id}`, t0)
213 const before = ((await $.store.get('race-list')) as string[] | undefined) ?? []
214 await $.clock.sleep(3000)
215 await $.store.set('race-list', [...before, id])
216 out(`store-race id=${id} keysAfter=${JSON.stringify((await $.store.keys()).filter(k => k.startsWith('race')))} list=${JSON.stringify(await $.store.get('race-list'))}`)
217 return { drop: 'cc-sticky-probe: store-race done' }
218 }
219
220 case 'store-read': {
221 out(`store-read keys=${JSON.stringify((await $.store.keys()).filter(k => k.startsWith('race')))} list=${JSON.stringify(await $.store.get('race-list'))}`)
222 return { drop: 'cc-sticky-probe: store-read done' }
223 }
224
225 case 'budget': {
226 // Does a long $ call count against the 10 s budget? $.clock.sleep does; http does not.
227 try {
228 await $.http.fetch('https://httpbin.org/delay/12')
229 out(`budget http 12s ok ms=${await ms()}`)
230 } catch (err) {
231 out(`budget http threw ${String(err)} ms=${await ms()}`)
232 }
233 return { drop: 'cc-sticky-probe: budget done' }
234 }
235
236 case 'context': {
237 // project_question path: pass through with extra context
238 return next({ ...e, text: rest ?? '', context: [...(e.context ?? []), '[sticky-notes] project question; answer briefly'] })
239 }
240
241 default:
242 return next(e)
243 }
244 })
245
246 // UI probe: a badge beside the original user row, and a hotkey the type says is refused.
247 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
248 if (!e.props.text.includes('badge-me')) return next(e)
249 const { Box, Button } = $.ui.resolve(e)
250 const original = await next(e)
251 return (
252 <Box flexDirection="row">
253 {original}
254 <Button key="badge" plain label="📌 1" onPress={() => $.ui.toast('badge pressed')} />
255 </Box>
256 )
257 })
258
259 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
260 const { Box, Button } = $.ui.resolve(e)
261 return (
262 <Box>
263 <Button key="alt" label="open" hotkey="⌥s" onPress={() => {}} />
264 </Box>
265 )
266 })
267}
268