Две базы. Одна отвечает на вопрос «что происходило», вторая — «сколько ушло денег». Смешивать их нельзя: в первой доллары почти всегда справочные, во второй — всегда настоящие.
~/.claude/analytics/
conversations.db turns — ходы: кто, кому, чем, за сколько
external-spend.db external_calls — оплаченные вызовы наружу
records-watermark.json сколько строк было в прошлую проверку
Каталог 700, базы 600 — внутри тексты переписки. Другое место задаётся
MILA_RECORDS_DIR.
1. Ходы (conversations.db, таблица turns)
Один ход — один обмен: сообщение человека и ответ агента, со всем, чем этот ответ был сделан.
Схема совместима с агентами. Первые четырнадцать колонок повторяют
таблицу turns агента дословно — id, ts, brand, account_id, bot_slug,
chat_id, message_id, user_text, bot_text, model, prompt_tokens,
completion_tokens, latency_ms, idempotency_key. Это не вежливость к чужому
коду: пока схема одна, «сколько мы разговаривали с этим клиентом» считается
одним запросом по всем агентам сразу. Как только схем становится две,
такой вопрос перестают задавать.
Своё добавлено сверху и добавляется через ALTER TABLE. Поэтому этим модулем
можно открыть живую базу агента — она дополнится недостающими колонками,
не потеряв ни строки, а старый инструмент продолжит читать первые четырнадцать
и не заметит остальных.
| Колонка | Что в ней |
|---|---|
agent, agent_kind |
кто говорил: director · companion · admin |
session_id, turn_source |
из какой сессии и откуда пришёл ход (claude-code, claude-code/subagent) |
temperature, max_tokens |
настройки модели — пусты у ходов из транскрипта, см. ниже |
reasoning_mode, reasoning_tokens |
режим рассуждения (high/medium/low) и сколько токенов на него ушло |
tool_calls, tools_used |
сколько инструментов позвано и какие |
cache_write_tokens, cache_read_tokens |
кэш отдельно от входа |
usd_cost, cost_basis |
цена и по какой таблице она посчитана |
paid_by |
subscription или api_key — что означает цена |
text_policy, user_len, bot_len |
что сделали с текстами и какой они были длины |
meta_json |
вид хода, модели с числом ответов, навыки, чат, файл транскрипта |
Чего в этой таблице нет и почему
Температуры и потолка токенов у ходов из транскрипта. Claude Code их не записывает. Ставить туда ноль или «по умолчанию» — значит выдумать настройку, по которой потом будут объяснять поведение модели. Колонки существуют для агентов, чей движок эти значения знает, и для навыков, которые зовут модель сами; у собранных ходов там пусто, и это честный пробел.
🔴 Цена в turns — справочная
Работа по подписке Claude Code не оплачивается этими долларами. Никто их не списывал. Цифра отвечает на другой вопрос: во сколько тот же объём обошёлся бы по прейскуранту API. Это мера того, что подписка отдаёт, и на загруженном дне она выглядит пугающе большой.
Что означает цифра в конкретной строке, говорит paid_by:
subscription— справочно. Не складывать с деньгами, не показывать клиенту как расход, не сверять с выпиской.api_key— настоящий расход по ключу провайдера. Вот его и сверяют.
Таблица цен — одна на весь кит, лежит в install/usage_collect.py и
перекрывается файлом ~/.claude/prices.json. Второй таблицы цен в проекте нет
и быть не должно. Незнакомая модель даёт usd_cost = NULL: токены посчитаны,
деньги — нет. Прочерк лучше уверенной цифры, взятой из чужого тарифа.
Как наполняется
mila turns # ходы за сегодня из всех транскриптов
mila turns --days 7
mila turns --project=-Users-milagpt
mila turns --dry-run --show 5 # посмотреть, ничего не записывая
Сборщик режет транскрипт Claude Code по promptId: он один на весь ход,
включая все ответы модели и все возвраты инструментов между ними. Резать по
«строке от пользователя» нельзя — возврат инструмента и вставки оболочки
приходят тем же типом, и ход развалился бы на два десятка кусков.
Повторный прогон ничего не удваивает: у каждого хода есть idempotency_key
(cc:<сессия>:<promptId>, у субагента плюс имя его файла — транскрипт
субагента наследует и сессию, и promptId родителя).
Расписание: раз в час
Сбор ставится установщиком и идёт раз в час — launchd на Маке
(com.milagpt.turns-collect), systemd на сервере (mila-turns-collect.timer).
Не в ровный час. Минута считается от имени машины и попадает в диапазон
10–49: в начале и в конце часа уже стоят чужие кроны, а сборщик, запущенный в
ту же секунду, что и крон, записывает работающий ход как провал. Своя минута
задаётся при установке: MILA_TURNS_MINUTE=17 install/install.sh. Имя агента
берётся из MILA_AGENT — оно должно совпадать с тем, под которым ходы пишутся
руками, иначе одна Мила разъедется в отчёте на две строки.
Раз в час, а не раз в сутки, по двум причинам. Ход, собранный ночью, теряет связь с разговором, который его породил, — а вся ценность записи в том, чтобы к вечеру можно было спросить «какой вопрос сколько стоил». И доктор, который сутки ругается на тишину, перестают читать.
Наложение безопасно. Пока идёт сбор, рядом с базой держится замок
turns-collect.lock (flock, а не «есть ли файл»: файл от убитого процесса лежал
бы вечно, а flock ядро снимает само). Второй сбор молча выходит с нулевым
кодом; человеку за терминалом он всё-таки скажет «сбор уже идёт».
Посмотреть последний сбор:
cat ~/.claude/analytics/turns-collect-last.json # когда, за сколько, сколько
tail ~/.claude/turns-collect.log # что печатал таймер
mila doctor # стоит ли таймер и свеж ли он
launchctl list com.milagpt.turns-collect # Мак
systemctl --user list-timers mila-turns-collect # сервер
Журнал пуст, пока добавлять нечего: таймер запускается с --quiet и печатает
строку только когда что-то записал. Строка раз в час о том, что всё в порядке,
делает журнал нечитаемым.
Журнал сам себя укорачивает. Перед началом сбора: если
turns-collect.log перевалил за 1 МБ, остаются последние 500 КБ, начало
отбрасывается, а на его месте появляется строка [журнал укорочен … — начало
отброшено] — чтобы не искать, куда делось начало. Отдельного таймера ротации
нет, и файл режется на месте: служба держит его открытым, подмена файла
отправила бы вывод текущего прогона в никуда.
Два подводных камня формата, из-за которых цифры расходились:
- Один ответ модели пишется в транскрипт несколькими строками — размышление,
текст, каждый вызов инструмента, — и блок
usageповторяется в каждой. Построчный счёт завышал расход в 1,7 раза (852 строки против 488 настоящих ответов в замеренной сессии). Считаем ответами. - Изредка верхний уровень
usageприходит нулями, а числа лежат вusage.iterations. Тогда берутся оттуда.
Оба разбирает usage_collect.py (функции usage_fields и response_key), и
сборщик ходов зовёт тот же код. Двух разборов одного формата в проекте нет —
расходиться они начинают молча.
Цифры до 10 сентября 2026 сравнивать с новыми нельзя. Всё, что записано в
usage-log.jsonlраньше, посчитано построчно и завышено примерно в 1,7 раза. Расход не упал — упала ошибка счёта. Если увидите на графике обрыв десятого сентября, это он и есть, а не экономия. Новые строки помечены полемcounting: per-response; у старых этого поля нет.
Тексты разговоров — это ПДн
Выключатель MILA_RECORD_TEXT:
| Значение | Что пишется |
|---|---|
full (по умолчанию) |
тексты целиком — это личный агент владельца |
length |
только длина в символах, самих текстов нет |
none |
ни текста, ни длины |
Выбор записан в каждой строке полем text_policy, поэтому пустой user_text
нельзя спутать с потерянными данными. Очень длинный текст режется с пометкой
…[обрезано на N знаках] (MILA_RECORD_TEXT_MAX, по умолчанию 100 000).
Ставя Милу клиенту, где в чатах чужие персональные данные, ставьте
length или none — и запишите это решение там, где его найдут через полгода.
2. Внешние траты (external-spend.db, таблица external_calls)
Всё, за что платит не подписка, а счёт у провайдера: картинка, голос, видео, чужая модель. Сегодня Мила наружу не ходит вовсе — ноль вызовов OpenRouter и fal. Леджер заводится именно поэтому: первый навык с генерацией начнёт тратить молча, и заметит это выписка, а не мы.
| Колонка | Что в ней |
|---|---|
ts, agent, agent_kind |
когда и кто |
provider |
openrouter · fal · other |
service, model |
что именно позвали |
usd |
настоящие деньги |
units, unit_kind |
сколько и чего: секунды видео, картинки, символы, токены |
request_id |
обязателен — по нему строка сверяется с выпиской |
meta_json |
что угодно ещё: чат, задача, размер |
request_id обязателен и не имеет значения по умолчанию. Леджер, который
нельзя сверить с выпиской провайдера, — это не леджер, а ощущение. Пара
provider + request_id уникальна: повторная запись того же вызова не удваивает
расход и возвращает None.
Как записать — одна строка
Из навыка:
from records import record_external
record_external(provider="fal", service="lyria-3", model="lyria-3-pro",
usd=0.08, units=2, unit_kind="треки", request_id="fal-7c1a")
Из оболочки:
records.py external --provider fal --service lyria-3 --usd 0.08 \
--units 2 --unit-kind треки --request-id fal-7c1a
Правило простое: вызов наружу и запись в леджер делаются в одном месте кода. Разнесённые, они расходятся на первой же ошибке сети.
3. Как посмотреть
mila records show turns --days 7
mila records show turns --days 1 --chat -987654321
mila records show external --days 30
mila records check # то же, что смотрит доктор
mila records show turns --days 7 --json # для своих подсчётов
Своим запросом:
sqlite3 ~/.claude/analytics/conversations.db \
"SELECT date(ts,'unixepoch','localtime') d, count(*), round(sum(usd_cost),2)
FROM turns WHERE paid_by='subscription' GROUP BY d ORDER BY d"
4. Что проверяет доктор
mila doctor спрашивает у учёта четыре вещи:
- есть ли базы — нет файла, значит сборщик ни разу не запускался;
- полна ли схема — не пропали ли колонки (лечится само при открытии);
- пишется ли — полтора дня без единого хода это замечание, а не норма;
- не усохла ли — счётчики сравниваются с прошлой проверкой
(
records-watermark.json). Это единственный способ поймать пропажу: база, из которой удалили строки, выглядит совершенно здоровой, и замечают такое через месяц; - стоит ли часовой таймер и отработал ли он за последние два часа — два разных вопроса: загруженный таймер, падающий на старте, выглядит здоровым в любом списке служб, а свежая отметка без таймера значит, что кто-то собрал руками один раз и на этом успокоился.
Плюс права 600 на базе с текстами и предупреждение, если тексты выключены —
не как ошибка, а чтобы это не оказалось сюрпризом при разборе.
5. Чем это НЕ является
- Не счётом. Цена в
turnsприpaid_by = subscriptionсправочная. Класть её в MONEY.md, в отчёт клиенту или в сравнение «дешевле/дороже» с настоящими деньгами нельзя. - Не полной стенограммой. В
bot_textидёт то, что человек получил в чат (текст отправки), а если отправки не было — то, что модель сказала в терминале. Возвраты инструментов, файлы и картинки в базу не попадают. - Не заменой
usage_collect.py. Тот отвечает на вопрос «сколько стоил день» и считает по всем транскриптам сразу, включая сессии, которые сюда не собирали. Числа сходятся, потому что разбор общий, но вопросы разные. - Не аудитом. Сборщик читает то, что записал Claude Code. Если ход не попал в транскрипт, его здесь не будет.
Tasks: cost and outcome per task type
install/tasks.py (mila tasks) adds one table, tasks, to conversations.db: a task is a time span [start, end] with a kind (post, email, doc…) and an outcome — done (accepted the first time), redo, complaint. Its cost is computed in the report from the turns of that span (same agent, same chat if given), so a lagging turn collector does not lose anything.
mila tasks start --kind email --chat <id> # → task id
mila tasks done <id> [--outcome redo]
mila tasks mark <id> complaint # later
mila tasks report --days 7 [--json]
--note is a short label of the task with no client names and no message content ("promo of the week", not "email to
The weekly report groups by kind: tasks, first-time %, redos, complaints, total cost, cost per good result, model mix. As everywhere in these ledgers, the cost is a reference price on subscription (paid_by), not a charge. Overlapping tasks in one chat share turns and are marked.