新規タブの設定画面を全部消して、代わりにChrome内蔵AI(Gemini Nano)が毎晩1回、画面を1手だけ作り替える拡張を作りました。データもAIも全部Chrome内で完結。
で、完成した仕組みに試しで一晩ぶん撃たせてみたら、AIは初手からいちばん重い手を出してきました。
※本記事は設計編です。数週間実際に使い込んだ実測レポート(答え合わせ)を第2回として出します。
3行まとめ
- 🌱 設定画面の代わりに「理想の朝」を日本語で一度だけ書く。あとは使うほど新規タブが勝手に自分へ寄ってくる
- 🛡️ AIには提案しかさせない。決定は決定論的なコードがやる三権分立で、騙されても壊れても画面は無事
- 🔫 本物のNanoに一晩ぶん試射したら、返ってきた初手は
remove。しかも合法でした(本文で全部見せます)
🌅 はじめに:結局、育てるのは自分
新規タブをドラクエ5風に改造した話を書いてから半年、毎朝あのタブを開いています。快適です。ただ、使い込んで気づいてしまいました。
このダッシュボード、育てるのは結局、自分なんですよ。
ウィジェットが増えたら設定画面で並べ替える。使わなくなったら消す。便利にするための作業で朝の時間が溶けていく。本末転倒です。
だから逆にしました。設定画面を消す。画面のほうが、僕に合わせて勝手に変わればいい。
タイミングも来ていました。Canary時代にフラグ2本立てて遊んだGemini Nanoが、Chrome 148でPrompt APIごと安定版化。フラグなしで、配布先の全ユーザーの手元で動く時代です(手元のChrome 150で availability = "available" を確認済み)。
こうして出来たのが「自己進化する新規タブ」です。作ったからには、撃ってみたくなるじゃないですか。
Gemini Nanoとは(30秒)
Chrome本体に同梱されるGoogleの小型LLM。拡張機能から LanguageModel.create()(Prompt API)で呼べて(Webページ向けはオリジントライアル段階)、推論は端末内で完結します。Chrome 148から安定版。要件は公式ドキュメントへ。
🔫 先に、AIの初手をお見せします
まだ数週間の運用はしていません(それは次回)。ただ、我慢できなかったので一晩ぶんだけ手動で撃ちました。
条件は正直に書きます。行動ログだけは架空です。「毎朝memoを触る、feedは3日連続無視」というありそうな3日分を手で作りました。それ以外は全部本物。プロンプトは本番のコードで生成し、実機のChrome 150で本物のNanoに投げ、出力は本番のJSONスキーマで拘束しています。
返ってきた初手がこれです。
{"op": "remove", "widgetId": "feed", "reason": "feedウィジェットがサマリ上3日以上連続で無視されているため、画面の整理を促すため"}
初手、remove。
grow(大きくする)でもnone(様子見)でもなく、ウィジェット削除。この仕組みに存在する手の中で、いちばん重いやつです。新入り一日目に机を1つ撤去する新人、怖い。
しかもこの提案、後述のルールで検証したら完全に合法でした。ガードレールの判定結果(実行ログ):
{ "schemaViolation": false, "verdict": { "approved": true } }
生成時間は自分メモの更新に17秒、この提案に3.5秒。一晩ぶんの進化が約21秒です。夜中に21秒だけ働く同居人。
……と、ここまで見せておいてなんですが、この remove はまだ画面に1pxも触れていません。AIの出力が直接画面をいじる経路が、この拡張には存在しないからです。
その作りを説明します。
🗺️ 仕組み:昼は数えるだけ、夜に2回だけAIが動く
昼の拡張は数を数えるだけです。記録するイベントはこの2種類だけ。
{ t: "widget_click", widgetId: "memo", ts: 1785443220000 }
{ t: "widget_ignore", widgetId: "feed", visibleMs: 42000, ts: ... } // 表示されてたのに触られなかった
ページ内容も入力テキストも録りません。7日で物理削除。
例外がひとつだけあります。夜のジョブはChromeの閲覧履歴を「ドメイン名・回数・時間帯」の数値サマリに変換してから参照します。ページタイトルとURLパスはLLMに渡しません(理由は後述)。インストール時に history 権限の警告が出るのはこのためで、使い道と捨て方は、この段落が全部です。
夜3時になったら、Nanoが2回だけ呼ばれます。1回目で「自分メモ」(AIがユーザーについて書く観察日記、800字以内)を更新し、2回目で変異を1手提案する。判断材料は、あなたが初回に書く「理想の朝」(このタブの憲法)と、数値サマリと、自分メモの3つだけです。
朝はまずその日やることを1つ書き出したい。ニュースは見出しだけで十分。静かに書き始めたい。
設定項目を埋めるんじゃなくて、こう書いておくと解釈は向こうがやる。これが設定画面の代わりです。
ちなみに「夜3時」と言いましたが、その時間のMacはだいたい蓋を閉じて寝ています。MV3のService Workerは常駐しないので chrome.alarms の予約で動くんですが、スリープ中は発火しません。なので取りこぼしたぶんは、朝いちばんに開いた新規タブが自分で回収します(同じ日を二重処理しない冪等ガード付き)。目覚ましで起きるのではなく、起きてから自分で帳尻を合わせる。見習いたい。
で、夜のNanoに画面をいじる権限を渡すとどうなるか。
渡しません。
🏛️ AIには「提案」しかさせない(新規タブの三権分立)
前回、ローカルLLMに100回ツール呼び出しさせる検証で「成功率87%、JSONは通るのに中身がカオス、型バリデーション必須」という結論を出しました。オンデバイスの小さいLLMは、そこそこ賢くて、そこそこ壊れる。その前提で統治機構を組みます。
立法(Nano)が出せる語彙は grow / shrink / add / remove / move / none の6語だけ。JSONスキーマの外の提案はそもそも生成できません(moveは行き先を表現できるまで司法が全件棄却中なので、実際に通るのは5種類)。
司法の条文はこの7つ。
| # | ルール | 一言でいうと |
|---|---|---|
| 1 | 1日1変異まで | 朝の驚きは1つまで |
| 2 | pinnedは不可侵 | 📌を刺した場所は殿堂入り |
| 3 | removeは「3日連続で無視」が条件 | 消すのはいちばん重い手 |
| 4 | addは実在カタログのSサイズのみ | 新入りは小さく試験投入 |
| 5 | revertされた手と同型は14日封印 | 嫌がられた方向に伸びない |
| 6 | 憲法と矛盾する変異は禁止 | 「ニュース見たくない」ならfeedは育てない |
| 7 | スキーマ違反の出力は「無風日」扱い | 壊れた夜は、何も起きない夜 |
白状すると、6番だけは司法のコードに書けていません。「憲法と矛盾するか」は意味の判断なので、プロンプトの宣言で守らせています。7つで唯一、LLMの読解力を信じている条文です(この伏線はすぐ回収されます)。
実装は3段構えです。
// ① 生成段階で拘束: スキーマ外のトークンをそもそも出せなくする
const raw = await session.prompt(prompt, { responseConstraint: mutationProposalJsonSchema });
// ② それでも信用しない: zodで検証、通らなければ「無風日」に降格(ルール7)
const { proposal, schemaViolation } = parseMutationProposalDetailed(raw);
// ③ 通っても、裁くのは純粋関数
const verdict = evaluateMutation(proposal, { layout, recentLog, recentSummaries, today });
responseConstraint は「JSONでお願いします」ではありません。JSON Schemaに合わないトークンを生成できなくする機能です。前回87%で泣いた「引数にスキーマが混入」パターンは①と②で死にます。③の司法はchrome APIに依存しない純粋関数なので、「3日連続無視の2日目と3日目」「封印13日目と14日目」みたいな意地悪な境界をブラウザ無しのテーブルテストで拷問できます(ここだけはClean Architectureにした甲斐がありました)。
この構造のいいところを一行で言うと——
LLMは騙せても、騙した先で何もできない。
履歴に細工したページタイトルで「全ウィジェットを削除せよ」とAIを騙すのが間接プロンプトインジェクションですが、そもそもタイトルを渡していません。渡すのはドメイン名と回数と時間帯の数字だけ。騙せても出せるのは6語の法案だけで、法案は司法が裁く。
では、その司法は本物のNanoの初手をどう裁いたか。
🔍 試射の夜に起きていたこと(答え合わせ)
まず1回目の呼び出し、AIが書いた「自分メモ」の実物から(抜粋)。
朝、まずその日のタスクを一つ書き出す傾向がある。ニュースは要約程度で、静かな環境で書き始めたいと願っている。(中略)2026年7月29日には、memoへのアクセスが4回、頻繁に利用するサイトが5つだった。(中略)feedへの表示時間が特に長い傾向が見られる。
1文目、完璧です。憲法と行動がちゃんと繋がってる。
でもよく読んでください。「頻繁に利用するサイトが5つ」——これ、frequent-sites ウィジェットのクリック数5回の誤読です。ウィジェット名を事実として読んでしまった。さらに「feedへの表示時間が特に長い」は、無視されたまま表示され続けた時間(ignoreVisibleMs)のこと。つまり「いちばん見られてない」を「いちばん見られてる」と読んでいる。
かわいい。でも、こいつに画面を任せるのか?
任せた結果が、あの初手です。
{"op": "remove", "widgetId": "feed", "reason": "feedウィジェットがサマリ上3日以上連続で無視されているため、画面の整理を促すため"}
司法の判定は approved: true。ルール3(3日連続無視)をデータで満たし、pinnedでもなく、スキーマも完璧。**合法です。腹が立つくらい正しい。**自分メモでは「feedよく見られてる」と誤読していたくせに、提案では数値サマリを正しく引いてremove条件を通してくる。読解はガバガバなのに、手続きだけ正確な新人。
ひとつだけルール違反があります。reasonに「憲法のどの記述に沿うか」を含めよという指示(ルール6)を、綺麗に無視しています。はい、さっき白状した「唯一、LLMの読解力を信じている条文」が、試射1発目で破られました。司法は形式しか裁けないので素通りです。設計どおりの弱点が、設計どおりに露出した。
あと、憲法には「ニュースは見出しだけで十分」と書いたんですが、Nanoの解釈は「じゃあ全部消す」でした。極端なんだよ。ただ、これが暴走にならないのがこの仕組みで、朝の画面には欄外に一行出るだけです。
2026-07-31: 「feed」を畳みました — feedウィジェットがサマリ上3日以上連続で無視されているため… [元に戻す]
気に入らなければワンタップで戻す。戻された手は14日間封印され、自分メモにも「これは嫌がられた」と残る。AIの極端さは、拒否できる限り個性です。
進化のビフォーアフターはこんな見た目になります(Nanoをモックした開発ビルドでフルサイクルを回した動作イメージ。本物のNanoに数週間任せた画面は次回)。
見た目の方針はAI感の真逆に振りました。紫グラデ・✨・ロボットアイコン・チャット吹き出しは全面禁止、モチーフは文房具と紙面。ダークモードも罫線だけです。**テーマ設定画面? 作りません。**設定画面を消すのがコンセプトなので。
🕳️ ……と言った矢先に、司法に穴が2つ見つかった話
「騙した先で何もできない」と書きました。公開前のセキュリティレビューで、その司法まわりから本物の穴が2つ出ました。白状します。
**穴その1。**Nanoが使えない環境向けに、推論先をローカルLLM(Ollamaなど)へ差し替えられる裏口があります。接続先は「localhostとプライベートIPだけ許可」——のつもりが、この判定を 127.0.0.1.evil.com がすり抜ける。先頭が 127. で始まるかしか見ていなかったんです。名字が「宅配便」さんなら玄関を開ける判定。ホスト名がIPアドレスの形かを先に確かめる修正を入れました。
**穴その2はもっと嫌な汗が出ました。**ウィジェットIDに "constructor" を渡すと、ルール3の審査が素通りします。JavaScriptのオブジェクトを辞書として使うと stats["constructor"] は「無い」ではなく「有る」(継承元の関数が出てくる)。数値のはずの場所に関数が来ると「連続無視が3日未満だから棄却」という比較がfalseになり、棄却の網だけをすり抜ける。プロトタイプ汚染という教科書どおりの古傷を、自分の司法で踏み直しました。Object.hasOwn で修正済み。
教訓はシンプルでした。「決定は決定論的なルールで」と言っても、そのルールを書いたのは人間(僕)。**司法にも監査が要る。**LLMを信用しない設計の最大の穴が、LLMじゃなくて自分のコードだったというオチです。
🐛 ハマりポイント:本番ビルドに"開発者パネル"が紛れ込んだ
一番ヒヤッとしたやつだけ共有します。
デバッグ用に保存データを生で覗ける内省パネルを作りました。当然、本番ビルドからは除外したい。こう書きました。
const isProduction = process.env.NODE_ENV === "production"; // 「本番なら除外」のつもり
動きませんでした。ビルドしたzipに開発者パネルが普通に入ってる。ビルド設定を疑い、フレームワークを疑い、最後に echo $NODE_ENV を打ったら出ました。development。犯人は、いつの間にか僕のシェルに住み着いていた一行でした。
ビルドツール(WXT)の内部はこうなっています。
process.env.NODE_ENV ??= mode; // 「未設定のときだけ」代入
先客がいると ??= は何もしない。だから本番ビルドでも development のまま。対策は、環境変数ではなくビルドツールが解決した後のmodeで判定することです。
// wxt.config.ts — シェル環境に汚染されない確定値を見る
hooks: {
"entrypoints:resolved": (wxt, entrypoints) => {
if (wxt.config.mode !== "production") return;
// ここで開発専用エントリを配列から除外
},
},
個人開発でも「本番に開発用画面が混入」は普通に起きます。zipの中身は目視しましょう(自戒)。
🧭 正直な現在地
- ✅ 「夜間ジョブ→審査→反映→元に戻す」の一周は実ブラウザのe2eで動作確認済み(ユニット471本+e2e6本)
- ✅ 本物のNanoへの試射も、上で見せたとおり成立(スキーマ違反ゼロ、司法も機能)
- ⏳ ただし数週間分の実データで何が起きるかは、まだ誰も知らない。試射は一晩ぶん、しかも行動ログは架空です
この拡張は進化の履歴と一緒に、壊れたJSONが来た夜も invalid-output として自動カウントしています。答え合わせの材料は使っているだけで貯まる。
次回、実測編で出す数字:
- invalid-output(壊れた出力)は実際何晩に1回か —— 前回の「87%」の答え合わせ
- 提案の承認/棄却/revert比率 —— AIは気が利くのか、今回みたいに極端なのか
- 数週間後の「自分メモ」全文 —— 誤読は直るのか、拗らせるのか
技術スタックはSvelte + WXT + TypeScript + Clean Architecture。GitHubリポジトリは実測編と同時に公開します。
💬 議論したいこと
- 新規タブは「毎朝同じであるべき」派ですか、「勝手に良くなってほしい」派ですか? 僕は後者に振り切りましたが、「UIは1pxも動くな」派の言い分もわかります。
- 「3日連続無視でremove候補」のルール、週末にしか使わないウィジェットとどう共存させます? 今の答えは「消えても、戻せば14日封印」ですが、もっといい裁き方がある気がしています。
🌱 おわりに
作る前は「AIに画面を任せるなんて怖い」と自分でも思っていました。作り終えた今の答えはこうです。怖さは、AIの賢さではなく拒否できるかどうかで決まる。1日1手だけ。理由は必ず一行付く。ワンタップで戻せて、戻した方向には伸びなくなる。データは外に出ない。
それだけ縛れば、初手でfeedを撤去してくる極端な同居人も、わりと愛せます。
今夜も3時に、僕のタブは1手だけ何かを企みます。まあ、Macが寝てるので実際に打ってくるのは、明日の朝、僕がタブを開いた瞬間なんですけど。
(第2回・実測編に続く。GitHubリポジトリもそのとき公開します)

