Привет-привет всем любителям математики! (с)

Зачем это всё?

Давно хотелось выстроить себе персональную среду для "игры в математику" со смартфона. Не калькулятор, не Jupyter-ноутбук — а именно исследовательскую среду, в которой можно думать о задачах через построение алгоритмов и задавать уточняющие вопросы. Формулируешь задачу — далее по шагам выстраиваешь алгоритм и, что тоже важно, визуализацию. И всё это сохраняется, копится, превращается в библиотеку.

Также мне симпатична Wolfram Engine. Wolfram даёт бесплатно для личного использования Wolfram Engine — это тот же движок, что под капотом у Mathematica. Мощнейший инструмент, грех им не пользоваться.

Ну и Qwen Code CLI - вроде все еще дается до 1000 запросов в сутки бесплатно к модели "coder-model" (Qwen 3.5 Plus). Почему бы из него не сделать AI-агента?

Идея простая: я со смартфона пишу задачу в мессенджер — бот на домашнем Mac mini передаёт её AI-агенту — тот строит алгоритм на Wolfram Language, выполняет его через wolframscript, возвращает результат с графиком — и я продолжаю диалог, углубляясь в задачу. Всё это работает как непрерывная исследовательская сессия.

ВНИМАНИЕ!!! Далее результаты Вайб-кодинга!!!

Вот как это выглядит:

Смартфон (Delta Chat)
    │  email (опционально E2E шифрование)
    ▼
Mac mini: Python-бот (deltabot-cli-py)
    │
    ├── /help, /start, /close, /continue, /resume, /archive, /peek → управление сессиями
    │
    └── всё остальное → qwen -p "текст" --yolo --continue
                              │
                         Qwen Code CLI (headless)
                              │
                         wolframscript → Wolfram Engine
                              │
                         JSON-ответ (stdout)
                              │
    Python-бот ◄─────────────┘
    │
    └── send_msg() → Delta Chat → Смартфон

Бот — тонкая прослойка между мессенджером и AI-агентом. Он реализует жизненный цикл исследовательских сессий (создание, продолжение, сохранение, архивацию), а всю интеллектуальную работу отдаёт Qwen Code CLI в headless-режиме. Qwen, в свою очередь, формулирует алгоритм, пишет код на Wolfram Language, выполняет его через wolframscript и возвращает структурированный ответ.


Из чего состоит и как развернуть

Описание ниже на примере macOS, но все компоненты — Python, Node.js, Delta Chat, Wolfram Engine, Qwen Code CLI — кроссплатформенные. deltachat-rpc-server имеет билды для macOS, Linux и Windows. Так что если у вас Ubuntu или Windows-машина — всё заработает с минимальными правками.

Компоненты

Перед тем как перейти к файлам проекта, нужно установить несколько вещей:

Wolfram Engine — бесплатный для персонального использования вычислительный движок от Wolfram. После установки в системе появляется команда wolframscript, через которую агент выполняет весь Wolfram-код. Подробнее про Wolfram Engine в этой статье.

Qwen Code CLI + Node.js — AI-агент, работающий в терминале. Мы используем его в headless-режиме: передаём текст через --prompt, получаем JSON на stdout. Для работы нужен Node.js. Установка описана в документации Qwen Code.

Python + Conda — в этом проекте Python-окружения создаются через conda. Если у вас другой менеджер окружений (venv, pyenv и т.д.) — скорректируйте команды под свою среду. Принципиально ничего не изменится, просто conda create и conda activate замените на свои аналоги.

Delta Chat — мессенджер поверх обычного email. Это, пожалуй, самое простое решение для связки «смартфон → домашний сервер». Не нужен VPN, не нужны открытые порты, не нужен белый IP. Достаточно обычного email-аккаунта — бот читает входящие через IMAP и отвечает через SMTP. Клиент ставится на смартфон из App Store / Google Play, а серверная часть (deltachat-rpc-server) тянется автоматически через pip-зависимости.
Для любителей: в Delta Chat можно настроить E2E шифрование через PGP (включается автоматически после подключения контакта через QR-код).

Файлы проекта

Проект состоит из пяти файлов:

Файл

Назначение

requirements.txt

Зависимости Python

config.py

Конфигурация: пути, whitelist, параметры Qwen

QWEN.md

Системный промпт для AI-агента — правила работы

init.sh

Инициализация рабочей директории (создание папок, шаблонов)

math_bot.py

Основной скрипт бота (900+ строк)

Дальше — содержимое каждого файла с пояснениями. Можно копировать как есть.

requirements.txt

Зависимостей ровно две: библиотека deltachat2 (Python-обёртка над Delta Chat core) и deltabot-cli (фреймворк для написания ботов). Пакет deltachat2[full] автоматически тянет deltachat-rpc-server — нативный бинарник, который работает как мост между Python-кодом и Delta Chat.

requirements.txt
# Math Research Lab — зависимости
# Установка:
#   conda create -n mathbot python=3.12 -y
#   conda activate mathbot
#   pip install -r requirements.txt

# Delta Chat бот-фреймворк (включает deltachat-rpc-server)
deltachat2[full]>=0.9.0
deltabot-cli>=8.0.0

config.py

Единственное место, где нужно что-то менять руками, — WHITELIST_EMAIL. Это email-адрес, с которого бот принимает сообщения. Все остальные отправители молча игнорируются. Остальные параметры имеют разумные значения по умолчанию и при этом переопределяются через переменные окружения — удобно для деплоя.

Обратите внимание на QWEN_PREAMBLE — это короткая инструкция, которая встраивается в каждый промпт к Qwen. Она гарантирует, что агент использует wolframscript, а не Python, и сохраняет графики в правильную директорию.

config.py
"""
Math Research Lab — конфигурация.

Все настройки вынесены в одно место. Перед первым запуском
укажите WHITELIST_EMAIL и путь PROJECT_DIR.
"""

import os
from pathlib import Path

# ─── Пути ────────────────────────────────────────────────────────────────────
# Корневая директория проекта (~/projects/math_research)
PROJECT_DIR = Path(os.environ.get(
    "MRL_PROJECT_DIR",
    Path.home() / "projects" / "math_research",
))

SESSIONS_DIR = PROJECT_DIR / "sessions"
ALGORITHMS_DIR = PROJECT_DIR / "algorithms"
SESSIONS_INDEX = SESSIONS_DIR / "_index.md"
ALGORITHMS_INDEX = ALGORITHMS_DIR / "_index.md"
QWEN_MD = PROJECT_DIR / "QWEN.md"

# ─── Безопасность ────────────────────────────────────────────────────────────
# Email-адрес, от которого бот принимает сообщения.
# Все остальные отправители игнорируются молча.
WHITELIST_EMAIL = os.environ.get(
    "MRL_WHITELIST_EMAIL",
    "ваш-email-в-delta-chat@example.com",
)

# ─── Qwen Code CLI ──────────────────────────────────────────────────────────
# Команда запуска Qwen Code CLI
QWEN_CMD = os.environ.get("MRL_QWEN_CMD", "qwen")

# Максимальное время ожидания ответа от Qwen (секунды)
# Qwen запускается в headless-режиме как подпроцесс.
# Для сложных задач с wolframscript может потребоваться больше времени.
QWEN_TIMEOUT = int(os.environ.get("MRL_QWEN_TIMEOUT", "600"))

# ─── /peek ───────────────────────────────────────────────────────────────────
PEEK_LINES = 40

# ─── Системная преамбула ─────────────────────────────────────────────────────────────
# Краткая инструкция, встраиваемая в каждый промпт к Qwen.
# Гарантирует, что Qwen использует wolframscript, а не Python.
QWEN_PREAMBLE = (
    "Используй только wolframscript (не Python). "
    "Графики сохраняй в /tmp/mrl_graphics/. "
    "Подписи на графиках — только латиница.\n\n"
)

# Временная папка для графики Qwen.
# Бот ищет новые PNG здесь и отправляет в чат.
# При /close файлы переносятся в _assets/ сессии.
GRAPHICS_DIR = Path(os.environ.get("MRL_GRAPHICS_DIR", "/tmp/mrl_graphics"))

# ─── PNG-перехват ────────────────────────────────────────────────────────────
# Директория, откуда бот подхватывает сгенерированные Qwen PNG-файлы
PNG_WATCH_DIR = os.environ.get("MRL_PNG_WATCH_DIR", "/tmp")
PNG_EXTENSIONS = {".png", ".jpg", ".jpeg", ".svg", ".pdf"}

QWEN.md

Это системный промпт — инструкция для AI-агента. Qwen читает этот файл при старте каждой новой сессии и следует описанным правилам. Тут зашита ключевая логика: сначала словесный алгоритм, потом код, обязательная верификация через wolframscript, запрет на «решение в уме».

Отдельно обратите внимание на правило про подписи на графиках — кириллица в wolframscript отображается некорректно, поэтому все подписи на латинице.

QWEN.md
# Math Research Lab

## Цель
Исследую математические задачи, строю алгоритмы на Wolfram Language.
Фиксирую процессы рассуждений для будущего обучающего сервиса.

## Правила работы
1. Сначала словесный алгоритм, потом код
2. Явно нумеровать шаги алгоритма
3. Фиксировать тупики и ошибочные пути — они важны не меньше решения
4. Перед /close: сохранить алгоритм в algorithms/, сделать /summary
5. ОБЯЗАТЕЛЬНО: каждое решение должно быть верифицировано через wolframscript. Никогда не решай задачу «в уме» — всегда формируй команду wolframscript и ВЫПОЛНЯЙ её через run_shell_command.
6. Не оценивай сложность задачи самостоятельно. Даже для простейших задач — строй алгоритм и верифицируй через Wolfram.
7. НИКОГДА не пиши результат сам. Всегда выполни команду реально и покажи фактический вывод. Не выдумывай и не предугадывай вывод wolframscript.

## Формат ответа
Отвечай кратко и структурировано. Формат каждого ответа:

1. Словесный алгоритм (кратко, нумерованные шаги)
2. Код Wolfram (точный код, который будет выполнен)
3. Результат wolframscript
4. Интерпретация результата (1-2 предложения)

КРИТИЧЕСКИ ВАЖНО для пункта 2:
ПРЕЖДЕ чем выполнять команду wolframscript, ТЫ ОБЯЗАН сначала написать весь код Wolfram Language в тексте ответа.
Никогда не выполняй wolframscript молча — пользователь должен видеть код до его выполнения.

Если используется однострочная команда:
  2. Код Wolfram
  wolframscript -code "Solve[x^2 - 4 == 0, x]"

Если код многострочный (скрипт .wl):
  2. Код Wolfram (скрипт)
  ---wl---
  f[x_] := x^2 + 2x - 15
  roots = Solve[f[x] == 0, x]
  Plot[f[x], {x, -7, 5}]
  ---wl---

СТРОГО ЗАПРЕЩЕНО использовать в ответах:
- Звёздочки для выделения (**жирный**, *курсив*)
- Обратные кавычки для кода (``` или `)
- Знаки решётки для заголовков (##)
- LaTeX-нотацию ($...$)
- Любую другую markdown-разметку

Пиши чистым текстом. Вместо заголовков используй нумерованные пункты. Не добавляй пояснений сверх четырёх пунктов выше.

## Инструменты
wolframscript -code "..."                          — выполнить выражение WL
wolframscript /tmp/mrl_graphics/script.wl          — запустить скрипт
Export["/tmp/mrl_graphics/plot.png", Plot[...]]     — сгенерировать PNG

## Графика
ВСЕ графические файлы (PNG, JPG, SVG, PDF) сохраняй ТОЛЬКО в /tmp/mrl_graphics/.
Примеры:
  Export["/tmp/mrl_graphics/plot.png", Plot[Sin[x], {x, 0, 2Pi}]]
  Export["/tmp/mrl_graphics/venn.png", Graphics[...]]

НИКОГДА не сохраняй графику в другие директории (sessions/, algorithms/, текущую папку).
Бот сам заберёт файлы из /tmp/mrl_graphics/ и отправит в чат.

ВСЕ подписи на графиках (PlotLabel, AxesLabel, FrameLabel, Text, заголовки) пиши ТОЛЬКО на латинице или на английском языке. Кириллица в графиках wolframscript отображается некорректно.

Примеры:
  ПРАВИЛЬНО: PlotLabel -> "f(x) = x^2 + 1"
  ПРАВИЛЬНО: AxesLabel -> {"x", "y"}
  ПРАВИЛЬНО: PlotLabel -> "Normal distribution on [0,1]"
  НЕПРАВИЛЬНО: PlotLabel -> "Нормальное распределение"

## Структура проекта
- sessions/ — записи исследовательских сессий (.md + _assets/)
- algorithms/ — финализированные алгоритмы (description.md, reasoning.md, solution.wl)

## Формат алгоритма (algorithms/ИМЯ/)
- description.md — постановка задачи, входы/выходы, ограничения
- reasoning.md — процесс построения: шаги, наблюдения, тупики, граничные случаи
- solution.wl — эталонная реализация на Wolfram Language

init.sh

Скрипт инициализации создаёт структуру директорий проекта, копирует QWEN.md на место и генерирует шаблоны для будущих алгоритмов. Запускается один раз при первой настройке. Идемпотентный — повторный запуск не затирает существующие файлы.

init.sh
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# Math Research Lab — инициализация проекта
# Запуск: bash init.sh  (или chmod +x init.sh && ./init.sh)
# ─────────────────────────────────────────────────────────────────────────────

set -euo pipefail

PROJECT_DIR="${MRL_PROJECT_DIR:-$HOME/projects/math_research}"

echo "📁 Инициализация Math Research Lab в $PROJECT_DIR"

# ─── Создание директорий ─────────────────────────────────────────────────────
mkdir -p "$PROJECT_DIR/sessions"
mkdir -p "$PROJECT_DIR/algorithms"

# ─── sessions/_index.md ──────────────────────────────────────────────────────
INDEX_FILE="$PROJECT_DIR/sessions/_index.md"
if [ ! -f "$INDEX_FILE" ]; then
cat > "$INDEX_FILE" << 'EOF'
Каталог исследовательских сессий

EOF
echo "  ✅ Создан $INDEX_FILE"
else
echo "  ⏭️  $INDEX_FILE уже существует, пропускаю"
fi

# ─── algorithms/_index.md ────────────────────────────────────────────────────
ALG_INDEX="$PROJECT_DIR/algorithms/_index.md"
if [ ! -f "$ALG_INDEX" ]; then
cat > "$ALG_INDEX" << 'EOF'
Каталог алгоритмов

EOF
echo "  ✅ Создан $ALG_INDEX"
else
echo "  ⏭️  $ALG_INDEX уже существует, пропускаю"
fi

# ─── QWEN.md ────────────────────────────────────────────────────────────────
QWEN_FILE="$PROJECT_DIR/QWEN.md"
if [ ! -f "$QWEN_FILE" ]; then
cp "$(dirname "$0")/QWEN.md" "$QWEN_FILE"
echo "  ✅ Создан $QWEN_FILE"
else
echo "  ⏭️  $QWEN_FILE уже существует, пропускаю"
fi

# ─── Шаблоны алгоритмов ─────────────────────────────────────────────────────
TEMPLATE_DIR="$PROJECT_DIR/algorithms/_templates"
mkdir -p "$TEMPLATE_DIR"

if [ ! -f "$TEMPLATE_DIR/description.md" ]; then
cat > "$TEMPLATE_DIR/description.md" << 'EOF'
# Название алгоритма

## Постановка задачи
[Формулировка задачи простым языком]

## Входные данные
- [описание входов]

## Выходные данные
- [описание выходов]

## Ограничения
- [граничные случаи, ограничения на входы]

## Примеры

Вход: ...
Выход: ...

EOF
echo "  ✅ Создан шаблон description.md"
fi

if [ ! -f "$TEMPLATE_DIR/reasoning.md" ]; then
cat > "$TEMPLATE_DIR/reasoning.md" << 'EOF'
# Процесс построения алгоритма

## Шаг 1: Понимание задачи
[Разбор формулировки, выделение ключевых понятий]

## Шаг 2: Первая идея
[Наивный подход, оценка сложности]

## Шаг 3: Ключевое наблюдение
[Инсайт, который ведёт к эффективному решению]

## Шаг N: Финальный алгоритм
[Итоговое словесное описание алгоритма]

## Тупики
[Что пробовали, но не сработало, и почему]

## Граничные случаи
[Какие крайние случаи обнаружили и как обработали]

## Верификация
[Как убедились, что алгоритм корректен — тесты, доказательства]
EOF
echo "  ✅ Создан шаблон reasoning.md"
fi

echo ""
echo "🎉 Инициализация завершена!"
echo "   Проект: $PROJECT_DIR"
echo ""
echo "Следующие шаги:"
echo "  1. Настройте email бота:  python math_bot.py init EMAIL:PASSWORD"
echo "  2. Запустите бота:        python math_bot.py serve"

math_bot.py

Основной (и единственный) скрипт бота — 913 строк. Вот что он делает:

  • Whitelist — принимает сообщения только от одного email-адреса, остальные молча игнорирует.

  • Команды (/start, /close, /continue, /save, /resume, /archive, /peek, /help) — управляют жизненным циклом исследовательских сессий.

  • Транзит в Qwen — всё, что не команда, передаётся в Qwen Code CLI через subprocess.run в headless-режиме.

  • Парсинг JSON — Qwen отвечает в формате JSON (массив сообщений). Бот извлекает текст и команды, дедуплицирует строки, очищает markdown-разметку.

  • Перехват графики — после каждого вызова Qwen бот проверяет /tmp/mrl_graphics/ на новые PNG-файлы и отправляет их в чат.

  • Сессии — при /close генерируется резюме, графика переносится в _assets/ сессии, запись добавляется в индекс.

  • Алгоритмы — при /save Qwen формирует три файла (description.md, reasoning.md, solution.wl) и они сохраняются в algorithms/.

math_bot.py
#!/usr/bin/env python3
"""
Math Research Lab — Delta Chat бот.

Тонкий слой между Delta Chat (смартфон) и Qwen Code CLI (headless) на Mac mini.
Реализует 7 команд жизненного цикла сессий, всё остальное транзитом
передаёт в Qwen Code CLI через headless-режим (--prompt --yolo --continue).

Запуск:
    python math_bot.py init EMAIL PASSWORD
    python math_bot.py serve
"""

import json
import logging
import os
import re
import shutil
import subprocess
import time
from datetime import datetime
from glob import glob
from pathlib import Path

from deltachat2 import MsgData, events
from deltabot_cli import BotCli

from config import (
    ALGORITHMS_DIR,
    ALGORITHMS_INDEX,
    GRAPHICS_DIR,
    PEEK_LINES,
    PNG_EXTENSIONS,
    PROJECT_DIR,
    QWEN_CMD,
    QWEN_PREAMBLE,
    QWEN_TIMEOUT,
    SESSIONS_DIR,
    SESSIONS_INDEX,
    WHITELIST_EMAIL,
)

# ─── Логирование ────────────────────────────────────────────────────────────
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
)
log = logging.getLogger("math_bot")

# ─── Бот ─────────────────────────────────────────────────────────────────────
cli = BotCli("math-research-lab")

# Состояние: имя активной сессии (None — нет активной)
_active_session: str | None = None

# Набор уже отправленных PNG, чтобы не слать дубли
_sent_pngs: set[str] = set()


# ═════════════════════════════════════════════════════════════════════════════
# Транс��итерация
# ═════════════════════════════════════════════════════════════════════════════

_TRANSLIT = {
    "а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "yo",
    "ж": "zh", "з": "z", "и": "i", "й": "y", "к": "k", "л": "l", "м": "m",
    "н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t", "у": "u",
    "ф": "f", "х": "kh", "ц": "ts", "ч": "ch", "ш": "sh", "щ": "shch",
    "ъ": "", "ы": "y", "ь": "", "э": "e", "ю": "yu", "я": "ya",
}


def _transliterate(text: str) -> str:
    """Транслитерировать русский текст в латиницу и нормализовать для имени папки."""
    result = []
    for ch in text.lower():
        if ch in _TRANSLIT:
            result.append(_TRANSLIT[ch])
        elif ch.isascii() and (ch.isalnum() or ch in "-_"):
            result.append(ch)
        elif ch in " \t":
            result.append("_")
        # остальные символы пропускаем
    # Убираем множественные подчёркивания
    name = re.sub(r"_+", "_", "".join(result)).strip("_")
    return name or "unnamed"


# ═════════════════════════════════════════════════════════════════════════════
# Взаимодействие с Qwen Code CLI (headless-режим)
# ═════════════════════════════════════════════════════════════════════════════

def _run_qwen(prompt: str, continue_session: bool = True) -> str:
    """
    Запустить Qwen Code CLI в headless-режиме и вернуть текстовый ответ.

    Использует --yolo (автоодобрение всех действий) и --continue
    (продолжение последней сессии в текущем проекте).
    Вывод в формате JSON для надёжного парсинга.
    """
    full_prompt = QWEN_PREAMBLE + prompt

    cmd = [
        QWEN_CMD,
        "--prompt", full_prompt,
        "--yolo",
        "--output-format", "json",
    ]

    if continue_session:
        cmd.append("--continue")

    log.info("→ Qwen: %s", prompt[:100])

    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=QWEN_TIMEOUT,
            cwd=str(PROJECT_DIR),
        )
    except subprocess.TimeoutExpired:
        log.warning("Таймаут Qwen (%d сек)", QWEN_TIMEOUT)
        return "⚠️ Qwen не ответил в течение отведённого времени. Попробуйте упростить запрос."

    if result.returncode != 0 and not result.stdout.strip():
        log.error("Qwen ошибка: %s", result.stderr[:500])
        return f"⚠️ Ошибка Qwen: {result.stderr[:300]}"

    return _parse_qwen_json(result.stdout)


def _parse_qwen_json(raw: str) -> str:
    """
    Извлечь полный текстовый ответ из JSON-вывода Qwen.

    Логика:
    1. Из каждого assistant-сообщения извлекаем text-блоки и tool_use-блоки.
       - tool_use-блоки, содержащие команды (bash/shell), превращаем в текст
         команды, чтобы пользователь видел, что именно Qwen выполнил.
    2. Промежуточные assistant-блоки (между tool call'ами) часто содержат
       «мысли вслух» Qwen: повторы заголовков, описания ошибок и пр.
       Берём только первый и последний assistant-блоки целиком;
       из промежуточных извлекаем только tool_use-команды.
    3. Дедупликация: если строка уже встречалась выше — не добавляем повторно.
    """
    try:
        messages = json.loads(raw)
    except json.JSONDecodeError:
        return raw.strip() if raw.strip() else "(пустой ответ от Qwen)"

    if not isinstance(messages, list):
        return raw.strip() or "(пустой ответ от Qwen)"

    # ── Собираем assistant-сообщения ────────────────────────────────────
    assistant_msgs = [
        msg for msg in messages if msg.get("type") == "assistant"
    ]

    if not assistant_msgs:
        # Фолбэк: result-сообщение
        for msg in reversed(messages):
            if msg.get("type") == "result" and msg.get("result"):
                return msg["result"]
        return raw.strip() or "(пустой ответ от Qwen)"

    # ── Вспомогательные функции ─────────────────────────────────────────
    def _extract_texts(msg_obj: dict) -> list[str]:
        """Извлечь текстовые блоки из assistant-сообщения."""
        parts = []
        for block in msg_obj.get("message", {}).get("content", []):
            if block.get("type") == "text":
                parts.append(block["text"])
        return parts

    def _extract_tool_commands(msg_obj: dict) -> list[str]:
        """
        Извлечь команды из tool_use-блоков (bash, shell, execute_command и пр.).
        Многострочные команды оборачивает в ---wl--- маркеры.
        """
        parts = []
        for block in msg_obj.get("message", {}).get("content", []):
            if block.get("type") != "tool_use":
                continue
            inp = block.get("input", {})
            # Ищем команду в типичных полях tool_use
            cmd = (
                inp.get("command")
                or inp.get("cmd")
                or inp.get("code")
                or inp.get("content")
            )
            if cmd and isinstance(cmd, str):
                cmd = cmd.strip()
                parts.append(_format_tool_command(cmd))
        return parts

    def _format_tool_command(cmd: str) -> str:
        """
        Отформатировать команду для отображения в чате.
        Однострочные команды остаются как есть.
        Многострочные скрипты оборачиваются в ---wl---.
        """
        # Определяем, это wolframscript или нет
        is_wolfram = (
            "wolframscript" in cmd
            or cmd.endswith(".wl")
            or cmd.endswith(".wls")
        )

        # Извлекаем WL-код из команды wolframscript -code "..."
        wl_code = None
        if is_wolfram and '-code' in cmd:
            # Извлекаем содержимое после -code
            for marker in ['-code "', "-code '", '-code ']:
                idx = cmd.find(marker)
                if idx != -1:
                    start = idx + len(marker)
                    if marker.endswith('"'):
                        end = cmd.rfind('"')
                        wl_code = cmd[start:end] if end > start else cmd[start:]
                    elif marker.endswith("'"):
                        end = cmd.rfind("'")
                        wl_code = cmd[start:end] if end > start else cmd[start:]
                    else:
                        wl_code = cmd[start:]
                    break

        # Многострочный WL-код — оборачиваем в ---wl---
        if wl_code and "\n" in wl_code:
            return f"---wl---\n{wl_code.strip()}\n---wl---"

        # Многострочная shell-команда со скриптом (cat > /tmp/script.wl ...)
        if is_wolfram and "\n" in cmd:
            return f"---wl---\n{cmd}\n---wl---"

        return cmd

    # ── Сборка ответа ───────────────────────────────────────────────────
    raw_parts: list[str] = []

    if len(assistant_msgs) == 1:
        # Простой случай: один assistant-блок
        raw_parts.extend(_extract_texts(assistant_msgs[0]))
        for cmd in _extract_tool_commands(assistant_msgs[0]):
            raw_parts.append(cmd)
    else:
        # Несколько assistant-блоков:
        #   первый  — основной ответ (алгоритм, команда)
        #   средние — промежуточные рассуждения (берём только команды)
        #   последний — финальный ответ (результат, интерпретация)
        raw_parts.extend(_extract_texts(assistant_msgs[0]))
        for cmd in _extract_tool_commands(assistant_msgs[0]):
            raw_parts.append(cmd)

        for mid_msg in assistant_msgs[1:-1]:
            for cmd in _extract_tool_commands(mid_msg):
                raw_parts.append(cmd)

        raw_parts.extend(_extract_texts(assistant_msgs[-1]))
        for cmd in _extract_tool_commands(assistant_msgs[-1]):
            raw_parts.append(cmd)

    if not raw_parts:
        # Фолбэк: result-сообщение
        for msg in reversed(messages):
            if msg.get("type") == "result" and msg.get("result"):
                return msg["result"]
        return raw.strip() or "(пустой ответ от Qwen)"

    # ── Дедупликация строк ──────────────────────────────────────────────
    combined = "\n".join(raw_parts)
    seen: set[str] = set()
    deduped_lines: list[str] = []
    for line in combined.split("\n"):
        stripped = line.strip()
        if not stripped:
            # Пустые строки сохраняем (форматирование)
            deduped_lines.append(line)
            continue
        if stripped in seen:
            continue
        seen.add(stripped)
        deduped_lines.append(line)

    return "\n".join(deduped_lines)


def _check_new_pngs() -> list[str]:
    """
    Проверить /tmp/mrl_graphics/ на новые изображения.
    Возвращает пути к файлам, которые ещё не отправлялись.
    Отправляет оригиналы напрямую (без копирования).
    При /close файлы переносятся в _assets/ сессии.
    """
    new_files = []

    if not GRAPHICS_DIR.exists():
        return new_files

    for ext in PNG_EXTENSIONS:
        for fpath in GRAPHICS_DIR.glob(f"*{ext}"):
            fstr = str(fpath)
            if fstr in _sent_pngs:
                continue

            try:
                mtime = fpath.stat().st_mtime
                if time.time() - mtime > 300:
                    continue
            except OSError:
                continue

            _sent_pngs.add(fstr)
            new_files.append(fstr)
            log.info("PNG обнаружен: %s", fpath)

    return new_files


def _strip_markdown(text: str) -> str:
    """Убрать markdown-разметку из текста для чистого отображения в Delta Chat."""
    # Убираем блоки кода (```...```)
    text = re.sub(r"```[a-zA-Z]*\n?", "", text)
    # Убираем инлайн-код (`...`)
    text = re.sub(r"`([^`]+)`", r"\1", text)
    # Убираем жирный (**...**)
    text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
    # Убираем курсив (*...*)
    text = re.sub(r"\*([^*]+)\*", r"\1", text)
    # Убираем заголовки (## текст → текст)
    text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
    # Убираем LaTeX ($...$)
    text = re.sub(r"\$([^$]+)\$", r"\1", text)
    return text


def _send_text(bot, accid: int, chat_id: int, text: str) -> None:
    """Отправить текстовое сообщение, очистив markdown и разбив длинные на части."""
    text = _strip_markdown(text)
    MAX_LEN = 50_000
    if len(text) <= MAX_LEN:
        bot.rpc.send_msg(accid, chat_id, MsgData(text=text))
    else:
        chunks = []
        current = ""
        for line in text.split("\n"):
            if len(current) + len(line) + 1 > MAX_LEN:
                chunks.append(current)
                current = line
            else:
                current = current + "\n" + line if current else line
        if current:
            chunks.append(current)

        for i, chunk in enumerate(chunks):
            prefix = f"[{i+1}/{len(chunks)}]\n" if len(chunks) > 1 else ""
            bot.rpc.send_msg(accid, chat_id, MsgData(text=prefix + chunk))


def _send_file(bot, accid: int, chat_id: int, filepath: str) -> None:
    """Отправить файл как вложение."""
    bot.rpc.send_msg(accid, chat_id, MsgData(file=filepath))


# ═══════════════════════════════════════════��═════════════════════════════════
# Справка
# ═════════════════════════════════════════════════════════════════════════════

HELP_TEXT = """📋 КОМАНДЫ БОТА (управление сессиями):
/start ИМЯ    — создать новую сессию
/close        — сохранить и завершить текущую сессию
/continue ИМЯ — продолжить сессию после перезапуска бота (с полным контекстом Qwen)
/save [ИМЯ]   — сохранить алгоритм как ценный (в algorithms/)
/resume ИМЯ   — возобновить работу по резюме прошлой сессии
/archive      — список всех сохранённых сессий
/peek ИМЯ     — заглянуть в сохранённую сессию (первые 40 строк)
/help         — эта подсказка
/help qwen    — справка по возможностям Qwen Code CLI

Внутри сессии просто пишите текст — он будет передан в Qwen Code CLI.
Имя в /save можно писать на русском — будет транслитерировано.

Разница /continue и /resume:
  /continue — продолжает сессию Qwen с полной историей диалога
  /resume   — новая сессия Qwen с резюме прошлой (если история удалена)"""

HELP_QWEN_TEXT = """🤖 QWEN CODE CLI — возможности AI-агента

Qwen Code CLI — это AI-агент, который работает в терминале. В нашем боте он запускается в headless-режиме: вы пишете текст, он отвечает и выполняет команды.

--- ИНСТРУМЕНТЫ ---

Qwen имеет доступ к следующим инструментам:

Bash        — выполнение shell-команд (в т.ч. wolframscript)
FileRead    — чтение файлов (код, данные, конфиги)
EditFile    — редактирование файлов (создание, изменение, запись)
Grep        — поиск по содержимому файлов (регулярные выражения)
Glob        — поиск файлов по шаблону имени
WebFetch    — загрузка содержимого веб-страниц по URL
WebSearch   — поиск в интернете

Все инструменты выполняются автоматически (режим YOLO).

--- ЧТО QWEN МОЖЕТ ---

• Решать математические задачи через wolframscript
• Строить графики и визуализации (Export["/tmp/plot.png", ...])
• Читать и редактировать файлы проекта
• Создавать скрипты на Wolfram Language
• Искать информацию в интернете
• Загружать содержимое веб-страниц
• Запускать любые shell-команды

--- КОНТЕКСТ СЕССИИ ---

Qwen сохраняет контекст между сообщениями внутри сессии (флаг --continue). Каждое новое сообщение продолжает диалог с того места, где остановились.

При /start создаётся новая сессия, Qwen читает QWEN.md с правилами работы. При /resume загружается резюме прошлой сессии.

--- РЕЖИМ YOLO ---

Бот работает в режиме YOLO — автоматическое одобрение всех действий. Qwen сам запускает wolframscript, создаёт файлы и выполняет команды без запроса подтверждения.

Другие режимы одобрения (tools.approvalMode в settings.json):
  plan      — только анализ, без выполнения
  default   — спрашивать перед каждым действием
  auto-edit — авто редактирование файлов, остальное с подтверждением
  yolo      — всё автоматически (текущий режим)

--- ФОРМАТ ОТВЕТА ---

По QWEN.md агент отвечает в 4 пункта:
  1. Словесный алгоритм (нумерованные шаги)
  2. Команда wolframscript (точная команда)
  3. Результат wolframscript
  4. Интерпретация результата (1-2 предложения)

--- HEADLESS-РЕЖИМ ---

Qwen запускается командой:
  qwen -p "ТЕКСТ" --yolo --continue --output-format json

Ключевые флаги:
  -p, --prompt    — текст запроса
  --yolo          — автоодобрение всех действий
  --continue      — продолжить последнюю сессию текущего проекта
  --output-format — формат вывода (text, json, stream-json)
  --resume ID     — восстановить конкретную сессию
  -a, --all-files — включить все файлы в контекст
  -d, --debug     — режим отладки

Документация Qwen Code CLI: https://qwenlm.github.io/qwen-code-docs/"""

BOT_COMMANDS = {"/start", "/close", "/continue", "/save", "/resume", "/archive", "/peek", "/help"}


# ═════════════════════════════════════════════════════════════════════════════
# Обработчики команд
# ═════════════════════════════════════════════════════════════════════════════

def _cmd_help(bot, accid, chat_id, topic: str = ""):
    if topic.lower() == "qwen":
        _send_text(bot, accid, chat_id, HELP_QWEN_TEXT)
    else:
        _send_text(bot, accid, chat_id, HELP_TEXT)


def _cmd_start(bot, accid, chat_id, session_name: str):
    global _active_session

    if not session_name:
        _send_text(bot, accid, chat_id, "⚠️ Укажите имя: /start имя_сессии")
        return

    if _active_session:
        _send_text(
            bot, accid, chat_id,
            f"⚠️ Уже есть активная сессия: {_active_session}\n"
            f"Закройте её командой /close, прежде чем начинать новую.",
        )
        return

    # Транслитерируем если русское имя
    safe_name = _transliterate(session_name)

    _active_session = safe_name
    _sent_pngs.clear()

    # Создаём/очищаем временную папку для графики
    if GRAPHICS_DIR.exists():
        for f in GRAPHICS_DIR.iterdir():
            f.unlink(missing_ok=True)
    GRAPHICS_DIR.mkdir(parents=True, exist_ok=True)

    # Первый вызов Qwen без --continue (новая сессия)
    init_prompt = (
        f"Начинаем новую исследовательскую сессию: {session_name}. "
        f"Прочитай файл QWEN.md в текущей директории для понимания контекста проекта."
    )
    _run_qwen(init_prompt, continue_session=False)

    log.info("Сессия запущена: %s", safe_name)
    _send_text(
        bot, accid, chat_id,
        f"✅ Сессия «{session_name}» запущена (папка: {safe_name}).\n"
        f"Qwen Code CLI готов. Пишите задачу.",
    )


def _cmd_close(bot, accid, chat_id):
    global _active_session

    if not _active_session:
        _send_text(bot, accid, chat_id, "⚠️ Нет активной сессии.")
        return

    session_name = _active_session
    _send_text(bot, accid, chat_id, "⏳ Завершаю сессию, генерирую резюме...")

    summary = _run_qwen(
        "Сгенерируй краткое резюме нашей сессии: "
        "что обсуждали, какие задачи решали, какие результаты. "
        "Без markdown-разметки, простой текст.",
        continue_session=True,
    )

    today = datetime.now().strftime("%Y-%m-%d")
    session_file = SESSIONS_DIR / f"{today}_{session_name}.md"

    content_parts = [
        f"# Сессия: {session_name}",
        f"Дата: {today}",
        "",
        "## Резюме",
        summary if summary else "(резюме не сгенерировано)",
    ]

    # Перенос графики из временной папки в _assets/ сессии
    assets_dir = SESSIONS_DIR / f"{today}_{session_name}_assets"
    if GRAPHICS_DIR.exists() and any(GRAPHICS_DIR.iterdir()):
        assets_dir.mkdir(parents=True, exist_ok=True)
        for src in sorted(GRAPHICS_DIR.iterdir()):
            if src.is_file() and src.suffix.lower() in PNG_EXTENSIONS:
                dest = assets_dir / src.name
                shutil.move(str(src), str(dest))
                log.info("Графика перенесена: %s → %s", src, dest)

    if assets_dir.exists() and any(assets_dir.iterdir()):
        content_parts.append("")
        content_parts.append("## Визуализации")
        for img in sorted(assets_dir.iterdir()):
            rel = f"./{assets_dir.name}/{img.name}"
            content_parts.append(f"![{img.stem}]({rel})")

    session_file.write_text("\n".join(content_parts), encoding="utf-8")
    log.info("Сессия сохранена: %s", session_file)

    # Дописываем в каталог, только если сессии ещё нет в индексе
    existing = SESSIONS_INDEX.read_text(encoding="utf-8") if SESSIONS_INDEX.exists() else ""
    if f"  {session_name}  " not in existing:
        index_line = f"{today}  {session_name}  [завершён]\n"
        with open(SESSIONS_INDEX, "a", encoding="utf-8") as f:
            f.write(index_line)

    _active_session = None
    _sent_pngs.clear()

    _send_text(
        bot, accid, chat_id,
        f"✅ Сессия «{session_name}» завершена и сохранена.\n"
        f"📄 {session_file.name}",
    )


def _cmd_continue(bot, accid, chat_id, session_name: str):
    """
    Продолжить сессию после перезапуска бота.

    В отличие от /resume, не создаёт новую сессию Qwen,
    а просто восстанавливает _active_session.
    Следующий вызов Qwen с --continue подхватит
    последнюю сессию из ~/.qwen/projects/ автоматически.
    """
    global _active_session

    if not session_name:
        _send_text(bot, accid, chat_id, "⚠️ Укажите имя: /continue имя_сессии")
        return

    if _active_session:
        _send_text(
            bot, accid, chat_id,
            f"⚠️ Уже есть активная сессия: {_active_session}\n"
            f"Закройте её командой /close.",
        )
        return

    safe_name = _transliterate(session_name)
    _active_session = safe_name
    _sent_pngs.clear()

    log.info("Сессия продолжена: %s", safe_name)
    _send_text(
        bot, accid, chat_id,
        f"✅ Сессия «{session_name}» продолжена.\n"
        f"Qwen подхватит полный контекст при следующем сообщении. Пишите задачу.",
    )


def _cmd_save(bot, accid, chat_id, algo_name: str):
    """Сохранить текущий алгоритм как ценный в algorithms/."""
    if not _active_session:
        _send_text(bot, accid, chat_id, "⚠️ Нет активной сессии.")
        return

    # Определяем имя папки
    if algo_name:
        safe_name = _transliterate(algo_name)
        display_name = algo_name
    else:
        safe_name = _active_session
        display_name = _active_session

    algo_dir = ALGORITHMS_DIR / safe_name
    algo_dir.mkdir(parents=True, exist_ok=True)

    _send_text(bot, accid, chat_id, f"⏳ Сохраняю алгоритм «{display_name}»...")

    # Просим Qwen сформировать три файла
    description = _run_qwen(
        "Сформируй файл description.md для текущего алгоритма. "
        "Включи: постановку задачи, входные данные, выходные данные, ограничения, примеры. "
        "Без markdown-разметки (без звёздочек). Простой текст с заголовками через #.",
        continue_session=True,
    )

    reasoning = _run_qwen(
        "Сформируй файл reasoning.md — процесс построения алгоритма. "
        "Включи: пошаговый ход рассуждений, ключевые наблюдения, тупики (если были), "
        "граничные случаи, верификацию. "
        "Без markdown-разметки (без звёздочек). Простой текст.",
        continue_session=True,
    )

    solution = _run_qwen(
        "Сформируй файл solution.wl — эталонный код на Wolfram Language. "
        "Только код, без пояснений. Код должен быть готов к запуску через wolframscript.",
        continue_session=True,
    )

    # Записываем файлы
    (algo_dir / "description.md").write_text(description, encoding="utf-8")
    (algo_dir / "reasoning.md").write_text(reasoning, encoding="utf-8")
    (algo_dir / "solution.wl").write_text(solution, encoding="utf-8")

    # Копируем ассеты если есть
    today = datetime.now().strftime("%Y-%m-%d")
    assets_src = SESSIONS_DIR / f"{today}_{_active_session}_assets"
    if assets_src.exists():
        assets_dst = algo_dir / "assets"
        if assets_dst.exists():
            shutil.rmtree(assets_dst)
        shutil.copytree(assets_src, assets_dst)

    # Дописываем в каталог, только если алгоритма ещё нет в индексе
    existing = ALGORITHMS_INDEX.read_text(encoding="utf-8") if ALGORITHMS_INDEX.exists() else ""
    if f"  {safe_name}  " not in existing:
        index_line = f"{today}  {safe_name}  [Wolfram Language]\n"
        with open(ALGORITHMS_INDEX, "a", encoding="utf-8") as f:
            f.write(index_line)

    log.info("Алгоритм сохранён: %s → %s", display_name, algo_dir)
    _send_text(
        bot, accid, chat_id,
        f"✅ Алгоритм «{display_name}» сохранён в algorithms/{safe_name}/\n"
        f"📄 description.md — постановка задачи\n"
        f"📄 reasoning.md — процесс построения\n"
        f"📄 solution.wl — код на Wolfram Language",
    )


def _cmd_resume(bot, accid, chat_id, session_name: str):
    global _active_session

    if not session_name:
        _send_text(bot, accid, chat_id, "⚠️ Укажите имя: /resume имя_сессии")
        return

    if _active_session:
        _send_text(
            bot, accid, chat_id,
            f"⚠️ Уже есть активная сессия: {_active_session}\n"
            f"Закройте её командой /close.",
        )
        return

    safe_name = _transliterate(session_name)
    pattern = str(SESSIONS_DIR / f"*_{safe_name}.md")
    matches = sorted(glob(pattern))
    if not matches:
        _send_text(
            bot, accid, chat_id,
            f"⚠️ Сессия '{session_name}' не найдена в архиве.",
        )
        return

    session_file = matches[-1]
    session_content = Path(session_file).read_text(encoding="utf-8")

    response = _run_qwen(
        f"Восстанавливаем сессию {session_name}. "
        f"Вот резюме предыдущей работы:\n\n{session_content}\n\n"
        f"Продолжаем работу. Подтверди, что контекст восстановлен.",
        continue_session=False,
    )

    _active_session = safe_name
    _sent_pngs.clear()

    log.info("Сессия восстановлена: %s (из %s)", safe_name, session_file)
    _send_text(
        bot, accid, chat_id,
        f"✅ Сессия «{session_name}» восстановлена.\n"
        f"Контекст загружен. Продолжайте работу.",
    )


def _cmd_archive(bot, accid, chat_id):
    if not SESSIONS_INDEX.exists():
        _send_text(bot, accid, chat_id, "📂 Архив пуст.")
        return

    content = SESSIONS_INDEX.read_text(encoding="utf-8").strip()
    if not content:
        _send_text(bot, accid, chat_id, "📂 Архив пуст.")
        return

    _send_text(bot, accid, chat_id, content)


def _cmd_peek(bot, accid, chat_id, session_name: str):
    if not session_name:
        _send_text(bot, accid, chat_id, "⚠️ Укажите имя: /peek имя_сессии")
        return

    safe_name = _transliterate(session_name)
    pattern = str(SESSIONS_DIR / f"*_{safe_name}.md")
    matches = sorted(glob(pattern))
    if not matches:
        _send_text(bot, accid, chat_id, f"⚠️ Сессия '{session_name}' не найдена.")
        return

    session_file = Path(matches[-1])
    lines = session_file.read_text(encoding="utf-8").split("\n")
    preview = "\n".join(lines[:PEEK_LINES])

    header = f"👁️ {session_file.name} (первые {PEEK_LINES} строк):\n\n"
    _send_text(bot, accid, chat_id, header + preview)


# ═════════════════════════════════════════════════════════════════════════════
# Главный обработчик сообщений
# ═════════════════════════════════════════════════════════════════════════════

@cli.on(events.RawEvent)
def log_event(bot, accid, event):
    if hasattr(event, "kind"):
        log.debug("Event: %s", event.kind)


@cli.on(events.NewMessage)
def handle_message(bot, accid, event):
    global _active_session

    msg = event.msg
    chat_id = msg.chat_id
    text = (msg.text or "").strip()

    if not text:
        return

    # ─── Whitelist-проверка ──────────────────────────────────────────────
    try:
        contact = bot.rpc.get_contact(accid, msg.from_id)
        sender_addr = contact.get("address", "") if isinstance(contact, dict) else getattr(contact, "address", "")
    except Exception:
        sender_addr = ""

    if sender_addr.lower() != WHITELIST_EMAIL.lower():
        log.warning("Сообщение от неизвестного адреса: %s", sender_addr)
        return

    log.info("← %s: %s", sender_addr, text[:100])

    # ─── Пометить сообщение как прочитанное на IMAP-сервере ────────────────
    try:
        bot.rpc.markseen_msgs(accid, [msg.id])
    except Exception as e:
        log.debug("Не удалось пометить как прочитанное: %s", e)

    # ─── Парсинг команд ─────────────────────────────────────────────────
    parts = text.split(None, 1)
    command = parts[0].lower() if parts else ""
    payload = parts[1].strip() if len(parts) > 1 else ""

    if command == "/help":
        _cmd_help(bot, accid, chat_id, payload)
        return

    if command == "/start":
        _cmd_start(bot, accid, chat_id, payload)
        return

    if command == "/close":
        _cmd_close(bot, accid, chat_id)
        return

    if command == "/continue":
        _cmd_continue(bot, accid, chat_id, payload)
        return

    if command == "/save":
        _cmd_save(bot, accid, chat_id, payload)
        return

    if command == "/resume":
        _cmd_resume(bot, accid, chat_id, payload)
        return

    if command == "/archive":
        _cmd_archive(bot, accid, chat_id)
        return

    if command == "/peek":
        _cmd_peek(bot, accid, chat_id, payload)
        return

    # ─── Неизвестная /команда без активной сессии → /help ────────────────
    if text.startswith("/") and not _active_session:
        _cmd_help(bot, accid, chat_id)
        return

    # ─── Передача в Qwen (активная сессия) ──────────────────────────────
    if not _active_session:
        _send_text(
            bot, accid, chat_id,
            "⚠️ Нет активной сессии. Создайте новую: /start имя_сессии",
        )
        return

    response = _run_qwen(text, continue_session=True)

    if response:
        _send_text(bot, accid, chat_id, response)

    new_pngs = _check_new_pngs()
    for png_path in new_pngs:
        _send_file(bot, accid, chat_id, png_path)
        log.info("→ PNG отправлен: %s", png_path)


# ═════════════════════════════════════════════════════════════════════════════
# Точка входа
# ═════════════════════════════════════════════════════════════════════════════

if __name__ == "__main__":
    cli.start()

Установка и настройка

Создаём директорию проекта и копируем туда все пять файлов:

mkdir -p ~/projects/math_research_bot
cd ~/projects/math_research_bot
# Скопировать файлы: math_bot.py, config.py, QWEN.md, init.sh, requirements.txt

1. Создаём conda-окружение и ставим зависимости:

conda create -n mathbot python=3.12 -y
conda activate mathbot
pip install -r requirements.txt

2. Редактируем config.py:

Укажите свой email-адрес в WHITELIST_EMAIL — тот самый, под которым вы будете работать в Delta Chat на смартфоне. Все остальные адреса бот будет игнорировать.

Я завел отдельный email для этого. Именно с него Delta Chat будет отправлять свои сообщения. И из него забирать входящие.

3. Инициализируем рабочую директорию:

bash init.sh

Скрипт создаст ~/projects/math_research/ со структурой sessions/, algorithms/, шаблонами и копией QWEN.md.

4. Настраиваем Qwen Code CLI в YOLO-режим:

Бот запускает Qwen с флагом --yolo, но для надёжности стоит продублировать это в настройках. Создайте или отредактируйте файл ~/.qwen/settings.json:

{
  "tools": {
    "approvalMode": "yolo"
  }
}

5. Инициализируем бота (привязываем email):

python math_bot.py init EMAIL_БОТА ПАРОЛЬ

EMAIL_БОТА — это отдельный email-аккаунт, от имени которого будет работать бот. Не тот же, что ваш личный в Delta Chat, а второй. Для Mail.ru, Gmail, Яндекса используйте пароль приложения (не основной пароль).

6. Запускаем:

python math_bot.py serve

Бот запустится и начнёт опрашивать IMAP-сервер. Для автозапуска на macOS можно настроить launchd, но это уже по вкусу.

Настройка Delta Chat на смартфоне

Тут совсем просто:

  1. Установите Delta Chat из App Store или Google Play.

  2. Войдите под своим личным email (тем самым, который указан в WHITELIST_EMAIL).

  3. Добавьте email бота как контакт.

  4. Отправьте /help — если бот работает, он ответит списком команд.

Как использовать

Команды бота

Команда

Описание

/start ИМЯ

Создать новую исследовательскую сессию

/close

Сохранить и завершить текущую сессию (генерирует резюме)

/continue ИМЯ

Продолжить сессию после перезапуска бота (полный контекст Qwen сохранён)

/resume ИМЯ

Возобновить работу по резюме прошлой сессии (если контекст Qwen утерян)

/save [ИМЯ]

Сохранить текущий алгоритм в algorithms/ (description + reasoning + solution.wl)

/archive

Список всех сохранённых сессий

/peek ИМЯ

Заглянуть в сохранённую сессию (первые 40 строк)

/help

Справка по командам бота

/help qwen

Справка по возможностям Qwen Code CLI

Важный нюанс — разница между /continue и /resume:

  • /continue — продолжает сессию Qwen с полной историей диалога.

  • /resume — начинает новую сессию Qwen, но загружает в неё резюме прошлой сессии из sessions/. Используйте, если контекст Qwen утерян (например, после переустановки).

Типичный рабочий процесс

1. Отправляете /start алгебра — бот создаёт новую сессию, Qwen читает QWEN.md и готов к работе.

2. Пишете задачу обычным текстом: «Реши уравнение x^4 + 1 = 0 и построй график функции». Бот передаёт текст в Qwen, тот:

  • Формулирует словесный алгоритм (нумерованные шаги).

  • Пишет код на Wolfram Language.

  • Выполняет wolframscript.

  • Возвращает результат с интерпретацией.

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

4. Если агент строит график — он сохраняет PNG в /tmp/mrl_graphics/, бот подхватывает его и присылает в чат как вложение.

5. Если текущий алгоритм ценный — /save Решение уравнения четвёртой степени. Qwen сформирует три файла (постановка задачи, ход рассуждений, эталонный код) и сохранит в algorithms/.

6. /close — бот попросит Qwen сгенерировать резюме, соберёт все графики в _assets/, сохранит сессию в sessions/ и обновит индекс.

7. После перезапуска бота: /continue алгебра для продолжения с полным контекстом. Или /resume алгебра для начала новой сессии с резюме прошлой.

Пример

Задача о двух старушках

Спросил классическую задачу: «Из пункта А в пункт Б и из Б в А одновременно на рассвете вышли две старушки. Встретились в полдень. Первая пришла в Б в 16:00, вторая в А в 21:00. В котором часу был рассвет?»

Как это выглядит на смартфоне в Delta Chat-е

Вся сессия сохранилась в sessions/2026-03-22_test1.md с резюме и ссылками на графику в _assets/.


Продолжение следует...