SLOPSHOPPER

mermaid-live

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

newbandprocessnetwork
v0.1.0MITupdated 2026-09-23vivi-0124/me/mermaid-live
A shopper browsing a rack in a slop shop
README

mermaid-live

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)。

flowchart のビューア

Jev に聞いていること

1 往復で 4〜5 問まとめて評価する(Jev は並列に答える)。

id型何を決めるか
diagrammablenoul(yes/no の確率)そもそも図にする価値があるか
kindchoiceflowchart / sequence / state / mindmap / none
readinessscore途中経過がどこまで描き切れる状態か
directionchoiceTD か LR か
changednoul前に描いた図から意味が変わったか(ちらつき防止)

changed が効くのが実感しやすい。これが無いと 1 秒ごとに描き直して画面が暴れる。

必要なもの

  • Claude Code 2.1.27x 以降(Mods は早期アクセス)
  • Node.js 22.6 以降(ビューアとテスト用。依存パッケージはゼロ)
  • Jev の API キー(console.typesafe.ai/keys)
  • 無くても動く。その場合は規則ベースの当て推量で図を出す(帯に「Jev 無効」と出る)

料金

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 度も呼ばれず、規則ベースで動く(料金ゼロ)。

使い方

1. 関数フックを有効にする

~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1",
    "TYPESAFE_API_KEY": "ts-...",
    "MERMAID_LIVE_VIEWER": "1"
  }
}

このフラグが無いと Mod は黙って読み込まれない。

2. 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

3. ビューアを開く

MERMAID_LIVE_VIEWER=1 なら、セッション開始時に自動で立ち上がる。 手で立てるなら:

node viewer/server.mjs          # http://127.0.0.1:4737

ブラウザを開いたまま Claude に何か聞くと、答えが伸びるのに合わせて図が育つ。

4. Claude Code を立ち上げずに見てみる

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_MODELjev-latestJev のモデル
MERMAID_LIVE_BASE_URLhttps://api.typesafe.ai/v1Vercel AI Gateway 等に向けるとき
MERMAID_LIVE_DIR.claude/mermaid-livecurrent.mmd と state.json の置き場
MERMAID_LIVE_PORT4737ビューアのポート
MERMAID_LIVE_VIEWERなし1 でセッション開始時にビューアを自動起動
MERMAID_LIVE_MIN_GROWTH240前回の判定から何文字増えたら次を聞くか
MERMAID_LIVE_MIN_INTERVAL_MS1200判定の最短間隔
MERMAID_LIVE_THRESHOLD0.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 に逃がしてあり、チャンクはそのまま素通しする。
  • 聞きすぎない。 「前回から N 文字増えた」かつ「M ミリ秒経った」ときだけ聞く。 時計を見るのもホストへの往復なので、その前に文字数だけで足切りしている。
  • 閉じていない ```mermaid は使わない。 応答自体が Mermaid を書き始めた場合、 閉じフェンスが来るまでは未完として扱い、材料から自前で組み立てる。

開発

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 を書く、の一本道が壊れたら落ちる。

既知の制約

  • Mods は早期アクセス。 $ の形もイベント名もリリース間で変わりうる。 変わったら /plugin-types で型を取り直して tsc に通すのが早い。
  • フックには実行時間の予算がある。 Jev の 1 往復には 6 秒で見切りをつけ、 遅い回はその場で捨てて次のチャンクに任せる。図が出ないより遅れるほうが困る。
  • ビューアは Mermaid を CDN(jsdelivr)から読む。 外に出られない環境では viewer/index.html の import を手元のファイルに差し替える必要がある。
  • 日本語の抽出は素朴。 番号付きリスト・箇条書き・A -> B の矢印が主な手がかりなので、 地の文だけで説明された手順は拾いきれない。ここは hooks/lib/extract.ts を育てる場所。
  • Jev には料金がかかる。 1000 応答あたり $0.07〜0.32 の実測。コストは応答の長さに対して二次で伸びる(毎回それまでの本文を送り直すため)。詳細と削り方は docs/pricing.md。

参考

Source 9 files
hooks/register.ts 316 lines
1/**
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}
316
hooks/lib/extract.ts 283 lines
1/**
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}
283
hooks/lib/mermaid.ts 133 lines
1/**
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}
133
hooks/lib/questions.ts 119 lines
1/**
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}
119
hooks/lib/jev.ts 175 lines
1/**
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}
175
hooks/lib/decide.ts 128 lines
1/**
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}
128
hooks/lib/buffer.ts 96 lines
1/**
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}
96
hooks/lib/config.ts 87 lines
1/**
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}
87
hooks/lib/band.ts 144 lines
1/**
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