Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Блокнот

Блокнот, который Ева ведёт для себя внутри одного чата. Не история и не резюме компакции: туда попадает то, что она решила не потерять, — и оно переживает и окно живой истории, и компакцию, и chat_clear.

Три соседних механизма легко перепутать:

чейкак достаётсячем живёт
Памятьглобальная, мимо чатовранжированием (BM25 + эмбеддинги), core — всегдапро Господина и про мир
Резюме компакциипер-чатноеавтоматически, по порогу символов«что было» в разговоре
Блокнотпер-чатныйцеликом либо индексом, по размерувыжимка работы: решение и почему, инвариант, путь/команда/id, тупик, состояние задачи

Резюме блокнота не заменяет: оно пишется постфактум чужой моделью и не знает, что из тура было важным.

Три режима

Режим считается по размеру блокнота на входе в тур и внутри тура не меняется: от него зависит список инструментов, а тот идёт в самом начале запроса и держит кэш промпта.

  1. inline — заметок мало: тела едут в промпт дословно. Инструмента чтения модель не видит вовсе, читать нечего — всё перед глазами.
  2. index — заметок много: в промпт едет только имя — описание (плюс закреплённые дословно), появляется chat_note_read.
  3. compact — заметок очень много: после тура запускается пересборка блокнота, после неё размер снова падает в режим 1 или 2.

Третий — не состояние промпта, а событие: в промпте всегда либо 1, либо 2.

Пороги и гистерезис

Считаем в символах тел (три заметки по 4 КБ хуже двадцати по сотне) и в штуках заодно.

вход в режимвозврат
inline → indexтела > inline_chars или > inline_max штуктела ≤ inline_back_chars иinline_back_max штук
→ пересборкатела > compact_after_chars или > max_notes штук или индекс > index_max_chars

Гистерезис обязателен: без него блокнот на границе мигает каждый ход, chat_note_read то появляется, то исчезает, и кэш промпта рвётся на списке инструментов. Режим прошлого входа лежит в chats.notes_mode.

Прячется chat_note_read не отдельным фильтром, а штатным гейтом реестра (Tool::indexed_notes_only, наравне с master_only). Мимо гейта он остался бы в реестре скрытого, и модель вытащила бы его tool_load’ом — правило «в режиме inline инструментов чтения не существует» протекло бы.

Закреплённые заметки

Флаг pinned — аналог core у памяти: такая заметка едет дословно в любом режиме, отдельным бюджетом (pinned_chars символов, pinned_max штук). Нужен для «цель текущей задачи», «команда сборки этого проекта» — того, за чем в режиме index модель ходила бы chat_note_read каждый ход. Пересборка обязана их сохранить: пропавшую закреплённую ядро возвращает из старой версии дословно.

Бюджет булавок не опускается ниже max_note_chars — иначе законная по размеру заметка не закрепляется ни при каком раскладе. Отказ говорит, что чинить: не влезающая сама заметка — резать её, перебор суммой — называет, кто бюджет занял. Мимо этой проверки булавка всё же приезжает (возврат из архива, опущенный руками потолок): такую блок не цитирует, но называет по имени с пометкой сходить за телом — и остальные закреплённые от неё не страдают.

Давление истории

У памяти повод записать приходит снаружи («он сказал это один раз»), у блокнота повода нет вовсе: тур оптимизируется на «ответить и закончить», а цена «не записала» приходит через десять туров и ни с каким решением не связывается. Поэтому рядом с блокнотом в [state] едет строка [window] — факт, не понукание:

  • сколько осталось — токены разговора и порог, за которым он свернётся в резюме. Показывается, когда до ближайшей стены — порога компакции или жёсткого потолка модели (context_window) — остаётся меньше 40% порога (PRESSURE_AT_PERCENT = 60): дальше свёртка далеко, и строка была бы шумом каждый ход;
  • сворачивайся — приглашение закончить шаг и отчитаться ДО компакции: она необратима, а предупреждение за pressure.wrap_up_reserve_tokens (8000) токенов до стены обычно позволяет доделать и не потерять ничего. Один раз на окно (до следующей свёртки) и только когда компакция не сработает сама прямо сейчас — иначе были бы и предупреждение, и свёртка в одном ходу;
  • свёртка только что случилась — вместо цифр одноразовый пинок: подробности ещё в свежем резюме, но через тур забудутся. Это самый сильный момент для записи, и он бывает раз на свёртку.

Мера — честный счёт токенов (tokens.rs): якорь prompt_tokens провайдера плюс оценка неучтённого хвоста, в той же области (compact_scope), которой меряет компакция, и с тем же профильным порогом (compact_budget): крон, агрессивные папки и чаты поверхностей жмутся жёстче лички. Иначе промпт врал бы о том, когда исчезнет хвост.

Одноразовость пинка про свёртку держит chats.notes_nudged_upto, а приглашения свернуться — chats.wrap_nudged_upto: в обоих summary_upto, для которого отметка уже показана. Условие производное — сама компакция (и ручная chat_compact заодно) о пинках ничего не знает.

Пустой блокнот под давлением обязан дать блок: молчать ровно в том состоянии, из которого надо выбираться, значит бить мимо главного случая — долгой кодовой сессии, где блокнота ещё нет, а терять уже есть что.

Тексты трёх ступеней настраиваются (context.pressure) — триажным моделям нужна другая формулировка; пороги ступеней — константы.

С другой стороны о том же говорят правила [notes]: писать в тот же тур, когда узнала. Внутри длинного тура потеря начинается ещё раньше свёртки — свежий выхлоп инструмента капается при добавлении в промпт, а уехавший за окно живого трафика схлопывается в огрызок: файл, прочитанный десять вызовов назад, из промпта уже ушёл, пока работа продолжается. Тур, переросший долю порога компакции, поджимает свой трафик прямо на ходу (context.turn_squeeze_percent) — и говорит об этом в [state]: то, что нужно донести до конца хода, живёт в заметке, а не в выхлопе.

Вес заметки и чем за него плачено

Заметка имеет вес: рамки «запись, а не правило» у блока [notebook] нет, и правила формы («описывает, а не велит») — тоже. Написанное в блокноте читается как своё, а не как справка о чужой просьбе.

Плата за это — риск, и он не теоретический. Недоверенный текст приезжает в рабочий чат не от человека: из web_fetch страницы, из README чужого репозитория, из выхлопа shell, из доклада подагента, из ответа MCP-сервера. Модель прочитала, сочла выводом, записала — и получила бессрочную инструкцию с полными правами. Отследить это происхождением тура нельзя: недоверенный ввод есть почти в каждом рабочем туре. Держат этот риск два рычага:

  • заметка не отменяет правил: противоречит стоячим правилам или просит их бросить — заметка неправа, о чём сказать вслух и удалить;
  • видимость записи — теперь главный: инъекция живёт, пока незаметна. Каждая запись и правка — обычный вызов инструмента в ленте тура, клиенты рисуют его сами (иконка 📝); панель показывает дату, дифф последнего изменения (prev_bodybody) и метит изменившееся с прошлого захода.

master_only у семейства chat_note_* гейтит тур, а не автора текста: зовёт эти инструменты сама Ева, в туре Господина, и записанное ею — обычный путь в блокнот. Господин пишет туда мимо тура, PUT /v1/chats/{id}/notes/{name}. В чужих турах блокнот в промпт не идёт вовсе.

Инструменты

Все в группе chat, все master_only + lead_only (подагенту блокнот не показывают — значит, и писать в него нечем) и тихие в лентах-мессенджерах (quiet), результат помечен uneventful.

тулкогда виденчто делает
chat_note_saveвсегдасоздать или переписать заметку целиком по имени
chat_note_forgetвсегдаудалить заметку
chat_note_readтолько в режиме indexдостать тела пачкой по именам
chat_note_compactвсегдапересобрать блокнот сейчас, не дожидаясь порога

Жёсткие лимиты на запись: имя ≤ 48 символов (slug [a-z0-9_-], уникален в чате), описание ≤ 120 (оно живёт в промпте в режиме index), тело — max_note_chars. Перебор — внятная ошибка «разбей на две заметки», а не молчаливое обрезание.

Затирание вслепую: в режиме index модель видит только описание, и chat_note_save под существующим именем снёс бы тело, которого она не читала. Поэтому перезапись существующей заметки в этом режиме требует, чтобы её прочитали в этом же туре, — иначе отказ со словами «сначала chat_note_read». В режиме inline тело и так перед глазами, проверки нет.

Поиска по телам нет намеренно: сорок строк индекса модель разбирает глазами, а BM25 здесь — машинерия ради машинерии.

Пересборка

Запускается после тура, там же, где компакция истории: ответ Господину к этому моменту уже утёк клиенту, и пересборка его не задерживает. Тур об неё не роняем.

Модель — та, что отвечает в этом чате (notes.model перекрывает). Заметки писала она и себе, своей стенографией; summary_model их причешет и обезличит. Вызов одноразовый, без инструментов и без записи в кэш промпта; расход ложится на чат и виден в /v1/chats/{id}/spend под меткой notes.

На вход — все заметки с телами и резюме компакции этого чата: без него «выкинь отработавшее» решать не на чем — заметка о задаче и заметка о её решении выглядят одинаково. Резюме идёт строго на чтение, в явной рамке «не переливай отсюда факты в блокнот»: те и так доезжают до модели другой дорогой, а держать одно и то же дважды — ровно то, что пересборка и лечит.

На выход — новая версия блокнота целиком:

## <имя> | <описание>
<тело>

Задача формулируется как цель по размеру: уложись в compact_target_chars и max_notes заметок; слей дубли, выкинь отработавшее, закреплённые сохрани. Просить «сделай режим inline» незачем — режим посчитается сам из размера результата.

Проверки перед подменой (иначе один кривой ответ стирает блокнот):

  • разобралась хотя бы одна заметка;
  • итог реально меньше исходного;
  • все pinned на месте — пропавшую возвращаем из старой версии дословно;
  • имена уникальны, поля в лимитах (иначе режем и логируем).

Не прошло — оставляем старый блокнот и пишем warn. Одна повторная попытка с вдвое более жёстким потолком, дальше не крутим.

Перед подменой старый блокнот целиком уходит в chat_notes_archive (одна строка на чат, перезаписывается) — вернуть его можно ручкой POST /v1/chats/{id}/notes/restore. Это единственная в схеме операция с необратимой потерей, поэтому страховка обязательна. Архив хранит записи целиком, а не дамп ## имя | описание: дамп — формат для модели, булавки и даты рождения в нём не выражены, а возврат обязан вернуть заметку той же самой, а не одноимённой новой.

prev_body пересборка заполняет по общему правилу: у выжившей заметки туда ложится тело до пересборки (ровно то, что Господин захочет сличить), у родившейся при слиянии — пусто, она новая. Пометку «новое» пересборка сбрасывает всему блокноту: пересобрано всё.

Смежное поведение

  • chat_delete — заметки уходят с чатом каскадом;
  • chat_clearзаметки оставляет и говорит, сколько оставил: смысл блокнота в том, чтобы пережить обнуление;
  • блокнота у подагента нет: он живёт одну задачу, и всё, что должно её пережить, едет наверх докладом. Правила и блок ему не показывают, а инструменты заперты lead_only — тур подагента мастерский по флагам (sender = None), и одного master_only не хватило бы;
  • крон-чаты — главный выигрыш: состояние между запусками наконец переживает агрессивную компакцию;
  • notes.enabled: false — тулы не регистрируются, блок в промпт не идёт, база не трогается.

API

ручкачто делает
GET /v1/chats/{id}/notes{mode, total_chars, seen_at, archive, notes}
PUT /v1/chats/{id}/notes/{name}создать/переписать ({description, body, pinned})
DELETE /v1/chats/{id}/notes/{name}удалить
POST /v1/chats/{id}/notes/compactпересобрать сейчас
POST /v1/chats/{id}/notes/restoreвернуть архивную версию
POST /v1/chats/{id}/notes/seenблокнот просмотрен: гасит пометки «новое» во всех клиентах разом

Список отдаёт prev_body вместе с телом — дифф считает клиент, ядру незачем возить готовый патч. Отметка захода серверная: иначе web, TUI и Android разошлись бы в том, что считать новым, и «новое» перестало бы что-либо значить. Живого события нет — клиент перечитывает блокнот по завершении тура (done), как уже делает с саммари.