セッションタイトルの設計
最初のアシスタント応答後に高速モデルによって生成される、3〜7語の文ケース(sentence case)セッションタイトル。セッションJSONL内に
titleSource: 'auto' | 'manual'タグとして保持され、セッションピッカーに表示され、必要に応じて/rename --autoで再生成可能。
概要
/rename(#3093)により、ユーザーはセッションにラベルを付けて後でピッカーで見つけられるようになりますが、実行するまではピッカーに最初のユーザープロンプトが表示されます。多くの場合、文の途中で切り詰められたり、フレーミングの質問を示しているだけで、セッションが実際に何になったのかを説明していません。手動でのリネームは追加の手間であり、ほとんどのユーザーは行いません。
目標は、セッション名をデフォルトで役立つものにすることです。
- 説明力がある: セッションが実際に達成したことを説明し、単なる最初の行ではない。3〜7語、文ケース、gitコミットサブジェクトスタイル。
- ベストエフォート: 最初の応答後にバックグラウンドで実行。失敗してもユーザーにエラーは表示されません。
- ユーザーに従う: ユーザーが意図的に
/renameで選択したタイトルを上書きしません。同じセッションの別のCLIタブ間でも同様。 - 明示的に再生成可能:
/rename --autoで「タイトルが古くなった/新しいものが欲しい」場合に対応。
トリガー
| トリガー | 条件 | 実装 |
|---|---|---|
| 自動 | recordAssistantTurn が発火した後。既存のタイトルが設定されている、別の試行が進行中、上限に達した、非対話モード、環境変数で無効化、高速モデルがない場合はスキップ。 | ChatRecordingService.maybeTriggerAutoTitle — ファイア&フォーゲット |
| 手動 | ユーザーが/rename --autoを実行 | renameCommand.ts 経由で tryGenerateSessionTitle |
両方のパスは単一の関数 tryGenerateSessionTitle(config, signal) に集約され、プロンプト、スキーマ、モデル選択、サニタイズが同一であることを保証します。自動トリガーはベストエフォートのバックグラウンド呼び出し、手動/rename --autoはブロッキングなユーザーアクションで、失敗時には理由固有のエラーが表示されます。
アーキテクチャ
┌─────────────────────────────────────────────────────────────────────────┐
│ packages/core/src/services/ │
│ │
│ ┌──────────────────────────┐ │
│ │ chatRecordingService.ts │ │
│ │ │ │
│ │ recordAssistantTurn() │ │
│ │ │ │ │
│ │ ↓ │ │
│ │ maybeTriggerAutoTitle() │── 6つのガード ──→ IIFE(autoTitleController)│
│ │ │ │ │ │
│ │ └── resume hydration │ ↓ │
│ │ via │ tryGenerateSessionTitle │
│ │ getSessionTitle- │ (sessionTitle.ts) │
│ │ Info │ │ │
│ │ │ ↓ │
│ └──────────────────────────┘ BaseLlmClient.generateJson │
│ (fastModel + JSON schema) │
│ │ │
│ ┌──────────────────────────┐ ↓ │
│ │ sessionService.ts │ sanitizeTitle + sanity checks │
│ │ │ │ │
│ │ getSessionTitleInfo() │◀── プロセス間 ↓ │
│ │ uses │ 再読み取り recordCustomTitle│
│ │ readLastJsonString- │ before write (…, 'auto') │
│ │ FieldsSync │ │
│ │ (sessionStorageUtils) │ │
│ └──────────────────────────┘ │
│ │
│ ┌─────────────────────┐ │
│ │ utils/terminalSafe │ │
│ │ stripTerminalCtrl- │ │
│ │ Sequences │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ packages/cli/src/ui/ │
│ │
│ commands/renameCommand.ts ─── /rename <name> → 手動 │
│ ─── /rename → kebab │
│ ─── /rename --auto → 自動 │
│ ─── /rename -- --literal → 手動 │
│ ─── /rename --unknown-flag → エラー │
│ │
│ components/SessionPicker.tsx ── 行を薄く表示 │
│ session.titleSource === 'auto' │
└─────────────────────────────────────────────────────────────────────────┘ファイル
| ファイル | 責任 |
|---|---|
packages/core/src/services/sessionTitle.ts | 単発の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 | stripTerminalControlSequences を文ケースパスとkebabパスの両方で共有。 |
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語、文ケース(最初の単語と固有名詞のみ大文字)。
- 末尾の句読点なし、Markdownなし、引用符なし。
- 会話の支配的な言語に合わせる。中国語の場合は、約12〜20文字を目安に。
- ユーザーの実際の目的を具体的に指定 — 機能、バグ、トピック領域を名付ける。「コード変更」や「ヘルプリクエスト」のような曖昧な汎用表現は避ける。
- 良い例を4つ(英語3つ+中国語1つ)と悪い例を4つ(曖昧すぎる/長すぎる/ケースが間違っている/末尾に句読点がある)。
- 単一の
titleキーを持つJSONオブジェクトのみを返す。
構造化出力(JSONスキーマ)
出力をタグでラップする(セッション要約が行っている)代わりに、BaseLlmClient.generateJson を関数呼び出しスキーマとともに使用します。
const TITLE_SCHEMA = {
type: 'object',
properties: {
title: {
type: 'string',
description:
'簡潔な文ケースのセッションタイトル、3〜7語、末尾の句読点なし。',
},
},
required: ['title'],
};なぜ関数呼び出しで、自由テキスト+タグ抽出ではないのか:
- プロバイダ横断の信頼性 — OpenAI互換エンドポイント、Gemini、Qwenのネイティブツール呼び出しはすべて関数呼び出しを実装しており、タグパースはすべてのモデルがテキストの規則に従う必要がある。
- 推論前置きの漏洩防止 — 関数呼び出しの引数は構造化されて返されるため、回答前の「思考」段落がタイトルに混入できない。
- 後処理の簡略化 — 単一の
typeof result.title === 'string'チェックとsanitizeTitleで、現実的なモデルの逸脱をすべてカバー。
モデルがスキーマで許可されているが、UXが拒否するものを返す可能性がある(空文字列、空白のみ、500文字、Markdownフェンシング、制御文字)。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() は Content[] を返し、ツール呼び出し、ツール応答(多くの場合10K+トークンのファイル内容)、モデル思考パーツが含まれる。これを未加工でタイトルLLMに渡すと、「Called grep on auth module」のような実装ノイズにバイアスがかかる。
filterToDialog はテキストが空でない user / model エントリのみを保持し、thought / thoughtSignature パーツを除外する。takeRecentDialog は最後の20メッセージにスライスし、ぶら下がったモデル/ツール応答で始まらないようにする。flattenToTail は “Role: text” 行に変換し、最後の1000文字にスライスする。
1000文字の末尾スライス
help me debug X で始まりリファクタリングYに転じたセッションは、Yについてタイトルを付けるべき。先頭でタイトルを付けると最初のフレーミングに固定される。末尾でタイトルを付けると、セッションが実際になった内容を捉える。
UTF-16サロゲートペア処理
.slice(-1000) をUTF-16コードユニット境界で行うと、CJK補助文字や絵文字がカットされた場合に上位または下位サロゲートが孤立する可能性がある。一部のプロバイダーは結果の不正なUTF-16に対して400を返す — 処理しないと試行を無駄にする。flattenToTail は先頭の孤立した下位サロゲートを削除する。sanitizeTitle は出力パスでの最大長トリム後にも孤立サロゲートを削除する。
永続化
レコード形状
CustomTitleRecordPayload にオプションの titleSource: 'auto' | 'manual' フィールドが追加される。
{
"type": "system",
"subtype": "custom_title",
"systemPayload": {
"customTitle": "モバイルのログインボタンをデバッグ",
"titleSource": "auto",
},
}このフィールドはオプションであり、レガシーレコードでは undefined として扱われる。SessionPicker は厳密に === 'auto' の一致でのみ行を薄く表示する — 変更前のユーザーによる /rename タイトルがモデルの推測として暗黙に再分類されることはない。
再開時のハイドレーション
再開時に、ChatRecordingService のコンストラクタは sessionService.getSessionTitleInfo(sessionId) を呼び出し、タイトルとそのソースの両方を読み取る。ソースをハイドレートしないと、finalize() の再追加(すべてのセッションライフサイクルイベントで実行される)が、再開サイクルごとに自動を手動として上書きし、薄く表示する機能を静かに失う。
アトミックペア読み取り
extractLastJsonStringFields は、単一のスキャンで同じ一致行から 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 はワンショットセッションで高速モデルトークンを消費しない。autoTitleDisabledByEnv()→ スキップ。QWEN_DISABLE_AUTO_TITLE=1明示的オプトアウト。!config.getFastModel()→ スキップ。高速モデルなし → 何もしない。
上限が3である理由(1ではない)
最初のアシスタントターンは純粋なツール呼び出しでユーザーに見えるテキストがない場合がある(例:モデルが 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 | 未設定 | 自動タイトリングに必要。未設定 → 何もしない(メインモデルへのフォールバックなし)。 |
QWEN_DISABLE_AUTO_TITLE=1 | 未設定 | fastModel を解除せずに自動トリガーをオプトアウト。/rename --auto はリクエスト時に動作する。 |
settings.json のトグルはなし — 環境変数のみがユーザーが目にするオフスイッチ。理由:この機能は装飾的でコストが低い。設定トグルは、無効にしたい少数のユーザーが1回の環境変数エクスポートで対応できるものに対して、UI面を追加することになる。
自動がメインモデルにフォールバックしない理由
自動タイトリングはすべてのアシスタントターンの後に無条件にトリガーされる。高速モデルを持たないユーザーが、新しいセッションのタイトルごとにメインモデルトークンを静かに課金されると、月額請求書が届くまでコスト差は見えない。静かに失敗する(何もしない、タイトルなし、コストなし)方が安全なデフォルト。/rename --auto は no_fast_model をアクション可能なエラーとして表示し、ユーザーが希望すれば設定できるようにする。
観測可能性
createDebugLogger('SESSION_TITLE') は、ジェネレータのキャッチブロックから debugLogger.warn を発行する。失敗はユーザーに対して完全に透過的 — 自動タイトルは補助的な機能であり、UIにエラーを投げることはない。
開発者はデバッグログ(~/.qwen/debug/<sessionId>.txt、latest.txt は現在のセッションへのシンボリックリンク)で [SESSION_TITLE] タグをgrepできる。正常なエンドツーエンド呼び出しはログ出力を生成せず、失敗した場合のみ基盤となるエラーメッセージを含む1行のWARNを出力する。
セキュリティ強化
タイトル値は端末(セッションピッカー)にそのまま表示され、かつユーザーが読み取り可能なJSONLファイルに永続化される。どちらの面も、侵害されたりプロンプトインジェクションされた高速モデルが敵対的なテキストを返した場合に攻撃可能。
| 懸念 | 対策 |
|---|---|
| ANSI / OSC-8 / CSI インジェクション | JSONL書き込みとピッカー表示の前に stripTerminalControlSequences を適用。 |
| OSC-8経由のクリック可能リンクの密輸 | 同様 — OSCシーケンスはESCバイトだけでなく、全体の単位として除去。 |
| 無効なUTF-16サロゲート | flattenToTail(LLM入力)と sanitizeTitle(LLM出力の最大長トリム後)で除去。 |
| ユーザーメッセージ内容によるサブタイプラインのなりすまし | lineContains: '"subtype":"custom_title"' — たまたまリテラル文字列を含むユーザーテキストは、実際のレコードを隠せない。 |
| セッション読み取り時のシンボリックリンクリダイレクト | O_NOFOLLOW(Windowsでは定数が存在しないため何もしない)。 |
| 切り詰められた末尾のJSONLレコード | extractLastJsonStringFields は閉じ引用符が存在しないとレコードが最新一致の競争に勝てない。 |
| 巨大なファイルサイズによるピッカーのフリーズ | フェーズ2のフルファイルスキャンに MAX_FULL_SCAN_BYTES = 64 MB の上限。 |
ペアになるCJK括弧デコレータ(【草稿】など) | ユニットとして除去し、閉じ括弧だけが残らないようにする。 |
対象外
| 項目 | 理由 |
|---|---|
| タイトルが古くなった場合の自動再生成 | /rename --auto は明示的なユーザー起動の手段です。セッション中にタイトルが静かに差し替わると、ピッカーをスクロールして戻るユーザーが混乱します。 |
| WebUI / VSCode の dim-styling の互換性 | これらの画面は既に customTitle を読み込んでおり、自動タイトルを手動タイトルと同様に表示します。フォローアップで titleSource を通じて配線できます。 |
| 自動生成の設定ダイアログのトグル | 環境変数が唯一の設定項目です。ユーザーからの要望があれば後で完全な設定 UI を追加することは容易です。 |
| 新しい文字列に対する i18n ロケールカタログエントリ | 既存の /rename 文字列と同様に、英語にフォールスルーします。リポジトリ全体の i18n 対応は対象外です。 |
| 既存レコードを再分類するマイグレーション | 設計上、下位互換性を維持しています: titleSource がない場合は手動として扱います。古いレコードを書き換えると、ユーザーの意図を失うリスクがあります。 |
| 非対話的な自動タイトル付与 | qwen -p / CI スクリプトはセッションを破棄します。誰も再開しないタイトルに高速モデルのトークンを使うのは無駄です。 |