Дизайн заголовков сессий
Генерируемый быстрой моделью заголовок сессии в регистре предложений (sentence-case) из 3–7 слов после первого ответа ассистента. Сохраняется в JSONL сессии с тегом
titleSource: 'auto' | 'manual', отображается в списке сессий и может быть регенерирован по запросу через/rename --auto.
Обзор
/rename (#3093) позволяет пользователю дать сессии имя, чтобы потом найти её в списке, но до его выполнения в списке отображается первый пользовательский промпт — часто обрезанный на середине предложения или описывающий вводный вопрос, а не то, чем сессия стала на самом деле. Ручное переименование — это дополнительное действие, которое большинство пользователей никогда не выполняют.
Цель — сделать имена сессий полезными по умолчанию:
- Описательными — отражающими то, что было сделано в сессии, а не просто первую строку. 3–7 слов, регистр предложений, в стиле тем коммитов Git.
- С минимальными усилиями — запускается в фоне после первого ответа; при неудаче пользователь не видит ошибки.
- С уважением к пользователю — никогда не перезаписывает заголовок, который пользователь сознательно задал через
/rename, даже между вкладками CLI одной и той же сессии. - Явно регенерируемыми —
/rename --autoдля случаев «автозаголовок устарел / хочу свежий».
Триггеры
| Триггер | Условия | Реализация |
|---|---|---|
| Авто | После срабатывания recordAssistantTurn. Пропускается, если уже есть заголовок, уже выполняется другая попытка, достигнут лимит, неинтерактивный режим, отключено окружением или нет быстрой модели. | ChatRecordingService.maybeTriggerAutoTitle — fire-and-forget |
| Ручной | Пользователь выполняет /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 | Одноразовый вызов LLM + фильтр истории + очистка. Экспортирует tryGenerateSessionTitle. |
packages/core/src/services/chatRecordingService.ts | Триггер maybeTriggerAutoTitle, проверки, повторное чтение между процессами, отмена при финализации. |
packages/core/src/services/sessionService.ts | Публичный аксессор getSessionTitleInfo; renameSession принимает titleSource. |
packages/core/src/utils/sessionStorageUtils.ts | Атомарный парный читатель extractLastJsonStringFields + readLastJsonStringFieldsSync. |
packages/core/src/utils/terminalSafe.ts | stripTerminalControlSequences — общий для путей sentence-case и kebab. |
packages/cli/src/ui/commands/renameCommand.ts | /rename --auto, парсер контрольных последовательностей, сообщения о причинах ошибок. |
packages/cli/src/ui/components/SessionPicker.tsx | Приглушённый стиль для titleSource === 'auto'. |
Дизайн промпта
Системный промпт
Заменяет системный промпт основного агента для этого единственного вызова, чтобы модель только присваивала заголовок сессии, а не вела себя как ассистент по программированию.
Пункты ниже соответствуют 1:1 с TITLE_SYSTEM_PROMPT:
- 3–7 слов, регистр предложений (только первое слово и имена собственные с заглавной).
- Без завершающего пунктуации, без markdown, без кавычек.
- Соответствовать доминирующему языку разговора; для китайского — примерно 12–20 символов.
- Быть конкретным о реальной цели пользователя — называть функцию, баг или предметную область. Избегать расплывчатых обобщений вроде «Изменения кода» или «Запрос помощи».
- Четыре хороших примера (три на английском + один на китайском) и четыре плохих (слишком расплывчато / слишком длинно / неправильный регистр / завершающая пунктуация).
- Возвращать только JSON-объект с единственным ключом
title.
Структурированный вывод (JSON-схема)
Вместо обёртки вывода в теги (как это делает session-recap) мы используем 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 | Заголовки — это вспомогательные метаданные без гарантий; повторные попытки блокировали бы пользовательский основной трафик. |
Контрастирует с session-recap, который использует основную модель в качестве запасного варианта. Генерация заголовков запускается автоматически и часто; молчаливая трата токенов основной модели без согласия пользователя — реальный сюрприз в счете. Ручной /rename --auto явно завершается ошибкой no_fast_model, без запасного варианта — заставляя пользователя осознанно выбрать быструю модель.
Фильтрация истории
geminiClient.getChat().getHistory() возвращает Content[], включающий вызовы инструментов, ответы инструментов (часто 10К+ токенов содержимого файла) и части мыслей модели. Подача этого сырья в LLM для заголовка сместила бы его в сторону шума реализации, вроде «Вызвал grep в модуле auth».
filterToDialog оставляет только записи user / model с непустым текстом и без частей thought / thoughtSignature. takeRecentDialog обрезает до последних 20 сообщений и отказывается начинать с «висячего» ответа модели/инструмента. flattenToTail преобразует в строки формата «Роль: текст» и обрезает до последних 1000 символов.
Срез хвоста в 1000 символов
Сессия, начинающаяся с «помоги отладить 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": "Debug login button on mobile",
"titleSource": "auto",
},
}Поле опционально, а отсутствующие в старых записях обрабатываются как undefined. SessionPicker приглушает строки только при строгом совпадении === 'auto' — заголовок, заданный пользователем через /rename до изменений, никогда не классифицируется как предположение модели.
Гидратация при возобновлении
При возобновлении конструктор ChatRecordingService вызывает sessionService.getSessionTitleInfo(sessionId), чтобы прочитать оба поля — заголовок и его источник. Без гидратации источника finalize()’s , перезаписывающий заголовок (выполняемый при каждом событии жизненного цикла сессии), переписал бы автоматический как ручной на каждом цикле возобновления — молчаливо убирая визуальное различие.
Атомарное парное чтение
extractLastJsonStringFields возвращает customTitle и titleSource из одной и той же подходящей строки за один проход. Два отдельных вызова readLastJsonStringFieldSync могли бы попасть на разные записи, если старая строка содержит только основное поле, что дало бы несовпадающую пару. Экстрактор также требует закрывающую кавычку на основном значении, поэтому запись, оборванная из-за сбоя, не может выиграть гонку последнего совпадения.
Лимит полного сканирования файла
Фаза 2 (когда быстрый путь по хвосту промахнулся) читает весь файл чанками по 64 КБ. Ограничена MAX_FULL_SCAN_BYTES = 64 МБ, чтобы повреждённый многогигабайтный JSONL не заморозил окно выбора сессий на главном цикле событий. Задержка окна выбора выживает при повреждении.
Защита от симлинков
Чтение сессий открывается с O_NOFOLLOW (на Windows, где константа не экспортирована, откатывается к обычному только для чтения). Защита в глубину, чтобы симлинк, размещённый в ~/.qwen/projects/<proj>/chats/, не перенаправлял чтение метаданных на посторонний файл.
Параллелизм и граничные случаи
Порядок проверок триггера
maybeTriggerAutoTitle проверяет шесть условий в точном порядке — каждое из них короткое замыкание, чтобы дешёвые выполнялись первыми:
currentCustomTitleзадан → пропустить. Никогда не перезаписывать ручной / предыдущий автоматический.autoTitleController !== undefined→ пропустить. Одна попытка за раз.autoTitleAttempts >= 3→ пропустить. Лимит ограничивает общую трату ресурсов.!config.isInteractive()→ пропустить. Безголовыйqwen -p/ CI никогда не тратит токены быстрой модели на одноразовую сессию.autoTitleDisabledByEnv()→ пропустить.QWEN_DISABLE_AUTO_TITLE=1— явный отказ.!config.getFastModel()→ пропустить. Нет быстрой модели → ничего не делаем.
Почему лимит 3, а не 1
Первый ответ ассистента может быть чистым вызовом инструмента без видимого пользователю текста (например, модель начинает с grep). tryGenerateSessionTitle возвращает {ok: false, reason: 'empty_history'} в этом случае. Без окна повторных попыток вся возможность получить заголовок для сессии была бы сожжена на первом обороте, ещё до того, как пользователь сказал что-то интересное. Лимит 3 покрывает распространённый случай «первый оборот — шум», одновременно ограничивая бесконечные повторные попытки при постоянном сбое быстрой модели.
Гонка ручного переименования между процессами
Две вкладки CLI на одном файле сессии могут иметь разную память. Вкладка A выполняет /rename foo и записывает titleSource: manual. Вкладка B имеет свой currentCustomTitle = undefined и наивно перезаписала бы его автоматическим заголовком.
После разрешения вызова LLM IIFE перечитывает JSONL через sessionService.getSessionTitleInfo. Если в файле указано source: 'manual', IIFE прерывается И синхронизирует своё состояние в памяти, чтобы последующие обороты также учитывали переименование. Стоимость: одно чтение хвоста в 64 КБ на успешную генерацию; пренебрежимо мало.
Распространение отмены при finalize()
autoTitleController выполняет также роль флага текущего выполнения. finalize() (выполняется при переключении сессии и завершении процесса) вызывает autoTitleController.abort() перед повторным добавлением записи заголовка. Сокет LLM отменяется немедленно; переключение сессии не ждёт медленного вызова быстрой модели. finally-блок IIFE очищает autoTitleController только если он всё ещё активен, поэтому finalize во время выполнения не гоняется с параллельным вызовом recordAssistantTurn.
Ручной /rename выполняется во время генерации
Между завершением await в IIFE и вызовом recordCustomTitle('auto') пользователь может выполнить /rename foo. IIFE повторно проверяет this.currentTitleSource === 'manual' и прерывается. Проверка в процессе И повторное чтение между процессами выполняются; ручной вариант побеждает на обоих уровнях.
Конфигурация
Видимые пользователю настройки
| Настройка / переменная окружения | По умолчанию | Эффект |
|---|---|---|
fastModel | не задана | Необходима для автоматического создания заголовков. Не задана → ничего не делается (без запасного варианта на основной модели). |
QWEN_DISABLE_AUTO_TITLE=1 | не задана | Отказаться от автотриггера без сброса fastModel. /rename --auto всё ещё работает по запросу. |
Нет переключателя в settings.json — переменная окружения — единственный видимый пользователю выключатель. Обоснование: функция косметическая и дешёвая; переключатель в настройках добавил бы поверхность UI для того, что можно реализовать как одноразовый экспорт env для тех немногих пользователей, которые хотят отключить.
Почему авто не использует основную модель в качестве запасного варианта
Автоматическое создание заголовков запускается безусловно после каждого ответа ассистента. Если бы пользователь без быстрой модели молчаливо заряжался токенами основной модели за заголовки каждой новой сессии, разница в стоимости была бы невидима до ежемесячного счёта. Тихий отказ (ничего не делаем, нет заголовка, нет стоимости) — более безопасное значение по умолчанию. /rename --auto показывает no_fast_model как действенную ошибку, чтобы пользователь мог установить быструю модель, если хочет.
Наблюдаемость
createDebugLogger('SESSION_TITLE') выводит debugLogger.warn из блока catch генератора. Сбои полностью прозрачны для пользователя — авто-заголовок — это вспомогательная функция, и она никогда не бросает исключения в UI.
Разработчики могут искать тег [SESSION_TITLE] в логах отладки (~/.qwen/debug/<sessionId>.txt; latest.txt — симлинк на текущую сессию). Успешный сквозной вызов не выдаёт вывода лога; сбойный выдаёт одну строку WARN с сообщением об ошибке.
Усиление безопасности
Значение заголовка отображается дословно в терминале (окно выбора сессий) И сохраняется в читаемом пользователем JSONL-файле. Обе поверхности достижимы для атаки, если скомпрометированная или инжектированная быстрая модель вернёт враждебный текст.
| Опасение | Защита |
|---|---|
| Инъекция ANSI / OSC-8 / CSI | stripTerminalControlSequences перед записью в JSONL и отображением в окне выбора. |
| Контрабанда кликабельных ссылок через OSC-8 | То же самое — последовательности OSC удаляются как целые единицы, а не только байт ESC. |
| Невалидные суррогатные пары UTF-16 | Очищаются в flattenToTail (вход LLM) и sanitizeTitle (выход LLM после обрезки по максимальной длине). |
| Подмена строки подтипа через текст сообщения | lineContains: '"subtype":"custom_title"' — пользовательский текст, случайно содержащий буквальную фразу, не может затенить реальную запись. |
| Перенаправление симлинка при чтении сессии | O_NOFOLLOW (на Windows, где константа отсутствует, бездействует). |
| Обрезанная запись JSONL в конце | extractLastJsonStringFields требует закрывающую кавычку, прежде чем запись выиграет гонку последнего совпадения. |
| Заморозка окна выбора из-за патологического размера файла | MAX_FULL_SCAN_BYTES = 64 МБ — лимит на полное сканирование файла на фазе 2. |
Парные скобки CJK (【Draft】) | Удаляются как единица, чтобы одиночная закрывающая скобка не болталась. |
Вне области действия
| Элемент | Почему нет |
|---|---|
| Автоматическое пересоздание при устаревании заголовка | /rename --auto — это явный путь, запускаемый пользователем. Бесшумная замена заголовка в середине сессии сбивала бы с толку пользователей, прокручивающих историю в picker’е. |
| Согласованность стилей затемнения WebUI / VSCode | Эти интерфейсы уже читают customTitle и будут показывать автоматические заголовки как ручные. В дальнейшем можно подключить titleSource. |
| Переключатель в диалоге настроек для автоматической генерации | Переменная окружения — единственный рычаг. Полный интерфейс настроек легко добавить позже, если появится спрос. |
| Записи в каталогах локалей i18n для новых строк | Согласованно с существующими строками /rename, которые отображаются на английском. Общий i18n-проход по репозиторию не входит в рамки. |
| Миграция для переклассификации устаревших записей | Обратная совместимость по замыслу: отсутствие titleSource считается ручным. Перезапись старых записей может привести к потере намерений пользователя. |
| Неинтерактивное автоматическое назначение заголовков | Скрипты qwen -p / CI выбрасывают сессию; токены быстрой модели для заголовка, который никто никогда не продолжит, — чистая трата. |