cc-sticky-notes: route curiosity questions to a side pane instead of the main conversation

一個 Claude Code mod:在專案 session 裡隨口問的「為什麼 / 這是什麼」不會進主線對話,而是變成「便利貼」,在背景回答、顯示在右側 pane,並存成一棵同專案所有 session 共用的樹。主線的 context 保持乾淨,追問也能一路掛在同一條旁支下。
版本 0.0.1,給朋友試用中。設計與決策:PLAN.md;Mods API 實測紀錄:PROBE.md。
git clone https://github.com/k7term1a/cc-sticky-notes.git
終端機:每次啟動時帶上資料夾路徑。
claude --plugin-dir /path/to/cc-sticky-notes
Claude Desktop(Code 分頁):在 ~/.claude/settings.json 的 env 加上這個資料夾,之後開的 session 都會載入。
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "C:\\Users\\you\\cc-sticky-notes",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}
}
CLAUDE_CODE_PLUGIN_DIR_WATCH 是選用的:設了之後,mod 的檔案一更新(例如 git pull),開著的 session 就會自動重載。cc-sticky-note。打 /sn doctor 可以檢查金鑰有沒有讀到。建一個 ~/.claude/sticky-notes/.env(Windows 是 C:\Users\<你>\.claude\sticky-notes\.env):
TYPESAFE_API_KEY=apikey_...
OPENAI_API_KEY=sk-...
~/.claude/sticky-notes/.env → mod 資料夾裡的 .env。不會讀你專案自己的 .env(那裡常有那個專案自己的 OpenAI key)。/sn doctor 只告訴你找到沒有、從哪裡找到。/sn new <問題> 或 pane 的追問框建立。像平常一樣打字。Jev 判斷這句是「專案任務」還是「知識旁問」:
A hook blocked your prompt
Prompt dropped by a hook: cc-sticky-note:Jev 96% 判斷是旁支 → 便利貼「CRDT 是什麼?」
幾秒後答案出現在右側 pane(會自動打開),對話裡留一行灰字預覽。灰字和卡片 Claude 都讀不到。
問完一則之後,輸入框下方會顯示 cc-sticky-note <正在追問的題目>。這時在輸入框直接打字,Jev 會判斷是不是在延續這個話題;是的話就掛在同一條旁支下。想結束追問,按 pane 裡的「回到主線」或打 /sn back。
也可以在 pane 裡點任何一則便利貼,接著打字就是追問它;或用 pane 卡片裡的追問框(不經 Jev,直接掛在那一則下面)。
打 /sn 開關。內容:
● 是這個 session 正在追問的、◀ 是目前選中的、… 是還在回答、↑ 是已回到主線的。/sn 和 /sticky-note 完全一樣,/sn 比較好打。
| 指令 | 作用 | |||
|---|---|---|---|---|
/sn | 開 / 關右側 pane | |||
/sn new <問題> | 手動開一張便利貼(不經 Jev) | |||
/sn back | 結束追問,回到主線 | |||
/sn feedback good | 剛剛那句 Jev 分對了 | |||
| `/sn feedback bad <main\ | sidebar\ | followup\ | project>` | 分錯了,正確應該是哪一種(也可以打「錯 主線」) |
/sn calibrate | 用你標記過的資料,看各門檻下 Jev 的準確率 | |||
/sn doctor | 檢查兩把金鑰找到沒有、從哪裡找到 |
最有用的回饋是 Jev 分錯的時候:被攔成便利貼的其實是任務,或任務被當成旁問。打 /sn feedback bad main(或 sidebar 等),再截圖給我。
下列欄位可以在 Claude Code 的設定選單(/config,終端機)調整:
| 欄位 | 預設 | 說明 |
|---|---|---|
autoOpenPane | true | 答案回來時自動打開 pane 並選中 |
routeThreshold | 0.6 | Jev 信心低於這個就問你 |
followupThreshold | 0.6 | 判成追問的門檻 |
summaryProvider | claude | 標題 / 摘要用誰寫:claude 或 openai |
claudeModel / claudeEffort | haiku / low | 寫標題摘要的 Claude 模型與 effort(旁答本身一律用主線的模型) |
openaiModel / openaiReasoningEffort | gpt-5-mini / minimal | summaryProvider = openai 時用 |
~/.claude/plugins/store/cc-sticky-notes_*.json。用 --plugin-dir 載入和正式安裝可能是不同的檔。claude plugin validate .
claude plugin test .
npx -p typescript@5.6 tsc -p .
tsconfig.json extends .claude-plugin/types/tsconfig.json,那是引擎載入 mod 時寫出來的型別(已 git-ignore),所以第一次 type-check 前要先載入一次 mod。
hooks/register.tsx:所有 hook,以及 portsOf($)。只有這個檔碰 $(PROBE.md P1)。hooks/tree.ts、route.ts、jev.ts、redact.ts、digest.ts、answer.ts:邏輯模組,純函式或吃 Ports。hooks/notes.ts:流程(路由 → 便利貼 → 背景回答 → 路由樣本)。hooks/feedback.ts:路由樣本、/sn feedback、calibrate。hooks/secrets.ts:金鑰從哪裡來。hooks/pane.tsx:右側 pane 與輸入框下方的狀態列。probes/m0/:M0 探針 mod(cc-sticky-probe),獨立的 plugin。hooks/register.tsx 210 lines1// Entry point: the hooks, and portsOf — the one place `$` is turned into the
2// closures the logic modules use (ports.ts says why `$` itself cannot cross).
3import type { EngineInterface, Register } from 'claude-code'
4import { atom, read, update } from 'claude-code'
5
6import { COMMAND, COMMAND_SPEC, maskNewArgs, runCommand, SHORT_COMMAND, SHORT_COMMAND_SPEC } from './commands'
7import { bookmarkNote, change, explainDrop, prepare, readOptions, recordRoute, routePrompt, startNote, type Options } from './notes'
8import { PANE_ID, PANE_TITLE, paneView, statusLine } from './pane'
9import type { Ports } from './ports'
10import { describeKey, findKey, keyFiles, resolveKey } from './secrets'
11import { setActive } from './tree'
12
13// $.state values (types/index.d.ts). The loader reads atoms only from this file's own consts.
14const treeAtom = atom({ plugin: 'cc-sticky-notes', key: 'tree' } as const, null)
15const selectedAtom = atom({ plugin: 'cc-sticky-notes', key: 'selectedId' } as const, null)
16const unreadAtom = atom({ plugin: 'cc-sticky-notes', key: 'unread' } as const, 0)
17const pendingAtom = atom({ plugin: 'cc-sticky-notes', key: 'pending' } as const, 0)
18const lastTurnAtom = atom({ plugin: 'cc-sticky-notes', key: 'lastTurnId' } as const, null)
19const turnsAtSidebarAtom = atom({ plugin: 'cc-sticky-notes', key: 'turnsAtLastSidebar' } as const, 0)
20const lastPromptAtom = atom({ plugin: 'cc-sticky-notes', key: 'lastPromptUuid' } as const, null)
21const lastSampleAtom = atom({ plugin: 'cc-sticky-notes', key: 'lastSampleId' } as const, null)
22
23/** Key files to try after the environment (secrets.ts says which and why). */
24async function keyFilesOf($: EngineInterface): Promise<string[]> {
25 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
26 return keyFiles(home, $.plugin.root)
27}
28
29/**
30 * The status line under the prompt (the developer's pick, PROBE.md「UI 能放在哪裡」 D):
31 * "cc-sticky-note <the question this session follows up>". Redrawn whenever the tree
32 * or the pending count changes.
33 */
34async function refreshStatus($: EngineInterface) {
35 $.ui.status(statusLine(await read($, treeAtom), await $.session.id(), await read($, pendingAtom)))
36}
37
38function portsOf($: EngineInterface): Ports {
39 const readText = (path: string) => $.fs.read(path) as Promise<string>
40 return {
41 sessionId: () => $.session.id(),
42 // PLAN.md: one tree per repo, shared by its worktrees; the session root outside a repo.
43 projectRoot: async () => (await $.session.repo())?.root ?? (await $.session.root()),
44 turns: () => $.session.turns(),
45 messages: async () => {
46 const list = await $.session.messages()
47 return Array.isArray(list) ? list : []
48 },
49 now: () => $.clock.now(),
50 later: fn => void $.clock.after(0, fn),
51 store: {
52 get: key => $.store.get(key),
53 set: (key, value) => $.store.set(key, value),
54 delete: key => $.store.delete(key),
55 keys: () => $.store.keys(),
56 },
57 fetch: (url, init) => $.http.fetch(url, init),
58 fork: prompt => $.model.fork({ prompt }),
59 complete: request => $.model.complete(request),
60 keys: {
61 typesafe: async () => resolveKey('TYPESAFE_API_KEY', await $.env.get('TYPESAFE_API_KEY'), await keyFilesOf($), readText),
62 openai: async () => resolveKey('OPENAI_API_KEY', await $.env.get('OPENAI_API_KEY'), await keyFilesOf($), readText),
63 report: async () => {
64 const files = await keyFilesOf($)
65 return [
66 describeKey('TYPESAFE_API_KEY', await findKey('TYPESAFE_API_KEY', await $.env.get('TYPESAFE_API_KEY'), files, readText)),
67 describeKey('OPENAI_API_KEY', await findKey('OPENAI_API_KEY', await $.env.get('OPENAI_API_KEY'), files, readText)),
68 `key 檔查找順序:${files.join(' → ')}`,
69 ]
70 },
71 },
72 ui: {
73 log: (text, to) => $.ui.log(text, { to: to ?? 'transcript' }),
74 toast: text => $.ui.toast(text),
75 ask: (question, options, header) => $.ui.ask(question, { options, header }),
76 openPane: async () => {
77 await update($, unreadAtom, () => 0)
78 await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
79 },
80 closePane: () => $.ui.close({ id: PANE_ID }),
81 isPaneOpen: async () => (await $.ui.panes()).some(pane => pane.id === PANE_ID),
82 status: text => $.ui.status(text),
83 },
84 state: {
85 publishTree: async tree => {
86 await update($, treeAtom, () => tree)
87 await refreshStatus($)
88 },
89 lastTurnId: () => read($, lastTurnAtom),
90 turnsAtLastSidebar: () => read($, turnsAtSidebarAtom),
91 setTurnsAtLastSidebar: async n => void (await update($, turnsAtSidebarAtom, () => n)),
92 addPending: async delta => {
93 await update($, pendingAtom, n => Math.max(0, n + delta))
94 await refreshStatus($)
95 },
96 addUnread: async delta => void (await update($, unreadAtom, n => Math.max(0, n + delta))),
97 lastPromptUuid: () => read($, lastPromptAtom),
98 lastSampleId: () => read($, lastSampleAtom),
99 setLastSampleId: async id => void (await update($, lastSampleAtom, () => id)),
100 setSelected: async id => void (await update($, selectedAtom, () => id)),
101 },
102 }
103}
104
105/** Pane click: show it and make it this session's activeThread. 回到主線 passes null. */
106async function selectNode($: EngineInterface, id: string | null) {
107 const p = portsOf($)
108 const sessionId = await p.sessionId()
109 await update($, selectedAtom, () => id)
110 await change(p, t => setActive(t, sessionId, id))
111}
112
113/** The pane's follow-up box: straight to a note under that node, no Jev (PLAN.md). */
114async function followUp($: EngineInterface, opts: Options, parentId: string, question: string) {
115 await startNote(portsOf($), opts, {
116 question,
117 attach: { kind: 'under', parentId },
118 answerer: 'fork',
119 route: { label: 'sidebar_knowledge', confidence: 1, source: 'manual' },
120 })
121}
122
123export const register: Register = (on, options) => {
124 const opts = readOptions(options)
125
126 on('session.start', async ($, e, next) => {
127 await $.command.register(COMMAND_SPEC)
128 await $.command.register(SHORT_COMMAND_SPEC)
129 await prepare(portsOf($))
130 return next(e)
131 })
132
133 on('turn.start', async ($, e, next) => {
134 await update($, lastTurnAtom, () => e.turnId)
135 return next(e)
136 })
137
138 on('session.append', async ($, e, next) => {
139 if (e.agentId !== undefined) return next(e)
140 // anchor.messageId: the main-line user message a later sidebar question sits after (M2 badge).
141 if (e.door === 'prompt' && e.message.type === 'user' && !e.message.isMeta) {
142 await update($, lastPromptAtom, () => e.uuid)
143 return next(e)
144 }
145 // PROBE.md P10: `/sticky-note new <問題>` would leave the question in the main context.
146 if (e.door === 'command') {
147 let masked = false
148 const content = e.message.content.map(block => {
149 if (block.type !== 'text' || typeof block.text !== 'string') return block
150 const text = maskNewArgs(block.text)
151 if (text === null) return block
152 masked = true
153 return { ...block, text }
154 })
155 if (masked) return next({ ...e, message: { ...e.message, content } })
156 }
157 return next(e)
158 })
159
160 on('prompt.submit', async ($, e, next) => {
161 // Only the person's own typed prompts are routed; notifications, peers and plugins pass.
162 if (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge') return next(e)
163 if (e.text.trim() === '' || e.text.trimStart().startsWith('/')) return next(e)
164
165 const p = portsOf($)
166 const routed = await routePrompt(p, opts, e.text)
167 const keep = (nodeId: string | null) => p.later(() => void recordRoute(p, opts, e.text, routed, nodeId))
168 const d = routed.decision
169 switch (d.kind) {
170 case 'main':
171 case 'ask':
172 keep(null)
173 return next(e)
174 case 'project_question':
175 keep(await bookmarkNote(p, e.text, d.route))
176 return next({ ...e, context: [...(e.context ?? []), d.context] })
177 case 'sidebar':
178 keep(
179 await startNote(p, opts, {
180 question: e.text,
181 attach: { kind: d.attach },
182 answerer: d.answerer,
183 route: d.route,
184 tag: d.tag.kind === 'existing' ? d.tag.tag : undefined,
185 }),
186 )
187 // The drop reason is the transparency line (PROBE.md P2): who decided, how sure, where it went.
188 return { drop: explainDrop(e.text, routed) }
189 }
190 })
191
192 on('command.run', { command: COMMAND }, async ($, e) => runCommand(portsOf($), opts, e.args))
193 // /sn: the keyboard shortcut for the pane (the status line cannot be clicked; PROBE.md)
194 on('command.run', { command: SHORT_COMMAND }, async ($, e) => runCommand(portsOf($), opts, e.args))
195
196 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
197 const data = {
198 tree: await read($, treeAtom),
199 selectedId: await read($, selectedAtom),
200 sessionId: await $.session.id(),
201 pending: await read($, pendingAtom),
202 }
203 return paneView($.ui.resolve(e), data, {
204 select: id => void selectNode($, id),
205 back: () => void selectNode($, null),
206 followUp: (parentId, question) => void followUp($, opts, parentId, question),
207 })
208 })
209}
210hooks/commands.ts 107 lines1// /sticky-note and its subcommands.
2// A command's `{ text }` is a transcript row the model also reads, so anything
3// long or private goes to $.ui.log / toast instead and the command returns {}.
4import type { CommandRunResult } from 'claude-code'
5
6import { calibrate, formatCalibration, listSamples, parseFeedback } from './feedback'
7import { change, dropReason, giveFeedback, projectKey, startNote, type Options } from './notes'
8import type { Ports } from './ports'
9import { setActive } from './tree'
10
11export const COMMAND = 'sticky-note'
12
13export const COMMAND_SPEC = {
14 name: COMMAND,
15 description: 'Sticky Notes: open/close the pane, or new | back | feedback | calibrate | doctor | promote | outline | refresh | digest | export | mode | stats',
16 argumentHint: '[new <問題> | back | feedback good|bad [main|sidebar|followup|project] | calibrate]',
17}
18
19/**
20 * /sn: the keyboard way to the pane. The status line cannot be clicked, a plugin
21 * button's hotkey works only while its site has the focus, and keybindings.json
22 * has no "run a command" action (PROBE.md), so a two-letter command is the shortcut.
23 * Takes the same subcommands as /sticky-note.
24 */
25export const SHORT_COMMAND = 'sn'
26
27export const SHORT_COMMAND_SPEC = {
28 name: SHORT_COMMAND,
29 description: 'Sticky Notes: open/close the pane (short for /sticky-note)',
30 argumentHint: '[new <問題> | back | …]',
31}
32
33const LATER: Record<string, string> = {
34 promote: 'M2',
35 outline: 'M3',
36 refresh: 'M3',
37 digest: 'M3',
38 export: 'M3',
39 mode: 'M4',
40 stats: 'M4',
41}
42
43export function parseArgs(args: string): { sub: string; rest: string } {
44 const m = /^\s*(\S*)\s*([\s\S]*)$/.exec(args)
45 return { sub: (m?.[1] ?? '').toLowerCase(), rest: (m?.[2] ?? '').trim() }
46}
47
48/** The args a /sticky-note new record keeps in the transcript (PROBE.md P10). */
49export const NEW_ARGS_MASK = 'new (sticky note)'
50
51/**
52 * Rewrites the transcript record of `/sticky-note new <問題>` so the question
53 * does not stay in the main context. Returns null when the text is not that record.
54 */
55export function maskNewArgs(text: string): string | null {
56 if (!text.includes(`<command-name>/${COMMAND}</command-name>`) && !text.includes(`<command-name>/${SHORT_COMMAND}</command-name>`)) return null
57 const re = /<command-args>\s*new\b[\s\S]*?<\/command-args>/
58 return re.test(text) ? text.replace(re, `<command-args>${NEW_ARGS_MASK}</command-args>`) : null
59}
60
61export async function runCommand(p: Ports, opts: Options, args: string): Promise<CommandRunResult> {
62 const { sub, rest } = parseArgs(args)
63 switch (sub) {
64 case '': {
65 if (await p.ui.isPaneOpen()) await p.ui.closePane()
66 else await p.ui.openPane()
67 return {}
68 }
69 case 'new': {
70 if (rest === '') return { text: '用法:/sticky-note new <問題>' }
71 await startNote(p, opts, {
72 question: rest,
73 attach: { kind: 'root' },
74 answerer: 'fork',
75 route: { label: 'sidebar_knowledge', confidence: 1, source: 'manual' },
76 })
77 p.ui.toast(dropReason(rest))
78 return {}
79 }
80 case 'back': {
81 const sessionId = await p.sessionId()
82 await change(p, t => setActive(t, sessionId, null))
83 return {}
84 }
85 case 'feedback': {
86 const fb = parseFeedback(rest)
87 if (fb === null) return { text: '用法:/sticky-note feedback good|bad [main|sidebar|followup|project]' }
88 const ok = await giveFeedback(p, { ...fb, source: 'command' })
89 p.ui.toast(ok ? `已記下:上一句分類${fb.verdict === 'right' ? '正確' : '錯誤'}${fb.expected ? `,應為 ${fb.expected}` : ''}` : '這個 session 還沒有可以回饋的分類')
90 return {}
91 }
92 case 'doctor': {
93 for (const line of await p.keys.report()) p.ui.log(line)
94 return {}
95 }
96 case 'calibrate': {
97 const lines = formatCalibration(calibrate(await listSamples(p.store, await projectKey(p))))
98 for (const line of lines) p.ui.log(line)
99 return {}
100 }
101 default: {
102 const when = LATER[sub]
103 return { text: when ? `/sticky-note ${sub}:尚未實作(${when})` : `未知的子指令:${sub}` }
104 }
105 }
106}
107hooks/notes.ts 335 lines1// Orchestration between the hooks and the pure modules: where a prompt goes,
2// creating a note, answering it in the background, keeping the pane's state in
3// sync, and keeping routing samples. Everything outside goes through Ports.
4import type { ModelEffort, PluginOptions } from 'claude-code'
5
6import type { RouteFeedback, Tree } from '../types'
7import { answerSide } from './answer'
8import { titleAndSummary } from './digest'
9import { choiceOf, recordSample, sampleFrom, sampleId, setFeedback } from './feedback'
10import * as jev from './jev'
11import type { Ports } from './ports'
12import { asEffort, claudeProvider } from './providers/claude'
13import { asContextMode, asReasoningEffort, openaiProvider, type OpenAIReasoningEffort } from './providers/openai'
14import { withFallback, type SummaryProvider } from './providers/types'
15import { ASK_OPTIONS, decide, decideFromAsk, NEEDS_CTX_AT, type Decision } from './route'
16import { addNode, loadTree, migrateLegacy, mutateTree, normalizeRoot, sideHistory, updateNode, type Attach } from './tree'
17
18export type Options = {
19 routeThreshold: number
20 followupThreshold: number
21 summaryProvider: 'claude' | 'openai'
22 claudeModel: string
23 claudeEffort: ModelEffort
24 openaiModel: string
25 openaiReasoningEffort: OpenAIReasoningEffort
26 openaiContextMode: 'off' | 'redacted' | 'full'
27 autoOpenPane: boolean
28}
29
30export function readOptions(o: PluginOptions): Options {
31 const num = (v: unknown, d: number) => (typeof v === 'number' && Number.isFinite(v) ? v : d)
32 const str = (v: unknown, d: string) => (typeof v === 'string' && v.trim() !== '' ? v.trim() : d)
33 return {
34 routeThreshold: num(o.routeThreshold, 0.6),
35 followupThreshold: num(o.followupThreshold, 0.6),
36 summaryProvider: o.summaryProvider === 'openai' ? 'openai' : 'claude',
37 claudeModel: str(o.claudeModel, 'haiku'),
38 claudeEffort: asEffort(o.claudeEffort),
39 openaiModel: str(o.openaiModel, 'gpt-5-mini'),
40 openaiReasoningEffort: asReasoningEffort(o.openaiReasoningEffort),
41 openaiContextMode: asContextMode(o.openaiContextMode),
42 autoOpenPane: o.autoOpenPane !== false, // default on (the developer's pick, 10/07)
43 }
44}
45
46/** The configured summary provider; openai without a key falls back to claude with one toast. */
47export function summaryProvider(p: Ports, opts: Options): SummaryProvider {
48 const log = (text: string) => p.ui.log(text, 'debug')
49 const claude = claudeProvider({ complete: p.complete, log }, { model: opts.claudeModel, effort: opts.claudeEffort })
50 if (opts.summaryProvider !== 'openai') return claude
51 const openai = openaiProvider(
52 { key: p.keys.openai, fetch: p.fetch, log },
53 { model: opts.openaiModel, contextMode: opts.openaiContextMode, reasoningEffort: opts.openaiReasoningEffort },
54 )
55 return withFallback(openai, claude, () => p.ui.toast('Sticky Notes:沒有 OPENAI_API_KEY,統整層改用 Claude'))
56}
57
58/** PLAN.md: the repo root (shared by every worktree), else the session root. */
59export async function projectKey(p: Ports): Promise<string> {
60 return normalizeRoot(await p.projectRoot())
61}
62
63export async function refresh(p: Ports): Promise<Tree> {
64 const tree = await loadTree(p.store, await projectKey(p))
65 await p.state.publishTree(tree)
66 return tree
67}
68
69/** session.start: move a tree saved in the old one-key layout, then load. */
70export async function prepare(p: Ports): Promise<Tree> {
71 const moved = await migrateLegacy(p.store, await projectKey(p))
72 if (moved > 0) p.ui.log(`sticky-notes: moved ${moved} notes to the per-node store layout`, 'debug')
73 return refresh(p)
74}
75
76export async function change(p: Ports, fn: (t: Tree) => Tree): Promise<Tree> {
77 const tree = await mutateTree(p.store, await projectKey(p), fn)
78 await p.state.publishTree(tree)
79 return tree
80}
81
82export function snippet(s: string, n: number): string {
83 const cps = [...s.replace(/\s+/g, ' ').trim()]
84 return cps.length > n ? cps.slice(0, n).join('') + '…' : cps.join('')
85}
86
87/** How much of the question the drop card quotes, and of the answer the log line previews (the developer: keep them short). */
88export const QUESTION_CHARS = 24
89export const PREVIEW_CHARS = 40
90
91/** Markdown flattened to one plain line (headings, emphasis, code, tables, list marks dropped), first n characters. */
92export function plainPreview(md: string, n: number): string {
93 const text = md
94 .replace(/```[\s\S]*?(```|$)/g, ' ')
95 .replace(/^\s*\|?[\s:|-]+\|[\s:|-]*$/gm, ' ') // table rules
96 .replace(/^\s{0,3}(#{1,6}|[-*+]|\d+\.)\s+/gm, '')
97 .replace(/[*_`|>#]+/g, ' ')
98 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
99 return snippet(text, n)
100}
101
102/** The drop reason is the transparency line (PLAN.md / PROBE.md P2). */
103export function dropReason(question: string): string {
104 return `cc-sticky-note:便利貼「${snippet(question, QUESTION_CHARS)}」`
105}
106
107/**
108 * The detail under the desktop's own "Prompt blocked by a hook" title (that title
109 * is the app's; the reason is ours): who decided, how sure Jev was, and where it went.
110 */
111export function explainDrop(question: string, routed: Routed): string {
112 const where =
113 routed.decision.kind === 'sidebar' && routed.decision.attach === 'followup' && routed.activeTitle !== null
114 ? `追問「${routed.activeTitle}」`
115 : '旁支'
116 const who =
117 routed.asked !== null
118 ? `你選了${where}`
119 : routed.verdict !== null
120 ? `Jev ${Math.round(routed.verdict.route.confidence * 100)}% 判斷是${where}`
121 : `判斷是${where}`
122 return `cc-sticky-note:${who} → 便利貼「${snippet(question, QUESTION_CHARS)}」`
123}
124
125/**
126 * A user message the person typed: not a slash-command record or caveat
127 * (`<command-name>…`, `<local-command-…>`), which $.session.messages() also lists.
128 */
129export function isTypedPrompt(m: { role: string; text: string }): boolean {
130 if (m.role !== 'user') return false
131 const t = m.text.trimStart()
132 return t !== '' && !/^<(command-|local-command-|system-reminder)/.test(t)
133}
134
135/** Jev input from the session's own transcript (PLAN.md: raw 300-char clips until M4). */
136export async function jevInput(p: Ports, tree: Tree, sessionId: string, prompt: string): Promise<jev.JevInput> {
137 const list = await p.messages()
138 const active = tree.activeThread[sessionId] ?? null
139 const node = active ? tree.nodes[active] : undefined
140 const turns = await p.turns()
141 const at = await p.state.turnsAtLastSidebar()
142 return {
143 recentUser: list.filter(isTypedPrompt).map(m => m.text).slice(-3),
144 lastAssistant: list.filter(m => m.role === 'assistant' && m.text !== '').at(-1)?.text ?? null,
145 activeTitle: node?.title ?? null,
146 activeLastQuestion: node?.question ?? null,
147 mainTurnsSinceLastSidebar: Math.max(0, turns - at),
148 prompt,
149 tags: tree.tags,
150 }
151}
152
153/** "這句要進主線還是旁支?(32%)": Jev's confidence in its own top route, shown when it was too low to act on. */
154export function askQuestion(verdict: jev.JevVerdict): string {
155 return `這句要進主線還是旁支?(${Math.round(verdict.route.confidence * 100)}%)`
156}
157
158export type Routed = {
159 decision: Decision
160 verdict: jev.JevVerdict | null
161 /** The label the user picked when asked; null when not asked (or dismissed). */
162 asked: string | null
163 /** Title of this session's activeThread when the prompt came in (a follow-up's parent). */
164 activeTitle: string | null
165}
166
167/**
168 * Jev, then the decision table. No Jev (no key, an error) → main line, no
169 * dialog (PLAN.md「沒有 Jev key 時」). Low confidence → $.ui.ask.
170 */
171export async function routePrompt(p: Ports, opts: Options, prompt: string): Promise<Routed> {
172 const sessionId = await p.sessionId()
173 const tree = await refresh(p)
174 const activeId = tree.activeThread[sessionId] ?? null
175 const hasActiveThread = activeId !== null
176 const activeTitle = activeId === null ? null : (tree.nodes[activeId]?.title ?? null)
177 const r = await jev.classify({ key: await p.keys.typesafe(), fetch: p.fetch, now: p.now }, await jevInput(p, tree, sessionId, prompt))
178 if (!r.ok) {
179 if (r.reason !== 'no-key') p.ui.log(`sticky-notes: Jev unavailable (${r.reason}${r.status ? ` ${r.status}` : ''}); main line`, 'debug')
180 return { decision: { kind: 'main' }, verdict: null, asked: null, activeTitle }
181 }
182 const decision = decide(r.verdict, { route: opts.routeThreshold, followup: opts.followupThreshold }, {
183 hasActiveThread,
184 tagsKnown: tree.tags.length,
185 })
186 if (decision.kind !== 'ask') return { decision, verdict: r.verdict, asked: null, activeTitle }
187 try {
188 const asked = await p.ui.ask(askQuestion(r.verdict), ASK_OPTIONS, 'Sticky Notes')
189 return { decision: decideFromAsk(asked, { hasActiveThread }), verdict: r.verdict, asked, activeTitle }
190 } catch {
191 // dismissed, or headless (-p): never swallow the user's input
192 return { decision: { kind: 'main' }, verdict: r.verdict, asked: null, activeTitle }
193 }
194}
195
196/**
197 * Keeps one routing sample for calibration. Prompts Jev never saw (no key)
198 * are not kept. When the user was asked, their answer is the label.
199 */
200export async function recordRoute(p: Ports, opts: Options, prompt: string, routed: Routed, nodeId: string | null): Promise<string | null> {
201 if (routed.verdict === null) return null
202 const at = await p.now()
203 const id = sampleId(at, crypto.randomUUID())
204 const feedback: RouteFeedback | null =
205 routed.asked === null ? null : { verdict: 'right', expected: choiceOf(routed.decision) as RouteFeedback['expected'], at, source: 'ask' }
206 await recordSample(
207 p.store,
208 await projectKey(p),
209 sampleFrom({
210 id,
211 at,
212 sessionId: await p.sessionId(),
213 prompt,
214 verdict: routed.verdict,
215 thresholds: { route: opts.routeThreshold, followup: opts.followupThreshold, needsCtx: NEEDS_CTX_AT },
216 decision: routed.asked === null ? routed.decision : { kind: 'ask', why: 'low-confidence' },
217 nodeId,
218 feedback,
219 }),
220 )
221 await p.state.setLastSampleId(id)
222 return id
223}
224
225/** /sticky-note feedback: marks this session's last routing sample. */
226export async function giveFeedback(p: Ports, feedback: Omit<RouteFeedback, 'at'>): Promise<boolean> {
227 const id = await p.state.lastSampleId()
228 if (id === null) return false
229 return (await setFeedback(p.store, await projectKey(p), id, { ...feedback, at: await p.now() })) !== null
230}
231
232export type NoteRequest = {
233 question: string
234 attach: Attach
235 answerer: 'fork' | 'summary-provider'
236 route: { label: string; confidence: number; source: 'jev' | 'ask' | 'manual' }
237 tag?: string
238}
239
240async function anchorFor(p: Ports) {
241 const sessionId = await p.sessionId()
242 const lastUser = (await p.messages()).filter(isTypedPrompt).at(-1)?.text ?? ''
243 return {
244 sessionId,
245 turnId: await p.state.lastTurnId(),
246 messageId: await p.state.lastPromptUuid(),
247 at: await p.now(),
248 mainSnippet: [...lastUser].slice(0, 120).join(''),
249 }
250}
251
252/**
253 * Writes the node now (answer pending) and answers it in the background.
254 * The caller drops the prompt with dropReason() as the visible line.
255 */
256export async function startNote(p: Ports, opts: Options, req: NoteRequest): Promise<string> {
257 const anchor = await anchorFor(p)
258 const id = crypto.randomUUID()
259 await change(p, t =>
260 addNode(
261 t,
262 anchor.sessionId,
263 {
264 id,
265 question: req.question,
266 anchor,
267 route: req.route,
268 answeredBy: req.answerer === 'fork' ? 'claude-fork' : opts.summaryProvider,
269 tags: req.tag ? [req.tag] : [],
270 },
271 req.attach,
272 ).tree,
273 )
274 await p.state.setTurnsAtLastSidebar(await p.turns())
275 await p.state.addPending(1)
276 // the pane follows the newest note of this session (its answer shows as it arrives)
277 await p.state.setSelected(id)
278
279 // Answered outside the prompt.submit dispatch (PROBE.md: works, and the
280 // drop line shows at once instead of after the 1–3 s fork). Nothing may
281 // escape a background job: a failure marks the note and says so.
282 p.later(() => {
283 answerNote(p, opts, id, req).catch(async (err: unknown) => {
284 p.ui.log(`sticky-notes: answering failed: ${String(err).slice(0, 200)}`, 'debug')
285 p.ui.toast('Sticky Notes:回答失敗')
286 await change(p, t => updateNode(t, id, { answer: '_(回答失敗)_' })).catch(() => {})
287 })
288 })
289 return id
290}
291
292/**
293 * project_question: the prompt goes to the main line with context; the tree
294 * keeps a bookmark that is already "promoted" and never becomes activeThread.
295 */
296export async function bookmarkNote(p: Ports, question: string, route: NoteRequest['route']): Promise<string> {
297 const anchor = await anchorFor(p)
298 const id = crypto.randomUUID()
299 await change(p, t =>
300 addNode(
301 t,
302 anchor.sessionId,
303 { id, question, anchor, route, answeredBy: 'claude', promotedAt: anchor.at, promotedIn: anchor.sessionId },
304 { kind: 'root' },
305 { makeActive: false },
306 ).tree,
307 )
308 return id
309}
310
311export async function answerNote(p: Ports, opts: Options, id: string, req: NoteRequest): Promise<void> {
312 const provider = summaryProvider(p, opts)
313 try {
314 const tree = await refresh(p)
315 const history = sideHistory(tree, tree.nodes[id]?.parentId ?? null)
316 const a = await answerSide(p.fork, { question: req.question, history, answerer: req.answerer }, provider)
317 if (!a.ok) {
318 await change(p, t => updateNode(t, id, { answer: `_(沒有答案:${a.reason})_` }))
319 p.ui.toast(`Sticky Notes:回答失敗(${a.reason})`)
320 return
321 }
322 await change(p, t => updateNode(t, id, { answer: a.text, answeredBy: a.answeredBy }))
323 const ts = await titleAndSummary(provider, req.question, a.text)
324 await change(p, t => updateNode(t, id, { title: ts.title, summary: ts.summary }))
325 await p.state.addUnread(1)
326 // M1 (PLAN.md): the answer is shown with $.ui.log — a dim transcript line the model never reads.
327 // $.ui.log is plain text (no Markdown): one clean line here, the rendered answer in the pane.
328 p.ui.log(`cc-sticky-note ${ts.title}:${plainPreview(ts.summary ?? a.text, PREVIEW_CHARS)} /sn 看完整答案`)
329 p.ui.toast(`cc-sticky-note ${ts.title}`)
330 if (opts.autoOpenPane) await p.ui.openPane()
331 } finally {
332 await p.state.addPending(-1)
333 }
334}
335hooks/pane.tsx 159 lines1// Pane and band views (M2 layout, first pass for review with the developer).
2// Pure views: register.ts reads the state and hands in the surface's element table
3// and the handlers; nothing here touches `$`.
4//
5// Sticky Notes 3 則 · 1 回答中
6// ╭ 便利貼 ───────────────────────────────────────────╮
7// │ ● 樹載入需多次讀取原因 │
8// │ └ ● 為何需全讀節點 │ ← ● = this session follows it up
9// │ ○ TCP backpressure ↑ │ ← ↑ = promoted to the main line
10// ╰────────────────────────────────────────────────────╯
11// ╭ 為何需全讀節點 ────────────────────────────────────╮
12// │ 10/07 10:41 · Claude(看得到專案)· 追問第 2 層 │
13// │ 樹載入需多次讀取原因 — <summary of the earlier step> │ ← earlier steps, compact
14// │ Q 沒看懂,為什麼需要讀取全部的節點… │
15// │ <answer, Markdown> │
16// │ [追問這一則… ] [送出] │
17// │ [回到主線] │
18// ╰────────────────────────────────────────────────────╯
19// 在主輸入框直接打字=追問「為何需全讀節點」
20import type { ElementTable, RenderElement } from 'claude-code'
21
22import type { Tree, TreeNode } from '../types'
23import { countNodes, flatten, pathTo, visibleRoots } from './tree'
24
25export const PANE_ID = 'sticky'
26export const PANE_TITLE = 'Sticky Notes'
27
28/** 正在追問:<title> lives in the band (PLAN.md: not $.prompt.suggest). */
29export function activeTitle(tree: Tree | null, sessionId: string): string | null {
30 const id = tree?.activeThread[sessionId] ?? null
31 return id === null ? null : (tree?.nodes[id]?.title ?? null)
32}
33
34/**
35 * The status line under the prompt (the developer's pick of the sites, no emoji):
36 * "cc-sticky-note <the question this session follows up>", plain "cc-sticky-note"
37 * when it follows none, "(回答中)" while answers are pending.
38 */
39export function statusLine(tree: Tree | null, sessionId: string, pending: number): string {
40 const following = activeTitle(tree, sessionId)
41 return (following === null ? 'cc-sticky-note' : `cc-sticky-note ${following}`) + (pending > 0 ? '(回答中)' : '')
42}
43
44/** P9: an answer that did not come from the fork had no project context. */
45export function contextMark(n: TreeNode): string {
46 return n.answeredBy === 'claude-fork' || n.answer === '' ? '' : ' · 無專案脈絡'
47}
48
49const ANSWERED_BY: Record<TreeNode['answeredBy'], string> = {
50 'claude-fork': 'Claude(看得到專案)',
51 claude: 'Claude',
52 openai: 'OpenAI',
53 manual: '手動',
54}
55
56const pad2 = (n: number) => String(n).padStart(2, '0')
57
58/** "10/07 10:41 · Claude(看得到專案)· 追問第 2 層" */
59export function metaLine(n: TreeNode, depth: number): string {
60 const d = new Date(n.anchor.at)
61 const when = `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`
62 const by = n.promotedAt !== null && n.answer === '' ? '在主線回答' : ANSWERED_BY[n.answeredBy]
63 return [when, by, depth > 0 ? `追問第 ${depth + 1} 層` : '旁支起點'].join(' · ') + contextMark(n)
64}
65
66const clip = (s: string, n: number) => {
67 const cps = [...s.replace(/\s+/g, ' ').trim()]
68 return cps.length > n ? cps.slice(0, n).join('') + '…' : cps.join('')
69}
70
71export type PaneData = { tree: Tree | null; selectedId: string | null; sessionId: string; pending: number }
72
73export type PaneActions = {
74 select(id: string): void
75 /** 回到主線: clears this session's activeThread and the selection. */
76 back(): void
77 /** The pane's follow-up box: a child of the selected node, not routed through Jev. */
78 followUp(parentId: string, question: string): void
79}
80
81export function paneView(els: ElementTable, d: PaneData, act: PaneActions): RenderElement {
82 const { Box, Text, Button, Markdown } = els
83 // mobile draws no Input (Elements): the follow-up box is left out there
84 const Input = 'Input' in els ? els.Input : undefined
85 const tree = d.tree
86 const active = tree?.activeThread[d.sessionId] ?? null
87 const rows = tree ? flatten(tree, visibleRoots(tree).map(r => r.id)) : []
88 const selected = d.selectedId !== null ? tree?.nodes[d.selectedId] : undefined
89 const path = selected && tree ? pathTo(tree, selected.id) : []
90 const following = activeTitle(tree, d.sessionId)
91
92 return (
93 <Box flexDirection="column" gap={1}>
94 <Box justifyContent="space-between">
95 <Text bold>Sticky Notes</Text>
96 <Text dimColor>
97 {rows.length} 則{d.pending > 0 ? ` · ${d.pending} 回答中` : ''}
98 </Text>
99 </Box>
100
101 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
102 <Text dimColor>便利貼</Text>
103 {rows.length === 0 && <Text dimColor>還沒有。在主輸入框問「為什麼/是什麼」,或用 /sticky-note new。</Text>}
104 {rows.map(({ node, depth }) => (
105 <Button
106 key={`node:${node.id}`}
107 plain
108 label={[
109 ' '.repeat(depth),
110 depth > 0 ? '└ ' : '',
111 node.id === active ? '● ' : '○ ',
112 node.title,
113 node.answer === '' && node.promotedAt === null ? ' …' : '',
114 node.promotedAt !== null ? ' ↑' : '',
115 node.id === d.selectedId ? ' ◀' : '',
116 ].join('')}
117 onPress={() => act.select(node.id)}
118 />
119 ))}
120 </Box>
121
122 {selected && (
123 <Box key="card" flexDirection="column" borderStyle="round" paddingX={1} gap={1}>
124 <Box flexDirection="column">
125 <Text bold>{selected.title}</Text>
126 <Text dimColor>{metaLine(selected, path.length - 1)}</Text>
127 </Box>
128 {path.slice(0, -1).map(step => (
129 <Text key={`step:${step.id}`} dimColor>
130 {step.title} — {clip(step.summary ?? step.answer, 90)}
131 </Text>
132 ))}
133 <Text bold>Q {selected.question}</Text>
134 <Markdown
135 text={selected.answer !== '' ? selected.answer : selected.promotedAt !== null ? '_這題在主線回答。_' : '_回答中…_'}
136 />
137 {Input && (
138 <Input
139 key="followup"
140 placeholder="追問這一則…"
141 submitLabel="送出"
142 onSubmit={value => {
143 if (value.trim() !== '') act.followUp(selected.id, value.trim())
144 }}
145 />
146 )}
147 <Box gap={1}>
148 <Button key="back" label="回到主線" onPress={() => act.back()} />
149 </Box>
150 </Box>
151 )}
152
153 <Text dimColor>
154 {following !== null ? `在主輸入框直接打字=追問「${following}」;按「回到主線」結束追問。` : '點一則便利貼,接著在主輸入框打字就是追問它。'}
155 </Text>
156 </Box>
157 )
158}
159hooks/ports.ts 63 lines1// What the logic modules may do outside themselves.
2//
3// The engine's loader refuses `$` passed into a function imported from another
4// file ("$ is followed only into a function declared in this same file, never
5// across an import", PROBE.md). So register.ts builds these closures over its
6// hook's `$` (portsOf) and hands them down; nothing below register.ts sees `$`.
7import type { HttpInit, HttpResponse, ModelCompleteRequest, ModelCompleteResult, ModelForkResult, SessionMessage } from 'claude-code'
8
9import type { Tree } from '../types'
10
11export type KV = {
12 get(key: string): Promise<unknown>
13 set(key: string, value: unknown): Promise<void>
14 delete(key: string): Promise<void>
15 keys(): Promise<string[]>
16}
17
18export type Ports = {
19 sessionId(): Promise<string>
20 /** The project key: $.session.repo()?.root ?? $.session.root() (shared across worktrees). */
21 projectRoot(): Promise<string>
22 turns(): Promise<number>
23 messages(): Promise<readonly SessionMessage[]>
24 now(): Promise<number>
25 /** Runs fn outside the current dispatch ($.clock.after(0, fn)). */
26 later(fn: () => void): void
27 store: KV
28 fetch(url: string, init?: HttpInit): Promise<HttpResponse>
29 fork(prompt: string): Promise<ModelForkResult>
30 complete(request: ModelCompleteRequest): Promise<ModelCompleteResult>
31 /** Env vars are read with string literals in register.ts; only their values cross. */
32 keys: {
33 typesafe(): Promise<string | undefined>
34 openai(): Promise<string | undefined>
35 /** One line per key: found or not, and where; never the value. */
36 report(): Promise<string[]>
37 }
38 ui: {
39 log(text: string, to?: 'transcript' | 'debug'): void
40 toast(text: string): void
41 /** Rejects when dismissed or headless. */
42 ask(question: string, options: readonly string[], header: string): Promise<string>
43 openPane(): Promise<void>
44 closePane(): Promise<void>
45 isPaneOpen(): Promise<boolean>
46 /** The plugin's one status line under the prompt; undefined clears it. */
47 status(text: string | undefined): void
48 }
49 state: {
50 publishTree(tree: Tree): Promise<void>
51 lastTurnId(): Promise<string | null>
52 turnsAtLastSidebar(): Promise<number>
53 setTurnsAtLastSidebar(n: number): Promise<void>
54 addPending(delta: number): Promise<void>
55 addUnread(delta: number): Promise<void>
56 lastPromptUuid(): Promise<string | null>
57 lastSampleId(): Promise<string | null>
58 setLastSampleId(id: string | null): Promise<void>
59 /** The node the pane shows. */
60 setSelected(id: string | null): Promise<void>
61 }
62}
63hooks/secrets.ts 68 lines1// Where the API keys come from (PLAN.md「金鑰」, as revised: no machine-wide
2// environment variables required). First hit wins:
3// 1. the process environment (a terminal, CI)
4// 2. ~/.claude/sticky-notes/.env — per user, outside every repo
5// 3. <plugin root>/.env — a development checkout (git-ignored)
6// The current project's own .env is never read: it often holds that app's own
7// OPENAI_API_KEY. Values are never logged or stored.
8
9export type KeyName = 'TYPESAFE_API_KEY' | 'OPENAI_API_KEY'
10
11/** KEY=value lines; tolerates a BOM, CRLF, `export `, quotes and comments. */
12export function parseDotenv(text: string): Record<string, string> {
13 const out: Record<string, string> = {}
14 for (const raw of text.replace(/^/, '').split(/\r?\n/)) {
15 const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(raw)
16 if (!m) continue
17 let v = m[2] ?? ''
18 const q = /^(['"])([\s\S]*)\1$/.exec(v)
19 if (q) v = q[2] ?? ''
20 else v = v.replace(/\s+#.*$/, '')
21 out[m[1]!] = v
22 }
23 return out
24}
25
26/** The key files, in the order they are tried. `home` is USERPROFILE / HOME. */
27export function keyFiles(home: string | undefined, pluginRoot: string): string[] {
28 const sep = pluginRoot.includes('\\') ? '\\' : '/'
29 const files: string[] = []
30 if (home) files.push([home.replace(/[\\/]+$/, ''), '.claude', 'sticky-notes', '.env'].join(sep))
31 files.push(`${pluginRoot.replace(/[\\/]+$/, '')}${sep}.env`)
32 return files
33}
34
35/** Where a key was found: 'env', a file path, or null when nowhere. */
36export type KeySource = { value: string; from: string } | null
37
38/** First non-empty value: the environment, then each file in order. */
39export async function findKey(
40 name: KeyName,
41 fromEnv: string | undefined,
42 files: readonly string[],
43 read: (path: string) => Promise<string>,
44): Promise<KeySource> {
45 if (fromEnv && fromEnv.trim() !== '') return { value: fromEnv.trim(), from: 'env' }
46 for (const f of files) {
47 let text: string
48 try {
49 text = await read(f)
50 } catch {
51 continue // missing or unreadable: try the next one
52 }
53 const v = parseDotenv(text)[name]
54 if (v && v.trim() !== '') return { value: v.trim(), from: f }
55 }
56 return null
57}
58
59export async function resolveKey(...args: Parameters<typeof findKey>): Promise<string | undefined> {
60 return (await findKey(...args))?.value
61}
62
63/** /sticky-note doctor: where each key comes from, never its value. */
64export function describeKey(name: KeyName, found: KeySource): string {
65 if (found === null) return `${name}:找不到(環境變數與 key 檔都沒有)`
66 return `${name}:有(${found.from === 'env' ? '環境變數' : found.from})`
67}
68hooks/tree.ts 419 lines1// Tree data model: pure operations on an in-memory Tree, plus the $.store layout.
2//
3// Layout (PLAN.md「資料模型」): one key per node, outline and session, so two
4// sessions writing at once never overwrite each other (PROBE.md P3):
5// node:<root>:<id> outline:<root>:<id> active:<root>:<sessionId> meta:<root>
6// `roots` and every node's `children` are derived from parentId on load, never
7// stored, so adding a child never rewrites its parent's key.
8import type { Anchor, AnsweredBy, MainDigest, Outline, RouteSource, Tree, TreeMeta, TreeNode } from '../types'
9import type { KV } from './ports'
10
11export const TITLE_MAX = 12
12
13export function emptyTree(): Tree {
14 return { nodes: {}, roots: [], outlines: {}, activeThread: {}, tags: [], mainDigest: null }
15}
16
17/**
18 * The project key: repo root (shared across worktrees) or session root, in one
19 * spelling — forward slashes, no trailing slash, lower-case drive letter — since
20 * $.session.repo().root and $.session.root() may spell the same folder differently.
21 */
22export function normalizeRoot(root: string): string {
23 return root
24 .replace(/\\/g, '/')
25 .replace(/\/+$/, '')
26 .replace(/^([A-Za-z]):/, (_, d: string) => `${d.toLowerCase()}:`)
27}
28
29export const keys = {
30 node: (root: string, id: string) => `node:${root}:${id}`,
31 outline: (root: string, id: string) => `outline:${root}:${id}`,
32 active: (root: string, sessionId: string) => `active:${root}:${sessionId}`,
33 meta: (root: string) => `meta:${root}`,
34}
35
36/** First TITLE_MAX characters (code points, so CJK and emoji are not split). */
37export function fallbackTitle(question: string): string {
38 return [...question.replace(/\s+/g, ' ').trim()].slice(0, TITLE_MAX).join('')
39}
40
41/** Depth-first rows for the pane: each node with its depth, children in anchor.at order. */
42export function flatten(tree: Tree, rootIds: readonly string[] = tree.roots): { node: TreeNode; depth: number }[] {
43 const out: { node: TreeNode; depth: number }[] = []
44 const walk = (id: string, depth: number) => {
45 const node = tree.nodes[id]
46 if (!node || depth > 64) return
47 out.push({ node, depth })
48 for (const c of node.children) walk(c, depth + 1)
49 }
50 for (const id of rootIds) walk(id, 0)
51 return out
52}
53
54/** Recomputes roots and children from parentId; a node whose parent is gone counts as a root. */
55export function derive(tree: Tree): Tree {
56 const byAt = (a: TreeNode, b: TreeNode) => a.anchor.at - b.anchor.at || a.id.localeCompare(b.id)
57 const all = Object.values(tree.nodes).sort(byAt)
58 const children: Record<string, string[]> = {}
59 const roots: string[] = []
60 for (const n of all) {
61 if (n.parentId !== null && tree.nodes[n.parentId]) (children[n.parentId] ??= []).push(n.id)
62 else roots.push(n.id)
63 }
64 const nodes: Tree['nodes'] = {}
65 for (const n of all) nodes[n.id] = { ...n, children: children[n.id] ?? [] }
66 return { ...tree, nodes, roots }
67}
68
69export type NewNode = {
70 id: string
71 question: string
72 anchor: Anchor
73 route: { label: string; confidence: number; source: RouteSource }
74 answeredBy: AnsweredBy
75 answer?: string
76 title?: string
77 tags?: string[]
78 /** Already promoted at creation (project_question bookmark). */
79 promotedAt?: number | null
80 promotedIn?: string | null
81}
82
83export type Attach =
84 /** New root. */
85 | { kind: 'root' }
86 /** Under this session's activeThread; a root when it has none. */
87 | { kind: 'followup' }
88 /** Under an explicit parent (pane follow-up box). */
89 | { kind: 'under'; parentId: string }
90
91/**
92 * Adds a node. By default it becomes this session's activeThread; a
93 * project_question bookmark passes `makeActive: false`.
94 * A follow-up whose parent is gone becomes a root rather than failing.
95 */
96export function addNode(
97 tree: Tree,
98 sessionId: string,
99 input: NewNode,
100 attach: Attach,
101 opts: { makeActive?: boolean } = {},
102): { tree: Tree; node: TreeNode } {
103 if (tree.nodes[input.id]) throw new Error(`node ${input.id} already exists`)
104 const wanted =
105 attach.kind === 'under' ? attach.parentId : attach.kind === 'followup' ? (tree.activeThread[sessionId] ?? null) : null
106 const parentId = wanted !== null && tree.nodes[wanted] ? wanted : null
107
108 const node: TreeNode = {
109 id: input.id,
110 parentId,
111 outlineId: null,
112 anchor: input.anchor,
113 question: input.question,
114 answer: input.answer ?? '',
115 answeredBy: input.answeredBy,
116 title: input.title ?? fallbackTitle(input.question),
117 summary: null,
118 tags: input.tags ?? [],
119 route: input.route,
120 promotedAt: input.promotedAt ?? null,
121 promotedIn: input.promotedIn ?? null,
122 children: [],
123 }
124
125 const outlines = parentId === null ? tree.outlines : markStale(tree, rootOf(tree, parentId), input.anchor.at)
126 const activeThread = opts.makeActive === false ? tree.activeThread : { ...tree.activeThread, [sessionId]: node.id }
127 const next = derive({ ...tree, nodes: { ...tree.nodes, [node.id]: node }, outlines, activeThread })
128 return { tree: next, node: next.nodes[node.id]! }
129}
130
131/** A new follow-up under an outline member makes that outline stale. */
132function markStale(tree: Tree, rootId: string, at: number): Tree['outlines'] {
133 const outlineId = tree.nodes[rootId]?.outlineId
134 if (!outlineId) return tree.outlines
135 const outline = tree.outlines[outlineId]
136 if (!outline || outline.staleSince !== null) return tree.outlines
137 return { ...tree.outlines, [outlineId]: { ...outline, staleSince: at } }
138}
139
140export function rootOf(tree: Tree, id: string): string {
141 let cur = tree.nodes[id]
142 if (!cur) throw new Error(`no node ${id}`)
143 const seen = new Set<string>()
144 while (cur.parentId !== null) {
145 if (seen.has(cur.id)) throw new Error(`cycle at ${cur.id}`)
146 seen.add(cur.id)
147 const parent: TreeNode | undefined = tree.nodes[cur.parentId]
148 if (!parent) break
149 cur = parent
150 }
151 return cur.id
152}
153
154/** Root → … → id. */
155export function pathTo(tree: Tree, id: string): TreeNode[] {
156 const out: TreeNode[] = []
157 let cur = tree.nodes[id]
158 while (cur) {
159 out.unshift(cur)
160 cur = cur.parentId === null ? undefined : tree.nodes[cur.parentId]
161 }
162 return out
163}
164
165/** The Q/A chain a follow-up is asked over (answered nodes only). */
166export function sideHistory(tree: Tree, id: string | null): { question: string; answer: string }[] {
167 if (id === null || !tree.nodes[id]) return []
168 return pathTo(tree, id)
169 .filter(n => n.answer !== '')
170 .map(n => ({ question: n.question, answer: n.answer }))
171}
172
173/** Pane click / "回到主線" (null). Unknown ids are ignored. */
174export function setActive(tree: Tree, sessionId: string, id: string | null): Tree {
175 if (id !== null && !tree.nodes[id]) return tree
176 return { ...tree, activeThread: { ...tree.activeThread, [sessionId]: id } }
177}
178
179export type NodePatch = Partial<Pick<TreeNode, 'answer' | 'answeredBy' | 'title' | 'summary' | 'tags'>>
180
181export function updateNode(tree: Tree, id: string, patch: NodePatch): Tree {
182 const node = tree.nodes[id]
183 if (!node) return tree
184 const title = patch.title === undefined ? node.title : [...patch.title].slice(0, TITLE_MAX).join('') || node.title
185 return { ...tree, nodes: { ...tree.nodes, [id]: { ...node, ...patch, title } } }
186}
187
188/** 回流主線: promotion only ever goes into the session it was pressed in. */
189export function markPromoted(tree: Tree, id: string, sessionId: string, at: number): Tree {
190 const node = tree.nodes[id]
191 if (!node) return tree
192 return { ...tree, nodes: { ...tree.nodes, [id]: { ...node, promotedAt: at, promotedIn: sessionId } } }
193}
194
195/** Removes a node and its whole subtree (重新分類 sends the question to the main line instead). */
196export function removeNode(tree: Tree, id: string): Tree {
197 const node = tree.nodes[id]
198 if (!node) return tree
199 const gone = new Set<string>()
200 const walk = (nid: string) => {
201 gone.add(nid)
202 for (const c of tree.nodes[nid]?.children ?? []) walk(c)
203 }
204 walk(id)
205
206 const nodes: Tree['nodes'] = {}
207 for (const [k, v] of Object.entries(tree.nodes)) if (!gone.has(k)) nodes[k] = v
208
209 const outlines: Tree['outlines'] = {}
210 for (const [k, o] of Object.entries(tree.outlines)) {
211 const memberIds = o.memberIds.filter(m => !gone.has(m))
212 if (memberIds.length > 0) outlines[k] = { ...o, memberIds }
213 }
214
215 const activeThread: Tree['activeThread'] = {}
216 for (const [s, a] of Object.entries(tree.activeThread)) activeThread[s] = a !== null && gone.has(a) ? node.parentId : a
217
218 return derive({ ...tree, nodes, outlines, activeThread })
219}
220
221export type NewOutline = { id: string; title: string; outline: string; memberIds: string[]; createdAt: number }
222
223/**
224 * Groups roots under a new outline. Only roots may be members; a root already in
225 * another outline is moved (returned in `moved` so the UI can say so), and an
226 * outline left with no members is deleted.
227 */
228export function createOutline(tree: Tree, input: NewOutline): { tree: Tree; outline: Outline; moved: string[] } {
229 const members = [...new Set(input.memberIds)]
230 if (members.length === 0) throw new Error('an outline needs at least one root')
231 if (tree.outlines[input.id]) throw new Error(`outline ${input.id} already exists`)
232 for (const m of members) {
233 if (tree.outlines[m]) throw new Error('an outline cannot be a member of an outline')
234 const n = tree.nodes[m]
235 if (!n) throw new Error(`no node ${m}`)
236 if (n.parentId !== null) throw new Error(`node ${m} is not a root`)
237 }
238
239 const moved = members.filter(m => tree.nodes[m]!.outlineId !== null)
240 const outlines: Tree['outlines'] = {}
241 for (const [k, o] of Object.entries(tree.outlines)) {
242 const memberIds = o.memberIds.filter(m => !members.includes(m))
243 if (memberIds.length > 0) outlines[k] = { ...o, memberIds }
244 }
245 const outline: Outline = { ...input, memberIds: members, staleSince: null }
246 outlines[outline.id] = outline
247
248 const nodes = { ...tree.nodes }
249 for (const m of members) nodes[m] = { ...nodes[m]!, outlineId: outline.id }
250 return { tree: { ...tree, nodes, outlines }, outline, moved }
251}
252
253/** 重算: new outline text, clears the stale mark. */
254export function refreshOutline(tree: Tree, id: string, outlineText: string): Tree {
255 const o = tree.outlines[id]
256 if (!o) return tree
257 return { ...tree, outlines: { ...tree.outlines, [id]: { ...o, outline: outlineText, staleSince: null } } }
258}
259
260/** Deleting an outline breaks nothing: members just become ungrouped roots. */
261export function deleteOutline(tree: Tree, id: string): Tree {
262 const o = tree.outlines[id]
263 if (!o) return tree
264 const nodes = { ...tree.nodes }
265 for (const m of o.memberIds) if (nodes[m]) nodes[m] = { ...nodes[m]!, outlineId: null }
266 const outlines = { ...tree.outlines }
267 delete outlines[id]
268 return { ...tree, nodes, outlines }
269}
270
271export function addTag(tree: Tree, tag: string): Tree {
272 const t = tag.trim()
273 if (t === '' || tree.tags.includes(t)) return tree
274 return { ...tree, tags: [...tree.tags, t] }
275}
276
277export function setMainDigest(tree: Tree, digest: MainDigest): Tree {
278 return { ...tree, mainDigest: digest }
279}
280
281/** Roots for the pane, optionally only those asked in one session. */
282export function visibleRoots(tree: Tree, onlySessionId?: string): TreeNode[] {
283 return tree.roots
284 .map(id => tree.nodes[id])
285 .filter((n): n is TreeNode => n !== undefined)
286 .filter(n => onlySessionId === undefined || n.anchor.sessionId === onlySessionId)
287}
288
289export function countNodes(tree: Tree): number {
290 return Object.keys(tree.nodes).length
291}
292
293// ---- $.store layout ---------------------------------------------------------
294
295const isObj = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object'
296
297function readMeta(raw: unknown): TreeMeta {
298 if (!isObj(raw) || raw.version !== 2) return { version: 2, tags: [], mainDigest: null }
299 return {
300 version: 2,
301 tags: Array.isArray(raw.tags) ? raw.tags.filter((t): t is string => typeof t === 'string') : [],
302 mainDigest: (raw.mainDigest as MainDigest | null | undefined) ?? null,
303 }
304}
305
306/** Fills fields older versions did not write (messageId; promotedTo → promotedIn). */
307function upgradeNode(raw: Record<string, unknown>): TreeNode {
308 const n = raw as Partial<TreeNode> & { promotedTo?: string | null }
309 const { promotedTo, ...rest } = n
310 return {
311 ...(rest as TreeNode),
312 anchor: { ...(n.anchor as TreeNode['anchor']), messageId: n.anchor?.messageId ?? null },
313 promotedIn: n.promotedIn ?? promotedTo ?? null,
314 children: [],
315 }
316}
317
318/**
319 * Moves a tree saved by the first M0 build (one `tree:<root>` key) into the
320 * per-node layout, then deletes the old key. Returns how many nodes moved.
321 */
322export async function migrateLegacy(store: KV, root: string): Promise<number> {
323 const r = normalizeRoot(root)
324 let moved = 0
325 for (const key of await store.keys()) {
326 if (!key.startsWith('tree:') || normalizeRoot(key.slice(5)) !== r) continue
327 const raw = await store.get(key)
328 if (isObj(raw) && isObj(raw.nodes)) {
329 for (const n of Object.values(raw.nodes)) {
330 if (!isObj(n) || typeof n.id !== 'string') continue
331 if ((await store.get(keys.node(r, n.id))) === undefined) {
332 await store.set(keys.node(r, n.id), { ...upgradeNode(n), children: [] })
333 moved++
334 }
335 }
336 if (isObj(raw.activeThread)) {
337 for (const [sid, a] of Object.entries(raw.activeThread)) {
338 if ((await store.get(keys.active(r, sid))) === undefined) await store.set(keys.active(r, sid), typeof a === 'string' ? a : null)
339 }
340 }
341 if (isObj(raw.outlines)) {
342 for (const o of Object.values(raw.outlines)) if (isObj(o) && typeof o.id === 'string') await store.set(keys.outline(r, o.id), o)
343 }
344 if ((await store.get(keys.meta(r))) === undefined) {
345 const tags = Array.isArray(raw.tags) ? raw.tags.filter((t): t is string => typeof t === 'string') : []
346 const meta: TreeMeta = { version: 2, tags, mainDigest: (raw.mainDigest as MainDigest | null | undefined) ?? null }
347 await store.set(keys.meta(r), meta)
348 }
349 }
350 await store.delete(key)
351 }
352 return moved
353}
354
355/** Assembles one project's tree from its keys. */
356export async function loadTree(store: KV, root: string): Promise<Tree> {
357 const r = normalizeRoot(root)
358 const all = await store.keys()
359 const tree = emptyTree()
360 const meta = readMeta(await store.get(keys.meta(r)))
361 tree.tags = meta.tags
362 tree.mainDigest = meta.mainDigest
363 for (const key of all) {
364 if (key.startsWith(keys.node(r, ''))) {
365 const n = await store.get(key)
366 if (isObj(n) && typeof n.id === 'string') tree.nodes[n.id] = upgradeNode(n)
367 } else if (key.startsWith(keys.outline(r, ''))) {
368 const o = await store.get(key)
369 if (isObj(o) && typeof o.id === 'string') tree.outlines[o.id] = o as Outline
370 } else if (key.startsWith(keys.active(r, ''))) {
371 const a = await store.get(key)
372 tree.activeThread[key.slice(keys.active(r, '').length)] = typeof a === 'string' ? a : null
373 }
374 }
375 return derive(tree)
376}
377
378/** A node as stored: children are derived, so they are not kept. */
379const stored = (n: TreeNode) => JSON.stringify({ ...n, children: [] })
380
381/** Writes only the keys whose value changed between `before` and `after`; deletes the ones that went away. */
382export async function saveDiff(store: KV, root: string, before: Tree, after: Tree): Promise<number> {
383 const r = normalizeRoot(root)
384 let writes = 0
385 for (const [id, n] of Object.entries(after.nodes)) {
386 const old = before.nodes[id]
387 if (!old || stored(old) !== stored(n)) {
388 await store.set(keys.node(r, id), { ...n, children: [] })
389 writes++
390 }
391 }
392 for (const id of Object.keys(before.nodes)) if (!after.nodes[id]) await store.delete(keys.node(r, id)), writes++
393 for (const [id, o] of Object.entries(after.outlines)) {
394 if (JSON.stringify(before.outlines[id]) !== JSON.stringify(o)) await store.set(keys.outline(r, id), o), writes++
395 }
396 for (const id of Object.keys(before.outlines)) if (!after.outlines[id]) await store.delete(keys.outline(r, id)), writes++
397 for (const [sid, a] of Object.entries(after.activeThread)) {
398 if (before.activeThread[sid] !== a) await store.set(keys.active(r, sid), a), writes++
399 }
400 if (JSON.stringify(before.tags) !== JSON.stringify(after.tags) || JSON.stringify(before.mainDigest) !== JSON.stringify(after.mainDigest)) {
401 const meta: TreeMeta = { version: 2, tags: after.tags, mainDigest: after.mainDigest }
402 await store.set(keys.meta(r), meta)
403 writes++
404 }
405 return writes
406}
407
408/**
409 * Load, apply, write back only what changed. Two sessions changing different
410 * nodes never collide; only a simultaneous edit of the same node or outline
411 * is last-writer-wins.
412 */
413export async function mutateTree(store: KV, root: string, fn: (tree: Tree) => Tree): Promise<Tree> {
414 const before = await loadTree(store, root)
415 const after = derive(fn(before))
416 await saveDiff(store, root, before, after)
417 return after
418}
419hooks/feedback.ts 158 lines1// Routing feedback: every routing decision is kept as a sample, the user can say
2// it was right or wrong, and calibrate() shows what each threshold would do.
3// The interface for tuning Jev later (PROBE.md D15); the UI is minimal for now:
4// /sticky-note feedback, the $.ui.ask answer, and (M2) the pane's 重新分類.
5//
6// $.store `route:<root>:<id>`, one key per sample (no shared key to race on),
7// at most SAMPLE_CAP per project. Prompts stay in the local store only.
8import type { RouteChoice, RouteFeedback, RouteSample } from '../types'
9import type { JevVerdict } from './jev'
10import type { KV } from './ports'
11import type { Decision } from './route'
12import { normalizeRoot } from './tree'
13
14export const SAMPLE_CAP = 500
15export const PROMPT_KEEP = 200
16
17const prefix = (root: string) => `route:${normalizeRoot(root)}:`
18
19/** Time-ordered id: base-36 epoch ms, zero-padded, so key order is time order. */
20export function sampleId(at: number, rand: string): string {
21 return `${at.toString(36).padStart(9, '0')}-${rand.slice(0, 8)}`
22}
23
24export function choiceOf(d: Decision): RouteSample['decision'] {
25 switch (d.kind) {
26 case 'sidebar':
27 return d.attach === 'followup' ? 'followup' : 'sidebar'
28 default:
29 return d.kind
30 }
31}
32
33export function sampleFrom(a: {
34 id: string
35 at: number
36 sessionId: string
37 prompt: string
38 verdict: JevVerdict | null
39 thresholds: RouteSample['thresholds']
40 decision: Decision
41 nodeId: string | null
42 feedback?: RouteFeedback | null
43}): RouteSample {
44 return {
45 id: a.id,
46 at: a.at,
47 sessionId: a.sessionId,
48 prompt: [...a.prompt].slice(0, PROMPT_KEEP).join(''),
49 jev: a.verdict && {
50 route: a.verdict.route.label,
51 confidence: a.verdict.route.confidence,
52 isFollowup: a.verdict.isFollowup,
53 needsProjectCtx: a.verdict.needsProjectCtx,
54 tag: a.verdict.tag?.label ?? null,
55 },
56 thresholds: a.thresholds,
57 decision: choiceOf(a.decision),
58 nodeId: a.nodeId,
59 feedback: a.feedback ?? null,
60 }
61}
62
63/** Stores a sample and drops the oldest beyond the cap. */
64export async function recordSample(store: KV, root: string, sample: RouteSample): Promise<void> {
65 await store.set(prefix(root) + sample.id, sample)
66 const mine = (await store.keys()).filter(k => k.startsWith(prefix(root))).sort()
67 for (const k of mine.slice(0, Math.max(0, mine.length - SAMPLE_CAP))) await store.delete(k)
68}
69
70export async function setFeedback(store: KV, root: string, id: string, feedback: RouteFeedback): Promise<RouteSample | null> {
71 const raw = (await store.get(prefix(root) + id)) as RouteSample | undefined
72 if (!raw) return null
73 const next = { ...raw, feedback }
74 await store.set(prefix(root) + id, next)
75 return next
76}
77
78export async function listSamples(store: KV, root: string): Promise<RouteSample[]> {
79 const out: RouteSample[] = []
80 for (const k of (await store.keys()).filter(k => k.startsWith(prefix(root))).sort()) {
81 const s = (await store.get(k)) as RouteSample | undefined
82 if (s) out.push(s)
83 }
84 return out
85}
86
87const CHOICES: Record<string, RouteChoice> = {
88 main: 'main',
89 主線: 'main',
90 sidebar: 'sidebar',
91 旁支: 'sidebar',
92 note: 'sidebar',
93 followup: 'followup',
94 追問: 'followup',
95 project: 'project_question',
96 project_question: 'project_question',
97 專案: 'project_question',
98}
99
100/** `good` / `bad [main|sidebar|followup|project]` (or 對 / 錯 and the Chinese labels). */
101export function parseFeedback(args: string): { verdict: 'right' | 'wrong'; expected: RouteChoice | null } | null {
102 const [v, e] = args.trim().split(/\s+/)
103 const verdict = v === 'good' || v === '對' ? 'right' : v === 'bad' || v === '錯' ? 'wrong' : null
104 if (verdict === null) return null
105 const expected = e ? (CHOICES[e.toLowerCase()] ?? null) : null
106 if (e && expected === null) return null
107 return { verdict, expected }
108}
109
110/** main / sidebar / project: sidebar and followup are the same route, they differ in attachment. */
111const family = (c: string): 'main' | 'sidebar' | 'project_question' =>
112 c === 'main' || c === 'main_task' ? 'main' : c === 'project_question' ? 'project_question' : 'sidebar'
113
114/** The truth a sample carries, if the user said anything. */
115export function truthOf(s: RouteSample): RouteChoice | null {
116 if (!s.feedback) return null
117 if (s.feedback.expected) return s.feedback.expected
118 if (s.feedback.verdict === 'right' && s.decision !== 'ask') return s.decision
119 return null
120}
121
122export type CalibrationRow = {
123 threshold: number
124 /** Labeled samples Jev would have routed by itself at this threshold. */
125 auto: number
126 /** …of which Jev's route matched what the user said. */
127 correct: number
128 /** correct / auto (null when nothing would be auto-routed). */
129 accuracy: number | null
130 /** Share of labeled samples that would have asked. */
131 askRate: number | null
132}
133
134/** For each candidate route threshold: how many labeled samples route by themselves, and how many of those are right. */
135export function calibrate(samples: readonly RouteSample[], candidates: readonly number[] = [0.5, 0.6, 0.7, 0.8, 0.9]): { labeled: number; rows: CalibrationRow[] } {
136 const labeled = samples.filter(s => s.jev !== null && truthOf(s) !== null)
137 const rows = candidates.map(threshold => {
138 const auto = labeled.filter(s => s.jev!.confidence >= threshold)
139 const correct = auto.filter(s => family(s.jev!.route) === family(truthOf(s)!)).length
140 return {
141 threshold,
142 auto: auto.length,
143 correct,
144 accuracy: auto.length ? correct / auto.length : null,
145 askRate: labeled.length ? 1 - auto.length / labeled.length : null,
146 }
147 })
148 return { labeled: labeled.length, rows }
149}
150
151export function formatCalibration(c: ReturnType<typeof calibrate>): string[] {
152 const pct = (x: number | null) => (x === null ? '—' : `${Math.round(x * 100)}%`)
153 return [
154 `路由校正:${c.labeled} 筆有標記`,
155 ...c.rows.map(r => `門檻 ${r.threshold.toFixed(2)} · 自動 ${r.auto} 筆,對 ${r.correct}(${pct(r.accuracy)})· 會詢問 ${pct(r.askRate)}`),
156 ]
157}
158hooks/answer.ts 73 lines1// Side answers: $.model.fork when the question needs the project, else the summary provider.
2import type { ModelForkResult } from 'claude-code'
3
4import type { AnsweredBy } from '../types'
5import { LANGUAGE_RULE } from './digest'
6import type { SummaryProvider } from './providers/types'
7
8export type QA = { question: string; answer: string }
9
10const FORK_FRAME =
11 '[sticky-notes] The user asked this as an aside. Answer the aside directly and concisely; ' +
12 'do not continue, plan or change the main task, and do not call tools. ' +
13 LANGUAGE_RULE
14
15/**
16 * ModelForkRequest is `{ prompt }` only (no system, no extra messages), so the
17 * side thread's history is written into the prompt text. See PROBE.md.
18 */
19export function buildForkPrompt(history: readonly QA[], question: string): string {
20 const parts = [FORK_FRAME]
21 if (history.length > 0) {
22 parts.push('', '[side thread so far]')
23 history.forEach((qa, i) => parts.push(`Q${i + 1}: ${qa.question}`, `A${i + 1}: ${qa.answer}`))
24 }
25 parts.push('', '[question]', question)
26 return parts.join('\n')
27}
28
29export const KNOWLEDGE_SYSTEM =
30 'You answer short background-knowledge questions for a developer. Be accurate and concise; ' +
31 'use Markdown. You do not see their project, so do not guess about it. ' +
32 LANGUAGE_RULE
33
34export function buildKnowledgePrompt(history: readonly QA[], question: string): string {
35 const lines = history.flatMap((qa, i) => [`Q${i + 1}: ${qa.question}`, `A${i + 1}: ${qa.answer}`])
36 return (lines.length ? `Earlier in this thread:\n${lines.join('\n')}\n\n` : '') + `Question: ${question}`
37}
38
39export type SideAnswer =
40 | { ok: true; text: string; answeredBy: AnsweredBy }
41 | { ok: false; reason: string }
42
43export async function answerSide(
44 fork: (prompt: string) => Promise<ModelForkResult>,
45 q: { question: string; history: readonly QA[]; answerer: 'fork' | 'summary-provider' },
46 provider: SummaryProvider | null,
47): Promise<SideAnswer> {
48 if (q.answerer === 'summary-provider' && provider) {
49 const r = await provider.summarize('knowledge-answer', {
50 system: KNOWLEDGE_SYSTEM,
51 prompt: buildKnowledgePrompt(q.history, q.question),
52 maxTokens: 1500,
53 hasProjectContent: false,
54 })
55 if (r.ok) return { ok: true, text: r.text, answeredBy: r.provider }
56 // fall through to the fork: PLAN.md「缺 OpenAI → 全部走 $.model.fork」
57 }
58 const r = await fork(buildForkPrompt(q.history, q.question))
59 if (r.isAnswered) return { ok: true, text: r.text, answeredBy: 'claude-fork' }
60 if (r.reason === 'nothing-to-fork' && provider) {
61 // A brand-new session (or right after /clear) has no transcript to fork:
62 // answer without project context through the summary provider.
63 const c = await provider.summarize('knowledge-answer', {
64 system: KNOWLEDGE_SYSTEM,
65 prompt: buildKnowledgePrompt(q.history, q.question),
66 maxTokens: 1500,
67 hasProjectContent: false,
68 })
69 return c.ok ? { ok: true, text: c.text, answeredBy: c.provider } : { ok: false, reason: c.reason }
70 }
71 return { ok: false, reason: r.reason }
72}
73hooks/digest.ts 48 lines1// Summary layer jobs. M1: the automatic title + three-sentence summary per node.
2// Outline / main digest / tag merge / digest-export are manual jobs for M3–M4.
3import type { SummaryProvider } from './providers/types'
4import { fallbackTitle, TITLE_MAX } from './tree'
5
6/** Same language as the question; Chinese is always Traditional Chinese (Taiwan usage). */
7export const LANGUAGE_RULE =
8 'Write in the same language as the question. If the question is in Chinese, write in Traditional Chinese ' +
9 'as used in Taiwan (繁體中文), never Simplified Chinese.'
10
11const TITLE_SYSTEM =
12 `Write a title of at most ${TITLE_MAX} characters and a summary of exactly three sentences for this Q/A. ` +
13 `${LANGUAGE_RULE} Reply with JSON only: {"title": "...", "summary": "..."}`
14
15export function titlePrompt(question: string, answer: string): string {
16 return `Question:\n${question}\n\nAnswer:\n${answer.slice(0, 6000)}`
17}
18
19/** Accepts a bare JSON object or one wrapped in a ```json fence. */
20export function parseTitleSummary(text: string): { title: string; summary: string } | null {
21 const m = /\{[\s\S]*\}/.exec(text)
22 if (!m) return null
23 try {
24 const o = JSON.parse(m[0]) as { title?: unknown; summary?: unknown }
25 if (typeof o.title !== 'string' || typeof o.summary !== 'string' || o.title.trim() === '') return null
26 return { title: [...o.title.trim()].slice(0, TITLE_MAX).join(''), summary: o.summary.trim() }
27 } catch {
28 return null
29 }
30}
31
32/** Never fails: falls back to the question's first 12 chars and no summary. */
33export async function titleAndSummary(
34 provider: SummaryProvider,
35 question: string,
36 answer: string,
37): Promise<{ title: string; summary: string | null; tokens: number }> {
38 const r = await provider.summarize('node-title-summary', {
39 system: TITLE_SYSTEM,
40 prompt: titlePrompt(question, answer),
41 maxTokens: 400,
42 hasProjectContent: true,
43 })
44 const parsed = r.ok ? parseTitleSummary(r.text) : null
45 if (!parsed) return { title: fallbackTitle(question), summary: null, tokens: r.ok ? r.tokens : 0 }
46 return { ...parsed, tokens: r.ok ? r.tokens : 0 }
47}
48hooks/jev.ts 151 lines1// TypeSafe Jev client. The only file that knows Jev's request/response schema.
2// Schema source: third-party write-up (apidog.com/blog/what-is-jev), NOT yet checked
3// against console.typesafe.ai — see PROBE.md. Endpoint reachability is verified
4// (403 "Must supply an API key!" without a key).
5import type { HttpInit, HttpResponse } from 'claude-code'
6
7export const JEV_URL = 'https://api.typesafe.ai/v1/systemone'
8export const JEV_MODEL = 'jev-latest'
9
10export type RouteLabel = 'main_task' | 'sidebar_knowledge' | 'project_question'
11export const NEW_TAG = '__new__'
12
13/** What the rest of the mod sees: schema-free. */
14export type JevVerdict = {
15 route: { label: RouteLabel; confidence: number }
16 /** Probability that the prompt continues the active thread. */
17 isFollowup: number
18 /** Probability that answering needs the project's code or conversation. */
19 needsProjectCtx: number
20 /** Absent when there were no tags to choose from. */
21 tag?: { label: string; confidence: number }
22}
23
24export type JevInput = {
25 /** Last user prompts (oldest first) and the last assistant reply, raw. */
26 recentUser: string[]
27 lastAssistant: string | null
28 activeTitle: string | null
29 activeLastQuestion: string | null
30 mainTurnsSinceLastSidebar: number
31 prompt: string
32 tags: string[]
33}
34
35const CLIP = 300
36
37const clip = (s: string) => ([...s].length > CLIP ? [...s].slice(0, CLIP).join('') + '…' : s)
38
39/** The `state` text, as PLAN.md「Jev 分類設計」lays it out. */
40export function buildState(input: JevInput): string {
41 const recent = [...input.recentUser.slice(-3).map(u => `user: ${clip(u)}`)]
42 if (input.lastAssistant !== null) recent.push(`assistant: ${clip(input.lastAssistant)}`)
43 return [
44 '[recent main-line context]',
45 recent.length ? recent.join('\n') : '(none)',
46 '',
47 '[sidebar state]',
48 `active_thread_title: ${input.activeTitle ?? 'none'}`,
49 `active_thread_last_question: ${input.activeLastQuestion ?? 'none'}`,
50 `main_turns_since_last_sidebar: ${input.mainTurnsSinceLastSidebar}`,
51 '',
52 '[new prompt]',
53 input.prompt,
54 ].join('\n')
55}
56
57export function buildRequest(input: JevInput): unknown {
58 const questions: Record<string, unknown> = {
59 route: {
60 type: 'choice',
61 instructions: 'Where should the new prompt go?',
62 criteria: {
63 main_task: 'Advances the project: an instruction, a code change, or a decision about what Claude just did',
64 sidebar_knowledge: 'Asks about a principle, concept or background knowledge; does not ask Claude to change anything',
65 project_question:
66 'A question about this project itself whose answer belongs in the main conversation: it refers to "we", "our", "here", ' +
67 '"this project" or to code and decisions in this conversation, e.g. "why do we use session cookies here instead of JWT?", ' +
68 '"為什麼我們這裡用 X?", "這個專案為什麼要這樣分層?"',
69 },
70 },
71 is_followup: {
72 type: 'noul',
73 instructions: 'Does the new prompt continue the topic of active_thread?',
74 },
75 needs_project_ctx: {
76 type: 'noul',
77 instructions: "Does answering the new prompt require seeing the project's code or conversation?",
78 },
79 }
80 if (input.tags.length > 0) {
81 const criteria: Record<string, string> = {}
82 for (const t of input.tags) criteria[t] = `About ${t}`
83 criteria[NEW_TAG] = 'None of the existing tags fits'
84 questions.tag = { type: 'choice', instructions: 'Which topic tag fits the new prompt?', criteria }
85 }
86 return { model: JEV_MODEL, state: buildState(input), questions }
87}
88
89type ChoiceAnswer = { type: 'choice'; choice: string; confidence: number }
90type NoulAnswer = { type: 'noul'; noul: number }
91
92const isRoute = (s: unknown): s is RouteLabel =>
93 s === 'main_task' || s === 'sidebar_knowledge' || s === 'project_question'
94
95/** Response → JevVerdict; null when the shape is not what we expect. */
96export function parseResponse(body: unknown): JevVerdict | null {
97 const answers = (body as { answers?: Record<string, unknown> } | null)?.answers
98 if (!answers) return null
99 const route = answers.route as ChoiceAnswer | undefined
100 const fu = answers.is_followup as NoulAnswer | undefined
101 const ctx = answers.needs_project_ctx as NoulAnswer | undefined
102 if (!route || !isRoute(route.choice) || typeof route.confidence !== 'number') return null
103 if (typeof fu?.noul !== 'number' || typeof ctx?.noul !== 'number') return null
104 const verdict: JevVerdict = {
105 route: { label: route.choice, confidence: route.confidence },
106 isFollowup: fu.noul,
107 needsProjectCtx: ctx.noul,
108 }
109 const tag = answers.tag as ChoiceAnswer | undefined
110 if (tag && typeof tag.choice === 'string' && typeof tag.confidence === 'number') {
111 verdict.tag = { label: tag.choice, confidence: tag.confidence }
112 }
113 return verdict
114}
115
116export type JevResult =
117 | { ok: true; verdict: JevVerdict; ms: number }
118 | { ok: false; reason: 'no-key' | 'http' | 'bad-response' | 'network'; status?: number; ms: number }
119
120export type JevDeps = {
121 key: string | undefined
122 fetch(url: string, init?: HttpInit): Promise<HttpResponse>
123 now(): Promise<number>
124}
125
126/** One classification call. Never throws; the caller degrades to $.ui.ask on !ok. */
127export async function classify(d: JevDeps, input: JevInput): Promise<JevResult> {
128 const t0 = await d.now()
129 const ms = async () => (await d.now()) - t0
130 const key = d.key
131 if (!key) return { ok: false, reason: 'no-key', ms: 0 }
132 try {
133 const r = await d.fetch(JEV_URL, {
134 method: 'POST',
135 headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
136 body: JSON.stringify(buildRequest(input)),
137 })
138 if (!r.ok) return { ok: false, reason: 'http', status: r.status, ms: await ms() }
139 let body: unknown
140 try {
141 body = JSON.parse(r.text)
142 } catch {
143 return { ok: false, reason: 'bad-response', status: r.status, ms: await ms() }
144 }
145 const verdict = parseResponse(body)
146 return verdict ? { ok: true, verdict, ms: await ms() } : { ok: false, reason: 'bad-response', status: r.status, ms: await ms() }
147 } catch {
148 return { ok: false, reason: 'network', ms: await ms() }
149 }
150}
151hooks/providers/claude.ts 36 lines1// Summary layer over the user's Claude subscription ($.model.complete).
2import type { ModelCompleteRequest, ModelCompleteResult, ModelEffort } from 'claude-code'
3
4import type { SummaryInput, SummaryJob, SummaryProvider, SummaryResult } from './types'
5
6const EFFORTS: readonly ModelEffort[] = ['low', 'medium', 'high', 'xhigh', 'max']
7
8export function asEffort(value: unknown): ModelEffort {
9 return EFFORTS.includes(value as ModelEffort) ? (value as ModelEffort) : 'low'
10}
11
12export type ClaudeDeps = {
13 complete(request: ModelCompleteRequest): Promise<ModelCompleteResult>
14 log(text: string): void
15}
16
17export function claudeProvider(d: ClaudeDeps, opts: { model: string; effort: ModelEffort; timeoutMs?: number }): SummaryProvider {
18 return {
19 name: 'claude',
20 async summarize(job: SummaryJob, input: SummaryInput): Promise<SummaryResult> {
21 const r = await d.complete({
22 model: opts.model,
23 effort: opts.effort,
24 system: input.system,
25 prompt: input.prompt,
26 maxTokens: input.maxTokens,
27 timeoutMs: opts.timeoutMs ?? 60_000,
28 })
29 const tokens = r.usage.input_tokens + r.usage.output_tokens + r.usage.cache_creation_input_tokens + r.usage.cache_read_input_tokens
30 d.log(`claude: ${job} · ${tokens} tokens`)
31 if (r.isAnswered) return { ok: true, text: r.text, tokens, provider: 'claude' }
32 return { ok: false, reason: r.reason === 'aborted' ? 'aborted' : 'error', detail: r.reason, provider: 'claude' }
33 },
34 }
35}
36