Claude の応答が流れてくる途中から Mermaid で図解する Mod。図にすべきか・どの図か・もう描いていいかの判断だけを Jev(TypeSafe AI の System One モデル)に確率で聞き、Mermaid のソースは決定的に組み立てる。

Claude の応答を、書き終わるのを待たずに Mermaid の図にする Claude Mod。
応答が流れてくる途中で図を出そうとすると、ふつうは「図にするほどの内容か」も 「もう描いていい状態か」も分からないまま描いてしまい、壊れた図がちらつく。 ここでは、その判断だけを Jev(TypeSafe AI の System One モデル)に任せている。 Jev は文章を書かず、型のついた質問に確率で答えるだけのモデルなので、 「0.92 なら描く / 0.31 なら待つ」と閾値で切れる。
図の文字列そのものは Jev もモデルも作らない。応答テキストから決定的に組み立てる。 だから途中経過でも構文が壊れない。
flowchart LR
A["Claude の応答<br/>(streaming)"] -->|turn.step| B["mermaid-live<br/>チャンクを溜める"]
B -->|状態 + 型つき質問| C{"Jev<br/>System One"}
C -->|"diagrammable 0.92<br/>kind flowchart<br/>readiness 1.9"| D["描く / 待つ / 据え置く"]
D -->|描く| E["Mermaid を組み立てる<br/>(決定的、モデル不使用)"]
E --> F["current.mmd + state.json"]
F --> G["ブラウザのビューア<br/>(SSE で即反映)"]
D --> H["入力欄の上の帯<br/>判定と確率を表示"]
応答が伸びるのに合わせて図が育つ。左が材料 2 本の途中、右が応答が終わった時点。
図は
extract.ts→toMermaid()が実際に生成したもの。ただし 0.78 / 0.91 という確率は表示例で、Jev が返した値ではない(撮影時に API キーを持っていないため)。
| 途中経過 | 確定 |
|---|---|
![]() | ![]() |
手順を説明した応答なら flowchart になる(種類を選んでいるのは Jev)。

1 往復で 4〜5 問まとめて評価する(Jev は並列に答える)。
| id | 型 | 何を決めるか |
|---|---|---|
diagrammable | noul(yes/no の確率) | そもそも図にする価値があるか |
kind | choice | flowchart / sequence / state / mindmap / none |
readiness | score | 途中経過がどこまで描き切れる状態か |
direction | choice | TD か LR か |
changed | noul | 前に描いた図から意味が変わったか(ちらつき防止) |
changed が効くのが実感しやすい。これが無いと 1 秒ごとに描き直して画面が暴れる。
Jev は $0.042 / 100万入力トークン、出力は $0(自己回帰的に生成しないので課金対象の出力トークンが無い)。
この Mod の実測では 1000 応答あたり $0.07〜0.32。1 日 100 応答を毎日使っても月 $1〜3 程度。
node tools/cost.ts # 実際に送るリクエストを組み立てて見積もる(通信しない)
node tools/cost.ts answer.md # 自分の文章で
内訳、コストを下げるつまみ、普通の LLM に同じ判定をさせた場合との比較、出典は docs/pricing.md にまとめてある。
払うのは、この Mod を動かしているマシンの環境変数に入っているキーの持ち主。 Claude Code の利用料とは別勘定で、TypeSafe から直接請求される。 キーが未設定なら Jev は 1 度も呼ばれず、規則ベースで動く(料金ゼロ)。
~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1",
"TYPESAFE_API_KEY": "ts-...",
"MERMAID_LIVE_VIEWER": "1"
}
}
このフラグが無いと Mod は黙って読み込まれない。
試すだけなら:
claude --plugin-dir /path/to/mermaid-live --debug
入れっぱなしにするなら、リポジトリのルートに .claude-plugin/marketplace.json を置いてあるので、 このリポジトリ自体をマーケットプレイスとして追加できる:
claude plugin marketplace add vivi-0124/me
claude plugin install mermaid-live@vivi-mods
MERMAID_LIVE_VIEWER=1 なら、セッション開始時に自動で立ち上がる。 手で立てるなら:
node viewer/server.mjs # http://127.0.0.1:4737
ブラウザを開いたまま Claude に何か聞くと、答えが伸びるのに合わせて図が育つ。
node viewer/server.mjs &
npm run demo # 用意した応答を 1 行ずつ流し込む
npm run demo -- answer.md # 自分の文章で試す
| 変数 | 既定値 | 意味 |
|---|---|---|
MERMAID_LIVE_API_KEY / TYPESAFE_API_KEY / TYPESAFE_AI_API_KEY | なし | Jev のキー。無ければ規則ベース(料金) |
MERMAID_LIVE_MODEL | jev-latest | Jev のモデル |
MERMAID_LIVE_BASE_URL | https://api.typesafe.ai/v1 | Vercel AI Gateway 等に向けるとき |
MERMAID_LIVE_DIR | .claude/mermaid-live | current.mmd と state.json の置き場 |
MERMAID_LIVE_PORT | 4737 | ビューアのポート |
MERMAID_LIVE_VIEWER | なし | 1 でセッション開始時にビューアを自動起動 |
MERMAID_LIVE_MIN_GROWTH | 240 | 前回の判定から何文字増えたら次を聞くか |
MERMAID_LIVE_MIN_INTERVAL_MS | 1200 | 判定の最短間隔 |
MERMAID_LIVE_THRESHOLD | 0.55 | 描き始める確率のつまみ。上げると寡黙に、下げるとお喋りになる |
hooks/register.ts Mod 本体。turn.step / ui.render / session.start
hooks/lib/extract.ts 応答テキスト → 図の材料(純関数)
hooks/lib/mermaid.ts 材料 + 種類 → Mermaid ソース(純関数)
hooks/lib/questions.ts Jev に投げる型つき質問
hooks/lib/jev.ts System One API のクライアント
hooks/lib/decide.ts 確率 → 描く / 待つ / 据え置く
hooks/lib/buffer.ts チャンクを溜めて、いつ聞くかを決める
hooks/lib/band.ts 入力欄の上の帯
viewer/ 依存ゼロのビューア(SSE + Mermaid は CDN から)
tools/demo.ts Claude Code 抜きで通す
tools/cost.ts 1 応答あたりの Jev のコストを見積もる
docs/pricing.md Jev の料金と実測コスト
設計で効いているのは 3 点:
turn.step のフックがやるのは文字列の連結だけ。 Jev への往復と書き出しは別の Promise に逃がしてあり、チャンクはそのまま素通しする。npm test # 60 件(純関数 + Mod の通し試験)
npx tsc -p tsconfig.json # 型チェック(.claude/types が要る)
claude plugin validate . # Mod の形と $ の呼び出しを検査
tsconfig.json が見る .claude/types/claude-code.d.ts は、Claude Code の セッション内スラッシュコマンド /plugin-types が書き出す。 ビルドごとに再生成されるのでコミットしない(.gitignore 済み)。
npm test は Claude Code を立ち上げずに Mod 全体を通す。$ と Jev と turn.step のストリームを偽物に差し替えて register.ts をそのまま動かしているので、 チャンクを溜める → Jev に聞く → .mmd を書く、の一本道が壊れたら落ちる。
$ の形もイベント名もリリース間で変わりうる。 変わったら /plugin-types で型を取り直して tsc に通すのが早い。viewer/index.html の import を手元のファイルに差し替える必要がある。A -> B の矢印が主な手がかりなので、 地の文だけで説明された手順は拾いきれない。ここは hooks/lib/extract.ts を育てる場所。mods/hooks/register.ts 316 lines1/**
2 * mermaid-live — Claude の応答を、流れてくる途中から Mermaid で図解する Mod。
3 *
4 * 役割分担:
5 * turn.step … 応答のテキストチャンクを溜める(流れは一切止めない)
6 * Jev … 「図にすべきか」「どの図か」「もう描いていいか」を確率で答える
7 * mermaid.ts … 実際の Mermaid ソースを決定的に組み立てる
8 * ui.render … 入力欄の上に判定と先頭数行を出す
9 * viewer … ブラウザで本物の図を描く(.mmd の更新を SSE で流す)
10 *
11 * 文章生成モデルに「図にして」と頼むとストリーミング中は必ず途中で構文が壊れる。
12 * Jev は文章を作らない代わりに判断だけを返すので、壊れた図が出ない。
13 */
14
15import type { Register } from 'claude-code'
16
17import { extract } from './lib/extract.ts'
18import { toMermaid } from './lib/mermaid.ts'
19import type { DiagramKind, Direction } from './lib/mermaid.ts'
20import { buildQuestions, buildState } from './lib/questions.ts'
21import { evaluate } from './lib/jev.ts'
22import { decide, decideWithoutJev } from './lib/decide.ts'
23import type { Decision } from './lib/decide.ts'
24import { TurnBuffer } from './lib/buffer.ts'
25import { readConfig } from './lib/config.ts'
26import type { Config } from './lib/config.ts'
27import { bandTree, IDLE_STATUS } from './lib/band.ts'
28import type { BandStatus } from './lib/band.ts'
29
30/** Jev の 1 往復に許す時間。これを超えたらその回は捨てて次のチャンクに任せる。 */
31const JEV_TIMEOUT_MS = 6000
32
33/** 直近に描いた図のメタ情報。応答が終わった時点で「確定」に書き換えるために持つ。 */
34type LastDraw = {
35 kind: DiagramKind
36 direction: Direction
37 confidence: number
38 title: string | null
39}
40
41type ModState = {
42 config: Config | null
43 buffer: TurnBuffer
44 status: BandStatus
45 mermaid: string | null
46 last: LastDraw | null
47 viewerStarted: boolean
48 turnId: string | null
49}
50
51const mod: ModState = {
52 config: null,
53 buffer: new TurnBuffer(),
54 status: { ...IDLE_STATUS },
55 mermaid: null,
56 last: null,
57 viewerStarted: false,
58 turnId: null,
59}
60
61function fallbackConfig(): Config {
62 return readConfig({})
63}
64
65export const register: Register = (on) => {
66 // 設定の読み出しとビューアの起動。env の名前は文字列リテラルでなければ
67 // ホストが拒むので、1 つずつ書き下している。
68 on('session.start', async ($, e, next) => {
69 const env = {
70 MERMAID_LIVE_API_KEY: await $.env.get('MERMAID_LIVE_API_KEY'),
71 TYPESAFE_API_KEY: await $.env.get('TYPESAFE_API_KEY'),
72 TYPESAFE_AI_API_KEY: await $.env.get('TYPESAFE_AI_API_KEY'),
73 MERMAID_LIVE_MODEL: await $.env.get('MERMAID_LIVE_MODEL'),
74 MERMAID_LIVE_BASE_URL: await $.env.get('MERMAID_LIVE_BASE_URL'),
75 MERMAID_LIVE_DIR: await $.env.get('MERMAID_LIVE_DIR'),
76 MERMAID_LIVE_PORT: await $.env.get('MERMAID_LIVE_PORT'),
77 MERMAID_LIVE_VIEWER: await $.env.get('MERMAID_LIVE_VIEWER'),
78 MERMAID_LIVE_MIN_GROWTH: await $.env.get('MERMAID_LIVE_MIN_GROWTH'),
79 MERMAID_LIVE_MIN_INTERVAL_MS: await $.env.get('MERMAID_LIVE_MIN_INTERVAL_MS'),
80 MERMAID_LIVE_THRESHOLD: await $.env.get('MERMAID_LIVE_THRESHOLD'),
81 }
82
83 const config = readConfig(env)
84 mod.config = config
85 mod.buffer = new TurnBuffer(config.policy)
86 mod.mermaid = null
87 mod.last = null
88 mod.turnId = null
89 mod.status = {
90 ...IDLE_STATUS,
91 jevEnabled: config.apiKey !== null,
92 viewerUrl: config.autoViewer ? `http://127.0.0.1:${config.port}` : null,
93 reason: config.apiKey === null ? 'Jev の API キーが無いので規則ベースで動きます' : '待機中',
94 }
95
96 if (config.autoViewer && !mod.viewerStarted) {
97 mod.viewerStarted = true
98 // --detach は自分を detached で起動し直して即終了する。
99 // $.process.run は子の終了まで待つので、常駐させるにはこの形しかない。
100 try {
101 await $.process.run(
102 [
103 'node',
104 `${$.plugin.root}/viewer/server.mjs`,
105 '--detach',
106 '--port',
107 String(config.port),
108 '--dir',
109 config.dir,
110 ],
111 { timeoutMs: 10000 },
112 )
113 $.ui.log(`mermaid-live: ビューアを起動しました http://127.0.0.1:${config.port}`)
114 } catch (error) {
115 mod.viewerStarted = false
116 $.ui.log(`mermaid-live: ビューアを起動できませんでした (${String(error)})`)
117 }
118 }
119
120 return next(e)
121 })
122
123 // 応答が流れてくるところ。ここではチャンクを溜めて素通しするだけで、
124 // 判定と書き出しは別の Promise に逃がす(ストリームを遅らせない)。
125 on('turn.step', async function* ($, e, next) {
126 // サブエージェントの応答まで図にすると帯が奪い合いになるので、本線だけ見る。
127 if (e.agentId !== undefined) {
128 return yield* next(e)
129 }
130
131 const config = mod.config ?? fallbackConfig()
132 // 1 ターンに step は何回も来る(ツール呼び出しのたび)。
133 // 溜めるのはターン単位なので、ターンが変わったときだけ捨てる。
134 if (mod.turnId !== e.turnId) {
135 mod.turnId = e.turnId
136 mod.buffer.reset()
137 mod.mermaid = null
138 mod.last = null
139 }
140
141 /**
142 * 判定の結果を書き出して帯を更新する。
143 *
144 * ビューアは state.json しか見ないので、.mmd と state.json を必ず対で書く。
145 */
146 const publish = async (mermaid: string, meta: LastDraw, final: boolean): Promise<void> => {
147 const file = `${config.dir}/current.mmd`
148 await $.fs.write(file, `${mermaid}\n`)
149 await $.fs.write(
150 `${config.dir}/state.json`,
151 `${JSON.stringify(
152 {
153 updatedAt: await $.clock.now(),
154 title: meta.title,
155 kind: meta.kind,
156 direction: meta.direction,
157 confidence: meta.confidence,
158 reason: final ? '応答が終わりました(確定)' : '応答の途中経過です',
159 jev: config.apiKey !== null,
160 final,
161 mermaid,
162 },
163 null,
164 2,
165 )}\n`,
166 )
167
168 mod.mermaid = mermaid
169 mod.last = meta
170 mod.status = {
171 ...mod.status,
172 phase: 'drawn',
173 kind: meta.kind,
174 confidence: meta.confidence,
175 reason: `${meta.kind} / ${meta.direction} で描画${final ? '(確定)' : '(途中)'}`,
176 mermaid,
177 file,
178 }
179 $.ui.invalidate('ui.render')
180 }
181
182 /**
183 * 溜まったテキストを 1 回評価して、決まれば図を書き出す。
184 *
185 * `$` はこの関数の外へ渡さない(ローダーがそれを拒む)。呼び出しは全部
186 * `$.noun.verb(...)` の形でこの中に書いてある。
187 */
188 const analyze = async (final: boolean): Promise<void> => {
189 const now = await $.clock.now()
190
191 if (!mod.buffer.shouldAnalyze(now, final)) {
192 // 応答が終わったが新しい文字は増えていない、というとき。
193 // 直前に描いた図はもう「途中」ではないので、確定として書き直す。
194 if (final && mod.mermaid !== null && mod.last !== null) {
195 await publish(mod.mermaid, mod.last, true)
196 }
197 return
198 }
199
200 const text = mod.buffer.begin(now)
201 try {
202 const outline = extract(text)
203
204 if (outline.counts.nodes === 0 && outline.mermaidFence === null) {
205 mod.status = { ...mod.status, phase: 'held', reason: '図の材料がまだ無い' }
206 $.ui.invalidate('ui.render')
207 return
208 }
209
210 mod.status = { ...mod.status, phase: 'thinking', reason: 'Jev に判定を依頼中' }
211 $.ui.invalidate('ui.render')
212
213 let decision: Decision
214 let tokens: number | null = mod.status.tokens
215
216 if (config.apiKey === null) {
217 decision = decideWithoutJev(outline, mod.mermaid !== null)
218 } else {
219 const evaluation = evaluate({
220 fetch: (url, init) => $.http.fetch(url, init),
221 apiKey: config.apiKey,
222 model: config.model,
223 baseUrl: config.baseUrl,
224 state: buildState(text, outline, mod.mermaid),
225 questions: buildQuestions(mod.mermaid !== null),
226 })
227 // $.http.fetch は signal を取らないので、時間切れは race で見る。
228 const timeout = $.clock.sleep(JEV_TIMEOUT_MS).then(() => 'timeout' as const)
229 const settled = await Promise.race([evaluation, timeout])
230 if (settled === 'timeout') {
231 mod.status = { ...mod.status, phase: 'held', reason: 'Jev の応答が遅いので今回は見送り' }
232 $.ui.invalidate('ui.render')
233 return
234 }
235 decision = decide(settled, mod.mermaid !== null, config.thresholds)
236 const used = (settled.usage.inputTokens ?? 0) + (settled.usage.outputTokens ?? 0)
237 tokens = used > 0 ? used : tokens
238 }
239
240 mod.status = { ...mod.status, tokens }
241
242 if (decision.action === 'keep') {
243 // 意味が変わっていないので描き直さない。確定のときだけ印を更新する。
244 if (final && mod.mermaid !== null && mod.last !== null) {
245 await publish(mod.mermaid, mod.last, true)
246 } else {
247 mod.status = { ...mod.status, phase: 'drawn', confidence: decision.confidence, reason: decision.reason }
248 $.ui.invalidate('ui.render')
249 }
250 return
251 }
252
253 if (decision.action === 'hold') {
254 mod.status = { ...mod.status, phase: 'held', confidence: decision.confidence, reason: decision.reason }
255 $.ui.invalidate('ui.render')
256 return
257 }
258
259 const mermaid = toMermaid(outline, decision.kind, decision.direction)
260 if (mermaid === null) {
261 mod.status = { ...mod.status, phase: 'held', reason: 'Mermaid に落とせなかった' }
262 $.ui.invalidate('ui.render')
263 return
264 }
265
266 await publish(
267 mermaid,
268 {
269 kind: decision.kind,
270 direction: decision.direction,
271 confidence: decision.confidence,
272 title: outline.title,
273 },
274 final,
275 )
276 } catch (error) {
277 mod.status = { ...mod.status, phase: 'error', reason: `失敗: ${String(error)}` }
278 $.ui.invalidate('ui.render')
279 } finally {
280 mod.buffer.end()
281 }
282 }
283
284 const stream = next(e)
285 let pending: Promise<void> = Promise.resolve()
286
287 for await (const chunk of stream) {
288 if (chunk.kind === 'text' && typeof chunk.text === 'string' && chunk.text.length > 0) {
289 mod.buffer.push(chunk.text)
290 // 時計を見る前に、文字数だけで足切りしておく(ホストへの往復を減らす)。
291 if (mod.buffer.mayAnalyze()) {
292 pending = analyze(false).catch(() => undefined)
293 }
294 }
295 yield chunk
296 }
297
298 const result = await stream.result
299 await pending
300 await analyze(true)
301 return result
302 }).catch(async function* ($, e, next) {
303 // 図解の失敗でターンを落とさない。素通しに戻す。
304 return yield* next(e)
305 })
306
307 // 入力欄の上の帯。描くものが何も無いときは engine に譲る。
308 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
309 if (e.surface !== 'terminal') return next(e)
310 if (e.props.hasSurvey) return next(e)
311 if (mod.status.phase === 'idle' && mod.mermaid === null) return next(e)
312
313 return bandTree($.ui.resolve(e), mod.status, e.props.bodyColumns, e.props.maxRows)
314 })
315}
316hooks/lib/extract.ts 283 lines1/**
2 * Claude の応答テキストから「図の材料」を取り出す純関数群。
3 *
4 * ここではモデルを一切呼ばない。ストリーミング中に何度も走るので、
5 * 速くて決定的であることを優先する。何の図にするか(種類)を決めるのは
6 * Jev の仕事で、このファイルは「描ける材料が何本あるか」だけを答える。
7 */
8
9/** ノードの形。分岐はひし形、終端は角丸、それ以外は四角。 */
10export type NodeShape = 'box' | 'round' | 'diamond'
11
12export type OutlineNode = {
13 id: string
14 label: string
15 shape: NodeShape
16}
17
18export type OutlineLink = {
19 from: string
20 to: string
21 label: string | null
22}
23
24/** 「A -> B: メッセージ」の形。sequenceDiagram の材料になる。 */
25export type Exchange = {
26 from: string
27 to: string
28 text: string
29}
30
31export type Outline = {
32 title: string | null
33 nodes: OutlineNode[]
34 links: OutlineLink[]
35 actors: string[]
36 exchanges: Exchange[]
37 /** 応答自体が ```mermaid を含んでいたら、その中身。 */
38 mermaidFence: string | null
39 /** その ```mermaid が閉じ終わっているか。ストリーミング中は false になる。 */
40 fenceClosed: boolean
41 /** 材料の量。gating のヒントとして Jev にも渡す。 */
42 counts: {
43 nodes: number
44 links: number
45 exchanges: number
46 actors: number
47 }
48}
49
50const ARROW = /\s*(?:-+>|=+>|→|⇒|=>)\s*/
51const ARROW_TEST = /(?:-+>|=+>|→|⇒)/
52
53/** 見出し。`## 手順` の類。 */
54const HEADING = /^(#{1,6})\s+(.+?)\s*#*$/
55/** 番号付きリスト。`1. ` `1) ` `(1) ` を拾う。 */
56const ORDERED = /^(\s*)\(?(\d+)[.)]\s+(.+)$/
57/** 箇条書き。`- ` `* ` `+ ` `・` を拾う。 */
58const BULLET = /^(\s*)(?:[-*+]|・)\s+(.+)$/
59
60/** 分岐っぽい言い回し。日本語と英語の両方を見る。 */
61const BRANCH = /(もし|の場合|ならば|なら$|かどうか|どちらか|判定|分岐|\?$|?$|^if\b|\bwhether\b|\bor not\b)/i
62/** 終端っぽい言い回し。 */
63const TERMINAL = /(完了|終了|おわり|終わり|done$|finish|complete$|end$)/i
64
65/**
66 * ラベルとして使えるようにマークダウンの飾りを落とす。
67 *
68 * `**太字**`、`` `コード` ``、`[文字](url)` を素の文字にして、
69 * 長すぎるものは切る。切るのは図が横に伸びすぎないようにするため。
70 */
71export function cleanLabel(raw: string, max = 48): string {
72 let text = raw.trim()
73 text = text.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
74 text = text.replace(/`{1,3}([^`]*)`{1,3}/g, '$1')
75 text = text.replace(/\*\*([^*]*)\*\*/g, '$1')
76 text = text.replace(/\*([^*]*)\*/g, '$1')
77 text = text.replace(/__([^_]*)__/g, '$1')
78 text = text.replace(/~~([^~]*)~~/g, '$1')
79 text = text.replace(/\s+/g, ' ').trim()
80 text = text.replace(/[。..::、,;;]+$/, '')
81 if (text.length > max) text = text.slice(0, max - 1) + '…'
82 return text
83}
84
85/** 同じことを言っている行をまとめるための正規化キー。 */
86function keyOf(label: string): string {
87 return label.toLowerCase().replace(/[\s "'`()()「」【】]/g, '')
88}
89
90function shapeOf(label: string): NodeShape {
91 if (BRANCH.test(label)) return 'diamond'
92 if (TERMINAL.test(label)) return 'round'
93 return 'box'
94}
95
96type FenceScan = {
97 body: string
98 mermaid: string | null
99 closed: boolean
100}
101
102/**
103 * コードフェンスを本文から切り離す。
104 *
105 * ```mermaid は「すでに答えが図になっている」ので最優先で拾う。
106 * それ以外の言語のフェンスは、中身の記号が矢印に誤検出されるので本文から捨てる。
107 * ストリーミング中は閉じフェンスがまだ来ていないことがあるので、
108 * 閉じたかどうかを `closed` で持ち帰る。
109 */
110export function scanFences(text: string): FenceScan {
111 const lines = text.split('\n')
112 const body: string[] = []
113 let mermaid: string | null = null
114 let closed = true
115
116 let inFence = false
117 let fenceLang = ''
118 let buffer: string[] = []
119
120 for (const line of lines) {
121 const open = /^\s*```+\s*([A-Za-z0-9_-]*)\s*$/.exec(line)
122 if (!inFence && open) {
123 inFence = true
124 fenceLang = (open[1] ?? '').toLowerCase()
125 buffer = []
126 continue
127 }
128 if (inFence && /^\s*```+\s*$/.test(line)) {
129 if (fenceLang === 'mermaid') {
130 mermaid = buffer.join('\n').trim()
131 closed = true
132 }
133 inFence = false
134 fenceLang = ''
135 buffer = []
136 continue
137 }
138 if (inFence) {
139 buffer.push(line)
140 continue
141 }
142 body.push(line)
143 }
144
145 // 閉じないまま終わった = まだ流れている途中。
146 if (inFence && fenceLang === 'mermaid') {
147 mermaid = buffer.join('\n').trim()
148 closed = false
149 }
150
151 return { body: body.join('\n'), mermaid, closed }
152}
153
154type Builder = {
155 nodes: OutlineNode[]
156 index: Map<string, string>
157}
158
159function addNode(builder: Builder, rawLabel: string): string | null {
160 const label = cleanLabel(rawLabel)
161 if (label.length === 0) return null
162 const key = keyOf(label)
163 if (key.length === 0) return null
164 const known = builder.index.get(key)
165 if (known !== undefined) return known
166 const id = `n${builder.nodes.length + 1}`
167 builder.index.set(key, id)
168 builder.nodes.push({ id, label, shape: shapeOf(label) })
169 return id
170}
171
172/** `A -> B -> C` を隣り合う組に割る。 */
173function arrowChain(line: string): string[] {
174 return line
175 .split(ARROW)
176 .map((part) => part.trim())
177 .filter((part) => part.length > 0)
178}
179
180/**
181 * 応答テキストから Outline を作る。
182 *
183 * 拾うのは 4 種類だけ:
184 * 1. ```mermaid フェンス(あればそれが答え)
185 * 2. `A -> B` の矢印行(`: メッセージ` 付きなら登場人物のやり取りとして扱う)
186 * 3. 番号付きリスト(手順の並び)
187 * 4. 箇条書きと見出し(リストが無いときの代わり)
188 */
189export function extract(text: string): Outline {
190 const { body, mermaid, closed } = scanFences(text)
191 const builder: Builder = { nodes: [], index: new Map() }
192 const links: OutlineLink[] = []
193 const exchanges: Exchange[] = []
194 const ordered: string[] = []
195 const bullets: string[] = []
196 const headings: string[] = []
197 let title: string | null = null
198
199 for (const line of body.split('\n')) {
200 if (line.trim().length === 0) continue
201
202 if (ARROW_TEST.test(line)) {
203 // `A -> B: メッセージ` はやり取り、`A -> B` はただの接続。
204 const [flow, ...rest] = line.split(/:\s+|:/)
205 const message = cleanLabel(rest.join(': '))
206 const parts = arrowChain(flow ?? '')
207 if (parts.length >= 2) {
208 for (let i = 0; i + 1 < parts.length; i += 1) {
209 const from = addNode(builder, parts[i] ?? '')
210 const to = addNode(builder, parts[i + 1] ?? '')
211 if (from === null || to === null) continue
212 links.push({ from, to, label: message.length > 0 ? message : null })
213 if (message.length > 0) {
214 exchanges.push({
215 from: cleanLabel(parts[i] ?? '', 24),
216 to: cleanLabel(parts[i + 1] ?? '', 24),
217 text: message,
218 })
219 }
220 }
221 continue
222 }
223 }
224
225 const heading = HEADING.exec(line)
226 if (heading !== null) {
227 const label = cleanLabel(heading[2] ?? '')
228 if (title === null && (heading[1] ?? '').length <= 2) title = label
229 else if (label.length > 0) headings.push(label)
230 continue
231 }
232
233 const orderedItem = ORDERED.exec(line)
234 if (orderedItem !== null) {
235 ordered.push(orderedItem[3] ?? '')
236 continue
237 }
238
239 const bulletItem = BULLET.exec(line)
240 if (bulletItem !== null) {
241 bullets.push(bulletItem[2] ?? '')
242 continue
243 }
244 }
245
246 // 手順の並びは「番号付き > 箇条書き > 見出し」の優先順で 1 つだけ採る。
247 // 3 つ混ぜると同じ話が二重にノードになって図が汚れる。
248 const sequence = ordered.length >= 2 ? ordered : bullets.length >= 2 ? bullets : headings
249 const stepIds: string[] = []
250 for (const item of sequence) {
251 const id = addNode(builder, item)
252 if (id !== null) stepIds.push(id)
253 }
254 for (let i = 0; i + 1 < stepIds.length; i += 1) {
255 const from = stepIds[i] as string
256 const to = stepIds[i + 1] as string
257 const already = links.some((link) => link.from === from && link.to === to)
258 if (!already) links.push({ from, to, label: null })
259 }
260
261 const actors: string[] = []
262 for (const exchange of exchanges) {
263 if (!actors.includes(exchange.from)) actors.push(exchange.from)
264 if (!actors.includes(exchange.to)) actors.push(exchange.to)
265 }
266
267 return {
268 title,
269 nodes: builder.nodes,
270 links,
271 actors,
272 exchanges,
273 mermaidFence: mermaid,
274 fenceClosed: closed,
275 counts: {
276 nodes: builder.nodes.length,
277 links: links.length,
278 exchanges: exchanges.length,
279 actors: actors.length,
280 },
281 }
282}
283hooks/lib/mermaid.ts 133 lines1/**
2 * Outline を Mermaid のソースに変換する純関数群。
3 *
4 * Mermaid はラベルの中の記号でわりと簡単に構文エラーになるので、
5 * ラベルは必ず二重引用符で包み、危ない文字は HTML エンティティに逃がす。
6 */
7
8import type { Outline, OutlineNode } from './extract.ts'
9
10/** Jev に選ばせる図の種類。増やすほど選択が鈍るので 4 つ + none に絞っている。 */
11export type DiagramKind = 'flowchart' | 'sequence' | 'state' | 'mindmap' | 'none'
12
13export type Direction = 'TD' | 'LR'
14
15export const DIAGRAM_KINDS: readonly DiagramKind[] = [
16 'flowchart',
17 'sequence',
18 'state',
19 'mindmap',
20 'none',
21]
22
23/**
24 * ラベルを Mermaid に渡せる形にする。
25 *
26 * `"` と `#` はそれぞれ文字列とエンティティの開始なので必ず逃がす。
27 * 改行はラベルの中では `<br/>` にしないと構文が壊れる。
28 */
29export function escapeLabel(label: string): string {
30 return label
31 .replace(/#/g, '#35;')
32 .replace(/"/g, '#quot;')
33 .replace(/[\r\n]+/g, '<br/>')
34 .trim()
35}
36
37/** participant 名は引用符で包めないので、記号を落として英数字と日本語だけ残す。 */
38export function safeActor(name: string): string {
39 const cleaned = name.replace(/["'`:;,()()\[\]{}<>|#]/g, '').replace(/\s+/g, '_').trim()
40 return cleaned.length > 0 ? cleaned : 'actor'
41}
42
43function nodeText(node: OutlineNode): string {
44 const label = `"${escapeLabel(node.label)}"`
45 if (node.shape === 'diamond') return `${node.id}{${label}}`
46 if (node.shape === 'round') return `${node.id}(${label})`
47 return `${node.id}[${label}]`
48}
49
50function flowchart(outline: Outline, direction: Direction): string | null {
51 if (outline.nodes.length === 0) return null
52 const lines = [`flowchart ${direction}`]
53 for (const node of outline.nodes) lines.push(` ${nodeText(node)}`)
54 for (const link of outline.links) {
55 const label = link.label === null ? '' : `|"${escapeLabel(link.label)}"|`
56 lines.push(` ${link.from} -->${label} ${link.to}`)
57 }
58 return lines.join('\n')
59}
60
61function sequence(outline: Outline): string | null {
62 if (outline.exchanges.length === 0) return null
63 const lines = ['sequenceDiagram']
64 for (const actor of outline.actors) lines.push(` participant ${safeActor(actor)}`)
65 for (const exchange of outline.exchanges) {
66 lines.push(
67 ` ${safeActor(exchange.from)}->>${safeActor(exchange.to)}: ${escapeLabel(exchange.text)}`,
68 )
69 }
70 return lines.join('\n')
71}
72
73function state(outline: Outline): string | null {
74 if (outline.nodes.length === 0) return null
75 const lines = ['stateDiagram-v2']
76 for (const node of outline.nodes) lines.push(` ${node.id} : ${escapeLabel(node.label)}`)
77
78 const hasIncoming = new Set(outline.links.map((link) => link.to))
79 const hasOutgoing = new Set(outline.links.map((link) => link.from))
80 const first = outline.nodes.find((node) => !hasIncoming.has(node.id))
81 if (first !== undefined) lines.push(` [*] --> ${first.id}`)
82
83 for (const link of outline.links) {
84 const label = link.label === null ? '' : ` : ${escapeLabel(link.label)}`
85 lines.push(` ${link.from} --> ${link.to}${label}`)
86 }
87
88 const last = [...outline.nodes].reverse().find((node) => !hasOutgoing.has(node.id))
89 if (last !== undefined && last.id !== first?.id) lines.push(` ${last.id} --> [*]`)
90 return lines.join('\n')
91}
92
93function mindmap(outline: Outline): string | null {
94 if (outline.nodes.length === 0) return null
95 const root = outline.title ?? 'answer'
96 const lines = ['mindmap', ` root(("${escapeLabel(root)}"))`]
97 for (const node of outline.nodes) lines.push(` ${escapeLabel(node.label)}`)
98 return lines.join('\n')
99}
100
101/**
102 * Mermaid ソースを組み立てる。描けないときは null。
103 *
104 * 応答自体が ```mermaid を持っていて、かつ閉じ終わっていれば、それをそのまま使う。
105 * 閉じていない(まだ流れている)フェンスは構文が欠けているので使わない。
106 */
107export function toMermaid(
108 outline: Outline,
109 kind: DiagramKind,
110 direction: Direction = 'TD',
111): string | null {
112 if (outline.mermaidFence !== null && outline.fenceClosed && outline.mermaidFence.length > 0) {
113 return outline.mermaidFence
114 }
115 if (kind === 'none') return null
116 if (kind === 'sequence') return sequence(outline) ?? flowchart(outline, direction)
117 if (kind === 'state') return state(outline)
118 if (kind === 'mindmap') return mindmap(outline)
119 return flowchart(outline, direction)
120}
121
122/**
123 * Jev に聞くまでもなく決まる場合の、種類の当て推量。
124 *
125 * API キーが無いときのフォールバックであり、キーがあるときは Jev の答えが勝つ。
126 */
127export function guessKind(outline: Outline): DiagramKind {
128 if (outline.mermaidFence !== null) return 'flowchart'
129 if (outline.exchanges.length >= 2 && outline.actors.length >= 2) return 'sequence'
130 if (outline.nodes.length >= 2) return 'flowchart'
131 return 'none'
132}
133hooks/lib/questions.ts 119 lines1/**
2 * Jev に投げる「型つきの質問」を組み立てる。
3 *
4 * Jev は文章を書かない。選択肢・段階・確率だけを返す。
5 * つまり「図にすべきか」「どの図か」「もう描いていいか」という判断だけを任せ、
6 * Mermaid の文字列そのものは mermaid.ts が決定的に作る。
7 * この分担のおかげで、ストリーミング中でも壊れた図が出ない。
8 */
9
10import type { Outline } from './extract.ts'
11import { DIAGRAM_KINDS } from './mermaid.ts'
12import type { DiagramKind } from './mermaid.ts'
13
14/** Jev のワイヤ上の質問。boolean は `noul` という名前になる。 */
15export type JevQuestion =
16 | { type: 'noul'; instructions: string; criteria?: { true?: string; false?: string } }
17 | { type: 'choice'; instructions: string; criteria: Record<string, string> }
18 | { type: 'score'; instructions: string; criteria: string[] }
19
20export type JevQuestions = Record<string, JevQuestion>
21
22/** readiness の段階。低い順。decide.ts が添字で読むので順番が意味を持つ。 */
23export const READINESS_LEVELS = [
24 'Only a fragment so far: the shape of the answer is not visible yet.',
25 'The skeleton is visible: the main steps or participants can be named.',
26 'Essentially complete: a diagram drawn now would not be misleading.',
27]
28
29const KIND_CRITERIA: Record<Exclude<DiagramKind, never>, string> = {
30 flowchart: 'A process, procedure, decision tree or pipeline: steps that follow one another.',
31 sequence: 'Messages exchanged between two or more named participants, in time order.',
32 state: 'A thing that moves between named states, with transitions and an end state.',
33 mindmap: 'A topic broken into unordered parts, categories or options: structure without flow.',
34 none: 'Prose, a direct answer, code, or anything a diagram would not clarify.',
35}
36
37/** Jev に渡す state。テキストは末尾だけ送る(長いほど遅く、高くなる)。 */
38export function buildState(
39 answerSoFar: string,
40 outline: Outline,
41 previousMermaid: string | null,
42 maxChars = 4000,
43): Record<string, unknown> {
44 const text =
45 answerSoFar.length > maxChars
46 ? `…${answerSoFar.slice(answerSoFar.length - maxChars)}`
47 : answerSoFar
48
49 return {
50 answer_so_far: text,
51 still_streaming: true,
52 extracted: {
53 title: outline.title,
54 steps: outline.nodes.map((node) => node.label),
55 links: outline.links.length,
56 participants: outline.actors,
57 messages: outline.exchanges.map((exchange) => `${exchange.from} -> ${exchange.to}: ${exchange.text}`),
58 has_mermaid_block: outline.mermaidFence !== null,
59 },
60 diagram_on_screen: previousMermaid,
61 }
62}
63
64/**
65 * 1 リクエストにまとめる質問の束。
66 *
67 * Jev は全部を並列に評価して 1 往復で返すので、分けて聞く理由がない。
68 */
69export function buildQuestions(hasPrevious: boolean): JevQuestions {
70 const questions: JevQuestions = {
71 diagrammable: {
72 type: 'noul',
73 instructions:
74 'Would a diagram help a reader understand this answer? Judge the answer itself, not the extracted hints.',
75 criteria: {
76 true: 'It describes a process, an exchange between parties, a state machine, or a structure with parts.',
77 false: 'It is prose, a one-line answer, a code walkthrough, or a list of unrelated facts.',
78 },
79 },
80 kind: {
81 type: 'choice',
82 instructions: 'Which diagram fits this answer best?',
83 criteria: Object.fromEntries(
84 DIAGRAM_KINDS.map((kind) => [kind, KIND_CRITERIA[kind]]),
85 ) as Record<string, string>,
86 },
87 readiness: {
88 type: 'score',
89 instructions:
90 'The answer is still streaming. How complete is what has arrived so far, for the purpose of drawing it?',
91 criteria: READINESS_LEVELS,
92 },
93 direction: {
94 type: 'choice',
95 instructions: 'Which layout reads better for this content?',
96 criteria: {
97 TD: 'Top to bottom: a procedure, a decision tree, many short steps.',
98 LR: 'Left to right: a pipeline, a timeline, few steps with long labels.',
99 },
100 },
101 }
102
103 if (hasPrevious) {
104 // 「前に描いた図と意味が変わったか」。
105 // これが無いと 1 秒ごとに描き直してちらつく。
106 questions.changed = {
107 type: 'noul',
108 instructions:
109 'Compared with diagram_on_screen, has the answer changed enough that the diagram should be redrawn?',
110 criteria: {
111 true: 'New steps, new participants, or a different structure has appeared.',
112 false: 'Only wording or detail was added; the diagram would look the same.',
113 },
114 }
115 }
116
117 return questions
118}
119hooks/lib/jev.ts 175 lines1/**
2 * Jev(TypeSafe AI の System One モデル)のクライアント。
3 *
4 * ワイヤ形式は POST {baseUrl}/systemone に
5 * { state, model, questions: { <id>: { type, instructions, criteria? } } }
6 * を送ると
7 * { model, answers: { <id>: { type:'noul'|'choice'|'score', ... } }, usage }
8 * が返る、というだけのもの。
9 *
10 * fetch は引数で受け取る。Mod の中では node を import できず `$.http.fetch` しか
11 * 使えないため、その形({ status, ok, headers, text })に合わせてある。
12 */
13
14import type { JevQuestions } from './questions.ts'
15
16export const JEV_BASE_URL = 'https://api.typesafe.ai/v1'
17export const JEV_DEFAULT_MODEL = 'jev-latest'
18
19/** `$.http.fetch` が返す形。 */
20export type HttpResponseLike = {
21 status: number
22 ok: boolean
23 headers?: Record<string, string>
24 text: string
25}
26
27export type FetchLike = (
28 url: string,
29 init?: { method?: string; headers?: Record<string, string>; body?: string },
30) => Promise<HttpResponseLike>
31
32export type NoulAnswer = { type: 'noul'; noul: number; confidence?: number }
33export type ChoiceAnswer = {
34 type: 'choice'
35 choice: string
36 probabilities?: Record<string, number>
37 confidence?: number
38}
39export type ScoreAnswer = {
40 type: 'score'
41 score: number
42 probabilities?: Record<string, number>
43 confidence?: number
44}
45export type JevAnswer = NoulAnswer | ChoiceAnswer | ScoreAnswer
46
47export type JevResult = {
48 answers: Record<string, JevAnswer>
49 model: string | null
50 usage: { inputTokens: number | null; outputTokens: number | null }
51}
52
53export class JevError extends Error {
54 readonly status: number | null
55 constructor(message: string, status: number | null = null) {
56 super(message)
57 this.name = 'JevError'
58 this.status = status
59 }
60}
61
62function isRecord(value: unknown): value is Record<string, unknown> {
63 return typeof value === 'object' && value !== null && !Array.isArray(value)
64}
65
66/** エラー本文から読める 1 文を取り出す。422 は detail にぶら下がる。 */
67export function errorMessage(body: string, status: number): string {
68 let parsed: unknown
69 try {
70 parsed = JSON.parse(body)
71 } catch {
72 return body.trim().length > 0 ? body.trim() : `Jev returned HTTP ${status}.`
73 }
74 if (isRecord(parsed)) {
75 for (const key of ['detail', 'message', 'error']) {
76 const value = parsed[key]
77 if (typeof value === 'string' && value.length > 0) return value
78 if (value !== null && typeof value === 'object') return JSON.stringify(value)
79 }
80 }
81 return body.trim().length > 0 ? body.trim() : `Jev returned HTTP ${status}.`
82}
83
84/** 1 つの答えを検証して型を付ける。形が違えば null(その質問だけ捨てる)。 */
85export function parseAnswer(raw: unknown): JevAnswer | null {
86 if (!isRecord(raw)) return null
87 const confidence = typeof raw.confidence === 'number' ? raw.confidence : undefined
88 if (raw.type === 'noul' && typeof raw.noul === 'number') {
89 return { type: 'noul', noul: raw.noul, ...(confidence === undefined ? {} : { confidence }) }
90 }
91 if (raw.type === 'choice' && typeof raw.choice === 'string') {
92 const probabilities = isRecord(raw.probabilities)
93 ? (raw.probabilities as Record<string, number>)
94 : undefined
95 return {
96 type: 'choice',
97 choice: raw.choice,
98 ...(probabilities === undefined ? {} : { probabilities }),
99 ...(confidence === undefined ? {} : { confidence }),
100 }
101 }
102 if (raw.type === 'score' && typeof raw.score === 'number') {
103 const probabilities = isRecord(raw.probabilities)
104 ? (raw.probabilities as Record<string, number>)
105 : undefined
106 return {
107 type: 'score',
108 score: raw.score,
109 ...(probabilities === undefined ? {} : { probabilities }),
110 ...(confidence === undefined ? {} : { confidence }),
111 }
112 }
113 return null
114}
115
116export function parseResult(body: string, status: number): JevResult {
117 let payload: unknown
118 try {
119 payload = JSON.parse(body)
120 } catch {
121 throw new JevError('Jev returned a body that is not JSON.', status)
122 }
123 if (!isRecord(payload) || !isRecord(payload.answers)) {
124 throw new JevError('Jev returned a response without an "answers" object.', status)
125 }
126
127 const answers: Record<string, JevAnswer> = {}
128 for (const [id, raw] of Object.entries(payload.answers)) {
129 const answer = parseAnswer(raw)
130 if (answer !== null) answers[id] = answer
131 }
132
133 const usage = isRecord(payload.usage) ? payload.usage : {}
134 return {
135 answers,
136 model: typeof payload.model === 'string' ? payload.model : null,
137 usage: {
138 inputTokens: typeof usage.input_tokens === 'number' ? usage.input_tokens : null,
139 outputTokens: typeof usage.output_tokens === 'number' ? usage.output_tokens : null,
140 },
141 }
142}
143
144export type EvaluateOptions = {
145 fetch: FetchLike
146 apiKey: string
147 state: unknown
148 questions: JevQuestions
149 model?: string
150 baseUrl?: string
151}
152
153/** 質問をまとめて 1 往復で評価する。 */
154export async function evaluate(options: EvaluateOptions): Promise<JevResult> {
155 const baseUrl = (options.baseUrl ?? JEV_BASE_URL).replace(/\/+$/, '')
156 const url = `${baseUrl}/systemone`
157 const body = JSON.stringify({
158 state: options.state,
159 model: options.model ?? JEV_DEFAULT_MODEL,
160 questions: options.questions,
161 })
162
163 const response = await options.fetch(url, {
164 method: 'POST',
165 headers: {
166 authorization: `Bearer ${options.apiKey}`,
167 'content-type': 'application/json',
168 },
169 body,
170 })
171
172 if (!response.ok) throw new JevError(errorMessage(response.text, response.status), response.status)
173 return parseResult(response.text, response.status)
174}
175hooks/lib/decide.ts 128 lines1/**
2 * Jev の答えを「描く / 待つ / 据え置く」の 3 択に落とす。
3 *
4 * ここが Jev を使う理由そのもの。文章生成モデルに「図にすべき?」と聞くと
5 * 毎回それらしい文章が返ってきて、閾値で切ることができない。
6 * Jev は確率を返すので、ちらつきも空振りも数字で止められる。
7 */
8
9import type { JevAnswer, JevResult } from './jev.ts'
10import { DIAGRAM_KINDS } from './mermaid.ts'
11import type { DiagramKind, Direction } from './mermaid.ts'
12import { READINESS_LEVELS } from './questions.ts'
13import type { Outline } from './extract.ts'
14import { guessKind } from './mermaid.ts'
15
16export type Thresholds = {
17 /** これ未満なら図にしない。 */
18 diagrammable: number
19 /** readiness(0〜levels-1 の実数)がこれ未満なら、材料が足りないので待つ。 */
20 readiness: number
21 /** 選ばれた種類の確率がこれ未満なら、迷っているので待つ。 */
22 kind: number
23 /** 「変わったか」がこれ未満なら、前の図を据え置く。 */
24 changed: number
25}
26
27export const DEFAULT_THRESHOLDS: Thresholds = {
28 diagrammable: 0.55,
29 readiness: 1.0,
30 kind: 0.4,
31 changed: 0.5,
32}
33
34export type Decision =
35 | { action: 'draw'; kind: DiagramKind; direction: Direction; confidence: number; reason: string }
36 | { action: 'hold'; reason: string; confidence: number }
37 | { action: 'keep'; reason: string; confidence: number }
38
39function noul(answer: JevAnswer | undefined): number | null {
40 return answer !== undefined && answer.type === 'noul' ? answer.noul : null
41}
42
43function choice(answer: JevAnswer | undefined): { value: string; probability: number } | null {
44 if (answer === undefined || answer.type !== 'choice') return null
45 const probability = answer.probabilities?.[answer.choice]
46 return { value: answer.choice, probability: typeof probability === 'number' ? probability : 1 }
47}
48
49function score(answer: JevAnswer | undefined): number | null {
50 return answer !== undefined && answer.type === 'score' ? answer.score : null
51}
52
53function asKind(value: string): DiagramKind | null {
54 return (DIAGRAM_KINDS as readonly string[]).includes(value) ? (value as DiagramKind) : null
55}
56
57/** 帯に出す 0〜1 の確信度。diagrammable と種類の確率の低いほうを採る。 */
58function confidenceOf(diagrammable: number | null, kindProbability: number | null): number {
59 const values = [diagrammable, kindProbability].filter((value): value is number => value !== null)
60 return values.length === 0 ? 0 : Math.min(...values)
61}
62
63/**
64 * Jev の答えから決める。
65 *
66 * @param hasPrevious すでに図が出ているか。出ているなら「据え置き」が選べる。
67 */
68export function decide(
69 result: JevResult,
70 hasPrevious: boolean,
71 thresholds: Thresholds = DEFAULT_THRESHOLDS,
72): Decision {
73 const diagrammable = noul(result.answers.diagrammable)
74 const kindAnswer = choice(result.answers.kind)
75 const readiness = score(result.answers.readiness)
76 const directionAnswer = choice(result.answers.direction)
77 const changed = noul(result.answers.changed)
78
79 const confidence = confidenceOf(diagrammable, kindAnswer?.probability ?? null)
80
81 if (diagrammable !== null && diagrammable < thresholds.diagrammable) {
82 return { action: 'hold', reason: `図にする価値が低い (${diagrammable.toFixed(2)})`, confidence }
83 }
84
85 const kind = kindAnswer === null ? null : asKind(kindAnswer.value)
86 if (kind === null) return { action: 'hold', reason: '図の種類が決まらない', confidence }
87 if (kind === 'none') return { action: 'hold', reason: '図にしないほうがよい応答', confidence }
88
89 if (kindAnswer !== null && kindAnswer.probability < thresholds.kind) {
90 return {
91 action: 'hold',
92 reason: `種類が割れている (${kind} ${kindAnswer.probability.toFixed(2)})`,
93 confidence,
94 }
95 }
96
97 if (readiness !== null && readiness < thresholds.readiness) {
98 const top = READINESS_LEVELS.length - 1
99 return {
100 action: 'hold',
101 reason: `まだ材料が足りない (${readiness.toFixed(2)}/${top})`,
102 confidence,
103 }
104 }
105
106 if (hasPrevious && changed !== null && changed < thresholds.changed) {
107 return { action: 'keep', reason: `前の図のままでよい (${changed.toFixed(2)})`, confidence }
108 }
109
110 const direction: Direction = directionAnswer?.value === 'LR' ? 'LR' : 'TD'
111 return { action: 'draw', kind, direction, confidence, reason: '描画' }
112}
113
114/**
115 * API キーが無い / Jev が落ちているときの判断。
116 *
117 * 決定的な規則だけで動く。確信度は「Jev に聞いていない」印として 0 を返す。
118 */
119export function decideWithoutJev(outline: Outline, hasPrevious: boolean): Decision {
120 const kind = guessKind(outline)
121 if (kind === 'none') return { action: 'hold', reason: '材料なし (Jev 無効)', confidence: 0 }
122 if (outline.counts.nodes < 2) return { action: 'hold', reason: 'ノードが 1 つだけ', confidence: 0 }
123 if (hasPrevious && outline.counts.nodes < 3) {
124 return { action: 'keep', reason: '変化が小さい (Jev 無効)', confidence: 0 }
125 }
126 return { action: 'draw', kind, direction: 'TD', confidence: 0, reason: '規則ベースで描画' }
127}
128hooks/lib/buffer.ts 96 lines1/**
2 * ストリーム中のテキストを溜めて、いつ Jev に聞くかを決める。
3 *
4 * turn.step のフックはチャンクが届くたびに走る。そこで毎回 Jev を呼ぶと
5 * 1 ターンで何十往復もしてしまうので、「前回から N 文字増えて、かつ M ミリ秒
6 * 経った」ときだけ聞く。応答が終わった瞬間(final)は必ず 1 回聞く。
7 */
8
9export type BufferPolicy = {
10 /** 前回の評価から最低これだけ文字が増えるまで聞かない。 */
11 minGrowth: number
12 /** 前回の評価から最低これだけミリ秒空ける。 */
13 minIntervalMs: number
14 /** これより短い応答は最後まで図にしない。 */
15 minLength: number
16}
17
18export const DEFAULT_POLICY: BufferPolicy = {
19 minGrowth: 240,
20 minIntervalMs: 1200,
21 minLength: 120,
22}
23
24export class TurnBuffer {
25 #text = ''
26 #analyzedLength = 0
27 #analyzedAt = 0
28 #inFlight = false
29 readonly #policy: BufferPolicy
30
31 constructor(policy: BufferPolicy = DEFAULT_POLICY) {
32 this.#policy = policy
33 }
34
35 get text(): string {
36 return this.#text
37 }
38
39 get length(): number {
40 return this.#text.length
41 }
42
43 get inFlight(): boolean {
44 return this.#inFlight
45 }
46
47 push(chunk: string): void {
48 this.#text += chunk
49 }
50
51 reset(): void {
52 this.#text = ''
53 this.#analyzedLength = 0
54 this.#analyzedAt = 0
55 this.#inFlight = false
56 }
57
58 /**
59 * 時計を見ずに分かる範囲の足切り。
60 *
61 * turn.step のフックはチャンクごとに走るので、`$.clock.now()`(ホストへの
62 * 往復)を毎回叩かないための前段。ここが false なら時刻を見る必要もない。
63 */
64 mayAnalyze(final = false): boolean {
65 if (this.#inFlight) return false
66 if (this.#text.length < this.#policy.minLength) return false
67 if (final) return this.#text.length > this.#analyzedLength
68 return this.#text.length - this.#analyzedLength >= this.#policy.minGrowth
69 }
70
71 /**
72 * いま Jev に聞くべきか。
73 *
74 * @param now 現在時刻(ms)。`$.clock.now()` の値を渡す。
75 * @param final 応答が終わった直後か。終わったなら間隔の条件を飛ばす。
76 */
77 shouldAnalyze(now: number, final = false): boolean {
78 if (!this.mayAnalyze(final)) return false
79 if (final) return true
80 return now - this.#analyzedAt >= this.#policy.minIntervalMs
81 }
82
83 /** 評価を始める。開始時点の本文を返す(その後に届いた分は次回に回る)。 */
84 begin(now: number): string {
85 this.#inFlight = true
86 this.#analyzedAt = now
87 this.#analyzedLength = this.#text.length
88 return this.#text
89 }
90
91 /** 評価が終わった(成功・失敗どちらでも必ず呼ぶ)。 */
92 end(): void {
93 this.#inFlight = false
94 }
95}
96hooks/lib/config.ts 87 lines1/**
2 * 環境変数から設定を作る。Mod からは `$.env.get()` で読んだ値を渡す。
3 */
4
5import { DEFAULT_THRESHOLDS } from './decide.ts'
6import type { Thresholds } from './decide.ts'
7import { DEFAULT_POLICY } from './buffer.ts'
8import type { BufferPolicy } from './buffer.ts'
9import { JEV_BASE_URL, JEV_DEFAULT_MODEL } from './jev.ts'
10
11/** 読む環境変数の名前。register.ts がこの順で `$.env.get` する。 */
12export const ENV_KEYS = [
13 'MERMAID_LIVE_API_KEY',
14 'TYPESAFE_API_KEY',
15 'TYPESAFE_AI_API_KEY',
16 'MERMAID_LIVE_MODEL',
17 'MERMAID_LIVE_BASE_URL',
18 'MERMAID_LIVE_DIR',
19 'MERMAID_LIVE_PORT',
20 'MERMAID_LIVE_VIEWER',
21 'MERMAID_LIVE_MIN_GROWTH',
22 'MERMAID_LIVE_MIN_INTERVAL_MS',
23 'MERMAID_LIVE_THRESHOLD',
24] as const
25
26export type EnvBag = Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>
27
28export type Config = {
29 apiKey: string | null
30 model: string
31 baseUrl: string
32 /** .mmd と state.json を書く場所。セッションの作業ディレクトリからの相対。 */
33 dir: string
34 port: number
35 /** ビューアを自動で起動するか。 */
36 autoViewer: boolean
37 policy: BufferPolicy
38 thresholds: Thresholds
39}
40
41function numberOr(value: string | undefined, fallback: number): number {
42 if (value === undefined) return fallback
43 const parsed = Number(value.trim())
44 return Number.isFinite(parsed) ? parsed : fallback
45}
46
47function truthy(value: string | undefined): boolean {
48 if (value === undefined) return false
49 const normalized = value.trim().toLowerCase()
50 return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'
51}
52
53export function readConfig(env: EnvBag): Config {
54 const apiKey =
55 env.MERMAID_LIVE_API_KEY?.trim() ??
56 env.TYPESAFE_API_KEY?.trim() ??
57 env.TYPESAFE_AI_API_KEY?.trim() ??
58 ''
59
60 // 1 つの閾値で全部を上下させる簡易つまみ。個別に変えたい人は decide.ts の既定値を読む。
61 const dial = env.MERMAID_LIVE_THRESHOLD === undefined ? null : numberOr(env.MERMAID_LIVE_THRESHOLD, 0)
62 const thresholds: Thresholds =
63 dial === null
64 ? DEFAULT_THRESHOLDS
65 : {
66 diagrammable: dial,
67 kind: Math.max(0, dial - 0.15),
68 readiness: DEFAULT_THRESHOLDS.readiness,
69 changed: DEFAULT_THRESHOLDS.changed,
70 }
71
72 return {
73 apiKey: apiKey.length > 0 ? apiKey : null,
74 model: env.MERMAID_LIVE_MODEL?.trim() || JEV_DEFAULT_MODEL,
75 baseUrl: env.MERMAID_LIVE_BASE_URL?.trim() || JEV_BASE_URL,
76 dir: env.MERMAID_LIVE_DIR?.trim() || '.claude/mermaid-live',
77 port: numberOr(env.MERMAID_LIVE_PORT, 4737),
78 autoViewer: truthy(env.MERMAID_LIVE_VIEWER),
79 policy: {
80 minGrowth: numberOr(env.MERMAID_LIVE_MIN_GROWTH, DEFAULT_POLICY.minGrowth),
81 minIntervalMs: numberOr(env.MERMAID_LIVE_MIN_INTERVAL_MS, DEFAULT_POLICY.minIntervalMs),
82 minLength: DEFAULT_POLICY.minLength,
83 },
84 thresholds,
85 }
86}
87hooks/lib/band.ts 144 lines1/**
2 * プロンプトのすぐ上(AbovePrompt)に出す帯の組み立て。
3 *
4 * 端末に Mermaid の絵そのものは描けないので、ここに出すのは
5 * 「いまどう判断しているか」と Mermaid ソースの先頭数行。
6 * 絵を見たいときはビューア(ブラウザ)を開く。
7 */
8
9import type { DiagramKind } from './mermaid.ts'
10
11export type Phase = 'idle' | 'thinking' | 'drawn' | 'held' | 'error'
12
13export type BandStatus = {
14 phase: Phase
15 kind: DiagramKind | null
16 confidence: number
17 reason: string
18 mermaid: string | null
19 file: string | null
20 viewerUrl: string | null
21 jevEnabled: boolean
22 tokens: number | null
23}
24
25export const IDLE_STATUS: BandStatus = {
26 phase: 'idle',
27 kind: null,
28 confidence: 0,
29 reason: '待機中',
30 mermaid: null,
31 file: null,
32 viewerUrl: null,
33 jevEnabled: false,
34 tokens: null,
35}
36
37/** 確率を 10 段のバーにする。Jev の確率をそのまま目で見るための表示。 */
38export function confidenceBar(value: number, width = 10): string {
39 const clamped = Math.max(0, Math.min(1, value))
40 const filled = Math.round(clamped * width)
41 return `${'█'.repeat(filled)}${'░'.repeat(width - filled)} ${clamped.toFixed(2)}`
42}
43
44const PHASE_LABEL: Record<Phase, string> = {
45 idle: '待機',
46 thinking: '判定中',
47 drawn: '描画',
48 held: '保留',
49 error: 'エラー',
50}
51
52const PHASE_COLOR: Record<Phase, string> = {
53 idle: 'gray',
54 thinking: 'yellow',
55 drawn: 'green',
56 held: 'yellow',
57 error: 'red',
58}
59
60/** 帯に入れる Mermaid ソースの行数(多いと入力欄が押し上げられる)。 */
61export function previewLines(mermaid: string | null, maxLines: number, columns: number): string[] {
62 if (mermaid === null) return []
63 const lines = mermaid.split('\n')
64 const shown = lines.slice(0, maxLines).map((line) => {
65 const trimmed = line.replace(/\t/g, ' ')
66 return trimmed.length > columns - 4 ? `${trimmed.slice(0, columns - 5)}…` : trimmed
67 })
68 if (lines.length > maxLines) shown.push(`… 他 ${lines.length - maxLines} 行`)
69 return shown
70}
71
72/**
73 * 要素コンストラクタ。props の型は面ごとに違うので、ここでは中身を見ない。
74 * 返り値 `R` は `$.ui.resolve(e)` が返すテーブルから推論させる。
75 */
76// eslint-disable-next-line @typescript-eslint/no-explicit-any
77type ElementFn<R> = (props: any) => R
78
79type ElementTable<R> = {
80 Box: ElementFn<R>
81 Text: ElementFn<R>
82}
83
84/**
85 * 帯の描画ツリーを組み立てる。
86 *
87 * 要素コンストラクタを引数で受け取るので、テストでは差し替えられる。
88 * Mod の中では `$.ui.resolve(e)` が返すテーブルをそのまま渡す。
89 */
90export function bandTree<R>(
91 elements: ElementTable<R>,
92 status: BandStatus,
93 columns: number,
94 maxRows: number,
95): R {
96 const { Box, Text } = elements
97 const width = Math.max(24, columns)
98 const previewRoom = Math.max(0, Math.min(8, maxRows - 4))
99
100 const header: R[] = [
101 Text({ color: PHASE_COLOR[status.phase], bold: true, children: [`mermaid-live ${PHASE_LABEL[status.phase]}`] }),
102 Text({ dimColor: true, children: [' '] }),
103 Text({ color: 'cyan', children: [status.kind ?? '-'] }),
104 Text({ dimColor: true, children: [' '] }),
105 Text({ dimColor: true, children: [confidenceBar(status.confidence)] }),
106 ]
107
108 if (!status.jevEnabled) {
109 header.push(Text({ dimColor: true, children: [' '] }))
110 header.push(Text({ color: 'yellow', children: ['Jev 無効'] }))
111 }
112
113 const rows: R[] = [Box({ flexDirection: 'row', children: header })]
114 rows.push(Text({ dimColor: true, wrap: 'truncate-end', children: [status.reason] }))
115
116 const preview = previewLines(status.mermaid, previewRoom, width)
117 if (preview.length > 0) {
118 rows.push(
119 Box({
120 flexDirection: 'column',
121 marginTop: 1,
122 children: preview.map((line) => Text({ dimColor: true, wrap: 'truncate-end', children: [line] })),
123 }),
124 )
125 }
126
127 const footer: string[] = []
128 if (status.file !== null) footer.push(status.file)
129 if (status.viewerUrl !== null) footer.push(status.viewerUrl)
130 if (status.tokens !== null) footer.push(`${status.tokens} tok`)
131 if (footer.length > 0) {
132 rows.push(Text({ dimColor: true, wrap: 'truncate-end', children: [footer.join(' · ')] }))
133 }
134
135 return Box({
136 flexDirection: 'column',
137 borderStyle: 'round',
138 borderDimColor: true,
139 paddingX: 1,
140 width,
141 children: rows,
142 })
143}
144