Русский
Задание приезжает заметкой
Записи созвонов живут у рекордера — у меня это Krisp: его аккаунт, его правила, его поиск. trip2g забирает их оттуда в ваш собственный волт — дословный транскрипт, одна заметка на созвон, поиск сверху. Забирает не сервис и не плагин. Забирает markdown-заметка: фронтматтер — конфигурация, тело — fenced-блок на питоне, который и есть задание. Её версионируют, правят и синкают как любую другую заметку, а токен от рекордера лежит запечатанным в её же шапке.
Ниже — анатомия этой заметки, ловушка, на которой я потерял два созвона, и резак цитат: приём, который снимает с модели ту работу, где она врёт.
Роль-заметка
В fleet роль — это заметка. У LLM-роли тело — инструкция; у код-роли тело — программа. Вот шапка ingest-роли, обрезанная до сути:
---
description: "Krisp meetings -> verbatim transcript notes (cron, no LLM)"
fleet_id: codellm
mode: cron
cron_schedule: "*/15 * * * *"
write_patterns: ["transcripts/**", "state/krisp-ingest.md"]
attach_notes: ["state/krisp-ingest.md"]
unseal: [krisp_token]
krisp_token: "sealed:v1:…"
timeout_seconds: 600
min_seconds: 60
max_per_run: 3
---
fleet_id называет флит, который заберёт роль, и он же решает тип запуска: за codellm тело исполняется как программа. mode: cron и cron_schedule заводят будильник — роль просыпается сама, а не по правке заметки. write_patterns — глобы, за которые роль не выйдет. attach_notes предзагружает нужные заметки в доставку. timeout_seconds ограничивает прогон, по умолчанию 300 секунд. Всё, что ниже, — уже мои собственные ключи: порог длины созвона, сколько записей брать за раз. Роль читает их у себя же в шапке.
Тело — блок на питоне, и он же программа. Наружу из неё ведут четыре вызова:
cfg = fleetkit.frontmatter() # собственные настройки роли
sec = fleetkit.secrets() # то, что открыто для этого запуска
changes.append(fleetkit.note(path, meta, body))
fleetkit.emit(changes)
Код не пишет в волт. Он собирает список заметок и возвращает его; записывает fleet, сверив пути с write_patterns. Роль, которой открыт transcripts/**, не тронет roles/, даже если в питоне будет написано обратное. Граница проходит не по доброй воле кода, а по шапке заметки, которую видно глазами.
Секрет лежит в самой заметке
Токен от рекордера нужен коду и не нужен никому больше. В шапке он выглядит так:
unseal: [krisp_token]
krisp_token: "sealed:v1:…"
Значение запечатано формой самого агента, /_system/codellm/seal, за админским логином. Ключ живёт в окружении codellm и больше нигде: ни в заметке, ни в репозитории, ни в моём ноутбуке — в песочницу с кодом он тоже не попадает. Заметка с секретом остаётся обычной заметкой — её синкают в Obsidian, открывают с телефона, кладут в бэкап. Внутри шифртекст. Распечатает его только тот процесс, которому она адресована, и только те поля, которые перечислены в unseal. Подробности — в «Секретах для код-ролей».
Очередь — тоже заметка
Роль помнит, докуда дошла, в state/krisp-ingest.md: одно поле since_ms во фронтматтере. Заметка приезжает в прогон через attach_notes, и тем же fleetkit.note() записывается обратно в конце.
Отдельного хранилища состояния нет. Волт и есть очередь. Курсор можно открыть глазами, увидеть, где стоит ingest, и отмотать назад, поправив число руками. Мне это понадобилось раньше, чем я думал.
Ловушка: расшифровка приходит позже записи
Рекордер расшифровывает созвон после того, как тот закончился. Прогон, попавший в это окно, видит запись — и пустое дерево реплик. Моя роль честно помечала такой созвон пустым и двигала курсор дальше. Созвон терялся навсегда: следующий прогон брал только то, что новее курсора, а расшифровка появлялась позади него.
Так пропали два сентябрьских созвона. У обоих транскрипт появился позже, 62 и 48 реплик, и без ручной отмотки курсора я бы этого не заметил.
Курсор в такой очереди — не «докуда я дочитал». Это водяной знак улаженных записей. Улажена та, что записана, либо демо, либо короче min_seconds. Созвон без реплик не улажен: водяной знак останавливается на нём, следующий прогон пробует снова. А через empty_grace_hours запись, которая расшифровки так и не получила, перестаёт держать очередь — иначе одна мёртвая строка заблокирует всё, что за ней.
Что лежит на выходе
Одна заметка на созвон, transcripts/YYYY-MM-DD-<id8>.md, дословно, без правок потом. Это первоисточник: всё остальное ссылается на него, никто его не переписывает.
Время созвона роль берёт не у рекордера, а из id встречи: это UUIDv7, старшие 48 бит — миллисекунды. Дальше сдвиг в пояс владельца из шапки роли, потому что дневная заметка — это местный день, а не UTC.
Так вышло не из любви к побитовым сдвигам. Метаданные рекордера оказались непригодны на вход: на разобранном вручную месяце (36 записей) названия неверны в 29 случаях из 36, а дата в названии расходится с id в 18 файлах на день-два. Волт какое-то время хранил три августовских транскрипта под чужой датой. Id прав, название — нет.
Сырой архив — уже продукт
Соблазн — считать, что архив станет полезным после того, как его разберёт модель. Неправда. Я выкачал 340 транскриптов за девять месяцев, и до первого прогона модели у меня появилось то, чего не было: поиск по тому, что было сказано. Поиск идёт двумя дорожками сразу: полнотекстовой — по точной формулировке, и семантической — по смыслу. Вопрос «что мы говорили про X в прошлом квартале» получает ответ с таймкодом, а не список встреч.
Разница с поиском рекордера не в качестве, а в принадлежности. Транскрипты лежат в вашем волте, рядом с остальными заметками, в тех же связях. Это RAG-хранилище, которым владеете вы: агент ходит в него как в любую другую папку, а субграфы решают, кто и что оттуда прочитает. Транскрипты помечены одним субграфом, служебные roles/ и state/ — другим; метку на всю папку ставит frontmatter-патч, руками её каждой заметке не проставляют.
Резак цитат
Следующий этап — заметка на созвон, которую человек читает: темы, выводы, цитаты как доказательство. Очевидный ход — дать модели транскрипт и попросить пересказ с цитатами. Я его сделал и получил три разных отказа на одном и том же промпте:
- 117-минутный созвон не получил заметки вовсе: один нестриминговый ответ не уложился в таймаут;
- 85 KB транскрипта вернулись как 34 KB — цитаты молча порезаны;
- 54 KB вернулись как 126 KB — цитаты задвоены.
Общее у трёх отказов одно: объём ответа рос вместе с объёмом входа, а модель пересказывала то, что должна была цитировать. Чинить это промптом бессмысленно — я просил модель работать копиром.
Поэтому работу разрезали по способностям. Модель пишет только карту границ: строка MM:SS | заголовок-вывод на каждый поворот темы, плюс название и саммари в два предложения. Пара килобайт независимо от того, сколько шёл созвон. Цитаты режет код — по этим таймкодам:
for n, (start, tc, head) in enumerate(bounds):
end = bounds[n + 1][0] if n + 1 < len(bounds) else None
chunk = [u for u in utt if u[0] >= start and (end is None or u[0] < end)]
Цитата после этого — буквальный срез транскрипта. Её нельзя сократить, задвоить или выдумать: этих операций в срезе нет. И длина созвона перестаёт быть риском, потому что выход модели от неё больше не зависит.
Остаётся один способ соврать: назвать таймкод, которого в транскрипте нет. Против него два предохранителя. Первый — каждый таймкод из карты обязан существовать в транскрипте дословно, а множество реальных — это времена реплик. Второй — что делать с несуществующим:
near = min(known, key=lambda k: abs(k - s))
if abs(near - s) <= 90:
snaps.append("%s->%s" % (tc, ntc))
else:
problems.append("timecode %s is not in the transcript and nothing within 90s" % tc)
Граница подтягивается к ближайшей настоящей реплике, если та ближе 90 секунд, и подтяжка попадает в шапку готовой заметки полем boundary_snaps. Ближе ничего нет — скрипт отказывается целиком, а не выдаёт черновик с выдуманным якорем. Правка видна читателю; молчаливой правки не бывает.
На 109-минутном созвоне это дало 240 цитат, ноль недословных, одну выдуманную границу — подтянутую и отмеченную. Тот же созвон в варианте с пересказом не давал заметки вообще.
Обобщается тут не резак, а шов. Отдай модели суждение: где разговор поворачивает. Отдай коду точность: что именно было сказано. Шов между ними — таймкод, и его можно проверить, не читая ни строчки. Тот же приём во второй раз: в первый код досчитывал интервалы за модель, здесь — вырезает цитаты.
Что дальше
Разбор построен рядом, скиллом на hermes, и живёт по другим правилам: у него двое ворот, на которых владелец говорит, кто был на созвоне и что из этого выносить, а извлечение заводит заметки только на названное. Это отдельная история, и она не про ingest. Во что транскрипты вырастают дальше — заметки созвонов, концепты, дневные — показано в «Звонках в базу знаний».
Что это и что не это
Замеры сделаны на одном архиве и одном рекордере: 340 транскриптов, из них месяц разобран руками. Цифры про метаданные — про этот рекордер и этот месяц, не про диктофоны вообще. Обобщается форма: сырой дословный архив полезен до всякого анализа, а точность стоит держать в коде.