セッションタイトル設計
最初のアシスタントターン後に高速モデルによって生成される、3〜7語のセンテンスケースのセッションタイトル。セッション JSONL に
titleSource: 'auto' | 'manual'タグと共に永続化され、セッションピッカーに表示され、/rename --auto経由でオンデマンドで再生成可能。
概要
/rename (#3093) を使用すると、ユーザーはセッションにラベルを付けて後でピッカーから再度見つけられるようになりますが、実行するまでピッカーには最初のユーザープロンプトが表示されます。これは多くの場合、文中で切り捨てられたり、セッションが実際に何についてのものであるかではなく、導入の質問を説明するものだったりします。手動でのリネームは、ほとんどのユーザーが行わないオプションな摩擦です。
セッション名をデフォルトで 有用にする ことが目標です:
- セッションが実際に達成した内容を 具体的に記述 する。単なる最初の行ではなく、3〜7語、センテンスケース、git コミットメッセージの件名スタイル。
- ベストエフォート:最初の返信後にバックグラウンドで実行。失敗してもユーザーにエラーは表示されない。
- ユーザーの選択を尊重:ユーザーが意図的に選択した
/renameタイトルを、同じセッションの CLI タブ間でも決して上書きしない。 /rename --auto経由で 明示的に再生成可能。「自動タイトルが古くなった/新しいものが欲しい」ケースに対応。
トリガー
| トリガー | 条件 | 実装 |
|---|---|---|
| Auto | recordAssistantTurn 発火後。既存のタイトルが設定されている場合、別の試行が進行中の場合、上限に達した場合、非インタラクティブの場合、環境変数で無効化されている場合、または高速モデルがない場合はスキップ。 | ChatRecordingService.maybeTriggerAutoTitle — fire-and-forget |
| Manual | ユーザーが /rename --auto を実行 | renameCommand.ts 経由 tryGenerateSessionTitle |
両方のパスは単一の関数 tryGenerateSessionTitle(config, signal) に集約され、プロンプト、スキーマ、モデル選択、サニタイズが同一であることを保証します。自動トリガーはベストエフォートのバックグラウンド呼び出しであり、手動の /rename --auto は失敗時に理由固有のエラーを表示するブロッキングユーザーアクションです。
アーキテクチャ
┌─────────────────────────────────────────────────────────────────────────┐
│ packages/core/src/services/ │
│ │
│ ┌──────────────────────────┐ │
│ │ chatRecordingService.ts │ │
│ │ │ │
│ │ recordAssistantTurn() │ │
│ │ │ │ │
│ │ ↓ │ │
│ │ maybeTriggerAutoTitle() │── 6 guards ──→ IIFE(autoTitleController) │
│ │ │ │ │ │
│ │ └── resume hydrate │ ↓ │
│ │ via │ tryGenerateSessionTitle │
│ │ getSessionTitle- │ (sessionTitle.ts) │
│ │ Info │ │ │
│ │ │ ↓ │
│ └──────────────────────────┘ BaseLlmClient.generateJson │
│ (fastModel + JSON schema) │
│ │ │
│ ┌──────────────────────────┐ ↓ │
│ │ sessionService.ts │ sanitizeTitle + sanity checks │
│ │ │ │ │
│ │ getSessionTitleInfo() │◀── cross-process ↓ │
│ │ uses │ re-read recordCustomTitle │
│ │ readLastJsonString- │ before write (…, 'auto') │
│ │ FieldsSync │ │
│ │ (sessionStorageUtils) │ │
│ └──────────────────────────┘ │
│ │
│ ┌─────────────────────┐ │
│ │ utils/terminalSafe │ │
│ │ stripTerminalCtrl- │ │
│ │ Sequences │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ packages/cli/src/ui/ │
│ │
│ commands/renameCommand.ts ─── /rename <name> → manual │
│ ─── /rename → kebab │
│ ─── /rename --auto → auto │
│ ─── /rename -- --literal → manual │
│ ─── /rename --unknown-flag → error │
│ │
│ components/SessionPicker.tsx ── dims rows where │
│ session.titleSource === 'auto' │
└─────────────────────────────────────────────────────────────────────────┘ファイル
| ファイル | 責任範囲 |
|---|---|
packages/core/src/services/sessionTitle.ts | 1回限りのLLM呼び出し + 履歴フィルタ + サニタイズ。tryGenerateSessionTitle をエクスポート。 |
packages/core/src/services/chatRecordingService.ts | maybeTriggerAutoTitle トリガー、ガード、プロセス間再読み込み、finalize 時の中断。 |
packages/core/src/services/sessionService.ts | getSessionTitleInfo パブリックアクセサ。renameSession が titleSource を受け入れる。 |
packages/core/src/utils/sessionStorageUtils.ts | extractLastJsonStringFields + readLastJsonStringFieldsSync アトミックペアリーダー。 |
packages/core/src/utils/terminalSafe.ts | センテンスケースと kebab パスの両方で共有される stripTerminalControlSequences。 |
packages/cli/src/ui/commands/renameCommand.ts | /rename --auto、センチネルパーサー、失敗理由メッセージマップ。 |
packages/cli/src/ui/components/SessionPicker.tsx | titleSource === 'auto' の行を薄く表示するスタイリング。 |
プロンプト設計
システムプロンプト
この単一呼び出しのためにメインエージェントのシステムプロンプトを置き換え、モデルがコーディングアシスタントとして振る舞うのではなく、セッションのラベル付けのみを試みるようにします。
以下の箇条書きは TITLE_SYSTEM_PROMPT と 1:1 で対応しています:
- 3〜7語、センテンスケース(最初の単語と固有名詞のみ大文字)。
- 末尾の句読点なし、マークダウンなし、引用符なし。
- 会話の主要な言語に一致させる。中国語の場合は約12〜20文字を予算とする。
- ユーザーの実際の目標を具体的に記述する。機能、バグ、または主題領域を名指しする。「コード変更」や「ヘルプリクエスト」などの曖昧な総称は避ける。
- 4つの良い例(英語3つ + 中国語1つ)と4つの悪い例(曖昧すぎる/長すぎる/ケースが間違っている/末尾に句読点がある)。
titleキーを1つだけ持つ JSON オブジェクトのみを返す。
構造化出力(JSON スキーマ)
セッション要約のように出力をタグで囲む代わりに、関数呼び出しスキーマを使用して BaseLlmClient.generateJson を利用します:
const TITLE_SCHEMA = {
type: 'object',
properties: {
title: {
type: 'string',
description:
'A concise sentence-case session title, 3-7 words, no trailing punctuation.',
},
},
required: ['title'],
};フリーテキスト + タグ抽出ではなく関数呼び出しを使用する理由:
- プロバイダー間の信頼性 — OpenAI 互換エンドポイント、Gemini、Qwen のネイティブツール呼び出しはすべて関数呼び出しを実装しています。タグ解析はすべてのモデルがテキスト規約に従うことに依存します。
- 推論プレアンブルの漏洩防止 — 関数呼び出しの引数は構造化されて返されるため、回答前の「思考」段落がタイトルに混入することはありません。
- 後処理の簡素化 —
typeof result.title === 'string'のチェックとsanitizeTitleの組み合わせで、現実的なモデルの逸脱をすべてカバーします。
モデルがスキーマでは許可されるが UX では拒否される値(空文字列、空白のみ、500文字、マークダウンのフェンス、制御文字)を返す可能性があります。sanitizeTitle はこれらすべてを処理し、'' を返します → サービスは {ok: false, reason: 'empty_result'} を返します。
呼び出しパラメータ
| パラメータ | 値 | 理由 |
|---|---|---|
model | getFastModel() — フォールバックなし | メインモデルのトークンで自動タイトル付けを行うと、サイレントにコストがかかりすぎるため。 |
schema | TITLE_SCHEMA | {title: string} を強制。トランスポート層で形状の逸脱をフィルタリング。 |
maxOutputTokens | 100 | 7語とスキーマのオーバーヘッドに対して十分すぎる量。 |
temperature | 0.2 | ほぼ決定的 — セッションタイトルは再生成時の安定性が有益。 |
maxAttempts | 1 | タイトルはベストエフォートのコスメティックメタデータ。リトライはユーザーに見えるメイントラフィックの後ろにキューイングされる。 |
セッション要約はメインモデルにフォールバックしますが、タイトル生成は自動的に頻繁にトリガーされます。ユーザーのオプトインなしにメインモデルのトークンをサイレントに消費することは、請求書上の驚きにつながります。手動の /rename --auto はフォールバックではなく no_fast_model で明示的に失敗し、ユーザーに高速モデルの選択を意識させます。
履歴フィルタリング
geminiClient.getChat().getHistory() は、ツール呼び出し、ツールレスポンス(多くの場合ファイルコンテンツの 10K+ トークン)、モデルの思考部分を含む Content[] を返します。これをそのままタイトル LLM にフィードすると、「認証モジュールで grep を呼び出した」などの実装ノイズにラベルが偏ります。
filterToDialog は、空でないテキストを持ち、thought / thoughtSignature 部分を持たない user / model エントリのみを保持します。takeRecentDialog は最後の 20 メッセージにスライスし、中途半端なモデル/ツールレスポンスで開始することを拒否します。flattenToTail は「Role: text」行に変換し、最後の 1000 文字にスライスします。
1000文字のテールスライス
help me debug X で始まるが Y のリファクタリングに転向するセッションは、Y についてタイトル付けされるべきです。ヘッドでタイトル付けすると導入の枠組みが固定されますが、テールでタイトル付けするとセッションが実際に何になったかを捉えます。
UTF-16 サロゲート処理
UTF-16 コードユニット境界での .slice(-1000) は、CJK 補助文字や絵文字が切断されると、高位または低位サロゲートを孤立させる可能性があります。一部のプロバイダーは結果として生じる無効な UTF-16 に対して 400 を返します。これを処理しないと、理由なく試行を消費してしまいます。flattenToTail は先頭の孤立した低位サロゲートを削除し、sanitizeTitle は出力パスの最大長トリム後も孤立したサロゲートをスクラブします。
永続化
レコード形状
CustomTitleRecordPayload にオプションの titleSource: 'auto' | 'manual' フィールドが追加されます:
{
"type": "system",
"subtype": "custom_title",
"systemPayload": {
"customTitle": "Debug login button on mobile",
"titleSource": "auto",
},
}このフィールドはオプションであり、レガシーレコードに存在しない場合は undefined として扱われます。SessionPicker は厳密な === 'auto' 一致でのみ行を薄く表示します。変更前のユーザー /rename タイトルがモデルの推測としてサイレントに再分類されることはありません。
再開時のハイドレーション
再開時、ChatRecordingService コンストラクタは sessionService.getSessionTitleInfo(sessionId) を呼び出して、タイトルとそのソースの 両方 を読み取ります。ソースをハイドレーションしないと、すべてのセッションライフサイクルイベントで実行される finalize() の再追加が、再開サイクルごとに auto を manual に書き換えてしまい、薄く表示する機能をサイレントに剥奪してしまいます。
アトミックペア読み取り
extractLastJsonStringFields は、1回のスキャンで 一致する同じ行 から customTitle と titleSource を返します。2回の別々の readLastJsonStringFieldSync 呼び出しは、古い行にプライマリフィールドしかない場合、異なるレコードに到達し、不一致のペアを生む可能性があります。エクストラクタはプライマリ値に適切な閉じ引用符も要求するため、クラッシュで切り捨てられた末尾のレコードが最新一致レースで勝つことはありません。
フルファイルスキャンの上限
フェーズ2(テールウィンドウの高速パスがヒットしない場合)は、ファイル全体を 64KB チャンクでストリーミングします。MAX_FULL_SCAN_BYTES = 64 MB に制限されており、破損した数 GB の JSONL がメインイベントループでセッションピッカーをフリーズさせるのを防ぎます。ピッカーのレイテンシエンベロープは破損時にも維持されます。
シンボリックリンク防御
セッション読み取りは O_NOFOLLOW で開きます(定数が公開されていない Windows ではプレーンな読み取り専用フォールバック)。~/.qwen/projects/<proj>/chats/ に配置されたシンボリックリンクが、メタデータ読み取りを無関係なファイルにリダイレクトできないようにする防御策です。
同時実行とエッジケース
トリガーガードの順序
maybeTriggerAutoTitle は以下の正確な順序で6つの条件をチェックします。各条件は残りをショートサーキットするため、コストの低いものが最初に実行されます:
currentCustomTitleが設定済み → スキップ。手動または以前の自動タイトルを上書きしない。autoTitleController !== undefined→ スキップ。一度に1つの試行のみ。autoTitleAttempts >= 3→ スキップ。上限が総浪費を制限する。!config.isInteractive()→ スキップ。ヘッドレスqwen -p/ CI は、1回限りのセッションに高速モデルトークンを消費しない。autoTitleDisabledByEnv()→ スキップ。QWEN_DISABLE_AUTO_TITLE=1による明示的なオプトアウト。!config.getFastModel()→ スキップ。高速モデルなし → no-op。
上限が 1 ではなく 3 である理由
最初のアシスタントターンは、ユーザーに見えるテキストのない純粋なツール呼び出し(例:モデルが grep で開始)である可能性があります。この場合、tryGenerateSessionTitle は {ok: false, reason: 'empty_history'} を返します。リトライウィンドウがないと、ユーザーが何か面白いことを言う前に、ターン1でセッション全体のタイトル取得機会が消費されてしまいます。上限3は「最初のターンがノイズである」一般的なケースをカバーしつつ、永続的に失敗する高速モデルに対する暴走リトライも制限します。
プロセス間手動リネームのレース
同じセッションファイルの2つの CLI タブはメモリ内で分岐する可能性があります。タブAが /rename foo を実行し titleSource: manual を書き込みます。タブBの ChatRecordingService は独自の currentCustomTitle = undefined を持ち、ナイーブに自動タイトルで上書きする可能性があります。
LLM 呼び出しが解決した後、IIFE は sessionService.getSessionTitleInfo 経由で JSONL を再読み込みします。ファイルに source: 'manual' が表示される場合、IIFE は中断し、インメモリ状態も同期するため、後続のターンもリネームを尊重します。コスト:成功した生成ごとに1回の 64KB テール読み取り。無視できるレベルです。
finalize() での中断伝播
autoTitleController は進行中フラグも兼ねます。finalize()(セッション切り替えとプロセス終了時に実行)は、タイトルレコードを再追加する前に autoTitleController.abort() を呼び出します。LLM ソケットは即座にキャンセルされ、セッション切り替えは低速な高速モデル呼び出しを待ちません。IIFE の finally ブロックは、アクティブなコントローラーである場合にのみ autoTitleController をクリアするため、進行中の finalize が同時の recordAssistantTurn とレースすることはありません。
手動 /rename が進行中に着弾
IIFE の await が完了してから recordCustomTitle('auto') 呼び出しまでの間に、ユーザーが /rename foo を実行する可能性があります。IIFE は this.currentTitleSource === 'manual' を再チェックして中断します。プロセス内チェックとプロセス間再読み込みの両方が実行され、両層で手動が優先されます。
設定
ユーザー向け設定項目
| 設定 / 環境変数 | デフォルト | 効果 |
|---|---|---|
fastModel | 未設定 | 自動タイトル付けに必須。未設定 → no-op(メインモデルへのフォールバックなし)。 |
QWEN_DISABLE_AUTO_TITLE=1 | 未設定 | fastModel を解除せずに自動トリガーをオプトアウト。/rename --auto はリクエスト時に引き続き機能。 |
settings.json トグルはありません。環境変数が唯一のユーザー向けオフスイッチです。理由:この機能はコスメティックで安価であり、設定トグルは、無効化したい少数のユーザーが1回限りの env export として扱えるものに UI サーフェスを追加することになります。
自動がメインモデルにフォールバックしない理由
自動タイトル付けは、すべてのアシスタントターン後に無条件にトリガーされます。高速モデルを持たないユーザーが、新しいセッションのタイトルごとにメインモデルのトークンをサイレントに請求された場合、コストの差は月次請求書が届くまで見えません。静かに失敗する(no-op、タイトルなし、コストなし)方が安全なデフォルトです。/rename --auto は no_fast_model を実行可能なエラーとして表示し、ユーザーが望む場合に設定できるようにします。
観測性
createDebugLogger('SESSION_TITLE') は、ジェネレーターの catch ブロックから debugLogger.warn を出力します。失敗はユーザーに対して完全に透過的です。自動タイトルは補助機能であり、UI にスローされることはありません。
開発者はデバッグログ(~/.qwen/debug/<sessionId>.txt。latest.txt は現在のセッションへのシンボリックリンク)で [SESSION_TITLE] タグを grep できます。正常なエンドツーエンド呼び出しはログ出力を生成しません。失敗した場合は、基盤となるエラーメッセージを含む WARN 行が1つ記録されます。
セキュリティ強化
タイトル値はターミナル(セッションピッカー)にそのままレンダリングされ、ユーザーが読み取り可能な JSONL ファイルにも永続化されます。侵害された、またはプロンプトインジェクションされた高速モデルが敵対的なテキストを返す場合、両方のサーフェスは攻撃可能になります。
| 懸念事項 | ガード |
|---|---|
| ANSI / OSC-8 / CSI インジェクション | JSONL 書き込みとピッカーレンダリングの両方の前に stripTerminalControlSequences を適用。 |
| OSC-8 経由のクリック可能リンクの密輸 | 同上 — OSC シーケンスは ESC バイトだけでなくユニット全体として削除。 |
| 無効な UTF-16 サロゲート | flattenToTail(LLM 入力)と sanitizeTitle(最大長トリム後の LLM 出力)でスクラブ。 |
| ユーザーメッセージコンテンツによるサブタイプ行の偽装 | lineContains: '"subtype":"custom_title"' — 偶然そのリテラルフレーズを含むユーザーテキストは、実際のレコードをシャドウできない。 |
| セッション読み取り時のシンボリックリダイレクト | O_NOFOLLOW(定数がない Windows では no-op)。 |
| 切り捨てられた末尾の JSONL レコード | extractLastJsonStringFields は、レコードが最新一致レースで勝つ前に閉じ引用符を要求。 |
| ピッカーをフリーズさせる病的なファイルサイズ | フェーズ2 フルファイルスキャンの MAX_FULL_SCAN_BYTES = 64 MB 上限。 |
ペアになった CJK 括弧デコレーター(【Draft】) | 単位の閉じ括弧が孤立しないようにユニットとして削除。 |
スコープ外
| 項目 | 理由 |
|---|---|
| タイトルが古くなったときの自動再生成 | /rename --auto が明示的なユーザートリガーパス。セッション中のサイレントなタイトルスワップは、ピッカーをスクロールバックするユーザーを混乱させる。 |
| WebUI / VSCode の薄く表示するスタイリングの同等性 | それらのサーフェスはすでに customTitle を読み取り、自動タイトルを手動として表示する。フォローアップで titleSource を配線可能。 |
| 自動生成の設定ダイアログトグル | 環境変数が唯一のノブ。ユーザー需要が表面化した場合、完全な設定 UI は後で簡単に追加可能。 |
| 新文字列の i18n ロケールカタログエントリ | 既存の /rename 文字列と一致し、英語にフォールスルーする。リポジトリ全体の i18n パスはスコープ外。 |
| レガシーレコードを再分類するマイグレーション | 設計による後方互換性:存在しない titleSource は manual として扱われる。古いレコードの書き換えはユーザーの意図を失うリスクがある。 |
| 非インタラクティブ自動タイトル付け | qwen -p / CI スクリプトはセッションを破棄する。誰も再開しないタイトルのための高速モデルトークンは純粋な浪費。 |