Блокнот
Блокнот, который Ева ведёт для себя внутри одного чата. Не история и не
резюме компакции: туда попадает то, что она решила не потерять, — и
оно переживает и окно живой истории, и компакцию, и chat_clear.
Три соседних механизма легко перепутать:
| чей | как достаётся | чем живёт | |
|---|---|---|---|
| Память | глобальная, мимо чатов | ранжированием (BM25 + эмбеддинги), core — всегда | про Господина и про мир |
| Резюме компакции | пер-чатное | автоматически, по порогу символов | «что было» в разговоре |
| Блокнот | пер-чатный | целиком либо индексом, по размеру | выжимка работы: решение и почему, инвариант, путь/команда/id, тупик, состояние задачи |
Резюме блокнота не заменяет: оно пишется постфактум чужой моделью и не знает, что из тура было важным.
Три режима
Режим считается по размеру блокнота на входе в тур и внутри тура не меняется: от него зависит список инструментов, а тот идёт в самом начале запроса и держит кэш промпта.
- inline — заметок мало: тела едут в промпт дословно. Инструмента чтения модель не видит вовсе, читать нечего — всё перед глазами.
- index — заметок много: в промпт едет только
имя — описание(плюс закреплённые дословно), появляетсяchat_note_read. - 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_body→body) и метит изменившееся с прошлого захода.
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), как уже делает с саммари.