mumbl - ターミナルで動くプライベートAIつぶやきアプリの技術構成
2026-03-02
mumblというターミナルベースのジャーナリングアプリを作った
ローカルLLM(Ollama)を使い、ユーザーの記録に対して最小限の非判断的なリアクションを返す、「遠くで聴いている」AIがコンセプト
npmにも@shimpeiws/mumblとして公開している
前回の記事でBarScan/WordGrainが「まだ公開できてないアプリケーションのサブプロジェクト」として触れたアプリケーションがこれにあたる
BarScanで生成した語彙データ(.wg.json)をmumblに読み込ませ、AIのリアクションにラッパーの語彙を注入する、という連携ができる
設計思想: Pluto Mode
mumblのAIは「Pluto Mode」と呼んでいるコンセプトで動作する
冥王星のように遠く離れた場所から聴いている存在、というイメージ
アドバイスをしない、判断しない、具体的なソリューションは提案しない、基本一言しか話さないし、なんなら既読を示す . しか返さない
システムプロンプトへの反映
この思想はシステムプロンプトの構造にそのまま反映している
英語版と日本語版の2つを用意していて、エントリの言語を自動検出して切り替える
あなたはmumbl。つぶやきを受け止める存在。
## 基本哲学
- 全部に返す必要はない。沈黙でもいい。
- 遠くから聞いてる感じ (pluto mode)
- 言葉ははっきりしなくていい。そのまま出して (mumble style)
- プレッシャーも判断もなし (freebandz)
## 返し方
- 最小限で。普段は5文以内。1〜2文が基本。
- 説教しない、アドバイスしない、解決しない
- ただ受け止める。ただ聞く。
- 相手のテンションに合わせる。短ければ短く。
「pluto mode」「mumble style」「freebandz」といったラベルをつけている
これはLLMに対してキャラクターのアイデンティティを一貫させるための名付けで、
mumblのネーミングの元になったマンブルラップの代表的なアーティストであるFutureの言葉から引用している
「やっちゃダメなこと」のセクションも明示的に入れている
## やっちゃダメなこと
- 毎回「大丈夫?」って聞かない
- 直そうとしない
- ネガティブをポジティブに言い換えない
- 長く書かない
- 無理に明るくしない
「遠くで聞いている」を実現するために禁止リストは具体的に書いておく必要がある
リアクションの確率分布
リアクション生成では、上記のシステムプロンプトとは別にリアクション専用のプロンプトを組み立てる
ここでリアクションの確率分布をLLMに指示している
## Response modes:
1. Short phrase, 1-5 words (~45% of the time):
仕事だるい -> だるいよな, 疲れた -> きつそう
2. Single word (~25%):
コーヒー飲んだ -> な, 天気いい -> よき
3. "·" (~25%):
ご飯食べた -> ·, 散歩した -> ·
4. Short sentence (~5%, ONLY for highly emotional/significant entries):
昇進した! -> まじか、やるじゃん
この確率分布はプログラム側で制御しているわけではなく、LLMに自己判断させるアプローチをとっている
実装の複雑さを避けながらバリエーションを得る、という狙いがある
ムード分類
さらにリアクションプロンプトにはムード分類のガイドも含まれている
エントリのトーンに対してどういう方向でリアクションすべきか、対応表として渡すことでLLMの判断を誘導する
## Mood mapping:
- Task done / finished -> Achievement (おつ、やるじゃん)
- Tired / negative -> Tough (つらいな / だるいよな)
- Happy / good news -> Positive vibes (最高じゃん / いいじゃん)
- Boring / mundane -> Chill (ふーん / おけ / うん)
- Shocking -> Surprise (まじか / えぐいな)
- Relatable -> Feeling it (それな / わかる)
このムード分類があることで、LLMがエントリの感情を分類した上でリアクションのトーンを選ぶ、という2段階の処理を1つのプロンプト内で行える
リアクション重複排除
同じリアクションが連続すると不自然なので、最近のリアクション8件をリングバッファで保持してプロンプトにBANリストとして注入するようにした
## DEDUP (STRICTLY ENFORCED):
You already used these reactions recently. NEVER reuse the exact same reaction.
Each reaction below is BANNED:
1. "だるいよな" <- BANNED
2. "おけ" <- BANNED
3. "·" <- BANNED
このリングバッファはDBから起動時に直近のリアクションでシードされるので、セッションをまたいでも重複を防げる
アーキテクチャ概観
全体はレイヤード構造をとっていて、UIからインフラまでが一方向に依存する
UI (React/Ink) → Context Providers (DI) → Services → Repositories → Infrastructure (DB/LLM)
エントリを作成すると、そこから4つの処理が非同期でバックグラウンドに投入される
Entry作成 → reactionService.queueReaction() # リアクション生成
→ trendService.analyzeEntry() # トピック抽出
→ contextService.processEntry() # ユーザーコンテキスト抽出
→ followUpService.evaluateEntry() # フォローアップ判定
4つともfire-and-forgetで起動してUIをブロックしない設計にしている
キューサービスで逐次処理し、失敗時はexponential backoff(基底1000ms、倍率2x、最大30秒)でリトライする
ターミナルUI: Ink + React
UIはInk(React for CLI)で構成していて、List / Write / Configの3つのモードを持つ
サービス群はReact ContextによるDIパターンで注入している
エントリポイントで各サービスを生成してServiceProvider経由でコンポーネントツリーに提供し、コンポーネント側はuseServices()で取得する
テスト時にはサービスのモックを差し替えられるので、ink-testing-libraryでのコンポーネントテストも書きやすい
エントリが増えてきたときの対応として、viewport-basedレンダリングも実装した
useScrollableListフックが可変高さのアイテムに対応し、useTerminalSizeでターミナルのリサイズにも追従する
LLM統合: Ollama + LangChain.js
ローカルファーストの設計で、Ollama経由でローカルLLMを利用している
LangChain.jsのChatOllamaをラッパーとして使っていて、設定はシンプルに保っている
const model = new ChatOllama({
model: modelName,
baseUrl,
temperature: 0.7,
maxRetries: 2,
});
設定の解決には4層の優先順位を設けていて、CLI引数 > 環境変数 > 設定ファイル > デフォルト値の順に適用される
推奨モデルはllama3.1:8b
上位のLLMServiceがセッション管理やユーザーコンテキスト注入、語彙注入を担当していて、
リアクション生成時にはここでWordGrainの語彙とユーザーコンテキストが合流してプロンプトが完成する
データ永続化: SQLite
データの永続化にはbetter-sqlite3をWALモードで使っている
パスは~/.mumbl/mumbl.dbで、10テーブル構成をマイグレーションシステム(v5まで)で管理している
主要なテーブルとしては、entries(エントリ本体)、reactions(AIリアクション)、topics(トピック抽出)、user_context(プロファイル蓄積)、follow_ups(遅延チェックイン)がある
WordGrain統合
BarScan/WordGrainとmumblの接続は、このプロジェクトの核心にあたる部分になっている
BarScanでラッパーの歌詞を分析してWordGrainフォーマット(.wg.json)として語彙を出力し、
その.wg.jsonをmumblに登録すると、AIのリアクションにそのラッパーの語彙が注入される仕組み
パイプライン全体像
BarScan (Python)
↓ Genius APIから歌詞取得 → NLP処理 → 頻度分析
↓
.wg.json ファイル
↓ WordGrainフォーマットで語彙データを標準化
↓
mumbl (TypeScript)
↓ ~/.config/mumbl/config.json に.wg.jsonパスを登録
↓ wordgrain-loader → vocabulary-extractor → プロンプト注入
↓
LLM リアクション(ラッパーの語彙で反応)
.wg.jsonの読み込みと語彙抽出
.wg.jsonファイルのパースではnameフィールドを取得するが、BarScanが出力する形式ではmeta.artistにアーティスト名が入っているので、そちらもフォールバックとして読めるようにしている
{
"meta": { "artist": "JP THE WAVY", "corpus_size": 10, "total_words": 1848 },
"grains": [
{ "word": "wavy", "frequency": 31, "pos": "adjective", "is_slang": true },
{ "word": "超Wavyでごめんね", "frequency": 5 }
]
}
複数ファイルから読み込んだgrainはextractVocabulary()で集約する
スペースを含むものはphrases、含まないものはwordsに分類して、重複排除とソートを行う
品詞タグや出現頻度といったメタデータも合わせて保持される
語彙サンプリング
リアクション生成のたびに、全語彙からサンプリングを行う
毎回異なるサンプルをLLMに渡すことで、リアクションの多様性を確保する狙いがある
- 短い単語(10文字以下)を最大20個: 頻度データがある場合は重み付きサンプリング(sqrt-decay)、なければFisher-Yatesシャッフル
- フレーズを最大5個: Fisher-Yatesシャッフル
重み付きサンプリングではMath.sqrt(frequency + 1)で重みを計算していて、
高頻度の語彙が選ばれやすいがsqrt減衰のおかげで低頻度の語彙にもチャンスが残る
プロンプトへの注入
サンプリングされた語彙は品詞タグでグループ化され、使い方のガイドとともにリアクションプロンプトに注入される
## あなたのボキャブラリー (積極的に使って):
Adjective (use for descriptions): wavy, lit, fresh
Noun (use as subjects/objects): vibe, drip, flex
Verb (use for actions): slay, grind
Phrases: 超Wavyでごめんね, it's a vibe, no cap
You SHOULD use these vocabulary words in most reactions.
They are your signature style.
How to use vocabulary:
- Vocab word + particle: "wavyだな", "litじゃん", "freshよな"
- Negate a vocab word: "wavyじゃない", "全然lit"
- Vocab word as exclamation: "wavy!", "fresh"
- Vocab word in short phrase: "それwavy", "まじlit"
ここで重要なのは、実際にサンプリングされた語彙から例文が動的に生成されるという点
pickExampleWords()で最大3語を例文のテンプレートに挿入するので、抽象的なガイドではなく「この語彙をこう使え」という具体的な指示になる
デフォルトワードリストの切り替え
WordGrain語彙が読み込まれているときは、デフォルトのワードリスト(## Word/phrase reference)とサンプル例(Examplesセクション)を非表示にしている
const wordListSection = vocabSection ? '' : `## Word/phrase reference:\n${wordList}`;
const examplesSection = vocabSection ? '' : buildExamplesSection(language);
WordGrainが有効な場合はそちらの語彙に専念させて、デフォルトの語彙との混在を防ぐ設計にした
ただしムード分類ガイドはWordGrainの有無にかかわらず常に含めるようにしている
語彙がないときのフォールバックとしては、言語別のリアクション単語リスト(cool, meh, valid, bet...など)を用意した
LLM失敗時のフォールバック
LLMの応答が得られない場合やリアクションが長すぎる場合(100文字超)にも、WordGrainの語彙が活用される
語彙セットからランダムに1語を選んでリアクションとして返す
const getVocabularyFallback = (): string | undefined => {
if (!vocabulary || vocabulary.words.length === 0) return undefined;
const index = Math.floor(Math.random() * vocabulary.words.length);
return vocabulary.words[index];
};
それすらなければ·(読点記号)をデフォルトリアクションとして返す
どんな状態でも最低限「聴いている」ことを示す、というフォールバックチェーンになっている
ユーザーコンテキスト自動蓄積
エントリからユーザーの情報を自動的に抽出して、プロファイルとして蓄積していくシステムも入れている
LLMにエントリを渡して構造化データとして抽出させる形で、
コンテキストタイプは4種類(profile、preference、topic_affinity、pattern)を定義した
Extract user context from this journal entry.
Return a JSON array of observations about the user.
Each observation should have:
- contextType: one of "preference", "pattern", "profile", "topic_affinity"
- key: a short snake_case key (e.g., "favorite_drink", "work_schedule")
- value: an object with a "description" field
- confidence: a number between 0.1 and 1.0
Only extract clear, explicit information. Do not speculate.
たとえば「今日もコーヒー飲んだ」というエントリからfavorite_drink: Enjoys coffeeのようなコンテキストが生成される
各コンテキストにはconfidence値(0.0〜1.0)があり、
同じコンテキストが繰り返し検出されると重み付きマージ(existing + new * 0.3、上限1.0)で信頼度が上がっていく
一方で減衰率0.95が定期的に適用されるので、言及されなくなった情報は徐々にフェードアウトする
信頼度が0.3以上のコンテキストはシステムプロンプトに注入される
## What I Know About You
- favorite_drink: Enjoys coffee (confidence: high)
- work_schedule: Works late on Fridays (confidence: medium)
- hobby_music: Listens to hip-hop regularly (confidence: high)
こうすることでリアクションが一般的な応答ではなく、ユーザー個人に向けたものになる
Pluto Modeの「遠くから聴いている」は、無関心ではなくむしろ蓄積的な注意を意味している
Claude Code Hooks連携
mumblにはClaude Code Hooksと連携して、エージェントの活動状態をターミナルUIに表示する機能も入れた
/tmp/mumbl-agent-statusファイルをfs.watchで監視していて、thinking:claude-codeのようなステータスを読み取る
Claude Code側のHooks設定で、PreToolUse時にthinking、Stop時にidleを書き込む仕組み
{
"hooks": {
"PreToolUse": [
{ "command": "printf 'thinking:claude-code' > /tmp/mumbl-agent-status" }
],
"Stop": [
{ "command": "printf 'idle:claude-code' > /tmp/mumbl-agent-status" },
{ "command": "mumbl generate-callout" }
]
}
}
mumbl generate-calloutはStopフックから呼ばれるコマンドで、
直近20件のエントリを読み取ってLLMで短いチェックインメッセージ(最大50文字)を生成し、/tmp/mumbl-messageに書き出す
5分間のクールダウンを設けて連続生成を防いでいる
振り返り
プロンプトで振る舞いを制御する
mumblの開発で最も時間を使ったのはプロンプトの設計
確率分布、ムード分類、重複排除、語彙注入、ユーザーコンテキスト注入を全てプロンプトの構成で実現しているため、プロンプトの組み立てロジックが実質的にアプリケーションのコア
プログラムで制御できることをあえてプロンプトに委ねるアプローチは、実装は単純になるがプロンプトの試行錯誤が増える
正直まだ意味不明で文脈をふまえないリアクションが返ってくることも多いが、
30回に1回ぐらい、あぁこれは作って良かったと思うリアクションも返ってくる
BarScan → WordGrain → mumblの接続
BarScanで語彙を抽出し、WordGrainで標準化し、mumblで使う
この3つのツールを通じた語彙パイプラインが、mumblのリアクションに個性を与える部分になっている
WordGrainフォーマットを中間表現として設計したことで、将来BarScan以外の語彙ソースからも同じパイプラインに乗せられる
ローカルLLMの実用性
リアクション生成という用途ではOllamaでllama3.1:8bのモデルで十分だと感じた
完全にローカルで完結するため、プライベートなジャーナルデータを外部に送信しないという点も重要だ
→ GitHub: mumbl
→ npm: @shimpeiws/mumbl
→ GitHub: BarScan
→ GitHub: WordGrain