SLOPSHOPPER

cc-mod-waitwhat

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…

newpanebandpromptmodelprocess
★ 1v0.8.2MITupdated 2026-10-04GGGODLIN/cc-mod-waitwhat
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-mod-waitwhat
│ ┃ waitwhat ✕ › fix the failing auth test and add an audit log call │ ┃ 按「白話」或「跟丟了」開始重講。 │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ [ 白話 ] [ 跟丟了 ] [ 該知道 ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ 白話 ] [ 跟丟了 ] [ 該知道 ] ⟨Claude Code's own drawing⟩
Pane · waitwhat
按「白話」或「跟丟了」開始重講。
README

cc-mod-waitwhat

在 Claude Code 提示框上方放三顆按鈕:重講它剛剛說的話,或提醒你可能漏看的事。內容都不進 transcript,模型看不到。

結果畫在 CC 自己的 pane:終端機夠寬時停在畫面右邊,窄時開在提示框上方。不需要 Herdr。

這是 cc-sidecar-waitwhat 的 Claude Mods 版:sidecar 跑在 CC 外面、讀 JSONL;這個 mod 跑在 CC 裡面、讀引擎給的對話,換來不用切終端機、不用選 session。兩邊共用同一組環境變數與 prompt 覆寫檔。

cc-mod-waitwhat demo

┌ 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 檔不會自動清。

為什麼模型看不到

  • 結果畫在 CC 的 pane,按鈕在 AbovePrompt(提示框上方那條 band),都不回傳任何文字給 transcript。
  • 不註冊 slash command。/wait-what 這類指令一敲,CC 就會把 <command-name> 寫進 transcript、模型下一輪就看到;按鈕走的是 ui.press,實測 JSONL 零筆記錄。
  • 就算退回 Claude 自家模型,走的也是 $.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 或重導向。

需求

  • Claude Code 2.1.288 以上($.model.complete 回傳結果物件、pane 與 Markdown 元件),並開 CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
  • 只在 terminal 有效。桌面版和手機版沒有 band。

裝

接進所有 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。

自動 recap

每一輪結束後閒置 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 不搬移,同一段舊對話升級後第一次重看仍會重問一次。

換掉 prompt

兩套 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。

License

MIT

Source 7 files
hooks/register.tsx 462 lines
1import 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}
462
hooks/cache.ts 48 lines
1export 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}
48
hooks/model.ts 77 lines
1export 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)
77
hooks/prompts.ts 31 lines
1export 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. 不要開場白,不要「以下是」,直接輸出重講後的內容。`
31
hooks/recap.ts 145 lines
1import 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
145
hooks/turns.ts 56 lines
1import 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')
56
hooks/ysk.ts 142 lines
1import 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