Metadata-Version: 2.5
Name: urok
Version: 0.11.2
Summary: Консольный помощник для учёбы: объясняет темы, проверяет домашку, помогает разобраться
License: MIT
Keywords: chat,cli,homework,study,zen
Classifier: Environment :: Console
Classifier: Intended Audience :: Education
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# urok

Консольный помощник для учёбы. Объясняет темы, проверяет домашку, помогает
разобраться в том, что не понял.

Работает сразу после установки: бэкенд — OpenCode Zen, модель
`space-bunny-free`, ключ `public`. Ничего настраивать не нужно.

Ноль зависимостей — только стандартная библиотека Python.

## Установка

```bash
pip install urok
```

## Три способа задать вопрос

```bash
urok                              # диалог, история в текущей сессии
urok "объясни дроби"              # один вопрос, ответ, выход
urok - < task.txt                 # вопрос из файла или пайпа
```

Разовый режим не открывает диалог: один вопрос — один запрос к API — один
ответ. Годится для скриптов и для bat-файла на рабочий стол.

## Под конкретный урок

```bash
urok --grade 9 --subject физика
```

## Многострочный вопрос

Если условие задачи не влезает в строку:

```
> !multi
  ... Реши задачу:
  ... Масса тела 2 кг,
  ... высота 3 м.
  !end
```

Свой маркер конца, если в тексте задачи встречается `!end`:
`!multi моя` … `!end моя`.

## Формулы

Модель сама пишет формулы юникодом — в системном промпте стоит инструкция с
конкретными правилами (взяты из [claude-math](https://github.com/vladimirrott/claude-math)):

| Вместо | Пишет |
| --- | --- |
| `\frac{1}{2}` | `1/2` |
| `x^{2}` | `x²` |
| `\sqrt{5}` | `√(5)` |
| `\leq \geq \neq \approx` | `≤ ≥ ≠ ≈` |
| `\alpha \beta \theta \pi` | `α β θ π` |
| `\sum \prod \int \infty` | `∑ ∏ ∫ ∞` |
| `\in \notin \subset \cup \cap` | `∈ ∉ ⊂ ∪ ∩` |
| `\mathbb{R}` | `ℝ` |

Если модель всё же выдаст LaTeX, `urok` переведёт его на лету, не ломая
стриминг: незакрытая формула придерживается до закрывающей скобки, обычный
текст печатается сразу.

Знак `$` разбирается по содержимому, а не по первому символу: `$x^2$` — это
формула, а `$HOME`, `$(whoami)`, `${HOME}` и `$5` — нет. Формула перед блоком
кода переводится, внутри блока не трогается.

Отключить перевод: флаг `--raw-math`.

Дроби линейные — `1/2`, а не вертикальная раскладка. Терминал не рисует
двумерные формулы, а линейная форма переживает копирование, поиск, tmux и
SSH. Блок Unicode-букв вроде 𝐀 и 𝑉𝑎𝑟 не используется: эти кодовые точки
ломают копирование и скринридеры.

## Вставка картинок

Три способа, все без набора пути:

```bash
urok --image photo.png "что на фото?"   # сразу, разовый запуск
```

В диалоге — просто **вставь путь и нажми Enter**. Путь сам превращается в
вложение, команда не нужна:

```
> C:\Users\me\Pictures\photo.png
┌ photo.png ────────┐
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀     │
│     █████          │
└────────────────────┘
```

**Ctrl+V:** скопируй картинку, нажми Ctrl+V, затем Enter на пустой строке.
Пустая строка означает «возьми то, что в буфере».

Работает в двух слоях, потому что терминалы ведут себя по-разному:

| Слой | Что делает | Где работает |
| --- | --- | --- |
| распознавание пути | терминал прислал путь файлом — ловим сразу | Windows Terminal, перетаскивание файла |
| буфер через WinAPI `CF_DIB` | читаем сырые байты и сами собираем PNG | conhost, любой терминал |

Второй слой — чистый `ctypes` плюс свой энкодер PNG. Запуск PowerShell обошёлся
бы в секунду на каждое нажатие.

Временный файл из буфера удаляется сразу после отправки запроса — эфемерный
режим обещает пустой диск, и обещание сдержано.

Поддерживаются png, jpg, gif, bmp, webp. Тип определяется по сигнатуре файла,
а не по расширению.

Зрение работает не у всех моделей. Из бесплатных на Zen проверено вживую:
`space-bunny-free` — видит и описывает картинки. Платные требуют настоящего
ключа, а не `public`. Проверить свою:

```bash
urok --model deepseek-v4-flash-free --image photo.png "опиши"
```

Путь к вложенному файлу передаётся модели текстом: без этого она не может
открыть картинку в инструменте и честно отвечает «файла у меня нет». Временная
копия живёт только до конца ответа.

В истории хранится **путь** к файлу, а не base64. При загрузке сессии файл
перекодируется заново; если файла нет, сообщение уходит без картинки, а не
падает.

## Команды

Пять команд и всё. Всё остальное делается флагами при запуске.

| Команда | Что делает |
| --- | --- |
| `/drop` | убрать приложенные картинки |
| `/model` | какая модель сейчас; `/model имя` — переключить |
| `/wipe` | удалить всю сохранённую историю |
| `/copy` | скопировать последний ответ в буфер обмена, целиком |
| `/exit` | выйти, Ctrl+D тоже |
| `/help` | эта подсказка, без неё тоже можно |

`/copy` берёт последний ответ диалога и кладёт в буфер без разметки: `**жирный**`
становится `жирный`, escape-последовательности вырезаются. Рассуждение и служебные
строки в буфер не попадают. Без команды Ctrl+C копирует только что напечатанный
кусок, а половина ответа с картинками и формулами так теряется.

Интерфейс английский, ответы модели — на русском. Английский выбран потому,
что терминал не переносит длинные кириллические строки аккуратно, а команды
и так набираются быстрее латиницей.

## Что пакет делает с системой

Коротко: ничего. Ни реестра, ни автозапуска, ни службы, ни своего `.exe`,
ни файлов вне `%TEMP%`, который удаляется сам. Ниже — подробно, потому что
именно это и проверяют антивирусы и корпоративные политики.

**Читает**

| что | когда | откуда |
| --- | --- | --- |
| буфер обмена (только формат CF_DIB, то есть картинка) | только по `Ctrl+V` + Enter на пустой строке | WinAPI `OpenClipboard` |
| буфер обмена (только текст) | только когда сам копирует ответ | WinAPI `SetClipboardData` |
| вопрос и файлы, которые ты назвал сам | каждый ход | — |

Буфер **никогда** не читается сам. Пока ты не нажмёшь `Ctrl+V`, пакет в него
не заглядывает. Ничего не перехватывает и не подменяет — только читает по
твоей команде и записывает, когда ты сам попросил скопировать.

**Отправляет**

Вопрос, картинка и результаты вызова инструментов уходят на
`https://opencode.ai/zen/v1` — тот сервер, который указан по умолчанию. Это
видимое следствие вопроса: чтобы ответить, текст должен уйти. Своего сервера
пакет не поднимает, телеметрии не шлёт, ничего не скачивает и не запускает.

**Пишет**

| где | что | когда удаляется |
| --- | --- | --- |
| `%TEMP%\urok-*.png` | картинка, вставленная из буфера | сразу после отправки запроса |
| `~/.urok` | история диалога | только с флагом `--keep-history` |

Осиротевшие `urok-*.png` (Ctrl+C или падение питания между вложением и
следующим ходом) подметаются при следующем запуске, если им больше часа.
Трогаются только файлы с нашим префиксом в системной папке temp.

**Запускает**

По умолчанию — **ничего**. Калькулятор считает в том же процессе, безопасным
разбором выражения, без `eval`. Запуск кода на Python — только за флагом
`--allow-code`, отдельным процессом, в отдельной временной папке, с таймаутом
и списком аргументов вместо `shell=True`. `clip.exe` не запускается никогда:
текст в буфер пишется прямо через WinAPI.

**Проверяется автоматически**

`python tests/check_footprint.py` — 31 проверка через `ast`, а не поиском по
тексту. Запрещены вызовы `eval`/`exec`/`compile`/`__import__`, `shell=True`,
запуск `cmd`/`powershell`/`clip.exe` и прочих LOLBin, WinAPI для инъекций и
повышения привилегий, запись в реестр, автозапуск, самостоятельное
добавление себя в исключения антивируса, обфускация и упаковка в
исполняемый файл. Тест падает, если что-то из этого появится, — чтобы
«потом пригодится» не превратилось в дыру.

## Если сработало антивирус

Честный ответ: гарантировать, что не сработает, нельзя. Детекция обновляется
незаметно, у каждой вендоры своя логика, и VirusTotal сам не выносит вердикты.
Что можно — сделать пакет проверяемым: он состоит из 23 текстовых файлов на
Python, без бинарника, без сборщика, без обфускации. Его можно прочитать
целиком, и это лучшая защита от подозрений.

Если `python.exe` с аргументами вызывает вопросы у корпоративного антивируса
или EDR — исключение добавляет **администратор на той машине**, не пакет:

```powershell
# Windows Defender: исключение по пути и по процессу
Add-MpPreference -ExclusionProcess "python.exe"
Add-MpPreference -ExclusionPath "$env:LOCALAPPDATA\Programs\Python"
```

Microsoft: <https://learn.microsoft.com/en-us/defender-endpoint/defender-endpoint-false-positives-negatives>
Подать файл на анализ как разработчик:
<https://www.microsoft.com/en-us/wdsi/filesubmission>

Чего пакет не делает и не будет делать: не обфускается, не пакуется в
исполняемый файл, не добавляет себя в исключения сам, не прячет поведение.
Обфускация ломает смысл учебного инструмента, а ASR-правило «Block execution
of potentially obfuscated scripts» делает подозрительным ровно то, что
выглядит безобидным.

## Скорость

Главное тормозное место оказалось не в модели. `urllib.request` отправляет
`Connection: close`, поэтому **каждый ход диалога платил новое TCP + TLS
рукопожатие**. Замерено на этом же эндпоинте: ~300 мс, из них ~240 мс — TLS.

Сделано три вещи:

1. **Одно соединение на весь диалог** вместо соединения на ход. Тот же слой
   `http.client`, на котором `urllib` построен, только без принудительного
   закрытия. Контрольный замер одного и того же вопроса три раза подряд:

   | | 1-й ход | 2-й | 3-й |
   | --- | --- | --- | --- |
   | было (urllib) | 1.42 с | 1.62 с | 1.64 с |
   | стало (пул) | 1.44 с | **0.97 с** | **0.96 с** |

2. **Прогрев в фоне при старте**: DNS, TCP и TLS уходят из первого вопроса,
   пока печатается баннер.

3. **`--effort none` больше не даёт 400.** На этом шлюзе
   `reasoning_effort: "none"` отклоняется, хотя у OpenAI значение документировано.
   Единственный способ отключить рассуждение — **не отправлять поле**, что
   теперь и делает `none`.

Что НЕ помогает, чтобы не тратить на это время: укоротить системный промпт
(он 1023 символа, ~255 токенов — на задержку это единицы миллисекунд), HTTP/2
(в стандартной библиотеке его нет, а на один последовательный запрос он даёт
меньше одного RTT), `X-Accel-Buffering` (это заголовок ответа nginx, клиент его
не читает), кеширование промпта (порог 1024 токена, мы ниже).

Остаточная задержка — серверная: ответ на «привет» у этого провайдера гуляет
от 1.4 с до 15 с в зависимости от загрузки. Модель `space-bunny-free`
бесплатная, и это её потолок, а не клиента.

## Инструменты

Модель может считать, и по умолчанию это разрешено — калькулятор безопасен.

| Флаг | Что даёт |
| --- | --- |
| (по умолчанию) | калькулятор: `calculate` через безопасный разбор, без `eval` |
| `--allow-code` | плюс запуск кода на Python в отдельной папке с таймаутом |
| `--no-tools` | выключить всё, модель отвечает только текстом |

Код приходит от модели и не проверяется: `--allow-code` — это доверие, не
песочница. Без флага модель получает отказ и объясняет решение словами.

Черновик перед вызовом инструмента не печатается: если модель пишет «сейчас
посчитаю» и вызывает калькулятор, пользователь видит только результат, а не
оба абзаца.

Глубина размышления: `--effort low` (по умолчанию) быстро, `medium`, `high`,
`none` — поле не отправляется совсем, рассуждение выключено. Без ограничения
модель может молчать минутами на расплывчатом вопросе.

## Как выглядит ответ

Всё оформление взято из бинаря Claude Code 2.1.284 — палитра, глифы, тайминги.
В терминале ответ печатается на лету, без сырых markdown-маркеров: `**жирный**`
становится жирным, `` `код` `` — жёлтым, `- пункт` — с маркером, блок ``` — с
полосой слева, ссылка → «текст (url)».

Модель часто пишет перед вызовом калькулятора «сейчас посчитаю» и повторяет это
же после результата. В терминале черновик затирается на настоящих строках, и
видно только итог. В пайпе или файле затереть нечего, поэтому ответ копится до
конца и печатается одним куском: `urok "вопрос" > ответ.txt` даёт в файле только
ответ, без кадров спиннера, escape-последовательностей и черновиков.

Три блока, и каждый узнаётся с одного взгляда:

| блок | как выглядит | куда |
| --- | --- | --- |
| вопрос | `> привет`, акцент + тусклый | stdout |
| рассуждение | за тусклым бордюром `│`, без отступа | stderr, только с `--show-reasoning` |
| вызов инструмента | `● calc 3**6` и результат под ним, максимум 6 строк | stderr |
| ответ | отступ 2 колонки, без бордюра | stdout |

Бордюр и есть разделитель: stdout и stderr смешиваются в терминале, и без
явной метки рассуждение не отличить от ответа. Вызов инструмента показывает
результат, а не выражение — слово `calculate` повторяло то, что модель только
что сказала вслух, а `3**6` без ответа ничего не сообщало.

| Элемент | Как у Claude Code | У нас в 8-цветном терминале |
| --- | --- | --- |
| акцент | `#D77757` терракота | bright red |
| тусклый | `#999999` | bright black |
| еле видный | `#505050` | bright black |
| рамка | `#888888` | bright black |
| успех / ошибка | `#4EBA65` / `#FF6B80` | green / bright red |
| зависло | `#AB2B3F` | bright red |
| разделитель | ` · ` (U+00B7) | тот же |
| промпт ввода | `>` | `>` |
| рамка приветствия | в версии 2 убрана | тоже убрана |

Пока модель думает, внизу пульсирует глиф из 12 кадров пинг-понгом
`· ✢ * ✶ ✻ ✽ ✽ ✻ ✶ * ✢ ·`, смена раз в 120 мс. Надпись нарастает сама, чтобы
долгое ожидание не выглядело зависанием: `thinking` → через 10 с
`still thinking` → 20 с `thinking more` → 30 с `thinking some more` → 45 с
`deep in thought`. Если 3 секунды нет ни байта, цвет уходит в красный: значит,
всё встало.

Никакого цвета там, где его не должно быть: `NO_COLOR`, перенаправленный stdout и
терминал без TrueColor отключают оформление сами. Без юникода глифы и рамки
переключаются на `|/-\` и `+-|`.

Ошибки всегда идут в stderr, ответ — в stdout. `urok "вопрос" > ответ.txt`
сохранит только ответ.

## Удобство ввода

| | |
| --- | --- |
| `↑` `↓` | история вопросов |
| `Tab` | дополняет команды (`/mo` → `/model`) и пути файлов |
| `Ctrl+V` + `Enter` на пустой строке | картинка из буфера |
| `Ctrl+D` | выход |
| `Ctrl+C` | прервать текущий ответ, диалог остаётся |

При старте делается ровно один вывод в одну строку и **ни одного** сетевого
запроса: сервер проверяется лениво, при первом вопросе. Раньше на старте
летел запрос со списком моделей — из-за него вход ощущался медленным.

## Рассуждение модели

Модели с thinking-режимом шлют два потока: рассуждение и ответ. По умолчанию
рассуждение скрыто. Включить — флагом `urok --show-reasoning` на весь запуск.

Рассуждение всегда идёт в **stderr** тусклым, ответ — в **stdout**. Это
означает, что `urok "вопрос" > ответ.txt` сохранит только ответ, без
рассуждения и без служебных строк.

## Другие бесплатные модели

```
space-bunny-free          по умолчанию
deepseek-v4-flash-free
muse-spark-1.3-contributor-free
muse-spark-1.2-contributor-free
mimo-v2.5-free
nemotron-3-ultra-free
longcat-2.5-preview-free
```

Полный список на сервере: `https://opencode.ai/zen/v1/models`. Переключить:
`/model muse-spark-1.3-contributor-free` или флагом
`urok --model deepseek-v4-flash-free`.

## Другой провайдер

Через флаги:

```bash
urok --base https://api.groq.com/openai/v1 --key gsk_... --model llama-3.3-70b-versatile
```

Или через переменные окружения, чтобы не светить ключ в истории:

```bash
set UROK_BASE_URL=https://api.groq.com/openai/v1
set UROK_API_KEY=gsk_...
set UROK_MODEL=llama-3.3-70b-versatile
urok
```

Подходит любой OpenAI-совместимый адрес: Groq, OpenAI, LM Studio, Ollama,
llama.cpp на `http://127.0.0.1:8080/v1`.

## История по умолчанию не пишется

Закрыл консоль — на диске ничего не осталось. Папка `~/.urok` не создаётся
вообще, даже `mkdir` не вызывается. При выходе печатается проверка:

```
на диск ничего не записано, следов нет
```

Если папка всё же есть от прошлых запусков с `--keep-history` — выход скажет
об этом прямо и предложит `/wipe`.

Запомнить диалог между запусками:

```bash
urok --keep-history
```

`--keep-history` записывает каждый запуск отдельной сессией в `~/.urok`.
Читать их обратно нечем: продолжения нет, `/wipe` удаляет папку целиком.
Флаг существует ради отладки и разбора того, что модель отвечала, — не ради
удобства.

## Что остаётся на диске помимо истории

Пакет убирает за собой только историю диалога. Это остаётся и находится вне
его досягаемости:

| След | Где | Как убрать |
| --- | --- | --- |
| сам пакет | `site-packages/urok` | `pip uninstall urok` |
| байт-код | `site-packages/urok/__pycache__` | `pip uninstall urok` |
| история команд PowerShell | `ConsoleHost_history.txt` | `Clear-History` и удаление файла |
| сетевой лог | DNS/прокси школной сети | вне твоего контроля |

Запросы к `opencode.ai` видны сетевому логу в любом случае — это происходит
независимо от того, что лежит на диске.

## Настройка своего промпта

```bash
urok --system "Объясняй как пятикласснику, только формулы и короткие примеры."
```

## Тесты

```bash
python tests/run_all.py
```

Один набор — десять прогонов, каждый в отдельном процессе с пределом времени:
10/10 наборов, 314 проверок. Список: `check_code`, `check_footprint`,
`check_readme`, `test_ui`, `test_math`, `test_tools`, `test_toolloop`,
`test_images`, `smoke`, `drive_repl`.

Отдельно полезно:

```bash
python tests/smoke.py        # транспорт, диалог, эфемерность, ошибки
python tests/test_ui.py      # цвета, markdown, рамки, спиннер, буфер
python tests/test_math.py    # рендер формул и разбор $
python tools/probe_dollar.py # разбор $ на живых примерах, для глаза
python tools/bench_pool.py   # переиспользуется ли соединение
python tools/bench_dialog.py # время каждого хода живого диалога
python tools/show_dialog.py  # как это выглядит на самом деле
python tools/measure_footprint.py  # что осталось на диске после прогона
```

Все поднимают фейковый API в потоках и гоняют через него настоящий код.
Ноль внешних зависимостей.

## Откуда взяты решения

Код свёрялся с четырьмя референсами.

**`simonw/llm`** — эталонный CLI для LLM:

- событийная модель стрима вместо колбэка на кусок текста (`llm/cli.py:103`)
- разделение stdout/stderr для ответа и рассуждения (`llm/cli.py:106`)
- многострочный ввод `!multi` / `!end` (`llm/cli.py:176`)
- свой `User-Agent` вместо `Python-urllib` (`llm/cli.py:256`)
- привязка стрелок к истории через `readline` (`llm/cli.py:1270`)

**`charmbracelet/bubbles`, `charmbracelet/lipgloss`, `charmbracelet/glow`** —
оформление терминала:

- markdown без подсветки синтаксиса, как в glow: `**` → жирный, `---` → список,
  ``` → блок с полосой, ссылка → «текст (url)»
- `NO_COLOR` и непроглядывающий stdout отключают оформление, а не ломают его

**Claude Code 2.1.284** — источник всей палитры и всех таймингов. Прочитан
не по блогам, а вытащен из поставленного бинаря:

- 12 кадров спиннера пинг-понгом, `Math.floor(t/120) % 12`, период 1,44 с
- акцент `#D77757`, тусклый `#999999`, еле видный `#505050`, зависло `#AB2B3F`
- перерисовка 50 мс во время запроса и 100 мс потом, но только сам ряд со
  спиннером — остальное перерисовывается ~25 раз за ход вместо ~380
- надпись нарастает: `thinking` → `still thinking` → `thinking more`
- рамка приветствия в версии 2 убрана, осталась одна строка
- сразу после отправки показывается глиф: ощущаемая задержка 50 мс, а не TTFT

**`vladimirrott/claude-math`** — рендер формул в терминале:

- основное решение на уровне промпта: просить юникод, а не LaTeX
- не использовать блок Mathematical Alphanumeric Symbols (𝐀, 𝑉𝑎𝑟)
- дроби линейные: форма должна переживать копирование и поиск

Не взято: pydantic, click, httpx, rich, sqlite-utils, pytest-recording.
У `llm` это 145 КБ кода и четыре зависимости, здесь около двух тысяч строк
и ноль.

Рендер формул проверялся и на `pylatexenc`, `flatlatex`, `pytexprint`,
`terminatex`. Первые три — либо зависимость, либо ограниченный набор команд;
`terminatex` ближе всего, но тянет `rich` и графические протоколы, которые в
обычном терминале не работают. Свой рендерет — 216 строк, ноль зависимостей.
