Buttons above the prompt: retell recent turns in plain words, or open what a background check flagged that you might miss. Results go to Claude Code's own…

在 Claude Code 提示框上方放三顆按鈕:重講它剛剛說的話,或提醒你可能漏看的事。內容都不進 transcript,模型看不到。
結果畫在 CC 自己的 pane:終端機夠寬時停在畫面右邊,窄時開在提示框上方。不需要 Herdr。
這是 cc-sidecar-waitwhat 的 Claude Mods 版:sidecar 跑在 CC 外面、讀 JSONL;這個 mod 跑在 CC 裡面、讀引擎給的對話,換來不用切終端機、不用選 session。兩邊共用同一組環境變數與 prompt 覆寫檔。

┌ transcript ┌ pane(寬終端停右邊)
│ … │ Heads up · 結帳可能重複扣款
├ band │ - 測試全過,但沒測重送…
│ [ 白話 ] [ 跟丟了 ] [ 該知道 ● ] │ 來源 http:gemini-3.8-flash-high
│ recap · 我已修好結帳 → 等你確認 │ [ 有幫助 ] [ 不相關 ]
├ prompt └
│ ❯
| 按鈕 | 做什麼 | 送什麼給模型 |
|---|---|---|
白話 | 看不懂這一輪,白話重講 | 最後一個 turn(你問一次加上 CC 那一輪的全部回應,工具呼叫不拆開算) |
跟丟了 | 跟丟了,重講整段脈絡 | 整個 session 的對話與工具紀錄 |
該知道 | 看背景檢查挑出的提醒;有新提醒時亮起來、加 ● | 不用按就會送,見下方「該知道」 |
仿 Claude Code 內建的 You should know mod(cc-plugin-you-should-know@builtin),改成走你自己的模型、只在 main 停下來等你時檢查。
| 項目 | 內建版 | 這裡 |
|---|---|---|
| 何時檢查 | main 跑的過程中,每隔幾步一次 | 每一輪結束、閒置 5 秒後一次;輸入框有草稿就跳過 |
| 送什麼 | $.model.fork:整段對話原樣、同一個模型,吃 main 的 prompt cache | 整段對話,但每筆工具參數與結果只留頭尾各 300 字、去掉 system-reminder;估算超過 60,000 token 就砍最舊的 |
| 誰判斷 | Claude 官方直連且開 telemetry 的 session 才能用 | 跟重講同一條鏈:cmd → http → Claude 自家模型;YSK_MODEL 可只換「該知道」的 http 模型 |
判斷標準照內建版:預設什麼都不講,只有漏掉會損失錢、時間、白做工、得出錯誤結果時才提醒;你已經在討論或問過的不提;已提醒過的列給模型跳過。提醒分兩種標籤:
You should know:某個系統、概念或設計怎麼運作,而且對你的工作影響很大。Heads up:這個 session 裡 main 自己做的決定、沒特別講的事、可能有錯的結果,漏掉馬上有代價。提醒的寫法要求:先補看懂這則提醒所需的對話脈絡,再用白話講發現與影響。一小段、2~4 句,中文最多 200 字;不重述整段對話、不列查核過程,也不硬塞建議或待辦。長度靠 prompt 約束,不裁切模型答案。
走 cmd 時另外帶 SIDECAR_WEB_EFFORT,預設 extended(YSK_WEB_EFFORT 可改):這個判斷比重講更吃推理。ChatGPT 網頁版接受 standard/extended/max,heavy 與不認得的名字會被拒(2026-10-03 實測)。網頁版的輸入上限實測落在 46~55 萬字元之間、模型本身約 20~30 萬 token 之間;這裡的 60,000 估算 token 換成字元遠低於兩者。
亮起來的提醒沒點開,你再送出兩次 prompt 就自動收掉。pane 裡的 有幫助/不相關 跟每次檢查的結果都寫進 ~/.cache/cc-ysk-log.jsonl(留最新 2,000 行);每次送出的完整內容另存在 ~/.cache/cc-ysk-payloads/<session id>-<訊息數>.txt,事後可以拿來判斷模型該講沒講、講的準不準:
{"at":1791041447277,"event":"checked","sessionId":"…","messages":3,"source":"cmd:sidecar-webchat","effort":"extended","fallback":null,"chars":109,"seconds":7.3,"payload":"~/.cache/cc-ysk-payloads/…-3.txt","reply":"{\"tag\":\"none\"}","outcome":"none"}
{"at":1791039600000,"event":"answered","sessionId":"…","answer":"helpful","tag":"Heads up","title":"結帳可能重複扣款"}
outcome 是 shown/none/repeat/parse_failed/error;answer 是 opened/helpful/not_relevant/ignored;fallback 是前面幾條來源失敗的原因。payload 檔不會自動清。
AbovePrompt(提示框上方那條 band),都不回傳任何文字給 transcript。/wait-what 這類指令一敲,CC 就會把 <command-name> 寫進 transcript、模型下一輪就看到;按鈕走的是 ui.press,實測 JSONL 零筆記錄。$.model.complete:一次獨立呼叫,沒有歷史、沒有工具,system prompt 只有你給的那段。跟 sidecar 同一條鏈:先試 cmd,不行換 http,都不行才退回 Claude 自家模型。每次跑完,標題行會寫實際來源,退回時多一行原因。
| 來源 | 是什麼 | 設定 |
|---|---|---|
cmd | 一個 shell 指令,prompt 從 stdin 進、答案從 stdout 出 | SIDECAR_CMD;沒設就跳過 |
http | 任何吃 OpenAI 格式 /v1/chat/completions 的端點 | SIDECAR_PROXY(預設 http://127.0.0.1:8317/v1/chat/completions)、SIDECAR_MODEL(預設 gemini-3.8-flash-high)、SIDECAR_API_KEY(沒設就讀 ~/.cli-proxy-api/config.yaml 的第一把 api-keys) |
claude | $.model.complete,走 session 自己的憑證 | WW_MODEL(預設 haiku) |
SIDECAR_SOURCE=cmd 或 http 只試那一條,失敗直接退回 claude。指令照 shell 規則切參數,但不經過 shell 執行,不能寫 pipe 或重導向。
$.model.complete 回傳結果物件、pane 與 Markdown 元件),並開 CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1接進所有 session:
claude plugin marketplace add /path/to/cc-mod-waitwhat
claude plugin install cc-mod-waitwhat@cc-mod-waitwhat --scope user
再把 CLAUDE_CODE_ENABLE_FUNCTION_HOOKS 放進 ~/.claude/settings.json 的 env:
{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
改了原始碼後跑 claude plugin update cc-mod-waitwhat@cc-mod-waitwhat,安裝的是複本。
只試一次、不裝:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir /path/to/cc-mod-waitwhat
按鈕用滑鼠點,或 ctrl+x tab 把焦點移進 band、左右鍵選、Enter 按、Esc 回到輸入框。ctrl+x ctrl+a 收合整條 band。
每一輪結束後閒置 5 秒,mod 自動寫一份三欄摘要,同一個 session 1 分鐘內最多一次。band 顯示一行 recap · <now> → <next>,同一份也寫到 ~/.cache/cc-recap/<session id>.json,給 Collie 這類外部畫面讀。
{"version":1,"sessionId":"4114…","at":1790239993523,"model":"groq-gpt-oss-120b",
"goal":"讀取 hello.txt 並用一句話總結內容","now":"已讀取檔案並完成回答","next":"等待你的下一個指令"}
| 項目 | 值 |
|---|---|
| 模型 | groq-gpt-oss-120b(reasoning_effort: low),失敗退回 gpt-5.6-luna-fast,都走 SIDECAR_PROXY |
| 送出內容 | 對話尾段,估計 5,000 token 內;max_tokens 300;Request too large 時砍半重送一次 |
| 跳過 | 對話沒有新內容、輸入框有草稿、claude -p、subagent 的回合 |
recap 跟重講一樣不進 transcript,模型看不到;失敗只寫 debug log。
重講結果寫進 ~/.cache/cc-sidecar-waitwhat.json,跟 sidecar 共用同一套 key:模式加上標準化後的 user/assistant 對話,再算 SHA-256。key 不含入口、模型來源或兩邊不同的 payload 包裝,所以按鈕與 terminal ww 會互相命中;哪邊先產生答案,另一邊就直接沿用。上限 200 筆、滿了丟最舊的。舊版 key 不搬移,同一段舊對話升級後第一次重看仍會重問一次。
兩套 system prompt 跟 sidecar 共用同一個覆寫位置:~/.config/cc-sidecar-waitwhat/wait-what.md(跟丟了)與 plain.md(白話)。檔案存在且非空就用它,否則用內建。
claude plugin validate .claude-plugin/plugin.json # 列出掛的事件、$ 呼叫、讀的環境變數
型別檢查要先在這個資料夾開一個帶 function hooks 的 session、跑 /plugin-types 產生 .claude/types/,再 bunx -p typescript tsc -p .。
用 --plugin-dir 跑時改檔會熱重載;裝進 marketplace 的複本要 claude plugin update。
會用到 $ 的函式全放在 hooks/register.tsx:validator 只追進同一檔案頂層宣告的函式,$ 傳進 import 來的函式或巢狀 closure 都會被拒。純函式(組 payload、解析回覆、快取 key)才拆到其他檔案。同一個事件(如 turn.complete)也只能不帶 matcher 掛一次,所以 recap 與「該知道」共用同一組 turn.start/turn.complete。
MIT
hooks/register.tsx 462 lines1import type { EngineInterface, On, SessionMessage, Timer } from 'claude-code'
2import { CACHE_FILE, lookup, parseCache, sharedKeyFor, withEntry } from './cache.ts'
3import {
4 DEFAULT_FALLBACK_MODEL,
5 DEFAULT_HTTP_MODEL,
6 DEFAULT_PROXY,
7 clip,
8 cmdStdin,
9 firstApiKey,
10 httpBody,
11 httpHeaders,
12 httpReplyText,
13 sourceOf,
14 splitArgv,
15} from './model.ts'
16import { DEFAULT_PLAIN, DEFAULT_WAIT_WHAT } from './prompts.ts'
17import {
18 RECAP_FALLBACK_MODEL,
19 RECAP_INPUT_BUDGET,
20 RECAP_MODEL,
21 RECAP_TIMEOUT_MS,
22 type RecapFields,
23 type RecapRecord,
24 delayFor,
25 fingerprintOf,
26 isTooLarge,
27 parseRecap,
28 recapBody,
29 recapInput,
30 recapPath,
31} from './recap.ts'
32import { cacheMessagesOf, lastCacheTurns, lastTurns, transcriptOf } from './turns.ts'
33import {
34 MARKDOWN_MAX,
35 YSK_CLEAR_AFTER_PROMPTS,
36 YSK_IDLE_MS,
37 YSK_INPUT_BUDGET,
38 YSK_LOG,
39 YSK_PROMPT,
40 YSK_REPLY_KEPT,
41 YSK_SEEN_KEPT,
42 YSK_WEB_EFFORT,
43 type YskAnswer,
44 type YskShown,
45 isRepeat,
46 parseYsk,
47 withLogLine,
48 yskPayload,
49 yskPayloadPath,
50} from './ysk.ts'
51
52// Every function that takes $ lives in this file: the engine's validator follows $ only into
53// functions declared in the same file as the hook, never across an import. Pure helpers stay
54// in their own modules.
55
56type Mode = 'plain' | 'lost'
57
58type State =
59 | { status: 'idle' }
60 | { status: 'busy'; label: string }
61 | { status: 'done'; label: string; text: string; seconds: string; source: string; chars: number; fallback: string | null; cached: boolean }
62 | { status: 'error'; label: string; text: string }
63
64type View = 'retell' | 'ysk'
65
66type Answer = { text: string; source: string; fallback: string | null }
67
68type AskOptions = { httpModel?: string; cmdEnv?: Record<string, string> }
69
70const HTTP_TIMEOUT_MS = 120000
71
72type RecapRunner = { pending: Timer | null; lastRunAt: number | null; lastFingerprint: string; running: boolean }
73
74type YskRunner = { pending: Timer | null; running: boolean; lastFingerprint: string; seen: string[]; promptsSince: number }
75
76export const PANE = 'waitwhat'
77const PROMPT_DIR = '.config/cc-sidecar-waitwhat'
78const KEY_FILE = '.cli-proxy-api/config.yaml'
79const PAYLOAD_HEAD = '以下是 Claude Code 的對話紀錄,USER 是使用者、ASSISTANT 是 Claude。只重講紀錄裡的內容,不要評論紀錄本身。'
80
81const apiKeyOf = async ($: EngineInterface) => {
82 const fromEnv = await $.env.get('SIDECAR_API_KEY')
83 if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv
84 const home = await $.env.get('HOME')
85 if (home === undefined) return null
86 const path = `${home}/${KEY_FILE}`
87 return (await $.fs.exists(path)) ? firstApiKey(await $.fs.read(path)) : null
88}
89
90const askCmd = async ($: EngineInterface, system: string, payload: string, env: Record<string, string>) => {
91 const command = await $.env.get('SIDECAR_CMD')
92 if (command === undefined || command.trim().length === 0) throw new Error('沒有設 SIDECAR_CMD')
93 const result = await $.process.run(splitArgv(command), { stdin: cmdStdin(system, payload), env, timeoutMs: 300000 })
94 if (result.exitCode !== 0) throw new Error(`${command} 失敗(exit ${result.exitCode}):${clip((result.stderr || result.stdout).trim(), 200)}`)
95 const text = result.stdout.trim()
96 if (text.length === 0) throw new Error(`${command} 沒有輸出任何內容`)
97 return { text, source: `cmd:${command}` }
98}
99
100const askHttp = async ($: EngineInterface, system: string, payload: string, model: string | undefined) => {
101 const url = (await $.env.get('SIDECAR_PROXY')) ?? DEFAULT_PROXY
102 const chosen = model ?? (await $.env.get('SIDECAR_MODEL')) ?? DEFAULT_HTTP_MODEL
103 const request = $.http.fetch(url, { method: 'POST', headers: httpHeaders(await apiKeyOf($)), body: httpBody(chosen, system, payload) })
104 // $.http.fetch takes no timeout; without this a stalled relay holds the chain before the Claude fallback.
105 const timedOut = $.clock.sleep(HTTP_TIMEOUT_MS).then(() => ({ status: 0, ok: false, headers: {}, text: `timed out after ${HTTP_TIMEOUT_MS}ms` }))
106 const response = await Promise.race([request, timedOut])
107 if (!response.ok) throw new Error(`HTTP ${response.status}:${clip(response.text.trim(), 200)}`)
108 return { text: httpReplyText(response.text), source: `http:${chosen}` }
109}
110
111// $.model.complete resolves a result object (2.1.288): a provider failure is an arm, not a rejection.
112const askFallback = async ($: EngineInterface, system: string, payload: string) => {
113 const model = (await $.env.get('WW_MODEL')) ?? DEFAULT_FALLBACK_MODEL
114 const result = await $.model.complete({ model, system, prompt: payload, maxTokens: 1500 })
115 if (!result.isAnswered) throw new Error(`claude:${model} 沒有回答(${result.reason})`)
116 return { text: result.text.trim(), source: `claude:${model}` }
117}
118
119// cmd, then http, then Claude's own model; SIDECAR_SOURCE pins one of the first two.
120const ask = async ($: EngineInterface, system: string, payload: string, options: AskOptions = {}): Promise<Answer> => {
121 const source = sourceOf(await $.env.get('SIDECAR_SOURCE'))
122 const chain = source === 'cmd' ? ['cmd'] : source === 'http' ? ['http'] : ['cmd', 'http']
123 const reasons: string[] = []
124 for (const step of chain) {
125 try {
126 const answer = step === 'cmd' ? await askCmd($, system, payload, options.cmdEnv ?? {}) : await askHttp($, system, payload, options.httpModel)
127 return { ...answer, fallback: reasons.length > 0 ? reasons.join(';') : null }
128 } catch (err) {
129 reasons.push(String(err instanceof Error ? err.message : err))
130 }
131 }
132 const answer = await askFallback($, system, payload)
133 return { ...answer, fallback: reasons.join(';') }
134}
135
136const askRecapOnce = async ($: EngineInterface, apiKey: string | null, model: string, messages: SessionMessage[], budget: number) => {
137 const url = (await $.env.get('SIDECAR_PROXY')) ?? DEFAULT_PROXY
138 const request = $.http.fetch(url, { method: 'POST', headers: httpHeaders(apiKey), body: recapBody(model, recapInput(messages, budget)) })
139 // $.http.fetch takes no timeout, so a stalled relay would otherwise hold the recap forever.
140 const timedOut = $.clock.sleep(RECAP_TIMEOUT_MS).then(() => ({ status: 0, ok: false, headers: {}, text: `timed out after ${RECAP_TIMEOUT_MS}ms` }))
141 const response = await Promise.race([request, timedOut])
142 return { ...response, model }
143}
144
145type RecapAsked = Awaited<ReturnType<typeof askRecapOnce>>
146
147const recapFieldsOf = (got: RecapAsked, reasons: string[]) => {
148 if (!got.ok) {
149 reasons.push(`${got.model} HTTP ${got.status}: ${clip(got.text.trim(), 160)}`)
150 return null
151 }
152 let reply = ''
153 try {
154 reply = httpReplyText(got.text)
155 } catch {
156 // An empty or non-JSON body counts as unparseable so the fallback still runs.
157 }
158 const fields: RecapFields | null = parseRecap(reply)
159 if (fields === null) reasons.push(`${got.model} 回了無法解析的內容`)
160 return fields
161}
162
163// Luna first; then Groq, with one retry at half the input when its per-minute token limit
164// refuses the size. A null answer leaves Collie on its "你:<prompt>" line.
165const askRecap = async ($: EngineInterface, apiKey: string | null, messages: SessionMessage[]) => {
166 const reasons: string[] = []
167 const primary = await askRecapOnce($, apiKey, RECAP_MODEL, messages, RECAP_INPUT_BUDGET)
168 const fromPrimary = recapFieldsOf(primary, reasons)
169 if (fromPrimary !== null) return { fields: fromPrimary, model: RECAP_MODEL, reasons }
170 let fallback = await askRecapOnce($, apiKey, RECAP_FALLBACK_MODEL, messages, RECAP_INPUT_BUDGET)
171 if (!fallback.ok && isTooLarge(fallback.status, fallback.text)) fallback = await askRecapOnce($, apiKey, RECAP_FALLBACK_MODEL, messages, RECAP_INPUT_BUDGET / 2)
172 const fromFallback = recapFieldsOf(fallback, reasons)
173 return fromFallback !== null ? { fields: fromFallback, model: RECAP_FALLBACK_MODEL, reasons } : { fields: null, model: null, reasons }
174}
175
176const recapNow = async ($: EngineInterface, runner: RecapRunner, onWrite: (record: RecapRecord) => void) => {
177 if (runner.running) return
178 runner.running = true
179 try {
180 if ((await $.session.surfaces()).length === 0) return
181 if ((await $.prompt.read()).text.trim().length > 0) return
182 const messages = await $.session.messages()
183 const fingerprint = fingerprintOf(messages)
184 if (fingerprint === runner.lastFingerprint) return
185 const home = await $.env.get('HOME')
186 const sessionId = await $.session.id()
187 const path = home === undefined ? null : recapPath(home, sessionId)
188 if (home === undefined || path === null) return
189 runner.lastRunAt = await $.clock.now()
190 const answer = await askRecap($, await apiKeyOf($), messages)
191 if (answer.fields === null || answer.model === null) {
192 $.ui.log(`recap failed: ${JSON.stringify(answer.reasons)}`, { to: 'debug' })
193 return
194 }
195 const record: RecapRecord = { version: 1, sessionId, at: await $.clock.now(), model: answer.model, ...answer.fields }
196 await $.fs.write(path, JSON.stringify(record))
197 runner.lastFingerprint = fingerprint
198 onWrite(record)
199 $.ui.invalidate('ui.render')
200 } catch (error) {
201 $.ui.log(`recap failed: ${JSON.stringify(String(error))}`, { to: 'debug' })
202 } finally {
203 runner.running = false
204 }
205}
206
207const yskLog = async ($: EngineInterface, entry: Record<string, unknown>) => {
208 try {
209 const home = await $.env.get('HOME')
210 if (home === undefined) return
211 const path = `${home}/${YSK_LOG}`
212 const before = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
213 await $.fs.write(path, withLogLine(before, { at: Date.now(), ...entry }))
214 } catch {
215 return
216 }
217}
218
219const settleYsk = async ($: EngineInterface, shown: YskShown, answer: YskAnswer, onSettled: (shown: YskShown) => void) => {
220 await yskLog($, { event: 'answered', sessionId: shown.sessionId, answer, tag: shown.item.tag, title: shown.item.title })
221 onSettled({ ...shown, isUnread: false, answer })
222 $.ui.invalidate('ui.render')
223}
224
225const openPane = async ($: EngineInterface, title: string) => {
226 $.ui.invalidate('ui.render')
227 await $.ui.open({ id: PANE, title })
228}
229
230const yskNow = async ($: EngineInterface, runner: YskRunner, onFound: (shown: YskShown) => void) => {
231 if (runner.running) return
232 runner.running = true
233 const started = Date.now()
234 const sessionId = await $.session.id()
235 try {
236 if ((await $.session.surfaces()).length === 0) return
237 if ((await $.prompt.read()).text.trim().length > 0) return
238 const messages = await $.session.messages()
239 const fingerprint = fingerprintOf(messages)
240 if (fingerprint === runner.lastFingerprint) return
241 runner.lastFingerprint = fingerprint
242 const payload = yskPayload(messages, runner.seen, YSK_INPUT_BUDGET)
243 const home = await $.env.get('HOME')
244 const payloadPath = home === undefined ? null : yskPayloadPath(home, sessionId, messages.length)
245 if (payloadPath !== null) await $.fs.write(payloadPath, `${YSK_PROMPT}\n\n---\n\n${payload}`).catch(() => undefined)
246 const effort = (await $.env.get('YSK_WEB_EFFORT')) ?? YSK_WEB_EFFORT
247 const answer = await ask($, YSK_PROMPT, payload, { httpModel: await $.env.get('YSK_MODEL'), cmdEnv: { SIDECAR_WEB_EFFORT: effort } })
248 const seconds = Number(((Date.now() - started) / 1000).toFixed(1))
249 const parsed = parseYsk(answer.text)
250 const base = {
251 event: 'checked', sessionId, messages: messages.length, source: answer.source, effort, fallback: answer.fallback,
252 chars: payload.length, seconds, payload: payloadPath, reply: answer.text.slice(0, YSK_REPLY_KEPT),
253 }
254 if (parsed === null) return await yskLog($, { ...base, outcome: 'parse_failed' })
255 if (parsed.item === null) return await yskLog($, { ...base, outcome: 'none' })
256 if (isRepeat(parsed.item.line, runner.seen)) return await yskLog($, { ...base, outcome: 'repeat' })
257 runner.seen = [...runner.seen, parsed.item.line].slice(-YSK_SEEN_KEPT)
258 runner.promptsSince = 0
259 onFound({ sessionId, item: parsed.item, isUnread: true, answer: null, source: answer.source })
260 await yskLog($, { ...base, outcome: 'shown', tag: parsed.item.tag, title: parsed.item.title })
261 $.ui.invalidate('ui.render')
262 } catch (error) {
263 await yskLog($, { event: 'checked', sessionId, outcome: 'error', reason: String(error instanceof Error ? error.message : error) })
264 } finally {
265 runner.running = false
266 }
267}
268
269export function register(on: On) {
270 let state: State = { status: 'idle' }
271 let view: View = 'retell'
272 let latest: RecapRecord | null = null
273 let flagged: YskShown | null = null
274 const recapRunner: RecapRunner = { pending: null, lastRunAt: null, lastFingerprint: '', running: false }
275 const yskRunner: YskRunner = { pending: null, running: false, lastFingerprint: '', seen: [], promptsSince: 0 }
276
277 const cancelPending = () => {
278 recapRunner.pending?.cancel()
279 recapRunner.pending = null
280 yskRunner.pending?.cancel()
281 yskRunner.pending = null
282 }
283
284 const settled = (shown: YskShown) => {
285 flagged = shown
286 }
287
288 on('turn.start', ($, e, next) => {
289 cancelPending()
290 return next(e)
291 })
292
293 // Both the recap and the You-should-know check run once the main agent stops and waits for
294 // the person: that is when they read.
295 on('turn.complete', async ($, e, next) => {
296 const result = await next(e)
297 if (e.agentId !== undefined) return result
298 cancelPending()
299 const now = await $.clock.now()
300 recapRunner.pending = $.clock.after(delayFor(now, recapRunner.lastRunAt), () => {
301 recapRunner.pending = null
302 void recapNow($, recapRunner, (record) => {
303 latest = record
304 })
305 })
306 yskRunner.pending = $.clock.after(YSK_IDLE_MS, () => {
307 yskRunner.pending = null
308 void yskNow($, yskRunner, (shown) => {
309 flagged = shown
310 })
311 })
312 return result
313 })
314
315 on('prompt.submit', async ($, e, next) => {
316 if (flagged !== null && flagged.isUnread) {
317 yskRunner.promptsSince += 1
318 if (yskRunner.promptsSince >= YSK_CLEAR_AFTER_PROMPTS) {
319 await settleYsk($, flagged, 'ignored', () => {
320 flagged = null
321 })
322 }
323 }
324 return next(e)
325 })
326
327 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
328 if (e.props.hasSurvey || e.surface !== 'terminal') return next(e)
329 const { Box, Button, Text } = await $.ui.resolve(e)
330 const redraw = () => $.ui.invalidate('ui.render')
331
332 const readOverride = async (name: string, fallback: string) => {
333 const home = await $.env.get('HOME')
334 if (home === undefined) return fallback
335 const path = `${home}/${PROMPT_DIR}/${name}.md`
336 if (!(await $.fs.exists(path))) return fallback
337 const text = (await $.fs.read(path)).trim()
338 return text.length > 0 ? text : fallback
339 }
340
341 const cachePath = async () => {
342 const home = await $.env.get('HOME')
343 return home === undefined ? null : `${home}/${CACHE_FILE}`
344 }
345
346 const readCache = async () => {
347 const path = await cachePath()
348 if (path === null || !(await $.fs.exists(path))) return {}
349 return parseCache(await $.fs.read(path))
350 }
351
352 const writeCache = async (key: string, answer: string, label: string, source: string) => {
353 const path = await cachePath()
354 if (path === null) return
355 try {
356 const entries = withEntry(await readCache(), key, { answer, label, source, at: Date.now() / 1000 })
357 await $.fs.write(path, JSON.stringify(entries))
358 } catch {
359 return
360 }
361 }
362
363 const fromCache = async (key: string) => lookup(await readCache(), key)
364
365 const run = (mode: Mode) => {
366 const label = mode === 'lost' ? '跟丟了 · 整段' : '白話 · 往回 1 turn'
367 view = 'retell'
368 void openPane($, mode === 'lost' ? '跟丟了' : '白話')
369 if (state.status === 'busy') return
370 state = { status: 'busy', label }
371 redraw()
372 const started = Date.now()
373 void (async () => {
374 try {
375 const messages = await $.session.messages()
376 const picked = mode === 'lost' ? messages : lastTurns(messages, 1)
377 const cachePicked = mode === 'lost' ? messages : lastCacheTurns(messages, 1)
378 const transcript = transcriptOf(picked)
379 const system = mode === 'lost' ? await readOverride('wait-what', DEFAULT_WAIT_WHAT) : await readOverride('plain', DEFAULT_PLAIN)
380 const payload = `${PAYLOAD_HEAD}\n\n${transcript}`
381 const key = await sharedKeyFor(mode, cacheMessagesOf(cachePicked))
382 const hit = await fromCache(key)
383 if (hit !== null) {
384 state = { status: 'done', label, text: hit.answer, seconds: '0.0', source: hit.source, chars: transcript.length, fallback: null, cached: true }
385 redraw()
386 return
387 }
388 const answer = await ask($, system, payload)
389 const seconds = ((Date.now() - started) / 1000).toFixed(1)
390 state = { status: 'done', label, text: answer.text, seconds, source: answer.source, chars: transcript.length, fallback: answer.fallback, cached: false }
391 await writeCache(key, answer.text, mode === 'lost' ? '跟丟了' : '白話', answer.source)
392 } catch (err) {
393 state = { status: 'error', label, text: String(err instanceof Error ? err.message : err) }
394 }
395 redraw()
396 })()
397 }
398
399 const sessionId = await $.session.id()
400 const recap = latest !== null && latest.sessionId === sessionId ? latest : null
401 const recapLine = recap === null ? null : `recap · ${recap.now}${recap.next.length > 0 ? ` → ${recap.next}` : ''}`
402 const lit = flagged !== null && flagged.sessionId === sessionId && flagged.isUnread ? flagged : null
403
404 const openYsk = () => {
405 view = 'ysk'
406 void openPane($, lit !== null ? lit.item.tag : '該知道')
407 if (lit !== null) void settleYsk($, lit, 'opened', settled)
408 }
409
410 return (
411 <Box flexDirection="column">
412 <Box flexDirection="row" columnGap={1}>
413 <Button key="ww:plain" label="白話" autoFocus onPress={() => run('plain')} />
414 <Button key="ww:lost" label="跟丟了" onPress={() => run('lost')} />
415 <Button key="ww:ysk" label={lit !== null ? '該知道 ●' : '該知道'} variant={lit !== null ? 'primary' : undefined} dimColor={lit === null} onPress={openYsk} />
416 </Box>
417 {recapLine !== null ? <Text dimColor wrap="truncate-end">{recapLine}</Text> : null}
418 {await next(e)}
419 </Box>
420 )
421 })
422
423 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
424 const { Box, Button, Markdown, Text } = await $.ui.resolve(e)
425 const width = e.props.bodyColumns
426
427 if (view === 'ysk') {
428 const shown = flagged !== null && flagged.sessionId === (await $.session.id()) ? flagged : null
429 if (shown === null) {
430 return <Text dimColor wrap="wrap">目前沒有要提醒的事。main 每次停下來等你時,背景會檢查一次。</Text>
431 }
432 const answered = shown.answer === 'helpful' ? '有幫助' : shown.answer === 'not_relevant' ? '不相關' : null
433 return (
434 <Box flexDirection="column" width={width}>
435 <Text bold wrap="wrap">{`${shown.item.tag} · ${shown.item.title}`}</Text>
436 <Markdown text={shown.item.explain} />
437 <Text dimColor wrap="truncate-end">{`來源 ${shown.source}`}</Text>
438 {answered !== null ? (
439 <Text dimColor>{`已記錄:${answered}`}</Text>
440 ) : (
441 <Box flexDirection="row" columnGap={1}>
442 <Button key="ysk:helpful" label="有幫助" onPress={() => void settleYsk($, shown, 'helpful', settled)} />
443 <Button key="ysk:not-relevant" label="不相關" onPress={() => void settleYsk($, shown, 'not_relevant', settled)} />
444 </Box>
445 )}
446 </Box>
447 )
448 }
449
450 return state.status === 'idle' ? <Text dimColor>按「白話」或「跟丟了」開始重講。</Text>
451 : state.status === 'busy' ? <Text dimColor>{`${state.label} · 重講中…`}</Text>
452 : state.status === 'error' ? <Text color="red" wrap="wrap">{`${state.label} · 失敗:${state.text}`}</Text>
453 : (
454 <Box flexDirection="column" width={width}>
455 <Text dimColor wrap="wrap">{state.cached ? `${state.label} (快取命中 · 來源 ${state.source})` : `${state.label} (送出 ${state.chars.toLocaleString()} 字 → ${state.source} · ${state.seconds}s)`}</Text>
456 {state.fallback !== null ? <Text dimColor color="yellow" wrap="wrap">{`退回原因:${state.fallback}`}</Text> : null}
457 <Markdown text={state.text.slice(0, MARKDOWN_MAX)} />
458 </Box>
459 )
460 })
461}
462hooks/cache.ts 48 lines1export const CACHE_FILE = '.cache/cc-sidecar-waitwhat.json'
2export const CACHE_LIMIT = 200
3
4export type CacheEntry = { answer: string; label: string; source: string; at: number }
5export type CacheMap = Record<string, CacheEntry>
6export type CacheMode = 'plain' | 'lost'
7export interface CacheMessage { role: 'user' | 'assistant'; text: string }
8
9const hex = (bytes: ArrayBuffer) =>
10 Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, '0')).join('')
11
12const normalizeText = (text: string) =>
13 text.replace(/\r\n?/g, '\n').replace(/^[ \t\n\r]+|[ \t\n\r]+$/g, '')
14
15export const sharedKeyFor = async (
16 mode: CacheMode,
17 messages: ReadonlyArray<CacheMessage>
18): Promise<string> => {
19 const normalized = messages
20 .map(({ role, text }) => [role, normalizeText(text)] as const)
21 .filter(([, text]) => text.length > 0)
22 const payload = JSON.stringify([mode, normalized])
23 return hex(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload)))
24}
25
26export const parseCache = (text: string): CacheMap => {
27 try {
28 const parsed: unknown = JSON.parse(text)
29 return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as CacheMap) : {}
30 } catch {
31 return {}
32 }
33}
34
35export const lookup = (entries: CacheMap, key: string) => {
36 const entry = entries[key]
37 return entry !== undefined && typeof entry.answer === 'string' && entry.answer.length > 0 ? entry : null
38}
39
40export const withEntry = (entries: CacheMap, key: string, entry: CacheEntry): CacheMap => {
41 const merged = { ...entries, [key]: entry }
42 if (Object.keys(merged).length <= CACHE_LIMIT) return merged
43 const kept = Object.entries(merged)
44 .sort(([, a], [, b]) => (a.at ?? 0) - (b.at ?? 0))
45 .slice(-CACHE_LIMIT)
46 return Object.fromEntries(kept)
47}
48hooks/model.ts 77 lines1export const DEFAULT_PROXY = 'http://127.0.0.1:8317/v1/chat/completions'
2export const DEFAULT_HTTP_MODEL = 'gemini-3.8-flash-high'
3export const DEFAULT_FALLBACK_MODEL = 'haiku'
4export const SOURCES = ['auto', 'cmd', 'http'] as const
5
6export type Source = (typeof SOURCES)[number]
7
8export const sourceOf = (value: string | undefined): Source =>
9 SOURCES.includes(value as Source) ? (value as Source) : 'auto'
10
11export const splitArgv = (command: string) => {
12 const argv: string[] = []
13 let current = ''
14 let quote: string | null = null
15 let pending = false
16 for (const ch of command) {
17 if (quote !== null) {
18 if (ch === quote) quote = null
19 else current += ch
20 pending = true
21 } else if (ch === '"' || ch === "'") {
22 quote = ch
23 pending = true
24 } else if (/\s/.test(ch)) {
25 if (pending) argv.push(current)
26 current = ''
27 pending = false
28 } else {
29 current += ch
30 pending = true
31 }
32 }
33 if (pending) argv.push(current)
34 return argv
35}
36
37export const cmdStdin = (system: string, payload: string) => `${system}\n\n---\n\n${payload}`
38
39export const firstApiKey = (yaml: string) => {
40 let inside = false
41 for (const line of yaml.split('\n')) {
42 if (/^api-keys:\s*$/.test(line)) {
43 inside = true
44 continue
45 }
46 if (!inside) continue
47 const entry = /^\s*-\s*"?([^"\s]+)"?\s*$/.exec(line)
48 if (entry !== null) return entry[1] ?? null
49 if (line.trim().length > 0 && !line.startsWith(' ')) break
50 }
51 return null
52}
53
54export const httpBody = (model: string, system: string, payload: string) =>
55 JSON.stringify({
56 model,
57 messages: [
58 { role: 'system', content: system },
59 { role: 'user', content: payload },
60 ],
61 temperature: 0.3,
62 })
63
64export const httpHeaders = (apiKey: string | null) => ({
65 'Content-Type': 'application/json',
66 ...(apiKey !== null ? { Authorization: `Bearer ${apiKey}` } : {}),
67})
68
69export const httpReplyText = (text: string) => {
70 const parsed = JSON.parse(text) as { choices?: { message?: { content?: unknown } }[] }
71 const content = parsed.choices?.[0]?.message?.content
72 if (typeof content !== 'string' || content.trim().length === 0) throw new Error('回應裡沒有 choices[0].message.content')
73 return content.trim()
74}
75
76export const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max)}…` : text)
77hooks/prompts.ts 31 lines1export const DEFAULT_WAIT_WHAT = `使用者跟丟了。你在讀一段對話紀錄,要重講一次讓他跟回來。
2
3你沒有參與這段對話,你是讀紀錄的第三方——所以只能講紀錄裡有的事,不能假裝知道紀錄沒寫的東西。
4
5重講不是把最後一則壓縮。讓他跟丟的通常不只最後一則。
6
7規則:
8
91. 先一句話講結論,再補他缺的前提與來龍去脈。
102. 從頭敘事:先講這個 session 在做什麼,再進正題。
113. 每個內部代號(實驗名、變數名、票號、縮寫)第一次出現就解釋它是什麼。紀錄裡查不到定義就直說查不到。
124. 這題有形狀就形狀先行,散文只補「為什麼」:呼叫關係用樹狀、檔案職責用檔案樹、邏輯或狀態流用虛擬碼、什麼變了用 + / - 列前後差異。四類都不沾才用散文。
135. 目標是更短「而且」更清楚。只砍字不補前提等於沒重講。
146. 用使用者自己的說法,不要換成你的同義詞。
157. 用跟紀錄相同的語言回答。一句話只講一件事。
168. 不要開場白,直接開始重講。`
17
18export const DEFAULT_PLAIN = `使用者剛看完一段技術說明,說看不懂。你的工作是重講一次。
19
20重講不是翻譯。照著換同義詞等於沒做事,他一樣看不懂。
21
22規則:
23
241. 大幅砍。目標是原文的四到六成長度。砍掉次要選項、重複論述、每個方案的完整分析。
252. 每個內部代號(設定名、變數名、專案代號、縮寫)第一次出現,先用一句話說它是什麼、它做什麼。
263. 抽象規則改成具體說法——與其寫「X 禁止 Y」,不如寫出它實際上在說什麼話。
274. 原文在要人做決定時,直接給一個建議。不要重列所有選項跟各自的後果。
285. 結尾補一句原文沒有的總結,用一句話說穿整件事。
296. 用跟原文相同的語言回答。一句話只講一件事。先講結論,再補細節。
307. 不要開場白,不要「以下是」,直接輸出重講後的內容。`
31hooks/recap.ts 145 lines1import type { SessionMessage } from 'claude-code'
2import { clip } from './model.ts'
3import { cleanText } from './turns.ts'
4
5export const RECAP_DIR = '.cache/cc-recap'
6// Luna wrote now/next right on 10 of 10 audited recaps where gpt-oss got 8, and a week of
7// recaps costs about 0.05% of the ChatGPT Pro cycle. Groq stays as the fallback because on
8// gpt-oss alone the recap filled about 94% of that model's daily Groq cap.
9export const RECAP_MODEL = 'gpt-6-luna'
10export const RECAP_FALLBACK_MODEL = 'groq-gpt-oss-120b'
11// Luna answered in 2.6–4.8s; past this the fallback answers instead of the band waiting on it.
12export const RECAP_TIMEOUT_MS = 10000
13export const RECAP_IDLE_MS = 5000
14export const RECAP_MIN_GAP_MS = 60000
15export const RECAP_INPUT_BUDGET = 5000
16// The user's own messages name the goal far back: 1500 tokens of them covered dozens of
17// turns where the whole 5000-token tail reached only the last 5–9.
18export const RECAP_USER_SHARE = 0.3
19export const RECAP_MAX_TOKENS = 600
20
21// Derived from the away-summary prompt in the Claude Code 2.1.281 binary; the JSON shape is
22// what Collie's list row, pane screen and push all read, so goal/now/next are a contract.
23export const RECAP_PROMPT =
24 'Summarize where this coding-agent session stands as JSON with exactly these keys: ' +
25 '"goal", "now", "next", "waiting_on_user_decision". ' +
26 'The input has two parts. <user_messages> holds the user\'s own messages, the most recent ones, oldest first; older ones may be omitted. ' +
27 '<recent> holds the latest part of the conversation with the assistant\'s replies and tool calls. ' +
28 '"goal" (under 15 words): the overall objective of the whole session, named at the level of the project or problem the user is working on. ' +
29 'Base it on <user_messages>, not only on the latest step. ' +
30 '"now" (under 15 words): what is happening right now or what it is waiting on, from <recent>. ' +
31 '"next" (under 15 words): the one next action and who takes it. ' +
32 '"waiting_on_user_decision" (boolean): true only when the assistant\'s latest reply asks the user to choose, approve, answer a question, ' +
33 'or perform a specific action before the work can continue. It is false when the assistant has finished and is only idle awaiting any new request, ' +
34 'when it is still working or waiting on a background job, or when the reply only lists limitations or things not done. ' +
35 'Items the assistant lists as not done, unverified, or out of scope are limitations, not current work: do not report them as now or next ' +
36 'unless the assistant says it will do them. ' +
37 'Refer to the assistant in the first person ("我" / "I") and to the user in the second person ("你" / "you"). ' +
38 'No markdown, no extra keys. Skip root-cause narrative, fix internals, secondary to-dos, and em-dash tangents. ' +
39 'Write the values in the same language as the conversation; Chinese means Traditional Chinese (繁體中文), never Simplified. ' +
40 'Output only the JSON object.'
41
42export type RecapFields = { goal: string; now: string; next: string; waiting: boolean }
43export type RecapRecord = RecapFields & { version: 1; sessionId: string; at: number; model: string }
44
45// Groq meters its 8,000 TPM on its own tokenizer; this over-counts CJK and prose alike, so a
46// tail under the budget stays under the real limit without a tokenizer in the plugin.
47export const estimateTokens = (text: string) => {
48 let cjk = 0
49 for (const ch of text) if (/[ -鿿가--]/.test(ch)) cjk += 1
50 return cjk + Math.ceil((text.length - cjk) / 3)
51}
52
53const lineOf = (message: SessionMessage) => {
54 if (message.role === 'user' && (message.toolResults?.length ?? 0) > 0) return ''
55 const head = message.role === 'user' ? 'User' : 'Assistant'
56 const text = clip(cleanText(message.text), 3000)
57 const tools = message.toolUses.map((use) => ` [${use.tool}] ${clip((use.text ?? '').replace(/\s+/g, ' '), 200)}`)
58 return [text.length > 0 ? `${head}: ${text}` : null, ...tools].filter((line) => line !== null).join('\n')
59}
60
61export const tailWithin = (messages: SessionMessage[], budget: number) => {
62 const kept: string[] = []
63 let used = 0
64 for (let i = messages.length - 1; i >= 0; i -= 1) {
65 const line = lineOf(messages[i]!)
66 if (line.length === 0) continue
67 const cost = estimateTokens(line)
68 if (used + cost > budget) break
69 kept.push(line)
70 used += cost
71 }
72 return kept.toReversed().join('\n\n')
73}
74
75const isHumanTurn = (message: SessionMessage) => {
76 if (message.role !== 'user' || (message.toolResults?.length ?? 0) > 0) return false
77 const text = cleanText(message.text)
78 return text.length > 0 && !text.startsWith('<task-notification')
79}
80
81export const userMessagesWithin = (messages: SessionMessage[], budget: number) => {
82 const kept: string[] = []
83 let used = 0
84 const human = messages.filter(isHumanTurn)
85 for (let i = human.length - 1; i >= 0; i -= 1) {
86 const line = `- ${clip(cleanText(human[i]!.text).replace(/\s+/g, ' '), 300)}`
87 const cost = estimateTokens(line)
88 if (used + cost > budget) break
89 kept.push(line)
90 used += cost
91 }
92 const omitted = human.length - kept.length
93 return [...(omitted > 0 ? [`(… ${omitted} earlier messages omitted …)`] : []), ...kept.toReversed()].join('\n')
94}
95
96export const recapInput = (messages: SessionMessage[], budget: number) => {
97 const userBudget = Math.floor(budget * RECAP_USER_SHARE)
98 return `<user_messages>\n${userMessagesWithin(messages, userBudget)}\n</user_messages>\n\n<recent>\n${tailWithin(messages, budget - userBudget)}\n</recent>`
99}
100
101export const fingerprintOf = (messages: SessionMessage[]) => {
102 const last = messages.at(-1)
103 return last === undefined ? '' : `${messages.length}:${last.role}:${last.text.length}:${last.toolUses.length}`
104}
105
106type RecapJson = { goal?: string | null; now?: string | null; next?: string | null; waiting_on_user_decision?: unknown }
107
108// String() rather than trusting the declared type: the model may answer a number or an object.
109const textOf = (value: string | null | undefined) => String(value ?? '').trim()
110
111export const parseRecap = (reply: string): RecapFields | null => {
112 const match = /\{[\s\S]*\}/.exec(reply)
113 if (match === null) return null
114 try {
115 const parsed: RecapJson = JSON.parse(match[0])
116 const fields = { goal: textOf(parsed.goal), now: textOf(parsed.now), next: textOf(parsed.next), waiting: parsed.waiting_on_user_decision === true }
117 return fields.now.length > 0 ? fields : null
118 } catch {
119 return null
120 }
121}
122
123export const recapBody = (model: string, transcript: string) =>
124 JSON.stringify({
125 model,
126 messages: [
127 { role: 'system', content: RECAP_PROMPT },
128 { role: 'user', content: transcript },
129 ],
130 max_tokens: RECAP_MAX_TOKENS,
131 reasoning_effort: 'low',
132 // gpt-oss at 0.3 drifted into Simplified Chinese about one reply in eight; at 0 it stayed
133 // Traditional. Luna was audited without a temperature, so none is sent to it.
134 ...(model === RECAP_FALLBACK_MODEL ? { temperature: 0 } : {}),
135 })
136
137export const isTooLarge = (status: number, text: string) => status === 413 || /request too large/i.test(text)
138
139// The next run waits out the idle delay and the per-session gap, whichever ends later.
140export const delayFor = (now: number, lastRunAt: number | null) =>
141 lastRunAt === null ? RECAP_IDLE_MS : Math.max(RECAP_IDLE_MS, lastRunAt + RECAP_MIN_GAP_MS - now)
142
143export const recapPath = (home: string, sessionId: string) =>
144 /^[A-Za-z0-9-]+$/.test(sessionId) ? `${home}/${RECAP_DIR}/${sessionId}.json` : null
145hooks/turns.ts 56 lines1import type { SessionMessage } from 'claude-code'
2import type { CacheMessage } from './cache.ts'
3import { clip } from './model.ts'
4
5export const cleanText = (text: string) =>
6 text
7 .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
8 .replace(/<command-(name|message|args)>[\s\S]*?<\/command-\1>/g, '')
9 .trim()
10
11export const cacheMessagesOf = (messages: SessionMessage[]): CacheMessage[] =>
12 messages.flatMap((message): CacheMessage[] => {
13 if (message.role !== 'user' && message.role !== 'assistant') return []
14 if (message.role === 'user' && (message.toolResults?.length ?? 0) > 0) return []
15 const text = cleanText(message.text)
16 return text.length > 0 ? [{ role: message.role, text }] : []
17 })
18
19const startsTurn = (message: SessionMessage) =>
20 message.role === 'user' && message.text.trim().length > 0 && (message.toolResults?.length ?? 0) === 0
21
22const startsCacheTurn = (message: SessionMessage) =>
23 message.role === 'user' && cleanText(message.text).length > 0 && (message.toolResults?.length ?? 0) === 0
24
25export const lastTurns = (messages: SessionMessage[], turns: number) => {
26 let seen = 0
27 for (let i = messages.length - 1; i >= 0; i -= 1) {
28 if (startsTurn(messages[i]!)) {
29 seen += 1
30 if (seen === turns) return messages.slice(i)
31 }
32 }
33 return messages
34}
35
36export const lastCacheTurns = (messages: SessionMessage[], turns: number) => {
37 let seen = 0
38 for (let i = messages.length - 1; i >= 0; i -= 1) {
39 if (startsCacheTurn(messages[i]!)) {
40 seen += 1
41 if (seen === turns) return messages.slice(i)
42 }
43 }
44 return messages
45}
46
47const lineOf = (message: SessionMessage) => {
48 const head = message.role === 'user' ? 'USER' : 'ASSISTANT'
49 const tools = message.toolUses.map((use) => ` [${use.tool}] ${clip((use.text ?? '').replace(/\s+/g, ' '), 200)}`)
50 const text = message.text.trim()
51 return [text.length > 0 ? `${head}: ${text}` : null, ...tools].filter((line) => line !== null).join('\n')
52}
53
54export const transcriptOf = (messages: SessionMessage[]) =>
55 messages.map(lineOf).filter((line) => line.length > 0).join('\n\n')
56hooks/ysk.ts 142 lines1import type { SessionMessage } from 'claude-code'
2import { estimateTokens } from './recap.ts'
3import { cleanText } from './turns.ts'
4
5export const YSK_LOG = '.cache/cc-ysk-log.jsonl'
6export const YSK_LOG_LINES = 2000
7// Each check's exact input is kept beside the log so a review can judge what the model missed,
8// not only what it flagged.
9export const YSK_PAYLOAD_DIR = '.cache/cc-ysk-payloads'
10export const YSK_REPLY_KEPT = 4000
11// ChatGPT web takes standard, extended and max (heavy and unknown names are refused, probed
12// 2026-10-03); this check needs judgment more than speed, so it asks above the web default.
13export const YSK_WEB_EFFORT = 'extended'
14// About a third of a long session once tool output is cut to its ends (measured on 8 sessions
15// over 150K tokens on 2026-10-03), so most sessions go in whole and the longest lose their oldest part.
16export const YSK_INPUT_BUDGET = 60000
17export const YSK_IDLE_MS = 5000
18// The built-in mod drops an unopened suggestion after two prompts; past that it is stale.
19export const YSK_CLEAR_AFTER_PROMPTS = 2
20export const YSK_SEEN_KEPT = 16
21const EDGE = 300
22const TEXT_EDGE = 3000
23// The engine refuses a Markdown element over 10000 characters.
24export const MARKDOWN_MAX = 10000
25
26export type YskTag = 'You should know' | 'Heads up'
27export type YskItem = { tag: YskTag; line: string; title: string; explain: string }
28export type YskAnswer = 'opened' | 'helpful' | 'not_relevant' | 'ignored'
29export type YskShown = { sessionId: string; item: YskItem; isUnread: boolean; answer: YskAnswer | null; source: string }
30
31// Modelled on the built-in "You should know" mod's prompt (Claude Code 2.1.288): same bar,
32// same two tags, same skip rules; reworded, and asked for JSON so the band can parse it.
33export const YSK_PROMPT = [
34 'You watch a Claude Code session from the side. The input is the session so far: USER is the human, ASSISTANT is the main agent, [Tool] lines are its tool calls with their results cut to the ends.',
35 'Find at most one thing the human should really know about this session and very likely does not: something worth interrupting them for. Most of the time there is nothing, and then you say so.',
36 '',
37 'Skip a topic when:',
38 '- the human is already discussing it, asked about it, replied to it, or it was the main point of an answer;',
39 '- it is listed below as already suggested;',
40 '- you are not confident it is true (if it is consequential but uncertain, say where you are unsure);',
41 '- it is merely interesting. Missing it must cost something real: money, time, wasted work, a wrong result, or a decision they are in the middle of.',
42 'A decision or detail the assistant mentioned in passing, inside a long answer or a long run of tool calls, does count when it is consequential: people do not read everything the assistant writes.',
43 'Do not chase docs or pages the assistant fetched along the way. The bar is very high; when in doubt, suggest nothing.',
44 '',
45 'Tag the one you keep:',
46 '- "You should know": how something works (a system, a concept, a design) that deeply matters to their work.',
47 '- "Heads up": about the work in this session: a decision the assistant made, something it did not highlight, or a result that may be off, with an immediate cost if missed.',
48 'If neither tag reads naturally, it does not clear the bar.',
49 '',
50 'The human may have lost track of the conversation. Make the reminder understandable on its own, without making it a recap:',
51 '- Start with only the session context needed to understand this reminder: what you are trying to do or the relevant recent decision. Then state what was found and why it matters to you. Do not invent missing context.',
52 '- Use everyday language. Replace internal labels and code identifiers with what they mean to the human; keep a technical term only when necessary and explain it briefly in the same sentence. In Chinese prose, translate explanatory jargon into plain Chinese instead of inserting English terms.',
53 '- Write explain as 2-4 short sentences in one paragraph, at most 200 Chinese characters or 70 words in other languages. No headings or bullet lists. Use less when it is enough.',
54 '- Do not recap the whole conversation, repeat the title, or list the investigation steps. Do not add a task or advice unless it is necessary for a decision you are making now. Keep any uncertainty explicit.',
55 '',
56 'Answer with one JSON object and nothing else:',
57 '{"tag": "You should know" | "Heads up" | "none", "line": "<the point in one sentence>", "title": "<the takeaway in 3-7 plain words, a statement, no question>", "explain": "<the short, self-contained paragraph described above: necessary session context, finding, and its impact>"}',
58 'With nothing to suggest answer {"tag": "none"}.',
59 'Write in the language of the conversation; Chinese means Traditional Chinese (繁體中文), never Simplified. Address the human as "you"; refer to the assistant only when needed, naturally in that language.',
60].join('\n')
61
62const edges = (text: string, edge: number) => (text.length <= edge * 2 ? text : `${text.slice(0, edge)} …(${text.length - edge * 2} chars cut)… ${text.slice(-edge)}`)
63
64const flat = (text: string) => text.replace(/\s+/g, ' ').trim()
65
66const inputOf = (input: Record<string, unknown>) => {
67 try {
68 return JSON.stringify(input)
69 } catch {
70 return ''
71 }
72}
73
74// Tool results ride on the assistant's toolUses, so the user rows that carry them are skipped.
75const blockOf = (message: SessionMessage) => {
76 if (message.role === 'user' && (message.toolResults?.length ?? 0) > 0) return ''
77 const text = cleanText(message.text)
78 const head = message.role === 'user' ? 'USER' : 'ASSISTANT'
79 const tools = message.toolUses.map((use) => {
80 const result = use.text === undefined ? '' : ` → ${edges(flat(use.text), EDGE)}`
81 return ` [${use.tool}] ${edges(flat(inputOf(use.input)), EDGE)}${result}`
82 })
83 return [text.length > 0 ? `${head}: ${edges(text, TEXT_EDGE)}` : null, ...tools].filter((line) => line !== null).join('\n')
84}
85
86export const yskTranscript = (messages: SessionMessage[], budget: number) => {
87 const kept: string[] = []
88 let used = 0
89 let isCut = false
90 for (let i = messages.length - 1; i >= 0; i -= 1) {
91 const block = blockOf(messages[i]!)
92 if (block.length === 0) continue
93 const cost = estimateTokens(block)
94 if (used + cost > budget) {
95 isCut = true
96 break
97 }
98 kept.push(block)
99 used += cost
100 }
101 const omitted = isCut ? '(… earlier part of the session omitted …)\n\n' : ''
102 return `${omitted}${kept.toReversed().join('\n\n')}`
103}
104
105export const yskPayload = (messages: SessionMessage[], seen: readonly string[], budget: number) => {
106 const already = seen.length > 0 ? seen.map((line) => `- ${line}`).join('\n') : '(none)'
107 return `<already_suggested>\n${already}\n</already_suggested>\n\n<session>\n${yskTranscript(messages, budget)}\n</session>`
108}
109
110type YskJson = { tag?: unknown; line?: unknown; title?: unknown; explain?: unknown }
111
112const textOf = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
113
114// null means the reply could not be read; { item: null } means the model chose to say nothing.
115export const parseYsk = (reply: string): { item: YskItem | null } | null => {
116 const match = /\{[\s\S]*\}/.exec(reply)
117 if (match === null) return null
118 let parsed: YskJson
119 try {
120 parsed = JSON.parse(match[0])
121 } catch {
122 return null
123 }
124 const tag = textOf(parsed.tag)
125 if (tag.toLowerCase() === 'none' || tag.length === 0) return { item: null }
126 if (tag !== 'You should know' && tag !== 'Heads up') return null
127 const item: YskItem = { tag, line: textOf(parsed.line), title: textOf(parsed.title), explain: textOf(parsed.explain).slice(0, MARKDOWN_MAX) }
128 return item.line.length > 0 && item.explain.length > 0 ? { item } : null
129}
130
131const normalised = (line: string) => line.toLowerCase().replace(/[\s\p{P}]+/gu, '')
132
133export const yskPayloadPath = (home: string, sessionId: string, messageCount: number) =>
134 /^[A-Za-z0-9-]+$/.test(sessionId) ? `${home}/${YSK_PAYLOAD_DIR}/${sessionId}-${messageCount}.txt` : null
135
136export const isRepeat =(line: string, seen: readonly string[]) => seen.some((one) => normalised(one) === normalised(line))
137
138export const withLogLine = (log: string, entry: Record<string, unknown>) => {
139 const lines = log.split('\n').filter((line) => line.length > 0)
140 return [...lines, JSON.stringify(entry)].slice(-YSK_LOG_LINES).join('\n') + '\n'
141}
142